From 737ae6bcc8c0133989c048ad16ee1c36675e2eaf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 00:54:43 +0000 Subject: [PATCH 1/2] feat(migration): encrypted credential vault for source-PMS tokens (TEL-70) Add AES-256-GCM app-level encryption for per-property source-PMS migration credentials with key-rotation support via encryption_key_id. - migration_source_credentials table (property-scoped, unique per source PMS) - MigrationSourceCredentialsService with encrypt/decrypt/upsert/delete APIs - HTTP endpoints return metadata only; decryptForRunner is server-side only - Audit logs and API responses never include plaintext secrets or ciphertext - Tests cover round-trip, wrong-key failure, and GCM tamper detection Co-authored-by: telivity-otaip --- .env.example | 7 + apps/api/src/app.module.ts | 2 + .../crypto/credential-encryption.spec.ts | 83 +++++ .../common/crypto/credential-encryption.ts | 175 ++++++++++ .../dto/upsert-migration-credential.dto.ts | 17 + ...gration-source-credentials.service.spec.ts | 278 ++++++++++++++++ .../migration-source-credentials.service.ts | 301 ++++++++++++++++++ .../migration/migration.controller.spec.ts | 29 ++ .../modules/migration/migration.controller.ts | 86 +++++ .../src/modules/migration/migration.module.ts | 12 + .../0019_migration_source_credentials.sql | 17 + packages/database/src/push-schema.ts | 20 ++ packages/database/src/schema/index.ts | 3 + packages/database/src/schema/migration.ts | 32 ++ packages/shared/src/index.ts | 4 + 15 files changed, 1066 insertions(+) create mode 100644 apps/api/src/common/crypto/credential-encryption.spec.ts create mode 100644 apps/api/src/common/crypto/credential-encryption.ts create mode 100644 apps/api/src/modules/migration/dto/upsert-migration-credential.dto.ts create mode 100644 apps/api/src/modules/migration/migration-source-credentials.service.spec.ts create mode 100644 apps/api/src/modules/migration/migration-source-credentials.service.ts create mode 100644 apps/api/src/modules/migration/migration.controller.spec.ts create mode 100644 apps/api/src/modules/migration/migration.controller.ts create mode 100644 apps/api/src/modules/migration/migration.module.ts create mode 100644 packages/database/src/migrations/0019_migration_source_credentials.sql create mode 100644 packages/database/src/schema/migration.ts diff --git a/.env.example b/.env.example index 7385cf41..99fae497 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,13 @@ STORAGE_DRIVER=local # S3_FORCE_PATH_STYLE=true # true for MinIO; false for AWS S3 # S3_PUBLIC_BASE_URL= # optional CDN/public base for object URLs +# Migration source-PMS credential vault (AES-256-GCM at rest) +# Generate: openssl rand -hex 32 +# MIGRATION_CREDENTIAL_ENCRYPTION_KEY= +# MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID=default +# Optional rotation map — JSON object of keyId → 64-char hex key +# MIGRATION_CREDENTIAL_ENCRYPTION_KEYS={"legacy":"<64 hex chars>"} + # Channex channel manager (optional env fallbacks; prefer connection.config) # CHANNEX_API_KEY= # CHANNEX_PROPERTY_ID= diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index eeea7b9b..70fd0102 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -52,6 +52,7 @@ import { LoyaltyModule } from './modules/loyalty/loyalty.module'; import { IntegrationsModule } from './modules/integrations/integrations.module'; import { IcalModule } from './modules/ical/ical.module'; import { FiscalModule } from './modules/fiscal/fiscal.module'; +import { MigrationModule } from './modules/migration/migration.module'; const imports: any[] = [ ConfigModule.forRoot({ @@ -106,6 +107,7 @@ const imports: any[] = [ IntegrationsModule, IcalModule, FiscalModule, + MigrationModule, ]; // Serve the bundled dashboard as static files. Enabled in production, or diff --git a/apps/api/src/common/crypto/credential-encryption.spec.ts b/apps/api/src/common/crypto/credential-encryption.spec.ts new file mode 100644 index 00000000..c6473308 --- /dev/null +++ b/apps/api/src/common/crypto/credential-encryption.spec.ts @@ -0,0 +1,83 @@ +import { randomBytes } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + CredentialEncryptionError, + decryptCredentialPlaintext, + encryptCredentialPlaintext, + loadMigrationCredentialKeyRingFromEnv, + parseAes256KeyHex, + serializeEncryptedBlob, + deserializeEncryptedBlob, +} from './credential-encryption'; + +const KEY_A = randomBytes(32).toString('hex'); +const KEY_B = randomBytes(32).toString('hex'); + +function env(overrides: Record = {}): NodeJS.ProcessEnv { + return { + MIGRATION_CREDENTIAL_ENCRYPTION_KEY: KEY_A, + MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID: 'default', + ...overrides, + }; +} + +describe('credential-encryption', () => { + it('round-trips plaintext', () => { + const keyRing = loadMigrationCredentialKeyRingFromEnv(env()); + const blob = encryptCredentialPlaintext('{"apiKey":"secret"}', keyRing, env()); + const out = decryptCredentialPlaintext(blob, keyRing); + expect(out).toBe('{"apiKey":"secret"}'); + }); + + it('fails closed when decrypting with a wrong key ring', () => { + const keyRing = loadMigrationCredentialKeyRingFromEnv(env()); + const blob = encryptCredentialPlaintext('sensitive', keyRing, env()); + const wrongRing = loadMigrationCredentialKeyRingFromEnv( + env({ MIGRATION_CREDENTIAL_ENCRYPTION_KEY: KEY_B }), + ); + expect(() => decryptCredentialPlaintext(blob, wrongRing)).toThrow(CredentialEncryptionError); + }); + + it('fails closed when the GCM auth tag is tampered', () => { + const keyRing = loadMigrationCredentialKeyRingFromEnv(env()); + const blob = encryptCredentialPlaintext('sensitive', keyRing, env()); + const tampered = { ...blob, authTag: '0'.repeat(blob.authTag.length) }; + expect(() => decryptCredentialPlaintext(tampered, keyRing)).toThrow(CredentialEncryptionError); + }); + + it('fails closed when ciphertext is tampered', () => { + const keyRing = loadMigrationCredentialKeyRingFromEnv(env()); + const blob = encryptCredentialPlaintext('sensitive', keyRing, env()); + const tampered = { ...blob, ciphertext: blob.ciphertext.replace(/a/g, 'b') }; + expect(() => decryptCredentialPlaintext(tampered, keyRing)).toThrow(CredentialEncryptionError); + }); + + it('decrypts with a rotated legacy key id from the key ring', () => { + const legacyEnv = env({ + MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID: 'legacy', + MIGRATION_CREDENTIAL_ENCRYPTION_KEY: KEY_B, + MIGRATION_CREDENTIAL_ENCRYPTION_KEYS: JSON.stringify({ legacy: KEY_B }), + }); + const legacyRing = loadMigrationCredentialKeyRingFromEnv(legacyEnv); + const blob = encryptCredentialPlaintext('rotated-secret', legacyRing, legacyEnv); + expect(blob.keyId).toBe('legacy'); + + const currentRing = loadMigrationCredentialKeyRingFromEnv( + env({ + MIGRATION_CREDENTIAL_ENCRYPTION_KEYS: JSON.stringify({ legacy: KEY_B }), + }), + ); + expect(decryptCredentialPlaintext(blob, currentRing)).toBe('rotated-secret'); + }); + + it('serializes and deserializes blobs for DB storage', () => { + const keyRing = loadMigrationCredentialKeyRingFromEnv(env()); + const blob = encryptCredentialPlaintext('x', keyRing, env()); + const roundTrip = deserializeEncryptedBlob(serializeEncryptedBlob(blob)); + expect(roundTrip).toEqual(blob); + }); + + it('rejects invalid key hex length', () => { + expect(() => parseAes256KeyHex('abcd', 'test')).toThrow(CredentialEncryptionError); + }); +}); diff --git a/apps/api/src/common/crypto/credential-encryption.ts b/apps/api/src/common/crypto/credential-encryption.ts new file mode 100644 index 00000000..49ee95ba --- /dev/null +++ b/apps/api/src/common/crypto/credential-encryption.ts @@ -0,0 +1,175 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +/** AES-256-GCM encrypted blob with key-rotation metadata. */ +export interface EncryptedCredentialBlob { + keyId: string; + iv: string; + ciphertext: string; + authTag: string; +} + +export type CredentialKeyRing = ReadonlyMap; + +const AES_ALGO = 'aes-256-gcm'; +const IV_BYTES = 16; +const KEY_BYTES = 32; + +export class CredentialEncryptionError extends Error { + constructor(message: string) { + super(message); + this.name = 'CredentialEncryptionError'; + } +} + +/** Parse a 64-char hex string into a 32-byte AES-256 key. */ +export function parseAes256KeyHex(hex: string, label: string): Buffer { + const key = Buffer.from(hex, 'hex'); + if (key.length !== KEY_BYTES) { + throw new CredentialEncryptionError( + `${label} must be ${KEY_BYTES * 2} hex characters (${KEY_BYTES} bytes)`, + ); + } + return key; +} + +/** + * Load encryption keys from env for migration source-PMS credentials. + * + * - `MIGRATION_CREDENTIAL_ENCRYPTION_KEY` — primary key (hex) + * - `MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID` — id for the primary key (default: "default") + * - `MIGRATION_CREDENTIAL_ENCRYPTION_KEYS` — optional JSON map of keyId → hex for rotation + */ +export function loadMigrationCredentialKeyRingFromEnv( + env: NodeJS.ProcessEnv = process.env, +): CredentialKeyRing { + const keys = new Map(); + const primaryHex = env['MIGRATION_CREDENTIAL_ENCRYPTION_KEY']; + const primaryId = env['MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID'] ?? 'default'; + if (primaryHex) { + keys.set(primaryId, parseAes256KeyHex(primaryHex, 'MIGRATION_CREDENTIAL_ENCRYPTION_KEY')); + } + + const extraJson = env['MIGRATION_CREDENTIAL_ENCRYPTION_KEYS']; + if (extraJson) { + let parsed: unknown; + try { + parsed = JSON.parse(extraJson); + } catch { + throw new CredentialEncryptionError( + 'MIGRATION_CREDENTIAL_ENCRYPTION_KEYS must be valid JSON', + ); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new CredentialEncryptionError( + 'MIGRATION_CREDENTIAL_ENCRYPTION_KEYS must be a JSON object', + ); + } + for (const [id, hex] of Object.entries(parsed as Record)) { + if (typeof hex !== 'string') { + throw new CredentialEncryptionError( + `MIGRATION_CREDENTIAL_ENCRYPTION_KEYS["${id}"] must be a hex string`, + ); + } + keys.set(id, parseAes256KeyHex(hex, `MIGRATION_CREDENTIAL_ENCRYPTION_KEYS["${id}"]`)); + } + } + + return keys; +} + +/** Resolve the active key id + material for new encryptions. */ +export function resolveActiveMigrationCredentialKey( + keyRing: CredentialKeyRing, + env: NodeJS.ProcessEnv = process.env, +): { keyId: string; key: Buffer } { + if (keyRing.size === 0) { + throw new CredentialEncryptionError( + 'MIGRATION_CREDENTIAL_ENCRYPTION_KEY is not configured', + ); + } + const activeId = env['MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID'] ?? 'default'; + const key = keyRing.get(activeId); + if (!key) { + throw new CredentialEncryptionError( + `Active encryption key id "${activeId}" is not in the key ring`, + ); + } + return { keyId: activeId, key }; +} + +/** Encrypt a UTF-8 plaintext string with AES-256-GCM. */ +export function encryptCredentialPlaintext( + plaintext: string, + keyRing: CredentialKeyRing, + env: NodeJS.ProcessEnv = process.env, +): EncryptedCredentialBlob { + const { keyId, key } = resolveActiveMigrationCredentialKey(keyRing, env); + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(AES_ALGO, key, iv); + let ciphertext = cipher.update(plaintext, 'utf8', 'hex'); + ciphertext += cipher.final('hex'); + const authTag = cipher.getAuthTag().toString('hex'); + return { + keyId, + iv: iv.toString('hex'), + ciphertext, + authTag, + }; +} + +/** Decrypt an AES-256-GCM blob. Fails closed on wrong/missing key or tampered ciphertext. */ +export function decryptCredentialPlaintext( + blob: EncryptedCredentialBlob, + keyRing: CredentialKeyRing, +): string { + const key = keyRing.get(blob.keyId); + if (!key) { + throw new CredentialEncryptionError( + `Encryption key id "${blob.keyId}" is not available`, + ); + } + const decipher = createDecipheriv(AES_ALGO, key, Buffer.from(blob.iv, 'hex')); + decipher.setAuthTag(Buffer.from(blob.authTag, 'hex')); + try { + let plaintext = decipher.update(blob.ciphertext, 'hex', 'utf8'); + plaintext += decipher.final('utf8'); + return plaintext; + } catch { + throw new CredentialEncryptionError('Credential decryption failed (wrong key or tampered data)'); + } +} + +/** Serialize blob columns for DB storage (single text column). */ +export function serializeEncryptedBlob(blob: EncryptedCredentialBlob): string { + return JSON.stringify(blob); +} + +/** Deserialize blob from DB storage. */ +export function deserializeEncryptedBlob(serialized: string): EncryptedCredentialBlob { + let parsed: unknown; + try { + parsed = JSON.parse(serialized); + } catch { + throw new CredentialEncryptionError('Stored credential ciphertext is not valid JSON'); + } + if ( + parsed === null || + typeof parsed !== 'object' || + !('keyId' in parsed) || + !('iv' in parsed) || + !('ciphertext' in parsed) || + !('authTag' in parsed) + ) { + throw new CredentialEncryptionError('Stored credential ciphertext is missing required fields'); + } + const blob = parsed as EncryptedCredentialBlob; + if ( + typeof blob.keyId !== 'string' || + typeof blob.iv !== 'string' || + typeof blob.ciphertext !== 'string' || + typeof blob.authTag !== 'string' + ) { + throw new CredentialEncryptionError('Stored credential ciphertext has invalid field types'); + } + return blob; +} diff --git a/apps/api/src/modules/migration/dto/upsert-migration-credential.dto.ts b/apps/api/src/modules/migration/dto/upsert-migration-credential.dto.ts new file mode 100644 index 00000000..fe790eba --- /dev/null +++ b/apps/api/src/modules/migration/dto/upsert-migration-credential.dto.ts @@ -0,0 +1,17 @@ +import { IsIn, IsObject } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { MIGRATION_SOURCE_PMS } from '@telivityhaip/shared'; + +export class UpsertMigrationCredentialDto { + @ApiProperty({ enum: MIGRATION_SOURCE_PMS, example: 'mews' }) + @IsIn([...MIGRATION_SOURCE_PMS]) + sourcePms!: (typeof MIGRATION_SOURCE_PMS)[number]; + + @ApiProperty({ + description: + 'Source-PMS credential payload (API keys, tokens). Encrypted at rest; never returned by the API.', + example: { clientToken: '***', accessToken: '***' }, + }) + @IsObject() + credentials!: Record; +} diff --git a/apps/api/src/modules/migration/migration-source-credentials.service.spec.ts b/apps/api/src/modules/migration/migration-source-credentials.service.spec.ts new file mode 100644 index 00000000..904ce6f9 --- /dev/null +++ b/apps/api/src/modules/migration/migration-source-credentials.service.spec.ts @@ -0,0 +1,278 @@ +import { randomBytes } from 'node:crypto'; +import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MigrationSourceCredentialsService } from './migration-source-credentials.service'; + +const KEY_HEX = randomBytes(32).toString('hex'); + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: any[]) => ({ op: 'and', conditions })), + desc: vi.fn((column: unknown) => ({ op: 'desc', column })), + eq: vi.fn((column: unknown, value: unknown) => ({ op: 'eq', column, value })), +})); + +vi.mock('@telivityhaip/database', () => ({ + auditLogs: { + __table: 'auditLogs', + propertyId: 'audit.propertyId', + action: 'audit.action', + entityType: 'audit.entityType', + entityId: 'audit.entityId', + description: 'audit.description', + newValue: 'audit.newValue', + }, + migrationSourceCredentials: { + __table: 'migrationSourceCredentials', + id: 'cred.id', + propertyId: 'cred.propertyId', + sourcePms: 'cred.sourcePms', + ciphertext: 'cred.ciphertext', + encryptionKeyId: 'cred.encryptionKeyId', + createdAt: 'cred.createdAt', + rotatedAt: 'cred.rotatedAt', + updatedAt: 'cred.updatedAt', + }, +})); + +const PROP = '11111111-1111-4111-8111-111111111111'; +const OTHER_PROP = '22222222-2222-4222-8222-222222222222'; +const CRED_ID = '33333333-3333-4333-8333-333333333333'; +const CREATED_AT = new Date('2026-01-01T00:00:00Z'); + +function metadataRow(overrides: Record = {}) { + return { + id: CRED_ID, + propertyId: PROP, + sourcePms: 'mews', + encryptionKeyId: 'default', + createdAt: CREATED_AT, + rotatedAt: null, + updatedAt: CREATED_AT, + ...overrides, + }; +} + +function createMockDb() { + const state = { + selectRows: [] as any[], + insertRows: [] as any[], + updateRows: [] as any[], + deleteWhere: [] as any[], + insertValues: [] as any[], + updateSet: undefined as any, + whereArgs: [] as any[], + auditValues: [] as any[], + storedCiphertext: '', + }; + + const db: any = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn((whereArg: any) => { + state.whereArgs.push(whereArg); + return { + orderBy: vi.fn(() => Promise.resolve(state.selectRows)), + limit: vi.fn(() => + Promise.resolve( + state.selectRows.length > 0 + ? state.selectRows + : state.storedCiphertext + ? [{ ciphertext: state.storedCiphertext, sourcePms: 'mews' }] + : [], + ), + ), + then: (resolve: any, reject: any) => + Promise.resolve(state.selectRows).then(resolve, reject), + }; + }), + })), + })), + insert: vi.fn((table: unknown) => ({ + values: vi.fn((values: any) => { + state.insertValues.push(values); + if ((table as any)?.__table === 'auditLogs') { + state.auditValues.push(values); + return Promise.resolve(); + } + if (values.ciphertext) { + state.storedCiphertext = values.ciphertext; + } + return { + returning: vi.fn(() => Promise.resolve(state.insertRows)), + }; + }), + })), + update: vi.fn(() => ({ + set: vi.fn((values: any) => { + state.updateSet = values; + if (values.ciphertext) { + state.storedCiphertext = values.ciphertext; + } + return { + where: vi.fn((whereArg: any) => { + state.whereArgs.push(whereArg); + return { + returning: vi.fn(() => Promise.resolve(state.updateRows)), + }; + }), + }; + }), + })), + delete: vi.fn(() => ({ + where: vi.fn((whereArg: any) => { + state.deleteWhere.push(whereArg); + return Promise.resolve(); + }), + })), + }; + + return { db, state }; +} + +describe('MigrationSourceCredentialsService', () => { + let mock: ReturnType; + let service: MigrationSourceCredentialsService; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { + ...originalEnv, + MIGRATION_CREDENTIAL_ENCRYPTION_KEY: KEY_HEX, + MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID: 'default', + }; + mock = createMockDb(); + service = new MigrationSourceCredentialsService(mock.db); + }); + + it('lists metadata without ciphertext or decrypted values', async () => { + mock.state.selectRows = [metadataRow()]; + + const result = await service.listMetadata(PROP); + + expect(result).toEqual([ + { + id: CRED_ID, + propertyId: PROP, + sourcePms: 'mews', + encryptionKeyId: 'default', + createdAt: CREATED_AT, + rotatedAt: null, + updatedAt: CREATED_AT, + }, + ]); + expect(result[0]).not.toHaveProperty('ciphertext'); + expect(result[0]).not.toHaveProperty('credentials'); + expect(mock.state.whereArgs[0]).toEqual({ + op: 'eq', + column: 'cred.propertyId', + value: PROP, + }); + }); + + it('encrypts credentials at rest and audits without secret values', async () => { + mock.state.selectRows = []; + mock.state.insertRows = [metadataRow()]; + + const result = await service.upsert( + PROP, + 'mews', + { clientToken: 'top-secret', accessToken: 'also-secret' }, + { userId: 'user-1', userEmail: 'admin@example.com', ipAddress: '127.0.0.1' }, + ); + + expect(result).not.toHaveProperty('credentials'); + expect(result).not.toHaveProperty('ciphertext'); + expect(mock.state.insertValues[0].ciphertext).not.toContain('top-secret'); + expect(mock.state.insertValues[0].ciphertext).not.toContain('also-secret'); + expect(mock.state.insertValues[0]).toMatchObject({ + propertyId: PROP, + sourcePms: 'mews', + encryptionKeyId: 'default', + }); + expect(mock.state.auditValues[0]).toMatchObject({ + propertyId: PROP, + action: 'create', + entityType: 'migration_source_credential', + entityId: CRED_ID, + description: 'migration_credential.created', + }); + expect(JSON.stringify(mock.state.auditValues[0].newValue)).not.toContain('top-secret'); + expect(mock.state.auditValues[0].newValue).not.toHaveProperty('ciphertext'); + }); + + it('round-trips credentials via decryptForRunner', async () => { + mock.state.selectRows = []; + mock.state.insertRows = [metadataRow()]; + await service.upsert(PROP, 'mews', { apiKey: 'runner-secret' }); + + const decrypted = await service.decryptForRunner(PROP, 'mews'); + expect(decrypted).toEqual({ apiKey: 'runner-secret' }); + }); + + it('fails closed when decrypting tampered ciphertext', async () => { + mock.state.selectRows = []; + mock.state.insertRows = [metadataRow()]; + await service.upsert(PROP, 'mews', { apiKey: 'runner-secret' }); + + const parsed = JSON.parse(mock.state.storedCiphertext); + parsed.authTag = '0'.repeat(parsed.authTag.length); + mock.state.storedCiphertext = JSON.stringify(parsed); + mock.state.selectRows = [{ ciphertext: mock.state.storedCiphertext, sourcePms: 'mews' }]; + + await expect(service.decryptForRunner(PROP, 'mews')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('rejects upsert when encryption key is not configured', async () => { + delete process.env['MIGRATION_CREDENTIAL_ENCRYPTION_KEY']; + await expect( + service.upsert(PROP, 'mews', { apiKey: 'x' }), + ).rejects.toBeInstanceOf(InternalServerErrorException); + }); + + it('deletes credentials scoped by property and source PMS', async () => { + mock.state.selectRows = [metadataRow()]; + + const result = await service.delete(PROP, 'mews', { + userId: 'user-1', + userEmail: 'admin@example.com', + ipAddress: '127.0.0.1', + }); + + expect(result).toEqual({ deleted: 1 }); + expect(mock.state.deleteWhere[0]).toEqual({ + op: 'and', + conditions: [ + { op: 'eq', column: 'cred.propertyId', value: PROP }, + { op: 'eq', column: 'cred.sourcePms', value: 'mews' }, + ], + }); + expect(mock.state.auditValues[0]).toMatchObject({ + action: 'delete', + description: 'migration_credential.erased', + }); + expect(mock.state.auditValues[0].newValue).not.toHaveProperty('ciphertext'); + }); + + it('does not delete credentials from another tenant', async () => { + mock.state.selectRows = []; + + await expect(service.delete(OTHER_PROP, 'mews')).rejects.toBeInstanceOf(NotFoundException); + expect(mock.state.deleteWhere).toEqual([]); + }); + + it('erases all credentials for a property when sourcePms is omitted', async () => { + mock.state.selectRows = [ + metadataRow({ sourcePms: 'mews' }), + metadataRow({ id: '44444444-4444-4444-8444-444444444444', sourcePms: 'apaleo' }), + ]; + + const result = await service.delete(PROP); + + expect(result).toEqual({ deleted: 2 }); + expect(mock.state.auditValues).toHaveLength(2); + expect(mock.state.auditValues[0].description).toBe('migration_credential.erased_all'); + }); +}); diff --git a/apps/api/src/modules/migration/migration-source-credentials.service.ts b/apps/api/src/modules/migration/migration-source-credentials.service.ts new file mode 100644 index 00000000..d45be74c --- /dev/null +++ b/apps/api/src/modules/migration/migration-source-credentials.service.ts @@ -0,0 +1,301 @@ +import { + BadRequestException, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { and, desc, eq } from 'drizzle-orm'; +import { auditLogs, migrationSourceCredentials } from '@telivityhaip/database'; +import type { MigrationSourcePms } from '@telivityhaip/shared'; +import { + CredentialEncryptionError, + decryptCredentialPlaintext, + deserializeEncryptedBlob, + encryptCredentialPlaintext, + loadMigrationCredentialKeyRingFromEnv, + serializeEncryptedBlob, +} from '../../common/crypto/credential-encryption'; +import { actorFields, type AuditActor } from '../../common/audit/audit-actor'; +import { DRIZZLE } from '../../database/database.module'; + +export interface MigrationCredentialMetadata { + id: string; + propertyId: string; + sourcePms: string; + encryptionKeyId: string; + createdAt: Date; + rotatedAt: Date | null; + updatedAt: Date; +} + +/** Redacted audit payload — never includes credential values or ciphertext. */ +function auditMetadata(row: { + id: string; + propertyId: string; + sourcePms: string; + encryptionKeyId: string; + createdAt: Date; + rotatedAt: Date | null; + updatedAt: Date; +}): MigrationCredentialMetadata { + return { + id: row.id, + propertyId: row.propertyId, + sourcePms: row.sourcePms, + encryptionKeyId: row.encryptionKeyId, + createdAt: row.createdAt, + rotatedAt: row.rotatedAt ?? null, + updatedAt: row.updatedAt, + }; +} + +/** + * Server-side vault for encrypted source-PMS migration credentials. + * Decrypted values are for the migration runner only — never exposed via HTTP. + */ +@Injectable() +export class MigrationSourceCredentialsService { + private readonly logger = new Logger(MigrationSourceCredentialsService.name); + + constructor(@Inject(DRIZZLE) private readonly db: any) {} + + private keyRing() { + return loadMigrationCredentialKeyRingFromEnv(); + } + + async listMetadata(propertyId: string): Promise { + const rows = await this.db + .select({ + id: migrationSourceCredentials.id, + propertyId: migrationSourceCredentials.propertyId, + sourcePms: migrationSourceCredentials.sourcePms, + encryptionKeyId: migrationSourceCredentials.encryptionKeyId, + createdAt: migrationSourceCredentials.createdAt, + rotatedAt: migrationSourceCredentials.rotatedAt, + updatedAt: migrationSourceCredentials.updatedAt, + }) + .from(migrationSourceCredentials) + .where(eq(migrationSourceCredentials.propertyId, propertyId)) + .orderBy(desc(migrationSourceCredentials.createdAt)); + + return rows.map((row: MigrationCredentialMetadata) => auditMetadata(row)); + } + + async upsert( + propertyId: string, + sourcePms: MigrationSourcePms, + credentials: Record, + actor?: AuditActor, + ): Promise { + let blob; + try { + blob = encryptCredentialPlaintext(JSON.stringify(credentials), this.keyRing()); + } catch (err) { + if (err instanceof CredentialEncryptionError) { + throw new InternalServerErrorException(err.message); + } + throw err; + } + + const serialized = serializeEncryptedBlob(blob); + const now = new Date(); + const [existing] = await this.db + .select({ id: migrationSourceCredentials.id }) + .from(migrationSourceCredentials) + .where( + and( + eq(migrationSourceCredentials.propertyId, propertyId), + eq(migrationSourceCredentials.sourcePms, sourcePms), + ), + ) + .limit(1); + + let row: { + id: string; + propertyId: string; + sourcePms: string; + encryptionKeyId: string; + createdAt: Date; + rotatedAt: Date | null; + updatedAt: Date; + }; + + if (existing) { + [row] = await this.db + .update(migrationSourceCredentials) + .set({ + ciphertext: serialized, + encryptionKeyId: blob.keyId, + rotatedAt: now, + updatedAt: now, + }) + .where( + and( + eq(migrationSourceCredentials.id, existing.id), + eq(migrationSourceCredentials.propertyId, propertyId), + ), + ) + .returning({ + id: migrationSourceCredentials.id, + propertyId: migrationSourceCredentials.propertyId, + sourcePms: migrationSourceCredentials.sourcePms, + encryptionKeyId: migrationSourceCredentials.encryptionKeyId, + createdAt: migrationSourceCredentials.createdAt, + rotatedAt: migrationSourceCredentials.rotatedAt, + updatedAt: migrationSourceCredentials.updatedAt, + }); + await this.writeAudit('update', propertyId, row.id, 'migration_credential.rotated', row, actor); + } else { + [row] = await this.db + .insert(migrationSourceCredentials) + .values({ + propertyId, + sourcePms, + ciphertext: serialized, + encryptionKeyId: blob.keyId, + }) + .returning({ + id: migrationSourceCredentials.id, + propertyId: migrationSourceCredentials.propertyId, + sourcePms: migrationSourceCredentials.sourcePms, + encryptionKeyId: migrationSourceCredentials.encryptionKeyId, + createdAt: migrationSourceCredentials.createdAt, + rotatedAt: migrationSourceCredentials.rotatedAt, + updatedAt: migrationSourceCredentials.updatedAt, + }); + await this.writeAudit('create', propertyId, row.id, 'migration_credential.created', row, actor); + } + + return auditMetadata(row); + } + + /** + * Decrypt credentials for the migration runner. Must never be called from a + * controller response path. + */ + async decryptForRunner( + propertyId: string, + sourcePms: MigrationSourcePms, + ): Promise> { + const [row] = await this.db + .select({ + ciphertext: migrationSourceCredentials.ciphertext, + sourcePms: migrationSourceCredentials.sourcePms, + }) + .from(migrationSourceCredentials) + .where( + and( + eq(migrationSourceCredentials.propertyId, propertyId), + eq(migrationSourceCredentials.sourcePms, sourcePms), + ), + ) + .limit(1); + + if (!row) { + throw new NotFoundException( + `Migration credentials for source PMS "${sourcePms}" not found`, + ); + } + + try { + const blob = deserializeEncryptedBlob(row.ciphertext); + const plaintext = decryptCredentialPlaintext(blob, this.keyRing()); + const parsed: unknown = JSON.parse(plaintext); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new CredentialEncryptionError('Stored credentials are not a JSON object'); + } + return parsed as Record; + } catch (err) { + this.logger.warn( + `Failed to decrypt migration credentials for property=${propertyId} sourcePms=${sourcePms}`, + ); + if (err instanceof CredentialEncryptionError) { + throw new BadRequestException(err.message); + } + throw err; + } + } + + /** + * Delete stored credentials — invoked on migration project completion or GDPR erasure. + * When sourcePms is omitted, all credentials for the property are removed. + */ + async delete( + propertyId: string, + sourcePms?: MigrationSourcePms, + actor?: AuditActor, + ): Promise<{ deleted: number }> { + const conditions = [eq(migrationSourceCredentials.propertyId, propertyId)]; + if (sourcePms) { + conditions.push(eq(migrationSourceCredentials.sourcePms, sourcePms)); + } + + const rows = await this.db + .select({ + id: migrationSourceCredentials.id, + propertyId: migrationSourceCredentials.propertyId, + sourcePms: migrationSourceCredentials.sourcePms, + encryptionKeyId: migrationSourceCredentials.encryptionKeyId, + createdAt: migrationSourceCredentials.createdAt, + rotatedAt: migrationSourceCredentials.rotatedAt, + updatedAt: migrationSourceCredentials.updatedAt, + }) + .from(migrationSourceCredentials) + .where(and(...conditions)); + + if (rows.length === 0) { + if (sourcePms) { + throw new NotFoundException( + `Migration credentials for source PMS "${sourcePms}" not found`, + ); + } + return { deleted: 0 }; + } + + await this.db + .delete(migrationSourceCredentials) + .where(and(...conditions)); + + for (const row of rows) { + await this.writeAudit( + 'delete', + propertyId, + row.id, + sourcePms ? 'migration_credential.erased' : 'migration_credential.erased_all', + row, + actor, + ); + } + + return { deleted: rows.length }; + } + + private async writeAudit( + action: 'create' | 'update' | 'delete', + propertyId: string, + entityId: string, + description: string, + row: { + id: string; + propertyId: string; + sourcePms: string; + encryptionKeyId: string; + createdAt: Date; + rotatedAt: Date | null; + updatedAt: Date; + }, + actor?: AuditActor, + ) { + await this.db.insert(auditLogs).values({ + propertyId, + action, + entityType: 'migration_source_credential', + entityId, + description, + newValue: auditMetadata(row), + ...actorFields(actor), + }); + } +} diff --git a/apps/api/src/modules/migration/migration.controller.spec.ts b/apps/api/src/modules/migration/migration.controller.spec.ts new file mode 100644 index 00000000..40ad2499 --- /dev/null +++ b/apps/api/src/modules/migration/migration.controller.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { PERMISSIONS_KEY } from '../auth/permissions.decorator'; +import { MigrationController } from './migration.controller'; + +describe('MigrationController authorization', () => { + it('requires settings.manage on credential routes', () => { + const listPerms = Reflect.getMetadata( + PERMISSIONS_KEY, + MigrationController.prototype.listCredentials, + ); + const upsertPerms = Reflect.getMetadata( + PERMISSIONS_KEY, + MigrationController.prototype.upsertCredentials, + ); + const deletePerms = Reflect.getMetadata( + PERMISSIONS_KEY, + MigrationController.prototype.deleteCredential, + ); + const deleteAllPerms = Reflect.getMetadata( + PERMISSIONS_KEY, + MigrationController.prototype.deleteAllCredentials, + ); + + expect(listPerms).toEqual(['settings.manage']); + expect(upsertPerms).toEqual(['settings.manage']); + expect(deletePerms).toEqual(['settings.manage']); + expect(deleteAllPerms).toEqual(['settings.manage']); + }); +}); diff --git a/apps/api/src/modules/migration/migration.controller.ts b/apps/api/src/modules/migration/migration.controller.ts new file mode 100644 index 00000000..8eca34a4 --- /dev/null +++ b/apps/api/src/modules/migration/migration.controller.ts @@ -0,0 +1,86 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { MIGRATION_SOURCE_PMS } from '@telivityhaip/shared'; +import { Roles } from '../auth/roles.decorator'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { AuditActorCtx, type AuditActor } from '../../common/audit/audit-actor'; +import { MigrationSourceCredentialsService } from './migration-source-credentials.service'; +import { UpsertMigrationCredentialDto } from './dto/upsert-migration-credential.dto'; + +/** + * Automated PMS migration — encrypted source credential vault. + * Credentials are encrypted at rest; API responses never include plaintext secrets. + */ +@ApiTags('migration') +@Controller('migration') +@Roles('admin') +export class MigrationController { + constructor(private readonly credentials: MigrationSourceCredentialsService) {} + + @Get('credentials') + @RequirePermissions('settings.manage') + @ApiOperation({ + summary: 'List stored source-PMS credential metadata (no secret values)', + }) + listCredentials(@Query('propertyId', new ParseUUIDPipe()) propertyId: string) { + return this.credentials.listMetadata(propertyId); + } + + @Post('credentials') + @RequirePermissions('settings.manage') + @ApiOperation({ + summary: 'Store or rotate encrypted source-PMS credentials for migration', + }) + upsertCredentials( + @Query('propertyId', new ParseUUIDPipe()) propertyId: string, + @Body() dto: UpsertMigrationCredentialDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.credentials.upsert(propertyId, dto.sourcePms, dto.credentials, actor); + } + + @Delete('credentials/:sourcePms') + @RequirePermissions('settings.manage') + @ApiOperation({ + summary: + 'Erase encrypted source-PMS credentials (migration completion or GDPR erasure)', + }) + deleteCredential( + @Query('propertyId', new ParseUUIDPipe()) propertyId: string, + @Param('sourcePms') sourcePms: string, + @AuditActorCtx() actor: AuditActor, + ) { + if (!MIGRATION_SOURCE_PMS.includes(sourcePms as (typeof MIGRATION_SOURCE_PMS)[number])) { + throw new BadRequestException( + `sourcePms must be one of: ${MIGRATION_SOURCE_PMS.join(', ')}`, + ); + } + return this.credentials.delete( + propertyId, + sourcePms as (typeof MIGRATION_SOURCE_PMS)[number], + actor, + ); + } + + @Delete('credentials') + @RequirePermissions('settings.manage') + @ApiOperation({ + summary: 'Erase all encrypted source-PMS credentials for a property', + }) + deleteAllCredentials( + @Query('propertyId', new ParseUUIDPipe()) propertyId: string, + @AuditActorCtx() actor: AuditActor, + ) { + return this.credentials.delete(propertyId, undefined, actor); + } +} diff --git a/apps/api/src/modules/migration/migration.module.ts b/apps/api/src/modules/migration/migration.module.ts new file mode 100644 index 00000000..0d87aff3 --- /dev/null +++ b/apps/api/src/modules/migration/migration.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { MigrationController } from './migration.controller'; +import { MigrationSourceCredentialsService } from './migration-source-credentials.service'; + +@Module({ + imports: [AuthModule], + controllers: [MigrationController], + providers: [MigrationSourceCredentialsService], + exports: [MigrationSourceCredentialsService], +}) +export class MigrationModule {} diff --git a/packages/database/src/migrations/0019_migration_source_credentials.sql b/packages/database/src/migrations/0019_migration_source_credentials.sql new file mode 100644 index 00000000..2bbe3689 --- /dev/null +++ b/packages/database/src/migrations/0019_migration_source_credentials.sql @@ -0,0 +1,17 @@ +-- Encrypted source-PMS credentials for automated migration connectors. +CREATE TABLE IF NOT EXISTS migration_source_credentials ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + source_pms varchar(50) NOT NULL, + ciphertext text NOT NULL, + encryption_key_id varchar(50) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + rotated_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS migration_source_credentials_property_source_unique + ON migration_source_credentials (property_id, source_pms); + +CREATE INDEX IF NOT EXISTS migration_source_credentials_property_idx + ON migration_source_credentials (property_id); diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index a0ff6ff0..bb91858d 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -1308,6 +1308,26 @@ async function main() { CREATE INDEX IF NOT EXISTS door_lock_credentials_property_status_idx ON door_lock_credentials (property_id, status)`)); + await db.execute(sql.raw(` + CREATE TABLE IF NOT EXISTS migration_source_credentials ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + source_pms varchar(50) NOT NULL, + ciphertext text NOT NULL, + encryption_key_id varchar(50) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + rotated_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now() + )`)); + + await db.execute(sql.raw(` + CREATE UNIQUE INDEX IF NOT EXISTS migration_source_credentials_property_source_unique + ON migration_source_credentials (property_id, source_pms)`)); + + await db.execute(sql.raw(` + CREATE INDEX IF NOT EXISTS migration_source_credentials_property_idx + ON migration_source_credentials (property_id)`)); + // iCal calendar bridge (.ics import/export) — availability subtracts ical_blocks await db.execute(sql.raw(` DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'ical_feed_direction') THEN diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 7f364370..0cb14ca2 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -261,3 +261,6 @@ export { icalFeeds, icalBlocks, } from './ical.js'; + +// Automated PMS migration — encrypted source credentials vault +export { migrationSourceCredentials } from './migration.js'; diff --git a/packages/database/src/schema/migration.ts b/packages/database/src/schema/migration.ts new file mode 100644 index 00000000..dce7dff0 --- /dev/null +++ b/packages/database/src/schema/migration.ts @@ -0,0 +1,32 @@ +import { pgTable, uuid, varchar, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { properties } from './property.js'; + +/** + * Encrypted source-PMS API credentials for automated migration (Mews, Cloudbeds, + * Apaleo, OHIP). Plaintext secrets are AES-256-GCM encrypted at the app layer + * before persistence — never stored in jsonb like channel_connections.config. + * + * Decrypted values are server-side only (migration runner); API responses expose + * metadata only. + */ +export const migrationSourceCredentials = pgTable( + 'migration_source_credentials', + { + id: uuid('id').primaryKey().defaultRandom(), + propertyId: uuid('property_id').notNull().references(() => properties.id), + /** Source PMS identifier, e.g. mews | cloudbeds | apaleo | ohip */ + sourcePms: varchar('source_pms', { length: 50 }).notNull(), + /** JSON-serialized EncryptedCredentialBlob (iv + ciphertext + authTag + keyId) */ + ciphertext: text('ciphertext').notNull(), + encryptionKeyId: varchar('encryption_key_id', { length: 50 }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + rotatedAt: timestamp('rotated_at', { withTimezone: true }), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + propertySourceUnique: uniqueIndex('migration_source_credentials_property_source_unique').on( + table.propertyId, + table.sourcePms, + ), + }), +); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 38337ba8..8692fa25 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -273,3 +273,7 @@ export function checkFnrhComplete( /** Legacy alias for checkFnrhComplete */ export const isFnrhComplete = checkFnrhComplete; +/** Tier-1 source PMS identifiers for automated migration connectors. */ +export const MIGRATION_SOURCE_PMS = ['mews', 'cloudbeds', 'apaleo', 'ohip'] as const; +export type MigrationSourcePms = (typeof MIGRATION_SOURCE_PMS)[number]; + From 4b470f3b399de9d01b3cf3ed46c3eee361d94d1f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 01:03:05 +0000 Subject: [PATCH 2/2] chore: sync README test counts after migration credential vault tests Co-authored-by: telivity-otaip --- README.md | 8 ++++---- docs/test-stats.json | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 23981f1f..523c75bd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License -1458 Tests Passing 12 AI Agents +1474 Tests Passing 12 AI Agents

