From 22ebc32d2726baa4c58201408d08ada1faa28577 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 2 Oct 2026 16:01:25 -0400 Subject: [PATCH 01/10] feat(core)!: fail closed on field-level encryption in deployed stages Field-level encryption used to be skipped whenever STAGE was dev, test or local, and silently turned off (with a warning) when no key was configured. `frigg deploy` defaults to --stage dev, so a plain deploy stored credentials in plaintext. The rule now keys off where the code runs, not the stage name, and lives in one place (database/encryption/encryption-config.js) shared by the Prisma client, DocumentDBEncryptionService, the integration-mapping helpers and the encryption health check: - Deployed (in Lambda, not serverless-offline / invoke local / Jest): KMS_KEY_ARN or AES_KEY_ID+AES_KEY encrypts on every stage, dev included. No key throws EncryptionConfigurationError at Prisma client creation, naming the fix, unless FRIGG_ENCRYPTION_DISABLED=true opts out explicitly (warned once per cold start). A configured key wins over the opt-out. - Local (frigg start, tests, scripts): unchanged. dev/test/local skip encryption; other stages encrypt when a key is set. The Prisma client also stops continuing without encryption when the extension fails to initialise. /health/detailed reports a deployed stage without a key as unhealthy and an opt-out as disabled; it no longer reads BYPASS_ENCRYPTION_STAGE, which never switched encryption off. BREAKING CHANGE: a deployed stage with no KMS_KEY_ARN and no AES_KEY_ID/AES_KEY now refuses to start instead of writing plaintext, including stages named dev, test or local. Set encryption.fieldLevelEncryptionMethod: 'kms' (or provide AES keys), or opt out with FRIGG_ENCRYPTION_DISABLED=true. Co-Authored-By: Claude Opus 5.5 --- .../documentdb-encryption-service.test.js | 62 ++++ .../database/documentdb-encryption-service.js | 31 +- .../database/encryption/encryption-config.js | 227 ++++++++++++ .../encryption/encryption-config.test.js | 332 ++++++++++++++++++ .../integration-mapping-encryption.js | 2 +- .../core/database/prisma.encryption.test.js | 125 +++++++ packages/core/database/prisma.js | 47 +-- .../check-encryption-health-use-case.js | 95 +++-- .../check-encryption-health-use-case.test.js | 102 +++++- 9 files changed, 911 insertions(+), 112 deletions(-) create mode 100644 packages/core/database/encryption/encryption-config.js create mode 100644 packages/core/database/encryption/encryption-config.test.js create mode 100644 packages/core/database/prisma.encryption.test.js diff --git a/packages/core/database/__tests__/documentdb-encryption-service.test.js b/packages/core/database/__tests__/documentdb-encryption-service.test.js index d5e4ac0b9..f6ad7bd6a 100644 --- a/packages/core/database/__tests__/documentdb-encryption-service.test.js +++ b/packages/core/database/__tests__/documentdb-encryption-service.test.js @@ -375,4 +375,66 @@ describe('DocumentDBEncryptionService', () => { ); }); }); + + describe('configuration from the environment', () => { + const ENV_KEYS = [ + 'AWS_LAMBDA_FUNCTION_NAME', + 'LAMBDA_TASK_ROOT', + 'AWS_EXECUTION_ENV', + 'IS_OFFLINE', + 'IS_LOCAL', + 'JEST_WORKER_ID', + 'STAGE', + 'NODE_ENV', + 'KMS_KEY_ARN', + 'AES_KEY_ID', + 'AES_KEY', + 'FRIGG_ENCRYPTION_DISABLED', + ]; + let savedEnv; + + beforeEach(() => { + savedEnv = Object.fromEntries( + ENV_KEYS.map((key) => [key, process.env[key]]) + ); + for (const key of ENV_KEYS) delete process.env[key]; + }); + + afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it('refuses to construct when deployed with no key, even on STAGE=dev', () => { + process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-app-dev-auth'; + process.env.STAGE = 'dev'; + + expect(() => new DocumentDBEncryptionService()).toThrow( + /No field-level encryption key/ + ); + }); + + it('encrypts when deployed with a KMS key on STAGE=dev', () => { + process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-app-dev-auth'; + process.env.STAGE = 'dev'; + process.env.KMS_KEY_ARN = 'arn:aws:kms:us-east-1:123456789012:key/abc'; + + const envService = new DocumentDBEncryptionService(); + + expect(envService.enabled).toBe(true); + expect(envService.cryptor.shouldUseAws).toBe(true); + }); + + it('stays disabled under frigg start on STAGE=dev', () => { + process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-app-dev-auth'; + process.env.IS_OFFLINE = 'true'; + process.env.STAGE = 'dev'; + + const envService = new DocumentDBEncryptionService(); + + expect(envService.enabled).toBe(false); + }); + }); }); diff --git a/packages/core/database/documentdb-encryption-service.js b/packages/core/database/documentdb-encryption-service.js index 68063fffc..f8c33bc99 100644 --- a/packages/core/database/documentdb-encryption-service.js +++ b/packages/core/database/documentdb-encryption-service.js @@ -4,6 +4,7 @@ const { getFieldsToEncryptOnWrite, loadCustomEncryptionSchema, } = require('./encryption/encryption-schema-registry'); +const { getEncryptionConfig } = require('./encryption/encryption-config'); /** * Encryption service specifically for DocumentDB repositories @@ -41,43 +42,29 @@ class DocumentDBEncryptionService { } /** - * Initialize Cryptor with environment-based configuration. - * Matches the logic from @friggframework/core/database/prisma.js + * Initialize Cryptor with environment-based configuration, using the + * same rule as the Prisma client (see encryption/encryption-config.js). * - * Encryption is bypassed in dev/test/local stages. - * Production uses AWS KMS (if available) or AES encryption. + * Local runs skip encryption on the dev, test and local stages. A + * deployed runtime with no key throws instead of writing plaintext. * * @private + * @throws {EncryptionConfigurationError} deployed with no usable key */ _initializeCryptor() { // Load custom encryption schema from app definition BEFORE checking configuration // This ensures custom fields (like User.username) are registered before any encryption operations loadCustomEncryptionSchema(); - // Match logic from packages/core/database/prisma.js - const stage = process.env.STAGE || process.env.NODE_ENV || 'development'; - const bypassEncryption = ['dev', 'test', 'local'].includes(stage.toLowerCase()); + const config = getEncryptionConfig(); - if (bypassEncryption) { + if (!config.enabled) { this.cryptor = null; this.enabled = false; return; } - // Determine encryption method (ensure boolean values) - const hasKMS = !!(process.env.KMS_KEY_ARN && process.env.KMS_KEY_ARN.trim() !== ''); - const hasAES = !!(process.env.AES_KEY_ID && process.env.AES_KEY_ID.trim() !== ''); - - if (!hasKMS && !hasAES) { - console.warn('[DocumentDBEncryptionService] No encryption keys configured. Encryption disabled.'); - this.cryptor = null; - this.enabled = false; - return; - } - - // KMS takes precedence over AES - const shouldUseAws = hasKMS; - this.cryptor = new Cryptor({ shouldUseAws }); + this.cryptor = new Cryptor({ shouldUseAws: config.method === 'kms' }); this.enabled = true; } diff --git a/packages/core/database/encryption/encryption-config.js b/packages/core/database/encryption/encryption-config.js new file mode 100644 index 000000000..d0de08cf2 --- /dev/null +++ b/packages/core/database/encryption/encryption-config.js @@ -0,0 +1,227 @@ +/** + * Field-level encryption configuration: decides whether sensitive fields are + * encrypted, and with which key, from the process environment. + * + * The rule fails closed: + * + * - **Deployed** (running in AWS Lambda, and not under + * serverless-offline, `serverless invoke local` or Jest): the stage name is + * ignored. A configured key (KMS_KEY_ARN, or AES_KEY_ID with AES_KEY) turns + * encryption on. With no key, the configuration is an error and the Prisma + * client refuses to start, unless FRIGG_ENCRYPTION_DISABLED=true opts out + * explicitly. + * - **Local** (`frigg start`, tests, scripts on a laptop): the stages dev, + * test and local skip encryption. Any other stage encrypts when a key is + * configured and stays plaintext, with a warning, when none is. + * + * Every module that encrypts or reports on encryption reads this one rule. + */ + +const { logger } = require('./logger'); + +const LOCAL_BYPASS_STAGES = ['dev', 'test', 'local']; +const OPT_OUT_VAR = 'FRIGG_ENCRYPTION_DISABLED'; + +class EncryptionConfigurationError extends Error { + constructor(message) { + super(message); + this.name = 'EncryptionConfigurationError'; + } +} + +function isSet(value) { + return typeof value === 'string' && value.trim() !== ''; +} + +function isTrue(value) { + if (typeof value !== 'string') return false; + const normalized = value.trim().toLowerCase(); + return normalized === 'true' || normalized === '1'; +} + +/** + * True when the process runs in AWS Lambda for real. The Lambda runtime sets + * AWS_LAMBDA_FUNCTION_NAME, LAMBDA_TASK_ROOT and AWS_EXECUTION_ENV + * (AWS_Lambda_). serverless-offline sets the first two as well, so + * IS_OFFLINE, IS_LOCAL and Jest mark a local run. Other AWS hosts (CloudShell, + * CodeBuild, ECS) set AWS_EXECUTION_ENV to other values and count as local: + * Frigg deploys its handlers to Lambda only, and CLI commands run from CI must + * not trip the deployed-stage check. + * + * @param {Object} [env=process.env] + * @returns {boolean} + */ +function isDeployedRuntime(env = process.env) { + const onAwsRuntime = + isSet(env.AWS_LAMBDA_FUNCTION_NAME) || + isSet(env.LAMBDA_TASK_ROOT) || + String(env.AWS_EXECUTION_ENV ?? '').startsWith('AWS_Lambda_'); + if (!onAwsRuntime) return false; + + const localRun = + isTrue(env.IS_OFFLINE) || + isTrue(env.IS_LOCAL) || + isSet(env.JEST_WORKER_ID); + return !localRun; +} + +function missingKeyMessage(stage) { + return ( + `[Frigg] No field-level encryption key is configured for stage "${stage}". ` + + 'A deployed Frigg app will not write credentials and other sensitive fields in plaintext. ' + + "Fix: set encryption: { fieldLevelEncryptionMethod: 'kms' } in the app definition and redeploy " + + '(Frigg creates or discovers a KMS key and sets KMS_KEY_ARN), ' + + 'or provide AES_KEY_ID and AES_KEY (32 characters) in the Lambda environment. ' + + `To run this stage without encryption on purpose, set ${OPT_OUT_VAR}=true ` + + "(or encryption: { fieldLevelEncryptionMethod: 'none' } in the app definition)." + ); +} + +function incompleteAesMessage(stage) { + return ( + `[Frigg] AES_KEY_ID is set but AES_KEY is not (stage "${stage}"). ` + + 'Provide AES_KEY (32 characters) alongside AES_KEY_ID, ' + + "or use KMS with encryption: { fieldLevelEncryptionMethod: 'kms' }." + ); +} + +/** + * Resolves the encryption configuration without side effects. + * + * @param {Object} [env=process.env] + * @returns {{ + * enabled: boolean, + * method: 'kms'|'aes'|undefined, + * mode: 'kms'|'aes'|'none', + * runtime: 'deployed'|'local', + * stage: string, + * bypassed: boolean, + * optedOut: boolean, + * optOutIgnored: boolean, + * hasKMS: boolean, + * hasAES: boolean, + * error: string|null, + * }} `error` is set when a deployed runtime has no usable key. + */ +function resolveEncryptionConfig(env = process.env) { + const stage = env.STAGE || env.NODE_ENV || 'development'; + const runtime = isDeployedRuntime(env) ? 'deployed' : 'local'; + const hasKMS = isSet(env.KMS_KEY_ARN); + const hasAES = isSet(env.AES_KEY_ID); + const optOutRequested = isTrue(env[OPT_OUT_VAR]); + + const base = { + enabled: false, + method: undefined, + mode: 'none', + runtime, + stage, + bypassed: false, + optedOut: false, + optOutIgnored: false, + hasKMS, + hasAES, + error: null, + }; + + if (runtime === 'local') { + if (LOCAL_BYPASS_STAGES.includes(String(stage).toLowerCase())) { + return { ...base, bypassed: true }; + } + if (hasKMS || hasAES) { + const method = hasKMS ? 'kms' : 'aes'; + return { ...base, enabled: true, method, mode: method }; + } + return base; + } + + const hasCompleteAES = hasAES && isSet(env.AES_KEY); + if (hasKMS || hasCompleteAES) { + const method = hasKMS ? 'kms' : 'aes'; + return { + ...base, + enabled: true, + method, + mode: method, + optOutIgnored: optOutRequested, + }; + } + + if (optOutRequested) { + return { ...base, optedOut: true }; + } + + return { + ...base, + error: hasAES ? incompleteAesMessage(stage) : missingKeyMessage(stage), + }; +} + +const warned = new Set(); + +function warnOnce(key, message) { + if (warned.has(key)) return; + warned.add(key); + logger.warn(message); +} + +/** + * Resolves the encryption configuration for use: throws when a deployed + * runtime has no usable key, and warns once per process (once per Lambda + * cold start) when encryption is explicitly disabled. + * + * @param {Object} [env=process.env] + * @returns {ReturnType} + * @throws {EncryptionConfigurationError} + */ +function getEncryptionConfig(env = process.env) { + const config = resolveEncryptionConfig(env); + + if (config.error) { + throw new EncryptionConfigurationError(config.error); + } + + if (config.optedOut) { + warnOnce( + 'opted-out', + `[Frigg] ${OPT_OUT_VAR}=true: field-level encryption is OFF for stage "${config.stage}". ` + + 'Credentials and other sensitive fields are stored in PLAINTEXT. ' + + `Remove ${OPT_OUT_VAR} and configure a key (fieldLevelEncryptionMethod: 'kms') to encrypt them.` + ); + } + + if (config.optOutIgnored) { + warnOnce( + 'opt-out-ignored', + `[Frigg] ${OPT_OUT_VAR}=true is ignored because an encryption key is configured; ` + + `fields are encrypted with ${config.method.toUpperCase()}. ` + + 'Turning encryption off while a key is present would leave encrypted data unreadable.' + ); + } + + if (config.runtime === 'local' && !config.enabled && !config.bypassed) { + warnOnce( + 'local-no-keys', + `No encryption keys configured (KMS_KEY_ARN or AES_KEY_ID) for local stage "${config.stage}". ` + + 'Field-level encryption disabled for this local run. ' + + 'A deployed stage without a key refuses to start.' + ); + } + + return config; +} + +/** Test helper: let the once-per-process warnings fire again. */ +function resetEncryptionConfigWarnings() { + warned.clear(); +} + +module.exports = { + EncryptionConfigurationError, + LOCAL_BYPASS_STAGES, + OPT_OUT_VAR, + getEncryptionConfig, + isDeployedRuntime, + resetEncryptionConfigWarnings, + resolveEncryptionConfig, +}; diff --git a/packages/core/database/encryption/encryption-config.test.js b/packages/core/database/encryption/encryption-config.test.js new file mode 100644 index 000000000..a0b00aa3e --- /dev/null +++ b/packages/core/database/encryption/encryption-config.test.js @@ -0,0 +1,332 @@ +const { logger } = require('./logger'); +const { + EncryptionConfigurationError, + getEncryptionConfig, + isDeployedRuntime, + resetEncryptionConfigWarnings, + resolveEncryptionConfig, +} = require('./encryption-config'); + +const KMS = { KMS_KEY_ARN: 'arn:aws:kms:us-east-1:123456789012:key/abc' }; +const AES = { + AES_KEY_ID: 'key-1', + AES_KEY: '0123456789abcdef0123456789abcdef', +}; +const OPT_OUT = { FRIGG_ENCRYPTION_DISABLED: 'true' }; + +// What each runtime looks like from inside the process. +const LAMBDA = { + AWS_LAMBDA_FUNCTION_NAME: 'my-app-dev-auth', + LAMBDA_TASK_ROOT: '/var/task', + AWS_EXECUTION_ENV: 'AWS_Lambda_nodejs20.x', +}; +// serverless-offline (frigg start) sets the Lambda variables too, plus IS_OFFLINE. +const OFFLINE = { ...LAMBDA, IS_OFFLINE: 'true' }; +// `serverless invoke local` sets IS_LOCAL. +const INVOKE_LOCAL = { ...LAMBDA, IS_LOCAL: 'true' }; +const JEST = { JEST_WORKER_ID: '1', NODE_ENV: 'test' }; +const SCRIPT = {}; // plain `node script.js` on a laptop + +describe('isDeployedRuntime', () => { + it.each([ + ['a deployed Lambda', LAMBDA, true], + [ + 'a Lambda seen only through AWS_EXECUTION_ENV', + { AWS_EXECUTION_ENV: 'AWS_Lambda_nodejs20.x' }, + true, + ], + [ + 'a Lambda seen only through LAMBDA_TASK_ROOT', + { LAMBDA_TASK_ROOT: '/var/task' }, + true, + ], + // Frigg deploys to Lambda only; CI and CloudShell also set AWS_EXECUTION_ENV. + [ + 'an ECS task or CodeBuild job', + { AWS_EXECUTION_ENV: 'AWS_ECS_FARGATE' }, + false, + ], + ['AWS CloudShell', { AWS_EXECUTION_ENV: 'CloudShell' }, false], + ['serverless-offline', OFFLINE, false], + [ + 'serverless-offline with IS_OFFLINE=1', + { ...LAMBDA, IS_OFFLINE: '1' }, + false, + ], + ['serverless invoke local', INVOKE_LOCAL, false], + ['Jest, even with Lambda variables set', { ...LAMBDA, ...JEST }, false], + ['a local script', SCRIPT, false], + [ + 'IS_OFFLINE=false inside Lambda', + { ...LAMBDA, IS_OFFLINE: 'false' }, + true, + ], + ])('%s → %p', (_label, env, expected) => { + expect(isDeployedRuntime(env)).toBe(expected); + }); +}); + +describe('resolveEncryptionConfig', () => { + describe('deployed (in Lambda, not offline): the stage name never matters', () => { + it.each(['dev', 'test', 'local', 'production', undefined])( + 'STAGE=%p with KMS_KEY_ARN → KMS', + (STAGE) => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + ...KMS, + STAGE, + }); + expect(config).toMatchObject({ + enabled: true, + method: 'kms', + mode: 'kms', + runtime: 'deployed', + bypassed: false, + error: null, + }); + } + ); + + it('AES_KEY_ID + AES_KEY → AES', () => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + ...AES, + STAGE: 'dev', + }); + expect(config).toMatchObject({ + enabled: true, + method: 'aes', + error: null, + }); + }); + + it('prefers KMS when both keys are set', () => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + ...KMS, + ...AES, + }); + expect(config.method).toBe('kms'); + }); + + it('treats a whitespace-only key as missing', () => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + KMS_KEY_ARN: ' ', + STAGE: 'prod', + }); + expect(config.enabled).toBe(false); + expect(config.error).toMatch(/No field-level encryption key/); + }); + + it.each(['dev', 'test', 'local', 'production'])( + 'STAGE=%p with no key → error, not plaintext', + (STAGE) => { + const config = resolveEncryptionConfig({ ...LAMBDA, STAGE }); + expect(config.enabled).toBe(false); + expect(config.mode).toBe('none'); + expect(config.bypassed).toBe(false); + expect(config.error).toMatch(/No field-level encryption key/); + expect(config.error).toMatch( + /fieldLevelEncryptionMethod: 'kms'/ + ); + expect(config.error).toMatch(/AES_KEY_ID/); + expect(config.error).toMatch(/FRIGG_ENCRYPTION_DISABLED=true/); + } + ); + + it('AES_KEY_ID without AES_KEY → error naming AES_KEY', () => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + AES_KEY_ID: 'key-1', + STAGE: 'prod', + }); + expect(config.enabled).toBe(false); + expect(config.error).toMatch( + /AES_KEY_ID is set but AES_KEY is not/ + ); + }); + + it('opt-out with no key → disabled without error', () => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + ...OPT_OUT, + STAGE: 'prod', + }); + expect(config).toMatchObject({ + enabled: false, + mode: 'none', + optedOut: true, + bypassed: false, + error: null, + }); + }); + + it.each(['TRUE', '1', ' true '])('opt-out accepts %p', (value) => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + FRIGG_ENCRYPTION_DISABLED: value, + }); + expect(config.optedOut).toBe(true); + expect(config.error).toBeNull(); + }); + + it.each(['false', '0', '', 'yes'])( + 'FRIGG_ENCRYPTION_DISABLED=%p is not an opt-out', + (value) => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + FRIGG_ENCRYPTION_DISABLED: value, + }); + expect(config.optedOut).toBe(false); + expect(config.error).not.toBeNull(); + } + ); + + it('opt-out does not override a configured key', () => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + ...KMS, + ...OPT_OUT, + }); + expect(config).toMatchObject({ + enabled: true, + method: 'kms', + optedOut: false, + optOutIgnored: true, + }); + }); + }); + + describe.each([ + ['frigg start (serverless-offline)', OFFLINE], + ['serverless invoke local', INVOKE_LOCAL], + ['Jest', JEST], + ['a local script', SCRIPT], + ])('local: %s', (_label, runtimeEnv) => { + it.each(['dev', 'test', 'local', 'DEV'])( + 'STAGE=%p bypasses even with a key', + (STAGE) => { + const config = resolveEncryptionConfig({ + ...runtimeEnv, + ...KMS, + STAGE, + }); + expect(config).toMatchObject({ + enabled: false, + bypassed: true, + runtime: 'local', + error: null, + }); + } + ); + + it('a non-local stage with KMS_KEY_ARN encrypts', () => { + const config = resolveEncryptionConfig({ + ...runtimeEnv, + ...KMS, + STAGE: 'production', + }); + expect(config).toMatchObject({ enabled: true, method: 'kms' }); + }); + + it('a non-local stage with AES_KEY_ID encrypts', () => { + const config = resolveEncryptionConfig({ + ...runtimeEnv, + ...AES, + STAGE: 'production', + }); + expect(config).toMatchObject({ enabled: true, method: 'aes' }); + }); + + it('a non-local stage with no key stays plaintext, without error', () => { + const config = resolveEncryptionConfig({ + ...runtimeEnv, + STAGE: 'production', + }); + expect(config).toMatchObject({ + enabled: false, + bypassed: false, + mode: 'none', + error: null, + }); + }); + }); + + it('falls back to NODE_ENV, then development, for the stage', () => { + expect( + resolveEncryptionConfig({ NODE_ENV: 'test', ...KMS }).bypassed + ).toBe(true); + expect(resolveEncryptionConfig({ ...KMS }).stage).toBe('development'); + expect(resolveEncryptionConfig({ ...KMS }).enabled).toBe(true); + }); +}); + +describe('getEncryptionConfig', () => { + let warn; + + beforeEach(() => { + resetEncryptionConfigWarnings(); + warn = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('returns the resolved config when encryption is on', () => { + expect(getEncryptionConfig({ ...LAMBDA, ...KMS })).toMatchObject({ + enabled: true, + method: 'kms', + }); + }); + + it('throws EncryptionConfigurationError when deployed with no key', () => { + expect(() => getEncryptionConfig({ ...LAMBDA, STAGE: 'dev' })).toThrow( + EncryptionConfigurationError + ); + expect(() => getEncryptionConfig({ ...LAMBDA, STAGE: 'dev' })).toThrow( + /No field-level encryption key/ + ); + }); + + it('does not throw locally with no key', () => { + expect(getEncryptionConfig({ ...OFFLINE, STAGE: 'dev' })).toMatchObject( + { + enabled: false, + } + ); + }); + + it('warns once per process on opt-out', () => { + const env = { ...LAMBDA, ...OPT_OUT }; + expect(getEncryptionConfig(env).enabled).toBe(false); + getEncryptionConfig(env); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/FRIGG_ENCRYPTION_DISABLED/); + expect(warn.mock.calls[0][0]).toMatch(/PLAINTEXT/); + }); + + it('warns when an opt-out is ignored because a key is set', () => { + getEncryptionConfig({ ...LAMBDA, ...KMS, ...OPT_OUT }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/ignored/); + }); + + it('reads process.env by default', () => { + const saved = { ...process.env }; + try { + process.env.AWS_LAMBDA_FUNCTION_NAME = 'fn'; + delete process.env.JEST_WORKER_ID; + delete process.env.IS_OFFLINE; + delete process.env.KMS_KEY_ARN; + delete process.env.AES_KEY_ID; + delete process.env.FRIGG_ENCRYPTION_DISABLED; + expect(() => getEncryptionConfig()).toThrow( + EncryptionConfigurationError + ); + } finally { + process.env = saved; + } + }); +}); diff --git a/packages/core/database/encryption/integration-mapping-encryption.js b/packages/core/database/encryption/integration-mapping-encryption.js index e936afe93..9ad9e15fc 100644 --- a/packages/core/database/encryption/integration-mapping-encryption.js +++ b/packages/core/database/encryption/integration-mapping-encryption.js @@ -1,4 +1,4 @@ -const { getEncryptionConfig } = require('../prisma'); +const { getEncryptionConfig } = require('./encryption-config'); const { Cryptor } = require('../../encrypt/Cryptor'); const { getFieldsToDecryptOnRead, diff --git a/packages/core/database/prisma.encryption.test.js b/packages/core/database/prisma.encryption.test.js new file mode 100644 index 000000000..231973c42 --- /dev/null +++ b/packages/core/database/prisma.encryption.test.js @@ -0,0 +1,125 @@ +/** + * Prisma client creation against the field-level encryption rule: a deployed + * runtime with no key refuses to start rather than writing plaintext. + */ + +jest.mock('./config', () => ({ DB_TYPE: 'postgresql' })); + +const mockExtends = jest.fn(); +jest.mock( + '../generated/prisma-postgresql', + () => ({ + PrismaClient: class { + $extends(extension) { + mockExtends(extension); + return this; + } + }, + }), + { virtual: true } +); + +jest.mock('./encryption/encryption-schema-registry', () => ({ + ...jest.requireActual('./encryption/encryption-schema-registry'), + loadCustomEncryptionSchema: jest.fn(), +})); + +const { + loadCustomEncryptionSchema, +} = require('./encryption/encryption-schema-registry'); +const { logger } = require('./encryption/logger'); +const { + EncryptionConfigurationError, +} = require('./encryption/encryption-config'); +const { prisma } = require('./prisma'); + +const ENV_KEYS = [ + 'AWS_LAMBDA_FUNCTION_NAME', + 'LAMBDA_TASK_ROOT', + 'AWS_EXECUTION_ENV', + 'IS_OFFLINE', + 'IS_LOCAL', + 'JEST_WORKER_ID', + 'STAGE', + 'NODE_ENV', + 'KMS_KEY_ARN', + 'AES_KEY_ID', + 'AES_KEY', + 'FRIGG_ENCRYPTION_DISABLED', +]; + +function createClient() { + // Any property read creates the singleton. + return prisma.$extends; +} + +describe('Prisma client creation and field-level encryption', () => { + let savedEnv; + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + for (const key of ENV_KEYS) delete process.env[key]; + delete global._prismaInstance; + mockExtends.mockReset(); + loadCustomEncryptionSchema.mockReset(); + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + jest.spyOn(logger, 'info').mockImplementation(() => {}); + jest.spyOn(logger, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + delete global._prismaInstance; + jest.restoreAllMocks(); + }); + + function deployed(extra = {}) { + Object.assign(process.env, { + AWS_LAMBDA_FUNCTION_NAME: 'my-app-dev-auth', + LAMBDA_TASK_ROOT: '/var/task', + STAGE: 'dev', + ...extra, + }); + } + + it('throws at startup when deployed with no key, even on STAGE=dev', () => { + deployed(); + expect(createClient).toThrow(EncryptionConfigurationError); + expect(createClient).toThrow(/No field-level encryption key/); + expect(global._prismaInstance).toBeUndefined(); + expect(mockExtends).not.toHaveBeenCalled(); + }); + + it('installs the encryption extension when deployed with a KMS key on STAGE=dev', () => { + deployed({ KMS_KEY_ARN: 'arn:aws:kms:us-east-1:123456789012:key/abc' }); + createClient(); + expect(mockExtends).toHaveBeenCalledTimes(1); + }); + + it('starts without encryption when deployed with the explicit opt-out', () => { + deployed({ FRIGG_ENCRYPTION_DISABLED: 'true' }); + createClient(); + expect(mockExtends).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/FRIGG_ENCRYPTION_DISABLED/) + ); + }); + + it('starts without encryption under frigg start (serverless-offline)', () => { + deployed({ IS_OFFLINE: 'true' }); + createClient(); + expect(mockExtends).not.toHaveBeenCalled(); + }); + + it('refuses to start when the encryption extension fails to initialise', () => { + deployed({ KMS_KEY_ARN: 'arn:aws:kms:us-east-1:123456789012:key/abc' }); + loadCustomEncryptionSchema.mockImplementation(() => { + throw new Error('bad custom schema'); + }); + expect(createClient).toThrow(/bad custom schema/); + expect(global._prismaInstance).toBeUndefined(); + }); +}); diff --git a/packages/core/database/prisma.js b/packages/core/database/prisma.js index 5042d1608..c035cb0e1 100644 --- a/packages/core/database/prisma.js +++ b/packages/core/database/prisma.js @@ -4,6 +4,7 @@ const { const { loadCustomEncryptionSchema } = require('./encryption/encryption-schema-registry'); const { logger } = require('./encryption/logger'); const { Cryptor } = require('../encrypt/Cryptor'); +const { getEncryptionConfig } = require('./encryption/encryption-config'); const config = require('./config'); /** @@ -32,33 +33,6 @@ function ensureMongoDbUrl() { ); } -function getEncryptionConfig() { - const STAGE = process.env.STAGE || process.env.NODE_ENV || 'development'; - const shouldBypassEncryption = ['dev', 'test', 'local'].includes(STAGE); - - if (shouldBypassEncryption) { - return { enabled: false }; - } - - const hasKMS = - process.env.KMS_KEY_ARN && process.env.KMS_KEY_ARN.trim() !== ''; - const hasAES = - process.env.AES_KEY_ID && process.env.AES_KEY_ID.trim() !== ''; - - if (!hasKMS && !hasAES) { - logger.warn( - 'No encryption keys configured (KMS_KEY_ARN or AES_KEY_ID). ' + - 'Field-level encryption disabled. Set STAGE=production and configure keys to enable.' - ); - return { enabled: false }; - } - - return { - enabled: true, - method: hasKMS ? 'kms' : 'aes', - }; -} - const prismaClientSingleton = () => { let PrismaClient; @@ -103,9 +77,13 @@ const prismaClientSingleton = () => { errorFormat: 'pretty', }); + // Throws when a deployed runtime has no encryption key: the app must not + // start and write sensitive fields in plaintext. const encryptionConfig = getEncryptionConfig(); if (encryptionConfig.enabled) { + // Fail closed: if the extension cannot be installed, do not hand out + // a client that would write plaintext. try { // Load custom encryption schema from appDefinition before creating extension loadCustomEncryptionSchema(); @@ -120,17 +98,14 @@ const prismaClientSingleton = () => { enabled: true, }) ); - - logger.info( - `Field-level encryption enabled using ${encryptionConfig.method.toUpperCase()}` - ); } catch (error) { - logger.error( - 'Failed to initialize encryption extension:', - error - ); - logger.warn('Continuing without encryption...'); + logger.error('Failed to initialize encryption extension:', error); + throw error; } + + logger.info( + `Field-level encryption enabled using ${encryptionConfig.method.toUpperCase()}` + ); } else { logger.info('Field-level encryption disabled'); } diff --git a/packages/core/database/use-cases/check-encryption-health-use-case.js b/packages/core/database/use-cases/check-encryption-health-use-case.js index 23b3d5b35..ca11e9b9e 100644 --- a/packages/core/database/use-cases/check-encryption-health-use-case.js +++ b/packages/core/database/use-cases/check-encryption-health-use-case.js @@ -1,3 +1,12 @@ +const { + resolveEncryptionConfig, + OPT_OUT_VAR, +} = require('../encryption/encryption-config'); + +/** + * Reports whether field-level encryption is on and working, using the same + * rule the Prisma client applies at startup (encryption/encryption-config.js). + */ class CheckEncryptionHealthUseCase { constructor({ testEncryptionUseCase }) { this.testEncryptionUseCase = testEncryptionUseCase; @@ -5,78 +14,60 @@ class CheckEncryptionHealthUseCase { async execute() { const config = this._getEncryptionConfiguration(); + const summary = { + mode: config.mode, + bypassed: config.bypassed, + optedOut: config.optedOut, + runtime: config.runtime, + stage: config.stage, + debug: { + hasKMS: config.hasKMS, + hasAES: config.hasAES, + }, + }; - if (config.isBypassed || config.mode === 'none') { - const testResult = config.isBypassed - ? 'Encryption bypassed for this stage' - : 'No encryption keys configured'; + if (config.error) { + return { + status: 'unhealthy', + ...summary, + testResult: config.error, + encryptionWorks: false, + }; + } + if (!config.enabled) { return { status: 'disabled', - mode: config.mode, - bypassed: config.isBypassed, - stage: config.stage, - testResult, + ...summary, + testResult: this._disabledReason(config), encryptionWorks: false, - debug: { - hasKMS: config.hasKMS, - hasAES: config.hasAES, - }, }; } try { const testResults = await this.testEncryptionUseCase.execute(); - - return { - ...testResults, - mode: config.mode, - bypassed: config.isBypassed, - stage: config.stage, - debug: { - hasKMS: config.hasKMS, - hasAES: config.hasAES, - }, - }; + return { ...testResults, ...summary }; } catch (error) { return { status: 'unhealthy', - mode: config.mode, - bypassed: config.isBypassed, - stage: config.stage, + ...summary, testResult: `Encryption test failed: ${error.message}`, encryptionWorks: false, - debug: { - hasKMS: config.hasKMS, - hasAES: config.hasAES, - }, }; } } - _getEncryptionConfiguration() { - const { STAGE, BYPASS_ENCRYPTION_STAGE, KMS_KEY_ARN, AES_KEY_ID } = - process.env; - - const defaultBypassStages = ['dev', 'test', 'local']; - const useEnv = BYPASS_ENCRYPTION_STAGE !== undefined; - const bypassStages = useEnv - ? BYPASS_ENCRYPTION_STAGE.split(',').map((s) => s.trim()) - : defaultBypassStages; - - const isBypassed = bypassStages.includes(STAGE); - const hasAES = AES_KEY_ID && AES_KEY_ID.trim() !== ''; - const hasKMS = KMS_KEY_ARN && KMS_KEY_ARN.trim() !== ''; - // Prefer KMS over AES when both are configured (KMS is more secure) - const mode = hasKMS ? 'kms' : hasAES ? 'aes' : 'none'; + _disabledReason(config) { + if (config.bypassed) return 'Encryption bypassed for this stage'; + if (config.optedOut) { + return `Encryption explicitly disabled (${OPT_OUT_VAR}=true); sensitive fields are stored in plaintext`; + } + return 'No encryption keys configured'; + } - return { - stage: STAGE || null, - isBypassed, - hasAES, - hasKMS, - mode, - }; + _getEncryptionConfiguration() { + const config = resolveEncryptionConfig(process.env); + return { ...config, stage: process.env.STAGE || null }; } } diff --git a/packages/core/database/use-cases/check-encryption-health-use-case.test.js b/packages/core/database/use-cases/check-encryption-health-use-case.test.js index ca6e08d3e..f4bd541ab 100644 --- a/packages/core/database/use-cases/check-encryption-health-use-case.test.js +++ b/packages/core/database/use-cases/check-encryption-health-use-case.test.js @@ -188,5 +188,105 @@ describe('CheckEncryptionHealthUseCase', () => { expect(result.testResult).toBe('No encryption keys configured'); }); }); -}); + describe('execute() - deployed runtime (in Lambda, not offline)', () => { + beforeEach(() => { + for (const key of [ + 'JEST_WORKER_ID', + 'IS_OFFLINE', + 'IS_LOCAL', + 'KMS_KEY_ARN', + 'AES_KEY_ID', + 'AES_KEY', + 'FRIGG_ENCRYPTION_DISABLED', + ]) { + delete process.env[key]; + } + process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-app-dev-health'; + }); + + it('runs the encryption test on STAGE=dev when a key is configured', async () => { + process.env.STAGE = 'dev'; + process.env.KMS_KEY_ARN = 'arn:aws:kms:us-east-1:123:key/abc'; + const testEncryption = { + execute: jest + .fn() + .mockResolvedValue({ status: 'enabled', encryptionWorks: true }), + }; + + const result = await new CheckEncryptionHealthUseCase({ + testEncryptionUseCase: testEncryption, + }).execute(); + + expect(testEncryption.execute).toHaveBeenCalled(); + expect(result.status).toBe('enabled'); + expect(result.bypassed).toBe(false); + expect(result.runtime).toBe('deployed'); + expect(result.mode).toBe('kms'); + }); + + it('reports unhealthy, with the fix, when no key is configured', async () => { + process.env.STAGE = 'dev'; + const testEncryption = { execute: jest.fn() }; + + const result = await new CheckEncryptionHealthUseCase({ + testEncryptionUseCase: testEncryption, + }).execute(); + + expect(testEncryption.execute).not.toHaveBeenCalled(); + expect(result.status).toBe('unhealthy'); + expect(result.mode).toBe('none'); + expect(result.bypassed).toBe(false); + expect(result.encryptionWorks).toBe(false); + expect(result.testResult).toMatch(/No field-level encryption key/); + expect(result.testResult).toMatch(/fieldLevelEncryptionMethod: 'kms'/); + }); + + it('reports disabled and opted out under FRIGG_ENCRYPTION_DISABLED=true', async () => { + process.env.STAGE = 'prod'; + process.env.FRIGG_ENCRYPTION_DISABLED = 'true'; + + const result = await new CheckEncryptionHealthUseCase({ + testEncryptionUseCase: { execute: jest.fn() }, + }).execute(); + + expect(result.status).toBe('disabled'); + expect(result.optedOut).toBe(true); + expect(result.testResult).toMatch(/FRIGG_ENCRYPTION_DISABLED/); + }); + + it('ignores BYPASS_ENCRYPTION_STAGE, which never switched encryption off', async () => { + process.env.STAGE = 'prod'; + process.env.BYPASS_ENCRYPTION_STAGE = 'prod'; + process.env.KMS_KEY_ARN = 'arn:aws:kms:us-east-1:123:key/abc'; + const testEncryption = { + execute: jest.fn().mockResolvedValue({ status: 'enabled' }), + }; + + const result = await new CheckEncryptionHealthUseCase({ + testEncryptionUseCase: testEncryption, + }).execute(); + + expect(result.bypassed).toBe(false); + expect(testEncryption.execute).toHaveBeenCalled(); + }); + }); + + describe('execute() - frigg start (serverless-offline)', () => { + it('reports the local bypass on STAGE=dev', async () => { + delete process.env.KMS_KEY_ARN; + delete process.env.AES_KEY_ID; + process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-app-dev-health'; + process.env.IS_OFFLINE = 'true'; + process.env.STAGE = 'dev'; + + const result = await new CheckEncryptionHealthUseCase({ + testEncryptionUseCase: { execute: jest.fn() }, + }).execute(); + + expect(result.status).toBe('disabled'); + expect(result.bypassed).toBe(true); + expect(result.runtime).toBe('local'); + }); + }); +}); From ae83e1e6682e89196f86d28a40247a19ee5cf08a Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 2 Oct 2026 16:01:25 -0400 Subject: [PATCH 02/10] feat(devtools,schemas): add fieldLevelEncryptionMethod 'none' opt-out `encryption: { fieldLevelEncryptionMethod: 'none' }` is the declarative way to run a deployed stage without field-level encryption now that core refuses to start without a key. The environment builder turns it into FRIGG_ENCRYPTION_DISABLED=true on every function, and the SSM offload blocklist keeps that variable in the Lambda environment, since Prisma reads it at INIT before SSM values load. The KMS wiring already ignores the stage name, so an app with fieldLevelEncryptionMethod: 'kms' deployed with --stage dev gets KMS_KEY_ARN and encrypts. Co-Authored-By: Claude Opus 5.5 --- packages/devtools/infrastructure/README.md | 6 ++++-- .../domains/parameters/offload-utils.js | 1 + .../domains/shared/environment-builder.js | 13 +++++++++++- .../shared/environment-builder.test.js | 20 +++++++++++++++++++ .../devtools/layers/prisma/.build-complete | 4 ++-- .../layers/prisma/nodejs/package.json | 2 +- .../schemas/app-definition.schema.json | 4 ++-- packages/schemas/tests/schemas.test.js | 19 ++++++++++++++++++ 8 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/devtools/infrastructure/README.md b/packages/devtools/infrastructure/README.md index cabce55a5..0bf227bde 100644 --- a/packages/devtools/infrastructure/README.md +++ b/packages/devtools/infrastructure/README.md @@ -163,9 +163,11 @@ const appDefinition = { enableVPCEndpoints: true // Optional: create VPC endpoints }, - // KMS encryption + // Field-level encryption. 'kms' wires KMS_KEY_ARN on every stage (dev too). + // A deployed stage with no key refuses to start; 'none' is the explicit + // plaintext opt-out (sets FRIGG_ENCRYPTION_DISABLED=true). encryption: { - fieldLevelEncryptionMethod: 'kms', + fieldLevelEncryptionMethod: 'kms', // 'kms' | 'aes' | 'none' createResourceIfNoneFound: true }, diff --git a/packages/devtools/infrastructure/domains/parameters/offload-utils.js b/packages/devtools/infrastructure/domains/parameters/offload-utils.js index 4ac55df52..ac8dbc693 100644 --- a/packages/devtools/infrastructure/domains/parameters/offload-utils.js +++ b/packages/devtools/infrastructure/domains/parameters/offload-utils.js @@ -40,6 +40,7 @@ const FRAMEWORK_ENV_BLOCKLIST = new Set([ 'KMS_KEY_ARN', 'AES_KEY', 'AES_KEY_ID', + 'FRIGG_ENCRYPTION_DISABLED', 'DATABASE_URL', 'DATABASE_HOST', 'DATABASE_PORT', diff --git a/packages/devtools/infrastructure/domains/shared/environment-builder.js b/packages/devtools/infrastructure/domains/shared/environment-builder.js index 94b2de0df..d54b12242 100644 --- a/packages/devtools/infrastructure/domains/shared/environment-builder.js +++ b/packages/devtools/infrastructure/domains/shared/environment-builder.js @@ -72,6 +72,16 @@ function addLoggingEnv(appDefinition, envVars) { envVars.FRIGG_LOG_LEVEL = normalized; } +// Field-level encryption fails closed in a deployed stage with no key (core's +// encryption-config.js). `fieldLevelEncryptionMethod: 'none'` is the explicit, +// documented way to run a stage in plaintext; core logs a warning on every +// cold start while it is set. +function addEncryptionEnv(appDefinition, envVars) { + if (appDefinition?.encryption?.fieldLevelEncryptionMethod === 'none') { + envVars.FRIGG_ENCRYPTION_DISABLED = 'true'; + } +} + /** * Get environment variables from AppDefinition * @@ -149,6 +159,7 @@ function getAppEnvironmentVars(appDefinition) { } addLoggingEnv(appDefinition, envVars); + addEncryptionEnv(appDefinition, envVars); if (envKeys.length > 0) { console.log( @@ -187,7 +198,7 @@ function getAppEnvironmentVars(appDefinition) { function buildEnvironment(appEnvironmentVars, discoveredResources) { const environment = { ...appEnvironmentVars, - STAGE: '${self:provider.stage}', // Used by encryption bypass logic + STAGE: '${self:provider.stage}', // Read by core: e.g. the local-only encryption bypass FRIGG_STACK: '${self:service}', FRIGG_STAGE: '${self:provider.stage}', FRIGG_REGION: '${self:provider.region}', diff --git a/packages/devtools/infrastructure/domains/shared/environment-builder.test.js b/packages/devtools/infrastructure/domains/shared/environment-builder.test.js index 1440bd65b..c9e77b209 100644 --- a/packages/devtools/infrastructure/domains/shared/environment-builder.test.js +++ b/packages/devtools/infrastructure/domains/shared/environment-builder.test.js @@ -468,6 +468,26 @@ describe('Environment Builder', () => { }); }); + describe('field-level encryption opt-out', () => { + it("emits FRIGG_ENCRYPTION_DISABLED=true for fieldLevelEncryptionMethod: 'none'", () => { + const result = getAppEnvironmentVars({ + encryption: { fieldLevelEncryptionMethod: 'none' }, + }); + + expect(result.FRIGG_ENCRYPTION_DISABLED).toBe('true'); + }); + + it.each([ + ['kms', { fieldLevelEncryptionMethod: 'kms' }], + ['aes', { fieldLevelEncryptionMethod: 'aes' }], + ['no encryption block', undefined], + ])('does not opt out for %s', (_label, encryption) => { + const result = getAppEnvironmentVars({ encryption }); + + expect(result.FRIGG_ENCRYPTION_DISABLED).toBeUndefined(); + }); + }); + describe('buildEnvironment()', () => { it('should combine app vars with standard Frigg variables', () => { const appEnvironmentVars = { diff --git a/packages/devtools/layers/prisma/.build-complete b/packages/devtools/layers/prisma/.build-complete index 4ef2d4fef..e39573a81 100644 --- a/packages/devtools/layers/prisma/.build-complete +++ b/packages/devtools/layers/prisma/.build-complete @@ -1,3 +1,3 @@ -Build completed: 2025-11-17T22:06:29.204Z -Node: v20.12.1 +Build completed: 2026-10-02T19:59:28.643Z +Node: v23.5.0 Platform: darwin diff --git a/packages/devtools/layers/prisma/nodejs/package.json b/packages/devtools/layers/prisma/nodejs/package.json index e7bbbc8bc..62b4ea8f3 100644 --- a/packages/devtools/layers/prisma/nodejs/package.json +++ b/packages/devtools/layers/prisma/nodejs/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "private": true, "dependencies": { - "@prisma/client": "^6.19.3" + "@prisma/client": "^6.16.3" } } diff --git a/packages/schemas/schemas/app-definition.schema.json b/packages/schemas/schemas/app-definition.schema.json index 95f6aa8af..39206ae78 100644 --- a/packages/schemas/schemas/app-definition.schema.json +++ b/packages/schemas/schemas/app-definition.schema.json @@ -311,8 +311,8 @@ "properties": { "fieldLevelEncryptionMethod": { "type": "string", - "description": "Method for field-level encryption", - "enum": ["kms", "aes"], + "description": "Method for field-level encryption. 'kms' creates or discovers a KMS key and sets KMS_KEY_ARN; 'aes' expects AES_KEY_ID and AES_KEY in the Lambda environment; 'none' sets FRIGG_ENCRYPTION_DISABLED=true and stores sensitive fields in plaintext. A deployed stage with no key and no 'none' opt-out refuses to start.", + "enum": ["kms", "aes", "none"], "default": "aes" }, "createResourceIfNoneFound": { diff --git a/packages/schemas/tests/schemas.test.js b/packages/schemas/tests/schemas.test.js index 750c85e2a..ca91a414f 100644 --- a/packages/schemas/tests/schemas.test.js +++ b/packages/schemas/tests/schemas.test.js @@ -69,6 +69,25 @@ describe('@friggframework/schemas', () => { expect(result.valid).toBe(true); }); + test.each(['kms', 'aes', 'none'])( + "accepts encryption.fieldLevelEncryptionMethod '%s'", + (method) => { + const result = validateAppDefinition({ + integrations: [], + encryption: { fieldLevelEncryptionMethod: method }, + }); + expect(result.valid).toBe(true); + } + ); + + test('rejects an unknown encryption.fieldLevelEncryptionMethod', () => { + const result = validateAppDefinition({ + integrations: [], + encryption: { fieldLevelEncryptionMethod: 'plaintext' }, + }); + expect(result.valid).toBe(false); + }); + test('should reject invalid app definition', () => { const appDef = { // Missing required integrations field From cf43fe616c520d70c29ee08ce48a46d639aed1b7 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 2 Oct 2026 16:01:25 -0400 Subject: [PATCH 03/10] docs: describe the fail-closed field-level encryption rule Replace the "dev, test and local stages bypass encryption" guidance in CLAUDE.md, the core encryption README, the DocumentDB encryption design note, the frigg and canary skills, the infrastructure README and the website assistant prompt with the deployed/local rule, the explicit opt-out, and the startup error and its fix. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/frigg-canary-test/SKILL.md | 3 +- .../assets/harness/.env.example | 3 +- .../frigg/references/security-encryption.md | 19 ++-- CLAUDE.md | 29 +++--- packages/core/database/encryption/README.md | 90 ++++++++++++++++--- .../documentdb-encryption-service.md | 79 +++++++++++++++- website/friggframework-api/assistant.mjs | 4 +- 7 files changed, 190 insertions(+), 37 deletions(-) diff --git a/.claude/skills/frigg-canary-test/SKILL.md b/.claude/skills/frigg-canary-test/SKILL.md index 2b8807f2d..261716fe6 100644 --- a/.claude/skills/frigg-canary-test/SKILL.md +++ b/.claude/skills/frigg-canary-test/SKILL.md @@ -78,7 +78,8 @@ replica set (`rs0`) because Prisma requires one; `mongo-init.sh` initialises it. `node_modules/@friggframework/core/generated/prisma-`. Run the matching `prisma:generate:*` before syncing schema or testing, or core fails to load. - **`DB_TYPE` must be set** — core's `prisma.js` picks the client from it. -- **`STAGE=dev`** bypasses field-level encryption, so persisted data is readable. +- **`STAGE=dev`** skips field-level encryption on this local run, so persisted data is readable. + (Only local runs skip it; a deployed stage with no key refuses to start.) - **Pin a matching canary.** Canaries publish per commit as `2.0.0--canary...0`; check `npm view @friggframework/core dist-tags.canary` and use the hash matching the PR diff --git a/.claude/skills/frigg-canary-test/assets/harness/.env.example b/.claude/skills/frigg-canary-test/assets/harness/.env.example index 060bb04ca..38aea35b8 100644 --- a/.claude/skills/frigg-canary-test/assets/harness/.env.example +++ b/.claude/skills/frigg-canary-test/assets/harness/.env.example @@ -9,5 +9,6 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5433/frigg_canary_test # DB_TYPE=mongodb # DATABASE_URL=mongodb://localhost:27018/frigg_canary_test?replicaSet=rs0&directConnection=true -# dev stage bypasses field-level encryption so persisted data is readable. +# On a local run the dev stage skips field-level encryption, so persisted data +# is readable. (A deployed stage with no key refuses to start instead.) STAGE=dev diff --git a/.claude/skills/frigg/references/security-encryption.md b/.claude/skills/frigg/references/security-encryption.md index 95d3b34ba..82c40edf7 100644 --- a/.claude/skills/frigg/references/security-encryption.md +++ b/.claude/skills/frigg/references/security-encryption.md @@ -17,22 +17,25 @@ Field-level encryption protects sensitive data at the application layer (databas 4. Cryptor performs the actual encryption via AWS KMS or AES (envelope pattern: a per-operation Data Encryption Key wrapped by a master key; format `keyId:encryptedText:encryptedKey`) 5. The database stores encrypted data -Auto-bypassed in dev/test/local stages. +Fails closed: a deployed stage (running in AWS, not under `frigg start`/serverless-offline or Jest) with no key refuses to start with `EncryptionConfigurationError`, whatever the stage name. Only local runs on dev/test/local stages skip encryption. The rule lives in `database/encryption/encryption-config.js`. ## Environment Configuration ```bash -# Production (AWS KMS - recommended) +# AWS KMS (recommended): set automatically on every stage, dev included, when +# the app definition has encryption: { fieldLevelEncryptionMethod: 'kms' } KMS_KEY_ARN=arn:aws:kms:... -STAGE=production -# AES Encryption (any environment) -AES_KEY_ID=local-dev-key +# AES encryption (any environment); both required when deployed +AES_KEY_ID=my-key-id AES_KEY=your-32-char-key -STAGE=production -# Bypass (dev/test/local stages) -STAGE=dev +# Explicit plaintext opt-out for a deployed stage with no key. Warns on every +# cold start. Also set by fieldLevelEncryptionMethod: 'none'. Never a default. +FRIGG_ENCRYPTION_DISABLED=true + +# Local runs only (frigg start, tests): these stages skip encryption +STAGE=dev # or test, local ``` ## Encrypted Fields diff --git a/CLAUDE.md b/CLAUDE.md index 5721194ad..99998a8eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,7 +207,7 @@ class MyIntegration extends IntegrationBase { - **Field-Level Encryption**: Transparent database-agnostic encryption via Prisma Client Extensions - **AWS KMS Integration**: Enterprise-grade encryption with envelope encryption pattern (recommended for production) - **AES Encryption**: Alternative encryption method for any environment including production -- **Environment-Based**: Auto-bypass in dev/test/local stages +- **Fails Closed**: A deployed stage with no key refuses to start; only local runs (`frigg start`, tests) on dev/test/local stages skip encryption - **OAuth2 Standardization**: Framework handles OAuth flows across API modules - **Signature Validation**: HMAC signature validation for webhook security - **VPC Support**: Lambda functions deployed in private subnets @@ -254,12 +254,13 @@ Database (encrypted storage) - **Infrastructure Layer**: Prisma extension handles encryption transparently - **External Services**: Cryptor adapts AWS KMS and crypto library -**Configuration** (`packages/core/database/prisma.js`): +**Configuration** (`packages/core/database/prisma.js`, rule in `packages/core/database/encryption/encryption-config.js`): ```javascript -// Automatic based on environment variables +// Automatic based on environment variables. +// Throws EncryptionConfigurationError in a deployed runtime with no key. const encryptionConfig = getEncryptionConfig(); -// Returns: { enabled: boolean, method: 'kms' | 'aes' } +// Returns: { enabled: boolean, method: 'kms' | 'aes' | undefined, runtime: 'deployed' | 'local', ... } if (encryptionConfig.enabled) { const cryptor = new Cryptor({ @@ -274,18 +275,24 @@ if (encryptionConfig.enabled) { **Environment Variables**: ```bash -# Production (AWS KMS - recommended) -KMS_KEY_ARN=arn:aws:kms:... # AWS KMS key (auto-discovered) -STAGE=production +# AWS KMS (recommended) - set by infrastructure when the app definition has +# encryption: { fieldLevelEncryptionMethod: 'kms' }, on every stage incl. dev +KMS_KEY_ARN=arn:aws:kms:... -# AES Encryption (valid for any environment) -AES_KEY_ID=local-dev-key +# AES encryption (valid for any environment) - both are required when deployed +AES_KEY_ID=my-key-id AES_KEY=your-32-char-key -STAGE=production # Can be used in production -# Stages that bypass: dev, test, local +# Explicit plaintext opt-out for a deployed stage with no key (warns on every +# cold start). Also set by fieldLevelEncryptionMethod: 'none'. Never a default. +FRIGG_ENCRYPTION_DISABLED=true ``` +**When encryption runs** — decided by where the code runs, not by the stage name: + +- **Deployed** (in AWS Lambda: `AWS_LAMBDA_FUNCTION_NAME` / `LAMBDA_TASK_ROOT` / `AWS_EXECUTION_ENV=AWS_Lambda_*`, and not `IS_OFFLINE` / `IS_LOCAL` / Jest): a key encrypts; no key throws `EncryptionConfigurationError` at Prisma client creation unless `FRIGG_ENCRYPTION_DISABLED=true`. +- **Local** (`frigg start` via serverless-offline, Jest, scripts): `STAGE` dev/test/local skips encryption; any other stage encrypts when a key is set and stays plaintext (with a warning) when not. + **Encrypted Fields** (defined in `encryption-schema-registry.js`): - **Credential**: `data.access_token`, `data.refresh_token`, `data.domain`, `data.id_token` diff --git a/packages/core/database/encryption/README.md b/packages/core/database/encryption/README.md index 8c609caf2..a3e362c2c 100644 --- a/packages/core/database/encryption/README.md +++ b/packages/core/database/encryption/README.md @@ -13,7 +13,7 @@ This module provides **transparent field-level encryption** for sensitive data i - ✅ **Hexagonal architecture**: Clean separation of concerns - ✅ **AWS KMS support**: Enterprise-grade encryption with AWS Key Management Service - ✅ **Local AES fallback**: Development mode using local encryption keys -- ✅ **Environment-based**: Automatic bypass in dev/test/local environments +- ✅ **Fails closed**: A deployed stage with no key refuses to start; only local runs (`frigg start`, tests) skip encryption - ✅ **Envelope encryption**: Secure key management pattern ## Architecture @@ -107,18 +107,46 @@ AES_KEY=your-32-character-secret-key-here STAGE=production # or development, staging, etc. ``` -**⚠️ Important**: Encryption is automatically **disabled** when `STAGE` is set to `dev`, `test`, or `local`, regardless of key configuration. +### When Encryption Runs -### Bypass Encryption +The rule lives in `encryption-config.js` and is shared by the Prisma client, the DocumentDB encryption service, the integration-mapping helpers and the `/health/detailed` check. It depends on **where the code runs**, not on the stage name. -To explicitly disable encryption: +A process is **deployed** when it runs in AWS Lambda (`AWS_LAMBDA_FUNCTION_NAME` or `LAMBDA_TASK_ROOT` is set, or `AWS_EXECUTION_ENV` starts with `AWS_Lambda_`) and is **not** a local run (`IS_OFFLINE=true` from serverless-offline / `frigg start`, `IS_LOCAL=true` from `serverless invoke local`, or `JEST_WORKER_ID` from Jest). Everything else is **local**. -```bash -# Disable encryption (development only) -STAGE=development # or dev, test, local +| Runtime | Key configured | `FRIGG_ENCRYPTION_DISABLED=true` | Result | +| --- | --- | --- | --- | +| Deployed, any stage (including `dev`) | `KMS_KEY_ARN` | any | Encrypt with KMS | +| Deployed, any stage | `AES_KEY_ID` + `AES_KEY` | any | Encrypt with AES | +| Deployed, any stage | `AES_KEY_ID` without `AES_KEY` | no | **Refuses to start** (`EncryptionConfigurationError`) | +| Deployed, any stage | none | no | **Refuses to start** (`EncryptionConfigurationError`) | +| Deployed, any stage | none | yes | Plaintext, with a warning on every cold start | +| Local, `STAGE` (or `NODE_ENV`) is `dev`, `test` or `local` | any | any | Skipped | +| Local, any other stage | `KMS_KEY_ARN` or `AES_KEY_ID` | any | Encrypt | +| Local, any other stage | none | any | Plaintext, with a warning | + +KMS wins when both keys are set. A configured key always wins over `FRIGG_ENCRYPTION_DISABLED`: switching encryption off while a key exists would leave already-encrypted data unreadable, so the opt-out is ignored (with a warning). + +"Refuses to start" means the Prisma client is never created: the first database access on a cold start throws + +``` +EncryptionConfigurationError: [Frigg] No field-level encryption key is configured for stage "dev". A deployed Frigg app will not write credentials and other sensitive fields in plaintext. Fix: ... +``` + +and `/health/detailed` reports `checks.encryption.status: "unhealthy"` with the same message. If a key is configured but the encryption extension fails to initialise, the client also refuses to start rather than continuing without encryption. + +To encrypt a local run, use a stage other than `dev`, `test` or `local` and configure a key. + +### Running a Deployed Stage Without Encryption + +Only do this on purpose, for a stage that never holds real credentials. Either set it in the app definition: + +```javascript +const appDefinition = { + encryption: { fieldLevelEncryptionMethod: 'none' }, +}; ``` -Or simply don't configure any encryption keys. In Production field level encryption **must** be enabled. +which the infrastructure builders turn into `FRIGG_ENCRYPTION_DISABLED=true` on every function, or set `FRIGG_ENCRYPTION_DISABLED=true` in the Lambda environment yourself. Every cold start logs a warning that sensitive fields are stored in plaintext. There is no default that does this. ## Encrypted Fields @@ -553,8 +581,8 @@ class MyRepositoryDocumentDB { #### Configuration -Uses the same environment variables and Cryptor as the Prisma Extension: -- `STAGE`: Bypasses encryption for dev/test/local +Uses the same rule (`encryption-config.js`, see [When Encryption Runs](#when-encryption-runs)) and Cryptor as the Prisma Extension. Constructing the service in a deployed runtime with no key throws `EncryptionConfigurationError`. +- `STAGE`: Skips encryption for dev/test/local on local runs only - `KMS_KEY_ARN`: AWS KMS encryption (production) - `AES_KEY_ID` + `AES_KEY`: AES encryption (fallback) @@ -615,10 +643,27 @@ curl http://localhost:3000/health/test-encryption "encryptionWorks": true } -# Response when encryption disabled: +# Response on a local run with STAGE=dev: { "status": "disabled", - "reason": "Encryption bypassed for stage: development" + "bypassed": true, + "runtime": "local", + "testResult": "Encryption bypassed for this stage" +} + +# Response in a deployed stage with no key (the app itself refuses to start): +{ + "status": "unhealthy", + "mode": "none", + "runtime": "deployed", + "testResult": "[Frigg] No field-level encryption key is configured for stage \"dev\". ..." +} + +# Response in a deployed stage with FRIGG_ENCRYPTION_DISABLED=true and no key: +{ + "status": "disabled", + "optedOut": true, + "testResult": "Encryption explicitly disabled (FRIGG_ENCRYPTION_DISABLED=true); sensitive fields are stored in plaintext" } ``` @@ -732,9 +777,10 @@ FRIGG_LOG_LEVEL=INFO **Check environment variables:** ```bash -echo $STAGE # Should be 'production' (not dev/test/local) echo $KMS_KEY_ARN # Should be set (for KMS) -echo $AES_KEY_ID # Should be set (for AES) +echo $AES_KEY_ID $AES_KEY # Both should be set (for AES) +echo $STAGE # Local runs only: dev/test/local skip encryption +echo $FRIGG_ENCRYPTION_DISABLED # Should be unset ``` **Check console logs:** @@ -749,6 +795,22 @@ or [Frigg] Field-level encryption disabled ``` +### Deployed Stage Refuses to Start + +**Error: "EncryptionConfigurationError: [Frigg] No field-level encryption key is configured for stage ..."** + +The function runs in AWS and has neither `KMS_KEY_ARN` nor `AES_KEY_ID` + `AES_KEY`. Earlier versions skipped encryption on stages named `dev`, `test` or `local` and wrote plaintext when no key was set; deployed stages now refuse instead. Fix one of: + +1. Set `encryption: { fieldLevelEncryptionMethod: 'kms' }` in the app definition and redeploy. Frigg creates or discovers a KMS key and sets `KMS_KEY_ARN` on every function, whatever the stage name. +2. Provide `AES_KEY_ID` and `AES_KEY` (32 characters) through the app definition's `environment` and the deploy environment. +3. To keep the stage in plaintext on purpose, set `encryption: { fieldLevelEncryptionMethod: 'none' }` (or `FRIGG_ENCRYPTION_DISABLED=true`). + +Data written in plaintext before the key existed stays readable: reads leave values that are not in the encrypted `keyId:iv:cipher:encKey` format untouched, and the next write of each record encrypts it. Records that are never rewritten stay in plaintext until you re-save them. + +**Error: "[Frigg] AES_KEY_ID is set but AES_KEY is not"** + +Provide `AES_KEY` alongside `AES_KEY_ID`, or switch to KMS. + ### AWS KMS Errors **Error: "User is not authorized to perform: kms:GenerateDataKey"** diff --git a/packages/core/database/encryption/documentdb-encryption-service.md b/packages/core/database/encryption/documentdb-encryption-service.md index 88f433546..3fd720ed0 100644 --- a/packages/core/database/encryption/documentdb-encryption-service.md +++ b/packages/core/database/encryption/documentdb-encryption-service.md @@ -225,7 +225,84 @@ Database (ENCRYPTED STORAGE) ✅ SECURE 1. **Consistency**: Same encryption format and Cryptor as Prisma Extension 2. **Reusability**: Single service used by all DocumentDB repositories 3. **Schema-Driven**: Uses `encryption-schema-registry.js` (same as Prisma) -4. **Environment-Aware**: Respects STAGE-based bypass (dev/test/local) +4. **Fails Closed**: Same rule as the Prisma client; a deployed runtime with no key throws, only local runs skip encryption +5. **Error-Tolerant**: Graceful handling of decryption failures +6. **Testable**: Can be unit tested independently of repositories + +--- + +## Technical Specification + +### Class Design + +```javascript +/** + * Encryption service specifically for DocumentDB repositories + * that use $runCommandRaw and bypass Prisma Extensions. + * + * Provides document-level encryption/decryption, + * handling nested fields according to the encryption schema registry. + */ +class DocumentDBEncryptionService { + constructor() + _initializeCryptor() + async encryptFields(modelName, document) + async decryptFields(modelName, document) + async _encryptFieldPath(document, fieldPath, modelName) + async _decryptFieldPath(document, fieldPath, modelName) + _isEncryptedValue(value) +} +``` + +### Method Specifications + +#### `constructor()` + +**Purpose**: Initialize the service and configure Cryptor + +**Behavior**: + +- Calls `_initializeCryptor()` immediately +- Sets up `this.cryptor` and `this.enabled` properties + +**No parameters** + +--- + +#### `_initializeCryptor()` + +**Purpose**: Initialize Cryptor with environment-based configuration + +**Logic**: + +```javascript +1. loadCustomEncryptionSchema() +2. config = getEncryptionConfig() // encryption/encryption-config.js, shared with the Prisma client + - throws EncryptionConfigurationError in a deployed runtime with no key +3. If !config.enabled (local dev/test/local stage, local run without keys, + or FRIGG_ENCRYPTION_DISABLED=true opt-out): + - Set this.cryptor = null + - Set this.enabled = false + - Return +4. Create Cryptor({ shouldUseAws: config.method === 'kms' }) +5. Set this.enabled = true +``` + +**Environment Variables Used** (see "When Encryption Runs" in README.md): + +- `AWS_LAMBDA_FUNCTION_NAME` / `LAMBDA_TASK_ROOT` / `AWS_EXECUTION_ENV=AWS_Lambda_*`, minus `IS_OFFLINE` / `IS_LOCAL` / `JEST_WORKER_ID`: deployed or local run +- `STAGE` or `NODE_ENV`: dev/test/local skip encryption on local runs only +- `KMS_KEY_ARN`: AWS KMS key ARN (enables KMS encryption) +- `AES_KEY_ID`: AES key identifier (enables AES encryption) +- `AES_KEY`: AES encryption key (required with AES_KEY_ID in a deployed runtime) +- `FRIGG_ENCRYPTION_DISABLED`: explicit plaintext opt-out for a deployed stage with no key + +### Design Principles + +1. **Consistency**: Same encryption format and Cryptor as Prisma Extension +2. **Reusability**: Single service used by all DocumentDB repositories +3. **Schema-Driven**: Uses `encryption-schema-registry.js` (same as Prisma) +4. **Fails Closed**: Same rule as the Prisma client; a deployed runtime with no key throws, only local runs skip encryption 5. **Error-Tolerant**: Graceful handling of decryption failures 6. **Testable**: Can be unit tested independently of repositories diff --git a/website/friggframework-api/assistant.mjs b/website/friggframework-api/assistant.mjs index 52d9a337e..fe8a3053a 100644 --- a/website/friggframework-api/assistant.mjs +++ b/website/friggframework-api/assistant.mjs @@ -99,7 +99,9 @@ developers and their agents: - Packaging/deploy: Serverless Framework fork (osls) + esbuild, generating serverless.yml and CloudFormation. - Database: PostgreSQL OR MongoDB, via Prisma (two schemas ship). -- Encryption: field-level, AWS KMS OR AES-256 (auto-bypassed in dev/test/local). +- Encryption: field-level, AWS KMS OR AES-256. Skipped only on local runs + (frigg start, tests) with STAGE dev/test/local; a deployed stage with no key + refuses to start unless FRIGG_ENCRYPTION_DISABLED=true opts out. - Async: AWS SQS queues; EventBridge Scheduler + cron for scheduled jobs. - Config/secrets: SSM Parameter Store + Secrets Manager. - HTTP: Express via serverless-http. From 9683f8b4ecbbb8a91b497c63e04277490769bad8 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 2 Oct 2026 16:02:27 -0400 Subject: [PATCH 04/10] chore: drop local Prisma layer build artifacts Co-Authored-By: Claude Opus 5.5 --- packages/devtools/layers/prisma/.build-complete | 4 ++-- packages/devtools/layers/prisma/nodejs/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/devtools/layers/prisma/.build-complete b/packages/devtools/layers/prisma/.build-complete index e39573a81..4ef2d4fef 100644 --- a/packages/devtools/layers/prisma/.build-complete +++ b/packages/devtools/layers/prisma/.build-complete @@ -1,3 +1,3 @@ -Build completed: 2026-10-02T19:59:28.643Z -Node: v23.5.0 +Build completed: 2025-11-17T22:06:29.204Z +Node: v20.12.1 Platform: darwin diff --git a/packages/devtools/layers/prisma/nodejs/package.json b/packages/devtools/layers/prisma/nodejs/package.json index 62b4ea8f3..e7bbbc8bc 100644 --- a/packages/devtools/layers/prisma/nodejs/package.json +++ b/packages/devtools/layers/prisma/nodejs/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "private": true, "dependencies": { - "@prisma/client": "^6.16.3" + "@prisma/client": "^6.19.3" } } From b3ca7f1295d79b4d0cb5fb9fb4ec41093033ba4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:08:12 +0000 Subject: [PATCH 05/10] fix(core): encrypt module credential fields, and read plaintext safely loadModuleEncryptionSchemas required a path that does not exist, so module credentialFields were never registered for encryption (the error was swallowed). Fix the path, consume the entity registry so registry-only modules count, filter fields core already encrypts, and add module fields to the app's own custom schema instead of replacing it (other models included). Because that makes more fields encrypted, make reads tolerate plaintext: _isEncrypted now requires the exact Cryptor shape (keyId:iv:ciphertext: encryptedKey, base64/32-hex/hex/base64) instead of any string with three colons, and a field that fails to decrypt is returned as stored with a warning instead of failing the whole read, findMany included. Existing rows stay plaintext until rewritten. See CHANGELOG (Unreleased). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- packages/core/CHANGELOG.md | 11 + packages/core/database/encryption/README.md | 2 + .../entity-registry-encryption.test.js | 197 +++++++++++++++++ .../encryption/encryption-schema-registry.js | 54 +++-- .../encryption/field-encryption-service.js | 33 ++- .../field-encryption-service.test.js | 202 +++++++++++++----- .../prisma-encryption-extension.test.js | 42 ++-- .../test-support/fake-ciphertext.js | 15 ++ 8 files changed, 454 insertions(+), 102 deletions(-) create mode 100644 packages/core/database/encryption/__tests__/entity-registry-encryption.test.js create mode 100644 packages/core/database/encryption/test-support/fake-ciphertext.js diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index f0f593628..661e4e458 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,3 +1,14 @@ +# Unreleased + +#### Behaviour change: module credential fields are now encrypted + +- `credentialFields` declared in an API module's `encryption` config (for example `Definition.encryption.credentialFields`) were never registered for encryption: `loadModuleEncryptionSchemas` required a path that does not exist and the error was swallowed. They are now registered, for modules bound by integrations and for modules registered in `appDefinition.entities` (ADR-059). +- Existing rows are not rewritten. A credential saved before this release stays plaintext until it is next written, and is read as stored. +- Reads now tolerate plaintext: a value is only treated as ciphertext when it has the exact `keyId:iv:ciphertext:encryptedKey` shape (previously any string with three or more colons, such as `https://user:pw@host:443`), and a value that fails to decrypt is returned as stored with a warning instead of failing the whole read. +- An app's own `encryption.schema` is no longer replaced when module fields are added to it. + +--- + # v1.2.2 (Fri Aug 09 2024) #### 🐛 Bug Fix diff --git a/packages/core/database/encryption/README.md b/packages/core/database/encryption/README.md index 8c609caf2..e4128ad43 100644 --- a/packages/core/database/encryption/README.md +++ b/packages/core/database/encryption/README.md @@ -428,6 +428,8 @@ Format: "keyId:encryptedText:encryptedKey" Example: "base64KeyId:iv:ciphertext:base64EncryptedDataKey" ``` +**Recognising ciphertext on read.** A value is decrypted only when it has exactly four colon-separated parts: base64 `keyId`, 32-hex-character `iv`, hex ciphertext, base64 `encryptedKey`. Plaintext that merely contains colons (`https://user:pw@host:443`) is left alone. A value with that shape that still fails to decrypt is returned as stored and logged as a warning, so one bad field never fails a whole read. A field that becomes encrypted later (for example a module's `credentialFields`) stays plaintext in existing rows until they are next written. + **Why Envelope Encryption?** - Reduces KMS API calls (one DEK per field, cached) diff --git a/packages/core/database/encryption/__tests__/entity-registry-encryption.test.js b/packages/core/database/encryption/__tests__/entity-registry-encryption.test.js new file mode 100644 index 000000000..cefe1f96a --- /dev/null +++ b/packages/core/database/encryption/__tests__/entity-registry-encryption.test.js @@ -0,0 +1,197 @@ +/** + * ADR-059 at the encryption schema: credential fields declared by a module + * that is registered in appDefinition.entities (and bound by no integration) + * are encrypted, through the same path an app takes at load. + */ +jest.mock('../../../utils', () => ({ + findNearestBackendPackageJson: () => '/virtual/backend/package.json', +})); + +const mockBackend = { Definition: null }; +jest.mock('/virtual/backend/index.js', () => mockBackend, { virtual: true }); + +const { + loadCustomEncryptionSchema, + loadModuleEncryptionSchemas, + registerCustomSchema, + getEncryptedFields, + resetCustomSchema, +} = require('../encryption-schema-registry'); +const { + EntityRegistryError, +} = require('../../../modules/entity-type-registry'); + +const xero = { + moduleName: 'xero', + encryption: { credentialFields: ['xero_tenant_secret'] }, +}; +const hubspot = { + moduleName: 'hubspot', + encryption: { credentialFields: ['hub_private_token'] }, +}; +const integration = (definition) => ({ + Definition: { name: 'sync', modules: { crm: { definition } } }, +}); + +describe('encryption schema and the entity registry (ADR-059)', () => { + afterEach(() => { + resetCustomSchema(); + mockBackend.Definition = null; + }); + + it('encrypts a registry-only module’s credential fields when the app loads', () => { + mockBackend.Definition = { + integrations: [integration(hubspot)], + entities: { xero: { definition: xero } }, + }; + + loadCustomEncryptionSchema(); + + const fields = getEncryptedFields('Credential'); + expect(fields).toContain('data.xero_tenant_secret'); + expect(fields).toContain('data.hub_private_token'); + }); + + it('encrypts it for an app with no integrations at all', () => { + mockBackend.Definition = { + integrations: [], + entities: { xero: { definition: xero } }, + }; + + loadCustomEncryptionSchema(); + + expect(getEncryptedFields('Credential')).toContain( + 'data.xero_tenant_secret' + ); + }); + + it('an app with no entities key still gets only its integrations’ fields', () => { + mockBackend.Definition = { integrations: [integration(hubspot)] }; + + loadCustomEncryptionSchema(); + + const fields = getEncryptedFields('Credential'); + expect(fields).toContain('data.hub_private_token'); + expect(fields).not.toContain('data.xero_tenant_secret'); + }); + + it('adds module fields to the app’s own custom Credential fields instead of replacing them', () => { + mockBackend.Definition = { + integrations: [integration(hubspot)], + entities: { xero: { definition: xero } }, + encryption: { + schema: { Credential: { fields: ['data.app_owned_secret'] } }, + }, + }; + + loadCustomEncryptionSchema(); + + const fields = getEncryptedFields('Credential'); + expect(fields).toEqual( + expect.arrayContaining([ + 'data.app_owned_secret', + 'data.xero_tenant_secret', + 'data.hub_private_token', + ]) + ); + }); + + it('keeps an app custom schema for another model when module fields are added', () => { + mockBackend.Definition = { + integrations: [integration(hubspot)], + entities: { xero: { definition: xero } }, + encryption: { + schema: { + Integration: { fields: ['config.appOwnedSecret'] }, + Credential: { fields: ['data.app_owned_secret'] }, + }, + }, + }; + + loadCustomEncryptionSchema(); + + expect(getEncryptedFields('Integration')).toContain( + 'config.appOwnedSecret' + ); + expect(getEncryptedFields('Credential')).toEqual( + expect.arrayContaining([ + 'data.app_owned_secret', + 'data.xero_tenant_secret', + 'data.hub_private_token', + ]) + ); + }); + + it('keeps an app custom schema for another model when only entities declare fields', () => { + mockBackend.Definition = { + integrations: [], + entities: { xero: { definition: xero } }, + encryption: { + schema: { Integration: { fields: ['config.appOwnedSecret'] } }, + }, + }; + + loadCustomEncryptionSchema(); + + expect(getEncryptedFields('Integration')).toContain( + 'config.appOwnedSecret' + ); + expect(getEncryptedFields('Credential')).toContain( + 'data.xero_tenant_secret' + ); + }); + + it('does not let a module field that core already encrypts abort the registration', () => { + mockBackend.Definition = { + integrations: [], + entities: { + xero: { + definition: { + ...xero, + encryption: { + credentialFields: ['api_key', 'xero_tenant_secret'], + }, + }, + }, + }, + }; + + loadCustomEncryptionSchema(); + + expect(getEncryptedFields('Credential')).toContain( + 'data.xero_tenant_secret' + ); + }); + + it('surfaces a registry error instead of swallowing it as "no custom schema"', () => { + mockBackend.Definition = { + integrations: [], + entities: { crm: { definition: xero } }, + }; + + expect(() => loadCustomEncryptionSchema()).toThrow(EntityRegistryError); + }); + + it('loadModuleEncryptionSchemas(integrations, entities) takes the registry directly', () => { + loadModuleEncryptionSchemas([], { xero: { definition: xero } }); + + expect(getEncryptedFields('Credential')).toContain( + 'data.xero_tenant_secret' + ); + }); + + it('keeps the legacy one-argument call working', () => { + loadModuleEncryptionSchemas([integration(hubspot)]); + + expect(getEncryptedFields('Credential')).toContain( + 'data.hub_private_token' + ); + }); + + it('registerCustomSchema is untouched by module loading when nothing declares fields', () => { + registerCustomSchema({ Other: { fields: ['x'] } }); + loadModuleEncryptionSchemas([integration({ moduleName: 'plain' })]); + + expect(getEncryptedFields('Other')).toEqual(['x']); + }); +}); diff --git a/packages/core/database/encryption/encryption-schema-registry.js b/packages/core/database/encryption/encryption-schema-registry.js index e4c94173c..8b19d41f3 100644 --- a/packages/core/database/encryption/encryption-schema-registry.js +++ b/packages/core/database/encryption/encryption-schema-registry.js @@ -10,6 +10,11 @@ const { logger } = require('./logger'); const { registerDeniedKeys } = require('../../logs/denied-keys'); +const { + getEntityTypeRegistry, + getEntityTypeDefinitions, + EntityRegistryError, +} = require('../../modules/entity-type-registry'); /** * Core encryption schema (immutable - cannot be overridden by custom schemas) @@ -166,8 +171,9 @@ function extractCredentialFieldsFromModules(moduleDefinitions) { * Each module can declare credentialFields to encrypt in its encryption config. * * @param {Array} integrations - Array of integration classes with modules + * @param {object} [entities] - `appDefinition.entities` (ADR-059 registry) */ -function loadModuleEncryptionSchemas(integrations) { +function loadModuleEncryptionSchemas(integrations, entities) { if (!integrations) { throw new Error('integrations parameter is required'); } @@ -176,34 +182,39 @@ function loadModuleEncryptionSchemas(integrations) { throw new Error('integrations must be an array'); } - if (integrations.length === 0) { + // `entities` is the app definition's registry (ADR-059): a module an app + // registers without any integration still carries credentials to encrypt. + if (integrations.length === 0 && !entities) { return; } - // NOTE: this path does not exist ('../integrations' resolves to - // database/integrations), so today this function throws MODULE_NOT_FOUND, - // loadCustomEncryptionSchema swallows it, and module credentialFields are - // effectively NOT registered. Fixing it changes which fields are encrypted - // and needs a decrypt that tolerates plaintext, so it ships separately: - // branch claude/internal-mcp-03b-module-credential-encryption. Left as is - // here on purpose (ADR-059 must not change what gets encrypted). - const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto'); - - const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations); + const moduleDefinitions = getEntityTypeDefinitions( + getEntityTypeRegistry({ integrations, entities }) + ); const credentialFields = extractCredentialFieldsFromModules(moduleDefinitions); - if (credentialFields.length === 0) { + // Core fields are encrypted already and a custom schema may not repeat + // them. Everything the app registered itself stays: module fields are added + // to the custom schema, never substituted for it (other models included). + const coreFields = new Set(CORE_ENCRYPTION_SCHEMA.Credential?.fields || []); + const fields = [ + ...new Set([ + ...(customSchema.Credential?.fields || []), + ...credentialFields.filter((field) => !coreFields.has(field)), + ]), + ]; + + if (fields.length === 0) { return; } const moduleSchema = { - Credential: { - fields: credentialFields - } + ...customSchema, + Credential: { ...customSchema.Credential, fields }, }; logger.info( - `Registering module-level encryption for ${credentialFields.length} credential fields` + `Registering module-level encryption for ${fields.length} credential fields` ); registerCustomSchema(moduleSchema); @@ -255,10 +266,15 @@ function loadCustomEncryptionSchema() { // Load module-level encryption schemas from integrations const integrations = appDefinition.integrations; - if (integrations && Array.isArray(integrations)) { - loadModuleEncryptionSchemas(integrations); + if (Array.isArray(integrations) || appDefinition.entities) { + loadModuleEncryptionSchemas( + Array.isArray(integrations) ? integrations : [], + appDefinition.entities + ); } } catch (error) { + // A bad entity registry is a configuration error, not "no custom schema". + if (error instanceof EntityRegistryError) throw error; // Silently ignore errors - custom schema is optional // This handles cases like: // - Backend package.json not found (tests, standalone usage) diff --git a/packages/core/database/encryption/field-encryption-service.js b/packages/core/database/encryption/field-encryption-service.js index 86f677e01..aac5b5030 100644 --- a/packages/core/database/encryption/field-encryption-service.js +++ b/packages/core/database/encryption/field-encryption-service.js @@ -4,6 +4,16 @@ * Infrastructure layer service that orchestrates field-level encryption/decryption. * Handles nested JSON paths (e.g., 'data.access_token') and bulk operations. */ +const { logger } = require('./logger'); + +// Cryptor.encrypt() returns `${keyId}:${iv}:${ciphertext}:${encryptedKey}`: +// keyId and encryptedKey are base64, iv is 16 bytes of hex, ciphertext is hex. +// Plaintext with three colons (`https://user:pw@host:443`, `a:b:c:d`) must not +// be mistaken for it. +const BASE64_PART = /^[A-Za-z0-9+/]+={0,2}$/; +const IV_PART = /^[0-9a-f]{32}$/i; +const CIPHERTEXT_PART = /^(?:[0-9a-f]{2})+$/i; + class FieldEncryptionService { constructor({ cryptor, schema }) { if (!cryptor) { @@ -97,12 +107,23 @@ class FieldEncryptionService { const decryptionPromises = fields.map(async (fieldPath) => { const value = this._getNestedValue(decrypted, fieldPath); - if (this._isEncrypted(value)) { + if (!this._isEncrypted(value)) { + return null; + } + try { const decryptedValue = await this.cryptor.decrypt(value); const deserializedValue = this._deserializeAfterDecryption(decryptedValue); return { fieldPath, decryptedValue: deserializedValue }; + } catch (error) { + // A value that looks like ciphertext but is not (or was written + // before the field was encrypted) is read as it is stored. One + // such field must not fail the whole read, findMany included. + logger.warn( + `Could not decrypt ${modelName}.${fieldPath}; returning the stored value`, + { errorName: error?.name } + ); + return null; } - return null; }); const results = await Promise.all(decryptionPromises); @@ -152,7 +173,13 @@ class FieldEncryptionService { } const parts = value.split(':'); - return parts.length >= 4; + return ( + parts.length === 4 && + BASE64_PART.test(parts[0]) && + IV_PART.test(parts[1]) && + CIPHERTEXT_PART.test(parts[2]) && + BASE64_PART.test(parts[3]) + ); } _getNestedValue(obj, path) { diff --git a/packages/core/database/encryption/field-encryption-service.test.js b/packages/core/database/encryption/field-encryption-service.test.js index 6d7a7e30a..200b4cdf5 100644 --- a/packages/core/database/encryption/field-encryption-service.test.js +++ b/packages/core/database/encryption/field-encryption-service.test.js @@ -1,4 +1,5 @@ const { FieldEncryptionService } = require('./field-encryption-service'); +const { fakeCiphertext, fakePlaintext } = require('./test-support/fake-ciphertext'); describe('FieldEncryptionService', () => { let mockCryptor; @@ -8,34 +9,8 @@ describe('FieldEncryptionService', () => { beforeEach(() => { // Mock Cryptor mockCryptor = { - encrypt: jest - .fn() - .mockImplementation( - (value) => `encrypted:${value}:keydata:enckey` - ), - decrypt: jest - .fn() - .mockImplementation((value) => { - // Handle multiple encrypted formats - // Format 1: "encrypted:ORIGINAL:keydata:enckey" - // Format 2: "keyId:ORIGINAL:iv:enckey" - - // Try format 1 (from our new tests) - const prefix1 = 'encrypted:'; - const suffix1 = ':keydata:enckey'; - if (value.startsWith(prefix1) && value.endsWith(suffix1)) { - return value.slice(prefix1.length, -suffix1.length); - } - - // Try format 2 (from existing tests) - const prefix2 = 'keyId:'; - const suffix2 = ':iv:enckey'; - if (value.startsWith(prefix2) && value.endsWith(suffix2)) { - return value.slice(prefix2.length, -suffix2.length); - } - - return value; // Fallback for non-standard format - }), + encrypt: jest.fn().mockImplementation((value) => fakeCiphertext(value)), + decrypt: jest.fn().mockImplementation((value) => fakePlaintext(value)), }; // Mock Schema Registry @@ -102,10 +77,10 @@ describe('FieldEncryptionService', () => { expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret123'); expect(mockCryptor.encrypt).toHaveBeenCalledWith('refresh456'); expect(result.data.access_token).toBe( - 'encrypted:secret123:keydata:enckey' + fakeCiphertext('secret123') ); expect(result.data.refresh_token).toBe( - 'encrypted:refresh456:keydata:enckey' + fakeCiphertext('refresh456') ); expect(result.data.other).toBe('public'); // Not encrypted }); @@ -120,7 +95,7 @@ describe('FieldEncryptionService', () => { expect(mockCryptor.encrypt).toHaveBeenCalledWith('password_hash'); expect(result.hashword).toBe( - 'encrypted:password_hash:keydata:enckey' + fakeCiphertext('password_hash') ); expect(result.id).toBe('123'); // Not encrypted }); @@ -179,7 +154,7 @@ describe('FieldEncryptionService', () => { it('should skip already encrypted values', async () => { const document = { data: { - access_token: 'already:encrypted:data:key', + access_token: fakeCiphertext('already'), refresh_token: 'plain', }, }; @@ -231,7 +206,7 @@ describe('FieldEncryptionService', () => { // The encrypted value should be the JSON string encrypted expect(encrypted.mapping).toBe( - `encrypted:${JSON.stringify(mappingObject)}:keydata:enckey` + fakeCiphertext(JSON.stringify(mappingObject)) ); // Now decrypt and verify object is restored @@ -277,8 +252,8 @@ describe('FieldEncryptionService', () => { const document = { id: '123', data: { - access_token: 'keyId:secret123:iv:enckey', - refresh_token: 'keyId:refresh456:iv:enckey', + access_token: fakeCiphertext('secret123'), + refresh_token: fakeCiphertext('refresh456'), other: 'public', }, }; @@ -286,10 +261,10 @@ describe('FieldEncryptionService', () => { const result = await service.decryptFields('Credential', document); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'keyId:secret123:iv:enckey' + fakeCiphertext('secret123') ); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'keyId:refresh456:iv:enckey' + fakeCiphertext('refresh456') ); expect(result.data.access_token).toBe('secret123'); expect(result.data.refresh_token).toBe('refresh456'); @@ -299,13 +274,13 @@ describe('FieldEncryptionService', () => { it('should decrypt top-level fields', async () => { const document = { id: '123', - hashword: 'keyId:password_hash:iv:enckey', + hashword: fakeCiphertext('password_hash'), }; const result = await service.decryptFields('User', document); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'keyId:password_hash:iv:enckey' + fakeCiphertext('password_hash') ); expect(result.hashword).toBe('password_hash'); }); @@ -314,7 +289,7 @@ describe('FieldEncryptionService', () => { const document = { data: { access_token: 'plaintext', // Not encrypted format - refresh_token: 'keyId:encrypted:iv:enckey', + refresh_token: fakeCiphertext('encrypted'), }, }; @@ -322,13 +297,13 @@ describe('FieldEncryptionService', () => { expect(mockCryptor.decrypt).toHaveBeenCalledTimes(1); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'keyId:encrypted:iv:enckey' + fakeCiphertext('encrypted') ); }); it('should not mutate original document', async () => { const document = { - data: { access_token: 'keyId:secret:iv:enckey' }, + data: { access_token: fakeCiphertext('secret') }, }; const original = JSON.parse(JSON.stringify(document)); @@ -337,21 +312,27 @@ describe('FieldEncryptionService', () => { expect(document).toEqual(original); }); - it('should throw on decryption errors', async () => { + it('returns the stored value, and warns, when a field fails to decrypt', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); mockCryptor.decrypt.mockRejectedValueOnce( new Error('Decryption failed') ); const document = { data: { - access_token: 'keyId:secret:iv:enckey', - refresh_token: 'keyId:valid:iv:enckey', + access_token: fakeCiphertext('secret'), + refresh_token: fakeCiphertext('valid'), }, }; - await expect( - service.decryptFields('Credential', document) - ).rejects.toThrow('Decryption failed'); + const result = await service.decryptFields('Credential', document); + + const failed = [result.data.access_token, result.data.refresh_token].filter( + (value) => value.startsWith('a2V5SWQ=') + ); + expect(failed).toHaveLength(1); + expect(console.warn).toHaveBeenCalled(); + console.warn.mockRestore(); }); }); @@ -369,10 +350,10 @@ describe('FieldEncryptionService', () => { expect(result).toHaveLength(2); expect(result[0].data.access_token).toBe( - 'encrypted:secret1:keydata:enckey' + fakeCiphertext('secret1') ); expect(result[1].data.access_token).toBe( - 'encrypted:secret2:keydata:enckey' + fakeCiphertext('secret2') ); }); @@ -394,8 +375,8 @@ describe('FieldEncryptionService', () => { describe('decryptFieldsInBulk', () => { it('should decrypt multiple documents', async () => { const documents = [ - { data: { access_token: 'keyId:secret1:iv:enckey' } }, - { data: { access_token: 'keyId:secret2:iv:enckey' } }, + { data: { access_token: fakeCiphertext('secret1') } }, + { data: { access_token: fakeCiphertext('secret2') } }, ]; const result = await service.decryptFieldsInBulk( @@ -410,14 +391,16 @@ describe('FieldEncryptionService', () => { }); describe('_isEncrypted', () => { - it('should detect encrypted format', () => { - expect(service._isEncrypted('keyId:data:iv:enckey')).toBe(true); + it('detects the Cryptor output shape (keyId:iv:ciphertext:encryptedKey)', () => { + expect(service._isEncrypted(fakeCiphertext('data'))).toBe(true); expect( - service._isEncrypted('keyId:longer:data:with:colons:enckey') + service._isEncrypted( + 'a2V5SWQ=:00112233445566778899aabbccddeeff:0a1b2c:ZW5jS2V5' + ) ).toBe(true); }); - it('should reject non-encrypted formats', () => { + it('rejects non-encrypted formats', () => { expect(service._isEncrypted('plaintext')).toBe(false); expect(service._isEncrypted('one:two:three')).toBe(false); expect(service._isEncrypted('one:two')).toBe(false); @@ -425,6 +408,111 @@ describe('FieldEncryptionService', () => { expect(service._isEncrypted(undefined)).toBe(false); expect(service._isEncrypted(123)).toBe(false); }); + + it.each([ + 'https://user:pw@host:443', + 'a:b:c:d', + 'host:8080:path:x', + 'keyId:longer:data:with:colons:enckey', + // right shape, wrong parts + 'a2V5SWQ=:tooshort:0a1b2c:ZW5jS2V5', + 'a2V5SWQ=:00112233:0a1b2c:ZW5jS2V5', // iv too short (hex, but not 16 bytes) + 'a2V5SWQ=:00112233445566778899aabbccddeeff00:0a1b2c:ZW5jS2V5', // iv too long + 'not base64!:00112233445566778899aabbccddeeff:0a1b2c:ZW5jS2V5', // keyId + 'a2V5SWQ=:00112233445566778899aabbccddeeff:0a1:ZW5jS2V5', + 'a2V5SWQ=:00112233445566778899aabbccddeeff:0a1b2c:not base64!', + // five parts: one colon too many + 'a2V5SWQ=:00112233445566778899aabbccddeeff:0a1b2c:ZW5jS2V5:extra', + '', + ])('rejects plaintext that merely has colons: %p', (value) => { + expect(service._isEncrypted(value)).toBe(false); + }); + }); + + describe('plaintext that looks a little like ciphertext (3+ colons)', () => { + const colonPlaintext = { + url: 'https://user:pw@host:443', + letters: 'a:b:c:d', + // matches the ciphertext SHAPE; only decrypt can tell it is not + shaped: 'a2V5SWQ=:00112233445566778899aabbccddeeff:0a1b2c:ZW5jS2V5', + }; + + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => console.warn.mockRestore()); + + it('decryptFields returns plain values with colons untouched, without calling the cryptor', async () => { + const document = { + data: { + access_token: colonPlaintext.url, + refresh_token: colonPlaintext.letters, + }, + }; + + const result = await service.decryptFields('Credential', document); + + expect(result.data).toEqual(document.data); + expect(mockCryptor.decrypt).not.toHaveBeenCalled(); + }); + + it('decryptFieldsInBulk reads every row, plaintext ones included', async () => { + const documents = [ + { data: { access_token: colonPlaintext.url } }, + { data: { access_token: fakeCiphertext('real-secret') } }, + { data: { refresh_token: colonPlaintext.letters } }, + ]; + + const result = await service.decryptFieldsInBulk('Credential', documents); + + expect(result[0].data.access_token).toBe(colonPlaintext.url); + expect(result[1].data.access_token).toBe('real-secret'); + expect(result[2].data.refresh_token).toBe(colonPlaintext.letters); + }); + + it('a value shaped like ciphertext that fails to decrypt is returned as stored, with a warning', async () => { + mockCryptor.decrypt.mockRejectedValueOnce(new Error('bad key')); + const document = { + data: { + access_token: colonPlaintext.shaped, + refresh_token: fakeCiphertext('still-decrypted'), + }, + }; + + const result = await service.decryptFields('Credential', document); + + expect(result.data.access_token).toBe(colonPlaintext.shaped); + expect(result.data.refresh_token).toBe('still-decrypted'); + expect(console.warn).toHaveBeenCalledWith( + '[Frigg]', + expect.stringContaining('Could not decrypt Credential.data.access_token'), + expect.anything() + ); + }); + + it('one undecryptable row does not fail decryptFieldsInBulk', async () => { + mockCryptor.decrypt + .mockRejectedValueOnce(new Error('bad key')) + .mockImplementation((value) => fakePlaintext(value)); + const documents = [ + { data: { access_token: colonPlaintext.shaped } }, + { data: { access_token: fakeCiphertext('ok') } }, + ]; + + const result = await service.decryptFieldsInBulk('Credential', documents); + + expect(result[0].data.access_token).toBe(colonPlaintext.shaped); + expect(result[1].data.access_token).toBe('ok'); + }); + + it('encrypts colon plaintext on write instead of skipping it as "already encrypted"', async () => { + const result = await service.encryptFields('Credential', { + data: { access_token: colonPlaintext.url }, + }); + + expect(mockCryptor.encrypt).toHaveBeenCalledWith(colonPlaintext.url); + expect(result.data.access_token).toBe(fakeCiphertext(colonPlaintext.url)); + }); }); describe('_getNestedValue', () => { @@ -575,7 +663,7 @@ describe('FieldEncryptionService', () => { }); it('should still decrypt opted-out fields on read (legacy data)', async () => { - const encryptedBlob = 'encrypted:{"crmId":"abc"}:keydata:enckey'; + const encryptedBlob = fakeCiphertext('{"crmId":"abc"}'); const document = { id: '123', mapping: encryptedBlob, diff --git a/packages/core/database/encryption/prisma-encryption-extension.test.js b/packages/core/database/encryption/prisma-encryption-extension.test.js index f67e0a85d..5d3e23e61 100644 --- a/packages/core/database/encryption/prisma-encryption-extension.test.js +++ b/packages/core/database/encryption/prisma-encryption-extension.test.js @@ -1,4 +1,5 @@ const { createEncryptionExtension } = require('./prisma-encryption-extension'); +const { fakeCiphertext, fakePlaintext } = require('./test-support/fake-ciphertext'); describe('Prisma Encryption Extension', () => { let mockCryptor; @@ -9,15 +10,10 @@ describe('Prisma Encryption Extension', () => { mockCryptor = { encrypt: jest .fn() - .mockImplementation( - (value) => `encrypted:${value}:iv:enckey` - ), + .mockImplementation((value) => fakeCiphertext(value)), decrypt: jest .fn() - .mockImplementation((value) => { - const parts = value.split(':'); - return parts[1]; // Extract original value - }), + .mockImplementation((value) => fakePlaintext(value)), }; // Mock Prisma query function @@ -83,7 +79,7 @@ describe('Prisma Encryption Extension', () => { }, mockResult: { id: '1', - data: { access_token: 'encrypted:secret123:iv:enckey' }, + data: { access_token: fakeCiphertext('secret123') }, }, }; @@ -96,7 +92,7 @@ describe('Prisma Encryption Extension', () => { expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret123'); expect(args.data.data.access_token).toBe( - 'encrypted:secret123:iv:enckey' + fakeCiphertext('secret123') ); }); @@ -105,7 +101,7 @@ describe('Prisma Encryption Extension', () => { data: { data: { access_token: 'secret123' } }, mockResult: { id: '1', - data: { access_token: 'encrypted:secret123:iv:enckey' }, + data: { access_token: fakeCiphertext('secret123') }, }, }; @@ -117,7 +113,7 @@ describe('Prisma Encryption Extension', () => { }); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'encrypted:secret123:iv:enckey' + fakeCiphertext('secret123') ); expect(result.data.access_token).toBe('secret123'); }); @@ -159,10 +155,10 @@ describe('Prisma Encryption Extension', () => { expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret1'); expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret2'); expect(args.data[0].data.access_token).toBe( - 'encrypted:secret1:iv:enckey' + fakeCiphertext('secret1') ); expect(args.data[1].data.access_token).toBe( - 'encrypted:secret2:iv:enckey' + fakeCiphertext('secret2') ); }); @@ -189,7 +185,7 @@ describe('Prisma Encryption Extension', () => { data: { data: { access_token: 'newsecret' } }, mockResult: { id: '1', - data: { access_token: 'encrypted:newsecret:iv:enckey' }, + data: { access_token: fakeCiphertext('newsecret') }, }, }; @@ -208,7 +204,7 @@ describe('Prisma Encryption Extension', () => { data: { data: { access_token: 'newsecret' } }, mockResult: { id: '1', - data: { access_token: 'encrypted:newsecret:iv:enckey' }, + data: { access_token: fakeCiphertext('newsecret') }, }, }; @@ -230,7 +226,7 @@ describe('Prisma Encryption Extension', () => { update: { data: { access_token: 'updatesecret' } }, mockResult: { id: '1', - data: { access_token: 'encrypted:createsecret:iv:enckey' }, + data: { access_token: fakeCiphertext('createsecret') }, }, }; @@ -252,7 +248,7 @@ describe('Prisma Encryption Extension', () => { where: { id: '1' }, mockResult: { id: '1', - data: { access_token: 'encrypted:secret:iv:enckey' }, + data: { access_token: fakeCiphertext('secret') }, }, }; @@ -264,7 +260,7 @@ describe('Prisma Encryption Extension', () => { }); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'encrypted:secret:iv:enckey' + fakeCiphertext('secret') ); expect(result.data.access_token).toBe('secret'); }); @@ -293,11 +289,11 @@ describe('Prisma Encryption Extension', () => { mockResult: [ { id: '1', - data: { access_token: 'encrypted:secret1:iv:enckey' }, + data: { access_token: fakeCiphertext('secret1') }, }, { id: '2', - data: { access_token: 'encrypted:secret2:iv:enckey' }, + data: { access_token: fakeCiphertext('secret2') }, }, ], }; @@ -336,7 +332,7 @@ describe('Prisma Encryption Extension', () => { where: { id: '1' }, mockResult: { id: '1', - data: { access_token: 'encrypted:secret:iv:enckey' }, + data: { access_token: fakeCiphertext('secret') }, }, }; @@ -411,8 +407,8 @@ describe('Prisma Encryption Extension', () => { mockResult: { id: '123', data: { - access_token: 'encrypted:secret:iv:enckey', - refresh_token: 'encrypted:refresh:iv:enckey', + access_token: fakeCiphertext('secret'), + refresh_token: fakeCiphertext('refresh'), other: 'public', }, }, diff --git a/packages/core/database/encryption/test-support/fake-ciphertext.js b/packages/core/database/encryption/test-support/fake-ciphertext.js new file mode 100644 index 000000000..485525869 --- /dev/null +++ b/packages/core/database/encryption/test-support/fake-ciphertext.js @@ -0,0 +1,15 @@ +/** + * Test double for Cryptor output. Same shape as the real thing + * (`keyId:iv:ciphertext:encryptedKey`: base64, 16-byte hex, hex, base64) so + * FieldEncryptionService._isEncrypted accepts it, with the plaintext + * recoverable for the fake decrypt. + */ +const fakeCiphertext = (plain) => + `a2V5SWQ=:${'0'.repeat(32)}:${Buffer.from(String(plain)).toString( + 'hex' + )}:ZW5jS2V5`; + +const fakePlaintext = (ciphertext) => + Buffer.from(String(ciphertext).split(':')[2], 'hex').toString(); + +module.exports = { fakeCiphertext, fakePlaintext }; From 8de669cbe925abbcd0908be6e792e38e2a14ffd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:21:15 +0000 Subject: [PATCH 06/10] fix(core): count a deployed Netlify function as deployed for field-level encryption isDeployedRuntime read only AWS Lambda's variables, so a Netlify function with STAGE=dev counted as a local run and skipped encryption. A non-aws FRIGG_PROVIDER (set by provider-netlify's generated entry points) now marks a deployed function, and NETLIFY_DEV marks `netlify dev` as local. Netlify's SITE_ID, CONTEXT and NETLIFY are build-time variables and are not used. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- CLAUDE.md | 2 +- packages/core/database/encryption/README.md | 2 +- .../documentdb-encryption-service.md | 2 +- .../database/encryption/encryption-config.js | 39 ++++++++----- .../encryption/encryption-config.test.js | 56 +++++++++++++++++++ 5 files changed, 85 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 99998a8eb..af04a554e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,7 +290,7 @@ FRIGG_ENCRYPTION_DISABLED=true **When encryption runs** — decided by where the code runs, not by the stage name: -- **Deployed** (in AWS Lambda: `AWS_LAMBDA_FUNCTION_NAME` / `LAMBDA_TASK_ROOT` / `AWS_EXECUTION_ENV=AWS_Lambda_*`, and not `IS_OFFLINE` / `IS_LOCAL` / Jest): a key encrypts; no key throws `EncryptionConfigurationError` at Prisma client creation unless `FRIGG_ENCRYPTION_DISABLED=true`. +- **Deployed** (in AWS Lambda: `AWS_LAMBDA_FUNCTION_NAME` / `LAMBDA_TASK_ROOT` / `AWS_EXECUTION_ENV=AWS_Lambda_*`; or on another provider: `FRIGG_PROVIDER` other than `aws`, which provider-netlify's functions set; and not `IS_OFFLINE` / `IS_LOCAL` / `NETLIFY_DEV` / Jest): a key encrypts; no key throws `EncryptionConfigurationError` at Prisma client creation unless `FRIGG_ENCRYPTION_DISABLED=true`. - **Local** (`frigg start` via serverless-offline, Jest, scripts): `STAGE` dev/test/local skips encryption; any other stage encrypts when a key is set and stays plaintext (with a warning) when not. **Encrypted Fields** (defined in `encryption-schema-registry.js`): diff --git a/packages/core/database/encryption/README.md b/packages/core/database/encryption/README.md index a3e362c2c..705724b30 100644 --- a/packages/core/database/encryption/README.md +++ b/packages/core/database/encryption/README.md @@ -111,7 +111,7 @@ STAGE=production # or development, staging, etc. The rule lives in `encryption-config.js` and is shared by the Prisma client, the DocumentDB encryption service, the integration-mapping helpers and the `/health/detailed` check. It depends on **where the code runs**, not on the stage name. -A process is **deployed** when it runs in AWS Lambda (`AWS_LAMBDA_FUNCTION_NAME` or `LAMBDA_TASK_ROOT` is set, or `AWS_EXECUTION_ENV` starts with `AWS_Lambda_`) and is **not** a local run (`IS_OFFLINE=true` from serverless-offline / `frigg start`, `IS_LOCAL=true` from `serverless invoke local`, or `JEST_WORKER_ID` from Jest). Everything else is **local**. +A process is **deployed** when it runs in AWS Lambda (`AWS_LAMBDA_FUNCTION_NAME` or `LAMBDA_TASK_ROOT` is set, or `AWS_EXECUTION_ENV` starts with `AWS_Lambda_`) or on another provider's platform (`FRIGG_PROVIDER` names a provider other than `aws`; provider-netlify's generated functions set `FRIGG_PROVIDER=netlify`), and is **not** a local run (`IS_OFFLINE=true` from serverless-offline / `frigg start`, `IS_LOCAL=true` from `serverless invoke local`, `NETLIFY_DEV=true` from `netlify dev`, or `JEST_WORKER_ID` from Jest). Everything else is **local**. Netlify's `SITE_ID`, `CONTEXT` and `NETLIFY` are build-time variables, so they are not used. | Runtime | Key configured | `FRIGG_ENCRYPTION_DISABLED=true` | Result | | --- | --- | --- | --- | diff --git a/packages/core/database/encryption/documentdb-encryption-service.md b/packages/core/database/encryption/documentdb-encryption-service.md index 3fd720ed0..137116e9a 100644 --- a/packages/core/database/encryption/documentdb-encryption-service.md +++ b/packages/core/database/encryption/documentdb-encryption-service.md @@ -290,7 +290,7 @@ class DocumentDBEncryptionService { **Environment Variables Used** (see "When Encryption Runs" in README.md): -- `AWS_LAMBDA_FUNCTION_NAME` / `LAMBDA_TASK_ROOT` / `AWS_EXECUTION_ENV=AWS_Lambda_*`, minus `IS_OFFLINE` / `IS_LOCAL` / `JEST_WORKER_ID`: deployed or local run +- `AWS_LAMBDA_FUNCTION_NAME` / `LAMBDA_TASK_ROOT` / `AWS_EXECUTION_ENV=AWS_Lambda_*`, or a non-`aws` `FRIGG_PROVIDER`, minus `IS_OFFLINE` / `IS_LOCAL` / `NETLIFY_DEV` / `JEST_WORKER_ID`: deployed or local run - `STAGE` or `NODE_ENV`: dev/test/local skip encryption on local runs only - `KMS_KEY_ARN`: AWS KMS key ARN (enables KMS encryption) - `AES_KEY_ID`: AES key identifier (enables AES encryption) diff --git a/packages/core/database/encryption/encryption-config.js b/packages/core/database/encryption/encryption-config.js index d0de08cf2..98f612e3f 100644 --- a/packages/core/database/encryption/encryption-config.js +++ b/packages/core/database/encryption/encryption-config.js @@ -40,29 +40,42 @@ function isTrue(value) { } /** - * True when the process runs in AWS Lambda for real. The Lambda runtime sets - * AWS_LAMBDA_FUNCTION_NAME, LAMBDA_TASK_ROOT and AWS_EXECUTION_ENV - * (AWS_Lambda_). serverless-offline sets the first two as well, so - * IS_OFFLINE, IS_LOCAL and Jest mark a local run. Other AWS hosts (CloudShell, - * CodeBuild, ECS) set AWS_EXECUTION_ENV to other values and count as local: - * Frigg deploys its handlers to Lambda only, and CLI commands run from CI must - * not trip the deployed-stage check. + * True when the process runs as a deployed function, on AWS Lambda or on + * another provider's platform. + * + * - AWS Lambda sets AWS_LAMBDA_FUNCTION_NAME, LAMBDA_TASK_ROOT and + * AWS_EXECUTION_ENV (AWS_Lambda_). Other AWS hosts (CloudShell, + * CodeBuild, ECS) set AWS_EXECUTION_ENV to other values and count as local: + * CLI commands run from CI must not trip the deployed-stage check. + * - Other providers: FRIGG_PROVIDER names a non-AWS provider. Frigg's + * generated function entry points for that provider set it (provider-netlify + * sets 'netlify'), so it is present in every deployed function. Netlify + * exposes SITE_ID, CONTEXT and NETLIFY at build time only, so they cannot + * tell a running function from a laptop. + * + * A local run is never deployed: serverless-offline and `serverless invoke + * local` (which set the Lambda variables themselves) mark IS_OFFLINE / + * IS_LOCAL, `netlify dev` marks NETLIFY_DEV, and Jest marks JEST_WORKER_ID. * * @param {Object} [env=process.env] * @returns {boolean} */ function isDeployedRuntime(env = process.env) { + const localRun = + isTrue(env.IS_OFFLINE) || + isTrue(env.IS_LOCAL) || + isTrue(env.NETLIFY_DEV) || + isSet(env.JEST_WORKER_ID); + if (localRun) return false; + const onAwsRuntime = isSet(env.AWS_LAMBDA_FUNCTION_NAME) || isSet(env.LAMBDA_TASK_ROOT) || String(env.AWS_EXECUTION_ENV ?? '').startsWith('AWS_Lambda_'); - if (!onAwsRuntime) return false; + if (onAwsRuntime) return true; - const localRun = - isTrue(env.IS_OFFLINE) || - isTrue(env.IS_LOCAL) || - isSet(env.JEST_WORKER_ID); - return !localRun; + const provider = String(env.FRIGG_PROVIDER ?? '').trim().toLowerCase(); + return provider !== '' && provider !== 'aws'; } function missingKeyMessage(stage) { diff --git a/packages/core/database/encryption/encryption-config.test.js b/packages/core/database/encryption/encryption-config.test.js index a0b00aa3e..ee1c6f0e8 100644 --- a/packages/core/database/encryption/encryption-config.test.js +++ b/packages/core/database/encryption/encryption-config.test.js @@ -26,6 +26,9 @@ const OFFLINE = { ...LAMBDA, IS_OFFLINE: 'true' }; const INVOKE_LOCAL = { ...LAMBDA, IS_LOCAL: 'true' }; const JEST = { JEST_WORKER_ID: '1', NODE_ENV: 'test' }; const SCRIPT = {}; // plain `node script.js` on a laptop +// A deployed Netlify function: no AWS Lambda variables are relied on, only the +// FRIGG_PROVIDER that provider-netlify's generated entry points set. +const NETLIFY = { FRIGG_PROVIDER: 'netlify' }; describe('isDeployedRuntime', () => { it.each([ @@ -61,6 +64,22 @@ describe('isDeployedRuntime', () => { { ...LAMBDA, IS_OFFLINE: 'false' }, true, ], + ['a deployed Netlify function', NETLIFY, true], + [ + 'a deployed Netlify function, provider name in another case', + { FRIGG_PROVIDER: ' Netlify ' }, + true, + ], + ['`netlify dev`', { ...NETLIFY, NETLIFY_DEV: 'true' }, false], + ['Jest with FRIGG_PROVIDER=netlify', { ...NETLIFY, ...JEST }, false], + // Build-time variables only: a laptop with them set is still local. + [ + "Netlify's build-time variables without FRIGG_PROVIDER", + { NETLIFY: 'true', SITE_ID: 'abc', CONTEXT: 'production' }, + false, + ], + ['FRIGG_PROVIDER=aws outside Lambda', { FRIGG_PROVIDER: 'aws' }, false], + ['an empty FRIGG_PROVIDER', { FRIGG_PROVIDER: ' ' }, false], ])('%s → %p', (_label, env, expected) => { expect(isDeployedRuntime(env)).toBe(expected); }); @@ -198,6 +217,43 @@ describe('resolveEncryptionConfig', () => { }); }); + describe('deployed on Netlify: the same rule as Lambda', () => { + it.each(['dev', 'test', 'local', 'production'])( + 'STAGE=%p with no key → error, not plaintext', + (STAGE) => { + const config = resolveEncryptionConfig({ ...NETLIFY, STAGE }); + expect(config).toMatchObject({ + enabled: false, + runtime: 'deployed', + bypassed: false, + }); + expect(config.error).toMatch(/No field-level encryption key/); + } + ); + + it('STAGE=dev with AES_KEY_ID + AES_KEY → AES', () => { + expect( + resolveEncryptionConfig({ ...NETLIFY, ...AES, STAGE: 'dev' }) + ).toMatchObject({ enabled: true, method: 'aes', runtime: 'deployed' }); + }); + + it('`netlify dev` with STAGE=dev is a local run and skips encryption', () => { + expect( + resolveEncryptionConfig({ + ...NETLIFY, + NETLIFY_DEV: 'true', + STAGE: 'dev', + }) + ).toMatchObject({ runtime: 'local', bypassed: true, error: null }); + }); + + it('getEncryptionConfig throws for a deployed dev stage with no key', () => { + expect(() => + getEncryptionConfig({ ...NETLIFY, STAGE: 'dev' }) + ).toThrow(EncryptionConfigurationError); + }); + }); + describe.each([ ['frigg start (serverless-offline)', OFFLINE], ['serverless invoke local', INVOKE_LOCAL], From daae24ec9d6b03c59759b6b42f6f99369b683981 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:23:18 +0000 Subject: [PATCH 07/10] fix(core): fail closed when a ciphertext-shaped field cannot be decrypted in a deployed runtime A value with Cryptor's keyId:iv:ciphertext:encryptedKey shape that fails to decrypt (wrong, rotated or missing key) now throws FieldDecryptionError, with the cause, when the process runs deployed (encryption-config's isDeployedRuntime: AWS Lambda or a non-aws FRIGG_PROVIDER such as Netlify). Returning it as stored would hand ciphertext to an API module as a token. Legacy plaintext never takes this path, and local runs keep the warning. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- packages/core/CHANGELOG.md | 2 +- packages/core/database/encryption/README.md | 2 +- .../encryption/field-encryption-service.js | 47 +++++++- .../field-encryption-service.test.js | 104 +++++++++++++++++- 4 files changed, 147 insertions(+), 8 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 661e4e458..71990e89d 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -4,7 +4,7 @@ - `credentialFields` declared in an API module's `encryption` config (for example `Definition.encryption.credentialFields`) were never registered for encryption: `loadModuleEncryptionSchemas` required a path that does not exist and the error was swallowed. They are now registered, for modules bound by integrations and for modules registered in `appDefinition.entities` (ADR-059). - Existing rows are not rewritten. A credential saved before this release stays plaintext until it is next written, and is read as stored. -- Reads now tolerate plaintext: a value is only treated as ciphertext when it has the exact `keyId:iv:ciphertext:encryptedKey` shape (previously any string with three or more colons, such as `https://user:pw@host:443`), and a value that fails to decrypt is returned as stored with a warning instead of failing the whole read. +- Reads now tolerate plaintext: a value is only treated as ciphertext when it has the exact `keyId:iv:ciphertext:encryptedKey` shape (previously any string with three or more colons, such as `https://user:pw@host:443`), and a value with that shape that fails to decrypt throws `FieldDecryptionError` in a deployed runtime (any stage, AWS Lambda or Netlify) and is returned as stored with a warning on a local run. - An app's own `encryption.schema` is no longer replaced when module fields are added to it. --- diff --git a/packages/core/database/encryption/README.md b/packages/core/database/encryption/README.md index 40c0f3648..0eed091de 100644 --- a/packages/core/database/encryption/README.md +++ b/packages/core/database/encryption/README.md @@ -456,7 +456,7 @@ Format: "keyId:encryptedText:encryptedKey" Example: "base64KeyId:iv:ciphertext:base64EncryptedDataKey" ``` -**Recognising ciphertext on read.** A value is decrypted only when it has exactly four colon-separated parts: base64 `keyId`, 32-hex-character `iv`, hex ciphertext, base64 `encryptedKey`. Plaintext that merely contains colons (`https://user:pw@host:443`) is left alone. A value with that shape that still fails to decrypt is returned as stored and logged as a warning, so one bad field never fails a whole read. A field that becomes encrypted later (for example a module's `credentialFields`) stays plaintext in existing rows until they are next written. +**Recognising ciphertext on read.** A value is decrypted only when it has exactly four colon-separated parts: base64 `keyId`, 32-hex-character `iv`, hex ciphertext, base64 `encryptedKey`. Plaintext that merely contains colons (`https://user:pw@host:443`) is left alone. A value with that shape that still fails to decrypt (a wrong, rotated or missing key) fails the read with `FieldDecryptionError` in a deployed runtime (see [When Encryption Runs](#when-encryption-runs)), so ciphertext never reaches an API module as if it were a token. Locally it is returned as stored and logged as a warning, so one bad field does not fail a whole read. A field that becomes encrypted later (for example a module's `credentialFields`) stays plaintext in existing rows until they are next written. **Why Envelope Encryption?** diff --git a/packages/core/database/encryption/field-encryption-service.js b/packages/core/database/encryption/field-encryption-service.js index aac5b5030..c23b60d1c 100644 --- a/packages/core/database/encryption/field-encryption-service.js +++ b/packages/core/database/encryption/field-encryption-service.js @@ -5,6 +5,19 @@ * Handles nested JSON paths (e.g., 'data.access_token') and bulk operations. */ const { logger } = require('./logger'); +const { isDeployedRuntime } = require('./encryption-config'); + +/** + * Thrown when a value that looks like ciphertext cannot be decrypted in a + * deployed runtime (a wrong, rotated or missing key). Returning the stored + * ciphertext would hand it to API modules as if it were a token. + */ +class FieldDecryptionError extends Error { + constructor(message, options) { + super(message, options); + this.name = 'FieldDecryptionError'; + } +} // Cryptor.encrypt() returns `${keyId}:${iv}:${ciphertext}:${encryptedKey}`: // keyId and encryptedKey are base64, iv is 16 bytes of hex, ciphertext is hex. @@ -15,7 +28,17 @@ const IV_PART = /^[0-9a-f]{32}$/i; const CIPHERTEXT_PART = /^(?:[0-9a-f]{2})+$/i; class FieldEncryptionService { - constructor({ cryptor, schema }) { + /** + * @param {Object} params + * @param {Object} params.cryptor + * @param {Object} params.schema + * @param {() => boolean} [params.failClosedOnDecryptError] - whether a + * ciphertext-shaped value that fails to decrypt throws. Defaults to + * "running deployed" (encryption-config's isDeployedRuntime), read on + * each failure. Locally such a value is returned as stored, with a + * warning. + */ + constructor({ cryptor, schema, failClosedOnDecryptError }) { if (!cryptor) { throw new Error('Cryptor instance required'); } @@ -25,6 +48,10 @@ class FieldEncryptionService { this.cryptor = cryptor; this.schema = schema; + this.failClosedOnDecryptError = + typeof failClosedOnDecryptError === 'function' + ? failClosedOnDecryptError + : () => isDeployedRuntime(); } /** @@ -115,9 +142,19 @@ class FieldEncryptionService { const deserializedValue = this._deserializeAfterDecryption(decryptedValue); return { fieldPath, decryptedValue: deserializedValue }; } catch (error) { - // A value that looks like ciphertext but is not (or was written - // before the field was encrypted) is read as it is stored. One - // such field must not fail the whole read, findMany included. + // Deployed: a ciphertext-shaped value under a wrong, rotated or + // missing key fails the read rather than reaching an API + // module as a token. Legacy plaintext never gets here: it does + // not look like ciphertext (_isEncrypted). + if (this.failClosedOnDecryptError()) { + throw new FieldDecryptionError( + `Could not decrypt ${modelName}.${fieldPath}`, + { cause: error } + ); + } + // Local: a value that looks like ciphertext but is not is read + // as it is stored. One such field must not fail the whole + // read, findMany included. logger.warn( `Could not decrypt ${modelName}.${fieldPath}; returning the stored value`, { errorName: error?.name } @@ -278,4 +315,4 @@ class FieldEncryptionService { } } -module.exports = { FieldEncryptionService }; +module.exports = { FieldEncryptionService, FieldDecryptionError }; diff --git a/packages/core/database/encryption/field-encryption-service.test.js b/packages/core/database/encryption/field-encryption-service.test.js index 200b4cdf5..965b85f64 100644 --- a/packages/core/database/encryption/field-encryption-service.test.js +++ b/packages/core/database/encryption/field-encryption-service.test.js @@ -1,4 +1,7 @@ -const { FieldEncryptionService } = require('./field-encryption-service'); +const { + FieldEncryptionService, + FieldDecryptionError, +} = require('./field-encryption-service'); const { fakeCiphertext, fakePlaintext } = require('./test-support/fake-ciphertext'); describe('FieldEncryptionService', () => { @@ -515,6 +518,105 @@ describe('FieldEncryptionService', () => { }); }); + describe('deployed: a ciphertext-shaped value that fails to decrypt fails the read', () => { + const ciphertext = fakeCiphertext('secret'); + let deployed; + + beforeEach(() => { + deployed = new FieldEncryptionService({ + cryptor: mockCryptor, + schema: mockSchema, + failClosedOnDecryptError: () => true, + }); + }); + + it('decryptFields throws FieldDecryptionError, with the cause, instead of returning ciphertext', async () => { + const cause = new Error('bad key'); + mockCryptor.decrypt.mockRejectedValueOnce(cause); + + const read = deployed.decryptFields('Credential', { + data: { access_token: ciphertext }, + }); + + await expect(read).rejects.toBeInstanceOf(FieldDecryptionError); + await expect(read).rejects.toMatchObject({ + message: 'Could not decrypt Credential.data.access_token', + cause, + }); + }); + + it('decryptFieldsInBulk fails too', async () => { + mockCryptor.decrypt.mockRejectedValueOnce(new Error('bad key')); + + await expect( + deployed.decryptFieldsInBulk('Credential', [ + { data: { access_token: ciphertext } }, + ]) + ).rejects.toBeInstanceOf(FieldDecryptionError); + }); + + it('legacy plaintext is still read as stored: it never reaches the cryptor', async () => { + const result = await deployed.decryptFields('Credential', { + data: { access_token: 'plain-legacy-token' }, + }); + + expect(result.data.access_token).toBe('plain-legacy-token'); + expect(mockCryptor.decrypt).not.toHaveBeenCalled(); + }); + + it('a value that decrypts is returned decrypted', async () => { + const result = await deployed.decryptFields('Credential', { + data: { access_token: ciphertext }, + }); + + expect(result.data.access_token).toBe('secret'); + }); + }); + + describe('fail-closed default follows the runtime', () => { + const saved = {}; + const keys = ['JEST_WORKER_ID', 'FRIGG_PROVIDER', 'NETLIFY_DEV']; + + beforeEach(() => { + for (const key of keys) saved[key] = process.env[key]; + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + for (const key of keys) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + console.warn.mockRestore(); + }); + + const readFailing = () => { + mockCryptor.decrypt.mockRejectedValueOnce(new Error('bad key')); + return new FieldEncryptionService({ + cryptor: mockCryptor, + schema: mockSchema, + }).decryptFields('Credential', { + data: { access_token: fakeCiphertext('secret') }, + }); + }; + + it('throws in a deployed Netlify function', async () => { + delete process.env.JEST_WORKER_ID; + delete process.env.NETLIFY_DEV; + process.env.FRIGG_PROVIDER = 'netlify'; + + await expect(readFailing()).rejects.toBeInstanceOf(FieldDecryptionError); + }); + + it('returns the stored value under `netlify dev`', async () => { + delete process.env.JEST_WORKER_ID; + process.env.FRIGG_PROVIDER = 'netlify'; + process.env.NETLIFY_DEV = 'true'; + + const result = await readFailing(); + expect(result.data.access_token).toBe(fakeCiphertext('secret')); + }); + }); + describe('_getNestedValue', () => { it('should get top-level value', () => { const obj = { name: 'test' }; From a432a52646c9192f33fe2f4903b22b4f93accdf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:32:17 +0000 Subject: [PATCH 08/10] fix(test): run the in-memory MongoDB on Ubuntu 24.04 runners GitHub's ubuntu-latest is now Ubuntu 24.04, which has no libcrypto.so.1.1, so mongodb-memory-server's default MongoDB 5.0.19 could not start and core's test step failed before any test ran. Pin MongoDB 7.0.14 (published for Ubuntu 24.04; MONGOMS_VERSION still overrides it) and the wiredTiger storage engine, since the library's default ephemeralForTest was removed in MongoDB 7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- packages/test/mongodb.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/test/mongodb.js b/packages/test/mongodb.js index 320b06955..3e3c08133 100644 --- a/packages/test/mongodb.js +++ b/packages/test/mongodb.js @@ -1,11 +1,21 @@ const { MongoMemoryServer } = require('mongodb-memory-server'); +// mongodb-memory-server 8 defaults to MongoDB 5.0.19, whose Linux build links +// libcrypto.so.1.1. Ubuntu 24.04 (GitHub's ubuntu-latest since Oct 2026) ships +// only OpenSSL 3, so that mongod cannot start. 7.0 publishes Ubuntu 24.04 +// builds. MONGOMS_VERSION still overrides it. +const MONGODB_VERSION = '7.0.14'; + class TestMongo { #mongoServer; // Start the in-memory mongo instance and set env variable for the app to use in its connection. async start() { - this.#mongoServer = await MongoMemoryServer.create(); + this.#mongoServer = await MongoMemoryServer.create({ + binary: { version: process.env.MONGOMS_VERSION || MONGODB_VERSION }, + // The library's default, ephemeralForTest, was removed in MongoDB 7. + instance: { storageEngine: 'wiredTiger' }, + }); process.env.MONGO_URI = this.#mongoServer.getUri(); console.log('Started in memory mongo server', process.env.MONGO_URI); } From 6bacd2666932cc417e175260ee2dbf3522740572 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:36:52 +0000 Subject: [PATCH 09/10] fix(test): pin MongoDB 8.0.4, which has an Ubuntu 24.04 build 7.0.14 has no ubuntu2404 build (fastdl answers 403), so the in-memory server still could not start on GitHub's runners. 8.0.4's ubuntu2404 build downloads and starts with the wiredTiger engine; checked with an empty binary cache. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- packages/test/mongodb.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/test/mongodb.js b/packages/test/mongodb.js index 3e3c08133..16d5730fc 100644 --- a/packages/test/mongodb.js +++ b/packages/test/mongodb.js @@ -2,9 +2,9 @@ const { MongoMemoryServer } = require('mongodb-memory-server'); // mongodb-memory-server 8 defaults to MongoDB 5.0.19, whose Linux build links // libcrypto.so.1.1. Ubuntu 24.04 (GitHub's ubuntu-latest since Oct 2026) ships -// only OpenSSL 3, so that mongod cannot start. 7.0 publishes Ubuntu 24.04 +// only OpenSSL 3, so that mongod cannot start. 8.0 publishes Ubuntu 24.04 // builds. MONGOMS_VERSION still overrides it. -const MONGODB_VERSION = '7.0.14'; +const MONGODB_VERSION = '8.0.4'; class TestMongo { #mongoServer; From d6706963c56674519299fca904c380268b721911 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 03:03:04 +0000 Subject: [PATCH 10/10] fix(core): fail closed when an app's encryption schema cannot be registered From the Stack A review on #680: - loadCustomEncryptionSchema swallowed every error, so a schema the app declares (encryption.schema, encryption.disable, module credentialFields) that failed to register left those fields in plaintext with only a debug line. Not finding an app definition is still quiet; a registration failure now throws in a deployed runtime and warns on a local run. - Local runs enabled AES on AES_KEY_ID alone (deployed needs both). An incomplete pair now keeps a local run plaintext and warns that AES_KEY is missing. Tests that turned encryption on with the id alone set both. - The DocumentDB decrypt error no longer logs a ciphertext prefix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- packages/core/CHANGELOG.md | 3 ++ .../database/documentdb-encryption-service.js | 1 - .../entity-registry-encryption.test.js | 47 +++++++++++++++++++ .../database/encryption/encryption-config.js | 16 +++++-- .../encryption/encryption-config.test.js | 25 ++++++++++ .../encryption/encryption-schema-registry.js | 35 +++++++++----- .../integration-mapping-encryption.test.js | 7 ++- ...apping-repository-query-documentdb.test.js | 3 +- ...ion-mapping-repository-query-mongo.test.js | 3 +- ...tegration-mapping-repository-query.test.js | 4 +- 10 files changed, 123 insertions(+), 21 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 71990e89d..e385f11aa 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -6,6 +6,9 @@ - Existing rows are not rewritten. A credential saved before this release stays plaintext until it is next written, and is read as stored. - Reads now tolerate plaintext: a value is only treated as ciphertext when it has the exact `keyId:iv:ciphertext:encryptedKey` shape (previously any string with three or more colons, such as `https://user:pw@host:443`), and a value with that shape that fails to decrypt throws `FieldDecryptionError` in a deployed runtime (any stage, AWS Lambda or Netlify) and is returned as stored with a warning on a local run. - An app's own `encryption.schema` is no longer replaced when module fields are added to it. +- A schema the app declares (`encryption.schema`, `encryption.disable`, module `credentialFields`) that cannot be registered no longer leaves those fields in plaintext silently: a deployed function refuses to start, and a local run logs a warning (it was a debug line). +- A local run now needs both `AES_KEY_ID` and `AES_KEY` to encrypt with AES, as a deployed one already did; `AES_KEY_ID` alone keeps the run plaintext and warns that `AES_KEY` is missing. +- The DocumentDB decrypt error no longer logs the first characters of the ciphertext. --- diff --git a/packages/core/database/documentdb-encryption-service.js b/packages/core/database/documentdb-encryption-service.js index f8c33bc99..0196f7ead 100644 --- a/packages/core/database/documentdb-encryption-service.js +++ b/packages/core/database/documentdb-encryption-service.js @@ -251,7 +251,6 @@ class DocumentDBEncryptionService { const errorContext = { modelName, fieldPath, - encryptedValuePrefix: encryptedValue.substring(0, 20), errorMessage: error.message }; diff --git a/packages/core/database/encryption/__tests__/entity-registry-encryption.test.js b/packages/core/database/encryption/__tests__/entity-registry-encryption.test.js index cefe1f96a..29c261143 100644 --- a/packages/core/database/encryption/__tests__/entity-registry-encryption.test.js +++ b/packages/core/database/encryption/__tests__/entity-registry-encryption.test.js @@ -20,6 +20,7 @@ const { const { EntityRegistryError, } = require('../../../modules/entity-type-registry'); +const { logger } = require('../logger'); const xero = { moduleName: 'xero', @@ -172,6 +173,52 @@ describe('encryption schema and the entity registry (ADR-059)', () => { expect(() => loadCustomEncryptionSchema()).toThrow(EntityRegistryError); }); + describe('a schema the app declares but that cannot be registered', () => { + const savedEnv = { ...process.env }; + const badSchema = () => { + mockBackend.Definition = { + encryption: { schema: { Credential: { fields: 'data.secret' } } }, + integrations: [], + }; + }; + let warn; + + beforeEach(() => { + warn = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + process.env = { ...savedEnv }; + }); + + it('refuses to start a deployed function, which would store those fields in plaintext', () => { + badSchema(); + delete process.env.JEST_WORKER_ID; + process.env.AWS_LAMBDA_FUNCTION_NAME = 'app-prod-auth'; + + expect(() => loadCustomEncryptionSchema()).toThrow( + /Invalid custom encryption schema/ + ); + }); + + it('warns (not a debug line) on a local run', () => { + badSchema(); + + expect(() => loadCustomEncryptionSchema()).not.toThrow(); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/could not be registered; those fields stay plaintext/) + ); + }); + + it('still carries on quietly when there is no app definition to read', () => { + mockBackend.Definition = null; + + expect(() => loadCustomEncryptionSchema()).not.toThrow(); + expect(warn).not.toHaveBeenCalled(); + }); + }); + it('loadModuleEncryptionSchemas(integrations, entities) takes the registry directly', () => { loadModuleEncryptionSchemas([], { xero: { definition: xero } }); diff --git a/packages/core/database/encryption/encryption-config.js b/packages/core/database/encryption/encryption-config.js index 98f612e3f..90665658e 100644 --- a/packages/core/database/encryption/encryption-config.js +++ b/packages/core/database/encryption/encryption-config.js @@ -121,6 +121,10 @@ function resolveEncryptionConfig(env = process.env) { const runtime = isDeployedRuntime(env) ? 'deployed' : 'local'; const hasKMS = isSet(env.KMS_KEY_ARN); const hasAES = isSet(env.AES_KEY_ID); + // AES needs the key as well as its id, locally too: a cryptor built from + // the id alone cannot encrypt, and a local run reads failed decrypts as + // stored values, so an incomplete pair would hand back blobs as tokens. + const hasCompleteAES = hasAES && isSet(env.AES_KEY); const optOutRequested = isTrue(env[OPT_OUT_VAR]); const base = { @@ -141,14 +145,13 @@ function resolveEncryptionConfig(env = process.env) { if (LOCAL_BYPASS_STAGES.includes(String(stage).toLowerCase())) { return { ...base, bypassed: true }; } - if (hasKMS || hasAES) { + if (hasKMS || hasCompleteAES) { const method = hasKMS ? 'kms' : 'aes'; return { ...base, enabled: true, method, mode: method }; } return base; } - const hasCompleteAES = hasAES && isSet(env.AES_KEY); if (hasKMS || hasCompleteAES) { const method = hasKMS ? 'kms' : 'aes'; return { @@ -212,7 +215,14 @@ function getEncryptionConfig(env = process.env) { ); } - if (config.runtime === 'local' && !config.enabled && !config.bypassed) { + if (config.runtime === 'local' && !config.enabled && !config.bypassed && config.hasAES) { + warnOnce( + 'local-incomplete-aes', + `AES_KEY_ID is set but AES_KEY is not for local stage "${config.stage}". ` + + 'Field-level encryption disabled for this local run; set both to encrypt. ' + + 'A deployed stage refuses to start without both.' + ); + } else if (config.runtime === 'local' && !config.enabled && !config.bypassed) { warnOnce( 'local-no-keys', `No encryption keys configured (KMS_KEY_ARN or AES_KEY_ID) for local stage "${config.stage}". ` + diff --git a/packages/core/database/encryption/encryption-config.test.js b/packages/core/database/encryption/encryption-config.test.js index ee1c6f0e8..fbcbe4263 100644 --- a/packages/core/database/encryption/encryption-config.test.js +++ b/packages/core/database/encryption/encryption-config.test.js @@ -295,6 +295,20 @@ describe('resolveEncryptionConfig', () => { expect(config).toMatchObject({ enabled: true, method: 'aes' }); }); + it('a non-local stage with AES_KEY_ID but no AES_KEY stays plaintext', () => { + const config = resolveEncryptionConfig({ + ...runtimeEnv, + AES_KEY_ID: AES.AES_KEY_ID, + STAGE: 'production', + }); + expect(config).toMatchObject({ + enabled: false, + method: undefined, + hasAES: true, + error: null, + }); + }); + it('a non-local stage with no key stays plaintext, without error', () => { const config = resolveEncryptionConfig({ ...runtimeEnv, @@ -354,6 +368,17 @@ describe('getEncryptionConfig', () => { ); }); + it('warns locally that AES_KEY is missing when only AES_KEY_ID is set', () => { + const config = getEncryptionConfig({ + ...OFFLINE, + AES_KEY_ID: AES.AES_KEY_ID, + STAGE: 'production', + }); + expect(config.enabled).toBe(false); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/AES_KEY is not/); + }); + it('warns once per process on opt-out', () => { const env = { ...LAMBDA, ...OPT_OUT }; expect(getEncryptionConfig(env).enabled).toBe(false); diff --git a/packages/core/database/encryption/encryption-schema-registry.js b/packages/core/database/encryption/encryption-schema-registry.js index 8b19d41f3..696815856 100644 --- a/packages/core/database/encryption/encryption-schema-registry.js +++ b/packages/core/database/encryption/encryption-schema-registry.js @@ -9,6 +9,7 @@ */ const { logger } = require('./logger'); +const { isDeployedRuntime } = require('./encryption-config'); const { registerDeniedKeys } = require('../../logs/denied-keys'); const { getEntityTypeRegistry, @@ -230,6 +231,7 @@ function loadModuleEncryptionSchemas(integrations, entities) { * Used by both Prisma (MongoDB/PostgreSQL) and DocumentDB encryption services. */ function loadCustomEncryptionSchema() { + let appDefinition; try { // Lazy require to avoid circular dependency issues const path = require('node:path'); @@ -244,12 +246,19 @@ function loadCustomEncryptionSchema() { const backendIndexPath = path.join(backendDir, 'index.js'); const backendModule = require(backendIndexPath); - const appDefinition = backendModule?.Definition; + appDefinition = backendModule?.Definition; + } catch (error) { + // No app definition to read (tests, standalone usage): there is + // nothing to register, so nothing is lost by carrying on. + logger.debug('Could not load custom encryption schema:', error.message); + return; + } - if (!appDefinition) { - return; // No app definition found - } + if (!appDefinition) { + return; // No app definition found + } + try { // Load app-level custom schema const customSchema = appDefinition.encryption?.schema; if (customSchema && Object.keys(customSchema).length > 0) { @@ -273,14 +282,16 @@ function loadCustomEncryptionSchema() { ); } } catch (error) { - // A bad entity registry is a configuration error, not "no custom schema". - if (error instanceof EntityRegistryError) throw error; - // Silently ignore errors - custom schema is optional - // This handles cases like: - // - Backend package.json not found (tests, standalone usage) - // - No appDefinition defined - // - No custom encryption schema specified - logger.debug('Could not load custom encryption schema:', error.message); + // The app declared fields to encrypt and they could not be registered: + // carrying on would store them in plaintext. A bad entity registry is a + // configuration error anywhere; a deployed function refuses to start + // for any registration failure, and a local run says so loudly. + if (error instanceof EntityRegistryError || isDeployedRuntime()) { + throw error; + } + logger.warn( + `Encryption schema from the app definition could not be registered; those fields stay plaintext in this local run: ${error.message}` + ); } } diff --git a/packages/core/database/encryption/integration-mapping-encryption.test.js b/packages/core/database/encryption/integration-mapping-encryption.test.js index 423a903fb..0bd622624 100644 --- a/packages/core/database/encryption/integration-mapping-encryption.test.js +++ b/packages/core/database/encryption/integration-mapping-encryption.test.js @@ -18,7 +18,7 @@ const { } = require('./integration-mapping-encryption'); describe('getMappingFieldsEncryptedOnWrite', () => { - const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'KMS_KEY_ARN']; + const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'AES_KEY', 'KMS_KEY_ARN']; let savedEnv; beforeEach(() => { @@ -45,6 +45,7 @@ describe('getMappingFieldsEncryptedOnWrite', () => { const enableEncryption = () => { process.env.STAGE = 'production'; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; delete process.env.KMS_KEY_ARN; }; @@ -100,6 +101,7 @@ describe('getMappingFieldsEncryptedOnWrite', () => { (stage) => { process.env.STAGE = stage; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; expect(getMappingFieldsEncryptedOnWrite()).toEqual([]); } @@ -140,7 +142,7 @@ describe('getMappingFieldsEncryptedOnWrite', () => { }); describe('decryptQueriedMappings', () => { - const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'KMS_KEY_ARN']; + const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'AES_KEY', 'KMS_KEY_ARN']; const rows = [{ id: '1', mapping: { externalId: '1' } }]; let savedEnv; @@ -173,6 +175,7 @@ describe('decryptQueriedMappings', () => { it('hands back the same rows when the schema lists nothing besides mapping', async () => { process.env.STAGE = 'production'; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; delete process.env.KMS_KEY_ARN; registerEncryptionOptOut({ IntegrationMapping: ['mapping'] }); diff --git a/packages/core/integrations/repositories/integration-mapping-repository-query-documentdb.test.js b/packages/core/integrations/repositories/integration-mapping-repository-query-documentdb.test.js index ff140d0ac..c5056ff49 100644 --- a/packages/core/integrations/repositories/integration-mapping-repository-query-documentdb.test.js +++ b/packages/core/integrations/repositories/integration-mapping-repository-query-documentdb.test.js @@ -242,7 +242,7 @@ describe('IntegrationMappingRepositoryDocumentDB.queryMappings', () => { }); describe('encryption guard', () => { - const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'KMS_KEY_ARN']; + const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'AES_KEY', 'KMS_KEY_ARN']; let savedEnv; beforeEach(() => { @@ -251,6 +251,7 @@ describe('IntegrationMappingRepositoryDocumentDB.queryMappings', () => { ); process.env.STAGE = 'production'; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; delete process.env.KMS_KEY_ARN; loadCustomEncryptionSchema.mockReset(); resetEncryptionOptOut(); diff --git a/packages/core/integrations/repositories/integration-mapping-repository-query-mongo.test.js b/packages/core/integrations/repositories/integration-mapping-repository-query-mongo.test.js index 1ad5e7e6c..f1fa2e350 100644 --- a/packages/core/integrations/repositories/integration-mapping-repository-query-mongo.test.js +++ b/packages/core/integrations/repositories/integration-mapping-repository-query-mongo.test.js @@ -501,7 +501,7 @@ describe('IntegrationMappingRepositoryMongo.queryMappings', () => { }); describe('encryption guard', () => { - const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'KMS_KEY_ARN']; + const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'AES_KEY', 'KMS_KEY_ARN']; let savedEnv; beforeEach(() => { @@ -510,6 +510,7 @@ describe('IntegrationMappingRepositoryMongo.queryMappings', () => { ); process.env.STAGE = 'production'; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; delete process.env.KMS_KEY_ARN; loadCustomEncryptionSchema.mockReset(); resetEncryptionOptOut(); diff --git a/packages/core/integrations/repositories/integration-mapping-repository-query.test.js b/packages/core/integrations/repositories/integration-mapping-repository-query.test.js index 9d63e21cf..e0ff69726 100644 --- a/packages/core/integrations/repositories/integration-mapping-repository-query.test.js +++ b/packages/core/integrations/repositories/integration-mapping-repository-query.test.js @@ -490,7 +490,7 @@ describe('IntegrationMappingRepositoryPostgres.queryMappings', () => { }); describe('encryption guard', () => { - const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'KMS_KEY_ARN']; + const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'AES_KEY', 'KMS_KEY_ARN']; let savedEnv; beforeEach(() => { @@ -516,6 +516,7 @@ describe('IntegrationMappingRepositoryPostgres.queryMappings', () => { const enableEncryption = () => { process.env.STAGE = 'production'; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; delete process.env.KMS_KEY_ARN; }; @@ -562,6 +563,7 @@ describe('IntegrationMappingRepositoryPostgres.queryMappings', () => { it('runs on STAGE=dev, where encryption is off and the opt-out is never registered', async () => { process.env.STAGE = 'dev'; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; const { repo } = makeRepo(); await expect(