feat(pii): self-service per-column masking policy enforced for the LLM

- pii_catalog: persistent per-column masking policy (default masked); GET/POST
  /api/pii/policy and POST /api/pii/lookup which redacts masked values server-side.
- get_pii masked flag now reflects the policy; dataflow exposes the dataset key.
- Data Flow PII inspector: per-column lock/unlock toggles + mask-all/unmask-all,
  so operators control exactly which data the assistant may reveal.
This commit is contained in:
mo
2026-06-27 11:36:36 +02:00
parent 9fb5b0a780
commit 215ce111f1
5 changed files with 227 additions and 22 deletions
+61 -15
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { GitBranch, Play, RefreshCw, ShieldAlert, ShieldCheck, Loader2, Bot } from 'lucide-react'
import { GitBranch, Lock, LockOpen, Play, RefreshCw, ShieldAlert, ShieldCheck, Loader2, Bot } from 'lucide-react'
import type { DataflowEdge, DataflowGraph, DataflowNode } from '../../types'
import { fetchDataflow, runDataflowMovement, toggleEtlAgent, fetchAgentOpsStatus } from '../../lib/api'
import { fetchDataflow, runDataflowMovement, toggleEtlAgent, fetchAgentOpsStatus, setPiiMask } from '../../lib/api'
import { Badge } from '../ui/Badge'
import { cn } from '../../lib/utils'
@@ -158,6 +158,17 @@ export function DataFlowView() {
setTimeout(() => setRefreshing(false), 400)
}, [load])
const [maskBusy, setMaskBusy] = useState<string | null>(null)
const onToggleMask = useCallback(async (key: string, column: string, masked: boolean) => {
setMaskBusy(`${key}.${column}`)
try {
await setPiiMask(key, column, masked)
await load(true)
} finally {
setMaskBusy(null)
}
}, [load])
const piiSummary = graph?.pii_summary ?? {}
const movementEdges = useMemo(
() => edges.filter((e) => e.movement_id),
@@ -329,22 +340,57 @@ export function DataFlowView() {
)}
{selNode.pii?.has_pii ? (
<div className="mt-1.5 border-t border-border pt-1.5">
<div className="mb-1 flex items-center gap-1 text-[9px] font-medium text-rose-300">
{selNode.pii.all_masked ? <ShieldCheck className="h-3 w-3 text-emerald-300" /> : <ShieldAlert className="h-3 w-3" />}
{selNode.pii.pii_count} PII column{selNode.pii.pii_count === 1 ? '' : 's'}
<div className="mb-1 flex items-center justify-between gap-1 text-[9px] font-medium text-rose-300">
<span className="flex items-center gap-1">
{selNode.pii.all_masked ? <ShieldCheck className="h-3 w-3 text-emerald-300" /> : <ShieldAlert className="h-3 w-3" />}
{selNode.pii.pii_count} PII column{selNode.pii.pii_count === 1 ? '' : 's'}
</span>
{!selNode.pii.masked_layer && (
<span className="flex gap-1">
<button
type="button"
onClick={() => onToggleMask(selNode.pii!.key, '*', true)}
className="rounded border border-emerald-400/40 px-1 text-[7px] text-emerald-300 hover:bg-emerald-500/15"
>mask all</button>
<button
type="button"
onClick={() => onToggleMask(selNode.pii!.key, '*', false)}
className="rounded border border-rose-400/40 px-1 text-[7px] text-rose-300 hover:bg-rose-500/15"
>unmask all</button>
</span>
)}
</div>
<p className="mb-1 text-[7px] leading-tight text-foreground-faint">
Masked columns are hidden from the assistant it refuses to reveal them.
</p>
<div className="flex flex-col gap-0.5">
{selNode.pii.columns.map((c) => (
<div key={c.name} className="flex items-center justify-between gap-1 text-[8px]">
<span className="truncate font-mono text-foreground-muted">{c.name}</span>
<span className="flex items-center gap-1">
<span className="text-blue-300">{c.category}</span>
<span className={c.masked ? 'text-emerald-300' : 'text-rose-300'}>
{c.masked ? 'masked' : 'raw'}
{selNode.pii.columns.map((c) => {
const locked = c.policy_locked
const busy = maskBusy === `${selNode.pii!.key}.${c.name}`
return (
<button
key={c.name}
type="button"
disabled={locked || busy}
onClick={() => onToggleMask(selNode.pii!.key, c.name, !c.masked)}
title={locked ? 'Physically masked in the curated layer' : c.masked ? 'Masked — click to allow the assistant to read it' : 'Visible to the assistant — click to mask'}
className={cn(
'flex items-center justify-between gap-1 rounded px-1 py-0.5 text-[8px] transition-colors',
locked ? 'cursor-default opacity-70' : 'hover:bg-surface-overlay',
)}
>
<span className="truncate font-mono text-foreground-muted">{c.name}</span>
<span className="flex items-center gap-1">
<span className="text-blue-300">{c.category}</span>
<span className={cn('inline-flex items-center gap-0.5', c.masked ? 'text-emerald-300' : 'text-rose-300')}>
{busy ? <Loader2 className="h-2.5 w-2.5 animate-spin" />
: c.masked ? <Lock className="h-2.5 w-2.5" /> : <LockOpen className="h-2.5 w-2.5" />}
{c.masked ? 'masked' : 'visible'}
</span>
</span>
</span>
</div>
))}
</button>
)
})}
</div>
</div>
) : (
+8
View File
@@ -155,6 +155,14 @@ export async function fetchPii(refresh = false): Promise<{ datasets: PiiDataset[
)
}
export function setPiiMask(key: string, column: string, masked: boolean) {
return fetch('/api/pii/policy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, column, masked }),
})
}
export function toggleEtlAgent(enabled?: boolean) {
return fetch('/api/agent-ops/etl/toggle', {
method: 'POST',
+2 -1
View File
@@ -70,7 +70,7 @@ export type CdcStats = {
consumed: number
}
export type PiiColumn = { name: string; category: string; masked: boolean }
export type PiiColumn = { name: string; category: string; masked: boolean; policy_locked?: boolean }
export type DataflowNode = {
id: string
@@ -83,6 +83,7 @@ export type DataflowNode = {
metric: string | null
url?: string
pii?: {
key: string
has_pii: boolean
pii_count: number
all_masked: boolean