diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76398a6502..bd8734fc27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -699,7 +699,7 @@ jobs: -c "CREATE DATABASE buzz_identity_tests" cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ - -E '(package(buzz-db) and test(/identity_binding::tests/)) or (package(buzz-relay) and test(/corporate_identity::tests/))' \ + -E '(package(buzz-db) and test(/identity_binding::tests/)) or (package(buzz-db) and test(/identity_lifecycle::tests|identity_lifecycle::deterministic_tests/)) or (package(buzz-db) and test(/migration::tests::identity_0030_|migration::deterministic_tests::identity_0030_/)) or (package(buzz-relay) and test(/corporate_identity::tests/))' \ --test-threads 1 \ --run-ignored ignored-only env: diff --git a/Cargo.lock b/Cargo.lock index a23390675d..d1b2474692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1003,6 +1003,7 @@ dependencies = [ name = "buzz-db" version = "0.1.0" dependencies = [ + "buzz-auth", "buzz-core", "chrono", "hex", diff --git a/crates/buzz-db/Cargo.toml b/crates/buzz-db/Cargo.toml index 6f76a11bc1..38e512bb42 100644 --- a/crates/buzz-db/Cargo.toml +++ b/crates/buzz-db/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true description = "Postgres event store and data access layer for Buzz" [dependencies] +buzz-auth = { workspace = true } buzz-core = { workspace = true } sqlx = { workspace = true } tokio = { workspace = true } diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index fbc842764e..13fe052805 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -67,7 +67,7 @@ pub struct ChannelRecord { } /// A channel membership row as returned from the database. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct MemberRecord { /// The channel this membership belongs to. pub channel_id: Uuid, @@ -400,7 +400,7 @@ pub async fn add_member( } /// Outcome of atomically adding a channel member and binding corporate identity. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ChannelAdmissionOutcome { /// Membership and any staged identity binding committed together. Joined { @@ -413,6 +413,9 @@ pub enum ChannelAdmissionOutcome { IdentityConflict(IdentityBindingConflict), /// The staged identity principal or key is revoked. IdentityRevoked, + /// The staged identity has no active binding and lacks sealed enrollment + /// evidence. + IdentityBindingRequired, } /// Add a channel member and optional corporate identity binding in one transaction. @@ -459,6 +462,10 @@ pub async fn add_member_with_identity( tx.rollback().await?; return Ok(ChannelAdmissionOutcome::IdentityRevoked); } + Ok(BindIdentityResult::BindingRequired) => { + tx.rollback().await?; + return Ok(ChannelAdmissionOutcome::IdentityBindingRequired); + } Err(error) => { tx.rollback().await?; return Err(error); @@ -1806,14 +1813,20 @@ mod tests { ) .await .expect("create private huddle"); - crate::identity_binding::bind_or_validate_identity( + crate::identity_binding::resolve_identity_binding( &pool, - community, - "https://idp.example", - "conflicting-principal", - &bound_key, - Some("bound@example.com"), - crate::identity_binding::SOURCE_JWT_NPUB, + &crate::identity_binding::ResolveBindingInput { + authorization_domain: community, + issuer: "https://idp.example", + subject: "conflicting-principal", + pubkey: &bound_key, + display_name: Some("bound@example.com"), + enrollment_mode: crate::identity_binding::EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "channel-test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, ) .await .expect("seed conflicting binding"); @@ -1843,7 +1856,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn atomic_huddle_admission_identity_storage_failure_rolls_back_membership() { + async fn atomic_huddle_admission_raw_identity_cannot_enroll_and_rolls_back_membership() { let pool = setup_pool().await; let community_id = make_test_community(&pool).await; let community = CommunityId::from_uuid(community_id); @@ -1852,7 +1865,7 @@ mod tests { let channel = create_test_channel( &pool, community_id, - "atomic-identity-storage-failure", + "atomic-identity-binding-required", ChannelType::Stream, ChannelVisibility::Private, None, @@ -1861,28 +1874,9 @@ mod tests { ) .await .expect("create private huddle"); - let suffix = community_id.simple(); - let function_name = format!("buzz_test_fail_identity_{suffix}"); - let trigger_name = format!("buzz_test_fail_identity_insert_{suffix}"); - // Identifiers and the literal UUID below are derived only from a generated UUID. - sqlx::query(sqlx::AssertSqlSafe(format!( - "CREATE FUNCTION {function_name}() RETURNS trigger LANGUAGE plpgsql AS $$ \ - BEGIN RAISE EXCEPTION 'injected identity storage failure'; END $$" - ))) - .execute(&pool) - .await - .expect("create failure function"); - sqlx::query(sqlx::AssertSqlSafe(format!( - "CREATE TRIGGER {trigger_name} BEFORE INSERT ON identity_bindings \ - FOR EACH ROW WHEN (NEW.community_id = '{community_id}'::uuid) \ - EXECUTE FUNCTION {function_name}()" - ))) - .execute(&pool) - .await - .expect("create failure trigger"); - let identity = identity_for(&joiner, "storage-failure"); + let identity = identity_for(&joiner, "binding-required"); - let result = add_member_with_identity( + let outcome = add_member_with_identity( &pool, community, channel.id, @@ -1891,22 +1885,10 @@ mod tests { Some(&owner), Some(&identity), ) - .await; - - sqlx::query(sqlx::AssertSqlSafe(format!( - "DROP TRIGGER {trigger_name} ON identity_bindings" - ))) - .execute(&pool) - .await - .expect("drop failure trigger"); - sqlx::query(sqlx::AssertSqlSafe(format!( - "DROP FUNCTION {function_name}()" - ))) - .execute(&pool) .await - .expect("drop failure function"); + .expect("typed binding-required outcome"); - assert!(matches!(result, Err(DbError::Sqlx(_))), "{result:?}"); + assert_eq!(outcome, ChannelAdmissionOutcome::IdentityBindingRequired); assert_eq!( active_membership_count(&pool, community, channel.id, &joiner).await, 0 @@ -1943,6 +1925,23 @@ mod tests { .await .expect("create private huddle"); let identity = identity_for(&joiner, "successful-principal"); + crate::identity_binding::resolve_identity_binding( + &pool, + &crate::identity_binding::ResolveBindingInput { + authorization_domain: community, + issuer: identity.issuer, + subject: identity.uid, + pubkey: identity.pubkey, + display_name: identity.display_name, + enrollment_mode: crate::identity_binding::EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "channel-test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("seed verified binding"); let first = add_member_with_identity( &pool, @@ -1958,7 +1957,7 @@ mod tests { assert!(matches!( first, ChannelAdmissionOutcome::Joined { - identity_binding: Some(BindIdentityResult::Created), + identity_binding: Some(BindIdentityResult::Matched), .. } )); @@ -1987,7 +1986,8 @@ mod tests { ); let binding_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM identity_bindings \ - WHERE community_id = $1 AND pubkey = $2 AND revoked_at IS NULL", + WHERE community_id = $1 AND pubkey = $2 \ + AND binding_state = 'active' AND revoked_at IS NULL", ) .bind(community.as_uuid()) .bind(&joiner) diff --git a/crates/buzz-db/src/identity_binding.rs b/crates/buzz-db/src/identity_binding.rs index 8b6515bde2..14d78c6152 100644 --- a/crates/buzz-db/src/identity_binding.rs +++ b/crates/buzz-db/src/identity_binding.rs @@ -7,26 +7,687 @@ //! single-key revocation, and authorized rotation; authentication never //! silently rewrites those states. +use std::fmt; + use chrono::{DateTime, Utc}; use sqlx::{PgPool, Postgres, Row, Transaction}; +use uuid::Uuid; use crate::error::{DbError, Result}; +use buzz_auth::context::{ + AuthoritativeBindingResolution, BindingExpiry as AuthorizedBindingExpiry, +}; +use buzz_auth::{ + AuthorityAdapterError, AuthorityAdapterFuture, BindingResolutionRequest, + BindingSource as AuthorizedBindingSource, BindingVersion as AuthorizedBindingVersion, + CurrentPolicyRequest, CurrentPolicyResolutionSink, DirectBindingResolutionSink, + EnrollmentMode as AuthorizedEnrollmentMode, ExistingBindingResolutionSink, + FederatedAuthorityAdapter, FederatedIdentityRequirement, ResolvedFederatedPolicy, +}; use buzz_core::CommunityId; +#[cfg(test)] +pub(crate) mod test_lock_schedule { + use std::cell::Cell; + use std::future::Future; + use std::sync::{Mutex, OnceLock}; + + use sqlx::{Postgres, Transaction}; + use tokio::sync::{mpsc, oneshot}; + + tokio::task_local! { + static ACTOR: &'static str; + static ROW_REQUEST_REPORTED: Cell; + static ROW_ACQUIRED_REPORTED: Cell; + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum LockPhase { + Request, + Acquired, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum RowLockPhase { + Request, + Acquired, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) struct AdvisoryLockKey { + class_id: u32, + object_id: u32, + } + + impl AdvisoryLockKey { + pub(crate) const fn class_id(self) -> u32 { + self.class_id + } + + pub(crate) const fn object_id(self) -> u32 { + self.object_id + } + } + + pub(crate) struct LockEvent { + actor: &'static str, + phase: LockPhase, + isolation: Option, + transaction_id: Option, + backend_pid: i32, + database_oid: u32, + lock_keys: Vec, + resume: oneshot::Sender<()>, + } + + impl LockEvent { + pub(crate) const fn actor(&self) -> &'static str { + self.actor + } + + pub(crate) const fn phase(&self) -> LockPhase { + self.phase + } + + pub(crate) fn isolation(&self) -> Option<&str> { + self.isolation.as_deref() + } + + pub(crate) const fn transaction_id(&self) -> Option { + self.transaction_id + } + + pub(crate) const fn backend_pid(&self) -> i32 { + self.backend_pid + } + + pub(crate) const fn database_oid(&self) -> u32 { + self.database_oid + } + + pub(crate) fn lock_keys(&self) -> &[AdvisoryLockKey] { + &self.lock_keys + } + + pub(crate) const fn coordinate_count(&self) -> usize { + self.lock_keys.len() + } + + pub(crate) fn resume(self) { + let _ = self.resume.send(()); + } + } + + fn controller() -> &'static Mutex>> { + static CONTROLLER: OnceLock>>> = + OnceLock::new(); + CONTROLLER.get_or_init(|| Mutex::new(None)) + } + + pub(crate) struct ControllerGuard; + + impl Drop for ControllerGuard { + fn drop(&mut self) { + *controller().lock().expect("lock test controller") = None; + } + } + + pub(crate) fn install() -> (mpsc::UnboundedReceiver, ControllerGuard) { + let (sender, receiver) = mpsc::unbounded_channel(); + let mut current = controller().lock().expect("lock test controller"); + assert!( + current.is_none(), + "only one deterministic lock controller may be active" + ); + *current = Some(sender); + (receiver, ControllerGuard) + } + + pub(crate) struct RowLockEvent { + actor: &'static str, + phase: RowLockPhase, + transaction_id: i64, + backend_pid: i32, + database_oid: u32, + resume: oneshot::Sender<()>, + } + + impl RowLockEvent { + pub(crate) const fn actor(&self) -> &'static str { + self.actor + } + + pub(crate) const fn phase(&self) -> RowLockPhase { + self.phase + } + + pub(crate) const fn transaction_id(&self) -> i64 { + self.transaction_id + } + + pub(crate) const fn backend_pid(&self) -> i32 { + self.backend_pid + } + + pub(crate) const fn database_oid(&self) -> u32 { + self.database_oid + } + + pub(crate) fn resume(self) { + let _ = self.resume.send(()); + } + } + + fn row_controller() -> &'static Mutex>> { + static CONTROLLER: OnceLock>>> = + OnceLock::new(); + CONTROLLER.get_or_init(|| Mutex::new(None)) + } + + pub(crate) struct RowControllerGuard; + + impl Drop for RowControllerGuard { + fn drop(&mut self) { + *row_controller().lock().expect("lock row test controller") = None; + } + } + + pub(crate) fn install_row() -> (mpsc::UnboundedReceiver, RowControllerGuard) { + let (sender, receiver) = mpsc::unbounded_channel(); + let mut current = row_controller().lock().expect("lock row test controller"); + assert!( + current.is_none(), + "only one deterministic row-lock controller may be active" + ); + *current = Some(sender); + (receiver, RowControllerGuard) + } + + pub(crate) async fn actor_scope(actor: &'static str, future: F) -> F::Output + where + F: Future, + { + ACTOR + .scope( + actor, + ROW_REQUEST_REPORTED.scope( + Cell::new(false), + ROW_ACQUIRED_REPORTED.scope(Cell::new(false), future), + ), + ) + .await + } + + pub(super) async fn checkpoint( + tx: &mut Transaction<'_, Postgres>, + phase: LockPhase, + coordinates: &[Vec], + ) { + let Ok(actor) = ACTOR.try_with(|actor| *actor) else { + return; + }; + let sender = controller().lock().expect("lock test controller").clone(); + let Some(sender) = sender else { + return; + }; + let (backend_pid, database_oid): (i32, i64) = sqlx::query_as( + "SELECT pg_backend_pid(), oid::BIGINT \ + FROM pg_database WHERE datname=current_database()", + ) + .fetch_one(&mut **tx) + .await + .expect("read test lock backend identity"); + let class_id: i32 = sqlx::query_scalar("SELECT hashtext('buzz_nip_fi_v1')") + .fetch_one(&mut **tx) + .await + .expect("hash test lock namespace"); + let mut lock_keys = Vec::with_capacity(coordinates.len()); + for coordinate in coordinates { + let object_id: i32 = sqlx::query_scalar("SELECT hashtext(encode($1, 'hex'))") + .bind(coordinate.as_slice()) + .fetch_one(&mut **tx) + .await + .expect("hash test lock coordinate"); + lock_keys.push(AdvisoryLockKey { + class_id: class_id as u32, + object_id: object_id as u32, + }); + } + let (isolation, transaction_id) = if phase == LockPhase::Acquired { + let isolation = sqlx::query_scalar("SHOW transaction_isolation") + .fetch_one(&mut **tx) + .await + .ok(); + let transaction_id = sqlx::query_scalar("SELECT txid_current()::BIGINT") + .fetch_one(&mut **tx) + .await + .ok(); + (isolation, transaction_id) + } else { + (None, None) + }; + let (resume, resumed) = oneshot::channel(); + if sender + .send(LockEvent { + actor, + phase, + isolation, + transaction_id, + backend_pid, + database_oid: u32::try_from(database_oid) + .expect("database OID fits the PostgreSQL OID type"), + lock_keys, + resume, + }) + .is_ok() + { + let _ = resumed.await; + } + } + + pub(crate) async fn row_checkpoint(tx: &mut Transaction<'_, Postgres>, phase: RowLockPhase) { + let Ok(actor) = ACTOR.try_with(|actor| *actor) else { + return; + }; + let should_report = match phase { + RowLockPhase::Request => ROW_REQUEST_REPORTED + .try_with(|reported| !reported.replace(true)) + .unwrap_or(false), + RowLockPhase::Acquired => ROW_ACQUIRED_REPORTED + .try_with(|reported| !reported.replace(true)) + .unwrap_or(false), + }; + if !should_report { + return; + } + let sender = row_controller() + .lock() + .expect("lock row test controller") + .clone(); + let Some(sender) = sender else { + return; + }; + let (backend_pid, database_oid, transaction_id): (i32, i64, i64) = sqlx::query_as( + "SELECT pg_backend_pid(), oid::BIGINT, txid_current()::BIGINT \ + FROM pg_database WHERE datname=current_database()", + ) + .fetch_one(&mut **tx) + .await + .expect("read test row-lock backend identity"); + let (resume, resumed) = oneshot::channel(); + if sender + .send(RowLockEvent { + actor, + phase, + transaction_id, + backend_pid, + database_oid: u32::try_from(database_oid) + .expect("database OID fits the PostgreSQL OID type"), + resume, + }) + .is_ok() + { + let _ = resumed.await; + } + } +} + /// Binding source when the IdP JWT carries the pubkey claim. pub const SOURCE_JWT_NPUB: &str = "jwt_npub"; /// Binding source when the relay falls back to the stored uid/pubkey binding. pub const SOURCE_DB_BINDING: &str = "db_binding"; +/// PostgreSQL-backed trust root for current enrollment policy and binding state. +/// +/// The adapter owns no request-selectable configuration. The application constructs one +/// long-lived instance from the writer pool at application startup and injects +/// that instance into the single authorization runtime. +#[derive(Clone)] +pub struct PostgresFederatedAuthorityAdapter { + pool: PgPool, +} + +impl PostgresFederatedAuthorityAdapter { + /// Bind the authority adapter to the authoritative writer pool. + pub const fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +/// Server-resolved first-enrollment policy for one authorization domain. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum EnrollmentMode { + /// Require verified issuer evidence that attests the proven key. + AttestedKey, + /// Require an out-of-band provisioned binding. + Provisioned, + /// Allow trust on first use. + Tofu, +} + +impl fmt::Debug for EnrollmentMode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("EnrollmentMode") + .field(&"[redacted]") + .finish() + } +} + +/// Provider-neutral persisted binding provenance. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum BindingProvenance { + /// Verified issuer evidence attested the key. + AttestedKey, + /// The binding was provisioned out of band. + Provisioned, + /// The binding was established by trust on first use. + Tofu, +} + +impl BindingProvenance { + /// Stable persistence label. + pub const fn as_str(self) -> &'static str { + match self { + Self::AttestedKey => "attested_key", + Self::Provisioned => "provisioned", + Self::Tofu => "tofu", + } + } + + pub(crate) fn legacy_source(self) -> &'static str { + match self { + Self::AttestedKey => SOURCE_JWT_NPUB, + Self::Provisioned | Self::Tofu => SOURCE_DB_BINDING, + } + } + + pub(crate) fn parse(value: &str) -> Result { + match value { + "attested_key" => Ok(Self::AttestedKey), + "provisioned" => Ok(Self::Provisioned), + "tofu" => Ok(Self::Tofu), + _ => Err(DbError::InvalidData( + "identity binding has invalid provenance".to_string(), + )), + } + } +} + +impl fmt::Debug for BindingProvenance { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("BindingProvenance") + .field(&"[redacted]") + .finish() + } +} + +/// Explicit persisted binding lifecycle state. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum BindingState { + /// The binding currently carries authority. + Active, + /// The binding was retired without an atomic replacement. + Revoked, + /// The binding was atomically replaced. + Rotated, + /// The inactive binding was archived with explicit attribution. + Archived, +} + +impl BindingState { + fn parse(value: &str) -> Result { + match value { + "active" => Ok(Self::Active), + "revoked" => Ok(Self::Revoked), + "rotated" => Ok(Self::Rotated), + "archived" => Ok(Self::Archived), + _ => Err(DbError::InvalidData( + "identity binding has invalid lifecycle state".to_string(), + )), + } + } +} + +/// Truthful provenance for immutable binding-creation attribution. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum CreationAttributionKind { + /// The legacy row predates trustworthy actor and policy attribution. + LegacyUnknown, + /// A directly authenticated key created the binding under verified policy. + AuthenticatedKey, + /// A verified operator lifecycle action created the binding. + Operator, +} + +impl CreationAttributionKind { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::LegacyUnknown => "legacy_unknown", + Self::AuthenticatedKey => "authenticated_key", + Self::Operator => "operator", + } + } + + fn parse(value: &str) -> Result { + match value { + "legacy_unknown" => Ok(Self::LegacyUnknown), + "authenticated_key" => Ok(Self::AuthenticatedKey), + "operator" => Ok(Self::Operator), + _ => Err(DbError::InvalidData( + "identity binding has invalid creation attribution".to_string(), + )), + } + } +} + +impl fmt::Debug for CreationAttributionKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("CreationAttributionKind") + .field(&"[redacted]") + .finish() + } +} + +impl fmt::Debug for BindingState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("BindingState") + .field(&"[redacted]") + .finish() + } +} + +/// Stable evidence returned by authoritative binding resolution. +#[derive(PartialEq, Eq)] +pub struct BindingEvidence { + pub(crate) authorization_domain: CommunityId, + pub(crate) issuer: String, + pub(crate) subject: String, + pub(crate) bound_pubkey: Vec, + pub(crate) binding_id: Uuid, + pub(crate) binding_version: u64, + pub(crate) binding_state: BindingState, + pub(crate) provenance: BindingProvenance, + pub(crate) creation_attribution: CreationAttributionKind, + pub(crate) created_by: Option>, + pub(crate) created_policy_version: Option, + pub(crate) expires_at: Option>, + pub(crate) created_at: DateTime, +} + +impl BindingEvidence { + /// Authorization domain whose active binding was resolved. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact private issuer. Callers must not disclose it publicly. + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// Exact private subject. Callers must not disclose it publicly. + pub fn subject(&self) -> &str { + &self.subject + } + + /// Exact bound key. + pub fn bound_pubkey(&self) -> &[u8] { + &self.bound_pubkey + } + + /// Stable non-nil binding identifier. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Positive version local to the stable binding identifier. + pub const fn binding_version(&self) -> u64 { + self.binding_version + } + + /// Explicit authoritative lifecycle state. + pub const fn binding_state(&self) -> BindingState { + self.binding_state + } + + /// Persisted provider-neutral provenance. + pub const fn provenance(&self) -> BindingProvenance { + self.provenance + } + + /// Truthful creation-attribution classification. + pub const fn creation_attribution(&self) -> CreationAttributionKind { + self.creation_attribution + } + + /// Verified creation actor, absent only for explicitly unknown legacy rows. + pub fn created_by(&self) -> Option<&[u8]> { + self.created_by.as_deref() + } + + /// Exact creation policy, absent only for explicitly unknown legacy rows. + pub fn created_policy_version(&self) -> Option<&str> { + self.created_policy_version.as_deref() + } + + /// Optional authoritative binding-expiry boundary. + pub const fn expires_at(&self) -> Option<&DateTime> { + self.expires_at.as_ref() + } + + /// Immutable creation timestamp persisted by PostgreSQL. + pub const fn created_at(&self) -> &DateTime { + &self.created_at + } +} + +impl fmt::Debug for BindingEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BindingEvidence") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("binding_state", &"[redacted]") + .field("provenance", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("principal", &"[redacted]") + .field("bound_pubkey", &"[redacted]") + .field("creation_attribution", &"[redacted]") + .field("expires_at", &"[redacted]") + .finish() + } +} + +/// Stable denial from ordinary binding resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingDenial { + /// Another active principal or key owns the requested coordinate. + Conflict, + /// A lifecycle selector or migration quarantine denies authority. + Revoked, + /// The domain requires an out-of-band binding. + BindingRequired, + /// Attested enrollment lacked a verified matching key claim. + KeyAttestationRequired, + /// The exact active binding remains slot-occupying but is no longer + /// authorization-eligible at database time. + BindingExpired, + /// Authorization or assertion evidence was no longer current at the + /// database mutation boundary. + StaleEvidence, +} + +/// Result of resolving a binding during ordinary authorization. +#[derive(Debug, PartialEq, Eq)] +pub enum ResolveBindingResult { + /// A new authoritative binding was atomically enrolled. + Enrolled(BindingEvidence), + /// The exact authoritative binding already existed. + Existing(BindingEvidence), + /// Resolution denied without authority mutation. + Denied(BindingDenial), +} + +/// Typed input to ordinary binding resolution. +pub(crate) struct ResolveBindingInput<'a> { + pub(crate) authorization_domain: CommunityId, + pub(crate) issuer: &'a str, + pub(crate) subject: &'a str, + pub(crate) pubkey: &'a [u8], + pub(crate) display_name: Option<&'a str>, + pub(crate) enrollment_mode: EnrollmentMode, + pub(crate) key_attested: bool, + pub(crate) policy_version: &'a str, + pub(crate) evidence_valid_from: u64, + pub(crate) evidence_valid_until: u64, +} + +impl fmt::Debug for ResolveBindingInput<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolveBindingInput") + .field("issuer", &"[redacted]") + .field("subject", &"[redacted]") + .field("pubkey", &"[redacted]") + .field("display_name", &"[redacted]") + .field("enrollment_mode", &"[redacted]") + .field("key_attested", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("evidence_valid_from", &"[redacted]") + .field("evidence_valid_until", &"[redacted]") + .finish() + } +} + /// Active corporate identity binding row. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct IdentityBinding { + /// Stable non-nil binding identifier. + pub binding_id: Uuid, /// Validated identity-provider issuer. pub issuer: String, /// Corporate IdP subject or configured stable uid claim. pub uid: String, /// Bound Nostr pubkey bytes. pub pubkey: Vec, + /// Positive authorization-relevant version. + pub binding_version: u64, + /// Explicit lifecycle state. + pub binding_state: BindingState, + /// Provider-neutral provenance. + pub binding_provenance: BindingProvenance, + /// Truthful creation-attribution classification. + pub creation_attribution: CreationAttributionKind, + /// Verified creation actor, absent only for legacy-unknown attribution. + pub created_by: Option>, + /// Verified creation policy, absent only for legacy-unknown attribution. + pub created_policy_version: Option, + /// Optional authoritative authorization-eligibility expiry. + pub expires_at: Option>, /// Human-readable display claim captured from the latest accepted JWT. pub display_name: Option, /// Source that established or last strengthened the active binding. @@ -39,17 +700,35 @@ pub struct IdentityBinding { pub last_seen_at: DateTime, } -/// Existing active binding that conflicts with a requested binding. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IdentityBindingConflict { - /// Existing active issuer. - pub issuer: String, - /// Existing active uid. - pub uid: String, - /// Existing active pubkey bytes. - pub pubkey: Vec, - /// Existing active binding source. - pub source: String, +impl fmt::Debug for IdentityBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("IdentityBinding") + .field("binding_id", &"[redacted]") + .field("issuer", &"[redacted]") + .field("uid", &"[redacted]") + .field("pubkey", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("binding_state", &"[redacted]") + .field("binding_provenance", &"[redacted]") + .field("display_name", &"[redacted]") + .field("source", &"[redacted]") + .field("timestamps", &"[redacted]") + .finish() + } +} + +/// Party-data-free marker for an active binding conflict. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct IdentityBindingConflict; + +impl fmt::Debug for IdentityBindingConflict { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("IdentityBindingConflict") + .field(&"[redacted]") + .finish() + } } /// Outcome of creating or validating a corporate identity binding. @@ -63,10 +742,13 @@ pub enum BindIdentityResult { Conflict(IdentityBindingConflict), /// The requested uid/pubkey pair was previously revoked. Revoked, + /// No active binding exists; sealed authorized-domain evidence is required + /// before first enrollment. + BindingRequired, } /// Corporate identity data staged for an atomic admission transaction. -#[derive(Debug, Clone, Copy)] +#[derive(Clone, Copy)] pub struct IdentityBindingInput<'a> { /// Validated identity-provider issuer. pub issuer: &'a str, @@ -76,26 +758,40 @@ pub struct IdentityBindingInput<'a> { pub pubkey: &'a [u8], /// Private display attribute retained in the binding table. pub display_name: Option<&'a str>, - /// Binding source (`jwt_npub` or `db_binding`). + /// Legacy verifier source (`jwt_npub` or `db_binding`). This compatibility + /// field never grants attested provenance; only sealed authorization evidence can. pub source: &'a str, } +impl fmt::Debug for IdentityBindingInput<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("IdentityBindingInput") + .field("issuer", &"[redacted]") + .field("uid", &"[redacted]") + .field("pubkey", &"[redacted]") + .field("display_name", &"[redacted]") + .field("source", &"[redacted]") + .finish() + } +} + fn validate_inputs(issuer: &str, uid: &str, pubkey: &[u8], source: &str) -> Result<()> { - if issuer.trim().is_empty() { + if issuer.is_empty() { return Err(DbError::InvalidData( "identity binding issuer must not be empty".to_string(), )); } - if uid.trim().is_empty() { + if uid.is_empty() { return Err(DbError::InvalidData( "identity binding uid must not be empty".to_string(), )); } validate_pubkey(pubkey)?; if !matches!(source, SOURCE_JWT_NPUB | SOURCE_DB_BINDING) { - return Err(DbError::InvalidData(format!( - "invalid identity binding source: {source}" - ))); + return Err(DbError::InvalidData( + "invalid identity binding source".to_string(), + )); } Ok(()) } @@ -127,10 +823,47 @@ pub(crate) fn validate_membership_identity_key( } fn row_to_binding(row: sqlx::postgres::PgRow) -> Result { + let binding_id: Uuid = row.try_get("binding_id")?; + let binding_version: i64 = row.try_get("binding_version")?; + if binding_id.is_nil() || binding_version <= 0 { + return Err(DbError::InvalidData( + "identity binding has invalid stable evidence".to_string(), + )); + } + let creation_attribution = + CreationAttributionKind::parse(row.try_get("creation_attribution_kind")?)?; + let created_by: Option> = row.try_get("created_by")?; + let created_policy_version: Option = row.try_get("created_policy_version")?; + let attribution_is_complete = match creation_attribution { + CreationAttributionKind::LegacyUnknown => { + created_by.is_none() && created_policy_version.is_none() + } + CreationAttributionKind::AuthenticatedKey | CreationAttributionKind::Operator => { + created_by.as_ref().is_some_and(|actor| actor.len() == 32) + && created_policy_version + .as_ref() + .is_some_and(|version| !version.is_empty()) + } + }; + if !attribution_is_complete { + return Err(DbError::InvalidData( + "identity binding has incomplete creation attribution".to_string(), + )); + } Ok(IdentityBinding { + binding_id, issuer: row.try_get("issuer")?, uid: row.try_get("uid")?, pubkey: row.try_get("pubkey")?, + binding_version: u64::try_from(binding_version).map_err(|_| { + DbError::InvalidData("identity binding version is out of range".to_string()) + })?, + binding_state: BindingState::parse(row.try_get("binding_state")?)?, + binding_provenance: BindingProvenance::parse(row.try_get("binding_provenance")?)?, + creation_attribution, + created_by, + created_policy_version, + expires_at: row.try_get("expires_at")?, display_name: row.try_get("display_name")?, source: row.try_get("source")?, created_at: row.try_get("created_at")?, @@ -139,6 +872,29 @@ fn row_to_binding(row: sqlx::postgres::PgRow) -> Result { }) } +fn evidence_from_binding( + authorization_domain: CommunityId, + binding: &IdentityBinding, + binding_version: u64, + provenance: BindingProvenance, +) -> BindingEvidence { + BindingEvidence { + authorization_domain, + issuer: binding.issuer.clone(), + subject: binding.uid.clone(), + bound_pubkey: binding.pubkey.clone(), + binding_id: binding.binding_id, + binding_version, + binding_state: BindingState::Active, + provenance, + creation_attribution: binding.creation_attribution, + created_by: binding.created_by.clone(), + created_policy_version: binding.created_policy_version.clone(), + expires_at: binding.expires_at, + created_at: binding.created_at.to_owned(), + } +} + async fn active_by_principal_tx( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, @@ -147,9 +903,13 @@ async fn active_by_principal_tx( ) -> Result> { let row = sqlx::query( r#" - SELECT issuer, uid, pubkey, display_name, source, created_at, updated_at, last_seen_at + SELECT binding_id, issuer, uid, pubkey, binding_version, binding_state, + binding_provenance, creation_attribution_kind, created_by, + created_policy_version, expires_at, display_name, source, + created_at, updated_at, last_seen_at FROM identity_bindings - WHERE community_id = $1 AND issuer = $2 AND uid = $3 AND revoked_at IS NULL + WHERE community_id = $1 AND issuer = $2 AND uid = $3 + AND binding_state = 'active' AND revoked_at IS NULL FOR UPDATE "#, ) @@ -168,9 +928,13 @@ async fn active_by_pubkey_tx( ) -> Result> { let row = sqlx::query( r#" - SELECT issuer, uid, pubkey, display_name, source, created_at, updated_at, last_seen_at + SELECT binding_id, issuer, uid, pubkey, binding_version, binding_state, + binding_provenance, creation_attribution_kind, created_by, + created_policy_version, expires_at, display_name, source, + created_at, updated_at, last_seen_at FROM identity_bindings - WHERE community_id = $1 AND pubkey = $2 AND revoked_at IS NULL + WHERE community_id = $1 AND pubkey = $2 + AND binding_state = 'active' AND revoked_at IS NULL FOR UPDATE "#, ) @@ -181,34 +945,6 @@ async fn active_by_pubkey_tx( row.map(row_to_binding).transpose() } -async fn revoked_pair_exists_tx( - tx: &mut Transaction<'_, Postgres>, - community_id: CommunityId, - issuer: &str, - uid: &str, - pubkey: &[u8], -) -> Result { - let row = sqlx::query( - r#" - SELECT 1 - FROM identity_bindings - WHERE community_id = $1 - AND issuer = $2 - AND uid = $3 - AND pubkey = $4 - AND revoked_at IS NOT NULL - LIMIT 1 - "#, - ) - .bind(community_id.as_uuid()) - .bind(issuer) - .bind(uid) - .bind(pubkey) - .fetch_optional(&mut **tx) - .await?; - Ok(row.is_some()) -} - async fn principal_disabled_tx( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, @@ -221,9 +957,15 @@ async fn principal_disabled_tx( WHERE community_id = $1 AND issuer = $2 AND uid = $3 AND disabled_at IS NOT NULL UNION ALL - SELECT 1 FROM identity_bindings - WHERE community_id = $1 AND issuer = $2 AND uid = $3 - AND revoked_at IS NOT NULL AND revocation_scope = 'principal' + SELECT 1 FROM identity_bindings legacy + WHERE legacy.community_id = $1 AND legacy.issuer = $2 AND legacy.uid = $3 + AND legacy.revoked_at IS NOT NULL AND legacy.revocation_scope = 'principal' + AND NOT EXISTS ( + SELECT 1 FROM identity_principals current + WHERE current.community_id = legacy.community_id + AND current.issuer = legacy.issuer + AND current.uid = legacy.uid + ) LIMIT 1 "#, ) @@ -244,9 +986,6 @@ async fn key_revoked_tx( r#" SELECT 1 FROM identity_revoked_keys WHERE community_id = $1 AND pubkey = $2 - UNION ALL - SELECT 1 FROM identity_bindings - WHERE community_id = $1 AND pubkey = $2 AND revoked_at IS NOT NULL LIMIT 1 "#, ) @@ -266,13 +1005,11 @@ async fn principal_requires_rotation_tx( let row = sqlx::query( r#" SELECT 1 - FROM identity_bindings + FROM identity_pending_replacements WHERE community_id = $1 AND issuer = $2 - AND uid = $3 - AND revoked_at IS NOT NULL - AND revocation_scope = 'key' - AND rotation_completed_at IS NULL + AND subject = $3 + AND cleared_at IS NULL LIMIT 1 "#, ) @@ -284,27 +1021,121 @@ async fn principal_requires_rotation_tx( Ok(row.is_some()) } -fn conflict_from(binding: IdentityBinding) -> IdentityBindingConflict { - IdentityBindingConflict { - issuer: binding.issuer, - uid: binding.uid, - pubkey: binding.pubkey, - source: binding.source, - } -} - -async fn lock_identity_key_strings_tx( +async fn retired_pair_exists_tx( tx: &mut Transaction<'_, Postgres>, - mut keys: Vec, -) -> Result<()> { - keys.sort(); - keys.dedup(); - for key in keys { - sqlx::query("SELECT pg_advisory_xact_lock(hashtext('identity_bindings'), hashtext($1))") - .bind(key) - .execute(&mut **tx) - .await?; - } + community_id: CommunityId, + issuer: &str, + subject: &str, + pubkey: &[u8], +) -> Result { + Ok(sqlx::query( + "SELECT 1 FROM identity_retired_pairs \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 AND pubkey=$4 \ + UNION ALL \ + SELECT 1 FROM identity_bindings \ + WHERE community_id=$1 AND issuer=$2 AND uid=$3 AND pubkey=$4 \ + AND revoked_at IS NOT NULL \ + LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(subject) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await? + .is_some()) +} + +async fn migration_denied_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + issuer: &str, + subject: &str, +) -> Result { + Ok(sqlx::query( + "SELECT 1 FROM identity_migration_denials \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3", + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(subject) + .fetch_optional(&mut **tx) + .await? + .is_some()) +} + +async fn migration_key_denied_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], +) -> Result { + Ok(sqlx::query( + "SELECT 1 FROM identity_migration_denied_keys \ + WHERE community_id=$1 AND pubkey=$2", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await? + .is_some()) +} + +const IDENTITY_LOCK_ENCODING_VERSION: u8 = 1; + +fn identity_lock_coordinate(kind: u8, community_id: CommunityId, parts: &[&[u8]]) -> Vec { + let mut coordinate = + Vec::with_capacity(2 + 16 + parts.iter().map(|part| 8 + part.len()).sum::()); + coordinate.push(IDENTITY_LOCK_ENCODING_VERSION); + coordinate.push(kind); + coordinate.extend_from_slice(community_id.as_uuid().as_bytes()); + for part in parts { + let length = part.len() as u64; + coordinate.extend_from_slice(&length.to_be_bytes()); + coordinate.extend_from_slice(part); + } + coordinate +} + +pub(crate) fn principal_lock_coordinate( + community_id: CommunityId, + issuer: &str, + subject: &str, +) -> Vec { + identity_lock_coordinate(1, community_id, &[issuer.as_bytes(), subject.as_bytes()]) +} + +pub(crate) fn key_lock_coordinate(community_id: CommunityId, pubkey: &[u8]) -> Vec { + identity_lock_coordinate(2, community_id, &[pubkey]) +} + +pub(crate) fn binding_lock_coordinate(community_id: CommunityId, binding_id: Uuid) -> Vec { + identity_lock_coordinate(3, community_id, &[binding_id.as_bytes()]) +} + +pub(crate) fn operation_lock_coordinate(community_id: CommunityId, operation_id: Uuid) -> Vec { + identity_lock_coordinate(4, community_id, &[operation_id.as_bytes()]) +} + +pub(crate) async fn lock_identity_coordinates_tx( + tx: &mut Transaction<'_, Postgres>, + mut coordinates: Vec>, +) -> Result<()> { + coordinates.sort(); + coordinates.dedup(); + #[cfg(test)] + test_lock_schedule::checkpoint(tx, test_lock_schedule::LockPhase::Request, &coordinates).await; + for coordinate in &coordinates { + sqlx::query( + "SELECT pg_advisory_xact_lock(\ + hashtext('buzz_nip_fi_v1'), hashtext(encode($1, 'hex'))\ + )", + ) + .bind(coordinate.as_slice()) + .execute(&mut **tx) + .await?; + } + #[cfg(test)] + test_lock_schedule::checkpoint(tx, test_lock_schedule::LockPhase::Acquired, &coordinates).await; Ok(()) } @@ -315,25 +1146,26 @@ async fn lock_identity_keys_tx( uid: &str, pubkey: &[u8], ) -> Result<()> { - lock_identity_key_strings_tx( + lock_identity_coordinates_tx( tx, vec![ - format!("{}:principal:{issuer}:{uid}", community_id.as_uuid()), - format!("{}:pubkey:{}", community_id.as_uuid(), hex::encode(pubkey)), + principal_lock_coordinate(community_id, issuer, uid), + key_lock_coordinate(community_id, pubkey), ], ) .await } -/// Create or validate an active corporate identity binding. +/// Validate an existing active corporate identity binding. /// -/// This is a fail-closed auth-time operation: -/// - same issuer + uid + pubkey updates display/last_seen and succeeds; +/// This compatibility operation cannot enroll or strengthen a binding because +/// it does not carry sealed authorization evidence: +/// - same issuer + uid + pubkey succeeds without mutating binding evidence; /// - same issuer + uid with a different pubkey conflicts; /// - same pubkey with a different issuer-qualified principal conflicts; /// - principal disablement and unresolved key revocation reject every key; /// - a previously revoked issuer/uid/pubkey tuple remains revoked; -/// - no active row creates a new binding. +/// - no active row returns [`BindIdentityResult::BindingRequired`]. pub async fn bind_or_validate_identity( pool: &PgPool, community_id: CommunityId, @@ -360,7 +1192,7 @@ pub async fn bind_or_validate_identity( Ok(result) } -/// Create or validate a binding inside a caller-owned admission transaction. +/// Validate an existing binding inside a caller-owned admission transaction. pub(crate) async fn bind_or_validate_identity_tx( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, @@ -370,86 +1202,742 @@ pub(crate) async fn bind_or_validate_identity_tx( issuer, uid, pubkey, - display_name, + display_name: _, source, } = *identity; validate_inputs(issuer, uid, pubkey, source)?; - sqlx::query("SET LOCAL lock_timeout = '3s'") .execute(&mut **tx) .await?; lock_identity_keys_tx(tx, community_id, issuer, uid, pubkey).await?; - if principal_disabled_tx(tx, community_id, issuer, uid).await? { + let denied = migration_denied_tx(tx, community_id, issuer, uid).await? + || migration_key_denied_tx(tx, community_id, pubkey).await? + || principal_disabled_tx(tx, community_id, issuer, uid).await? + || key_revoked_tx(tx, community_id, pubkey).await? + || principal_requires_rotation_tx(tx, community_id, issuer, uid).await? + || retired_pair_exists_tx(tx, community_id, issuer, uid, pubkey).await?; + if denied { return Ok(BindIdentityResult::Revoked); } - if key_revoked_tx(tx, community_id, pubkey).await? { - return Ok(BindIdentityResult::Revoked); + + let active_principal = active_by_principal_tx(tx, community_id, issuer, uid).await?; + if active_principal + .as_ref() + .is_some_and(|binding| binding.pubkey != pubkey) + { + return Ok(BindIdentityResult::Conflict(IdentityBindingConflict)); } - if principal_requires_rotation_tx(tx, community_id, issuer, uid).await? { - return Ok(BindIdentityResult::Revoked); + let active_key = active_by_pubkey_tx(tx, community_id, pubkey).await?; + if active_key + .as_ref() + .is_some_and(|binding| binding.issuer != issuer || binding.uid != uid) + { + return Ok(BindIdentityResult::Conflict(IdentityBindingConflict)); } - let active_principal = active_by_principal_tx(tx, community_id, issuer, uid).await?; - if let Some(binding) = active_principal { - if binding.pubkey != pubkey { - return Ok(BindIdentityResult::Conflict(conflict_from(binding))); + Ok(if active_principal.is_some() { + BindIdentityResult::Matched + } else { + BindIdentityResult::BindingRequired + }) +} + +#[derive(Clone, Copy)] +struct AuthoritativeBindingRequestView<'a> { + authorization_domain: CommunityId, + issuer: &'a str, + subject: &'a str, + pubkey: [u8; 32], + policy_id: Uuid, + policy_epoch: u64, + policy_requirement: FederatedIdentityRequirement, + key_attested: bool, + effective_from: u64, + effective_until: u64, +} + +impl<'a> AuthoritativeBindingRequestView<'a> { + fn from_sealed(request: &'a BindingResolutionRequest) -> Self { + Self { + authorization_domain: request.authorization_domain(), + issuer: request.principal().issuer(), + subject: request.principal().subject(), + pubkey: request.bound_pubkey().to_bytes(), + policy_id: request.policy_id(), + policy_epoch: request.policy_epoch(), + policy_requirement: request.policy_requirement(), + key_attested: request.key_attested(), + effective_from: request.effective_from(), + effective_until: request.effective_until(), } + } +} - sqlx::query( +#[derive(Clone, Copy)] +struct CurrentEnrollmentPolicy { + policy_id: Uuid, + policy_epoch: u64, + requirement: FederatedIdentityRequirement, + effective_from: u64, + effective_until: u64, +} + +impl CurrentEnrollmentPolicy { + fn from_row(row: sqlx::postgres::PgRow) -> Result { + let policy_id: Uuid = row.try_get("policy_id")?; + let policy_epoch: i64 = row.try_get("policy_epoch")?; + let effective_from: i64 = row.try_get("effective_from")?; + let effective_until: i64 = row.try_get("effective_until")?; + if policy_id.is_nil() + || policy_epoch <= 0 + || effective_from < 0 + || effective_from >= effective_until + { + return Err(DbError::InvalidData( + "identity enrollment policy has invalid authoritative state".to_string(), + )); + } + Ok(Self { + policy_id, + policy_epoch: u64::try_from(policy_epoch).map_err(|_| { + DbError::InvalidData("identity enrollment policy epoch is out of range".to_string()) + })?, + requirement: policy_requirement(row.try_get("requirement")?)?, + effective_from: u64::try_from(effective_from).map_err(|_| { + DbError::InvalidData( + "identity enrollment policy lower bound is out of range".to_string(), + ) + })?, + effective_until: u64::try_from(effective_until).map_err(|_| { + DbError::InvalidData( + "identity enrollment policy upper bound is out of range".to_string(), + ) + })?, + }) + } + + fn matches_binding_request(&self, request: &AuthoritativeBindingRequestView<'_>) -> bool { + self.policy_id == request.policy_id + && self.policy_epoch == request.policy_epoch + && self.requirement == request.policy_requirement + && request.effective_from >= self.effective_from + && request.effective_until <= self.effective_until + && request.effective_from < request.effective_until + } +} + +fn policy_requirement(value: &str) -> Result { + match value { + "not_required" => Ok(FederatedIdentityRequirement::NotRequired), + "attested_key" => Ok(FederatedIdentityRequirement::Required( + AuthorizedEnrollmentMode::AttestedKey, + )), + "provisioned" => Ok(FederatedIdentityRequirement::Required( + AuthorizedEnrollmentMode::Provisioned, + )), + "tofu" => Ok(FederatedIdentityRequirement::Required( + AuthorizedEnrollmentMode::Tofu, + )), + _ => Err(DbError::InvalidData( + "identity enrollment policy has invalid requirement".to_string(), + )), + } +} + +fn storage_enrollment_mode( + requirement: FederatedIdentityRequirement, +) -> std::result::Result { + match requirement { + FederatedIdentityRequirement::Required(AuthorizedEnrollmentMode::AttestedKey) => { + Ok(EnrollmentMode::AttestedKey) + } + FederatedIdentityRequirement::Required(AuthorizedEnrollmentMode::Provisioned) => { + Ok(EnrollmentMode::Provisioned) + } + FederatedIdentityRequirement::Required(AuthorizedEnrollmentMode::Tofu) => { + Ok(EnrollmentMode::Tofu) + } + FederatedIdentityRequirement::NotRequired => { + Err(buzz_auth::AuthContextError::UnexpectedFederatedAuthorization) + } + } +} + +async fn read_current_enrollment_policy( + pool: &PgPool, + authorization_domain: CommunityId, +) -> Result { + let row = sqlx::query( + "SELECT policy_id, policy_epoch, requirement, effective_from, effective_until \ + FROM identity_enrollment_policies WHERE community_id=$1", + ) + .bind(authorization_domain.as_uuid()) + .fetch_optional(pool) + .await? + .ok_or_else(|| { + DbError::InvalidData("current identity enrollment policy is unavailable".to_string()) + })?; + CurrentEnrollmentPolicy::from_row(row) +} + +async fn lock_current_enrollment_policy_tx( + tx: &mut Transaction<'_, Postgres>, + authorization_domain: CommunityId, +) -> Result> { + sqlx::query( + "SELECT policy_id, policy_epoch, requirement, effective_from, effective_until \ + FROM identity_enrollment_policies WHERE community_id=$1 FOR UPDATE", + ) + .bind(authorization_domain.as_uuid()) + .fetch_optional(&mut **tx) + .await? + .map(CurrentEnrollmentPolicy::from_row) + .transpose() +} + +enum PolicyPreconditionState { + Current, + Changed, + NotYetEffective, + Expired, +} + +async fn policy_precondition_state_tx( + tx: &mut Transaction<'_, Postgres>, + request: &AuthoritativeBindingRequestView<'_>, +) -> Result { + let Some(policy) = lock_current_enrollment_policy_tx(tx, request.authorization_domain).await? + else { + return Ok(PolicyPreconditionState::Changed); + }; + if !policy.matches_binding_request(request) { + return Ok(PolicyPreconditionState::Changed); + } + let now: i64 = + sqlx::query_scalar("SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT") + .fetch_one(&mut **tx) + .await?; + let effective_from = i64::try_from(request.effective_from).map_err(|_| { + DbError::InvalidData("identity evidence lower bound is out of range".to_string()) + })?; + let effective_until = i64::try_from(request.effective_until).map_err(|_| { + DbError::InvalidData("identity evidence upper bound is out of range".to_string()) + })?; + Ok(if now < effective_from { + PolicyPreconditionState::NotYetEffective + } else if now >= effective_until { + PolicyPreconditionState::Expired + } else { + PolicyPreconditionState::Current + }) +} + +fn enforce_policy_precondition( + state: PolicyPreconditionState, +) -> std::result::Result<(), AuthorityAdapterError> { + match state { + PolicyPreconditionState::Current => Ok(()), + PolicyPreconditionState::Changed => Err(AuthorityAdapterError::policy_changed()), + PolicyPreconditionState::NotYetEffective => { + Err(buzz_auth::AuthContextError::FederatedPolicyNotYetEffective.into()) + } + PolicyPreconditionState::Expired => { + Err(buzz_auth::AuthContextError::FederatedPolicyExpired.into()) + } + } +} + +async fn resolve_authoritative_binding_request( + pool: &PgPool, + request: &BindingResolutionRequest, + enrollment_allowed: bool, +) -> std::result::Result> { + resolve_authoritative_binding_view( + pool, + &AuthoritativeBindingRequestView::from_sealed(request), + enrollment_allowed, + ) + .await +} + +async fn resolve_authoritative_binding_view( + pool: &PgPool, + request: &AuthoritativeBindingRequestView<'_>, + enrollment_allowed: bool, +) -> std::result::Result> { + let storage_mode = if enrollment_allowed { + storage_enrollment_mode(request.policy_requirement)? + } else { + EnrollmentMode::Provisioned + }; + let policy_version = format!("{}:{}", request.policy_id, request.policy_epoch); + let input = ResolveBindingInput { + authorization_domain: request.authorization_domain, + issuer: request.issuer, + subject: request.subject, + pubkey: &request.pubkey, + display_name: None, + enrollment_mode: storage_mode, + key_attested: request.key_attested, + policy_version: &policy_version, + evidence_valid_from: request.effective_from, + evidence_valid_until: request.effective_until, + }; + let mut tx = pool + .begin() + .await + .map_err(|error| AuthorityAdapterError::adapter(error.into()))?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await + .map_err(|error| AuthorityAdapterError::adapter(error.into()))?; + let initial = policy_precondition_state_tx(&mut tx, request) + .await + .map_err(AuthorityAdapterError::adapter)?; + enforce_policy_precondition(initial)?; + let result = resolve_identity_binding_tx(&mut tx, request.authorization_domain, &input, true) + .await + .map_err(AuthorityAdapterError::adapter)?; + if matches!( + result, + ResolveBindingResult::Existing(_) | ResolveBindingResult::Enrolled(_) + ) { + let final_state = policy_precondition_state_tx(&mut tx, request) + .await + .map_err(AuthorityAdapterError::adapter)?; + enforce_policy_precondition(final_state)?; + tx.commit() + .await + .map_err(|error| AuthorityAdapterError::adapter(error.into()))?; + } else { + tx.rollback() + .await + .map_err(|error| AuthorityAdapterError::adapter(error.into()))?; + } + Ok(result) +} + +fn authorized_binding_parts( + evidence: &BindingEvidence, +) -> std::result::Result< + ( + AuthorizedBindingVersion, + Option, + AuthorizedBindingSource, + ), + buzz_auth::AuthContextError, +> { + let binding_version = AuthorizedBindingVersion::new(evidence.binding_version)?; + let expires_at = evidence + .expires_at + .map(|value| { + u64::try_from(value.timestamp()) + .map_err(|_| buzz_auth::AuthContextError::InvalidBindingExpiry) + .and_then(AuthorizedBindingExpiry::new) + }) + .transpose()?; + let source = match evidence.provenance { + BindingProvenance::AttestedKey => AuthorizedBindingSource::AttestedKey, + BindingProvenance::Provisioned => AuthorizedBindingSource::Provisioned, + BindingProvenance::Tofu => AuthorizedBindingSource::Tofu, + }; + Ok((binding_version, expires_at, source)) +} + +fn authority_denial(denial: BindingDenial, delegated: bool) -> AuthorityAdapterError { + match denial { + BindingDenial::KeyAttestationRequired => { + buzz_auth::AuthContextError::KeyAttestationRequired.into() + } + BindingDenial::BindingExpired => buzz_auth::AuthContextError::BindingExpired.into(), + BindingDenial::StaleEvidence => buzz_auth::AuthContextError::FederatedPolicyExpired.into(), + BindingDenial::BindingRequired if delegated => { + buzz_auth::AuthContextError::DelegatedBindingNotExistingActive.into() + } + BindingDenial::Conflict | BindingDenial::Revoked | BindingDenial::BindingRequired => { + AuthorityAdapterError::adapter(DbError::InvalidData( + "identity binding resolution denied".to_string(), + )) + } + } +} + +impl FederatedAuthorityAdapter for PostgresFederatedAuthorityAdapter { + type Error = DbError; + + fn resolve_current_policy<'a>( + &'a self, + request: CurrentPolicyRequest, + sink: CurrentPolicyResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + std::result::Result>, + > { + Box::pin(async move { + let policy = read_current_enrollment_policy(&self.pool, request.authorization_domain()) + .await + .map_err(AuthorityAdapterError::adapter)?; + sink.resolved( + request.authorization_domain(), + policy.policy_id, + policy.policy_epoch, + policy.requirement, + policy.effective_from, + policy.effective_until, + ) + .map_err(Into::into) + }) + } + + fn resolve_direct_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + std::result::Result>, + > { + Box::pin(async move { + let result = resolve_authoritative_binding_request(&self.pool, &request, true).await?; + let evidence = match result { + ResolveBindingResult::Existing(evidence) => (evidence, false), + ResolveBindingResult::Enrolled(evidence) => (evidence, true), + ResolveBindingResult::Denied(denial) => { + return Err(authority_denial(denial, false)) + } + }; + let (binding_version, expires_at, source) = authorized_binding_parts(&evidence.0)?; + let seal = if evidence.1 { + DirectBindingResolutionSink::atomically_enrolled + } else { + DirectBindingResolutionSink::existing_active + }; + seal( + sink, + evidence.0.authorization_domain, + evidence.0.binding_id, + request.principal().clone(), + request.bound_pubkey(), + binding_version, + expires_at, + source, + ) + .map_err(Into::into) + }) + } + + fn resolve_existing_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: ExistingBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + std::result::Result>, + > { + Box::pin(async move { + let result = resolve_authoritative_binding_request(&self.pool, &request, false).await?; + let evidence = match result { + ResolveBindingResult::Existing(evidence) => evidence, + ResolveBindingResult::Enrolled(_) => { + return Err( + buzz_auth::AuthContextError::DelegatedBindingNotExistingActive.into(), + ) + } + ResolveBindingResult::Denied(denial) => return Err(authority_denial(denial, true)), + }; + let (binding_version, expires_at, source) = authorized_binding_parts(&evidence)?; + sink.existing_active( + evidence.authorization_domain, + evidence.binding_id, + request.principal().clone(), + request.bound_pubkey(), + binding_version, + expires_at, + source, + ) + .map_err(Into::into) + }) + } +} + +/// Resolve an exact issuer/subject/key binding under server-owned enrollment policy. +#[cfg(test)] +pub(crate) async fn resolve_identity_binding( + pool: &PgPool, + input: &ResolveBindingInput<'_>, +) -> Result { + let mut tx = pool.begin().await?; + let result = + resolve_identity_binding_tx(&mut tx, input.authorization_domain, input, false).await?; + if matches!( + &result, + ResolveBindingResult::Enrolled(_) | ResolveBindingResult::Existing(_) + ) && !evidence_is_current_tx(&mut tx, input).await? + { + tx.rollback().await?; + return Ok(ResolveBindingResult::Denied(BindingDenial::StaleEvidence)); + } + tx.commit().await?; + Ok(result) +} + +async fn evidence_is_current_tx( + tx: &mut Transaction<'_, Postgres>, + input: &ResolveBindingInput<'_>, +) -> Result { + let evidence_valid_from = i64::try_from(input.evidence_valid_from).map_err(|_| { + DbError::InvalidData( + "identity evidence lower bound is outside the supported range".to_string(), + ) + })?; + let evidence_valid_until = i64::try_from(input.evidence_valid_until).map_err(|_| { + DbError::InvalidData( + "identity evidence upper bound is outside the supported range".to_string(), + ) + })?; + if evidence_valid_from >= evidence_valid_until { + return Err(DbError::InvalidData( + "identity binding authorization evidence has no valid interval".to_string(), + )); + } + sqlx::query_scalar( + "SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT >= $1 \ + AND FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT < $2", + ) + .bind(evidence_valid_from) + .bind(evidence_valid_until) + .fetch_one(&mut **tx) + .await + .map_err(Into::into) +} + +async fn resolve_identity_binding_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + input: &ResolveBindingInput<'_>, + existing_read_only: bool, +) -> Result { + if community_id != input.authorization_domain || input.policy_version.is_empty() { + return Err(DbError::InvalidData( + "identity binding authorization domain or policy is invalid".to_string(), + )); + } + validate_inputs(input.issuer, input.subject, input.pubkey, SOURCE_DB_BINDING)?; + let evidence_valid_from = i64::try_from(input.evidence_valid_from).map_err(|_| { + DbError::InvalidData( + "identity evidence lower bound is outside the supported range".to_string(), + ) + })?; + let evidence_valid_until = i64::try_from(input.evidence_valid_until).map_err(|_| { + DbError::InvalidData( + "identity evidence upper bound is outside the supported range".to_string(), + ) + })?; + if evidence_valid_from >= evidence_valid_until { + return Err(DbError::InvalidData( + "identity binding authorization evidence has no valid interval".to_string(), + )); + } + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut **tx) + .await?; + lock_identity_keys_tx(tx, community_id, input.issuer, input.subject, input.pubkey).await?; + if !evidence_is_current_tx(tx, input).await? { + return Ok(ResolveBindingResult::Denied(BindingDenial::StaleEvidence)); + } + + let denied = migration_denied_tx(tx, community_id, input.issuer, input.subject).await? + || migration_key_denied_tx(tx, community_id, input.pubkey).await? + || principal_disabled_tx(tx, community_id, input.issuer, input.subject).await? + || key_revoked_tx(tx, community_id, input.pubkey).await? + || principal_requires_rotation_tx(tx, community_id, input.issuer, input.subject).await? + || retired_pair_exists_tx(tx, community_id, input.issuer, input.subject, input.pubkey) + .await?; + let active_principal = + active_by_principal_tx(tx, community_id, input.issuer, input.subject).await?; + let active_key = active_by_pubkey_tx(tx, community_id, input.pubkey).await?; + + // Authorization evidence is a lease only for this mutation. Recheck the + // database clock after every potentially blocking lock/read so a waiter + // cannot enroll or refresh after expiry, and reject future-dated evidence. + if !evidence_is_current_tx(tx, input).await? { + return Ok(ResolveBindingResult::Denied(BindingDenial::StaleEvidence)); + } + if denied { + return Ok(ResolveBindingResult::Denied(BindingDenial::Revoked)); + } + if active_principal + .as_ref() + .is_some_and(|binding| binding.pubkey != input.pubkey) + || active_key + .as_ref() + .is_some_and(|binding| binding.issuer != input.issuer || binding.uid != input.subject) + { + return Ok(ResolveBindingResult::Denied(BindingDenial::Conflict)); + } + + if let Some(binding) = active_principal { + let database_now: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&mut **tx) + .await?; + if binding + .expires_at + .is_some_and(|expires_at| expires_at <= database_now) + { + return Ok(ResolveBindingResult::Denied(BindingDenial::BindingExpired)); + } + if existing_read_only { + return Ok(ResolveBindingResult::Existing(evidence_from_binding( + community_id, + &binding, + binding.binding_version, + binding.binding_provenance, + ))); + } + let strengthen = + binding.binding_provenance == BindingProvenance::Tofu && input.key_attested; + let version = binding + .binding_version + .checked_add(u64::from(strengthen)) + .ok_or_else(|| { + DbError::InvalidData("identity binding version exhausted".to_string()) + })?; + let updated = sqlx::query( r#" UPDATE identity_bindings - SET display_name = $5, - source = CASE - WHEN source = 'jwt_npub' AND $6 = 'db_binding' THEN source - ELSE $6 - END, - updated_at = NOW(), - last_seen_at = NOW() - WHERE community_id = $1 - AND issuer = $2 - AND uid = $3 - AND pubkey = $4 - AND revoked_at IS NULL + SET display_name=$5, + source=CASE WHEN $6 THEN 'jwt_npub' ELSE source END, + binding_provenance=CASE WHEN $6 THEN 'attested_key' ELSE binding_provenance END, + binding_version=CASE WHEN $6 THEN binding_version + 1 ELSE binding_version END, + updated_at=NOW(), last_seen_at=NOW() + WHERE community_id=$1 AND issuer=$2 AND uid=$3 AND pubkey=$4 + AND binding_state='active' AND revoked_at IS NULL + AND FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT >= $7 + AND FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT < $8 "#, ) .bind(community_id.as_uuid()) - .bind(issuer) - .bind(uid) - .bind(pubkey) - .bind(display_name) - .bind(source) + .bind(input.issuer) + .bind(input.subject) + .bind(input.pubkey) + .bind(input.display_name) + .bind(strengthen) + .bind(evidence_valid_from) + .bind(evidence_valid_until) .execute(&mut **tx) .await?; - return Ok(BindIdentityResult::Matched); - } - - let active_pubkey = active_by_pubkey_tx(tx, community_id, pubkey).await?; - if let Some(binding) = active_pubkey { - if binding.issuer != issuer || binding.uid != uid { - return Ok(BindIdentityResult::Conflict(conflict_from(binding))); + if updated.rows_affected() != 1 { + return Ok(ResolveBindingResult::Denied(BindingDenial::StaleEvidence)); } + if strengthen { + sqlx::query( + r#" + INSERT INTO identity_binding_history + (community_id, binding_id, binding_version, issuer, subject, + pubkey, binding_state, binding_provenance, transition_kind, actor, reason) + VALUES ($1, $2, $3, $4, $5, $6, 'active', 'attested_key', + 'provenance_strengthened', $6, 'verified key attestation') + "#, + ) + .bind(community_id.as_uuid()) + .bind(binding.binding_id) + .bind(i64::try_from(version).map_err(|_| { + DbError::InvalidData("identity binding version is out of range".to_string()) + })?) + .bind(input.issuer) + .bind(input.subject) + .bind(input.pubkey) + .execute(&mut **tx) + .await?; + } + return Ok(ResolveBindingResult::Existing(evidence_from_binding( + community_id, + &binding, + version, + if strengthen { + BindingProvenance::AttestedKey + } else { + binding.binding_provenance + }, + ))); } - if revoked_pair_exists_tx(tx, community_id, issuer, uid, pubkey).await? { - return Ok(BindIdentityResult::Revoked); - } - + let provenance = match input.enrollment_mode { + EnrollmentMode::AttestedKey if !input.key_attested => { + return Ok(ResolveBindingResult::Denied( + BindingDenial::KeyAttestationRequired, + )) + } + EnrollmentMode::AttestedKey => BindingProvenance::AttestedKey, + EnrollmentMode::Provisioned => { + return Ok(ResolveBindingResult::Denied(BindingDenial::BindingRequired)) + } + EnrollmentMode::Tofu if input.key_attested => BindingProvenance::AttestedKey, + EnrollmentMode::Tofu => BindingProvenance::Tofu, + }; + let binding_id = Uuid::new_v4(); + let created_at: Option> = sqlx::query_scalar( + r#" + INSERT INTO identity_bindings + (community_id, issuer, uid, pubkey, display_name, source, binding_id, + binding_version, binding_state, binding_provenance, created_by, + created_policy_version, expires_at, creation_attribution_kind) + SELECT $1, $2, $3, $4, $5, $6, $7, 1, 'active', $8, $4, $9, NULL, $10 + WHERE FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT >= $11 + AND FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT < $12 + RETURNING created_at + "#, + ) + .bind(community_id.as_uuid()) + .bind(input.issuer) + .bind(input.subject) + .bind(input.pubkey) + .bind(input.display_name) + .bind(provenance.legacy_source()) + .bind(binding_id) + .bind(provenance.as_str()) + .bind(input.policy_version) + .bind(CreationAttributionKind::AuthenticatedKey.as_str()) + .bind(evidence_valid_from) + .bind(evidence_valid_until) + .fetch_optional(&mut **tx) + .await?; + let Some(created_at) = created_at else { + return Ok(ResolveBindingResult::Denied(BindingDenial::StaleEvidence)); + }; sqlx::query( r#" - INSERT INTO identity_bindings (community_id, issuer, uid, pubkey, display_name, source) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO identity_binding_history + (community_id, binding_id, binding_version, issuer, subject, pubkey, + binding_state, binding_provenance, transition_kind, actor, reason) + VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, 'enroll', $6, 'first enrollment') "#, ) .bind(community_id.as_uuid()) - .bind(issuer) - .bind(uid) - .bind(pubkey) - .bind(display_name) - .bind(source) + .bind(binding_id) + .bind(1_i64) + .bind(input.issuer) + .bind(input.subject) + .bind(input.pubkey) + .bind(provenance.as_str()) .execute(&mut **tx) .await?; - Ok(BindIdentityResult::Created) + Ok(ResolveBindingResult::Enrolled(BindingEvidence { + authorization_domain: community_id, + issuer: input.issuer.to_owned(), + subject: input.subject.to_owned(), + bound_pubkey: input.pubkey.to_vec(), + binding_id, + binding_version: 1, + binding_state: BindingState::Active, + provenance, + creation_attribution: CreationAttributionKind::AuthenticatedKey, + created_by: Some(input.pubkey.to_vec()), + created_policy_version: Some(input.policy_version.to_owned()), + expires_at: None, + created_at, + })) } /// Return the active binding for `pubkey`, if one exists. @@ -459,18 +1947,39 @@ pub async fn get_active_identity_binding_by_pubkey( pubkey: &[u8], ) -> Result> { validate_pubkey(pubkey)?; - let row = sqlx::query( - r#" - SELECT issuer, uid, pubkey, display_name, source, created_at, updated_at, last_seen_at - FROM identity_bindings - WHERE community_id = $1 AND pubkey = $2 AND revoked_at IS NULL - "#, - ) - .bind(community_id.as_uuid()) - .bind(pubkey) - .fetch_optional(pool) - .await?; - row.map(row_to_binding).transpose() + let mut tx = pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + lock_identity_coordinates_tx(&mut tx, vec![key_lock_coordinate(community_id, pubkey)]).await?; + let binding = active_by_pubkey_tx(&mut tx, community_id, pubkey).await?; + if let Some(binding) = binding.as_ref() { + let database_now: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&mut *tx) + .await?; + if binding + .expires_at + .is_some_and(|expires_at| expires_at <= database_now) + { + tx.commit().await?; + return Ok(None); + } + let denied = migration_key_denied_tx(&mut tx, community_id, pubkey).await? + || migration_denied_tx(&mut tx, community_id, &binding.issuer, &binding.uid).await? + || principal_disabled_tx(&mut tx, community_id, &binding.issuer, &binding.uid).await? + || key_revoked_tx(&mut tx, community_id, pubkey).await? + || principal_requires_rotation_tx(&mut tx, community_id, &binding.issuer, &binding.uid) + .await? + || retired_pair_exists_tx(&mut tx, community_id, &binding.issuer, &binding.uid, pubkey) + .await?; + if denied { + return Err(DbError::InvalidData( + "active identity binding conflicts with lifecycle state".to_string(), + )); + } + } + tx.commit().await?; + Ok(binding) } /// Disable an issuer-qualified principal and revoke its active key. @@ -481,85 +1990,26 @@ pub async fn get_active_identity_binding_by_pubkey( pub async fn revoke_identity_principal( pool: &PgPool, community_id: CommunityId, + operation_id: crate::identity_lifecycle::LifecycleOperationId, issuer: &str, uid: &str, - revoked_by: Option<&[u8]>, + revoked_by: &[u8], reason: &str, ) -> Result { - if issuer.trim().is_empty() || uid.trim().is_empty() || reason.trim().is_empty() { - return Err(DbError::InvalidData( - "identity principal revocation requires issuer, uid, and reason".to_string(), - )); - } - if let Some(pubkey) = revoked_by { - validate_pubkey(pubkey)?; - } - let mut tx = pool.begin().await?; - sqlx::query("SET LOCAL lock_timeout = '3s'") - .execute(&mut *tx) - .await?; - // Enrollment and rotation take the principal lock first. Take it before - // discovering the current key so a concurrent first enrollment cannot - // slip between the lookup and the durable principal tombstone. - lock_identity_key_strings_tx( - &mut tx, - vec![format!( - "{}:principal:{issuer}:{uid}", - community_id.as_uuid() - )], - ) - .await?; - let active_pubkey: Option> = sqlx::query_scalar( - "SELECT pubkey FROM identity_bindings WHERE community_id = $1 AND issuer = $2 AND uid = $3 AND revoked_at IS NULL", - ) - .bind(community_id.as_uuid()) - .bind(issuer) - .bind(uid) - .fetch_optional(&mut *tx) - .await?; - if let Some(pubkey) = active_pubkey.as_ref() { - lock_identity_key_strings_tx( - &mut tx, - vec![format!( - "{}:pubkey:{}", - community_id.as_uuid(), - hex::encode(pubkey) - )], - ) - .await?; - } - - sqlx::query( - r#" - INSERT INTO identity_principals - (community_id, issuer, uid, disabled_at, disabled_by, disabled_reason) - VALUES ($1, $2, $3, NOW(), $4, $5) - ON CONFLICT (community_id, issuer, uid) DO NOTHING - "#, - ) - .bind(community_id.as_uuid()) - .bind(issuer) - .bind(uid) - .bind(revoked_by) - .bind(reason) - .execute(&mut *tx) - .await?; - sqlx::query( - r#" - UPDATE identity_bindings - SET revoked_at = NOW(), revoked_by = $4, revoked_reason = $5, - revocation_scope = 'principal', updated_at = NOW() - WHERE community_id = $1 AND issuer = $2 AND uid = $3 AND revoked_at IS NULL - "#, + crate::identity_lifecycle::disable_identity_principal( + pool, + community_id, + crate::identity_lifecycle::LifecycleContext { + operation_id, + actor: revoked_by, + reason, + }, + crate::identity_lifecycle::IdentityPrincipal { + issuer, + subject: uid, + }, ) - .bind(community_id.as_uuid()) - .bind(issuer) - .bind(uid) - .bind(revoked_by) - .bind(reason) - .execute(&mut *tx) .await?; - tx.commit().await?; Ok(true) } @@ -568,64 +2018,22 @@ pub async fn revoke_identity_principal( pub async fn revoke_identity_key( pool: &PgPool, community_id: CommunityId, + operation_id: crate::identity_lifecycle::LifecycleOperationId, pubkey: &[u8], - revoked_by: Option<&[u8]>, + revoked_by: &[u8], reason: &str, ) -> Result { - validate_pubkey(pubkey)?; - if let Some(operator) = revoked_by { - validate_pubkey(operator)?; - } - if reason.trim().is_empty() { - return Err(DbError::InvalidData( - "identity key revocation reason must not be empty".to_string(), - )); - } - let mut tx = pool.begin().await?; - sqlx::query("SET LOCAL lock_timeout = '3s'") - .execute(&mut *tx) - .await?; - // A community-scoped key tombstone is the entire correctness boundary. - // Do not acquire a principal lock after it: enrollment and rotation use - // principal→key ordering, and reversing that order can deadlock. - lock_identity_key_strings_tx( - &mut tx, - vec![format!( - "{}:pubkey:{}", - community_id.as_uuid(), - hex::encode(pubkey) - )], - ) - .await?; - sqlx::query( - r#" - INSERT INTO identity_revoked_keys - (community_id, pubkey, revoked_at, revoked_by, reason) - VALUES ($1, $2, NOW(), $3, $4) - ON CONFLICT (community_id, pubkey) DO NOTHING - "#, - ) - .bind(community_id.as_uuid()) - .bind(pubkey) - .bind(revoked_by) - .bind(reason) - .execute(&mut *tx) - .await?; - sqlx::query( - r#" - UPDATE identity_bindings - SET revoked_at = NOW(), revoked_by = $3, revoked_reason = $4, - revocation_scope = 'key', updated_at = NOW() - WHERE community_id = $1 AND pubkey = $2 AND revoked_at IS NULL - "#, + crate::identity_lifecycle::revoke_identity_key( + pool, + community_id, + crate::identity_lifecycle::LifecycleContext { + operation_id, + actor: revoked_by, + reason, + }, + pubkey, ) - .bind(community_id.as_uuid()) - .bind(pubkey) - .bind(revoked_by) - .bind(reason) - .execute(&mut *tx) .await?; - tx.commit().await?; Ok(true) } @@ -634,155 +2042,33 @@ pub async fn revoke_identity_key( pub async fn rotate_identity_binding( pool: &PgPool, community_id: CommunityId, + operation_id: crate::identity_lifecycle::LifecycleOperationId, issuer: &str, uid: &str, old_pubkey: &[u8], - new_pubkey: &[u8], - display_name: Option<&str>, - source: &str, - rotated_by: Option<&[u8]>, + replacement: crate::identity_lifecycle::VerifiedReplacementKey<'_>, + rotated_by: &[u8], reason: &str, ) -> Result<()> { - validate_inputs(issuer, uid, old_pubkey, source)?; - validate_pubkey(new_pubkey)?; - if old_pubkey == new_pubkey { - return Err(DbError::InvalidData( - "identity rotation requires a different replacement key".to_string(), - )); - } - if let Some(operator) = rotated_by { - validate_pubkey(operator)?; - } - if reason.trim().is_empty() { - return Err(DbError::InvalidData( - "identity rotation reason must not be empty".to_string(), - )); - } - - let mut tx = pool.begin().await?; - sqlx::query("SET LOCAL lock_timeout = '3s'") - .execute(&mut *tx) - .await?; - lock_identity_key_strings_tx( - &mut tx, - vec![ - format!("{}:principal:{issuer}:{uid}", community_id.as_uuid()), - format!( - "{}:pubkey:{}", - community_id.as_uuid(), - hex::encode(old_pubkey) - ), - format!( - "{}:pubkey:{}", - community_id.as_uuid(), - hex::encode(new_pubkey) - ), - ], - ) - .await?; - if principal_disabled_tx(&mut tx, community_id, issuer, uid).await? { - return Err(DbError::InvalidData( - "disabled identity principal cannot be rotated".to_string(), - )); - } - if key_revoked_tx(&mut tx, community_id, new_pubkey).await? { - return Err(DbError::InvalidData( - "identity rotation replacement key is revoked".to_string(), - )); - } - let active = active_by_principal_tx(&mut tx, community_id, issuer, uid).await?; - if active - .as_ref() - .is_some_and(|binding| binding.pubkey != old_pubkey) - { - return Err(DbError::InvalidData( - "identity rotation source key does not match active binding".to_string(), - )); - } - if active.is_none() { - let revoked_key = sqlx::query( - r#" - SELECT 1 FROM identity_bindings - WHERE community_id = $1 AND issuer = $2 AND uid = $3 - AND pubkey = $4 AND revoked_at IS NOT NULL - AND revocation_scope = 'key' - FOR UPDATE - "#, - ) - .bind(community_id.as_uuid()) - .bind(issuer) - .bind(uid) - .bind(old_pubkey) - .fetch_optional(&mut *tx) - .await?; - if revoked_key.is_none() { - return Err(DbError::InvalidData( - "identity rotation source is neither active nor key-revoked".to_string(), - )); - } - } - if active_by_pubkey_tx(&mut tx, community_id, new_pubkey) - .await? - .is_some() - { - return Err(DbError::InvalidData( - "identity rotation replacement key is already bound".to_string(), - )); - } - - sqlx::query( - r#" - UPDATE identity_bindings - SET revoked_at = COALESCE(revoked_at, NOW()), - revoked_by = CASE WHEN revoked_at IS NULL THEN $5 ELSE revoked_by END, - revoked_reason = CASE WHEN revoked_at IS NULL THEN $6 ELSE revoked_reason END, - revocation_scope = CASE WHEN revoked_at IS NULL THEN 'rotation' ELSE revocation_scope END, - rotation_completed_at = NOW(), rotated_to_pubkey = $7, - rotation_by = $5, rotation_reason = $6, updated_at = NOW() - WHERE community_id = $1 AND issuer = $2 AND uid = $3 - AND pubkey = $4 - AND (revoked_at IS NULL OR revocation_scope = 'key') - "#, - ) - .bind(community_id.as_uuid()) - .bind(issuer) - .bind(uid) - .bind(old_pubkey) - .bind(rotated_by) - .bind(reason) - .bind(new_pubkey) - .execute(&mut *tx) - .await?; - sqlx::query( - r#" - INSERT INTO identity_revoked_keys - (community_id, pubkey, revoked_at, revoked_by, reason) - VALUES ($1, $2, NOW(), $3, $4) - ON CONFLICT (community_id, pubkey) DO NOTHING - "#, - ) - .bind(community_id.as_uuid()) - .bind(old_pubkey) - .bind(rotated_by) - .bind(reason) - .execute(&mut *tx) - .await?; - sqlx::query( - r#" - INSERT INTO identity_bindings - (community_id, issuer, uid, pubkey, display_name, source) - VALUES ($1, $2, $3, $4, $5, $6) - "#, + validate_inputs(issuer, uid, old_pubkey, SOURCE_DB_BINDING)?; + let principal = crate::identity_lifecycle::IdentityPrincipal { + issuer, + subject: uid, + }; + let context = crate::identity_lifecycle::LifecycleContext { + operation_id, + actor: rotated_by, + reason, + }; + crate::identity_lifecycle::rotate_identity_binding( + pool, + community_id, + context, + principal, + old_pubkey, + replacement, ) - .bind(community_id.as_uuid()) - .bind(issuer) - .bind(uid) - .bind(new_pubkey) - .bind(display_name) - .bind(source) - .execute(&mut *tx) .await?; - tx.commit().await?; Ok(()) } @@ -792,6 +2078,13 @@ mod tests { use nostr::Keys; use uuid::Uuid; + #[test] + fn postgres_authority_adapter_implements_the_sealed_provider_contract() { + fn assert_adapter>() {} + + assert_adapter::(); + } + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; const TEST_ISSUER: &str = "https://idp.example"; @@ -820,10 +2113,544 @@ mod tests { CommunityId::from_uuid(id) } + async fn database_now(pool: &PgPool) -> u64 { + let now: i64 = + sqlx::query_scalar("SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT") + .fetch_one(pool) + .await + .expect("read database clock"); + u64::try_from(now).expect("database clock is non-negative") + } + + async fn install_policy( + pool: &PgPool, + community: CommunityId, + policy_id: Uuid, + epoch: u64, + requirement: &str, + effective_from: u64, + effective_until: u64, + ) { + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_id, policy_epoch, requirement, effective_from, effective_until) \ + VALUES ($1,$2,$3,$4,$5,$6)", + ) + .bind(community.as_uuid()) + .bind(policy_id) + .bind(i64::try_from(epoch).expect("test epoch fits BIGINT")) + .bind(requirement) + .bind(i64::try_from(effective_from).expect("test lower bound fits BIGINT")) + .bind(i64::try_from(effective_until).expect("test upper bound fits BIGINT")) + .execute(pool) + .await + .expect("install test enrollment policy"); + } + + // Keep each authority input explicit so the negative-test matrix can vary + // one security-relevant field at a time without hiding defaults. + #[allow(clippy::too_many_arguments)] + fn authoritative_request<'a>( + community: CommunityId, + issuer: &'a str, + subject: &'a str, + pubkey: [u8; 32], + policy_id: Uuid, + policy_epoch: u64, + requirement: FederatedIdentityRequirement, + key_attested: bool, + effective_from: u64, + effective_until: u64, + ) -> AuthoritativeBindingRequestView<'a> { + AuthoritativeBindingRequestView { + authorization_domain: community, + issuer, + subject, + pubkey, + policy_id, + policy_epoch, + policy_requirement: requirement, + key_attested, + effective_from, + effective_until, + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn current_policy_adapter_returns_exact_authoritative_lineage() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let policy_id = Uuid::new_v4(); + let now = database_now(&pool).await; + install_policy(&pool, community, policy_id, 7, "tofu", now - 1, now + 60).await; + let correlation_id = Uuid::new_v4(); + let adapter = PostgresFederatedAuthorityAdapter::new(pool); + + let resolved = + buzz_auth::resolve_current_federated_policy(&adapter, community, correlation_id, now) + .await + .expect("authoritative current policy resolves"); + + assert_eq!(resolved.authorization_domain(), community); + assert_eq!(resolved.stamp().policy_id(), policy_id); + assert_eq!(resolved.stamp().epoch(), 7); + assert_eq!(resolved.stamp().correlation_id(), correlation_id); + assert_eq!(resolved.stamp().effective_from(), now - 1); + assert_eq!(resolved.stamp().effective_until(), now + 60); + assert_eq!( + resolved.requirement(), + FederatedIdentityRequirement::Required(AuthorizedEnrollmentMode::Tofu) + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn stale_policy_epoch_fails_closed_without_binding_mutation() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let policy_id = Uuid::new_v4(); + let now = database_now(&pool).await; + install_policy(&pool, community, policy_id, 1, "tofu", now - 1, now + 60).await; + sqlx::query( + "UPDATE identity_enrollment_policies \ + SET policy_epoch=2, requirement='provisioned', updated_at=NOW() \ + WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .execute(&pool) + .await + .expect("advance test policy epoch"); + let request = authoritative_request( + community, + TEST_ISSUER, + "stale-policy-user", + random_pubkey().try_into().expect("test key is 32 bytes"), + policy_id, + 1, + FederatedIdentityRequirement::Required(AuthorizedEnrollmentMode::Tofu), + false, + now - 1, + now + 60, + ); + + let error = resolve_authoritative_binding_view(&pool, &request, true) + .await + .expect_err("stale policy epoch must fail closed"); + + assert!(matches!(error, AuthorityAdapterError::PolicyChanged)); + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("count bindings after stale policy"); + assert_eq!(count, 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn enrollment_policy_lineage_is_stable_and_monotonic() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let policy_id = Uuid::new_v4(); + let now = database_now(&pool).await; + install_policy(&pool, community, policy_id, 1, "tofu", now - 1, now + 120).await; + + let unchanged_epoch = sqlx::query( + "UPDATE identity_enrollment_policies SET requirement='attested_key' \ + WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .execute(&pool) + .await; + assert!( + unchanged_epoch.is_err(), + "policy changes must advance the epoch" + ); + + let changed_id = sqlx::query( + "UPDATE identity_enrollment_policies SET policy_id=$1, policy_epoch=2 \ + WHERE community_id=$2", + ) + .bind(Uuid::new_v4()) + .bind(community.as_uuid()) + .execute(&pool) + .await; + assert!(changed_id.is_err(), "a community's policy ID is immutable"); + + sqlx::query( + "UPDATE identity_enrollment_policies \ + SET policy_epoch=2, requirement='attested_key', updated_at=NOW() \ + WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .execute(&pool) + .await + .expect("strictly monotonic policy update succeeds"); + let lineage: (Uuid, i64, String) = sqlx::query_as( + "SELECT policy_id,policy_epoch,requirement FROM identity_enrollment_policies \ + WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("read stable policy lineage"); + assert_eq!(lineage, (policy_id, 2, "attested_key".to_owned())); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn policy_lock_wait_rechecks_database_time_before_any_binding_mutation() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let policy_id = Uuid::new_v4(); + let now = database_now(&pool).await; + install_policy(&pool, community, policy_id, 1, "tofu", now - 1, now + 1).await; + + let mut blocker = pool.begin().await.expect("start policy lock blocker"); + sqlx::query("SELECT 1 FROM identity_enrollment_policies WHERE community_id=$1 FOR UPDATE") + .bind(community.as_uuid()) + .fetch_one(&mut *blocker) + .await + .expect("lock current policy"); + + let request = authoritative_request( + community, + TEST_ISSUER, + "lock-wait-user", + random_pubkey().try_into().expect("test key is 32 bytes"), + policy_id, + 1, + FederatedIdentityRequirement::Required(AuthorizedEnrollmentMode::Tofu), + false, + now - 1, + now + 1, + ); + let request_pool = pool.clone(); + let waiter = tokio::spawn(async move { + resolve_authoritative_binding_view(&request_pool, &request, true).await + }); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + blocker.commit().await.expect("release policy lock blocker"); + + let error = waiter + .await + .expect("policy waiter task completes") + .expect_err("expired policy after lock wait must fail closed"); + assert!(matches!( + error, + AuthorityAdapterError::Contract(buzz_auth::AuthContextError::FederatedPolicyExpired) + )); + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("count bindings after expired policy wait"); + assert_eq!(count, 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authoritative_modes_and_expiry_are_fail_closed_and_non_mutating() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let policy_id = Uuid::new_v4(); + let now = database_now(&pool).await; + install_policy(&pool, community, policy_id, 1, "tofu", now - 1, now + 120).await; + let key: [u8; 32] = random_pubkey().try_into().expect("test key is 32 bytes"); + let request = authoritative_request( + community, + TEST_ISSUER, + "authoritative-user", + key, + policy_id, + 1, + FederatedIdentityRequirement::Required(AuthorizedEnrollmentMode::Tofu), + false, + now - 1, + now + 120, + ); + + let enrolled = resolve_authoritative_binding_view(&pool, &request, true) + .await + .expect("current TOFU policy resolves"); + let ResolveBindingResult::Enrolled(evidence) = enrolled else { + panic!("expected atomic enrollment, got {enrolled:?}"); + }; + assert_eq!(evidence.provenance(), BindingProvenance::Tofu); + assert_eq!( + evidence.created_policy_version(), + Some(format!("{policy_id}:1").as_str()) + ); + + let before: (DateTime, DateTime, i64) = sqlx::query_as( + "SELECT updated_at, last_seen_at, \ + (SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1) \ + FROM identity_bindings WHERE community_id=$1 AND binding_id=$2", + ) + .bind(community.as_uuid()) + .bind(evidence.binding_id()) + .fetch_one(&pool) + .await + .expect("read binding before existing resolution"); + assert!(matches!( + resolve_authoritative_binding_view(&pool, &request, true) + .await + .expect("existing binding resolves read-only"), + ResolveBindingResult::Existing(_) + )); + let after: (DateTime, DateTime, i64) = sqlx::query_as( + "SELECT updated_at, last_seen_at, \ + (SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1) \ + FROM identity_bindings WHERE community_id=$1 AND binding_id=$2", + ) + .bind(community.as_uuid()) + .bind(evidence.binding_id()) + .fetch_one(&pool) + .await + .expect("read binding after existing resolution"); + assert_eq!(after, before, "existing resolution must be read-only"); + + sqlx::query( + "UPDATE identity_bindings SET expires_at=clock_timestamp() \ + WHERE community_id=$1 AND binding_id=$2", + ) + .bind(community.as_uuid()) + .bind(evidence.binding_id()) + .execute(&pool) + .await + .expect("expire authoritative binding"); + assert_eq!( + resolve_authoritative_binding_view(&pool, &request, true) + .await + .expect("expired binding is a typed denial"), + ResolveBindingResult::Denied(BindingDenial::BindingExpired) + ); + let active_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_bindings \ + WHERE community_id=$1 AND binding_id=$2 \ + AND binding_state='active' AND revoked_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(evidence.binding_id()) + .fetch_one(&pool) + .await + .expect("count slot-occupying expired binding"); + assert_eq!(active_count, 1, "expiry must not free lifecycle slots"); + assert!( + get_active_identity_binding_by_pubkey(&pool, community, &key) + .await + .expect("legacy authorization lookup handles expiry") + .is_none(), + "expired bindings cannot authorize delegated or revalidated sessions" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn provisioned_attested_and_delegated_absence_never_enroll() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let policy_id = Uuid::new_v4(); + let now = database_now(&pool).await; + install_policy( + &pool, + community, + policy_id, + 1, + "provisioned", + now - 1, + now + 120, + ) + .await; + let key: [u8; 32] = random_pubkey().try_into().expect("test key is 32 bytes"); + let provisioned = authoritative_request( + community, + TEST_ISSUER, + "mode-user", + key, + policy_id, + 1, + FederatedIdentityRequirement::Required(AuthorizedEnrollmentMode::Provisioned), + false, + now - 1, + now + 120, + ); + assert_eq!( + resolve_authoritative_binding_view(&pool, &provisioned, true) + .await + .expect("provisioned absence is a typed denial"), + ResolveBindingResult::Denied(BindingDenial::BindingRequired) + ); + assert_eq!( + resolve_authoritative_binding_view(&pool, &provisioned, false) + .await + .expect("delegated absence is a typed denial"), + ResolveBindingResult::Denied(BindingDenial::BindingRequired) + ); + + sqlx::query( + "UPDATE identity_enrollment_policies \ + SET policy_epoch=2, requirement='attested_key', updated_at=NOW() \ + WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .execute(&pool) + .await + .expect("advance policy to attested-key mode"); + let unattested = authoritative_request( + community, + TEST_ISSUER, + "mode-user", + key, + policy_id, + 2, + FederatedIdentityRequirement::Required(AuthorizedEnrollmentMode::AttestedKey), + false, + now - 1, + now + 120, + ); + assert_eq!( + resolve_authoritative_binding_view(&pool, &unattested, true) + .await + .expect("missing attestation is a typed denial"), + ResolveBindingResult::Denied(BindingDenial::KeyAttestationRequired) + ); + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("count bindings after denied modes"); + assert_eq!(count, 0); + } + fn random_pubkey() -> Vec { Keys::generate().public_key().to_bytes().to_vec() } + async fn enroll_for_test( + pool: &PgPool, + community: CommunityId, + issuer: &str, + subject: &str, + pubkey: &[u8], + display_name: Option<&str>, + ) -> BindingEvidence { + match resolve_identity_binding( + pool, + &ResolveBindingInput { + authorization_domain: community, + issuer, + subject, + pubkey, + display_name, + enrollment_mode: EnrollmentMode::Tofu, + key_attested: false, + policy_version: "test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("resolve test binding") + { + ResolveBindingResult::Enrolled(evidence) => evidence, + other => panic!("expected enrolled binding, got {other:?}"), + } + } + + #[test] + fn identity_lock_coordinates_are_typed_length_prefixed_and_domain_scoped() { + let first_domain = CommunityId::from_uuid(Uuid::from_u128(1)); + let second_domain = CommunityId::from_uuid(Uuid::from_u128(2)); + let principal_ab_c = principal_lock_coordinate(first_domain, "ab", "c"); + let principal_a_bc = principal_lock_coordinate(first_domain, "a", "bc"); + + assert_ne!( + principal_ab_c, principal_a_bc, + "lengths must be unambiguous" + ); + assert_ne!( + principal_ab_c, + principal_lock_coordinate(second_domain, "ab", "c"), + "domains must not share lock coordinates" + ); + assert_ne!( + principal_ab_c, + key_lock_coordinate(first_domain, &[0_u8; 32]), + "coordinate kinds must be disjoint" + ); + } + + #[test] + fn identity_debug_formatting_redacts_private_coordinates() { + let input = IdentityBindingInput { + issuer: "private-issuer", + uid: "private-subject", + pubkey: &[42_u8; 32], + display_name: Some("private-display"), + source: SOURCE_JWT_NPUB, + }; + let formatted = format!("{input:?}"); + + assert!(formatted.contains("[redacted]")); + for secret in ["private-issuer", "private-subject", "private-display"] { + assert!(!formatted.contains(secret)); + } + } + + #[test] + fn complete_binding_evidence_debug_is_party_data_free() { + let evidence = BindingEvidence { + authorization_domain: CommunityId::from_uuid(Uuid::from_u128(7)), + issuer: "private-evidence-issuer".to_string(), + subject: "private-evidence-subject".to_string(), + bound_pubkey: vec![0xBC; 32], + binding_id: Uuid::from_u128(8), + binding_version: 3, + binding_state: BindingState::Active, + provenance: BindingProvenance::AttestedKey, + creation_attribution: CreationAttributionKind::AuthenticatedKey, + created_by: Some(vec![0xBD; 32]), + created_policy_version: Some("private-policy-version".to_string()), + expires_at: Some(DateTime::::from_timestamp(2, 0).expect("test expiry timestamp")), + created_at: DateTime::::from_timestamp(1, 0).expect("test timestamp"), + }; + let formatted = format!("{evidence:?}"); + let encoded_key = hex::encode([0xBC; 32]); + let binding_id = evidence.binding_id().to_string(); + assert!(formatted.contains("[redacted]")); + for secret in [ + "private-evidence-issuer", + "private-evidence-subject", + "private-policy-version", + encoded_key.as_str(), + binding_id.as_str(), + ] { + assert!(!formatted.contains(secret)); + } + assert_eq!( + evidence.creation_attribution(), + CreationAttributionKind::AuthenticatedKey + ); + assert_eq!(evidence.created_by(), Some(&[0xBD; 32][..])); + } + + #[test] + fn conflict_marker_cannot_carry_party_data() { + assert_eq!(std::mem::size_of::(), 0); + assert_eq!( + format!( + "{:?}", + BindIdentityResult::Conflict(IdentityBindingConflict) + ), + "Conflict(IdentityBindingConflict(\"[redacted]\"))" + ); + } + #[test] fn staged_identity_key_must_match_membership_key() { let identity_key = [7_u8; 32]; @@ -846,12 +2673,12 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn bind_identity_creates_then_matches_idempotently() { + async fn legacy_binding_api_cannot_enroll_without_authorized_domain_evidence() { let pool = setup_pool().await; let community = make_community(&pool).await; let pubkey = random_pubkey(); - let created = bind_or_validate_identity( + let denied = bind_or_validate_identity( &pool, community, TEST_ISSUER, @@ -861,30 +2688,419 @@ mod tests { SOURCE_DB_BINDING, ) .await - .expect("create binding"); - assert_eq!(created, BindIdentityResult::Created); + .expect("fail closed without authorized domain evidence"); + assert_eq!(denied, BindIdentityResult::BindingRequired); + assert!( + get_active_identity_binding_by_pubkey(&pool, community, &pubkey) + .await + .expect("lookup binding") + .is_none() + ); + let history_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("count binding history"); + assert_eq!(history_count, 0); + } - let matched = bind_or_validate_identity( + #[tokio::test] + #[ignore = "requires Postgres"] + async fn new_bindings_require_immutable_creation_attribution() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let pubkey = random_pubkey(); + + enroll_for_test( &pool, community, TEST_ISSUER, - "user-1", + "attributed-user", &pubkey, - Some("second@example.com"), - SOURCE_JWT_NPUB, + None, ) + .await; + + let attribution: (Option>, Option, Option) = sqlx::query_as( + "SELECT created_by, created_policy_version, creation_attribution_kind \ + FROM identity_bindings WHERE community_id=$1 AND issuer=$2 AND uid=$3", + ) + .bind(community.as_uuid()) + .bind(TEST_ISSUER) + .bind("attributed-user") + .fetch_one(&pool) .await - .expect("match existing binding"); - assert_eq!(matched, BindIdentityResult::Matched); + .expect("read creation attribution"); + assert_eq!(attribution.0.as_deref(), Some(pubkey.as_slice())); + assert_eq!(attribution.1.as_deref(), Some("test-policy-v1")); + assert_eq!(attribution.2.as_deref(), Some("authenticated_key")); + } - let binding = get_active_identity_binding_by_pubkey(&pool, community, &pubkey) + #[tokio::test] + #[ignore = "requires Postgres"] + async fn stale_authorized_evidence_cannot_enroll_or_refresh_metadata() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let pubkey = random_pubkey(); + enroll_for_test( + &pool, + community, + TEST_ISSUER, + "stale-evidence-user", + &pubkey, + Some("original@example.com"), + ) + .await; + let before: (Option, DateTime, DateTime) = sqlx::query_as( + "SELECT display_name, updated_at, last_seen_at FROM identity_bindings \ + WHERE community_id=$1 AND issuer=$2 AND uid=$3", + ) + .bind(community.as_uuid()) + .bind(TEST_ISSUER) + .bind("stale-evidence-user") + .fetch_one(&pool) + .await + .expect("read binding before stale replay"); + + let stale = resolve_identity_binding( + &pool, + &ResolveBindingInput { + authorization_domain: community, + issuer: TEST_ISSUER, + subject: "stale-evidence-user", + pubkey: &pubkey, + display_name: Some("changed@example.com"), + enrollment_mode: EnrollmentMode::Tofu, + key_attested: false, + policy_version: "test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: 1, + }, + ) + .await + .expect("stale resolution is a typed denial"); + assert_eq!( + stale, + ResolveBindingResult::Denied(BindingDenial::StaleEvidence) + ); + let after: (Option, DateTime, DateTime) = sqlx::query_as( + "SELECT display_name, updated_at, last_seen_at FROM identity_bindings \ + WHERE community_id=$1 AND issuer=$2 AND uid=$3", + ) + .bind(community.as_uuid()) + .bind(TEST_ISSUER) + .bind("stale-evidence-user") + .fetch_one(&pool) + .await + .expect("read binding after stale replay"); + assert_eq!(after, before); + + let missing_key = random_pubkey(); + let missing = resolve_identity_binding( + &pool, + &ResolveBindingInput { + authorization_domain: community, + issuer: TEST_ISSUER, + subject: "never-enrolled-stale-user", + pubkey: &missing_key, + display_name: None, + enrollment_mode: EnrollmentMode::Tofu, + key_attested: false, + policy_version: "test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: 1, + }, + ) + .await + .expect("stale first enrollment is a typed denial"); + assert_eq!( + missing, + ResolveBindingResult::Denied(BindingDenial::StaleEvidence) + ); + assert!( + get_active_identity_binding_by_pubkey(&pool, community, &missing_key) + .await + .expect("lookup missing stale key") + .is_none() + ); + + let database_now: i64 = + sqlx::query_scalar("SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT") + .fetch_one(&pool) + .await + .expect("read database clock"); + let future_key = random_pubkey(); + let future = resolve_identity_binding( + &pool, + &ResolveBindingInput { + authorization_domain: community, + issuer: TEST_ISSUER, + subject: "not-yet-valid-user", + pubkey: &future_key, + display_name: None, + enrollment_mode: EnrollmentMode::Tofu, + key_attested: false, + policy_version: "test-policy-v1", + evidence_valid_from: u64::try_from(database_now + 60).unwrap(), + evidence_valid_until: u64::try_from(database_now + 120).unwrap(), + }, + ) + .await + .expect("future evidence is a typed denial"); + assert_eq!( + future, + ResolveBindingResult::Denied(BindingDenial::StaleEvidence) + ); + assert!( + get_active_identity_binding_by_pubkey(&pool, community, &future_key) + .await + .expect("lookup future-evidence key") + .is_none() + ); + let history_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("count unchanged binding history"); + assert_eq!(history_count, 1, "only the verified seed may have history"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn evidence_expiring_while_waiting_for_identity_lock_cannot_enroll() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let pubkey = random_pubkey(); + let database_now: i64 = + sqlx::query_scalar("SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT") + .fetch_one(&pool) + .await + .expect("read database clock"); + + let mut blocker = pool.begin().await.expect("begin blocking transaction"); + lock_identity_keys_tx( + &mut blocker, + community, + TEST_ISSUER, + "lock-wait-expiry-user", + &pubkey, + ) + .await + .expect("hold identity coordinates"); + + let resolving_pool = pool.clone(); + let resolving_key = pubkey.clone(); + let resolving = tokio::spawn(async move { + resolve_identity_binding( + &resolving_pool, + &ResolveBindingInput { + authorization_domain: community, + issuer: TEST_ISSUER, + subject: "lock-wait-expiry-user", + pubkey: &resolving_key, + display_name: None, + enrollment_mode: EnrollmentMode::Tofu, + key_attested: false, + policy_version: "test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: u64::try_from(database_now + 1).unwrap(), + }, + ) .await - .expect("lookup binding") - .expect("binding exists"); - assert_eq!(binding.uid, "user-1"); - assert_eq!(binding.issuer, TEST_ISSUER); - assert_eq!(binding.display_name.as_deref(), Some("second@example.com")); - assert_eq!(binding.source, SOURCE_JWT_NPUB); + }); + tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; + blocker + .rollback() + .await + .expect("release identity coordinates"); + + assert_eq!( + resolving + .await + .expect("join blocked resolver") + .expect("resolver returns typed denial"), + ResolveBindingResult::Denied(BindingDenial::StaleEvidence) + ); + assert!( + get_active_identity_binding_by_pubkey(&pool, community, &pubkey) + .await + .expect("lookup expired waiter") + .is_none() + ); + let history_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("count waiter history"); + assert_eq!(history_count, 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn evidence_expiring_during_existing_binding_update_rolls_back_metadata() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let pubkey = random_pubkey(); + enroll_for_test( + &pool, + community, + TEST_ISSUER, + "commit-boundary-expiry-user", + &pubkey, + Some("original@example.com"), + ) + .await; + let before: (Option, DateTime, DateTime) = sqlx::query_as( + "SELECT display_name, updated_at, last_seen_at FROM identity_bindings \ + WHERE community_id=$1 AND issuer=$2 AND uid=$3", + ) + .bind(community.as_uuid()) + .bind(TEST_ISSUER) + .bind("commit-boundary-expiry-user") + .fetch_one(&pool) + .await + .expect("read pre-boundary metadata"); + + let suffix = community.as_uuid().simple(); + let sequence_name = format!("buzz_test_freshness_seq_{suffix}"); + let function_name = format!("buzz_test_freshness_fn_{suffix}"); + let trigger_name = format!("buzz_test_freshness_trigger_{suffix}"); + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE SEQUENCE {sequence_name}" + ))) + .execute(&pool) + .await + .expect("create freshness trigger sequence"); + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE FUNCTION {function_name}() RETURNS trigger LANGUAGE plpgsql AS $$ \ + BEGIN PERFORM nextval('{sequence_name}'); PERFORM pg_sleep(2.5); RETURN NEW; END; $$" + ))) + .execute(&pool) + .await + .expect("create freshness delay function"); + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE TRIGGER {trigger_name} BEFORE UPDATE ON identity_bindings \ + FOR EACH ROW WHEN (OLD.community_id = '{}'::uuid) \ + EXECUTE FUNCTION {function_name}()", + community.as_uuid() + ))) + .execute(&pool) + .await + .expect("create freshness delay trigger"); + + let database_now: i64 = + sqlx::query_scalar("SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT") + .fetch_one(&pool) + .await + .expect("read database clock"); + let result = resolve_identity_binding( + &pool, + &ResolveBindingInput { + authorization_domain: community, + issuer: TEST_ISSUER, + subject: "commit-boundary-expiry-user", + pubkey: &pubkey, + display_name: Some("changed@example.com"), + enrollment_mode: EnrollmentMode::Tofu, + key_attested: false, + policy_version: "test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: u64::try_from(database_now + 2).unwrap(), + }, + ) + .await + .expect("commit-boundary expiry is a typed denial"); + let trigger_state: (i64, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!( + "SELECT last_value, is_called FROM {sequence_name}" + ))) + .fetch_one(&pool) + .await + .expect("read freshness trigger count"); + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP TRIGGER {trigger_name} ON identity_bindings" + ))) + .execute(&pool) + .await + .expect("drop freshness delay trigger"); + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP FUNCTION {function_name}()" + ))) + .execute(&pool) + .await + .expect("drop freshness delay function"); + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP SEQUENCE {sequence_name}" + ))) + .execute(&pool) + .await + .expect("drop freshness trigger sequence"); + + assert_eq!( + trigger_state, + (1, true), + "the update reached the forced expiry gap" + ); + assert_eq!( + result, + ResolveBindingResult::Denied(BindingDenial::StaleEvidence) + ); + let after: (Option, DateTime, DateTime) = sqlx::query_as( + "SELECT display_name, updated_at, last_seen_at FROM identity_bindings \ + WHERE community_id=$1 AND issuer=$2 AND uid=$3", + ) + .bind(community.as_uuid()) + .bind(TEST_ISSUER) + .bind("commit-boundary-expiry-user") + .fetch_one(&pool) + .await + .expect("read rolled-back metadata"); + assert_eq!(after, before); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn lifecycle_state_and_revocation_timestamp_cannot_diverge() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let pubkey = random_pubkey(); + + enroll_for_test( + &pool, + community, + TEST_ISSUER, + "state-parity-user", + &pubkey, + None, + ) + .await; + + let malformed = sqlx::query( + "UPDATE identity_bindings SET binding_state='revoked' \ + WHERE community_id=$1 AND issuer=$2 AND uid=$3 \ + AND binding_state='active' AND revoked_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(TEST_ISSUER) + .bind("state-parity-user") + .execute(&pool) + .await; + assert!( + malformed.is_err(), + "storage must reject revoked or rotated labels with an active timestamp shape" + ); + + assert!( + get_active_identity_binding_by_pubkey(&pool, community, &pubkey) + .await + .expect("read active binding") + .is_some() + ); } #[tokio::test] @@ -895,17 +3111,15 @@ mod tests { let original_pubkey = random_pubkey(); let conflicting_pubkey = random_pubkey(); - bind_or_validate_identity( + enroll_for_test( &pool, community, TEST_ISSUER, "user-1", &original_pubkey, Some("user@example.com"), - SOURCE_DB_BINDING, ) - .await - .expect("create binding"); + .await; let result = bind_or_validate_identity( &pool, @@ -921,12 +3135,7 @@ mod tests { assert_eq!( result, - BindIdentityResult::Conflict(IdentityBindingConflict { - issuer: TEST_ISSUER.to_string(), - uid: "user-1".to_string(), - pubkey: original_pubkey, - source: SOURCE_DB_BINDING.to_string(), - }) + BindIdentityResult::Conflict(IdentityBindingConflict) ); } @@ -937,17 +3146,15 @@ mod tests { let community = make_community(&pool).await; let pubkey = random_pubkey(); - bind_or_validate_identity( + enroll_for_test( &pool, community, TEST_ISSUER, "user-1", &pubkey, Some("user@example.com"), - SOURCE_DB_BINDING, ) - .await - .expect("create binding"); + .await; let result = bind_or_validate_identity( &pool, @@ -963,33 +3170,26 @@ mod tests { assert_eq!( result, - BindIdentityResult::Conflict(IdentityBindingConflict { - issuer: TEST_ISSUER.to_string(), - uid: "user-1".to_string(), - pubkey, - source: SOURCE_DB_BINDING.to_string(), - }) + BindIdentityResult::Conflict(IdentityBindingConflict) ); } #[tokio::test] #[ignore = "requires Postgres"] - async fn bind_identity_does_not_downgrade_jwt_npub_source() { + async fn legacy_binding_bridge_cannot_manufacture_attested_provenance() { let pool = setup_pool().await; let community = make_community(&pool).await; let pubkey = random_pubkey(); - bind_or_validate_identity( + enroll_for_test( &pool, community, TEST_ISSUER, "user-1", &pubkey, Some("user@example.com"), - SOURCE_JWT_NPUB, ) - .await - .expect("create strong binding"); + .await; let matched = bind_or_validate_identity( &pool, @@ -1008,7 +3208,8 @@ mod tests { .await .expect("lookup binding") .expect("binding exists"); - assert_eq!(binding.source, SOURCE_JWT_NPUB); + assert_eq!(binding.source, SOURCE_DB_BINDING); + assert_eq!(binding.binding_provenance, BindingProvenance::Tofu); } #[tokio::test] @@ -1018,22 +3219,21 @@ mod tests { let community = make_community(&pool).await; let pubkey = random_pubkey(); - bind_or_validate_identity( + enroll_for_test( &pool, community, TEST_ISSUER, "user-1", &pubkey, Some("user@example.com"), - SOURCE_JWT_NPUB, ) - .await - .expect("create binding"); + .await; sqlx::query( r#" UPDATE identity_bindings - SET revoked_at = NOW(), revoked_reason = 'test revocation' + SET binding_state = 'revoked', revoked_at = NOW(), + revoked_reason = 'test revocation' WHERE community_id = $1 AND issuer = $2 AND uid = $3 AND pubkey = $4 "#, ) @@ -1087,28 +3287,31 @@ mod tests { let community = make_community(&pool).await; let old_pubkey = random_pubkey(); let new_pubkey = random_pubkey(); - bind_or_validate_identity( + enroll_for_test( &pool, community, TEST_ISSUER, "rotating-user", &old_pubkey, Some("user@example.com"), - SOURCE_JWT_NPUB, ) - .await - .expect("create binding"); + .await; rotate_identity_binding( &pool, community, + crate::identity_lifecycle::LifecycleOperationId::issue(), TEST_ISSUER, "rotating-user", &old_pubkey, - &new_pubkey, - Some("user@example.com"), - SOURCE_JWT_NPUB, - None, + crate::identity_lifecycle::VerifiedReplacementKey::after_verified_proof( + &new_pubkey, + Some("user@example.com"), + BindingProvenance::AttestedKey, + "test-policy-v1", + ) + .expect("verified replacement"), + &old_pubkey, "device replacement", ) .await @@ -1151,22 +3354,25 @@ mod tests { let community = make_community(&pool).await; let old_pubkey = random_pubkey(); let new_pubkey = random_pubkey(); - bind_or_validate_identity( + enroll_for_test( &pool, community, TEST_ISSUER, "key-revoked-user", &old_pubkey, None, - SOURCE_DB_BINDING, + ) + .await; + assert!(revoke_identity_key( + &pool, + community, + crate::identity_lifecycle::LifecycleOperationId::issue(), + &old_pubkey, + &old_pubkey, + "lost device", ) .await - .expect("create binding"); - assert!( - revoke_identity_key(&pool, community, &old_pubkey, None, "lost device",) - .await - .expect("revoke key") - ); + .expect("revoke key")); assert_eq!( bind_or_validate_identity( @@ -1183,20 +3389,53 @@ mod tests { BindIdentityResult::Revoked ); - rotate_identity_binding( + assert!(rotate_identity_binding( &pool, community, + crate::identity_lifecycle::LifecycleOperationId::issue(), TEST_ISSUER, "key-revoked-user", &old_pubkey, - &new_pubkey, - None, - SOURCE_DB_BINDING, - None, + crate::identity_lifecycle::VerifiedReplacementKey::after_verified_proof( + &new_pubkey, + None, + BindingProvenance::Provisioned, + "test-policy-v1", + ) + .expect("verified recovery key"), + &old_pubkey, "approved replacement", ) .await - .expect("explicit rotation after key revocation"); + .is_err()); + let principal = crate::identity_lifecycle::IdentityPrincipal { + issuer: TEST_ISSUER, + subject: "key-revoked-user", + }; + let expected = crate::identity_lifecycle::get_pending_lineage(&pool, community, principal) + .await + .expect("read pending recovery") + .expect("pending recovery exists"); + crate::identity_lifecycle::recover_identity_binding( + &pool, + community, + crate::identity_lifecycle::LifecycleContext { + operation_id: crate::identity_lifecycle::LifecycleOperationId::issue(), + actor: &old_pubkey, + reason: "approved recovery", + }, + principal, + &expected, + crate::identity_lifecycle::VerifiedReplacementKey::after_verified_proof( + &new_pubkey, + None, + BindingProvenance::Provisioned, + "test-policy-v1", + ) + .expect("verified recovery key"), + ) + .await + .expect("explicit recovery after key revocation"); assert!( get_active_identity_binding_by_pubkey(&pool, community, &new_pubkey) .await @@ -1225,7 +3464,7 @@ mod tests { ); assert_eq!( retired.try_get::("rotation_reason").unwrap(), - "approved replacement" + "approved recovery" ); assert_eq!( retired.try_get::, _>("rotated_to_pubkey").unwrap(), @@ -1250,9 +3489,10 @@ mod tests { assert!(revoke_identity_principal( &pool, community, + crate::identity_lifecycle::LifecycleOperationId::issue(), TEST_ISSUER, "never-enrolled", - None, + &[0xA4; 32], "employment ended", ) .await @@ -1279,20 +3519,25 @@ mod tests { let pool = setup_pool().await; let community = make_community(&pool).await; let pubkey = random_pubkey(); - bind_or_validate_identity( + enroll_for_test( &pool, community, TEST_ISSUER, "first-principal", &pubkey, None, - SOURCE_DB_BINDING, + ) + .await; + revoke_identity_key( + &pool, + community, + crate::identity_lifecycle::LifecycleOperationId::issue(), + &pubkey, + &pubkey, + "compromised key", ) .await - .expect("create binding"); - revoke_identity_key(&pool, community, &pubkey, None, "compromised key") - .await - .expect("revoke key"); + .expect("revoke key"); assert_eq!( bind_or_validate_identity( @@ -1316,19 +3561,18 @@ mod tests { let pool = setup_pool().await; let community = make_community(&pool).await; let pubkey = random_pubkey(); - bind_or_validate_identity( + enroll_for_test( &pool, community, TEST_ISSUER, "legacy-principal", &pubkey, None, - SOURCE_DB_BINDING, ) - .await - .expect("create binding"); + .await; sqlx::query( - "UPDATE identity_bindings SET revoked_at = NOW(), revoked_reason = 'legacy revoke' \ + "UPDATE identity_bindings \ + SET binding_state = 'revoked', revoked_at = NOW(), revoked_reason = 'legacy revoke' \ WHERE community_id = $1 AND issuer = $2 AND uid = $3 AND pubkey = $4", ) .bind(community.as_uuid()) @@ -1338,6 +3582,15 @@ mod tests { .execute(&pool) .await .expect("simulate pre-lifecycle revocation"); + sqlx::query( + "INSERT INTO identity_revoked_keys (community_id, pubkey, reason) \ + VALUES ($1,$2,'legacy revoke')", + ) + .bind(community.as_uuid()) + .bind(&pubkey) + .execute(&pool) + .await + .expect("simulate legacy key tombstone projection"); assert_eq!( bind_or_validate_identity( @@ -1363,31 +3616,25 @@ mod tests { let first_pubkey = random_pubkey(); let second_pubkey = random_pubkey(); - let first = bind_or_validate_identity( + enroll_for_test( &pool, community, "https://issuer-a.example", "shared-subject", &first_pubkey, Some("first@example.com"), - SOURCE_DB_BINDING, ) - .await - .expect("create first issuer binding"); - let second = bind_or_validate_identity( + .await; + enroll_for_test( &pool, community, "https://issuer-b.example", "shared-subject", &second_pubkey, Some("second@example.com"), - SOURCE_DB_BINDING, ) - .await - .expect("create second issuer binding"); + .await; - assert_eq!(first, BindIdentityResult::Created); - assert_eq!(second, BindIdentityResult::Created); assert_eq!( get_active_identity_binding_by_pubkey(&pool, community, &second_pubkey) .await diff --git a/crates/buzz-db/src/identity_lifecycle.rs b/crates/buzz-db/src/identity_lifecycle.rs new file mode 100644 index 0000000000..510546c8e7 --- /dev/null +++ b/crates/buzz-db/src/identity_lifecycle.rs @@ -0,0 +1,3297 @@ +//! Linearizable storage transitions for relay-verified identity bindings. +//! +//! This module consumes caller-verified lifecycle facts. It does not define an +//! operator transport or authorization policy. Every transition shares the +//! authorization lock coordinates from [`crate::identity_binding`] and records +//! state history in the same transaction. + +use std::fmt; + +use sha2::{Digest, Sha256}; +use sqlx::{PgPool, Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::error::{DbError, Result}; +use crate::identity_binding::{ + binding_lock_coordinate, key_lock_coordinate, lock_identity_coordinates_tx, + operation_lock_coordinate, principal_lock_coordinate, BindingEvidence, BindingProvenance, + BindingState, CreationAttributionKind, EnrollmentMode, +}; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; + +/// Opaque server-issued identifier for one retryable lifecycle operation. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct LifecycleOperationId(Uuid); + +impl LifecycleOperationId { + /// Mint a new server-controlled identifier before accepting a transition. + pub fn issue() -> Self { + Self(Uuid::new_v4()) + } + + /// Stable UUID retained by the server across retries. + pub const fn as_uuid(self) -> Uuid { + self.0 + } + + #[cfg(test)] + pub(crate) const fn from_uuid_for_test(value: Uuid) -> Self { + Self(value) + } +} + +impl fmt::Debug for LifecycleOperationId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("LifecycleOperationId") + .field(&"[redacted]") + .finish() + } +} + +/// Immutable evidence common to one privileged lifecycle request. +#[derive(Clone, Copy)] +pub struct LifecycleContext<'a> { + /// Server-issued idempotency identifier retained across retries. + pub operation_id: LifecycleOperationId, + /// Authenticated actor key. + pub actor: &'a [u8], + /// Non-empty private transition reason. + pub reason: &'a str, +} + +impl fmt::Debug for LifecycleContext<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LifecycleContext") + .field("operation_id", &"[redacted]") + .field("actor", &"[redacted]") + .field("reason", &"[redacted]") + .finish() + } +} + +/// Exact issuer-qualified principal in one server-resolved domain. +#[derive(Clone, Copy)] +pub struct IdentityPrincipal<'a> { + /// Exact validated issuer. + pub issuer: &'a str, + /// Exact validated subject. + pub subject: &'a str, +} + +impl fmt::Debug for IdentityPrincipal<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("IdentityPrincipal") + .field("issuer", &"[redacted]") + .field("subject", &"[redacted]") + .finish() + } +} + +/// Replacement-key storage facts accepted only after an in-crate verifier has +/// completed fresh proof. The fields and constructor are intentionally not +/// available to external callers; a later proof-owning boundary must supply +/// this value rather than selecting provenance or policy from raw input. +#[derive(Clone, Copy)] +pub struct VerifiedReplacementKey<'a> { + pubkey: &'a [u8], + display_name: Option<&'a str>, + provenance: BindingProvenance, + created_policy_version: &'a str, +} + +impl<'a> VerifiedReplacementKey<'a> { + /// Construct storage input after fresh replacement-key proof is verified. + #[allow(dead_code)] // The proof-owning runtime boundary must wire this before activation. + pub(crate) fn after_verified_proof( + pubkey: &'a [u8], + display_name: Option<&'a str>, + provenance: BindingProvenance, + created_policy_version: &'a str, + ) -> Result { + validate_pubkey(pubkey)?; + if provenance == BindingProvenance::Tofu { + return Err(DbError::InvalidData( + "privileged replacement cannot use TOFU provenance".to_string(), + )); + } + if created_policy_version.is_empty() { + return Err(DbError::InvalidData( + "identity policy version must not be empty".to_string(), + )); + } + Ok(VerifiedReplacementKey { + pubkey, + display_name, + provenance, + created_policy_version, + }) + } + + /// Proven replacement public key. + pub const fn pubkey(&self) -> &[u8] { + self.pubkey + } +} + +impl fmt::Debug for VerifiedReplacementKey<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedReplacementKey") + .field("pubkey", &"[redacted]") + .field("display_name", &"[redacted]") + .field("provenance", &"[redacted]") + .field("created_policy_version", &"[redacted]") + .finish() + } +} + +/// Exact pending-replacement selector observed by a caller. +#[derive(Clone, PartialEq, Eq)] +pub struct PendingLineage { + /// Retired key selected by Q. + pub retired_pubkey: Vec, + /// Stable retired binding identifier. + pub retired_binding_id: Uuid, + /// Version of the retired binding referenced by Q. + pub retired_binding_version: u64, + /// Monotonic selector version protecting against ABA. + pub selector_version: u64, +} + +impl fmt::Debug for PendingLineage { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PendingLineage") + .field("retired_pubkey", &"[redacted]") + .field("retired_binding_id", &"[redacted]") + .field("retired_binding_version", &"[redacted]") + .field("selector_version", &"[redacted]") + .finish() + } +} + +/// Redaction-safe receipt for a committed lifecycle transition. +#[derive(Clone, PartialEq, Eq)] +pub struct LifecycleReceipt { + /// Binding primarily affected by the transition. + pub binding_id: Option, + /// Replacement binding created by the transition. + pub replacement_binding_id: Option, + /// Resulting authorization-relevant binding version. + pub binding_version: Option, + /// Version of the replacement binding ID, when a replacement was created. + pub replacement_binding_version: Option, + /// Resulting pending selector version. + pub selector_version: Option, +} + +impl fmt::Debug for LifecycleReceipt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LifecycleReceipt") + .field("binding_id", &"[redacted]") + .field("replacement_binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("replacement_binding_version", &"[redacted]") + .field("selector_version", &"[redacted]") + .finish() + } +} + +/// Outcome of an idempotent lifecycle operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LifecycleResult { + /// This invocation committed the transition. + Applied(LifecycleReceipt), + /// The same operation ID and request had already committed. + AlreadyApplied(LifecycleReceipt), +} + +#[derive(Clone)] +struct ActiveBinding { + binding_id: Uuid, + issuer: String, + subject: String, + pubkey: Vec, + binding_version: u64, + provenance: BindingProvenance, +} + +const OP_PROVISION: &str = "provision"; +const OP_RETIRE_PAIR: &str = "retire_pair"; +const OP_DISABLE: &str = "disable_identity"; +const OP_REVOKE_KEY: &str = "revoke_key"; +const OP_ROTATE: &str = "rotate"; +const OP_RECOVER: &str = "recover"; +const OP_ENABLE: &str = "enable_identity"; +const OP_ARCHIVE: &str = "archive"; + +fn validate_context(context: LifecycleContext<'_>) -> Result<()> { + if context.operation_id.as_uuid().is_nil() || context.reason.trim().is_empty() { + return Err(DbError::InvalidData( + "identity lifecycle requires an operation ID and reason".to_string(), + )); + } + validate_pubkey(context.actor)?; + Ok(()) +} + +fn validate_principal(principal: IdentityPrincipal<'_>) -> Result<()> { + if principal.issuer.is_empty() || principal.subject.is_empty() { + return Err(DbError::InvalidData( + "identity lifecycle principal must be exact and non-empty".to_string(), + )); + } + Ok(()) +} + +fn validate_pubkey(pubkey: &[u8]) -> Result<()> { + if pubkey.len() != 32 { + return Err(DbError::InvalidData( + "identity lifecycle pubkey must be 32 bytes".to_string(), + )); + } + Ok(()) +} + +fn digest_parts(parts: &[&[u8]]) -> Vec { + let mut digest = Sha256::new(); + for part in parts { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part); + } + digest.finalize().to_vec() +} + +fn request_fingerprint( + kind: &str, + community_id: CommunityId, + context: LifecycleContext<'_>, + parts: &[&[u8]], +) -> Vec { + let operation_id = context.operation_id.as_uuid(); + let mut all = vec![ + kind.as_bytes(), + community_id.as_uuid().as_bytes(), + operation_id.as_bytes(), + context.actor, + context.reason.as_bytes(), + ]; + all.extend_from_slice(parts); + digest_parts(&all) +} + +fn optional_fingerprint_bytes(value: Option<&[u8]>) -> Vec { + let mut encoded = Vec::with_capacity(1 + value.map_or(0, <[u8]>::len)); + encoded.push(u8::from(value.is_some())); + if let Some(value) = value { + encoded.extend_from_slice(value); + } + encoded +} + +fn replacement_request_fingerprint( + kind: &str, + community_id: CommunityId, + context: LifecycleContext<'_>, + parts: &[&[u8]], + replacement: &VerifiedReplacementKey<'_>, +) -> Vec { + let display_name = optional_fingerprint_bytes(replacement.display_name.map(str::as_bytes)); + let policy_version = replacement.created_policy_version.as_bytes(); + let mut all = parts.to_vec(); + all.push(replacement.pubkey); + all.push(&display_name); + all.push(replacement.provenance.as_str().as_bytes()); + all.push(policy_version); + request_fingerprint(kind, community_id, context, &all) +} + +fn i64_version(value: u64) -> Result { + i64::try_from(value) + .map_err(|_| DbError::InvalidData("identity lifecycle version is out of range".to_string())) +} + +fn u64_version(value: i64) -> Result { + u64::try_from(value) + .map_err(|_| DbError::InvalidData("identity lifecycle version is out of range".to_string())) +} + +async fn begin_locked<'a>( + pool: &'a PgPool, + coordinates: Vec>, +) -> Result> { + let mut tx = pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + lock_identity_coordinates_tx(&mut tx, coordinates).await?; + Ok(tx) +} + +fn lifecycle_coordinates( + community_id: CommunityId, + context: LifecycleContext<'_>, + principal: Option>, + pubkeys: &[&[u8]], + binding_id: Option, +) -> Vec> { + let mut coordinates = vec![operation_lock_coordinate( + community_id, + context.operation_id.as_uuid(), + )]; + if let Some(principal) = principal { + coordinates.push(principal_lock_coordinate( + community_id, + principal.issuer, + principal.subject, + )); + } + coordinates.extend( + pubkeys + .iter() + .map(|pubkey| key_lock_coordinate(community_id, pubkey)), + ); + if let Some(binding_id) = binding_id { + coordinates.push(binding_lock_coordinate(community_id, binding_id)); + } + coordinates +} + +async fn principal_quarantined_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + principal: IdentityPrincipal<'_>, +) -> Result { + Ok(sqlx::query( + "SELECT 1 FROM identity_migration_denials \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3", + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .fetch_optional(&mut **tx) + .await? + .is_some()) +} + +async fn require_not_quarantined_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + principal: IdentityPrincipal<'_>, +) -> Result<()> { + if principal_quarantined_tx(tx, community_id, principal).await? { + return Err(DbError::InvalidData( + "identity principal is blocked by migrated lineage".to_string(), + )); + } + Ok(()) +} + +async fn key_quarantined_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], +) -> Result { + Ok(sqlx::query( + "SELECT 1 FROM identity_migration_denied_keys \ + WHERE community_id=$1 AND pubkey=$2", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await? + .is_some()) +} + +async fn require_key_not_quarantined_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], +) -> Result<()> { + if key_quarantined_tx(tx, community_id, pubkey).await? { + return Err(DbError::InvalidData( + "identity key is blocked by migrated lineage".to_string(), + )); + } + Ok(()) +} + +async fn active_principal_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + principal: IdentityPrincipal<'_>, +) -> Result> { + #[cfg(test)] + crate::identity_binding::test_lock_schedule::row_checkpoint( + tx, + crate::identity_binding::test_lock_schedule::RowLockPhase::Request, + ) + .await; + let row = sqlx::query( + "SELECT binding_id, issuer, uid, pubkey, binding_version, binding_provenance \ + FROM identity_bindings \ + WHERE community_id=$1 AND issuer=$2 AND uid=$3 \ + AND binding_state='active' AND revoked_at IS NULL \ + FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .fetch_optional(&mut **tx) + .await?; + #[cfg(test)] + crate::identity_binding::test_lock_schedule::row_checkpoint( + tx, + crate::identity_binding::test_lock_schedule::RowLockPhase::Acquired, + ) + .await; + active_binding_from_row(row.as_ref()) +} + +async fn active_key_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], +) -> Result> { + #[cfg(test)] + crate::identity_binding::test_lock_schedule::row_checkpoint( + tx, + crate::identity_binding::test_lock_schedule::RowLockPhase::Request, + ) + .await; + let row = sqlx::query( + "SELECT binding_id, issuer, uid, pubkey, binding_version, binding_provenance \ + FROM identity_bindings \ + WHERE community_id=$1 AND pubkey=$2 \ + AND binding_state='active' AND revoked_at IS NULL \ + FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await?; + #[cfg(test)] + crate::identity_binding::test_lock_schedule::row_checkpoint( + tx, + crate::identity_binding::test_lock_schedule::RowLockPhase::Acquired, + ) + .await; + active_binding_from_row(row.as_ref()) +} + +fn active_binding_from_row(row: Option<&sqlx::postgres::PgRow>) -> Result> { + row.map(|row| { + let version: i64 = row.try_get("binding_version")?; + let provenance: String = row.try_get("binding_provenance")?; + Ok(ActiveBinding { + binding_id: row.try_get("binding_id")?, + issuer: row.try_get("issuer")?, + subject: row.try_get("uid")?, + pubkey: row.try_get("pubkey")?, + binding_version: u64_version(version)?, + provenance: BindingProvenance::parse(&provenance)?, + }) + }) + .transpose() +} + +async fn principal_disabled_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + principal: IdentityPrincipal<'_>, +) -> Result { + Ok(sqlx::query( + "SELECT 1 FROM identity_principals \ + WHERE community_id=$1 AND issuer=$2 AND uid=$3 AND disabled_at IS NOT NULL", + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .fetch_optional(&mut **tx) + .await? + .is_some()) +} + +async fn key_revoked_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], +) -> Result { + Ok( + sqlx::query("SELECT 1 FROM identity_revoked_keys WHERE community_id=$1 AND pubkey=$2") + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await? + .is_some(), + ) +} + +async fn pair_retired_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + principal: IdentityPrincipal<'_>, + pubkey: &[u8], +) -> Result { + Ok(sqlx::query( + "SELECT 1 FROM identity_retired_pairs \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 AND pubkey=$4 \ + UNION ALL \ + SELECT 1 FROM identity_bindings \ + WHERE community_id=$1 AND issuer=$2 AND uid=$3 AND pubkey=$4 \ + AND revoked_at IS NOT NULL \ + LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await? + .is_some()) +} + +async fn pending_lineage_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + principal: IdentityPrincipal<'_>, +) -> Result> { + let row = sqlx::query( + r#" + SELECT retired_pubkey, retired_binding_id, retired_binding_version, selector_version + FROM identity_pending_replacements + WHERE community_id=$1 AND issuer=$2 AND subject=$3 AND cleared_at IS NULL + FOR UPDATE + "#, + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .fetch_optional(&mut **tx) + .await?; + row.map(|row| { + Ok(PendingLineage { + retired_pubkey: row.try_get("retired_pubkey")?, + retired_binding_id: row.try_get("retired_binding_id")?, + retired_binding_version: u64_version(row.try_get("retired_binding_version")?)?, + selector_version: u64_version(row.try_get("selector_version")?)?, + }) + }) + .transpose() +} + +/// Read the current exact pending lineage under the shared principal lock. +pub async fn get_pending_lineage( + pool: &PgPool, + community_id: CommunityId, + principal: IdentityPrincipal<'_>, +) -> Result> { + validate_principal(principal)?; + let mut tx = begin_locked( + pool, + vec![principal_lock_coordinate( + community_id, + principal.issuer, + principal.subject, + )], + ) + .await?; + require_not_quarantined_tx(&mut tx, community_id, principal).await?; + let pending = pending_lineage_tx(&mut tx, community_id, principal).await?; + tx.commit().await?; + Ok(pending) +} + +async fn replacement_eligible_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + principal: IdentityPrincipal<'_>, + replacement: &VerifiedReplacementKey<'_>, +) -> Result<()> { + if active_key_tx(tx, community_id, replacement.pubkey) + .await? + .is_some() + || key_quarantined_tx(tx, community_id, replacement.pubkey).await? + || key_revoked_tx(tx, community_id, replacement.pubkey).await? + || pair_retired_tx(tx, community_id, principal, replacement.pubkey).await? + { + return Err(DbError::InvalidData( + "replacement identity key is not eligible".to_string(), + )); + } + Ok(()) +} + +async fn insert_binding_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + context: LifecycleContext<'_>, + principal: IdentityPrincipal<'_>, + replacement: &VerifiedReplacementKey<'_>, + transition_kind: &str, +) -> Result { + let version = 1; + let binding_id = Uuid::new_v4(); + let created_at: DateTime = sqlx::query_scalar( + r#" + INSERT INTO identity_bindings + (community_id, issuer, uid, pubkey, display_name, source, binding_id, + binding_version, binding_state, binding_provenance, created_by, + created_policy_version, creation_attribution_kind) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active', $9, $10, $11, $12) + RETURNING created_at + "#, + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .bind(replacement.pubkey) + .bind(replacement.display_name) + .bind(replacement.provenance.legacy_source()) + .bind(binding_id) + .bind(i64_version(version)?) + .bind(replacement.provenance.as_str()) + .bind(context.actor) + .bind(replacement.created_policy_version) + .bind(CreationAttributionKind::Operator.as_str()) + .fetch_one(&mut **tx) + .await?; + append_history_tx( + tx, + community_id, + context, + binding_id, + version, + principal, + replacement.pubkey, + "active", + replacement.provenance, + transition_kind, + None, + ) + .await?; + Ok(BindingEvidence { + authorization_domain: community_id, + issuer: principal.issuer.to_owned(), + subject: principal.subject.to_owned(), + bound_pubkey: replacement.pubkey.to_vec(), + binding_id, + binding_version: version, + binding_state: BindingState::Active, + provenance: replacement.provenance, + creation_attribution: CreationAttributionKind::Operator, + created_by: Some(context.actor.to_vec()), + created_policy_version: Some(replacement.created_policy_version.to_owned()), + expires_at: None, + created_at, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn append_history_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + context: LifecycleContext<'_>, + binding_id: Uuid, + binding_version: u64, + principal: IdentityPrincipal<'_>, + pubkey: &[u8], + binding_state: &str, + provenance: BindingProvenance, + transition_kind: &str, + replacement_binding_id: Option, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO identity_binding_history + (community_id, binding_id, binding_version, issuer, subject, pubkey, + binding_state, binding_provenance, transition_kind, + replacement_binding_id, operation_id, actor, reason) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + "#, + ) + .bind(community_id.as_uuid()) + .bind(binding_id) + .bind(i64_version(binding_version)?) + .bind(principal.issuer) + .bind(principal.subject) + .bind(pubkey) + .bind(binding_state) + .bind(provenance.as_str()) + .bind(transition_kind) + .bind(replacement_binding_id) + .bind(context.operation_id.as_uuid()) + .bind(context.actor) + .bind(context.reason) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn insert_pair_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + context: LifecycleContext<'_>, + principal: IdentityPrincipal<'_>, + binding: &ActiveBinding, + retired_version: u64, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO identity_retired_pairs + (community_id, issuer, subject, pubkey, retired_binding_id, + retired_binding_version, retired_at, retired_by, reason) + VALUES ($1,$2,$3,$4,$5,$6,NOW(),$7,$8) + ON CONFLICT (community_id, issuer, subject, pubkey) DO NOTHING + "#, + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .bind(&binding.pubkey) + .bind(binding.binding_id) + .bind(i64_version(retired_version)?) + .bind(context.actor) + .bind(context.reason) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn insert_pending_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + context: LifecycleContext<'_>, + principal: IdentityPrincipal<'_>, + binding: &ActiveBinding, + retired_version: u64, +) -> Result { + let selector_version: i64 = sqlx::query_scalar( + "SELECT COALESCE(MAX(selector_version), 0) + 1 \ + FROM identity_pending_replacements \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3", + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .fetch_one(&mut **tx) + .await?; + sqlx::query( + r#" + INSERT INTO identity_pending_replacements + (community_id, issuer, subject, selector_version, retired_pubkey, + retired_binding_id, retired_binding_version, created_operation_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + "#, + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .bind(selector_version) + .bind(&binding.pubkey) + .bind(binding.binding_id) + .bind(i64_version(retired_version)?) + .bind(context.operation_id.as_uuid()) + .execute(&mut **tx) + .await?; + u64_version(selector_version) +} + +struct RetireActive<'a> { + transition_kind: &'a str, + revocation_scope: &'a str, + replacement: Option<(&'a [u8], Uuid)>, + create_pending: bool, +} + +async fn retire_active_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + context: LifecycleContext<'_>, + principal: IdentityPrincipal<'_>, + binding: &ActiveBinding, + transition: RetireActive<'_>, +) -> Result<(u64, Option)> { + if pending_lineage_tx(tx, community_id, principal) + .await? + .is_some() + { + return Err(DbError::InvalidData( + "active identity binding overlaps pending lineage".to_string(), + )); + } + let next_version = binding + .binding_version + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("identity binding version exhausted".to_string()))?; + let (replacement_pubkey, replacement_binding_id) = transition + .replacement + .map(|(key, id)| (Some(key), Some(id))) + .unwrap_or((None, None)); + let changed = sqlx::query( + r#" + UPDATE identity_bindings + SET binding_version=$5, binding_state=$6, revoked_at=NOW(), + revoked_by=$7, revoked_reason=$8, revocation_scope=$9, + rotation_completed_at=CASE WHEN $6='rotated' THEN NOW() ELSE rotation_completed_at END, + rotated_to_pubkey=CASE WHEN $6='rotated' THEN $10 ELSE rotated_to_pubkey END, + rotation_by=CASE WHEN $6='rotated' THEN $7 ELSE rotation_by END, + rotation_reason=CASE WHEN $6='rotated' THEN $8 ELSE rotation_reason END, + replacement_binding_id=$11, updated_at=NOW() + WHERE community_id=$1 AND binding_id=$2 AND issuer=$3 AND uid=$4 + AND binding_state='active' AND revoked_at IS NULL AND binding_version=$12 + "#, + ) + .bind(community_id.as_uuid()) + .bind(binding.binding_id) + .bind(principal.issuer) + .bind(principal.subject) + .bind(i64_version(next_version)?) + .bind(if transition.replacement.is_some() { + "rotated" + } else { + "revoked" + }) + .bind(context.actor) + .bind(context.reason) + .bind(transition.revocation_scope) + .bind(replacement_pubkey) + .bind(replacement_binding_id) + .bind(i64_version(binding.binding_version)?) + .execute(&mut **tx) + .await?; + if changed.rows_affected() != 1 { + return Err(DbError::InvalidData( + "identity binding changed during lifecycle transition".to_string(), + )); + } + insert_pair_tx(tx, community_id, context, principal, binding, next_version).await?; + let selector_version = if transition.create_pending { + Some(insert_pending_tx(tx, community_id, context, principal, binding, next_version).await?) + } else { + None + }; + append_history_tx( + tx, + community_id, + context, + binding.binding_id, + next_version, + principal, + &binding.pubkey, + if transition.replacement.is_some() { + "rotated" + } else { + "revoked" + }, + binding.provenance, + transition.transition_kind, + replacement_binding_id, + ) + .await?; + Ok((next_version, selector_version)) +} + +fn receipt_from_row(row: &sqlx::postgres::PgRow) -> Result { + let binding_version: Option = row.try_get("binding_version")?; + let replacement_binding_version: Option = row.try_get("replacement_binding_version")?; + let selector_version: Option = row.try_get("selector_version")?; + Ok(LifecycleReceipt { + binding_id: row.try_get("binding_id")?, + replacement_binding_id: row.try_get("replacement_binding_id")?, + binding_version: binding_version.map(u64_version).transpose()?, + replacement_binding_version: replacement_binding_version.map(u64_version).transpose()?, + selector_version: selector_version.map(u64_version).transpose()?, + }) +} + +async fn existing_operation_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + context: LifecycleContext<'_>, + kind: &str, + fingerprint: &[u8], +) -> Result> { + let row = sqlx::query( + r#" + SELECT operation_kind, request_fingerprint, binding_id, + replacement_binding_id, binding_version, + replacement_binding_version, selector_version + FROM identity_lifecycle_operations + WHERE community_id=$1 AND operation_id=$2 + FOR UPDATE + "#, + ) + .bind(community_id.as_uuid()) + .bind(context.operation_id.as_uuid()) + .fetch_optional(&mut **tx) + .await?; + let Some(row) = row else { + return Ok(None); + }; + let existing_kind: String = row.try_get("operation_kind")?; + let existing_fingerprint: Vec = row.try_get("request_fingerprint")?; + if existing_kind != kind || existing_fingerprint != fingerprint { + return Err(DbError::InvalidData( + "identity lifecycle operation ID was reused".to_string(), + )); + } + Ok(Some(receipt_from_row(&row)?)) +} + +struct OperationFinish<'a> { + kind: &'a str, + fingerprint: &'a [u8], + principal: Option>, + pubkey: Option<&'a [u8]>, + replacement_pubkey: Option<&'a [u8]>, + receipt: LifecycleReceipt, +} + +async fn record_operation_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + context: LifecycleContext<'_>, + completion: &OperationFinish<'_>, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO identity_lifecycle_operations + (community_id, operation_id, operation_kind, request_fingerprint, + issuer, subject, pubkey, replacement_pubkey, binding_id, + replacement_binding_id, binding_version, replacement_binding_version, + selector_version, actor, reason) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + "#, + ) + .bind(community_id.as_uuid()) + .bind(context.operation_id.as_uuid()) + .bind(completion.kind) + .bind(completion.fingerprint) + .bind(completion.principal.map(|value| value.issuer)) + .bind(completion.principal.map(|value| value.subject)) + .bind(completion.pubkey) + .bind(completion.replacement_pubkey) + .bind(completion.receipt.binding_id) + .bind(completion.receipt.replacement_binding_id) + .bind( + completion + .receipt + .binding_version + .map(i64_version) + .transpose()?, + ) + .bind( + completion + .receipt + .replacement_binding_version + .map(i64_version) + .transpose()?, + ) + .bind( + completion + .receipt + .selector_version + .map(i64_version) + .transpose()?, + ) + .bind(context.actor) + .bind(context.reason) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn finish( + mut tx: Transaction<'_, Postgres>, + community_id: CommunityId, + context: LifecycleContext<'_>, + completion: OperationFinish<'_>, +) -> Result { + record_operation_tx(&mut tx, community_id, context, &completion).await?; + tx.commit().await?; + Ok(LifecycleResult::Applied(completion.receipt)) +} + +/// Provision a binding only after the server resolves provisioned mode. +pub async fn provision_identity_binding( + pool: &PgPool, + community_id: CommunityId, + context: LifecycleContext<'_>, + principal: IdentityPrincipal<'_>, + enrollment_mode: EnrollmentMode, + replacement: VerifiedReplacementKey<'_>, +) -> Result { + validate_context(context)?; + validate_principal(principal)?; + if enrollment_mode != EnrollmentMode::Provisioned + || replacement.provenance != BindingProvenance::Provisioned + { + return Err(DbError::InvalidData( + "provisioning requires server-resolved provisioned mode".to_string(), + )); + } + let fingerprint = replacement_request_fingerprint( + OP_PROVISION, + community_id, + context, + &[principal.issuer.as_bytes(), principal.subject.as_bytes()], + &replacement, + ); + let coordinates = lifecycle_coordinates( + community_id, + context, + Some(principal), + &[replacement.pubkey], + None, + ); + let mut tx = begin_locked(pool, coordinates).await?; + if let Some(receipt) = + existing_operation_tx(&mut tx, community_id, context, OP_PROVISION, &fingerprint).await? + { + tx.commit().await?; + return Ok(LifecycleResult::AlreadyApplied(receipt)); + } + require_not_quarantined_tx(&mut tx, community_id, principal).await?; + if active_principal_tx(&mut tx, community_id, principal) + .await? + .is_some() + || principal_disabled_tx(&mut tx, community_id, principal).await? + || pending_lineage_tx(&mut tx, community_id, principal) + .await? + .is_some() + { + return Err(DbError::InvalidData( + "identity principal is not eligible for provisioning".to_string(), + )); + } + replacement_eligible_tx(&mut tx, community_id, principal, &replacement).await?; + let binding = insert_binding_tx( + &mut tx, + community_id, + context, + principal, + &replacement, + OP_PROVISION, + ) + .await?; + let receipt = LifecycleReceipt { + binding_id: Some(binding.binding_id), + replacement_binding_id: None, + binding_version: Some(binding.binding_version), + replacement_binding_version: None, + selector_version: None, + }; + finish( + tx, + community_id, + context, + OperationFinish { + kind: OP_PROVISION, + fingerprint: &fingerprint, + principal: Some(principal), + pubkey: None, + replacement_pubkey: Some(replacement.pubkey), + receipt, + }, + ) + .await +} + +/// Retire one exact active pair without revoking its key domain-wide. +pub async fn retire_identity_pair( + pool: &PgPool, + community_id: CommunityId, + context: LifecycleContext<'_>, + principal: IdentityPrincipal<'_>, + pubkey: &[u8], +) -> Result { + validate_context(context)?; + validate_principal(principal)?; + validate_pubkey(pubkey)?; + let fingerprint = request_fingerprint( + OP_RETIRE_PAIR, + community_id, + context, + &[ + principal.issuer.as_bytes(), + principal.subject.as_bytes(), + pubkey, + ], + ); + let coordinates = + lifecycle_coordinates(community_id, context, Some(principal), &[pubkey], None); + let mut tx = begin_locked(pool, coordinates).await?; + if let Some(receipt) = + existing_operation_tx(&mut tx, community_id, context, OP_RETIRE_PAIR, &fingerprint).await? + { + tx.commit().await?; + return Ok(LifecycleResult::AlreadyApplied(receipt)); + } + require_not_quarantined_tx(&mut tx, community_id, principal).await?; + let active = active_principal_tx(&mut tx, community_id, principal) + .await? + .filter(|binding| binding.pubkey == pubkey) + .ok_or_else(|| DbError::InvalidData("identity pair is not active".to_string()))?; + let (version, selector_version) = retire_active_tx( + &mut tx, + community_id, + context, + principal, + &active, + RetireActive { + transition_kind: OP_RETIRE_PAIR, + revocation_scope: "rotation", + replacement: None, + create_pending: true, + }, + ) + .await?; + let receipt = LifecycleReceipt { + binding_id: Some(active.binding_id), + replacement_binding_id: None, + binding_version: Some(version), + replacement_binding_version: None, + selector_version, + }; + finish( + tx, + community_id, + context, + OperationFinish { + kind: OP_RETIRE_PAIR, + fingerprint: &fingerprint, + principal: Some(principal), + pubkey: Some(pubkey), + replacement_pubkey: None, + receipt, + }, + ) + .await +} + +/// Disable an exact principal, preserving any existing pending lineage. +pub async fn disable_identity_principal( + pool: &PgPool, + community_id: CommunityId, + context: LifecycleContext<'_>, + principal: IdentityPrincipal<'_>, +) -> Result { + validate_context(context)?; + validate_principal(principal)?; + let fingerprint = request_fingerprint( + OP_DISABLE, + community_id, + context, + &[principal.issuer.as_bytes(), principal.subject.as_bytes()], + ); + let coordinates = lifecycle_coordinates(community_id, context, Some(principal), &[], None); + let mut tx = begin_locked(pool, coordinates).await?; + if let Some(receipt) = + existing_operation_tx(&mut tx, community_id, context, OP_DISABLE, &fingerprint).await? + { + tx.commit().await?; + return Ok(LifecycleResult::AlreadyApplied(receipt)); + } + require_not_quarantined_tx(&mut tx, community_id, principal).await?; + let active = active_principal_tx(&mut tx, community_id, principal).await?; + let pending = pending_lineage_tx(&mut tx, community_id, principal).await?; + if active.is_some() && pending.is_some() { + return Err(DbError::InvalidData( + "active identity binding overlaps pending lineage".to_string(), + )); + } + sqlx::query( + r#" + INSERT INTO identity_principals + (community_id, issuer, uid, disabled_at, disabled_by, disabled_reason) + VALUES ($1,$2,$3,NOW(),$4,$5) + ON CONFLICT (community_id, issuer, uid) DO UPDATE + SET disabled_at=COALESCE(identity_principals.disabled_at, NOW()), + disabled_by=COALESCE(identity_principals.disabled_by, EXCLUDED.disabled_by), + disabled_reason=COALESCE(identity_principals.disabled_reason, EXCLUDED.disabled_reason) + "#, + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .bind(context.actor) + .bind(context.reason) + .execute(&mut *tx) + .await?; + let (binding_id, version, selector_version) = if let Some(active) = active.as_ref() { + let (version, selector_version) = retire_active_tx( + &mut tx, + community_id, + context, + principal, + active, + RetireActive { + transition_kind: OP_DISABLE, + revocation_scope: "principal", + replacement: None, + create_pending: true, + }, + ) + .await?; + (Some(active.binding_id), Some(version), selector_version) + } else { + (None, None, pending.map(|value| value.selector_version)) + }; + let receipt = LifecycleReceipt { + binding_id, + replacement_binding_id: None, + binding_version: version, + replacement_binding_version: None, + selector_version, + }; + finish( + tx, + community_id, + context, + OperationFinish { + kind: OP_DISABLE, + fingerprint: &fingerprint, + principal: Some(principal), + pubkey: None, + replacement_pubkey: None, + receipt, + }, + ) + .await +} + +/// Revoke one key throughout a server-resolved authorization domain. +pub async fn revoke_identity_key( + pool: &PgPool, + community_id: CommunityId, + context: LifecycleContext<'_>, + pubkey: &[u8], +) -> Result { + validate_context(context)?; + validate_pubkey(pubkey)?; + let fingerprint = request_fingerprint(OP_REVOKE_KEY, community_id, context, &[pubkey]); + let coordinates = lifecycle_coordinates(community_id, context, None, &[pubkey], None); + let mut tx = begin_locked(pool, coordinates).await?; + if let Some(receipt) = + existing_operation_tx(&mut tx, community_id, context, OP_REVOKE_KEY, &fingerprint).await? + { + tx.commit().await?; + verify_key_revocation_committed(pool, community_id, context, pubkey, None).await?; + return Ok(LifecycleResult::AlreadyApplied(receipt)); + } + let active = active_key_tx(&mut tx, community_id, pubkey).await?; + let principal = active.as_ref().map(|binding| IdentityPrincipal { + issuer: binding.issuer.as_str(), + subject: binding.subject.as_str(), + }); + if let Some(principal) = principal { + require_not_quarantined_tx(&mut tx, community_id, principal).await?; + if pending_lineage_tx(&mut tx, community_id, principal) + .await? + .is_some() + { + return Err(DbError::InvalidData( + "active identity binding overlaps pending lineage".to_string(), + )); + } + } + sqlx::query( + r#" + INSERT INTO identity_revoked_keys + (community_id, pubkey, revoked_at, revoked_by, reason) + VALUES ($1,$2,NOW(),$3,$4) + ON CONFLICT (community_id, pubkey) DO NOTHING + "#, + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .bind(context.actor) + .bind(context.reason) + .execute(&mut *tx) + .await?; + let (binding_id, version, selector_version) = + if let (Some(active), Some(principal)) = (active.as_ref(), principal) { + let (version, selector_version) = retire_active_tx( + &mut tx, + community_id, + context, + principal, + active, + RetireActive { + transition_kind: OP_REVOKE_KEY, + revocation_scope: "key", + replacement: None, + create_pending: true, + }, + ) + .await?; + (Some(active.binding_id), Some(version), selector_version) + } else { + (None, None, None) + }; + let receipt = LifecycleReceipt { + binding_id, + replacement_binding_id: None, + binding_version: version, + replacement_binding_version: None, + selector_version, + }; + let outcome = finish( + tx, + community_id, + context, + OperationFinish { + kind: OP_REVOKE_KEY, + fingerprint: &fingerprint, + principal, + pubkey: Some(pubkey), + replacement_pubkey: None, + receipt, + }, + ) + .await?; + verify_key_revocation_committed( + pool, + community_id, + context, + pubkey, + active.as_ref().zip(version), + ) + .await?; + Ok(outcome) +} + +async fn link_replacement_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + context: LifecycleContext<'_>, + old_binding_id: Uuid, + replacement_binding_id: Uuid, + replacement_pubkey: &[u8], +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO identity_binding_lineage + (community_id, predecessor_binding_id, successor_binding_id) + VALUES ($1,$2,$3) + "#, + ) + .bind(community_id.as_uuid()) + .bind(old_binding_id) + .bind(replacement_binding_id) + .execute(&mut **tx) + .await?; + sqlx::query( + "UPDATE identity_bindings \ + SET replacement_binding_id=$3, \ + rotation_completed_at=COALESCE(rotation_completed_at, NOW()), \ + rotated_to_pubkey=COALESCE(rotated_to_pubkey, $4), \ + rotation_by=COALESCE(rotation_by, $5), \ + rotation_reason=COALESCE(rotation_reason, $6), updated_at=NOW() \ + WHERE community_id=$1 AND binding_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(old_binding_id) + .bind(replacement_binding_id) + .bind(replacement_pubkey) + .bind(context.actor) + .bind(context.reason) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn verify_key_revocation_committed( + pool: &PgPool, + community_id: CommunityId, + context: LifecycleContext<'_>, + pubkey: &[u8], + retired: Option<(&ActiveBinding, u64)>, +) -> Result<()> { + let selector_and_operation: (bool, bool) = sqlx::query_as( + "SELECT \ + EXISTS(SELECT 1 FROM identity_revoked_keys WHERE community_id=$1 AND pubkey=$2), \ + EXISTS(SELECT 1 FROM identity_lifecycle_operations WHERE community_id=$1 AND operation_id=$3 AND operation_kind='revoke_key')", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .bind(context.operation_id.as_uuid()) + .fetch_one(pool) + .await?; + if selector_and_operation != (true, true) { + return Err(DbError::InvalidData( + "committed identity key revocation could not be verified".to_string(), + )); + } + if let Some((binding, retired_version)) = retired { + let pair: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM identity_retired_pairs \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 AND pubkey=$4 \ + AND retired_binding_id=$5 AND retired_binding_version=$6)", + ) + .bind(community_id.as_uuid()) + .bind(&binding.issuer) + .bind(&binding.subject) + .bind(pubkey) + .bind(binding.binding_id) + .bind(i64_version(retired_version)?) + .fetch_one(pool) + .await?; + if !pair { + return Err(DbError::InvalidData( + "committed identity pair retirement could not be verified".to_string(), + )); + } + } + Ok(()) +} + +/// Atomically replace one active pair without creating a domain key tombstone. +pub async fn rotate_identity_binding( + pool: &PgPool, + community_id: CommunityId, + context: LifecycleContext<'_>, + principal: IdentityPrincipal<'_>, + old_pubkey: &[u8], + replacement: VerifiedReplacementKey<'_>, +) -> Result { + validate_context(context)?; + validate_principal(principal)?; + validate_pubkey(old_pubkey)?; + if old_pubkey == replacement.pubkey { + return Err(DbError::InvalidData( + "identity rotation requires a different key".to_string(), + )); + } + let fingerprint = replacement_request_fingerprint( + OP_ROTATE, + community_id, + context, + &[ + principal.issuer.as_bytes(), + principal.subject.as_bytes(), + old_pubkey, + ], + &replacement, + ); + let coordinates = lifecycle_coordinates( + community_id, + context, + Some(principal), + &[old_pubkey, replacement.pubkey], + None, + ); + let mut tx = begin_locked(pool, coordinates).await?; + if let Some(receipt) = + existing_operation_tx(&mut tx, community_id, context, OP_ROTATE, &fingerprint).await? + { + tx.commit().await?; + return Ok(LifecycleResult::AlreadyApplied(receipt)); + } + require_not_quarantined_tx(&mut tx, community_id, principal).await?; + require_key_not_quarantined_tx(&mut tx, community_id, old_pubkey).await?; + if key_revoked_tx(&mut tx, community_id, old_pubkey).await? { + return Err(DbError::InvalidData( + "revoked identity key cannot rotate".to_string(), + )); + } + if principal_disabled_tx(&mut tx, community_id, principal).await? { + return Err(DbError::InvalidData( + "disabled identity cannot rotate".to_string(), + )); + } + let active = active_principal_tx(&mut tx, community_id, principal) + .await? + .filter(|binding| binding.pubkey == old_pubkey) + .ok_or_else(|| DbError::InvalidData("rotation source is not active".to_string()))?; + replacement_eligible_tx(&mut tx, community_id, principal, &replacement).await?; + if pending_lineage_tx(&mut tx, community_id, principal) + .await? + .is_some() + { + return Err(DbError::InvalidData( + "rotation cannot consume pending lineage".to_string(), + )); + } + let replacement_binding_id = Uuid::new_v4(); + let (old_version, _) = retire_active_tx( + &mut tx, + community_id, + context, + principal, + &active, + RetireActive { + transition_kind: OP_ROTATE, + revocation_scope: "rotation", + replacement: Some((replacement.pubkey, replacement_binding_id)), + create_pending: false, + }, + ) + .await?; + let new_version = 1; + sqlx::query( + r#" + INSERT INTO identity_bindings + (community_id, issuer, uid, pubkey, display_name, source, binding_id, + binding_version, binding_state, binding_provenance, created_by, + created_policy_version, creation_attribution_kind) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'active',$9,$10,$11,$12) + "#, + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .bind(replacement.pubkey) + .bind(replacement.display_name) + .bind(replacement.provenance.legacy_source()) + .bind(replacement_binding_id) + .bind(i64_version(new_version)?) + .bind(replacement.provenance.as_str()) + .bind(context.actor) + .bind(replacement.created_policy_version) + .bind(CreationAttributionKind::Operator.as_str()) + .execute(&mut *tx) + .await?; + append_history_tx( + &mut tx, + community_id, + context, + replacement_binding_id, + new_version, + principal, + replacement.pubkey, + "active", + replacement.provenance, + OP_ROTATE, + None, + ) + .await?; + link_replacement_tx( + &mut tx, + community_id, + context, + active.binding_id, + replacement_binding_id, + replacement.pubkey, + ) + .await?; + let receipt = LifecycleReceipt { + binding_id: Some(active.binding_id), + replacement_binding_id: Some(replacement_binding_id), + binding_version: Some(old_version), + replacement_binding_version: Some(new_version), + selector_version: None, + }; + finish( + tx, + community_id, + context, + OperationFinish { + kind: OP_ROTATE, + fingerprint: &fingerprint, + principal: Some(principal), + pubkey: Some(old_pubkey), + replacement_pubkey: Some(replacement.pubkey), + receipt, + }, + ) + .await +} + +async fn compare_and_clear_pending_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + context: LifecycleContext<'_>, + principal: IdentityPrincipal<'_>, + expected: &PendingLineage, +) -> Result<()> { + let changed = sqlx::query( + r#" + UPDATE identity_pending_replacements + SET cleared_at=NOW(), cleared_operation_id=$8 + WHERE community_id=$1 AND issuer=$2 AND subject=$3 + AND retired_pubkey=$4 AND retired_binding_id=$5 + AND retired_binding_version=$6 AND selector_version=$7 + AND cleared_at IS NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .bind(&expected.retired_pubkey) + .bind(expected.retired_binding_id) + .bind(i64_version(expected.retired_binding_version)?) + .bind(i64_version(expected.selector_version)?) + .bind(context.operation_id.as_uuid()) + .execute(&mut **tx) + .await?; + if changed.rows_affected() != 1 { + return Err(DbError::InvalidData( + "pending identity lineage changed concurrently".to_string(), + )); + } + Ok(()) +} + +async fn require_expected_pending_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + principal: IdentityPrincipal<'_>, + expected: &PendingLineage, +) -> Result<()> { + let current = pending_lineage_tx(tx, community_id, principal) + .await? + .filter(|current| current == expected) + .ok_or_else(|| DbError::InvalidData("pending identity lineage is stale".to_string()))?; + let retired: bool = sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 FROM identity_retired_pairs + WHERE community_id=$1 AND issuer=$2 AND subject=$3 AND pubkey=$4 + AND retired_binding_id=$5 AND retired_binding_version=$6 + ) + "#, + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .bind(¤t.retired_pubkey) + .bind(current.retired_binding_id) + .bind(i64_version(current.retired_binding_version)?) + .fetch_one(&mut **tx) + .await?; + if !retired { + return Err(DbError::InvalidData( + "pending identity lineage lacks an exact retired pair".to_string(), + )); + } + Ok(()) +} + +/// Recover a non-disabled principal using exact compare-and-clear lineage. +pub async fn recover_identity_binding( + pool: &PgPool, + community_id: CommunityId, + context: LifecycleContext<'_>, + principal: IdentityPrincipal<'_>, + expected: &PendingLineage, + replacement: VerifiedReplacementKey<'_>, +) -> Result { + validate_context(context)?; + validate_principal(principal)?; + validate_pubkey(&expected.retired_pubkey)?; + let retired_binding_version = expected.retired_binding_version.to_be_bytes(); + let selector_version = expected.selector_version.to_be_bytes(); + let fingerprint = replacement_request_fingerprint( + OP_RECOVER, + community_id, + context, + &[ + principal.issuer.as_bytes(), + principal.subject.as_bytes(), + &expected.retired_pubkey, + expected.retired_binding_id.as_bytes(), + &retired_binding_version, + &selector_version, + ], + &replacement, + ); + let coordinates = lifecycle_coordinates( + community_id, + context, + Some(principal), + &[&expected.retired_pubkey, replacement.pubkey], + Some(expected.retired_binding_id), + ); + let mut tx = begin_locked(pool, coordinates).await?; + if let Some(receipt) = + existing_operation_tx(&mut tx, community_id, context, OP_RECOVER, &fingerprint).await? + { + tx.commit().await?; + return Ok(LifecycleResult::AlreadyApplied(receipt)); + } + require_not_quarantined_tx(&mut tx, community_id, principal).await?; + require_key_not_quarantined_tx(&mut tx, community_id, &expected.retired_pubkey).await?; + if principal_disabled_tx(&mut tx, community_id, principal).await? + || active_principal_tx(&mut tx, community_id, principal) + .await? + .is_some() + { + return Err(DbError::InvalidData( + "identity principal is not eligible for recovery".to_string(), + )); + } + require_expected_pending_tx(&mut tx, community_id, principal, expected).await?; + replacement_eligible_tx(&mut tx, community_id, principal, &replacement).await?; + compare_and_clear_pending_tx(&mut tx, community_id, context, principal, expected).await?; + let binding = insert_binding_tx( + &mut tx, + community_id, + context, + principal, + &replacement, + OP_RECOVER, + ) + .await?; + link_replacement_tx( + &mut tx, + community_id, + context, + expected.retired_binding_id, + binding.binding_id, + replacement.pubkey, + ) + .await?; + let receipt = LifecycleReceipt { + binding_id: Some(expected.retired_binding_id), + replacement_binding_id: Some(binding.binding_id), + binding_version: Some(expected.retired_binding_version), + replacement_binding_version: Some(binding.binding_version), + selector_version: Some(expected.selector_version), + }; + finish( + tx, + community_id, + context, + OperationFinish { + kind: OP_RECOVER, + fingerprint: &fingerprint, + principal: Some(principal), + pubkey: Some(&expected.retired_pubkey), + replacement_pubkey: Some(replacement.pubkey), + receipt, + }, + ) + .await +} + +/// Re-enable a disabled principal and optionally clear exact pending lineage. +pub async fn enable_identity_principal( + pool: &PgPool, + community_id: CommunityId, + context: LifecycleContext<'_>, + principal: IdentityPrincipal<'_>, + expected: Option<&PendingLineage>, + replacement: VerifiedReplacementKey<'_>, +) -> Result { + validate_context(context)?; + validate_principal(principal)?; + let empty = []; + let expected_presence = [u8::from(expected.is_some())]; + let expected_key = expected.map_or(empty.as_slice(), |value| value.retired_pubkey.as_slice()); + let expected_binding_id = expected + .map(|value| value.retired_binding_id.as_bytes().as_slice()) + .unwrap_or(empty.as_slice()); + let expected_binding_version = expected + .map(|value| value.retired_binding_version) + .unwrap_or_default() + .to_be_bytes(); + let expected_selector_version = expected + .map(|value| value.selector_version) + .unwrap_or_default() + .to_be_bytes(); + let fingerprint = replacement_request_fingerprint( + OP_ENABLE, + community_id, + context, + &[ + principal.issuer.as_bytes(), + principal.subject.as_bytes(), + &expected_presence, + expected_key, + expected_binding_id, + &expected_binding_version, + &expected_selector_version, + ], + &replacement, + ); + let mut keys = vec![replacement.pubkey]; + if let Some(expected) = expected { + validate_pubkey(&expected.retired_pubkey)?; + keys.push(&expected.retired_pubkey); + } + let coordinates = lifecycle_coordinates( + community_id, + context, + Some(principal), + &keys, + expected.map(|value| value.retired_binding_id), + ); + let mut tx = begin_locked(pool, coordinates).await?; + if let Some(receipt) = + existing_operation_tx(&mut tx, community_id, context, OP_ENABLE, &fingerprint).await? + { + tx.commit().await?; + return Ok(LifecycleResult::AlreadyApplied(receipt)); + } + require_not_quarantined_tx(&mut tx, community_id, principal).await?; + if let Some(expected) = expected { + require_key_not_quarantined_tx(&mut tx, community_id, &expected.retired_pubkey).await?; + } + if !principal_disabled_tx(&mut tx, community_id, principal).await? + || active_principal_tx(&mut tx, community_id, principal) + .await? + .is_some() + { + return Err(DbError::InvalidData( + "identity principal is not eligible for re-enablement".to_string(), + )); + } + let current = pending_lineage_tx(&mut tx, community_id, principal).await?; + match (current.as_ref(), expected) { + (Some(current), Some(expected)) if current == expected => { + require_expected_pending_tx(&mut tx, community_id, principal, expected).await?; + } + (None, None) => {} + _ => { + return Err(DbError::InvalidData( + "pending identity lineage is stale".to_string(), + )) + } + } + replacement_eligible_tx(&mut tx, community_id, principal, &replacement).await?; + if let Some(expected) = expected { + compare_and_clear_pending_tx(&mut tx, community_id, context, principal, expected).await?; + } + let binding = insert_binding_tx( + &mut tx, + community_id, + context, + principal, + &replacement, + OP_ENABLE, + ) + .await?; + let changed = sqlx::query( + r#" + UPDATE identity_principals + SET disabled_at=NULL, disabled_by=NULL, disabled_reason=NULL + WHERE community_id=$1 AND issuer=$2 AND uid=$3 AND disabled_at IS NOT NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .execute(&mut *tx) + .await?; + if changed.rows_affected() != 1 { + return Err(DbError::InvalidData( + "identity disablement changed concurrently".to_string(), + )); + } + if let Some(expected) = expected { + link_replacement_tx( + &mut tx, + community_id, + context, + expected.retired_binding_id, + binding.binding_id, + replacement.pubkey, + ) + .await?; + } + let receipt = expected.map_or_else( + || LifecycleReceipt { + binding_id: Some(binding.binding_id), + replacement_binding_id: None, + binding_version: Some(binding.binding_version), + replacement_binding_version: None, + selector_version: None, + }, + |expected| LifecycleReceipt { + binding_id: Some(expected.retired_binding_id), + replacement_binding_id: Some(binding.binding_id), + binding_version: Some(expected.retired_binding_version), + replacement_binding_version: Some(binding.binding_version), + selector_version: Some(expected.selector_version), + }, + ); + finish( + tx, + community_id, + context, + OperationFinish { + kind: OP_ENABLE, + fingerprint: &fingerprint, + principal: Some(principal), + pubkey: expected.map(|value| value.retired_pubkey.as_slice()), + replacement_pubkey: Some(replacement.pubkey), + receipt, + }, + ) + .await +} + +/// Archive one already-inactive binding with complete actor and reason +/// attribution. Archival never creates or restores authorization. +pub async fn archive_identity_binding( + pool: &PgPool, + community_id: CommunityId, + context: LifecycleContext<'_>, + binding_id: Uuid, +) -> Result { + validate_context(context)?; + if binding_id.is_nil() { + return Err(DbError::InvalidData( + "identity archive requires a binding ID".to_string(), + )); + } + let fingerprint = + request_fingerprint(OP_ARCHIVE, community_id, context, &[binding_id.as_bytes()]); + let coordinates = lifecycle_coordinates(community_id, context, None, &[], Some(binding_id)); + let mut tx = begin_locked(pool, coordinates).await?; + if let Some(receipt) = + existing_operation_tx(&mut tx, community_id, context, OP_ARCHIVE, &fingerprint).await? + { + tx.commit().await?; + return Ok(LifecycleResult::AlreadyApplied(receipt)); + } + let row = sqlx::query( + r#" + SELECT issuer, uid, pubkey, binding_version, binding_provenance, + replacement_binding_id + FROM identity_bindings + WHERE community_id=$1 AND binding_id=$2 + AND binding_state IN ('revoked', 'rotated') AND revoked_at IS NOT NULL + FOR UPDATE + "#, + ) + .bind(community_id.as_uuid()) + .bind(binding_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| DbError::InvalidData("identity binding is not archivable".to_string()))?; + let issuer: String = row.try_get("issuer")?; + let subject: String = row.try_get("uid")?; + let pubkey: Vec = row.try_get("pubkey")?; + let version = u64_version(row.try_get("binding_version")?)?; + let provenance = BindingProvenance::parse(row.try_get("binding_provenance")?)?; + let replacement_binding_id: Option = row.try_get("replacement_binding_id")?; + let principal = IdentityPrincipal { + issuer: &issuer, + subject: &subject, + }; + let changed = sqlx::query( + r#" + UPDATE identity_bindings + SET binding_state='archived', archived_at=NOW(), archived_by=$3, + archived_reason=$4, updated_at=NOW() + WHERE community_id=$1 AND binding_id=$2 + AND binding_state IN ('revoked', 'rotated') AND revoked_at IS NOT NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(binding_id) + .bind(context.actor) + .bind(context.reason) + .execute(&mut *tx) + .await?; + if changed.rows_affected() != 1 { + return Err(DbError::InvalidData( + "identity binding changed during archive".to_string(), + )); + } + append_history_tx( + &mut tx, + community_id, + context, + binding_id, + version, + principal, + &pubkey, + "archived", + provenance, + OP_ARCHIVE, + replacement_binding_id, + ) + .await?; + let receipt = LifecycleReceipt { + binding_id: Some(binding_id), + replacement_binding_id: None, + binding_version: Some(version), + replacement_binding_version: None, + selector_version: None, + }; + finish( + tx, + community_id, + context, + OperationFinish { + kind: OP_ARCHIVE, + fingerprint: &fingerprint, + principal: Some(principal), + pubkey: Some(&pubkey), + replacement_pubkey: None, + receipt, + }, + ) + .await +} + +#[cfg(test)] +#[path = "identity_lifecycle_deterministic_tests.rs"] +mod deterministic_tests; + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::identity_binding::{ + resolve_identity_binding, BindingDenial, ResolveBindingInput, ResolveBindingResult, + }; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const ISSUER: &str = "https://idp.example"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect test DB"); + crate::migration::run_migrations(&pool) + .await + .expect("run migrations"); + pool + } + + async fn make_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(id) + .bind(format!("identity-lifecycle-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert community"); + CommunityId::from_uuid(id) + } + + async fn enroll( + pool: &PgPool, + community_id: CommunityId, + subject: &str, + pubkey: &[u8], + ) -> BindingEvidence { + match resolve_identity_binding( + pool, + &ResolveBindingInput { + authorization_domain: community_id, + issuer: ISSUER, + subject, + pubkey, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("resolve enrollment") + { + ResolveBindingResult::Enrolled(evidence) => evidence, + other => panic!("unexpected enrollment result: {other:?}"), + } + } + + fn context<'a>(reason: &'a str) -> LifecycleContext<'a> { + const ACTOR: [u8; 32] = [0xA3; 32]; + LifecycleContext { + operation_id: LifecycleOperationId::issue(), + actor: &ACTOR, + reason, + } + } + + fn replacement(pubkey: &[u8]) -> VerifiedReplacementKey<'_> { + VerifiedReplacementKey::after_verified_proof( + pubkey, + None, + BindingProvenance::AttestedKey, + "test-policy-v1", + ) + .expect("verified replacement") + } + + #[test] + fn privileged_replacement_rejects_tofu() { + assert!(VerifiedReplacementKey::after_verified_proof( + &[1_u8; 32], + None, + BindingProvenance::Tofu, + "test-policy-v1", + ) + .is_err()); + } + + #[test] + fn lifecycle_reason_must_contain_non_whitespace_attribution() { + assert!(validate_context(context(" \t\n ")).is_err()); + } + + #[test] + fn server_operation_identifier_is_opaque_and_redacted() { + let raw = Uuid::from_u128(0x1234); + let identifier = LifecycleOperationId::from_uuid_for_test(raw); + assert_eq!(identifier.as_uuid(), raw); + assert!(!format!("{identifier:?}").contains(&raw.to_string())); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn inactive_binding_archive_is_attributed_and_idempotent() { + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let pubkey = [0xA5_u8; 32]; + let evidence = enroll(&pool, community_id, "archive-subject", &pubkey).await; + retire_identity_pair( + &pool, + community_id, + context("retire before archive"), + IdentityPrincipal { + issuer: ISSUER, + subject: "archive-subject", + }, + &pubkey, + ) + .await + .expect("retire binding"); + let pending_before = get_pending_lineage( + &pool, + community_id, + IdentityPrincipal { + issuer: ISSUER, + subject: "archive-subject", + }, + ) + .await + .expect("read pending lineage before archive") + .expect("retirement creates pending lineage"); + let archive_context = context("archive inactive binding"); + let applied = + archive_identity_binding(&pool, community_id, archive_context, evidence.binding_id()) + .await + .expect("archive binding"); + assert!(matches!(applied, LifecycleResult::Applied(_))); + let replay = + archive_identity_binding(&pool, community_id, archive_context, evidence.binding_id()) + .await + .expect("replay archive"); + assert!(matches!(replay, LifecycleResult::AlreadyApplied(_))); + type ArchivedBindingRow = ( + String, + Option>, + Option>, + Option, + i64, + ); + let archived: ArchivedBindingRow = sqlx::query_as( + "SELECT binding_state, archived_at, archived_by, archived_reason, binding_version \ + FROM identity_bindings \ + WHERE community_id=$1 AND binding_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(evidence.binding_id()) + .fetch_one(&pool) + .await + .expect("read archived binding"); + assert_eq!(archived.0, "archived"); + assert!(archived.1.is_some()); + assert_eq!(archived.2.as_deref(), Some(archive_context.actor)); + assert_eq!(archived.3.as_deref(), Some(archive_context.reason)); + assert_eq!( + u64::try_from(archived.4).unwrap(), + pending_before.retired_binding_version + ); + assert_eq!( + get_pending_lineage( + &pool, + community_id, + IdentityPrincipal { + issuer: ISSUER, + subject: "archive-subject", + }, + ) + .await + .expect("read pending lineage after archive") + .expect("archive preserves recovery lineage"), + pending_before + ); + let archive_history: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_binding_history \ + WHERE community_id=$1 AND binding_id=$2 AND transition_kind='archive'", + ) + .bind(community_id.as_uuid()) + .bind(evidence.binding_id()) + .fetch_one(&pool) + .await + .expect("count archive history"); + assert_eq!(archive_history, 1); + let ordinary = resolve_identity_binding( + &pool, + &ResolveBindingInput { + authorization_domain: community_id, + issuer: ISSUER, + subject: "archive-subject", + pubkey: &pubkey, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("ordinary authorization returns typed denial"); + assert_eq!( + ordinary, + ResolveBindingResult::Denied(BindingDenial::Revoked) + ); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn committed_rotation_replays_exact_receipt_after_response_loss_and_new_pool() { + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let principal = IdentityPrincipal { + issuer: ISSUER, + subject: "response-loss-rotation", + }; + let old_key = [0xB1_u8; 32]; + let new_key = [0xB2_u8; 32]; + let old_evidence = enroll(&pool, community_id, principal.subject, &old_key).await; + let request = LifecycleContext { + operation_id: LifecycleOperationId::issue(), + actor: &old_key, + reason: "rotate with lost response", + }; + let first = rotate_identity_binding( + &pool, + community_id, + request, + principal, + &old_key, + replacement(&new_key), + ) + .await + .expect("commit rotation before simulated response loss"); + let LifecycleResult::Applied(applied_receipt) = first else { + panic!("first rotation must apply"); + }; + assert_eq!(applied_receipt.binding_id, Some(old_evidence.binding_id())); + drop(pool); + + let restarted_pool = setup_pool().await; + let replay = rotate_identity_binding( + &restarted_pool, + community_id, + request, + principal, + &old_key, + replacement(&new_key), + ) + .await + .expect("retry exact rotation through a new pool"); + assert_eq!( + replay, + LifecycleResult::AlreadyApplied(applied_receipt.clone()) + ); + let counts: (i64, i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_lifecycle_operations \ + WHERE community_id=$1 AND operation_id=$2)", + ) + .bind(community_id.as_uuid()) + .bind(request.operation_id.as_uuid()) + .fetch_one(&restarted_pool) + .await + .expect("read response-loss retry counts"); + assert_eq!(counts, (2, 3, 1)); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn pending_lineage_is_cleared_only_by_exact_compare_and_clear() { + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let principal = IdentityPrincipal { + issuer: ISSUER, + subject: "cas-subject", + }; + let retired_key = [1_u8; 32]; + let replacement_key = [2_u8; 32]; + let retired_evidence = enroll(&pool, community_id, principal.subject, &retired_key).await; + revoke_identity_key( + &pool, + community_id, + context("retire for recovery"), + &retired_key, + ) + .await + .expect("revoke old key"); + let expected = get_pending_lineage(&pool, community_id, principal) + .await + .expect("read pending") + .expect("pending selector"); + let mut wrong_key = expected.clone(); + wrong_key.retired_pubkey = vec![3_u8; 32]; + let mut wrong_id = expected.clone(); + wrong_id.retired_binding_id = Uuid::new_v4(); + let mut wrong_version = expected.clone(); + wrong_version.retired_binding_version += 1; + let mut wrong_selector = expected.clone(); + wrong_selector.selector_version += 1; + for stale in [wrong_key, wrong_id, wrong_version, wrong_selector] { + assert!(recover_identity_binding( + &pool, + community_id, + context("stale recovery"), + principal, + &stale, + replacement(&replacement_key), + ) + .await + .is_err()); + assert_eq!( + get_pending_lineage(&pool, community_id, principal) + .await + .expect("read unchanged pending"), + Some(expected.clone()) + ); + } + + let recovered = recover_identity_binding( + &pool, + community_id, + context("exact recovery"), + principal, + &expected, + replacement(&replacement_key), + ) + .await + .expect("recover by exact selector"); + let LifecycleResult::Applied(receipt) = recovered else { + panic!("first exact recovery must apply"); + }; + assert_eq!(receipt.binding_id, Some(retired_evidence.binding_id())); + assert_eq!( + receipt.binding_version, + Some(expected.retired_binding_version) + ); + assert!(receipt.replacement_binding_id.is_some()); + assert_ne!(receipt.replacement_binding_id, receipt.binding_id); + assert_eq!(receipt.replacement_binding_version, Some(1)); + assert_eq!(receipt.selector_version, Some(expected.selector_version)); + assert!(get_pending_lineage(&pool, community_id, principal) + .await + .expect("read cleared pending") + .is_none()); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn enablement_requires_exact_pending_lineage_and_restores_authority() { + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let principal = IdentityPrincipal { + issuer: ISSUER, + subject: "enable-cas-subject", + }; + let retired_key = [4_u8; 32]; + let replacement_key = [5_u8; 32]; + let retired_evidence = enroll(&pool, community_id, principal.subject, &retired_key).await; + disable_identity_principal( + &pool, + community_id, + context("disable before enable"), + principal, + ) + .await + .expect("disable identity"); + let expected = get_pending_lineage(&pool, community_id, principal) + .await + .expect("read enable pending") + .expect("enable pending selector"); + let mut stale = expected.clone(); + stale.selector_version += 1; + assert!(enable_identity_principal( + &pool, + community_id, + context("stale enable"), + principal, + Some(&stale), + replacement(&replacement_key), + ) + .await + .is_err()); + let enable_request = context("exact enable"); + let applied = enable_identity_principal( + &pool, + community_id, + enable_request, + principal, + Some(&expected), + replacement(&replacement_key), + ) + .await + .expect("enable by exact selector"); + let LifecycleResult::Applied(receipt) = applied else { + panic!("first exact enablement must apply"); + }; + assert_eq!(receipt.binding_id, Some(retired_evidence.binding_id())); + assert_eq!( + receipt.binding_version, + Some(expected.retired_binding_version) + ); + assert!(receipt.replacement_binding_id.is_some()); + assert_ne!(receipt.replacement_binding_id, receipt.binding_id); + assert_eq!(receipt.replacement_binding_version, Some(1)); + assert_eq!(receipt.selector_version, Some(expected.selector_version)); + assert_eq!( + enable_identity_principal( + &pool, + community_id, + enable_request, + principal, + Some(&expected), + replacement(&replacement_key), + ) + .await + .expect("replay exact enablement"), + LifecycleResult::AlreadyApplied(receipt) + ); + let resolved = resolve_identity_binding( + &pool, + &ResolveBindingInput { + authorization_domain: community_id, + issuer: principal.issuer, + subject: principal.subject, + pubkey: &replacement_key, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("authorize enabled identity"); + assert!(matches!(resolved, ResolveBindingResult::Existing(_))); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn operation_id_reuse_rejects_changed_replacement_and_pending_inputs() { + let pool = setup_pool().await; + for variant in 0_u8..2 { + let community_id = make_community(&pool).await; + let subject = format!("fingerprint-replacement-{variant}"); + let principal = IdentityPrincipal { + issuer: ISSUER, + subject: &subject, + }; + let key = [60_u8 + variant; 32]; + let operation_id = Uuid::new_v4(); + let request = LifecycleContext { + operation_id: LifecycleOperationId::from_uuid_for_test(operation_id), + actor: &key, + reason: "fingerprint replacement request", + }; + let original = VerifiedReplacementKey::after_verified_proof( + &key, + None, + BindingProvenance::Provisioned, + "policy-v1", + ) + .expect("construct original replacement"); + provision_identity_binding( + &pool, + community_id, + request, + principal, + EnrollmentMode::Provisioned, + original, + ) + .await + .expect("apply original operation"); + let changed = match variant { + 0 => VerifiedReplacementKey::after_verified_proof( + &key, + Some("changed display"), + BindingProvenance::AttestedKey, + "policy-v1", + ), + _ => VerifiedReplacementKey::after_verified_proof( + &key, + None, + BindingProvenance::Provisioned, + "policy-v2", + ), + } + .expect("construct changed replacement"); + assert!(provision_identity_binding( + &pool, + community_id, + request, + principal, + EnrollmentMode::Provisioned, + changed, + ) + .await + .is_err()); + let counts: (i64, i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_lifecycle_operations WHERE community_id=$1)", + ) + .bind(community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("read replacement fingerprint counts"); + assert_eq!(counts, (1, 1, 1)); + } + + let community_id = make_community(&pool).await; + let principal = IdentityPrincipal { + issuer: ISSUER, + subject: "fingerprint-provenance", + }; + let old_key = [64_u8; 32]; + let new_key = [65_u8; 32]; + let old_evidence = enroll(&pool, community_id, principal.subject, &old_key).await; + let request = LifecycleContext { + operation_id: LifecycleOperationId::issue(), + actor: &old_key, + reason: "fingerprint rotation request", + }; + let original = VerifiedReplacementKey::after_verified_proof( + &new_key, + None, + BindingProvenance::Provisioned, + "policy-v1", + ) + .expect("construct provisioned rotation"); + let rotated = + rotate_identity_binding(&pool, community_id, request, principal, &old_key, original) + .await + .expect("apply original rotation"); + let LifecycleResult::Applied(receipt) = rotated else { + panic!("first rotation must apply"); + }; + assert_eq!(receipt.binding_id, Some(old_evidence.binding_id())); + assert_eq!( + receipt.binding_version, + Some(old_evidence.binding_version() + 1) + ); + assert!(receipt.replacement_binding_id.is_some()); + assert_ne!(receipt.replacement_binding_id, receipt.binding_id); + assert_eq!(receipt.replacement_binding_version, Some(1)); + assert_eq!(receipt.selector_version, None); + let changed = VerifiedReplacementKey::after_verified_proof( + &new_key, + None, + BindingProvenance::AttestedKey, + "policy-v1", + ) + .expect("construct changed rotation provenance"); + assert!(rotate_identity_binding( + &pool, + community_id, + request, + principal, + &old_key, + changed, + ) + .await + .is_err()); + let counts: (i64, i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_lifecycle_operations WHERE community_id=$1)", + ) + .bind(community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("read provenance fingerprint counts"); + assert_eq!(counts, (2, 3, 1)); + + let community_id = make_community(&pool).await; + let principal = IdentityPrincipal { + issuer: ISSUER, + subject: "fingerprint-enable", + }; + let retired_key = [70_u8; 32]; + let replacement_key = [71_u8; 32]; + enroll(&pool, community_id, principal.subject, &retired_key).await; + disable_identity_principal( + &pool, + community_id, + context("disable fingerprint principal"), + principal, + ) + .await + .expect("disable fingerprint principal"); + let expected = get_pending_lineage(&pool, community_id, principal) + .await + .expect("read fingerprint pending") + .expect("fingerprint pending selector"); + let operation_id = Uuid::new_v4(); + let request = LifecycleContext { + operation_id: LifecycleOperationId::from_uuid_for_test(operation_id), + actor: &old_key, + reason: "fingerprint enable request", + }; + enable_identity_principal( + &pool, + community_id, + request, + principal, + Some(&expected), + replacement(&replacement_key), + ) + .await + .expect("apply exact enable request"); + let mut changed = [ + expected.clone(), + expected.clone(), + expected.clone(), + expected.clone(), + ]; + changed[0].retired_pubkey = vec![72_u8; 32]; + changed[1].retired_binding_id = Uuid::new_v4(); + changed[2].retired_binding_version += 1; + changed[3].selector_version += 1; + for stale in changed { + assert!(enable_identity_principal( + &pool, + community_id, + request, + principal, + Some(&stale), + replacement(&replacement_key), + ) + .await + .is_err()); + } + let counts: (i64, i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_lifecycle_operations WHERE community_id=$1 AND operation_id=$2)", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_one(&pool) + .await + .expect("read enable fingerprint counts"); + assert_eq!(counts, (2, 3, 1)); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn arbitrary_rotation_history_is_lossless_without_new_key_tombstones() { + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let principal = IdentityPrincipal { + issuer: ISSUER, + subject: "rotations", + }; + let keys = [[10_u8; 32], [11_u8; 32], [12_u8; 32], [13_u8; 32]]; + enroll(&pool, community_id, principal.subject, &keys[0]).await; + for pair in keys.windows(2) { + rotate_identity_binding( + &pool, + community_id, + context("verified rotation"), + principal, + &pair[0], + replacement(&pair[1]), + ) + .await + .expect("rotate binding"); + } + + let versions: Vec<(Vec, i64, String)> = sqlx::query_as( + "SELECT pubkey, binding_version, binding_state FROM identity_bindings \ + WHERE community_id=$1 AND issuer=$2 AND uid=$3 ORDER BY pubkey", + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .fetch_all(&pool) + .await + .expect("read rotation history"); + assert_eq!(versions.len(), keys.len()); + let expected_versions = [2_i64, 2, 2, 1]; + for (index, (pubkey, version, state)) in versions.iter().enumerate() { + assert_eq!(pubkey, &keys[index]); + assert_eq!(*version, expected_versions[index]); + assert_eq!( + state, + if index + 1 == keys.len() { + "active" + } else { + "rotated" + } + ); + } + let fresh_tombstones: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM identity_revoked_keys WHERE community_id=$1") + .bind(community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("count tombstones"); + assert_eq!(fresh_tombstones, 0); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn migration_denial_blocks_authorization_and_lifecycle() { + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let principal = IdentityPrincipal { + issuer: ISSUER, + subject: "ambiguous", + }; + let key = [21_u8; 32]; + enroll(&pool, community_id, principal.subject, &key).await; + sqlx::query( + "INSERT INTO identity_migration_denials (community_id, issuer, subject, reason) \ + VALUES ($1,$2,$3,'ambiguous test lineage')", + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .execute(&pool) + .await + .expect("quarantine principal"); + + assert_eq!( + resolve_identity_binding( + &pool, + &ResolveBindingInput { + authorization_domain: community_id, + issuer: principal.issuer, + subject: principal.subject, + pubkey: &key, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("resolve quarantined binding"), + ResolveBindingResult::Denied(BindingDenial::Revoked) + ); + assert!(disable_identity_principal( + &pool, + community_id, + context("must not mutate quarantine"), + principal, + ) + .await + .is_err()); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn concurrent_enrollment_and_disablement_finish_fail_closed() { + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let gate = Arc::new(tokio::sync::Barrier::new(3)); + let enroll_pool = pool.clone(); + let enroll_gate = Arc::clone(&gate); + let enroll_task = tokio::spawn(async move { + let key = [31_u8; 32]; + enroll_gate.wait().await; + resolve_identity_binding( + &enroll_pool, + &ResolveBindingInput { + authorization_domain: community_id, + issuer: ISSUER, + subject: "racing-subject", + pubkey: &key, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + }); + let disable_pool = pool.clone(); + let disable_gate = Arc::clone(&gate); + let disable_task = tokio::spawn(async move { + disable_gate.wait().await; + disable_identity_principal( + &disable_pool, + community_id, + context("concurrent disable"), + IdentityPrincipal { + issuer: ISSUER, + subject: "racing-subject", + }, + ) + .await + }); + gate.wait().await; + enroll_task + .await + .expect("join enrollment") + .expect("enrollment result"); + disable_task + .await + .expect("join disablement") + .expect("disablement result"); + + let state: (bool, bool) = sqlx::query_as( + "SELECT \ + EXISTS(SELECT 1 FROM identity_principals WHERE community_id=$1 AND issuer=$2 AND uid=$3 AND disabled_at IS NOT NULL), \ + EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 AND issuer=$2 AND uid=$3 AND binding_state='active' AND revoked_at IS NULL)", + ) + .bind(community_id.as_uuid()) + .bind(ISSUER) + .bind("racing-subject") + .fetch_one(&pool) + .await + .expect("read final race state"); + assert_eq!(state, (true, false)); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn identical_identity_and_key_coordinates_are_independent_across_domains() { + let pool = setup_pool().await; + let domains = [make_community(&pool).await, make_community(&pool).await]; + let key = [79_u8; 32]; + let gate = Arc::new(tokio::sync::Barrier::new(3)); + let mut tasks = Vec::new(); + for community_id in domains { + let task_pool = pool.clone(); + let task_gate = Arc::clone(&gate); + tasks.push(tokio::spawn(async move { + task_gate.wait().await; + resolve_identity_binding( + &task_pool, + &ResolveBindingInput { + authorization_domain: community_id, + issuer: ISSUER, + subject: "same-cross-domain-principal", + pubkey: &key, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + })); + } + gate.wait().await; + for task in tasks { + assert!(matches!( + task.await + .expect("join cross-domain enrollment") + .expect("cross-domain enrollment result"), + ResolveBindingResult::Enrolled(_) + )); + } + for community_id in domains { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_bindings \ + WHERE community_id=$1 AND issuer=$2 AND uid=$3 AND pubkey=$4 \ + AND binding_state='active' AND revoked_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(ISSUER) + .bind("same-cross-domain-principal") + .bind(key) + .fetch_one(&pool) + .await + .expect("count cross-domain binding"); + assert_eq!(count, 1); + } + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn competing_recoveries_and_enablements_have_one_winner() { + let pool = setup_pool().await; + for enable in [false, true] { + let community_id = make_community(&pool).await; + let subject = if enable { + "competing-enable" + } else { + "competing-recovery" + }; + let principal = IdentityPrincipal { + issuer: ISSUER, + subject, + }; + let retired_key = [if enable { 81_u8 } else { 80_u8 }; 32]; + enroll(&pool, community_id, subject, &retired_key).await; + if enable { + disable_identity_principal( + &pool, + community_id, + context("prepare competing enable"), + principal, + ) + .await + .expect("disable before competing enable"); + } else { + revoke_identity_key( + &pool, + community_id, + context("prepare competing recovery"), + &retired_key, + ) + .await + .expect("revoke before competing recovery"); + } + let expected = get_pending_lineage(&pool, community_id, principal) + .await + .expect("read competing pending") + .expect("competing pending selector"); + let gate = Arc::new(tokio::sync::Barrier::new(3)); + let mut tasks = Vec::new(); + for byte in [82_u8, 83_u8] { + let task_pool = pool.clone(); + let task_gate = Arc::clone(&gate); + let task_expected = expected.clone(); + tasks.push(tokio::spawn(async move { + let replacement_key = [byte; 32]; + task_gate.wait().await; + if enable { + enable_identity_principal( + &task_pool, + community_id, + context("competing enable"), + principal, + Some(&task_expected), + replacement(&replacement_key), + ) + .await + } else { + recover_identity_binding( + &task_pool, + community_id, + context("competing recovery"), + principal, + &task_expected, + replacement(&replacement_key), + ) + .await + } + })); + } + gate.wait().await; + let first = tasks.remove(0).await.expect("join first competitor"); + let second = tasks.remove(0).await.expect("join second competitor"); + assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); + let state: (i64, bool, bool) = sqlx::query_as( + "SELECT \ + (SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1 AND issuer=$2 AND uid=$3 AND binding_state='active' AND revoked_at IS NULL), \ + EXISTS(SELECT 1 FROM identity_principals WHERE community_id=$1 AND issuer=$2 AND uid=$3 AND disabled_at IS NOT NULL), \ + EXISTS(SELECT 1 FROM identity_pending_replacements WHERE community_id=$1 AND issuer=$2 AND subject=$3 AND cleared_at IS NULL)", + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .fetch_one(&pool) + .await + .expect("read competing transition state"); + assert_eq!(state, (1, false, false)); + } + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn recovery_disable_and_enable_revoke_races_fail_closed() { + let pool = setup_pool().await; + + let community_id = make_community(&pool).await; + let principal = IdentityPrincipal { + issuer: ISSUER, + subject: "recover-vs-disable", + }; + let retired_key = [84_u8; 32]; + let replacement_key = [85_u8; 32]; + enroll(&pool, community_id, principal.subject, &retired_key).await; + revoke_identity_key( + &pool, + community_id, + context("prepare recover disable race"), + &retired_key, + ) + .await + .expect("prepare recovery pending"); + let expected = get_pending_lineage(&pool, community_id, principal) + .await + .expect("read recovery pending") + .expect("recovery pending selector"); + let gate = Arc::new(tokio::sync::Barrier::new(3)); + let recover_pool = pool.clone(); + let recover_gate = Arc::clone(&gate); + let recover_task = tokio::spawn(async move { + recover_gate.wait().await; + recover_identity_binding( + &recover_pool, + community_id, + context("racing recovery"), + principal, + &expected, + replacement(&replacement_key), + ) + .await + }); + let disable_pool = pool.clone(); + let disable_gate = Arc::clone(&gate); + let disable_task = tokio::spawn(async move { + disable_gate.wait().await; + disable_identity_principal( + &disable_pool, + community_id, + context("racing disable"), + principal, + ) + .await + }); + gate.wait().await; + let _ = recover_task.await.expect("join racing recovery"); + disable_task + .await + .expect("join racing disable") + .expect("disable wins or follows recovery"); + let state: (bool, bool, bool) = sqlx::query_as( + "SELECT \ + EXISTS(SELECT 1 FROM identity_principals WHERE community_id=$1 AND issuer=$2 AND uid=$3 AND disabled_at IS NOT NULL), \ + EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 AND issuer=$2 AND uid=$3 AND binding_state='active' AND revoked_at IS NULL), \ + EXISTS(SELECT 1 FROM identity_pending_replacements WHERE community_id=$1 AND issuer=$2 AND subject=$3 AND cleared_at IS NULL)", + ) + .bind(community_id.as_uuid()) + .bind(principal.issuer) + .bind(principal.subject) + .fetch_one(&pool) + .await + .expect("read recover disable final state"); + assert_eq!(state, (true, false, true)); + + let community_id = make_community(&pool).await; + let principal = IdentityPrincipal { + issuer: ISSUER, + subject: "enable-vs-revoke", + }; + let retired_key = [86_u8; 32]; + let replacement_key = [87_u8; 32]; + enroll(&pool, community_id, principal.subject, &retired_key).await; + disable_identity_principal( + &pool, + community_id, + context("prepare enable revoke race"), + principal, + ) + .await + .expect("prepare disabled principal"); + let expected = get_pending_lineage(&pool, community_id, principal) + .await + .expect("read enable pending") + .expect("enable pending selector"); + let gate = Arc::new(tokio::sync::Barrier::new(3)); + let enable_pool = pool.clone(); + let enable_gate = Arc::clone(&gate); + let enable_task = tokio::spawn(async move { + enable_gate.wait().await; + enable_identity_principal( + &enable_pool, + community_id, + context("racing enable"), + principal, + Some(&expected), + replacement(&replacement_key), + ) + .await + }); + let revoke_pool = pool.clone(); + let revoke_gate = Arc::clone(&gate); + let revoke_task = tokio::spawn(async move { + revoke_gate.wait().await; + revoke_identity_key( + &revoke_pool, + community_id, + context("racing replacement revoke"), + &replacement_key, + ) + .await + }); + gate.wait().await; + let _ = enable_task.await.expect("join racing enable"); + revoke_task + .await + .expect("join replacement revoke") + .expect("replacement revoke commits"); + let state: (bool, bool, bool) = sqlx::query_as( + "SELECT \ + EXISTS(SELECT 1 FROM identity_bindings binding \ + JOIN identity_revoked_keys revoked USING (community_id,pubkey) \ + WHERE binding.community_id=$1 AND binding.binding_state='active' AND binding.revoked_at IS NULL), \ + EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 AND pubkey=$2 AND binding_state='active' AND revoked_at IS NULL), \ + EXISTS(SELECT 1 FROM identity_revoked_keys WHERE community_id=$1 AND pubkey=$2)", + ) + .bind(community_id.as_uuid()) + .bind(replacement_key) + .fetch_one(&pool) + .await + .expect("read enable revoke final state"); + assert_eq!(state, (false, false, true)); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn rotation_against_old_or_new_key_revocation_never_resurrects_authority() { + let pool = setup_pool().await; + for revoke_new in [false, true] { + let community_id = make_community(&pool).await; + let subject = if revoke_new { + "rotate-vs-new-revoke" + } else { + "rotate-vs-old-revoke" + }; + let principal = IdentityPrincipal { + issuer: ISSUER, + subject, + }; + let old_key = [if revoke_new { 88_u8 } else { 90_u8 }; 32]; + let new_key = [if revoke_new { 89_u8 } else { 91_u8 }; 32]; + let revoked_key = if revoke_new { new_key } else { old_key }; + enroll(&pool, community_id, subject, &old_key).await; + let gate = Arc::new(tokio::sync::Barrier::new(3)); + let rotate_pool = pool.clone(); + let rotate_gate = Arc::clone(&gate); + let rotate_task = tokio::spawn(async move { + rotate_gate.wait().await; + rotate_identity_binding( + &rotate_pool, + community_id, + context("racing rotation"), + principal, + &old_key, + replacement(&new_key), + ) + .await + }); + let revoke_pool = pool.clone(); + let revoke_gate = Arc::clone(&gate); + let revoke_task = tokio::spawn(async move { + revoke_gate.wait().await; + revoke_identity_key( + &revoke_pool, + community_id, + context("racing key revoke"), + &revoked_key, + ) + .await + }); + gate.wait().await; + let _ = rotate_task.await.expect("join racing rotation"); + revoke_task + .await + .expect("join racing key revoke") + .expect("key revocation commits"); + let state: (i64, bool, bool) = sqlx::query_as( + "SELECT \ + (SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1 AND binding_state='active' AND revoked_at IS NULL), \ + EXISTS(SELECT 1 FROM identity_bindings binding \ + JOIN identity_revoked_keys revoked USING (community_id,pubkey) \ + WHERE binding.community_id=$1 AND binding.binding_state='active' AND binding.revoked_at IS NULL), \ + EXISTS(SELECT 1 FROM identity_bindings binding \ + JOIN identity_retired_pairs retired \ + ON retired.community_id=binding.community_id \ + AND retired.issuer=binding.issuer \ + AND retired.subject=binding.uid \ + AND retired.pubkey=binding.pubkey \ + WHERE binding.community_id=$1 AND binding.binding_state='active' AND binding.revoked_at IS NULL)", + ) + .bind(community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("read rotate revoke final state"); + assert!(state.0 <= 1); + assert_eq!((state.1, state.2), (false, false)); + } + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn lifecycle_failure_rolls_back_selectors_history_and_authority() { + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let key = [41_u8; 32]; + enroll(&pool, community_id, "rollback-subject", &key).await; + let reason = "identity lifecycle failure injection"; + sqlx::query( + "CREATE OR REPLACE FUNCTION identity_test_fail_history() RETURNS trigger LANGUAGE plpgsql AS $$ \ + BEGIN IF NEW.reason = 'identity lifecycle failure injection' THEN RAISE EXCEPTION 'injected history failure'; END IF; RETURN NEW; END $$", + ) + .execute(&pool) + .await + .expect("create failure function"); + sqlx::query( + "CREATE TRIGGER identity_test_fail_history_trigger BEFORE INSERT ON identity_binding_history \ + FOR EACH ROW EXECUTE FUNCTION identity_test_fail_history()", + ) + .execute(&pool) + .await + .expect("create failure trigger"); + + let failed = revoke_identity_key(&pool, community_id, context(reason), &key) + .await + .is_err(); + let state: (bool, bool, bool, bool) = sqlx::query_as( + "SELECT \ + EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 AND pubkey=$2 AND binding_state='active' AND revoked_at IS NULL), \ + EXISTS(SELECT 1 FROM identity_revoked_keys WHERE community_id=$1 AND pubkey=$2), \ + EXISTS(SELECT 1 FROM identity_retired_pairs WHERE community_id=$1 AND pubkey=$2), \ + EXISTS(SELECT 1 FROM identity_pending_replacements WHERE community_id=$1 AND retired_pubkey=$2 AND cleared_at IS NULL)", + ) + .bind(community_id.as_uuid()) + .bind(key) + .fetch_one(&pool) + .await + .expect("read rollback state"); + + sqlx::query("DROP TRIGGER identity_test_fail_history_trigger ON identity_binding_history") + .execute(&pool) + .await + .expect("drop failure trigger"); + sqlx::query("DROP FUNCTION identity_test_fail_history()") + .execute(&pool) + .await + .expect("drop failure function"); + assert!(failed); + assert_eq!(state, (true, false, false, false)); + } +} diff --git a/crates/buzz-db/src/identity_lifecycle_deterministic_tests.rs b/crates/buzz-db/src/identity_lifecycle_deterministic_tests.rs new file mode 100644 index 0000000000..4bfc3032a3 --- /dev/null +++ b/crates/buzz-db/src/identity_lifecycle_deterministic_tests.rs @@ -0,0 +1,1020 @@ +use super::*; + +use crate::identity_binding::{ + get_active_identity_binding_by_pubkey, resolve_identity_binding, test_lock_schedule, + BindingDenial, ResolveBindingInput, ResolveBindingResult, +}; +use serde_json::Value; +use sqlx::PgPool; +use uuid::Uuid; + +const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; +const ISSUER: &str = "https://idp.example"; +const SUBJECT: &str = "deterministic-subject"; +const OLD_KEY: [u8; 32] = [31; 32]; +const ENROLL_KEY: [u8; 32] = [32; 32]; +const ROTATE_KEY: [u8; 32] = [33; 32]; +const RECOVER_KEY: [u8; 32] = [34; 32]; +const ENABLE_KEY: [u8; 32] = [35; 32]; +const DOMAIN_B_KEY: [u8; 32] = [201; 32]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Action { + Enroll, + Retire, + Rotate, + Recover, + Disable, + Revoke, + Enable, + Archive, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Outcome { + Applied, + Existing, + Denied, + Error, +} + +#[derive(Clone)] +struct Fixture { + community_id: CommunityId, + expected_pending: Option, + enrollment_key: [u8; 32], + archive_binding_id: Option, +} + +async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect deterministic test DB"); + crate::migration::run_migrations(&pool) + .await + .expect("run migrations"); + pool +} + +async fn make_community(pool: &PgPool, label: &str) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(id) + .bind(format!("{label}-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert deterministic community"); + CommunityId::from_uuid(id) +} + +fn principal() -> IdentityPrincipal<'static> { + IdentityPrincipal { + issuer: ISSUER, + subject: SUBJECT, + } +} + +fn context(operation_id: Uuid, reason: &'static str) -> LifecycleContext<'static> { + const ACTOR: [u8; 32] = [0xA1; 32]; + LifecycleContext { + operation_id: LifecycleOperationId::from_uuid_for_test(operation_id), + actor: &ACTOR, + reason, + } +} + +fn replacement(pubkey: &'static [u8; 32]) -> VerifiedReplacementKey<'static> { + VerifiedReplacementKey::after_verified_proof( + pubkey, + None, + BindingProvenance::AttestedKey, + "deterministic-policy-v1", + ) + .expect("construct deterministic replacement") +} + +async fn enroll_key( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], +) -> crate::identity_binding::BindingEvidence { + let result = resolve_identity_binding( + pool, + &ResolveBindingInput { + authorization_domain: community_id, + issuer: ISSUER, + subject: SUBJECT, + pubkey, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "deterministic-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("seed enrollment"); + match result { + ResolveBindingResult::Enrolled(evidence) => evidence, + other => panic!("expected deterministic enrollment, got {other:?}"), + } +} + +fn pair_contains(pair: (Action, Action), action: Action) -> bool { + pair.0 == action || pair.1 == action +} + +async fn setup_fixture(pool: &PgPool, pair: (Action, Action), label: &str) -> Fixture { + let community_id = make_community(pool, label).await; + let mut expected_pending = None; + let mut enrollment_key = ENROLL_KEY; + let mut archive_binding_id = None; + + if pair_contains(pair, Action::Archive) { + let evidence = enroll_key(pool, community_id, &OLD_KEY).await; + retire_identity_pair( + pool, + community_id, + context(Uuid::from_u128(9), "prepare archive recovery"), + principal(), + &OLD_KEY, + ) + .await + .expect("prepare archivable pending recovery"); + expected_pending = get_pending_lineage(pool, community_id, principal()) + .await + .expect("read archive recovery selector"); + archive_binding_id = Some(evidence.binding_id()); + } else if pair_contains(pair, Action::Enroll) && pair_contains(pair, Action::Rotate) { + enrollment_key = OLD_KEY; + } else if pair_contains(pair, Action::Enroll) && pair_contains(pair, Action::Recover) { + enroll_key(pool, community_id, &OLD_KEY).await; + retire_identity_pair( + pool, + community_id, + context(Uuid::from_u128(10), "prepare enrollment recovery"), + principal(), + &OLD_KEY, + ) + .await + .expect("prepare pending recovery"); + expected_pending = get_pending_lineage(pool, community_id, principal()) + .await + .expect("read recovery selector"); + enrollment_key = RECOVER_KEY; + } else { + enroll_key(pool, community_id, &OLD_KEY).await; + let needs_enabled_pending = + pair_contains(pair, Action::Recover) && !pair_contains(pair, Action::Enable); + let needs_disabled_pending = pair_contains(pair, Action::Enable); + if needs_enabled_pending { + retire_identity_pair( + pool, + community_id, + context(Uuid::from_u128(11), "prepare enabled pending"), + principal(), + &OLD_KEY, + ) + .await + .expect("prepare enabled pending"); + } else if needs_disabled_pending { + disable_identity_principal( + pool, + community_id, + context(Uuid::from_u128(12), "prepare disabled pending"), + principal(), + ) + .await + .expect("prepare disabled pending"); + } + expected_pending = get_pending_lineage(pool, community_id, principal()) + .await + .expect("read prepared selector"); + } + + Fixture { + community_id, + expected_pending, + enrollment_key, + archive_binding_id, + } +} + +async fn run_action( + pool: &PgPool, + fixture: &Fixture, + action: Action, + operation_id: Uuid, +) -> Outcome { + match action { + Action::Enroll => match resolve_identity_binding( + pool, + &ResolveBindingInput { + authorization_domain: fixture.community_id, + issuer: ISSUER, + subject: SUBJECT, + pubkey: &fixture.enrollment_key, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "deterministic-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + { + Ok(ResolveBindingResult::Enrolled(_)) => Outcome::Applied, + Ok(ResolveBindingResult::Existing(_)) => Outcome::Existing, + Ok(ResolveBindingResult::Denied( + BindingDenial::Conflict + | BindingDenial::Revoked + | BindingDenial::BindingRequired + | BindingDenial::KeyAttestationRequired + | BindingDenial::BindingExpired + | BindingDenial::StaleEvidence, + )) => Outcome::Denied, + Err(_) => Outcome::Error, + }, + Action::Rotate => rotate_identity_binding( + pool, + fixture.community_id, + context(operation_id, "deterministic rotate"), + principal(), + &OLD_KEY, + replacement(&ROTATE_KEY), + ) + .await + .map_or(Outcome::Error, |_| Outcome::Applied), + Action::Retire => retire_identity_pair( + pool, + fixture.community_id, + context(operation_id, "deterministic retire"), + principal(), + &OLD_KEY, + ) + .await + .map_or(Outcome::Error, |_| Outcome::Applied), + Action::Recover => { + let Some(expected) = fixture.expected_pending.as_ref() else { + return Outcome::Error; + }; + recover_identity_binding( + pool, + fixture.community_id, + context(operation_id, "deterministic recover"), + principal(), + expected, + replacement(&RECOVER_KEY), + ) + .await + .map_or(Outcome::Error, |_| Outcome::Applied) + } + Action::Disable => disable_identity_principal( + pool, + fixture.community_id, + context(operation_id, "deterministic disable"), + principal(), + ) + .await + .map_or(Outcome::Error, |_| Outcome::Applied), + Action::Revoke => revoke_identity_key( + pool, + fixture.community_id, + context(operation_id, "deterministic revoke"), + &OLD_KEY, + ) + .await + .map_or(Outcome::Error, |_| Outcome::Applied), + Action::Enable => enable_identity_principal( + pool, + fixture.community_id, + context(operation_id, "deterministic enable"), + principal(), + fixture.expected_pending.as_ref(), + replacement(&ENABLE_KEY), + ) + .await + .map_or(Outcome::Error, |_| Outcome::Applied), + Action::Archive => { + let Some(binding_id) = fixture.archive_binding_id else { + return Outcome::Error; + }; + archive_identity_binding( + pool, + fixture.community_id, + context(operation_id, "deterministic archive"), + binding_id, + ) + .await + .map_or(Outcome::Error, |_| Outcome::Applied) + } + } +} + +async fn normalized_rows( + pool: &PgPool, + community_id: CommunityId, + query: &'static str, +) -> Vec { + let mut rows = sqlx::query_scalar::<_, Value>(query) + .bind(community_id.as_uuid()) + .fetch_all(pool) + .await + .expect("read normalized identity projection") + .into_iter() + .map(|value| value.to_string()) + .collect::>(); + rows.sort(); + rows +} + +async fn logical_snapshot(pool: &PgPool, community_id: CommunityId) -> Vec { + let queries = [ + "SELECT jsonb_build_object('t','binding','v',to_jsonb(b)-ARRAY['community_id','binding_id','replacement_binding_id','created_at','updated_at','last_seen_at','revoked_at','revoked_by','rotation_completed_at','rotation_by','archived_at']::text[]) FROM identity_bindings b WHERE community_id=$1", + "SELECT jsonb_build_object('t','principal','v',jsonb_build_object('issuer',issuer,'subject',uid,'disabled',disabled_at IS NOT NULL,'reason',disabled_reason)) FROM identity_principals WHERE community_id=$1", + "SELECT jsonb_build_object('t','revoked_key','v',jsonb_build_object('pubkey',encode(pubkey,'hex'),'reason',reason)) FROM identity_revoked_keys WHERE community_id=$1", + "SELECT jsonb_build_object('t','retired','v',to_jsonb(r)-ARRAY['community_id','retired_binding_id','retired_at','retired_by']::text[]) FROM identity_retired_pairs r WHERE community_id=$1", + "SELECT jsonb_build_object('t','pending','v',to_jsonb(p)-ARRAY['community_id','retired_binding_id','created_at','created_operation_id','cleared_at','cleared_operation_id']::text[] || jsonb_build_object('cleared',cleared_at IS NOT NULL)) FROM identity_pending_replacements p WHERE community_id=$1", + "SELECT jsonb_build_object('t','history','v',to_jsonb(h)-ARRAY['community_id','history_id','binding_id','replacement_binding_id','operation_id','recorded_at']::text[]) FROM identity_binding_history h WHERE community_id=$1", + "SELECT jsonb_build_object('t','operation','v',to_jsonb(o)-ARRAY['community_id','operation_id','request_fingerprint','binding_id','replacement_binding_id','created_at']::text[]) FROM identity_lifecycle_operations o WHERE community_id=$1", + "SELECT jsonb_build_object('t','lineage','v',jsonb_build_object('predecessor',encode(p.pubkey,'hex'),'successor',encode(s.pubkey,'hex'))) FROM identity_binding_lineage l JOIN identity_bindings p ON p.community_id=l.community_id AND p.binding_id=l.predecessor_binding_id JOIN identity_bindings s ON s.community_id=l.community_id AND s.binding_id=l.successor_binding_id WHERE l.community_id=$1", + ]; + let mut snapshot = Vec::new(); + for query in queries { + snapshot.extend(normalized_rows(pool, community_id, query).await); + } + snapshot.sort(); + snapshot +} + +async fn raw_domain_snapshot(pool: &PgPool, community_id: CommunityId) -> Vec { + let community: String = sqlx::query_scalar( + "SELECT to_jsonb(community)::text FROM communities community WHERE id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_one(pool) + .await + .expect("read exact domain community sentinel"); + let tables = [ + "identity_bindings", + "identity_principals", + "identity_revoked_keys", + "identity_migration_denials", + "identity_migration_denied_keys", + "identity_binding_lineage", + "identity_retired_pairs", + "identity_pending_replacements", + "identity_binding_history", + "identity_lifecycle_operations", + "audit_log", + ]; + let mut snapshot = vec![format!("communities:{community}")]; + for table in tables { + let query = format!( + "SELECT to_jsonb(row_value)::text FROM {table} row_value WHERE community_id=$1 ORDER BY 1" + ); + let rows = sqlx::query_scalar::<_, String>(sqlx::AssertSqlSafe(query)) + .bind(community_id.as_uuid()) + .fetch_all(pool) + .await + .expect("read exact domain sentinel"); + snapshot.extend(rows.into_iter().map(|row| format!("{table}:{row}"))); + } + snapshot +} + +async fn authorization_sentinel(pool: &PgPool, community_id: CommunityId) -> (Uuid, u64) { + let binding = get_active_identity_binding_by_pubkey(pool, community_id, &DOMAIN_B_KEY) + .await + .expect("read domain-B authorization") + .expect("domain-B binding remains active"); + (binding.binding_id, binding.binding_version) +} + +async fn wait_for_advisory_waiter( + pool: &PgPool, + database_oid: u32, + holder_pid: i32, + waiter_pid: i32, + lock_keys: &[test_lock_schedule::AdvisoryLockKey], +) { + assert_ne!(holder_pid, waiter_pid); + assert!(!lock_keys.is_empty(), "actors must share an identity lock"); + for _ in 0..10_000 { + for lock_key in lock_keys { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(\ + SELECT 1 FROM pg_locks held JOIN pg_locks waiting \ + ON waiting.locktype=held.locktype \ + AND waiting.database=held.database \ + AND waiting.classid=held.classid \ + AND waiting.objid=held.objid \ + AND waiting.objsubid=held.objsubid \ + WHERE held.locktype='advisory' \ + AND held.database::BIGINT=$1 \ + AND held.classid::BIGINT=$2 \ + AND held.objid::BIGINT=$3 \ + AND held.pid=$4 AND held.granted \ + AND waiting.pid=$5 AND NOT waiting.granted\ + )", + ) + .bind(i64::from(database_oid)) + .bind(i64::from(lock_key.class_id())) + .bind(i64::from(lock_key.object_id())) + .bind(holder_pid) + .bind(waiter_pid) + .fetch_one(pool) + .await + .expect("inspect exact identity advisory waiter"); + if waiting { + return; + } + } + tokio::task::yield_now().await; + } + panic!("second actor never blocked on the shared identity advisory lock"); +} + +async fn wait_for_transaction_waiter( + pool: &PgPool, + database_oid: u32, + holder_pid: i32, + holder_transaction_id: i64, + waiter_pid: i32, +) { + assert_ne!(holder_pid, waiter_pid); + for _ in 0..10_000 { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(\ + SELECT 1 FROM pg_locks held JOIN pg_locks waiting \ + ON waiting.locktype=held.locktype \ + AND waiting.transactionid=held.transactionid \ + WHERE held.locktype='transactionid' \ + AND held.database IS NULL AND waiting.database IS NULL \ + AND held.transactionid::text::BIGINT=$1 \ + AND held.pid=$2 AND held.mode='ExclusiveLock' AND held.granted \ + AND waiting.pid=$3 AND waiting.mode='ShareLock' AND NOT waiting.granted \ + AND EXISTS(SELECT 1 FROM pg_stat_activity activity \ + WHERE activity.pid=waiting.pid \ + AND activity.datid::BIGINT=$4 \ + AND activity.wait_event_type='Lock' \ + AND activity.wait_event='transactionid')\ + )", + ) + .bind(holder_transaction_id) + .bind(holder_pid) + .bind(waiter_pid) + .bind(i64::from(database_oid)) + .fetch_one(pool) + .await + .expect("inspect exact identity row-lock waiter"); + if waiting { + return; + } + tokio::task::yield_now().await; + } + panic!("second actor never blocked on the first actor's exact row transaction lock"); +} + +fn serializes_on_active_binding_row(first: Action, second: Action) -> bool { + matches!( + (first, second), + (Action::Disable, Action::Revoke) | (Action::Revoke, Action::Disable) + ) +} + +async fn force_order( + pool: &PgPool, + fixture: &Fixture, + first: Action, + second: Action, + domain_b: CommunityId, + domain_b_auth: (Uuid, u64), +) -> (Outcome, Outcome) { + let (mut events, _controller) = test_lock_schedule::install(); + let row_schedule = serializes_on_active_binding_row(first, second); + let (mut row_events, _row_controller) = row_schedule + .then(test_lock_schedule::install_row) + .map_or((None, None), |(events, guard)| (Some(events), Some(guard))); + let first_pool = pool.clone(); + let first_fixture = fixture.clone(); + let first_task = tokio::spawn(test_lock_schedule::actor_scope("first", async move { + run_action(&first_pool, &first_fixture, first, Uuid::from_u128(100)).await + })); + let event = events.recv().await.expect("first lock request trace"); + assert_eq!( + (event.actor(), event.phase()), + ("first", test_lock_schedule::LockPhase::Request) + ); + let first_pid = event.backend_pid(); + let database_oid = event.database_oid(); + let first_lock_keys = event.lock_keys().to_vec(); + event.resume(); + let first_acquired = events.recv().await.expect("first lock acquired trace"); + assert_eq!( + (first_acquired.actor(), first_acquired.phase()), + ("first", test_lock_schedule::LockPhase::Acquired) + ); + assert_eq!(first_acquired.isolation(), Some("read committed")); + assert!(first_acquired.transaction_id().is_some()); + assert!(first_acquired.coordinate_count() >= 2); + assert_eq!(first_acquired.backend_pid(), first_pid); + assert_eq!(first_acquired.database_oid(), database_oid); + assert_eq!(first_acquired.lock_keys(), first_lock_keys); + + let second_pool = pool.clone(); + let second_fixture = fixture.clone(); + let second_task = tokio::spawn(test_lock_schedule::actor_scope("second", async move { + run_action(&second_pool, &second_fixture, second, Uuid::from_u128(101)).await + })); + let event = events.recv().await.expect("second lock request trace"); + assert_eq!( + (event.actor(), event.phase()), + ("second", test_lock_schedule::LockPhase::Request) + ); + let second_pid = event.backend_pid(); + assert_eq!(event.database_oid(), database_oid); + let shared_lock_keys = first_lock_keys + .iter() + .copied() + .filter(|lock_key| event.lock_keys().contains(lock_key)) + .collect::>(); + event.resume(); + if row_schedule { + assert!( + shared_lock_keys.is_empty(), + "disable/revoke must prove its actual row-lock serialization point" + ); + let second_acquired = events + .recv() + .await + .expect("second independent advisory lock acquired trace"); + assert_eq!( + (second_acquired.actor(), second_acquired.phase()), + ("second", test_lock_schedule::LockPhase::Acquired) + ); + assert_eq!(second_acquired.isolation(), Some("read committed")); + assert_eq!(second_acquired.backend_pid(), second_pid); + assert_eq!(second_acquired.database_oid(), database_oid); + let first_advisory_transaction_id = first_acquired.transaction_id(); + let second_advisory_transaction_id = second_acquired.transaction_id(); + + first_acquired.resume(); + let row_events = row_events.as_mut().expect("installed row-lock schedule"); + let first_row_request = row_events + .recv() + .await + .expect("first row-lock request trace"); + assert_eq!( + (first_row_request.actor(), first_row_request.phase()), + ("first", test_lock_schedule::RowLockPhase::Request) + ); + assert_eq!(first_row_request.backend_pid(), first_pid); + assert_eq!(first_row_request.database_oid(), database_oid); + assert_eq!( + Some(first_row_request.transaction_id()), + first_advisory_transaction_id + ); + first_row_request.resume(); + let first_row_acquired = row_events + .recv() + .await + .expect("first row-lock acquired trace"); + assert_eq!( + (first_row_acquired.actor(), first_row_acquired.phase()), + ("first", test_lock_schedule::RowLockPhase::Acquired) + ); + assert_eq!(first_row_acquired.backend_pid(), first_pid); + assert_eq!(first_row_acquired.database_oid(), database_oid); + let first_transaction_id = first_row_acquired.transaction_id(); + assert_eq!(Some(first_transaction_id), first_advisory_transaction_id); + + second_acquired.resume(); + let second_row_request = row_events + .recv() + .await + .expect("second row-lock request trace"); + assert_eq!( + (second_row_request.actor(), second_row_request.phase()), + ("second", test_lock_schedule::RowLockPhase::Request) + ); + assert_eq!(second_row_request.backend_pid(), second_pid); + assert_eq!(second_row_request.database_oid(), database_oid); + assert_eq!( + Some(second_row_request.transaction_id()), + second_advisory_transaction_id + ); + second_row_request.resume(); + wait_for_transaction_waiter( + pool, + database_oid, + first_pid, + first_transaction_id, + second_pid, + ) + .await; + assert_eq!(authorization_sentinel(pool, domain_b).await, domain_b_auth); + + first_row_acquired.resume(); + let second_row_acquired = row_events + .recv() + .await + .expect("second row-lock acquired trace"); + assert_eq!( + (second_row_acquired.actor(), second_row_acquired.phase()), + ("second", test_lock_schedule::RowLockPhase::Acquired) + ); + assert_eq!(second_row_acquired.backend_pid(), second_pid); + assert_eq!(second_row_acquired.database_oid(), database_oid); + assert_eq!( + Some(second_row_acquired.transaction_id()), + second_advisory_transaction_id + ); + second_row_acquired.resume(); + } else { + wait_for_advisory_waiter(pool, database_oid, first_pid, second_pid, &shared_lock_keys) + .await; + assert_eq!(authorization_sentinel(pool, domain_b).await, domain_b_auth); + + first_acquired.resume(); + let second_acquired = events.recv().await.expect("second lock acquired trace"); + assert_eq!( + (second_acquired.actor(), second_acquired.phase()), + ("second", test_lock_schedule::LockPhase::Acquired) + ); + assert_eq!(second_acquired.isolation(), Some("read committed")); + assert_eq!(second_acquired.backend_pid(), second_pid); + assert_eq!(second_acquired.database_oid(), database_oid); + second_acquired.resume(); + } + + ( + first_task.await.expect("join first scheduled actor"), + second_task.await.expect("join second scheduled actor"), + ) +} + +async fn exercise_ordered_case(pool: &PgPool, first: Action, second: Action) { + let pair = (first, second); + let reference = setup_fixture(pool, pair, "identity-reference").await; + let scheduled = setup_fixture(pool, pair, "identity-scheduled").await; + let domain_b = make_community(pool, "identity-domain-b").await; + enroll_key(pool, domain_b, &DOMAIN_B_KEY).await; + let domain_b_bytes = raw_domain_snapshot(pool, domain_b).await; + let domain_b_auth = authorization_sentinel(pool, domain_b).await; + + let expected_first = run_action(pool, &reference, first, Uuid::from_u128(100)).await; + let expected_second = run_action(pool, &reference, second, Uuid::from_u128(101)).await; + let actual = force_order(pool, &scheduled, first, second, domain_b, domain_b_auth).await; + + assert_eq!(actual, (expected_first, expected_second)); + assert_eq!( + logical_snapshot(pool, scheduled.community_id).await, + logical_snapshot(pool, reference.community_id).await, + "forced schedule must match its independently executed sequential reference: {first:?} then {second:?}" + ); + assert_eq!(raw_domain_snapshot(pool, domain_b).await, domain_b_bytes); + assert_eq!(authorization_sentinel(pool, domain_b).await, domain_b_auth); +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn forced_lock_schedules_match_every_ordered_lifecycle_reference() { + let pool = setup_pool().await; + let actions = [ + Action::Retire, + Action::Disable, + Action::Revoke, + Action::Rotate, + Action::Recover, + Action::Enable, + ]; + for left in 0..actions.len() { + for right in (left + 1)..actions.len() { + exercise_ordered_case(&pool, actions[left], actions[right]).await; + exercise_ordered_case(&pool, actions[right], actions[left]).await; + } + } +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn enrollment_rotate_and_recover_have_forced_orders_and_references() { + let pool = setup_pool().await; + for lifecycle in [Action::Rotate, Action::Recover] { + exercise_ordered_case(&pool, Action::Enroll, lifecycle).await; + exercise_ordered_case(&pool, lifecycle, Action::Enroll).await; + } +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn archive_and_recovery_have_forced_orders_and_references() { + let pool = setup_pool().await; + exercise_ordered_case(&pool, Action::Archive, Action::Recover).await; + exercise_ordered_case(&pool, Action::Recover, Action::Archive).await; +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn compare_and_clear_rejects_aba_recreation_and_preserves_domain_b() { + let pool = setup_pool().await; + let fixture = setup_fixture(&pool, (Action::Recover, Action::Disable), "identity-aba").await; + let expected = fixture.expected_pending.expect("pending selector"); + let independently_observed = get_pending_lineage(&pool, fixture.community_id, principal()) + .await + .expect("read second stale G1 observation") + .expect("G1 remains active before either actor"); + assert!(expected == independently_observed); + let domain_b = make_community(&pool, "identity-aba-domain-b").await; + enroll_key(&pool, domain_b, &DOMAIN_B_KEY).await; + let domain_b_bytes = raw_domain_snapshot(&pool, domain_b).await; + let domain_b_auth = authorization_sentinel(&pool, domain_b).await; + let history_before: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1") + .bind(fixture.community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("count pre-ABA history"); + let operations_before: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_lifecycle_operations WHERE community_id=$1", + ) + .bind(fixture.community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("count pre-ABA operations"); + + let (mut events, _controller) = test_lock_schedule::install(); + let winner_pool = pool.clone(); + let winner_expected = expected.clone(); + let winner_community = fixture.community_id; + let winner_task = tokio::spawn(test_lock_schedule::actor_scope("aba-winner", async move { + let winner_context = context(Uuid::from_u128(200), "committed ABA recreation"); + let coordinates = lifecycle_coordinates( + winner_community, + winner_context, + Some(principal()), + &[], + None, + ); + let mut tx = begin_locked(&winner_pool, coordinates) + .await + .expect("begin locked ABA winner"); + let cleared = sqlx::query( + "UPDATE identity_pending_replacements \ + SET cleared_at=NOW(),cleared_operation_id=$8 \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 \ + AND retired_pubkey=$4 AND retired_binding_id=$5 \ + AND retired_binding_version=$6 AND selector_version=$7 \ + AND cleared_at IS NULL", + ) + .bind(winner_community.as_uuid()) + .bind(ISSUER) + .bind(SUBJECT) + .bind(&winner_expected.retired_pubkey) + .bind(winner_expected.retired_binding_id) + .bind(i64::try_from(winner_expected.retired_binding_version).unwrap()) + .bind(i64::try_from(winner_expected.selector_version).unwrap()) + .bind(winner_context.operation_id.as_uuid()) + .execute(&mut *tx) + .await + .expect("clear committed G1"); + assert_eq!(cleared.rows_affected(), 1); + sqlx::query( + "INSERT INTO identity_pending_replacements \ + (community_id,issuer,subject,selector_version,retired_pubkey, \ + retired_binding_id,retired_binding_version,created_operation_id) \ + VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", + ) + .bind(winner_community.as_uuid()) + .bind(ISSUER) + .bind(SUBJECT) + .bind(i64::try_from(winner_expected.selector_version + 1).unwrap()) + .bind(&winner_expected.retired_pubkey) + .bind(winner_expected.retired_binding_id) + .bind(i64::try_from(winner_expected.retired_binding_version).unwrap()) + .bind(Uuid::from_u128(201)) + .execute(&mut *tx) + .await + .expect("create semantically equal G2"); + tx.commit().await.expect("durably commit G1-to-G2 ABA"); + })); + let winner_request = events.recv().await.expect("winner lock request"); + assert_eq!( + (winner_request.actor(), winner_request.phase()), + ("aba-winner", test_lock_schedule::LockPhase::Request) + ); + let winner_pid = winner_request.backend_pid(); + let database_oid = winner_request.database_oid(); + let winner_lock_keys = winner_request.lock_keys().to_vec(); + winner_request.resume(); + let winner_acquired = events.recv().await.expect("winner lock acquired"); + assert_eq!( + (winner_acquired.actor(), winner_acquired.phase()), + ("aba-winner", test_lock_schedule::LockPhase::Acquired) + ); + let winner_transaction_id = winner_acquired + .transaction_id() + .expect("winner transaction assigned"); + + let loser_pool = pool.clone(); + let loser_expected = independently_observed.clone(); + let loser_community = fixture.community_id; + let loser_task = tokio::spawn(test_lock_schedule::actor_scope("aba-loser", async move { + let loser_context = context(Uuid::from_u128(202), "stale committed ABA compare"); + let coordinates = + lifecycle_coordinates(loser_community, loser_context, Some(principal()), &[], None); + let mut tx = begin_locked(&loser_pool, coordinates) + .await + .expect("begin locked ABA loser"); + let g2_before: String = sqlx::query_scalar( + "SELECT to_jsonb(row_value)::TEXT FROM identity_pending_replacements row_value \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 AND cleared_at IS NULL", + ) + .bind(loser_community.as_uuid()) + .bind(ISSUER) + .bind(SUBJECT) + .fetch_one(&mut *tx) + .await + .expect("read committed G2 before stale compare"); + let exact_stale_match: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_pending_replacements \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 \ + AND retired_pubkey=$4 AND retired_binding_id=$5 \ + AND retired_binding_version=$6 AND selector_version=$7 \ + AND cleared_at IS NULL", + ) + .bind(loser_community.as_uuid()) + .bind(ISSUER) + .bind(SUBJECT) + .bind(&loser_expected.retired_pubkey) + .bind(loser_expected.retired_binding_id) + .bind(i64::try_from(loser_expected.retired_binding_version).unwrap()) + .bind(i64::try_from(loser_expected.selector_version).unwrap()) + .fetch_one(&mut *tx) + .await + .expect("count stale G1 tuple at compare point"); + assert_eq!(exact_stale_match, 0, "stale compare must affect zero rows"); + let error = compare_and_clear_pending_tx( + &mut tx, + loser_community, + loser_context, + principal(), + &loser_expected, + ) + .await + .expect_err("committed G2 must reject stale G1 compare"); + assert!(error + .to_string() + .contains("pending identity lineage changed concurrently")); + let g2_after: String = sqlx::query_scalar( + "SELECT to_jsonb(row_value)::TEXT FROM identity_pending_replacements row_value \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 AND cleared_at IS NULL", + ) + .bind(loser_community.as_uuid()) + .bind(ISSUER) + .bind(SUBJECT) + .fetch_one(&mut *tx) + .await + .expect("read G2 after stale compare"); + assert_eq!(g2_after, g2_before); + tx.rollback().await.expect("rollback stale ABA actor"); + g2_after + })); + let loser_request = events.recv().await.expect("loser lock request"); + assert_eq!( + (loser_request.actor(), loser_request.phase()), + ("aba-loser", test_lock_schedule::LockPhase::Request) + ); + let loser_pid = loser_request.backend_pid(); + let shared_keys = winner_lock_keys + .iter() + .copied() + .filter(|key| loser_request.lock_keys().contains(key)) + .collect::>(); + assert!(!shared_keys.is_empty()); + loser_request.resume(); + wait_for_advisory_waiter(&pool, database_oid, winner_pid, loser_pid, &shared_keys).await; + assert_eq!(raw_domain_snapshot(&pool, domain_b).await, domain_b_bytes); + assert_eq!(authorization_sentinel(&pool, domain_b).await, domain_b_auth); + + winner_acquired.resume(); + winner_task.await.expect("join committed ABA winner"); + let loser_acquired = events + .recv() + .await + .expect("loser lock acquired after commit"); + assert_eq!( + (loser_acquired.actor(), loser_acquired.phase()), + ("aba-loser", test_lock_schedule::LockPhase::Acquired) + ); + assert_ne!( + loser_acquired.transaction_id(), + Some(winner_transaction_id), + "ABA actors must use distinct transactions" + ); + loser_acquired.resume(); + let g2_from_loser = loser_task.await.expect("join stale ABA loser"); + + let final_g2: String = sqlx::query_scalar( + "SELECT to_jsonb(row_value)::TEXT FROM identity_pending_replacements row_value \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 AND cleared_at IS NULL", + ) + .bind(fixture.community_id.as_uuid()) + .bind(ISSUER) + .bind(SUBJECT) + .fetch_one(&pool) + .await + .expect("read final committed G2"); + assert_eq!(final_g2, g2_from_loser); + let final_lineage = get_pending_lineage(&pool, fixture.community_id, principal()) + .await + .expect("read final G2") + .expect("G2 remains active"); + assert_eq!( + final_lineage.selector_version, + expected.selector_version + 1 + ); + assert_eq!(final_lineage.retired_pubkey, expected.retired_pubkey); + assert_eq!( + final_lineage.retired_binding_id, + expected.retired_binding_id + ); + assert_eq!( + final_lineage.retired_binding_version, + expected.retired_binding_version + ); + let active_pending: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_pending_replacements \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 AND cleared_at IS NULL", + ) + .bind(fixture.community_id.as_uuid()) + .bind(ISSUER) + .bind(SUBJECT) + .fetch_one(&pool) + .await + .expect("count final active G2"); + assert_eq!(active_pending, 1); + let cleared_g1: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_pending_replacements \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 \ + AND selector_version=$4 AND cleared_at IS NOT NULL", + ) + .bind(fixture.community_id.as_uuid()) + .bind(ISSUER) + .bind(SUBJECT) + .bind(i64::try_from(expected.selector_version).unwrap()) + .fetch_one(&pool) + .await + .expect("count durably cleared G1"); + assert_eq!(cleared_g1, 1); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1", + ) + .bind(fixture.community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("count post-ABA history"), + history_before + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM identity_lifecycle_operations WHERE community_id=$1", + ) + .bind(fixture.community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("count post-ABA operations"), + operations_before + ); + let fresh_attempt = resolve_identity_binding( + &pool, + &ResolveBindingInput { + authorization_domain: fixture.community_id, + issuer: ISSUER, + subject: SUBJECT, + pubkey: &ENABLE_KEY, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "deterministic-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("pending G2 denies routine enrollment"); + assert_eq!( + fresh_attempt, + ResolveBindingResult::Denied(BindingDenial::Revoked) + ); + + assert_eq!(raw_domain_snapshot(&pool, domain_b).await, domain_b_bytes); + assert_eq!(authorization_sentinel(&pool, domain_b).await, domain_b_auth); +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 7d5d4b81b3..590590a345 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -29,6 +29,8 @@ pub mod feed; pub mod git_repo; /// Corporate identity binding persistence. pub mod identity_binding; +/// Linearizable corporate identity lifecycle persistence. +pub mod identity_lifecycle; /// Embedded database migrations. pub mod migration; /// Community moderation: reports, bans/timeouts, audit actions. @@ -641,6 +643,14 @@ pub struct TokenSummary { } impl Db { + /// Build the single PostgreSQL authority adapter for application-root + /// authorization runtime composition. + pub fn federated_authority_adapter( + &self, + ) -> identity_binding::PostgresFederatedAuthorityAdapter { + identity_binding::PostgresFederatedAuthorityAdapter::new(self.pool.clone()) + } + /// Creates a new `Db` by connecting a Postgres pool with the given config. /// /// When `config.read_database_url` is set, a second pool with the same @@ -2595,14 +2605,16 @@ impl Db { pub async fn revoke_identity_principal( &self, community_id: CommunityId, + operation_id: identity_lifecycle::LifecycleOperationId, issuer: &str, uid: &str, - revoked_by: Option<&[u8]>, + revoked_by: &[u8], reason: &str, ) -> Result { identity_binding::revoke_identity_principal( &self.pool, community_id, + operation_id, issuer, uid, revoked_by, @@ -2615,12 +2627,20 @@ impl Db { pub async fn revoke_identity_key( &self, community_id: CommunityId, + operation_id: identity_lifecycle::LifecycleOperationId, pubkey: &[u8], - revoked_by: Option<&[u8]>, + revoked_by: &[u8], reason: &str, ) -> Result { - identity_binding::revoke_identity_key(&self.pool, community_id, pubkey, revoked_by, reason) - .await + identity_binding::revoke_identity_key( + &self.pool, + community_id, + operation_id, + pubkey, + revoked_by, + reason, + ) + .await } /// Atomically rotate a corporate principal to a replacement key. @@ -2628,24 +2648,22 @@ impl Db { pub async fn rotate_identity_binding( &self, community_id: CommunityId, + operation_id: identity_lifecycle::LifecycleOperationId, issuer: &str, uid: &str, old_pubkey: &[u8], - new_pubkey: &[u8], - display_name: Option<&str>, - source: &str, - rotated_by: Option<&[u8]>, + replacement: identity_lifecycle::VerifiedReplacementKey<'_>, + rotated_by: &[u8], reason: &str, ) -> Result<()> { identity_binding::rotate_identity_binding( &self.pool, community_id, + operation_id, issuer, uid, old_pubkey, - new_pubkey, - display_name, - source, + replacement, rotated_by, reason, ) diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index fc9c836734..860a300e2f 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -10,6 +10,10 @@ use crate::Result; static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations"); +#[cfg(test)] +#[path = "migration_deterministic_tests.rs"] +mod deterministic_tests; + /// Run all pending Buzz database migrations. pub async fn run_migrations(pool: &PgPool) -> Result<()> { reject_legacy_nip_rs_cardinality_ambiguity(pool).await?; @@ -101,6 +105,7 @@ mod tests { use std::collections::BTreeSet; const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + type MigratedIdentityChainRow = (Vec, i64, Option>, String); #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConstraintKind { @@ -561,7 +566,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 29); + assert_eq!(migrations.len(), 30); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -977,6 +982,157 @@ mod tests { .sql .as_str() .contains("CREATE TABLE identity_revoked_keys")); + + // Migration 0030 is a brownfield-safe additive projection over the + // frozen 0028/0029 identity tables. It must not rewrite or discard + // legacy authority. + assert_eq!(migrations[29].version, 30); + let projection = migrations[29].sql.as_str(); + for required in [ + "ADD COLUMN binding_id", + "ADD COLUMN binding_version", + "ADD COLUMN binding_state", + "ADD COLUMN binding_provenance", + "ADD COLUMN expires_at", + "CREATE TABLE identity_enrollment_policies", + "CREATE TRIGGER identity_enrollment_policy_lineage_guard", + "CREATE TABLE identity_retired_pairs", + "CREATE TABLE identity_pending_replacements", + "CREATE TABLE identity_binding_history", + "CREATE TABLE identity_lifecycle_operations", + "CREATE TABLE identity_migration_denials", + ] { + assert!( + projection.contains(required), + "migration 0030 is missing {required}" + ); + } + } + + fn additive_identity_executable_sql(sql: &str) -> String { + let mut output = String::with_capacity(sql.len()); + let mut chars = sql.chars().peekable(); + let mut in_line_comment = false; + let mut in_string = false; + while let Some(ch) = chars.next() { + if in_line_comment { + if ch == '\n' { + in_line_comment = false; + output.push(ch); + } + continue; + } + if !in_string && ch == '-' && chars.peek() == Some(&'-') { + chars.next(); + in_line_comment = true; + continue; + } + if ch == '\'' { + if in_string && chars.peek() == Some(&'\'') { + chars.next(); + continue; + } + in_string = !in_string; + output.push(' '); + continue; + } + output.push(if in_string { ' ' } else { ch }); + } + output + } + + #[test] + fn migration_0030_is_strictly_additive() { + let migration = MIGRATOR + .iter() + .find(|migration| migration.version == 30) + .expect("migration 0030"); + let executable = additive_identity_executable_sql(migration.sql.as_str()); + let normalized = normalize_sql(&executable); + + for forbidden in [" rename ", " drop ", " truncate ", " delete "] { + assert!( + !format!(" {normalized} ").contains(forbidden), + "migration 0030 contains forbidden legacy mutation: {forbidden}" + ); + } + assert!(!normalized.contains("update identity_revoked_keys")); + assert!(!normalized.contains("update identity_principals")); + assert!(!normalized.contains("insert into identity_revoked_keys")); + + let allowed_binding_columns = [ + "binding_id", + "binding_version", + "binding_state", + "binding_provenance", + "replacement_binding_id", + "creation_attribution_kind", + ]; + for statement in split_sql_statements(&executable) { + let statement = normalize_sql(&statement); + if !statement.starts_with("update identity_bindings ") { + continue; + } + let assignments = statement + .split_once(" set ") + .map(|(_, tail)| tail.split_once(" where ").map_or(tail, |(set, _)| set)) + .expect("identity binding metadata update has SET clause"); + for assignment in split_top_level_csv(assignments) { + let column = assignment + .split_once('=') + .map(|(column, _)| column.trim()) + .expect("metadata assignment"); + assert!( + allowed_binding_columns.contains(&column), + "migration 0030 rewrites legacy identity_bindings.{column}" + ); + } + } + } + + #[test] + fn migration_0030_enforces_authoritative_binding_state_and_attribution() { + let migration = MIGRATOR + .iter() + .find(|migration| migration.version == 30) + .expect("migration 0030"); + // This gate intentionally checks persisted enum literals; unlike the + // additive-mutation scanner above, it must retain SQL string contents. + let normalized = normalize_sql(migration.sql.as_str()); + + assert!( + normalized.contains("binding_state = 'active' and revoked_at is null"), + "every authoritative partial index and lookup contract must require active state" + ); + assert!( + normalized.contains("'archived'"), + "migration 0030 must represent archived bindings explicitly" + ); + assert!( + normalized.contains("creation_attribution_kind"), + "migration 0030 must distinguish verified creation attribution from legacy unknowns" + ); + assert!( + !normalized.contains("set binding_provenance = 'provisioned'"), + "legacy db_binding rows must remain tofu, including imported successors" + ); + + let desired = normalize_sql(include_str!("../../../schema/schema.sql")); + for required in [ + "binding_state = 'active' and revoked_at is null", + "creation_attribution_kind", + "archived_at", + "archived_by", + "archived_reason", + "identity_enrollment_policies", + "identity_enrollment_policy_lineage_guard", + "expires_at", + ] { + assert!( + desired.contains(required), + "desired schema is missing identity-binding authority invariant: {required}" + ); + } } #[test] @@ -1152,6 +1308,74 @@ mod tests { .expect("read applied migrations") } + #[derive(Debug, Clone, PartialEq, Eq)] + struct LegacyIdentitySnapshot { + bindings: Vec, + principals: Vec, + revoked_keys: Vec, + catalog: Vec, + } + + async fn legacy_identity_snapshot(pool: &PgPool) -> LegacyIdentitySnapshot { + async fn json_rows(pool: &PgPool, query: &'static str) -> Vec { + let mut rows = sqlx::query_scalar::<_, serde_json::Value>(query) + .fetch_all(pool) + .await + .expect("snapshot identity rows") + .into_iter() + .map(|value| value.to_string()) + .collect::>(); + rows.sort(); + rows + } + + let bindings = json_rows( + pool, + "SELECT to_jsonb(binding) - ARRAY[\ + 'binding_id', 'binding_version', 'binding_state',\ + 'binding_provenance', 'replacement_binding_id', 'created_by',\ + 'created_policy_version', 'expires_at', 'creation_attribution_kind',\ + 'archived_at', 'archived_by', 'archived_reason']::text[] \ + FROM identity_bindings binding", + ) + .await; + let principals = json_rows( + pool, + "SELECT to_jsonb(principal) FROM identity_principals principal", + ) + .await; + let revoked_keys = json_rows( + pool, + "SELECT to_jsonb(revoked) FROM identity_revoked_keys revoked", + ) + .await; + let mut catalog = sqlx::query_scalar::<_, String>( + r#" + SELECT definition FROM ( + SELECT 'constraint:' || conrelid::regclass::text || ':' || conname || ':' || pg_get_constraintdef(oid) AS definition + FROM pg_constraint + WHERE conrelid IN ('identity_bindings'::regclass, 'identity_principals'::regclass, 'identity_revoked_keys'::regclass) + UNION ALL + SELECT 'index:' || tablename || ':' || indexname || ':' || indexdef + FROM pg_indexes + WHERE schemaname='public' + AND tablename IN ('identity_bindings', 'identity_principals', 'identity_revoked_keys') + ) definitions + ORDER BY definition + "#, + ) + .fetch_all(pool) + .await + .expect("snapshot identity catalog"); + catalog.sort(); + LegacyIdentitySnapshot { + bindings, + principals, + revoked_keys, + catalog, + } + } + #[tokio::test] #[ignore = "requires Postgres"] async fn pre_0007_ambiguous_nip_rs_data_blocks_without_mutation_and_allows_retry() { @@ -1219,7 +1443,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(29)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(30)); } #[tokio::test] @@ -1282,6 +1506,1059 @@ mod tests { assert_eq!(after, vec![(1, Some(true)), (30_350, None)]); } + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn identity_0030_additive_upgrade_preserves_legacy_state_and_handles_history() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(29, &pool) + .await + .expect("apply migrations through legacy identity lifecycle"); + + let domain_a = uuid::Uuid::new_v4(); + let domain_b = uuid::Uuid::new_v4(); + for (domain, label) in [(domain_a, "a"), (domain_b, "b")] { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(domain) + .bind(format!("identity-0030-{label}-{}.example", domain.simple())) + .execute(&pool) + .await + .expect("insert fixture community"); + } + + let active_key = vec![1_u8; 32]; + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, created_at, updated_at, last_seen_at) \ + VALUES ($1, ' Issuer ', ' Subject ', $2, 'jwt_npub', \ + TIMESTAMPTZ '2025-01-01 00:00:00Z', TIMESTAMPTZ '2025-01-01 00:00:00Z', TIMESTAMPTZ '2025-01-01 00:00:00Z')", + ) + .bind(domain_a) + .bind(&active_key) + .execute(&pool) + .await + .expect("insert literal active principal"); + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, created_at, updated_at, last_seen_at) \ + VALUES ($1, ' Issuer ', ' Subject ', $2, 'jwt_npub', \ + TIMESTAMPTZ '2025-01-01 00:00:00Z', TIMESTAMPTZ '2025-01-01 00:00:00Z', TIMESTAMPTZ '2025-01-01 00:00:00Z')", + ) + .bind(domain_b) + .bind(&active_key) + .execute(&pool) + .await + .expect("insert cross-domain control"); + + let chain_keys = [vec![10_u8; 32], vec![11_u8; 32], vec![12_u8; 32]]; + for (index, key) in chain_keys.iter().enumerate() { + if let Some(successor) = chain_keys.get(index + 1) { + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, created_at, updated_at, last_seen_at, \ + revoked_at, revoked_reason, revocation_scope, rotation_completed_at, \ + rotated_to_pubkey, rotation_reason) \ + VALUES ($1, 'https://idp.example', 'chain', $2, 'db_binding', \ + TIMESTAMPTZ '2025-02-01 00:00:00Z', TIMESTAMPTZ '2025-02-01 00:00:00Z', \ + TIMESTAMPTZ '2025-02-01 00:00:00Z', TIMESTAMPTZ '2025-02-01 00:00:00Z', \ + 'legacy rotation', 'rotation', TIMESTAMPTZ '2025-02-01 00:00:00Z', \ + $3, 'legacy rotation')", + ) + .bind(domain_a) + .bind(key) + .bind(successor) + .execute(&pool) + .await + .unwrap_or_else(|error| panic!("insert retired chain node {index}: {error}")); + } else { + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, created_at, updated_at, last_seen_at) \ + VALUES ($1, 'https://idp.example', 'chain', $2, 'db_binding', \ + TIMESTAMPTZ '2025-02-01 00:00:00Z', TIMESTAMPTZ '2025-02-01 00:00:00Z', \ + TIMESTAMPTZ '2025-02-01 00:00:00Z')", + ) + .bind(domain_a) + .bind(key) + .execute(&pool) + .await + .unwrap_or_else(|error| panic!("insert active chain node {index}: {error}")); + } + } + for old in chain_keys.iter().take(chain_keys.len() - 1) { + sqlx::query( + "INSERT INTO identity_revoked_keys (community_id, pubkey, revoked_at, reason) \ + VALUES ($1, $2, TIMESTAMPTZ '2025-02-01 00:00:00Z', 'legacy rotation')", + ) + .bind(domain_a) + .bind(old) + .execute(&pool) + .await + .expect("insert legacy rotation tombstone"); + } + + let revoked_key = vec![20_u8; 32]; + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, revoked_at, revoked_reason, revocation_scope) \ + VALUES ($1, 'https://idp.example', 'pending', $2, 'db_binding', NOW(), 'explicit revoke', 'key')", + ) + .bind(domain_a) + .bind(&revoked_key) + .execute(&pool) + .await + .expect("insert pending revoked binding"); + sqlx::query( + "INSERT INTO identity_revoked_keys (community_id, pubkey, revoked_at, reason) \ + VALUES ($1, $2, TIMESTAMPTZ '2025-03-01 00:00:00Z', 'explicit revoke')", + ) + .bind(domain_a) + .bind(&revoked_key) + .execute(&pool) + .await + .expect("insert explicit key selector"); + + let active_tombstoned_key = vec![21_u8; 32]; + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source) \ + VALUES ($1, 'https://idp.example', 'active-tombstone-owner', $2, 'db_binding')", + ) + .bind(domain_a) + .bind(&active_tombstoned_key) + .execute(&pool) + .await + .expect("insert active binding overlapping legacy key tombstone"); + sqlx::query( + "INSERT INTO identity_revoked_keys (community_id, pubkey, revoked_at, reason) \ + VALUES ($1, $2, TIMESTAMPTZ '2025-03-02 00:00:00Z', 'legacy active overlap')", + ) + .bind(domain_a) + .bind(&active_tombstoned_key) + .execute(&pool) + .await + .expect("insert authoritative tombstone overlapping active binding"); + + sqlx::query( + "INSERT INTO identity_principals \ + (community_id, issuer, uid, disabled_at, disabled_reason) \ + VALUES ($1, 'https://idp.example', 'never-enrolled', NOW(), 'disabled before enrollment')", + ) + .bind(domain_a) + .execute(&pool) + .await + .expect("insert disabled never-enrolled principal"); + + let missing_old = vec![30_u8; 32]; + let missing_target = vec![31_u8; 32]; + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, revoked_at, revoked_reason, revocation_scope, \ + rotation_completed_at, rotated_to_pubkey, rotation_reason) \ + VALUES ($1, 'https://idp.example', 'ambiguous', $2, 'jwt_npub', NOW(), \ + 'missing successor', 'rotation', NOW(), $3, 'missing successor')", + ) + .bind(domain_a) + .bind(&missing_old) + .bind(&missing_target) + .execute(&pool) + .await + .expect("insert missing-lineage fixture"); + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source) \ + VALUES ($1, 'https://idp.example', 'active-target-owner', $2, 'db_binding')", + ) + .bind(domain_a) + .bind(&missing_target) + .execute(&pool) + .await + .expect("insert cross-principal active target fixture"); + sqlx::query( + "INSERT INTO identity_revoked_keys (community_id, pubkey, reason) VALUES ($1, $2, 'ambiguous legacy selector')", + ) + .bind(domain_a) + .bind(&missing_old) + .execute(&pool) + .await + .expect("insert ambiguous selector"); + + let before = legacy_identity_snapshot(&pool).await; + run_migrations(&pool) + .await + .expect("additive identity migration succeeds on populated history"); + let after = legacy_identity_snapshot(&pool).await; + assert_eq!(after.bindings, before.bindings, "legacy bindings changed"); + assert_eq!( + after.principals, before.principals, + "legacy principals changed" + ); + assert_eq!(after.revoked_keys, before.revoked_keys, "legacy Y changed"); + assert!( + before + .catalog + .iter() + .all(|definition| after.catalog.contains(definition)), + "legacy identity constraints/indexes must remain unchanged" + ); + + let chain: Vec = sqlx::query_as( + "SELECT binding.pubkey, binding.binding_version, replacement.pubkey, binding.binding_provenance \ + FROM identity_bindings binding \ + LEFT JOIN identity_bindings replacement \ + ON replacement.community_id=binding.community_id \ + AND replacement.binding_id=binding.replacement_binding_id \ + WHERE binding.community_id=$1 AND binding.issuer='https://idp.example' AND binding.uid='chain' \ + ORDER BY binding.pubkey", + ) + .bind(domain_a) + .fetch_all(&pool) + .await + .expect("read migrated chain"); + assert_eq!(chain.len(), 3); + assert_eq!( + chain[0], + ( + chain_keys[0].clone(), + 1, + Some(chain_keys[1].clone()), + "tofu".to_owned() + ) + ); + assert_eq!( + chain[1], + ( + chain_keys[1].clone(), + 1, + Some(chain_keys[2].clone()), + "tofu".to_owned() + ) + ); + assert_eq!( + chain[2], + (chain_keys[2].clone(), 1, None, "tofu".to_owned()) + ); + + let pending: (Vec, i64, i64) = sqlx::query_as( + "SELECT retired_pubkey, retired_binding_version, selector_version \ + FROM identity_pending_replacements \ + WHERE community_id=$1 AND issuer='https://idp.example' AND subject='pending' AND cleared_at IS NULL", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("read pending selector"); + assert_eq!(pending, (revoked_key.clone(), 1, 1)); + + let quarantined: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM identity_migration_denials \ + WHERE community_id=$1 AND issuer='https://idp.example' AND subject='ambiguous')", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("read migration quarantine"); + assert!(quarantined); + + let target_quarantined: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM identity_migration_denied_keys \ + WHERE community_id=$1 AND pubkey=$2)", + ) + .bind(domain_a) + .bind(&missing_target) + .fetch_one(&pool) + .await + .expect("read migrated key quarantine"); + assert!(target_quarantined); + + assert!( + crate::identity_binding::get_active_identity_binding_by_pubkey( + &pool, + buzz_core::CommunityId::from_uuid(domain_a), + &missing_target, + ) + .await + .is_err(), + "migrated domain-key quarantine must deny active authorization lookup" + ); + + let clean_replacement_key = vec![32_u8; 32]; + let clean_replacement = + crate::identity_lifecycle::VerifiedReplacementKey::after_verified_proof( + &clean_replacement_key, + None, + crate::identity_binding::BindingProvenance::Provisioned, + "migration-test-policy", + ) + .expect("construct clean rotation replacement"); + assert!(crate::identity_lifecycle::rotate_identity_binding( + &pool, + buzz_core::CommunityId::from_uuid(domain_a), + crate::identity_lifecycle::LifecycleContext { + operation_id: crate::identity_lifecycle::LifecycleOperationId::issue(), + actor: &missing_target, + reason: "migrated key quarantine must not be laundered", + }, + crate::identity_lifecycle::IdentityPrincipal { + issuer: "https://idp.example", + subject: "active-target-owner", + }, + &missing_target, + clean_replacement, + ) + .await + .is_err()); + + assert!( + crate::identity_binding::get_active_identity_binding_by_pubkey( + &pool, + buzz_core::CommunityId::from_uuid(domain_a), + &active_tombstoned_key, + ) + .await + .is_err(), + "legacy key tombstone must deny migrated active authorization lookup" + ); + let tombstone_rotation_key = vec![33_u8; 32]; + let tombstone_rotation = + crate::identity_lifecycle::VerifiedReplacementKey::after_verified_proof( + &tombstone_rotation_key, + None, + crate::identity_binding::BindingProvenance::Provisioned, + "migration-test-policy", + ) + .expect("construct tombstone rotation replacement"); + assert!(crate::identity_lifecycle::rotate_identity_binding( + &pool, + buzz_core::CommunityId::from_uuid(domain_a), + crate::identity_lifecycle::LifecycleContext { + operation_id: crate::identity_lifecycle::LifecycleOperationId::issue(), + actor: &active_tombstoned_key, + reason: "legacy key tombstone must not be laundered", + }, + crate::identity_lifecycle::IdentityPrincipal { + issuer: "https://idp.example", + subject: "active-tombstone-owner", + }, + &active_tombstoned_key, + tombstone_rotation, + ) + .await + .is_err()); + + let before_denials: (i64, i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_lifecycle_operations WHERE community_id=$1)", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("snapshot migrated authority state"); + let domain_a_id = buzz_core::CommunityId::from_uuid(domain_a); + for (subject, key) in [ + ("ambiguous", missing_old.as_slice()), + ("other-principal", missing_target.as_slice()), + ("other-revoked-principal", revoked_key.as_slice()), + ] { + let result = crate::identity_binding::resolve_identity_binding( + &pool, + &crate::identity_binding::ResolveBindingInput { + authorization_domain: domain_a_id, + issuer: "https://idp.example", + subject, + pubkey: key, + display_name: None, + enrollment_mode: crate::identity_binding::EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "migration-test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("resolve migrated denied identity"); + assert_eq!( + result, + crate::identity_binding::ResolveBindingResult::Denied( + crate::identity_binding::BindingDenial::Revoked + ) + ); + } + let denied_replacement = + crate::identity_lifecycle::VerifiedReplacementKey::after_verified_proof( + &missing_target, + None, + crate::identity_binding::BindingProvenance::Provisioned, + "migration-test-policy", + ) + .expect("construct denied migrated replacement"); + assert!(crate::identity_lifecycle::provision_identity_binding( + &pool, + domain_a_id, + crate::identity_lifecycle::LifecycleContext { + operation_id: crate::identity_lifecycle::LifecycleOperationId::issue(), + actor: &missing_target, + reason: "migrated ambiguity must block lifecycle", + }, + crate::identity_lifecycle::IdentityPrincipal { + issuer: "https://idp.example", + subject: "lifecycle-other-principal", + }, + crate::identity_binding::EnrollmentMode::Provisioned, + denied_replacement, + ) + .await + .is_err()); + let after_denials: (i64, i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_lifecycle_operations WHERE community_id=$1)", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("verify migrated denials did not mutate authority"); + assert_eq!(after_denials, before_denials); + + let domain_b_result = crate::identity_binding::resolve_identity_binding( + &pool, + &crate::identity_binding::ResolveBindingInput { + authorization_domain: buzz_core::CommunityId::from_uuid(domain_b), + issuer: "https://idp.example", + subject: "cross-domain-allowed", + pubkey: &missing_target, + display_name: None, + enrollment_mode: crate::identity_binding::EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "migration-test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("resolve same key in independent domain"); + assert!(matches!( + domain_b_result, + crate::identity_binding::ResolveBindingResult::Enrolled(_) + )); + } + + const IDENTITY_HISTORY_LENGTHS: [usize; 6] = [0, 1, 2, 3, 8, 32]; + + fn identity_history_keys(length: usize, namespace: u8) -> Vec> { + (0..length) + .map(|index| { + let mut key = vec![0_u8; 32]; + key[0] = length as u8; + key[1] = index as u8; + key[30] = namespace; + key[31] = 0xa5; + key + }) + .collect() + } + + async fn insert_legacy_identity_history( + pool: &PgPool, + domain: uuid::Uuid, + issuer: &str, + subject: &str, + keys: &[Vec], + ) { + // Deliberately reverse insertion order. Equal timestamps model the + // transaction-stable NOW() values emitted by the legacy helper. + for index in (0..keys.len()).rev() { + if index + 1 == keys.len() { + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,created_at,updated_at,last_seen_at) \ + VALUES ($1,$2,$3,$4,'db_binding', \ + TIMESTAMPTZ '2025-04-01 00:00:00Z', \ + TIMESTAMPTZ '2025-04-01 00:00:00Z', \ + TIMESTAMPTZ '2025-04-01 00:00:00Z')", + ) + .bind(domain) + .bind(issuer) + .bind(subject) + .bind(&keys[index]) + .execute(pool) + .await + .expect("insert terminal history node"); + } else { + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,created_at,updated_at,last_seen_at, \ + revoked_at,revoked_reason,revocation_scope,rotation_completed_at, \ + rotated_to_pubkey,rotation_reason) \ + VALUES ($1,$2,$3,$4,'db_binding', \ + TIMESTAMPTZ '2025-04-01 00:00:00Z', \ + TIMESTAMPTZ '2025-04-01 00:00:00Z', \ + TIMESTAMPTZ '2025-04-01 00:00:00Z', \ + TIMESTAMPTZ '2025-04-01 00:00:00Z','legacy rotation','rotation', \ + TIMESTAMPTZ '2025-04-01 00:00:00Z',$5,'legacy rotation')", + ) + .bind(domain) + .bind(issuer) + .bind(subject) + .bind(&keys[index]) + .bind(&keys[index + 1]) + .execute(pool) + .await + .expect("insert retired history node"); + } + } + } + + async fn assert_legacy_domain_sentinels( + pool: &PgPool, + domain: uuid::Uuid, + keys: &[Vec], + expected_facts: &[String], + expected_authorization: &[bool], + expected_audit: &[String], + expected_history: &[String], + ) { + assert_eq!( + super::deterministic_tests::legacy_identity_facts(pool, domain).await, + expected_facts + ); + let mut authorization = Vec::with_capacity(keys.len()); + for key in keys { + authorization + .push(super::deterministic_tests::raw_domain_authorized(pool, domain, key).await); + } + assert_eq!(authorization, expected_authorization); + assert_eq!( + super::deterministic_tests::domain_audit_snapshot(pool, domain).await, + expected_audit + ); + assert_eq!( + super::deterministic_tests::domain_legacy_history_snapshot(pool, domain).await, + expected_history + ); + } + + async fn assert_imported_identity_history( + pool: &PgPool, + domain: uuid::Uuid, + issuer: &str, + subject: &str, + keys: &[Vec], + ) { + type BindingRow = (uuid::Uuid, i64, Vec, String, String, Option); + let rows: Vec = sqlx::query_as( + "SELECT binding_id,binding_version,pubkey,binding_state, \ + binding_provenance,replacement_binding_id \ + FROM identity_bindings \ + WHERE community_id=$1 AND issuer=$2 AND uid=$3 \ + ORDER BY pubkey", + ) + .bind(domain) + .bind(issuer) + .bind(subject) + .fetch_all(pool) + .await + .expect("read imported history"); + assert_eq!(rows.len(), keys.len()); + for (index, row) in rows.iter().enumerate() { + assert_eq!(row.1, 1); + assert_eq!(row.2, keys[index]); + assert_eq!( + row.3, + if index + 1 == keys.len() { + "active" + } else { + "rotated" + } + ); + assert_eq!(row.4, "tofu"); + assert_eq!(row.5, rows.get(index + 1).map(|successor| successor.0)); + } + + let edges: Vec<(uuid::Uuid, uuid::Uuid)> = sqlx::query_as( + "SELECT predecessor.binding_id,successor.binding_id \ + FROM identity_binding_lineage lineage \ + JOIN identity_bindings predecessor \ + ON predecessor.community_id=lineage.community_id \ + AND predecessor.binding_id=lineage.predecessor_binding_id \ + JOIN identity_bindings successor \ + ON successor.community_id=lineage.community_id \ + AND successor.binding_id=lineage.successor_binding_id \ + WHERE predecessor.community_id=$1 \ + AND predecessor.issuer=$2 AND predecessor.uid=$3 \ + ORDER BY predecessor.pubkey", + ) + .bind(domain) + .bind(issuer) + .bind(subject) + .fetch_all(pool) + .await + .expect("read imported lineage"); + let expected_edges = rows + .windows(2) + .map(|pair| (pair[0].0, pair[1].0)) + .collect::>(); + assert_eq!(edges, expected_edges); + + let retired: Vec<(Vec, Option, Option)> = sqlx::query_as( + "SELECT pubkey,retired_binding_id,retired_binding_version \ + FROM identity_retired_pairs \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 \ + ORDER BY pubkey", + ) + .bind(domain) + .bind(issuer) + .bind(subject) + .fetch_all(pool) + .await + .expect("read imported retired pairs"); + let expected_retired = rows + .iter() + .take(rows.len().saturating_sub(1)) + .map(|row| (row.2.clone(), Some(row.0), Some(row.1))) + .collect::>(); + assert_eq!(retired, expected_retired); + + type HistoryRow = ( + uuid::Uuid, + i64, + Vec, + String, + String, + String, + Option, + ); + let history: Vec = sqlx::query_as( + "SELECT binding_id,binding_version,pubkey,binding_state, \ + binding_provenance,transition_kind,replacement_binding_id \ + FROM identity_binding_history \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 \ + ORDER BY pubkey", + ) + .bind(domain) + .bind(issuer) + .bind(subject) + .fetch_all(pool) + .await + .expect("read imported binding history"); + assert_eq!(history.len(), rows.len()); + for (binding, history_row) in rows.iter().zip(&history) { + assert_eq!(history_row.0, binding.0); + assert_eq!(history_row.1, binding.1); + assert_eq!(history_row.2, binding.2); + assert_eq!(history_row.3, binding.3); + assert_eq!(history_row.4, binding.4); + assert_eq!(history_row.5, "legacy_import"); + assert_eq!(history_row.6, binding.5); + } + + let denied: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM identity_migration_denials \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3)", + ) + .bind(domain) + .bind(issuer) + .bind(subject) + .fetch_one(pool) + .await + .expect("read imported principal denial"); + assert!(!denied); + let pending: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_pending_replacements \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3", + ) + .bind(domain) + .bind(issuer) + .bind(subject) + .fetch_one(pool) + .await + .expect("read imported pending selectors"); + assert_eq!(pending, 0); + for (index, key) in keys.iter().enumerate() { + let denied_key: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM identity_migration_denied_keys \ + WHERE community_id=$1 AND pubkey=$2)", + ) + .bind(domain) + .bind(key) + .fetch_one(pool) + .await + .expect("read imported key denial"); + assert!(!denied_key); + let active = crate::identity_binding::get_active_identity_binding_by_pubkey( + pool, + buzz_core::CommunityId::from_uuid(domain), + key, + ) + .await + .expect("read imported authorization"); + if index + 1 == keys.len() { + let active = active.expect("history head remains authoritative"); + assert_eq!(active.binding_id, rows[index].0); + assert_eq!(active.binding_version, rows[index].1 as u64); + assert_eq!(active.issuer, issuer); + assert_eq!(active.uid, subject); + assert_eq!(active.pubkey, *key); + assert_eq!( + active.binding_state, + crate::identity_binding::BindingState::Active + ); + } else { + assert!(active.is_none(), "retired history key regained authority"); + } + } + if keys.is_empty() { + let absent = crate::identity_binding::get_active_identity_binding_by_pubkey( + pool, + buzz_core::CommunityId::from_uuid(domain), + &[0xe0_u8; 32], + ) + .await + .expect("read zero-history authorization sentinel"); + assert!(absent.is_none(), "zero history invented authority"); + } + } + + async fn assert_temporal_inversion_quarantined( + pool: &PgPool, + domain: uuid::Uuid, + older_target: &[u8], + ) { + let inversion_denied: (bool, bool) = sqlx::query_as( + "SELECT \ + EXISTS(SELECT 1 FROM identity_migration_denials \ + WHERE community_id=$1 AND issuer='https://idp.example' AND subject='temporal-inversion'), \ + EXISTS(SELECT 1 FROM identity_migration_denied_keys \ + WHERE community_id=$1 AND pubkey=$2)", + ) + .bind(domain) + .bind(older_target) + .fetch_one(pool) + .await + .expect("read temporal inversion quarantine"); + assert_eq!(inversion_denied, (true, true)); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn identity_0030_imports_arbitrary_histories_and_quarantines_temporal_inversion() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(29, &pool) + .await + .expect("apply migrations through legacy identity lifecycle"); + let domain = uuid::Uuid::new_v4(); + let domain_b = uuid::Uuid::new_v4(); + for (community, suffix) in [(domain, "a"), (domain_b, "b")] { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(community) + .bind(format!( + "identity-0030-history-{suffix}-{}.example", + community.simple() + )) + .execute(&pool) + .await + .expect("insert history community"); + } + + let mut seeded_lengths = BTreeSet::new(); + for length in IDENTITY_HISTORY_LENGTHS { + let subject = format!("history-{length}"); + let keys = identity_history_keys(length, 0); + insert_legacy_identity_history(&pool, domain, "https://idp.example", &subject, &keys) + .await; + assert!(seeded_lengths.insert(length)); + } + assert_eq!( + seeded_lengths, + IDENTITY_HISTORY_LENGTHS.into_iter().collect() + ); + + let domain_b_keys = identity_history_keys(3, 0xb7); + insert_legacy_identity_history( + &pool, + domain_b, + "https://domain-b.example", + "domain-b-history-sentinel", + &domain_b_keys, + ) + .await; + sqlx::query( + "INSERT INTO audit_log \ + (community_id,seq,hash,action,object_id,detail,created_at) \ + VALUES ($1,1,$2,'preexisting_domain_b_history', \ + 'domain-b-history-sentinel', \ + '{\"sentinel\":\"before-domain-a-operation\"}'::jsonb, \ + TIMESTAMPTZ '2025-04-01 00:00:00Z')", + ) + .bind(domain_b) + .bind(vec![0xb7_u8; 32]) + .execute(&pool) + .await + .expect("insert substantive domain-B audit sentinel"); + + let domain_b_facts_pre = + super::deterministic_tests::legacy_identity_facts(&pool, domain_b).await; + let domain_b_audit_pre = + super::deterministic_tests::domain_audit_snapshot(&pool, domain_b).await; + let domain_b_history_pre = + super::deterministic_tests::domain_legacy_history_snapshot(&pool, domain_b).await; + let domain_b_authorization_pre = vec![false, false, true]; + assert_legacy_domain_sentinels( + &pool, + domain_b, + &domain_b_keys, + &domain_b_facts_pre, + &domain_b_authorization_pre, + &domain_b_audit_pre, + &domain_b_history_pre, + ) + .await; + + let older_target = vec![240_u8; 32]; + let newer_predecessor = vec![241_u8; 32]; + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,created_at,updated_at,last_seen_at) \ + VALUES ($1,'https://idp.example','temporal-inversion',$2,'db_binding', \ + TIMESTAMPTZ '2024-01-01 00:00:00Z', \ + TIMESTAMPTZ '2024-01-01 00:00:00Z', \ + TIMESTAMPTZ '2024-01-01 00:00:00Z')", + ) + .bind(domain) + .bind(&older_target) + .execute(&pool) + .await + .expect("insert older target"); + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,created_at,updated_at,last_seen_at, \ + revoked_at,revoked_reason,revocation_scope,rotation_completed_at, \ + rotated_to_pubkey,rotation_reason) \ + VALUES ($1,'https://idp.example','temporal-inversion',$2,'db_binding', \ + TIMESTAMPTZ '2025-01-01 00:00:00Z', \ + TIMESTAMPTZ '2025-01-01 00:00:00Z', \ + TIMESTAMPTZ '2025-01-01 00:00:00Z', \ + TIMESTAMPTZ '2025-01-01 00:00:00Z','invalid rotation','rotation', \ + TIMESTAMPTZ '2025-01-01 00:00:00Z',$3,'invalid rotation')", + ) + .bind(domain) + .bind(&newer_predecessor) + .bind(&older_target) + .execute(&pool) + .await + .expect("insert temporally inverted predecessor"); + + run_migrations(&pool) + .await + .expect("import arbitrary valid histories without aborting"); + // Prove domain B before the first domain-A read after the operation. + assert_legacy_domain_sentinels( + &pool, + domain_b, + &domain_b_keys, + &domain_b_facts_pre, + &domain_b_authorization_pre, + &domain_b_audit_pre, + &domain_b_history_pre, + ) + .await; + assert_imported_identity_history( + &pool, + domain_b, + "https://domain-b.example", + "domain-b-history-sentinel", + &domain_b_keys, + ) + .await; + let domain_b_state_post = + super::deterministic_tests::domain_identity_snapshot(&pool, domain_b).await; + let domain_b_history_post = + super::deterministic_tests::domain_binding_history_snapshot(&pool, domain_b).await; + assert_eq!(domain_b_history_post.len(), domain_b_keys.len()); + + let mut verified_lengths = BTreeSet::new(); + for length in IDENTITY_HISTORY_LENGTHS { + let subject = format!("history-{length}"); + let keys = identity_history_keys(length, 0); + assert_imported_identity_history(&pool, domain, "https://idp.example", &subject, &keys) + .await; + assert!(verified_lengths.insert(length)); + } + assert_eq!( + verified_lengths, + IDENTITY_HISTORY_LENGTHS.into_iter().collect() + ); + assert_temporal_inversion_quarantined(&pool, domain, &older_target).await; + let domain_a_state_post = + super::deterministic_tests::domain_identity_snapshot(&pool, domain).await; + assert_legacy_domain_sentinels( + &pool, + domain_b, + &domain_b_keys, + &domain_b_facts_pre, + &domain_b_authorization_pre, + &domain_b_audit_pre, + &domain_b_history_pre, + ) + .await; + assert_eq!( + super::deterministic_tests::domain_identity_snapshot(&pool, domain_b).await, + domain_b_state_post + ); + assert_eq!( + super::deterministic_tests::domain_binding_history_snapshot(&pool, domain_b).await, + domain_b_history_post + ); + + pool.close().await; + let pool = connect_test_pool().await; + for length in IDENTITY_HISTORY_LENGTHS { + let subject = format!("history-{length}"); + let keys = identity_history_keys(length, 0); + assert_imported_identity_history(&pool, domain, "https://idp.example", &subject, &keys) + .await; + } + assert_temporal_inversion_quarantined(&pool, domain, &older_target).await; + assert_eq!( + super::deterministic_tests::domain_identity_snapshot(&pool, domain).await, + domain_a_state_post + ); + assert_legacy_domain_sentinels( + &pool, + domain_b, + &domain_b_keys, + &domain_b_facts_pre, + &domain_b_authorization_pre, + &domain_b_audit_pre, + &domain_b_history_pre, + ) + .await; + assert_eq!( + super::deterministic_tests::domain_identity_snapshot(&pool, domain_b).await, + domain_b_state_post + ); + assert_eq!( + super::deterministic_tests::domain_binding_history_snapshot(&pool, domain_b).await, + domain_b_history_post + ); + + run_migrations(&pool) + .await + .expect("retry arbitrary-history migration idempotently"); + for length in IDENTITY_HISTORY_LENGTHS { + let subject = format!("history-{length}"); + let keys = identity_history_keys(length, 0); + assert_imported_identity_history(&pool, domain, "https://idp.example", &subject, &keys) + .await; + } + assert_temporal_inversion_quarantined(&pool, domain, &older_target).await; + assert_eq!( + super::deterministic_tests::domain_identity_snapshot(&pool, domain).await, + domain_a_state_post + ); + assert_legacy_domain_sentinels( + &pool, + domain_b, + &domain_b_keys, + &domain_b_facts_pre, + &domain_b_authorization_pre, + &domain_b_audit_pre, + &domain_b_history_pre, + ) + .await; + assert_eq!( + super::deterministic_tests::domain_identity_snapshot(&pool, domain_b).await, + domain_b_state_post + ); + assert_eq!( + super::deterministic_tests::domain_binding_history_snapshot(&pool, domain_b).await, + domain_b_history_post + ); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn identity_0030_failure_rolls_back_every_additive_projection() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(29, &pool) + .await + .expect("apply migrations through legacy identity lifecycle"); + let domain = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(domain) + .bind(format!( + "identity-0030-rollback-{}.example", + domain.simple() + )) + .execute(&pool) + .await + .expect("insert rollback community"); + sqlx::query( + "INSERT INTO identity_bindings (community_id, issuer, uid, pubkey, source) \ + VALUES ($1,'https://idp.example','rollback',$2,'db_binding')", + ) + .bind(domain) + .bind(vec![99_u8; 32]) + .execute(&pool) + .await + .expect("insert rollback fixture"); + // Reserve the name of 0030's final index so the transaction fails only + // after every preceding additive DDL and backfill statement has run. + sqlx::query( + "CREATE INDEX idx_identity_lifecycle_operations_key \ + ON identity_bindings (community_id, pubkey)", + ) + .execute(&pool) + .await + .expect("create late migration conflict"); + + assert!(MIGRATOR.run_to(30, &pool).await.is_err()); + let projected_tables: (Option, Option, Option) = sqlx::query_as( + "SELECT to_regclass('identity_retired_pairs')::text, \ + to_regclass('identity_pending_replacements')::text, \ + to_regclass('identity_binding_history')::text", + ) + .fetch_one(&pool) + .await + .expect("inspect rolled back tables"); + assert_eq!(projected_tables, (None, None, None)); + let projected_columns: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.columns \ + WHERE table_schema='public' AND table_name='identity_bindings' \ + AND column_name IN ('binding_id','binding_version','binding_state','binding_provenance')", + ) + .fetch_one(&pool) + .await + .expect("inspect rolled back columns"); + assert_eq!(projected_columns, 0); + let latest: i64 = + sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations WHERE success") + .fetch_one(&pool) + .await + .expect("read latest migration"); + assert_eq!(latest, 29); + // sqlx's failed pool-backed migration attempt can return the session + // while its session advisory lock is still held. Closing this + // disposable pool releases that lock before the explicit retry. + pool.close().await; + let pool = connect_test_pool().await; + sqlx::query("DROP INDEX idx_identity_lifecycle_operations_key") + .execute(&pool) + .await + .expect("drop late migration conflict"); + run_migrations(&pool) + .await + .expect("retry additive projection after rollback"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn run_migrations_applies_consolidated_initial_schema_on_fresh_database() { diff --git a/crates/buzz-db/src/migration_deterministic_tests.rs b/crates/buzz-db/src/migration_deterministic_tests.rs new file mode 100644 index 0000000000..89423ba8c8 --- /dev/null +++ b/crates/buzz-db/src/migration_deterministic_tests.rs @@ -0,0 +1,2519 @@ +use super::MIGRATOR; + +use crate::identity_binding::{ + get_active_identity_binding_by_pubkey, resolve_identity_binding, BindingDenial, + BindingProvenance, EnrollmentMode, ResolveBindingInput, ResolveBindingResult, +}; +use crate::identity_lifecycle::{ + disable_identity_principal, enable_identity_principal, provision_identity_binding, + recover_identity_binding, retire_identity_pair, revoke_identity_key, rotate_identity_binding, + IdentityPrincipal, LifecycleContext, LifecycleOperationId, PendingLineage, + VerifiedReplacementKey, +}; +use buzz_core::CommunityId; +use sqlx::{Acquire, PgPool}; +use std::collections::BTreeSet; +use uuid::Uuid; + +const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + +fn database_url() -> String { + std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()) +} + +async fn connect_pool() -> PgPool { + PgPool::connect(&database_url()) + .await + .expect("connect deterministic migration DB") +} + +async fn reset_empty_to_0029(pool: &PgPool, label: &str) -> (Uuid, Uuid) { + sqlx::query("DROP SCHEMA IF EXISTS public CASCADE") + .execute(pool) + .await + .expect("drop public schema"); + sqlx::query("CREATE SCHEMA public") + .execute(pool) + .await + .expect("create public schema"); + MIGRATOR + .run_to(29, pool) + .await + .expect("migrate through 0029"); + let domain_a = Uuid::new_v4(); + let domain_b = Uuid::new_v4(); + for (domain, suffix) in [(domain_a, "a"), (domain_b, "b")] { + sqlx::query("INSERT INTO communities (id,host) VALUES ($1,$2)") + .bind(domain) + .bind(format!("{label}-{suffix}-{}.example", domain.simple())) + .execute(pool) + .await + .expect("insert deterministic migration domain"); + } + sqlx::query( + "INSERT INTO identity_bindings (community_id,issuer,uid,pubkey,source) \ + VALUES ($1,'https://domain-b.example','domain-b-sentinel',$2,'jwt_npub')", + ) + .bind(domain_b) + .bind(vec![72_u8; 32]) + .execute(pool) + .await + .expect("insert domain-B migration sentinel"); + (domain_a, domain_b) +} + +async fn reset_to_0029(pool: &PgPool) -> (Uuid, Uuid) { + let (domain_a, domain_b) = reset_empty_to_0029(pool, "migration-fault").await; + sqlx::query( + "INSERT INTO identity_bindings (community_id,issuer,uid,pubkey,source) \ + VALUES ($1,'https://idp.example','migration-fault-a-subject',$2,'db_binding')", + ) + .bind(domain_a) + .bind(vec![71_u8; 32]) + .execute(pool) + .await + .expect("insert migration fault binding"); + (domain_a, domain_b) +} + +fn split_statements(sql: &str) -> Vec { + let bytes = sql.as_bytes(); + let mut statements = Vec::new(); + let mut start = 0; + let mut index = 0; + let mut single_quote = false; + let mut dollar_quote = false; + let mut line_comment = false; + while index < bytes.len() { + if line_comment { + if bytes[index] == b'\n' { + line_comment = false; + } + index += 1; + continue; + } + if !single_quote + && !dollar_quote + && index + 1 < bytes.len() + && &bytes[index..index + 2] == b"--" + { + line_comment = true; + index += 2; + continue; + } + match bytes[index] { + b'\'' if !dollar_quote => { + if single_quote && index + 1 < bytes.len() && bytes[index + 1] == b'\'' { + index += 2; + continue; + } + single_quote = !single_quote; + index += 1; + } + b'$' if !single_quote && index + 1 < bytes.len() && bytes[index + 1] == b'$' => { + dollar_quote = !dollar_quote; + index += 2; + } + b';' if !single_quote && !dollar_quote => { + let statement = sql[start..index].trim(); + if !statement.is_empty() { + statements.push(statement.to_owned()); + } + start = index + 1; + index += 1; + } + _ => index += 1, + } + } + let tail = sql[start..].trim(); + if !tail.is_empty() { + statements.push(tail.to_owned()); + } + statements +} + +fn migration_0030() -> &'static sqlx::migrate::Migration { + MIGRATOR + .iter() + .find(|migration| migration.version == 30) + .expect("embedded migration 0030") +} + +async fn legacy_snapshot(pool: &PgPool) -> Vec { + let tables = [ + "communities", + "identity_bindings", + "identity_principals", + "identity_revoked_keys", + "audit_log", + ]; + let mut snapshot = Vec::new(); + for table in tables { + let query = format!("SELECT to_jsonb(t)::text FROM {table} t ORDER BY 1"); + let rows = sqlx::query_scalar::<_, String>(sqlx::AssertSqlSafe(query)) + .fetch_all(pool) + .await + .expect("snapshot legacy rows"); + snapshot.extend(rows.into_iter().map(|row| format!("row:{table}:{row}"))); + } + let catalog = sqlx::query_scalar::<_, String>( + "SELECT value FROM (\ + SELECT 'column:'||table_name||':'||column_name||':'||data_type||':'||is_nullable||':'||COALESCE(column_default,'') AS value \ + FROM information_schema.columns WHERE table_schema='public' AND table_name LIKE 'identity_%' \ + UNION ALL \ + SELECT 'constraint:'||conrelid::regclass::text||':'||conname||':'||pg_get_constraintdef(oid) \ + FROM pg_constraint WHERE conrelid::regclass::text LIKE 'identity_%' \ + UNION ALL \ + SELECT 'index:'||tablename||':'||indexname||':'||indexdef \ + FROM pg_indexes WHERE schemaname='public' AND tablename LIKE 'identity_%'\ + ) catalog ORDER BY value", + ) + .fetch_all(pool) + .await + .expect("snapshot legacy catalog"); + snapshot.extend(catalog); + let versions = sqlx::query_scalar::<_, i64>( + "SELECT version FROM _sqlx_migrations WHERE success ORDER BY version", + ) + .fetch_all(pool) + .await + .expect("snapshot migration versions"); + snapshot.extend( + versions + .into_iter() + .map(|version| format!("version:{version}")), + ); + snapshot.sort(); + snapshot +} + +async fn identity_catalog_contract(pool: &PgPool) -> Vec { + sqlx::query_scalar::<_, String>( + "SELECT value FROM (\ + SELECT 'column:'||class.relname||':'||attribute.attname||':'||\ + format_type(attribute.atttypid,attribute.atttypmod)||':'||\ + attribute.attnotnull::text||':'||\ + COALESCE(pg_get_expr(default_value.adbin,default_value.adrelid),'') AS value \ + FROM pg_attribute attribute \ + JOIN pg_class class ON class.oid=attribute.attrelid \ + JOIN pg_namespace namespace ON namespace.oid=class.relnamespace \ + LEFT JOIN pg_attrdef default_value \ + ON default_value.adrelid=attribute.attrelid \ + AND default_value.adnum=attribute.attnum \ + WHERE namespace.nspname='public' AND class.relname LIKE 'identity_%' \ + AND class.relkind='r' AND attribute.attnum>0 AND NOT attribute.attisdropped \ + UNION ALL \ + SELECT 'constraint:'||conrelid::regclass::text||':'||conname||':'||pg_get_constraintdef(oid) \ + FROM pg_constraint WHERE conrelid::regclass::text LIKE 'identity_%' \ + UNION ALL \ + SELECT 'index:'||tablename||':'||indexname||':'||indexdef \ + FROM pg_indexes WHERE schemaname='public' AND tablename LIKE 'identity_%'\ + ) catalog ORDER BY value", + ) + .fetch_all(pool) + .await + .expect("snapshot normalized identity catalog") +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn populated_0030_upgrade_matches_desired_identity_catalog() { + let pool = connect_pool().await; + sqlx::raw_sql("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public") + .execute(&pool) + .await + .expect("reset for desired identity catalog"); + sqlx::raw_sql(include_str!("../../../schema/schema.sql")) + .execute(&pool) + .await + .expect("apply desired schema"); + let desired = identity_catalog_contract(&pool).await; + + sqlx::raw_sql("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public") + .execute(&pool) + .await + .expect("reset for populated upgrade"); + MIGRATOR + .run_to(29, &pool) + .await + .expect("apply migrations through 0029"); + let domain = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id,host) VALUES ($1,$2)") + .bind(domain) + .bind(format!("catalog-{}.example", domain.simple())) + .execute(&pool) + .await + .expect("insert populated-upgrade domain"); + insert_rotated_legacy_row(&pool, domain, "catalog-principal", &[0xC1; 32], &[0xC2; 32]).await; + sqlx::query( + "INSERT INTO identity_bindings (community_id,issuer,uid,pubkey,source) \ + VALUES ($1,'https://idp.example','catalog-principal',$2,'db_binding')", + ) + .bind(domain) + .bind([0xC2_u8; 32]) + .execute(&pool) + .await + .expect("insert populated-upgrade successor"); + super::run_migrations(&pool) + .await + .expect("apply populated 0030 upgrade"); + let upgraded = identity_catalog_contract(&pool).await; + assert_eq!(upgraded, desired); +} + +async fn full_identity_snapshot(pool: &PgPool) -> Vec { + let tables = [ + "identity_bindings", + "identity_enrollment_policies", + "identity_principals", + "identity_revoked_keys", + "identity_migration_denials", + "identity_migration_denied_keys", + "identity_binding_lineage", + "identity_retired_pairs", + "identity_pending_replacements", + "identity_binding_history", + "identity_lifecycle_operations", + "audit_log", + ]; + let mut snapshot = Vec::new(); + for table in tables { + let query = format!("SELECT to_jsonb(t)::text FROM {table} t ORDER BY 1"); + let rows = sqlx::query_scalar::<_, String>(sqlx::AssertSqlSafe(query)) + .fetch_all(pool) + .await + .expect("snapshot migrated identity rows"); + snapshot.extend(rows.into_iter().map(|row| format!("{table}:{row}"))); + } + let marker: Vec = + sqlx::query_scalar("SELECT to_jsonb(m)::text FROM _sqlx_migrations m WHERE version=30") + .fetch_all(pool) + .await + .expect("snapshot 0030 marker"); + snapshot.extend(marker.into_iter().map(|row| format!("marker:{row}"))); + snapshot.sort(); + snapshot +} + +pub(super) async fn raw_domain_authorized(pool: &PgPool, domain: Uuid, key: &[u8]) -> bool { + sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM identity_bindings binding \ + WHERE community_id=$1 AND pubkey=$2 AND revoked_at IS NULL \ + AND COALESCE(to_jsonb(binding)->>'binding_state','active')='active')", + ) + .bind(domain) + .bind(key) + .fetch_one(pool) + .await + .expect("read raw legacy authorization sentinel") +} + +pub(super) async fn domain_audit_snapshot(pool: &PgPool, domain: Uuid) -> Vec { + sqlx::query_scalar::<_, String>( + "SELECT to_jsonb(row_value)::text FROM audit_log row_value \ + WHERE community_id=$1 ORDER BY 1", + ) + .bind(domain) + .fetch_all(pool) + .await + .expect("read domain audit sentinel") +} + +pub(super) async fn legacy_identity_facts(pool: &PgPool, domain: Uuid) -> Vec { + let mut facts = sqlx::query_scalar::<_, serde_json::Value>( + "SELECT to_jsonb(binding)-ARRAY[\ + 'binding_id','binding_version','binding_state','binding_provenance',\ + 'replacement_binding_id','created_by','created_policy_version',\ + 'expires_at','creation_attribution_kind','archived_at','archived_by','archived_reason']::text[] \ + FROM identity_bindings binding WHERE community_id=$1", + ) + .bind(domain) + .fetch_all(pool) + .await + .expect("read legacy binding facts") + .into_iter() + .map(|value| format!("binding:{value}")) + .collect::>(); + for (table, query) in [ + ( + "principal", + "SELECT to_jsonb(row_value) FROM identity_principals row_value WHERE community_id=$1", + ), + ( + "revoked_key", + "SELECT to_jsonb(row_value) FROM identity_revoked_keys row_value WHERE community_id=$1", + ), + ] { + let rows = sqlx::query_scalar::<_, serde_json::Value>(query) + .bind(domain) + .fetch_all(pool) + .await + .expect("read legacy selector facts"); + facts.extend(rows.into_iter().map(|value| format!("{table}:{value}"))); + } + facts.sort(); + facts +} + +pub(super) async fn domain_legacy_history_snapshot(pool: &PgPool, domain: Uuid) -> Vec { + sqlx::query_scalar::<_, String>( + "SELECT jsonb_build_object(\ + 'issuer_hex',encode(convert_to(issuer,'UTF8'),'hex'),\ + 'subject_hex',encode(convert_to(uid,'UTF8'),'hex'),\ + 'pubkey_hex',encode(pubkey,'hex'),\ + 'source_hex',encode(convert_to(source,'UTF8'),'hex'),\ + 'revoked_at',revoked_at,\ + 'revoked_reason_hex',CASE WHEN revoked_reason IS NULL THEN NULL ELSE encode(convert_to(revoked_reason,'UTF8'),'hex') END,\ + 'revocation_scope_hex',CASE WHEN revocation_scope IS NULL THEN NULL ELSE encode(convert_to(revocation_scope,'UTF8'),'hex') END,\ + 'rotation_completed_at',rotation_completed_at,\ + 'rotated_to_pubkey_hex',CASE WHEN rotated_to_pubkey IS NULL THEN NULL ELSE encode(rotated_to_pubkey,'hex') END,\ + 'rotation_reason_hex',CASE WHEN rotation_reason IS NULL THEN NULL ELSE encode(convert_to(rotation_reason,'UTF8'),'hex') END\ + )::text \ + FROM identity_bindings WHERE community_id=$1 ORDER BY 1", + ) + .bind(domain) + .fetch_all(pool) + .await + .expect("read normalized legacy history sentinel") +} + +pub(super) async fn domain_binding_history_snapshot(pool: &PgPool, domain: Uuid) -> Vec { + sqlx::query_scalar::<_, String>( + "SELECT to_jsonb(row_value)::text FROM identity_binding_history row_value \ + WHERE community_id=$1 ORDER BY 1", + ) + .bind(domain) + .fetch_all(pool) + .await + .expect("read domain binding history sentinel") +} + +pub(super) async fn domain_identity_snapshot(pool: &PgPool, domain: Uuid) -> Vec { + let tables = [ + "identity_bindings", + "identity_enrollment_policies", + "identity_principals", + "identity_revoked_keys", + "identity_migration_denials", + "identity_migration_denied_keys", + "identity_binding_lineage", + "identity_retired_pairs", + "identity_pending_replacements", + "identity_binding_history", + "identity_lifecycle_operations", + "audit_log", + ]; + let mut snapshot = Vec::new(); + for table in tables { + let query = format!( + "SELECT to_jsonb(row_value)::text FROM {table} row_value WHERE community_id=$1 ORDER BY 1" + ); + let rows = sqlx::query_scalar::<_, String>(sqlx::AssertSqlSafe(query)) + .bind(domain) + .fetch_all(pool) + .await + .expect("read domain identity snapshot"); + snapshot.extend(rows.into_iter().map(|row| format!("{table}:{row}"))); + } + snapshot +} + +async fn assert_response_loss_legacy_sentinels( + pool: &PgPool, + domain: Uuid, + expected_facts: &[String], + expected_authorized: bool, + expected_audit: &[String], + expected_history: &[String], +) { + assert_eq!(legacy_identity_facts(pool, domain).await, expected_facts); + assert_eq!( + raw_domain_authorized(pool, domain, &[72_u8; 32]).await, + expected_authorized + ); + assert_eq!(domain_audit_snapshot(pool, domain).await, expected_audit); + assert_eq!( + domain_legacy_history_snapshot(pool, domain).await, + expected_history + ); +} + +async fn response_loss_migrated_domain_sentinels( + pool: &PgPool, + domain: Uuid, +) -> (Vec, Vec) { + let marker_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations WHERE version=30 AND success") + .fetch_one(pool) + .await + .expect("count successful response-loss migration markers"); + assert_eq!(marker_count, 1); + + type MigratedRow = (Uuid, i64, String, String, Vec, String, String); + let binding: MigratedRow = sqlx::query_as( + "SELECT binding_id,binding_version,issuer,uid,pubkey,binding_state,binding_provenance \ + FROM identity_bindings WHERE community_id=$1", + ) + .bind(domain) + .fetch_one(pool) + .await + .expect("read exact migrated domain-B binding"); + assert_eq!(binding.1, 1); + assert_eq!(binding.2, "https://domain-b.example"); + assert_eq!(binding.3, "domain-b-sentinel"); + assert_eq!(binding.4, vec![72_u8; 32]); + assert_eq!(binding.5, "active"); + assert_eq!(binding.6, "attested_key"); + + type HistoryRow = (Uuid, i64, String, String, Vec, String, String, String); + let history_rows: Vec = sqlx::query_as( + "SELECT binding_id,binding_version,issuer,subject,pubkey,binding_state, \ + binding_provenance,transition_kind \ + FROM identity_binding_history WHERE community_id=$1 \ + ORDER BY binding_version,history_id", + ) + .bind(domain) + .fetch_all(pool) + .await + .expect("read exact migrated domain-B history"); + assert_eq!(history_rows.len(), 1); + let history = &history_rows[0]; + assert_eq!(history.0, binding.0); + assert_eq!(history.1, binding.1); + assert_eq!(history.2, binding.2); + assert_eq!(history.3, binding.3); + assert_eq!(history.4, binding.4); + assert_eq!(history.5, binding.5); + assert_eq!(history.6, binding.6); + assert_eq!(history.7, "legacy_import"); + + let authorized = + get_active_identity_binding_by_pubkey(pool, CommunityId::from_uuid(domain), &[72_u8; 32]) + .await + .expect("read migrated response-loss authorization") + .expect("domain-B active binding survives response-loss operation"); + assert_eq!(authorized.binding_id, binding.0); + assert_eq!(authorized.binding_version, binding.1 as u64); + assert_eq!(authorized.issuer, binding.2); + assert_eq!(authorized.uid, binding.3); + assert_eq!(authorized.pubkey, binding.4); + + ( + domain_identity_snapshot(pool, domain).await, + domain_binding_history_snapshot(pool, domain).await, + ) +} + +async fn insert_rotated_legacy_row( + pool: &PgPool, + domain: Uuid, + subject: &str, + key: &[u8], + target: &[u8], +) { + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,revoked_at,revoked_reason,revocation_scope,\ + rotation_completed_at,rotated_to_pubkey,rotation_reason) \ + VALUES ($1,'https://idp.example',$2,$3,'db_binding',NOW(),'legacy rotation',\ + 'rotation',NOW(),$4,'legacy rotation')", + ) + .bind(domain) + .bind(subject) + .bind(key) + .bind(target) + .execute(pool) + .await + .expect("insert rotated legacy row"); +} + +async fn insert_revoked_legacy_row(pool: &PgPool, domain: Uuid, subject: &str, key: &[u8]) { + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,revoked_at,revoked_reason,revocation_scope) \ + VALUES ($1,'https://idp.example',$2,$3,'db_binding',NOW(),'legacy revoke','key')", + ) + .bind(domain) + .bind(subject) + .bind(key) + .execute(pool) + .await + .expect("insert revoked legacy row"); +} + +fn lifecycle_context(id: u128, reason: &'static str) -> LifecycleContext<'static> { + const ACTOR: [u8; 32] = [0xA2; 32]; + LifecycleContext { + operation_id: LifecycleOperationId::from_uuid_for_test(Uuid::from_u128(id)), + actor: &ACTOR, + reason, + } +} + +fn replacement( + key: &'static [u8; 32], + provenance: BindingProvenance, +) -> VerifiedReplacementKey<'static> { + VerifiedReplacementKey::after_verified_proof( + key, + None, + provenance, + "migration-denial-policy-v1", + ) + .expect("construct migrated denial replacement") +} + +const MASK_BINDING: u8 = 0b1_0000; +const MASK_REVOCATION: u8 = 0b0_1000; +const MASK_RETIRED_PAIR: u8 = 0b0_0100; +const MASK_DISABLED_IDENTITY: u8 = 0b0_0010; +const MASK_PENDING_LINEAGE: u8 = 0b0_0001; + +const MATRIX_BINDING_KEY: [u8; 32] = [101; 32]; +const MATRIX_REVOCATION_KEY: [u8; 32] = [102; 32]; +const MATRIX_RETIRED_KEY: [u8; 32] = [103; 32]; +const MATRIX_SUCCESSOR_KEY: [u8; 32] = [104; 32]; +const MATRIX_PENDING_KEY: [u8; 32] = [105; 32]; +const MATRIX_FRESH_KEY: [u8; 32] = [106; 32]; + +type LiteralBindingRow = (Vec, Vec, Vec, Uuid, i64, String, String); +type MatrixBindingRow = (Vec, Vec, Vec, i64, String, String, Option); +type MatrixHistoryRow = (Uuid, Vec, i64, String, String, Option); + +async fn seed_legacy_presence_mask(pool: &PgPool, domain: Uuid, mask: u8) { + if mask & MASK_BINDING != 0 { + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,created_at,updated_at,last_seen_at) \ + VALUES ($1,' Issuer://EXAMPLE/%2f/é ',' Subject/Case/%41/é ',$2,'jwt_npub', \ + '2026-01-01T03:04:05Z','2026-01-01T03:04:05Z','2026-01-01T03:04:05Z')", + ) + .bind(domain) + .bind(MATRIX_BINDING_KEY.as_slice()) + .execute(pool) + .await + .expect("seed matrix active binding"); + } + if mask & MASK_REVOCATION != 0 { + sqlx::query( + "INSERT INTO identity_revoked_keys \ + (community_id,pubkey,revoked_at,reason) \ + VALUES ($1,$2,'2026-01-02T03:04:05Z','oracle-revoked')", + ) + .bind(domain) + .bind(MATRIX_REVOCATION_KEY.as_slice()) + .execute(pool) + .await + .expect("seed matrix standalone revocation"); + } + if mask & MASK_RETIRED_PAIR != 0 { + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,created_at,updated_at,last_seen_at, \ + revoked_at,revoked_reason,revocation_scope,rotation_completed_at, \ + rotated_to_pubkey,rotation_reason) \ + VALUES ($1,'Retired://Issuer/%2F/ß',' Retired Subject ',$2,'db_binding', \ + '2026-01-03T01:04:05Z','2026-01-03T03:04:05Z','2026-01-03T03:04:05Z', \ + '2026-01-03T03:04:05Z','oracle retired pair','rotation', \ + '2026-01-03T03:04:05Z',$3,'oracle retired pair')", + ) + .bind(domain) + .bind(MATRIX_RETIRED_KEY.as_slice()) + .bind(MATRIX_SUCCESSOR_KEY.as_slice()) + .execute(pool) + .await + .expect("seed matrix retired predecessor"); + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,created_at,updated_at,last_seen_at) \ + VALUES ($1,'Retired://Issuer/%2F/ß',' Retired Subject ',$2,'db_binding', \ + '2026-01-03T04:04:05Z','2026-01-03T04:04:05Z','2026-01-03T04:04:05Z')", + ) + .bind(domain) + .bind(MATRIX_SUCCESSOR_KEY.as_slice()) + .execute(pool) + .await + .expect("seed matrix retired successor"); + sqlx::query( + "INSERT INTO identity_revoked_keys \ + (community_id,pubkey,revoked_at,reason) \ + VALUES ($1,$2,'2026-01-03T03:04:05Z','oracle retired support')", + ) + .bind(domain) + .bind(MATRIX_RETIRED_KEY.as_slice()) + .execute(pool) + .await + .expect("seed matrix retired tombstone support"); + } + if mask & MASK_DISABLED_IDENTITY != 0 { + sqlx::query( + "INSERT INTO identity_principals \ + (community_id,issuer,uid,disabled_at,disabled_reason) \ + VALUES ($1,'Disabled://Issuer/%2f/é',' Disabled Subject ', \ + '2026-01-04T03:04:05Z','oracle-disabled')", + ) + .bind(domain) + .execute(pool) + .await + .expect("seed matrix disabled identity"); + } + if mask & MASK_PENDING_LINEAGE != 0 { + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,created_at,updated_at,last_seen_at, \ + revoked_at,revoked_reason,revocation_scope) \ + VALUES ($1,'Pending://Issuer/%2F/é',' Pending Subject ',$2,'db_binding', \ + '2026-01-05T01:04:05Z','2026-01-05T03:04:05Z','2026-01-05T03:04:05Z', \ + '2026-01-05T03:04:05Z','oracle pending lineage','key')", + ) + .bind(domain) + .bind(MATRIX_PENDING_KEY.as_slice()) + .execute(pool) + .await + .expect("seed matrix terminal revoked binding"); + sqlx::query( + "INSERT INTO identity_revoked_keys \ + (community_id,pubkey,revoked_at,reason) \ + VALUES ($1,$2,'2026-01-05T03:04:05Z','oracle pending support')", + ) + .bind(domain) + .bind(MATRIX_PENDING_KEY.as_slice()) + .execute(pool) + .await + .expect("seed matrix pending tombstone support"); + } +} + +async fn domain_table_count(pool: &PgPool, table: &str, domain: Uuid) -> i64 { + let query = format!("SELECT COUNT(*) FROM {table} WHERE community_id=$1"); + sqlx::query_scalar(sqlx::AssertSqlSafe(query)) + .bind(domain) + .fetch_one(pool) + .await + .expect("count domain table rows") +} + +async fn resolve_result( + pool: &PgPool, + domain: Uuid, + issuer: &str, + subject: &str, + key: &[u8], +) -> ResolveBindingResult { + resolve_identity_binding( + pool, + &ResolveBindingInput { + authorization_domain: CommunityId::from_uuid(domain), + issuer, + subject, + pubkey: key, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "migration-test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("resolve matrix identity coordinate") +} + +async fn legacy_authorization_projection(pool: &PgPool, domain: Uuid) -> Vec<(&'static str, bool)> { + let mut projection = Vec::new(); + for (name, key) in [ + ("binding", &MATRIX_BINDING_KEY[..]), + ("revocation", &MATRIX_REVOCATION_KEY[..]), + ("retired", &MATRIX_RETIRED_KEY[..]), + ("retired_successor", &MATRIX_SUCCESSOR_KEY[..]), + ("disabled_reenroll", &MATRIX_FRESH_KEY[..]), + ("pending_retired", &MATRIX_PENDING_KEY[..]), + ("pending_reenroll", &MATRIX_FRESH_KEY[..]), + ] { + let authorized: bool = sqlx::query_scalar( + "SELECT EXISTS(\ + SELECT 1 FROM identity_bindings binding \ + WHERE binding.community_id=$1 AND binding.pubkey=$2 \ + AND COALESCE(to_jsonb(binding)->>'binding_state','active')='active' \ + AND binding.revoked_at IS NULL \ + AND NOT EXISTS(\ + SELECT 1 FROM identity_principals principal \ + WHERE principal.community_id=binding.community_id \ + AND principal.issuer=binding.issuer AND principal.uid=binding.uid \ + AND principal.disabled_at IS NOT NULL\ + ) \ + AND NOT EXISTS(\ + SELECT 1 FROM identity_revoked_keys revoked \ + WHERE revoked.community_id=binding.community_id \ + AND revoked.pubkey=binding.pubkey\ + )\ + )", + ) + .bind(domain) + .bind(key) + .fetch_one(pool) + .await + .expect("read legacy authorization projection"); + projection.push((name, authorized)); + } + projection +} + +async fn migrated_authorization_projection( + pool: &PgPool, + domain: Uuid, +) -> Vec<(&'static str, bool)> { + let mut projection = Vec::new(); + for (name, key) in [ + ("binding", &MATRIX_BINDING_KEY[..]), + ("revocation", &MATRIX_REVOCATION_KEY[..]), + ("retired", &MATRIX_RETIRED_KEY[..]), + ("retired_successor", &MATRIX_SUCCESSOR_KEY[..]), + ("disabled_reenroll", &MATRIX_FRESH_KEY[..]), + ("pending_retired", &MATRIX_PENDING_KEY[..]), + ("pending_reenroll", &MATRIX_FRESH_KEY[..]), + ] { + let authorized = matches!( + get_active_identity_binding_by_pubkey(pool, CommunityId::from_uuid(domain), key,).await, + Ok(Some(_)) + ); + projection.push((name, authorized)); + } + projection +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn identity_0030_all_32_legacy_presence_masks_are_lossless_and_fail_closed() { + let mut executed = BTreeSet::new(); + for mask in 0_u8..32 { + let case_id = format!("MIG-CART-{mask:05b}"); + assert!(executed.insert(case_id.clone()), "duplicate {case_id}"); + + let pool = connect_pool().await; + let (domain_a, domain_b) = reset_empty_to_0029(&pool, "migration-mask").await; + seed_legacy_presence_mask(&pool, domain_a, mask).await; + let legacy_a = legacy_identity_facts(&pool, domain_a).await; + let legacy_b = legacy_identity_facts(&pool, domain_b).await; + let audit_b = domain_audit_snapshot(&pool, domain_b).await; + let authorization_a_before = legacy_authorization_projection(&pool, domain_a).await; + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + + MIGRATOR + .run_to(30, &pool) + .await + .unwrap_or_else(|error| panic!("{case_id} migration failed: {error}")); + assert_eq!( + legacy_identity_facts(&pool, domain_a).await, + legacy_a, + "{case_id}" + ); + assert_eq!( + legacy_identity_facts(&pool, domain_b).await, + legacy_b, + "{case_id}" + ); + assert_eq!( + domain_audit_snapshot(&pool, domain_b).await, + audit_b, + "{case_id}" + ); + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM _sqlx_migrations WHERE version=30 AND success", + ) + .fetch_one(&pool) + .await + .expect("count first matrix migration marker"), + 1, + "{case_id}" + ); + let domain_b_post = domain_identity_snapshot(&pool, domain_b).await; + assert_eq!( + migrated_authorization_projection(&pool, domain_a).await, + authorization_a_before, + "{case_id} pre/post authorization decisions" + ); + + let binding_rows = i64::from(mask & MASK_BINDING != 0) + + 2 * i64::from(mask & MASK_RETIRED_PAIR != 0) + + i64::from(mask & MASK_PENDING_LINEAGE != 0); + let tombstone_rows = i64::from(mask & MASK_REVOCATION != 0) + + i64::from(mask & MASK_RETIRED_PAIR != 0) + + i64::from(mask & MASK_PENDING_LINEAGE != 0); + assert_eq!( + domain_table_count(&pool, "identity_bindings", domain_a).await, + binding_rows, + "{case_id}" + ); + assert_eq!( + domain_table_count(&pool, "identity_principals", domain_a).await, + i64::from(mask & MASK_DISABLED_IDENTITY != 0), + "{case_id}" + ); + assert_eq!( + domain_table_count(&pool, "identity_revoked_keys", domain_a).await, + tombstone_rows, + "{case_id}" + ); + assert_eq!( + domain_table_count(&pool, "identity_binding_lineage", domain_a).await, + i64::from(mask & MASK_RETIRED_PAIR != 0), + "{case_id}" + ); + assert_eq!( + domain_table_count(&pool, "identity_retired_pairs", domain_a).await, + i64::from(mask & MASK_RETIRED_PAIR != 0) + i64::from(mask & MASK_PENDING_LINEAGE != 0), + "{case_id}" + ); + assert_eq!( + domain_table_count(&pool, "identity_pending_replacements", domain_a).await, + i64::from(mask & MASK_PENDING_LINEAGE != 0), + "{case_id}" + ); + assert_eq!( + domain_table_count(&pool, "identity_binding_history", domain_a).await, + binding_rows, + "{case_id}" + ); + assert_eq!( + domain_table_count(&pool, "identity_migration_denials", domain_a).await, + 0, + "{case_id}" + ); + assert_eq!( + domain_table_count(&pool, "identity_migration_denied_keys", domain_a).await, + 0, + "{case_id}" + ); + assert_eq!( + domain_table_count(&pool, "identity_lifecycle_operations", domain_a).await, + 0, + "{case_id}" + ); + + let binding_coordinate_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_bindings \ + WHERE community_id=$1 AND issuer=' Issuer://EXAMPLE/%2f/é ' \ + AND uid=' Subject/Case/%41/é ' AND pubkey=$2", + ) + .bind(domain_a) + .bind(MATRIX_BINDING_KEY.as_slice()) + .fetch_one(&pool) + .await + .expect("count exact matrix binding coordinate"); + assert_eq!( + binding_coordinate_count, + i64::from(mask & MASK_BINDING != 0), + "{case_id}" + ); + let revocation_coordinate_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_revoked_keys WHERE community_id=$1 AND pubkey=$2", + ) + .bind(domain_a) + .bind(MATRIX_REVOCATION_KEY.as_slice()) + .fetch_one(&pool) + .await + .expect("count exact matrix revocation coordinate"); + assert_eq!( + revocation_coordinate_count, + i64::from(mask & MASK_REVOCATION != 0), + "{case_id}" + ); + let retired_coordinate_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_bindings \ + WHERE community_id=$1 AND issuer='Retired://Issuer/%2F/ß' \ + AND uid=' Retired Subject '", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("count exact matrix retired coordinate"); + assert_eq!( + retired_coordinate_count, + 2 * i64::from(mask & MASK_RETIRED_PAIR != 0), + "{case_id}" + ); + let disabled_coordinate_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_principals \ + WHERE community_id=$1 AND issuer='Disabled://Issuer/%2f/é' \ + AND uid=' Disabled Subject '", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("count exact matrix disabled coordinate"); + assert_eq!( + disabled_coordinate_count, + i64::from(mask & MASK_DISABLED_IDENTITY != 0), + "{case_id}" + ); + let pending_coordinate_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_bindings \ + WHERE community_id=$1 AND issuer='Pending://Issuer/%2F/é' \ + AND uid=' Pending Subject ' AND pubkey=$2", + ) + .bind(domain_a) + .bind(MATRIX_PENDING_KEY.as_slice()) + .fetch_one(&pool) + .await + .expect("count exact matrix pending coordinate"); + assert_eq!( + pending_coordinate_count, + i64::from(mask & MASK_PENDING_LINEAGE != 0), + "{case_id}" + ); + + let ids_are_valid: bool = sqlx::query_scalar( + "SELECT NOT EXISTS(SELECT 1 FROM identity_bindings \ + WHERE community_id=$1 AND (binding_id='00000000-0000-0000-0000-000000000000' OR binding_version < 1))", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("validate matrix binding coordinates"); + assert!(ids_are_valid, "{case_id}"); + let exact_history_mirrors: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_binding_history history \ + JOIN identity_bindings binding \ + ON binding.community_id=history.community_id \ + AND binding.binding_id=history.binding_id \ + AND binding.binding_version=history.binding_version \ + AND binding.issuer=history.issuer AND binding.uid=history.subject \ + AND binding.pubkey=history.pubkey \ + AND binding.binding_state=history.binding_state \ + AND binding.binding_provenance=history.binding_provenance \ + WHERE binding.community_id=$1 AND history.transition_kind='legacy_import'", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("count exact matrix history mirrors"); + assert_eq!(exact_history_mirrors, binding_rows, "{case_id}"); + + if mask & MASK_BINDING != 0 { + let binding: MatrixBindingRow = sqlx::query_as( + "SELECT convert_to(issuer,'UTF8'),convert_to(uid,'UTF8'),pubkey, \ + binding_version,binding_state,binding_provenance,replacement_binding_id \ + FROM identity_bindings WHERE community_id=$1 AND pubkey=$2", + ) + .bind(domain_a) + .bind(MATRIX_BINDING_KEY.as_slice()) + .fetch_one(&pool) + .await + .expect("read exact matrix active representation"); + assert_eq!(binding.0, " Issuer://EXAMPLE/%2f/é ".as_bytes()); + assert_eq!(binding.1, " Subject/Case/%41/é ".as_bytes()); + assert_eq!(binding.2, MATRIX_BINDING_KEY); + assert_eq!( + (binding.3, binding.4.as_str(), binding.5.as_str()), + (1, "active", "attested_key") + ); + assert!(binding.6.is_none()); + assert!(matches!( + resolve_result( + &pool, + domain_a, + " Issuer://EXAMPLE/%2f/é ", + " Subject/Case/%41/é ", + &MATRIX_BINDING_KEY, + ) + .await, + ResolveBindingResult::Existing(_) + )); + } else { + assert!(!raw_domain_authorized(&pool, domain_a, &MATRIX_BINDING_KEY).await); + } + if mask & MASK_REVOCATION != 0 { + let revocation_exact: bool = sqlx::query_scalar( + "SELECT reason='oracle-revoked' AND revoked_at='2026-01-02T03:04:05Z'::TIMESTAMPTZ \ + FROM identity_revoked_keys WHERE community_id=$1 AND pubkey=$2", + ) + .bind(domain_a) + .bind(MATRIX_REVOCATION_KEY.as_slice()) + .fetch_one(&pool) + .await + .expect("read exact matrix revocation representation"); + assert!(revocation_exact, "{case_id}"); + assert_eq!( + resolve_result( + &pool, + domain_a, + "Revoked://Issuer", + "Revoked Subject", + &MATRIX_REVOCATION_KEY, + ) + .await, + ResolveBindingResult::Denied(BindingDenial::Revoked) + ); + } + if mask & MASK_RETIRED_PAIR != 0 { + let retired_rows: Vec = sqlx::query_as( + "SELECT binding_id,pubkey,binding_version,binding_state,binding_provenance, \ + replacement_binding_id \ + FROM identity_bindings WHERE community_id=$1 \ + AND issuer='Retired://Issuer/%2F/ß' AND uid=' Retired Subject ' \ + ORDER BY pubkey", + ) + .bind(domain_a) + .fetch_all(&pool) + .await + .expect("read exact retired history representation"); + assert_eq!(retired_rows.len(), 2, "{case_id}"); + assert_eq!(retired_rows[0].1, MATRIX_RETIRED_KEY); + assert_eq!( + ( + retired_rows[0].2, + retired_rows[0].3.as_str(), + retired_rows[0].4.as_str() + ), + (1, "rotated", "tofu") + ); + assert_eq!(retired_rows[0].5, Some(retired_rows[1].0)); + assert_eq!(retired_rows[1].1, MATRIX_SUCCESSOR_KEY); + assert_eq!( + ( + retired_rows[1].2, + retired_rows[1].3.as_str(), + retired_rows[1].4.as_str() + ), + (1, "active", "tofu") + ); + assert!(retired_rows[1].5.is_none()); + let lineage: (Uuid, Uuid) = sqlx::query_as( + "SELECT predecessor_binding_id,successor_binding_id \ + FROM identity_binding_lineage WHERE community_id=$1 \ + AND predecessor_binding_id=$2", + ) + .bind(domain_a) + .bind(retired_rows[0].0) + .fetch_one(&pool) + .await + .expect("read exact matrix lineage edge"); + assert_eq!(lineage, (retired_rows[0].0, retired_rows[1].0)); + let retired_pair: (Vec, Option, Option, String, bool) = sqlx::query_as( + "SELECT pubkey,retired_binding_id,retired_binding_version,reason, \ + retired_at='2026-01-03T03:04:05Z'::TIMESTAMPTZ \ + FROM identity_retired_pairs WHERE community_id=$1 \ + AND issuer='Retired://Issuer/%2F/ß' AND subject=' Retired Subject '", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("read exact retired-pair representation"); + assert_eq!(retired_pair.0, MATRIX_RETIRED_KEY); + assert_eq!(retired_pair.1, Some(retired_rows[0].0)); + assert_eq!(retired_pair.2, Some(1)); + assert_eq!(retired_pair.3, "oracle retired pair"); + assert!(retired_pair.4); + assert_eq!( + resolve_result( + &pool, + domain_a, + "Retired://Issuer/%2F/ß", + " Retired Subject ", + &MATRIX_RETIRED_KEY, + ) + .await, + ResolveBindingResult::Denied(BindingDenial::Revoked) + ); + assert!(get_active_identity_binding_by_pubkey( + &pool, + CommunityId::from_uuid(domain_a), + &MATRIX_SUCCESSOR_KEY, + ) + .await + .expect("read matrix successor") + .is_some()); + } + if mask & MASK_DISABLED_IDENTITY != 0 { + let disabled_exact: bool = sqlx::query_scalar( + "SELECT disabled_at='2026-01-04T03:04:05Z'::TIMESTAMPTZ \ + AND disabled_reason='oracle-disabled' \ + FROM identity_principals WHERE community_id=$1 \ + AND issuer='Disabled://Issuer/%2f/é' AND uid=' Disabled Subject '", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("read exact disabled identity representation"); + assert!(disabled_exact, "{case_id}"); + assert_eq!( + resolve_result( + &pool, + domain_a, + "Disabled://Issuer/%2f/é", + " Disabled Subject ", + &MATRIX_FRESH_KEY, + ) + .await, + ResolveBindingResult::Denied(BindingDenial::Revoked) + ); + } + if mask & MASK_PENDING_LINEAGE != 0 { + let pending_binding: (Uuid, i64, String, String) = sqlx::query_as( + "SELECT binding_id,binding_version,binding_state,binding_provenance \ + FROM identity_bindings WHERE community_id=$1 \ + AND issuer='Pending://Issuer/%2F/é' AND uid=' Pending Subject ' \ + AND pubkey=$2", + ) + .bind(domain_a) + .bind(MATRIX_PENDING_KEY.as_slice()) + .fetch_one(&pool) + .await + .expect("read exact pending source representation"); + assert_eq!( + ( + pending_binding.1, + pending_binding.2.as_str(), + pending_binding.3.as_str() + ), + (1, "revoked", "tofu") + ); + let pending: (i64, Vec, Uuid, i64, bool) = sqlx::query_as( + "SELECT selector_version,retired_pubkey,retired_binding_id, \ + retired_binding_version,cleared_at IS NULL \ + FROM identity_pending_replacements WHERE community_id=$1 \ + AND issuer='Pending://Issuer/%2F/é' AND subject=' Pending Subject '", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("read exact pending selector representation"); + assert_eq!(pending.0, 1); + assert_eq!(pending.1, MATRIX_PENDING_KEY); + assert_eq!(pending.2, pending_binding.0); + assert_eq!(pending.3, 1); + assert!(pending.4); + let pending_retired: (Option, Option, String, bool) = sqlx::query_as( + "SELECT retired_binding_id,retired_binding_version,reason, \ + retired_at='2026-01-05T03:04:05Z'::TIMESTAMPTZ \ + FROM identity_retired_pairs WHERE community_id=$1 \ + AND issuer='Pending://Issuer/%2F/é' AND subject=' Pending Subject ' \ + AND pubkey=$2", + ) + .bind(domain_a) + .bind(MATRIX_PENDING_KEY.as_slice()) + .fetch_one(&pool) + .await + .expect("read exact pending retired-pair support"); + assert_eq!(pending_retired.0, Some(pending_binding.0)); + assert_eq!(pending_retired.1, Some(1)); + assert_eq!(pending_retired.2, "oracle pending lineage"); + assert!(pending_retired.3); + for key in [&MATRIX_PENDING_KEY[..], &MATRIX_FRESH_KEY[..]] { + assert_eq!( + resolve_result( + &pool, + domain_a, + "Pending://Issuer/%2F/é", + " Pending Subject ", + key, + ) + .await, + ResolveBindingResult::Denied(BindingDenial::Revoked) + ); + } + } + + assert_eq!( + domain_identity_snapshot(&pool, domain_b).await, + domain_b_post, + "{case_id} domain-A calls changed domain B" + ); + assert_eq!( + legacy_identity_facts(&pool, domain_b).await, + legacy_b, + "{case_id} domain-A calls changed domain-B legacy bytes" + ); + assert_eq!( + domain_audit_snapshot(&pool, domain_b).await, + audit_b, + "{case_id} domain-A calls changed domain-B audit state" + ); + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + let complete_post = full_identity_snapshot(&pool).await; + pool.close().await; + let pool = connect_pool().await; + assert_eq!( + domain_identity_snapshot(&pool, domain_b).await, + domain_b_post, + "{case_id} restart domain B" + ); + assert_eq!(legacy_identity_facts(&pool, domain_b).await, legacy_b); + assert_eq!(domain_audit_snapshot(&pool, domain_b).await, audit_b); + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + assert_eq!( + full_identity_snapshot(&pool).await, + complete_post, + "{case_id} restart" + ); + MIGRATOR + .run_to(30, &pool) + .await + .unwrap_or_else(|error| panic!("{case_id} retry failed: {error}")); + assert_eq!( + domain_identity_snapshot(&pool, domain_b).await, + domain_b_post, + "{case_id} retry domain B" + ); + assert_eq!(legacy_identity_facts(&pool, domain_b).await, legacy_b); + assert_eq!(domain_audit_snapshot(&pool, domain_b).await, audit_b); + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + assert_eq!( + full_identity_snapshot(&pool).await, + complete_post, + "{case_id} retry" + ); + pool.close().await; + } + assert_eq!(executed.len(), 32); + assert!(executed.contains("MIG-CART-00000")); + assert!(executed.contains("MIG-CART-11111")); +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn identity_0030_frozen_20_case_literal_selector_corpus_preserves_exact_bytes() { + const LITERALS: [(&str, &str); 10] = [ + ("Issuer", "Subject"), + ("issuer", "subject"), + (" Issuer", "Subject "), + ("Issuer://EXAMPLE/a", "Issuer://example/a"), + ("https://example.test/%2F", "https://example.test//"), + ("é", "é"), + ("A%41", "AA"), + ("subject+one", "subject one"), + ("urn:example:01", "urn:example:1"), + ("/a/../b", "/b"), + ]; + let mut executed = BTreeSet::new(); + for (index, pair) in LITERALS.iter().enumerate() { + for varied_field in ["ISSUER", "SUBJECT"] { + let case_id = format!("LITERAL-{:02}-{varied_field}", index + 1); + assert!(executed.insert(case_id.clone()), "duplicate {case_id}"); + assert_ne!(pair.0.as_bytes(), pair.1.as_bytes(), "{case_id}"); + + let pool = connect_pool().await; + let (domain_a, domain_b) = reset_empty_to_0029(&pool, "literal-corpus").await; + let fixed_issuer = "literal://fixed/issuer"; + let fixed_subject = " literal fixed subject "; + let keys = [vec![111_u8; 32], vec![112_u8; 32]]; + for (literal_index, literal) in [pair.0, pair.1].into_iter().enumerate() { + let (issuer, subject) = if varied_field == "ISSUER" { + (literal, fixed_subject) + } else { + (fixed_issuer, literal) + }; + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,created_at,updated_at,last_seen_at) \ + VALUES ($1,$2,$3,$4,'jwt_npub','2026-02-01T00:00:00Z', \ + '2026-02-01T00:00:00Z','2026-02-01T00:00:00Z')", + ) + .bind(domain_a) + .bind(issuer) + .bind(subject) + .bind(&keys[literal_index]) + .execute(&pool) + .await + .unwrap_or_else(|error| panic!("seed {case_id}: {error}")); + } + let domain_b_legacy_before = legacy_identity_facts(&pool, domain_b).await; + let domain_b_audit_before = domain_audit_snapshot(&pool, domain_b).await; + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + + MIGRATOR + .run_to(30, &pool) + .await + .unwrap_or_else(|error| panic!("migrate {case_id}: {error}")); + assert_eq!( + legacy_identity_facts(&pool, domain_b).await, + domain_b_legacy_before, + "{case_id} domain-B legacy bytes after migration" + ); + assert_eq!( + domain_audit_snapshot(&pool, domain_b).await, + domain_b_audit_before, + "{case_id} domain-B audit after migration" + ); + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + let domain_b_post = domain_identity_snapshot(&pool, domain_b).await; + let rows: Vec = sqlx::query_as( + "SELECT convert_to(issuer,'UTF8'),convert_to(uid,'UTF8'),pubkey, \ + binding_id,binding_version,binding_state,binding_provenance \ + FROM identity_bindings WHERE community_id=$1 ORDER BY pubkey", + ) + .bind(domain_a) + .fetch_all(&pool) + .await + .expect("read literal selector rows"); + assert_eq!(rows.len(), 2, "{case_id}"); + assert_ne!(rows[0].3, rows[1].3, "{case_id}"); + for (literal_index, row) in rows.iter().enumerate() { + let literal = if literal_index == 0 { pair.0 } else { pair.1 }; + let expected_issuer = if varied_field == "ISSUER" { + literal + } else { + fixed_issuer + }; + let expected_subject = if varied_field == "SUBJECT" { + literal + } else { + fixed_subject + }; + assert_eq!(row.0, expected_issuer.as_bytes(), "{case_id} issuer bytes"); + assert_eq!( + row.1, + expected_subject.as_bytes(), + "{case_id} subject bytes" + ); + assert_eq!(row.2, keys[literal_index], "{case_id} key"); + assert_eq!(row.4, 1, "{case_id} version"); + assert_eq!(row.5, "active", "{case_id} state"); + assert_eq!(row.6, "attested_key", "{case_id} provenance"); + assert!(matches!( + resolve_result( + &pool, + domain_a, + expected_issuer, + expected_subject, + &keys[literal_index] + ) + .await, + ResolveBindingResult::Existing(_) + )); + let binding = get_active_identity_binding_by_pubkey( + &pool, + CommunityId::from_uuid(domain_a), + &keys[literal_index], + ) + .await + .expect("read literal binding") + .expect("literal binding remains active"); + assert_eq!( + binding.issuer.as_bytes(), + expected_issuer.as_bytes(), + "{case_id}" + ); + assert_eq!( + binding.uid.as_bytes(), + expected_subject.as_bytes(), + "{case_id}" + ); + } + let (issuer_a, subject_a) = if varied_field == "ISSUER" { + (pair.0, fixed_subject) + } else { + (fixed_issuer, pair.0) + }; + let (issuer_b, subject_b) = if varied_field == "ISSUER" { + (pair.1, fixed_subject) + } else { + (fixed_issuer, pair.1) + }; + assert_eq!( + resolve_result(&pool, domain_a, issuer_a, subject_a, &keys[1]).await, + ResolveBindingResult::Denied(BindingDenial::Conflict), + "{case_id} cross near-miss A" + ); + assert_eq!( + resolve_result(&pool, domain_a, issuer_b, subject_b, &keys[0]).await, + ResolveBindingResult::Denied(BindingDenial::Conflict), + "{case_id} cross near-miss B" + ); + + assert_eq!( + domain_identity_snapshot(&pool, domain_b).await, + domain_b_post, + "{case_id} domain-B state after domain-A selections" + ); + assert_eq!( + domain_audit_snapshot(&pool, domain_b).await, + domain_b_audit_before, + "{case_id} domain-B audit after domain-A selections" + ); + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + let post = full_identity_snapshot(&pool).await; + pool.close().await; + let pool = connect_pool().await; + assert_eq!( + domain_identity_snapshot(&pool, domain_b).await, + domain_b_post, + "{case_id} domain-B restart" + ); + assert_eq!( + full_identity_snapshot(&pool).await, + post, + "{case_id} restart" + ); + MIGRATOR + .run_to(30, &pool) + .await + .unwrap_or_else(|error| panic!("retry {case_id}: {error}")); + assert_eq!(full_identity_snapshot(&pool).await, post, "{case_id} retry"); + assert_eq!( + domain_identity_snapshot(&pool, domain_b).await, + domain_b_post, + "{case_id} domain-B retry" + ); + pool.close().await; + } + } + assert_eq!(executed.len(), 20); + assert!(executed.contains("LITERAL-01-ISSUER")); + assert!(executed.contains("LITERAL-10-SUBJECT")); +} + +#[derive(Debug, Clone, Copy)] +enum UnreadableLegacyVariant { + Binding, + Revocation, + RetiredPair, + DisabledIdentity, + PendingLineage, +} + +impl UnreadableLegacyVariant { + const ALL: [Self; 5] = [ + Self::Binding, + Self::Revocation, + Self::RetiredPair, + Self::DisabledIdentity, + Self::PendingLineage, + ]; + + const fn name(self) -> &'static str { + match self { + Self::Binding => "binding", + Self::Revocation => "revocation", + Self::RetiredPair => "retired_pair", + Self::DisabledIdentity => "disabled_identity", + Self::PendingLineage => "pending_lineage", + } + } + + fn table_and_policy_expression(self) -> (&'static str, String, String) { + match self { + Self::Binding => ( + "identity_bindings", + "migration_test_legacy_row_readable('binding',community_id,encode(pubkey,'hex'))" + .to_owned(), + hex::encode(MATRIX_BINDING_KEY), + ), + Self::Revocation => ( + "identity_revoked_keys", + "migration_test_legacy_row_readable('revocation',community_id,encode(pubkey,'hex'))" + .to_owned(), + hex::encode(MATRIX_REVOCATION_KEY), + ), + Self::RetiredPair => ( + "identity_bindings", + "migration_test_legacy_row_readable('retired_pair',community_id,encode(pubkey,'hex'))" + .to_owned(), + hex::encode(MATRIX_RETIRED_KEY), + ), + Self::DisabledIdentity => ( + "identity_principals", + "migration_test_legacy_row_readable('disabled_identity',community_id,uid)" + .to_owned(), + " Disabled Subject ".to_owned(), + ), + Self::PendingLineage => ( + "identity_bindings", + "migration_test_legacy_row_readable('pending_lineage',community_id,encode(pubkey,'hex'))" + .to_owned(), + hex::encode(MATRIX_PENDING_KEY), + ), + } + } +} + +async fn arm_unreadable_legacy_row( + pool: &PgPool, + role: &str, + domain: Uuid, + variant: UnreadableLegacyVariant, +) -> &'static str { + let (table, policy_expression, coordinate) = variant.table_and_policy_expression(); + sqlx::raw_sql( + r#" + CREATE TABLE migration_test_unreadable_control ( + kind TEXT NOT NULL, + community_id UUID NOT NULL, + coordinate TEXT NOT NULL + ); + CREATE FUNCTION migration_test_legacy_row_readable( + row_kind TEXT, + row_domain UUID, + row_coordinate TEXT + ) RETURNS BOOLEAN + LANGUAGE plpgsql VOLATILE SECURITY DEFINER + SET search_path=pg_catalog,public + AS $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM public.migration_test_unreadable_control fault + WHERE fault.kind=row_kind + AND fault.community_id=row_domain + AND fault.coordinate=row_coordinate + ) THEN + RAISE EXCEPTION USING + ERRCODE='P0001', + MESSAGE='legacy identity state is unreadable'; + END IF; + RETURN TRUE; + END + $$; + REVOKE ALL ON FUNCTION migration_test_legacy_row_readable(TEXT,UUID,TEXT) FROM PUBLIC; + "#, + ) + .execute(pool) + .await + .expect("create unreadable legacy guard"); + sqlx::query( + "INSERT INTO migration_test_unreadable_control (kind,community_id,coordinate) VALUES ($1,$2,$3)", + ) + .bind(variant.name()) + .bind(domain) + .bind(coordinate) + .execute(pool) + .await + .expect("arm unreadable legacy guard"); + let policy_sql = format!( + "ALTER TABLE {table} ENABLE ROW LEVEL SECURITY; \ + ALTER TABLE {table} FORCE ROW LEVEL SECURITY; \ + CREATE POLICY migration_test_unreadable_policy ON {table} USING ({policy_expression}); \ + GRANT EXECUTE ON FUNCTION migration_test_legacy_row_readable(TEXT,UUID,TEXT) TO {role};" + ); + sqlx::raw_sql(sqlx::AssertSqlSafe(policy_sql)) + .execute(pool) + .await + .expect("install unreadable row policy"); + table +} + +async fn disarm_unreadable_legacy_row(pool: &PgPool, table: &str) { + let sql = format!( + "DROP POLICY migration_test_unreadable_policy ON {table}; \ + ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY; \ + ALTER TABLE {table} DISABLE ROW LEVEL SECURITY; \ + DROP FUNCTION migration_test_legacy_row_readable(TEXT,UUID,TEXT); \ + DROP TABLE migration_test_unreadable_control;" + ); + sqlx::raw_sql(sqlx::AssertSqlSafe(sql)) + .execute(pool) + .await + .expect("remove unreadable row policy without changing legacy rows"); +} + +async fn create_restricted_migration_role(pool: &PgPool, role: &str) -> String { + let controller: String = sqlx::query_scalar("SELECT quote_ident(current_user)") + .fetch_one(pool) + .await + .expect("read controller role"); + let sql = format!( + "CREATE ROLE {role} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE \ + NOINHERIT NOREPLICATION NOBYPASSRLS; \ + GRANT USAGE,CREATE ON SCHEMA public TO {role}; \ + GRANT ALL PRIVILEGES ON TABLE identity_bindings,_sqlx_migrations TO {role}; \ + GRANT SELECT ON TABLE identity_principals,identity_revoked_keys TO {role}; \ + GRANT SELECT,REFERENCES ON TABLE communities TO {role}; \ + ALTER TABLE identity_bindings OWNER TO {role};" + ); + sqlx::raw_sql(sqlx::AssertSqlSafe(sql)) + .execute(pool) + .await + .expect("create restricted migration owner"); + controller +} + +async fn run_migration_as_role( + pool: &PgPool, + role: &str, +) -> Result<(), sqlx::migrate::MigrateError> { + let mut connection = pool + .acquire() + .await + .expect("acquire restricted migration backend"); + let set_role = format!("SET ROLE {role}"); + sqlx::raw_sql(sqlx::AssertSqlSafe(set_role)) + .execute(&mut *connection) + .await + .expect("enter restricted migration role"); + let result = MIGRATOR.run_to(30, &mut *connection).await; + sqlx::query("RESET ROLE") + .execute(&mut *connection) + .await + .expect("leave restricted migration role"); + result +} + +async fn drop_restricted_migration_role(pool: &PgPool, role: &str, controller: &str) { + let sql = format!( + "REASSIGN OWNED BY {role} TO {controller}; \ + DROP OWNED BY {role}; \ + DROP ROLE {role};" + ); + sqlx::raw_sql(sqlx::AssertSqlSafe(sql)) + .execute(pool) + .await + .expect("drop restricted migration owner"); +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn identity_0030_all_five_unreadable_legacy_variants_rollback_then_retry() { + let mut executed = BTreeSet::new(); + for variant in UnreadableLegacyVariant::ALL { + let case_id = format!("MIG-AMB-005-{}", variant.name()); + assert!(executed.insert(case_id.clone()), "duplicate {case_id}"); + let pool = connect_pool().await; + let (domain_a, domain_b) = reset_empty_to_0029(&pool, "unreadable-legacy").await; + seed_legacy_presence_mask(&pool, domain_a, 0b1_1111).await; + let before = legacy_snapshot(&pool).await; + let domain_b_facts = legacy_identity_facts(&pool, domain_b).await; + let domain_b_audit = domain_audit_snapshot(&pool, domain_b).await; + let role = format!("identity_unreadable_{}", Uuid::new_v4().simple()); + let controller = create_restricted_migration_role(&pool, &role).await; + let table = arm_unreadable_legacy_row(&pool, &role, domain_a, variant).await; + + let error = run_migration_as_role(&pool, &role) + .await + .expect_err("unreadable retained state must abort 0030"); + let error_text = error.to_string(); + assert!( + error_text.contains("legacy identity state is unreadable"), + "{case_id}: {error_text}" + ); + for forbidden in [ + " Issuer://EXAMPLE/%2f/é ", + " Subject/Case/%41/é ", + " Retired Subject ", + " Disabled Subject ", + " Pending Subject ", + &hex::encode(MATRIX_BINDING_KEY), + ] { + assert!( + !error_text.contains(forbidden), + "{case_id} disclosed a legacy coordinate" + ); + } + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM _sqlx_migrations WHERE version=30 AND success", + ) + .fetch_one(&pool) + .await + .expect("count failed unreadable marker"), + 0, + "{case_id}" + ); + let projected_table: Option = + sqlx::query_scalar("SELECT to_regclass('identity_retired_pairs')::TEXT") + .fetch_one(&pool) + .await + .expect("read failed projection table"); + assert!(projected_table.is_none(), "{case_id}"); + let projected_column: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.columns \ + WHERE table_schema='public' AND table_name='identity_bindings' \ + AND column_name='binding_id'", + ) + .fetch_one(&pool) + .await + .expect("read failed projection column"); + assert_eq!(projected_column, 0, "{case_id}"); + assert_eq!( + legacy_identity_facts(&pool, domain_b).await, + domain_b_facts, + "{case_id}" + ); + assert_eq!( + domain_audit_snapshot(&pool, domain_b).await, + domain_b_audit, + "{case_id}" + ); + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + pool.close().await; + + let pool = connect_pool().await; + let fault_still_armed: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM migration_test_unreadable_control WHERE kind=$1 AND community_id=$2)", + ) + .bind(variant.name()) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("read durable unreadable fault after restart"); + assert!(fault_still_armed, "{case_id}"); + assert_eq!( + legacy_snapshot(&pool).await, + before, + "{case_id} retained legacy bytes while the read fault remained armed" + ); + assert!( + resolve_identity_binding( + &pool, + &ResolveBindingInput { + authorization_domain: CommunityId::from_uuid(domain_a), + issuer: " Issuer://EXAMPLE/%2f/é ", + subject: " Subject/Case/%41/é ", + pubkey: &MATRIX_BINDING_KEY, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "migration-test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .is_err(), + "{case_id} incomplete migration must fail closed in the application path" + ); + disarm_unreadable_legacy_row(&pool, table).await; + assert_eq!( + legacy_snapshot(&pool).await, + before, + "{case_id} exact rollback" + ); + + run_migration_as_role(&pool, &role) + .await + .unwrap_or_else(|error| panic!("{case_id} retry failed: {error}")); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM _sqlx_migrations WHERE version=30 AND success", + ) + .fetch_one(&pool) + .await + .expect("count retried unreadable marker"), + 1, + "{case_id}" + ); + assert_eq!( + legacy_identity_facts(&pool, domain_b).await, + domain_b_facts, + "{case_id}" + ); + assert_eq!( + domain_audit_snapshot(&pool, domain_b).await, + domain_b_audit, + "{case_id}" + ); + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + assert_eq!( + resolve_result( + &pool, + domain_a, + "Pending://Issuer/%2F/é", + " Pending Subject ", + &MATRIX_FRESH_KEY, + ) + .await, + ResolveBindingResult::Denied(BindingDenial::Revoked), + "{case_id}" + ); + let complete_post = full_identity_snapshot(&pool).await; + pool.close().await; + + let pool = connect_pool().await; + assert_eq!( + full_identity_snapshot(&pool).await, + complete_post, + "{case_id} restart" + ); + MIGRATOR + .run_to(30, &pool) + .await + .unwrap_or_else(|error| panic!("{case_id} no-op retry failed: {error}")); + assert_eq!( + full_identity_snapshot(&pool).await, + complete_post, + "{case_id} no-op retry" + ); + drop_restricted_migration_role(&pool, &role, &controller).await; + pool.close().await; + } + assert_eq!(executed.len(), 5); +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn identity_0030_fault_at_every_statement_boundary_restarts_and_retries() { + let statements = split_statements(migration_0030().sql.as_ref()); + assert_eq!( + statements.len(), + 34, + "0030 boundary count is part of the oracle adapter" + ); + + for boundary in 0..=statements.len() { + let pool = connect_pool().await; + let (_domain_a, domain_b) = reset_to_0029(&pool).await; + let before = legacy_snapshot(&pool).await; + let domain_b_facts = legacy_identity_facts(&pool, domain_b).await; + let domain_b_audit = domain_audit_snapshot(&pool, domain_b).await; + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + + let mut connection = pool.acquire().await.expect("acquire crashable backend"); + let backend_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *connection) + .await + .expect("read crashable backend pid"); + let mut tx = connection + .begin() + .await + .expect("begin crashable boundary migration"); + for statement in &statements[..boundary] { + sqlx::raw_sql(sqlx::AssertSqlSafe(statement.as_str())) + .execute(&mut *tx) + .await + .unwrap_or_else(|error| { + panic!("0030 statement before boundary {boundary} failed: {error}") + }); + } + let terminated: bool = sqlx::query_scalar("SELECT pg_terminate_backend($1)") + .bind(backend_pid) + .fetch_one(&pool) + .await + .unwrap_or_else(|error| { + panic!("terminate boundary {boundary} migration backend: {error}") + }); + assert!(terminated, "boundary {boundary} backend must terminate"); + assert!( + sqlx::query("SELECT 1").execute(&mut *tx).await.is_err(), + "boundary {boundary} transaction must observe backend loss" + ); + drop(tx); + drop(connection); + pool.close().await; + + let pool = connect_pool().await; + assert_eq!(legacy_snapshot(&pool).await, before, "boundary {boundary}"); + assert_eq!( + legacy_identity_facts(&pool, domain_b).await, + domain_b_facts, + "boundary {boundary} domain-B facts before retry" + ); + assert_eq!( + domain_audit_snapshot(&pool, domain_b).await, + domain_b_audit, + "boundary {boundary} domain-B audit before retry" + ); + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + + MIGRATOR.run_to(30, &pool).await.unwrap_or_else(|error| { + panic!("boundary {boundary} retry after restart failed: {error}") + }); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM _sqlx_migrations WHERE version=30 AND success" + ) + .fetch_one(&pool) + .await + .expect("count boundary retry marker"), + 1, + "boundary {boundary} must produce one durable migration marker" + ); + assert_eq!( + legacy_identity_facts(&pool, domain_b).await, + domain_b_facts, + "boundary {boundary} domain-B facts after retry" + ); + assert_eq!( + domain_audit_snapshot(&pool, domain_b).await, + domain_b_audit, + "boundary {boundary} domain-B audit after retry" + ); + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + let complete_post = full_identity_snapshot(&pool).await; + pool.close().await; + + let pool = connect_pool().await; + assert_eq!( + full_identity_snapshot(&pool).await, + complete_post, + "boundary {boundary} complete post-state must survive restart" + ); + MIGRATOR.run_to(30, &pool).await.unwrap_or_else(|error| { + panic!("boundary {boundary} idempotent post-restart retry failed: {error}") + }); + assert_eq!( + full_identity_snapshot(&pool).await, + complete_post, + "boundary {boundary} retry must remain an exact no-op" + ); + pool.close().await; + } +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn identity_0030_commit_failure_rolls_back_projection_and_success_marker() { + let pool = connect_pool().await; + let (_domain_a, domain_b) = reset_to_0029(&pool).await; + let before = legacy_snapshot(&pool).await; + let migration = migration_0030(); + let statements = split_statements(migration.sql.as_ref()); + let mut tx = pool.begin().await.expect("begin commit-failure migration"); + for statement in statements { + sqlx::raw_sql(sqlx::AssertSqlSafe(statement)) + .execute(&mut *tx) + .await + .expect("execute 0030 before commit failure"); + } + sqlx::query( + "INSERT INTO _sqlx_migrations \ + (version,description,installed_on,success,checksum,execution_time) \ + VALUES ($1,$2,NOW(),TRUE,$3,0)", + ) + .bind(migration.version) + .bind(migration.description.as_ref()) + .bind(migration.checksum.as_ref()) + .execute(&mut *tx) + .await + .expect("insert transactional 0030 marker"); + sqlx::query( + "UPDATE identity_bindings SET replacement_binding_id=$1 \ + WHERE community_id=$2", + ) + .bind(Uuid::from_u128(u128::MAX)) + .bind(domain_b) + .execute(&mut *tx) + .await + .expect("deferred FK accepts invalid replacement before commit"); + assert!( + tx.commit().await.is_err(), + "deferred FK must fail at commit" + ); + assert_eq!(legacy_snapshot(&pool).await, before); + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + + MIGRATOR + .run_to(30, &pool) + .await + .expect("retry after commit failure"); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM _sqlx_migrations WHERE version=30 AND success" + ) + .fetch_one(&pool) + .await + .expect("count successful 0030 marker"), + 1 + ); +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn identity_0030_backend_loss_restarts_cleanly_and_retry_converges() { + let pool = connect_pool().await; + let (_domain_a, domain_b) = reset_to_0029(&pool).await; + let before = legacy_snapshot(&pool).await; + let statements = split_statements(migration_0030().sql.as_ref()); + let mut connection = pool.acquire().await.expect("acquire migration backend"); + let backend_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *connection) + .await + .expect("read migration backend pid"); + let mut tx = connection.begin().await.expect("begin crashable migration"); + for statement in &statements[..statements.len() / 2] { + sqlx::raw_sql(sqlx::AssertSqlSafe(statement.as_str())) + .execute(&mut *tx) + .await + .expect("execute pre-crash migration prefix"); + } + let terminated: bool = sqlx::query_scalar("SELECT pg_terminate_backend($1)") + .bind(backend_pid) + .fetch_one(&pool) + .await + .expect("terminate migration backend"); + assert!(terminated); + assert!(sqlx::query("SELECT 1").execute(&mut *tx).await.is_err()); + drop(tx); + drop(connection); + pool.close().await; + + let pool = connect_pool().await; + assert_eq!(legacy_snapshot(&pool).await, before); + assert!(raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await); + MIGRATOR + .run_to(30, &pool) + .await + .expect("retry after backend restart"); +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn identity_0030_real_post_commit_response_loss_is_idempotent_after_restart() { + const GATE_CLASS: i32 = 2_147_400_030; + const GATE_OBJECT: i32 = 30; + let pool = connect_pool().await; + let (_domain_a, domain_b) = reset_to_0029(&pool).await; + sqlx::query( + "INSERT INTO audit_log \ + (community_id,seq,hash,action,object_id,detail,created_at) \ + VALUES ($1,1,$2,'preexisting_response_loss_sentinel','domain-b-sentinel', \ + '{\"sentinel\":\"before-response-loss-operation\"}'::jsonb, \ + TIMESTAMPTZ '2025-04-01 00:00:00Z')", + ) + .bind(domain_b) + .bind(vec![72_u8; 32]) + .execute(&pool) + .await + .expect("insert substantive response-loss audit sentinel"); + let domain_b_facts = legacy_identity_facts(&pool, domain_b).await; + let domain_b_audit = domain_audit_snapshot(&pool, domain_b).await; + let domain_b_history_pre = domain_legacy_history_snapshot(&pool, domain_b).await; + let domain_b_authorized = raw_domain_authorized(&pool, domain_b, &[72_u8; 32]).await; + assert!(domain_b_authorized); + assert_response_loss_legacy_sentinels( + &pool, + domain_b, + &domain_b_facts, + domain_b_authorized, + &domain_b_audit, + &domain_b_history_pre, + ) + .await; + let migration = migration_0030(); + let statements = split_statements(migration.sql.as_ref()); + + let mut gate = pool + .acquire() + .await + .expect("acquire response-loss gate backend"); + sqlx::query("SELECT pg_advisory_lock($1,$2)") + .bind(GATE_CLASS) + .bind(GATE_OBJECT) + .execute(&mut *gate) + .await + .expect("hold post-commit response gate"); + + let migration_pool = pool.clone(); + let (pid_sender, pid_receiver) = tokio::sync::oneshot::channel(); + let migration_task = tokio::spawn(async move { + let mut connection = migration_pool + .acquire() + .await + .expect("acquire response-loss migration backend"); + let backend_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *connection) + .await + .expect("read response-loss backend pid"); + pid_sender + .send(backend_pid) + .expect("send response-loss backend pid"); + let mut tx = connection + .begin() + .await + .expect("begin response-loss migration"); + sqlx::query("SET LOCAL synchronous_commit=on") + .execute(&mut *tx) + .await + .expect("require durable response-loss commit"); + for statement in statements { + sqlx::raw_sql(sqlx::AssertSqlSafe(statement)) + .execute(&mut *tx) + .await + .expect("execute response-loss migration statement"); + } + sqlx::query( + "INSERT INTO _sqlx_migrations \ + (version,description,installed_on,success,checksum,execution_time) \ + VALUES ($1,$2,NOW(),TRUE,$3,0)", + ) + .bind(migration.version) + .bind(migration.description.as_ref()) + .bind(migration.checksum.as_ref()) + .execute(&mut *tx) + .await + .expect("insert response-loss migration marker"); + let commit_then_block = + format!("COMMIT; SELECT pg_advisory_lock({GATE_CLASS},{GATE_OBJECT})"); + let response = sqlx::raw_sql(sqlx::AssertSqlSafe(commit_then_block)) + .execute(&mut *tx) + .await; + assert!( + response.is_err(), + "terminated post-commit backend must lose the real response stream" + ); + }); + let migration_pid = pid_receiver.await.expect("receive migration backend pid"); + + let mut observed_durable_commit_and_waiter = false; + for _ in 0..20_000 { + let observed: bool = sqlx::query_scalar( + "SELECT \ + EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version=30 AND success) \ + AND EXISTS(\ + SELECT 1 FROM pg_locks lock_row \ + WHERE lock_row.locktype='advisory' \ + AND lock_row.pid=$1 AND NOT lock_row.granted \ + AND lock_row.classid::BIGINT=$2 AND lock_row.objid::BIGINT=$3\ + )", + ) + .bind(migration_pid) + .bind(i64::from(GATE_CLASS)) + .bind(i64::from(GATE_OBJECT)) + .fetch_one(&pool) + .await + .expect("observe committed migration blocked before success response"); + if observed { + observed_durable_commit_and_waiter = true; + break; + } + tokio::task::yield_now().await; + } + assert!( + observed_durable_commit_and_waiter, + "migration never reached the proven post-commit/pre-response boundary" + ); + assert_response_loss_legacy_sentinels( + &pool, + domain_b, + &domain_b_facts, + domain_b_authorized, + &domain_b_audit, + &domain_b_history_pre, + ) + .await; + let (domain_b_state_post, domain_b_history_post) = + response_loss_migrated_domain_sentinels(&pool, domain_b).await; + let committed = full_identity_snapshot(&pool).await; + let terminated: bool = sqlx::query_scalar("SELECT pg_terminate_backend($1)") + .bind(migration_pid) + .fetch_one(&pool) + .await + .expect("terminate backend after durable commit before response"); + assert!(terminated); + migration_task + .await + .expect("join real response-loss migration task"); + sqlx::query("SELECT pg_advisory_unlock($1,$2)") + .bind(GATE_CLASS) + .bind(GATE_OBJECT) + .execute(&mut *gate) + .await + .expect("release response-loss gate"); + drop(gate); + pool.close().await; + + let pool = connect_pool().await; + assert_response_loss_legacy_sentinels( + &pool, + domain_b, + &domain_b_facts, + domain_b_authorized, + &domain_b_audit, + &domain_b_history_pre, + ) + .await; + let (reconnected_domain_b_state, reconnected_domain_b_history) = + response_loss_migrated_domain_sentinels(&pool, domain_b).await; + assert_eq!(reconnected_domain_b_state, domain_b_state_post); + assert_eq!(reconnected_domain_b_history, domain_b_history_post); + assert_eq!(full_identity_snapshot(&pool).await, committed); + MIGRATOR + .run_to(30, &pool) + .await + .expect("idempotent retry after response loss"); + assert_response_loss_legacy_sentinels( + &pool, + domain_b, + &domain_b_facts, + domain_b_authorized, + &domain_b_audit, + &domain_b_history_pre, + ) + .await; + let (retried_domain_b_state, retried_domain_b_history) = + response_loss_migrated_domain_sentinels(&pool, domain_b).await; + assert_eq!(retried_domain_b_state, domain_b_state_post); + assert_eq!(retried_domain_b_history, domain_b_history_post); + assert_eq!(full_identity_snapshot(&pool).await, committed); +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn identity_0030_readable_ambiguities_preserve_facts_and_never_create_authority() { + static PROVISION_KEY: [u8; 32] = [91; 32]; + static ROTATE_KEY: [u8; 32] = [92; 32]; + static RECOVER_KEY: [u8; 32] = [93; 32]; + static ENABLE_KEY: [u8; 32] = [94; 32]; + + let pool = connect_pool().await; + let (domain_a, domain_b) = reset_to_0029(&pool).await; + let active_key = vec![71_u8; 32]; + let missing_predecessor = vec![73_u8; 32]; + let missing_target = vec![74_u8; 32]; + insert_rotated_legacy_row( + &pool, + domain_a, + "migration-fault-a-subject", + &missing_predecessor, + &missing_target, + ) + .await; + + let duplicate_key = vec![80_u8; 32]; + insert_revoked_legacy_row(&pool, domain_a, "duplicate", &duplicate_key).await; + insert_revoked_legacy_row(&pool, domain_a, "duplicate", &duplicate_key).await; + + let cycle_a = vec![81_u8; 32]; + let cycle_b = vec![82_u8; 32]; + insert_rotated_legacy_row(&pool, domain_a, "cycle", &cycle_a, &cycle_b).await; + insert_rotated_legacy_row(&pool, domain_a, "cycle", &cycle_b, &cycle_a).await; + + let fork_source = vec![83_u8; 32]; + let fork_target = vec![84_u8; 32]; + insert_rotated_legacy_row(&pool, domain_a, "fork", &fork_source, &fork_target).await; + insert_revoked_legacy_row(&pool, domain_a, "fork", &fork_target).await; + insert_revoked_legacy_row(&pool, domain_a, "fork", &fork_target).await; + + let conflicting_key = vec![85_u8; 32]; + sqlx::query( + "INSERT INTO identity_bindings (community_id,issuer,uid,pubkey,source) \ + VALUES ($1,'https://idp.example','active-with-tombstone',$2,'db_binding')", + ) + .bind(domain_a) + .bind(&conflicting_key) + .execute(&pool) + .await + .expect("insert active binding selected by legacy tombstone"); + sqlx::query( + "INSERT INTO identity_revoked_keys (community_id,pubkey,reason) \ + VALUES ($1,$2,'legacy key tombstone')", + ) + .bind(domain_a) + .bind(&conflicting_key) + .execute(&pool) + .await + .expect("insert conflicting legacy tombstone"); + + let legacy_a = legacy_identity_facts(&pool, domain_a).await; + let legacy_b = legacy_identity_facts(&pool, domain_b).await; + MIGRATOR + .run_to(30, &pool) + .await + .expect("readable ambiguity migrates into fail-closed quarantine"); + assert_eq!(legacy_identity_facts(&pool, domain_a).await, legacy_a); + assert_eq!(legacy_identity_facts(&pool, domain_b).await, legacy_b); + + let quarantined: Vec = sqlx::query_scalar( + "SELECT subject FROM identity_migration_denials WHERE community_id=$1 ORDER BY subject", + ) + .bind(domain_a) + .fetch_all(&pool) + .await + .expect("read principal quarantines"); + assert_eq!( + quarantined, + vec![ + "cycle".to_owned(), + "duplicate".to_owned(), + "fork".to_owned(), + "migration-fault-a-subject".to_owned(), + ] + ); + for key in [ + active_key.as_slice(), + missing_predecessor.as_slice(), + missing_target.as_slice(), + duplicate_key.as_slice(), + cycle_a.as_slice(), + cycle_b.as_slice(), + fork_source.as_slice(), + fork_target.as_slice(), + ] { + let denied: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM identity_migration_denied_keys \ + WHERE community_id=$1 AND pubkey=$2)", + ) + .bind(domain_a) + .bind(key) + .fetch_one(&pool) + .await + .expect("read implicated key quarantine"); + assert!( + denied, + "every stored or referenced implicated key is denied" + ); + } + let imported_ambiguous_edges: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_binding_lineage lineage \ + JOIN identity_bindings binding \ + ON binding.community_id=lineage.community_id \ + AND binding.binding_id=lineage.predecessor_binding_id \ + WHERE binding.community_id=$1 AND binding.uid IN \ + ('cycle','duplicate','fork','migration-fault-a-subject')", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("count ambiguous imported lineage"); + assert_eq!(imported_ambiguous_edges, 0); + + let domain_a_id = CommunityId::from_uuid(domain_a); + let domain_b_id = CommunityId::from_uuid(domain_b); + let main_principal = IdentityPrincipal { + issuer: "https://idp.example", + subject: "migration-fault-a-subject", + }; + assert!( + get_active_identity_binding_by_pubkey(&pool, domain_a_id, &active_key) + .await + .is_err() + ); + assert!( + get_active_identity_binding_by_pubkey(&pool, domain_a_id, &conflicting_key) + .await + .is_err() + ); + for (subject, key) in [ + (main_principal.subject, active_key.as_slice()), + ("different-principal", missing_target.as_slice()), + ("active-with-tombstone", conflicting_key.as_slice()), + ] { + let result = resolve_identity_binding( + &pool, + &ResolveBindingInput { + authorization_domain: domain_a_id, + issuer: "https://idp.example", + subject, + pubkey: key, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "migration-test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("migrated denial resolves without authority"); + assert_eq!(result, ResolveBindingResult::Denied(BindingDenial::Revoked)); + } + + let domain_b_cross = resolve_identity_binding( + &pool, + &ResolveBindingInput { + authorization_domain: domain_b_id, + issuer: "https://idp.example", + subject: "independent-domain-subject", + pubkey: &missing_target, + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + policy_version: "migration-test-policy-v1", + evidence_valid_from: 0, + evidence_valid_until: i64::MAX as u64, + }, + ) + .await + .expect("same bytes remain independent in domain B"); + assert!(matches!(domain_b_cross, ResolveBindingResult::Enrolled(_))); + let domain_b_sentinel = domain_identity_snapshot(&pool, domain_b).await; + + let (retired_binding_id, retired_binding_version): (Uuid, i64) = sqlx::query_as( + "SELECT binding_id,binding_version FROM identity_bindings \ + WHERE community_id=$1 AND issuer='https://idp.example' \ + AND uid='migration-fault-a-subject' AND pubkey=$2", + ) + .bind(domain_a) + .bind(&missing_predecessor) + .fetch_one(&pool) + .await + .expect("read quarantined retired coordinate"); + let fabricated_pending = PendingLineage { + retired_pubkey: missing_predecessor.clone(), + retired_binding_id, + retired_binding_version: u64::try_from(retired_binding_version).expect("positive version"), + selector_version: 1, + }; + let before_denials = domain_identity_snapshot(&pool, domain_a).await; + assert!(provision_identity_binding( + &pool, + domain_a_id, + lifecycle_context(301, "quarantine provision denial"), + main_principal, + EnrollmentMode::Provisioned, + replacement(&PROVISION_KEY, BindingProvenance::Provisioned), + ) + .await + .is_err()); + assert!(retire_identity_pair( + &pool, + domain_a_id, + lifecycle_context(302, "quarantine retire denial"), + main_principal, + &active_key, + ) + .await + .is_err()); + assert!(disable_identity_principal( + &pool, + domain_a_id, + lifecycle_context(303, "quarantine disable denial"), + main_principal, + ) + .await + .is_err()); + assert!(revoke_identity_key( + &pool, + domain_a_id, + lifecycle_context(304, "quarantine revoke denial"), + &active_key, + ) + .await + .is_err()); + assert!(rotate_identity_binding( + &pool, + domain_a_id, + lifecycle_context(305, "quarantine rotate denial"), + main_principal, + &active_key, + replacement(&ROTATE_KEY, BindingProvenance::AttestedKey), + ) + .await + .is_err()); + assert!(recover_identity_binding( + &pool, + domain_a_id, + lifecycle_context(306, "quarantine recover denial"), + main_principal, + &fabricated_pending, + replacement(&RECOVER_KEY, BindingProvenance::AttestedKey), + ) + .await + .is_err()); + assert!(enable_identity_principal( + &pool, + domain_a_id, + lifecycle_context(307, "quarantine enable denial"), + main_principal, + Some(&fabricated_pending), + replacement(&ENABLE_KEY, BindingProvenance::AttestedKey), + ) + .await + .is_err()); + assert_eq!( + domain_identity_snapshot(&pool, domain_a).await, + before_denials + ); + assert_eq!( + domain_identity_snapshot(&pool, domain_b).await, + domain_b_sentinel + ); + assert!( + get_active_identity_binding_by_pubkey(&pool, domain_b_id, &missing_target) + .await + .expect("domain-B authorization sentinel") + .is_some() + ); +} diff --git a/crates/buzz-db/src/relay_invite.rs b/crates/buzz-db/src/relay_invite.rs index b617732681..7189a2381f 100644 --- a/crates/buzz-db/src/relay_invite.rs +++ b/crates/buzz-db/src/relay_invite.rs @@ -62,6 +62,9 @@ pub enum ClaimOutcome { IdentityConflict(IdentityBindingConflict), /// The staged identity principal or key is revoked. IdentityRevoked, + /// The staged identity has no active binding and lacks sealed enrollment + /// evidence. + IdentityBindingRequired, } /// A freshly minted v2 invite, including the plaintext code and metadata. @@ -287,6 +290,17 @@ pub async fn claim_relay_invite_with_identity( ); return Ok(ClaimOutcome::IdentityRevoked); } + BindIdentityResult::BindingRequired => { + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "identity_binding_required", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::IdentityBindingRequired); + } } } else { None @@ -469,6 +483,42 @@ mod tests { async fn delete_test_community(pool: &PgPool, community: CommunityId) { let mut tx = pool.begin().await.expect("begin test cleanup"); + for (table, statement) in [ + ( + "identity_lifecycle_operations", + "DELETE FROM identity_lifecycle_operations WHERE community_id = $1", + ), + ( + "identity_binding_history", + "DELETE FROM identity_binding_history WHERE community_id = $1", + ), + ( + "identity_pending_replacements", + "DELETE FROM identity_pending_replacements WHERE community_id = $1", + ), + ( + "identity_retired_pairs", + "DELETE FROM identity_retired_pairs WHERE community_id = $1", + ), + ( + "identity_binding_lineage", + "DELETE FROM identity_binding_lineage WHERE community_id = $1", + ), + ( + "identity_migration_denied_keys", + "DELETE FROM identity_migration_denied_keys WHERE community_id = $1", + ), + ( + "identity_migration_denials", + "DELETE FROM identity_migration_denials WHERE community_id = $1", + ), + ] { + sqlx::query(statement) + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .unwrap_or_else(|error| panic!("delete test rows from {table}: {error}")); + } sqlx::query("DELETE FROM identity_revoked_keys WHERE community_id = $1") .bind(community.as_uuid()) .execute(&mut *tx) @@ -626,7 +676,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn invite_claim_commits_identity_and_membership_atomically() { + async fn invite_claim_requires_verified_binding_and_rolls_back_membership_atomically() { let pool = setup_pool().await; let community = make_test_community(&pool).await; let claimer = test_pubkey(); @@ -666,7 +716,7 @@ mod tests { .await .expect("mint invite"); let hash = hash_v2_code(&invite.code); - assert!(matches!( + assert_eq!( claim_relay_invite_with_identity( &pool, community, @@ -676,22 +726,21 @@ mod tests { Some(&identity), ) .await - .expect("valid atomic claim"), - ClaimOutcome::Joined { .. } - )); - assert!(is_relay_member(&pool, community, &claimer) + .expect("binding-required atomic claim"), + ClaimOutcome::IdentityBindingRequired + ); + assert!(!is_relay_member(&pool, community, &claimer) .await - .expect("membership committed")); - assert_eq!( + .expect("membership rolled back")); + assert!( crate::identity_binding::get_active_identity_binding_by_pubkey( &pool, community, &pubkey, ) .await .expect("binding lookup") - .expect("binding committed") - .uid, - "atomic-user" + .is_none() ); + assert_eq!(use_count(&pool, community, invite.invite_id).await, 0); delete_test_community(&pool, community).await; } diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index afb31bc890..affddd8177 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -158,11 +158,11 @@ pub async fn claim_relay_membership( .await? { MembershipClaimOutcome::Joined { inserted, .. } => Ok(inserted), - MembershipClaimOutcome::IdentityConflict(_) | MembershipClaimOutcome::IdentityRevoked => { - Err(crate::DbError::InvalidData( - "unexpected corporate identity result without staged identity".to_string(), - )) - } + MembershipClaimOutcome::IdentityConflict(_) + | MembershipClaimOutcome::IdentityRevoked + | MembershipClaimOutcome::IdentityBindingRequired => Err(crate::DbError::InvalidData( + "unexpected corporate identity result without staged identity".to_string(), + )), } } @@ -180,6 +180,9 @@ pub enum MembershipClaimOutcome { IdentityConflict(IdentityBindingConflict), /// The staged identity is revoked. IdentityRevoked, + /// The staged identity has no active binding and lacks sealed enrollment + /// evidence. + IdentityBindingRequired, } /// Claims relay membership and an optional corporate identity in one transaction. @@ -206,6 +209,10 @@ pub async fn claim_relay_membership_with_identity( tx.rollback().await?; return Ok(MembershipClaimOutcome::IdentityRevoked); } + BindIdentityResult::BindingRequired => { + tx.rollback().await?; + return Ok(MembershipClaimOutcome::IdentityBindingRequired); + } } } else { None diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 83ecb2fcc3..c8cd2b1121 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -548,6 +548,16 @@ pub async fn claim_invite( ) .await) } + buzz_db::relay_invite::ClaimOutcome::IdentityBindingRequired => { + Err(record_atomic_identity_rejection( + &state, + tenant.community(), + pubkey, + identity_proof, + buzz_db::identity_binding::BindIdentityResult::BindingRequired, + ) + .await) + } }; } @@ -614,6 +624,16 @@ pub async fn claim_invite( ) .await); } + buzz_db::relay_members::MembershipClaimOutcome::IdentityBindingRequired => { + return Err(record_atomic_identity_rejection( + &state, + tenant.community(), + pubkey, + identity_proof, + buzz_db::identity_binding::BindIdentityResult::BindingRequired, + ) + .await); + } }; crate::corporate_identity::finalize_atomic_corporate_identity_result( &state, diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 7bfd6d4b20..c47e8df376 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -695,6 +695,9 @@ async fn handle_active_audio_connection( Ok(buzz_db::channel::ChannelAdmissionOutcome::IdentityRevoked) => { Some(buzz_db::identity_binding::BindIdentityResult::Revoked) } + Ok(buzz_db::channel::ChannelAdmissionOutcome::IdentityBindingRequired) => { + Some(buzz_db::identity_binding::BindIdentityResult::BindingRequired) + } Err(e) => { warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership auto-add failed: {e}"); let _ = ws_send diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs index 1e203eafcb..ee4ba45155 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -122,8 +122,8 @@ impl CorporateIdentityService { let decoded = decode::(token, &decoding_key, &validation) .map_err(|e| CorporateIdentityError::InvalidJwt(e.to_string()))?; - let issuer = claim_string(&decoded.claims.claims, "iss")?; - let uid = claim_string(&decoded.claims.claims, &self.config.uid_claim)?; + let issuer = claim_string_exact(&decoded.claims.claims, "iss")?; + let uid = claim_string_exact(&decoded.claims.claims, &self.config.uid_claim)?; let display_name = claim_string(&decoded.claims.claims, &self.config.display_claim)?; let public_display_name = self .config @@ -496,6 +496,9 @@ pub enum CorporateIdentityError { /// The requested uid/pubkey binding was previously revoked. #[error("corporate identity binding revoked")] BindingRevoked, + /// No active binding exists and this compatibility path cannot enroll one. + #[error("corporate identity binding requires authorized enrollment")] + BindingRequired, /// NIP-OA delegation was present but did not satisfy corporate identity. #[error("corporate identity delegation denied")] DelegationDenied, @@ -515,6 +518,7 @@ impl CorporateIdentityError { | Self::NpubMismatch | Self::BindingConflict | Self::BindingRevoked + | Self::BindingRequired | Self::DelegationDenied => StatusCode::FORBIDDEN, Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, } @@ -531,6 +535,7 @@ impl CorporateIdentityError { Self::NpubMismatch => "relay identity pubkey mismatch", Self::BindingConflict => "relay identity binding conflict", Self::BindingRevoked => "relay identity binding revoked", + Self::BindingRequired => "relay identity binding required", Self::DelegationDenied => "relay identity delegation denied", Self::Db(_) => "relay identity unavailable", } @@ -725,7 +730,7 @@ async fn complete_direct_corporate_identity( binding: BindIdentityResult, ) -> Result { let binding = match binding { - BindIdentityResult::Conflict(conflict) => { + BindIdentityResult::Conflict(_) => { metrics::counter!("buzz_corporate_identity_bindings_total", "result" => "conflict") .increment(1); record_identity_binding_audit( @@ -737,19 +742,10 @@ async fn complete_direct_corporate_identity( &claims.uid, serde_json::json!({ "source": source, - "issuer": claims.issuer, - "existing_uid": conflict.uid, - "existing_issuer": conflict.issuer, - "existing_pubkey": hex::encode(conflict.pubkey), - "existing_source": conflict.source, }), ) .await; - warn!( - uid = %claims.uid, - signer = %signer.to_hex(), - "corporate identity binding conflict" - ); + warn!("corporate identity binding conflict"); return Err(CorporateIdentityError::BindingConflict); } BindIdentityResult::Revoked => { @@ -765,13 +761,18 @@ async fn complete_direct_corporate_identity( serde_json::json!({ "source": source, "issuer": claims.issuer }), ) .await; - warn!( - uid = %claims.uid, - signer = %signer.to_hex(), - "corporate identity binding was previously revoked" - ); + warn!("corporate identity binding was previously revoked"); return Err(CorporateIdentityError::BindingRevoked); } + BindIdentityResult::BindingRequired => { + metrics::counter!( + "buzz_corporate_identity_bindings_total", + "result" => "binding_required" + ) + .increment(1); + warn!("corporate identity binding requires sealed enrollment evidence"); + return Err(CorporateIdentityError::BindingRequired); + } binding => binding, }; record_identity_binding_metric(&binding); @@ -1128,6 +1129,21 @@ fn claim_string( Ok(value.to_string()) } +fn claim_string_exact( + claims: &Map, + claim: &str, +) -> Result { + claims + .get(claim) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| CorporateIdentityError::InvalidClaim { + claim: claim.to_string(), + reason: "must be a non-empty literal string".to_string(), + }) +} + fn configured_pubkey_claim( claims: &Map, claim: Option<&str>, @@ -1179,6 +1195,7 @@ fn record_identity_binding_metric(binding: &BindIdentityResult) { BindIdentityResult::Matched => "matched", BindIdentityResult::Conflict(_) => "conflict", BindIdentityResult::Revoked => "revoked", + BindIdentityResult::BindingRequired => "binding_required", }; metrics::counter!("buzz_corporate_identity_bindings_total", "result" => result).increment(1); } @@ -1193,6 +1210,7 @@ fn record_corporate_identity_denial(error: &CorporateIdentityError) { CorporateIdentityError::NpubMismatch => "npub_mismatch", CorporateIdentityError::BindingConflict => "binding_conflict", CorporateIdentityError::BindingRevoked => "binding_revoked", + CorporateIdentityError::BindingRequired => "binding_required", CorporateIdentityError::DelegationDenied => "delegation_denied", CorporateIdentityError::Db(_) => "db", }; @@ -1264,6 +1282,16 @@ mod tests { } } + #[test] + fn issuer_and_subject_claims_preserve_literal_identity() { + let claims = serde_json::json!({"iss": " issuer ", "sub": " subject "}); + let claims = claims.as_object().expect("claims object"); + + assert_eq!(claim_string_exact(claims, "iss").unwrap(), " issuer "); + assert_eq!(claim_string_exact(claims, "sub").unwrap(), " subject "); + assert!(claim_string_exact(claims, "missing").is_err()); + } + fn test_identity_binding( issuer: &str, uid: &str, @@ -1271,9 +1299,18 @@ mod tests { ) -> buzz_db::identity_binding::IdentityBinding { let now = chrono::Utc::now(); buzz_db::identity_binding::IdentityBinding { + binding_id: uuid::Uuid::new_v4(), issuer: issuer.to_string(), uid: uid.to_string(), pubkey: pubkey.to_bytes().to_vec(), + binding_version: 1, + binding_state: buzz_db::identity_binding::BindingState::Active, + binding_provenance: buzz_db::identity_binding::BindingProvenance::Tofu, + creation_attribution: + buzz_db::identity_binding::CreationAttributionKind::AuthenticatedKey, + created_by: Some(pubkey.to_bytes().to_vec()), + created_policy_version: Some("relay-test-policy-v1".to_owned()), + expires_at: None, display_name: None, source: SOURCE_DB_BINDING.to_string(), created_at: now, @@ -2091,16 +2128,24 @@ mod tests { .expect_err("owner without binding should be denied"); assert!(matches!(err, CorporateIdentityError::DelegationDenied)); - db.bind_or_validate_identity( - community, - &config.issuer, - "owner-uid", - owner_keys.public_key().as_bytes(), - Some("owner@example.com"), - SOURCE_DB_BINDING, + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, display_name, source, binding_id, \ + binding_version, binding_state, binding_provenance, created_by, \ + created_policy_version, creation_attribution_kind) \ + VALUES ($1,$2,$3,$4,$5,$6,$7,1,'active','attested_key',$4,$8,'authenticated_key')", ) + .bind(community.as_uuid()) + .bind(&config.issuer) + .bind("owner-uid") + .bind(owner_keys.public_key().as_bytes()) + .bind("owner@example.com") + .bind(SOURCE_DB_BINDING) + .bind(Uuid::new_v4()) + .bind("relay-test-policy-v1") + .execute(&pool) .await - .expect("create owner binding"); + .expect("seed verified owner binding fixture"); let decision = verify_delegated_corporate_identity( &db, diff --git a/crates/buzz-relay/src/mesh_boot.rs b/crates/buzz-relay/src/mesh_boot.rs index 20e550aa08..2ad3ce5fa7 100644 --- a/crates/buzz-relay/src/mesh_boot.rs +++ b/crates/buzz-relay/src/mesh_boot.rs @@ -315,25 +315,39 @@ pub(crate) async fn run_demo_echo( tracing::info!(%session_id, %peer, "mesh demo echo: session open"); let mut drain_tick = tokio::time::interval(std::time::Duration::from_millis(100)); loop { - let frame = tokio::select! { - _ = drain_tick.tick() => { - if shutting_down.load(Ordering::Relaxed) { - if let Some(community_id) = stream.community_id() { - if let Err(e) = stream.send_goodbye(community_id, GoodbyeReason::Draining).await { - tracing::warn!(%session_id, "mesh demo echo: draining goodbye failed: {e}"); - } else { - tracing::info!(%session_id, "mesh demo echo: sent draining goodbye"); + // recv_validated reads the frame before asynchronously checking its + // Redis fence. Keep that future alive across drain polls: cancelling + // and recreating it after a tick would discard an already-read frame. + let frame = { + let recv = stream.recv_validated(&directory); + tokio::pin!(recv); + loop { + tokio::select! { + frame = &mut recv => break frame, + _ = drain_tick.tick() => { + if shutting_down.load(Ordering::Relaxed) { + break Ok(None); } - } else { - let _ = stream.finish(); - tracing::info!(%session_id, "mesh demo echo: drain before community latch — closing"); } - return; } - continue; } - frame = stream.recv_validated(&directory) => frame, }; + if shutting_down.load(Ordering::Relaxed) { + if let Some(community_id) = stream.community_id() { + if let Err(e) = stream + .send_goodbye(community_id, GoodbyeReason::Draining) + .await + { + tracing::warn!(%session_id, "mesh demo echo: draining goodbye failed: {e}"); + } else { + tracing::info!(%session_id, "mesh demo echo: sent draining goodbye"); + } + } else { + let _ = stream.finish(); + tracing::info!(%session_id, "mesh demo echo: drain before community latch — closing"); + } + return; + } match frame { Ok(Some(ReliableFrame::Data(payload))) => { // recv_validated latched the community from the frame it just diff --git a/crates/git-sign-nostr/src/lib.rs b/crates/git-sign-nostr/src/lib.rs index d316711200..9da8f70c22 100644 --- a/crates/git-sign-nostr/src/lib.rs +++ b/crates/git-sign-nostr/src/lib.rs @@ -84,7 +84,7 @@ use chrono::DateTime; use nostr::hashes::sha256::Hash as Sha256Hash; use nostr::hashes::{Hash, HashEngine}; use nostr::secp256k1::schnorr::Signature; -use nostr::secp256k1::{Keypair, Message}; +use nostr::secp256k1::{Keypair, Message, XOnlyPublicKey}; use nostr::{FromBech32, PublicKey, SecretKey, SECP256K1}; use zeroize::Zeroize; @@ -1017,7 +1017,7 @@ fn do_sign(key_id: &str, status: &mut StatusWriter) -> Result<(), Error> { let oa = load_auth_tag()?; if let Some(ref oa_val) = oa { // Owner pubkey must be a valid BIP-340 key - if PublicKey::from_hex(&oa_val.0).is_err() { + if parse_bip340_xonly_public_key(&oa_val.0).is_err() { return Err(Error::Fatal( "auth tag owner (oa[0]) is not a valid BIP-340 public key".to_string(), )); @@ -1188,7 +1188,7 @@ fn do_verify(sig_file: &str, status: &mut StatusWriter) -> Result<(), Error> { } // Validate pk is a valid BIP-340 x-only public key - let pk = PublicKey::from_hex(&envelope.pk).map_err(|e| { + let xonly = parse_bip340_xonly_public_key(&envelope.pk).map_err(|e| { write_errsig(status, Some(&envelope.pk)); Error::VerifyFailed { pk: Some(envelope.pk.clone()), @@ -1223,13 +1223,6 @@ fn do_verify(sig_file: &str, status: &mut StatusWriter) -> Result<(), Error> { })?; // Verify BIP-340 signature - let xonly = pk.xonly().map_err(|_| { - write_errsig(status, Some(&envelope.pk)); - Error::VerifyFailed { - pk: Some(envelope.pk.clone()), - msg: "invalid public key xonly conversion".to_string(), - } - })?; if SECP256K1.verify_schnorr(&sig, &message, &xonly).is_err() { status.write_line("NEWSIG"); status.write_line(&format!("BADSIG {} {}", envelope.pk, envelope.pk)); @@ -1243,7 +1236,7 @@ fn do_verify(sig_file: &str, status: &mut StatusWriter) -> Result<(), Error> { let oa_result = if let Some(ref oa) = envelope.oa { // Validate oa[0] is a valid BIP-340 public key. Per NIP-GS spec, // an invalid owner pubkey is a structural error → ERRSIG. - if PublicKey::from_hex(&oa.0).is_err() { + if parse_bip340_xonly_public_key(&oa.0).is_err() { write_errsig(status, Some(&envelope.pk)); return Err(Error::VerifyFailed { pk: Some(envelope.pk), @@ -1420,7 +1413,7 @@ fn parse_envelope(json_str: &str) -> Result { } // Validate oa[0] is a valid BIP-340 x-only public key (not just hex) - PublicKey::from_hex(owner) + parse_bip340_xonly_public_key(owner) .map_err(|e| format!("oa[0] is not a valid BIP-340 public key: {e}"))?; // Self-attestation is meaningless — owner must differ from signer @@ -1461,6 +1454,17 @@ fn validate_hex_field(val: &str, expected_len: usize, name: &str) -> Result<(), Ok(()) } +/// Decode a 32-byte public-key value and require a valid secp256k1 x-only +/// point. `nostr::PublicKey::from_hex` checks only the byte encoding; BIP-340 +/// validity is established by the x-only conversion. +fn parse_bip340_xonly_public_key(public_key: &str) -> Result { + let public_key = + PublicKey::from_hex(public_key).map_err(|e| format!("invalid hex encoding: {e}"))?; + public_key + .xonly() + .map_err(|e| format!("invalid x-only point: {e}")) +} + fn parse_armor(content: &str) -> Result<&str, String> { // NIP-GS spec requires armor to end with a newline after the END marker. let content = content @@ -1500,9 +1504,9 @@ fn parse_armor(content: &str) -> Result<&str, String> { fn verify_oa(agent_pk_hex: &str, oa: &(String, String, String)) -> bool { let (owner_pk_hex, conditions, owner_sig_hex) = oa; - // Parse owner pubkey - let owner_pk = match PublicKey::from_hex(owner_pk_hex) { - Ok(p) => p, + // Parse owner pubkey and require a valid x-only point. + let xonly = match parse_bip340_xonly_public_key(owner_pk_hex) { + Ok(xonly) => xonly, Err(_) => { eprintln!("warning: oa owner pubkey is not a valid BIP-340 key"); return false; @@ -1530,13 +1534,6 @@ fn verify_oa(agent_pk_hex: &str, oa: &(String, String, String)) -> bool { } }; - let xonly = match owner_pk.xonly() { - Ok(x) => x, - Err(_) => { - eprintln!("warning: oa owner pubkey conversion to xonly failed"); - return false; - } - }; if SECP256K1.verify_schnorr(&sig, &message, &xonly).is_err() { eprintln!("warning: NIP-OA owner attestation signature verification failed"); return false; @@ -2261,7 +2258,7 @@ Initial commit" if !is_lower_hex(&owner, 64) { return Err("auth tag owner must be 64 lowercase hex chars".to_string()); } - PublicKey::from_hex(&owner) + parse_bip340_xonly_public_key(&owner) .map_err(|e| format!("auth tag owner is not a valid BIP-340 key: {e}"))?; if !is_lower_hex(&sig, 128) { return Err("auth tag sig must be 128 lowercase hex chars".to_string()); diff --git a/migrations/0030_additive_identity_binding_state.sql b/migrations/0030_additive_identity_binding_state.sql new file mode 100644 index 0000000000..928d14759f --- /dev/null +++ b/migrations/0030_additive_identity_binding_state.sql @@ -0,0 +1,664 @@ +-- Additive identity-binding state projection. +-- +-- Migrations 0027/0028 are a frozen compatibility boundary. This migration +-- never renames or removes their columns, constraints, indexes, rows, or +-- lifecycle selectors. In particular, every legacy identity_revoked_keys row +-- remains authoritative because rotation-created and explicitly strengthened +-- tombstones cannot be distinguished after the fact. + +-- Materialize every retained legacy identity row before projecting any new +-- state. A storage/decoding/read-policy failure must abort this migration; +-- treating an unreadable binding, principal denial, or key tombstone as +-- absent could otherwise invent authority. This block is read-only and runs +-- inside the migration transaction before the first persisted write. +WITH legacy_rows AS MATERIALIZED ( + SELECT to_jsonb(row_value) AS payload FROM identity_bindings row_value + UNION ALL + SELECT to_jsonb(row_value) AS payload FROM identity_principals row_value + UNION ALL + SELECT to_jsonb(row_value) AS payload FROM identity_revoked_keys row_value +) +SELECT COUNT(payload) FROM legacy_rows; + +-- Current verifier-owned enrollment policy. The table is intentionally empty +-- after migration: installing a policy is a separately authorized server +-- configuration action, so the disabled candidate fails closed by default. +CREATE TABLE identity_enrollment_policies ( + community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), + policy_id UUID NOT NULL, + policy_epoch BIGINT NOT NULL, + requirement TEXT NOT NULL, + effective_from BIGINT NOT NULL, + effective_until BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (policy_id <> '00000000-0000-0000-0000-000000000000'::UUID), + CHECK (policy_epoch > 0), + CHECK (requirement IN ('not_required', 'attested_key', 'provisioned', 'tofu')), + CHECK (effective_from >= 0), + CHECK (effective_from < effective_until) +); + +-- The policy UUID is a stable namespace and every semantic replacement must +-- advance its positive epoch. This makes an ID/epoch comparison inside the +-- binding transaction sufficient to detect requirement or interval drift. +CREATE FUNCTION enforce_identity_enrollment_policy_lineage() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.community_id <> OLD.community_id + OR NEW.policy_id <> OLD.policy_id + OR NEW.policy_epoch <= OLD.policy_epoch THEN + RAISE EXCEPTION 'identity enrollment policy lineage must advance monotonically'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER identity_enrollment_policy_lineage_guard +BEFORE UPDATE ON identity_enrollment_policies +FOR EACH ROW EXECUTE FUNCTION enforce_identity_enrollment_policy_lineage(); + +ALTER TABLE identity_bindings + ADD COLUMN binding_id UUID, + ADD COLUMN binding_version BIGINT, + ADD COLUMN binding_state TEXT, + ADD COLUMN binding_provenance TEXT, + ADD COLUMN replacement_binding_id UUID, + ADD COLUMN created_by BYTEA, + ADD COLUMN created_policy_version TEXT, + ADD COLUMN expires_at TIMESTAMPTZ, + ADD COLUMN creation_attribution_kind TEXT, + ADD COLUMN archived_at TIMESTAMPTZ, + ADD COLUMN archived_by BYTEA, + ADD COLUMN archived_reason TEXT; + +-- Every row receives a stable persisted identifier. Unique legacy exact pairs +-- use a reproducible length-prefixed hash. Byte-identical duplicate rows lack +-- a legacy row identifier, so they retain random persisted IDs and their exact +-- principal is quarantined below. +UPDATE identity_bindings +SET binding_id = gen_random_uuid(); + +WITH unique_pairs AS ( + SELECT + community_id, + issuer, + uid, + pubkey, + ( + substr(fingerprint, 1, 8) || '-' || + substr(fingerprint, 9, 4) || '-' || + substr(fingerprint, 13, 4) || '-' || + substr(fingerprint, 17, 4) || '-' || + substr(fingerprint, 21, 12) + )::UUID AS stable_id + FROM ( + SELECT + community_id, + issuer, + uid, + pubkey, + encode( + digest( + E'\\x01'::BYTEA || + uuid_send(community_id) || + int8send(octet_length(convert_to(issuer, 'UTF8'))::BIGINT) || + convert_to(issuer, 'UTF8') || + int8send(octet_length(convert_to(uid, 'UTF8'))::BIGINT) || + convert_to(uid, 'UTF8') || + int8send(octet_length(pubkey)::BIGINT) || + pubkey, + 'sha256' + ), + 'hex' + ) AS fingerprint + FROM identity_bindings + GROUP BY community_id, issuer, uid, pubkey + HAVING COUNT(*) = 1 + ) fingerprints +) +UPDATE identity_bindings binding +SET binding_id = unique_pairs.stable_id +FROM unique_pairs +WHERE binding.community_id = unique_pairs.community_id + AND binding.issuer = unique_pairs.issuer + AND binding.uid = unique_pairs.uid + AND binding.pubkey = unique_pairs.pubkey; + +UPDATE identity_bindings +SET binding_state = CASE + WHEN rotation_completed_at IS NOT NULL THEN 'rotated' + WHEN revoked_at IS NOT NULL THEN 'revoked' + ELSE 'active' + END, + binding_provenance = CASE source + WHEN 'jwt_npub' THEN 'attested_key' + ELSE 'tofu' + END, + creation_attribution_kind = 'legacy_unknown'; + +ALTER TABLE identity_bindings + ALTER COLUMN binding_id SET NOT NULL, + ALTER COLUMN binding_id SET DEFAULT gen_random_uuid(), + ALTER COLUMN binding_state SET NOT NULL, + ALTER COLUMN binding_state SET DEFAULT 'active', + ALTER COLUMN binding_provenance SET NOT NULL, + ALTER COLUMN binding_provenance SET DEFAULT 'tofu', + ALTER COLUMN creation_attribution_kind SET NOT NULL, + ADD CONSTRAINT identity_bindings_binding_id_unique + UNIQUE (community_id, binding_id), + ADD CONSTRAINT chk_identity_bindings_id_not_nil + CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::UUID), + ADD CONSTRAINT chk_identity_bindings_state + CHECK (binding_state IN ('active', 'revoked', 'rotated', 'archived')), + ADD CONSTRAINT chk_identity_bindings_provenance + CHECK (binding_provenance IN ('attested_key', 'provisioned', 'tofu')), + ADD CONSTRAINT chk_identity_bindings_created_by_len + CHECK (created_by IS NULL OR length(created_by) = 32), + ADD CONSTRAINT chk_identity_bindings_policy_version + CHECK (created_policy_version IS NULL OR length(created_policy_version) > 0), + ADD CONSTRAINT chk_identity_bindings_expiry + CHECK (expires_at IS NULL OR expires_at > TIMESTAMPTZ 'epoch'), + ADD CONSTRAINT chk_identity_bindings_creation_attribution + CHECK ( + (creation_attribution_kind = 'legacy_unknown' + AND created_by IS NULL AND created_policy_version IS NULL) + OR + (creation_attribution_kind IN ('authenticated_key', 'operator') + AND created_by IS NOT NULL AND length(created_by) = 32 + AND created_policy_version IS NOT NULL + AND length(created_policy_version) > 0) + ), + ADD CONSTRAINT chk_identity_bindings_authority_state + CHECK ((binding_state = 'active') = (revoked_at IS NULL)), + ADD CONSTRAINT chk_identity_bindings_archive_attribution + CHECK ( + (binding_state <> 'archived' + AND archived_at IS NULL AND archived_by IS NULL AND archived_reason IS NULL) + OR + (binding_state = 'archived' + AND archived_at IS NOT NULL + AND archived_by IS NOT NULL AND length(archived_by) = 32 + AND archived_reason IS NOT NULL AND length(archived_reason) > 0) + ); + +-- Preserve the checksum-frozen legacy indexes while making the authoritative +-- predicate explicit in the current catalog as well as in every authority read. +CREATE UNIQUE INDEX idx_identity_bindings_authoritative_principal + ON identity_bindings (community_id, issuer, uid) + WHERE binding_state = 'active' AND revoked_at IS NULL; +CREATE UNIQUE INDEX idx_identity_bindings_authoritative_pubkey + ON identity_bindings (community_id, pubkey) + WHERE binding_state = 'active' AND revoked_at IS NULL; + +-- Invalid legacy graphs remain stored verbatim but are not usable as binding +-- authority. The binding subsystem exposes no operation that clears these +-- migration denials. +CREATE TABLE identity_migration_denials ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + reason TEXT NOT NULL, + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, issuer, subject), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (length(reason) > 0) +); + +INSERT INTO identity_migration_denials (community_id, issuer, subject, reason) +SELECT community_id, issuer, uid, 'duplicate legacy exact-pair rows' +FROM identity_bindings +GROUP BY community_id, issuer, uid, pubkey +HAVING COUNT(*) > 1 +ON CONFLICT (community_id, issuer, subject) DO NOTHING; + +-- A valid legacy principal is one complete, non-branching, acyclic chain. Edge +-- candidates include inactive replacements. Equality is intentional because +-- NOW() is transaction-stable in the legacy rotation helper; an older row can +-- never be selected as a successor. +WITH RECURSIVE +candidate_edges AS ( + SELECT + predecessor.community_id, + predecessor.issuer, + predecessor.uid, + predecessor.binding_id AS predecessor_id, + successor.binding_id AS successor_id, + COUNT(*) OVER ( + PARTITION BY predecessor.community_id, predecessor.binding_id + ) AS outgoing_candidates + FROM identity_bindings predecessor + JOIN identity_bindings successor + ON successor.community_id = predecessor.community_id + AND successor.issuer = predecessor.issuer + AND successor.uid = predecessor.uid + AND successor.pubkey = predecessor.rotated_to_pubkey + AND successor.binding_id <> predecessor.binding_id + AND successor.created_at >= predecessor.rotation_completed_at + WHERE predecessor.rotation_completed_at IS NOT NULL +), +resolved_edges AS ( + SELECT community_id, issuer, uid, predecessor_id, successor_id + FROM candidate_edges + WHERE outgoing_candidates = 1 +), +principals AS ( + SELECT community_id, issuer, uid, COUNT(*)::BIGINT AS node_count + FROM identity_bindings + GROUP BY community_id, issuer, uid +), +roots AS ( + SELECT node.community_id, node.issuer, node.uid, node.binding_id + FROM identity_bindings node + WHERE NOT EXISTS ( + SELECT 1 + FROM resolved_edges edge + WHERE edge.community_id = node.community_id + AND edge.successor_id = node.binding_id + ) +), +reachable AS ( + SELECT root.community_id, root.issuer, root.uid, root.binding_id + FROM roots root + UNION + SELECT edge.community_id, edge.issuer, edge.uid, edge.successor_id + FROM reachable current_node + JOIN resolved_edges edge + ON edge.community_id = current_node.community_id + AND edge.predecessor_id = current_node.binding_id +), +graph_stats AS ( + SELECT + principal.community_id, + principal.issuer, + principal.uid, + principal.node_count, + COUNT(DISTINCT edge.predecessor_id)::BIGINT AS edge_count, + COUNT(DISTINCT root.binding_id)::BIGINT AS root_count, + COUNT(DISTINCT reached.binding_id)::BIGINT AS reached_count, + COALESCE(MAX(incoming.incoming_count), 0)::BIGINT AS max_incoming + FROM principals principal + LEFT JOIN resolved_edges edge + ON edge.community_id = principal.community_id + AND edge.issuer = principal.issuer + AND edge.uid = principal.uid + LEFT JOIN roots root + ON root.community_id = principal.community_id + AND root.issuer = principal.issuer + AND root.uid = principal.uid + LEFT JOIN reachable reached + ON reached.community_id = principal.community_id + AND reached.issuer = principal.issuer + AND reached.uid = principal.uid + LEFT JOIN ( + SELECT community_id, issuer, uid, successor_id, COUNT(*)::BIGINT AS incoming_count + FROM resolved_edges + GROUP BY community_id, issuer, uid, successor_id + ) incoming + ON incoming.community_id = principal.community_id + AND incoming.issuer = principal.issuer + AND incoming.uid = principal.uid + GROUP BY principal.community_id, principal.issuer, principal.uid, principal.node_count +), +unresolved_rotations AS ( + SELECT predecessor.community_id, predecessor.issuer, predecessor.uid + FROM identity_bindings predecessor + LEFT JOIN candidate_edges edge + ON edge.community_id = predecessor.community_id + AND edge.predecessor_id = predecessor.binding_id + WHERE predecessor.rotation_completed_at IS NOT NULL + GROUP BY predecessor.community_id, predecessor.issuer, predecessor.uid, predecessor.binding_id + HAVING COUNT(edge.successor_id) <> 1 + OR COALESCE(MAX(edge.outgoing_candidates), 0) <> 1 +), +invalid_principals AS ( + SELECT community_id, issuer, uid FROM unresolved_rotations + UNION + SELECT community_id, issuer, uid + FROM graph_stats + WHERE edge_count <> node_count - 1 + OR root_count <> 1 + OR reached_count <> node_count + OR max_incoming > 1 +) +INSERT INTO identity_migration_denials (community_id, issuer, subject, reason) +SELECT community_id, issuer, uid, 'ambiguous legacy replacement lineage' +FROM invalid_principals +ON CONFLICT (community_id, issuer, subject) DO NOTHING; + +-- Principal quarantine alone is insufficient: a missing or ambiguous target +-- key must not be resurrected under a different principal in the same domain. +-- Retain a domain-key denial for every stored or referenced key implicated by +-- an invalid legacy graph. +CREATE TABLE identity_migration_denied_keys ( + community_id UUID NOT NULL REFERENCES communities(id), + pubkey BYTEA NOT NULL, + reason TEXT NOT NULL, + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, pubkey), + CHECK (length(pubkey) = 32), + CHECK (length(reason) > 0) +); + +INSERT INTO identity_migration_denied_keys (community_id, pubkey, reason) +SELECT DISTINCT binding.community_id, binding.pubkey, + 'key implicated by ambiguous legacy replacement lineage' +FROM identity_bindings binding +JOIN identity_migration_denials denial + ON denial.community_id = binding.community_id + AND denial.issuer = binding.issuer + AND denial.subject = binding.uid +UNION +SELECT DISTINCT binding.community_id, binding.rotated_to_pubkey, + 'key implicated by ambiguous legacy replacement lineage' +FROM identity_bindings binding +JOIN identity_migration_denials denial + ON denial.community_id = binding.community_id + AND denial.issuer = binding.issuer + AND denial.subject = binding.uid +WHERE binding.rotated_to_pubkey IS NOT NULL; + +-- A version belongs to one stable binding ID. Imported rows are snapshots of +-- distinct legacy binding IDs, so each starts at version 1; later transitions +-- increment only the binding ID whose authorization state changes. +UPDATE identity_bindings SET binding_version = 1; + +ALTER TABLE identity_bindings + ALTER COLUMN binding_version SET NOT NULL, + ALTER COLUMN binding_version SET DEFAULT 1, + ADD CONSTRAINT chk_identity_bindings_version_positive + CHECK (binding_version > 0); + +CREATE TABLE identity_binding_lineage ( + community_id UUID NOT NULL REFERENCES communities(id), + predecessor_binding_id UUID NOT NULL, + successor_binding_id UUID NOT NULL, + imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, predecessor_binding_id), + UNIQUE (community_id, successor_binding_id), + FOREIGN KEY (community_id, predecessor_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + FOREIGN KEY (community_id, successor_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (predecessor_binding_id <> successor_binding_id) +); + +WITH candidate_edges AS ( + SELECT + predecessor.community_id, + predecessor.issuer, + predecessor.uid, + predecessor.binding_id AS predecessor_id, + successor.binding_id AS successor_id, + COUNT(*) OVER ( + PARTITION BY predecessor.community_id, predecessor.binding_id + ) AS outgoing_candidates + FROM identity_bindings predecessor + JOIN identity_bindings successor + ON successor.community_id = predecessor.community_id + AND successor.issuer = predecessor.issuer + AND successor.uid = predecessor.uid + AND successor.pubkey = predecessor.rotated_to_pubkey + AND successor.binding_id <> predecessor.binding_id + AND successor.created_at >= predecessor.rotation_completed_at + WHERE predecessor.rotation_completed_at IS NOT NULL +) +INSERT INTO identity_binding_lineage + (community_id, predecessor_binding_id, successor_binding_id) +SELECT edge.community_id, edge.predecessor_id, edge.successor_id +FROM candidate_edges edge +WHERE edge.outgoing_candidates = 1 + AND NOT EXISTS ( + SELECT 1 FROM identity_migration_denials denial + WHERE denial.community_id = edge.community_id + AND denial.issuer = edge.issuer + AND denial.subject = edge.uid + ); + +UPDATE identity_bindings predecessor +SET replacement_binding_id = lineage.successor_binding_id +FROM identity_binding_lineage lineage +WHERE lineage.community_id = predecessor.community_id + AND lineage.predecessor_binding_id = predecessor.binding_id; + +-- A legacy row whose successor could not be proven is inactive but not a +-- completed rotation. Keep its legacy fields and quarantine intact while +-- projecting the truthful binding state as revoked. +UPDATE identity_bindings +SET binding_state = 'revoked' +WHERE binding_state = 'rotated' AND replacement_binding_id IS NULL; + +ALTER TABLE identity_bindings + ADD CONSTRAINT identity_bindings_replacement_fk + FOREIGN KEY (community_id, replacement_binding_id) + REFERENCES identity_bindings (community_id, binding_id) + DEFERRABLE INITIALLY DEFERRED, + ADD CONSTRAINT chk_identity_bindings_rotated_lineage + CHECK (binding_state <> 'rotated' OR replacement_binding_id IS NOT NULL); + +CREATE TABLE identity_retired_pairs ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + pubkey BYTEA NOT NULL, + retired_binding_id UUID, + retired_binding_version BIGINT, + retired_at TIMESTAMPTZ NOT NULL, + retired_by BYTEA, + reason TEXT NOT NULL, + PRIMARY KEY (community_id, issuer, subject, pubkey), + UNIQUE ( + community_id, issuer, subject, pubkey, + retired_binding_id, retired_binding_version + ), + FOREIGN KEY (community_id, retired_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (length(pubkey) = 32), + CHECK (retired_binding_version IS NULL OR retired_binding_version > 0), + CHECK ( + (retired_binding_id IS NULL AND retired_binding_version IS NULL) + OR + (retired_binding_id IS NOT NULL AND retired_binding_version IS NOT NULL) + ), + CHECK (retired_by IS NULL OR length(retired_by) = 32), + CHECK (length(reason) > 0) +); + +INSERT INTO identity_retired_pairs + (community_id, issuer, subject, pubkey, retired_binding_id, + retired_binding_version, retired_at, retired_by, reason) +SELECT + community_id, + issuer, + uid, + pubkey, + CASE WHEN COUNT(*) = 1 THEN (array_agg(binding_id))[1] END, + CASE WHEN COUNT(*) = 1 THEN (array_agg(binding_version))[1] END, + MIN(COALESCE(revoked_at, rotation_completed_at, updated_at)), + CASE WHEN COUNT(*) = 1 THEN (array_agg(COALESCE(rotation_by, revoked_by)))[1] END, + MIN(COALESCE(NULLIF(rotation_reason, ''), NULLIF(revoked_reason, ''), 'legacy pair retirement')) +FROM identity_bindings +WHERE revoked_at IS NOT NULL +GROUP BY community_id, issuer, uid, pubkey; + +-- Q is append-only history. cleared_at marks selector absence while retaining +-- the selector version so recreation of the same tuple cannot cause ABA. +CREATE TABLE identity_pending_replacements ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + selector_version BIGINT NOT NULL, + retired_pubkey BYTEA NOT NULL, + retired_binding_id UUID NOT NULL, + retired_binding_version BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_operation_id UUID, + cleared_at TIMESTAMPTZ, + cleared_operation_id UUID, + PRIMARY KEY (community_id, issuer, subject, selector_version), + FOREIGN KEY ( + community_id, issuer, subject, retired_pubkey, + retired_binding_id, retired_binding_version + ) REFERENCES identity_retired_pairs ( + community_id, issuer, subject, pubkey, + retired_binding_id, retired_binding_version + ), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (selector_version > 0), + CHECK (length(retired_pubkey) = 32), + CHECK (retired_binding_version > 0), + CHECK ( + (cleared_at IS NULL AND cleared_operation_id IS NULL) + OR + (cleared_at IS NOT NULL AND cleared_operation_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX idx_identity_pending_replacements_active + ON identity_pending_replacements (community_id, issuer, subject) + WHERE cleared_at IS NULL; + +INSERT INTO identity_pending_replacements + (community_id, issuer, subject, selector_version, retired_pubkey, + retired_binding_id, retired_binding_version) +SELECT + terminal.community_id, + terminal.issuer, + terminal.uid, + 1, + terminal.pubkey, + terminal.binding_id, + terminal.binding_version +FROM identity_bindings terminal +WHERE terminal.revoked_at IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM identity_bindings active + WHERE active.community_id = terminal.community_id + AND active.issuer = terminal.issuer + AND active.uid = terminal.uid + AND active.binding_state = 'active' + AND active.revoked_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM identity_binding_lineage lineage + WHERE lineage.community_id = terminal.community_id + AND lineage.predecessor_binding_id = terminal.binding_id + ) + AND NOT EXISTS ( + SELECT 1 FROM identity_migration_denials denial + WHERE denial.community_id = terminal.community_id + AND denial.issuer = terminal.issuer + AND denial.subject = terminal.uid + ); + +CREATE TABLE identity_binding_history ( + community_id UUID NOT NULL REFERENCES communities(id), + history_id UUID NOT NULL DEFAULT gen_random_uuid(), + binding_id UUID NOT NULL, + binding_version BIGINT NOT NULL, + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + pubkey BYTEA NOT NULL, + binding_state TEXT NOT NULL, + binding_provenance TEXT NOT NULL, + transition_kind TEXT NOT NULL, + replacement_binding_id UUID, + operation_id UUID, + actor BYTEA, + reason TEXT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, history_id), + UNIQUE (community_id, binding_id, binding_version, transition_kind), + FOREIGN KEY (community_id, binding_id) + REFERENCES identity_bindings (community_id, binding_id), + FOREIGN KEY (community_id, replacement_binding_id) + REFERENCES identity_bindings (community_id, binding_id) + DEFERRABLE INITIALLY DEFERRED, + CHECK (history_id <> '00000000-0000-0000-0000-000000000000'::UUID), + CHECK (binding_version > 0), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (length(pubkey) = 32), + CHECK (binding_state IN ('active', 'revoked', 'rotated', 'archived')), + CHECK (binding_provenance IN ('attested_key', 'provisioned', 'tofu')), + CHECK (transition_kind IN ( + 'legacy_import', 'enroll', 'provision', 'provenance_strengthened', + 'retire_pair', 'disable_identity', 'revoke_key', 'rotate', + 'recover', 'enable_identity', 'archive' + )), + CHECK (actor IS NULL OR length(actor) = 32), + CHECK (length(reason) > 0) +); + +CREATE INDEX idx_identity_binding_history_principal + ON identity_binding_history (community_id, issuer, subject, recorded_at); + +INSERT INTO identity_binding_history + (community_id, binding_id, binding_version, issuer, subject, pubkey, + binding_state, binding_provenance, transition_kind, + replacement_binding_id, actor, reason, recorded_at) +SELECT + community_id, + binding_id, + binding_version, + issuer, + uid, + pubkey, + binding_state, + binding_provenance, + 'legacy_import', + replacement_binding_id, + COALESCE(rotation_by, revoked_by), + COALESCE(NULLIF(rotation_reason, ''), NULLIF(revoked_reason, ''), 'legacy import'), + updated_at +FROM identity_bindings; + +-- Idempotency and local state history only. Authorization and complete +-- operator audit authority remain outside the binding persistence layer. +CREATE TABLE identity_lifecycle_operations ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + operation_kind TEXT NOT NULL, + request_fingerprint BYTEA NOT NULL, + issuer TEXT, + subject TEXT, + pubkey BYTEA, + replacement_pubkey BYTEA, + binding_id UUID, + replacement_binding_id UUID, + binding_version BIGINT, + replacement_binding_version BIGINT, + selector_version BIGINT, + actor BYTEA NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, operation_id), + FOREIGN KEY (community_id, binding_id) + REFERENCES identity_bindings (community_id, binding_id), + FOREIGN KEY (community_id, replacement_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::UUID), + CHECK (operation_kind IN ( + 'provision', 'retire_pair', 'disable_identity', 'revoke_key', + 'rotate', 'recover', 'enable_identity', 'archive' + )), + CHECK (length(request_fingerprint) = 32), + CHECK (issuer IS NULL OR length(issuer) > 0), + CHECK (subject IS NULL OR length(subject) > 0), + CHECK (pubkey IS NULL OR length(pubkey) = 32), + CHECK (replacement_pubkey IS NULL OR length(replacement_pubkey) = 32), + CHECK (binding_version IS NULL OR binding_version > 0), + CHECK (replacement_binding_version IS NULL OR replacement_binding_version > 0), + CHECK (selector_version IS NULL OR selector_version > 0), + CHECK (length(actor) = 32), + CHECK (length(reason) > 0) +); + +CREATE INDEX idx_identity_lifecycle_operations_principal + ON identity_lifecycle_operations (community_id, issuer, subject, created_at); + +CREATE INDEX idx_identity_lifecycle_operations_key + ON identity_lifecycle_operations (community_id, pubkey, created_at); diff --git a/schema/schema.sql b/schema/schema.sql index 9b18bc730e..11344d37b3 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -193,9 +193,43 @@ CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id) -- ── Relay-verified identity bindings ───────────────────────────────────────── -- Conformance: verified identity is community-scoped. An issuer-qualified uid -- is the stable product/user-management identity; a Nostr pubkey is the --- protocol credential currently bound to it. This table is intentionally a --- binding and lifecycle authority. Revocation scope distinguishes principal --- disablement, a single-key revocation, and an operator-authorized rotation. +-- protocol credential currently bound to it. The binding table below is the +-- lifecycle authority. Revocation scope distinguishes principal disablement, +-- single-key revocation, and operator-authorized rotation. +-- +-- Current verifier-owned enrollment policy. A fresh database intentionally +-- contains no row, so the disabled candidate cannot enroll until a separately +-- authorized server-configuration action installs one. +CREATE TABLE identity_enrollment_policies ( + community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), + policy_id UUID NOT NULL, + policy_epoch BIGINT NOT NULL, + requirement TEXT NOT NULL, + effective_from BIGINT NOT NULL, + effective_until BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (policy_id <> '00000000-0000-0000-0000-000000000000'::UUID), + CHECK (policy_epoch > 0), + CHECK (requirement IN ('not_required', 'attested_key', 'provisioned', 'tofu')), + CHECK (effective_from >= 0), + CHECK (effective_from < effective_until) +); + +CREATE FUNCTION enforce_identity_enrollment_policy_lineage() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.community_id <> OLD.community_id + OR NEW.policy_id <> OLD.policy_id + OR NEW.policy_epoch <= OLD.policy_epoch THEN + RAISE EXCEPTION 'identity enrollment policy lineage must advance monotonically'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER identity_enrollment_policy_lineage_guard +BEFORE UPDATE ON identity_enrollment_policies +FOR EACH ROW EXECUTE FUNCTION enforce_identity_enrollment_policy_lineage(); CREATE TABLE identity_bindings ( community_id UUID NOT NULL REFERENCES communities(id), @@ -216,6 +250,18 @@ CREATE TABLE identity_bindings ( rotated_to_pubkey BYTEA, rotation_by BYTEA, rotation_reason TEXT, + binding_id UUID NOT NULL DEFAULT gen_random_uuid(), + binding_version BIGINT NOT NULL DEFAULT 1, + binding_state TEXT NOT NULL DEFAULT 'active', + binding_provenance TEXT NOT NULL DEFAULT 'tofu', + replacement_binding_id UUID, + created_by BYTEA, + created_policy_version TEXT, + expires_at TIMESTAMPTZ, + creation_attribution_kind TEXT NOT NULL, + archived_at TIMESTAMPTZ, + archived_by BYTEA, + archived_reason TEXT, CONSTRAINT chk_identity_bindings_issuer_not_empty CHECK (length(issuer) > 0), CONSTRAINT chk_identity_bindings_uid_not_empty CHECK (length(uid) > 0), CONSTRAINT chk_identity_bindings_pubkey_len CHECK (length(pubkey) = 32), @@ -232,15 +278,64 @@ CREATE TABLE identity_bindings ( AND (rotation_by IS NULL OR length(rotation_by) = 32) AND rotation_reason IS NOT NULL AND length(rotation_reason) > 0) - ) + ), + CONSTRAINT identity_bindings_binding_id_unique UNIQUE (community_id, binding_id), + CONSTRAINT identity_bindings_replacement_fk + FOREIGN KEY (community_id, replacement_binding_id) + REFERENCES identity_bindings (community_id, binding_id) + DEFERRABLE INITIALLY DEFERRED, + CONSTRAINT chk_identity_bindings_id_not_nil + CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::UUID), + CONSTRAINT chk_identity_bindings_version_positive CHECK (binding_version > 0), + CONSTRAINT chk_identity_bindings_state + CHECK (binding_state IN ('active', 'revoked', 'rotated', 'archived')), + CONSTRAINT chk_identity_bindings_provenance + CHECK (binding_provenance IN ('attested_key', 'provisioned', 'tofu')), + CONSTRAINT chk_identity_bindings_created_by_len + CHECK (created_by IS NULL OR length(created_by) = 32), + CONSTRAINT chk_identity_bindings_policy_version + CHECK (created_policy_version IS NULL OR length(created_policy_version) > 0), + CONSTRAINT chk_identity_bindings_expiry + CHECK (expires_at IS NULL OR expires_at > TIMESTAMPTZ 'epoch'), + CONSTRAINT chk_identity_bindings_creation_attribution CHECK ( + (creation_attribution_kind = 'legacy_unknown' + AND created_by IS NULL AND created_policy_version IS NULL) + OR + (creation_attribution_kind IN ('authenticated_key', 'operator') + AND created_by IS NOT NULL AND length(created_by) = 32 + AND created_policy_version IS NOT NULL + AND length(created_policy_version) > 0) + ), + CONSTRAINT chk_identity_bindings_authority_state + CHECK ((binding_state = 'active') = (revoked_at IS NULL)), + CONSTRAINT chk_identity_bindings_archive_attribution CHECK ( + (binding_state <> 'archived' + AND archived_at IS NULL AND archived_by IS NULL AND archived_reason IS NULL) + OR + (binding_state = 'archived' + AND archived_at IS NOT NULL + AND archived_by IS NOT NULL AND length(archived_by) = 32 + AND archived_reason IS NOT NULL AND length(archived_reason) > 0) + ), + CONSTRAINT chk_identity_bindings_rotated_lineage + CHECK (binding_state <> 'rotated' OR replacement_binding_id IS NOT NULL) ); +-- Frozen 0027 compatibility indexes. The authority-state CHECK above makes +-- `revoked_at IS NULL` equivalent to `binding_state = 'active'`; authoritative +-- readers and the current indexes below still spell out both predicates. CREATE UNIQUE INDEX idx_identity_bindings_active_principal ON identity_bindings (community_id, issuer, uid) WHERE revoked_at IS NULL; CREATE UNIQUE INDEX idx_identity_bindings_active_pubkey ON identity_bindings (community_id, pubkey) WHERE revoked_at IS NULL; +CREATE UNIQUE INDEX idx_identity_bindings_authoritative_principal + ON identity_bindings (community_id, issuer, uid) + WHERE binding_state = 'active' AND revoked_at IS NULL; +CREATE UNIQUE INDEX idx_identity_bindings_authoritative_pubkey + ON identity_bindings (community_id, pubkey) + WHERE binding_state = 'active' AND revoked_at IS NULL; CREATE INDEX idx_identity_bindings_pubkey ON identity_bindings (community_id, pubkey); CREATE INDEX idx_identity_bindings_revoked_principal @@ -273,6 +368,195 @@ CREATE TABLE identity_revoked_keys ( CHECK (length(reason) > 0) ); +CREATE TABLE identity_migration_denials ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + reason TEXT NOT NULL, + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, issuer, subject), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (length(reason) > 0) +); + +CREATE TABLE identity_migration_denied_keys ( + community_id UUID NOT NULL REFERENCES communities(id), + pubkey BYTEA NOT NULL, + reason TEXT NOT NULL, + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, pubkey), + CHECK (length(pubkey) = 32), + CHECK (length(reason) > 0) +); + +CREATE TABLE identity_binding_lineage ( + community_id UUID NOT NULL REFERENCES communities(id), + predecessor_binding_id UUID NOT NULL, + successor_binding_id UUID NOT NULL, + imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, predecessor_binding_id), + UNIQUE (community_id, successor_binding_id), + FOREIGN KEY (community_id, predecessor_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + FOREIGN KEY (community_id, successor_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (predecessor_binding_id <> successor_binding_id) +); + +CREATE TABLE identity_retired_pairs ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + pubkey BYTEA NOT NULL, + retired_binding_id UUID, + retired_binding_version BIGINT, + retired_at TIMESTAMPTZ NOT NULL, + retired_by BYTEA, + reason TEXT NOT NULL, + PRIMARY KEY (community_id, issuer, subject, pubkey), + UNIQUE ( + community_id, issuer, subject, pubkey, + retired_binding_id, retired_binding_version + ), + FOREIGN KEY (community_id, retired_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (length(pubkey) = 32), + CHECK (retired_binding_version IS NULL OR retired_binding_version > 0), + CHECK ( + (retired_binding_id IS NULL AND retired_binding_version IS NULL) + OR + (retired_binding_id IS NOT NULL AND retired_binding_version IS NOT NULL) + ), + CHECK (retired_by IS NULL OR length(retired_by) = 32), + CHECK (length(reason) > 0) +); + +CREATE TABLE identity_pending_replacements ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + selector_version BIGINT NOT NULL, + retired_pubkey BYTEA NOT NULL, + retired_binding_id UUID NOT NULL, + retired_binding_version BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_operation_id UUID, + cleared_at TIMESTAMPTZ, + cleared_operation_id UUID, + PRIMARY KEY (community_id, issuer, subject, selector_version), + FOREIGN KEY ( + community_id, issuer, subject, retired_pubkey, + retired_binding_id, retired_binding_version + ) REFERENCES identity_retired_pairs ( + community_id, issuer, subject, pubkey, + retired_binding_id, retired_binding_version + ), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (selector_version > 0), + CHECK (length(retired_pubkey) = 32), + CHECK (retired_binding_version > 0), + CHECK ( + (cleared_at IS NULL AND cleared_operation_id IS NULL) + OR + (cleared_at IS NOT NULL AND cleared_operation_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX idx_identity_pending_replacements_active + ON identity_pending_replacements (community_id, issuer, subject) + WHERE cleared_at IS NULL; + +CREATE TABLE identity_binding_history ( + community_id UUID NOT NULL REFERENCES communities(id), + history_id UUID NOT NULL DEFAULT gen_random_uuid(), + binding_id UUID NOT NULL, + binding_version BIGINT NOT NULL, + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + pubkey BYTEA NOT NULL, + binding_state TEXT NOT NULL, + binding_provenance TEXT NOT NULL, + transition_kind TEXT NOT NULL, + replacement_binding_id UUID, + operation_id UUID, + actor BYTEA, + reason TEXT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, history_id), + UNIQUE (community_id, binding_id, binding_version, transition_kind), + FOREIGN KEY (community_id, binding_id) + REFERENCES identity_bindings (community_id, binding_id), + FOREIGN KEY (community_id, replacement_binding_id) + REFERENCES identity_bindings (community_id, binding_id) + DEFERRABLE INITIALLY DEFERRED, + CHECK (history_id <> '00000000-0000-0000-0000-000000000000'::UUID), + CHECK (binding_version > 0), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (length(pubkey) = 32), + CHECK (binding_state IN ('active', 'revoked', 'rotated', 'archived')), + CHECK (binding_provenance IN ('attested_key', 'provisioned', 'tofu')), + CHECK (transition_kind IN ( + 'legacy_import', 'enroll', 'provision', 'provenance_strengthened', + 'retire_pair', 'disable_identity', 'revoke_key', 'rotate', + 'recover', 'enable_identity', 'archive' + )), + CHECK (actor IS NULL OR length(actor) = 32), + CHECK (length(reason) > 0) +); + +CREATE INDEX idx_identity_binding_history_principal + ON identity_binding_history (community_id, issuer, subject, recorded_at); + +CREATE TABLE identity_lifecycle_operations ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + operation_kind TEXT NOT NULL, + request_fingerprint BYTEA NOT NULL, + issuer TEXT, + subject TEXT, + pubkey BYTEA, + replacement_pubkey BYTEA, + binding_id UUID, + replacement_binding_id UUID, + binding_version BIGINT, + replacement_binding_version BIGINT, + selector_version BIGINT, + actor BYTEA NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, operation_id), + FOREIGN KEY (community_id, binding_id) + REFERENCES identity_bindings (community_id, binding_id), + FOREIGN KEY (community_id, replacement_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::UUID), + CHECK (operation_kind IN ( + 'provision', 'retire_pair', 'disable_identity', 'revoke_key', + 'rotate', 'recover', 'enable_identity', 'archive' + )), + CHECK (length(request_fingerprint) = 32), + CHECK (issuer IS NULL OR length(issuer) > 0), + CHECK (subject IS NULL OR length(subject) > 0), + CHECK (pubkey IS NULL OR length(pubkey) = 32), + CHECK (replacement_pubkey IS NULL OR length(replacement_pubkey) = 32), + CHECK (binding_version IS NULL OR binding_version > 0), + CHECK (replacement_binding_version IS NULL OR replacement_binding_version > 0), + CHECK (selector_version IS NULL OR selector_version > 0), + CHECK (length(actor) = 32), + CHECK (length(reason) > 0) +); + +CREATE INDEX idx_identity_lifecycle_operations_principal + ON identity_lifecycle_operations (community_id, issuer, subject, created_at); + +CREATE INDEX idx_identity_lifecycle_operations_key + ON identity_lifecycle_operations (community_id, pubkey, created_at); + -- ── Events (partitioned by month on created_at) ────────────────────────────── -- Conformance: "Channel-less global events and DMs". `community_id` leads the -- PK and every hot-path index. Partition stays BY RANGE (created_at) — the