@@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire | OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) | | XML Processing | fast-xml-parser | Booking.com OTA XML protocol | | Package Manager | pnpm workspaces | Monorepo management | -| Testing | Vitest (1458 tests across 206 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | +| Testing | Vitest (1474 tests across 209 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | | Containers | Docker + docker-compose | Local dev and production deployment | | CI/CD | GitHub Actions | Automated testing, builds, and releases | @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# All tests (1458 tests across 206 test files) +# All tests (1474 tests across 209 test files) # API tests only pnpm --filter @telivityhaip/api test @@ -1188,7 +1188,7 @@ HAIP is built in public and contributions are welcome. pnpm install # Install dependencies pnpm build # Build all workspace packages pnpm dev # Start API in dev mode (hot reload) -pnpm test # Run all tests (1458 tests, 206 files) +pnpm test # Run all tests (1474 tests, 209 files) pnpm lint # ESLint ``` diff --git a/docs/test-stats.json b/docs/test-stats.json index 08bef479..550998b0 100644 --- a/docs/test-stats.json +++ b/docs/test-stats.json @@ -1,5 +1,5 @@ { - "tests": 1458, - "files": 206, - "updatedAt": "2026-08-09T19:14:16.625Z" + "tests": 1474, + "files": 209, + "updatedAt": "2026-08-11T01:02:26.116Z" }