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:
parent
4de3974a26
commit
7613d6890f
@ -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);
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -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[]> = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user