feat(field-encrypt): admin-panel encryption toggle via feature flags

- FieldEncryptorConfig.enabled: false returns NullFieldEncryptor (no-op)
- NullFieldEncryptor stores plaintext as-is, decrypt returns ct directly
- 7 new tests for toggle behavior (50/50 total)
- encryption_enabled added to COMMON_FLAGS (seeded for all 10 products)
This commit is contained in:
saravanakumardb1 2026-03-21 15:24:19 -07:00
parent 4de3974a26
commit 7613d6890f
5 changed files with 133 additions and 1 deletions

View File

@ -154,6 +154,51 @@ function resolveKeyProvider(config: FieldEncryptorConfig): KeyProvider {
}
}
/**
* No-op encryptor stores/returns plaintext unchanged.
*
* Returned by createFieldEncryptor({ enabled: false, ... }).
* All repositories continue calling encrypt()/decrypt() without branching.
*/
export class NullFieldEncryptor extends FieldEncryptor {
constructor() {
// Use memory provider — it will never be called, but satisfies the constructor
super({ keyProvider: 'memory' });
}
override async encrypt(plaintext: string, _ctx: FieldEncryptContext): Promise<EncryptedField> {
// Return a sentinel object that looks encrypted but stores plaintext
return {
__encrypted: true,
v: 1,
alg: 'aes-256-gcm',
ct: plaintext,
iv: 'disabled',
tag: 'disabled',
dekId: 'disabled',
};
}
override async decrypt(field: EncryptedField, _ctx: FieldEncryptContext): Promise<string> {
// If encryption was disabled, ct contains the plaintext directly
return field.ct;
}
override async encryptBatch(
plaintexts: string[],
ctx: FieldEncryptContext
): Promise<EncryptedField[]> {
return Promise.all(plaintexts.map(pt => this.encrypt(pt, ctx)));
}
override async decryptBatch(
fields: EncryptedField[],
ctx: FieldEncryptContext
): Promise<string[]> {
return Promise.all(fields.map(f => this.decrypt(f, ctx)));
}
}
/**
* Create a FieldEncryptor instance.
*
@ -165,7 +210,18 @@ function resolveKeyProvider(config: FieldEncryptorConfig): KeyProvider {
* keyVaultUrl: config.AZURE_KEYVAULT_URL,
* });
* ```
*
* To disable encryption globally or per-product:
* ```typescript
* const encryptor = createFieldEncryptor({
* enabled: false, // ← no-op passthrough
* keyProvider: 'memory',
* });
* ```
*/
export function createFieldEncryptor(config: FieldEncryptorConfig): FieldEncryptor {
if (config.enabled === false) {
return new NullFieldEncryptor();
}
return new FieldEncryptor(config);
}

View File

@ -10,6 +10,7 @@ import { randomBytes } from 'node:crypto';
import {
createFieldEncryptor,
FieldEncryptor,
NullFieldEncryptor,
isEncryptedField,
encryptField,
decryptField,
@ -370,6 +371,67 @@ describe('createFieldEncryptor config', () => {
});
});
// ── Encryption toggle (enabled/disabled) ────────────
describe('enabled: false (NullFieldEncryptor)', () => {
const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' };
it('createFieldEncryptor returns NullFieldEncryptor when enabled=false', () => {
const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
expect(enc).toBeInstanceOf(NullFieldEncryptor);
});
it('createFieldEncryptor returns real FieldEncryptor when enabled=true', () => {
const enc = createFieldEncryptor({ enabled: true, keyProvider: 'memory' });
expect(enc).not.toBeInstanceOf(NullFieldEncryptor);
expect(enc).toBeInstanceOf(FieldEncryptor);
});
it('createFieldEncryptor returns real FieldEncryptor when enabled is omitted', () => {
const enc = createFieldEncryptor({ keyProvider: 'memory' });
expect(enc).not.toBeInstanceOf(NullFieldEncryptor);
});
it('encrypt returns sentinel with plaintext in ct field', async () => {
const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
const result = await enc.encrypt('hello world', ctx);
expect(result.__encrypted).toBe(true);
expect(result.ct).toBe('hello world');
expect(result.iv).toBe('disabled');
expect(result.tag).toBe('disabled');
expect(result.dekId).toBe('disabled');
});
it('decrypt returns plaintext from ct field', async () => {
const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
const encrypted = await enc.encrypt('secret', ctx);
const decrypted = await enc.decrypt(encrypted, ctx);
expect(decrypted).toBe('secret');
});
it('encryptBatch returns sentinels for all items', async () => {
const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
const results = await enc.encryptBatch(['a', 'b', 'c'], ctx);
expect(results).toHaveLength(3);
expect(results[0].ct).toBe('a');
expect(results[1].ct).toBe('b');
expect(results[2].ct).toBe('c');
});
it('decryptBatch returns plaintexts from ct fields', async () => {
const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
const encrypted = await enc.encryptBatch(['x', 'y'], ctx);
const decrypted = await enc.decryptBatch(encrypted, ctx);
expect(decrypted).toEqual(['x', 'y']);
});
it('isEncrypted returns true for disabled sentinel', async () => {
const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
const result = await enc.encrypt('test', ctx);
expect(enc.isEncrypted(result)).toBe(true);
});
});
// ── Migration ───────────────────────────────────────
describe('migrateDocuments', () => {

View File

@ -25,7 +25,7 @@
*/
// ── Main API ────────────────────────────────────────
export { createFieldEncryptor, FieldEncryptor } from './field-encryptor.js';
export { createFieldEncryptor, FieldEncryptor, NullFieldEncryptor } from './field-encryptor.js';
// ── Type guards ─────────────────────────────────────
export { isEncryptedField } from './guards.js';

View File

@ -67,6 +67,12 @@ export interface DekStore {
/** Configuration for createFieldEncryptor(). */
export interface FieldEncryptorConfig {
/**
* Master toggle set to false to disable encryption entirely.
* When disabled, encrypt() returns plaintext as-is and decrypt() passes through.
* Default: true.
*/
enabled?: boolean;
/** Key provider type. */
keyProvider: KeyProviderType;
/** Azure Key Vault URL (required for 'akv' provider). */

View File

@ -33,6 +33,14 @@ const COMMON_FLAGS: FlagSeedDef[] = [
platforms: [],
percentage: 100,
},
{
key: 'encryption_enabled',
enabled: true,
description:
'Field-level encryption at rest — disable to store plaintext (e.g., for debugging)',
platforms: [],
percentage: 100,
},
];
const PRODUCT_FLAGS: Record<string, FlagSeedDef[]> = {