FN-9003: reconcile approval audit events with project schema
Align approval audit event declarations and reads with PostgreSQL project ownership. - Model composite project-scoped audit event identities in the schema - Scope public audit history reads and cover project isolation behavior - Document the storage contract and add a patch changeset Files changed: .changeset/fn-9003-approval-audit-project-scope.md | 7 + docs/storage.md | 2 +- ...oval-request-audit-project-isolation.pg.test.ts | 183 +++++++++++++++++++++ .../async-stores/async-approval-request-store.ts | 5 +- packages/core/src/postgres/schema/project.ts | 9 +- 5 files changed, 202 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-9003 Fusion-Task-Lineage: 5a9caac3-7c2f-4584-8844-4ac711cc48bb Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9003-approval-audit-project-scope.md
Normal file
7
.changeset/fn-9003-approval-audit-project-scope.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep approval audit history isolated to the active project.
|
||||
category: fix
|
||||
dev: Reconciles approval audit event ownership declarations and threads getApprovalAuditHistory projectId from ApprovalRequestStore.
|
||||
@@ -510,7 +510,7 @@ The `tasks.cumulativeActiveMs` and `tasks.executionCompletedAt` columns are the
|
||||
| `agents` | Agent registry/state/task assignment metadata. |
|
||||
| `agentHeartbeats` | Heartbeat run events linked to agents (`agentId` FK cascade). |
|
||||
| `approval_requests` | Durable approval request records: requester actor snapshot, target action payload (category/action/resource/context), lifecycle status (`pending`/`approved`/`denied`/`completed`), optional task/run context, and requested/decided/completed timestamps. |
|
||||
| `approval_request_audit_events` | Append-only audit trail for approval requests. Each row stores event type (`created`/`approved`/`denied`/`completed`), immutable actor snapshot, optional note, and deterministic per-request ordering by `(createdAt, rowid)`. |
|
||||
| `approval_request_audit_events` | Append-only audit trail for approval requests. PostgreSQL uses the physical `(project_id, id)` identity, while public `ApprovalRequestStore.getAuditHistory` scopes bound audit-history reads; Command Center analytics remains intentionally unbound-tolerant. The ownership trigger normalizes only NULL/exact `''`, so a whitespace-only binding is stored literally. Rows store event type (`created`/`approved`/`denied`/`completed`), immutable actor snapshot, optional note, and deterministic per-request ordering by `(createdAt, rowid)`. |
|
||||
| `secrets` | Encrypted secret KV rows (`key` unique) with raw BLOB `value_ciphertext` + per-row random `nonce` (AES-256-GCM), per-secret `access_policy` CHECK (`auto`/`prompt`/`deny`), env-materialization metadata (`env_exportable`, `env_export_key`), and read-audit fields (`last_read_at`, `last_read_by`). Plaintext is never written to the database. |
|
||||
| `task_documents` | Task-scoped document metadata/content keyed by `(taskId, key)` with current revision pointer. |
|
||||
| `task_document_revisions` | Immutable revision history for task documents (content snapshots by revision). |
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
createSharedPgTaskStoreTestHarness,
|
||||
pgDescribe,
|
||||
type SharedPgTaskStoreHarness,
|
||||
} from "../../__test-utils__/pg-test-harness.js";
|
||||
import {
|
||||
createApprovalRequest,
|
||||
getApprovalAuditHistory,
|
||||
} from "../../async-stores/async-approval-request-store.js";
|
||||
import { ApprovalRequestStore } from "../../agents/approval-request-store.js";
|
||||
import type { AsyncDataLayer } from "../../postgres/data-layer.js";
|
||||
import * as schema from "../../postgres/schema/index.js";
|
||||
|
||||
pgDescribe("approval request audit project isolation", () => {
|
||||
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
|
||||
prefix: "fusion_approval_audit_isolation",
|
||||
});
|
||||
const bind = (projectId: string): AsyncDataLayer => ({ ...h.layer(), projectId });
|
||||
const input = {
|
||||
requester: { actorId: "agent", actorType: "agent" as const, actorName: "Agent" },
|
||||
targetAction: {
|
||||
category: "other" as const,
|
||||
action: "test",
|
||||
summary: "Test approval audit ownership",
|
||||
resourceType: "task",
|
||||
resourceId: "FN-9003",
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(h.beforeAll);
|
||||
afterAll(h.afterAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
|
||||
it("models the live 0006 ownership shape exactly", async () => {
|
||||
const columns = await h.adminSql()<Array<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
column_default: string | null;
|
||||
}>>`
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'project'
|
||||
AND table_name = 'approval_request_audit_events'
|
||||
AND column_name = 'project_id'
|
||||
`;
|
||||
expect(columns).toEqual([expect.objectContaining({
|
||||
column_name: "project_id",
|
||||
data_type: "text",
|
||||
is_nullable: "NO",
|
||||
column_default: expect.stringContaining("current_setting"),
|
||||
})]);
|
||||
|
||||
const keys = await h.adminSql()<Array<{ conname: string; columns: string[] }>>`
|
||||
SELECT c.conname, array_agg(a.attname ORDER BY k.ordinality) AS columns
|
||||
FROM pg_constraint c
|
||||
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY k(attnum, ordinality)
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
|
||||
WHERE c.conrelid = 'project.approval_request_audit_events'::regclass
|
||||
AND c.contype = 'p'
|
||||
GROUP BY c.conname
|
||||
`;
|
||||
expect(keys).toEqual([{
|
||||
conname: "approval_request_audit_events_pkey",
|
||||
columns: ["project_id", "id"],
|
||||
}]);
|
||||
expect(await h.adminSql()<Array<{ polname: string }>>`
|
||||
SELECT polname FROM pg_policy
|
||||
WHERE polrelid = 'project.approval_request_audit_events'::regclass
|
||||
`).toEqual([{ polname: "fusion_project_isolation" }]);
|
||||
expect(await h.adminSql()<Array<{ tgname: string }>>`
|
||||
SELECT tgname FROM pg_trigger
|
||||
WHERE tgrelid = 'project.approval_request_audit_events'::regclass
|
||||
AND NOT tgisinternal
|
||||
`).toEqual([{ tgname: "fusion_assign_project_id" }]);
|
||||
expect(await h.adminSql()<Array<{ indexname: string }>>`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE schemaname = 'project'
|
||||
AND tablename = 'approval_request_audit_events'
|
||||
AND indexname IN ('idxApprovalRequestAuditRequestCreatedAt', 'idxApprovalRequestAuditProjectCreatedAt')
|
||||
ORDER BY indexname
|
||||
`).toEqual([
|
||||
{ indexname: "idxApprovalRequestAuditProjectCreatedAt" },
|
||||
{ indexname: "idxApprovalRequestAuditRequestCreatedAt" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("isolates colliding audit identities through helper and public store paths", async () => {
|
||||
/*
|
||||
FNXC:ApprovalAuditProjectIsolation 2026-08-12-15:37:
|
||||
Owner connections can bypass RLS. Deterministic event IDs can collide when requests share
|
||||
a creation instant, so make the two generated rows share an ID after creation. This proves
|
||||
both the physical composite key and public-store binding prevent an authorization-trail leak.
|
||||
*/
|
||||
const projectA = bind("approval-audit-a");
|
||||
const projectB = bind("approval-audit-b");
|
||||
const sharedRequestId = "apr-audit-shared";
|
||||
await createApprovalRequest(projectA, { ...input, id: sharedRequestId });
|
||||
await createApprovalRequest(projectB, { ...input, id: sharedRequestId });
|
||||
|
||||
const events = await h.adminDb().select({
|
||||
projectId: schema.project.approvalRequestAuditEvents.projectId,
|
||||
id: schema.project.approvalRequestAuditEvents.id,
|
||||
}).from(schema.project.approvalRequestAuditEvents)
|
||||
.where(eq(schema.project.approvalRequestAuditEvents.requestId, sharedRequestId));
|
||||
const aEvent = events.find((event) => event.projectId === projectA.projectId)!;
|
||||
const bEvent = events.find((event) => event.projectId === projectB.projectId)!;
|
||||
await h.adminDb().update(schema.project.approvalRequestAuditEvents).set({ id: aEvent.id })
|
||||
.where(and(
|
||||
eq(schema.project.approvalRequestAuditEvents.projectId, projectB.projectId!),
|
||||
eq(schema.project.approvalRequestAuditEvents.id, bEvent.id),
|
||||
));
|
||||
const collidingEvents = await h.adminDb().select({
|
||||
projectId: schema.project.approvalRequestAuditEvents.projectId,
|
||||
id: schema.project.approvalRequestAuditEvents.id,
|
||||
}).from(schema.project.approvalRequestAuditEvents)
|
||||
.where(eq(schema.project.approvalRequestAuditEvents.requestId, sharedRequestId));
|
||||
expect(collidingEvents).toEqual([
|
||||
{ projectId: "approval-audit-a", id: aEvent.id },
|
||||
{ projectId: "approval-audit-b", id: aEvent.id },
|
||||
]);
|
||||
|
||||
expect((await getApprovalAuditHistory(projectA.db, sharedRequestId, projectA.projectId)).map((event) => event.id))
|
||||
.toEqual([aEvent.id]);
|
||||
expect((await getApprovalAuditHistory(projectB.db, sharedRequestId, projectB.projectId)).map((event) => event.id))
|
||||
.toEqual([aEvent.id]);
|
||||
expect(await getApprovalAuditHistory(bind("approval-audit-empty").db, sharedRequestId, "approval-audit-empty"))
|
||||
.toEqual([]);
|
||||
|
||||
const storeA = new ApprovalRequestStore(null, { asyncLayer: projectA });
|
||||
const storeB = new ApprovalRequestStore(null, { asyncLayer: projectB });
|
||||
const unboundStore = new ApprovalRequestStore(null, {
|
||||
asyncLayer: { ...h.layer(), projectId: undefined },
|
||||
});
|
||||
expect((await storeA.getAuditHistory(sharedRequestId)).map((event) => event.actor.actorName))
|
||||
.toEqual(["Agent"]);
|
||||
expect((await storeB.getAuditHistory(sharedRequestId)).map((event) => event.actor.actorName))
|
||||
.toEqual(["Agent"]);
|
||||
const unboundEvents = await unboundStore.getAuditHistory(sharedRequestId);
|
||||
expect(unboundEvents).toHaveLength(2);
|
||||
expect(Object.keys(unboundEvents[0]!).sort()).toEqual([
|
||||
"actor", "createdAt", "eventType", "id", "note", "requestId",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves unbound, whitespace, and ordering behavior", async () => {
|
||||
const unbound = { ...h.layer(), projectId: undefined };
|
||||
const empty = { ...h.layer(), projectId: "" };
|
||||
const whitespace = { ...h.layer(), projectId: " " };
|
||||
await createApprovalRequest(unbound, { ...input, id: "apr-unbound" });
|
||||
await createApprovalRequest(empty, { ...input, id: "apr-empty" });
|
||||
await createApprovalRequest(whitespace, { ...input, id: "apr-whitespace" });
|
||||
|
||||
const stored = await h.adminDb().select({
|
||||
requestId: schema.project.approvalRequestAuditEvents.requestId,
|
||||
projectId: schema.project.approvalRequestAuditEvents.projectId,
|
||||
}).from(schema.project.approvalRequestAuditEvents)
|
||||
.where(and(
|
||||
eq(schema.project.approvalRequestAuditEvents.eventType, "created"),
|
||||
eq(schema.project.approvalRequestAuditEvents.actorId, "agent"),
|
||||
));
|
||||
expect(stored.find((row) => row.requestId === "apr-unbound")?.projectId).toBe("__legacy_unscoped__");
|
||||
expect(stored.find((row) => row.requestId === "apr-empty")?.projectId).toBe("__legacy_unscoped__");
|
||||
// The read helper trims, but the trigger's exact-'' NULLIF deliberately stores whitespace.
|
||||
expect(stored.find((row) => row.requestId === "apr-whitespace")?.projectId).toBe(" ");
|
||||
expect(stored.find((row) => row.requestId === "apr-whitespace")?.projectId).not.toBe("__legacy_unscoped__");
|
||||
|
||||
expect(await getApprovalAuditHistory(unbound.db, "apr-unbound", unbound.projectId)).toHaveLength(1);
|
||||
expect(await getApprovalAuditHistory(empty.db, "apr-unbound", empty.projectId)).toHaveLength(1);
|
||||
expect(await getApprovalAuditHistory(whitespace.db, "apr-unbound", whitespace.projectId)).toHaveLength(1);
|
||||
|
||||
await h.adminDb().insert(schema.project.approvalRequestAuditEvents).values([
|
||||
{ projectId: "order-project", id: "b", requestId: "apr-order", eventType: "created", actorId: "agent", actorType: "agent", actorName: "Agent", createdAt: "2026-08-12T15:00:00.000Z" },
|
||||
{ projectId: "order-project", id: "a", requestId: "apr-order", eventType: "approved", actorId: "agent", actorType: "agent", actorName: "Agent", createdAt: "2026-08-12T15:00:00.000Z" },
|
||||
{ projectId: "order-project", id: "c", requestId: "apr-order", eventType: "completed", actorId: "agent", actorType: "agent", actorName: "Agent", createdAt: "2026-08-12T16:00:00.000Z" },
|
||||
]);
|
||||
expect((await getApprovalAuditHistory(h.layer().db, "apr-order", "order-project")).map((event) => event.id))
|
||||
.toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
@@ -120,7 +120,7 @@ function rowToAuditEvent(row: ApprovalRequestAuditEventRow): ApprovalRequestAudi
|
||||
* Append an audit event row inside the given transaction handle.
|
||||
*
|
||||
* FNXC:ApprovalAnalyticsIsolation 2026-07-14-01:04:
|
||||
* Audit events must carry the bound layer's project ID at write time because request IDs alone do not provide a reliable tenant ownership join for Command Center intervention analytics.
|
||||
* Audit events must carry the bound layer's project ID at write time because request IDs alone do not provide a reliable tenant ownership join for Command Center intervention analytics. The live `(project_id, id)` key also permits deterministic audit IDs to collide safely across partitions; preserve the explicit value so the ownership trigger observes blank writes unchanged.
|
||||
*/
|
||||
async function appendAuditEvent(
|
||||
tx: DbTransaction,
|
||||
@@ -358,6 +358,9 @@ export async function markApprovalRequestCompleted(
|
||||
|
||||
/**
|
||||
* Get the audit history for a request, ordered by createdAt ASC.
|
||||
*
|
||||
* FNXC:ApprovalAuditProjectIsolation 2026-08-12-15:37:
|
||||
* Owner and superuser connections can enable `fusion.project_bypass`, so RLS cannot backstop this bare request-id lookup. Scope in SQL from the public ApprovalRequestStore layer binding; projectScopeFor intentionally treats blank and whitespace-only bindings as unbound even though fusion_assign_project_id preserves whitespace writes literally.
|
||||
*/
|
||||
export async function getApprovalAuditHistory(
|
||||
handle: QueryHandle,
|
||||
|
||||
@@ -2433,9 +2433,13 @@ export const approvalRequests = projectSchema.table("approval_requests", {
|
||||
index("idxApprovalRequestsTaskCreatedAt").on(t.taskId, t.createdAt),
|
||||
]);
|
||||
|
||||
/*
|
||||
FNXC:MultiProjectIsolation 2026-08-12-15:37:
|
||||
Migrations 0000 and 0003 created approval audit events with an empty-string default and id-only identity. Migration 0006 rewrote the live table to the trigger/GUC-owned partition default and `(project_id, id)` key; this declaration mirrors that physical ownership shape.
|
||||
*/
|
||||
export const approvalRequestAuditEvents = projectSchema.table("approval_request_audit_events", {
|
||||
projectId: text("project_id").notNull().default(""),
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
id: text("id").notNull(),
|
||||
requestId: text("request_id").notNull(),
|
||||
eventType: text("event_type").notNull(),
|
||||
actorId: text("actor_id").notNull(),
|
||||
@@ -2444,6 +2448,7 @@ export const approvalRequestAuditEvents = projectSchema.table("approval_request_
|
||||
note: text("note"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
index("idxApprovalRequestAuditRequestCreatedAt").on(t.requestId, t.createdAt, t.id),
|
||||
index("idxApprovalRequestAuditProjectCreatedAt").on(t.projectId, t.createdAt),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user