Skip to content

Commit 55a0af3

Browse files
feat(guardrails): the operator's masking policy now governs PRODUCTION redaction
Closes the advanced-anonymizers gap. The defect: an operator could configure per-entity operators (mask/redact/hash/encrypt/keep) and the ADMIN TEST box honoured them, but production redaction (getPii().scan -> scanWithPresidio) sent a hard-coded single 'replace' for every entity — so the configured policy never governed real runs. Same class as 'verify the real path, not the proxy'. - adapters/presidio.ts: loadPolicy now also loads the org operator policy and, only when the policy actually encrypts, binds the vaulted AES key; the /anonymize body is built by the SAME pure buildAnonymizeRequest the test surface uses (DRY — one shaping authority). Absent policy => legacy replace, so every existing caller is byte-identical. - policy store: strips inline key material before persisting (intent survives). - New admin route .../masking/encrypt-key: GET reports only whether a key exists; POST generates one server-side (CSPRNG) straight into the secrets store and NEVER returns it; DELETE removes it. Audited create/rotate/remove. - UI: the plaintext AES-key input is gone (it persisted a secret); replaced by an EncryptionKeyPanel showing configured/not-set + Generate/Rotate, and an honest note that without a key encrypt degrades to masking. 5 integration tests assert the real /anonymize wire body (legacy replace, configured operators, bound encrypt key, fail-safe downgrade, anonymizer-down fallback still redacts). Suite 5069 pass / 0 fail; coverage:check green.
1 parent df65c79 commit 55a0af3

5 files changed

Lines changed: 303 additions & 13 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { randomBytes } from 'node:crypto';
2+
import { NextResponse } from 'next/server';
3+
import { auditFromSession } from '@/lib/audit-actor';
4+
import { requireAdmin } from '@/lib/authz';
5+
import { PRESIDIO_ENCRYPT_KEY_SECRET } from '@/lib/presidio-anonymizers';
6+
import { currentOrgId } from '@/lib/tenancy';
7+
8+
export const dynamic = 'force-dynamic';
9+
10+
// Lifecycle of the org's Presidio AES key for the `encrypt` anonymizer operator.
11+
//
12+
// The key is SECRET MATERIAL: it is generated HERE (server-side CSPRNG), written straight to the
13+
// secrets store, and NEVER returned by any endpoint, echoed to a client, or written to a file — the
14+
// operator only ever learns *whether* a key is configured. This is what makes a keyless `encrypt`
15+
// policy spec safe to persist: the intent lives in the policy row, the key lives only in the vault.
16+
//
17+
// A 24-byte CSPRNG value base64-encodes to 32 ASCII characters = a valid AES-256 key length (Presidio
18+
// accepts 16/24/32 BYTES).
19+
function generateAesKey(): string {
20+
return randomBytes(24).toString('base64');
21+
}
22+
23+
async function secrets() {
24+
const { openBaoSecrets } = await import('@/lib/adapters/secrets');
25+
return openBaoSecrets;
26+
}
27+
28+
/** Is a key resolvable (vault first, env bootstrap second)? Never reveals the value. */
29+
async function keyConfigured(): Promise<boolean> {
30+
try {
31+
const vaulted = await (await secrets()).get(PRESIDIO_ENCRYPT_KEY_SECRET);
32+
if (typeof vaulted === 'string' && vaulted.trim()) return true;
33+
} catch {
34+
/* vault unreachable — fall through to the env bootstrap */
35+
}
36+
return Boolean(process.env.OFFGRID_PRESIDIO_ENCRYPT_KEY?.trim());
37+
}
38+
39+
export async function GET(req: Request) {
40+
const gate = await requireAdmin(req);
41+
if (gate instanceof NextResponse) return gate;
42+
return NextResponse.json({
43+
configured: await keyConfigured(),
44+
note: 'The AES key is generated server-side and held in the secrets store. It is never returned. Without a key, an encrypt operator degrades to masking (the value is still redacted, never plaintext).',
45+
});
46+
}
47+
48+
/** Generate (or rotate) the key. Returns only whether one is now configured — never the material. */
49+
export async function POST(req: Request) {
50+
const gate = await requireAdmin(req);
51+
if (gate instanceof NextResponse) return gate;
52+
const orgId = await currentOrgId();
53+
const store = await secrets();
54+
if (!store.set) {
55+
return NextResponse.json({ error: 'secrets backend is not writable' }, { status: 503 });
56+
}
57+
const rotated = await keyConfigured();
58+
try {
59+
await store.set(PRESIDIO_ENCRYPT_KEY_SECRET, generateAesKey());
60+
} catch (e) {
61+
return NextResponse.json(
62+
{ error: `could not store the key: ${(e as Error).message}` },
63+
{ status: 502 },
64+
);
65+
}
66+
auditFromSession(gate, orgId, {
67+
action: rotated ? 'governance.masking.encrypt-key.rotate' : 'governance.masking.encrypt-key.create',
68+
resource: `secret:${PRESIDIO_ENCRYPT_KEY_SECRET}`,
69+
outcome: 'ok',
70+
});
71+
return NextResponse.json({ ok: true, configured: true, rotated });
72+
}
73+
74+
/** Remove the key. Encryption then degrades to masking (fail safe), which the surface reports. */
75+
export async function DELETE(req: Request) {
76+
const gate = await requireAdmin(req);
77+
if (gate instanceof NextResponse) return gate;
78+
const orgId = await currentOrgId();
79+
try {
80+
const store = await secrets();
81+
if (store.remove) await store.remove(PRESIDIO_ENCRYPT_KEY_SECRET);
82+
} catch {
83+
/* best-effort */
84+
}
85+
auditFromSession(gate, orgId, {
86+
action: 'governance.masking.encrypt-key.remove',
87+
resource: `secret:${PRESIDIO_ENCRYPT_KEY_SECRET}`,
88+
outcome: 'ok',
89+
});
90+
return NextResponse.json({ ok: true, configured: await keyConfigured() });
91+
}

