feat(db): federation schema — grants/peers/audit_log [FED-M2-01] #486

Merged
jason.woltje merged 2 commits from feat/federation-m2-schema into main 2026-04-22 02:02:22 +00:00
5 changed files with 107 additions and 17 deletions
Showing only changes of commit 0e0ad9defe - Show all commits

View File

@@ -23,7 +23,10 @@ CREATE TABLE "federation_audit_log" (
"result_count" integer, "result_count" integer,
"denied_reason" text, "denied_reason" text,
"latency_ms" integer, "latency_ms" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL "created_at" timestamp with time zone DEFAULT now() NOT NULL,
"query_hash" text,
"outcome" text,
"bytes_out" integer
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE "federation_grants" ( CREATE TABLE "federation_grants" (
@@ -63,9 +66,9 @@ ALTER TABLE "federation_grants" ADD CONSTRAINT "federation_grants_subject_user_i
ALTER TABLE "federation_grants" ADD CONSTRAINT "federation_grants_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "federation_grants" ADD CONSTRAINT "federation_grants_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "admin_tokens_user_id_idx" ON "admin_tokens" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "admin_tokens_user_id_idx" ON "admin_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "admin_tokens_hash_idx" ON "admin_tokens" USING btree ("token_hash");--> statement-breakpoint CREATE UNIQUE INDEX "admin_tokens_hash_idx" ON "admin_tokens" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "federation_audit_log_peer_created_at_idx" ON "federation_audit_log" USING btree ("peer_id","created_at");--> statement-breakpoint CREATE INDEX "federation_audit_log_peer_created_at_idx" ON "federation_audit_log" USING btree ("peer_id","created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "federation_audit_log_subject_created_at_idx" ON "federation_audit_log" USING btree ("subject_user_id","created_at");--> statement-breakpoint CREATE INDEX "federation_audit_log_subject_created_at_idx" ON "federation_audit_log" USING btree ("subject_user_id","created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "federation_audit_log_created_at_idx" ON "federation_audit_log" USING btree ("created_at");--> statement-breakpoint CREATE INDEX "federation_audit_log_created_at_idx" ON "federation_audit_log" USING btree ("created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "federation_grants_subject_status_idx" ON "federation_grants" USING btree ("subject_user_id","status");--> statement-breakpoint CREATE INDEX "federation_grants_subject_status_idx" ON "federation_grants" USING btree ("subject_user_id","status");--> statement-breakpoint
CREATE INDEX "federation_grants_peer_status_idx" ON "federation_grants" USING btree ("peer_id","status");--> statement-breakpoint CREATE INDEX "federation_grants_peer_status_idx" ON "federation_grants" USING btree ("peer_id","status");--> statement-breakpoint
CREATE INDEX "federation_peers_cert_serial_idx" ON "federation_peers" USING btree ("cert_serial");--> statement-breakpoint CREATE INDEX "federation_peers_cert_serial_idx" ON "federation_peers" USING btree ("cert_serial");--> statement-breakpoint

View File

@@ -1,5 +1,5 @@
{ {
"id": "e5d39db4-c672-4085-9f17-0f936c3e1143", "id": "1ecd9663-a2eb-4819-a5a5-818a0e84fd95",
"prevId": "3431aafd-8ea0-499d-989c-d01e995f4764", "prevId": "3431aafd-8ea0-499d-989c-d01e995f4764",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -967,6 +967,24 @@
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"default": "now()" "default": "now()"
},
"query_hash": {
"name": "query_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"outcome": {
"name": "outcome",
"type": "text",
"primaryKey": false,
"notNull": false
},
"bytes_out": {
"name": "bytes_out",
"type": "integer",
"primaryKey": false,
"notNull": false
} }
}, },
"indexes": { "indexes": {
@@ -982,7 +1000,7 @@
{ {
"expression": "created_at", "expression": "created_at",
"isExpression": false, "isExpression": false,
"asc": true, "asc": false,
"nulls": "last" "nulls": "last"
} }
], ],
@@ -1003,7 +1021,7 @@
{ {
"expression": "created_at", "expression": "created_at",
"isExpression": false, "isExpression": false,
"asc": true, "asc": false,
"nulls": "last" "nulls": "last"
} }
], ],
@@ -1018,7 +1036,7 @@
{ {
"expression": "created_at", "expression": "created_at",
"isExpression": false, "isExpression": false,
"asc": true, "asc": false,
"nulls": "last" "nulls": "last"
} }
], ],

View File

