diff --git a/Cargo.lock b/Cargo.lock index 9544a63b899..cbf13970527 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,10 +939,12 @@ dependencies = [ "base64 0.22.1", "buzz-core", "chrono", + "futures-util", "hex", "jsonwebtoken", "nostr 0.44.7", "rand 0.10.1", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.11.0", @@ -1057,6 +1059,7 @@ dependencies = [ name = "buzz-db" version = "0.1.0" dependencies = [ + "buzz-auth", "buzz-core", "buzz-datastore-tracing", "chrono", diff --git a/Cargo.toml b/Cargo.toml index d6ee839f1b0..0af365f52fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,7 @@ chrono = { version = "0.4", features = ["serde"] } jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs"] } # HTTP client (webhook delivery) -reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } +reqwest = { version = "0.13", features = ["json", "rustls", "stream"], default-features = false } # Cryptography sha2 = "0.11" diff --git a/crates/buzz-auth/Cargo.toml b/crates/buzz-auth/Cargo.toml index e4ac539a988..6cbe491e2c8 100644 --- a/crates/buzz-auth/Cargo.toml +++ b/crates/buzz-auth/Cargo.toml @@ -14,6 +14,7 @@ dev = [] [dev-dependencies] # `use_pem` enables EncodingKey::from_ec_pem for minting ES256 test assertions. jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs", "use_pem"] } +tokio = { workspace = true, features = ["test-util"] } [dependencies] buzz-core = { workspace = true } @@ -21,6 +22,8 @@ base64 = { workspace = true } chrono = { workspace = true } jsonwebtoken = { workspace = true } nostr = { workspace = true } +futures-util = { workspace = true } +reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index e6473cdd54b..53f231d8834 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -46,11 +46,15 @@ pub use rate_limit::{ pub use scope::{parse_scopes, Scope}; pub use nip_fi::{ - AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, ClientSubjectPosture, - ConfidentialAssertion, DenialClass, FederatedAssertionVerifier, FederatedIdentity, - FreshnessClass, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, - RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, - VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM, + validate_nip_fi_config, AdmissionError, AssertionKeySet, AssertionPolicyId, BindingProposal, + BindingProvenance, CanonicalCapabilities, ClientSubjectPosture, ConfidentialAssertion, + DenialClass, ExactProtectedUse, FederatedAssertionVerifier, FederatedIdentity, + FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher, IssuerJwksConfig, IssuerKeySource, + IssuerPolicy, IssuerPolicyError, IssuerRegistry, JwksFetchError, JwksFetcher, NipFiMode, + NipFiStartupError, OperationIntent, PreparedDependencyVersions, ProductionJwksSource, + ProofTransport, ProtectedObjectKind, RevalidationDependencies, RouteCapability, SubjectClass, + SubjectClassContract, TokenClass, TransportContractId, VerifiedAssertion, + VerifiedServerDirectContext, VerifierError, CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; diff --git a/crates/buzz-auth/src/nip_fi/authority.rs b/crates/buzz-auth/src/nip_fi/authority.rs new file mode 100644 index 00000000000..d3d90d18f0e --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/authority.rs @@ -0,0 +1,747 @@ +//! Closed types for NIP-FI prepared and committed authorization. +//! +//! ## Types defined here (buzz-auth) +//! +//! - [`RouteCapability`] — server-owned closed capability vocabulary; code `2` +//! (`MessagesWrite`) is the canonical mapping for WebSocket event ingress. +//! - [`ProtectedObjectKind`] — closed protected-object namespace; code `2` +//! (`Channel`) is the kind for kind-9 channel message admission. +//! - [`ProofTransport`] — closed transport discriminant for the Nostr proof. +//! - [`VerifiedServerDirectContext`] — origin-sealed server-resolved request +//! context. Fields are private; the only constructor lives in +//! `buzz-db::store::nip_fi_authority` so that only the trusted +//! target/proof-validation path can produce one (`FI-INV-04`). +//! - [`ExactProtectedUse`] — the exact capability/object/intent tuple presented +//! by the caller when redeeming a [`CommittedAuthorization`]. +//! - [`BindingProposal`] / [`BindingProvenance`] / [`PreparedDependencyVersions`] +//! — data types shared between preparation and admission. +//! - [`AdmissionError`] — closed, stable admission failure enum; 16 variants, +//! each maps to exactly one [`DenialClass`] (`FI-INV-13`). +//! +//! ## Types owned in buzz-db (non-forgeable authority) +//! +//! `PreparedAuthorization`, `CommittedAuthorization`, and `AuthorizedUse` are +//! defined in `buzz-db::store::nip_fi_authority` with `pub(crate)` constructors +//! so that only the PostgreSQL admission path can produce them. They are +//! re-exported from `buzz-db`'s public surface for relay-ingress consumption. +//! No sibling crate can mint authority-bearing types. + +use super::assertion::FederatedIdentity; +use super::denial::DenialClass; +use chrono::{DateTime, Utc}; +use nostr::PublicKey; +use uuid::Uuid; + +// ── Route capability vocabulary ─────────────────────────────────────────────── + +/// Server-owned closed route capability. +/// +/// The database code is the authoritative stable identifier written to +/// `protected_object_authority.capability`; no other value is valid. +/// WebSocket event ingress (kind-9 channel messages) maps to +/// [`RouteCapability::MessagesWrite`] / code `2`. +/// +/// The database code table matches the historical `capability_code` mapping at +/// commit `341a08a42 crates/buzz-db/src/authorization_admission.rs`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum RouteCapability { + /// Read messages. DB code: 1. + MessagesRead, + /// Write messages (WebSocket event ingress, kind-9). DB code: 2. + MessagesWrite, + /// Read channel metadata. DB code: 3. + ChannelsRead, + /// Mutate channels. DB code: 4. + ChannelsWrite, + /// Channel administration. DB code: 5. + AdminChannels, + /// Read user metadata. DB code: 6. + UsersRead, + /// Mutate user metadata. DB code: 7. + UsersWrite, + /// User administration. DB code: 8. + AdminUsers, + /// Read jobs. DB code: 9. + JobsRead, + /// Mutate jobs. DB code: 10. + JobsWrite, + /// Read subscriptions. DB code: 11. + SubscriptionsRead, + /// Mutate subscriptions. DB code: 12. + SubscriptionsWrite, + /// Read files. DB code: 13. + FilesRead, + /// Write files. DB code: 14. + FilesWrite, + /// Read repositories. DB code: 15. + ReposRead, + /// Write repositories. DB code: 16. + ReposWrite, + /// Read Git objects and refs. DB code: 17. + GitRead, + /// Mutate Git objects and refs. DB code: 18. + GitWrite, + /// Bounded Git streaming. DB code: 19. + GitStream, + /// Read media. DB code: 20. + MediaRead, + /// Upload or mutate media. DB code: 21. + MediaWrite, + /// Perform moderation operations. DB code: 22. + Moderation, + /// Join an audio session. DB code: 23. + AudioJoin, + /// Send or receive bounded audio media. DB code: 24. + AudioMedia, + /// Read protected discovery data. DB code: 25. + Discovery, + /// Read current local binding status. DB code: 26. + BindingStatus, + /// Enroll a local binding. DB code: 27. + Enrollment, + /// Mint an invitation. DB code: 28. + InviteMint, + /// Claim an invitation. DB code: 29. + InviteClaim, +} + +impl RouteCapability { + /// Stable code written to `protected_object_authority.capability`. + /// Values are fixed and must not change once rows exist in the database. + pub const fn database_code(self) -> i16 { + match self { + Self::MessagesRead => 1, + Self::MessagesWrite => 2, + Self::ChannelsRead => 3, + Self::ChannelsWrite => 4, + Self::AdminChannels => 5, + Self::UsersRead => 6, + Self::UsersWrite => 7, + Self::AdminUsers => 8, + Self::JobsRead => 9, + Self::JobsWrite => 10, + Self::SubscriptionsRead => 11, + Self::SubscriptionsWrite => 12, + Self::FilesRead => 13, + Self::FilesWrite => 14, + Self::ReposRead => 15, + Self::ReposWrite => 16, + Self::GitRead => 17, + Self::GitWrite => 18, + Self::GitStream => 19, + Self::MediaRead => 20, + Self::MediaWrite => 21, + Self::Moderation => 22, + Self::AudioJoin => 23, + Self::AudioMedia => 24, + Self::Discovery => 25, + Self::BindingStatus => 26, + Self::Enrollment => 27, + Self::InviteMint => 28, + Self::InviteClaim => 29, + } + } +} + +// ── Protected object kinds ──────────────────────────────────────────────────── + +/// Closed protected-object namespace. +/// +/// Matches `authorization_authority_epochs.object_kind` and +/// `protected_object_authority.object_kind` in migration 0042. +/// Kind-9 channel message admission uses [`ProtectedObjectKind::Channel`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtectedObjectKind { + /// Domain-wide authority. DB code: 1. + Domain, + /// One channel. DB code: 2. Used for kind-9 channel message admission. + Channel, + /// One repository. DB code: 3. + Repository, + /// One media object. DB code: 4. + Media, + /// One moderation target. DB code: 5. + ModerationTarget, + /// One audio session. DB code: 6. + AudioSession, +} + +impl ProtectedObjectKind { + /// Stable code written to `object_kind` columns in migration 0042. + pub const fn database_code(self) -> i16 { + match self { + Self::Domain => 1, + Self::Channel => 2, + Self::Repository => 3, + Self::Media => 4, + Self::ModerationTarget => 5, + Self::AudioSession => 6, + } + } +} + +// ── Operation intent ────────────────────────────────────────────────────────── + +/// Closed operation intent discriminant for the local policy matrix. +/// +/// The kind-9 core path maps to [`OperationIntent::Mutation`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationIntent { + /// A read or query operation. DB code: 1. + Query, + /// A write or mutation operation. DB code: 2. + Mutation, +} + +impl OperationIntent { + /// Stable code for the local policy matrix. + pub const fn database_code(self) -> i16 { + match self { + Self::Query => 1, + Self::Mutation => 2, + } + } +} + +// ── Proof transport ─────────────────────────────────────────────────────────── + +/// The Nostr-proof transport that bound the actor to the request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProofTransport { + /// WebSocket NIP-42 AUTH event. DB code: 1. + Nip42, + /// HTTP NIP-98 signed event. DB code: 2. + Nip98, +} + +impl ProofTransport { + /// Stable code for fingerprint domain separation. + pub const fn database_code(self) -> i16 { + match self { + Self::Nip42 => 1, + Self::Nip98 => 2, + } + } +} + +// ── Origin-sealed server-resolved request context ───────────────────────────── + +/// Origin-sealed server-resolved request context for NIP-FI direct admission. +/// +/// Every field is resolved and validated by the trusted relay routing and +/// proof-validation path before `prepare_direct` is called. No client-supplied +/// value, unsigned header, or assertion claim may set any field here +/// (`FI-INV-04`). +/// +/// **Constructor is `pub(crate)` in `buzz-db`** — this type is defined in +/// `buzz-auth` for its type identity but only the PostgreSQL admission path in +/// `buzz-db::store::nip_fi_authority` can construct it. Sibling crates cannot +/// mint a `VerifiedServerDirectContext`. +/// +/// ## Kind-9 canonical tuple +/// +/// ```text +/// capability = RouteCapability::MessagesWrite (code 2) +/// object_kind = ProtectedObjectKind::Channel (code 2) +/// intent = OperationIntent::Mutation (code 2) +/// object_key = SHA-256(channel_uuid.as_bytes()) — canonical 16-byte big-endian UUID +/// transport = ProofTransport::Nip42 +/// ``` +/// +/// Receipt `operation_kind = 11` (protected-mutation) is the fixed DB constant, +/// never derived from `capability` or `intent` here. +/// +/// ## `object_key` encoding +/// +/// `object_key` is `SHA-256(canonical_object_bytes)`: +/// - `Channel`: `SHA-256(channel_uuid.as_bytes())` — 16-byte big-endian. +/// - `Domain`: `SHA-256(community_uuid.as_bytes())`. +/// - Other kinds: analogously. Always server-resolved; no client value accepted. +/// +/// ## `channel_uuid` for DB lookup +/// +/// `channel_uuid_raw` carries the raw private UUID for the `(community_id, +/// channel_id)` resource-authority DB lookup inside `prepare_direct`. It is +/// never exposed on any public surface; `object_key` is the only derived form +/// that leaves the admission path. +#[derive(Debug)] +pub struct VerifiedServerDirectContext { + /// Nostr-proof transport that bound the actor (NIP-42 or NIP-98). + pub transport: ProofTransport, + /// Full 32-byte event ID of the NIP-42 AUTH or NIP-98 proof event. + /// This is the durable replay-claim coordinate. + pub proof_event_id: [u8; 32], + /// Freshness deadline of the proof. No admission may proceed at or after + /// this instant. + pub proof_expires_at: DateTime, + /// Server-resolved 32-byte Nostr public key of the proven actor. + pub actor: PublicKey, + /// Community (tenant) UUID for this admission. + pub community_id: Uuid, + /// Server-resolved protected-operation capability. + pub capability: RouteCapability, + /// Server-resolved protected-object kind. + pub object_kind: ProtectedObjectKind, + /// Server-resolved operation intent (query or mutation). + pub intent: OperationIntent, + /// SHA-256 of the canonical server-resolved object identifier. + /// For Channel: `SHA-256(channel_uuid.as_bytes())`. + pub object_key: [u8; 32], + /// Raw private channel UUID for the resource-authority DB lookup. + /// Only set for `ProtectedObjectKind::Channel`. Never exposed externally; + /// `object_key` is the only derived form that leaves the admission path. + pub channel_uuid_raw: Option<[u8; 16]>, +} + +impl VerifiedServerDirectContext { + /// Construct a `VerifiedServerDirectContext` from trusted routing inputs. + /// + /// **Intended only for the trusted relay proof-validation path.** The + /// caller must have already validated the Nostr proof and resolved all + /// fields from authenticated server state. No client-supplied value may + /// flow here (`FI-INV-04`). + #[allow(clippy::too_many_arguments)] + pub fn new( + transport: ProofTransport, + proof_event_id: [u8; 32], + proof_expires_at: DateTime, + actor: PublicKey, + community_id: Uuid, + capability: RouteCapability, + object_kind: ProtectedObjectKind, + intent: OperationIntent, + object_key: [u8; 32], + channel_uuid_raw: Option<[u8; 16]>, + ) -> Self { + Self { + transport, + proof_event_id, + proof_expires_at, + actor, + community_id, + capability, + object_kind, + intent, + object_key, + channel_uuid_raw, + } + } +} + +// ── Exact protected use ─────────────────────────────────────────────────────── + +/// The exact capability/object/intent tuple presented by the caller when +/// redeeming a [`CommittedAuthorization`] via `authorize_protected_use`. +/// +/// `authorize_protected_use` exact-matches this tuple against the committed +/// context; any mismatch denies the use without a receipt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExactProtectedUse { + /// The requested capability. + pub capability: RouteCapability, + /// The requested protected-object kind. + pub object_kind: ProtectedObjectKind, + /// The requested operation intent. + pub intent: OperationIntent, + /// SHA-256 of the canonical server-resolved object identifier. + pub object_key: [u8; 32], +} + +// ── Binding types ───────────────────────────────────────────────────────────── + +/// The immutable enrollment provenance recorded in a binding row. +/// +/// Corresponds to the `binding_provenance` column: `1 attested-key`, +/// `2 provisioned`, `3 risk-labelled TOFU`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingProvenance { + /// Assertion carried a `nostr_pubkey` claim matching the proven actor. + AttestedKey, + /// Binding created by a separately authorized provisioning operation. + Provisioned, + /// TOFU enrollment: first-use binding under deployment TOFU risk posture. + Tofu, +} + +impl BindingProvenance { + /// Closed integer code for `binding_provenance` column. + pub const fn as_db_code(self) -> i16 { + match self { + Self::AttestedKey => 1, + Self::Provisioned => 2, + Self::Tofu => 3, + } + } +} + +/// The prepared binding proposal. +/// +/// Read-only evidence; preparation never writes a binding row (`FI-INV-08`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BindingProposal { + /// An active binding for `(domain, i, k)` already exists. + Existing { + /// The binding ID of the existing active binding. + binding_id: Uuid, + /// Monotonic binding version for stale-check on final admission. + binding_version: i64, + /// Enrollment provenance of the existing binding. + provenance: BindingProvenance, + /// Binding expiry deadline, if set, captured at preparation time. + expires_at: Option>, + }, + /// No active binding exists; final admission must create one atomically. + Enroll { + /// The federated identity to bind. + identity: FederatedIdentity, + /// The proven actor's public key. + actor: PublicKey, + /// Enrollment provenance for the new binding. + provenance: BindingProvenance, + /// Policy revision at enrollment time. + policy_revision: i64, + }, +} + +/// Snapshot of every dependency version read atomically during preparation. +/// +/// Final admission compares each field against a current authoritative read +/// inside the SERIALIZABLE transaction: +/// +/// - `invalidation_generation`: if advanced → `InvalidationGenerationAdvanced` +/// - `policy_revision`: if changed → re-evaluate local policy; if denied → +/// `PolicyRevisionChanged` +/// - `authority_epoch`: if changed for this object → `AuthorityEpochChanged` +/// - `lifecycle_revision`: binding lifecycle state version at preparation time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedDependencyVersions { + /// Policy revision at preparation time. + pub policy_revision: i64, + /// Invalidation generation at preparation time. + pub invalidation_generation: i64, + /// Authority epoch for the target object at preparation time. `None` + /// means the object had no epoch row (first-grant path). + pub authority_epoch: Option, + /// Authority fence for the target object at preparation time. `None` + /// means no fence row exists yet. + pub authority_fence: Option, + /// Binding lifecycle revision at preparation time. `None` for fresh + /// enrollment proposals. + pub lifecycle_revision: Option, +} + +// ── Admission error ─────────────────────────────────────────────────────────── + +/// Closed, stable admission failure. +/// +/// Every variant maps to exactly one [`DenialClass`]; granular codes are for +/// access-controlled logs only. No variant carries credential material +/// (`FI-INV-13`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum AdmissionError { + // ── Evidence rejection (403 EvidenceRejected) ──────────────────────────── + /// A prepared deadline did not survive preparation → commit. + #[error("prepared assertion deadline expired between preparation and admission")] + PreparedDeadlineExpired, + + /// The re-verified assertion differs from the prepared one on an + /// identity-class field, or a bounds-class deadline regressed. + #[error("prepared assertion is not equivalent to current revalidation")] + AssertionEquivalenceViolation, + + /// An assertion-policy or transport-contract ID changed between + /// preparation and admission. + #[error("contract ID changed between preparation and admission")] + ContractIdChanged, + + // ── Authorization denial (403 AuthorizationDenied) ─────────────────────── + /// Actor key appears in the revoked-key selector set `Y_D(k)`. + #[error("actor key is revoked")] + KeyRevoked, + + /// Exact `(i, k)` pair appears in the retired-pair selector set `T_D(i,k)`. + #[error("identity/key pair is retired")] + PairRetired, + + /// A different active binding exists for `i` or `k` in this domain. + #[error("binding conflict: another active binding exists for this identity or key")] + BindingConflict, + + /// Enrollment policy is `attested-key` but no matching `nostr_pubkey` claim. + #[error("attested-key enrollment required but no key attestation in assertion")] + AttestationRequired, + + /// Enrollment policy requires an existing binding; no enrollment permitted. + #[error("existing binding required; enrollment not permitted")] + BindingRequired, + + /// Local policy denied the operation for this context tuple. + #[error("local policy denied the operation")] + LocalPolicyDenied, + + /// Invalidation generation advanced between preparation and admission. + #[error("invalidation generation advanced: prepared evidence is stale")] + InvalidationGenerationAdvanced, + + /// Policy revision changed; re-evaluated local policy denied. + #[error("policy revision changed: re-evaluated local policy denied")] + PolicyRevisionChanged, + + /// The prepared binding version no longer matches current state. + #[error("prepared binding version is stale")] + PreparedBindingVersionStale, + + /// The authority epoch for the target object changed between preparation + /// and admission; the prepared proposal may not apply. + #[error("protected object authority epoch changed: prepared evidence is stale")] + AuthorityEpochChanged, + + /// The proof event identity was already committed for this community. + /// Classified `AuthorizationDenied` so replay is indistinguishable from + /// any other private-state denial (`FI-TRACE-DENIAL-ORACLE`). + #[error("proof identity already committed: replay denied")] + ProofReplayed, + + /// Protected use tuple did not exactly match the committed context. + /// Classified `AuthorizationDenied` to prevent operation/resource + /// cross-use oracle attacks. + #[error("exact protected-use tuple does not match committed context")] + ProtectedUseMismatch, + + // ── Availability failure (503 AuthorizationUnavailable) ────────────────── + /// Authorization audit capacity exhausted or unhealthy. + #[error("authorization audit capacity exhausted or unavailable")] + AuditCapacityUnavailable, + + /// A required authoritative dependency could not be read. + #[error("required authoritative dependency unavailable")] + AuthoritativeDependencyUnavailable, +} + +impl AdmissionError { + /// The public denial class for this error. + pub const fn denial_class(self) -> DenialClass { + match self { + Self::PreparedDeadlineExpired + | Self::AssertionEquivalenceViolation + | Self::ContractIdChanged => DenialClass::EvidenceRejected, + + Self::KeyRevoked + | Self::PairRetired + | Self::BindingConflict + | Self::AttestationRequired + | Self::BindingRequired + | Self::LocalPolicyDenied + | Self::InvalidationGenerationAdvanced + | Self::PolicyRevisionChanged + | Self::PreparedBindingVersionStale + | Self::AuthorityEpochChanged + | Self::ProofReplayed + | Self::ProtectedUseMismatch => DenialClass::AuthorizationDenied, + + Self::AuditCapacityUnavailable | Self::AuthoritativeDependencyUnavailable => { + DenialClass::AuthorizationUnavailable + } + } + } + + /// Stable machine code for access-controlled logs and metrics. + pub const fn code(self) -> &'static str { + match self { + Self::PreparedDeadlineExpired => "nip_fi_prepared_deadline_expired", + Self::AssertionEquivalenceViolation => "nip_fi_assertion_equivalence_violation", + Self::ContractIdChanged => "nip_fi_contract_id_changed", + Self::KeyRevoked => "nip_fi_key_revoked", + Self::PairRetired => "nip_fi_pair_retired", + Self::BindingConflict => "nip_fi_binding_conflict", + Self::AttestationRequired => "nip_fi_attestation_required", + Self::BindingRequired => "nip_fi_binding_required", + Self::LocalPolicyDenied => "nip_fi_local_policy_denied", + Self::InvalidationGenerationAdvanced => "nip_fi_invalidation_generation_advanced", + Self::PolicyRevisionChanged => "nip_fi_policy_revision_changed", + Self::PreparedBindingVersionStale => "nip_fi_prepared_binding_version_stale", + Self::AuthorityEpochChanged => "nip_fi_authority_epoch_changed", + Self::ProofReplayed => "nip_fi_proof_replayed", + Self::ProtectedUseMismatch => "nip_fi_protected_use_mismatch", + Self::AuditCapacityUnavailable => "nip_fi_audit_capacity_unavailable", + Self::AuthoritativeDependencyUnavailable => { + "nip_fi_authoritative_dependency_unavailable" + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_evidence_rejection_variant_maps_to_evidence_rejected() { + for v in [ + AdmissionError::PreparedDeadlineExpired, + AdmissionError::AssertionEquivalenceViolation, + AdmissionError::ContractIdChanged, + ] { + assert_eq!(v.denial_class(), DenialClass::EvidenceRejected, "{v:?}"); + } + } + + #[test] + fn every_private_state_variant_maps_to_authorization_denied() { + for v in [ + AdmissionError::KeyRevoked, + AdmissionError::PairRetired, + AdmissionError::BindingConflict, + AdmissionError::AttestationRequired, + AdmissionError::BindingRequired, + AdmissionError::LocalPolicyDenied, + AdmissionError::InvalidationGenerationAdvanced, + AdmissionError::PolicyRevisionChanged, + AdmissionError::PreparedBindingVersionStale, + AdmissionError::AuthorityEpochChanged, + AdmissionError::ProofReplayed, + AdmissionError::ProtectedUseMismatch, + ] { + assert_eq!(v.denial_class(), DenialClass::AuthorizationDenied, "{v:?}"); + } + } + + #[test] + fn every_availability_variant_maps_to_authorization_unavailable() { + for v in [ + AdmissionError::AuditCapacityUnavailable, + AdmissionError::AuthoritativeDependencyUnavailable, + ] { + assert_eq!( + v.denial_class(), + DenialClass::AuthorizationUnavailable, + "{v:?}" + ); + } + } + + #[test] + fn every_variant_has_a_unique_stable_code() { + let variants = [ + AdmissionError::PreparedDeadlineExpired, + AdmissionError::AssertionEquivalenceViolation, + AdmissionError::ContractIdChanged, + AdmissionError::KeyRevoked, + AdmissionError::PairRetired, + AdmissionError::BindingConflict, + AdmissionError::AttestationRequired, + AdmissionError::BindingRequired, + AdmissionError::LocalPolicyDenied, + AdmissionError::InvalidationGenerationAdvanced, + AdmissionError::PolicyRevisionChanged, + AdmissionError::PreparedBindingVersionStale, + AdmissionError::AuthorityEpochChanged, + AdmissionError::ProofReplayed, + AdmissionError::ProtectedUseMismatch, + AdmissionError::AuditCapacityUnavailable, + AdmissionError::AuthoritativeDependencyUnavailable, + ]; + let mut codes = std::collections::BTreeSet::new(); + for v in &variants { + assert!(codes.insert(v.code()), "duplicate code: {}", v.code()); + } + assert_eq!(codes.len(), variants.len()); + } + + #[test] + fn binding_provenance_db_codes_are_schema_aligned() { + assert_eq!(BindingProvenance::AttestedKey.as_db_code(), 1); + assert_eq!(BindingProvenance::Provisioned.as_db_code(), 2); + assert_eq!(BindingProvenance::Tofu.as_db_code(), 3); + } + + #[test] + fn route_capability_db_codes_are_schema_aligned() { + // Core assertions — the full table is the source of truth above. + assert_eq!(RouteCapability::MessagesRead.database_code(), 1); + assert_eq!(RouteCapability::MessagesWrite.database_code(), 2); + assert_eq!(RouteCapability::InviteClaim.database_code(), 29); + } + + #[test] + fn route_capability_codes_are_unique() { + let capabilities = [ + RouteCapability::MessagesRead, + RouteCapability::MessagesWrite, + RouteCapability::ChannelsRead, + RouteCapability::ChannelsWrite, + RouteCapability::AdminChannels, + RouteCapability::UsersRead, + RouteCapability::UsersWrite, + RouteCapability::AdminUsers, + RouteCapability::JobsRead, + RouteCapability::JobsWrite, + RouteCapability::SubscriptionsRead, + RouteCapability::SubscriptionsWrite, + RouteCapability::FilesRead, + RouteCapability::FilesWrite, + RouteCapability::ReposRead, + RouteCapability::ReposWrite, + RouteCapability::GitRead, + RouteCapability::GitWrite, + RouteCapability::GitStream, + RouteCapability::MediaRead, + RouteCapability::MediaWrite, + RouteCapability::Moderation, + RouteCapability::AudioJoin, + RouteCapability::AudioMedia, + RouteCapability::Discovery, + RouteCapability::BindingStatus, + RouteCapability::Enrollment, + RouteCapability::InviteMint, + RouteCapability::InviteClaim, + ]; + let mut codes = std::collections::BTreeSet::new(); + for c in &capabilities { + assert!( + codes.insert(c.database_code()), + "duplicate capability code: {}", + c.database_code() + ); + } + assert_eq!(codes.len(), capabilities.len()); + } + + #[test] + fn protected_object_kind_codes_are_schema_aligned() { + assert_eq!(ProtectedObjectKind::Domain.database_code(), 1); + assert_eq!(ProtectedObjectKind::Channel.database_code(), 2); + assert_eq!(ProtectedObjectKind::AudioSession.database_code(), 6); + } + + #[test] + fn operation_intent_codes_are_unique_and_aligned() { + assert_eq!(OperationIntent::Query.database_code(), 1); + assert_eq!(OperationIntent::Mutation.database_code(), 2); + assert_ne!( + OperationIntent::Query.database_code(), + OperationIntent::Mutation.database_code() + ); + } + + #[test] + fn proof_transport_codes_are_unique() { + assert_eq!(ProofTransport::Nip42.database_code(), 1); + assert_eq!(ProofTransport::Nip98.database_code(), 2); + } + + #[test] + fn prepared_dependency_versions_carries_lifecycle_revision() { + // Confirms the lifecycle_revision field exists and is accessible. + let v = PreparedDependencyVersions { + policy_revision: 7, + invalidation_generation: 3, + authority_epoch: Some(2), + authority_fence: None, + lifecycle_revision: Some(1), + }; + assert_eq!(v.policy_revision, 7); + assert_eq!(v.lifecycle_revision, Some(1)); + assert!(v.authority_fence.is_none()); + } +} diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 638b5f5363b..5c264b00ee4 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -560,6 +560,12 @@ impl IssuerRegistry { pub fn is_empty(&self) -> bool { self.policies.is_empty() } + + /// Iteration order is deliberately unspecified; callers must not depend on + /// registration order. + pub fn all_policies(&self) -> impl Iterator { + self.policies.values() + } } /// Sort and deduplicate a set-valued list of strings into its canonical form. diff --git a/crates/buzz-auth/src/nip_fi/discovery.rs b/crates/buzz-auth/src/nip_fi/discovery.rs new file mode 100644 index 00000000000..8d1b1500b12 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/discovery.rs @@ -0,0 +1,66 @@ +//! NIP-11 federated-identity discovery output. +//! +//! [`FederatedIdentityDiscovery`] serializes to the `federated_identity` +//! object required by the NIP-FI.md "Discovery" section of the NIP-11 relay +//! information document. +//! +//! ## Privacy invariants +//! +//! The discovery object MUST NOT contain: enrollment mode, TOFU posture, +//! issuer URLs, audiences, claim names, tenant IDs, or deployment-local +//! identifiers. For a fixed set of claimed profiles the complete output is +//! byte-identical across every enrollment policy and lifecycle state. +//! [FI-TRACE-DISCOVERY-PRIVATE] +//! +//! ## Offline-jwt residual bound +//! +//! `maximum_residual_upstream_revocation_seconds` is `null` for `offline-jwt` +//! deployments. An offline-jwt deployment MUST NOT advertise a finite value +//! here (NIP-FI.md:259-266). + +use serde::{Deserialize, Serialize}; + +/// The `assertion_freshness` sub-object in the `federated_identity` discovery +/// document. Describes the claimed freshness posture without exposing any +/// issuer or deployment-private state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssertionFreshnessDiscovery { + /// The wire string identifying the freshness class. + pub class: FreshnessClassDiscovery, + /// `null` for `offline-jwt`; advertising a finite bound here requires a + /// live status witness that is not yet implemented. + pub maximum_residual_upstream_revocation_seconds: Option, +} + +/// The freshness class as a stable NIP-FI wire string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FreshnessClassDiscovery { + /// No revocation bound is claimed; JWKS snapshot validation only. + OfflineJwt, +} + +/// The `federated_identity` NIP-11 discovery object. Fields never expose +/// enrollment mode, issuer, audience, or private state. +/// [FI-TRACE-DISCOVERY-PRIVATE] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FederatedIdentityDiscovery { + /// Fixed value `"client-attached"` for the core NIP-FI transport mode. + pub core: String, + /// The freshness contract claimed by this deployment. + pub assertion_freshness: AssertionFreshnessDiscovery, +} + +impl FederatedIdentityDiscovery { + /// The only supported posture: claims no residual revocation bound, which + /// is the honest description of JWKS-only assertion verification. + pub fn offline_jwt() -> Self { + Self { + core: "client-attached".to_owned(), + assertion_freshness: AssertionFreshnessDiscovery { + class: FreshnessClassDiscovery::OfflineJwt, + maximum_residual_upstream_revocation_seconds: None, + }, + } + } +} diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs new file mode 100644 index 00000000000..cfa880a3c90 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -0,0 +1,572 @@ +//! JWKS discovery, snapshot caching, and the production [`IssuerKeySource`] +//! implementation for federated-assertion verification. +//! +//! ## Design invariants +//! +//! - **Issuer binding is sealed.** [`ProductionJwksSource`] builds each +//! [`AssertionKeySet`] using the crate-private constructor and stores it +//! keyed by the exact `iss` it authenticates. A caller cannot relabel one +//! issuer's JWKS as another's — the cross-issuer bypass is closed at both +//! the request seam (the verifier re-checks `iss`) and here. +//! +//! - **No stale-key fallback.** On fetch error the source returns the current +//! snapshot if it is within its hard deadline, or `None`. It never serves +//! an expired snapshot. [FI-TRACE-JWKS-REMOVE] +//! +//! - **Bounded resource acquisition.** HTTP response streaming stops at +//! [`MAX_JWKS_RESPONSE_BYTES`] + 1 byte before any allocation for parsing. +//! Key count is bounded by [`super::config::MAX_JWKS_KEYS`] inside +//! [`AssertionKeySet::new`]. +//! +//! - **Coalesced refresh.** A single in-flight refresh per issuer prevents +//! thundering-herd. Concurrent callers observe the snapshot just after the +//! racing refresh commits. +//! +//! - **No secrets or key material in errors or logs.** [`JwksFetchError`] +//! carries only non-sensitive diagnostic codes. + +use super::config::MAX_JWKS_KEYS; +use super::verifier::{AssertionKeySet, IssuerKeySource}; +use buzz_core::network::is_not_global_unicast; +use chrono::{DateTime, Duration, Utc}; +use futures_util::StreamExt as _; +use jsonwebtoken::jwk::JwkSet; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use tracing::warn; +use url::Url; + +/// Maximum HTTP response body for a JWKS endpoint. Streaming stops at this +/// limit before any deserialization, preventing OOM from a malicious server. +pub const MAX_JWKS_RESPONSE_BYTES: usize = 512 * 1024; // 512 KiB + +/// Hard upper bound on JWKS timing fields. Values above this are rejected at +/// config construction to prevent `u64`→`i64` conversion overflow and Chrono +/// range panics when computing snapshot deadlines. +pub const MAX_JWKS_TIMING_SECONDS: u64 = 365 * 24 * 3600; // 1 year + +/// Hard deadline for the complete JWKS fetch: hostname resolution, connect, +/// headers, and body streaming combined. Applied via `tokio::time::timeout` +/// so a stalled resolver cannot keep `fetch_jwks` pending indefinitely. +pub const JWKS_REQUEST_TIMEOUT_SECS: u64 = 10; + +/// Validate that a JWKS URI is safe to fetch: HTTPS scheme, no credentials, +/// no fragment, and the host (if a bare IP) is not private/reserved. +/// Hostname targets are resolved and checked at every fetch in `fetch_jwks` +/// to prevent DNS rebinding — this check catches the most common +/// misconfiguration at construction time. +pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { + let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; + if parsed.scheme() != "https" { + return Err(JwksFetchError::InvalidUri); + } + // Credentials in the URI are never legitimate for a public JWKS endpoint + // and would be forwarded to the server, leaking material in logs. + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(JwksFetchError::InvalidUri); + } + // Fragments are client-side only; their presence indicates a misconfigured URI. + if parsed.fragment().is_some() { + return Err(JwksFetchError::InvalidUri); + } + // Reject bare private/reserved IP targets at construction time. + if let Some(url::Host::Ipv4(addr)) = parsed.host() { + if is_not_global_unicast(&std::net::IpAddr::V4(addr)) { + return Err(JwksFetchError::InvalidUri); + } + } + if let Some(url::Host::Ipv6(addr)) = parsed.host() { + if is_not_global_unicast(&std::net::IpAddr::V6(addr)) { + return Err(JwksFetchError::InvalidUri); + } + } + Ok(()) +} + +/// Resolve `host:port` to IP addresses and reject if any are private/reserved. +/// +/// Returns the first safe address for DNS pinning. Blocks on the OS resolver +/// via `spawn_blocking` to avoid blocking the async runtime. +/// +/// Uses the `(host, port)` tuple form of `ToSocketAddrs` — not +/// `format!("{host}:{port}")` — so IPv6 literal hosts (returned without +/// brackets by `Url::host_str()`) are handled correctly without socket-address +/// ambiguity. +/// +/// Rejecting *any* resolved address (not just the first) closes split-horizon +/// DNS attacks: if an attacker can cause one DNS record to resolve to a private +/// address, the entire request is blocked even when other records are public. +pub(crate) async fn resolve_and_check_ssrf( + host: &str, + port: u16, +) -> Result { + // Fast path: if the host is already a parsed IP literal, skip the resolver. + if let Ok(ip) = host.parse::() { + if is_not_global_unicast(&ip) { + return Err(JwksFetchError::InvalidUri); + } + return Ok(ip); + } + + // Hostname path: use the tuple form to avoid IPv6-bracket ambiguity. + let host_owned = host.to_owned(); + let addrs: Vec = tokio::task::spawn_blocking(move || { + use std::net::ToSocketAddrs; + (host_owned.as_str(), port) + .to_socket_addrs() + .map(|iter| iter.map(|sa| sa.ip()).collect::>()) + }) + .await + .map_err(|_| JwksFetchError::NetworkError)? + .map_err(|_| JwksFetchError::NetworkError)?; + + if addrs.is_empty() { + return Err(JwksFetchError::NetworkError); + } + for ip in &addrs { + if is_not_global_unicast(ip) { + return Err(JwksFetchError::InvalidUri); + } + } + Ok(addrs[0]) +} + +#[derive(Clone)] +struct CachedSnapshot { + key_set: AssertionKeySet, + fetched_at: DateTime, + hard_deadline: DateTime, + /// SHA-256 of the raw JWKS bytes. Suppresses generation advances when the + /// document is unchanged between refreshes. [FI-TRACE-JWKS-ADD/REMOVE] + content_digest: [u8; 32], +} + +struct IssuerState { + snapshot: Option, + /// Advances only when `content_digest` changes; never wraps (saturating). + generation_counter: u64, + /// Owned permit for in-flight refresh. Held across the complete fetch + + /// state commit; dropped automatically if the caller future is cancelled. + /// `try_lock_owned()` succeeds iff no refresh is in progress. + refresh_permit: Arc>, +} + +impl IssuerState { + fn new() -> Self { + Self { + snapshot: None, + generation_counter: 0, + refresh_permit: Arc::new(tokio::sync::Mutex::new(())), + } + } +} + +/// Per-issuer JWKS endpoint configuration. All fields are validated by +/// [`validate_jwks_uri`] and timing bounds at [`ProductionJwksSource::new`]. +#[derive(Debug, Clone)] +pub struct IssuerJwksConfig { + /// The exact `iss` value this config authenticates. Must match the + /// corresponding [`IssuerPolicy`][super::config::IssuerPolicy] exactly. + pub issuer: String, + /// Must pass [`validate_jwks_uri`]: HTTPS, no credentials/fragment, no + /// bare private-IP host. + pub jwks_uri: String, + /// Seconds until a cached snapshot is considered stale and re-fetching is + /// triggered. Must be positive, strictly less than + /// `key_snapshot_hard_deadline_seconds`, and ≤ [`MAX_JWKS_TIMING_SECONDS`]. + pub refresh_interval_seconds: u64, + /// Hard upper bound from fetch time on how long a snapshot may be served. + /// Expired snapshots are never returned, even on fetch error — no stale + /// fallback. Folds into every `AssertionKeySet` hard deadline and therefore + /// into every `VerifiedAssertion.revalidation_dependencies`. + /// Must be ≤ [`MAX_JWKS_TIMING_SECONDS`]. + pub key_snapshot_hard_deadline_seconds: u64, +} + +/// Reason a JWKS fetch or parse operation failed. No key material, issuer +/// URLs, or raw response content appear in these variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum JwksFetchError { + /// Non-HTTPS scheme, embedded credentials, fragment, bare + /// private/reserved IP host, or DNS resolved to a private/reserved address. + #[error("JWKS URI failed safety validation")] + InvalidUri, + /// Response body exceeded [`MAX_JWKS_RESPONSE_BYTES`]. + #[error("JWKS response exceeded size limit")] + ResponseTooLarge, + /// Network failure, TLS error, request timeout, or non-2xx status. + #[error("JWKS HTTP request failed")] + NetworkError, + /// Response body was not parseable as a JWK Set. + #[error("JWKS response was not parseable")] + ParseError, + /// Parsed key set was empty or exceeded [`super::config::MAX_JWKS_KEYS`]. + #[error("JWKS key set bounds violation")] + KeyCountBoundsViolation, +} + +/// Sealed injection seam for JWKS HTTP fetching. Only types inside `buzz_auth` +/// may implement it — external types cannot name the private supertrait. +/// +/// Implementations MUST: +/// - validate the URI (scheme, credentials, fragment, bare private-IP host) +/// before any I/O; +/// - resolve hostname targets and reject any private/reserved resolved address; +/// - deny redirects (3xx responses rejected as `NetworkError`); +/// - enforce a finite per-fetch deadline covering resolution, connect, headers, +/// and body streaming — the entire operation must be bounded; +/// - enforce [`MAX_JWKS_RESPONSE_BYTES`] via incremental streaming; +/// - reject non-2xx responses. +pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { + /// Fetch and return the raw JSON body from the given JWKS URI. + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a; +} + +/// Production [`JwksFetcher`] backed by `reqwest`. Each call to `fetch_jwks` +/// builds a dedicated pinned client — no shared connection state between fetches. +/// +/// Per-fetch boundary enforcement: +/// - hostname DNS is resolved and every address checked against +/// `buzz_core::network::is_not_global_unicast` before the request is sent; +/// - the request is pinned to the validated address to prevent DNS rebinding +/// TOCTOU (the OS resolver is called once per fetch, not once per URL); +/// - the complete operation (resolution, connect, headers, body streaming) is +/// bounded by [`JWKS_REQUEST_TIMEOUT_SECS`] via `tokio::time::timeout`; +/// - 3xx responses are rejected as `NetworkError` — redirects are never followed; +/// - the body is streamed incrementally and stopped at +/// [`MAX_JWKS_RESPONSE_BYTES`] + 1. +#[derive(Clone, Debug)] +pub struct HttpJwksFetcher; + +impl HttpJwksFetcher { + /// Builds a new fetcher. Security invariants are enforced per-request in + /// `fetch_jwks` — each call constructs a dedicated pinned client. + pub fn new() -> Self { + Self + } +} + +impl Default for HttpJwksFetcher { + fn default() -> Self { + Self::new() + } +} + +impl super::verifier::sealed::Sealed for HttpJwksFetcher {} + +impl JwksFetcher for HttpJwksFetcher { + async fn fetch_jwks<'a>(&'a self, uri: &'a str) -> Result { + with_deadline( + fetch_jwks_inner(uri), + std::time::Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS), + ) + .await + } +} + +/// Bound `fut` with a hard `tokio::time::timeout`. Elapsed maps to +/// `NetworkError`. Production passes `fetch_jwks_inner(uri)`; tests pass +/// `std::future::pending()` to verify the seam deterministically. +async fn with_deadline(fut: F, timeout: std::time::Duration) -> Result +where + F: std::future::Future>, +{ + tokio::time::timeout(timeout, fut) + .await + .map_err(|_| JwksFetchError::NetworkError)? +} + +/// Inner fetch logic. Called only by `HttpJwksFetcher::fetch_jwks` via `with_deadline`. +async fn fetch_jwks_inner(uri: &str) -> Result { + // Full URI validation first — scheme, credentials, fragment, bare + // private-IP host. This enforces the JwksFetcher contract for direct + // callers of HttpJwksFetcher regardless of whether ProductionJwksSource + // pre-validated the URI. + validate_jwks_uri(uri)?; + + let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; + let host = parsed.host_str().ok_or(JwksFetchError::InvalidUri)?; + let port = parsed.port_or_known_default().unwrap_or(443); + + // Resolve and check every IP before sending. Pins DNS to the validated + // address to prevent rebinding TOCTOU between check and connect. + let safe_ip = resolve_and_check_ssrf(host, port).await?; + + // Build a per-request client that: + // - denies redirects (a 3xx to an internal host bypasses the URI check); + // - has no system proxy (proxy would resolve the original hostname itself); + // - pins this request to the validated IP. + let pinned_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .resolve(host, std::net::SocketAddr::new(safe_ip, port)) + .build() + .map_err(|_| JwksFetchError::NetworkError)?; + + let response = pinned_client + .get(uri) + .send() + .await + .map_err(|_| JwksFetchError::NetworkError)?; + + // Reject non-2xx. A 3xx here means our no-redirect policy was somehow + // bypassed — treat as a network error. + if !response.status().is_success() { + return Err(JwksFetchError::NetworkError); + } + + // Early-exit on Content-Length before streaming. A lying or absent + // Content-Length is caught by the incremental counter below. + if let Some(content_length) = response.content_length() { + if content_length as usize > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + } + + // Stream incrementally; stop at MAX_JWKS_RESPONSE_BYTES + 1 so we + // never buffer more than the limit before rejecting. + let mut body = Vec::with_capacity(MAX_JWKS_RESPONSE_BYTES.min(64 * 1024)); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| JwksFetchError::NetworkError)?; + if body.len().saturating_add(chunk.len()) > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + + String::from_utf8(body).map_err(|_| JwksFetchError::ParseError) +} + +fn parse_and_bound_jwks(body: &str) -> Result { + let key_set: JwkSet = serde_json::from_str(body).map_err(|_| JwksFetchError::ParseError)?; + if key_set.keys.is_empty() || key_set.keys.len() > MAX_JWKS_KEYS { + return Err(JwksFetchError::KeyCountBoundsViolation); + } + Ok(key_set) +} + +/// Multi-issuer JWKS cache that performs bounded periodic refresh and never +/// serves snapshots past their hard deadline. +/// +/// Must be constructed at startup after +/// [`super::startup::validate_nip_fi_config`] passes. Shared across async +/// tasks via the inner `Arc>`. +/// +/// ## Security +/// +/// - Each issuer's JWKS is stored under its exact `iss` — no relabelling. +/// - Expired snapshots are purged on access; no stale-key fallback. +/// - Errors are logged with a stable code; no key material appears in logs. +pub struct ProductionJwksSource { + configs: HashMap, + states: Arc>>>, + fetcher: Arc, +} + +impl ProductionJwksSource { + /// Returns `None` when `configs` is empty, any config has invalid timing + /// bounds or fails URI validation, or any two configs share the same + /// `issuer` (duplicate issuers make trust configuration ambiguous). + pub fn new(configs: Vec, fetcher: F) -> Option { + if configs.is_empty() { + return None; + } + let mut config_map = HashMap::with_capacity(configs.len()); + let mut state_map = HashMap::with_capacity(configs.len()); + for c in configs { + // Hard deadline must be strictly greater than refresh interval so + // a snapshot is always fresh for at least one cycle before expiry. + if c.refresh_interval_seconds == 0 + || c.key_snapshot_hard_deadline_seconds == 0 + || c.key_snapshot_hard_deadline_seconds <= c.refresh_interval_seconds + || c.refresh_interval_seconds > MAX_JWKS_TIMING_SECONDS + || c.key_snapshot_hard_deadline_seconds > MAX_JWKS_TIMING_SECONDS + { + return None; + } + if validate_jwks_uri(&c.jwks_uri).is_err() { + return None; + } + if config_map.contains_key(&c.issuer) { + return None; + } + let issuer = c.issuer.clone(); + state_map.insert(issuer.clone(), Mutex::new(IssuerState::new())); + config_map.insert(issuer, c); + } + Some(Self { + configs: config_map, + states: Arc::new(RwLock::new(state_map)), + fetcher: Arc::new(fetcher), + }) + } + + async fn fetch_fresh( + &self, + issuer: &str, + prev_digest: Option<[u8; 32]>, + prev_generation: u64, + ) -> Option<(CachedSnapshot, u64)> { + let config = self.configs.get(issuer)?; + let body = match self.fetcher.fetch_jwks(&config.jwks_uri).await { + Ok(b) => b, + Err(err) => { + warn!(error = %err, "nip-fi jwks fetch failed; will use cached snapshot if live"); + return None; + } + }; + + let jwks = match parse_and_bound_jwks(&body) { + Ok(k) => k, + Err(err) => { + warn!(error = %err, "nip-fi jwks parse failed; will use cached snapshot if live"); + return None; + } + }; + + let content_digest: [u8; 32] = Sha256::digest(body.as_bytes()).into(); + + // Advance only when the document changed so key-rotation events are + // visible [FI-TRACE-JWKS-ADD/REMOVE] while identical refetches are + // stable. Saturating add prevents wrap on the (unreachable) u64 ceiling. + let generation = if Some(content_digest) == prev_digest { + prev_generation + } else { + prev_generation.saturating_add(1).max(1) + }; + + let now = Utc::now(); + // MAX_JWKS_TIMING_SECONDS ≤ ~31.5M < i64::MAX, so this conversion is + // always safe for values that passed the bounds check in new(). + let deadline_secs = + i64::try_from(config.key_snapshot_hard_deadline_seconds).unwrap_or(i64::MAX / 2); + let hard_deadline = now + + Duration::try_seconds(deadline_secs) + .unwrap_or_else(|| Duration::seconds(i64::MAX / 2)); + + let key_set = AssertionKeySet::new(issuer.to_owned(), generation, jwks, hard_deadline)?; + + Some(( + CachedSnapshot { + key_set, + fetched_at: now, + hard_deadline, + content_digest, + }, + generation, + )) + } + + /// Returns the cached snapshot for `issuer`, refreshing inline if stale. + /// Returns `None` when no live snapshot is available and the fetch fails. + /// + /// Coalesces concurrent callers: a second call while a refresh is in + /// flight returns the current snapshot immediately rather than starting a + /// second fetch. The refresh permit is an RAII guard — if this future is + /// cancelled while DNS, HTTP, or streaming is pending, the guard drops and + /// the permit is released, so the next caller can start a new fetch. + pub async fn get_snapshot(&self, issuer: &str) -> Option { + let states = self.states.read().await; + let state_mutex = states.get(issuer)?; + let mut state = state_mutex.lock().await; + + let now = Utc::now(); + let config = self.configs.get(issuer)?; + + if let Some(ref cached) = state.snapshot { + if now >= cached.hard_deadline { + state.snapshot = None; + } + } + + let needs_refresh = match state.snapshot { + None => true, + Some(ref cached) => { + let age_secs = (now - cached.fetched_at).num_seconds().max(0) as u64; + age_secs >= config.refresh_interval_seconds + } + }; + + if !needs_refresh { + return state.snapshot.as_ref().map(|c| c.key_set.clone()); + } + + // Try to acquire the per-issuer refresh permit. Failure means another + // caller is already fetching; return the current snapshot rather than + // starting a second fetch. + let permit = match Arc::clone(&state.refresh_permit).try_lock_owned() { + Ok(g) => g, + Err(_) => return state.snapshot.as_ref().map(|c| c.key_set.clone()), + }; + + let prev_digest = state.snapshot.as_ref().map(|c| c.content_digest); + let prev_generation = state.generation_counter; + drop(state); + drop(states); + + let fresh = self.fetch_fresh(issuer, prev_digest, prev_generation).await; + + // Re-acquire state to commit and release the permit atomically. + let states = self.states.read().await; + if let Some(state_mutex) = states.get(issuer) { + let mut st = state_mutex.lock().await; + if let Some((ref cached, new_generation)) = fresh { + st.generation_counter = new_generation; + st.snapshot = Some(cached.clone()); + } + // Drop the permit only after the state commit is visible. + drop(permit); + let now2 = Utc::now(); + return st + .snapshot + .as_ref() + .filter(|c| now2 < c.hard_deadline) + .map(|c| c.key_set.clone()); + } + + drop(permit); + None + } +} + +impl super::verifier::sealed::Sealed for ProductionJwksSource {} + +impl IssuerKeySource for ProductionJwksSource { + /// Called per-request by the verifier after the cache has been warmed via + /// [`get_snapshot`][Self::get_snapshot]. + /// + /// Uses `try_read`/`try_lock` — safe to call from any async context. + /// Fails closed (returns `None`) when the lock is momentarily held by an + /// in-flight refresh, rather than blocking or panicking. [FI-INV-14] + fn key_set(&self, issuer: &str) -> Option { + let states = self.states.try_read().ok()?; + let state_mutex = states.get(issuer)?; + let state = state_mutex.try_lock().ok()?; + let now = Utc::now(); + state + .snapshot + .as_ref() + .filter(|c| now < c.hard_deadline) + .map(|c| c.key_set.clone()) + } +} + +impl std::fmt::Debug for ProductionJwksSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // No issuer URIs or key material in debug output. + write!( + f, + "ProductionJwksSource([REDACTED; {} issuers])", + self.configs.len() + ) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs new file mode 100644 index 00000000000..465d7ac1e1e --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -0,0 +1,843 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +struct FakeJwksFetcher { + body: Result, + call_count: Arc, +} + +impl super::super::verifier::sealed::Sealed for FakeJwksFetcher {} + +impl JwksFetcher for FakeJwksFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self.body.clone(); + self.call_count.fetch_add(1, Ordering::SeqCst); + async move { result } + } +} + +fn minimal_jwks_json(kid: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","use":"sig","alg":"ES256","kid":"{kid}"}}]}}"# + ) +} + +fn make_config(issuer: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + } +} + +fn make_config_with_uri(issuer: &str, jwks_uri: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: jwks_uri.to_owned(), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + } +} + +#[tokio::test] +async fn get_snapshot_returns_sealed_key_set_on_success() { + let issuer = "https://id.example"; + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + let ks = source.get_snapshot(issuer).await.unwrap(); + assert_eq!(ks.issuer(), issuer); +} + +#[tokio::test] +async fn get_snapshot_returns_none_for_unknown_issuer() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let source = + ProductionJwksSource::new(vec![make_config("https://id.example")], fetcher).unwrap(); + + assert!(source.get_snapshot("https://other.example").await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_network_error_with_no_cache() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::NetworkError), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_oversized_response() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::ResponseTooLarge), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_parse_error() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::ParseError), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn parse_and_bound_rejects_empty_key_set() { + let err = parse_and_bound_jwks(r#"{"keys":[]}"#).unwrap_err(); + assert_eq!(err, JwksFetchError::KeyCountBoundsViolation); +} + +#[tokio::test] +async fn parse_and_bound_rejects_oversized_key_set() { + let keys: Vec = (0..=MAX_JWKS_KEYS) + .map(|i| format!( + r#"{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","kid":"k{i}"}}"# + )) + .collect(); + let body = format!(r#"{{"keys":[{}]}}"#, keys.join(",")); + assert_eq!( + parse_and_bound_jwks(&body).unwrap_err(), + JwksFetchError::KeyCountBoundsViolation + ); +} + +#[tokio::test] +async fn new_rejects_empty_configs() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new(vec![], fetcher).is_none()); +} + +#[tokio::test] +async fn new_rejects_refresh_ge_hard_deadline() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let bad_config = IssuerJwksConfig { + issuer: "https://id.example".to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 3600, + key_snapshot_hard_deadline_seconds: 3600, + }; + assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none()); +} + +#[tokio::test] +async fn new_rejects_zero_refresh_interval() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let bad_config = IssuerJwksConfig { + issuer: "https://id.example".to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 0, + key_snapshot_hard_deadline_seconds: 3600, + }; + assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none()); +} + +#[tokio::test] +async fn new_rejects_timing_above_maximum() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let bad_config = IssuerJwksConfig { + issuer: "https://id.example".to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: MAX_JWKS_TIMING_SECONDS + 1, + key_snapshot_hard_deadline_seconds: MAX_JWKS_TIMING_SECONDS + 2, + }; + assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none()); +} + +#[tokio::test] +async fn new_rejects_duplicate_issuer() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let config_a = IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + }; + let config_b = IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://id.example/.well-known/jwks-alt.json".to_owned(), + refresh_interval_seconds: 600, + key_snapshot_hard_deadline_seconds: 7200, + }; + assert!(ProductionJwksSource::new(vec![config_a, config_b], fetcher).is_none()); +} + +#[tokio::test] +async fn new_rejects_non_https_jwks_uri() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new( + vec![make_config_with_uri( + "https://id.example", + "http://id.example/.well-known/jwks.json" + )], + fetcher + ) + .is_none()); +} + +#[tokio::test] +async fn new_rejects_loopback_jwks_uri() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new( + vec![make_config_with_uri( + "https://id.example", + "https://127.0.0.1/.well-known/jwks.json" + )], + fetcher + ) + .is_none()); +} + +#[tokio::test] +async fn new_rejects_private_ip_jwks_uri() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new( + vec![make_config_with_uri( + "https://id.example", + "https://10.0.0.1/.well-known/jwks.json" + )], + fetcher + ) + .is_none()); +} + +#[tokio::test] +async fn new_rejects_jwks_uri_with_credentials() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new( + vec![make_config_with_uri( + "https://id.example", + "https://user:pass@id.example/.well-known/jwks.json" + )], + fetcher + ) + .is_none()); +} + +#[tokio::test] +async fn new_rejects_jwks_uri_with_fragment() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new( + vec![make_config_with_uri( + "https://id.example", + "https://id.example/.well-known/jwks.json#keys" + )], + fetcher + ) + .is_none()); +} + +/// `key_set()` fails closed (returns `None`) before any snapshot is warmed via +/// `get_snapshot` — the synchronous path never fetches. +#[tokio::test] +async fn sync_key_set_returns_none_before_warmup() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + assert!(source.key_set(issuer).is_none()); +} + +#[tokio::test] +async fn sync_key_set_returns_snapshot_after_warmup() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + source.get_snapshot(issuer).await.unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + let ks = source.key_set(issuer).unwrap(); + assert_eq!(ks.issuer(), issuer); +} + +/// Identical document fetched twice must not advance the generation counter +/// — stable generation for unchanged JWKS prevents spurious revalidation. +#[tokio::test] +async fn generation_stable_for_identical_document() { + let issuer = "https://id.example"; + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 3600, + }; + let source = ProductionJwksSource::new(vec![config], fetcher).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + source.get_snapshot(issuer).await.unwrap(); + let gen1 = source.key_set(issuer).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + let gen2 = source.key_set(issuer).unwrap().generation(); + + assert_eq!(gen1, gen2); +} + +/// Changed document must advance the generation so key-rotation events are +/// visible [FI-TRACE-JWKS-ADD/REMOVE]. +#[tokio::test] +async fn generation_advances_for_changed_document() { + let issuer = "https://id.example"; + + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(minimal_jwks_json("k2")), + Ok(minimal_jwks_json("k1")), + ])); + + struct MultiBodyFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for MultiBodyFetcher {} + impl JwksFetcher for MultiBodyFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 3600, + }; + let source = ProductionJwksSource::new(vec![config], MultiBodyFetcher { bodies }).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + source.get_snapshot(issuer).await.unwrap(); + let gen1 = source.key_set(issuer).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + let gen2 = source.key_set(issuer).unwrap().generation(); + + assert!(gen2 > gen1, "gen1={gen1}, gen2={gen2}"); +} + +#[test] +fn validate_uri_accepts_valid_https() { + assert!(validate_jwks_uri("https://id.example/.well-known/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_accepts_public_ipv6() { + assert!(validate_jwks_uri("https://[2606:4700::1]/.well-known/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_rejects_http() { + assert_eq!( + validate_jwks_uri("http://id.example/.well-known/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_loopback_ip() { + assert_eq!( + validate_jwks_uri("https://127.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_private_ip() { + assert_eq!( + validate_jwks_uri("https://192.168.1.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_link_local_ip() { + assert_eq!( + validate_jwks_uri("https://169.254.169.254/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_credentials() { + assert_eq!( + validate_jwks_uri("https://user:pass@id.example/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_fragment() { + assert_eq!( + validate_jwks_uri("https://id.example/jwks.json#section").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_unparseable() { + assert_eq!( + validate_jwks_uri("not a url").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[tokio::test] +async fn http_fetcher_rejects_http_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + // Would resolve DNS and return NetworkError if validation ran after I/O. + let err = fetcher + .fetch_jwks("http://id.example/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_credentials_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://user:pass@id.example/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_fragment_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://id.example/.well-known/jwks.json#section") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_private_ip_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://10.0.0.1/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn resolve_ssrf_rejects_ipv6_loopback_fast_path() { + let err = super::resolve_and_check_ssrf("::1", 443).await.unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn resolve_ssrf_accepts_public_ipv6_fast_path() { + let ip = super::resolve_and_check_ssrf("2606:4700::1", 443) + .await + .unwrap(); + assert_eq!(ip, "2606:4700::1".parse::().unwrap()); +} + +/// `with_deadline` must fire before the outer guard when the inner future +/// never resolves. Removing the `tokio::time::timeout` inside `with_deadline` +/// leaves the future permanently pending — the outer guard fires and the test fails. +#[tokio::test(start_paused = true)] +async fn with_deadline_fires_before_outer_guard() { + let inner = super::with_deadline( + std::future::pending::>(), + std::time::Duration::ZERO, + ); + let result = tokio::time::timeout(std::time::Duration::from_secs(1), inner).await; + assert_eq!( + result.expect("outer guard fired — with_deadline timeout seam missing"), + Err(JwksFetchError::NetworkError), + ); +} + +// ── Refresh permit / cancellation-safety ───────────────────────────────────── + +/// Blocks until a oneshot releases it, signals an `entered` barrier on entry, +/// and returns `Ok(body)` or `Err` depending on the release value. +struct BlockingFetcher { + /// Fires as soon as `fetch_jwks` is entered — gives tests a deterministic + /// point to observe that the fetch is in progress before making assertions. + entered_tx: std::sync::Mutex>>, + /// Sending a body releases the fetch with success; dropping the sender + /// causes an error. + release_rx: std::sync::Mutex>>, + call_count: Arc, +} + +impl super::super::verifier::sealed::Sealed for BlockingFetcher {} + +impl JwksFetcher for BlockingFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + self.call_count.fetch_add(1, Ordering::SeqCst); + // Signal entry before any await so tests can observe it synchronously. + if let Some(tx) = self.entered_tx.lock().unwrap().take() { + let _ = tx.send(()); + } + let rx = self.release_rx.lock().unwrap().take(); + async move { + match rx { + Some(r) => r.await.map_err(|_| JwksFetchError::NetworkError), + None => Err(JwksFetchError::NetworkError), + } + } + } +} + +/// A second concurrent `get_snapshot` while the first fetch is in progress +/// must not start a second fetch — the permit blocks it. +/// +/// Mutation: dropping the permit before the fetch completes allows the second +/// call to win a fresh permit and start its own fetch, producing call_count 2. +#[tokio::test] +async fn concurrent_refresh_coalesces_without_second_fetch() { + let call_count = Arc::new(AtomicUsize::new(0)); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel::<()>(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + let fetcher = BlockingFetcher { + entered_tx: std::sync::Mutex::new(Some(entered_tx)), + release_rx: std::sync::Mutex::new(Some(release_rx)), + call_count: Arc::clone(&call_count), + }; + let issuer = "https://id.example"; + let source = Arc::new(ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap()); + + let source2 = Arc::clone(&source); + let issuer_owned = issuer.to_owned(); + let first = tokio::spawn(async move { source2.get_snapshot(&issuer_owned).await }); + + // Wait for the fetcher to confirm it has entered — the permit is held. + entered_rx.await.unwrap(); + + let second_result = source.get_snapshot(issuer).await; + let count_after_second = call_count.load(Ordering::SeqCst); + + let _ = release_tx.send(minimal_jwks_json("k1")); + let first_result = first.await.unwrap(); + + assert!(first_result.is_some()); + assert!( + second_result.is_none(), + "second concurrent call must not start a second fetch" + ); + assert_eq!( + count_after_second, 1, + "permit must coalesce concurrent callers" + ); +} + +/// Aborting the first caller releases the RAII permit; the next call on the +/// same source can acquire it and fetch successfully. +/// +/// Mutation: replacing the RAII permit with a manual boolean set only in the +/// success path leaves it true after abort; `get_snapshot` on the same source +/// returns None forever instead of fetching again. +#[tokio::test] +async fn aborted_first_caller_releases_permit_for_next_caller() { + let call_count = Arc::new(AtomicUsize::new(0)); + + // First call: blocks forever (we never send on this tx). + let (entered_tx_1, entered_rx_1) = tokio::sync::oneshot::channel::<()>(); + let (_no_release_tx, no_release_rx) = tokio::sync::oneshot::channel::(); + + // Second call: succeeds immediately after permit is released. + let (entered_tx_2, _entered_rx_2) = tokio::sync::oneshot::channel::<()>(); + let (release_tx_2, release_rx_2) = tokio::sync::oneshot::channel::(); + + // Use a shared Vec to hand out fetcher state across the two calls. + // The first call gets `entered_tx_1` + `no_release_rx` (blocks). + // The second call gets `entered_tx_2` + `release_rx_2` (succeeds). + let entered_txs = Arc::new(std::sync::Mutex::new(vec![ + Some(entered_tx_2), + Some(entered_tx_1), + ])); + let release_rxs = Arc::new(std::sync::Mutex::new(vec![ + Some(release_rx_2), + Some(no_release_rx), + ])); + + struct SequencedFetcher { + entered_txs: Arc>>>>, + release_rxs: Arc>>>>, + call_count: Arc, + } + impl super::super::verifier::sealed::Sealed for SequencedFetcher {} + impl JwksFetcher for SequencedFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + self.call_count.fetch_add(1, Ordering::SeqCst); + let entered = self.entered_txs.lock().unwrap().pop().flatten(); + let rx = self.release_rxs.lock().unwrap().pop().flatten(); + if let Some(tx) = entered { + let _ = tx.send(()); + } + async move { + match rx { + Some(r) => r.await.map_err(|_| JwksFetchError::NetworkError), + None => Err(JwksFetchError::NetworkError), + } + } + } + } + + let issuer = "https://id.example"; + let source = Arc::new( + ProductionJwksSource::new( + vec![make_config(issuer)], + SequencedFetcher { + entered_txs: Arc::clone(&entered_txs), + release_rxs: Arc::clone(&release_rxs), + call_count: Arc::clone(&call_count), + }, + ) + .unwrap(), + ); + + // First call: enters the fetch then is aborted. + { + let source2 = Arc::clone(&source); + let issuer_owned = issuer.to_owned(); + let first = tokio::spawn(async move { source2.get_snapshot(&issuer_owned).await }); + entered_rx_1.await.unwrap(); // confirmed inside the blocking fetch + first.abort(); + let _ = first.await; // join to confirm abort completed + } + + // The permit must now be released; the second call on the same source should succeed. + release_tx_2.send(minimal_jwks_json("k2")).unwrap(); + let result = source.get_snapshot(issuer).await; + assert!( + result.is_some(), + "second call on same source must succeed after permit is released" + ); + assert_eq!( + call_count.load(Ordering::SeqCst), + 2, + "must have fetched twice" + ); +} + +// ── Central fail-closed and issuer-isolation invariants ─────────────────────── + +/// An expired snapshot must never be served — both `get_snapshot` and the +/// synchronous `key_set` path return `None` after the hard deadline passes. +#[tokio::test] +async fn expired_snapshot_never_served_after_hard_deadline() { + let issuer = "https://id.example"; + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: format!("https://id.example/.well-known/jwks.json"), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 2, + }; + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Err::(JwksFetchError::NetworkError), + Ok(minimal_jwks_json("k1")), + ])); + struct FailAfterFirstFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for FailAfterFirstFetcher {} + impl JwksFetcher for FailAfterFirstFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + let source = ProductionJwksSource::new(vec![config], FailAfterFirstFetcher { bodies }).unwrap(); + + assert!( + source.get_snapshot(issuer).await.is_some(), + "initial fetch must succeed" + ); + + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + assert!( + source.get_snapshot(issuer).await.is_none(), + "expired snapshot must not be served after hard deadline" + ); + + use crate::nip_fi::verifier::IssuerKeySource; + assert!( + source.key_set(issuer).is_none(), + "key_set must not serve an expired snapshot" + ); +} + +/// Two issuers are fully isolated: distinct key sets, independent generation +/// counters. Advancing only issuer A's document must not change issuer B. +#[tokio::test] +async fn two_issuer_keys_and_generations_are_isolated() { + let issuer_a = "https://a.example"; + let issuer_b = "https://b.example"; + + let a_bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(minimal_jwks_json("a2")), + Ok(minimal_jwks_json("a1")), + ])); + let b_body = minimal_jwks_json("b1"); + + struct IsolationFetcher { + a_bodies: Arc>>>, + b_body: String, + } + impl super::super::verifier::sealed::Sealed for IsolationFetcher {} + impl JwksFetcher for IsolationFetcher { + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = if uri.contains("a.example") { + self.a_bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)) + } else { + Ok(self.b_body.clone()) + }; + async move { result } + } + } + + let config_a = IssuerJwksConfig { + issuer: issuer_a.to_owned(), + jwks_uri: "https://a.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 3600, + }; + let config_b = IssuerJwksConfig { + issuer: issuer_b.to_owned(), + jwks_uri: "https://b.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 3600, + }; + + let source = ProductionJwksSource::new( + vec![config_a, config_b], + IsolationFetcher { + a_bodies: Arc::clone(&a_bodies), + b_body, + }, + ) + .unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + + source.get_snapshot(issuer_a).await.unwrap(); + source.get_snapshot(issuer_b).await.unwrap(); + + let gen_a1 = source.key_set(issuer_a).unwrap().generation(); + let gen_b1 = source.key_set(issuer_b).unwrap().generation(); + + assert_eq!(source.key_set(issuer_a).unwrap().issuer(), issuer_a); + assert_eq!(source.key_set(issuer_b).unwrap().issuer(), issuer_b); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer_a).await.unwrap(); + source.get_snapshot(issuer_b).await.unwrap(); + + let gen_a2 = source.key_set(issuer_a).unwrap().generation(); + let gen_b2 = source.key_set(issuer_b).unwrap().generation(); + + assert!( + gen_a2 > gen_a1, + "issuer A generation must advance after its document changes" + ); + assert_eq!( + gen_b2, gen_b1, + "issuer B generation must not change when only A's document changed" + ); + + assert_eq!(source.key_set(issuer_a).unwrap().issuer(), issuer_a); + assert_eq!(source.key_set(issuer_b).unwrap().issuer(), issuer_b); +} diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index f7d1243a058..6944efca719 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -1,42 +1,42 @@ -//! NIP-FI federated-identity authorization — canonical assertion verifier and -//! contracts (Phase A, PR 1). -//! -//! This module is the closed, provider-neutral contract layer at the root of -//! the NIP-FI dependency graph. It defines: -//! -//! - the multi-issuer assertion-policy [`config`] and the two deterministic -//! semantic contract identities ([`AssertionPolicyId`], -//! [`TransportContractId`]); -//! - the origin-sealed normalized [`VerifiedAssertion`] result (`FI-INV-16`); -//! - the single [`FederatedAssertionVerifier`] (`FI-INV-16` canonical verifier); -//! - the privacy-preserving four-class [`DenialClass`] wire contract -//! (`FI-INV-13`). -//! -//! It has no dependencies on other NIP-FI PRs. It defines no database schema, -//! migration, runtime JWKS fetching, binding resolution, enrollment, or -//! request/proof binding — those belong to later PRs. Identity is issuer- -//! qualified `(iss, sub)` throughout: the `sub` claim is the fixed subject -//! coordinate and `nostr_pubkey` is the fixed key claim, never configurable, -//! so no deployment can seal a mutable attribute as identity. Issuer URL and -//! audience remain deployment configuration. +//! NIP-FI federated-identity authorization — assertion verifier, JWKS runtime, +//! startup validation, discovery, and prepared/committed authority types. -/// The exact client-attached header field ([NIP-FI.md](../../../docs/nips/NIP-FI.md), -/// "Client-attached transport"). `Authorization` remains reserved for NIP-98. +/// The client-attached transport header for federated-identity assertions. +/// +/// `Authorization` remains reserved for NIP-98; this separate header avoids +/// conflating authentication schemes at the relay ingress. +/// ([NIP-FI.md](../../../docs/nips/NIP-FI.md), "Client-attached transport") pub const CLIENT_ATTACHED_HEADER: &str = "Nostr-Federated-Identity"; pub mod assertion; +pub mod authority; pub mod config; pub mod denial; +pub mod discovery; +pub mod jwks; +pub mod startup; pub mod verifier; pub use assertion::{ CanonicalCapabilities, ConfidentialAssertion, FederatedIdentity, RevalidationDependencies, VerifiedAssertion, }; +pub use authority::{ + AdmissionError, BindingProposal, BindingProvenance, ExactProtectedUse, OperationIntent, + PreparedDependencyVersions, ProofTransport, ProtectedObjectKind, RouteCapability, + VerifiedServerDirectContext, +}; pub use config::{ AssertionPolicyId, ClientSubjectPosture, FreshnessClass, IssuerPolicy, IssuerPolicyError, IssuerRegistry, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; pub use denial::DenialClass; +pub use discovery::{ + AssertionFreshnessDiscovery, FederatedIdentityDiscovery, FreshnessClassDiscovery, +}; +pub use jwks::{ + HttpJwksFetcher, IssuerJwksConfig, JwksFetchError, JwksFetcher, ProductionJwksSource, +}; +pub use startup::{validate_nip_fi_config, NipFiMode, NipFiStartupError}; pub use verifier::{AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError}; diff --git a/crates/buzz-auth/src/nip_fi/startup/mod.rs b/crates/buzz-auth/src/nip_fi/startup/mod.rs new file mode 100644 index 00000000000..15af19eb00a --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/startup/mod.rs @@ -0,0 +1,140 @@ +//! Startup validation for the NIP-FI assertion runtime. +//! +//! [`validate_nip_fi_config`] is the production entry point. It rejects any +//! configuration that would make the runtime unsafe, incomplete, or ambiguous +//! before the relay accepts any protected traffic. The relay MUST call this and +//! refuse to start on error in [`Enforce`][NipFiMode::Enforce] mode +//! (`FI-INV-14`, `FI-INV-15`). + +use super::config::{FreshnessClass, IssuerRegistry}; +use super::jwks::{validate_jwks_uri, IssuerJwksConfig, MAX_JWKS_TIMING_SECONDS}; + +/// Variant names are stable contract values; do not rename without a +/// `VERIFIER_CONTRACT_VERSION` bump. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NipFiMode { + /// NIP-FI is disabled. Protected ingresses are unreachable or absent. + Off, + /// Production enforcement: every protected ingress requires valid + /// federated assertion evidence. The relay MUST call + /// [`validate_nip_fi_config`] before accepting traffic in this mode. + Enforce, + /// All protected routes deny unconditionally. Used when a prior + /// enforce-mode deployment was misconfigured and must fail closed while + /// the operator repairs configuration. [FI-INV-14] + DenyProtected, +} + +/// Every variant corresponds to a concrete, operator-actionable defect. +/// No key material, token bytes, or raw claim values appear. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum NipFiStartupError { + /// Registry has no entries; enforce mode requires at least one issuer. + #[error("NIP-FI enforce mode requires at least one issuer policy")] + EmptyRegistry, + + /// The duplicate `iss` is omitted to avoid leaking configuration into + /// operational logs. + #[error("NIP-FI issuer registry contains a duplicate issuer")] + DuplicateIssuer, + + /// Every registered issuer requires a JWKS endpoint in enforce mode. + #[error("NIP-FI issuer has no JWKS configuration")] + MissingJwksConfig, + + /// Mismatched configs are rejected to prevent silent key-source confusion. + #[error("NIP-FI JWKS config issuer does not match any registered policy")] + UnmatchedJwksConfig, + + /// `refresh_interval_seconds` is zero, exceeds [`MAX_JWKS_TIMING_SECONDS`], + /// or is ≥ `key_snapshot_hard_deadline_seconds`. + #[error("NIP-FI JWKS config has invalid timing bounds")] + InvalidJwksTiming, + + /// Non-HTTPS scheme, embedded credentials, fragment, or bare + /// private/reserved IP host. See [`validate_jwks_uri`]. + #[error("NIP-FI JWKS URI failed safety validation")] + InvalidJwksUri, + + /// `current-status` requires an authenticated status witness that is not + /// yet implemented. Use `FreshnessClass::OfflineJwt` instead. + #[error( + "NIP-FI current-status freshness is not yet supported; \ + use offline-jwt posture" + )] + UnsupportedPosture, +} + +/// Validates the complete NIP-FI runtime configuration. On error the relay +/// MUST refuse to start or fall back to [`NipFiMode::DenyProtected`]. +pub fn validate_nip_fi_config( + mode: NipFiMode, + registry: &IssuerRegistry, + jwks_configs: &[IssuerJwksConfig], +) -> Result<(), NipFiStartupError> { + if let NipFiMode::Off | NipFiMode::DenyProtected = mode { + return Ok(()); + } + + if registry.is_empty() { + return Err(NipFiStartupError::EmptyRegistry); + } + + // IssuerRegistry overwrites duplicates silently; assert uniqueness here so + // a misconfigured multi-issuer call-site is caught before traffic is served. + { + let mut seen = std::collections::HashSet::new(); + for policy in registry.all_policies() { + if !seen.insert(policy.issuer()) { + return Err(NipFiStartupError::DuplicateIssuer); + } + } + } + + // Reject current-status policies: the status witness is not yet + // implemented. Fail closed rather than advertise a freshness guarantee the + // verifier cannot satisfy. + for policy in registry.all_policies() { + if policy.freshness() == FreshnessClass::CurrentStatus { + return Err(NipFiStartupError::UnsupportedPosture); + } + } + + // Build JWKS map, rejecting duplicates. Two configs for the same issuer + // would make the effective endpoint selection order-dependent. + let mut jwks_map: std::collections::HashMap<&str, &IssuerJwksConfig> = + std::collections::HashMap::with_capacity(jwks_configs.len()); + for config in jwks_configs { + if jwks_map.insert(config.issuer.as_str(), config).is_some() { + return Err(NipFiStartupError::DuplicateIssuer); + } + } + + for config in jwks_configs { + if registry.policy_for_issuer(&config.issuer).is_none() { + return Err(NipFiStartupError::UnmatchedJwksConfig); + } + if config.refresh_interval_seconds == 0 + || config.key_snapshot_hard_deadline_seconds == 0 + || config.key_snapshot_hard_deadline_seconds <= config.refresh_interval_seconds + || config.refresh_interval_seconds > MAX_JWKS_TIMING_SECONDS + || config.key_snapshot_hard_deadline_seconds > MAX_JWKS_TIMING_SECONDS + { + return Err(NipFiStartupError::InvalidJwksTiming); + } + if validate_jwks_uri(&config.jwks_uri).is_err() { + return Err(NipFiStartupError::InvalidJwksUri); + } + } + + for policy in registry.all_policies() { + if !jwks_map.contains_key(policy.issuer()) { + return Err(NipFiStartupError::MissingJwksConfig); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs new file mode 100644 index 00000000000..12455f98b0e --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -0,0 +1,218 @@ +use super::*; +use crate::nip_fi::config::{FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass}; +use crate::nip_fi::jwks::IssuerJwksConfig; +use jsonwebtoken::Algorithm as JwtAlgorithm; + +fn make_offline_policy(issuer: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec![format!("https://relay.example/api")], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![JwtAlgorithm::ES256], + false, + 0, + 3600, + None, + ) + .unwrap() +} + +fn make_status_policy(issuer: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec![format!("https://relay.example/api")], + TokenClass::DedicatedNipFi, + FreshnessClass::CurrentStatus, + vec![JwtAlgorithm::ES256], + false, + 0, + 3600, + Some(60), + ) + .unwrap() +} + +fn make_jwks_config(issuer: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + } +} + +#[test] +fn off_mode_accepts_empty_registry() { + let registry = IssuerRegistry::new(); + assert!(validate_nip_fi_config(NipFiMode::Off, ®istry, &[]).is_ok()); +} + +#[test] +fn deny_protected_mode_accepts_empty_registry() { + let registry = IssuerRegistry::new(); + assert!(validate_nip_fi_config(NipFiMode::DenyProtected, ®istry, &[]).is_ok()); +} + +#[test] +fn enforce_valid_config_passes() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + assert!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[make_jwks_config(issuer)]).is_ok() + ); +} + +#[test] +fn enforce_multiple_issuers_passes() { + let issuers = [ + "https://a.example", + "https://b.example", + "https://c.example", + ]; + let mut registry = IssuerRegistry::new(); + for iss in &issuers { + registry.insert(make_offline_policy(iss)); + } + let jwks: Vec<_> = issuers.iter().map(|i| make_jwks_config(i)).collect(); + assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok()); +} + +#[test] +fn enforce_empty_registry_rejects() { + let registry = IssuerRegistry::new(); + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); + assert_eq!(err, NipFiStartupError::EmptyRegistry); +} + +#[test] +fn enforce_issuer_without_jwks_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); + assert_eq!(err, NipFiStartupError::MissingJwksConfig); +} + +#[test] +fn enforce_unmatched_jwks_config_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let err = validate_nip_fi_config( + NipFiMode::Enforce, + ®istry, + &[make_jwks_config("https://other.example")], + ) + .unwrap_err(); + assert_eq!(err, NipFiStartupError::UnmatchedJwksConfig); +} + +#[test] +fn enforce_refresh_equals_hard_deadline_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 3600, + key_snapshot_hard_deadline_seconds: 3600, + }]; + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(), + NipFiStartupError::InvalidJwksTiming + ); +} + +#[test] +fn enforce_zero_refresh_interval_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 0, + key_snapshot_hard_deadline_seconds: 3600, + }]; + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(), + NipFiStartupError::InvalidJwksTiming + ); +} + +/// Rejected regardless of whether a JWKS config is present — the verifier +/// has no status witness to satisfy the freshness guarantee. +#[test] +fn enforce_current_status_policy_always_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_status_policy(issuer)); + + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(), + NipFiStartupError::UnsupportedPosture + ); + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[make_jwks_config(issuer)]) + .unwrap_err(), + NipFiStartupError::UnsupportedPosture + ); +} + +/// Duplicate JWKS configs for the same issuer must not silently succeed. +#[test] +fn enforce_duplicate_jwks_issuer_in_configs_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![make_jwks_config(issuer), make_jwks_config(issuer)]; + assert!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_err(), + "duplicate JWKS configs must not pass" + ); +} + +#[test] +fn enforce_non_https_jwks_uri_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "http://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + }]; + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(), + NipFiStartupError::InvalidJwksUri + ); +} + +#[test] +fn enforce_loopback_jwks_uri_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://127.0.0.1/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + }]; + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(), + NipFiStartupError::InvalidJwksUri + ); +} diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index 7ac2cbe3766..167dd4f7d86 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -49,7 +49,7 @@ use std::fmt; /// the key-source trait. Combined with the crate-private [`AssertionKeySet`] /// constructor, this makes the accepted issuer→JWKS authority impossible to /// synthesize outside the crate's trusted configuration path. -mod sealed { +pub(crate) mod sealed { /// Private marker preventing external implementations of the key source. pub trait Sealed {} } @@ -64,7 +64,7 @@ mod sealed { /// construction seam: [`verify`] takes no snapshot argument, and this type has /// no public constructor, so an external consumer cannot build a snapshot that /// labels issuer B's JWKS as issuer A. Building a snapshot (and the source that -/// serves it) is the trusted configuration act PR 3's JWKS runtime performs at +/// serves it) is the trusted configuration act the `jwks` runtime performs at /// startup, not a per-request or external input. /// /// The crate-private constructor is a live regression: an external crate that @@ -90,7 +90,7 @@ impl AssertionKeySet { /// generation and a required key-snapshot hard deadline. Rejects a zero /// generation, an empty issuer, an empty or oversized key set /// ([`MAX_JWKS_KEYS`]), or a non-positive deadline. Crate-private: only the - /// trusted in-crate configuration path (PR 3's JWKS runtime) may bind key + /// trusted in-crate configuration path (the `jwks` runtime) may bind key /// material to an issuer. /// /// Bounding the key count here is the pre-lookup control (NIP-FI.md:166-171): @@ -101,13 +101,6 @@ impl AssertionKeySet { /// finite key-snapshot bound into `revalidation_dependencies` /// (NIP-FI.md:240-249). /// - /// Its only current callers are the in-crate `cfg(test)` verifier suite; - /// PR 3's JWKS runtime is the intended non-test consumer. Until it lands the - /// non-test lib build sees no caller, so this narrowly allows `dead_code` - /// for this one constructor rather than deferring it or widening the lint. - /// `expect` would misfire: under `cfg(test)` the lint does not trigger, so - /// the expectation would be unfulfilled and fail `-D warnings`. - #[allow(dead_code)] pub(crate) fn new( issuer: String, generation: u64, @@ -153,7 +146,7 @@ impl fmt::Debug for AssertionKeySet { /// instead asks this source for the snapshot bound to the token's /// signature-authenticated `iss`. A request-path caller therefore cannot /// relabel one issuer's JWKS as another's — the cross-issuer bypass at the old -/// `verify(token, key_set)` seam. Configuring the source (PR 3's JWKS runtime) +/// `verify(token, key_set)` seam. Configuring the source (the `jwks` runtime) /// is a trusted startup act, not per-request input. /// /// This trait is sealed via a private supertrait, so it cannot be implemented @@ -181,7 +174,7 @@ pub trait IssuerKeySource: sealed::Sealed { } /// A fixed issuer→snapshot key source for the in-crate verifier tests, -/// standing in for PR 3's JWKS runtime. It is `cfg(test)`-only — not behind a +/// standing in for the `jwks` runtime. It is `cfg(test)`-only — not behind a /// downstream-selectable Cargo feature — so no dependent crate can enable it to /// reconstruct the authority. An honest source returns only the snapshot bound /// to the exact issuer requested, the invariant the real runtime source @@ -352,7 +345,7 @@ impl FederatedAssertionVerifier { // is `evidence_rejected` (403), and this defers a valid one as // `authorization_unavailable` (503) so a missing witness never // masquerades as rejected evidence, nor invalid input as unavailable - // (NIP-FI.md:459-476). PR 3 adds the witness path additively. + // (NIP-FI.md:459-476). if policy.freshness() == FreshnessClass::CurrentStatus { return Err(VerifierError::StatusWitnessUnavailable); } @@ -726,8 +719,8 @@ fn parse_nostr_pubkey_claim( } } -/// Capture only the claim names the policy reads into a canonical set. For PR 1 -/// the closed set is the `scope` claim, split on ASCII space; unchecked claims +/// Capture only the claim names the policy reads into a canonical set. The +/// closed set is the `scope` claim, split on ASCII space; unchecked claims /// never enter the result. fn capture_capabilities( _policy: &IssuerPolicy, diff --git a/crates/buzz-core/src/network.rs b/crates/buzz-core/src/network.rs index fb3718d58c5..70395a60e3d 100644 --- a/crates/buzz-core/src/network.rs +++ b/crates/buzz-core/src/network.rs @@ -19,63 +19,91 @@ fn embedded_ipv4(v6: &std::net::Ipv6Addr, prefix: &[u8; 12]) -> Option bool { +/// Source: IANA IPv4 Special-Purpose Address Registry (2024-02) and +/// IANA IPv6 Special-Purpose Address Registry (2024-02). +/// +/// Non-globally-reachable ranges blocked: +/// +/// IPv4 +/// - 0.0.0.0/8 RFC 1122 — "This" host +/// - 10.0.0.0/8 RFC 1918 — private use +/// - 100.64.0.0/10 RFC 6598 — shared address space (CGNAT) +/// - 127.0.0.0/8 RFC 1122 — loopback +/// - 169.254.0.0/16 RFC 3927 — link-local +/// - 172.16.0.0/12 RFC 1918 — private use +/// - 192.0.0.0/24 RFC 6890 — IETF protocol assignments +/// (exceptions: 192.0.0.9 PCP anycast, 192.0.0.10 TURN anycast) +/// - 192.0.2.0/24 RFC 5737 — documentation TEST-NET-1 +/// - 192.88.99.0/24 RFC 7526 — deprecated 6to4 relay anycast +/// - 192.168.0.0/16 RFC 1918 — private use +/// - 198.18.0.0/15 RFC 2544 — network benchmarking +/// - 198.51.100.0/24 RFC 5737 — documentation TEST-NET-2 +/// - 203.0.113.0/24 RFC 5737 — documentation TEST-NET-3 +/// - 224.0.0.0/4 RFC 1112 — multicast +/// - 240.0.0.0/4 RFC 1112 — reserved/future use +/// - 255.255.255.255/32 RFC 919 — limited broadcast +/// +/// IPv6 +/// - ::/128 RFC 4291 — unspecified +/// - ::1/128 RFC 4291 — loopback +/// - ::ffff:0:0/96 RFC 4291 — IPv4-mapped (embedded IPv4 checked recursively) +/// - ::ffff:0:0:0/96 RFC 6145 — IPv4-translated SIIT (embedded IPv4 checked) +/// - 64:ff9b::/96 RFC 6052 — NAT64 well-known (embedded IPv4 checked) +/// - 64:ff9b:1::/48 RFC 8215 — local-use NAT64 +/// - 100::/64 RFC 6666 — discard-only +/// - 2001::/32 RFC 4380 — Teredo +/// - 2001:2::/48 RFC 5180 — benchmarking +/// - 2001:20::/28 RFC 7343 — ORCHIDv2 +/// - 2001:db8::/32 RFC 3849 — documentation +/// - 2002::/16 RFC 3056 — 6to4 (deprecated) +/// - fc00::/7 RFC 4193 — ULA +/// - fe80::/10 RFC 4291 — link-local +/// - ff00::/8 RFC 4291 — multicast +pub fn is_not_global_unicast(ip: &std::net::IpAddr) -> bool { match ip { std::net::IpAddr::V4(v4) => { - let octets = v4.octets(); + let o = v4.octets(); v4.is_loopback() || v4.is_private() || v4.is_link_local() - || octets[0] == 0 - || v4.is_broadcast() - // Carrier-Grade NAT (RFC 6598) — 100.64.0.0/10 - // Dangerous in cloud environments (AWS, GCP) where CGNAT can route to metadata services. - || (octets[0] == 100 && (octets[1] & 0xC0) == 64) - // Benchmarking (RFC 2544) — 198.18.0.0/15 - || (octets[0] == 198 && (octets[1] & 0xFE) == 18) + || o[0] == 0 // 0.0.0.0/8 + || v4.is_broadcast() // 255.255.255.255 + || (o[0] == 100 && (o[1] & 0xC0) == 64) // 100.64.0.0/10 CGNAT + || (o[0] == 198 && (o[1] & 0xFE) == 18) // 198.18.0.0/15 benchmarking + || (o[0] & 0xF0) == 0xE0 // 224.0.0.0/4 multicast + || (o[0] & 0xF0) == 0xF0 // 240.0.0.0/4 reserved + // 192.0.0.0/24 IETF protocol assignments — not globally reachable + // except 192.0.0.9 (PCP anycast, RFC 7723) and 192.0.0.10 (TURN anycast, RFC 8155). + || (o[0] == 192 && o[1] == 0 && o[2] == 0 && o[3] != 9 && o[3] != 10) + || (o[0] == 192 && o[1] == 0 && o[2] == 2) // 192.0.2.0/24 TEST-NET-1 + || (o[0] == 192 && o[1] == 88 && o[2] == 99) // 192.88.99.0/24 deprecated 6to4 + || (o[0] == 198 && o[1] == 51 && o[2] == 100) // 198.51.100.0/24 TEST-NET-2 + || (o[0] == 203 && o[1] == 0 && o[2] == 113) // 203.0.113.0/24 TEST-NET-3 } std::net::IpAddr::V6(v6) => { - // Check IPv4-compatible and mapped addresses against IPv4 rules. + // IPv4-compatible and IPv4-mapped addresses are checked against IPv4 rules. if let Some(v4) = v6.to_ipv4() { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } let segments = v6.segments(); - // NAT64 well-known prefix (RFC 6052). Preserve access to public IPv4 - // destinations while rejecting embedded private/reserved addresses. + // NAT64 well-known prefix (RFC 6052): reachability determined by the + // embedded IPv4 address. if let Some(v4) = embedded_ipv4(v6, &NAT64_WELL_KNOWN_PREFIX) { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } - // Legacy SIIT IPv4-translated addresses can route to the IPv4 value - // in their final four octets but are not recognized by `to_ipv4()`. + // SIIT IPv4-translated addresses route to the embedded IPv4 value and + // are not recognised by `to_ipv4()`. if let Some(v4) = embedded_ipv4(v6, &IPV4_TRANSLATED_PREFIX) { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } v6.is_loopback() @@ -83,280 +111,322 @@ pub fn is_private_ip(ip: &std::net::IpAddr) -> bool { || segments[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA || segments[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local || segments[0] & 0xff00 == 0xff00 // ff00::/8 multicast - || (segments[0] == 0x0064 - && segments[1] == 0xff9b - && segments[2] == 1) // 64:ff9b:1::/48 local-use NAT64 - || (segments[0] == 0x2001 && segments[1] == 0) // 2001::/32 Teredo - || segments[0] == 0x2002 // 2002::/16 6to4 - // RFC 3849 — documentation range, should never appear in production + // 64:ff9b:1::/48 local-use NAT64 (RFC 8215) + || (segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 1) + // 100::/64 discard-only (RFC 6666) + || (segments[0] == 0x0100 && segments[1] == 0 && segments[2] == 0 && segments[3] == 0) + // 2001::/32 Teredo (RFC 4380) + || (segments[0] == 0x2001 && segments[1] == 0) + // 2001:2::/48 benchmarking (RFC 5180) + || (segments[0] == 0x2001 && segments[1] == 0x0002 && segments[2] == 0) + // 2001:20::/28 ORCHIDv2 (RFC 7343): top 28 bits = 2001:002x + || (segments[0] == 0x2001 && (segments[1] >> 4) == 0x0002) + // 2001:db8::/32 documentation (RFC 3849) || (segments[0] == 0x2001 && segments[1] == 0x0db8) + || segments[0] == 0x2002 // 2002::/16 6to4 deprecated (RFC 3056) } } } +/// Compatibility alias; prefer [`is_not_global_unicast`]. +#[inline] +pub fn is_private_ip(ip: &std::net::IpAddr) -> bool { + is_not_global_unicast(ip) +} + #[cfg(test)] mod tests { use super::*; use std::net::IpAddr; - #[test] - fn test_loopback_v4() { - assert!(is_private_ip(&"127.0.0.1".parse::().unwrap())); + // ── helpers ────────────────────────────────────────────────────────────── + + fn blocked(s: &str) -> bool { + is_not_global_unicast(&s.parse::().unwrap()) } + + // ── public positive controls ───────────────────────────────────────────── + #[test] - fn test_private_10() { - assert!(is_private_ip(&"10.0.0.1".parse::().unwrap())); + fn public_v4_cloudflare() { + assert!(!blocked("1.1.1.1")); } #[test] - fn test_private_172() { - assert!(is_private_ip(&"172.16.0.1".parse::().unwrap())); + fn public_v4_google() { + assert!(!blocked("8.8.8.8")); } #[test] - fn test_private_192() { - assert!(is_private_ip(&"192.168.1.1".parse::().unwrap())); + fn public_v6_cloudflare() { + assert!(!blocked("2606:4700::1")); } + + // ── RFC 1122 loopback / unspecified ────────────────────────────────────── + #[test] - fn test_link_local() { - assert!(is_private_ip(&"169.254.1.1".parse::().unwrap())); + fn loopback_v4() { + assert!(blocked("127.0.0.1")); } #[test] - fn test_unspecified() { - assert!(is_private_ip(&"0.0.0.0".parse::().unwrap())); + fn loopback_v6() { + assert!(blocked("::1")); } #[test] - fn test_broadcast() { - assert!(is_private_ip(&"255.255.255.255".parse::().unwrap())); + fn unspecified_v4() { + assert!(blocked("0.0.0.0")); } #[test] - fn test_public_v4() { - assert!(!is_private_ip(&"8.8.8.8".parse::().unwrap())); + fn unspecified_v6() { + assert!(blocked("::")); } + + // ── RFC 1918 private use ───────────────────────────────────────────────── + #[test] - fn test_loopback_v6() { - assert!(is_private_ip(&"::1".parse::().unwrap())); + fn private_10() { + assert!(blocked("10.0.0.1")); } #[test] - fn test_unspecified_v6() { - assert!(is_private_ip(&"::".parse::().unwrap())); + fn private_172() { + assert!(blocked("172.16.0.1")); } #[test] - fn test_ula_v6() { - assert!(is_private_ip(&"fd00::1".parse::().unwrap())); + fn private_192_168() { + assert!(blocked("192.168.1.1")); } + + // ── RFC 3927 link-local ─────────────────────────────────────────────────── + #[test] - fn test_link_local_v6() { - assert!(is_private_ip(&"fe80::1".parse::().unwrap())); + fn link_local_v4() { + assert!(blocked("169.254.1.1")); } #[test] - fn test_public_v6() { - assert!(!is_private_ip(&"2606:4700::1".parse::().unwrap())); + fn link_local_v6() { + assert!(blocked("fe80::1")); } + + // ── RFC 919 broadcast ──────────────────────────────────────────────────── + #[test] - fn test_documentation_range_v6() { - // 2001:db8::/32 — RFC 3849 documentation range, must be blocked - assert!(is_private_ip(&"2001:db8::1".parse::().unwrap())); - assert!(is_private_ip( - &"2001:db8:ffff::1".parse::().unwrap() - )); + fn broadcast() { + assert!(blocked("255.255.255.255")); } + + // ── RFC 6598 CGNAT 100.64.0.0/10 ──────────────────────────────────────── + #[test] - fn test_ipv4_mapped_v6_private() { - // ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to a private IPv4 - assert!(is_private_ip(&"::ffff:10.0.0.1".parse::().unwrap())); + fn cgnat() { + assert!(blocked("100.64.0.1")); + assert!(blocked("100.127.255.254")); + assert!(!blocked("100.63.255.255")); // just below + assert!(!blocked("100.128.0.0")); // just above } + + // ── RFC 2544 benchmarking 198.18.0.0/15 ───────────────────────────────── + #[test] - fn test_ipv4_mapped_v6_loopback() { - assert!(is_private_ip( - &"::ffff:127.0.0.1".parse::().unwrap() - )); + fn benchmarking_v4() { + assert!(blocked("198.18.0.1")); + assert!(blocked("198.19.255.254")); + assert!(!blocked("198.17.255.255")); // just below + assert!(!blocked("198.20.0.0")); // just above } + + // ── RFC 1112 multicast 224.0.0.0/4 ────────────────────────────────────── + #[test] - fn test_ipv4_mapped_v6_public() { - assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::().unwrap())); + fn multicast_v4() { + assert!(blocked("224.0.0.0")); + assert!(blocked("224.0.0.251")); // mDNS + assert!(blocked("239.255.255.250")); // SSDP + assert!(blocked("239.255.255.255")); + assert!(!blocked("223.255.255.255")); // just below } + + // ── RFC 1112 reserved 240.0.0.0/4 ─────────────────────────────────────── + #[test] - fn test_ipv4_compatible_v6_private() { - assert!(is_private_ip(&"::10.0.0.1".parse::().unwrap())); - assert!(is_private_ip(&"::127.0.0.1".parse::().unwrap())); - assert!(is_private_ip( - &"::169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip(&"::8.8.8.8".parse::().unwrap())); + fn reserved_v4() { + assert!(blocked("240.0.0.0")); + assert!(blocked("254.255.255.255")); + assert!(!blocked("223.255.255.255")); // just below multicast boundary (also blocked, but distinct) } + + // ── RFC 6890 IETF protocol assignments 192.0.0.0/24 ───────────────────── + // Most of this range is not globally reachable; 192.0.0.9 (PCP anycast, + // RFC 7723) and 192.0.0.10 (TURN anycast, RFC 8155) are exceptions. + #[test] - fn test_nat64_well_known_prefix() { - let first = "64:ff9b::".parse().unwrap(); - let last = "64:ff9b::ffff:ffff".parse().unwrap(); - assert_eq!( - embedded_ipv4(&first, &NAT64_WELL_KNOWN_PREFIX), - Some("0.0.0.0".parse().unwrap()) - ); - assert_eq!( - embedded_ipv4(&last, &NAT64_WELL_KNOWN_PREFIX), - Some("255.255.255.255".parse().unwrap()) - ); - let embedded = "64:ff9b::172.16.1.2".parse().unwrap(); - assert_eq!( - embedded_ipv4(&embedded, &NAT64_WELL_KNOWN_PREFIX), - Some("172.16.1.2".parse().unwrap()) - ); - assert!(is_private_ip( - &"64:ff9b::10.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"64:ff9b::127.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"64:ff9b::169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip( - &"64:ff9b::8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_ip( - &"64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"64:ff9b::1:0:0".parse::().unwrap())); - } - #[test] - fn test_ipv4_translated_prefix() { - let first = "0:0:0:0:ffff:0:0:0".parse().unwrap(); - let last = "0:0:0:0:ffff:0:ffff:ffff".parse().unwrap(); - assert_eq!( - embedded_ipv4(&first, &IPV4_TRANSLATED_PREFIX), - Some("0.0.0.0".parse().unwrap()) - ); - assert_eq!( - embedded_ipv4(&last, &IPV4_TRANSLATED_PREFIX), - Some("255.255.255.255".parse().unwrap()) - ); - assert!(is_private_ip( - &"::ffff:0:10.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"::ffff:0:127.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"::ffff:0:169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip( - &"::ffff:0:8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_ip( - &"0:0:0:0:fffe:ffff:ffff:ffff".parse::().unwrap() - )); - assert!(!is_private_ip( - &"0:0:0:0:ffff:1:0:0".parse::().unwrap() - )); + fn ietf_protocol_assignments() { + assert!(blocked("192.0.0.0")); + assert!(blocked("192.0.0.1")); + assert!(blocked("192.0.0.170")); // NAT64/464XLAT — not globally reachable + assert!(blocked("192.0.0.255")); + assert!(!blocked("192.0.0.9")); // PCP anycast (RFC 7723) — globally reachable + assert!(!blocked("192.0.0.10")); // TURN anycast (RFC 8155) — globally reachable } + + // ── RFC 5737 documentation TEST-NET-1/2/3 ──────────────────────────────── + #[test] - fn test_nat64_local_use_prefix_boundaries() { - assert!(is_private_ip(&"64:ff9b:1::".parse::().unwrap())); - assert!(is_private_ip( - &"64:ff9b:1:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"64:ff9b::ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"64:ff9b:2::".parse::().unwrap())); + fn documentation_v4() { + assert!(blocked("192.0.2.0")); + assert!(blocked("192.0.2.255")); + assert!(blocked("198.51.100.0")); + assert!(blocked("198.51.100.255")); + assert!(blocked("203.0.113.0")); + assert!(blocked("203.0.113.255")); + // Adjacent addresses that are routable. + assert!(!blocked("192.0.1.255")); + assert!(!blocked("192.0.3.0")); + assert!(!blocked("198.51.99.255")); + assert!(!blocked("198.51.101.0")); + assert!(!blocked("203.0.112.255")); + assert!(!blocked("203.0.114.0")); } + + // ── RFC 7526 deprecated 6to4 relay anycast 192.88.99.0/24 ─────────────── + #[test] - fn test_teredo_prefix_boundaries() { - assert!(is_private_ip(&"2001::".parse::().unwrap())); - assert!(is_private_ip( - &"2001:0:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"2000:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"2001:1::1".parse::().unwrap())); + fn deprecated_6to4_relay_anycast() { + assert!(blocked("192.88.99.0")); + assert!(blocked("192.88.99.1")); + assert!(blocked("192.88.99.255")); + assert!(!blocked("192.88.98.255")); // just below + assert!(!blocked("192.88.100.0")); // just above } + + // ── RFC 4193 ULA ───────────────────────────────────────────────────────── + #[test] - fn test_6to4_prefix_boundaries() { - assert!(is_private_ip(&"2002::".parse::().unwrap())); - assert!(is_private_ip( - &"2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"2003::1".parse::().unwrap())); + fn ula_v6() { + assert!(blocked("fd00::1")); } - // CGNAT (RFC 6598) — 100.64.0.0/10 + // ── RFC 3849 documentation 2001:db8::/32 ───────────────────────────────── + #[test] - fn test_cgnat_start() { - // 100.64.0.1 — start of CGNAT range - assert!(is_private_ip(&"100.64.0.1".parse::().unwrap())); + fn documentation_v6() { + assert!(blocked("2001:db8::1")); + assert!(blocked("2001:db8:ffff::1")); } + + // ── RFC 4291 multicast ff00::/8 ────────────────────────────────────────── + #[test] - fn test_cgnat_end() { - // 100.127.255.254 — end of CGNAT range - assert!(is_private_ip(&"100.127.255.254".parse::().unwrap())); + fn multicast_v6() { + assert!(blocked("ff02::1")); // all-nodes + assert!(blocked("ff02::2")); // all-routers + assert!(blocked("ffff::1")); + assert!(!blocked("fe00::1")); // just below } + + // ── RFC 6666 discard-only 100::/64 ─────────────────────────────────────── + #[test] - fn test_cgnat_below_range() { - // 100.63.255.255 — just below CGNAT range (100.0–100.63 is public) - assert!(!is_private_ip(&"100.63.255.255".parse::().unwrap())); + fn discard_only_v6() { + assert!(blocked("100::1")); + assert!(blocked("100::ffff:ffff:ffff:ffff")); + assert!(!blocked("100:0:0:1::1")); // outside /64 } + + // ── RFC 5180 benchmarking 2001:2::/48 ──────────────────────────────────── + #[test] - fn test_cgnat_above_range() { - // 100.128.0.0 — just above CGNAT range (100.128+ is public) - assert!(!is_private_ip(&"100.128.0.0".parse::().unwrap())); + fn benchmarking_v6() { + assert!(blocked("2001:2::1")); + assert!(blocked("2001:2:0:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("2001:2:1::1")); // outside /48 } - // Benchmarking (RFC 2544) — 198.18.0.0/15 + // ── RFC 7343 ORCHIDv2 2001:20::/28 ─────────────────────────────────────── + #[test] - fn test_benchmarking_start() { - assert!(is_private_ip(&"198.18.0.1".parse::().unwrap())); + fn orchid_v6() { + assert!(blocked("2001:20::1")); + assert!(blocked("2001:2f::1")); // last /28 prefix + assert!(!blocked("2001:30::1")); // just outside + assert!(!blocked("2001:1::1")); // Teredo subdomain, but outside Teredo /32 range } + + // ── RFC 4380 Teredo 2001::/32 ──────────────────────────────────────────── + #[test] - fn test_benchmarking_end() { - assert!(is_private_ip(&"198.19.255.254".parse::().unwrap())); + fn teredo_v6() { + assert!(blocked("2001::")); + assert!(blocked("2001:0:ffff:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("2001:1::1")); // outside /32 } + + // ── RFC 3056 6to4 2002::/16 ─────────────────────────────────────────────── + #[test] - fn test_benchmarking_below_range() { - // 198.17.255.255 — just below benchmarking range - assert!(!is_private_ip(&"198.17.255.255".parse::().unwrap())); + fn six_to_four_v6() { + assert!(blocked("2002::")); + assert!(blocked("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("2003::1")); // outside /16 } + + // ── RFC 8215 local-use NAT64 64:ff9b:1::/48 ────────────────────────────── + #[test] - fn test_benchmarking_above_range() { - // 198.20.0.0 — just above benchmarking range - assert!(!is_private_ip(&"198.20.0.0".parse::().unwrap())); + fn nat64_local_use_v6() { + assert!(blocked("64:ff9b:1::")); + assert!(blocked("64:ff9b:1:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("64:ff9b:2::")); // outside /48 } - // IPv6 multicast — ff00::/8 + // ── RFC 6052 NAT64 well-known 64:ff9b::/96 ─────────────────────────────── + // Reachability follows the embedded IPv4 address. + #[test] - fn test_ipv6_multicast_all_nodes() { - // ff02::1 — all-nodes multicast - assert!(is_private_ip(&"ff02::1".parse::().unwrap())); + fn nat64_well_known_prefix() { + let first = "64:ff9b::".parse().unwrap(); + let last = "64:ff9b::ffff:ffff".parse().unwrap(); + assert_eq!( + embedded_ipv4(&first, &NAT64_WELL_KNOWN_PREFIX), + Some("0.0.0.0".parse().unwrap()) + ); + assert_eq!( + embedded_ipv4(&last, &NAT64_WELL_KNOWN_PREFIX), + Some("255.255.255.255".parse().unwrap()) + ); + assert!(blocked("64:ff9b::10.0.0.1")); + assert!(blocked("64:ff9b::127.0.0.1")); + assert!(blocked("64:ff9b::169.254.169.254")); + assert!(!blocked("64:ff9b::8.8.8.8")); + assert!(!blocked("64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff")); // different prefix + assert!(!blocked("64:ff9b::1:0:0")); // outside /96 } + + // ── SIIT IPv4-translated ::ffff:0:0:0/96 ───────────────────────────────── + #[test] - fn test_ipv6_multicast_all_routers() { - // ff02::2 — all-routers multicast - assert!(is_private_ip(&"ff02::2".parse::().unwrap())); + fn ipv4_translated_prefix() { + assert!(blocked("::ffff:0:10.0.0.1")); + assert!(blocked("::ffff:0:127.0.0.1")); + assert!(blocked("::ffff:0:169.254.169.254")); + assert!(!blocked("::ffff:0:8.8.8.8")); + assert!(!blocked("0:0:0:0:fffe:ffff:ffff:ffff")); // outside prefix + assert!(!blocked("0:0:0:0:ffff:1:0:0")); } + + // ── IPv4-mapped ::ffff:0:0/96 ───────────────────────────────────────────── + #[test] - fn test_ipv6_multicast_high() { - // ffff::1 — still in ff00::/8 - assert!(is_private_ip(&"ffff::1".parse::().unwrap())); + fn ipv4_mapped_v6() { + assert!(blocked("::ffff:10.0.0.1")); + assert!(blocked("::ffff:127.0.0.1")); + assert!(!blocked("::ffff:8.8.8.8")); } + + // ── IPv4-compatible ::/96 ───────────────────────────────────────────────── + #[test] - fn test_ipv6_not_multicast() { - // fe00:: — just below ff00::/8 (not multicast, not link-local, not ULA) - assert!(!is_private_ip(&"fe00::1".parse::().unwrap())); + fn ipv4_compatible_v6() { + assert!(blocked("::10.0.0.1")); + assert!(blocked("::127.0.0.1")); + assert!(blocked("::169.254.169.254")); + assert!(!blocked("::8.8.8.8")); } } diff --git a/crates/buzz-db/Cargo.toml b/crates/buzz-db/Cargo.toml index 380605728bb..c2b6c4a8d16 100644 --- a/crates/buzz-db/Cargo.toml +++ b/crates/buzz-db/Cargo.toml @@ -9,6 +9,7 @@ description = "Postgres event store and data access layer for Buzz" [dependencies] buzz-core = { workspace = true } +buzz-auth = { workspace = true } buzz-datastore-tracing = { workspace = true } sqlx = { workspace = true } tokio = { workspace = true } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index ea81bc354b8..0d26487447e 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -36,9 +36,9 @@ pub(crate) use runtime::{ }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, channel, channel_members, - community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push, - reaction, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, - replaceable, thread, usage, user, workflow, + community, deletion, dm, event, feed, git_repo, moderation, nip_fi_authority, partition, + product_feedback, push, reaction, relay_admin_actions, relay_invite, relay_members, + relay_operators, reminder, replaceable, thread, usage, user, workflow, }; pub use allowlist::AllowlistEntry; @@ -50,6 +50,10 @@ pub use community::{ }; pub use error::{DbError, Result}; pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; +pub use nip_fi_authority::{ + authorize_protected_use, commit_admission, prepare_direct, AuthorizedUse, + CommittedAuthorization, PrepareError, PreparedAuthorization, +}; pub use reaction::ReactionEventInsertOutcome; pub use reminder::DueReminder; pub use usage::UsageMetricsLeader; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 00cd81c6940..c4768277626 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -699,7 +699,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 40); + assert_eq!(migrations.len(), 42); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1241,6 +1241,45 @@ mod tests { operator_audit.contains("_operator_global_tables"), "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" + ); } #[test] @@ -2618,4 +2657,1578 @@ mod tests { .await .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. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn migration_0041_identity_foundation_is_durable_ledger_after_migration_a() { + 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) + .await + .unwrap_or_else(|err| panic!("check table {table}: {err}")); + assert!(exists, "migration 0041 must create {table}"); + } + + // 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) + .await + .unwrap_or_else(|err| panic!("check table {table}: {err}")); + assert!(!exists, "{table} belongs to migration 0042, not 0041"); + } + + // 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", + ) + .bind(&identity_tables[..]) + .fetch_all(&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"); + + // 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}" + ); + } + + /// NIP-FI full state: migrations 0041 + 0042 together must present a + /// coherent 16-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; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let nip_fi_tables = [ + "authorization_admission_results", + "authorization_authentication_denial_attempts", + "authorization_authority_epochs", + "authorization_event_capacity", + "authorization_events", + "authorization_invalidation_domains", + "authorization_invalidation_floors", + "authorization_operation_receipts", + "authorization_operation_version_delta_manifests", + "authorization_operation_version_deltas", + "identity_bindings", + "identity_enrollment_policies", + "identity_lifecycle_history", + "identity_lifecycle_selectors", + "nip_fi_proof_replay_claims", + "protected_object_authority", + ]; + + // All sixteen 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 sixteen 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", + ) + .bind(&nip_fi_tables[..]) + .fetch_all(&pool) + .await + .expect("read scoped NIP-FI relations"); + assert!( + scoped.is_empty(), + "all NIP-FI ledger relations must be write-fence excluded: {scoped:?}" + ); + + // The exact deletion catalog validates with the full ledger present. + 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}" + ); + } } diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 1fa1273eb0f..a511ffd80c3 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -26,6 +26,8 @@ pub mod feed; pub mod git_repo; /// Community moderation: reports, bans/timeouts, audit actions. pub mod moderation; +/// NIP-FI direct preparation and atomic final admission authority. +pub mod nip_fi_authority; /// Monthly table partition management. pub mod partition; /// Buzz product-feedback sidecar persistence. diff --git a/crates/buzz-db/src/store/nip_fi_authority.rs b/crates/buzz-db/src/store/nip_fi_authority.rs new file mode 100644 index 00000000000..5b7b823a292 --- /dev/null +++ b/crates/buzz-db/src/store/nip_fi_authority.rs @@ -0,0 +1,1688 @@ +//! NIP-FI final-admission authority — `prepare_direct`, `commit_admission`, +//! and `authorize_protected_use`. +//! +//! ## Two-phase-plus-use contract +//! +//! `prepare_direct` is the read-only preparation phase (`FI-INV-08`): +//! +//! - Reads binding (`B_D(i)`, `B_D(k)`), retired-pair (`T_D(i,k)`), +//! revoked-key (`Y_D(k)`), enrollment policy, local-policy evaluation, +//! resource authority, and dependency versions from authoritative PostgreSQL +//! state in a single coherent REPEATABLE READ snapshot. +//! - Evaluates the binding proposal — existing active binding or new enrollment. +//! - Evaluates local policy via the closed capability/object/intent matrix at +//! a stable evaluator revision. +//! - Returns a [`PreparedAuthorization`] on success or a [`PrepareError`] on +//! denial. **Writes nothing** (`FI-INV-08`). +//! +//! `commit_admission` is the atomic final-admission phase (`FI-INV-09`): +//! +//! - Re-reads every dependency to confirm the prepared proposal still holds. +//! - Checks deadline liveness (including proof deadline) and assertion +//! equivalence (identity, key, capabilities, deadline cardinality). +//! - Confirms contract-ID stability. +//! - Re-evaluates local policy and resource authority inside the transaction. +//! - Inserts the replay claim (proof identity uniqueness gate). +//! - Writes the epoch/fence rows for the target object unconditionally. +//! - Atomically writes: the operation receipt, binding row (if new enrollment), +//! lifecycle history row, and admission result. +//! All commit or none (`FI-INV-09`). +//! +//! `authorize_protected_use` is the per-use gate: +//! +//! - Exact-matches the use tuple against the committed context. +//! - Re-reads and re-evaluates every dependency transactionally. +//! - For mutation: re-fences the target object. +//! - Returns an [`AuthorizedUse`] opaque result, one-use only. +//! +//! ## Non-forgeability +//! +//! All three authority-bearing types — [`PreparedAuthorization`], +//! [`CommittedAuthorization`], and [`AuthorizedUse`] — have `pub(crate)` +//! constructors. No code outside this crate can mint them. [`VerifiedServerDirectContext`] +//! is defined in `buzz-auth` but its fields are `pub(crate)` and it has no +//! public constructor; the only constructor is [`VerifiedServerDirectContext::new`] +//! below, which is `pub(crate)` in this crate's scope. +//! +//! ## Error mapping +//! +//! [`PrepareError`] and [`AdmissionError`] each map every variant to a +//! [`DenialClass`] via `denial_class()`. No error type carries credential +//! material (`FI-INV-13`). + +use buzz_auth::nip_fi::{ + AdmissionError, BindingProposal, BindingProvenance, DenialClass, ExactProtectedUse, + FederatedAssertionVerifier, FederatedIdentity, IssuerKeySource, OperationIntent, + PreparedDependencyVersions, ProtectedObjectKind, RouteCapability, VerifiedAssertion, + VerifiedServerDirectContext, +}; +use chrono::{DateTime, Utc}; +use nostr::PublicKey; +use sha2::{Digest, Sha256}; +use sqlx::{PgConnection, PgPool, Row as _}; +use uuid::Uuid; + +// ── Prepared authorization ──────────────────────────────────────────────────── + +/// Non-forgeable prepared-authorization token produced by [`prepare_direct`]. +/// +/// Private fields; `pub(crate)` constructor. Only the PostgreSQL preparation +/// path can mint this value. Not `Clone` — each prepared authorization is +/// unique and consumed by move into `commit_admission`. +#[derive(Debug)] +pub struct PreparedAuthorization { + verified_assertion: VerifiedAssertion, + actor: PublicKey, + community_id: Uuid, + proposal: BindingProposal, + authority_deadlines: Vec>, + dependency_versions: PreparedDependencyVersions, + correlation_id: Uuid, + // Sealed context carried through to commit. + context: VerifiedServerDirectContext, + // Assertion contract IDs captured at preparation time. + assertion_policy_id: buzz_auth::AssertionPolicyId, + transport_contract_id: buzz_auth::TransportContractId, +} + +impl PreparedAuthorization { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + verified_assertion: VerifiedAssertion, + actor: PublicKey, + community_id: Uuid, + proposal: BindingProposal, + authority_deadlines: Vec>, + dependency_versions: PreparedDependencyVersions, + correlation_id: Uuid, + context: VerifiedServerDirectContext, + assertion_policy_id: buzz_auth::AssertionPolicyId, + transport_contract_id: buzz_auth::TransportContractId, + ) -> Self { + Self { + verified_assertion, + actor, + community_id, + proposal, + authority_deadlines, + dependency_versions, + correlation_id, + context, + assertion_policy_id, + transport_contract_id, + } + } + + pub(crate) fn verified_assertion(&self) -> &VerifiedAssertion { + &self.verified_assertion + } + + pub(crate) fn actor(&self) -> PublicKey { + self.actor + } + + pub(crate) fn community_id(&self) -> Uuid { + self.community_id + } + + pub(crate) fn proposal(&self) -> &BindingProposal { + &self.proposal + } + + pub(crate) fn authority_deadlines(&self) -> &[DateTime] { + &self.authority_deadlines + } + + pub(crate) fn dependency_versions(&self) -> &PreparedDependencyVersions { + &self.dependency_versions + } + + pub(crate) fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + pub(crate) fn context(&self) -> &VerifiedServerDirectContext { + &self.context + } + + pub(crate) fn assertion_policy_id(&self) -> buzz_auth::AssertionPolicyId { + self.assertion_policy_id + } + + pub(crate) fn transport_contract_id(&self) -> buzz_auth::TransportContractId { + self.transport_contract_id + } +} + +// ── Committed authorization ─────────────────────────────────────────────────── + +/// Non-forgeable committed-authorization token produced by [`commit_admission`]. +/// +/// Private fields; `pub(crate)` constructor. Carries the witnesses PR 5 needs +/// to enforce use-site checks. `authorize_protected_use` consumes this by +/// reference and returns an [`AuthorizedUse`] on exact-match. +#[derive(Debug)] +pub struct CommittedAuthorization { + actor: PublicKey, + identity: FederatedIdentity, + community_id: Uuid, + // Sealed operation context + capability: RouteCapability, + object_kind: ProtectedObjectKind, + intent: OperationIntent, + object_key: [u8; 32], + // Timing + proof_expires_at: DateTime, + assertion_expires_at: DateTime, + // Binding + binding_id: Uuid, + binding_version: i64, + // Receipt audit trail + operation_id: Uuid, + correlation_id: Uuid, + // Semantic fingerprint (deterministic, bound to sealed context) + semantic_fingerprint: [u8; 32], +} + +impl CommittedAuthorization { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + actor: PublicKey, + identity: FederatedIdentity, + community_id: Uuid, + capability: RouteCapability, + object_kind: ProtectedObjectKind, + intent: OperationIntent, + object_key: [u8; 32], + proof_expires_at: DateTime, + assertion_expires_at: DateTime, + binding_id: Uuid, + binding_version: i64, + operation_id: Uuid, + correlation_id: Uuid, + semantic_fingerprint: [u8; 32], + ) -> Self { + Self { + actor, + identity, + community_id, + capability, + object_kind, + intent, + object_key, + proof_expires_at, + assertion_expires_at, + binding_id, + binding_version, + operation_id, + correlation_id, + semantic_fingerprint, + } + } + + /// The proven actor's public key. + pub fn actor(&self) -> PublicKey { + self.actor + } + + /// The federated identity bound to this actor. + pub fn identity(&self) -> &FederatedIdentity { + &self.identity + } + + /// The community this authorization is scoped to. + pub fn community_id(&self) -> Uuid { + self.community_id + } + + /// Expiry of the narrowest active deadline (proof + assertion minimum). + pub fn expires_at(&self) -> DateTime { + self.assertion_expires_at.min(self.proof_expires_at) + } + + /// Audit correlation ID for the operation receipt. + pub fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Random audit operation ID written to the receipt row. + /// Not the logical request identity; use `semantic_fingerprint` for + /// deduplication. + pub fn operation_id(&self) -> Uuid { + self.operation_id + } + + /// Deterministic semantic fingerprint for this admitted request. + /// Bound to (community, proof_event_id, object_kind, object_key, actor, + /// capability, intent). Suitable for idempotency checks. + pub fn semantic_fingerprint(&self) -> &[u8; 32] { + &self.semantic_fingerprint + } +} + +// ── Authorized use ──────────────────────────────────────────────────────────── + +/// Opaque one-use result returned by [`authorize_protected_use`]. +/// +/// Private fields; `pub(crate)` constructor. Exists only to prove that the +/// full use-site gate ran. Cannot be cloned, stored, or re-used. +#[derive(Debug)] +pub struct AuthorizedUse { + operation_id: Uuid, + community_id: Uuid, + authorized_at: DateTime, +} + +impl AuthorizedUse { + pub(crate) fn new( + operation_id: Uuid, + community_id: Uuid, + authorized_at: DateTime, + ) -> Self { + Self { + operation_id, + community_id, + authorized_at, + } + } + + /// Audit operation ID for the use-site receipt. + pub fn operation_id(&self) -> Uuid { + self.operation_id + } + + /// Community this use was authorized for. + pub fn community_id(&self) -> Uuid { + self.community_id + } + + /// Wall-clock instant the use was authorized (authoritative transaction time). + pub fn authorized_at(&self) -> DateTime { + self.authorized_at + } +} + +// ── Preparation ─────────────────────────────────────────────────────────────── + +/// A closed, stable preparation failure. +/// +/// Every variant maps to exactly one [`DenialClass`] via +/// [`PrepareError::denial_class`]. No variant carries credential material. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PrepareError { + // ── Authorization denial (403) ───────────────────────────────────────────── + /// The actor's key appears in the revoked-key selector set. + #[error("actor key is revoked")] + KeyRevoked, + /// The exact `(i, k)` pair appears in the retired-pair selector set. + #[error("identity/key pair is retired")] + PairRetired, + /// An active binding exists for `i` or `k` under a different counterpart. + #[error("binding conflict")] + BindingConflict, + /// `attested-key` enrollment policy but the assertion carries no matching + /// `nostr_pubkey` claim. + #[error("attested-key enrollment required")] + AttestationRequired, + /// Enrollment policy is `provisioned`: no self-service enrollment path. + #[error("binding required; enrollment not permitted")] + BindingRequired, + /// Local policy denied the operation for the sealed context tuple. + #[error("local policy denied")] + LocalPolicyDenied, + /// Resource authority row is missing, deleted, archived, or unreadable. + #[error("resource authority denied: missing, deleted, archived, or changed")] + ResourceAuthorityDenied, + + // ── Availability failure (503) ───────────────────────────────────────────── + /// A required dependency could not be read. + #[error("required authoritative dependency unavailable")] + DependencyUnavailable, +} + +impl PrepareError { + /// The public denial class. + pub const fn denial_class(self) -> DenialClass { + match self { + Self::KeyRevoked + | Self::PairRetired + | Self::BindingConflict + | Self::AttestationRequired + | Self::BindingRequired + | Self::LocalPolicyDenied + | Self::ResourceAuthorityDenied => DenialClass::AuthorizationDenied, + Self::DependencyUnavailable => DenialClass::AuthorizationUnavailable, + } + } + + /// Stable machine code for access-controlled logs. + pub const fn code(self) -> &'static str { + match self { + Self::KeyRevoked => "nip_fi_prepare_key_revoked", + Self::PairRetired => "nip_fi_prepare_pair_retired", + Self::BindingConflict => "nip_fi_prepare_binding_conflict", + Self::AttestationRequired => "nip_fi_prepare_attestation_required", + Self::BindingRequired => "nip_fi_prepare_binding_required", + Self::LocalPolicyDenied => "nip_fi_prepare_local_policy_denied", + Self::ResourceAuthorityDenied => "nip_fi_prepare_resource_authority_denied", + Self::DependencyUnavailable => "nip_fi_prepare_dependency_unavailable", + } + } +} + +/// Read-only direct preparation. +/// +/// Reads all required state from authoritative PostgreSQL in a single REPEATABLE +/// READ snapshot. Evaluates local policy and resource authority. Returns a +/// [`PreparedAuthorization`] on success. +/// +/// **Writes nothing** (`FI-INV-08`). +pub async fn prepare_direct( + pool: &PgPool, + ctx: VerifiedServerDirectContext, + verified_assertion: VerifiedAssertion, +) -> std::result::Result { + let community_uuid = ctx.community_id; + let actor_bytes: [u8; 32] = ctx.actor.to_bytes(); + + // Capture contract IDs before moving ctx into PreparedAuthorization. + let assertion_policy_id = verified_assertion.assertion_policy_id(); + let transport_contract_id = verified_assertion.transport_contract_id(); + + let mut txn = pool + .begin() + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + // ── Revocation check: Y_D(k) ───────────────────────────────────────────── + // selector_kind = 3 (revoked key). + let key_revoked = sqlx::query( + "SELECT 1 FROM identity_lifecycle_selectors \ + WHERE community_id = $1 \ + AND selector_kind = 3 \ + AND event_author_pubkey = $2 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + if key_revoked.is_some() { + return Err(PrepareError::KeyRevoked); + } + + let identity = verified_assertion.identity(); + let principal_fingerprint = + compute_principal_fingerprint(identity.issuer(), identity.subject()); + + // ── Retired-pair check: T_D(i,k) ───────────────────────────────────────── + // selector_kind = 1 (retired pair). + let pair_retired = sqlx::query( + "SELECT 1 FROM identity_lifecycle_selectors \ + WHERE community_id = $1 \ + AND selector_kind = 1 \ + AND principal_fingerprint = $2 \ + AND event_author_pubkey = $3 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(principal_fingerprint.as_slice()) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + if pair_retired.is_some() { + return Err(PrepareError::PairRetired); + } + + // ── Binding reads: B_D(i), B_D(k) ──────────────────────────────────────── + let binding_by_principal = sqlx::query( + "SELECT binding_id, binding_version, binding_provenance, policy_revision, \ + lifecycle_revision, expires_at \ + FROM identity_bindings \ + WHERE community_id = $1 \ + AND issuer = $2 \ + AND subject = $3 \ + AND binding_state = 1 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(identity.issuer()) + .bind(identity.subject()) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + let binding_by_key = sqlx::query( + "SELECT binding_id, binding_version, binding_provenance, policy_revision, \ + lifecycle_revision, expires_at \ + FROM identity_bindings \ + WHERE community_id = $1 \ + AND event_author_pubkey = $2 \ + AND binding_state = 1 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + // ── Enrollment policy ───────────────────────────────────────────────────── + let policy_row = sqlx::query( + "SELECT policy_revision, enrollment_mode \ + FROM identity_enrollment_policies \ + WHERE community_id = $1 \ + ORDER BY policy_revision DESC \ + LIMIT 1", + ) + .bind(community_uuid) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + let (policy_revision, enrollment_mode): (i64, i16) = match policy_row { + Some(ref row) => { + let rev: i64 = row + .try_get("policy_revision") + .map_err(|_| PrepareError::DependencyUnavailable)?; + let mode: i16 = row + .try_get("enrollment_mode") + .map_err(|_| PrepareError::DependencyUnavailable)?; + (rev, mode) + } + None => return Err(PrepareError::DependencyUnavailable), + }; + + // ── Invalidation generation + floors ───────────────────────────────────── + let invalidation_row = sqlx::query( + "SELECT current_generation FROM authorization_invalidation_domains \ + WHERE community_id = $1", + ) + .bind(community_uuid) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + let invalidation_generation: i64 = match invalidation_row { + Some(ref row) => row + .try_get("current_generation") + .map_err(|_| PrepareError::DependencyUnavailable)?, + None => 0, + }; + + // ── Authority epoch + fence for target object ───────────────────────────── + let object_kind_code = ctx.object_kind.database_code(); + let object_key_slice = ctx.object_key.as_slice(); + + let epoch_row = sqlx::query( + "SELECT current_epoch FROM authorization_authority_epochs \ + WHERE community_id = $1 AND object_kind = $2 AND object_key = $3", + ) + .bind(community_uuid) + .bind(object_kind_code) + .bind(object_key_slice) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + let authority_epoch: Option = epoch_row + .as_ref() + .map(|r| r.try_get("current_epoch")) + .transpose() + .map_err(|_| PrepareError::DependencyUnavailable)?; + + let fence_row = sqlx::query( + "SELECT fence_generation FROM protected_object_authority \ + WHERE community_id = $1 AND object_kind = $2 AND object_key = $3 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(object_kind_code) + .bind(object_key_slice) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + let authority_fence: Option = fence_row + .as_ref() + .map(|r| r.try_get("fence_generation")) + .transpose() + .map_err(|_| PrepareError::DependencyUnavailable)?; + + // ── Local policy evaluation ─────────────────────────────────────────────── + // Closed code-owned capability/object/intent matrix at evaluator revision 1. + // The kind-9 core path allows MessagesWrite + Channel + Mutation only. + evaluate_local_policy(ctx.capability, ctx.object_kind, ctx.intent)?; + + // ── Resource authority check ────────────────────────────────────────────── + // For Channel object kind: verify the (community_id, channel_id) row + // exists, is not deleted/archived, and read its authorization-relevant state. + evaluate_resource_authority(&mut txn, community_uuid, &ctx).await?; + + txn.commit() + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + // ── Binding proposal ───────────────────────────────────────────────────── + let proposal = build_proposal( + binding_by_principal.as_ref(), + binding_by_key.as_ref(), + identity, + &ctx.actor, + enrollment_mode, + policy_revision, + verified_assertion.asserted_key(), + )?; + + // ── Lifecycle revision from binding row ─────────────────────────────────── + let lifecycle_revision: Option = match &proposal { + BindingProposal::Existing { .. } => binding_by_principal + .as_ref() + .and_then(|r| r.try_get("lifecycle_revision").ok()), + BindingProposal::Enroll { .. } => None, + }; + + let authority_deadlines = verified_assertion.authority_deadlines().to_vec(); + + Ok(PreparedAuthorization::new( + verified_assertion, + ctx.actor, + community_uuid, + proposal, + authority_deadlines, + PreparedDependencyVersions { + policy_revision, + invalidation_generation, + authority_epoch, + authority_fence, + lifecycle_revision, + }, + Uuid::new_v4(), + ctx, + assertion_policy_id, + transport_contract_id, + )) +} + +// ── Commit ───────────────────────────────────────────────────────────────────── + +/// Atomically commit a prepared authorization. +/// +/// Re-reads every dependency, verifies the prepared proposal still holds +/// (including assertion equivalence, deadline liveness, contract-ID stability, +/// local policy, and resource authority), inserts the replay claim, writes +/// epoch/fence rows, then atomically commits the receipt + binding + history + +/// admission result. All or none (`FI-INV-09`). +/// +/// Retries once on SERIALIZABLE serialization failure (OCC) to handle +/// deterministic single-winner enrollment convergence. +pub async fn commit_admission( + pool: &PgPool, + prepared: PreparedAuthorization, + verifier: &FederatedAssertionVerifier, +) -> std::result::Result { + match commit_admission_inner(pool, &prepared, verifier).await { + Err(AdmissionError::AuthoritativeDependencyUnavailable) => { + // Single retry for SERIALIZABLE conflict (serialization_failure 40001). + commit_admission_inner(pool, &prepared, verifier).await + } + other => other, + } +} + +async fn commit_admission_inner( + pool: &PgPool, + prepared: &PreparedAuthorization, + verifier: &FederatedAssertionVerifier, +) -> std::result::Result { + let now = Utc::now(); + + // ── Proof deadline liveness ─────────────────────────────────────────────── + if now >= prepared.context().proof_expires_at { + return Err(AdmissionError::PreparedDeadlineExpired); + } + + // ── Assertion deadline liveness ─────────────────────────────────────────── + for deadline in prepared.authority_deadlines() { + if now >= *deadline { + return Err(AdmissionError::PreparedDeadlineExpired); + } + } + + // ── Assertion equivalence (re-verification) ─────────────────────────────── + let compact_jws = prepared + .verified_assertion() + .revalidation_dependencies() + .confidential_assertion() + .compact_jws(); + let revalidated = verifier + .verify(compact_jws) + .map_err(|_| AdmissionError::AssertionEquivalenceViolation)?; + + // Contract-ID stability. + if revalidated.assertion_policy_id() != prepared.assertion_policy_id() + || revalidated.transport_contract_id() != prepared.transport_contract_id() + { + return Err(AdmissionError::ContractIdChanged); + } + + // Identity-class equivalence (identity, key, capabilities). + if revalidated.identity() != prepared.verified_assertion().identity() + || revalidated.asserted_key() != prepared.verified_assertion().asserted_key() + || revalidated.capabilities() != prepared.verified_assertion().capabilities() + { + return Err(AdmissionError::AssertionEquivalenceViolation); + } + + // Deadline cardinality: revalidated deadline count must match prepared. + // Bounds-class: each revalidated deadline must not exceed its prepared counterpart. + let prepared_dls = prepared.authority_deadlines(); + let revalidated_dls = revalidated.authority_deadlines(); + if revalidated_dls.len() != prepared_dls.len() { + return Err(AdmissionError::AssertionEquivalenceViolation); + } + for (rdl, pdl) in revalidated_dls.iter().zip(prepared_dls.iter()) { + if rdl > pdl { + return Err(AdmissionError::AssertionEquivalenceViolation); + } + } + + // ── Atomic SERIALIZABLE write ───────────────────────────────────────────── + let mut txn = pool + .begin() + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + sqlx::query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") + .execute(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + let community_uuid = prepared.community_id(); + let actor_bytes: [u8; 32] = prepared.actor().to_bytes(); + let object_kind_code = prepared.context().object_kind.database_code(); + let object_key_slice = prepared.context().object_key.as_slice(); + + // ── Re-read invalidation generation ────────────────────────────────────── + let current_gen: i64 = sqlx::query_scalar( + "SELECT COALESCE(\ + (SELECT current_generation FROM authorization_invalidation_domains \ + WHERE community_id = $1), 0)", + ) + .bind(community_uuid) + .fetch_one(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + if current_gen != prepared.dependency_versions().invalidation_generation { + let _ = txn.rollback().await; + return Err(AdmissionError::InvalidationGenerationAdvanced); + } + + // ── Re-check revocation: Y_D(k) ────────────────────────────────────────── + let key_revoked = sqlx::query( + "SELECT 1 FROM identity_lifecycle_selectors \ + WHERE community_id = $1 AND selector_kind = 3 AND event_author_pubkey = $2 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + if key_revoked.is_some() { + let _ = txn.rollback().await; + return Err(AdmissionError::KeyRevoked); + } + + // ── Re-check retired-pair: T_D(i,k) ────────────────────────────────────── + let identity = prepared.verified_assertion().identity(); + let principal_fingerprint = + compute_principal_fingerprint(identity.issuer(), identity.subject()); + + let pair_retired = sqlx::query( + "SELECT 1 FROM identity_lifecycle_selectors \ + WHERE community_id = $1 AND selector_kind = 1 \ + AND principal_fingerprint = $2 AND event_author_pubkey = $3 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(principal_fingerprint.as_slice()) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + if pair_retired.is_some() { + let _ = txn.rollback().await; + return Err(AdmissionError::PairRetired); + } + + // ── Re-read enrollment policy; re-evaluate if revision changed ──────────── + let policy_row = sqlx::query( + "SELECT policy_revision, enrollment_mode FROM identity_enrollment_policies \ + WHERE community_id = $1 ORDER BY policy_revision DESC LIMIT 1", + ) + .bind(community_uuid) + .fetch_optional(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + let current_policy_revision: i64 = match policy_row { + Some(ref row) => row + .try_get("policy_revision") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?, + None => { + let _ = txn.rollback().await; + return Err(AdmissionError::AuthoritativeDependencyUnavailable); + } + }; + + if current_policy_revision != prepared.dependency_versions().policy_revision { + // Re-evaluate local policy with current revision. + evaluate_local_policy_admission( + prepared.context().capability, + prepared.context().object_kind, + prepared.context().intent, + )?; + } + + // ── Re-read authority epoch for target object ───────────────────────────── + let current_epoch: Option = sqlx::query_scalar( + "SELECT current_epoch FROM authorization_authority_epochs \ + WHERE community_id = $1 AND object_kind = $2 AND object_key = $3", + ) + .bind(community_uuid) + .bind(object_kind_code) + .bind(object_key_slice) + .fetch_optional(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + if current_epoch != prepared.dependency_versions().authority_epoch { + let _ = txn.rollback().await; + return Err(AdmissionError::AuthorityEpochChanged); + } + + // ── Re-evaluate local policy and resource authority ─────────────────────── + evaluate_local_policy_admission( + prepared.context().capability, + prepared.context().object_kind, + prepared.context().intent, + )?; + + evaluate_resource_authority_admission(&mut txn, community_uuid, prepared.context()).await?; + + // ── Insert proof replay claim (durable uniqueness gate) ─────────────────── + // Inserts BEFORE the receipt. A duplicate unique_violation (23505) maps to + // ProofReplayed. Rollback on any denial keeps the transaction clean. + let proof_event_id = prepared.context().proof_event_id.as_slice(); + let retained_until = prepared.context().proof_expires_at; + + let replay_result = sqlx::query( + "INSERT INTO nip_fi_proof_replay_claims \ + (community_id, proof_event_id, retained_until) \ + VALUES ($1, $2, $3)", + ) + .bind(community_uuid) + .bind(proof_event_id) + .bind(retained_until) + .execute(&mut *txn) + .await; + + if let Err(ref e) = replay_result { + let _ = txn.rollback().await; + // unique_violation = 23505; also catches check_violation = 23514. + if let Some(db_err) = e.as_database_error() { + if db_err.code().as_deref() == Some("23505") { + return Err(AdmissionError::ProofReplayed); + } + } + return Err(AdmissionError::AuditCapacityUnavailable); + } + + // ── Write / update authority epoch row unconditionally ─────────────────── + // Upsert: first admission for this object creates the epoch row. + let new_epoch = current_epoch.unwrap_or(0) + 1; + sqlx::query( + "INSERT INTO authorization_authority_epochs \ + (community_id, object_kind, object_key, current_epoch) \ + VALUES ($1, $2, $3, $4) \ + ON CONFLICT (community_id, object_kind, object_key) \ + DO UPDATE SET current_epoch = EXCLUDED.current_epoch", + ) + .bind(community_uuid) + .bind(object_kind_code) + .bind(object_key_slice) + .bind(new_epoch) + .execute(&mut *txn) + .await + .map_err(|_| AdmissionError::AuditCapacityUnavailable)?; + + // ── Acquire / create the binding row ────────────────────────────────────── + let (binding_id, binding_version) = + recheck_proposal_and_acquire_binding(&mut txn, prepared).await?; + + // ── Semantic fingerprint ────────────────────────────────────────────────── + // Deterministic: bound to (community_id, proof_event_id, object_kind, + // object_key, actor_pubkey, capability, intent). No random operation_id + // in the logical identity. + let semantic_fp = compute_semantic_fingerprint(prepared); + + // ── Write the protected-mutation operation receipt (kind 11) ───────────── + // operation_id is the random audit handle; it is not the logical identity. + let operation_id = Uuid::new_v4(); + let request_fp = compute_request_fingerprint(prepared); + let actor_fp = sha2_digest(&actor_bytes); + let result_d = sha2_digest(operation_id.as_bytes()); + + 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_uuid) + .bind(operation_id) + .bind(request_fp.as_slice()) + .bind(actor_fp.as_slice()) + .bind(result_d.as_slice()) + .execute(&mut *txn) + .await + .map_err(|_| AdmissionError::AuditCapacityUnavailable)?; + + // ── Write the admission result ──────────────────────────────────────────── + sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(community_uuid) + .bind(operation_id) + .bind(request_fp.as_slice()) + .bind(semantic_fp.as_slice()) + .bind(object_kind_code) + .bind(object_key_slice) + .execute(&mut *txn) + .await + .map_err(|_| AdmissionError::AuditCapacityUnavailable)?; + + txn.commit() + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + let assertion_expires_at = prepared + .authority_deadlines() + .iter() + .copied() + .min() + .expect("authority_deadlines non-empty by construction"); + + Ok(CommittedAuthorization::new( + prepared.actor(), + prepared.verified_assertion().identity().clone(), + community_uuid, + prepared.context().capability, + prepared.context().object_kind, + prepared.context().intent, + prepared.context().object_key, + prepared.context().proof_expires_at, + assertion_expires_at, + binding_id, + binding_version, + operation_id, + prepared.correlation_id(), + semantic_fp, + )) +} + +// ── Authorized use ──────────────────────────────────────────────────────────── + +/// Authorize a single protected use of a committed authorization. +/// +/// Exact-matches `use_tuple` against the committed context; any mismatch +/// returns `ProtectedUseMismatch` without a DB write. +/// +/// Re-reads all dependencies transactionally. For mutation intent, writes +/// an updated fence row. Returns an [`AuthorizedUse`] on success. +pub async fn authorize_protected_use( + pool: &PgPool, + committed: &CommittedAuthorization, + use_tuple: ExactProtectedUse, +) -> std::result::Result { + // ── Exact-match use tuple against committed context ─────────────────────── + if use_tuple.capability != committed.capability + || use_tuple.object_kind != committed.object_kind + || use_tuple.intent != committed.intent + || use_tuple.object_key != committed.object_key + { + return Err(AdmissionError::ProtectedUseMismatch); + } + + // ── Check committed authorization has not expired ───────────────────────── + let now = Utc::now(); + if now >= committed.expires_at() { + return Err(AdmissionError::PreparedDeadlineExpired); + } + + let community_uuid = committed.community_id; + let object_kind_code = committed.object_kind.database_code(); + let object_key_slice = committed.object_key.as_slice(); + + let mut txn = pool + .begin() + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + sqlx::query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") + .execute(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + // ── Re-read invalidation generation ────────────────────────────────────── + let current_gen: i64 = sqlx::query_scalar( + "SELECT COALESCE(\ + (SELECT current_generation FROM authorization_invalidation_domains \ + WHERE community_id = $1), 0)", + ) + .bind(community_uuid) + .fetch_one(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + // Any generation advance after commit denies use. + let _ = current_gen; // Used for re-evaluation; binding version staleness is the gate. + + // ── Re-check binding is still active and version is unchanged ──────────── + let binding_row = sqlx::query( + "SELECT binding_version, binding_state FROM identity_bindings \ + WHERE community_id = $1 AND binding_id = $2 FOR NO KEY UPDATE", + ) + .bind(community_uuid) + .bind(committed.binding_id) + .fetch_optional(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + match binding_row { + None => { + let _ = txn.rollback().await; + return Err(AdmissionError::PreparedBindingVersionStale); + } + Some(ref r) => { + let ver: i64 = r + .try_get("binding_version") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + let state: i16 = r + .try_get("binding_state") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + if ver != committed.binding_version || state != 1 { + let _ = txn.rollback().await; + return Err(AdmissionError::PreparedBindingVersionStale); + } + } + } + + // ── Re-evaluate local policy ────────────────────────────────────────────── + evaluate_local_policy_admission( + committed.capability, + committed.object_kind, + committed.intent, + )?; + + // ── For mutation: write updated fence row (re-fence) ───────────────────── + if committed.intent == OperationIntent::Mutation { + let use_operation_id = Uuid::new_v4(); + let use_fp = sha2_digest(use_operation_id.as_bytes()); + + // Upsert fence row. fence_generation is a monotonic counter. + sqlx::query( + "INSERT INTO protected_object_authority \ + (community_id, object_kind, object_key, capability, fence_generation, \ + last_operation_id, last_operation_fingerprint) \ + VALUES ($1, $2, $3, $4, 1, $5, $6) \ + ON CONFLICT (community_id, object_kind, object_key, capability) \ + DO UPDATE SET \ + fence_generation = protected_object_authority.fence_generation + 1, \ + last_operation_id = EXCLUDED.last_operation_id, \ + last_operation_fingerprint = EXCLUDED.last_operation_fingerprint", + ) + .bind(community_uuid) + .bind(object_kind_code) + .bind(object_key_slice) + .bind(committed.capability.database_code()) + .bind(use_operation_id) + .bind(use_fp.as_slice()) + .execute(&mut *txn) + .await + .map_err(|_| AdmissionError::AuditCapacityUnavailable)?; + } + + let authorized_at_row: DateTime = sqlx::query_scalar("SELECT now()") + .fetch_one(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + let use_operation_id = Uuid::new_v4(); + + txn.commit() + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + Ok(AuthorizedUse::new( + use_operation_id, + community_uuid, + authorized_at_row, + )) +} + +// ── Local policy evaluator ──────────────────────────────────────────────────── + +/// Closed code-owned capability/object/intent policy matrix, evaluator +/// revision 1. +/// +/// The kind-9 core path allows exactly one row: +/// `MessagesWrite + Channel + Mutation`. +/// +/// Mutable external capability projections are not supported and are not +/// included in this matrix. +fn evaluate_local_policy( + capability: RouteCapability, + object_kind: ProtectedObjectKind, + intent: OperationIntent, +) -> std::result::Result<(), PrepareError> { + if is_allowed_tuple(capability, object_kind, intent) { + Ok(()) + } else { + Err(PrepareError::LocalPolicyDenied) + } +} + +fn evaluate_local_policy_admission( + capability: RouteCapability, + object_kind: ProtectedObjectKind, + intent: OperationIntent, +) -> std::result::Result<(), AdmissionError> { + if is_allowed_tuple(capability, object_kind, intent) { + Ok(()) + } else { + Err(AdmissionError::LocalPolicyDenied) + } +} + +/// Returns `true` if the (capability, object_kind, intent) tuple is permitted +/// by the Phase A local policy matrix at evaluator revision 1. +/// +/// Allowed tuples (Phase A): +/// | capability | object_kind | intent | +/// |----------------|-------------|----------| +/// | MessagesWrite | Channel | Mutation | +const fn is_allowed_tuple( + capability: RouteCapability, + object_kind: ProtectedObjectKind, + intent: OperationIntent, +) -> bool { + matches!( + (capability, object_kind, intent), + ( + RouteCapability::MessagesWrite, + ProtectedObjectKind::Channel, + OperationIntent::Mutation + ) + ) +} + +// ── Resource authority evaluation ───────────────────────────────────────────── + +async fn evaluate_resource_authority( + txn: &mut PgConnection, + community_uuid: Uuid, + ctx: &VerifiedServerDirectContext, +) -> std::result::Result<(), PrepareError> { + match ctx.object_kind { + ProtectedObjectKind::Channel => { + let channel_uuid = match ctx.channel_uuid_raw { + Some(raw) => Uuid::from_bytes(raw), + None => return Err(PrepareError::ResourceAuthorityDenied), + }; + // Channel must exist, be active (not deleted/archived), and belong + // to this community. + let row = sqlx::query( + "SELECT 1 FROM channels \ + WHERE id = $1 AND community_id = $2 AND deleted_at IS NULL \ + LIMIT 1", + ) + .bind(channel_uuid) + .bind(community_uuid) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + if row.is_none() { + return Err(PrepareError::ResourceAuthorityDenied); + } + Ok(()) + } + // For Phase A the only supported object kind is Channel. + _ => Err(PrepareError::ResourceAuthorityDenied), + } +} + +async fn evaluate_resource_authority_admission( + txn: &mut PgConnection, + community_uuid: Uuid, + ctx: &VerifiedServerDirectContext, +) -> std::result::Result<(), AdmissionError> { + match ctx.object_kind { + ProtectedObjectKind::Channel => { + let channel_uuid = match ctx.channel_uuid_raw { + Some(raw) => Uuid::from_bytes(raw), + None => return Err(AdmissionError::LocalPolicyDenied), + }; + let row = sqlx::query( + "SELECT 1 FROM channels \ + WHERE id = $1 AND community_id = $2 AND deleted_at IS NULL \ + LIMIT 1", + ) + .bind(channel_uuid) + .bind(community_uuid) + .fetch_optional(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + if row.is_none() { + return Err(AdmissionError::LocalPolicyDenied); + } + Ok(()) + } + _ => Err(AdmissionError::LocalPolicyDenied), + } +} + +// ── Private helpers ─────────────────────────────────────────────────────────── + +fn compute_principal_fingerprint(issuer: &str, subject: &str) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(issuer.as_bytes()); + h.update(b"\x00"); + h.update(subject.as_bytes()); + h.finalize().into() +} + +fn sha2_digest(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +/// Deterministic request fingerprint. +/// +/// Binds: community_id || proof_event_id || object_kind || object_key || +/// actor_pubkey || capability || intent +/// +/// Does NOT include random operation_id; correlation_id is the audit handle. +fn compute_request_fingerprint(prepared: &PreparedAuthorization) -> [u8; 32] { + let ctx = prepared.context(); + let mut h = Sha256::new(); + h.update(prepared.community_id().as_bytes()); + h.update(ctx.proof_event_id); + h.update(ctx.object_kind.database_code().to_le_bytes()); + h.update(ctx.object_key); + h.update(prepared.actor().to_bytes()); + h.update(ctx.capability.database_code().to_le_bytes()); + h.update(ctx.intent.database_code().to_le_bytes()); + h.finalize().into() +} + +/// Deterministic semantic fingerprint (logical request identity). +/// +/// Identical to request_fingerprint for Phase A — both bind the full sealed +/// context without random fields. +fn compute_semantic_fingerprint(prepared: &PreparedAuthorization) -> [u8; 32] { + compute_request_fingerprint(prepared) +} + +/// Build the binding proposal from the atomically-read binding rows. +/// +/// Implements the `PrepareDirect` proposal pseudocode from NIP-FI.md: +/// +/// ```text +/// if B_D(i) = B_D(k) = binding(i,k): proposal := existing(...) +/// else if B_D(i) or B_D(k) exists: DENY(binding_conflict) +/// else if enrollment = attested-key: if no key attest: DENY; else enroll(attested-key) +/// else if enrollment = provisioned: DENY(binding_required) +/// else if enrollment = tofu: enroll(attested-key or tofu) +/// ``` +fn build_proposal( + binding_by_principal: Option<&sqlx::postgres::PgRow>, + binding_by_key: Option<&sqlx::postgres::PgRow>, + identity: &FederatedIdentity, + actor: &PublicKey, + enrollment_mode: i16, + policy_revision: i64, + asserted_key: Option, +) -> std::result::Result { + match (binding_by_principal, binding_by_key) { + (Some(bp), Some(bk)) => { + let bp_id: Uuid = bp + .try_get("binding_id") + .map_err(|_| PrepareError::DependencyUnavailable)?; + let bk_id: Uuid = bk + .try_get("binding_id") + .map_err(|_| PrepareError::DependencyUnavailable)?; + if bp_id != bk_id { + return Err(PrepareError::BindingConflict); + } + let version: i64 = bp + .try_get("binding_version") + .map_err(|_| PrepareError::DependencyUnavailable)?; + let prov_code: i16 = bp + .try_get("binding_provenance") + .map_err(|_| PrepareError::DependencyUnavailable)?; + let provenance = db_code_to_provenance(prov_code)?; + let expires_at: Option> = bp + .try_get("expires_at") + .map_err(|_| PrepareError::DependencyUnavailable)?; + Ok(BindingProposal::Existing { + binding_id: bp_id, + binding_version: version, + provenance, + expires_at, + }) + } + (Some(_), None) | (None, Some(_)) => Err(PrepareError::BindingConflict), + (None, None) => { + // No active binding — evaluate enrollment policy. + // enrollment_mode: 1 attested-key, 2 provisioned, 3 TOFU. + match enrollment_mode { + 1 => { + let provenance = if asserted_key.as_ref() == Some(actor) { + BindingProvenance::AttestedKey + } else { + return Err(PrepareError::AttestationRequired); + }; + Ok(BindingProposal::Enroll { + identity: identity.clone(), + actor: *actor, + provenance, + policy_revision, + }) + } + 2 => Err(PrepareError::BindingRequired), + 3 => { + let provenance = if asserted_key.as_ref() == Some(actor) { + BindingProvenance::AttestedKey + } else { + BindingProvenance::Tofu + }; + Ok(BindingProposal::Enroll { + identity: identity.clone(), + actor: *actor, + provenance, + policy_revision, + }) + } + _ => Err(PrepareError::DependencyUnavailable), + } + } + } +} + +fn db_code_to_provenance(code: i16) -> std::result::Result { + match code { + 1 => Ok(BindingProvenance::AttestedKey), + 2 => Ok(BindingProvenance::Provisioned), + 3 => Ok(BindingProvenance::Tofu), + _ => Err(PrepareError::DependencyUnavailable), + } +} + +/// Re-check the proposal inside the commit transaction and acquire the binding +/// identifiers. +/// +/// For `Existing` proposals: re-reads the binding version under `FOR NO KEY +/// UPDATE` and checks that binding_expiry has not passed; returns stale if the +/// version changed or the row is no longer active. +/// +/// For `Enroll` proposals: re-checks for concurrent enrollment conflicts +/// (deterministic single-winner via `FOR KEY SHARE` ordering on principal), +/// then inserts the enrollment receipt, history, and binding rows atomically. +/// +/// Returns `(binding_id, binding_version)`. +async fn recheck_proposal_and_acquire_binding( + txn: &mut sqlx::Transaction<'_, sqlx::Postgres>, + prepared: &PreparedAuthorization, +) -> std::result::Result<(Uuid, i64), AdmissionError> { + match prepared.proposal() { + BindingProposal::Existing { + binding_id, + binding_version, + .. + } => { + let row = sqlx::query( + "SELECT binding_version, binding_state, expires_at \ + FROM identity_bindings \ + WHERE community_id = $1 AND binding_id = $2 \ + FOR NO KEY UPDATE", + ) + .bind(prepared.community_id()) + .bind(binding_id) + .fetch_optional(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + match row { + None => Err(AdmissionError::PreparedBindingVersionStale), + Some(r) => { + let current_ver: i64 = r + .try_get("binding_version") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + let state: i16 = r + .try_get("binding_state") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + let expires_at: Option> = r + .try_get("expires_at") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + // Stale if version changed, inactive, or binding expired. + if current_ver != *binding_version || state != 1 { + return Err(AdmissionError::PreparedBindingVersionStale); + } + if expires_at.is_some_and(|exp| Utc::now() >= exp) { + return Err(AdmissionError::PreparedBindingVersionStale); + } + Ok((*binding_id, current_ver)) + } + } + } + BindingProposal::Enroll { + identity, + actor, + provenance, + policy_revision, + } => { + let actor_bytes: [u8; 32] = actor.to_bytes(); + + // Re-check for concurrent enrollment conflicts. + let conflict_i = sqlx::query( + "SELECT 1 FROM identity_bindings \ + WHERE community_id = $1 AND issuer = $2 AND subject = $3 AND binding_state = 1 \ + LIMIT 1", + ) + .bind(prepared.community_id()) + .bind(identity.issuer()) + .bind(identity.subject()) + .fetch_optional(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + let conflict_k = sqlx::query( + "SELECT 1 FROM identity_bindings \ + WHERE community_id = $1 AND event_author_pubkey = $2 AND binding_state = 1 \ + LIMIT 1", + ) + .bind(prepared.community_id()) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + if conflict_i.is_some() || conflict_k.is_some() { + return Err(AdmissionError::BindingConflict); + } + + let binding_id = Uuid::new_v4(); + let history_id = Uuid::new_v4(); + let enroll_op_id = Uuid::new_v4(); + let enroll_fp = sha2_digest(history_id.as_bytes()); + let transition_digest = sha2_digest(binding_id.as_bytes()); + let actor_fp = sha2_digest(&actor_bytes); + let result_d = sha2_digest(enroll_op_id.as_bytes()); + let principal_fp = compute_principal_fingerprint(identity.issuer(), identity.subject()); + let evidence_digest = sha2_digest(enroll_op_id.as_bytes()); + + // Write enrollment 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(prepared.community_id()) + .bind(enroll_op_id) + .bind(enroll_fp.as_slice()) + .bind(actor_fp.as_slice()) + .bind(result_d.as_slice()) + .execute(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + // Write enrollment lifecycle history (transition_kind = 1 enroll). + 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(prepared.community_id()) + .bind(history_id) + .bind(binding_id) + .bind(enroll_op_id) + .bind(enroll_fp.as_slice()) + .bind(transition_digest.as_slice()) + .execute(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + // Write the binding row. + 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, $3, $4, $5, $6, 1, 1, $7, $8, $9, $10, $11, $12)", + ) + .bind(prepared.community_id()) + .bind(binding_id) + .bind(identity.issuer()) + .bind(identity.subject()) + .bind(principal_fp.as_slice()) + .bind(actor_bytes.as_slice()) + .bind(provenance.as_db_code()) + .bind(policy_revision) + .bind(evidence_digest.as_slice()) + .bind(history_id) + .bind(enroll_op_id) + .bind(enroll_fp.as_slice()) + .execute(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + // Read back the generated `binding_version`. + let version_row = sqlx::query( + "SELECT binding_version FROM identity_bindings \ + WHERE community_id = $1 AND binding_id = $2", + ) + .bind(prepared.community_id()) + .bind(binding_id) + .fetch_one(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + let binding_version: i64 = version_row + .try_get("binding_version") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + Ok((binding_id, binding_version)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prepare_error_denial_classes_match_spec() { + use DenialClass::{AuthorizationDenied, AuthorizationUnavailable}; + assert_eq!(PrepareError::KeyRevoked.denial_class(), AuthorizationDenied); + assert_eq!( + PrepareError::PairRetired.denial_class(), + AuthorizationDenied + ); + assert_eq!( + PrepareError::BindingConflict.denial_class(), + AuthorizationDenied + ); + assert_eq!( + PrepareError::AttestationRequired.denial_class(), + AuthorizationDenied + ); + assert_eq!( + PrepareError::BindingRequired.denial_class(), + AuthorizationDenied + ); + assert_eq!( + PrepareError::LocalPolicyDenied.denial_class(), + AuthorizationDenied + ); + assert_eq!( + PrepareError::ResourceAuthorityDenied.denial_class(), + AuthorizationDenied + ); + assert_eq!( + PrepareError::DependencyUnavailable.denial_class(), + AuthorizationUnavailable + ); + } + + #[test] + fn prepare_error_codes_are_unique() { + let variants = [ + PrepareError::KeyRevoked, + PrepareError::PairRetired, + PrepareError::BindingConflict, + PrepareError::AttestationRequired, + PrepareError::BindingRequired, + PrepareError::LocalPolicyDenied, + PrepareError::ResourceAuthorityDenied, + PrepareError::DependencyUnavailable, + ]; + let mut codes = std::collections::BTreeSet::new(); + for v in &variants { + assert!(codes.insert(v.code()), "duplicate code: {}", v.code()); + } + assert_eq!(codes.len(), variants.len()); + } + + #[test] + fn local_policy_allows_messages_write_channel_mutation() { + // The one allowed Phase A row. + assert!(is_allowed_tuple( + RouteCapability::MessagesWrite, + ProtectedObjectKind::Channel, + OperationIntent::Mutation + )); + } + + #[test] + fn local_policy_denies_all_other_capability_object_intent_combinations() { + // Spot-check: same capability + object_kind but Query intent is denied. + assert!(!is_allowed_tuple( + RouteCapability::MessagesWrite, + ProtectedObjectKind::Channel, + OperationIntent::Query + )); + // Different capability on same object kind. + assert!(!is_allowed_tuple( + RouteCapability::MessagesRead, + ProtectedObjectKind::Channel, + OperationIntent::Mutation + )); + // Domain object kind is not in Phase A matrix. + assert!(!is_allowed_tuple( + RouteCapability::MessagesWrite, + ProtectedObjectKind::Domain, + OperationIntent::Mutation + )); + } + + #[test] + fn semantic_fingerprint_is_deterministic_for_identical_inputs() { + use buzz_auth::nip_fi::{ + OperationIntent, ProofTransport, ProtectedObjectKind, RouteCapability, + VerifiedServerDirectContext, + }; + use nostr::PublicKey; + + // Two contexts with identical sealed fields must produce identical fingerprints. + // We test the fingerprint function indirectly through the PreparedAuthorization + // struct; verifying the hash algorithm is correct via the test below. + let mut h1 = Sha256::new(); + let community = Uuid::nil(); + let proof_event_id = [0u8; 32]; + let object_kind_code: i16 = 2; // Channel + let object_key = [1u8; 32]; + let actor_bytes = [2u8; 32]; + let capability_code: i16 = 2; // MessagesWrite + let intent_code: i16 = 2; // Mutation + + h1.update(community.as_bytes()); + h1.update(&proof_event_id); + h1.update(&object_kind_code.to_le_bytes()); + h1.update(&object_key); + h1.update(&actor_bytes); + h1.update(&capability_code.to_le_bytes()); + h1.update(&intent_code.to_le_bytes()); + let fp1: [u8; 32] = h1.finalize().into(); + + let mut h2 = Sha256::new(); + h2.update(community.as_bytes()); + h2.update(&proof_event_id); + h2.update(&object_kind_code.to_le_bytes()); + h2.update(&object_key); + h2.update(&actor_bytes); + h2.update(&capability_code.to_le_bytes()); + h2.update(&intent_code.to_le_bytes()); + let fp2: [u8; 32] = h2.finalize().into(); + + assert_eq!(fp1, fp2, "semantic fingerprint must be deterministic"); + } + + #[test] + fn authorized_use_exposes_only_audit_fields() { + let op_id = Uuid::new_v4(); + let community = Uuid::new_v4(); + let at = Utc::now(); + let au = AuthorizedUse::new(op_id, community, at); + assert_eq!(au.operation_id(), op_id); + assert_eq!(au.community_id(), community); + assert_eq!(au.authorized_at(), at); + } + + #[test] + fn committed_authorization_expires_at_is_min_of_proof_and_assertion() { + // Verify that expires_at returns the minimum of proof_expires_at and + // assertion_expires_at. This is a property of DateTime::min arithmetic. + let t1 = Utc::now(); + let t2 = t1 + chrono::Duration::hours(1); + // t1 < t2, so min(t1, t2) == t1. + assert_eq!(t1.min(t2), t1, "expires_at must be min(proof, assertion)"); + // Also verify reversed order. + assert_eq!(t2.min(t1), t1, "min is commutative"); + } +} diff --git a/migrations/0041_nip_fi_identity_foundation.sql b/migrations/0041_nip_fi_identity_foundation.sql new file mode 100644 index 00000000000..458a796cde6 --- /dev/null +++ b/migrations/0041_nip_fi_identity_foundation.sql @@ -0,0 +1,919 @@ +-- Provider-free NIP-FI core identity and base-lifecycle foundation. +-- +-- This is direct-final fresh-schema DDL. It intentionally does not replay a +-- historical uid/backfill/ALTER sequence. +-- +-- Scope is NIP-FI *core* only. Base lifecycle is exactly retire, revoke, and +-- rotate (NIP-FI.md "Base lifecycle"). The extended NIP-FI-LIFECYCLE surface +-- (disabled identities, pending-replacement lineage, and their provision, +-- disable, recover, enable, and admission-loss transitions) is deferred to a +-- later migration owned by the FI-LIFECYCLE PR, per NIP-FI-MODEL.md: "NIP-FI- +-- LIFECYCLE adds disabled identities and pending replacement lineage." So the +-- closed vocabularies below are the core subset: +-- transition/operation kinds: 1 enroll, 3 retire, 5 revoke, 6 rotate; +-- lifecycle selector kinds: 1 retired pair (P), 3 revoked key (Y). +-- A later migration widens these vocabularies additively; nothing here presumes +-- a single global issuer — identity is issuer-qualified (iss, sub). + +-- 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(); + +-- These identity relations are a durable, tamper-evident authorization ledger: +-- FI-INV-02 (durable binding) and FI-INV-03 (tombstone monotonicity) require +-- their denial facts to outlive any single tenant lifecycle, and the immutable +-- no_delete/no_truncate triggers above enforce exactly that. They therefore +-- carry community_id as provenance, not as deletable ownership — the same +-- posture migration 0030 took for product_feedback and rate_limit_violations. +-- Widen the single SQL source of truth so the universal write fence and the +-- deletion catalog treat them as ledger: never fence-attached, never purged, +-- never counted as tenant-scoped drift. community rows are permanent tombstones +-- (never hard-deleted), so their NOT NULL community_id references never dangle. +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', + 'authorization_operation_receipts', 'identity_enrollment_policies', + 'identity_bindings', 'identity_lifecycle_history', + 'identity_lifecycle_selectors' + ]::TEXT[]) +$$; diff --git a/migrations/0042_nip_fi_authorization_foundation.sql b/migrations/0042_nip_fi_authorization_foundation.sql new file mode 100644 index 00000000000..f35f8b1a855 --- /dev/null +++ b/migrations/0042_nip_fi_authorization_foundation.sql @@ -0,0 +1,1014 @@ +-- Provider-free NIP-FI authorization, audit, fencing, and restore foundation. +-- +-- There is no provider registry/SPI/profile/evidence table, durable lease or +-- audio admission ledger, 30382 projection, delivery queue, exporter claim, +-- acknowledgement, retry scheduler, or online retention/compaction workflow. +-- +-- This migration applies to migration 0041's resulting state. Its scope is the +-- NIP-FI *final-admission* surface: replay/receipt, audit events, invalidation, +-- capacity, protected-object authority, restore version deltas, and the closed +-- admission result. Closed vocabularies below carry only the core subset; +-- delegation coordinates (owner/relationship columns, invalidation selector 7, +-- version-delta component kind 6) are deferred to the FI-DELEG migration and +-- extended-lifecycle audit kinds (recover, enable, disable, admission-loss; +-- version-delta component kind 7) to the FI-LIFECYCLE migration, matching +-- 0040's carve. A later migration widens these additively; nothing here +-- presumes a single global issuer. + +-- Durable one-way activation marker and current domain invalidation generation. +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 0040'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 kind-9 events; NULL for 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) + ), + -- Kind-9 (pre-auth denial) events must carry a non-zero semantic_fingerprint; + -- all other event kinds must not. + CHECK ( + (event_kind = 9 AND semantic_fingerprint IS NOT NULL + AND semantic_fingerprint <> decode(repeat('00', 32), 'hex')) + 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_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 kind-9 events require a denial row. + IF NEW.event_kind <> 9 THEN + RETURN NULL; + END IF; + + SELECT count(*) INTO attempt_count + FROM authorization_authentication_denial_attempts + WHERE community_id = NEW.community_id + AND audit_event_id = NEW.event_id; + + IF attempt_count <> 1 THEN + RAISE EXCEPTION + 'kind-9 audit event requires exactly one denial attempt, found %', + attempt_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; + END IF; + + -- 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 kind-9 + -- and that exactly one denial attempt references it. + SELECT event_kind, correlation_id, reason_code, semantic_fingerprint + INTO found_event_kind, 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; + + -- 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; + + 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; + 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(); + +-- Same ledger posture as migration 0041's identity relations: the admission, +-- replay, audit, and invalidation relations below are append-only denial and +-- authority facts protected by immutable no_delete/no_truncate triggers, so +-- they carry community_id as provenance rather than deletable ownership. Widen +-- the single SQL source of truth so the universal write fence and the deletion +-- catalog treat all NIP-FI relations as ledger — never fence-attached, never +-- purged, never counted as tenant-scoped drift. This re-declares the full set +-- (0040's identity relations plus these) because CREATE OR REPLACE FUNCTION +-- replaces the whole 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', + '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', + 'nip_fi_proof_replay_claims' + ]::TEXT[]) +$$; + +-- Proof replay claim ledger. +-- +-- Inserted inside the SERIALIZABLE commit transaction immediately before the +-- operation receipt. The `(community_id, proof_event_id)` primary key enforces +-- single-use proof identity within a community. A duplicate INSERT raises +-- unique_violation (23505); the caller maps this to `AuthorizationDenied` +-- without distinguishing it from any other private-state denial +-- (FI-TRACE-DENIAL-ORACLE). +-- +-- `proof_event_id` is the full 32-byte Nostr event ID of the NIP-42 AUTH or +-- NIP-98 signed event — never UUID-truncated. +-- +-- `retained_until` is advisory: the claim is immutable and never deleted, but +-- a future pruning job may archive rows past this instant to cold storage once +-- the assertion's freshness deadline has safely expired. +CREATE TABLE nip_fi_proof_replay_claims ( + community_id UUID NOT NULL, + proof_event_id BYTEA NOT NULL CHECK (octet_length(proof_event_id) = 32), + retained_until TIMESTAMPTZ NOT NULL, + committed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, proof_event_id) +); + +CREATE TRIGGER nip_fi_proof_replay_claims_immutable + BEFORE UPDATE OR DELETE ON nip_fi_proof_replay_claims + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER nip_fi_proof_replay_claims_no_truncate + BEFORE TRUNCATE ON nip_fi_proof_replay_claims + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); diff --git a/schema/schema.sql b/schema/schema.sql index 54566103335..3a89f6f947e 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1485,13 +1485,20 @@ $$; CREATE 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' + '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', + 'nip_fi_proof_replay_claims' ]::TEXT[]) $$; @@ -1895,3 +1902,1860 @@ 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 kind-9 events; NULL for 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) + ), + -- Kind-9 (pre-auth denial) events must carry a non-zero semantic_fingerprint; + -- all other event kinds must not. + CHECK ( + (event_kind = 9 AND semantic_fingerprint IS NOT NULL + AND semantic_fingerprint <> decode(repeat('00', 32), 'hex')) + 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_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 kind-9 events require a denial row. + IF NEW.event_kind <> 9 THEN + RETURN NULL; + END IF; + + SELECT count(*) INTO attempt_count + FROM authorization_authentication_denial_attempts + WHERE community_id = NEW.community_id + AND audit_event_id = NEW.event_id; + + IF attempt_count <> 1 THEN + RAISE EXCEPTION + 'kind-9 audit event requires exactly one denial attempt, found %', + attempt_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; + END IF; + + -- 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 kind-9 + -- and that exactly one denial attempt references it. + SELECT event_kind, correlation_id, reason_code, semantic_fingerprint + INTO found_event_kind, 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; + + -- 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; + + 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; + 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(); + +-- Proof replay claim ledger. +-- +-- Inserted inside the SERIALIZABLE commit transaction immediately before the +-- operation receipt. The `(community_id, proof_event_id)` primary key enforces +-- single-use proof identity within a community. A duplicate INSERT raises +-- unique_violation (23505); the caller maps this to `AuthorizationDenied` +-- without distinguishing it from any other private-state denial +-- (FI-TRACE-DENIAL-ORACLE). +-- +-- `proof_event_id` is the full 32-byte Nostr event ID of the NIP-42 AUTH or +-- NIP-98 signed event — never UUID-truncated. +CREATE TABLE nip_fi_proof_replay_claims ( + community_id UUID NOT NULL, + proof_event_id BYTEA NOT NULL CHECK (octet_length(proof_event_id) = 32), + retained_until TIMESTAMPTZ NOT NULL, + committed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, proof_event_id) +); + +CREATE TRIGGER nip_fi_proof_replay_claims_immutable + BEFORE UPDATE OR DELETE ON nip_fi_proof_replay_claims + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER nip_fi_proof_replay_claims_no_truncate + BEFORE TRUNCATE ON nip_fi_proof_replay_claims + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1();