feat: protect sensitive catalog samples
This commit is contained in:
@@ -3,7 +3,7 @@ import { Menu } from "@base-ui/react/menu";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import type { ColDef, ICellRendererParams } from "ag-grid-community";
|
||||
import { ChevronDown, KeyRound, Link2, Pencil, RefreshCw, Save, X } from "lucide-react";
|
||||
import { ChevronDown, KeyRound, Link2, Pencil, RefreshCw, Save, Sparkles, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { ApiError, apiErrorMessage } from "../../api/client";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
consolidateCatalogDescriptions,
|
||||
listCatalogColumns,
|
||||
startDescriptionGenerationRun,
|
||||
suggestSensitiveFields,
|
||||
updateCatalogColumnMetadata,
|
||||
type CatalogColumn,
|
||||
type CatalogTable,
|
||||
@@ -31,7 +32,9 @@ interface Props {
|
||||
|
||||
interface GridContext {
|
||||
canManage: boolean;
|
||||
busy: boolean;
|
||||
onEdit: (column: CatalogColumn, origin: HTMLButtonElement) => void;
|
||||
onSensitiveChange: (column: CatalogColumn, sensitive: boolean) => void;
|
||||
}
|
||||
|
||||
function KeyCell({ data }: ICellRendererParams<CatalogColumn>) {
|
||||
@@ -55,6 +58,23 @@ function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknow
|
||||
);
|
||||
}
|
||||
|
||||
function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unknown, GridContext>) {
|
||||
if (!data || !context) return null;
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center" onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-primary outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
checked={data.sensitive}
|
||||
disabled={!context.canManage || context.busy}
|
||||
aria-label={`Sensitive data for ${data.name}`}
|
||||
onChange={(event) => context.onSensitiveChange(data, event.target.checked)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatabaseColumns({
|
||||
databaseId,
|
||||
table,
|
||||
@@ -77,6 +97,8 @@ export function DatabaseColumns({
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [description, setDescription] = useState("");
|
||||
const [generatedDescription, setGeneratedDescription] = useState("");
|
||||
const [sensitiveDrafts, setSensitiveDrafts] = useState<Record<string, boolean>>({});
|
||||
const [sensitiveAction, setSensitiveAction] = useState<"suggest" | "save" | null>(null);
|
||||
const [baseline, setBaseline] = useState("");
|
||||
const [version, setVersion] = useState<number | null>(null);
|
||||
const [stale, setStale] = useState(false);
|
||||
@@ -86,7 +108,17 @@ export function DatabaseColumns({
|
||||
const originRef = useRef<HTMLButtonElement | null>(null);
|
||||
const active = editingId ? data.find((column) => column.id === editingId) : undefined;
|
||||
const fingerprint = JSON.stringify([description, generatedDescription]);
|
||||
const dirty = Boolean(editingId && fingerprint !== baseline);
|
||||
const editorDirty = Boolean(editingId && fingerprint !== baseline);
|
||||
const changedSensitiveColumns = data.filter((column) => (
|
||||
Object.hasOwn(sensitiveDrafts, column.id)
|
||||
&& sensitiveDrafts[column.id] !== column.sensitive
|
||||
));
|
||||
const dirty = editorDirty || changedSensitiveColumns.length > 0;
|
||||
const displayedColumns = useMemo(() => data.map((column) => (
|
||||
Object.hasOwn(sensitiveDrafts, column.id)
|
||||
? { ...column, sensitive: sensitiveDrafts[column.id]! }
|
||||
: column
|
||||
)), [data, sensitiveDrafts]);
|
||||
|
||||
useEffect(() => { onNavigationStateChange({ dirty, busy }); }, [busy, dirty, onNavigationStateChange]);
|
||||
useEffect(() => {
|
||||
@@ -107,7 +139,7 @@ export function DatabaseColumns({
|
||||
};
|
||||
const closeEditor = () => {
|
||||
if (busy) return;
|
||||
if (dirty && !window.confirm("Discard unsaved column metadata?")) return;
|
||||
if (editorDirty && !window.confirm("Discard unsaved column metadata?")) return;
|
||||
setEditingId(null);
|
||||
window.setTimeout(() => originRef.current?.focus(), 0);
|
||||
};
|
||||
@@ -178,8 +210,74 @@ export function DatabaseColumns({
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const changeSensitive = (column: CatalogColumn, sensitive: boolean) => {
|
||||
const persisted = data.find((candidate) => candidate.id === column.id);
|
||||
if (!persisted) return;
|
||||
setSensitiveDrafts((current) => {
|
||||
const next = { ...current };
|
||||
if (sensitive === persisted.sensitive) delete next[column.id];
|
||||
else next[column.id] = sensitive;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const suggestSensitive = async () => {
|
||||
if (!selectedMetadataModel) return;
|
||||
setBusy(true);
|
||||
setSensitiveAction("suggest");
|
||||
try {
|
||||
const result = await suggestSensitiveFields(databaseId, selectedMetadataModel);
|
||||
const currentById = new Map(data.map((column) => [column.id, column]));
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const suggestion of result.suggestions) {
|
||||
const column = currentById.get(suggestion.columnId);
|
||||
if (column && suggestion.sensitive !== column.sensitive) {
|
||||
next[column.id] = suggestion.sensitive;
|
||||
}
|
||||
}
|
||||
setSensitiveDrafts(next);
|
||||
toast.success("Sensitive field suggestions ready for review");
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
} finally {
|
||||
setSensitiveAction(null);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSensitive = async () => {
|
||||
if (changedSensitiveColumns.length === 0) return;
|
||||
setBusy(true);
|
||||
setSensitiveAction("save");
|
||||
try {
|
||||
const updated = await Promise.all(changedSensitiveColumns.map((column) => (
|
||||
updateCatalogColumnMetadata(
|
||||
databaseId,
|
||||
table.id,
|
||||
column.id,
|
||||
column.version,
|
||||
column.description,
|
||||
column.generatedDescription,
|
||||
sensitiveDrafts[column.id],
|
||||
)
|
||||
)));
|
||||
const updatedById = new Map(updated.map((column) => [column.id, column]));
|
||||
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map(
|
||||
(column) => updatedById.get(column.id) ?? column,
|
||||
));
|
||||
setSensitiveDrafts({});
|
||||
toast.success("Sensitive fields saved");
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
} finally {
|
||||
setSensitiveAction(null);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
|
||||
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
|
||||
{ field: "sensitive", headerName: "Sensitive", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
|
||||
{ field: "name", headerName: "Name", minWidth: 190, flex: 1, cellClass: "font-mono text-xs" },
|
||||
{ field: "dataType", headerName: "Type", minWidth: 150, flex: 0.8, cellClass: "font-mono text-xs" },
|
||||
{ headerName: "Keys", minWidth: 125, width: 125, sortable: false, filter: false, cellRenderer: KeyCell },
|
||||
@@ -189,7 +287,12 @@ export function DatabaseColumns({
|
||||
{ field: "description", headerName: "Description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
|
||||
{ colId: "actions", headerName: "", width: 64, maxWidth: 64, pinned: "right", sortable: false, filter: false, resizable: false, cellRenderer: ActionCell },
|
||||
], []);
|
||||
const context = useMemo<GridContext>(() => ({ canManage, onEdit: edit }), [canManage, data]);
|
||||
const context = useMemo<GridContext>(() => ({
|
||||
canManage,
|
||||
busy,
|
||||
onEdit: edit,
|
||||
onSensitiveChange: changeSensitive,
|
||||
}), [busy, canManage, data]);
|
||||
|
||||
if (editingId && active) {
|
||||
return (
|
||||
@@ -213,7 +316,7 @@ export function DatabaseColumns({
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
|
||||
<Button type="button" disabled={!canManage || !dirty || busy || stale} onClick={() => void save()}><Save />{busy ? "Saving…" : "Save metadata"}</Button>
|
||||
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save />{busy ? "Saving…" : "Save metadata"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -255,6 +358,19 @@ export function DatabaseColumns({
|
||||
<span className="thot-label whitespace-nowrap">Catalog columns</span>
|
||||
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy}
|
||||
onClick={() => void suggestSensitive()}
|
||||
>
|
||||
<Sparkles />{sensitiveAction === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
|
||||
</Button>
|
||||
{changedSensitiveColumns.length > 0 ? (
|
||||
<Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}>
|
||||
<Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
||||
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
|
||||
</>
|
||||
@@ -264,7 +380,7 @@ export function DatabaseColumns({
|
||||
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
|
||||
<AgGridReact<CatalogColumn>
|
||||
ref={gridRef}
|
||||
rowData={data}
|
||||
rowData={displayedColumns}
|
||||
columnDefs={columns}
|
||||
context={context}
|
||||
loading={isLoading}
|
||||
|
||||
@@ -61,13 +61,17 @@ export function MetadataGenerationModelSelector({
|
||||
className="space-y-1 text-xs font-normal leading-4 text-muted-foreground"
|
||||
>
|
||||
<p>
|
||||
Description generation may send up to five real source rows and up to five representative
|
||||
non-null example values to the selected model provider.
|
||||
Description generation may send up to five source rows and up to five representative
|
||||
non-null example values to the selected model provider. Values from columns marked
|
||||
sensitive are replaced with plausible synthetic values before the request.
|
||||
</p>
|
||||
<p>
|
||||
Samples are transient and are not stored in run logs or catalog metadata. Automated
|
||||
Sensitive Data Policy filtering and anonymization are not currently provided; they are
|
||||
planned for future work.
|
||||
Values from unmarked columns may be sent unchanged. Samples are transient and are not
|
||||
stored in run logs or catalog metadata.
|
||||
</p>
|
||||
<p>
|
||||
Sensitive-field suggestions use structural metadata only and remain unsaved until you
|
||||
choose Save sensitive fields.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user