32591b6690
catch (e: any) in groups, module-grants, ldap, user, admin-seed, calendar
und vier tenders-Diensten auf catch (e: unknown) umgestellt. Die
Eingrenzung passiert an der Verwendungsstelle, nicht per Zusicherung.
Neu: apps/api/src/prisma/prisma-error.ts mit prismaErrorCode() und
prismaErrorTarget(). Bewusst Form-Pruefungen statt instanceof
Prisma.PrismaClientKnownRequestError - gemessen: samtliche Testdoppel in
apps/api werfen new Error(...) mit angehaengtem .code (groups, user, ldap,
tenders, module-grants, admin-seed) und dashboard.service.spec.ts:451 ein
reines { code: 'P2002' }. Ein instanceof-Test haette all diese Werte in den
anderen Zweig geschickt - Verhaltensaenderung, verboten nach D-03/T-M34-06.
Die Helfer bilden err?.code und err?.meta?.target eins zu eins ab.
ldap.service.ts liest zusaetzlich meta.target; prismaErrorTarget() gibt
unknown zurueck, weil der Bestand dort Array UND Zeichenkette getrennt
behandelt - ein engerer Typ waere eine Behauptung.
calendar.service.ts:341 nutzt instanceof Error statt e?.message: gemessen
wirft validateUrlNotPrivate() ausschliesslich ForbiddenException (der
eigene catch dort setzt jeden Fremdfehler in eine um), also trifft
instanceof dieselben Faelle. Ersatzzweig 'URL not allowed' unveraendert.
noExplicitAny in apps/api/src: 56 -> 38. type-check 4/4, lint 5/5 (0
error), apps/api 72/1143, apps/web 73/531, rls-access-inventory 30/30.
noNonNullAssertion 56, as unknown as 33, Unterdrueckungsmarker 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
import { ConflictException, Injectable, Logger, Optional } from '@nestjs/common';
|
|
import { CryptoService } from '../crypto/crypto.service';
|
|
import { ExchangeInboxProvider } from '../inbox/exchange-inbox.provider';
|
|
import { ImapProvider } from '../inbox/imap.provider';
|
|
import type { InboxConfig, InboxProvider } from '../inbox/inbox-provider.interface';
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
|
import { prismaErrorCode } from '../prisma/prisma-error';
|
|
import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
|
|
|
|
/**
|
|
* Prisma select for TenderEmailConfig — never includes encryptedInboxCreds.
|
|
* T-07-12: Encrypted credential blob is excluded from all API responses.
|
|
*/
|
|
const EMAIL_CONFIG_SAFE_SELECT = {
|
|
id: true,
|
|
userId: true,
|
|
tenantId: true,
|
|
protocol: true,
|
|
host: true,
|
|
port: true,
|
|
encryption: true,
|
|
folder: true,
|
|
senderFilter: true,
|
|
domain: true,
|
|
isActive: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
// encryptedInboxCreds: NEVER included — T-07-12
|
|
} as const;
|
|
|
|
/**
|
|
* TenderEmailConfigService — per-USER CRUD for the portal-alert mailbox
|
|
* config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07; ownership
|
|
* moved from tenant to user in Phase 17, Plan 01, D-01). Structural clone
|
|
* of DkvService's config half (safe-select + encrypt-preserve-empty
|
|
* semantics), mirroring the exact same pattern already proven for DKV's
|
|
* own (separate, D-03) mailbox config.
|
|
*
|
|
* Ownership (Phase 17, D-01): a mailbox belongs to exactly one user
|
|
* (`userId @unique`) — two users at the same tenant each connect their own
|
|
* inbox independently, neither can read or overwrite the other's config.
|
|
* `tenantId` stays denormalized on every row (SMTP resolution, same role as
|
|
* `tenantId` on TenderMatch) and is written on both create and update,
|
|
* since a user's tenant can in principle change.
|
|
*
|
|
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-laa): every read/write
|
|
* below runs through `forTenant()`. Because `userId` alone (not
|
|
* `(userId, tenantId)`) is the row's unique key, a user whose stored
|
|
* `tenantId` has gone stale sees their OWN row become invisible under the
|
|
* now-bound context — `getConfigForApi`/`testConnection` then correctly
|
|
* report "no mailbox configured" (safe direction, no cross-tenant leak),
|
|
* and a bound `saveConfig` upsert on that stale row hits the platform-wide
|
|
* uniqueness constraint on `userId` and surfaces as a translated
|
|
* ConflictException, not a raw 500 (T-LAA-07, Befund F, Aufgabe 1).
|
|
*
|
|
* Benutzerdimension seit 20260911120000 (Etappe 3b, 260911-nke): every
|
|
* `forTenant()` call above also passes `userId` as the third argument, so
|
|
* the `tenant_isolation_policy` on TenderEmailConfig ALSO enforces
|
|
* `userId = current_user_id()` — a second net, not a replacement for the
|
|
* `userId @unique` ownership model above.
|
|
*
|
|
* Security:
|
|
* - T-07-12: encryptedInboxCreds is excluded from every read-path select;
|
|
* getConfigForApi returns `hasPassword: boolean` instead of the password.
|
|
* - T-05-13: decrypted credentials only ever exist within a method's local
|
|
* scope — never logged.
|
|
* - T-17-01: userId/tenantId are supplied by the caller from the auth
|
|
* context (TendersController.extractTriageContext) — this service never
|
|
* derives ownership from anything the DTO carries.
|
|
*
|
|
* This service is used ONLY by the GET/PUT /email-config routes
|
|
* (TendersController). EmailAlertAdapter's own poll-time fan-out decrypts
|
|
* credentials independently via a direct CryptoService injection
|
|
* (RESEARCH.md Pattern 1) — it does NOT go through this service, since the
|
|
* adapter's cross-tenant `findMany({where:{isActive:true}})` read is a
|
|
* deliberate platform-scheduler exception (see EmailAlertAdapter's
|
|
* docstring), structurally different from this service's per-user CRUD.
|
|
*/
|
|
@Injectable()
|
|
export class TenderEmailConfigService {
|
|
private readonly logger = new Logger(TenderEmailConfigService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly crypto: CryptoService,
|
|
/**
|
|
* Appended as OPTIONAL trailing constructor params (Quick 260907-let) —
|
|
* preserves every existing `new TenderEmailConfigService(prisma,
|
|
* crypto)` 2-arg call site in the spec unchanged. NestJS DI always
|
|
* resolves and injects both in production (InboxModule is imported in
|
|
* tenders.module.ts and exports both providers) — same pattern as
|
|
* TendersController.tenderIngestionService (tenders.controller.ts).
|
|
*/
|
|
@Optional() private readonly imapProvider?: ImapProvider,
|
|
@Optional() private readonly exchangeProvider?: ExchangeInboxProvider,
|
|
) {}
|
|
|
|
/**
|
|
* Load config for API response: safe fields + decrypted username +
|
|
* hasPassword flag. T-07-12: password is NEVER returned. Scoped strictly
|
|
* by userId (T-17-01) — a user only ever reads their own mailbox.
|
|
*/
|
|
async getConfigForApi(userId: string, tenantId: string) {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
|
const safe = await tenantPrisma.tenderEmailConfig.findUnique({
|
|
where: { userId },
|
|
select: EMAIL_CONFIG_SAFE_SELECT,
|
|
});
|
|
if (!safe) return null;
|
|
|
|
let username: string | null = null;
|
|
let hasPassword = false;
|
|
try {
|
|
const raw = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
|
|
if (raw?.encryptedInboxCreds) {
|
|
const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as {
|
|
username?: string;
|
|
password?: string;
|
|
};
|
|
username = creds.username ?? null;
|
|
hasPassword = Boolean(creds.password);
|
|
}
|
|
} catch {
|
|
/* ignore decrypt errors — return empty username, matches DkvService.getConfigForApi */
|
|
}
|
|
|
|
return { ...safe, username, hasPassword };
|
|
}
|
|
|
|
/**
|
|
* Upsert TenderEmailConfig for a user.
|
|
*
|
|
* Credential handling (identical semantics to DkvService.saveConfig):
|
|
* - dto.password non-empty: re-encrypt {username, password} together.
|
|
* - dto.username non-empty but dto.password empty: preserve existing
|
|
* password, re-encrypt with the new username.
|
|
* - both empty/undefined: preserve existing encryptedInboxCreds entirely.
|
|
*
|
|
* tenantId is written on both create AND update (Phase 17, D-01): it is
|
|
* denormalized, so if the resolved tenant for this user ever changes the
|
|
* stored value must follow.
|
|
*
|
|
* T-07-12: Returns safe select (no encryptedInboxCreds).
|
|
* T-05-13: Never logs decrypted credentials.
|
|
*
|
|
* T-LAA-07 (Befund F): the upsert target (`userId`) has no tenant
|
|
* dimension. A P2002 here means the caller's stale-tenant row already
|
|
* exists and is now invisible under the bound context — translated into
|
|
* a German ConflictException instead of a raw error (same pattern as
|
|
* `tender-saved-search.service.ts`).
|
|
*/
|
|
async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
|
|
const { userId, tenantId } = ctx;
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
|
let encryptedInboxCreds: string | undefined;
|
|
|
|
const credChanged =
|
|
(dto.password && dto.password.length > 0) ||
|
|
(dto.username !== undefined && dto.username !== null);
|
|
|
|
if (credChanged) {
|
|
let username: string = dto.username ?? '';
|
|
let password: string = dto.password ?? '';
|
|
|
|
if (!dto.password || !dto.username) {
|
|
try {
|
|
const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
|
|
if (existing?.encryptedInboxCreds) {
|
|
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
|
|
username?: string;
|
|
password?: string;
|
|
};
|
|
if (!dto.username) username = stored.username ?? '';
|
|
if (!dto.password) password = stored.password ?? '';
|
|
}
|
|
} catch {
|
|
// Ignore decrypt errors — will overwrite with whatever was provided
|
|
}
|
|
}
|
|
|
|
encryptedInboxCreds = this.crypto.encrypt(JSON.stringify({ username, password }));
|
|
}
|
|
|
|
const data: Record<string, unknown> = {
|
|
protocol: dto.protocol,
|
|
encryption: dto.encryption,
|
|
...(dto.host !== undefined && { host: dto.host }),
|
|
...(dto.port !== undefined && { port: dto.port }),
|
|
...(dto.folder !== undefined && { folder: dto.folder }),
|
|
...(dto.senderFilter !== undefined && { senderFilter: dto.senderFilter }),
|
|
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
|
...(dto.domain !== undefined && { domain: dto.domain }),
|
|
...(encryptedInboxCreds !== undefined && { encryptedInboxCreds }),
|
|
};
|
|
|
|
// tenantId is written on BOTH create and update — it is denormalized
|
|
// (Phase 17, D-01), so a user's resolved tenant must always overwrite
|
|
// whatever was stored previously, not just be set once on create.
|
|
try {
|
|
return await tenantPrisma.tenderEmailConfig.upsert({
|
|
where: { userId },
|
|
create: { userId, tenantId, ...data },
|
|
update: { tenantId, ...data },
|
|
select: EMAIL_CONFIG_SAFE_SELECT,
|
|
});
|
|
} catch (error: unknown) {
|
|
if (prismaErrorCode(error) === 'P2002') {
|
|
throw new ConflictException(
|
|
'Die Postfach-Konfiguration konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tests the inbox connection using credentials from the form DTO,
|
|
* WITHOUT persisting anything (structural clone of
|
|
* DkvService.testConnection, dkv.service.ts:201).
|
|
*
|
|
* Credential fallback (T-QT16-03): a DTO field left empty falls back to
|
|
* this SAME user's stored, decrypted credentials — never another user's
|
|
* row, since `where: { userId }` is the only lookup key. Unlike
|
|
* saveConfig's credChanged branching, BOTH username and password are
|
|
* independently backfilled here (saveConfig only ever needs to backfill
|
|
* the one field the caller didn't touch on a partial re-save; a
|
|
* connection test with a fully blank form needs both).
|
|
*
|
|
* T-05-13: decrypted credentials exist only within this method's local
|
|
* scope — never returned, never logged beyond the userId itself.
|
|
*/
|
|
async testConnection(
|
|
userId: string,
|
|
tenantId: string,
|
|
dto: TenderEmailConfigDto,
|
|
): Promise<{ success: boolean; message?: string }> {
|
|
let username: string | undefined = dto.username;
|
|
let password: string | undefined = dto.password;
|
|
|
|
if (!username || !password) {
|
|
try {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
|
const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
|
|
if (existing?.encryptedInboxCreds) {
|
|
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
|
|
username?: string;
|
|
password?: string;
|
|
};
|
|
if (!username) username = stored.username;
|
|
if (!password) password = stored.password;
|
|
}
|
|
} catch {
|
|
// T-05-13: generic — no credential details, only the userId
|
|
this.logger.error(`testConnection: failed to load stored credentials for user ${userId}`);
|
|
}
|
|
}
|
|
|
|
const inboxConfig: InboxConfig = {
|
|
protocol: dto.protocol,
|
|
host: dto.host ?? '',
|
|
port: dto.port ?? 993,
|
|
username,
|
|
password,
|
|
encryption: dto.encryption,
|
|
folder: dto.folder ?? 'INBOX',
|
|
senderFilter: dto.senderFilter,
|
|
domain: dto.domain,
|
|
};
|
|
|
|
const provider: InboxProvider | undefined =
|
|
dto.protocol === 'exchange' ? this.exchangeProvider : this.imapProvider;
|
|
if (!provider) {
|
|
return { success: false, message: 'Inbox-Anbieter nicht verfuegbar' };
|
|
}
|
|
|
|
return provider.testConnection(inboxConfig);
|
|
}
|
|
}
|