refactor: rename the encryption key to what it actually protects
CALENDAR_ENCRYPTION_KEY was named after the calendar module because that module needed encryption first, in Phase 5. Every feature since has shared the same key -- SMTP, the DKV and tender mailboxes, and as of today the LDAP bind password -- so the name has been describing one of five users rather than the thing itself, and each new feature inherited the confusion. TESSERA_ENCRYPTION_KEY is the name now. The old one is still read, because renaming outright would stop every existing installation at the next start: their .env carries the old name, and compose was just made to fail hard on a missing key. When only the old name is present the API logs a deprecation warning naming both, and when both are set the new one wins -- otherwise a half-migrated .env would encrypt with one key and decrypt with the other. CalendarCryptoService becomes CryptoService in its own global CryptoModule. Four modules used to import CalendarModule purely to reach the provider, which read as a dependency on calendars where there was none; that import is gone. Compose keeps the hard failure: without either name the stack refuses to start. Verified in both files for all three cases -- neither name set (abort), only the old name (starts), only the new name (starts). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import { DomaincheckModule } from './domaincheck/domaincheck.module';
|
||||
import { GroupsModule } from './groups/groups.module';
|
||||
import { ModuleRegistryModule } from './module-registry/module-registry.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { CryptoModule } from './crypto/crypto.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { TenantGuard } from './tenant/tenant.guard';
|
||||
import { TenantModule } from './tenant/tenant.module';
|
||||
@@ -29,6 +30,7 @@ import { UserModule } from './user/user.module';
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ScheduleModule.forRoot(),
|
||||
PrismaModule,
|
||||
CryptoModule,
|
||||
AuthModule,
|
||||
UserModule,
|
||||
TenantModule,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CalendarController } from './calendar.controller';
|
||||
import { CalendarService } from './calendar.service';
|
||||
import { CalendarCryptoService } from './crypto.service';
|
||||
import { CalDAVProvider } from './providers/caldav.provider';
|
||||
import { ICSProvider } from './providers/ics.provider';
|
||||
import { ExchangeProvider } from './providers/exchange.provider';
|
||||
@@ -10,7 +9,6 @@ import { ExchangeProvider } from './providers/exchange.provider';
|
||||
* NestJS module for calendar source management and event aggregation.
|
||||
*
|
||||
* Provides:
|
||||
* - CalendarCryptoService: AES-256-GCM encryption for calendar credentials
|
||||
* - CalendarService: Source CRUD + event aggregation across providers
|
||||
* - CalDAVProvider: CalDAV protocol integration via tsdav
|
||||
* - ICSProvider: ICS file fetch + parse via node-ical
|
||||
@@ -23,11 +21,10 @@ import { ExchangeProvider } from './providers/exchange.provider';
|
||||
controllers: [CalendarController],
|
||||
providers: [
|
||||
CalendarService,
|
||||
CalendarCryptoService,
|
||||
CalDAVProvider,
|
||||
ICSProvider,
|
||||
ExchangeProvider,
|
||||
],
|
||||
exports: [CalendarService, CalendarCryptoService],
|
||||
exports: [CalendarService],
|
||||
})
|
||||
export class CalendarModule {}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CalendarCryptoService } from './crypto.service';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
|
||||
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
|
||||
import { TestCalendarSourceConfigDto } from './dto/test-calendar-source-config.dto';
|
||||
@@ -99,7 +99,7 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
* Service for calendar source CRUD and event aggregation.
|
||||
*
|
||||
* Source config is per-user (D-09), not per-tenant.
|
||||
* Credentials encrypted at rest via CalendarCryptoService (T-05-10).
|
||||
* Credentials encrypted at rest via CryptoService (T-05-10).
|
||||
*/
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
@@ -110,7 +110,7 @@ export class CalendarService {
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: CalendarCryptoService,
|
||||
private readonly crypto: CryptoService,
|
||||
private readonly icsProvider: ICSProvider,
|
||||
private readonly caldavProvider: CalDAVProvider,
|
||||
private readonly exchangeProvider: ExchangeProvider,
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
|
||||
/**
|
||||
* Encryption service for calendar source credentials.
|
||||
*
|
||||
* Uses AES-256-GCM with a 32-byte hex key from CALENDAR_ENCRYPTION_KEY env var.
|
||||
* Encrypted values are stored as `iv:authTag:ciphertext` (hex-joined).
|
||||
*
|
||||
* Security: T-05-10 — credentials encrypted at rest, never returned in GET responses.
|
||||
*
|
||||
* Key is initialised in the constructor (not onModuleInit) so that async factory
|
||||
* functions in other modules (e.g. MailModule.forRootAsync) can call decrypt()
|
||||
* before NestJS lifecycle hooks run.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CalendarCryptoService {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const hexKey = this.configService.get<string>('CALENDAR_ENCRYPTION_KEY');
|
||||
|
||||
if (!hexKey) {
|
||||
throw new Error(
|
||||
'CALENDAR_ENCRYPTION_KEY is not set. Generate one with: openssl rand -hex 32',
|
||||
);
|
||||
}
|
||||
|
||||
if (hexKey.length !== 64) {
|
||||
throw new Error(
|
||||
`CALENDAR_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). Got ${hexKey.length} characters.`,
|
||||
);
|
||||
}
|
||||
|
||||
this.key = Buffer.from(hexKey, 'hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts plaintext using AES-256-GCM.
|
||||
* @returns `iv:authTag:ciphertext` (all hex-encoded, colon-separated)
|
||||
*/
|
||||
encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(12); // 96-bit IV for GCM
|
||||
const cipher = createCipheriv('aes-256-gcm', this.key, iv);
|
||||
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag().toString('hex');
|
||||
|
||||
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a stored `iv:authTag:ciphertext` value.
|
||||
* @returns The original plaintext
|
||||
*/
|
||||
decrypt(stored: string): string {
|
||||
const parts = stored.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted value format. Expected iv:authTag:ciphertext');
|
||||
}
|
||||
|
||||
const [ivHex, authTagHex, ciphertext] = parts;
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
|
||||
const decipher = createDecipheriv('aes-256-gcm', this.key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { CryptoService } from './crypto.service';
|
||||
|
||||
/**
|
||||
* CryptoModule — the platform's single AES-256-GCM provider for credentials
|
||||
* that have to be replayed against a third party and therefore cannot be
|
||||
* hashed.
|
||||
*
|
||||
* Global on purpose. Before this module existed the provider lived in
|
||||
* CalendarModule, so SettingsModule, DkvModule, TendersModule and LdapModule
|
||||
* each imported CalendarModule just to reach it — an import that suggested a
|
||||
* dependency on calendars where there was none. Making it global removes that
|
||||
* false coupling instead of moving it to a different host module.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CryptoService],
|
||||
exports: [CryptoService],
|
||||
})
|
||||
export class CryptoModule {}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CryptoService,
|
||||
ENCRYPTION_KEY_ENV,
|
||||
LEGACY_ENCRYPTION_KEY_ENV,
|
||||
} from './crypto.service';
|
||||
|
||||
/**
|
||||
* Der Schluessel hiess frueher CALENDAR_ENCRYPTION_KEY, weil das Kalender-Modul
|
||||
* die Verschluesselung zuerst brauchte. Er gilt laengst fuer alle gespeicherten
|
||||
* Zugangsdaten. Diese Tests halten fest, dass die Umbenennung bestehende
|
||||
* Installationen nicht stehen laesst: der alte Name wird weiter gelesen.
|
||||
*/
|
||||
|
||||
const KEY_A = 'a'.repeat(64);
|
||||
const KEY_B = 'b'.repeat(64);
|
||||
|
||||
function makeConfig(values: Record<string, string | undefined>) {
|
||||
return { get: (name: string) => values[name] } as any;
|
||||
}
|
||||
|
||||
describe('CryptoService — Schluesselherkunft', () => {
|
||||
it('nimmt den neuen Namen', () => {
|
||||
const service = new CryptoService(
|
||||
makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A }),
|
||||
);
|
||||
expect(service.decrypt(service.encrypt('geheim'))).toBe('geheim');
|
||||
});
|
||||
|
||||
it('faellt auf den alten Namen zurueck, damit bestehende Installationen starten', () => {
|
||||
const service = new CryptoService(
|
||||
makeConfig({ [LEGACY_ENCRYPTION_KEY_ENV]: KEY_A }),
|
||||
);
|
||||
expect(service.decrypt(service.encrypt('geheim'))).toBe('geheim');
|
||||
});
|
||||
|
||||
it('warnt beim Rueckfall auf den alten Namen, statt still weiterzulaufen', () => {
|
||||
const spy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
|
||||
new CryptoService(makeConfig({ [LEGACY_ENCRYPTION_KEY_ENV]: KEY_A }));
|
||||
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
const message = String(spy.mock.calls[0][0]);
|
||||
expect(message).toContain(LEGACY_ENCRYPTION_KEY_ENV);
|
||||
expect(message).toContain(ENCRYPTION_KEY_ENV);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('warnt NICHT, wenn der neue Name gesetzt ist', () => {
|
||||
const spy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A }));
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('bevorzugt den neuen Namen, wenn beide gesetzt sind', () => {
|
||||
// Entscheidend fuer die Uebergangszeit: steht in der .env noch der alte
|
||||
// Wert und daneben schon der neue, muss der neue gewinnen — sonst
|
||||
// verschluesselt die Anwendung mit dem einen und entschluesselt mit dem
|
||||
// anderen Schluessel.
|
||||
const withBoth = new CryptoService(
|
||||
makeConfig({
|
||||
[ENCRYPTION_KEY_ENV]: KEY_A,
|
||||
[LEGACY_ENCRYPTION_KEY_ENV]: KEY_B,
|
||||
}),
|
||||
);
|
||||
const withNewOnly = new CryptoService(
|
||||
makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A }),
|
||||
);
|
||||
|
||||
// Was mit dem neuen Schluessel allein verschluesselt wurde, muss die
|
||||
// Instanz mit beiden Namen lesen koennen.
|
||||
expect(withBoth.decrypt(withNewOnly.encrypt('geheim'))).toBe('geheim');
|
||||
});
|
||||
|
||||
it('startet ohne Schluessel gar nicht', () => {
|
||||
expect(() => new CryptoService(makeConfig({}))).toThrow(
|
||||
/TESSERA_ENCRYPTION_KEY is not set/,
|
||||
);
|
||||
});
|
||||
|
||||
it('weist einen Schluessel falscher Laenge ab und nennt den verwendeten Namen', () => {
|
||||
expect(
|
||||
() => new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: 'zu-kurz' })),
|
||||
).toThrow(/TESSERA_ENCRYPTION_KEY must be a 64-character hex string/);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new CryptoService(makeConfig({ [LEGACY_ENCRYPTION_KEY_ENV]: 'zu-kurz' })),
|
||||
).toThrow(/CALENDAR_ENCRYPTION_KEY must be a 64-character hex string/);
|
||||
});
|
||||
|
||||
it('kann nicht entschluesseln, was mit einem anderen Schluessel verschluesselt wurde', () => {
|
||||
const a = new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A }));
|
||||
const b = new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_B }));
|
||||
expect(() => b.decrypt(a.encrypt('geheim'))).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
|
||||
/** Current name of the platform-wide encryption key. */
|
||||
export const ENCRYPTION_KEY_ENV = 'TESSERA_ENCRYPTION_KEY';
|
||||
|
||||
/**
|
||||
* Previous name, still accepted. It was called after the calendar module
|
||||
* because that module happened to need encryption first (Phase 5); every
|
||||
* feature since — SMTP, the DKV and tender mailboxes, and the LDAP bind
|
||||
* password — has shared the same key. Renaming without keeping this fallback
|
||||
* would stop every existing installation at the next start, since their .env
|
||||
* still carries the old name.
|
||||
*/
|
||||
export const LEGACY_ENCRYPTION_KEY_ENV = 'CALENDAR_ENCRYPTION_KEY';
|
||||
|
||||
/**
|
||||
* Platform-wide encryption for stored credentials.
|
||||
*
|
||||
* AES-256-GCM with a 32-byte hex key, values stored as `iv:authTag:ciphertext`
|
||||
* (hex-joined). Used for every credential Tessera has to replay against a
|
||||
* third party and therefore cannot hash: calendar sources, SMTP, the DKV and
|
||||
* tender mailboxes, and the LDAP bind password.
|
||||
*
|
||||
* Security: T-05-10 — credentials encrypted at rest, never returned in GET
|
||||
* responses.
|
||||
*
|
||||
* The key is read in the constructor (not onModuleInit) so async factories in
|
||||
* other modules (e.g. MailModule.forRootAsync) can call decrypt() before
|
||||
* NestJS lifecycle hooks run.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CryptoService {
|
||||
private readonly logger = new Logger(CryptoService.name);
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const current = this.configService.get<string>(ENCRYPTION_KEY_ENV);
|
||||
const legacy = this.configService.get<string>(LEGACY_ENCRYPTION_KEY_ENV);
|
||||
const hexKey = current || legacy;
|
||||
|
||||
if (!hexKey) {
|
||||
throw new Error(
|
||||
`${ENCRYPTION_KEY_ENV} is not set. Generate one with: openssl rand -hex 32`,
|
||||
);
|
||||
}
|
||||
|
||||
if (hexKey.length !== 64) {
|
||||
const usedName = current ? ENCRYPTION_KEY_ENV : LEGACY_ENCRYPTION_KEY_ENV;
|
||||
throw new Error(
|
||||
`${usedName} must be a 64-character hex string (32 bytes). Got ${hexKey.length} characters.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!current && legacy) {
|
||||
this.logger.warn(
|
||||
`${LEGACY_ENCRYPTION_KEY_ENV} ist veraltet und wird nur noch aus Kompatibilitaet gelesen. ` +
|
||||
`Denselben Wert unter ${ENCRYPTION_KEY_ENV} eintragen — der Schluessel gilt fuer alle ` +
|
||||
`gespeicherten Zugangsdaten, nicht nur fuer Kalender.`,
|
||||
);
|
||||
}
|
||||
|
||||
this.key = Buffer.from(hexKey, 'hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts plaintext using AES-256-GCM.
|
||||
* @returns `iv:authTag:ciphertext` (all hex-encoded, colon-separated)
|
||||
*/
|
||||
encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(12); // 96-bit IV for GCM
|
||||
const cipher = createCipheriv('aes-256-gcm', this.key, iv);
|
||||
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag().toString('hex');
|
||||
|
||||
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a stored `iv:authTag:ciphertext` value.
|
||||
* @returns The original plaintext
|
||||
*/
|
||||
decrypt(stored: string): string {
|
||||
const parts = stored.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted value format. Expected iv:authTag:ciphertext');
|
||||
}
|
||||
|
||||
const [ivHex, authTagHex, ciphertext] = parts;
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
|
||||
const decipher = createDecipheriv('aes-256-gcm', this.key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
import { CalendarModule } from '../calendar/calendar.module';
|
||||
import { InboxModule } from '../inbox/inbox.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { DkvController } from './dkv.controller';
|
||||
@@ -26,8 +25,7 @@ import { seedDkvModule } from './dkv.seed';
|
||||
*
|
||||
* Imports:
|
||||
* - ModuleRegistryModule: for registry self-seed on init
|
||||
* - CalendarModule: provides CalendarCryptoService (AES-256-GCM encryption)
|
||||
* re-exported from CalendarModule.exports — no re-declaration needed here.
|
||||
* - CryptoService comes from the global CryptoModule — no import needed.
|
||||
* - InboxModule: exports ImapProvider / ExchangeInboxProvider (shared connection
|
||||
* mechanics — DKV's own mailbox config stays independent, D-03)
|
||||
*
|
||||
@@ -41,7 +39,6 @@ import { seedDkvModule } from './dkv.seed';
|
||||
@Module({
|
||||
imports: [
|
||||
ModuleRegistryModule,
|
||||
CalendarModule,
|
||||
InboxModule,
|
||||
SettingsModule,
|
||||
],
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CalendarCryptoService } from '../calendar/crypto.service';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DkvExportService } from './dkv-export.service';
|
||||
import { DkvMailService } from './dkv-mail.service';
|
||||
@@ -75,7 +76,7 @@ export class DkvService {
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: CalendarCryptoService,
|
||||
private readonly crypto: CryptoService,
|
||||
private readonly parser: DkvParserService,
|
||||
private readonly exporter: DkvExportService,
|
||||
private readonly mailer: DkvMailService,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { CalendarCryptoService } from '../calendar/crypto.service';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CreateFieldMappingDto,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
} from './dto/ldap-config.dto';
|
||||
|
||||
/**
|
||||
* Shape of a stored AES-256-GCM value as CalendarCryptoService writes it:
|
||||
* Shape of a stored AES-256-GCM value as CryptoService writes it:
|
||||
* `iv:authTag:ciphertext`, all hex. Used to tell an encrypted value apart from
|
||||
* a legacy plaintext one that predates the encryption of this column.
|
||||
*/
|
||||
@@ -37,7 +38,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private readonly crypto: CalendarCryptoService,
|
||||
private readonly crypto: CryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -100,7 +101,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
// A wrong or rotated key must not read as "no password configured" —
|
||||
// that would silently turn an authenticated bind into an anonymous one.
|
||||
this.logger.error(
|
||||
`LDAP-Bind-Passwort konnte nicht entschluesselt werden (falscher CALENDAR_ENCRYPTION_KEY?): ${(err as Error).message}`,
|
||||
`LDAP-Bind-Passwort konnte nicht entschluesselt werden (falscher TESSERA_ENCRYPTION_KEY?): ${(err as Error).message}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { CalendarModule } from '../calendar/calendar.module';
|
||||
import { GroupsModule } from '../groups/groups.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { LdapConfigService } from './ldap-config.service';
|
||||
@@ -19,14 +18,11 @@ import { LdapService } from './ldap.service';
|
||||
* syncBoundGroupsForTenant() (Plan 16-03, D-06) — no cycle: GroupsModule
|
||||
* imports neither LdapModule nor UserModule.
|
||||
*
|
||||
* CalendarModule is imported for CalendarCryptoService, which encrypts the
|
||||
* bind password at rest — the same provider SettingsModule, DkvModule and
|
||||
* TendersModule already use for their own credentials. The name is a
|
||||
* historical accident (the calendar module happened to need encryption
|
||||
* first), not a statement about ownership.
|
||||
* The bind password is encrypted at rest via CryptoService from the global
|
||||
* CryptoModule — the same provider every other stored credential uses.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), UserModule, GroupsModule, CalendarModule],
|
||||
imports: [ScheduleModule.forRoot(), UserModule, GroupsModule],
|
||||
controllers: [LdapController],
|
||||
providers: [LdapService, LdapConfigService, LdapSyncScheduler],
|
||||
exports: [LdapService, LdapConfigService],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CalendarModule } from '../calendar/calendar.module';
|
||||
import { SettingsController } from './settings.controller';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@@ -10,12 +9,11 @@ import { SettingsService } from './settings.service';
|
||||
* - SettingsService: SmtpConfig CRUD (encrypted), connection test, startup accessor
|
||||
* - SettingsController: REST endpoints GET/PUT /settings/smtp, POST /settings/smtp/test
|
||||
*
|
||||
* Imports CalendarModule to get CalendarCryptoService for AES-256-GCM encryption.
|
||||
* CryptoModule is global — CryptoService is injectable without an import.
|
||||
* PrismaModule is global — no explicit import needed.
|
||||
* Exports SettingsService so other modules (e.g. MailModule, DkvModule) can inject it.
|
||||
*/
|
||||
@Module({
|
||||
imports: [CalendarModule],
|
||||
controllers: [SettingsController],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CalendarCryptoService } from '../calendar/crypto.service';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmtpConfigDto } from './dto/smtp-config.dto';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
@@ -27,7 +28,7 @@ export class SettingsService {
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: CalendarCryptoService,
|
||||
private readonly crypto: CryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -50,7 +51,7 @@ export class SettingsService {
|
||||
* Encrypts the password with AES-256-GCM when a new password is provided.
|
||||
* When `dto.password` is empty or absent, the existing encrypted password is preserved.
|
||||
*
|
||||
* T-07-08: Encryption via CalendarCryptoService. Never logs the plaintext password.
|
||||
* T-07-08: Encryption via CryptoService. Never logs the plaintext password.
|
||||
*/
|
||||
async saveSmtpConfig(tenantId: string, dto: SmtpConfigDto) {
|
||||
// Determine the encrypted password to store
|
||||
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
* email-alert.adapter.spec — Task 1 (test-first, TDD) proof for the generic
|
||||
* link/subject extraction (D-04): pure functions only, no I/O, mirroring
|
||||
* cosinex.adapter.spec.ts's pure-function spec style. Task 2 adds
|
||||
* fetchTenders() fan-out coverage (mocked PrismaService/CalendarCryptoService/
|
||||
* fetchTenders() fan-out coverage (mocked PrismaService/CryptoService/
|
||||
* inbox providers — no live DB/network I/O).
|
||||
*/
|
||||
|
||||
/** Fake CalendarCryptoService — deterministic reversible encode, not real AES. */
|
||||
/** Fake CryptoService — deterministic reversible encode, not real AES. */
|
||||
function makeFakeCrypto() {
|
||||
return {
|
||||
encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CryptoService } from '../../crypto/crypto.service';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { createHash } from 'crypto';
|
||||
import { CalendarCryptoService } from '../../calendar/crypto.service';
|
||||
|
||||
import { ExchangeInboxProvider } from '../../inbox/exchange-inbox.provider';
|
||||
import { ImapProvider } from '../../inbox/imap.provider';
|
||||
import type { InboxConfig, InboxMessage } from '../../inbox/inbox-provider.interface';
|
||||
@@ -123,7 +124,7 @@ export class EmailAlertAdapter implements TenderSourceAdapter {
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: CalendarCryptoService,
|
||||
private readonly crypto: CryptoService,
|
||||
private readonly imapProvider: ImapProvider,
|
||||
private readonly exchangeProvider: ExchangeInboxProvider,
|
||||
) {}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TenderEmailConfigService } from './tender-email-config.service';
|
||||
|
||||
/**
|
||||
* TenderEmailConfigService.spec — Phase 14, Plan 03 (CONFIG-02, D-06/D-07).
|
||||
* Hand-rolled fake PrismaService (Map) + a fake CalendarCryptoService
|
||||
* Hand-rolled fake PrismaService (Map) + a fake CryptoService
|
||||
* (deterministic reversible encode, NOT real AES) — same convention as
|
||||
* tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving
|
||||
* this service's own encrypt-preserve-empty / safe-select contract.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CalendarCryptoService } from '../calendar/crypto.service';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
|
||||
|
||||
@@ -38,7 +39,7 @@ const EMAIL_CONFIG_SAFE_SELECT = {
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
@@ -48,7 +49,7 @@ const EMAIL_CONFIG_SAFE_SELECT = {
|
||||
export class TenderEmailConfigService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: CalendarCryptoService,
|
||||
private readonly crypto: CryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { CalendarModule } from '../calendar/calendar.module';
|
||||
import { InboxModule } from '../inbox/inbox.module';
|
||||
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
@@ -112,8 +111,9 @@ import { TendersController } from './tenders.controller';
|
||||
* Phase 14, Plan 03 (INGEST-05): adds `EmailAlertAdapter` (registered
|
||||
* alongside the existing adapters — `email-alert` is not denylisted) and
|
||||
* `TenderEmailConfigService` (per-tenant admin CRUD for the alert mailbox
|
||||
* config, D-06/D-07). `CalendarModule`/`InboxModule` are imported so the
|
||||
* adapter/service can inject `CalendarCryptoService` (credential encryption)
|
||||
* config, D-06/D-07). `InboxModule` is imported so the
|
||||
* adapter/service can reach the mailbox providers (credential encryption comes
|
||||
* from the global CryptoModule)
|
||||
* and `ImapProvider`/`ExchangeInboxProvider` (shared connection mechanics,
|
||||
* D-01) — the same imports DkvModule already uses for its own, separate
|
||||
* mailbox config (D-03). Unlike `rss`, the `email-alert`
|
||||
@@ -123,7 +123,7 @@ import { TendersController } from './tenders.controller';
|
||||
* safe default mailbox to seed (unlike RSS's service.bund.de default).
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule, SettingsModule, CalendarModule, InboxModule],
|
||||
imports: [ModuleRegistryModule, SettingsModule, InboxModule],
|
||||
controllers: [TendersController],
|
||||
providers: [
|
||||
DoeOpenDataAdapter,
|
||||
|
||||
@@ -44,7 +44,12 @@ services:
|
||||
# Unset used to resolve to an empty value and only fail later, inside the
|
||||
# API, with a stack trace. Fail at compose level with a usable message
|
||||
# instead. Generate with: openssl rand -hex 32
|
||||
CALENDAR_ENCRYPTION_KEY: "${CALENDAR_ENCRYPTION_KEY:?set CALENDAR_ENCRYPTION_KEY in .env, generate one with openssl rand -hex 32}"
|
||||
#
|
||||
# CALENDAR_ENCRYPTION_KEY is the previous name and is still accepted, so
|
||||
# an existing .env keeps working; the API logs a deprecation warning when
|
||||
# it falls back to it.
|
||||
TESSERA_ENCRYPTION_KEY: "${TESSERA_ENCRYPTION_KEY:-${CALENDAR_ENCRYPTION_KEY:?set TESSERA_ENCRYPTION_KEY in .env, generate one with openssl rand -hex 32}}"
|
||||
CALENDAR_ENCRYPTION_KEY: "${CALENDAR_ENCRYPTION_KEY:-}"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"]
|
||||
interval: 10s
|
||||
|
||||
+6
-1
@@ -51,7 +51,12 @@ services:
|
||||
# Generate one with: openssl rand -hex 32
|
||||
# Keep it with your backups but stored separately from the database dump;
|
||||
# losing it means re-entering every stored credential by hand.
|
||||
CALENDAR_ENCRYPTION_KEY: "${CALENDAR_ENCRYPTION_KEY:?set CALENDAR_ENCRYPTION_KEY in .env, generate one with openssl rand -hex 32}"
|
||||
#
|
||||
# CALENDAR_ENCRYPTION_KEY is the previous name and is still accepted, so
|
||||
# an existing .env keeps working; the API logs a deprecation warning when
|
||||
# it falls back to it.
|
||||
TESSERA_ENCRYPTION_KEY: "${TESSERA_ENCRYPTION_KEY:-${CALENDAR_ENCRYPTION_KEY:?set TESSERA_ENCRYPTION_KEY in .env, generate one with openssl rand -hex 32}}"
|
||||
CALENDAR_ENCRYPTION_KEY: "${CALENDAR_ENCRYPTION_KEY:-}"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"]
|
||||
interval: 10s
|
||||
|
||||
Reference in New Issue
Block a user