feat(db): federation schema — grants/peers/audit_log [FED-M2-01] (#486)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed

This commit was merged in pull request #486.
This commit is contained in:
2026-04-22 02:02:21 +00:00
parent 4dbd429203
commit 54c278b871
7 changed files with 4095 additions and 1 deletions

View File

@@ -5,6 +5,7 @@
import {
pgTable,
pgEnum,
text,
timestamp,
boolean,
@@ -585,3 +586,194 @@ export const summarizationJobs = pgTable(
},
(t) => [index('summarization_jobs_status_idx').on(t.status)],
);
// ─── Federation ──────────────────────────────────────────────────────────────
// Enums declared before tables that reference them.
// All federation definitions live in this file (avoids CJS/ESM cross-import
// issues when drizzle-kit loads schema files via esbuild-register).
// Application code imports from `federation.ts` which re-exports from here.
/**
* Lifecycle state of a federation peer.
* - pending: registered but not yet approved / TLS handshake not confirmed
* - active: fully operational; mTLS verified
* - suspended: temporarily blocked; cert still valid
* - revoked: cert revoked; no traffic allowed
*/
export const peerStateEnum = pgEnum('peer_state', ['pending', 'active', 'suspended', 'revoked']);
/**
* Lifecycle state of a federation grant.
* - active: grant is in effect
* - revoked: manually revoked before expiry
* - expired: natural expiry (expires_at passed)
*/
export const grantStatusEnum = pgEnum('grant_status', ['active', 'revoked', 'expired']);
/**
* A registered peer gateway identified by its Step-CA certificate CN.
* Represents both inbound peers (other gateways querying us) and outbound
* peers (gateways we query — identified by client_key_pem being set).
*/
export const federationPeers = pgTable(
'federation_peers',
{
id: uuid('id').primaryKey().defaultRandom(),
/** Certificate CN, e.g. "gateway-uscllc-com". Unique — one row per peer identity. */
commonName: text('common_name').notNull().unique(),
/** Human-friendly label shown in admin UI. */
displayName: text('display_name').notNull(),
/** Pinned PEM certificate used for mTLS verification. */
certPem: text('cert_pem').notNull(),
/** Certificate serial number — used for CRL / revocation lookup. */
certSerial: text('cert_serial').notNull().unique(),
/** Certificate expiry — used by the renewal scheduler (FED-M6). */
certNotAfter: timestamp('cert_not_after', { withTimezone: true }).notNull(),
/**
* Sealed (encrypted) private key for outbound connections TO this peer.
* NULL for inbound-only peer rows (we serve them; we don't call them).
*/
clientKeyPem: text('client_key_pem'),
/** Current peer lifecycle state. */
state: peerStateEnum('state').notNull().default('pending'),
/** Base URL for outbound queries, e.g. "https://woltje.com:443". NULL for inbound-only peers. */
endpointUrl: text('endpoint_url'),
/** Timestamp of the most recent successful inbound or outbound request. */
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
/** Populated when the cert is revoked; NULL while the peer is active. */
revokedAt: timestamp('revoked_at', { withTimezone: true }),
},
(t) => [
// CRL / revocation lookups by serial.
index('federation_peers_cert_serial_idx').on(t.certSerial),
// Filter peers by state (e.g. find all active peers for outbound routing).
index('federation_peers_state_idx').on(t.state),
],
);
/**
* A grant lets a specific peer cert query a specific local user's data within
* a defined scope. Scopes are validated by JSON Schema in M2-03; this table
* stores them as raw jsonb.
*/
export const federationGrants = pgTable(
'federation_grants',
{
id: uuid('id').primaryKey().defaultRandom(),
/**
* The local user whose data this grant exposes.
* Cascade delete: if the user account is deleted, revoke all their grants.
*/
subjectUserId: text('subject_user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/**
* The peer gateway holding the grant.
* Cascade delete: if the peer record is removed, the grant is moot.
*/
peerId: uuid('peer_id')
.notNull()
.references(() => federationPeers.id, { onDelete: 'cascade' }),
/**
* Scope object — validated by JSON Schema (M2-03).
* Example: { "resources": ["tasks", "notes"], "operations": ["list", "get"] }
*/
scope: jsonb('scope').notNull(),
/** Current grant lifecycle state. */
status: grantStatusEnum('status').notNull().default('active'),
/** Optional hard expiry. NULL means the grant does not expire automatically. */
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
/** Populated when the grant is explicitly revoked. */
revokedAt: timestamp('revoked_at', { withTimezone: true }),
/** Human-readable reason for revocation (audit trail). */
revokedReason: text('revoked_reason'),
},
(t) => [
// Hot path: look up active grants for a subject user (auth middleware).
index('federation_grants_subject_status_idx').on(t.subjectUserId, t.status),
// Hot path: look up active grants held by a peer (inbound request check).
index('federation_grants_peer_status_idx').on(t.peerId, t.status),
],
);
/**
* Append-only audit log of all federation requests.
* M4 writes rows here. M2 only creates the table.
*
* All FKs use SET NULL so audit rows survive peer/user/grant deletion.
*/
export const federationAuditLog = pgTable(
'federation_audit_log',
{
id: uuid('id').primaryKey().defaultRandom(),
/** UUIDv7 from the X-Request-ID header — correlates with OTEL traces. */
requestId: text('request_id').notNull(),
/** Peer that made the request. SET NULL if the peer is later deleted. */
peerId: uuid('peer_id').references(() => federationPeers.id, { onDelete: 'set null' }),
/** Subject user whose data was queried. SET NULL if the user is deleted. */
subjectUserId: text('subject_user_id').references(() => users.id, { onDelete: 'set null' }),
/** Grant under which the request was authorised. SET NULL if the grant is deleted. */
grantId: uuid('grant_id').references(() => federationGrants.id, { onDelete: 'set null' }),
/** Request verb: "list" | "get" | "search". */
verb: text('verb').notNull(),
/** Resource type: "tasks" | "notes" | "memory" | etc. */
resource: text('resource').notNull(),
/** HTTP status code returned to the peer. */
statusCode: integer('status_code').notNull(),
/** Number of items returned (NULL for non-list requests or errors). */
resultCount: integer('result_count'),
/** Why the request was denied (NULL when allowed). */
deniedReason: text('denied_reason'),
/** End-to-end latency in milliseconds. */
latencyMs: integer('latency_ms'),
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) => [
// Per-peer request history in reverse chronological order.
index('federation_audit_log_peer_created_at_idx').on(t.peerId, t.createdAt.desc()),
// Per-user access log in reverse chronological order.
index('federation_audit_log_subject_created_at_idx').on(t.subjectUserId, t.createdAt.desc()),
// Global time-range scans (dashboards, rate-limit windows).
index('federation_audit_log_created_at_idx').on(t.createdAt.desc()),
],
);