feat: protect sensitive catalog samples

This commit is contained in:
Codex
2026-08-30 12:14:23 +02:00
parent 6278ee9d81
commit 0736983bc5
28 changed files with 1162 additions and 128 deletions
@@ -141,6 +141,83 @@ async function waitForTerminalRun(app: ReturnType<typeof buildApp>, runId: strin
throw new Error(`Description Generation Run ${runId} did not finish`);
}
test("suggests sensitive flags from structural metadata without persisting them", async () => {
const modelCompleter = {
complete: vi.fn(async () => JSON.stringify({
suggestions: [{ columnId: expect.any(String), sensitive: true }],
})),
};
const { app, repository, database, column } = await setup(modelCompleter);
modelCompleter.complete.mockResolvedValueOnce(JSON.stringify({
suggestions: [{ columnId: column.id, sensitive: true }],
}));
try {
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/sensitive-data-suggestions`,
payload: { modelId: configuredModel.id },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
suggestions: [{ columnId: column.id, sensitive: true }],
});
expect(await repository.getColumn(database.id, column.tableId, column.id))
.toMatchObject({ sensitive: false });
const request = modelCompleter.complete.mock.calls[0]![0] as ModelCompletionRequest;
const prompt = request.messages.map((message) => message.content).join("\n");
expect(prompt).toContain("patients");
expect(prompt).toContain("birth_date");
expect(prompt).toContain("date");
expect(prompt).not.toContain("Patient date of birth");
expect(prompt).not.toContain("test-provider-secret");
} finally {
await app.close();
}
});
test.each(["malformed", "incomplete", "duplicate"] as const)(
"fails safely when sensitive-data suggestions are %s",
async (kind) => {
const modelCompleter: ModelCompleter = {
complete: vi.fn(async () => "unused"),
};
const { app, repository, database, column } = await setup(modelCompleter);
const rawResponse = kind === "malformed"
? "RAW_PROVIDER_RESPONSE_DO_NOT_EXPOSE_{"
: kind === "incomplete"
? JSON.stringify({ suggestions: [] })
: JSON.stringify({
suggestions: [
{ columnId: column.id, sensitive: true },
{ columnId: column.id, sensitive: true },
],
});
vi.mocked(modelCompleter.complete).mockResolvedValueOnce(rawResponse);
try {
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/sensitive-data-suggestions`,
payload: { modelId: configuredModel.id },
});
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({
code: "sensitive_data_suggestion_failed",
message: "Sensitive-data suggestions could not be prepared.",
});
expect(response.body).not.toContain(rawResponse);
expect(await repository.getColumn(database.id, column.tableId, column.id))
.toMatchObject({ sensitive: false });
} finally {
await app.close();
}
},
);
interface SseFrame {
id?: string;
event?: string;
@@ -398,6 +475,102 @@ test("keeps real source samples transient across the Fastify API and application
}
});
test("never exposes a protected source value to the model, persistence, logs, or browser APIs", async () => {
let selectedColumnId = "";
const protectedValue = "PROTECTED_SOURCE_VALUE_8f4c2a";
const modelCompleter: ModelCompleter = {
complete: vi.fn(async () => JSON.stringify({
results: [{
targetId: selectedColumnId,
outcome: "generated",
description: "Data di nascita del paziente.",
}],
})),
};
const descriptionSourceSampler: DescriptionSourceSampler = {
sample: vi.fn(async (_database, targets) => [{
targetId: targets[0]!.targetId,
tableName: targets[0]!.tableName,
rows: [{ fields: [{ name: "birth_date", value: protectedValue }] }],
representativeValues: [{ column: "birth_date", values: [protectedValue] }],
}]),
};
const { app, repository, database, table, column } = await setup(
modelCompleter,
{},
"it",
descriptionSourceSampler,
);
selectedColumnId = column.id;
await repository.updateColumnMetadata(
database.id,
table.id,
column.id,
column.version,
column.description,
column.generatedDescription,
true,
);
const logSpies = [
vi.spyOn(app.log, "info"),
vi.spyOn(app.log, "warn"),
vi.spyOn(app.log, "error"),
];
try {
const start = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/description-generation-runs`,
payload: {
modelId: configuredModel.id,
scope: "selected_columns",
targetIds: [column.id],
},
});
expect(start.statusCode).toBe(202);
await waitForTerminalRun(app, start.json().id);
expect(descriptionSourceSampler.sample).toHaveBeenCalledWith(
expect.anything(),
[expect.objectContaining({ targetId: column.id, columnNames: [] })],
expect.any(AbortSignal),
);
const completionRequest = vi.mocked(modelCompleter.complete).mock.calls[0]![0];
const providerPayload = JSON.stringify(completionRequest.messages);
expect(providerPayload).not.toContain(protectedValue);
expect(providerPayload).toContain("1981-01-01");
const apiResponses = await Promise.all([
app.inject({ method: "GET", url: `/catalog/description-generation-runs/${start.json().id}` }),
app.inject({
method: "GET",
url: `/catalog/description-generation-runs/${start.json().id}/events-list`,
}),
app.inject({ method: "GET", url: `/catalog/databases/${database.id}` }),
app.inject({ method: "GET", url: `/catalog/databases/${database.id}/tables` }),
app.inject({
method: "GET",
url: `/catalog/databases/${database.id}/tables/${table.id}/columns`,
}),
]);
expect(apiResponses.every((response) => response.statusCode === 200)).toBe(true);
expect(apiResponses.map((response) => response.body).join("\n")).not.toContain(protectedValue);
const persisted = JSON.stringify({
run: await repository.getDescriptionGenerationRun(start.json().id),
events: await repository.listDescriptionGenerationEvents(start.json().id),
database: await repository.get(database.id),
table: await repository.getTable(database.id, table.id),
column: await repository.getColumn(database.id, table.id, column.id),
});
expect(persisted).not.toContain(protectedValue);
expect(JSON.stringify(logSpies.flatMap((spy) => spy.mock.calls))).not.toContain(protectedValue);
} finally {
for (const spy of logSpies) spy.mockRestore();
await app.close();
}
});
test("wires the production sampler to the same injected CatalogPostgresAccess instance", async () => {
let selectedColumnId = "";
const sampleSecret = "PRODUCTION_WIRING_SAMPLE_f2986a";