src/components/guardrails/PresidioAnonymizers.tsx

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { Plus, Trash } from '@phosphor-icons/react/dist/ssr';
44
import { useRouter } from 'next/navigation';
5-
import { useId, useState } from 'react';
5+
import { useEffect, useId, useState } from 'react';
66
import { toast } from 'sonner';
77
import { Badge } from '@/components/ui/badge';
88
import { Button } from '@/components/ui/button';
@@ -27,6 +27,7 @@ import {
2727
HASH_TYPES,
2828
type HashType,
2929
type OperatorSpec,
30+
policyUsesEncrypt,
3031
} from '@/lib/presidio-anonymizers';
3132

3233
// Per-entity anonymizer OPERATOR policy editor + a live test box. This is the "how do we mask each
@@ -254,6 +255,7 @@ export function PresidioAnonymizers({
254255
</div>
255256

256257
{/* Image redaction — the real upload → redact → review surface */}
258+
<EncryptionKeyPanel usesEncrypt={policyUsesEncrypt(policy)} />
257259
<ImageRedactionPanel available={imageRedactionAvailable} />
258260
</div>
259261
</div>
@@ -287,7 +289,8 @@ function OperatorEditor({
287289
onChange({ type, hashType: 'sha256' });
288290
break;
289291
case 'encrypt':
290-
onChange({ type, key: '' });
292+
// No inline key: a keyless encrypt spec means "use the org's vaulted key".
293+
onChange({ type });
291294
break;
292295
case 'replace':
293296
onChange({ type, newValue: '' });
@@ -374,15 +377,77 @@ function OperatorEditor({
374377
) : null}
375378

376379
{spec.type === 'encrypt' ? (
377-
<Input
378-
value={spec.key ?? ''}
379-
placeholder="AES key (16 / 24 / 32 chars)"
380-
className="font-mono"
381-
onChange={(e) => onChange({ type: 'encrypt', key: e.target.value })}
382-
/>
380+
// The AES key is NEVER typed or stored here — it is generated into the secrets store and
381+
// bound at scan time. Manage it with "Encryption key" below.
382+
<p className="rounded border border-dashed border-border px-2 py-1.5 text-[11px] text-muted-foreground">
383+
Uses the organization&rsquo;s encryption key from the secrets store. Manage it under
384+
<span className="text-foreground"> Encryption key</span> below.
385+
</p>
383386
) : null}
384387

385388
<p className="text-[11px] text-muted-foreground">{describeOperator(spec)}</p>
386389
</div>
387390
);
388391
}
392+
393+
// ─── Encryption key — the vault-backed AES key for the `encrypt` operator ─────────────────────────
394+
// The key is generated server-side into the secrets store and NEVER returned, so this panel only ever
395+
// shows whether one is configured. Without a key an encrypt operator degrades to masking (the value is
396+
// still redacted, never plaintext) — stated plainly rather than failing silently.
397+
function EncryptionKeyPanel({ usesEncrypt }: Readonly<{ usesEncrypt: boolean }>) {
398+
const [configured, setConfigured] = useState<boolean | null>(null);
399+
const [busy, setBusy] = useState(false);
400+
401+
useEffect(() => {
402+
let live = true;
403+
fetch('/api/v1/admin/governance/masking/encrypt-key')
404+
.then((r) => (r.ok ? r.json() : null))
405+
.then((j) => {
406+
if (live) setConfigured(j ? Boolean(j.configured) : null);
407+
})
408+
.catch(() => {
409+
if (live) setConfigured(null);
410+
});
411+
return () => {
412+
live = false;
413+
};
414+
}, []);
415+
416+
async function generate(): Promise<void> {
417+
setBusy(true);
418+
try {
419+
const res = await fetch('/api/v1/admin/governance/masking/encrypt-key', { method: 'POST' });
420+
const body = (await res.json().catch(() => ({}))) as { configured?: boolean; rotated?: boolean; error?: string };
421+
if (!res.ok) {
422+
toast.error(body.error ?? 'Could not store the key');
423+
return;
424+
}
425+
setConfigured(Boolean(body.configured));
426+
toast.success(body.rotated ? 'Encryption key rotated' : 'Encryption key generated');
427+
} catch {
428+
toast.error('Could not reach the secrets store');
429+
} finally {
430+
setBusy(false);
431+
}
432+
}
433+
434+
return (
435+
<div className="space-y-2 rounded-md border border-border p-4">
436+
<div className="flex flex-wrap items-center gap-2">
437+
<span className="text-sm font-medium">Encryption key</span>
438+
<Badge variant={configured ? 'default' : 'secondary'}>
439+
{configured === null ? 'unknown' : configured ? 'configured' : 'not set'}
440+
</Badge>
441+
<Button type="button" size="sm" variant="outline" className="ml-auto" onClick={generate} disabled={busy}>
442+
{busy ? 'Working…' : configured ? 'Rotate key' : 'Generate key'}
443+
</Button>
444+
</div>
445+
<p className="text-xs text-muted-foreground">
446+
Generated on the server and held in the secrets store — it is never displayed or downloadable.
447+
{usesEncrypt && configured === false
448+
? ' Your policy encrypts an entity but no key is set, so those values are masked instead until you generate one.'
449+
: ' Required only by the encrypt operator; without it, encrypt degrades to masking.'}
450+
</p>
451+
</div>
452+
);
453+
}

src/lib/adapters/presidio.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ import {
55
type NormalizedRecognizer,
66
type ThresholdConfig,
77
} from '@/lib/presidio-recognizers';
8+
import {
9+
type AnonymizerPolicy,
10+
bindEncryptKey,
11+
buildAnonymizeRequest,
12+
PRESIDIO_ENCRYPT_KEY_SECRET,
13+
policyUsesEncrypt,
14+
} from '@/lib/presidio-anonymizers';
815
import { regexScan } from './pii-regex';
916
import type { PiiPort, PiiResult } from './types';
1017

@@ -21,6 +28,14 @@ export interface PresidioScanPolicy {
2128
recognizers?: NormalizedRecognizer[];
2229
thresholds?: ThresholdConfig;
2330
language?: string;
31+
/**
32+
* The org's per-entity OPERATOR policy (mask / redact / hash / encrypt / keep). ABSENT ⇒ the legacy
33+
* single-`replace` behaviour, so every existing caller is unchanged. PRESENT ⇒ the operator's
34+
* configured operators actually govern production redaction (they previously only applied on the
35+
* admin test surface, which is the gap this closes). Encrypt keys are already BOUND here (the
36+
* caller resolves them from the vault via bindEncryptKey) — this layer never touches secrets.
37+
*/
38+
operators?: AnonymizerPolicy;
2439
}
2540

2641
interface PresidioEntity {
@@ -152,9 +167,15 @@ export async function scanWithPresidio(
152167
}
153168

154169
try {
170+
// The org's OPERATOR policy governs production redaction when present (reusing the SAME pure
171+
// request builder the admin test surface uses — one authority, no second shaping rule); absent,
172+
// the legacy single-`replace` body is sent verbatim.
173+
const body = policy.operators
174+
? buildAnonymizeRequest(text, entities, policy.operators)
175+
: { text, analyzer_results: entities, anonymizers: anonymizers(entities) };
155176
const anonymized = (await postJson(
156177
`${config.anonymizerUrl}/anonymize`,
157-
{ text, analyzer_results: entities, anonymizers: anonymizers(entities) },
178+
body,
158179
config.timeoutMs,
159180
fetcher,
160181
)) as { text?: unknown };
@@ -197,18 +218,41 @@ export async function scanWithPresidio(
197218
}
198219
}
199220

221+
/**
222+
* Resolve the org's AES key for the `encrypt` operator from the SECRETS STORE (OpenBao), with an env
223+
* bootstrap fallback. Never throws and never logs the material — an unresolved key makes
224+
* bindEncryptKey downgrade encryption to masking rather than fail open.
225+
*/
226+
async function resolveEncryptKey(): Promise<string | null> {
227+
try {
228+
const { openBaoSecrets } = await import('@/lib/adapters/secrets');
229+
const vaulted = await openBaoSecrets.get(PRESIDIO_ENCRYPT_KEY_SECRET);
230+
if (typeof vaulted === 'string' && vaulted.trim()) return vaulted.trim();
231+
} catch {
232+
/* vault unreachable — fall through to the env bootstrap */
233+
}
234+
return process.env.OFFGRID_PRESIDIO_ENCRYPT_KEY?.trim() || null;
235+
}
236+
200237
async function loadPolicy(orgId?: string): Promise<PresidioScanPolicy> {
201238
try {
202239
const { getThresholds, listRecognizers } = await import('@/lib/presidio-recognizers');
240+
const { getAnonymizerPolicy } = await import('@/lib/presidio-anonymizer-policy-store');
203241
const resolvedOrg = orgId?.trim() || 'default';
204-
const [recognizers, thresholds] = await Promise.all([
242+
const [recognizers, thresholds, stored] = await Promise.all([
205243
listRecognizers(resolvedOrg),
206244
getThresholds(resolvedOrg),
245+
getAnonymizerPolicy(resolvedOrg),
207246
]);
208-
return { recognizers, thresholds };
247+
// Bind the vaulted key ONLY when the policy actually encrypts, so a non-encrypting org never
248+
// touches the secrets store. An unresolvable key downgrades to masking (fail safe, reported).
249+
const operators = policyUsesEncrypt(stored)
250+
? bindEncryptKey(stored, await resolveEncryptKey()).policy
251+
: stored;
252+
return { recognizers, thresholds, operators };
209253
} catch {
210254
// The default India recognizers are folded in by buildAnalyzeRequest. A policy-store failure must
211-
// not bypass redaction; it only loses the org overrides for this call.
255+
// not bypass redaction; it only loses the org overrides for this call (legacy replace applies).
212256
return {};
213257
}
214258
}

src/lib/presidio-anonymizer-policy-store.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type AnonymizerPolicy,
1010
DEFAULT_ANONYMIZER_POLICY,
1111
normalizeAnonymizerPolicy,
12+
stripInlineEncryptKeys,
1213
} from '@/lib/presidio-anonymizers';
1314

1415
let ensurePromise: Promise<void> | null = null;
@@ -64,7 +65,9 @@ export async function setAnonymizerPolicy(
6465
orgId = 'default',
6566
): Promise<AnonymizerPolicy> {
6667
await ensureAnonymizerPolicySchema();
67-
const normalized = normalizeAnonymizerPolicy(value);
68+
// NEVER persist AES key material: strip inline keys, keeping the encrypt INTENT (a keyless encrypt
69+
// spec resolves the org's vaulted key at call time via bindEncryptKey).
70+
const normalized = stripInlineEncryptKeys(normalizeAnonymizerPolicy(value));
6871
const { db } = await import('@/db');
6972
const { sql } = await import('drizzle-orm');
7073
await db.execute(sql`

0 commit comments

Comments
 (0)