diff --git a/src/app/api/projects/[projectId]/approvals/[approvalId]/route.ts b/src/app/api/projects/[projectId]/approvals/[approvalId]/route.ts index 14808d1e0..3c77f3abe 100644 --- a/src/app/api/projects/[projectId]/approvals/[approvalId]/route.ts +++ b/src/app/api/projects/[projectId]/approvals/[approvalId]/route.ts @@ -25,12 +25,11 @@ export async function PATCH(request: Request, context: Params) { return handleApiError(error); } } - export async function DELETE(request: Request, context: Params) { try { assertSameOriginMutatingRequest(request); const { projectId, approvalId } = await readParams(context); - const approval = await deleteApproval(projectId, approvalId); + const approval = await deleteApproval(projectId, approvalId, await readJson(request)); return approval ? ok({ deleted: true }) : notFound("Approval not found."); } catch (error) { return handleApiError(error); diff --git a/src/app/api/projects/[projectId]/approvals/route.ts b/src/app/api/projects/[projectId]/approvals/route.ts index 3ab40d3fb..0dc27cdbd 100644 --- a/src/app/api/projects/[projectId]/approvals/route.ts +++ b/src/app/api/projects/[projectId]/approvals/route.ts @@ -22,15 +22,33 @@ export async function GET(request: Request, context: Params) { try { const { projectId } = await readParams(context); const params = new URL(request.url).searchParams; + const approvals = await listApprovals(projectId, { + limit: readLimit(params.get("limit"), DEFAULT_APPROVAL_LIST_LIMIT, MAX_APPROVAL_LIST_LIMIT), + }); + const { active, history } = partitionApprovalHistory(approvals); return ok({ - approvals: await listApprovals(projectId, { - limit: readLimit(params.get("limit"), DEFAULT_APPROVAL_LIST_LIMIT, MAX_APPROVAL_LIST_LIMIT), - }), + approvals, + activeApprovals: active, + approvalHistory: history, }); } catch (error) { return handleApiError(error); } } +function partitionApprovalHistory(approvals: Awaited>) { + const now = Date.now(); + const active = approvals.filter((approval) => { + if (approval.status === "pending") return true; + if (approval.status !== "approved" || approval.metadata?.consumedAt) return false; + const expiresAt = approval.metadata?.expiresAt; + return typeof expiresAt !== "string" || Date.parse(expiresAt) > now; + }); + const activeIds = new Set(active.map((approval) => approval.id)); + return { + active, + history: approvals.filter((approval) => !activeIds.has(approval.id)), + }; +} function readLimit(value: string | null, fallback: number, max: number) { if (!value) { diff --git a/src/app/api/projects/[projectId]/authorizations/[authorizationId]/route.ts b/src/app/api/projects/[projectId]/authorizations/[authorizationId]/route.ts index f3b536d57..46e71f50b 100644 --- a/src/app/api/projects/[projectId]/authorizations/[authorizationId]/route.ts +++ b/src/app/api/projects/[projectId]/authorizations/[authorizationId]/route.ts @@ -52,18 +52,28 @@ export async function PATCH(request: Request, context: Params) { if (action !== "revoke") { return notFound(`Unsupported authorization action: ${action}.`); } - const authorization = await revokeProjectAuthorization(projectId, authorizationId); + const toolRunId = readOptionalString(body, "toolRunId"); + const authorization = await revokeProjectAuthorization(projectId, authorizationId, { + actor: readRequiredString(body, "actor"), + reason: readRequiredString(body, "reason"), + ...(toolRunId ? { toolRunId } : {}), + }); return authorization ? ok({ authorization }) : notFound("Authorization not found."); } catch (error) { return handleApiError(error); } } - function readOptionalString(body: Record, key: string) { const value = body[key]; return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function readRequiredString(body: Record, key: string) { + const value = readOptionalString(body, key); + if (!value) throw new Error(`Authorization revocation ${key} is required.`); + return value; +} + function readOptionalRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -96,7 +106,13 @@ export async function DELETE(request: Request, context: Params) { try { assertSameOriginMutatingRequest(request); const { projectId, authorizationId } = await readParams(context); - const authorization = await deleteProjectAuthorization(projectId, authorizationId); + const body = (await readJson(request)) as Record; + if (body.confirmDraftDeletion !== true) { + throw new Error("Draft deletion confirmation must be true."); + } + const authorization = await deleteProjectAuthorization(projectId, authorizationId, { + confirmDraftDeletion: true, + }); return authorization ? ok({ deleted: true }) : notFound("Authorization not found."); } catch (error) { return handleApiError(error); diff --git a/src/app/api/projects/[projectId]/authorizations/route.ts b/src/app/api/projects/[projectId]/authorizations/route.ts index 16cdbb8f1..d4db2a9b0 100644 --- a/src/app/api/projects/[projectId]/authorizations/route.ts +++ b/src/app/api/projects/[projectId]/authorizations/route.ts @@ -24,19 +24,40 @@ export async function GET(request: Request, context: Params) { try { const { projectId } = await readParams(context); const params = new URL(request.url).searchParams; + const authorizations = await listProjectAuthorizations(projectId, { + limit: readLimit( + params.get("limit"), + DEFAULT_AUTHORIZATION_LIST_LIMIT, + MAX_AUTHORIZATION_LIST_LIMIT, + ), + }); + const { active, history } = partitionAuthorizationHistory(authorizations); return ok({ - authorizations: await listProjectAuthorizations(projectId, { - limit: readLimit( - params.get("limit"), - DEFAULT_AUTHORIZATION_LIST_LIMIT, - MAX_AUTHORIZATION_LIST_LIMIT, - ), - }), + authorizations, + activeAuthorizations: active, + authorizationHistory: history, }); } catch (error) { return handleApiError(error); } } +function partitionAuthorizationHistory( + authorizations: Awaited>, +) { + const now = Date.now(); + const active = authorizations.filter((authorization) => { + if (authorization.status === "draft" || authorization.status === "requested") return true; + if (authorization.status !== "approved" || authorization.revokedAt || authorization.consumedAt) { + return false; + } + return !authorization.expiresAt || Date.parse(authorization.expiresAt) > now; + }); + const activeIds = new Set(active.map((authorization) => authorization.id)); + return { + active, + history: authorizations.filter((authorization) => !activeIds.has(authorization.id)), + }; +} function readLimit(value: string | null, fallback: number, max: number) { if (!value) { diff --git a/src/server/chat/projectAdapter.ts b/src/server/chat/projectAdapter.ts index d8f48572e..bcb5eeaee 100644 --- a/src/server/chat/projectAdapter.ts +++ b/src/server/chat/projectAdapter.ts @@ -20,6 +20,7 @@ import { buildModelConfigUri } from "./model-config-uri"; import type { Approval, ApprovalCreateInput, + ApprovalDraftDeletionInput, ApprovalListOptions, ApprovalUpdateInput, ArtifactCreateInput, @@ -64,7 +65,6 @@ type ProjectRow = { created_at: string; updated_at: string; }; - type ApprovalRow = { id: string; project_id: string; @@ -1171,6 +1171,45 @@ class DbChatStore implements ChatStore { async updateApproval(projectId: string, approvalId: string, input: ApprovalUpdateInput) { const approval = await withProjectDb(async (db) => { const status = input.status ? normalizeApprovalStatus(input.status) : null; + const existing = await db.query<{ + status: ApprovalRow["status"]; + consumed_at: string | null; + }>( + `SELECT status, consumed_at FROM approvals WHERE project_id = $1 AND id = $2`, + [projectId, approvalId], + ); + const current = existing.rows[0]; + if (!current) return null; + const currentStatus = normalizeApprovalStatus(current.status); + if ( + currentStatus !== "pending" && + !(currentStatus === "approved" && status === "cancelled") + ) { + throw new Error( + "A decided approval must be immutable; an approved decision may only be cancelled.", + ); + } + if (status === "cancelled" && !input.cancellation) { + throw new Error("Approval cancellation actor and reason are required."); + } + if (input.cancellation?.toolRunId) { + const linked = await db.query<{ id: string }>( + `SELECT id FROM tool_runs WHERE project_id = $1 AND id = $2`, + [projectId, input.cancellation.toolRunId], + ); + if (!linked.rows[0]) { + throw new Error("Approval cancellation Tool Run must belong to the same project."); + } + } + const cancellation = input.cancellation + ? { + actor: input.cancellation.actor, + reason: input.cancellation.reason, + cancelledAt: new Date().toISOString(), + previousStatus: currentStatus, + ...(input.cancellation.toolRunId ? { toolRunId: input.cancellation.toolRunId } : {}), + } + : undefined; const requestPatch = { ...(input.title ? { title: input.title } : {}), ...(input.description ? { description: input.description } : {}), @@ -1185,6 +1224,10 @@ class DbChatStore implements ChatStore { decided_at = CASE WHEN $1 IS NULL THEN decided_at ELSE now() END, updated_at = now() WHERE project_id = $6 AND id = $7 + AND ( + (status = 'pending' AND consumed_at IS NULL) + OR (status = 'approved' AND consumed_at IS NULL AND $1 = 'cancelled') + ) RETURNING id, project_id, thread_id, kind, source, reason, status, request, evidence, decision_scope, target_binding, single_use, consumed_at, expires_at, metadata, created_at, updated_at`, @@ -1192,13 +1235,19 @@ class DbChatStore implements ChatStore { status, input.title ?? null, JSON.stringify(requestPatch), - JSON.stringify(input.metadata ?? {}), + JSON.stringify({ + ...(input.metadata ?? {}), + ...(cancellation ? { cancellation } : {}), + }), JSON.stringify(status ? { status } : {}), projectId, approvalId, ], ); - return result.rows[0] ? mapApproval(result.rows[0]) : null; + if (result.rows[0]) return mapApproval(result.rows[0]); + throw new Error( + "The approval changed while this decision was being recorded; terminal decisions are immutable.", + ); }); if (approval) { const threadId = @@ -1211,11 +1260,45 @@ class DbChatStore implements ChatStore { return approval; } - async deleteApproval(projectId: string, approvalId: string) { + async deleteApproval( + projectId: string, + approvalId: string, + _input: ApprovalDraftDeletionInput, + ) { const approval = await withProjectDb(async (db) => { + const existing = await db.query< + ApprovalRow & { + tool_run_id?: string | null; + decided_at?: string | null; + } + >( + `SELECT id, project_id, thread_id, kind, source, reason, status, request, evidence, + decision_scope, target_binding, single_use, consumed_at, expires_at, metadata, + tool_run_id, decided_at, created_at, updated_at + FROM approvals WHERE project_id = $1 AND id = $2`, + [projectId, approvalId], + ); + const draft = existing.rows[0]; + if (!draft) return null; + if ( + draft.status !== "pending" || + (draft.kind ?? "manual") !== "manual" || + draft.decided_at || + draft.consumed_at || + draft.tool_run_id + ) { + throw new Error( + "Approval must be an undecided, unlinked manual draft before it can be deleted.", + ); + } const result = await db.query( `DELETE FROM approvals WHERE project_id = $1 AND id = $2 + AND status = 'pending' + AND kind = 'manual' + AND decided_at IS NULL + AND consumed_at IS NULL + AND tool_run_id IS NULL RETURNING id, project_id, reason, status, request, metadata, created_at, updated_at`, [projectId, approvalId], ); diff --git a/src/server/chat/service.ts b/src/server/chat/service.ts index faffdc594..1e8da053c 100644 --- a/src/server/chat/service.ts +++ b/src/server/chat/service.ts @@ -24,6 +24,7 @@ import { import { getProjectStore } from "./projectAdapter"; import type { ApprovalCreateInput, + ApprovalDraftDeletionInput, ApprovalListOptions, ApprovalStatus, ApprovalUpdateInput, @@ -50,7 +51,6 @@ const requiredString = (body: Record, key: string) => { const value = body[key]; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; }; - const optionalString = (body: Record, key: string) => { const value = body[key]; return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; @@ -612,6 +612,19 @@ export const parseApprovalUpdate = (body: unknown): ApprovalUpdateInput => { if (Object.hasOwn(body, "metadata")) { update.metadata = optionalMetadata(body); } + if (update.status === "cancelled") { + const actor = requiredString(body, "actor"); + const reason = requiredString(body, "reason"); + if (!actor || !reason) { + throw new Error("Approval cancellation actor and reason are required."); + } + const toolRunId = optionalString(body, "toolRunId"); + update.cancellation = { + actor, + reason, + ...(toolRunId ? { toolRunId } : {}), + }; + } if ( !update.status && !update.title && @@ -624,6 +637,13 @@ export const parseApprovalUpdate = (body: unknown): ApprovalUpdateInput => { return update; }; +export const parseApprovalDraftDeletion = (body: unknown): ApprovalDraftDeletionInput => { + if (!isRecord(body) || body.confirmDraftDeletion !== true) { + throw new Error("Draft deletion confirmation must be true."); + } + return { confirmDraftDeletion: true }; +}; + export const parseModelConfigUpdate = (body: unknown): Partial => { if (!isRecord(body)) { throw new Error("Request body must be an object."); @@ -859,9 +879,9 @@ export const updateApproval = async (projectId: string, approvalId: string, body return store.updateApproval(projectId, approvalId, parseApprovalUpdate(body)); }; -export const deleteApproval = async (projectId: string, approvalId: string) => { +export const deleteApproval = async (projectId: string, approvalId: string, body: unknown) => { const store = await getProjectStore(); - return store.deleteApproval(projectId, approvalId); + return store.deleteApproval(projectId, approvalId, parseApprovalDraftDeletion(body)); }; export const listArtifacts = async (projectId: string, options: ArtifactListOptions = {}) => { diff --git a/src/server/chat/types.ts b/src/server/chat/types.ts index 32eec89da..7eb92e0d8 100644 --- a/src/server/chat/types.ts +++ b/src/server/chat/types.ts @@ -41,7 +41,6 @@ export type McpConnection = { status?: "unknown" | "ready" | "warning" | "error"; statusMessage?: string; }; - export type ProjectSettings = { egressProfile?: EgressProfile; agencyLevel?: AgencyLevel; @@ -269,6 +268,15 @@ export type ApprovalUpdateInput = { description?: string; status?: ApprovalStatus; metadata?: Record; + cancellation?: { + actor: string; + reason: string; + toolRunId?: string; + }; +}; + +export type ApprovalDraftDeletionInput = { + confirmDraftDeletion: true; }; export type ArtifactCreateInput = { @@ -479,7 +487,11 @@ export type ChatStore = SecurityResearchTurnStore & { approvalId: string, input: ApprovalUpdateInput, ): Promise; - deleteApproval(projectId: string, approvalId: string): Promise; + deleteApproval( + projectId: string, + approvalId: string, + input: ApprovalDraftDeletionInput, + ): Promise; listArtifacts(projectId: string, options?: ArtifactListOptions): Promise; createArtifact(projectId: string, input: ArtifactCreateInput): Promise; createToolRun?(projectId: string, input: ToolRunCreateInput): Promise; diff --git a/src/server/targets/index.ts b/src/server/targets/index.ts index f3949a52b..3352e9086 100644 --- a/src/server/targets/index.ts +++ b/src/server/targets/index.ts @@ -92,6 +92,16 @@ export type UpdateAuthorizationInput = { metadata?: Record; }; +export type RevokeAuthorizationInput = { + actor: string; + reason: string; + toolRunId?: string; +}; + +export type DeleteAuthorizationDraftInput = { + confirmDraftDeletion: true; +}; + export type AuthorizationListOptions = { limit?: number; }; @@ -186,7 +196,6 @@ export async function upsertProjectTarget( ): Promise { return withDatabase((db) => upsertProjectTargetRow(db, projectId, input)); } - export async function listProjectTargets(projectId: string): Promise { return withDatabase((db) => listProjectTargetRows(db, projectId)); } @@ -336,16 +345,47 @@ export async function listProjectAuthorizations( export async function revokeProjectAuthorization( projectId: string, authorizationId: string, + input: RevokeAuthorizationInput, ): Promise { return withDatabase(async (db) => { + const existing = await db.query( + `SELECT id, project_id, target_id, status, granted_by, granted_at, expires_at, scope, + network_profile, single_use, consumed_at, revoked_at, evidence_artifact_id, + constraints, metadata, created_at, updated_at + FROM authorizations WHERE project_id = $1 AND id = $2`, + [projectId, authorizationId], + ); + const current = existing.rows[0]; + if (!current) return null; + if (current.status !== "approved") { + throw new Error("Authorization must be approved before it can be revoked."); + } + if (input.toolRunId) { + const linked = await db.query<{ id: string }>( + `SELECT id FROM tool_runs WHERE project_id = $1 AND id = $2`, + [projectId, input.toolRunId], + ); + if (!linked.rows[0]) { + throw new Error("Revocation Tool Run must belong to the same project."); + } + } + const revokedAt = new Date().toISOString(); + const revocation = { + actor: input.actor, + reason: input.reason, + revokedAt, + previousStatus: current.status, + ...(input.toolRunId ? { toolRunId: input.toolRunId } : {}), + }; const result = await db.query( `UPDATE authorizations SET status = 'revoked', - revoked_at = now(), - updated_at = now() - WHERE project_id = $1 AND id = $2 + revoked_at = $3, + metadata = ${mergeJsonObject(db, "metadata", "$4::jsonb")}, + updated_at = $3 + WHERE project_id = $1 AND id = $2 AND status = 'approved' RETURNING id, project_id, target_id, status, granted_by, granted_at, expires_at, scope, network_profile, single_use, consumed_at, revoked_at, evidence_artifact_id, constraints, metadata, created_at, updated_at`, - [projectId, authorizationId], + [projectId, authorizationId, revokedAt, JSON.stringify({ revocation })], ); return result.rows[0] ? mapAuthorizationRow(result.rows[0]) : null; }); @@ -357,6 +397,22 @@ export async function updateProjectAuthorization( input: UpdateAuthorizationInput, ): Promise { return withDatabase(async (db) => { + const existing = await db.query<{ status: AuthorizationRecord["status"] }>( + `SELECT status FROM authorizations WHERE project_id = $1 AND id = $2`, + [projectId, authorizationId], + ); + const current = existing.rows[0]; + if (!current) return null; + if (current.status !== "draft" && current.status !== "requested") { + throw new Error( + "A decided authorization must be immutable; revoke an approved grant instead.", + ); + } + if (input.status === "revoked") { + throw new Error( + "Authorization revocation must use the revocation action with actor and reason.", + ); + } const status = input.status ?? null; const grantedAtExpr = status === "approved" ? "now()" : "granted_at"; const result = await db.query( @@ -374,6 +430,7 @@ export async function updateProjectAuthorization( revoked_at = CASE WHEN $2 = 'revoked' THEN now() WHEN $2 = 'approved' THEN NULL ELSE revoked_at END, updated_at = now() WHERE project_id = $14 AND id = $15 + AND status IN ('draft', 'requested') RETURNING id, project_id, target_id, status, granted_by, granted_at, expires_at, scope, network_profile, single_use, consumed_at, revoked_at, evidence_artifact_id, constraints, metadata, created_at, updated_at`, [ input.targetId ?? null, @@ -393,18 +450,40 @@ export async function updateProjectAuthorization( authorizationId, ], ); - return result.rows[0] ? mapAuthorizationRow(result.rows[0]) : null; + if (result.rows[0]) return mapAuthorizationRow(result.rows[0]); + throw new Error( + "The authorization changed while this decision was being recorded; terminal decisions are immutable.", + ); }); } export async function deleteProjectAuthorization( projectId: string, authorizationId: string, + _input: DeleteAuthorizationDraftInput, ): Promise { return withDatabase(async (db) => { + const existing = await db.query( + `SELECT id, project_id, target_id, status, granted_by, granted_at, expires_at, scope, + network_profile, single_use, consumed_at, revoked_at, evidence_artifact_id, + constraints, metadata, created_at, updated_at + FROM authorizations WHERE project_id = $1 AND id = $2`, + [projectId, authorizationId], + ); + const draft = existing.rows[0]; + if (!draft) return null; + if (draft.status !== "draft" || draft.granted_at || draft.consumed_at || draft.revoked_at) { + throw new Error( + "Authorization must be an ungranted, unconsumed draft before it can be deleted.", + ); + } const result = await db.query( `DELETE FROM authorizations WHERE project_id = $1 AND id = $2 + AND status = 'draft' + AND granted_at IS NULL + AND consumed_at IS NULL + AND revoked_at IS NULL RETURNING id, project_id, target_id, status, granted_by, granted_at, expires_at, scope, network_profile, single_use, consumed_at, revoked_at, evidence_artifact_id, constraints, metadata, created_at, updated_at`, [projectId, authorizationId], ); diff --git a/tests/integration/chain-of-custody.test.ts b/tests/integration/chain-of-custody.test.ts index 11e25287d..454b4a1d9 100644 --- a/tests/integration/chain-of-custody.test.ts +++ b/tests/integration/chain-of-custody.test.ts @@ -131,10 +131,38 @@ describe("chain-of-custody bundle", () => { expect(result.bundle.authorizations[0]).toMatchObject({ id: "authorization-1", targetId: "target-1", - status: "approved", + status: "revoked", grantedBy: "user", + grantedAt: "2026-07-08T10:55:00.000Z", + revokedAt: "2026-07-08T11:45:00.000Z", networkProfile: "approved-targets", scope: { locator: "http://127.0.0.1:3000" }, + metadata: { + source: "target-authorization", + revocation: { + actor: "reviewer@example.test", + reason: "The engagement ended.", + previousStatus: "approved", + revokedAt: "2026-07-08T11:45:00.000Z", + toolRunId: "run-1", + }, + }, + }); + expect(result.bundle.approvals[0]).toMatchObject({ + id: "approval-1", + toolRunId: "run-1", + status: "cancelled", + reason: "Run baseline HTTP probe.", + metadata: { + networkProfile: "approved-targets", + cancellation: { + actor: "reviewer@example.test", + reason: "The probe is no longer authorized.", + previousStatus: "approved", + cancelledAt: "2026-07-08T11:40:00.000Z", + toolRunId: "run-1", + }, + }, }); expect(result.bundle.usage).toEqual([ { unit: "tokens", quantity: 123, costUsd: 0.0042 }, @@ -447,27 +475,46 @@ async function seedCustodyRows(pool: DbPoolLike) { "thread-1", "run-1", "Run baseline HTTP probe.", - "approved", + "cancelled", { action: "http-probe" }, "target", true, - { networkProfile: "approved-targets" }, + { + networkProfile: "approved-targets", + cancellation: { + actor: "reviewer@example.test", + reason: "The probe is no longer authorized.", + previousStatus: "approved", + cancelledAt: "2026-07-08T11:40:00.000Z", + toolRunId: "run-1", + }, + }, ], ); await pool.query( - `INSERT INTO authorizations (id, project_id, target_id, status, granted_by, granted_at, scope, network_profile, single_use, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10::jsonb)`, + `INSERT INTO authorizations (id, project_id, target_id, status, granted_by, granted_at, revoked_at, scope, network_profile, single_use, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11::jsonb)`, [ "authorization-1", "project-1", "target-1", - "approved", + "revoked", "user", "2026-07-08T10:55:00.000Z", + "2026-07-08T11:45:00.000Z", { locator: "http://127.0.0.1:3000" }, "approved-targets", false, - { source: "target-authorization" }, + { + source: "target-authorization", + revocation: { + actor: "reviewer@example.test", + reason: "The engagement ended.", + previousStatus: "approved", + revokedAt: "2026-07-08T11:45:00.000Z", + toolRunId: "run-1", + }, + }, ], ); await pool.query( diff --git a/tests/integration/decision-history-api.test.ts b/tests/integration/decision-history-api.test.ts new file mode 100644 index 000000000..3c8705c1f --- /dev/null +++ b/tests/integration/decision-history-api.test.ts @@ -0,0 +1,270 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + DELETE as deleteApproval, + PATCH as patchApproval, +} from "../../src/app/api/projects/[projectId]/approvals/[approvalId]/route"; +import { GET as listApprovals } from "../../src/app/api/projects/[projectId]/approvals/route"; +import { + DELETE as deleteAuthorization, + PATCH as patchAuthorization, +} from "../../src/app/api/projects/[projectId]/authorizations/[authorizationId]/route"; +import { GET as listAuthorizations } from "../../src/app/api/projects/[projectId]/authorizations/route"; +import { getProjectStore } from "../../src/server/chat/projectAdapter"; +import { + createTargetAuthorization, + updateProjectAuthorization, +} from "../../src/server/targets"; + +describe("append-only approval and authorization history API", () => { + let databaseRoot: string; + let previousDatabaseUrl: string | undefined; + + beforeEach(async () => { + previousDatabaseUrl = process.env.EH_APP_DB_URL; + databaseRoot = await mkdtemp( + join(tmpdir(), "exploit-hunter-decision-history-"), + ); + process.env.EH_APP_DB_URL = `sqlite://${join(databaseRoot, "app.sqlite")}`; + }); + + afterEach(async () => { + if (previousDatabaseUrl === undefined) delete process.env.EH_APP_DB_URL; + else process.env.EH_APP_DB_URL = previousDatabaseUrl; + await rm(databaseRoot, { recursive: true, force: true }); + }); + + it("keeps terminal decisions, records revocation provenance, and deletes only confirmed drafts", async () => { + const store = await getProjectStore(); + const project = await store.createProject({ name: "Decision history" }); + const approval = await store.createApproval(project.id, { + title: "Run active probe", + }); + await store.updateApproval(project.id, approval.id, { status: "approved" }); + + const approvalDelete = await deleteApproval( + sameOriginRequest(`/approvals/${approval.id}`, "DELETE", { + confirmDraftDeletion: true, + }), + routeParams(project.id, "approvalId", approval.id), + ); + expect(approvalDelete.status).toBe(400); + + const draftApproval = await store.createApproval(project.id, { + title: "Unsent draft", + }); + const unconfirmedDraftDelete = await deleteApproval( + sameOriginRequest(`/approvals/${draftApproval.id}`, "DELETE", {}), + routeParams(project.id, "approvalId", draftApproval.id), + ); + expect(unconfirmedDraftDelete.status).toBe(400); + const confirmedDraftDelete = await deleteApproval( + sameOriginRequest(`/approvals/${draftApproval.id}`, "DELETE", { + confirmDraftDeletion: true, + }), + routeParams(project.id, "approvalId", draftApproval.id), + ); + expect(confirmedDraftDelete.status).toBe(200); + + const authorization = await createTargetAuthorization(project.id, { + status: "approved", + grantedBy: "operator@example.test", + scope: { locator: "https://example.test" }, + }); + const toolRun = await store.createToolRun?.(project.id, { + toolName: "authorization-review", + input: { authorizationId: authorization.id }, + }); + expect(toolRun).toBeDefined(); + + const revoke = await patchAuthorization( + sameOriginRequest(`/authorizations/${authorization.id}`, "PATCH", { + action: "revoke", + actor: "reviewer@example.test", + reason: "The engagement ended.", + toolRunId: toolRun?.id, + }), + routeParams(project.id, "authorizationId", authorization.id), + ); + expect(revoke.status).toBe(200); + const revoked = (await revoke.json()) as { + authorization: { + status: string; + grantedBy: string; + grantedAt: string; + revokedAt: string; + metadata: Record; + }; + }; + expect(revoked.authorization).toMatchObject({ + status: "revoked", + grantedBy: "operator@example.test", + grantedAt: expect.any(String), + revokedAt: expect.any(String), + metadata: { + revocation: { + actor: "reviewer@example.test", + reason: "The engagement ended.", + previousStatus: "approved", + toolRunId: toolRun?.id, + revokedAt: expect.any(String), + }, + }, + }); + const authorizationRevival = await patchAuthorization( + sameOriginRequest(`/authorizations/${authorization.id}`, "PATCH", { + action: "update", + status: "approved", + }), + routeParams(project.id, "authorizationId", authorization.id), + ); + expect(authorizationRevival.status).toBe(400); + + const authorizationDelete = await deleteAuthorization( + sameOriginRequest(`/authorizations/${authorization.id}`, "DELETE", { + confirmDraftDeletion: true, + }), + routeParams(project.id, "authorizationId", authorization.id), + ); + expect(authorizationDelete.status).toBe(400); + + const draftAuthorization = await createTargetAuthorization(project.id, { + status: "draft", + }); + const unconfirmedAuthorizationDraftDelete = await deleteAuthorization( + sameOriginRequest( + `/authorizations/${draftAuthorization.id}`, + "DELETE", + {}, + ), + routeParams(project.id, "authorizationId", draftAuthorization.id), + ); + expect(unconfirmedAuthorizationDraftDelete.status).toBe(400); + const confirmedAuthorizationDraftDelete = await deleteAuthorization( + sameOriginRequest(`/authorizations/${draftAuthorization.id}`, "DELETE", { + confirmDraftDeletion: true, + }), + routeParams(project.id, "authorizationId", draftAuthorization.id), + ); + expect(confirmedAuthorizationDraftDelete.status).toBe(200); + + const cancelledApproval = await patchApproval( + sameOriginRequest(`/approvals/${approval.id}`, "PATCH", { + status: "cancelled", + actor: "reviewer@example.test", + reason: "The probe is no longer authorized.", + toolRunId: toolRun?.id, + }), + routeParams(project.id, "approvalId", approval.id), + ); + expect(cancelledApproval.status).toBe(200); + expect(await cancelledApproval.json()).toMatchObject({ + approval: { + status: "cancelled", + metadata: { + cancellation: { + actor: "reviewer@example.test", + reason: "The probe is no longer authorized.", + previousStatus: "approved", + toolRunId: toolRun?.id, + }, + }, + }, + }); + const approvalRevival = await patchApproval( + sameOriginRequest(`/approvals/${approval.id}`, "PATCH", { + status: "approved", + }), + routeParams(project.id, "approvalId", approval.id), + ); + expect(approvalRevival.status).toBe(400); + + const approvalHistory = (await ( + await listApprovals( + new Request( + `http://localhost:3210/api/projects/${project.id}/approvals`, + ), + { params: Promise.resolve({ projectId: project.id }) }, + ) + ).json()) as { + activeApprovals: Array<{ id: string }>; + approvalHistory: Array<{ id: string }>; + }; + expect(approvalHistory.activeApprovals).toEqual([]); + expect( + approvalHistory.approvalHistory.map((record) => record.id), + ).toContain(approval.id); + + const authorizationHistory = (await ( + await listAuthorizations( + new Request( + `http://localhost:3210/api/projects/${project.id}/authorizations`, + ), + { params: Promise.resolve({ projectId: project.id }) }, + ) + ).json()) as { + activeAuthorizations: Array<{ id: string }>; + authorizationHistory: Array<{ id: string }>; + }; + expect(authorizationHistory.activeAuthorizations).toEqual([]); + expect( + authorizationHistory.authorizationHistory.map((record) => record.id), + ).toContain(authorization.id); + }); + + it("allows only one concurrent terminal decision to win", async () => { + const store = await getProjectStore(); + const project = await store.createProject({ name: "Concurrent decisions" }); + const approval = await store.createApproval(project.id, { + title: "Choose one terminal decision", + }); + + const approvalResults = await Promise.allSettled([ + store.updateApproval(project.id, approval.id, { status: "approved" }), + store.updateApproval(project.id, approval.id, { status: "denied" }), + ]); + expect(approvalResults.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(approvalResults.filter((result) => result.status === "rejected")).toHaveLength(1); + + const authorization = await createTargetAuthorization(project.id, { + status: "requested", + }); + const authorizationResults = await Promise.allSettled([ + updateProjectAuthorization(project.id, authorization.id, { status: "approved" }), + updateProjectAuthorization(project.id, authorization.id, { status: "denied" }), + ]); + expect( + authorizationResults.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect(authorizationResults.filter((result) => result.status === "rejected")).toHaveLength(1); + }); +}); + +function sameOriginRequest( + path: string, + method: string, + body: Record, +) { + return new Request(`http://localhost:3210/api/projects/project/${path}`, { + method, + headers: { + "content-type": "application/json", + origin: "http://localhost:3210", + }, + body: JSON.stringify(body), + }); +} + +function routeParams( + projectId: string, + key: Key, + id: string, +) { + return { params: Promise.resolve({ projectId, [key]: id }) } as { + params: Promise<{ projectId: string } & Record>; + }; +}