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:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user