@@ -61,8 +61,8 @@
{ {
"idx": 8, "idx": 8,
"version": "7", "version": "7",
"when": 1776821378331, "when": 1776822435828,
"tag": "0008_careless_lake", "tag": "0008_smart_lyja",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@@ -114,20 +114,23 @@ beforeAll(async () => {
result_count integer, result_count integer,
denied_reason text, denied_reason text,
latency_ms integer, latency_ms integer,
created_at timestamp with time zone NOT NULL DEFAULT now() created_at timestamp with time zone NOT NULL DEFAULT now(),
query_hash text,
outcome text,
bytes_out integer
) )
`; `;
await sql` await sql`
CREATE INDEX IF NOT EXISTS federation_audit_log_peer_created_at_idx CREATE INDEX IF NOT EXISTS federation_audit_log_peer_created_at_idx
ON federation_audit_log (peer_id, created_at) ON federation_audit_log (peer_id, created_at DESC NULLS LAST)
`; `;
await sql` await sql`
CREATE INDEX IF NOT EXISTS federation_audit_log_subject_created_at_idx CREATE INDEX IF NOT EXISTS federation_audit_log_subject_created_at_idx
ON federation_audit_log (subject_user_id, created_at) ON federation_audit_log (subject_user_id, created_at DESC NULLS LAST)
`; `;
await sql` await sql`
CREATE INDEX IF NOT EXISTS federation_audit_log_created_at_idx CREATE INDEX IF NOT EXISTS federation_audit_log_created_at_idx
ON federation_audit_log (created_at) ON federation_audit_log (created_at DESC NULLS LAST)
`; `;
}); });
@@ -360,4 +363,62 @@ describe.skipIf(!run)('federation schema — integration', () => {
`, `,
).rejects.toThrow(); ).rejects.toThrow();
}, 10_000); }, 10_000);
// ── 7. FK cascade: peer delete cascades to federation_grants ─────────────
it('cascade-deletes federation_grants when the owning peer is deleted', async () => {
const PEER3_ID = `f2000003-0000-4000-8000-000000000003`;
const cascadeGrantUserId = `${T}-cascade-grant-user`;
// Insert a dedicated user and peer for this test.
await sql!`
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
VALUES (${cascadeGrantUserId}, ${'Cascade Grant User'}, ${cascadeGrantUserId + '@example.com'}, false, now(), now())
ON CONFLICT (id) DO NOTHING
`;
await sql!`
INSERT INTO federation_peers
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
VALUES (
${PEER3_ID},
${T + '-gateway-cascade-peer'},
${'Cascade Peer'},
${'cert-pem-cascade'},
${T + '-serial-003'},
now() + interval '1 year',
${'active'},
now()
)
ON CONFLICT (id) DO NOTHING
`;
const scopeJson = JSON.stringify({ resources: ['tasks'] });
await sql!`
INSERT INTO federation_grants
(subject_user_id, peer_id, scope, status, created_at)
VALUES (
${cascadeGrantUserId},
${PEER3_ID},
${scopeJson}::jsonb,
${'active'},
now()
)
`;
const before = await sql!`
SELECT count(*)::int AS cnt FROM federation_grants WHERE peer_id = ${PEER3_ID}
`;
expect(before[0]!['cnt']).toBe(1);
// Delete peer → grants should cascade-delete.
await sql!`DELETE FROM federation_peers WHERE id = ${PEER3_ID}`;
const after = await sql!`
SELECT count(*)::int AS cnt FROM federation_grants WHERE peer_id = ${PEER3_ID}
`;
expect(after[0]!['cnt']).toBe(0);
// Cleanup
await sql!`DELETE FROM users WHERE id = ${cascadeGrantUserId}`.catch(() => {});
}, 15_000);
}); });

View File

@@ -759,13 +759,21 @@ export const federationAuditLog = pgTable(
latencyMs: integer('latency_ms'), latencyMs: integer('latency_ms'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
// Reserved for M4 — see PRD 7.3
/** SHA-256 of the normalised GraphQL/REST query string; written by M4 search. */
queryHash: text('query_hash'),
/** Request outcome: "allowed" | "denied" | "partial"; written by M4. */
outcome: text('outcome'),
/** Response payload size in bytes; written by M4. */
bytesOut: integer('bytes_out'),
}, },
(t) => [ (t) => [
// Per-peer request history in reverse chronological order. // Per-peer request history in reverse chronological order.
index('federation_audit_log_peer_created_at_idx').on(t.peerId, t.createdAt), index('federation_audit_log_peer_created_at_idx').on(t.peerId, t.createdAt.desc()),
// Per-user access log in reverse chronological order. // Per-user access log in reverse chronological order.
index('federation_audit_log_subject_created_at_idx').on(t.subjectUserId, t.createdAt), index('federation_audit_log_subject_created_at_idx').on(t.subjectUserId, t.createdAt.desc()),
// Global time-range scans (dashboards, rate-limit windows). // Global time-range scans (dashboards, rate-limit windows).
index('federation_audit_log_created_at_idx').on(t.createdAt), index('federation_audit_log_created_at_idx').on(t.createdAt.desc()),
], ],
); );