feat: classify sensitive columns locally

This commit is contained in:
Codex
2026-09-03 02:11:13 +02:00
parent 7b87e95427
commit f114d0065a
57 changed files with 4038 additions and 1149 deletions
@@ -9,7 +9,7 @@ import type {
CatalogSyncRun,
CatalogTable,
DescriptionGenerationRun,
SensitiveDataSuggestionRun,
SensitivityAnalysisRun,
} from "../api/catalog-databases";
import { Toaster } from "../components/ui/sonner";
import { DatabaseManagementPage } from "./DatabaseManagementPage";
@@ -159,18 +159,21 @@ function makeDescriptionGenerationRun(
};
}
function makeSensitiveDataSuggestionRun(
overrides: Partial<SensitiveDataSuggestionRun> = {},
): SensitiveDataSuggestionRun {
function makeSensitivityAnalysisRun(
overrides: Partial<SensitivityAnalysisRun> = {},
): SensitivityAnalysisRun {
return {
id: "99999999-9999-4999-8999-999999999999",
databaseId: "11111111-1111-4111-8111-111111111111",
modelId: "local-qwen",
engine: "local",
modelId: null,
policyVersion: "sensitivity-v1",
scope: "selected_columns",
status: "completed",
total: 2,
suggestedSensitive: 1,
suggestedNonSensitive: 1,
unknown: 0,
createdAt: "2026-08-28T11:00:00Z",
startedAt: "2026-08-28T11:00:00Z",
updatedAt: "2026-08-28T11:00:01Z",
@@ -505,7 +508,7 @@ test("keeps both run-history buttons visible beside the metadata-description sel
name: "View description generation history",
});
const suggestionHistoryButton = within(metadataControls).getByRole("button", {
name: "View sensitive suggestion history",
name: "View sensitivity analysis history",
});
expect(toolbar).toHaveClass("sm:items-end", "sm:justify-between");
@@ -517,7 +520,7 @@ test("keeps both run-history buttons visible beside the metadata-description sel
expect(descriptionHistoryButton).toHaveClass("disabled:opacity-70");
expect(suggestionHistoryButton).toBeVisible();
expect(suggestionHistoryButton).toBeEnabled();
expect(suggestionHistoryButton).toHaveTextContent("View sensitive suggestion history");
expect(suggestionHistoryButton).toHaveTextContent("View sensitivity analysis history");
expect(toolbar.lastElementChild).toBe(actions);
expect(actions).toHaveClass("sm:justify-end");
expect(actions).toContainElement(screen.getByRole("button", { name: "Refresh" }));
@@ -533,8 +536,8 @@ test("keeps both run-history buttons visible beside the metadata-description sel
await user.click(suggestionHistoryButton);
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
const suggestionDrawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
expect(within(suggestionDrawer).getByText("No sensitive suggestion runs yet.")).toBeVisible();
const suggestionDrawer = await screen.findByRole("dialog", { name: "Sensitivity analysis history" });
expect(within(suggestionDrawer).getByText("No sensitivity analysis runs yet.")).toBeVisible();
});
test("changes the metadata-description model in page-local state", async () => {
@@ -1642,8 +1645,8 @@ test("observes an active run from another browser and reopens a terminal run fro
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
});
test("keeps the sensitive suggestion history label stable while showing active status separately", async () => {
const runningRun = makeSensitiveDataSuggestionRun({
test("keeps the sensitivity analysis history label stable while showing active status separately", async () => {
const runningRun = makeSensitivityAnalysisRun({
status: "running",
finishedAt: null,
updatedAt: new Date().toISOString(),
@@ -1655,12 +1658,12 @@ test("keeps the sensitive suggestion history label stable while showing active s
renderPage();
const historyButton = await screen.findByRole("button", {
name: "View sensitive suggestion history",
name: "View sensitivity analysis history",
});
expect(historyButton).toHaveTextContent("View sensitive suggestion history");
expect(historyButton).toHaveTextContent("View sensitivity analysis history");
await waitFor(() => expect(historyButton).toHaveAttribute(
"title",
"Sensitive suggestion generation is active",
"Sensitivity analysis is active",
));
expect(historyButton.querySelector("[aria-hidden='true'].bg-primary")).not.toBeNull();
});
@@ -2015,7 +2018,7 @@ test("starts one selected column with the configured default model", async () =>
expect(await screen.findByText("Description generation started for 1 column")).toBeVisible();
});
test("shows database sensitive suggestions only for a selection and rejects multiple databases clearly", async () => {
test("shows database sensitivity analysis only for a selection and rejects multiple databases clearly", async () => {
const user = userEvent.setup();
let suggestionCalls = 0;
const radiology = makeDatabase({
@@ -2035,24 +2038,24 @@ test("shows database sensitive suggestions only for a selection and rejects mult
);
renderPage({ rows: [makeDatabase(), radiology] });
expect(screen.queryByRole("button", { name: "Suggest sensitive fields" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Analyze sensitive fields" })).not.toBeInTheDocument();
const psdRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
const radiologyRow = await screen.findByRole("row", { name: /Radiology/ });
await user.click(within(psdRow).getByRole("checkbox", { name: /toggle row selection/i }));
expect(screen.getByRole("button", { name: "Suggest sensitive fields" })).toBeVisible();
expect(screen.getByRole("button", { name: "Analyze sensitive fields" })).toBeVisible();
await user.click(within(radiologyRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
expect(await screen.findByText("Sensitive-field suggestions can be requested for only one database at a time. Select one database and try again.")).toBeVisible();
expect(await screen.findByText("Sensitivity analysis can run for only one database at a time. Select one database and try again.")).toBeVisible();
expect(suggestionCalls).toBe(0);
});
test("requests database-level sensitive suggestions for the only selected database", async () => {
test("requests database-level sensitivity analysis for the only selected database", async () => {
const user = userEvent.setup();
let suggestionBody: unknown;
let suggestionFinished = false;
let historyCalls = 0;
const run = makeSensitiveDataSuggestionRun({ scope: "all", total: 1 });
const run = makeSensitivityAnalysisRun({ scope: "all", total: 1 });
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
@@ -2075,6 +2078,9 @@ test("requests database-level sensitive suggestions for the only selected databa
version: patientIdColumn.version,
currentSensitive: false,
sensitive: true,
assessment: "sensitive",
evidence: [{ kind: "content", ruleId: "pii.email" }],
observedValues: 1,
}],
});
}),
@@ -2083,14 +2089,14 @@ test("requests database-level sensitive suggestions for the only selected databa
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
await waitFor(() => expect(suggestionBody).toEqual({ modelId: "local-qwen", scope: "all" }));
await waitFor(() => expect(suggestionBody).toEqual({ scope: "all" }));
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
await waitFor(() => expect(historyCalls).toBeGreaterThanOrEqual(2));
});
test("refetches sensitive suggestion history after a failed request", async () => {
test("refetches sensitivity analysis history after a failed request", async () => {
const user = userEvent.setup();
let historyCalls = 0;
server.use(
@@ -2104,8 +2110,8 @@ test("refetches sensitive suggestion history after a failed request", async () =
}),
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", () => (
HttpResponse.json({
code: "sensitive_data_suggestion_invalid_response",
message: "The LLM returned an incomplete or invalid classification. No suggestions were applied.",
code: "sensitivity_source_scan_failed",
message: "Sensitivity analysis could not read the source. No assessments were applied.",
}, { status: 502 })
)),
);
@@ -2113,13 +2119,13 @@ test("refetches sensitive suggestion history after a failed request", async () =
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
await waitFor(() => expect(historyCalls).toBeGreaterThanOrEqual(2));
expect(screen.queryByRole("dialog", { name: "Sensitive field review" })).not.toBeInTheDocument();
});
test("requests sensitive suggestions only for selected tables", async () => {
test("requests sensitivity analysis only for selected tables", async () => {
const user = userEvent.setup();
const visitsTable: CatalogTable = {
...patientsTable,
@@ -2153,6 +2159,9 @@ test("requests sensitive suggestions only for selected tables", async () => {
version: visitColumn.version,
currentSensitive: false,
sensitive: true,
assessment: "sensitive",
evidence: [{ kind: "metadata", ruleId: "metadata.health" }],
observedValues: 0,
}],
});
}),
@@ -2163,17 +2172,16 @@ test("requests sensitive suggestions only for selected tables", async () => {
await user.click(screen.getByRole("tab", { name: "Tables" }));
const visitsRow = await screen.findByRole("row", { name: /visits/ });
await user.click(within(visitsRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
await waitFor(() => expect(suggestionBody).toEqual({
modelId: "local-qwen",
scope: "selected_tables",
targetIds: [visitsTable.id],
}));
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
});
test("reviews AI-sensitive-field suggestions as an editable draft and saves only changed columns", async () => {
test("allows a human downgrade and saves only explicit sensitivity changes", async () => {
const user = userEvent.setup();
const idColumn = { ...patientIdColumn, sensitive: false };
const nameColumn = {
@@ -2185,7 +2193,7 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
isPrimaryKey: false,
description: "Patient name",
generatedDescription: "Name of the patient",
sensitive: false,
sensitive: true,
};
const unselectedColumn = {
...patientIdColumn,
@@ -2225,6 +2233,9 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
version: idColumn.version,
currentSensitive: false,
sensitive: true,
assessment: "sensitive",
evidence: [{ kind: "content", ruleId: "pii.email" }],
observedValues: 1,
},
{
columnId: nameColumn.id,
@@ -2232,8 +2243,11 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
tableName: patientsTable.name,
columnName: nameColumn.name,
version: nameColumn.version,
currentSensitive: false,
sensitive: true,
currentSensitive: true,
sensitive: false,
assessment: "non_sensitive",
evidence: [],
observedValues: 2,
},
],
});
@@ -2263,8 +2277,8 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
const idSensitive = await screen.findByRole("checkbox", { name: "Sensitive data for id" });
const nameSensitive = await screen.findByRole("checkbox", { name: "Sensitive data for name" });
expect(idSensitive).not.toBeChecked();
expect(nameSensitive).not.toBeChecked();
expect(screen.queryByRole("button", { name: "Suggest sensitive fields" })).not.toBeInTheDocument();
expect(nameSensitive).toBeChecked();
expect(screen.queryByRole("button", { name: "Analyze sensitive fields" })).not.toBeInTheDocument();
const selectableRow = async (name: RegExp) => {
const rows = await screen.findAllByRole("row", { name });
@@ -2274,31 +2288,30 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
.getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(await selectableRow(/Patient name/))
.getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
await waitFor(() => expect(suggestionBody).toEqual({
modelId: "local-qwen",
scope: "selected_columns",
targetIds: [idColumn.id, nameColumn.id],
}));
const review = await screen.findByRole("dialog", { name: "Sensitive field review" });
expect(within(review).getByRole("checkbox", { name: "Protect patients.id" })).toBeChecked();
expect(within(review).getByRole("checkbox", { name: "Protect patients.name" })).toBeChecked();
expect(within(review).getByRole("checkbox", { name: "Protect patients.name" })).not.toBeChecked();
expect(patches).toHaveLength(0);
await user.click(within(review).getByRole("checkbox", { name: "Protect patients.name" }));
await user.click(within(review).getByRole("checkbox", { name: "Protect patients.id" }));
await user.click(within(review).getByRole("button", { name: "Save 1" }));
await waitFor(() => expect(patches).toEqual([{
columnId: idColumn.id,
columnId: nameColumn.id,
body: {
version: idColumn.version,
sensitive: true,
version: nameColumn.version,
sensitive: false,
},
}]));
expect(await screen.findByText("Saved 1 sensitive flag")).toBeVisible();
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sensitive field review" })).not.toBeInTheDocument());
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).toBeChecked());
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).not.toBeChecked());
expect(screen.getByRole("checkbox", { name: "Sensitive data for name" })).not.toBeChecked();
});