From 152c5070b5773bfbe282cfeb525536f187a03a85 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Sat, 29 Aug 2026 14:03:30 -0400 Subject: [PATCH] =?UTF-8?q?fix(buzz-db):=20add=20NIP-FI=20Carl=20r2=20guar?= =?UTF-8?q?ds=20=E2=80=94=20policy=20monotonicity,=20admission=20result=20?= =?UTF-8?q?cardinality,=20denial=20attempt=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 (policy revision monotonicity): add identity_enrollment_policy_revision_guard_v1() BEFORE INSERT on identity_enrollment_policies. Uses a per-community advisory lock via hashtextextended so concurrent writers serialize the max-revision read, then asserts both policy_revision and effective_at strictly exceed the current community maximum (FI-INV-06 — stable assertion policy). Finding 2 (admission result ↔ kind-11 receipt cardinality): add authorization_admission_result_guard_v1(), bidirectional deferred constraint trigger on both authorization_operation_receipts (kind-11 receipt must have exactly one result) and authorization_admission_results (result must attach to a kind-11 receipt). Mirrors the pattern of the existing authorization_operation_receipt_event_guard_v1. Finding 3 (denial event ↔ attempt binding): add authorization_denial_attempt_guard_v1(), bidirectional deferred constraint trigger on both authorization_events (kind-9 event must have exactly one denial attempt) and authorization_authentication_denial_attempts (attempt must reference an existing kind-9 event). The existing FK binds (audit_event_kind=9) but does not require a kind-9 event to have a matching attempt row; this guard closes that gap. All three fixes applied identically in migrations/0041, migrations/0042, and schema/schema.sql; the parity assertion continues to pass. Tests added (all three mutation-sensitive, two-sided): - identity_enrollment_policy_revision_is_monotonic - authorization_admission_result_requires_kind_11_receipt_bidirectional - authorization_denial_attempt_requires_kind_9_event_bidirectional No new tables; fence exclusion list unchanged; #[ignore] deletion suite need not rerun. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 493 ++++++++++++++++++ .../0041_nip_fi_identity_foundation.sql | 53 ++ .../0042_nip_fi_authorization_foundation.sql | 148 ++++++ schema/schema.sql | 201 +++++++ 4 files changed, 895 insertions(+) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index e05bb0027f..dd75f899ea 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -3227,4 +3227,497 @@ mod tests { .await .expect("rollback absent-policy transaction"); } + + /// NIP-FI policy-revision monotonicity: each new policy revision for a + /// community must strictly exceed the current maximum revision, and its + /// effective_at must strictly exceed the current maximum effective_at + /// (FI-INV-06 — stable assertion policy). + /// + /// Mutation sensitivity is two-sided: + /// - neutering the guard lets a replayed or backfilled revision through + /// (the positive half detects insertion into a guarded table); + /// - leaving the guard intact rejects equal/regressive inserts (negative + /// halves detect that each rejection fires). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn identity_enrollment_policy_revision_is_monotonic() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(41, &pool) + .await + .expect("apply migrations 1-41"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("policy-mono-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // First insertion: no prior rows — should always succeed. + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 1, 1, $2, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA1_u8; 32]) + .execute(&pool) + .await + .expect("first policy insertion (revision 1) must succeed"); + + // Forward advance: revision 2 with effective_at strictly after revision 1. + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 2, 1, $2, '2026-06-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA2_u8; 32]) + .execute(&pool) + .await + .expect("forward advance to revision 2 must succeed"); + + // Negative: replay the same revision (2 <= 2). + let replay_err = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 2, 1, $2, '2027-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA3_u8; 32]) + .execute(&pool) + .await + .expect_err("replayed revision must be rejected"); + assert!( + replay_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for replayed revision, got: {replay_err}" + ); + + // Negative: backfill a lower revision (1 < 2). + let backfill_err = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 1, 2, $2, '2027-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA4_u8; 32]) + .execute(&pool) + .await + .expect_err("backfilled lower revision must be rejected"); + assert!( + backfill_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for backfilled revision, got: {backfill_err}" + ); + + // Negative: higher revision but effective_at not strictly after max. + let stale_time_err = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 3, 1, $2, '2026-06-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA5_u8; 32]) + .execute(&pool) + .await + .expect_err("equal effective_at must be rejected even with higher revision"); + assert!( + stale_time_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for non-advancing effective_at, got: {stale_time_err}" + ); + + // Confirm only revisions 1 and 2 persisted. + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM identity_enrollment_policies WHERE community_id = $1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("count persisted policy revisions"); + assert_eq!(count, 2, "only the two accepted revisions must persist"); + } + + /// NIP-FI admission-result ↔ kind-11 receipt cardinality: a kind-11 + /// (protected-mutation) receipt must commit with exactly one admission + /// result; an admission result must commit against a kind-11 receipt. + /// + /// Mutation sensitivity is two-sided: + /// - the guard is load-bearing when a kind-11 receipt has no result row + /// (negative A) — without the guard this commits silently; + /// - the guard is load-bearing when a result attaches to a non-kind-11 + /// receipt (negative B) — without the guard this commits silently. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorization_admission_result_requires_kind_11_receipt_bidirectional() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("adm-result-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Capacity must exist for authorization_events inserts; admission-result + // tests exercise only authorization_operation_receipts and + // authorization_admission_results — no authorization_events rows are + // needed here, but insert capacity anyway to satisfy any trigger + // that reads the policy row defensively. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + let mut conn = pool.acquire().await.expect("acquire connection"); + + // --- Positive: kind-11 receipt + admission result in one transaction --- + let op1 = uuid::Uuid::new_v4(); + let fp1 = vec![0xB1_u8; 32]; + + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 11, $4, 1, $5)", + ) + .bind(community_id) + .bind(op1) + .bind(&fp1) + .bind(vec![0xB2_u8; 32]) + .bind(vec![0xB3_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert kind-11 receipt"); + + sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, 1, $5)", + ) + .bind(community_id) + .bind(op1) + .bind(&fp1) + .bind(vec![0xB4_u8; 32]) // semantic_fingerprint + .bind(vec![0xB5_u8; 32]) // object_key + .execute(&mut *conn) + .await + .expect("insert admission result"); + + sqlx::query("COMMIT") + .execute(&mut *conn) + .await + .expect("kind-11 receipt + result must commit"); + drop(conn); + + // --- Negative A: kind-11 receipt without result must be rejected --- + let op2 = uuid::Uuid::new_v4(); + let fp2 = vec![0xC1_u8; 32]; + + let mut conn_a = pool.acquire().await.expect("acquire connection A"); + sqlx::query("BEGIN") + .execute(&mut *conn_a) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 11, $4, 1, $5)", + ) + .bind(community_id) + .bind(op2) + .bind(&fp2) + .bind(vec![0xC2_u8; 32]) + .bind(vec![0xC3_u8; 32]) + .execute(&mut *conn_a) + .await + .expect("insert kind-11 receipt for negative A"); + + let no_result_err = sqlx::query("COMMIT") + .execute(&mut *conn_a) + .await + .expect_err("kind-11 receipt without result must be rejected at commit"); + assert!( + no_result_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for kind-11 without result, got: {no_result_err}" + ); + drop(conn_a); + + // --- Negative B: admission result against non-kind-11 receipt --- + // Use operation_kind 12 (invalidation) — no admission result should + // ever attach to it. The guard fires at COMMIT (deferred trigger). + let op3 = uuid::Uuid::new_v4(); + let fp3 = vec![0xD1_u8; 32]; + + let mut conn_b = pool.acquire().await.expect("acquire connection B"); + sqlx::query("BEGIN") + .execute(&mut *conn_b) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 12, $4, 1, $5)", + ) + .bind(community_id) + .bind(op3) + .bind(&fp3) + .bind(vec![0xD2_u8; 32]) + .bind(vec![0xD3_u8; 32]) + .execute(&mut *conn_b) + .await + .expect("insert kind-12 receipt"); + + // The guard is deferred: the INSERT succeeds; the violation surfaces + // at COMMIT when the guard checks that the receipt is kind-11. + sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, 1, $5)", + ) + .bind(community_id) + .bind(op3) + .bind(&fp3) + .bind(vec![0xD4_u8; 32]) + .bind(vec![0xD5_u8; 32]) + .execute(&mut *conn_b) + .await + .expect("result insert must pass — deferred guard fires at commit, not here"); + + let wrong_kind_err = sqlx::query("COMMIT") + .execute(&mut *conn_b) + .await + .expect_err("result against non-kind-11 receipt must be rejected at commit"); + assert!( + wrong_kind_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for result against non-kind-11 receipt, got: {wrong_kind_err}" + ); + } + + /// NIP-FI denial-attempt ↔ kind-9 event cardinality: a kind-9 + /// (pre-authentication denial) audit event must commit with exactly one + /// denial attempt; a denial attempt must commit with a matching kind-9 + /// audit event. + /// + /// Mutation sensitivity is two-sided: + /// - the event-side guard is load-bearing when a kind-9 event has no + /// attempt row (negative A) — without it this commits silently, making + /// replay reconstruction impossible; + /// - the attempt-side guard is load-bearing when an attempt has no + /// matching event at commit (negative B). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorization_denial_attempt_requires_kind_9_event_bidirectional() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("denial-attempt-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Capacity is required by the authorization_events BEFORE INSERT trigger. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + let mut conn = pool.acquire().await.expect("acquire connection"); + + // --- Positive: kind-9 event + denial attempt in one transaction --- + let op1 = uuid::Uuid::new_v4(); + let event1 = uuid::Uuid::new_v4(); + let corr1 = uuid::Uuid::new_v4(); + let attempt1_id = uuid::Uuid::new_v4(); + + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + // Insert denial attempt first (FK is deferred). + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 1, $5, 9)", + ) + .bind(community_id) + .bind(op1) + .bind(corr1) + .bind(vec![0xE1_u8; 32]) // semantic_fingerprint + .bind(event1) + .execute(&mut *conn) + .await + .expect("insert denial attempt before event"); + + // Insert the kind-9 event (actor_kind 4, no request_fingerprint). + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ + '2026-01-01T00:00:00Z', $6, $7)", + ) + .bind(community_id) + .bind(event1) + .bind(op1) + .bind(corr1) + .bind(attempt1_id) + .bind(vec![0xE2_u8; 64]) // canonical_envelope (≤16384 bytes) + .bind(vec![0xE3_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("insert kind-9 event"); + + sqlx::query("COMMIT") + .execute(&mut *conn) + .await + .expect("kind-9 event + denial attempt must commit"); + drop(conn); + + // --- Negative A: kind-9 event alone must be rejected at commit --- + let op2 = uuid::Uuid::new_v4(); + let event2 = uuid::Uuid::new_v4(); + let corr2 = uuid::Uuid::new_v4(); + let attempt2_id = uuid::Uuid::new_v4(); + + let mut conn_a = pool.acquire().await.expect("acquire connection A"); + sqlx::query("BEGIN") + .execute(&mut *conn_a) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ + '2026-01-01T00:00:00Z', $6, $7)", + ) + .bind(community_id) + .bind(event2) + .bind(op2) + .bind(corr2) + .bind(attempt2_id) + .bind(vec![0xF1_u8; 64]) + .bind(vec![0xF2_u8; 32]) + .execute(&mut *conn_a) + .await + .expect("insert kind-9 event without attempt"); + + let no_attempt_err = sqlx::query("COMMIT") + .execute(&mut *conn_a) + .await + .expect_err("kind-9 event without denial attempt must be rejected at commit"); + assert!( + no_attempt_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for kind-9 event without attempt, got: {no_attempt_err}" + ); + drop(conn_a); + + // --- Negative B: denial attempt without matching event at commit --- + // The denial attempt's deferred FK to authorization_events fires at + // commit, as does the guard's NOT FOUND branch. Either catches the + // absent event; the guard adds the kind-9 semantic check on top. + let op3 = uuid::Uuid::new_v4(); + let absent_event = uuid::Uuid::new_v4(); // never inserted + let corr3 = uuid::Uuid::new_v4(); + + let mut conn_b = pool.acquire().await.expect("acquire connection B"); + sqlx::query("BEGIN") + .execute(&mut *conn_b) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 2, 1, 1, 2, $5, 9)", + ) + .bind(community_id) + .bind(op3) + .bind(corr3) + .bind(vec![0xFA_u8; 32]) + .bind(absent_event) + .execute(&mut *conn_b) + .await + .expect("insert denial attempt with absent event"); + + let no_event_err = sqlx::query("COMMIT") + .execute(&mut *conn_b) + .await + .expect_err("denial attempt without matching event must be rejected at commit"); + // Deferred FK (23503) or guard check_violation (23514) — either proves + // the absent event is caught. + assert!( + no_event_err + .as_database_error() + .map(|e| { + let code = e.code(); + let c = code.as_deref().unwrap_or(""); + c == "23503" || c == "23514" + }) + .unwrap_or(false), + "expected FK violation (23503) or check_violation (23514) for absent event, got: {no_event_err}" + ); + } } diff --git a/migrations/0041_nip_fi_identity_foundation.sql b/migrations/0041_nip_fi_identity_foundation.sql index b2311a18f3..586f231297 100644 --- a/migrations/0041_nip_fi_identity_foundation.sql +++ b/migrations/0041_nip_fi_identity_foundation.sql @@ -413,6 +413,56 @@ CREATE INDEX identity_lifecycle_selectors_asserted_history ON identity_lifecycle_selectors (community_id, asserted_history_id, selector_kind); +-- Serializes policy-revision inserts per community: each new revision must +-- strictly exceed the current maximum, and each effective_at must strictly +-- exceed the current maximum effective_at (FI-INV-06 — stable assertion +-- policy; a revision that moves either coordinate backward is incoherent). +-- The per-community advisory lock prevents two concurrent writers from both +-- passing a plain SELECT MAX() check and committing conflicting revisions. +CREATE FUNCTION identity_enrollment_policy_revision_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + lock_key BIGINT; + max_revision BIGINT; + max_effective_at TIMESTAMPTZ; +BEGIN + -- Acquire a per-community exclusive transaction-scoped advisory lock so + -- that concurrent insertions serialize here. The key is a stable hash of + -- the namespace string and the community_id bytes. + lock_key := hashtextextended( + 'buzz:enrollment-policy-revision:v1:' || NEW.community_id::text, + 0 + ); + PERFORM pg_advisory_xact_lock(lock_key); + + SELECT MAX(policy_revision), MAX(effective_at) + INTO max_revision, max_effective_at + FROM identity_enrollment_policies + WHERE community_id = NEW.community_id; + + IF max_revision IS NOT NULL + AND NEW.policy_revision <= max_revision + THEN + RAISE EXCEPTION + 'policy_revision % does not strictly exceed current maximum % for community %', + NEW.policy_revision, max_revision, NEW.community_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; + END IF; + + IF max_effective_at IS NOT NULL + AND NEW.effective_at <= max_effective_at + THEN + RAISE EXCEPTION + 'effective_at % does not strictly exceed current maximum % for community %', + NEW.effective_at, max_effective_at, NEW.community_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + CREATE FUNCTION nip_fi_reject_row_mutation_v1() RETURNS TRIGGER AS $$ BEGIN RAISE EXCEPTION '% is immutable', TG_TABLE_NAME @@ -833,6 +883,9 @@ CREATE TRIGGER authorization_operation_receipts_no_truncate BEFORE TRUNCATE ON authorization_operation_receipts FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +CREATE TRIGGER identity_enrollment_policies_revision_guard + BEFORE INSERT ON identity_enrollment_policies + FOR EACH ROW EXECUTE FUNCTION identity_enrollment_policy_revision_guard_v1(); CREATE TRIGGER identity_enrollment_policies_immutable BEFORE UPDATE OR DELETE ON identity_enrollment_policies FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); diff --git a/migrations/0042_nip_fi_authorization_foundation.sql b/migrations/0042_nip_fi_authorization_foundation.sql index 633d115070..b9403c7ae9 100644 --- a/migrations/0042_nip_fi_authorization_foundation.sql +++ b/migrations/0042_nip_fi_authorization_foundation.sql @@ -506,6 +506,85 @@ CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate BEFORE TRUNCATE ON authorization_authentication_denial_attempts FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +-- Bidirectional deferred guard: a kind-9 (pre-authentication denial) audit +-- event must commit with exactly one denial attempt; a denial attempt must +-- commit with its audit event present and kind-9. Both directions deferred so +-- event and attempt may be inserted in any order inside one transaction. +CREATE FUNCTION authorization_denial_attempt_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + found_event_kind SMALLINT; + attempt_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_events' THEN + -- Firing from the event side: only kind-9 events require a denial row. + IF NEW.event_kind <> 9 THEN + RETURN NULL; + END IF; + + SELECT count(*) INTO attempt_count + FROM authorization_authentication_denial_attempts + WHERE community_id = NEW.community_id + AND audit_event_id = NEW.event_id; + + IF attempt_count <> 1 THEN + RAISE EXCEPTION + 'kind-9 audit event requires exactly one denial attempt, found %', + attempt_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; + END IF; + ELSE + -- Firing from the denial-attempt side: verify the audit event is kind-9 + -- and that exactly one denial attempt references it. + SELECT event_kind INTO found_event_kind + FROM authorization_events + WHERE community_id = NEW.community_id + AND event_id = NEW.audit_event_id; + + IF NOT FOUND THEN + RAISE EXCEPTION + 'denial attempt references non-existent audit event %', + NEW.audit_event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_kind'; + END IF; + + IF found_event_kind <> 9 THEN + RAISE EXCEPTION + 'denial attempt audit event must be kind 9, got %', + found_event_kind + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_kind'; + END IF; + + SELECT count(*) INTO attempt_count + FROM authorization_authentication_denial_attempts + WHERE community_id = NEW.community_id + AND audit_event_id = NEW.audit_event_id; + + IF attempt_count <> 1 THEN + RAISE EXCEPTION + 'exactly one denial attempt must reference audit event %, found %', + NEW.audit_event_id, attempt_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_denial_attempt_event_cardinality + AFTER INSERT ON authorization_events + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_denial_attempt_guard_v1(); + +CREATE CONSTRAINT TRIGGER authorization_denial_event_attempt_cardinality + AFTER INSERT ON authorization_authentication_denial_attempts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_denial_attempt_guard_v1(); + CREATE FUNCTION authorization_operation_version_delta_cardinality_guard_v1() RETURNS TRIGGER AS $$ DECLARE @@ -644,6 +723,75 @@ CREATE TRIGGER authorization_admission_results_no_truncate BEFORE TRUNCATE ON authorization_admission_results FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +-- Bidirectional deferred cardinality guard: a kind-11 (protected-mutation) +-- receipt must commit with exactly one admission result; an admission result +-- must commit against a kind-11 receipt. Deferred so receipt and result may +-- be inserted in any order inside one transaction. +CREATE FUNCTION authorization_admission_result_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + receipt authorization_operation_receipts%ROWTYPE; + result_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN + receipt := NEW; + ELSE + -- Firing from authorization_admission_results: look up the receipt. + SELECT * INTO receipt + FROM authorization_operation_receipts + WHERE community_id = NEW.community_id + AND operation_id = NEW.operation_id; + IF NOT FOUND THEN + -- FK on the result table already guards the non-existent receipt + -- case; this path should not occur in normal operation. + RAISE EXCEPTION + 'admission result references non-existent receipt for operation %', + NEW.operation_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_admission_result_receipt_kind'; + END IF; + END IF; + + -- Non-kind-11 receipts require no admission result. + IF receipt.operation_kind <> 11 THEN + -- If this fired from the result side and the receipt is not kind 11, + -- the result is attaching to the wrong receipt kind. + IF TG_TABLE_NAME = 'authorization_admission_results' THEN + RAISE EXCEPTION + 'admission result may only attach to a kind-11 (protected-mutation) receipt, got kind %', + receipt.operation_kind + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_admission_result_receipt_kind'; + END IF; + RETURN NULL; + END IF; + + SELECT count(*) INTO result_count + FROM authorization_admission_results + WHERE community_id = receipt.community_id + AND operation_id = receipt.operation_id; + + IF result_count <> 1 THEN + RAISE EXCEPTION + 'kind-11 receipt requires exactly one admission result, found %', + result_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_admission_result_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_admission_result_receipt_cardinality + AFTER INSERT ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_admission_result_guard_v1(); + +CREATE CONSTRAINT TRIGGER authorization_admission_result_result_cardinality + AFTER INSERT ON authorization_admission_results + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_admission_result_guard_v1(); + -- Every successful/no-op core lifecycle receipt has exactly one privacy-safe -- audit event with the closed transition-kind mapping. Both directions are -- deferred so receipt, history, event, selectors, and binding may be inserted diff --git a/schema/schema.sql b/schema/schema.sql index 537ea283e4..de20d9a09f 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -2308,6 +2308,56 @@ CREATE INDEX identity_lifecycle_selectors_asserted_history ON identity_lifecycle_selectors (community_id, asserted_history_id, selector_kind); +-- Serializes policy-revision inserts per community: each new revision must +-- strictly exceed the current maximum, and each effective_at must strictly +-- exceed the current maximum effective_at (FI-INV-06 — stable assertion +-- policy; a revision that moves either coordinate backward is incoherent). +-- The per-community advisory lock prevents two concurrent writers from both +-- passing a plain SELECT MAX() check and committing conflicting revisions. +CREATE FUNCTION identity_enrollment_policy_revision_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + lock_key BIGINT; + max_revision BIGINT; + max_effective_at TIMESTAMPTZ; +BEGIN + -- Acquire a per-community exclusive transaction-scoped advisory lock so + -- that concurrent insertions serialize here. The key is a stable hash of + -- the namespace string and the community_id bytes. + lock_key := hashtextextended( + 'buzz:enrollment-policy-revision:v1:' || NEW.community_id::text, + 0 + ); + PERFORM pg_advisory_xact_lock(lock_key); + + SELECT MAX(policy_revision), MAX(effective_at) + INTO max_revision, max_effective_at + FROM identity_enrollment_policies + WHERE community_id = NEW.community_id; + + IF max_revision IS NOT NULL + AND NEW.policy_revision <= max_revision + THEN + RAISE EXCEPTION + 'policy_revision % does not strictly exceed current maximum % for community %', + NEW.policy_revision, max_revision, NEW.community_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; + END IF; + + IF max_effective_at IS NOT NULL + AND NEW.effective_at <= max_effective_at + THEN + RAISE EXCEPTION + 'effective_at % does not strictly exceed current maximum % for community %', + NEW.effective_at, max_effective_at, NEW.community_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + CREATE FUNCTION nip_fi_reject_row_mutation_v1() RETURNS TRIGGER AS $$ BEGIN RAISE EXCEPTION '% is immutable', TG_TABLE_NAME @@ -2728,6 +2778,9 @@ CREATE TRIGGER authorization_operation_receipts_no_truncate BEFORE TRUNCATE ON authorization_operation_receipts FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +CREATE TRIGGER identity_enrollment_policies_revision_guard + BEFORE INSERT ON identity_enrollment_policies + FOR EACH ROW EXECUTE FUNCTION identity_enrollment_policy_revision_guard_v1(); CREATE TRIGGER identity_enrollment_policies_immutable BEFORE UPDATE OR DELETE ON identity_enrollment_policies FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); @@ -3244,6 +3297,85 @@ CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate BEFORE TRUNCATE ON authorization_authentication_denial_attempts FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +-- Bidirectional deferred guard: a kind-9 (pre-authentication denial) audit +-- event must commit with exactly one denial attempt; a denial attempt must +-- commit with its audit event present and kind-9. Both directions deferred so +-- event and attempt may be inserted in any order inside one transaction. +CREATE FUNCTION authorization_denial_attempt_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + found_event_kind SMALLINT; + attempt_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_events' THEN + -- Firing from the event side: only kind-9 events require a denial row. + IF NEW.event_kind <> 9 THEN + RETURN NULL; + END IF; + + SELECT count(*) INTO attempt_count + FROM authorization_authentication_denial_attempts + WHERE community_id = NEW.community_id + AND audit_event_id = NEW.event_id; + + IF attempt_count <> 1 THEN + RAISE EXCEPTION + 'kind-9 audit event requires exactly one denial attempt, found %', + attempt_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; + END IF; + ELSE + -- Firing from the denial-attempt side: verify the audit event is kind-9 + -- and that exactly one denial attempt references it. + SELECT event_kind INTO found_event_kind + FROM authorization_events + WHERE community_id = NEW.community_id + AND event_id = NEW.audit_event_id; + + IF NOT FOUND THEN + RAISE EXCEPTION + 'denial attempt references non-existent audit event %', + NEW.audit_event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_kind'; + END IF; + + IF found_event_kind <> 9 THEN + RAISE EXCEPTION + 'denial attempt audit event must be kind 9, got %', + found_event_kind + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_kind'; + END IF; + + SELECT count(*) INTO attempt_count + FROM authorization_authentication_denial_attempts + WHERE community_id = NEW.community_id + AND audit_event_id = NEW.audit_event_id; + + IF attempt_count <> 1 THEN + RAISE EXCEPTION + 'exactly one denial attempt must reference audit event %, found %', + NEW.audit_event_id, attempt_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_denial_attempt_event_cardinality + AFTER INSERT ON authorization_events + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_denial_attempt_guard_v1(); + +CREATE CONSTRAINT TRIGGER authorization_denial_event_attempt_cardinality + AFTER INSERT ON authorization_authentication_denial_attempts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_denial_attempt_guard_v1(); + CREATE FUNCTION authorization_operation_version_delta_cardinality_guard_v1() RETURNS TRIGGER AS $$ DECLARE @@ -3382,6 +3514,75 @@ CREATE TRIGGER authorization_admission_results_no_truncate BEFORE TRUNCATE ON authorization_admission_results FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +-- Bidirectional deferred cardinality guard: a kind-11 (protected-mutation) +-- receipt must commit with exactly one admission result; an admission result +-- must commit against a kind-11 receipt. Deferred so receipt and result may +-- be inserted in any order inside one transaction. +CREATE FUNCTION authorization_admission_result_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + receipt authorization_operation_receipts%ROWTYPE; + result_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN + receipt := NEW; + ELSE + -- Firing from authorization_admission_results: look up the receipt. + SELECT * INTO receipt + FROM authorization_operation_receipts + WHERE community_id = NEW.community_id + AND operation_id = NEW.operation_id; + IF NOT FOUND THEN + -- FK on the result table already guards the non-existent receipt + -- case; this path should not occur in normal operation. + RAISE EXCEPTION + 'admission result references non-existent receipt for operation %', + NEW.operation_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_admission_result_receipt_kind'; + END IF; + END IF; + + -- Non-kind-11 receipts require no admission result. + IF receipt.operation_kind <> 11 THEN + -- If this fired from the result side and the receipt is not kind 11, + -- the result is attaching to the wrong receipt kind. + IF TG_TABLE_NAME = 'authorization_admission_results' THEN + RAISE EXCEPTION + 'admission result may only attach to a kind-11 (protected-mutation) receipt, got kind %', + receipt.operation_kind + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_admission_result_receipt_kind'; + END IF; + RETURN NULL; + END IF; + + SELECT count(*) INTO result_count + FROM authorization_admission_results + WHERE community_id = receipt.community_id + AND operation_id = receipt.operation_id; + + IF result_count <> 1 THEN + RAISE EXCEPTION + 'kind-11 receipt requires exactly one admission result, found %', + result_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_admission_result_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_admission_result_receipt_cardinality + AFTER INSERT ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_admission_result_guard_v1(); + +CREATE CONSTRAINT TRIGGER authorization_admission_result_result_cardinality + AFTER INSERT ON authorization_admission_results + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_admission_result_guard_v1(); + -- Every successful/no-op core lifecycle receipt has exactly one privacy-safe -- audit event with the closed transition-kind mapping. Both directions are -- deferred so receipt, history, event, selectors, and binding may be inserted