feat(14-03): add per-tenant encrypted TenderEmailConfig + ownerTenantId write-side (D-13)
Prisma: new TenderEmailConfig model (per-tenant, tenantId @unique, mirrors
DkvModuleConfig) + Tender.ownerTenantId nullable column + index (D-13:
null = global/platform-wide, unchanged for all existing rows and every
public source; set = visible only to that tenant). Migration
20260723113917_tender_email_config_owner_tenant_id applied locally.
TenderEmailConfigService: safe-select admin CRUD (GET never returns the
password, only hasPassword — T-07-12) with DkvService's encrypt-preserve-
empty semantics, via CalendarCryptoService (AES-256-GCM).
RawTenderRecord/NormalizedTenderFields gain optional ownerTenantId,
threaded through TenderNormalizerService.assemble() unchanged.
TenderDedupService's CREATE branch writes ownerTenantId (defaulting to
null); the UPDATE branch deliberately never references it, so a tender
later also seen on a public source is never retroactively hidden.
EmailAlertAdapter.fetchTenders() now does the real per-tenant fan-out:
findMany({isActive:true}) across ALL tenants (deliberate, documented
cross-tenant platform-scheduler read, never forTenant()/RLS), decrypts
each tenant's credentials, picks imap/exchange provider, and tags every
extracted candidate with ownerTenantId — catch-per-tenant so one broken
mailbox never blocks the others.
tenders.module.ts: imports CalendarModule/InboxModule, registers
EmailAlertAdapter + TenderEmailConfigService, seeds an 'email-alert'
TenderSourcePollConfig row (pollGranularity='tick', isActive=false —
no default mailbox to activate yet, D-02 framework-ready stance).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CalendarCryptoService } from '../calendar/crypto.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
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,
|
||||
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-tenant admin CRUD for the portal-alert
|
||||
* mailbox config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07).
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* This service is used ONLY by the admin GET/PUT /email-config routes
|
||||
* (TendersController). EmailAlertAdapter's own per-tenant poll-time fan-out
|
||||
* decrypts credentials independently via a direct CalendarCryptoService
|
||||
* 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 tenant-scoped CRUD.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TenderEmailConfigService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: CalendarCryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Load config for API response: safe fields + decrypted username +
|
||||
* hasPassword flag. T-07-12: password is NEVER returned.
|
||||
*/
|
||||
async getConfigForApi(tenantId: string) {
|
||||
const safe = await this.prisma.tenderEmailConfig.findUnique({
|
||||
where: { tenantId },
|
||||
select: EMAIL_CONFIG_SAFE_SELECT,
|
||||
});
|
||||
if (!safe) return null;
|
||||
|
||||
let username: string | null = null;
|
||||
let hasPassword = false;
|
||||
try {
|
||||
const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } });
|
||||
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 tenant.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* T-07-12: Returns safe select (no encryptedInboxCreds).
|
||||
* T-05-13: Never logs decrypted credentials.
|
||||
*/
|
||||
async saveConfig(tenantId: string, dto: TenderEmailConfigDto) {
|
||||
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 this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } });
|
||||
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 }),
|
||||
};
|
||||
|
||||
return this.prisma.tenderEmailConfig.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, ...data },
|
||||
update: data,
|
||||
select: EMAIL_CONFIG_SAFE_SELECT,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user