diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 37628669a12..8dabb00b12b 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -69,7 +69,11 @@ pub(crate) const MAX_JWKS_KEYS: usize = 64; /// semantics** so prepared evidence built against an older contract is /// invalidated. Per-policy fields (issuer, class, bounds, …) are hashed /// separately and need no bump. -pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 1; +/// +/// v2 (PR #7221): `nostr_pubkey` absence now unconditionally rejects — the +/// per-issuer `require_attested_key` knob is removed and the NIP-FI v2 spec +/// requirement is always enforced. +pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 2; /// The transport-contract fingerprint folded into [`TransportContractId`]. /// **Bump on any change** to the client-attached parsing, attachment, @@ -347,7 +351,6 @@ pub struct IssuerPolicy { token_class: TokenClass, freshness: FreshnessClass, algorithms: Vec, - require_attested_key: bool, skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, @@ -404,7 +407,6 @@ impl IssuerPolicy { token_class: TokenClass, freshness: FreshnessClass, algorithms: Vec, - require_attested_key: bool, skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, @@ -467,7 +469,6 @@ impl IssuerPolicy { &token_class, freshness, &algorithms, - require_attested_key, skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, @@ -480,7 +481,6 @@ impl IssuerPolicy { token_class, freshness, algorithms, - require_attested_key, skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, @@ -514,11 +514,6 @@ impl IssuerPolicy { &self.algorithms } - /// Whether enrollment requires a `nostr_pubkey` claim equal to the actor. - pub const fn require_attested_key(&self) -> bool { - self.require_attested_key - } - /// The accepted clock skew, in seconds. pub const fn skew_seconds(&self) -> u64 { self.skew_seconds @@ -646,7 +641,6 @@ fn derive_assertion_policy_id( token_class: &TokenClass, freshness: FreshnessClass, algorithms: &[Algorithm], - require_attested_key: bool, skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, @@ -702,7 +696,6 @@ fn derive_assertion_policy_id( &mut hasher, algorithms.iter().map(|a| algorithm_tag(*a).as_bytes()), ); - hasher.update([u8::from(require_attested_key)]); hasher.update(skew_seconds.to_be_bytes()); hasher.update(maximum_assertion_age_seconds.to_be_bytes()); hasher.update(maximum_status_age_seconds.unwrap_or(0).to_be_bytes()); diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs index 6d9f21a1502..df75e70da06 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/tests.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -896,7 +896,9 @@ async fn two_issuer_keys_and_generations_are_isolated() { fn sign(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { let now = chrono::Utc::now().timestamp(); + // nostr_pubkey is required unconditionally by spec v2. let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "iat": now, "exp": now + 600}); let mut hdr = Header::new(Algorithm::ES256); hdr.kid = Some(kid.to_owned()); @@ -921,7 +923,6 @@ async fn two_issuer_keys_and_generations_are_isolated() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -1112,7 +1113,9 @@ async fn shared_arc_source_verifier_observes_rotation() { fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { let now = chrono::Utc::now().timestamp(); + // nostr_pubkey is required unconditionally by spec v2. let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "iat": now, "exp": now + 600}); let mut hdr = Header::new(Algorithm::ES256); hdr.kid = Some(kid.to_owned()); @@ -1171,7 +1174,6 @@ async fn shared_arc_source_verifier_observes_rotation() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -1233,7 +1235,6 @@ fn jwks_contract_uri_canonicalization_convergence_and_divergence() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 30, 600, None, @@ -1489,7 +1490,9 @@ async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() { fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { let wall_now = chrono::Utc::now().timestamp(); + // nostr_pubkey is required unconditionally by spec v2. let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "iat": wall_now, "exp": wall_now + 600}); let mut hdr = Header::new(Algorithm::ES256); hdr.kid = Some(kid.to_owned()); @@ -1572,7 +1575,6 @@ async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, HARD_DEADLINE_SECS, None, diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs index 04b28a7b964..9b1d59b1877 100644 --- a/crates/buzz-auth/src/nip_fi/startup/tests.rs +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -21,7 +21,6 @@ fn make_offline_policy(issuer: &str) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![JwtAlgorithm::ES256], - false, 0, 3600, None, @@ -37,7 +36,6 @@ fn make_status_policy(issuer: &str) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![JwtAlgorithm::ES256], - false, 0, 3600, Some(60), diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index aa3b5796a0f..cf20b57a86e 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -362,7 +362,7 @@ impl FederatedAssertionVerifier { enforce_claim_semantics(policy, &claims)?; let subject = claim_string(&claims, SUBJECT_CLAIM, MAX_SUBJECT_BYTES)?; - let asserted_key = parse_nostr_pubkey_claim(policy, &claims)?; + let asserted_key = parse_nostr_pubkey_claim(&claims)?; let now = Utc::now(); let deadlines = self.check_time_and_deadlines(policy, &key_set, &claims, now)?; @@ -720,20 +720,13 @@ fn enforce_claim_semantics( } /// Parse the fixed `nostr_pubkey` claim: lowercase hex of exactly one 32-byte -/// key. Bech32 and other aliases deny. Absence is permitted unless the policy -/// requires an attested key. +/// key. Bech32 and other aliases deny. Absence denies; the merged NIP-FI +/// spec v2 (PR #7214) requires the `nostr_pubkey` claim unconditionally. fn parse_nostr_pubkey_claim( - policy: &IssuerPolicy, claims: &Map, ) -> Result, VerifierError> { match claims.get(NOSTR_PUBKEY_CLAIM) { - None => { - if policy.require_attested_key() { - Err(VerifierError::ClaimRejected) - } else { - Ok(None) - } - } + None => Err(VerifierError::ClaimRejected), Some(value) => { let raw = value.as_str().ok_or(VerifierError::ClaimRejected)?; if raw.len() != 64 diff --git a/crates/buzz-auth/src/nip_fi/verifier/tests.rs b/crates/buzz-auth/src/nip_fi/verifier/tests.rs index 990a3310e40..8f6c60c40ae 100644 --- a/crates/buzz-auth/src/nip_fi/verifier/tests.rs +++ b/crates/buzz-auth/src/nip_fi/verifier/tests.rs @@ -27,6 +27,9 @@ const TEST_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; const TEST_KID: &str = "test-key-1"; const ISSUER: &str = "https://issuer.example"; const AUDIENCE: &str = "https://relay.example"; +/// A canonical lowercase-hex nostr pubkey for tokens that are not testing +/// the nostr_pubkey claim specifically. Spec v2 requires the claim unconditionally. +const TEST_NOSTR_PUBKEY: &str = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; /// A canonical JWKS contract for the default test issuer. Used wherever a /// `JwksSourceContract` is required but JWKS behavior is not under test. @@ -109,7 +112,6 @@ fn access_token_policy_with(subject_class: SubjectClassContract) -> IssuerPolicy TokenClass::AccessTokenAtJwt { subject_class }, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -131,7 +133,6 @@ fn dedicated_policy(issuer: &str) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -147,7 +148,6 @@ fn dedicated_policy_with_audiences(audiences: Vec) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -163,7 +163,6 @@ fn dedicated_policy_with_algorithms(algorithms: Vec) -> IssuerPolicy TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, algorithms, - false, 60, 3600, None, @@ -198,6 +197,10 @@ fn mint_signed_by(pkcs8_pem: &str, typ: Option<&str>, kid: &str, mut claims: Val obj.entry("aud").or_insert(json!(AUDIENCE)); obj.entry("iat").or_insert(json!(now())); obj.entry("exp").or_insert(json!(now() + 600)); + // Spec v2 requires nostr_pubkey unconditionally; inject a canonical + // test pubkey so tokens that test other behaviours pass the claim check. + obj.entry(NOSTR_PUBKEY_CLAIM) + .or_insert(json!(TEST_NOSTR_PUBKEY)); } let mut header = Header::new(Algorithm::ES256); header.kid = Some(kid.to_owned()); @@ -206,6 +209,26 @@ fn mint_signed_by(pkcs8_pem: &str, typ: Option<&str>, kid: &str, mut claims: Val jsonwebtoken::encode(&header, &claims, &key).expect("sign") } +/// Mint a valid, signed token that deliberately omits `nostr_pubkey`. Used +/// only to exercise the unconditional missing-claim rejection path; the normal +/// `mint`/`mint_signed_by` helpers always inject the claim via `or_insert` so +/// they cannot produce an absent-claim token. +fn mint_no_pubkey(typ: Option<&str>, kid: &str, mut claims: Value) -> String { + { + let obj = claims.as_object_mut().expect("claims object"); + obj.entry("iss").or_insert(json!(ISSUER)); + obj.entry("aud").or_insert(json!(AUDIENCE)); + obj.entry("iat").or_insert(json!(now())); + obj.entry("exp").or_insert(json!(now() + 600)); + // Intentionally does NOT inject nostr_pubkey. + } + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(kid.to_owned()); + header.typ = typ.map(str::to_owned); + let key = EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign") +} + /// A resource-owner `at+jwt` claim set: valid subject-class marker plus client_id. fn resource_owner_claims() -> Value { json!({ "sub": "user-123", "client_id": "app-1", "sub_type": "user" }) @@ -240,7 +263,8 @@ fn valid_access_token_verifies() { let assertion = verifier.verify(&token).expect("verifies"); assert_eq!(assertion.identity().issuer(), ISSUER); assert_eq!(assertion.identity().subject(), "user-123"); - assert!(assertion.asserted_key().is_none()); + // Spec v2: nostr_pubkey is injected by mint() and unconditionally required. + assert!(assertion.asserted_key().is_some()); assert!(!assertion.authority_deadlines().is_empty()); assert_eq!(assertion.assertion_policy_id(), access_token_policy().id()); } @@ -698,22 +722,18 @@ fn uppercase_nostr_pubkey_denies() { } #[test] -fn missing_nostr_pubkey_denies_under_attested_key_policy() { - let policy = IssuerPolicy::new( - ISSUER.to_owned(), - vec![AUDIENCE.to_owned()], - TokenClass::DedicatedNipFi, - FreshnessClass::OfflineJwt, - vec![Algorithm::ES256], - true, // require attested key - 60, - 3600, - None, - test_jwks_contract(), - ) - .unwrap(); - let verifier = verifier_with(policy); - let token = mint(Some("nip-fi+jwt"), TEST_KID, json!({ "sub": "u" })); +fn absent_nostr_pubkey_claim_denies() { + // `nostr_pubkey` absence must unconditionally reject — NIP-FI v2 dropped + // the per-issuer `require_attested_key` knob that previously made it + // optional. This is a direct falsifiable regression test: removing the + // `None => Err(VerifierError::ClaimRejected)` arm from + // `parse_nostr_pubkey_claim` must turn this test red. + let verifier = verifier_with(access_token_policy()); + let token = mint_no_pubkey( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "u", "client_id": "a", "sub_type": "user" }), + ); assert_eq!( verifier.verify(&token).unwrap_err(), VerifierError::ClaimRejected @@ -1105,7 +1125,6 @@ fn current_status_policy() -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![Algorithm::ES256], - false, 60, 3600, Some(120), // maximum_status_age required for current-status @@ -1385,7 +1404,6 @@ fn assertion_policy_id_is_deterministic_and_semantic() { changed.token_class().clone(), FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 120, // different skew => different semantics 3600, None, @@ -1411,7 +1429,6 @@ fn offline_policy_rejects_inapplicable_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, Some(120), @@ -1430,7 +1447,6 @@ fn offline_policy_accepts_absent_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -1449,7 +1465,6 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -1463,7 +1478,6 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![Algorithm::ES256], - false, 60, 3600, Some(0), @@ -1579,7 +1593,6 @@ fn policy_with_contract(contract: crate::nip_fi::jwks::JwksSourceContract) -> Is TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 68071e0ed24..3db40eddb73 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -699,7 +699,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 43); + assert_eq!(migrations.len(), 44); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1242,44 +1242,15 @@ mod postgres_tests { "migration 39 must register relay_operator_audit in _operator_global_tables" ); - // NIP-FI core identity + base-lifecycle foundation (migration 0041) and - // final-admission foundation (0042). Both widen the single SQL source of - // truth `community_write_fence_excluded_table` so their durable, - // immutable ledger relations are never fence-attached, purged, or - // counted as tenant-scoped drift. schema.sql keeps one consolidated - // definition of that function whose body must match 0042's exactly. assert_eq!(migrations[40].version, 41); let identity_foundation = migrations[40].sql.as_str(); assert!(identity_foundation.contains("CREATE TABLE identity_bindings")); assert!(identity_foundation.contains("CREATE TABLE identity_lifecycle_history")); - assert!(identity_foundation - .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); - assert!(identity_foundation.contains("'identity_bindings'")); assert_eq!(migrations[41].version, 42); let authorization_foundation = migrations[41].sql.as_str(); assert!(authorization_foundation.contains("CREATE TABLE authorization_events")); assert!(authorization_foundation.contains("CREATE TABLE protected_object_authority")); - assert!(authorization_foundation.contains("CREATE TABLE authorization_admission_results")); - - // The consolidated desired-state exclusion function must byte-match - // migration 0042's CREATE OR REPLACE body, or a future schema - // consolidation would silently drop NIP-FI relations from the ledger. - fn extract_excluded_table_array(sql: &str) -> &str { - let anchor = "community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN"; - let start = sql.find(anchor).expect("exclusion function definition"); - let array_start = sql[start..].find("ARRAY[").expect("exclusion array") + start; - let array_end = sql[array_start..] - .find("]::TEXT[]") - .expect("exclusion array end") - + array_start; - &sql[array_start..array_end] - } - assert_eq!( - extract_excluded_table_array(authorization_foundation), - extract_excluded_table_array(desired_schema), - "schema.sql exclusion list drifted from migration 0042" - ); // Brownfield relay databases created through SQLx still carry the // production/sandbox constraint from 0015. Converge them to the same @@ -1292,6 +1263,27 @@ mod postgres_tests { .contains("DROP CONSTRAINT push_gateway_installations_app_profile_check")); assert!(dogfood_profile.contains("CHECK (app_profile = 'buzz-ios-dogfood')")); assert!(desired_schema.contains("CHECK (app_profile = 'buzz-ios-dogfood')")); + + // Drop the Phase-A NIP-FI relay-side authority ledger (0041 + 0042). + // OSS Buzz is stateless for identity (spec v2, PR #7214); the durable + // ledger tables are dead code. Restores community_write_fence_excluded_table + // to its pre-0041 body so the deletion catalog no longer includes the + // removed relations. + assert_eq!(migrations[43].version, 44); + let ledger_removal = migrations[43].sql.as_str(); + assert!(ledger_removal.contains("DROP TABLE authorization_operation_receipts")); + assert!(ledger_removal.contains("DROP TABLE identity_bindings")); + assert!(ledger_removal.contains("DROP TABLE authorization_events")); + assert!(ledger_removal + .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); + // The restored exclusion function must NOT list any NIP-FI relation. + assert!(!ledger_removal.contains("'authorization_operation_receipts'")); + assert!(!ledger_removal.contains("'identity_bindings'")); + // schema.sql exclusion list must match the restored (pre-0041) body. + assert!( + desired_schema.contains("'rate_limit_violations'\n ]::TEXT[])"), + "schema.sql exclusion list must match the pre-0041 body after ledger removal" + ); } #[test] @@ -2670,111 +2662,63 @@ mod postgres_tests { .expect("drop late-table fixtures"); } - /// NIP-FI intermediate state: migration 0041 (identity + base lifecycle) - /// alone must present a coherent catalog. Its five community-scoped ledger - /// relations are immutable and durable, so they are registered in the - /// write-fence exclusion — never counted as tenant-scoped drift, never - /// fence-attached — and the exact deletion catalog must still validate. + /// Verify migration 0044 applies cleanly against a DB that has rows in + /// the NIP-FI 0041+0042 tables. The immutability guards (no_delete, + /// no_truncate) are enforced via triggers; DROP TABLE bypasses them and + /// must succeed even when rows are present. #[tokio::test] #[ignore = "requires Postgres"] - async fn migration_0041_identity_foundation_is_durable_ledger_after_migration_a() { + async fn migration_0044_drops_populated_nip_fi_ledger_cleanly() { let pool = connect_test_pool().await; reset_public_schema(&pool).await; MIGRATOR - .run_to(41, &pool) - .await - .expect("apply migrations 1-41"); - - // The five identity relations exist. - let identity_tables = [ - "authorization_operation_receipts", - "identity_enrollment_policies", - "identity_bindings", - "identity_lifecycle_history", - "identity_lifecycle_selectors", - ]; - for table in identity_tables { - let exists = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ - WHERE table_schema = 'public' AND table_name = $1)", - ) - .bind(table) - .fetch_one(&pool) + .run_to(42, &pool) .await - .unwrap_or_else(|err| panic!("check table {table}: {err}")); - assert!(exists, "migration 0041 must create {table}"); - } + .expect("apply migrations 1-42"); - // Migration B's relations must NOT exist yet. - for table in ["authorization_events", "protected_object_authority"] { - let exists = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ - WHERE table_schema = 'public' AND table_name = $1)", - ) - .bind(table) - .fetch_one(&pool) + // Seed a community and minimal rows in a selection of 0041+0042 tables. + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("drop-test-{}.example", community_id.simple())) + .execute(&pool) .await - .unwrap_or_else(|err| panic!("check table {table}: {err}")); - assert!(!exists, "{table} belongs to migration 0042, not 0041"); - } + .expect("seed community"); - // Every identity relation is excluded from the write fence: none may - // appear as tenant-scoped drift or carry the fence trigger. - let scoped_or_fenced: Vec = sqlx::query_scalar( - "WITH scoped AS ( \ - SELECT c.relname FROM pg_class c \ - JOIN pg_namespace n ON n.oid = c.relnamespace \ - JOIN pg_attribute a ON a.attrelid = c.oid \ - WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ - AND NOT c.relispartition AND a.attname = 'community_id' \ - AND NOT a.attisdropped \ - AND NOT community_write_fence_excluded_table(c.relname) \ - ) \ - SELECT relname FROM scoped \ - WHERE relname = ANY($1) ORDER BY relname", + // Seed a receipt (used as FK anchor for several 0042 tables). + let operation_id = uuid::Uuid::new_v4(); + 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(&identity_tables[..]) - .fetch_all(&pool) + .bind(community_id) + .bind(operation_id) + .bind(vec![0xAA_u8; 32]) + .bind(vec![0xBB_u8; 32]) + .bind(vec![0xCC_u8; 32]) + .execute(&pool) .await - .expect("read scoped identity relations"); - assert!( - scoped_or_fenced.is_empty(), - "identity ledger relations must be write-fence excluded, not scoped: {scoped_or_fenced:?}" - ); - - // The exact deletion catalog validates: the excluded ledger relations - // do not perturb the scoped-table/fence equality check. - crate::deletion::DeletionStore::new(pool.clone()) - .validate_catalog() - .await - .expect("deletion catalog validates after migration 0041"); + .expect("seed operation receipt"); - // The immutability contract is enforced, not merely declared. TRUNCATE - // fires the statement-level guard unconditionally, so this proves the - // rejection without constructing a fully valid ledger row. - let rejected = sqlx::query("TRUNCATE identity_lifecycle_selectors") - .execute(&pool) - .await - .expect_err("identity_lifecycle_selectors truncation must be rejected"); - assert!( - rejected.to_string().contains("cannot be truncated"), - "expected immutability rejection, got: {rejected}" - ); - } + // Seed an invalidation domain (0042 table with no FK to receipts). + sqlx::query( + "INSERT INTO authorization_invalidation_domains \ + (community_id, current_generation) VALUES ($1, 0)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("seed invalidation domain"); - /// NIP-FI full state: migrations 0041 + 0042 together must present a - /// coherent 15-relation catalog with zero dangling foreign keys, all - /// relations write-fence excluded, and an intact exact deletion catalog. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn nip_fi_foundation_is_a_closed_durable_ledger_after_migrations_a_and_b() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; + // Apply migration 0043 (dogfood profile) and 0044 (ledger removal). MIGRATOR - .run_to(42, &pool) + .run_to(44, &pool) .await - .expect("apply migrations 1-42"); + .expect("migration 0044 must apply cleanly against a populated NIP-FI DB"); + // All NIP-FI tables must be gone. let nip_fi_tables = [ "authorization_admission_results", "authorization_authentication_denial_attempts", @@ -2792,2455 +2736,23 @@ mod postgres_tests { "identity_lifecycle_selectors", "protected_object_authority", ]; - - // All fifteen relations exist. let present: Vec = sqlx::query_scalar( "SELECT table_name FROM information_schema.tables \ - WHERE table_schema = 'public' AND table_name = ANY($1) ORDER BY table_name", - ) - .bind(&nip_fi_tables[..]) - .fetch_all(&pool) - .await - .expect("read NIP-FI table catalog"); - let mut expected: Vec = nip_fi_tables.iter().map(|t| t.to_string()).collect(); - expected.sort(); - assert_eq!( - present, expected, - "all NIP-FI relations must exist after 0042" - ); - - // Zero dangling foreign keys: every FK target is a live relation. - let invalid_fks: i64 = sqlx::query_scalar( - "SELECT count(*)::BIGINT FROM pg_constraint \ - WHERE contype = 'f' AND NOT convalidated", - ) - .fetch_one(&pool) - .await - .expect("read FK validity"); - assert_eq!( - invalid_fks, 0, - "no NIP-FI foreign key may be left unvalidated" - ); - - // None of the fifteen appear as tenant-scoped drift; all are excluded. - let scoped: Vec = sqlx::query_scalar( - "SELECT c.relname FROM pg_class c \ - JOIN pg_namespace n ON n.oid = c.relnamespace \ - JOIN pg_attribute a ON a.attrelid = c.oid \ - WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ - AND NOT c.relispartition AND a.attname = 'community_id' \ - AND NOT a.attisdropped \ - AND NOT community_write_fence_excluded_table(c.relname) \ - AND c.relname = ANY($1) ORDER BY c.relname", + WHERE table_schema = 'public' AND table_name = ANY($1)", ) .bind(&nip_fi_tables[..]) .fetch_all(&pool) .await - .expect("read scoped NIP-FI relations"); + .expect("catalog check after ledger removal"); assert!( - scoped.is_empty(), - "all NIP-FI ledger relations must be write-fence excluded: {scoped:?}" + present.is_empty(), + "all NIP-FI tables must be absent after migration 0044: {present:?}" ); - // The exact deletion catalog validates with the full ledger present. + // The deletion catalog must validate with ledger relations gone. crate::deletion::DeletionStore::new(pool.clone()) .validate_catalog() .await - .expect("deletion catalog validates after migrations 0041 + 0042"); - - // A migration-B relation is immutable too. TRUNCATE fires the - // statement-level guard unconditionally. - let rejected = sqlx::query("TRUNCATE authorization_admission_results") - .execute(&pool) - .await - .expect_err("authorization_admission_results truncation must be rejected"); - assert!( - rejected.to_string().contains("cannot be truncated"), - "expected immutability rejection, got: {rejected}" - ); - } - - /// NIP-FI monotonic invalidation-floor advancement must actually run - /// through the `BEFORE UPDATE` guard. PL/pgSQL defers record-field - /// resolution to execution, so a guard that references a column absent from - /// its Phase-A table passes every catalog/parity test yet aborts the first - /// real advancement. This test exercises live UPDATEs: legitimate forward - /// moves on `floor_generation` and `binding_version_floor` must commit, and - /// equal/regressive moves must be rejected. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn authorization_invalidation_floor_advances_through_guard() { - 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!("floor-guard-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Each floor state points at an operation receipt via - // (community_id, operation_id, request_fingerprint). Seed one receipt - // per operation the test advances through. - let operations: [(uuid::Uuid, u8); 4] = [ - (uuid::Uuid::new_v4(), 0x11), - (uuid::Uuid::new_v4(), 0x22), - (uuid::Uuid::new_v4(), 0x33), - (uuid::Uuid::new_v4(), 0x44), - ]; - for (operation_id, fp_byte) in operations { - 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(operation_id) - .bind(vec![fp_byte; 32]) - .bind(vec![0xAA_u8; 32]) - .bind(vec![0xBB_u8; 32]) - .execute(&pool) - .await - .expect("seed operation receipt"); - } - - // selector_kind 3 requires binding_version_floor, so this row exercises - // both monotonic dimensions the guard still governs. - let selector_fingerprint = vec![0xCC_u8; 32]; - sqlx::query( - "INSERT INTO authorization_invalidation_floors \ - (community_id, selector_kind, selector_fingerprint, floor_generation, \ - binding_version_floor, operation_id, request_fingerprint, updated_at) \ - VALUES ($1, 3, $2, 1, 1, $3, $4, '2026-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(&selector_fingerprint) - .bind(operations[0].0) - .bind(vec![operations[0].1; 32]) - .execute(&pool) - .await - .expect("insert initial invalidation floor"); - - let advance = - |generation: i64, binding_floor: i64, op_index: usize, updated_at: &'static str| { - sqlx::query( - "UPDATE authorization_invalidation_floors \ - SET floor_generation = $1, binding_version_floor = $2, \ - operation_id = $3, request_fingerprint = $4, updated_at = $5::timestamptz \ - WHERE community_id = $6 AND selector_kind = 3 AND selector_fingerprint = $7", - ) - .bind(generation) - .bind(binding_floor) - .bind(operations[op_index].0) - .bind(vec![operations[op_index].1; 32]) - .bind(updated_at) - .bind(community_id) - .bind(selector_fingerprint.clone()) - .execute(&pool) - }; - - // Forward generation advance commits. - advance(2, 1, 1, "2026-01-01T00:01:00Z") - .await - .expect("forward floor_generation advance must pass the guard"); - - // Forward binding_version_floor advance commits (generation unchanged). - advance(2, 2, 2, "2026-01-01T00:02:00Z") - .await - .expect("forward binding_version_floor advance must pass the guard"); - - // Regressive generation is rejected. - let regressive = advance(1, 2, 3, "2026-01-01T00:03:00Z") - .await - .expect_err("regressive floor_generation must be rejected"); - assert!( - regressive.to_string().contains("cannot move backward"), - "expected monotonic rejection, got: {regressive}" - ); - - // Equal floors with only a new operation is a rejected no-op advance. - let no_op = advance(2, 2, 3, "2026-01-01T00:03:00Z") - .await - .expect_err("equal-floor no-op advance must be rejected"); - assert!( - no_op.to_string().contains("cannot move backward"), - "expected no-op rejection, got: {no_op}" - ); - - // The committed state reflects only the two accepted advances. - let (generation, binding_floor): (i64, i64) = sqlx::query_as( - "SELECT floor_generation, binding_version_floor \ - FROM authorization_invalidation_floors \ - WHERE community_id = $1 AND selector_kind = 3 AND selector_fingerprint = $2", - ) - .bind(community_id) - .bind(&selector_fingerprint) - .fetch_one(&pool) - .await - .expect("read final floor state"); - assert_eq!( - (generation, binding_floor), - (2, 2), - "only the accepted forward advances may persist" - ); - } - - /// NIP-FI identity FK contract: a binding's provenance is determined from - /// operation evidence and is independent of the enrollment policy's mode. - /// The corrected FK references only `(community_id, policy_revision)`; - /// the original composite FK `(community_id, policy_revision, - /// binding_provenance) → (community_id, policy_revision, enrollment_mode)` - /// would have rejected valid admissions such as TOFU policy + - /// attested-key provenance (NIP-FI.md §352, §424). - #[tokio::test] - #[ignore = "requires Postgres"] - async fn identity_binding_provenance_is_independent_of_enrollment_mode() { - 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!("provenance-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Enrollment policy: mode 3 (TOFU). - let policy_revision: i64 = 1; - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(policy_revision) - .bind(vec![0xA0_u8; 32]) // policy_digest - .execute(&pool) - .await - .expect("insert TOFU enrollment policy"); - - // Insert a binding with provenance 1 (attested-key) under the TOFU - // policy. The circular deferred FK between identity_bindings and - // identity_lifecycle_history requires both to be committed in one - // transaction; all cross-table FKs in this pair are DEFERRABLE - // INITIALLY DEFERRED. A pinned connection is required so that BEGIN - // and each subsequent statement share the same session/transaction. - let binding_id = uuid::Uuid::new_v4(); - let history_id = uuid::Uuid::new_v4(); - let operation_id = uuid::Uuid::new_v4(); - let request_fingerprint = vec![0xAB_u8; 32]; - - let mut conn = pool.acquire().await.expect("acquire connection"); - - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - // Enrollment history must be inserted BEFORE the operation receipt: - // authorization_operation_receipt_history_guard_v1 fires AFTER INSERT - // on authorization_operation_receipts and checks that lifecycle receipts - // already have exactly one history row. The history → receipt FK is - // DEFERRABLE INITIALLY DEFERRED, so this order is safe. - sqlx::query( - "INSERT INTO identity_lifecycle_history \ - (community_id, history_id, transition_kind, outcome_code, \ - successor_binding_id, successor_binding_version, \ - successor_lifecycle_revision, successor_state, \ - operation_id, request_fingerprint, transition_digest) \ - VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)", - ) - .bind(community_id) - .bind(history_id) - .bind(binding_id) - .bind(operation_id) - .bind(&request_fingerprint) - .bind(vec![0xAE_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert lifecycle history"); - - // Operation receipt: kind 1 (enroll), outcome 1 (applied). - // The receipt_history_cardinality trigger fires here and validates the - // history row inserted above. - 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, 1, $4, 1, $5)", - ) - .bind(community_id) - .bind(operation_id) - .bind(&request_fingerprint) - .bind(vec![0xAC_u8; 32]) - .bind(vec![0xAD_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert operation receipt"); - - // Binding: provenance 1 (attested-key) under TOFU-mode policy. - // Before the FK fix this INSERT would fail at commit with a FK - // violation because 1 (attested-key) ≠ 3 (TOFU mode). - sqlx::query( - "INSERT INTO identity_bindings \ - (community_id, binding_id, issuer, subject, \ - principal_fingerprint, event_author_pubkey, \ - binding_state, lifecycle_revision, binding_provenance, \ - policy_revision, enrollment_evidence_digest, \ - birth_history_id, creation_operation_id, \ - creation_request_fingerprint) \ - VALUES ($1, $2, 'https://issuer.example', 'sub-01', \ - $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", - ) - .bind(community_id) - .bind(binding_id) - .bind(vec![0xAF_u8; 32]) // principal_fingerprint - .bind(vec![0xB0_u8; 32]) // event_author_pubkey - .bind(policy_revision) - .bind(vec![0xB1_u8; 32]) // enrollment_evidence_digest - .bind(history_id) - .bind(operation_id) - .bind(&request_fingerprint) - .execute(&mut *conn) - .await - .expect("insert binding"); - - sqlx::query("COMMIT") - .execute(&mut *conn) - .await - .expect("attested-key binding under TOFU policy must commit — FK is on (community_id, policy_revision) only"); - - // Confirm the binding persisted with provenance 1, policy mode 3. - let (stored_provenance, stored_mode): (i16, i16) = sqlx::query_as( - "SELECT b.binding_provenance, p.enrollment_mode \ - FROM identity_bindings b \ - JOIN identity_enrollment_policies p \ - ON p.community_id = b.community_id AND p.policy_revision = b.policy_revision \ - WHERE b.community_id = $1 AND b.binding_id = $2", - ) - .bind(community_id) - .bind(binding_id) - .fetch_one(&pool) - .await - .expect("read persisted binding"); - assert_eq!(stored_provenance, 1, "provenance must be attested-key (1)"); - assert_eq!(stored_mode, 3, "enrollment mode must be TOFU (3)"); - assert_ne!( - stored_provenance, stored_mode, - "provenance and mode are independent: they must differ here" - ); - - // --- Negative half: absent policy revision --- - // - // Two-sided mutation sensitivity requires that a FK dropped or neutered - // entirely is also detected. A second otherwise-valid deferred - // transaction uses a nonexistent policy_revision (999) and must fail - // with SQLSTATE 23503 — the narrowed FK - // identity_bindings(community_id, policy_revision) - // → identity_enrollment_policies(community_id, policy_revision) - // rejects the row. This FK is not deferred, so it fires at INSERT - // time; a `COMMIT` is unnecessary and not reached. If the FK were - // absent the INSERT would succeed and this assertion would catch the - // regression. - let absent_binding_id = uuid::Uuid::new_v4(); - let absent_history_id = uuid::Uuid::new_v4(); - let absent_operation_id = uuid::Uuid::new_v4(); - let absent_fp = vec![0xC0_u8; 32]; - let nonexistent_policy_revision: i64 = 999; - - let mut conn2 = pool.acquire().await.expect("acquire second connection"); - - sqlx::query("BEGIN") - .execute(&mut *conn2) - .await - .expect("begin absent-policy transaction"); - - // History first (receipt_history_cardinality guard fires on receipt - // insert and requires the history row to already exist). - sqlx::query( - "INSERT INTO identity_lifecycle_history \ - (community_id, history_id, transition_kind, outcome_code, \ - successor_binding_id, successor_binding_version, \ - successor_lifecycle_revision, successor_state, \ - operation_id, request_fingerprint, transition_digest) \ - VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)", - ) - .bind(community_id) - .bind(absent_history_id) - .bind(absent_binding_id) - .bind(absent_operation_id) - .bind(&absent_fp) - .bind(vec![0xC1_u8; 32]) - .execute(&mut *conn2) - .await - .expect("insert absent-policy lifecycle history"); - - 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, 1, $4, 1, $5)", - ) - .bind(community_id) - .bind(absent_operation_id) - .bind(&absent_fp) - .bind(vec![0xC2_u8; 32]) - .bind(vec![0xC3_u8; 32]) - .execute(&mut *conn2) - .await - .expect("insert absent-policy operation receipt"); - - // The policy FK is not deferred; it fires at INSERT, not COMMIT. - let absent_policy_err = sqlx::query( - "INSERT INTO identity_bindings \ - (community_id, binding_id, issuer, subject, \ - principal_fingerprint, event_author_pubkey, \ - binding_state, lifecycle_revision, binding_provenance, \ - policy_revision, enrollment_evidence_digest, \ - birth_history_id, creation_operation_id, \ - creation_request_fingerprint) \ - VALUES ($1, $2, 'https://issuer.example', 'sub-02', \ - $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", - ) - .bind(community_id) - .bind(absent_binding_id) - .bind(vec![0xC4_u8; 32]) // principal_fingerprint (unique, different from first binding) - .bind(vec![0xC5_u8; 32]) // event_author_pubkey (unique, different from first binding) - .bind(nonexistent_policy_revision) - .bind(vec![0xC6_u8; 32]) // enrollment_evidence_digest - .bind(absent_history_id) - .bind(absent_operation_id) - .bind(&absent_fp) - .execute(&mut *conn2) - .await - .expect_err("binding with nonexistent policy_revision must be rejected by the FK"); - assert!( - absent_policy_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23503")) - .unwrap_or(false), - "expected FK violation (23503) for absent policy_revision, got: {absent_policy_err}" - ); - - sqlx::query("ROLLBACK") - .execute(&mut *conn2) - .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 - /// (FI-INV-06 — stable assertion policy). `effective_at` ordering is - /// deliberately not enforced — the downstream constructor stamps every - /// immediately-effective revision with Unix epoch. - /// - /// 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. - 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"); - - // Seed a gap: skip from 2 to 100, then advance to 101. - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 100, 1, $2, '2027-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA3_u8; 32]) - .execute(&pool) - .await - .expect("jump to revision 100 must succeed"); - - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 101, 1, $2, '2027-06-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA4_u8; 32]) - .execute(&pool) - .await - .expect("advance to revision 101 must succeed"); - - // Negative: unused lower revision 99 — not a PK duplicate (never inserted), - // but the guard must reject it because 99 < MAX(100, 101). This is the - // case a plain PK constraint cannot catch; the named guard must fire. - let backfill_err = sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 99, 1, $2, '2028-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA5_u8; 32]) - .execute(&pool) - .await - .expect_err("unused lower revision 99 must be rejected by the guard"); - assert!( - backfill_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from identity_enrollment_policy_revision_monotonic \ - guard for backfilled revision 99, got: {backfill_err}" - ); - - // Negative: equal revision (101 <= 101). The PK is (community_id, policy_revision) - // so this is a PK duplicate regardless of policy_digest; either 23505 from the PK - // or 23514 from the guard fires first. This case is secondary — the load-bearing - // proof is the unused-99 case above, which is not a PK duplicate. - let replay_err = sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 101, 2, $2, '2028-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA6_u8; 32]) - .execute(&pool) - .await - .expect_err("equal revision must be rejected"); - // PK (23505) or guard (23514) — either proves the insert cannot commit. - assert!( - replay_err - .as_database_error() - .map(|e| { - let code = e.code(); - let c = code.as_deref().unwrap_or(""); - c == "23514" || c == "23505" - }) - .unwrap_or(false), - "expected check_violation (23514) or unique_violation (23505) for replayed revision, \ - got: {replay_err}" - ); - - // Concurrency regression: prove the advisory lock is load-bearing. The - // test uses a controlled two-connection schedule: - // - // 1. tx1 opens a transaction and inserts revision 102. The BEFORE INSERT - // trigger acquires `pg_advisory_xact_lock(lock_key)` and completes the - // INSERT — tx1 now holds the advisory lock until it commits. - // 2. tx2 opens a transaction on a second backend and issues INSERT for - // revision 103. The trigger fires and blocks inside - // `pg_advisory_xact_lock(lock_key)` waiting for tx1 to release. - // 3. We observe tx2's backend entering a Lock-wait state via - // pg_stat_activity (wait_event_type='Lock', wait_event='advisory'), - // with a bounded timeout — not a sleep. If the advisory-lock call is - // removed from the guard, the trigger returns immediately; tx2 never - // enters the advisory wait, and the poll times out, failing the test. - // This is the mutation-sensitivity guarantee. - // 4. tx1 commits, releasing the advisory lock. tx2 unblocks, its trigger - // reads the fresh MAX=102, and the INSERT succeeds (103 > 102). - // 5. tx2 commits. Both revisions 102 and 103 are present. - use std::time::Instant; - - // tx1: open a transaction and insert revision 102. The INSERT returns after - // the trigger acquires the lock and succeeds; the advisory lock stays held - // until the transaction commits. - let mut conn1 = pool.acquire().await.expect("acquire conn1"); - sqlx::query("BEGIN") - .execute(&mut *conn1) - .await - .expect("begin tx1"); - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 102, 1, $2, '2029-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xB1_u8; 32]) - .execute(&mut *conn1) - .await - .expect("tx1 INSERT revision 102 must succeed"); - // tx1 holds the advisory lock. Do NOT commit yet. - - // tx2: acquire a separate backend, record its PID, then issue the INSERT. - // The trigger will block on the advisory lock held by tx1. - let pool2 = pool.clone(); - let pool3 = pool.clone(); - let (pid_tx, pid_rx) = tokio::sync::oneshot::channel::(); - let tx2_task = tokio::spawn(async move { - let mut conn2 = pool2.acquire().await.expect("acquire conn2"); - // Report this backend's PID so the observer can poll pg_stat_activity. - let backend_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") - .fetch_one(&mut *conn2) - .await - .expect("get conn2 backend pid"); - let _ = pid_tx.send(backend_pid); - sqlx::query("BEGIN") - .execute(&mut *conn2) - .await - .expect("begin tx2"); - // This INSERT will block inside the trigger waiting for tx1's advisory lock. - let insert_r = sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 103, 1, $2, '2029-06-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xB2_u8; 32]) - .execute(&mut *conn2) - .await; - let commit_r = sqlx::query("COMMIT").execute(&mut *conn2).await; - (insert_r, commit_r) - }); - - // Receive tx2's backend PID and wait until it enters an advisory-lock wait. - // Mutation proof: without pg_advisory_xact_lock in the guard, the trigger - // returns immediately; tx2 never parks on an advisory lock; the poll below - // times out and panics, making this test deterministically red. - let tx2_pid = pid_rx.await.expect("tx2 reports its backend pid"); - let deadline = Instant::now() + std::time::Duration::from_secs(10); - loop { - let waiting: bool = sqlx::query_scalar( - "SELECT EXISTS (\ - SELECT 1 FROM pg_stat_activity \ - WHERE pid = $1 \ - AND wait_event_type = 'Lock' \ - AND wait_event = 'advisory'\ - )", - ) - .bind(tx2_pid) - .fetch_one(&pool3) - .await - .expect("poll tx2 advisory-lock wait"); - if waiting { - break; - } - assert!( - Instant::now() < deadline, - "tx2 never entered advisory-lock wait — pg_advisory_xact_lock \ - must be present in the guard for the lock to serialize writers" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - - // tx2 is observably blocked. Commit tx1, releasing the advisory lock. - sqlx::query("COMMIT") - .execute(&mut *conn1) - .await - .expect("tx1 COMMIT must succeed"); - - // tx2 unblocks: the trigger re-runs its SELECT MAX, sees committed 102, - // and INSERT 103 succeeds. Both the INSERT and COMMIT must complete. - let (insert2, commit2) = tx2_task.await.expect("tx2 task completed"); - insert2.expect("tx2 INSERT revision 103 must succeed after tx1 commits"); - commit2.expect("tx2 COMMIT must succeed"); - - // Both revisions 102 and 103 must be present (total: 1, 2, 100, 101, 102, 103). - 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, 6, - "exactly six revisions must persist after the controlled concurrency sequence" - ); - } - - /// 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}" - ); - drop(conn_b); - - // --- Negative C: mismatched request_fingerprint rejected by composite FK --- - // The admission result table has an immediate composite FK - // (community_id, operation_id, request_fingerprint) - // REFERENCES authorization_operation_receipts(...) - // A result referencing a receipt that exists but with a different - // request_fingerprint must be rejected. This exercises the semantic half - // of Carl finding 2 — cardinality is handled by the deferred trigger; - // coordinate binding is handled by the structural FK. - let op4 = uuid::Uuid::new_v4(); - let fp4_receipt = vec![0xE1_u8; 32]; // fingerprint stored in the receipt - let fp4_wrong = vec![0xE2_u8; 32]; // wrong fingerprint used in the result - - let mut conn_c = pool.acquire().await.expect("acquire connection C"); - sqlx::query("BEGIN") - .execute(&mut *conn_c) - .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(op4) - .bind(&fp4_receipt) - .bind(vec![0xE3_u8; 32]) - .bind(vec![0xE4_u8; 32]) - .execute(&mut *conn_c) - .await - .expect("insert kind-11 receipt for negative C"); - - // The admission result FK is immediate (not deferred), so the INSERT - // itself rejects a fingerprint with no matching receipt row. - let wrong_fp_err = 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(op4) - .bind(&fp4_wrong) // wrong fingerprint — no matching receipt row - .bind(vec![0xE5_u8; 32]) - .bind(vec![0xE6_u8; 32]) - .execute(&mut *conn_c) - .await - .expect_err("result with mismatched request_fingerprint must be rejected at INSERT"); - // Immediate composite FK fires as foreign_key_violation (23503). - assert!( - wrong_fp_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23503")) - .unwrap_or(false), - "expected foreign_key_violation (23503) for mismatched request_fingerprint, got: {wrong_fp_err}" - ); - sqlx::query("ROLLBACK").execute(&mut *conn_c).await.ok(); - } - - /// 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 for semantic mismatches (negatives - /// B1–B3) — the old deferred FK only checks event existence/kind and would - /// not catch a correlation, reason_code, or attempt_id mismatch. - #[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 (FKs are deferred). - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op1) - .bind(corr1) - .bind(vec![0xE1_u8; 32]) // semantic_fingerprint - .bind(attempt1_id) - .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, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event1) - .bind(op1) - .bind(corr1) - .bind(attempt1_id) - .bind(vec![0xE1_u8; 32]) // semantic_fingerprint matches denial attempt - .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, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event2) - .bind(op2) - .bind(corr2) - .bind(attempt2_id) - .bind(vec![0xF0_u8; 32]) // semantic_fingerprint (non-zero) - .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); - - // --- Negatives B1-B3: semantic coordinate mismatches, each attributed to - // the named guard (23514), not the old deferred FK (23503). Each case - // inserts a valid event then a denial attempt that matches everywhere - // except one coordinate; the guard must fire for that mismatch. - - // B1: correlation_id mismatch — attempt carries a different correlation - // than the event it references. - let op_b1 = uuid::Uuid::new_v4(); - let event_b1 = uuid::Uuid::new_v4(); - let corr_b1_event = uuid::Uuid::new_v4(); - let corr_b1_wrong = uuid::Uuid::new_v4(); // different from corr_b1_event - let attempt_b1 = uuid::Uuid::new_v4(); - - let mut conn_b1 = pool.acquire().await.expect("acquire connection B1"); - sqlx::query("BEGIN") - .execute(&mut *conn_b1) - .await - .expect("begin B1"); - - // Insert the event first (deferred FK allows this ordering). - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b1) - .bind(op_b1) - .bind(corr_b1_event) - .bind(attempt_b1) - .bind(vec![0xB3_u8; 32]) // semantic_fingerprint matches denial attempt - .bind(vec![0xB1_u8; 64]) - .bind(vec![0xB2_u8; 32]) - .execute(&mut *conn_b1) - .await - .expect("insert kind-9 event for B1"); - - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op_b1) - .bind(corr_b1_wrong) // wrong correlation_id - .bind(vec![0xB3_u8; 32]) - .bind(attempt_b1) - .bind(event_b1) - .execute(&mut *conn_b1) - .await - .expect("insert denial attempt with wrong correlation_id (guard deferred)"); - - let corr_err = sqlx::query("COMMIT") - .execute(&mut *conn_b1) - .await - .expect_err("mismatched correlation_id must be rejected at commit"); - assert!( - corr_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from authorization_denial_attempt_semantic_binding \ - for correlation_id mismatch, got: {corr_err}" - ); - drop(conn_b1); - - // B2: reason_code mismatch — attempt carries denial_reason=1 (MissingCredential, - // requires reason_code=2 per canonical mapping), but event carries reason_code=1. - // The attempt INSERT passes (denial_reason=1↔reason_code=2 is a valid mapping pair), - // then the deferred guard fires at commit because event reason_code=1 ≠ attempt - // reason_code=2. - let op_b2 = uuid::Uuid::new_v4(); - let event_b2 = uuid::Uuid::new_v4(); - let corr_b2 = uuid::Uuid::new_v4(); - let attempt_b2 = uuid::Uuid::new_v4(); - - let mut conn_b2 = pool.acquire().await.expect("acquire connection B2"); - sqlx::query("BEGIN") - .execute(&mut *conn_b2) - .await - .expect("begin B2"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b2) - .bind(op_b2) - .bind(corr_b2) - .bind(attempt_b2) - .bind(vec![0xC3_u8; 32]) // semantic_fingerprint matches denial attempt - .bind(vec![0xC1_u8; 64]) - .bind(vec![0xC2_u8; 32]) - .execute(&mut *conn_b2) - .await - .expect("insert kind-9 event for B2 (reason_code=1)"); - - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - // reason_code = 2 but event has reason_code = 1 - ) - .bind(community_id) - .bind(op_b2) - .bind(corr_b2) - .bind(vec![0xC3_u8; 32]) - .bind(attempt_b2) - .bind(event_b2) - .execute(&mut *conn_b2) - .await - .expect( - "insert denial attempt with wrong reason_code (deferred guard will fire at commit)", - ); - - let reason_err = sqlx::query("COMMIT") - .execute(&mut *conn_b2) - .await - .expect_err("mismatched reason_code must be rejected at commit"); - assert!( - reason_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from authorization_denial_attempt_semantic_binding \ - for reason_code mismatch, got: {reason_err}" - ); - drop(conn_b2); - - // B3: attempt_id mismatch — the denial attempt's attempt_id FK references - // a different event (attempt_b3_wrong) than the one being paired (event_b3). - // The attempt_id FK on the denial attempt table binds - // (community_id, operation_id, audit_event_kind, attempt_id) - // -> authorization_events(community_id, operation_id, event_kind, attempt_id) - // so using a different attempt_id that doesn't exist for this operation - // will be caught as a FK violation (23503) at commit. - let op_b3 = uuid::Uuid::new_v4(); - let event_b3 = uuid::Uuid::new_v4(); - let corr_b3 = uuid::Uuid::new_v4(); - let attempt_b3_correct = uuid::Uuid::new_v4(); - let attempt_b3_wrong = uuid::Uuid::new_v4(); // not registered for this operation - - let mut conn_b3 = pool.acquire().await.expect("acquire connection B3"); - sqlx::query("BEGIN") - .execute(&mut *conn_b3) - .await - .expect("begin B3"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b3) - .bind(op_b3) - .bind(corr_b3) - .bind(attempt_b3_correct) - .bind(vec![0xD3_u8; 32]) // semantic_fingerprint (matches denial attempt) - .bind(vec![0xD1_u8; 64]) - .bind(vec![0xD2_u8; 32]) - .execute(&mut *conn_b3) - .await - .expect("insert kind-9 event for B3"); - - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op_b3) - .bind(corr_b3) - .bind(vec![0xD3_u8; 32]) - .bind(attempt_b3_wrong) // wrong attempt_id — no matching UNIQUE row on events - .bind(event_b3) - .execute(&mut *conn_b3) - .await - .expect("insert denial attempt with wrong attempt_id (FK is deferred)"); - - let attempt_err = sqlx::query("COMMIT") - .execute(&mut *conn_b3) - .await - .expect_err("mismatched attempt_id must be rejected at commit"); - // The attempt_id FK is deferred and fires as foreign_key_violation (23503). - assert!( - attempt_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23503")) - .unwrap_or(false), - "expected foreign_key_violation (23503) for attempt_id mismatch \ - (deferred FK on denial attempt), got: {attempt_err}" - ); - - // B4: denial_reason ↔ reason_code mapping violation — the denial attempt - // row carries denial_reason=2 (InvalidCredential) but reason_code=2 - // (Missing). The canonical mapping requires InvalidCredential(2)↔Invalid(3); - // reason_code=2 is only valid for MissingCredential(denial_reason=1). - // The immediate CHECK constraint authorization_denial_reason_reason_code_binding - // fires at INSERT, not commit. Mutation-sensitive: removing the CHECK lets - // this INSERT succeed (the guard does not compare denial_reason; only the - // paired event's reason_code is checked at commit). - let op_b4 = uuid::Uuid::new_v4(); - let event_b4 = uuid::Uuid::new_v4(); - let corr_b4 = uuid::Uuid::new_v4(); - let attempt_b4 = uuid::Uuid::new_v4(); - - let mut conn_b4 = pool.acquire().await.expect("acquire connection B4"); - sqlx::query("BEGIN") - .execute(&mut *conn_b4) - .await - .expect("begin B4"); - - // Insert the matching kind-9 event first. - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b4) - .bind(op_b4) - .bind(corr_b4) - .bind(attempt_b4) - .bind(vec![0xE4_u8; 32]) // semantic_fingerprint - .bind(vec![0xE5_u8; 64]) - .bind(vec![0xE6_u8; 32]) - .execute(&mut *conn_b4) - .await - .expect("insert kind-9 event for B4"); - - // Insert denial attempt with denial_reason=2 (InvalidCredential) but - // reason_code=2 (Missing) — violates the canonical mapping (requires reason_code=3). - let denial_reason_err = sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 2, 1, 1, 2, $5, $6, 9)", - // denial_reason=2 (InvalidCredential) requires reason_code=3; reason_code=2 is wrong - ) - .bind(community_id) - .bind(op_b4) - .bind(corr_b4) - .bind(vec![0xE4_u8; 32]) - .bind(attempt_b4) - .bind(event_b4) - .execute(&mut *conn_b4) - .await - .expect_err("denial_reason/reason_code mapping violation must be rejected at INSERT"); - - assert!( - denial_reason_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from \ - authorization_denial_reason_reason_code_binding for denial_reason mismatch, \ - got: {denial_reason_err}" - ); - - // B5: semantic_fingerprint mismatch — the event carries semantic_fingerprint - // 0xB5…B5 while the denial attempt carries 0xB6…B6. correlation_id, reason_code, - // and attempt_id all match; only the fingerprint differs. The deferred guard - // authorization_denial_attempt_guard_v1 fires at COMMIT on the denial-attempt - // side, compares found_semantic_fingerprint (from the event) with - // NEW.semantic_fingerprint (from the attempt), and raises 23514 with named - // constraint authorization_denial_attempt_semantic_binding. - // Mutation-sensitive: removing the semantic_fingerprint comparison block from - // the guard function lets this transaction commit. - let op_b5 = uuid::Uuid::new_v4(); - let event_b5 = uuid::Uuid::new_v4(); - let corr_b5 = uuid::Uuid::new_v4(); - let attempt_b5 = uuid::Uuid::new_v4(); - let fp_event_b5 = vec![0xB5_u8; 32]; // event semantic_fingerprint - let fp_attempt_b5 = vec![0xB6_u8; 32]; // mismatched attempt semantic_fingerprint - - let mut conn_b5 = pool.acquire().await.expect("acquire connection B5"); - sqlx::query("BEGIN") - .execute(&mut *conn_b5) - .await - .expect("begin B5"); - - // Insert the kind-9 event with fingerprint 0xB5…B5. - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b5) - .bind(op_b5) - .bind(corr_b5) - .bind(attempt_b5) - .bind(fp_event_b5) - .bind(vec![0xB7_u8; 64]) // canonical_envelope - .bind(vec![0xB8_u8; 32]) // envelope_digest - .execute(&mut *conn_b5) - .await - .expect("insert kind-9 event for B5"); - - // Insert denial attempt with the WRONG semantic_fingerprint (0xB6…B6). - // correlation_id, reason_code=2, denial_reason=1 (MissingCredential↔Missing), - // and attempt_id all match the event — only semantic_fingerprint differs. - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op_b5) - .bind(corr_b5) - .bind(fp_attempt_b5) // 0xB6…B6 ≠ event's 0xB5…B5 - .bind(attempt_b5) - .bind(event_b5) - .execute(&mut *conn_b5) - .await - .expect( - "insert denial attempt with mismatched fingerprint (deferred guard fires at commit)", - ); - - let fp_mismatch_err = sqlx::query("COMMIT") - .execute(&mut *conn_b5) - .await - .expect_err("commit with mismatched semantic_fingerprint must be rejected"); - - assert!( - fp_mismatch_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from \ - authorization_denial_attempt_semantic_binding for semantic_fingerprint mismatch, \ - got: {fp_mismatch_err}" - ); - } - - /// NIP-FI authenticated kind-9 OperatorDenied denial: an authenticated - /// kind-9 event (actor_kind 1–3, non-null request_fingerprint) must commit - /// without an authorization_authentication_denial_attempts row and must - /// reject any attempt to attach one. - /// - /// Mutation sensitivity: - /// - Removing the `actor_kind <> 4` guard from the event-side trigger makes - /// positive A red: the COMMIT fails because the guard now requires a - /// denial-attempt row for the authenticated event and none is present. - /// - Removing the `actor_kind <> 4` shape guard from the attempt-side - /// trigger makes negative B red: the COMMIT is rejected by the pre-existing - /// `authorization_denial_attempt_semantic_binding` guard instead (non-null - /// attempt `semantic_fingerprint` vs. null on the authenticated event), so - /// `assert_eq!` on the constraint name fails. The exact constraint name - /// assertion is therefore the load-bearing proof that the new shape guard — - /// not the pre-existing semantic-binding check — is what fires. - /// - /// The unresolved pre-auth positive path (actor_kind 4) is exercised in - /// `authorization_denial_attempt_requires_kind_9_event_bidirectional` and - /// is unchanged by this fix. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn authenticated_kind_9_denial_commits_without_denial_attempt() { - 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!("auth-denial-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - 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"); - - // Seed an operation receipt for the authenticated denial. Use - // operation_kind = 12 (invalidation) with outcome_code = 2 (denied): - // this satisfies the receipt CHECK constraints without triggering the - // lifecycle history guard (expected_count = 0 for non-lifecycle kinds) - // and without requiring a lifecycle event (expected_event_kind = NULL). - // The authorization_events FK on (community_id, operation_id, - // request_fingerprint) requires a receipt row. - let op_auth = uuid::Uuid::new_v4(); - let fp_auth = vec![0xA1_u8; 32]; - - 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, 2, $5)", - // operation_kind 12 (invalidation), outcome_code 2 (denied) - ) - .bind(community_id) - .bind(op_auth) - .bind(&fp_auth) - .bind(vec![0xA2_u8; 32]) // actor_fingerprint - .bind(vec![0xA3_u8; 32]) // result_digest - .execute(&pool) - .await - .expect("seed authenticated denial receipt"); - - let event_auth = uuid::Uuid::new_v4(); - let corr_auth = uuid::Uuid::new_v4(); - let attempt_auth = uuid::Uuid::new_v4(); - - // --- Positive A: authenticated kind-9 denial (actor_kind = 1) commits - // without any denial-attempt row. The semantic_fingerprint must be NULL - // per the corrected shape CHECK. The deferred cardinality guard must - // skip this event because actor_kind ≠ 4. - let mut conn = pool.acquire().await.expect("acquire connection"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - ) - .bind(community_id) - .bind(event_auth) - .bind(vec![0xA4_u8; 32]) // actor_fingerprint (required for actor_kind 1) - .bind(op_auth) - .bind(&fp_auth) // non-null request_fingerprint (authenticated shape) - .bind(corr_auth) - .bind(attempt_auth) - // semantic_fingerprint = NULL: authenticated kind-9 must not carry one - .bind(vec![0xA5_u8; 64]) // canonical_envelope - .bind(vec![0xA6_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("insert authenticated kind-9 event"); - - sqlx::query("COMMIT").execute(&mut *conn).await.expect( - "authenticated kind-9 denial must commit without a denial-attempt row \ - — the event-side cardinality guard must skip actor_kind 1", - ); - drop(conn); - - // Confirm no denial attempt was needed: the table must have zero rows - // for this event. - let attempt_count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM authorization_authentication_denial_attempts \ - WHERE community_id = $1 AND audit_event_id = $2", - ) - .bind(community_id) - .bind(event_auth) - .fetch_one(&pool) - .await - .expect("count denial attempts for authenticated event"); - assert_eq!( - attempt_count, 0, - "no denial-attempt row should exist for an authenticated kind-9 event" - ); - - // --- Negative B: a denial attempt cannot bind to the authenticated kind-9 - // event. The attempt-side shape guard must reject this at commit because - // the referenced event has actor_kind = 1 (not 4). The rejection must - // name the exact shape constraint (authorization_denial_attempt_event_kind) - // rather than merely returning 23514, proving the new actor/request-fingerprint - // guard fires — not the pre-existing semantic_fingerprint equality check - // (which would fire as authorization_denial_attempt_semantic_binding if - // the shape guard were absent, because the attempt carries a non-null - // semantic_fingerprint while the authenticated event has null). - // - // Reuse attempt_auth from the committed event so the deferred attempt_id - // FK resolves (wrong attempt_id would activate that FK first and make the - // negative non-isolated to the new guard). - let mut conn_b = pool.acquire().await.expect("acquire connection B"); - sqlx::query("BEGIN") - .execute(&mut *conn_b) - .await - .expect("begin B"); - - // Insert the denial attempt referencing the authenticated event. - // The attempt table FKs are deferred, so this INSERT succeeds; - // the shape guard fires at COMMIT. - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op_auth) - .bind(corr_auth) - .bind(vec![0xA7_u8; 32]) // semantic_fingerprint on the attempt (non-null) - .bind(attempt_auth) // reuse the event's attempt_id — FK isolation - .bind(event_auth) // references the authenticated event (actor_kind = 1) - .execute(&mut *conn_b) - .await - .expect("attempt INSERT must pass — shape guard is deferred"); - - let cross_shape_err = sqlx::query("COMMIT") - .execute(&mut *conn_b) - .await - .expect_err( - "denial attempt binding to authenticated kind-9 event must be rejected at commit", - ); - // The exact constraint name must be authorization_denial_attempt_event_kind — - // the new actor/request_fingerprint shape guard. If the shape guard were - // removed, the pre-existing semantic_fingerprint equality check would fire - // instead, named authorization_denial_attempt_semantic_binding. Requiring - // the exact name makes the mutation reliably red. - assert_eq!( - cross_shape_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denial_attempt_event_kind"), - "rejection must be attributed to authorization_denial_attempt_event_kind \ - shape guard (not an incidental FK or semantic-binding check), \ - got: {cross_shape_err}" - ); - } - - /// NIP-FI denied lifecycle receipt: a denied core lifecycle receipt - /// (outcome_code = 2) must commit without a paired audit event. Requiring - /// one would falsely record that the lifecycle transition occurred. - /// - /// The denied branch forbids any event from the complete core - /// success-transition class (kinds 1, 2, 3, 6). This test uses the mapped - /// kind (kind 1 for enroll). Cross-kind rejection — a wrong success-transition - /// kind on a denied receipt — is exercised by - /// `denied_lifecycle_receipt_wrong_kind_receipt_side` (receipt-side trigger) - /// and `denied_lifecycle_receipt_wrong_kind_event_side` (event-side trigger). - /// - /// Mutation sensitivity: - /// - Removing the `outcome_code IN (1, 3)` branch entirely (or replacing it with a - /// blanket early-return) makes the positive case red — the denied enroll receipt - /// cannot commit alone because the guard then demands a paired enroll audit event - /// (expected_event_kind = 1) that is absent. - /// - Removing the `ELSIF outcome_code = 2` zero-event branch makes the - /// receipt-then-event negative below green (COMMIT succeeds when it must not), - /// failing `expect_err`. The event-side isolation in - /// `denied_lifecycle_receipt_event_side_trigger_isolated` independently confirms - /// the same branch using only the `authorization_event_receipt_cardinality` - /// trigger direction. - /// Applied/no-op lifecycle cardinality is exercised by - /// `applied_lifecycle_receipt_requires_exactly_one_event`. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn denied_lifecycle_receipt_commits_without_audit_event() { - 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!( - "denied-lifecycle-{}.example", - community_id.simple() - )) - .execute(&pool) - .await - .expect("insert community"); - - // Denied enroll receipt (operation_kind = 1, outcome_code = 2) must commit - // without any paired authorization_events row. The guard must skip it - // because outcome_code = 2 is not in (1, 3). - // - // The receipt history guard (migration 0041) uses `outcome_code IN (1, 3)` - // for lifecycle receipts, so a denied enroll receipt (outcome_code = 2) - // expects zero lifecycle history rows — no history setup is needed. - let op_denied = uuid::Uuid::new_v4(); - let fp_denied = vec![0xB1_u8; 32]; - - 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, 1, $4, 2, $5)", - // operation_kind 1 (enroll), outcome_code 2 (denied) - ) - .bind(community_id) - .bind(op_denied) - .bind(&fp_denied) - .bind(vec![0xB2_u8; 32]) - .bind(vec![0xB3_u8; 32]) - .execute(&pool) - .await - .expect("denied enroll receipt must commit without a paired audit event"); - - // No audit event for this operation; confirm the table is empty for it. - let event_count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM authorization_events \ - WHERE community_id = $1 AND operation_id = $2", - ) - .bind(community_id) - .bind(op_denied) - .fetch_one(&pool) - .await - .expect("count events for denied receipt"); - assert_eq!( - event_count, 0, - "no audit event should be required or present for a denied lifecycle receipt" - ); - - // --- Negative: denied enroll receipt paired with its mapped success- - // transition event (event_kind = 1, enrolled) must be rejected at COMMIT. - // The receipt-side deferred trigger fires here (receipt was inserted in - // this same transaction). The event-side trigger direction is isolated in - // `denied_lifecycle_receipt_event_side_trigger_isolated`. - // - // Seed event capacity; the authorization_events BEFORE INSERT trigger - // requires a capacity row. No lifecycle history is needed: denied receipts - // (outcome_code = 2) expect zero history rows per the history guard. - 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 op_neg = uuid::Uuid::new_v4(); - let fp_neg = vec![0xD1_u8; 32]; - let event_neg = uuid::Uuid::new_v4(); - let corr_neg = uuid::Uuid::new_v4(); - let attempt_neg = uuid::Uuid::new_v4(); - - let mut conn_neg = pool.acquire().await.expect("acquire connection neg"); - sqlx::query("BEGIN") - .execute(&mut *conn_neg) - .await - .expect("begin neg"); - - // Denied enroll receipt — no history row needed (outcome_code = 2). - 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, 1, $4, 2, $5)", - ) - .bind(community_id) - .bind(op_neg) - .bind(&fp_neg) - .bind(vec![0xD2_u8; 32]) - .bind(vec![0xD3_u8; 32]) - .execute(&mut *conn_neg) - .await - .expect("insert denied receipt — event guard is deferred"); - - // Insert the mapped success-transition event (event_kind = 1, enrolled). - // actor_kind = 1 requires a non-null actor_fingerprint and a matching - // receipt FK (satisfied by the denied receipt above, which shares the - // same (community_id, operation_id, request_fingerprint)). - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - // event_kind 1 (enrolled) — the mapped success transition for enroll - ) - .bind(community_id) - .bind(event_neg) - .bind(vec![0xD4_u8; 32]) // actor_fingerprint - .bind(op_neg) - .bind(&fp_neg) - .bind(corr_neg) - .bind(attempt_neg) - .bind(vec![0xD5_u8; 64]) // canonical_envelope - .bind(vec![0xD6_u8; 32]) // envelope_digest - .execute(&mut *conn_neg) - .await - .expect("event INSERT must pass — deferred guard fires at COMMIT"); - - let contradiction_err = sqlx::query("COMMIT") - .execute(&mut *conn_neg) - .await - .expect_err( - "denied receipt + mapped success event must be rejected at COMMIT \ - — contradictory durable facts must not be permitted", - ); - assert_eq!( - contradiction_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denied_lifecycle_receipt_no_success_event"), - "expected authorization_denied_lifecycle_receipt_no_success_event constraint \ - rejection for denied receipt + success event, got: {contradiction_err}" - ); - } - - /// NIP-FI event-side trigger isolation: when a denied enroll receipt is already - /// committed (auto-commit via pool), a new independent transaction that inserts - /// only the mapped success-transition event must be rejected at COMMIT by - /// `authorization_event_receipt_cardinality` (the event-side deferred trigger). - /// - /// This isolates the `authorization_event_receipt_cardinality` trigger path. - /// In `denied_lifecycle_receipt_commits_without_audit_event`'s receipt-then-event - /// negative, the receipt-side trigger (`authorization_operation_receipt_event_cardinality`) - /// also fires. Here the committed receipt produces no deferred trigger, so rejection - /// can only come from the event-side trigger. Uses the mapped kind (kind 1 for - /// enroll). Wrong-kind event-side isolation is in - /// `denied_lifecycle_receipt_wrong_kind_event_side`. - /// - /// Mutation sensitivity: disabling the - /// `authorization_event_receipt_cardinality` trigger (DROP or ALTER TABLE - /// DISABLE TRIGGER) makes this negative green — the COMMIT succeeds when it - /// must not, so `expect_err` panics. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn denied_lifecycle_receipt_event_side_trigger_isolated() { - 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!("evt-side-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Seed event capacity before any event insert. - 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"); - - // Commit a denied enroll receipt in auto-commit mode (no explicit BEGIN). - // This receipt produces no deferred trigger — the receipt-side deferred - // trigger only fires within the transaction that inserts the receipt row. - let op_id = uuid::Uuid::new_v4(); - let fp = vec![0xE1_u8; 32]; - 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, 1, $4, 2, $5)", - ) - .bind(community_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xE2_u8; 32]) - .bind(vec![0xE3_u8; 32]) - .execute(&pool) - .await - .expect("denied receipt must commit alone in auto-commit mode"); - - // Now open a NEW transaction and insert only the mapped success-transition - // event (event_kind = 1, enrolled). The receipt is already committed and - // its deferred trigger is no longer active. Rejection at COMMIT must come - // from authorization_event_receipt_cardinality (the event-side trigger). - let event_id = uuid::Uuid::new_v4(); - let corr_id = uuid::Uuid::new_v4(); - let attempt_id = uuid::Uuid::new_v4(); - - let mut conn = pool - .acquire() - .await - .expect("acquire connection for event-side test"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin event-side transaction"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - ) - .bind(community_id) - .bind(event_id) - .bind(vec![0xE4_u8; 32]) // actor_fingerprint - .bind(op_id) - .bind(&fp) - .bind(corr_id) - .bind(attempt_id) - .bind(vec![0xE5_u8; 64]) // canonical_envelope - .bind(vec![0xE6_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("event INSERT must pass — event-side deferred guard fires at COMMIT"); - - let event_side_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( - "mapping a success-transition event to a committed denied receipt \ - must be rejected at COMMIT by the event-side trigger", - ); - assert_eq!( - event_side_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denied_lifecycle_receipt_no_success_event"), - "event-side trigger must reject with authorization_denied_lifecycle_receipt_no_success_event, \ - got: {event_side_err}" - ); - } - - /// NIP-FI applied lifecycle receipt: an applied core lifecycle enroll receipt - /// (outcome_code = 1) requires exactly one mapped success-transition event - /// (event_kind = 1, enrolled). This exercises the `outcome_code IN (1, 3)` - /// branch of `authorization_operation_receipt_event_guard_v1` at migration 42. - /// - /// Mutation sensitivity: - /// - Removing/bypassing the applied/no-op branch (replacing it with a blanket - /// RETURN NULL) makes the positive transaction commit without an event, leaving - /// the contract silently unenforced. The negative below requires the cardinality - /// constraint to fire when the event is absent. - /// - Removing the negative assertion: the absent-event case would commit when it - /// must not. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn applied_lifecycle_receipt_requires_exactly_one_event() { - 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!( - "applied-lifecycle-{}.example", - community_id.simple() - )) - .execute(&pool) - .await - .expect("insert community"); - - // Enrollment policy (TOFU, mode 3). - let policy_revision: i64 = 1; - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(policy_revision) - .bind(vec![0xF0_u8; 32]) - .execute(&pool) - .await - .expect("insert enrollment policy"); - - // Event capacity — required by authorization_event_capacity_before_insert_v1. - 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"); - - // --- Positive: applied enroll commits with exactly one mapped event --- - // - // All cross-table FKs between identity_lifecycle_history, identity_bindings, - // authorization_operation_receipts, and authorization_events are - // DEFERRABLE INITIALLY DEFERRED — insert order within the transaction is - // flexible, but a pinned connection is required for BEGIN/COMMIT to share - // the same session. The receipt_history_cardinality trigger (migration 0041) - // fires at COMMIT and requires exactly one history row for applied enroll. - let op_id = uuid::Uuid::new_v4(); - let binding_id = uuid::Uuid::new_v4(); - let history_id = uuid::Uuid::new_v4(); - let fp = vec![0xF1_u8; 32]; - let event_id = uuid::Uuid::new_v4(); - let corr_id = uuid::Uuid::new_v4(); - let attempt_id = uuid::Uuid::new_v4(); - - let mut conn = pool - .acquire() - .await - .expect("acquire connection for positive case"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin positive transaction"); - - // History first: the receipt_history_cardinality AFTER INSERT trigger - // on authorization_operation_receipts is DEFERRED and checks at COMMIT - // time, but inserting history before receipt is idiomatic. - // successor_binding_version = 1 because binding_version is an identity - // sequence starting at 1 per community; this is the first binding. - sqlx::query( - "INSERT INTO identity_lifecycle_history \ - (community_id, history_id, transition_kind, outcome_code, \ - successor_binding_id, successor_binding_version, \ - successor_lifecycle_revision, successor_state, \ - operation_id, request_fingerprint, transition_digest) \ - VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)", - ) - .bind(community_id) - .bind(history_id) - .bind(binding_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xF2_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert lifecycle history"); - - // Applied enroll receipt (operation_kind = 1, outcome_code = 1). - 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, 1, $4, 1, $5)", - ) - .bind(community_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xF3_u8; 32]) - .bind(vec![0xF4_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert applied enroll receipt"); - - // Binding — birth_history_id FK is deferred; binding_version is generated. - sqlx::query( - "INSERT INTO identity_bindings \ - (community_id, binding_id, issuer, subject, \ - principal_fingerprint, event_author_pubkey, \ - binding_state, lifecycle_revision, binding_provenance, \ - policy_revision, enrollment_evidence_digest, \ - birth_history_id, creation_operation_id, \ - creation_request_fingerprint) \ - VALUES ($1, $2, 'https://issuer.example', 'sub-applied', \ - $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", - ) - .bind(community_id) - .bind(binding_id) - .bind(vec![0xF5_u8; 32]) // principal_fingerprint - .bind(vec![0xF6_u8; 32]) // event_author_pubkey - .bind(policy_revision) - .bind(vec![0xF7_u8; 32]) // enrollment_evidence_digest - .bind(history_id) - .bind(op_id) - .bind(&fp) - .execute(&mut *conn) - .await - .expect("insert identity binding"); - - // Mapped success-transition event (event_kind = 1, enrolled). - // actor_kind = 1 requires non-null actor_fingerprint and matching receipt FK. - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - ) - .bind(community_id) - .bind(event_id) - .bind(vec![0xF8_u8; 32]) // actor_fingerprint - .bind(op_id) - .bind(&fp) - .bind(corr_id) - .bind(attempt_id) - .bind(vec![0xF9_u8; 64]) // canonical_envelope - .bind(vec![0xFA_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("insert mapped success-transition event"); - - sqlx::query("COMMIT").execute(&mut *conn).await.expect( - "applied enroll receipt + exactly one mapped event must commit — \ - authorization_operation_receipt_event_guard_v1 applied/no-op branch", - ); - - // Confirm exactly one event committed for this operation. - let event_count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM authorization_events \ - WHERE community_id = $1 AND operation_id = $2", - ) - .bind(community_id) - .bind(op_id) - .fetch_one(&pool) - .await - .expect("count events for applied receipt"); - assert_eq!( - event_count, 1, - "exactly one audit event must be present for an applied enroll receipt" - ); - - // --- Negative: applied enroll receipt without a mapped event must reject --- - // - // A second applied enroll transaction that commits receipt + history + binding - // but no event must be rejected with authorization_operation_receipt_event_cardinality. - let op_neg = uuid::Uuid::new_v4(); - let binding_neg = uuid::Uuid::new_v4(); - let history_neg = uuid::Uuid::new_v4(); - let fp_neg = vec![0xFB_u8; 32]; - - let mut conn_neg = pool - .acquire() - .await - .expect("acquire connection for negative case"); - sqlx::query("BEGIN") - .execute(&mut *conn_neg) - .await - .expect("begin negative transaction"); - - sqlx::query( - "INSERT INTO identity_lifecycle_history \ - (community_id, history_id, transition_kind, outcome_code, \ - successor_binding_id, successor_binding_version, \ - successor_lifecycle_revision, successor_state, \ - operation_id, request_fingerprint, transition_digest) \ - VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)", - // successor_binding_version = 2: second binding in this community - ) - .bind(community_id) - .bind(history_neg) - .bind(binding_neg) - .bind(op_neg) - .bind(&fp_neg) - .bind(vec![0xFC_u8; 32]) - .execute(&mut *conn_neg) - .await - .expect("insert negative lifecycle history"); - - 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, 1, $4, 1, $5)", - ) - .bind(community_id) - .bind(op_neg) - .bind(&fp_neg) - .bind(vec![0xFD_u8; 32]) - .bind(vec![0xFE_u8; 32]) - .execute(&mut *conn_neg) - .await - .expect("insert negative applied receipt"); - - sqlx::query( - "INSERT INTO identity_bindings \ - (community_id, binding_id, issuer, subject, \ - principal_fingerprint, event_author_pubkey, \ - binding_state, lifecycle_revision, binding_provenance, \ - policy_revision, enrollment_evidence_digest, \ - birth_history_id, creation_operation_id, \ - creation_request_fingerprint) \ - VALUES ($1, $2, 'https://issuer.example', 'sub-applied-neg', \ - $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", - ) - .bind(community_id) - .bind(binding_neg) - .bind(vec![0xE7_u8; 32]) // principal_fingerprint (distinct from positive) - .bind(vec![0xE8_u8; 32]) // event_author_pubkey (distinct from positive) - .bind(policy_revision) - .bind(vec![0xE9_u8; 32]) - .bind(history_neg) - .bind(op_neg) - .bind(&fp_neg) - .execute(&mut *conn_neg) - .await - .expect("insert negative binding — no event inserted"); - - // Commit without the mapped event — guard must reject. - let absent_event_err = sqlx::query("COMMIT") - .execute(&mut *conn_neg) - .await - .expect_err( - "applied enroll receipt without a mapped success-transition event \ - must be rejected at COMMIT", - ); - assert_eq!( - absent_event_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_operation_receipt_event_cardinality"), - "expected authorization_operation_receipt_event_cardinality rejection \ - for applied receipt without event, got: {absent_event_err}" - ); - } - - /// NIP-FI cross-kind denied lifecycle: a wrong success-transition kind paired - /// with a denied lifecycle receipt must be rejected through the receipt-side - /// deferred trigger. Uses a denied enroll receipt (operation_kind = 1, mapped - /// kind = 1) with a kind-6 (retired) event — a different success-transition - /// kind that is equally forbidden by the class-based guard (kinds 1, 2, 3, 6). - /// - /// Both the receipt and the wrong-kind event are inserted in the same - /// transaction, so the receipt-side deferred trigger - /// (`authorization_operation_receipt_event_cardinality`) fires at COMMIT. - /// - /// Mutation sensitivity: narrowing the denied filter back to - /// `event_kind = expected_event_kind` (the mapped kind, 1) removes kind 6 - /// from the forbidden set, causing this negative to turn green — COMMIT - /// succeeds when it must not, failing `expect_err`. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn denied_lifecycle_receipt_wrong_kind_receipt_side() { - 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!("wrong-kind-rcpt-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - 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 op_id = uuid::Uuid::new_v4(); - let fp = vec![0xA0_u8; 32]; - let event_id = uuid::Uuid::new_v4(); - let corr_id = uuid::Uuid::new_v4(); - let attempt_id = uuid::Uuid::new_v4(); - - let mut conn = pool - .acquire() - .await - .expect("acquire connection for wrong-kind receipt-side test"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - // Denied enroll receipt (operation_kind = 1, outcome_code = 2). - // No history row needed: outcome_code = 2 expects zero history rows. - 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, 1, $4, 2, $5)", - ) - .bind(community_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xA1_u8; 32]) - .bind(vec![0xA2_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert denied enroll receipt — deferred guard"); - - // Wrong success-transition kind: event_kind = 6 (retired), not the mapped - // kind 1 (enrolled). Both are in the forbidden class (1, 2, 3, 6). - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 6, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - // event_kind 6 (retired) — wrong kind for a denied enroll receipt - ) - .bind(community_id) - .bind(event_id) - .bind(vec![0xA3_u8; 32]) // actor_fingerprint - .bind(op_id) - .bind(&fp) - .bind(corr_id) - .bind(attempt_id) - .bind(vec![0xA4_u8; 64]) // canonical_envelope - .bind(vec![0xA5_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("event INSERT must pass — deferred guard fires at COMMIT"); - - let wrong_kind_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( - "denied receipt + wrong success-transition kind (6) must be rejected at COMMIT \ - — class-based guard forbids all of kinds 1, 2, 3, 6", - ); - assert_eq!( - wrong_kind_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denied_lifecycle_receipt_no_success_event"), - "expected authorization_denied_lifecycle_receipt_no_success_event for \ - denied receipt + wrong kind (6), got: {wrong_kind_err}" - ); - } - - /// NIP-FI cross-kind denied lifecycle event-side: after a denied enroll - /// receipt is committed alone (auto-commit), a new transaction that inserts - /// only a wrong success-transition kind (kind 6, retired) must be rejected at - /// COMMIT by `authorization_event_receipt_cardinality` (event-side trigger). - /// - /// This isolates the event-side trigger path for the cross-kind case. - /// The committed receipt produces no active deferred trigger, so rejection - /// can only come from the event-side trigger. - /// - /// Mutation sensitivity: narrowing the denied filter to - /// `event_kind = expected_event_kind` (kind 1) removes kind 6 from the - /// forbidden set, making this negative green — COMMIT succeeds when it must - /// not, failing `expect_err`. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn denied_lifecycle_receipt_wrong_kind_event_side() { - 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!("wrong-kind-evt-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - 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"); - - // Commit a denied enroll receipt in auto-commit mode. No deferred trigger - // is active after this commit; the receipt-side trigger fires only within - // the transaction that inserts the receipt. - let op_id = uuid::Uuid::new_v4(); - let fp = vec![0xB0_u8; 32]; - 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, 1, $4, 2, $5)", - ) - .bind(community_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xB1_u8; 32]) - .bind(vec![0xB2_u8; 32]) - .execute(&pool) - .await - .expect("denied receipt must commit alone in auto-commit mode"); - - // New transaction: insert only a kind-6 (retired) event for the same - // operation. The event-side trigger is the only active deferred trigger. - let event_id = uuid::Uuid::new_v4(); - let corr_id = uuid::Uuid::new_v4(); - let attempt_id = uuid::Uuid::new_v4(); - - let mut conn = pool - .acquire() - .await - .expect("acquire connection for wrong-kind event-side test"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin event-side wrong-kind transaction"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 6, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - // event_kind 6 (retired) — wrong kind for the denied enroll receipt - ) - .bind(community_id) - .bind(event_id) - .bind(vec![0xB3_u8; 32]) // actor_fingerprint - .bind(op_id) - .bind(&fp) - .bind(corr_id) - .bind(attempt_id) - .bind(vec![0xB4_u8; 64]) // canonical_envelope - .bind(vec![0xB5_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("event INSERT must pass — event-side deferred guard fires at COMMIT"); - - let wrong_kind_evt_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( - "kind-6 event paired with a committed denied enroll receipt must be \ - rejected at COMMIT by the event-side trigger", - ); - assert_eq!( - wrong_kind_evt_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denied_lifecycle_receipt_no_success_event"), - "event-side trigger must reject with authorization_denied_lifecycle_receipt_no_success_event \ - for wrong kind (6) against denied receipt, got: {wrong_kind_evt_err}" - ); + .expect("deletion catalog validates after migration 0044"); } } diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 845a150a4ee..08fa5762ee0 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -155,11 +155,8 @@ single-issuer deployment is a registry of length one. [FI-TRACE-CROSS-DOMAIN-CO The existing `FederatedAssertionVerifier` and `ProductionJwksSource` (merged in PR 3 / `70895b355`) implement the verification procedure described -here. The `require_attested_key` flag in `IssuerPolicy` is the per-issuer -enforcement primitive for the unconditional `nostr_pubkey` requirement in this -section; conformance to NIP-FI v2 requires startup validation that forces this -flag true for every configured issuer. That integration is a follow-on code -change outside this PR. +here. The `nostr_pubkey` claim is unconditionally required — absence rejects +regardless of issuer policy (NIP-FI v2, PR #7221). ### JWKS snapshot @@ -402,8 +399,7 @@ Any failure at any step is fail-closed: no side effects occur and the relay returns the appropriate error. This verifier and the disconnect API endpoint are follow-on code changes -outside this PR, in the same way that the `require_attested_key` enforcement -integration is. +outside this PR. ### Request diff --git a/migrations/0044_drop_nip_fi_ledger.sql b/migrations/0044_drop_nip_fi_ledger.sql new file mode 100644 index 00000000000..34dead49a71 --- /dev/null +++ b/migrations/0044_drop_nip_fi_ledger.sql @@ -0,0 +1,69 @@ +-- Remove the Phase-A NIP-FI relay-side authority ledger (migrations 0041 and +-- 0042). Will+Tyler resolved 2026-09-01: OSS Buzz is stateless for identity +-- ("Buzz speaks Nostr, nothing else"). The merged NIP-FI spec v2 (PR #7214, +-- squash d4420eb47) requires the nostr_pubkey claim + NIP-42 proof +-- unconditionally; the durable ledger tables are dead code. +-- +-- CASCADE handles the circular deferred FK between identity_bindings and +-- identity_lifecycle_history, and dispenses with strict drop ordering. + +-- ── 0042 tables ───────────────────────────────────────────────────────────── +DROP TABLE authorization_operation_version_deltas CASCADE; +DROP TABLE authorization_operation_version_delta_manifests CASCADE; +DROP TABLE authorization_authentication_denial_attempts CASCADE; +DROP TABLE authorization_admission_results CASCADE; +DROP TABLE authorization_events CASCADE; +DROP TABLE protected_object_authority CASCADE; +DROP TABLE authorization_authority_epochs CASCADE; +DROP TABLE authorization_invalidation_floors CASCADE; +DROP TABLE authorization_invalidation_domains CASCADE; +DROP TABLE authorization_event_capacity CASCADE; + +-- ── 0041 tables ───────────────────────────────────────────────────────────── +-- Circular deferred FK: identity_bindings ↔ identity_lifecycle_history. +-- DROP TABLE with CASCADE resolves it without a two-step ALTER/DROP. +DROP TABLE identity_lifecycle_selectors CASCADE; +DROP TABLE identity_lifecycle_history CASCADE; +DROP TABLE identity_bindings CASCADE; +DROP TABLE identity_enrollment_policies CASCADE; +DROP TABLE authorization_operation_receipts CASCADE; + +-- ── Shared functions (0041 introduced, 0042 widened) ──────────────────────── +-- Triggers were dropped with their tables above; drop functions separately. +DROP FUNCTION identity_enrollment_policy_revision_guard_v1; +DROP FUNCTION nip_fi_reject_row_mutation_v1; +DROP FUNCTION nip_fi_reject_truncate_v1; +DROP FUNCTION identity_lifecycle_lock_coordinates_v1; +DROP FUNCTION identity_bindings_insert_guard_v1; +DROP FUNCTION identity_bindings_transition_guard_v1; +DROP FUNCTION identity_lifecycle_history_insert_guard_v1; +DROP FUNCTION identity_binding_history_semantics_guard_v1; +DROP FUNCTION identity_binding_birth_eligibility_guard_v1; +DROP FUNCTION authorization_operation_receipt_history_guard_v1; +DROP FUNCTION identity_lifecycle_selector_insert_guard_v1; +DROP FUNCTION identity_lifecycle_selector_history_guard_v1; +DROP FUNCTION identity_lifecycle_transition_integrity_guard_v1; +DROP FUNCTION authorization_event_capacity_before_insert_v1; +DROP FUNCTION authorization_invalidation_domain_guard_v1; +DROP FUNCTION authorization_invalidation_floor_guard_v1; +DROP FUNCTION authorization_authority_epoch_guard_v1; +DROP FUNCTION authorization_event_capacity_guard_v1; +DROP FUNCTION protected_object_authority_guard_v1; +DROP FUNCTION authorization_denial_attempt_guard_v1; +DROP FUNCTION authorization_operation_version_delta_cardinality_guard_v1; +DROP FUNCTION authorization_admission_result_guard_v1; +DROP FUNCTION authorization_operation_receipt_event_guard_v1; + +-- ── Restore community_write_fence_excluded_table to its pre-0041 body ─────── +-- 0041 and 0042 each widened this function via CREATE OR REPLACE to exempt +-- the NIP-FI ledger relations from the community write fence and deletion +-- catalog. With those tables gone, revert to migration 0030's body. +CREATE OR REPLACE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN +LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT target::TEXT = ANY (ARRAY[ + 'community_deletion_requests', 'community_deletion_approvals', + 'community_deletion_checkpoints', 'community_serving_write_leases', + 'community_deletion_executor_heartbeats', 'product_feedback', + 'rate_limit_violations' + ]::TEXT[]) +$$; diff --git a/schema/schema.sql b/schema/schema.sql index 7d18d825a8b..09508125622 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1488,16 +1488,7 @@ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ 'community_deletion_requests', 'community_deletion_approvals', 'community_deletion_checkpoints', 'community_serving_write_leases', 'community_deletion_executor_heartbeats', 'product_feedback', - 'rate_limit_violations', - 'authorization_operation_receipts', 'identity_enrollment_policies', - 'identity_bindings', 'identity_lifecycle_history', - 'identity_lifecycle_selectors', - 'authorization_invalidation_domains', 'authorization_invalidation_floors', - 'authorization_authority_epochs', 'protected_object_authority', - 'authorization_event_capacity', 'authorization_events', - 'authorization_authentication_denial_attempts', - 'authorization_operation_version_delta_manifests', - 'authorization_operation_version_deltas', 'authorization_admission_results' + 'rate_limit_violations' ]::TEXT[]) $$; @@ -1902,1888 +1893,3 @@ CREATE INDEX idx_relay_operator_audit_target INSERT INTO _operator_global_tables (table_name, reason) VALUES ('relay_operator_audit', 'deployment-global append-only roster mutation audit trail; no community_id intentionally'); - --- ============================================================================ --- NIP-FI core identity + base-lifecycle foundation (mirror of migration 0041). --- The community_write_fence_excluded_table definition above already folds in --- the NIP-FI ledger relations; the per-migration CREATE OR REPLACE bodies are --- intentionally omitted here (desired state keeps one consolidated definition). --- ============================================================================ - --- The sole idempotency/result root shared by identity base lifecycle, --- protected operations, and invalidation. Pre-authentication denials never --- write this table. ExactReplay and IntentConflict are read-time observations, --- not persisted outcomes. -CREATE TABLE authorization_operation_receipts ( - community_id UUID NOT NULL REFERENCES communities(id), - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - -- Core operation kinds: 1 enroll, 3 retire, 5 revoke, 6 rotate, - -- 11 protected mutation, 12 invalidation. Extended lifecycle kinds - -- (2 provision, 4 disable, 7 recover, 8 enable, 9 admission loss) and - -- 10 operator are introduced by their owning later migrations. - operation_kind SMALLINT NOT NULL CHECK ( - operation_kind IN (1, 3, 5, 6, 11, 12) - ), - actor_fingerprint BYTEA NOT NULL CHECK (octet_length(actor_fingerprint) = 32), - -- 1 applied, 2 denied, 3 no-op. - outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3)), - result_digest BYTEA NOT NULL CHECK (octet_length(result_digest) = 32), - recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, operation_id), - UNIQUE (community_id, operation_id, request_fingerprint), - UNIQUE ( - community_id, - operation_id, - request_fingerprint, - operation_kind, - outcome_code - ), - CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid) -); - --- Immutable monotonic local policy revisions. Enrollment modes are the closed --- provider-free V1 set: 1 attested-key, 2 provisioned, 3 risk-labelled TOFU. -CREATE TABLE identity_enrollment_policies ( - community_id UUID NOT NULL REFERENCES communities(id), - policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), - enrollment_mode SMALLINT NOT NULL CHECK (enrollment_mode IN (1, 2, 3)), - policy_digest BYTEA NOT NULL CHECK (octet_length(policy_digest) = 32), - effective_at TIMESTAMPTZ NOT NULL, - -- Optional local binding-policy expiry. Federated token `exp` MUST NOT be - -- copied here: token lifetime bounds an authorization lease, not this - -- durable binding generation. - expires_at TIMESTAMPTZ, - recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, policy_revision), - CHECK (expires_at IS NULL OR effective_at < expires_at) -); - --- One row is one immutable binding generation. binding_version is allocated --- from one non-cycling PostgreSQL identity sequence and is never changed or --- reused. Explicit lifecycle may only retire the generation; X/Y denial --- semantics live in immutable selector facts below, not alternate row states. -CREATE TABLE identity_bindings ( - community_id UUID NOT NULL REFERENCES communities(id), - binding_id UUID NOT NULL, - binding_version BIGINT GENERATED ALWAYS AS IDENTITY ( - START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 NO CYCLE - ), - issuer TEXT COLLATE "C" NOT NULL CHECK (octet_length(issuer) BETWEEN 1 AND 2048), - subject TEXT COLLATE "C" NOT NULL CHECK (octet_length(subject) BETWEEN 1 AND 2048), - principal_fingerprint BYTEA NOT NULL CHECK (octet_length(principal_fingerprint) = 32), - event_author_pubkey BYTEA NOT NULL CHECK (octet_length(event_author_pubkey) = 32), - -- 1 active, 2 retired. - binding_state SMALLINT NOT NULL CHECK (binding_state IN (1, 2)), - lifecycle_revision BIGINT NOT NULL CHECK (lifecycle_revision IN (1, 2)), - -- 1 attested-key, 2 provisioned, 3 risk-labelled TOFU. - binding_provenance SMALLINT NOT NULL CHECK (binding_provenance IN (1, 2, 3)), - policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), - -- Canonical evidence for the selected provenance. This is an assertion - -- digest for attested/TOFU admission and a provisioning receipt digest for - -- separately provisioned admission; it never stores credential bytes. - enrollment_evidence_digest BYTEA NOT NULL CHECK ( - octet_length(enrollment_evidence_digest) = 32 - ), - expires_at TIMESTAMPTZ, - birth_history_id UUID NOT NULL, - creation_operation_id UUID NOT NULL, - creation_request_fingerprint BYTEA NOT NULL CHECK ( - octet_length(creation_request_fingerprint) = 32 - ), - retirement_history_id UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, binding_id), - UNIQUE (community_id, binding_version), - UNIQUE (community_id, binding_id, binding_version), - FOREIGN KEY (community_id, policy_revision) - REFERENCES identity_enrollment_policies - (community_id, policy_revision), - CHECK (binding_version > 0), - CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (birth_history_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (creation_operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (expires_at IS NULL OR created_at < expires_at), - CHECK ( - (binding_state = 1 AND lifecycle_revision = 1 AND retirement_history_id IS NULL) - OR (binding_state = 2 AND lifecycle_revision = 2 AND retirement_history_id IS NOT NULL) - ) -); - --- State 1 is Active. Expiry is evaluated with authoritative PostgreSQL time --- at read/finalization and is exclusive; it cannot appear in an index predicate. -CREATE UNIQUE INDEX identity_bindings_active_principal - ON identity_bindings (community_id, issuer, subject) - WHERE binding_state = 1; -CREATE INDEX identity_bindings_principal_fingerprint_lookup - ON identity_bindings (community_id, principal_fingerprint) - WHERE binding_state = 1; -CREATE UNIQUE INDEX identity_bindings_active_event_author - ON identity_bindings (community_id, event_author_pubkey) - WHERE binding_state = 1; -CREATE INDEX identity_bindings_current_lookup - ON identity_bindings (community_id, event_author_pubkey, binding_state, expires_at); - --- The one canonical immutable lifecycle transition row for a successful or --- no-op lifecycle operation. A transition can name an old generation, a new --- successor generation, both (Rotate), or neither (a semantic no-op). It is not --- a second result/effect engine: the shared receipt remains the sole persisted --- operation outcome. Core transition kinds only: 1 enroll, 3 retire, 5 revoke, --- 6 rotate. -CREATE TABLE identity_lifecycle_history ( - community_id UUID NOT NULL REFERENCES communities(id), - history_id UUID NOT NULL, - transition_kind SMALLINT NOT NULL CHECK ( - transition_kind IN (1, 3, 5, 6) - ), - -- Matches the shared receipt: 1 applied, 3 no-op. - outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 3)), - old_binding_id UUID, - old_binding_version BIGINT CHECK (old_binding_version IS NULL OR old_binding_version > 0), - old_prior_lifecycle_revision BIGINT CHECK ( - old_prior_lifecycle_revision IS NULL OR old_prior_lifecycle_revision IN (1, 2) - ), - old_prior_state SMALLINT CHECK (old_prior_state IS NULL OR old_prior_state IN (1, 2)), - old_resulting_lifecycle_revision BIGINT CHECK ( - old_resulting_lifecycle_revision IS NULL OR old_resulting_lifecycle_revision IN (1, 2) - ), - old_resulting_state SMALLINT CHECK ( - old_resulting_state IS NULL OR old_resulting_state IN (1, 2) - ), - successor_binding_id UUID, - successor_binding_version BIGINT CHECK ( - successor_binding_version IS NULL OR successor_binding_version > 0 - ), - successor_lifecycle_revision BIGINT CHECK ( - successor_lifecycle_revision IS NULL OR successor_lifecycle_revision = 1 - ), - successor_state SMALLINT CHECK (successor_state IS NULL OR successor_state = 1), - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - transition_digest BYTEA NOT NULL CHECK (octet_length(transition_digest) = 32), - recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, history_id), - UNIQUE (community_id, operation_id), - UNIQUE (community_id, history_id, operation_id, request_fingerprint), - UNIQUE ( - community_id, - history_id, - successor_binding_id, - successor_binding_version, - operation_id, - request_fingerprint - ), - UNIQUE ( - community_id, - history_id, - old_binding_id, - old_binding_version, - old_resulting_lifecycle_revision, - old_resulting_state - ), - FOREIGN KEY ( - community_id, - operation_id, - request_fingerprint, - transition_kind, - outcome_code - ) REFERENCES authorization_operation_receipts ( - community_id, - operation_id, - request_fingerprint, - operation_kind, - outcome_code - ) DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY (community_id, old_binding_id, old_binding_version) - REFERENCES identity_bindings (community_id, binding_id, binding_version) - DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY (community_id, successor_binding_id, successor_binding_version) - REFERENCES identity_bindings (community_id, binding_id, binding_version) - DEFERRABLE INITIALLY DEFERRED, - CHECK (history_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK ( - (old_binding_id IS NULL - AND old_binding_version IS NULL - AND old_prior_lifecycle_revision IS NULL - AND old_prior_state IS NULL - AND old_resulting_lifecycle_revision IS NULL - AND old_resulting_state IS NULL) - OR (old_binding_id IS NOT NULL - AND old_binding_version IS NOT NULL - AND old_prior_lifecycle_revision IS NOT NULL - AND old_prior_state IS NOT NULL - AND old_resulting_lifecycle_revision IS NOT NULL - AND old_resulting_state IS NOT NULL) - ), - CHECK ( - (successor_binding_id IS NULL - AND successor_binding_version IS NULL - AND successor_lifecycle_revision IS NULL - AND successor_state IS NULL) - OR (successor_binding_id IS NOT NULL - AND successor_binding_version IS NOT NULL - AND successor_lifecycle_revision = 1 - AND successor_state = 1) - ), - CHECK ( - old_binding_id IS NULL - OR successor_binding_id IS NULL - OR old_binding_id <> successor_binding_id - ), - CHECK ( - old_binding_version IS NULL - OR successor_binding_version IS NULL - OR old_binding_version <> successor_binding_version - ), - -- Core lifecycle only ever moves Active/r1 to Retired/r2 for a named old - -- generation. Extended re-enablement (recover/enable from Retired/r2) is a - -- later migration's concern. - CHECK ( - old_binding_id IS NULL - OR (old_prior_lifecycle_revision = 1 - AND old_prior_state = 1 - AND old_resulting_lifecycle_revision = 2 - AND old_resulting_state = 2) - ), - CHECK ( - (outcome_code = 3 - AND old_binding_id IS NULL - AND successor_binding_id IS NULL) - OR (outcome_code = 1 AND ( - (transition_kind = 1 - AND old_binding_id IS NULL - AND successor_binding_id IS NOT NULL) - OR (transition_kind = 3 - AND old_binding_id IS NOT NULL - AND successor_binding_id IS NULL) - OR (transition_kind = 5 - AND successor_binding_id IS NULL) - OR (transition_kind = 6 - AND old_binding_id IS NOT NULL - AND successor_binding_id IS NOT NULL) - )) - ) -); - -CREATE INDEX identity_lifecycle_history_old_binding - ON identity_lifecycle_history (community_id, old_binding_id, old_binding_version, recorded_at); -CREATE INDEX identity_lifecycle_history_successor_binding - ON identity_lifecycle_history ( - community_id, - successor_binding_id, - successor_binding_version, - recorded_at - ); - --- Circular birth/transition ordering is deliberate and fully deferred. Every --- generation must commit with its exact birth transition, and a retired row --- must commit with the exact transition that changed Active/r1 to Retired/r2. -ALTER TABLE identity_bindings - ADD CONSTRAINT identity_bindings_exact_birth_history_fk - FOREIGN KEY ( - community_id, - birth_history_id, - binding_id, - binding_version, - creation_operation_id, - creation_request_fingerprint - ) REFERENCES identity_lifecycle_history ( - community_id, - history_id, - successor_binding_id, - successor_binding_version, - operation_id, - request_fingerprint - ) DEFERRABLE INITIALLY DEFERRED; - -ALTER TABLE identity_bindings - ADD CONSTRAINT identity_bindings_exact_retirement_history_fk - FOREIGN KEY ( - community_id, - retirement_history_id, - binding_id, - binding_version, - lifecycle_revision, - binding_state - ) REFERENCES identity_lifecycle_history ( - community_id, - history_id, - old_binding_id, - old_binding_version, - old_resulting_lifecycle_revision, - old_resulting_state - ) DEFERRABLE INITIALLY DEFERRED; - --- One immutable closed-scope fact table. Core selector kinds only: --- 1 retired pair (P), 3 revoked key (Y). Both are permanent. The extended --- disabled-identity (X) and pending-replacement (Q) selectors, and their --- one-shot consumption, are introduced by the FI-LIFECYCLE migration. -CREATE TABLE identity_lifecycle_selectors ( - community_id UUID NOT NULL REFERENCES communities(id), - selector_id UUID NOT NULL, - selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 3)), - selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32), - fact_generation BIGINT NOT NULL CHECK (fact_generation > 0), - principal_fingerprint BYTEA CHECK ( - principal_fingerprint IS NULL OR octet_length(principal_fingerprint) = 32 - ), - event_author_pubkey BYTEA CHECK ( - event_author_pubkey IS NULL OR octet_length(event_author_pubkey) = 32 - ), - binding_id UUID, - binding_version BIGINT CHECK (binding_version IS NULL OR binding_version > 0), - asserted_history_id UUID NOT NULL, - selected_by_operation_id UUID NOT NULL, - selected_by_request_fingerprint BYTEA NOT NULL CHECK ( - octet_length(selected_by_request_fingerprint) = 32 - ), - selected_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, selector_id), - UNIQUE (community_id, selector_id, selector_kind), - UNIQUE (community_id, selector_kind, selector_fingerprint, fact_generation), - FOREIGN KEY ( - community_id, - asserted_history_id, - selected_by_operation_id, - selected_by_request_fingerprint - ) REFERENCES identity_lifecycle_history ( - community_id, - history_id, - operation_id, - request_fingerprint - ) DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY ( - community_id, - selected_by_operation_id, - selected_by_request_fingerprint - ) REFERENCES authorization_operation_receipts ( - community_id, - operation_id, - request_fingerprint - ) DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY (community_id, binding_id, binding_version) - REFERENCES identity_bindings (community_id, binding_id, binding_version) - DEFERRABLE INITIALLY DEFERRED, - CHECK (selector_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK ( - (selector_kind = 1 - AND fact_generation = 1 - AND principal_fingerprint IS NOT NULL - AND event_author_pubkey IS NOT NULL - AND binding_id IS NOT NULL - AND binding_version IS NOT NULL) - OR (selector_kind = 3 - AND fact_generation = 1 - AND principal_fingerprint IS NULL - AND event_author_pubkey IS NOT NULL - AND binding_id IS NULL - AND binding_version IS NULL) - ) -); - -CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_pair - ON identity_lifecycle_selectors (community_id, binding_id, binding_version) - WHERE selector_kind = 1; -CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_principal_key - ON identity_lifecycle_selectors ( - community_id, - principal_fingerprint, - event_author_pubkey - ) WHERE selector_kind = 1; -CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_key - ON identity_lifecycle_selectors (community_id, event_author_pubkey) - WHERE selector_kind = 3; -CREATE INDEX identity_lifecycle_selectors_principal_lookup - ON identity_lifecycle_selectors - (community_id, selector_kind, principal_fingerprint, fact_generation); -CREATE INDEX identity_lifecycle_selectors_key_lookup - ON identity_lifecycle_selectors - (community_id, selector_kind, event_author_pubkey, fact_generation); -CREATE INDEX identity_lifecycle_selectors_binding_lookup - ON identity_lifecycle_selectors - (community_id, selector_kind, binding_id, binding_version, fact_generation); -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 (FI-INV-06 — stable assertion policy --- anchor; a backfilled or replayed revision 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; -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) - INTO max_revision - 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; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION nip_fi_reject_row_mutation_v1() RETURNS TRIGGER AS $$ -BEGIN - RAISE EXCEPTION '% is immutable', TG_TABLE_NAME - USING ERRCODE = 'check_violation'; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION nip_fi_reject_truncate_v1() RETURNS TRIGGER AS $$ -BEGIN - RAISE EXCEPTION '% cannot be truncated', TG_TABLE_NAME - USING ERRCODE = 'check_violation'; -END; -$$ LANGUAGE plpgsql; - --- Every binding/selector path derives the same domain-scoped coordinates and --- takes their signed BIGINT advisory keys in numeric order. Typed transaction --- APIs take these locks before row mutation; the triggers are the fail-closed --- backstop for direct SQL. -CREATE FUNCTION identity_lifecycle_lock_coordinates_v1( - locked_community_id UUID, - locked_principal_fingerprint BYTEA, - locked_event_author_pubkey BYTEA -) RETURNS VOID AS $$ -DECLARE - principal_lock_key BIGINT; - event_author_lock_key BIGINT; -BEGIN - IF locked_principal_fingerprint IS NOT NULL THEN - principal_lock_key := hashtextextended( - 'buzz:identity-lifecycle-coordinate:v1:principal:' - || locked_community_id::text || ':' - || encode(locked_principal_fingerprint, 'hex'), - 0 - ); - END IF; - IF locked_event_author_pubkey IS NOT NULL THEN - event_author_lock_key := hashtextextended( - 'buzz:identity-lifecycle-coordinate:v1:key:' - || locked_community_id::text || ':' - || encode(locked_event_author_pubkey, 'hex'), - 0 - ); - END IF; - - IF principal_lock_key IS NOT NULL AND event_author_lock_key IS NOT NULL THEN - PERFORM pg_advisory_xact_lock(LEAST(principal_lock_key, event_author_lock_key)); - IF principal_lock_key <> event_author_lock_key THEN - PERFORM pg_advisory_xact_lock(GREATEST(principal_lock_key, event_author_lock_key)); - END IF; - ELSIF principal_lock_key IS NOT NULL THEN - PERFORM pg_advisory_xact_lock(principal_lock_key); - ELSIF event_author_lock_key IS NOT NULL THEN - PERFORM pg_advisory_xact_lock(event_author_lock_key); - END IF; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_bindings_insert_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - PERFORM identity_lifecycle_lock_coordinates_v1( - NEW.community_id, - NEW.principal_fingerprint, - NEW.event_author_pubkey - ); - IF NEW.binding_state <> 1 - OR NEW.lifecycle_revision <> 1 - OR NEW.retirement_history_id IS NOT NULL - THEN - RAISE EXCEPTION 'identity binding birth must be Active at lifecycle revision 1' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_bindings_birth_state'; - END IF; - NEW.created_at := transaction_timestamp(); - NEW.updated_at := transaction_timestamp(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_bindings_transition_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF NEW IS NOT DISTINCT FROM OLD THEN - RETURN NEW; - END IF; - PERFORM identity_lifecycle_lock_coordinates_v1( - OLD.community_id, - OLD.principal_fingerprint, - OLD.event_author_pubkey - ); - IF NEW.community_id IS DISTINCT FROM OLD.community_id - OR NEW.binding_id IS DISTINCT FROM OLD.binding_id - OR NEW.binding_version IS DISTINCT FROM OLD.binding_version - OR NEW.issuer IS DISTINCT FROM OLD.issuer - OR NEW.subject IS DISTINCT FROM OLD.subject - OR NEW.principal_fingerprint IS DISTINCT FROM OLD.principal_fingerprint - OR NEW.event_author_pubkey IS DISTINCT FROM OLD.event_author_pubkey - OR NEW.binding_provenance IS DISTINCT FROM OLD.binding_provenance - OR NEW.policy_revision IS DISTINCT FROM OLD.policy_revision - OR NEW.enrollment_evidence_digest IS DISTINCT FROM OLD.enrollment_evidence_digest - OR NEW.expires_at IS DISTINCT FROM OLD.expires_at - OR NEW.birth_history_id IS DISTINCT FROM OLD.birth_history_id - OR NEW.creation_operation_id IS DISTINCT FROM OLD.creation_operation_id - OR NEW.creation_request_fingerprint IS DISTINCT FROM OLD.creation_request_fingerprint - OR NEW.created_at IS DISTINCT FROM OLD.created_at - THEN - RAISE EXCEPTION 'identity binding generation coordinates are immutable' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_bindings_immutable_generation'; - END IF; - IF OLD.binding_state <> 1 - OR OLD.lifecycle_revision <> 1 - OR OLD.retirement_history_id IS NOT NULL - OR NEW.binding_state <> 2 - OR NEW.lifecycle_revision <> 2 - OR NEW.retirement_history_id IS NULL - OR NEW.retirement_history_id = OLD.birth_history_id - THEN - RAISE EXCEPTION 'identity binding permits only Active/r1 to Retired/r2' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_bindings_active_to_retired'; - END IF; - NEW.updated_at := transaction_timestamp(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_lifecycle_history_insert_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - NEW.recorded_at := transaction_timestamp(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_binding_history_semantics_guard_v1() RETURNS TRIGGER AS $$ -DECLARE - retirement identity_lifecycle_history%ROWTYPE; -BEGIN - IF NEW.binding_state = 2 THEN - SELECT * INTO STRICT retirement - FROM identity_lifecycle_history - WHERE community_id = NEW.community_id - AND history_id = NEW.retirement_history_id - AND old_binding_id = NEW.binding_id - AND old_binding_version = NEW.binding_version; - IF retirement.outcome_code <> 1 - OR retirement.old_prior_lifecycle_revision <> 1 - OR retirement.old_prior_state <> 1 - OR retirement.old_resulting_lifecycle_revision <> 2 - OR retirement.old_resulting_state <> 2 - THEN - RAISE EXCEPTION 'retired binding must reference its exact Active-to-Retired transition' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_bindings_retirement_history_semantics'; - END IF; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_binding_birth_eligibility_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM identity_lifecycle_selectors selector - WHERE selector.community_id = NEW.community_id - AND ( - (selector.selector_kind = 1 - AND selector.principal_fingerprint = NEW.principal_fingerprint - AND selector.event_author_pubkey = NEW.event_author_pubkey) - OR (selector.selector_kind = 3 - AND selector.event_author_pubkey = NEW.event_author_pubkey) - ) - ) THEN - RAISE EXCEPTION 'binding birth conflicts with an effective lifecycle selector' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_bindings_birth_eligibility'; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION authorization_operation_receipt_history_guard_v1() RETURNS TRIGGER AS $$ -DECLARE - history_count BIGINT; - expected_count BIGINT; -BEGIN - SELECT count(*) INTO history_count - FROM identity_lifecycle_history history - WHERE history.community_id = NEW.community_id - AND history.operation_id = NEW.operation_id; - - -- Core lifecycle receipts (enroll, retire, revoke, rotate) each require - -- exactly one lifecycle-history row. Non-lifecycle receipts (protected - -- mutation, invalidation) require none. - expected_count := CASE - WHEN NEW.operation_kind IN (1, 3, 5, 6) AND NEW.outcome_code IN (1, 3) THEN 1 - ELSE 0 - END; - IF history_count <> expected_count THEN - RAISE EXCEPTION 'operation receipt requires % lifecycle history row, found %', - expected_count, history_count - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_operation_receipt_history_cardinality'; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_lifecycle_selector_insert_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - NEW.selected_at := transaction_timestamp(); - PERFORM identity_lifecycle_lock_coordinates_v1( - NEW.community_id, - CASE WHEN NEW.selector_kind = 1 THEN NEW.principal_fingerprint END, - NEW.event_author_pubkey - ); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_lifecycle_selector_history_guard_v1() RETURNS TRIGGER AS $$ -DECLARE - history identity_lifecycle_history%ROWTYPE; - old_binding identity_bindings%ROWTYPE; -BEGIN - SELECT * INTO STRICT history - FROM identity_lifecycle_history - WHERE community_id = NEW.community_id - AND history_id = NEW.asserted_history_id - AND operation_id = NEW.selected_by_operation_id - AND request_fingerprint = NEW.selected_by_request_fingerprint; - - IF history.old_binding_id IS NOT NULL THEN - SELECT * INTO STRICT old_binding - FROM identity_bindings - WHERE community_id = history.community_id - AND binding_id = history.old_binding_id - AND binding_version = history.old_binding_version; - END IF; - - -- A retired-pair (P) selector is asserted by retire, revoke, or rotate of a - -- named old generation; a revoked-key (Y) selector by revoke. - IF history.outcome_code <> 1 - OR (NEW.selector_kind = 1 AND ( - history.transition_kind NOT IN (3, 5, 6) - OR history.old_binding_id IS DISTINCT FROM NEW.binding_id - OR history.old_binding_version IS DISTINCT FROM NEW.binding_version - OR old_binding.principal_fingerprint IS DISTINCT FROM NEW.principal_fingerprint - OR old_binding.event_author_pubkey IS DISTINCT FROM NEW.event_author_pubkey - )) - OR (NEW.selector_kind = 3 AND ( - history.transition_kind <> 5 - OR (history.old_binding_id IS NOT NULL - AND old_binding.event_author_pubkey - IS DISTINCT FROM NEW.event_author_pubkey) - )) - THEN - RAISE EXCEPTION 'selector does not match its lifecycle transition' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_lifecycle_selector_history_semantics'; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_lifecycle_transition_integrity_guard_v1() RETURNS TRIGGER AS $$ -DECLARE - transition identity_lifecycle_history%ROWTYPE; - old_binding_state SMALLINT; - asserted_p BIGINT; - asserted_y BIGINT; -BEGIN - IF TG_TABLE_NAME = 'identity_lifecycle_history' THEN - transition := NEW; - ELSIF TG_TABLE_NAME = 'identity_lifecycle_selectors' THEN - SELECT * INTO STRICT transition - FROM identity_lifecycle_history - WHERE community_id = NEW.community_id - AND history_id = NEW.asserted_history_id; - ELSE - SELECT * INTO STRICT transition - FROM identity_lifecycle_history - WHERE community_id = NEW.community_id - AND history_id = CASE - WHEN NEW.binding_state = 2 THEN NEW.retirement_history_id - ELSE NEW.birth_history_id - END; - END IF; - - SELECT - count(*) FILTER (WHERE selector_kind = 1), - count(*) FILTER (WHERE selector_kind = 3) - INTO asserted_p, asserted_y - FROM identity_lifecycle_selectors - WHERE community_id = transition.community_id - AND asserted_history_id = transition.history_id; - - IF transition.old_binding_id IS NOT NULL THEN - SELECT binding_state INTO STRICT old_binding_state - FROM identity_bindings - WHERE community_id = transition.community_id - AND binding_id = transition.old_binding_id - AND binding_version = transition.old_binding_version; - IF old_binding_state <> 2 THEN - RAISE EXCEPTION 'lifecycle transition old binding must be retired at commit' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_lifecycle_transition_integrity'; - END IF; - END IF; - - IF EXISTS ( - SELECT 1 - FROM identity_lifecycle_selectors selector - JOIN identity_bindings active - ON active.community_id = selector.community_id - AND active.binding_state = 1 - AND ( - (selector.selector_kind = 1 - AND active.principal_fingerprint = selector.principal_fingerprint - AND active.event_author_pubkey = selector.event_author_pubkey) - OR (selector.selector_kind = 3 - AND active.event_author_pubkey = selector.event_author_pubkey) - ) - WHERE selector.community_id = transition.community_id - ) THEN - RAISE EXCEPTION 'effective lifecycle selector conflicts with an active binding' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_lifecycle_transition_integrity'; - END IF; - - IF transition.outcome_code = 3 THEN - IF asserted_p + asserted_y <> 0 THEN - RAISE EXCEPTION 'no-op lifecycle transition cannot create selector facts' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_lifecycle_transition_integrity'; - END IF; - RETURN NULL; - END IF; - - -- Core selector companions per transition: - -- enroll (1): none - -- retire (3): exactly one P - -- revoke (5): one Y always; one P when a named old generation is removed - -- rotate (6): exactly one P (old generation retired) - IF (transition.transition_kind = 1 - AND (asserted_p, asserted_y) <> (0, 0)) - OR (transition.transition_kind = 3 - AND (asserted_p, asserted_y) <> (1, 0)) - OR (transition.transition_kind = 5 AND ( - (transition.old_binding_id IS NOT NULL - AND (asserted_p, asserted_y) <> (1, 1)) - OR (transition.old_binding_id IS NULL - AND (asserted_p, asserted_y) <> (0, 1)) - )) - OR (transition.transition_kind = 6 - AND (asserted_p, asserted_y) <> (1, 0)) - THEN - RAISE EXCEPTION 'lifecycle transition has incomplete or forbidden selector companions' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_lifecycle_transition_integrity'; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER identity_bindings_insert_guard - BEFORE INSERT ON identity_bindings - FOR EACH ROW EXECUTE FUNCTION identity_bindings_insert_guard_v1(); -CREATE TRIGGER identity_bindings_transition_guard - BEFORE UPDATE ON identity_bindings - FOR EACH ROW EXECUTE FUNCTION identity_bindings_transition_guard_v1(); -CREATE CONSTRAINT TRIGGER identity_bindings_history_semantics - AFTER INSERT OR UPDATE ON identity_bindings - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION identity_binding_history_semantics_guard_v1(); -CREATE CONSTRAINT TRIGGER identity_bindings_birth_eligibility - AFTER INSERT ON identity_bindings - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION identity_binding_birth_eligibility_guard_v1(); -CREATE CONSTRAINT TRIGGER identity_bindings_transition_integrity - AFTER INSERT OR UPDATE ON identity_bindings - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); -CREATE TRIGGER identity_bindings_no_delete - BEFORE DELETE ON identity_bindings - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER identity_bindings_no_truncate - BEFORE TRUNCATE ON identity_bindings - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER identity_lifecycle_history_insert_guard - BEFORE INSERT ON identity_lifecycle_history - FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_history_insert_guard_v1(); -CREATE CONSTRAINT TRIGGER authorization_operation_receipt_history_cardinality - AFTER INSERT ON authorization_operation_receipts - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_history_guard_v1(); -CREATE CONSTRAINT TRIGGER identity_lifecycle_transition_integrity - AFTER INSERT ON identity_lifecycle_history - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); - -CREATE TRIGGER identity_lifecycle_selector_insert_guard - BEFORE INSERT ON identity_lifecycle_selectors - FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_selector_insert_guard_v1(); -CREATE CONSTRAINT TRIGGER identity_lifecycle_selector_history_semantics - AFTER INSERT ON identity_lifecycle_selectors - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_selector_history_guard_v1(); -CREATE CONSTRAINT TRIGGER identity_lifecycle_selector_transition_integrity - AFTER INSERT ON identity_lifecycle_selectors - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); - -CREATE TRIGGER authorization_operation_receipts_immutable - BEFORE UPDATE OR DELETE ON authorization_operation_receipts - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -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(); -CREATE TRIGGER identity_enrollment_policies_no_truncate - BEFORE TRUNCATE ON identity_enrollment_policies - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER identity_lifecycle_history_immutable - BEFORE UPDATE OR DELETE ON identity_lifecycle_history - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER identity_lifecycle_history_no_truncate - BEFORE TRUNCATE ON identity_lifecycle_history - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER identity_lifecycle_selectors_immutable - BEFORE UPDATE OR DELETE ON identity_lifecycle_selectors - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER identity_lifecycle_selectors_no_truncate - BEFORE TRUNCATE ON identity_lifecycle_selectors - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - - --- ============================================================================ --- NIP-FI final-admission foundation (mirror of migration 0042). --- ============================================================================ - -CREATE TABLE authorization_invalidation_domains ( - community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), - current_generation BIGINT NOT NULL CHECK (current_generation >= 0), - activated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp() -); - --- Closed selectors: 1 principal, 2 Nostr key, 3 binding, 4 session, 5 domain, --- 6 configuration revision. Selector 7 (delegated relationship) and its --- relationship-revision floor are deferred to the FI-DELEG migration. -CREATE TABLE authorization_invalidation_floors ( - community_id UUID NOT NULL REFERENCES communities(id), - selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 2, 3, 4, 5, 6)), - selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32), - floor_generation BIGINT NOT NULL CHECK (floor_generation > 0), - binding_version_floor BIGINT CHECK (binding_version_floor IS NULL OR binding_version_floor > 0), - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, selector_kind, selector_fingerprint), - FOREIGN KEY (community_id, operation_id, request_fingerprint) - REFERENCES authorization_operation_receipts - (community_id, operation_id, request_fingerprint) - DEFERRABLE INITIALLY DEFERRED, - CHECK ( - (selector_kind = 3 AND binding_version_floor IS NOT NULL) - OR (selector_kind <> 3 AND binding_version_floor IS NULL) - ) -); - --- Protected-object kinds: 1 domain, 2 channel, 3 repository, 4 media, --- 5 moderation target, 6 audio session. Kind 7 is retired: current binding --- status is connection-local evidence and never a durable protected object. -CREATE TABLE authorization_authority_epochs ( - community_id UUID NOT NULL REFERENCES communities(id), - object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), - object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), - authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), - fence BYTEA NOT NULL CHECK ( - octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') - ), - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, object_kind, object_key), - UNIQUE ( - community_id, - object_kind, - object_key, - authority_epoch, - fence, - operation_id, - request_fingerprint - ), - FOREIGN KEY (community_id, operation_id, request_fingerprint) - REFERENCES authorization_operation_receipts - (community_id, operation_id, request_fingerprint) - DEFERRABLE INITIALLY DEFERRED -); - --- Direct-final current authority for a protected object. The authorization --- lease itself is sealed in memory and dies on restart; this durable row is the --- exact source re-fenced immediately before a protected mutation or emission. -CREATE TABLE protected_object_authority ( - community_id UUID NOT NULL REFERENCES communities(id), - object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), - object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), - capability SMALLINT NOT NULL CHECK ( - capability IN ( - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, - 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29 - ) - ), - actor_pubkey BYTEA NOT NULL CHECK (octet_length(actor_pubkey) = 32), - binding_id UUID NOT NULL, - binding_version BIGINT NOT NULL CHECK (binding_version > 0), - policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), - invalidation_generation BIGINT NOT NULL CHECK (invalidation_generation >= 0), - authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), - fence BYTEA NOT NULL CHECK ( - octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') - ), - issued_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - PRIMARY KEY (community_id, object_kind, object_key), - FOREIGN KEY (community_id, binding_id, binding_version) - REFERENCES identity_bindings (community_id, binding_id, binding_version) - DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY (community_id, operation_id, request_fingerprint) - REFERENCES authorization_operation_receipts - (community_id, operation_id, request_fingerprint) - DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY ( - community_id, - object_kind, - object_key, - authority_epoch, - fence, - operation_id, - request_fingerprint - ) REFERENCES authorization_authority_epochs ( - community_id, - object_kind, - object_key, - authority_epoch, - fence, - operation_id, - request_fingerprint - ) DEFERRABLE INITIALLY DEFERRED, - CHECK (issued_at < expires_at) -); - --- Explicit immutable-capacity policy required by Enforce mode. Hard ceilings --- match buzz-auth; installation limits must be sized explicitly below them. --- V1 has no online pruning/export/reset workflow. -CREATE TABLE authorization_event_capacity ( - community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), - max_events_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_events CHECK ( - max_events_per_domain BETWEEN 1 AND 10000 - ), - max_bytes_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_bytes CHECK ( - max_bytes_per_domain BETWEEN 1 AND 16777216 - ), - max_envelope_bytes INTEGER NOT NULL CONSTRAINT authorization_event_capacity_max_envelope CHECK ( - max_envelope_bytes BETWEEN 1 AND 16384 - ), - retained_event_count BIGINT NOT NULL DEFAULT 0 CHECK (retained_event_count >= 0), - retained_envelope_bytes BIGINT NOT NULL DEFAULT 0 CHECK (retained_envelope_bytes >= 0), - -- 1 healthy, 2 audit unavailable/exhausted. Recovery/reset is not a V1 - -- online workflow; enabled runtime latches failure when insertion aborts. - health_state SMALLINT NOT NULL DEFAULT 1 CHECK (health_state IN (1, 2)), - failure_code SMALLINT CHECK (failure_code IS NULL OR failure_code IN (1, 2, 3)), - failure_observed_at TIMESTAMPTZ, - configured_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - CHECK (max_envelope_bytes <= max_bytes_per_domain), - CHECK (retained_event_count <= max_events_per_domain), - CHECK (retained_envelope_bytes <= max_bytes_per_domain), - CHECK ( - (health_state = 1 AND failure_code IS NULL AND failure_observed_at IS NULL) - OR (health_state = 2 AND failure_code IS NOT NULL AND failure_observed_at IS NOT NULL) - ) -); - --- Durable versioned pseudonymous authorization envelope. event_kind: --- 1 enrolled, 2 revoked, 3 rotated, 6 retired, 9 operator denied, --- 10 protected allowed, 11 protected denied, 14 invalidation advanced. --- The extended-lifecycle audit kinds (4 recovered, 5 principal enabled, --- 7 principal disabled, 8 admission lost) are deferred to the FI-LIFECYCLE --- migration, matching 0041's core lifecycle carve. Kinds 12 and 13 are --- retired: kind 24244 publication/withdrawal is ephemeral connection state and --- never a durable authorization event. -CREATE TABLE authorization_events ( - community_id UUID NOT NULL REFERENCES communities(id), - event_id UUID NOT NULL, - schema_version SMALLINT NOT NULL DEFAULT 1 CHECK (schema_version = 1), - event_kind SMALLINT NOT NULL CHECK ( - event_kind IN (1, 2, 3, 6, 9, 10, 11, 14) - ), - outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3, 4, 5)), - reason_code SMALLINT NOT NULL CHECK ( - reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) - ), - actor_kind SMALLINT NOT NULL CHECK (actor_kind IN (1, 2, 3, 4)), - actor_fingerprint BYTEA CHECK ( - actor_fingerprint IS NULL OR octet_length(actor_fingerprint) = 32 - ), - subject_fingerprint BYTEA CHECK ( - subject_fingerprint IS NULL OR octet_length(subject_fingerprint) = 32 - ), - -- Always retains attempted operation identity. Only unresolved pre-auth - -- event kind 9 omits the canonical receipt fingerprint; authenticated - -- OperatorDenied events remain linked to their exact canonical receipt. - operation_id UUID NOT NULL, - request_fingerprint BYTEA CHECK ( - request_fingerprint IS NULL OR octet_length(request_fingerprint) = 32 - ), - correlation_id UUID NOT NULL, - attempt_id UUID NOT NULL, - -- Redaction-safe pre-authentication denial identity. Present and non-zero - -- for unresolved pre-auth kind-9 events (actor_kind = 4); NULL for - -- authenticated kind-9 events (actor_kind 1-3) and all other event kinds. - -- Binds the event to the exact denial attempt's semantic_fingerprint - -- (intent_digest) for exact replay. - semantic_fingerprint BYTEA CHECK ( - semantic_fingerprint IS NULL OR octet_length(semantic_fingerprint) = 32 - ), - occurred_at TIMESTAMPTZ NOT NULL, - accepted_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - canonical_envelope BYTEA NOT NULL CONSTRAINT authorization_events_envelope_size CHECK ( - octet_length(canonical_envelope) BETWEEN 1 AND 16384 - ), - envelope_digest BYTEA NOT NULL CHECK (octet_length(envelope_digest) = 32), - PRIMARY KEY (community_id, event_id), - UNIQUE (community_id, event_id, operation_id), - UNIQUE (community_id, event_id, event_kind, operation_id), - UNIQUE (community_id, operation_id, event_kind, attempt_id), - FOREIGN KEY (community_id, operation_id, request_fingerprint) - REFERENCES authorization_operation_receipts - (community_id, operation_id, request_fingerprint) - DEFERRABLE INITIALLY DEFERRED, - CHECK (event_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK ( - (actor_kind = 4 AND event_kind = 9 AND request_fingerprint IS NULL) - OR (actor_kind IN (1, 2, 3) AND request_fingerprint IS NOT NULL) - ), - CHECK ( - (actor_kind = 4 AND actor_fingerprint IS NULL AND subject_fingerprint IS NULL) - OR (actor_kind IN (1, 2, 3) AND actor_fingerprint IS NOT NULL) - ), - -- Unresolved pre-auth kind-9 events (actor_kind = 4) carry a non-zero - -- semantic_fingerprint; authenticated kind-9 events (actor_kind 1-3) and - -- all other event kinds must not. - CHECK ( - (event_kind = 9 AND actor_kind = 4 AND semantic_fingerprint IS NOT NULL - AND semantic_fingerprint <> decode(repeat('00', 32), 'hex')) - OR (event_kind = 9 AND actor_kind IN (1, 2, 3) AND semantic_fingerprint IS NULL) - OR (event_kind <> 9 AND semantic_fingerprint IS NULL) - ) -); - --- Credential-free pre-authentication denial attempts. The five-column key is --- exact replay identity; no row or FK occupies canonical operation/result, --- effect, authority, approval, or consumption state. -CREATE TABLE authorization_authentication_denial_attempts ( - community_id UUID NOT NULL REFERENCES communities(id), - operation_id UUID NOT NULL, - correlation_id UUID NOT NULL, - semantic_fingerprint BYTEA NOT NULL CHECK (octet_length(semantic_fingerprint) = 32), - denial_reason SMALLINT NOT NULL CHECK (denial_reason IN (1, 2, 3)), - expected_revision BIGINT NOT NULL CHECK (expected_revision > 0), - action SMALLINT NOT NULL CHECK (action IN (1, 2, 3, 4, 5, 6, 7, 8)), - reason_code SMALLINT NOT NULL CHECK ( - reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) - ), - attempt_id UUID NOT NULL, - audit_event_id UUID NOT NULL, - audit_event_kind SMALLINT NOT NULL DEFAULT 9 CHECK (audit_event_kind = 9), - recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY ( - community_id, - operation_id, - correlation_id, - semantic_fingerprint, - denial_reason - ), - UNIQUE (community_id, audit_event_id), - FOREIGN KEY (community_id, audit_event_id, audit_event_kind, operation_id) - REFERENCES authorization_events (community_id, event_id, event_kind, operation_id) - DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY (community_id, operation_id, audit_event_kind, attempt_id) - REFERENCES authorization_events (community_id, operation_id, event_kind, attempt_id) - DEFERRABLE INITIALLY DEFERRED, - -- Canonical denial_reason ↔ reason_code binding: MissingCredential(1)↔Missing(2), - -- InvalidCredential(2)↔Invalid(3), Unauthenticated(3)↔Unauthenticated(4). - CONSTRAINT authorization_denial_reason_reason_code_binding CHECK ( - (denial_reason = 1 AND reason_code = 2) - OR (denial_reason = 2 AND reason_code = 3) - OR (denial_reason = 3 AND reason_code = 4) - ), - CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid) -); - --- Exact per-operation authority-version attribution for restore. Empty --- manifests are valid; every stored component must advance strictly. -CREATE TABLE authorization_operation_version_delta_manifests ( - community_id UUID NOT NULL REFERENCES communities(id), - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - component_count INTEGER NOT NULL CHECK (component_count BETWEEN 0 AND 1024), - before_digest BYTEA NOT NULL CHECK (octet_length(before_digest) = 32), - after_digest BYTEA NOT NULL CHECK (octet_length(after_digest) = 32), - manifest_digest BYTEA NOT NULL CHECK (octet_length(manifest_digest) = 32), - recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, operation_id), - UNIQUE (community_id, operation_id, request_fingerprint), - FOREIGN KEY (community_id, operation_id, request_fingerprint) - REFERENCES authorization_operation_receipts - (community_id, operation_id, request_fingerprint) - DEFERRABLE INITIALLY DEFERRED -); - --- component_kind: 1 binding version, 2 policy revision, --- 3 invalidation generation, 4 authority epoch. Kind 6 (delegated-relationship --- revision) is deferred to the FI-DELEG migration and kind 7 (lifecycle-selector --- generation) to the FI-LIFECYCLE migration. Kind 5 is retired with durable --- client-status revisions; retained kinds keep their original identities. -CREATE TABLE authorization_operation_version_deltas ( - community_id UUID NOT NULL REFERENCES communities(id), - operation_id UUID NOT NULL, - component_kind SMALLINT NOT NULL CHECK (component_kind IN (1, 2, 3, 4)), - component_key BYTEA NOT NULL CHECK (octet_length(component_key) = 32), - before_version BIGINT NOT NULL CHECK (before_version >= 0), - after_version BIGINT NOT NULL, - component_digest BYTEA NOT NULL CHECK (octet_length(component_digest) = 32), - PRIMARY KEY (community_id, operation_id, component_kind, component_key), - FOREIGN KEY (community_id, operation_id) - REFERENCES authorization_operation_version_delta_manifests - (community_id, operation_id), - CHECK (after_version > before_version) -); - -CREATE FUNCTION authorization_event_capacity_before_insert_v1() RETURNS TRIGGER AS $$ -DECLARE - policy authorization_event_capacity%ROWTYPE; - envelope_bytes BIGINT; -BEGIN - SELECT * INTO policy - FROM authorization_event_capacity - WHERE community_id = NEW.community_id - FOR UPDATE; - - IF NOT FOUND THEN - RAISE EXCEPTION 'authorization event capacity policy missing' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_event_capacity_policy_required'; - END IF; - IF policy.health_state <> 1 THEN - RAISE EXCEPTION 'authorization audit is unavailable' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_event_capacity_health'; - END IF; - - envelope_bytes := octet_length(NEW.canonical_envelope); - IF envelope_bytes > policy.max_envelope_bytes - OR policy.retained_event_count + 1 > policy.max_events_per_domain - OR policy.retained_envelope_bytes + envelope_bytes > policy.max_bytes_per_domain - THEN - -- The INSERT and protected mutation abort together. The runtime maps - -- this stable constraint to typed CapacityExhausted and latches audit - -- health outside the rolled-back transaction. - RAISE EXCEPTION 'authorization event capacity exhausted' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_event_capacity_exhausted'; - END IF; - - UPDATE authorization_event_capacity - SET retained_event_count = retained_event_count + 1, - retained_envelope_bytes = retained_envelope_bytes + envelope_bytes, - updated_at = transaction_timestamp() - WHERE community_id = NEW.community_id; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER authorization_events_capacity - BEFORE INSERT ON authorization_events - FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_before_insert_v1(); - -CREATE FUNCTION authorization_invalidation_domain_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF NEW IS NOT DISTINCT FROM OLD THEN - RETURN NEW; - END IF; - IF NEW.community_id IS DISTINCT FROM OLD.community_id - OR NEW.activated_at IS DISTINCT FROM OLD.activated_at - OR NEW.current_generation <= OLD.current_generation - OR NEW.updated_at <= OLD.updated_at - THEN - RAISE EXCEPTION 'authorization invalidation activation/generation cannot move backward' - USING ERRCODE = 'check_violation'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER authorization_invalidation_domains_monotonic - BEFORE UPDATE ON authorization_invalidation_domains - FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_domain_guard_v1(); -CREATE TRIGGER authorization_invalidation_domains_no_delete - BEFORE DELETE ON authorization_invalidation_domains - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_invalidation_domains_no_truncate - BEFORE TRUNCATE ON authorization_invalidation_domains - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE FUNCTION authorization_invalidation_floor_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF NEW IS NOT DISTINCT FROM OLD THEN - RETURN NEW; - END IF; - IF NEW.community_id IS DISTINCT FROM OLD.community_id - OR NEW.selector_kind IS DISTINCT FROM OLD.selector_kind - OR NEW.selector_fingerprint IS DISTINCT FROM OLD.selector_fingerprint - OR NEW.floor_generation < OLD.floor_generation - OR COALESCE(NEW.binding_version_floor, 0) < COALESCE(OLD.binding_version_floor, 0) - OR ( - NEW.floor_generation = OLD.floor_generation - AND COALESCE(NEW.binding_version_floor, 0) - = COALESCE(OLD.binding_version_floor, 0) - ) - OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id - OR NEW.updated_at <= OLD.updated_at - THEN - RAISE EXCEPTION 'authorization invalidation floor cannot move backward' - USING ERRCODE = 'check_violation'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER authorization_invalidation_floors_monotonic - BEFORE UPDATE ON authorization_invalidation_floors - FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_floor_guard_v1(); -CREATE TRIGGER authorization_invalidation_floors_no_delete - BEFORE DELETE ON authorization_invalidation_floors - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_invalidation_floors_no_truncate - BEFORE TRUNCATE ON authorization_invalidation_floors - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE FUNCTION authorization_authority_epoch_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF NEW IS NOT DISTINCT FROM OLD THEN - RETURN NEW; - END IF; - IF NEW.community_id IS DISTINCT FROM OLD.community_id - OR NEW.object_kind IS DISTINCT FROM OLD.object_kind - OR NEW.object_key IS DISTINCT FROM OLD.object_key - OR NEW.authority_epoch <= OLD.authority_epoch - OR NEW.fence IS NOT DISTINCT FROM OLD.fence - OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id - OR NEW.updated_at <= OLD.updated_at - THEN - RAISE EXCEPTION 'authorization authority epoch cannot move backward' - USING ERRCODE = 'check_violation'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER authorization_authority_epochs_monotonic - BEFORE UPDATE ON authorization_authority_epochs - FOR EACH ROW EXECUTE FUNCTION authorization_authority_epoch_guard_v1(); -CREATE TRIGGER authorization_authority_epochs_no_delete - BEFORE DELETE ON authorization_authority_epochs - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_authority_epochs_no_truncate - BEFORE TRUNCATE ON authorization_authority_epochs - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE FUNCTION authorization_event_capacity_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF NEW.community_id IS DISTINCT FROM OLD.community_id - OR NEW.max_events_per_domain IS DISTINCT FROM OLD.max_events_per_domain - OR NEW.max_bytes_per_domain IS DISTINCT FROM OLD.max_bytes_per_domain - OR NEW.max_envelope_bytes IS DISTINCT FROM OLD.max_envelope_bytes - OR NEW.configured_at IS DISTINCT FROM OLD.configured_at - OR NEW.retained_event_count < OLD.retained_event_count - OR NEW.retained_envelope_bytes < OLD.retained_envelope_bytes - OR NEW.updated_at < OLD.updated_at - OR (OLD.health_state = 2 AND ( - NEW.health_state <> 2 - OR NEW.failure_code IS DISTINCT FROM OLD.failure_code - OR NEW.failure_observed_at IS DISTINCT FROM OLD.failure_observed_at - )) - OR (OLD.health_state = 1 AND NEW.health_state = 1 AND ( - NEW.failure_code IS NOT NULL OR NEW.failure_observed_at IS NOT NULL - )) - THEN - RAISE EXCEPTION 'authorization event capacity cannot be reset online' - USING ERRCODE = 'check_violation'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION protected_object_authority_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF NEW IS NOT DISTINCT FROM OLD THEN - RETURN NEW; - END IF; - IF NEW.community_id IS DISTINCT FROM OLD.community_id - OR NEW.object_kind IS DISTINCT FROM OLD.object_kind - OR NEW.object_key IS DISTINCT FROM OLD.object_key - OR NEW.authority_epoch <= OLD.authority_epoch - OR NEW.fence IS NOT DISTINCT FROM OLD.fence - OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id - OR NEW.issued_at <= OLD.issued_at - THEN - RAISE EXCEPTION 'protected authority replacement requires a new operation and epoch' - USING ERRCODE = 'check_violation'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER authorization_event_capacity_monotonic - BEFORE UPDATE ON authorization_event_capacity - FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_guard_v1(); -CREATE TRIGGER authorization_event_capacity_no_delete - BEFORE DELETE ON authorization_event_capacity - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_event_capacity_no_truncate - BEFORE TRUNCATE ON authorization_event_capacity - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER authorization_events_immutable - BEFORE UPDATE OR DELETE ON authorization_events - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_events_no_truncate - BEFORE TRUNCATE ON authorization_events - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER authorization_authentication_denial_attempts_immutable - BEFORE UPDATE OR DELETE ON authorization_authentication_denial_attempts - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -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, kind-9, and matching semantic --- coordinates (correlation_id, reason_code, and semantic_fingerprint). Both --- directions deferred so event and attempt may be inserted in any order inside --- one transaction. The static denial_reason↔reason_code mapping is enforced --- by an immediate CHECK on the denial attempt table; the guard enforces the --- matching semantic coordinates between event and attempt. -CREATE FUNCTION authorization_denial_attempt_guard_v1() -RETURNS TRIGGER AS $$ -DECLARE - found_event_kind SMALLINT; - found_actor_kind SMALLINT; - found_request_fingerprint BYTEA; - found_correlation_id UUID; - found_reason_code SMALLINT; - found_semantic_fingerprint BYTEA; - attempt_count BIGINT; -BEGIN - IF TG_TABLE_NAME = 'authorization_events' THEN - -- Firing from the event side: only unresolved pre-auth kind-9 events - -- (actor_kind = 4) require a denial attempt row. Authenticated - -- OperatorDenied events (actor_kind 1-3) have a canonical receipt and - -- no denial attempt. - IF NEW.event_kind <> 9 OR NEW.actor_kind <> 4 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; - - -- Verify semantic coordinates match between event and denial attempt. - SELECT correlation_id, reason_code, semantic_fingerprint - INTO found_correlation_id, found_reason_code, found_semantic_fingerprint - FROM authorization_authentication_denial_attempts - WHERE community_id = NEW.community_id - AND audit_event_id = NEW.event_id; - - IF found_correlation_id IS DISTINCT FROM NEW.correlation_id THEN - RAISE EXCEPTION - 'denial attempt correlation_id % does not match event correlation_id % for event %', - found_correlation_id, NEW.correlation_id, NEW.event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; - END IF; - - IF found_reason_code IS DISTINCT FROM NEW.reason_code THEN - RAISE EXCEPTION - 'denial attempt reason_code % does not match event reason_code % for event %', - found_reason_code, NEW.reason_code, NEW.event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; - END IF; - - IF found_semantic_fingerprint IS DISTINCT FROM NEW.semantic_fingerprint THEN - RAISE EXCEPTION - 'denial attempt semantic_fingerprint does not match event semantic_fingerprint for event %', - NEW.event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; - END IF; - ELSE - -- Firing from the denial-attempt side: verify the audit event is the - -- unresolved pre-auth kind-9 shape (actor_kind = 4, null receipt - -- fingerprint) and that exactly one denial attempt references it. - SELECT event_kind, actor_kind, request_fingerprint, - correlation_id, reason_code, semantic_fingerprint - INTO found_event_kind, found_actor_kind, found_request_fingerprint, - found_correlation_id, found_reason_code, - found_semantic_fingerprint - 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; - - -- The referenced event must be the unresolved pre-auth shape: actor_kind - -- 4 with a null receipt fingerprint. Attaching a denial attempt to an - -- authenticated OperatorDenied (actor_kind 1-3) would violate the - -- credential-free pre-authentication contract. - IF found_actor_kind <> 4 OR found_request_fingerprint IS NOT NULL THEN - RAISE EXCEPTION - 'denial attempt must reference an unresolved pre-auth kind-9 event ' - '(actor_kind 4, null request_fingerprint); got actor_kind % ' - 'and request_fingerprint % for event %', - found_actor_kind, - CASE WHEN found_request_fingerprint IS NULL THEN 'null' ELSE 'non-null' END, - NEW.audit_event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_event_kind'; - END IF; - - -- Verify semantic coordinates match. - IF found_correlation_id IS DISTINCT FROM NEW.correlation_id THEN - RAISE EXCEPTION - 'denial attempt correlation_id % does not match event correlation_id % for event %', - NEW.correlation_id, found_correlation_id, NEW.audit_event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; - END IF; - - IF found_reason_code IS DISTINCT FROM NEW.reason_code THEN - RAISE EXCEPTION - 'denial attempt reason_code % does not match event reason_code % for event %', - NEW.reason_code, found_reason_code, NEW.audit_event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; - END IF; - - IF found_semantic_fingerprint IS DISTINCT FROM NEW.semantic_fingerprint THEN - RAISE EXCEPTION - 'denial attempt semantic_fingerprint does not match event semantic_fingerprint for event %', - NEW.audit_event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; - 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 - manifest authorization_operation_version_delta_manifests%ROWTYPE; - actual_component_count BIGINT; -BEGIN - IF TG_TABLE_NAME = 'authorization_operation_version_delta_manifests' THEN - manifest := NEW; - ELSE - SELECT * INTO STRICT manifest - FROM authorization_operation_version_delta_manifests - WHERE community_id = NEW.community_id - AND operation_id = NEW.operation_id - FOR NO KEY UPDATE; - END IF; - - SELECT count(*) INTO actual_component_count - FROM authorization_operation_version_deltas - WHERE community_id = manifest.community_id - AND operation_id = manifest.operation_id; - - IF actual_component_count <> manifest.component_count THEN - RAISE EXCEPTION 'operation version manifest declares % components, found %', - manifest.component_count, actual_component_count - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_operation_version_delta_cardinality'; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_manifest_cardinality - AFTER INSERT ON authorization_operation_version_delta_manifests - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1(); -CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_component_cardinality - AFTER INSERT ON authorization_operation_version_deltas - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1(); - -CREATE TRIGGER authorization_operation_version_delta_manifests_immutable - BEFORE UPDATE OR DELETE ON authorization_operation_version_delta_manifests - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_operation_version_delta_manifests_no_truncate - BEFORE TRUNCATE ON authorization_operation_version_delta_manifests - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER authorization_operation_version_deltas_immutable - BEFORE UPDATE OR DELETE ON authorization_operation_version_deltas - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_operation_version_deltas_no_truncate - BEFORE TRUNCATE ON authorization_operation_version_deltas - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER protected_object_authority_no_delete - BEFORE DELETE ON protected_object_authority - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER protected_object_authority_no_truncate - BEFORE TRUNCATE ON protected_object_authority - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); -CREATE TRIGGER protected_object_authority_strict_replacement - BEFORE UPDATE ON protected_object_authority - FOR EACH ROW EXECUTE FUNCTION protected_object_authority_guard_v1(); - --- Canonical admission keeps its complete logical intent and the closed, --- credential-free application result beside the immutable receipt. This is --- what lets an identical request replay reconstruct the same typed result --- without repeating membership or other application DML. Object kinds match --- protected_object_authority: 1 domain, 2 channel, 3 repository, 4 media, --- 5 moderation target, 6 audio session. -CREATE TABLE authorization_admission_results ( - community_id UUID NOT NULL, - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - semantic_fingerprint BYTEA NOT NULL CHECK ( - octet_length(semantic_fingerprint) = 32 - AND semantic_fingerprint <> decode(repeat('00', 32), 'hex') - ), - object_kind SMALLINT NOT NULL CHECK (object_kind BETWEEN 1 AND 6), - object_key BYTEA NOT NULL CHECK ( - octet_length(object_key) = 32 - AND object_key <> decode(repeat('00', 32), 'hex') - ), - application_type BYTEA CHECK ( - application_type IS NULL - OR (octet_length(application_type) = 32 - AND application_type <> decode(repeat('00', 32), 'hex')) - ), - application_version SMALLINT CHECK (application_version > 0), - application_code SMALLINT CHECK (application_code > 0), - application_payload BYTEA CHECK ( - application_payload IS NULL OR octet_length(application_payload) <= 4096 - ), - application_intent_digest BYTEA CHECK ( - application_intent_digest IS NULL - OR (octet_length(application_intent_digest) = 32 - AND application_intent_digest <> decode(repeat('00', 32), 'hex')) - ), - application_effect_digest BYTEA CHECK ( - application_effect_digest IS NULL - OR (octet_length(application_effect_digest) = 32 - AND application_effect_digest <> decode(repeat('00', 32), 'hex')) - ), - application_result_digest BYTEA CHECK ( - application_result_digest IS NULL - OR (octet_length(application_result_digest) = 32 - AND application_result_digest <> decode(repeat('00', 32), 'hex')) - ), - recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, operation_id), - FOREIGN KEY (community_id, operation_id, request_fingerprint) - REFERENCES authorization_operation_receipts - (community_id, operation_id, request_fingerprint), - CHECK ( - (application_type IS NULL - AND application_version IS NULL - AND application_code IS NULL - AND application_payload IS NULL - AND application_intent_digest IS NULL - AND application_effect_digest IS NULL - AND application_result_digest IS NULL) - OR (application_type IS NOT NULL - AND application_version IS NOT NULL - AND application_code IS NOT NULL - AND application_payload IS NOT NULL - AND application_intent_digest IS NOT NULL - AND application_effect_digest IS NOT NULL - AND application_result_digest IS NOT NULL) - ) -); - -CREATE TRIGGER authorization_admission_results_no_update - BEFORE UPDATE OR DELETE ON authorization_admission_results - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -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 --- in any order inside one transaction but can never commit partially. The --- extended-lifecycle operation kinds (2 provision, 4 disable, 7 recover, --- 8 enable, 9 admission loss) and their event kinds arrive with the --- FI-LIFECYCLE migration; here the mapping covers only enroll/retire/revoke/ --- rotate. Non-lifecycle receipts (protected mutation, invalidation) carry no --- audit-event cardinality requirement. -CREATE FUNCTION authorization_operation_receipt_event_guard_v1() -RETURNS TRIGGER AS $$ -DECLARE - receipt authorization_operation_receipts%ROWTYPE; - expected_event_kind SMALLINT; - matching_event_count BIGINT; - expected_event_count BIGINT; -BEGIN - IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN - receipt := NEW; - ELSE - SELECT * INTO receipt - FROM authorization_operation_receipts - WHERE community_id = NEW.community_id - AND operation_id = NEW.operation_id; - IF NOT FOUND THEN - -- Credential-free pre-authentication denials intentionally have no - -- canonical receipt. Their separate FK/shape guards still run. - RETURN NULL; - END IF; - END IF; - - expected_event_kind := CASE receipt.operation_kind - WHEN 1 THEN 1 -- enroll - WHEN 3 THEN 6 -- retire - WHEN 5 THEN 2 -- revoke - WHEN 6 THEN 3 -- rotate - ELSE NULL - END; - IF expected_event_kind IS NULL THEN - RETURN NULL; - END IF; - - -- Only applied (outcome_code = 1) and no-op (outcome_code = 3) lifecycle - -- receipts require exactly one paired success-transition event. A denied - -- lifecycle receipt (outcome_code = 2) requires zero events from the - -- complete core lifecycle success-transition class (kinds 1, 2, 3, 6: - -- enrolled, revoked, rotated, retired). Forbidding only the mapped kind - -- would allow a wrong-kind transition event to attach to the denied receipt, - -- which is equally a contradictory durable fact. Legitimate audit/denial - -- events of other kinds (e.g., authenticated kind 9) remain allowed. - -- Other outcome codes (4, 5) are not core lifecycle outcomes; skip. - IF receipt.outcome_code IN (1, 3) THEN - SELECT - count(*), - count(*) FILTER (WHERE event_kind = expected_event_kind) - INTO matching_event_count, expected_event_count - FROM authorization_events - WHERE community_id = receipt.community_id - AND operation_id = receipt.operation_id - AND request_fingerprint = receipt.request_fingerprint; - - IF matching_event_count <> 1 OR expected_event_count <> 1 THEN - RAISE EXCEPTION - 'lifecycle receipt requires exactly one event kind %, found % total and % expected', - expected_event_kind, matching_event_count, expected_event_count - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_operation_receipt_event_cardinality'; - END IF; - ELSIF receipt.outcome_code = 2 THEN - SELECT count(*) FILTER (WHERE event_kind IN (1, 2, 3, 6)) - INTO expected_event_count - FROM authorization_events - WHERE community_id = receipt.community_id - AND operation_id = receipt.operation_id - AND request_fingerprint = receipt.request_fingerprint; - - IF expected_event_count <> 0 THEN - RAISE EXCEPTION - 'denied lifecycle receipt must not have any core success-transition event ' - '(kinds 1/2/3/6); found % — contradictory durable facts are not permitted', - expected_event_count - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denied_lifecycle_receipt_no_success_event'; - END IF; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE CONSTRAINT TRIGGER authorization_operation_receipt_event_cardinality - AFTER INSERT ON authorization_operation_receipts - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); - -CREATE CONSTRAINT TRIGGER authorization_event_receipt_cardinality - AFTER INSERT ON authorization_events - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1();