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.
|
* Create a FieldEncryptor instance.
|
||||||
*
|
*
|
||||||
@ -165,7 +210,18 @@ function resolveKeyProvider(config: FieldEncryptorConfig): KeyProvider {
|
|||||||
* keyVaultUrl: config.AZURE_KEYVAULT_URL,
|
* 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 {
|
export function createFieldEncryptor(config: FieldEncryptorConfig): FieldEncryptor {
|
||||||
|
if (config.enabled === false) {
|
||||||
|
return new NullFieldEncryptor();
|
||||||
|
}
|
||||||
return new FieldEncryptor(config);
|
return new FieldEncryptor(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { randomBytes } from 'node:crypto';
|
|||||||
import {
|
import {
|
||||||
createFieldEncryptor,
|
createFieldEncryptor,
|
||||||
FieldEncryptor,
|
FieldEncryptor,
|
||||||
|
NullFieldEncryptor,
|
||||||
isEncryptedField,
|
isEncryptedField,
|
||||||
encryptField,
|
encryptField,
|
||||||
decryptField,
|
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 ───────────────────────────────────────
|
// ── Migration ───────────────────────────────────────
|
||||||
|
|
||||||
describe('migrateDocuments', () => {
|
describe('migrateDocuments', () => {
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// ── Main API ────────────────────────────────────────
|
// ── Main API ────────────────────────────────────────
|
||||||
export { createFieldEncryptor, FieldEncryptor } from './field-encryptor.js';
|
export { createFieldEncryptor, FieldEncryptor, NullFieldEncryptor } from './field-encryptor.js';
|
||||||
|
|
||||||
// ── Type guards ─────────────────────────────────────
|
// ── Type guards ─────────────────────────────────────
|
||||||
export { isEncryptedField } from './guards.js';
|
export { isEncryptedField } from './guards.js';
|
||||||
|
|||||||
@ -67,6 +67,12 @@ export interface DekStore {
|
|||||||
|
|
||||||
/** Configuration for createFieldEncryptor(). */
|
/** Configuration for createFieldEncryptor(). */
|
||||||
export interface FieldEncryptorConfig {
|
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. */
|
/** Key provider type. */
|
||||||
keyProvider: KeyProviderType;
|
keyProvider: KeyProviderType;
|
||||||
/** Azure Key Vault URL (required for 'akv' provider). */
|
/** Azure Key Vault URL (required for 'akv' provider). */
|
||||||
|
|||||||
@ -33,6 +33,14 @@ const COMMON_FLAGS: FlagSeedDef[] = [
|
|||||||
platforms: [],
|
platforms: [],
|
||||||
percentage: 100,
|
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[]> = {
|
const PRODUCT_FLAGS: Record<string, FlagSeedDef[]> = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user