From 961315bc84994bff402a072520408a3f2f684815 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:55:18 -0500 Subject: [PATCH] fix(identity): enforce binding lifecycle invariants Fail closed across database and relay authority paths when identity state is ambiguous, stale, revoked, or unreadable. Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- Cargo.lock | 1 + crates/buzz-db/Cargo.toml | 1 + crates/buzz-db/src/channel.rs | 96 +- crates/buzz-db/src/identity_binding.rs | 2187 ++++++++++++++--- crates/buzz-db/src/identity_lifecycle.rs | 654 ++++- .../identity_lifecycle_deterministic_tests.rs | 411 +++- crates/buzz-db/src/lib.rs | 38 +- crates/buzz-db/src/migration.rs | 685 +++++- .../src/migration_deterministic_tests.rs | 1763 ++++++++++++- crates/buzz-db/src/relay_invite.rs | 35 +- crates/buzz-db/src/relay_members.rs | 17 +- crates/buzz-relay/src/api/invites.rs | 20 + crates/buzz-relay/src/audio/handler.rs | 3 + crates/buzz-relay/src/corporate_identity.rs | 64 +- crates/buzz-relay/src/mesh_boot.rs | 42 +- crates/git-sign-nostr/src/lib.rs | 43 +- .../0029_additive_identity_binding_state.sql | 242 +- schema/schema.sql | 112 +- 18 files changed, 5504 insertions(+), 910 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a23390675..d1b247469 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 6f76a11bc..38e512bb4 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 fbc842764..13fe05280 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 7e2cfa9b2..14d78c615 100644 --- a/crates/buzz-db/src/identity_binding.rs +++ b/crates/buzz-db/src/identity_binding.rs @@ -14,6 +14,16 @@ 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)] @@ -327,6 +337,23 @@ 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 { @@ -405,6 +432,8 @@ pub enum BindingState { Revoked, /// The binding was atomically replaced. Rotated, + /// The inactive binding was archived with explicit attribution. + Archived, } impl BindingState { @@ -413,6 +442,7 @@ impl BindingState { "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(), )), @@ -420,6 +450,47 @@ impl BindingState { } } +/// 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 @@ -430,14 +501,88 @@ impl fmt::Debug for BindingState { } /// Stable evidence returned by authoritative binding resolution. -#[derive(Clone, PartialEq, Eq)] +#[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 binding_id: Uuid, - /// Positive authorization-relevant binding version. - pub binding_version: u64, - /// Persisted binding provenance. - pub provenance: BindingProvenance, + 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 { @@ -446,7 +591,13 @@ impl fmt::Debug for BindingEvidence { .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() } } @@ -462,10 +613,16 @@ pub enum BindingDenial { 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, Clone, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub enum ResolveBindingResult { /// A new authoritative binding was atomically enrolled. Enrolled(BindingEvidence), @@ -476,20 +633,17 @@ pub enum ResolveBindingResult { } /// Typed input to ordinary binding resolution. -#[derive(Clone, Copy)] -pub struct ResolveBindingInput<'a> { - /// Exact validated issuer bytes represented as UTF-8. - pub issuer: &'a str, - /// Exact validated subject bytes represented as UTF-8. - pub subject: &'a str, - /// Proven 32-byte Nostr public key. - pub pubkey: &'a [u8], - /// Private display metadata. - pub display_name: Option<&'a str>, - /// Server-resolved enrollment mode. - pub enrollment_mode: EnrollmentMode, - /// Whether verified identity evidence attested `pubkey`. - pub key_attested: bool, +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<'_> { @@ -502,6 +656,9 @@ impl fmt::Debug for ResolveBindingInput<'_> { .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() } } @@ -523,6 +680,14 @@ pub struct IdentityBinding { 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. @@ -553,27 +718,15 @@ impl fmt::Debug for IdentityBinding { } } -/// Existing active binding that conflicts with a requested binding. -#[derive(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, -} +/// 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_struct("IdentityBindingConflict") - .field("issuer", &"[redacted]") - .field("uid", &"[redacted]") - .field("pubkey", &"[redacted]") - .field("source", &"[redacted]") + .debug_tuple("IdentityBindingConflict") + .field(&"[redacted]") .finish() } } @@ -589,6 +742,9 @@ 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. @@ -602,7 +758,8 @@ 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, } @@ -632,9 +789,9 @@ fn validate_inputs(issuer: &str, uid: &str, pubkey: &[u8], source: &str) -> Resu } 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(()) } @@ -673,6 +830,26 @@ fn row_to_binding(row: sqlx::postgres::PgRow) -> Result { "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")?, @@ -683,6 +860,10 @@ fn row_to_binding(row: sqlx::postgres::PgRow) -> Result { })?, 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")?, @@ -691,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, @@ -700,9 +904,12 @@ async fn active_by_principal_tx( let row = sqlx::query( r#" SELECT binding_id, issuer, uid, pubkey, binding_version, binding_state, - binding_provenance, display_name, source, created_at, updated_at, last_seen_at + 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 "#, ) @@ -722,9 +929,12 @@ async fn active_by_pubkey_tx( let row = sqlx::query( r#" SELECT binding_id, issuer, uid, pubkey, binding_version, binding_state, - binding_provenance, display_name, source, created_at, updated_at, last_seen_at + 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 "#, ) @@ -870,15 +1080,6 @@ async fn migration_key_denied_tx( .is_some()) } -fn conflict_from(binding: IdentityBinding) -> IdentityBindingConflict { - IdentityBindingConflict { - issuer: binding.issuer, - uid: binding.uid, - pubkey: binding.pubkey, - source: binding.source, - } -} - const IDENTITY_LOCK_ENCODING_VERSION: u8 = 1; fn identity_lock_coordinate(kind: u8, community_id: CommunityId, parts: &[&[u8]]) -> Vec { @@ -955,15 +1156,16 @@ async fn lock_identity_keys_tx( .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, @@ -990,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, @@ -1000,76 +1202,550 @@ pub(crate) async fn bind_or_validate_identity_tx( issuer, uid, pubkey, - display_name, + display_name: _, source, } = *identity; validate_inputs(issuer, uid, pubkey, source)?; - let (enrollment_mode, key_attested) = match source { - SOURCE_JWT_NPUB => (EnrollmentMode::AttestedKey, true), - SOURCE_DB_BINDING => (EnrollmentMode::Tofu, false), - _ => { - return Err(DbError::InvalidData( - "identity binding has invalid legacy source".to_string(), - )) - } - }; - let result = resolve_identity_binding_tx( - tx, - community_id, - &ResolveBindingInput { - issuer, - subject: uid, - pubkey, - display_name, - enrollment_mode, - key_attested, - }, - ) - .await?; - Ok(match result { - ResolveBindingResult::Enrolled(_) => BindIdentityResult::Created, - ResolveBindingResult::Existing(_) => BindIdentityResult::Matched, - ResolveBindingResult::Denied(BindingDenial::Conflict) => { - let conflict = active_by_principal_tx(tx, community_id, issuer, uid) - .await? - .or(active_by_pubkey_tx(tx, community_id, pubkey).await?) - .ok_or_else(|| { - DbError::InvalidData( - "identity binding conflict disappeared inside transaction".to_string(), - ) - })?; - BindIdentityResult::Conflict(conflict_from(conflict)) - } - ResolveBindingResult::Denied( - BindingDenial::Revoked - | BindingDenial::BindingRequired - | BindingDenial::KeyAttestationRequired, - ) => BindIdentityResult::Revoked, + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut **tx) + .await?; + lock_identity_keys_tx(tx, community_id, issuer, uid, pubkey).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); + } + + 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)); + } + 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)); + } + + 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(), + } + } +} + +#[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. -pub async fn resolve_identity_binding( +#[cfg(test)] +pub(crate) async fn resolve_identity_binding( pool: &PgPool, - community_id: CommunityId, input: &ResolveBindingInput<'_>, ) -> Result { let mut tx = pool.begin().await?; - let result = resolve_identity_binding_tx(&mut tx, community_id, input).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? @@ -1078,27 +1754,47 @@ async fn resolve_identity_binding_tx( || 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)); } - - let active_principal = - active_by_principal_tx(tx, community_id, input.issuer, input.subject).await?; if active_principal .as_ref() .is_some_and(|binding| binding.pubkey != input.pubkey) - { - return Ok(ResolveBindingResult::Denied(BindingDenial::Conflict)); - } - let active_key = active_by_pubkey_tx(tx, community_id, input.pubkey).await?; - if active_key - .as_ref() - .is_some_and(|binding| binding.issuer != input.issuer || binding.uid != input.subject) + || 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 @@ -1107,7 +1803,7 @@ async fn resolve_identity_binding_tx( .ok_or_else(|| { DbError::InvalidData("identity binding version exhausted".to_string()) })?; - sqlx::query( + let updated = sqlx::query( r#" UPDATE identity_bindings SET display_name=$5, @@ -1116,7 +1812,9 @@ async fn resolve_identity_binding_tx( 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 revoked_at IS NULL + 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()) @@ -1125,16 +1823,21 @@ async fn resolve_identity_binding_tx( .bind(input.pubkey) .bind(input.display_name) .bind(strengthen) + .bind(evidence_valid_from) + .bind(evidence_valid_until) .execute(&mut **tx) .await?; + 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, reason) + pubkey, binding_state, binding_provenance, transition_kind, actor, reason) VALUES ($1, $2, $3, $4, $5, $6, 'active', 'attested_key', - 'provenance_strengthened', 'verified key attestation') + 'provenance_strengthened', $6, 'verified key attestation') "#, ) .bind(community_id.as_uuid()) @@ -1148,15 +1851,16 @@ async fn resolve_identity_binding_tx( .execute(&mut **tx) .await?; } - return Ok(ResolveBindingResult::Existing(BindingEvidence { - binding_id: binding.binding_id, - binding_version: version, - provenance: if strengthen { + return Ok(ResolveBindingResult::Existing(evidence_from_binding( + community_id, + &binding, + version, + if strengthen { BindingProvenance::AttestedKey } else { binding.binding_provenance }, - })); + ))); } let provenance = match input.enrollment_mode { @@ -1172,27 +1876,17 @@ async fn resolve_identity_binding_tx( EnrollmentMode::Tofu if input.key_attested => BindingProvenance::AttestedKey, EnrollmentMode::Tofu => BindingProvenance::Tofu, }; - let next_version: i64 = sqlx::query_scalar( - "SELECT COALESCE(MAX(binding_version), 0) + 1 FROM identity_bindings \ - WHERE community_id=$1 AND issuer=$2 AND uid=$3", - ) - .bind(community_id.as_uuid()) - .bind(input.issuer) - .bind(input.subject) - .fetch_one(&mut **tx) - .await?; - if next_version <= 0 { - return Err(DbError::InvalidData( - "identity binding version exhausted".to_string(), - )); - } let binding_id = Uuid::new_v4(); - sqlx::query( + 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) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active', $9) + 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()) @@ -1202,21 +1896,27 @@ async fn resolve_identity_binding_tx( .bind(input.display_name) .bind(provenance.legacy_source()) .bind(binding_id) - .bind(next_version) .bind(provenance.as_str()) - .execute(&mut **tx) + .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_binding_history (community_id, binding_id, binding_version, issuer, subject, pubkey, - binding_state, binding_provenance, transition_kind, reason) - VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, 'enroll', 'first enrollment') + 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(binding_id) - .bind(next_version) + .bind(1_i64) .bind(input.issuer) .bind(input.subject) .bind(input.pubkey) @@ -1224,11 +1924,19 @@ async fn resolve_identity_binding_tx( .execute(&mut **tx) .await?; 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: u64::try_from(next_version).map_err(|_| { - DbError::InvalidData("identity binding version is out of range".to_string()) - })?, + 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, })) } @@ -1246,6 +1954,16 @@ pub async fn get_active_identity_binding_by_pubkey( 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? @@ -1272,16 +1990,17 @@ 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 { crate::identity_lifecycle::disable_identity_principal( pool, community_id, crate::identity_lifecycle::LifecycleContext { - operation_id: Uuid::new_v4(), + operation_id, actor: revoked_by, reason, }, @@ -1299,15 +2018,16 @@ 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 { crate::identity_lifecycle::revoke_identity_key( pool, community_id, crate::identity_lifecycle::LifecycleContext { - operation_id: Uuid::new_v4(), + operation_id, actor: revoked_by, reason, }, @@ -1322,68 +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)?; - let provenance = match source { - SOURCE_JWT_NPUB => BindingProvenance::AttestedKey, - SOURCE_DB_BINDING => BindingProvenance::Provisioned, - _ => { - return Err(DbError::InvalidData( - "identity rotation has invalid legacy source".to_string(), - )) - } - }; - let replacement = crate::identity_lifecycle::VerifiedReplacementKey::after_verified_proof( - new_pubkey, - display_name, - provenance, - None, - )?; + 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: Uuid::new_v4(), + operation_id, actor: rotated_by, reason, }; - if let Some(expected) = - crate::identity_lifecycle::get_pending_lineage(pool, community_id, principal).await? - { - if expected.retired_pubkey != old_pubkey { - return Err(DbError::InvalidData( - "identity rotation source does not match pending lineage".to_string(), - )); - } - crate::identity_lifecycle::recover_identity_binding( - pool, - community_id, - context, - principal, - &expected, - replacement, - ) - .await?; - } else { - crate::identity_lifecycle::rotate_identity_binding( - pool, - community_id, - context, - principal, - old_pubkey, - replacement, - ) - .await?; - } + crate::identity_lifecycle::rotate_identity_binding( + pool, + community_id, + context, + principal, + old_pubkey, + replacement, + ) + .await?; Ok(()) } @@ -1393,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"; @@ -1421,10 +2113,455 @@ 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)); @@ -1465,6 +2602,55 @@ mod tests { } } + #[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]; @@ -1487,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, @@ -1502,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("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")); + } + + #[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("match existing binding"); - assert_eq!(matched, BindIdentityResult::Matched); + .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 binding = get_active_identity_binding_by_pubkey(&pool, community, &pubkey) + 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] @@ -1536,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, @@ -1562,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) ); } @@ -1578,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, @@ -1604,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, @@ -1649,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] @@ -1659,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 "#, ) @@ -1728,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 @@ -1792,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( @@ -1824,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 @@ -1866,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(), @@ -1891,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 @@ -1920,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( @@ -1957,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()) @@ -2013,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 index f750ccd70..510546c8e 100644 --- a/crates/buzz-db/src/identity_lifecycle.rs +++ b/crates/buzz-db/src/identity_lifecycle.rs @@ -15,17 +15,48 @@ 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, - EnrollmentMode, + 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> { - /// Caller-supplied idempotency identifier. - pub operation_id: Uuid, - /// Authenticated actor key, when available. - pub actor: Option<&'a [u8]>, + /// 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, } @@ -60,22 +91,26 @@ impl fmt::Debug for IdentityPrincipal<'_> { } } -/// Replacement-key facts already verified by the caller. +/// 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: Option<&'a str>, + created_policy_version: &'a str, } impl<'a> VerifiedReplacementKey<'a> { /// Construct storage input after fresh replacement-key proof is verified. - pub fn after_verified_proof( + #[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: Option<&'a str>, + created_policy_version: &'a str, ) -> Result { validate_pubkey(pubkey)?; if provenance == BindingProvenance::Tofu { @@ -83,7 +118,7 @@ impl<'a> VerifiedReplacementKey<'a> { "privileged replacement cannot use TOFU provenance".to_string(), )); } - if created_policy_version.is_some_and(str::is_empty) { + if created_policy_version.is_empty() { return Err(DbError::InvalidData( "identity policy version must not be empty".to_string(), )); @@ -148,6 +183,8 @@ pub struct LifecycleReceipt { 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, } @@ -159,6 +196,7 @@ impl fmt::Debug for LifecycleReceipt { .field("binding_id", &"[redacted]") .field("replacement_binding_id", &"[redacted]") .field("binding_version", &"[redacted]") + .field("replacement_binding_version", &"[redacted]") .field("selector_version", &"[redacted]") .finish() } @@ -190,16 +228,15 @@ 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.is_nil() || context.reason.is_empty() { + 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(), )); } - if let Some(actor) = context.actor { - validate_pubkey(actor)?; - } + validate_pubkey(context.actor)?; Ok(()) } @@ -236,12 +273,12 @@ fn request_fingerprint( context: LifecycleContext<'_>, parts: &[&[u8]], ) -> Vec { - let actor = context.actor.unwrap_or_default(); + let operation_id = context.operation_id.as_uuid(); let mut all = vec![ kind.as_bytes(), community_id.as_uuid().as_bytes(), - context.operation_id.as_bytes(), - actor, + operation_id.as_bytes(), + context.actor, context.reason.as_bytes(), ]; all.extend_from_slice(parts); @@ -265,13 +302,12 @@ fn replacement_request_fingerprint( replacement: &VerifiedReplacementKey<'_>, ) -> Vec { let display_name = optional_fingerprint_bytes(replacement.display_name.map(str::as_bytes)); - let policy_version = - optional_fingerprint_bytes(replacement.created_policy_version.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); + all.push(policy_version); request_fingerprint(kind, community_id, context, &all) } @@ -306,7 +342,7 @@ fn lifecycle_coordinates( ) -> Vec> { let mut coordinates = vec![operation_lock_coordinate( community_id, - context.operation_id, + context.operation_id.as_uuid(), )]; if let Some(principal) = principal { coordinates.push(principal_lock_coordinate( @@ -399,7 +435,8 @@ async fn active_principal_tx( 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 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", ) .bind(community_id.as_uuid()) @@ -430,7 +467,8 @@ async fn active_key_tx( 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 revoked_at IS NULL \ + WHERE community_id=$1 AND pubkey=$2 \ + AND binding_state='active' AND revoked_at IS NULL \ FOR UPDATE", ) .bind(community_id.as_uuid()) @@ -569,23 +607,6 @@ pub async fn get_pending_lineage( Ok(pending) } -async fn next_binding_version_tx( - tx: &mut Transaction<'_, Postgres>, - community_id: CommunityId, - principal: IdentityPrincipal<'_>, -) -> Result { - let version: i64 = sqlx::query_scalar( - "SELECT COALESCE(MAX(binding_version), 0) + 1 FROM identity_bindings \ - WHERE community_id=$1 AND issuer=$2 AND uid=$3", - ) - .bind(community_id.as_uuid()) - .bind(principal.issuer) - .bind(principal.subject) - .fetch_one(&mut **tx) - .await?; - u64_version(version) -} - async fn replacement_eligible_tx( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, @@ -614,15 +635,16 @@ async fn insert_binding_tx( replacement: &VerifiedReplacementKey<'_>, transition_kind: &str, ) -> Result { - let version = next_binding_version_tx(tx, community_id, principal).await?; + let version = 1; let binding_id = Uuid::new_v4(); - sqlx::query( + 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) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active', $9, $10, $11) + 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()) @@ -636,7 +658,8 @@ async fn insert_binding_tx( .bind(replacement.provenance.as_str()) .bind(context.actor) .bind(replacement.created_policy_version) - .execute(&mut **tx) + .bind(CreationAttributionKind::Operator.as_str()) + .fetch_one(&mut **tx) .await?; append_history_tx( tx, @@ -653,9 +676,19 @@ async fn insert_binding_tx( ) .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, }) } @@ -692,7 +725,7 @@ async fn append_history_tx( .bind(provenance.as_str()) .bind(transition_kind) .bind(replacement_binding_id) - .bind(context.operation_id) + .bind(context.operation_id.as_uuid()) .bind(context.actor) .bind(context.reason) .execute(&mut **tx) @@ -763,7 +796,7 @@ async fn insert_pending_tx( .bind(&binding.pubkey) .bind(binding.binding_id) .bind(i64_version(retired_version)?) - .bind(context.operation_id) + .bind(context.operation_id.as_uuid()) .execute(&mut **tx) .await?; u64_version(selector_version) @@ -811,7 +844,7 @@ async fn retire_active_tx( 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 revoked_at IS NULL AND binding_version=$12 + AND binding_state='active' AND revoked_at IS NULL AND binding_version=$12 "#, ) .bind(community_id.as_uuid()) @@ -866,11 +899,13 @@ async fn retire_active_tx( 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()?, }) } @@ -885,14 +920,15 @@ async fn existing_operation_tx( let row = sqlx::query( r#" SELECT operation_kind, request_fingerprint, binding_id, - replacement_binding_id, binding_version, selector_version + 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) + .bind(context.operation_id.as_uuid()) .fetch_optional(&mut **tx) .await?; let Some(row) = row else { @@ -928,13 +964,13 @@ async fn record_operation_tx( 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, selector_version, - actor, reason) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + 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) + .bind(context.operation_id.as_uuid()) .bind(completion.kind) .bind(completion.fingerprint) .bind(completion.principal.map(|value| value.issuer)) @@ -950,6 +986,13 @@ async fn record_operation_tx( .map(i64_version) .transpose()?, ) + .bind( + completion + .receipt + .replacement_binding_version + .map(i64_version) + .transpose()?, + ) .bind( completion .receipt @@ -1041,6 +1084,7 @@ pub async fn provision_identity_binding( binding_id: Some(binding.binding_id), replacement_binding_id: None, binding_version: Some(binding.binding_version), + replacement_binding_version: None, selector_version: None, }; finish( @@ -1112,6 +1156,7 @@ pub async fn retire_identity_pair( binding_id: Some(active.binding_id), replacement_binding_id: None, binding_version: Some(version), + replacement_binding_version: None, selector_version, }; finish( @@ -1202,6 +1247,7 @@ pub async fn disable_identity_principal( binding_id, replacement_binding_id: None, binding_version: version, + replacement_binding_version: None, selector_version, }; finish( @@ -1293,6 +1339,7 @@ pub async fn revoke_identity_key( binding_id, replacement_binding_id: None, binding_version: version, + replacement_binding_version: None, selector_version, }; let outcome = finish( @@ -1374,7 +1421,7 @@ async fn verify_key_revocation_committed( ) .bind(community_id.as_uuid()) .bind(pubkey) - .bind(context.operation_id) + .bind(context.operation_id.as_uuid()) .fetch_one(pool) .await?; if selector_and_operation != (true, true) { @@ -1487,14 +1534,14 @@ pub async fn rotate_identity_binding( }, ) .await?; - let new_version = next_binding_version_tx(&mut tx, community_id, principal).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) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'active',$9,$10,$11) + 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()) @@ -1508,6 +1555,7 @@ pub async fn rotate_identity_binding( .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( @@ -1536,10 +1584,10 @@ pub async fn rotate_identity_binding( let receipt = LifecycleReceipt { binding_id: Some(active.binding_id), replacement_binding_id: Some(replacement_binding_id), - binding_version: Some(new_version), + binding_version: Some(old_version), + replacement_binding_version: Some(new_version), selector_version: None, }; - let _ = old_version; finish( tx, community_id, @@ -1580,7 +1628,7 @@ async fn compare_and_clear_pending_tx( .bind(expected.retired_binding_id) .bind(i64_version(expected.retired_binding_version)?) .bind(i64_version(expected.selector_version)?) - .bind(context.operation_id) + .bind(context.operation_id.as_uuid()) .execute(&mut **tx) .await?; if changed.rows_affected() != 1 { @@ -1703,7 +1751,8 @@ pub async fn recover_identity_binding( let receipt = LifecycleReceipt { binding_id: Some(expected.retired_binding_id), replacement_binding_id: Some(binding.binding_id), - binding_version: Some(binding.binding_version), + binding_version: Some(expected.retired_binding_version), + replacement_binding_version: Some(binding.binding_version), selector_version: Some(expected.selector_version), }; finish( @@ -1847,12 +1896,22 @@ pub async fn enable_identity_principal( ) .await?; } - let receipt = LifecycleReceipt { - binding_id: Some(binding.binding_id), - replacement_binding_id: None, - binding_version: Some(binding.binding_version), - selector_version: expected.map(|value| value.selector_version), - }; + 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, @@ -1869,6 +1928,112 @@ pub async fn enable_identity_principal( .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; @@ -1917,14 +2082,17 @@ mod tests { ) -> BindingEvidence { match resolve_identity_binding( pool, - community_id, &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 @@ -1936,9 +2104,10 @@ mod tests { } fn context<'a>(reason: &'a str) -> LifecycleContext<'a> { + const ACTOR: [u8; 32] = [0xA3; 32]; LifecycleContext { - operation_id: Uuid::new_v4(), - actor: None, + operation_id: LifecycleOperationId::issue(), + actor: &ACTOR, reason, } } @@ -1948,7 +2117,7 @@ mod tests { pubkey, None, BindingProvenance::AttestedKey, - Some("test-policy-v1"), + "test-policy-v1", ) .expect("verified replacement") } @@ -1959,11 +2128,200 @@ mod tests { &[1_u8; 32], None, BindingProvenance::Tofu, - None, + "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() { @@ -1975,7 +2333,7 @@ mod tests { }; let retired_key = [1_u8; 32]; let replacement_key = [2_u8; 32]; - enroll(&pool, community_id, principal.subject, &retired_key).await; + let retired_evidence = enroll(&pool, community_id, principal.subject, &retired_key).await; revoke_identity_key( &pool, community_id, @@ -2015,7 +2373,7 @@ mod tests { ); } - recover_identity_binding( + let recovered = recover_identity_binding( &pool, community_id, context("exact recovery"), @@ -2025,6 +2383,18 @@ mod tests { ) .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") @@ -2042,7 +2412,7 @@ mod tests { }; let retired_key = [4_u8; 32]; let replacement_key = [5_u8; 32]; - enroll(&pool, community_id, principal.subject, &retired_key).await; + let retired_evidence = enroll(&pool, community_id, principal.subject, &retired_key).await; disable_identity_principal( &pool, community_id, @@ -2067,26 +2437,55 @@ mod tests { ) .await .is_err()); - enable_identity_principal( + let enable_request = context("exact enable"); + let applied = enable_identity_principal( &pool, community_id, - context("exact enable"), + 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, - community_id, &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 @@ -2108,15 +2507,15 @@ mod tests { let key = [60_u8 + variant; 32]; let operation_id = Uuid::new_v4(); let request = LifecycleContext { - operation_id, - actor: None, + 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, - Some("policy-v1"), + "policy-v1", ) .expect("construct original replacement"); provision_identity_binding( @@ -2134,13 +2533,13 @@ mod tests { &key, Some("changed display"), BindingProvenance::AttestedKey, - Some("policy-v1"), + "policy-v1", ), _ => VerifiedReplacementKey::after_verified_proof( &key, None, BindingProvenance::Provisioned, - Some("policy-v2"), + "policy-v2", ), } .expect("construct changed replacement"); @@ -2174,27 +2573,40 @@ mod tests { }; let old_key = [64_u8; 32]; let new_key = [65_u8; 32]; - enroll(&pool, community_id, principal.subject, &old_key).await; + let old_evidence = enroll(&pool, community_id, principal.subject, &old_key).await; let request = LifecycleContext { - operation_id: Uuid::new_v4(), - actor: None, + operation_id: LifecycleOperationId::issue(), + actor: &old_key, reason: "fingerprint rotation request", }; let original = VerifiedReplacementKey::after_verified_proof( &new_key, None, BindingProvenance::Provisioned, - Some("policy-v1"), + "policy-v1", ) .expect("construct provisioned rotation"); - rotate_identity_binding(&pool, community_id, request, principal, &old_key, original) - .await - .expect("apply original 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, - Some("policy-v1"), + "policy-v1", ) .expect("construct changed rotation provenance"); assert!(rotate_identity_binding( @@ -2241,8 +2653,8 @@ mod tests { .expect("fingerprint pending selector"); let operation_id = Uuid::new_v4(); let request = LifecycleContext { - operation_id, - actor: None, + operation_id: LifecycleOperationId::from_uuid_for_test(operation_id), + actor: &old_key, reason: "fingerprint enable request", }; enable_identity_principal( @@ -2317,7 +2729,7 @@ mod tests { 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 binding_version", + WHERE community_id=$1 AND issuer=$2 AND uid=$3 ORDER BY pubkey", ) .bind(community_id.as_uuid()) .bind(principal.issuer) @@ -2326,7 +2738,7 @@ mod tests { .await .expect("read rotation history"); assert_eq!(versions.len(), keys.len()); - let expected_versions = [2_i64, 4, 6, 7]; + 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]); @@ -2373,14 +2785,17 @@ mod tests { assert_eq!( resolve_identity_binding( &pool, - community_id, &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 @@ -2410,14 +2825,17 @@ mod tests { enroll_gate.wait().await; resolve_identity_binding( &enroll_pool, - community_id, &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 @@ -2450,7 +2868,7 @@ mod tests { 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 revoked_at IS 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) @@ -2476,14 +2894,17 @@ mod tests { task_gate.wait().await; resolve_identity_binding( &task_pool, - community_id, &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 @@ -2501,7 +2922,8 @@ mod tests { 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 revoked_at IS NULL", + 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) @@ -2592,7 +3014,7 @@ mod tests { 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 revoked_at IS NULL), \ + (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)", ) @@ -2667,7 +3089,7 @@ mod tests { 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 revoked_at IS 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()) @@ -2735,8 +3157,8 @@ mod tests { "SELECT \ EXISTS(SELECT 1 FROM identity_bindings binding \ JOIN identity_revoked_keys revoked USING (community_id,pubkey) \ - WHERE binding.community_id=$1 AND binding.revoked_at IS NULL), \ - EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 AND pubkey=$2 AND revoked_at IS NULL), \ + 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()) @@ -2801,17 +3223,17 @@ mod tests { .expect("key revocation commits"); let state: (i64, bool, bool) = sqlx::query_as( "SELECT \ - (SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1 AND revoked_at IS NULL), \ + (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.revoked_at IS NULL), \ + 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.revoked_at IS NULL)", + WHERE binding.community_id=$1 AND binding.binding_state='active' AND binding.revoked_at IS NULL)", ) .bind(community_id.as_uuid()) .fetch_one(&pool) @@ -2829,17 +3251,17 @@ mod tests { let community_id = make_community(&pool).await; let key = [41_u8; 32]; enroll(&pool, community_id, "rollback-subject", &key).await; - let reason = "o3 lifecycle failure injection"; + let reason = "identity lifecycle failure injection"; sqlx::query( - "CREATE OR REPLACE FUNCTION o3_fail_history() RETURNS trigger LANGUAGE plpgsql AS $$ \ - BEGIN IF NEW.reason = 'o3 lifecycle failure injection' THEN RAISE EXCEPTION 'injected history failure'; END IF; RETURN NEW; END $$", + "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 o3_fail_history_trigger BEFORE INSERT ON identity_binding_history \ - FOR EACH ROW EXECUTE FUNCTION o3_fail_history()", + "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 @@ -2850,7 +3272,7 @@ mod tests { .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 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), \ 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)", @@ -2861,11 +3283,11 @@ mod tests { .await .expect("read rollback state"); - sqlx::query("DROP TRIGGER o3_fail_history_trigger ON identity_binding_history") + sqlx::query("DROP TRIGGER identity_test_fail_history_trigger ON identity_binding_history") .execute(&pool) .await .expect("drop failure trigger"); - sqlx::query("DROP FUNCTION o3_fail_history()") + sqlx::query("DROP FUNCTION identity_test_fail_history()") .execute(&pool) .await .expect("drop failure function"); diff --git a/crates/buzz-db/src/identity_lifecycle_deterministic_tests.rs b/crates/buzz-db/src/identity_lifecycle_deterministic_tests.rs index fad602b84..4bfc3032a 100644 --- a/crates/buzz-db/src/identity_lifecycle_deterministic_tests.rs +++ b/crates/buzz-db/src/identity_lifecycle_deterministic_tests.rs @@ -27,6 +27,7 @@ enum Action { Disable, Revoke, Enable, + Archive, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -42,6 +43,7 @@ struct Fixture { community_id: CommunityId, expected_pending: Option, enrollment_key: [u8; 32], + archive_binding_id: Option, } async fn setup_pool() -> PgPool { @@ -76,9 +78,10 @@ fn principal() -> IdentityPrincipal<'static> { } fn context(operation_id: Uuid, reason: &'static str) -> LifecycleContext<'static> { + const ACTOR: [u8; 32] = [0xA1; 32]; LifecycleContext { - operation_id, - actor: None, + operation_id: LifecycleOperationId::from_uuid_for_test(operation_id), + actor: &ACTOR, reason, } } @@ -88,27 +91,37 @@ fn replacement(pubkey: &'static [u8; 32]) -> VerifiedReplacementKey<'static> { pubkey, None, BindingProvenance::AttestedKey, - Some("deterministic-policy-v1"), + "deterministic-policy-v1", ) .expect("construct deterministic replacement") } -async fn enroll_key(pool: &PgPool, community_id: CommunityId, pubkey: &[u8]) { +async fn enroll_key( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], +) -> crate::identity_binding::BindingEvidence { let result = resolve_identity_binding( pool, - community_id, &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"); - assert!(matches!(result, ResolveBindingResult::Enrolled(_))); + match result { + ResolveBindingResult::Enrolled(evidence) => evidence, + other => panic!("expected deterministic enrollment, got {other:?}"), + } } fn pair_contains(pair: (Action, Action), action: Action) -> bool { @@ -119,8 +132,24 @@ async fn setup_fixture(pool: &PgPool, pair: (Action, Action), label: &str) -> Fi 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::Enroll) && pair_contains(pair, Action::Rotate) { + 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; @@ -171,6 +200,7 @@ async fn setup_fixture(pool: &PgPool, pair: (Action, Action), label: &str) -> Fi community_id, expected_pending, enrollment_key, + archive_binding_id, } } @@ -183,14 +213,17 @@ async fn run_action( match action { Action::Enroll => match resolve_identity_binding( pool, - fixture.community_id, &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 @@ -201,7 +234,9 @@ async fn run_action( BindingDenial::Conflict | BindingDenial::Revoked | BindingDenial::BindingRequired - | BindingDenial::KeyAttestationRequired, + | BindingDenial::KeyAttestationRequired + | BindingDenial::BindingExpired + | BindingDenial::StaleEvidence, )) => Outcome::Denied, Err(_) => Outcome::Error, }, @@ -265,6 +300,19 @@ async fn run_action( ) .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) + } } } @@ -287,7 +335,7 @@ async fn normalized_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']::text[]) FROM identity_bindings b WHERE community_id=$1", + "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", @@ -664,71 +712,308 @@ async fn enrollment_rotate_and_recover_have_forced_orders_and_references() { } } +#[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 tx = pool.begin().await.expect("begin ABA transaction"); - sqlx::query( - "UPDATE identity_pending_replacements SET cleared_at=NOW(), cleared_operation_id=$4 \ - 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) - .bind(Uuid::from_u128(200)) - .execute(&mut *tx) - .await - .expect("clear G1"); - 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(fixture.community_id.as_uuid()) - .bind(ISSUER) - .bind(SUBJECT) - .bind(i64::try_from(expected.selector_version + 1).expect("selector fits i64")) - .bind(&expected.retired_pubkey) - .bind(expected.retired_binding_id) - .bind(i64::try_from(expected.retired_binding_version).expect("version fits i64")) - .bind(Uuid::from_u128(201)) - .execute(&mut *tx) - .await - .expect("recreate G2"); - assert!(compare_and_clear_pending_tx( - &mut tx, - fixture.community_id, - context(Uuid::from_u128(202), "stale ABA compare"), - principal(), - &expected, - ) - .await - .is_err()); - let active_selector: i64 = sqlx::query_scalar( - "SELECT selector_version 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(&mut *tx) - .await - .expect("G2 remains active"); - assert_eq!( - active_selector, - i64::try_from(expected.selector_version + 1).unwrap() - ); - tx.rollback() + 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("rollback synthetic ABA recreation"); + .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 d2b212498..590590a34 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -643,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 @@ -2597,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, @@ -2617,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. @@ -2630,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 65b37327b..ab2be54c1 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -959,15 +959,19 @@ mod tests { .as_str() .contains("CREATE TABLE identity_revoked_keys")); - // O3 is a brownfield-safe additive projection over the frozen 0027/0028 - // identity tables. It must not rewrite or discard legacy authority. + // Migration 0029 is a brownfield-safe additive projection over the + // frozen 0027/0028 identity tables. It must not rewrite or discard + // legacy authority. assert_eq!(migrations[28].version, 29); - let o3 = migrations[28].sql.as_str(); + let projection = migrations[28].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", @@ -975,13 +979,13 @@ mod tests { "CREATE TABLE identity_migration_denials", ] { assert!( - o3.contains(required), + projection.contains(required), "migration 0029 is missing {required}" ); } } - fn o3_executable_sql(sql: &str) -> String { + 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; @@ -1019,7 +1023,7 @@ mod tests { .iter() .find(|migration| migration.version == 29) .expect("migration 0029"); - let executable = o3_executable_sql(migration.sql.as_str()); + let executable = additive_identity_executable_sql(migration.sql.as_str()); let normalized = normalize_sql(&executable); for forbidden in [" rename ", " drop ", " truncate ", " delete "] { @@ -1038,6 +1042,7 @@ mod tests { "binding_state", "binding_provenance", "replacement_binding_id", + "creation_attribution_kind", ]; for statement in split_sql_statements(&executable) { let statement = normalize_sql(&statement); @@ -1061,6 +1066,51 @@ mod tests { } } + #[test] + fn migration_0029_enforces_authoritative_binding_state_and_attribution() { + let migration = MIGRATOR + .iter() + .find(|migration| migration.version == 29) + .expect("migration 0029"); + // 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 0029 must represent archived bindings explicitly" + ); + assert!( + normalized.contains("creation_attribution_kind"), + "migration 0029 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] fn migration_lint_detects_tables_missing_community_id_by_default() { let sql = r#" @@ -1260,7 +1310,8 @@ mod tests { "SELECT to_jsonb(binding) - ARRAY[\ 'binding_id', 'binding_version', 'binding_state',\ 'binding_provenance', 'replacement_binding_id', 'created_by',\ - 'created_policy_version']::text[] \ + 'created_policy_version', 'expires_at', 'creation_attribution_kind',\ + 'archived_at', 'archived_by', 'archived_reason']::text[] \ FROM identity_bindings binding", ) .await; @@ -1635,7 +1686,7 @@ mod tests { 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.binding_version", + ORDER BY binding.pubkey", ) .bind(domain_a) .fetch_all(&pool) @@ -1655,14 +1706,14 @@ mod tests { chain[1], ( chain_keys[1].clone(), - 2, + 1, Some(chain_keys[2].clone()), - "provisioned".to_owned() + "tofu".to_owned() ) ); assert_eq!( chain[2], - (chain_keys[2].clone(), 3, None, "provisioned".to_owned()) + (chain_keys[2].clone(), 1, None, "tofu".to_owned()) ); let pending: (Vec, i64, i64) = sqlx::query_as( @@ -1714,15 +1765,15 @@ mod tests { &clean_replacement_key, None, crate::identity_binding::BindingProvenance::Provisioned, - Some("migration-test-policy"), + "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: uuid::Uuid::new_v4(), - actor: None, + operation_id: crate::identity_lifecycle::LifecycleOperationId::issue(), + actor: &missing_target, reason: "migrated key quarantine must not be laundered", }, crate::identity_lifecycle::IdentityPrincipal { @@ -1751,15 +1802,15 @@ mod tests { &tombstone_rotation_key, None, crate::identity_binding::BindingProvenance::Provisioned, - Some("migration-test-policy"), + "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: uuid::Uuid::new_v4(), - actor: None, + operation_id: crate::identity_lifecycle::LifecycleOperationId::issue(), + actor: &active_tombstoned_key, reason: "legacy key tombstone must not be laundered", }, crate::identity_lifecycle::IdentityPrincipal { @@ -1790,14 +1841,17 @@ mod tests { ] { let result = crate::identity_binding::resolve_identity_binding( &pool, - domain_a_id, &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 @@ -1814,15 +1868,15 @@ mod tests { &missing_target, None, crate::identity_binding::BindingProvenance::Provisioned, - Some("migration-test-policy"), + "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: uuid::Uuid::new_v4(), - actor: None, + operation_id: crate::identity_lifecycle::LifecycleOperationId::issue(), + actor: &missing_target, reason: "migrated ambiguity must block lifecycle", }, crate::identity_lifecycle::IdentityPrincipal { @@ -1848,14 +1902,17 @@ mod tests { let domain_b_result = crate::identity_binding::resolve_identity_binding( &pool, - buzz_core::CommunityId::from_uuid(domain_b), &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 @@ -1866,6 +1923,302 @@ mod tests { )); } + 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_0029_imports_arbitrary_histories_and_quarantines_temporal_inversion() { @@ -1876,66 +2229,73 @@ mod tests { .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-0029-history-{}.example", domain.simple())) - .execute(&pool) - .await - .expect("insert history community"); - - for length in [1_usize, 2, 3, 8, 32] { - let subject = format!("history-{length}"); - let keys = (0..length) - .map(|index| { - let mut key = vec![0_u8; 32]; - key[0] = length as u8; - key[1] = index as u8; - key[31] = 0xa5; - key - }) - .collect::>(); - // Deliberately reverse insertion order. Equal timestamps model the - // transaction-stable NOW() values emitted by the legacy helper. - for index in (0..length).rev() { - if index + 1 == length { - sqlx::query( - "INSERT INTO identity_bindings \ - (community_id,issuer,uid,pubkey,source,created_at,updated_at,last_seen_at) \ - VALUES ($1,'https://idp.example',$2,$3,'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(&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,'https://idp.example',$2,$3,'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',$4,'legacy rotation')", - ) - .bind(domain) - .bind(&subject) - .bind(&keys[index]) - .bind(&keys[index + 1]) - .execute(&pool) - .await - .expect("insert retired history node"); - } - } + 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-0029-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( @@ -1973,50 +2333,129 @@ mod tests { run_migrations(&pool) .await .expect("import arbitrary valid histories without aborting"); - for length in [1_usize, 2, 3, 8, 32] { - let subject = format!("history-{length}"); - let rows: Vec<(i64, String)> = sqlx::query_as( - "SELECT binding_version,binding_provenance FROM identity_bindings \ - WHERE community_id=$1 AND issuer='https://idp.example' AND uid=$2 \ - ORDER BY binding_version", - ) - .bind(domain) - .bind(&subject) - .fetch_all(&pool) - .await - .expect("read imported history"); - assert_eq!(rows.len(), length); - for (index, (version, provenance)) in rows.iter().enumerate() { - assert_eq!(*version, (index + 1) as i64); - assert_eq!(provenance, if index == 0 { "tofu" } else { "provisioned" }); - } - let 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.issuer='https://idp.example' AND binding.uid=$2", - ) - .bind(domain) - .bind(&subject) - .fetch_one(&pool) - .await - .expect("count imported lineage"); - assert_eq!(edges, length.saturating_sub(1) as i64); - } - 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)", + // 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, ) - .bind(domain) - .bind(&older_target) - .fetch_one(&pool) - .await - .expect("read temporal inversion quarantine"); - assert_eq!(inversion_denied, (true, true)); + .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] diff --git a/crates/buzz-db/src/migration_deterministic_tests.rs b/crates/buzz-db/src/migration_deterministic_tests.rs index e92078ed6..c990f39dc 100644 --- a/crates/buzz-db/src/migration_deterministic_tests.rs +++ b/crates/buzz-db/src/migration_deterministic_tests.rs @@ -7,24 +7,29 @@ use crate::identity_binding::{ 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, PendingLineage, VerifiedReplacementKey, + 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"; -async fn connect_pool() -> PgPool { - let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") +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()); - PgPool::connect(&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_to_0028(pool: &PgPool) -> (Uuid, Uuid) { +async fn reset_empty_to_0028(pool: &PgPool, label: &str) -> (Uuid, Uuid) { sqlx::query("DROP SCHEMA IF EXISTS public CASCADE") .execute(pool) .await @@ -39,27 +44,37 @@ async fn reset_to_0028(pool: &PgPool) -> (Uuid, Uuid) { .expect("migrate through 0028"); let domain_a = Uuid::new_v4(); let domain_b = Uuid::new_v4(); - for (domain, label, key) in [ - (domain_a, "migration-fault-a", vec![71_u8; 32]), - (domain_b, "migration-fault-b", vec![72_u8; 32]), - ] { + 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}-{}.example", domain.simple())) + .bind(format!("{label}-{suffix}-{}.example", domain.simple())) .execute(pool) .await - .expect("insert migration fault domain"); - sqlx::query( - "INSERT INTO identity_bindings (community_id,issuer,uid,pubkey,source) \ - VALUES ($1,'https://idp.example',$2,$3,'db_binding')", - ) - .bind(domain) - .bind(format!("{label}-subject")) - .bind(key) - .execute(pool) - .await - .expect("insert migration fault binding"); + .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_0028(pool: &PgPool) -> (Uuid, Uuid) { + let (domain_a, domain_b) = reset_empty_to_0028(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) } @@ -69,6 +84,7 @@ fn split_statements(sql: &str) -> Vec { 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 { @@ -78,13 +94,17 @@ fn split_statements(sql: &str) -> Vec { index += 1; continue; } - if !single_quote && index + 1 < bytes.len() && &bytes[index..index + 2] == b"--" { + if !single_quote + && !dollar_quote + && index + 1 < bytes.len() + && &bytes[index..index + 2] == b"--" + { line_comment = true; index += 2; continue; } match bytes[index] { - b'\'' => { + b'\'' if !dollar_quote => { if single_quote && index + 1 < bytes.len() && bytes[index + 1] == b'\'' { index += 2; continue; @@ -92,7 +112,11 @@ fn split_statements(sql: &str) -> Vec { single_quote = !single_quote; index += 1; } - b';' if !single_quote => { + 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()); @@ -165,9 +189,84 @@ async fn legacy_snapshot(pool: &PgPool) -> Vec { 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_0029_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(28, &pool) + .await + .expect("apply migrations through 0028"); + 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 0029 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", @@ -198,10 +297,11 @@ async fn full_identity_snapshot(pool: &PgPool) -> Vec { snapshot } -async fn raw_domain_authorized(pool: &PgPool, domain: Uuid, key: &[u8]) -> bool { +pub(super) async fn raw_domain_authorized(pool: &PgPool, domain: Uuid, key: &[u8]) -> bool { sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM identity_bindings \ - WHERE community_id=$1 AND pubkey=$2 AND revoked_at IS NULL)", + "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) @@ -210,7 +310,7 @@ async fn raw_domain_authorized(pool: &PgPool, domain: Uuid, key: &[u8]) -> bool .expect("read raw legacy authorization sentinel") } -async fn domain_audit_snapshot(pool: &PgPool, domain: Uuid) -> Vec { +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", @@ -221,11 +321,12 @@ async fn domain_audit_snapshot(pool: &PgPool, domain: Uuid) -> Vec { .expect("read domain audit sentinel") } -async fn legacy_identity_facts(pool: &PgPool, domain: Uuid) -> Vec { +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']::text[] \ + '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) @@ -256,9 +357,43 @@ async fn legacy_identity_facts(pool: &PgPool, domain: Uuid) -> Vec { facts } -async fn domain_identity_snapshot(pool: &PgPool, domain: Uuid) -> Vec { +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", @@ -285,6 +420,92 @@ async fn domain_identity_snapshot(pool: &PgPool, domain: Uuid) -> Vec { 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=29 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, @@ -323,9 +544,10 @@ async fn insert_revoked_legacy_row(pool: &PgPool, domain: Uuid, subject: &str, k } fn lifecycle_context(id: u128, reason: &'static str) -> LifecycleContext<'static> { + const ACTOR: [u8; 32] = [0xA2; 32]; LifecycleContext { - operation_id: Uuid::from_u128(id), - actor: None, + operation_id: LifecycleOperationId::from_uuid_for_test(Uuid::from_u128(id)), + actor: &ACTOR, reason, } } @@ -338,18 +560,1295 @@ fn replacement( key, None, provenance, - Some("migration-denial-policy-v1"), + "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_0029_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_0028(&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(29, &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=29 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(29, &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_0029_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_0028(&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(29, &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(29, &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(29, &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_0029_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_0028(&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 0029"); + 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=29 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=29 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(29, &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_0029_fault_at_every_statement_boundary_restarts_and_retries() { let statements = split_statements(migration_0029().sql.as_ref()); assert_eq!( statements.len(), - 28, + 34, "0029 boundary count is part of the oracle adapter" ); @@ -551,24 +2050,192 @@ async fn identity_0029_backend_loss_restarts_cleanly_and_retry_converges() { #[tokio::test] #[ignore = "requires a dedicated disposable Postgres database"] -async fn identity_0029_post_commit_response_loss_is_idempotent_after_restart() { +async fn identity_0029_real_post_commit_response_loss_is_idempotent_after_restart() { + const GATE_CLASS: i32 = 2_147_400_029; + const GATE_OBJECT: i32 = 29; let pool = connect_pool().await; - reset_to_0028(&pool).await; - MIGRATOR - .run_to(29, &pool) + let (_domain_a, domain_b) = reset_to_0028(&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_0029(); + let statements = split_statements(migration.sql.as_ref()); + + let mut gate = pool + .acquire() .await - .expect("commit 0029 before response loss"); + .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=29 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 synthetic_response: Result<(), &str> = Err("response lost after confirmed commit"); - assert!(synthetic_response.is_err()); + 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(29, &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); } @@ -715,14 +2382,17 @@ async fn identity_0029_readable_ambiguities_preserve_facts_and_never_create_auth ] { let result = resolve_identity_binding( &pool, - domain_a_id, &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 @@ -732,14 +2402,17 @@ async fn identity_0029_readable_ambiguities_preserve_facts_and_never_create_auth let domain_b_cross = resolve_identity_binding( &pool, - domain_b_id, &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 diff --git a/crates/buzz-db/src/relay_invite.rs b/crates/buzz-db/src/relay_invite.rs index 33c0cda66..7189a2381 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 @@ -662,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(); @@ -702,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, @@ -712,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 afb31bc89..affddd817 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 83ecb2fcc..c8cd2b112 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 7bfd6d4b2..c47e8df37 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 e9e45504c..ee4ba4515 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -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); @@ -1194,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); } @@ -1208,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", }; @@ -1303,6 +1306,11 @@ mod tests { 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, @@ -2120,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 20e550aa0..2ad3ce5fa 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 d31671120..9da8f70c2 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/0029_additive_identity_binding_state.sql b/migrations/0029_additive_identity_binding_state.sql index ac382f715..928d14759 100644 --- a/migrations/0029_additive_identity_binding_state.sql +++ b/migrations/0029_additive_identity_binding_state.sql @@ -1,4 +1,4 @@ --- Additive O3 identity-binding projection. +-- 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 @@ -6,6 +6,57 @@ -- 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, @@ -13,7 +64,12 @@ ALTER TABLE identity_bindings ADD COLUMN binding_provenance TEXT, ADD COLUMN replacement_binding_id UUID, ADD COLUMN created_by BYTEA, - ADD COLUMN created_policy_version TEXT; + 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 @@ -77,7 +133,8 @@ SET binding_state = CASE binding_provenance = CASE source WHEN 'jwt_npub' THEN 'attested_key' ELSE 'tofu' - END; + END, + creation_attribution_kind = 'legacy_unknown'; ALTER TABLE identity_bindings ALTER COLUMN binding_id SET NOT NULL, @@ -86,21 +143,56 @@ ALTER TABLE identity_bindings ALTER COLUMN binding_state SET DEFAULT 'active', ALTER COLUMN binding_provenance SET NOT NULL, ALTER COLUMN binding_provenance SET DEFAULT 'tofu', - ADD CONSTRAINT identity_bindings_o3_id_unique + 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_o3_id_not_nil + ADD CONSTRAINT chk_identity_bindings_id_not_nil CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::UUID), - ADD CONSTRAINT chk_identity_bindings_o3_state - CHECK (binding_state IN ('active', 'revoked', 'rotated')), - ADD CONSTRAINT chk_identity_bindings_o3_provenance + 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_o3_created_by_len + ADD CONSTRAINT chk_identity_bindings_created_by_len CHECK (created_by IS NULL OR length(created_by) = 32), - ADD CONSTRAINT chk_identity_bindings_o3_policy_version - CHECK (created_policy_version IS NULL OR length(created_policy_version) > 0); + 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. O3 exposes no operation that clears these migration denials. +-- 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, @@ -266,92 +358,16 @@ JOIN identity_migration_denials denial AND denial.subject = binding.uid WHERE binding.rotated_to_pubkey IS NOT NULL; --- Topological versions are deterministic for valid histories. Quarantined --- rows receive stable positive versions solely for attribution, never auth. -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 -), -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 - ) -), -walk AS ( - SELECT root.community_id, root.issuer, root.uid, root.binding_id, 1::BIGINT AS binding_version - FROM roots root - WHERE NOT EXISTS ( - SELECT 1 FROM identity_migration_denials denial - WHERE denial.community_id = root.community_id - AND denial.issuer = root.issuer - AND denial.subject = root.uid - ) - UNION ALL - SELECT edge.community_id, edge.issuer, edge.uid, edge.successor_id, walk.binding_version + 1 - FROM walk - JOIN resolved_edges edge - ON edge.community_id = walk.community_id - AND edge.predecessor_id = walk.binding_id -), -quarantined AS ( - SELECT - binding.community_id, - binding.binding_id, - ROW_NUMBER() OVER ( - PARTITION BY binding.community_id, binding.issuer, binding.uid - ORDER BY binding.created_at, binding.updated_at, - encode(binding.pubkey, 'hex'), binding.binding_id - )::BIGINT AS binding_version - 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 -), -versions AS ( - SELECT community_id, binding_id, binding_version FROM walk - UNION ALL - SELECT community_id, binding_id, binding_version FROM quarantined -) -UPDATE identity_bindings binding -SET binding_version = versions.binding_version -FROM versions -WHERE binding.community_id = versions.community_id - AND binding.binding_id = versions.binding_id; +-- 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_o3_version_positive - CHECK (binding_version > 0), - ADD CONSTRAINT identity_bindings_o3_principal_version_unique - UNIQUE (community_id, issuer, uid, binding_version); + ADD CONSTRAINT chk_identity_bindings_version_positive + CHECK (binding_version > 0); CREATE TABLE identity_binding_lineage ( community_id UUID NOT NULL REFERENCES communities(id), @@ -399,27 +415,26 @@ WHERE edge.outgoing_candidates = 1 AND denial.subject = edge.uid ); --- The legacy rotation helper admits `db_binding` only after privileged --- replacement proof. Roots remain TOFU; unique imported successors retain the --- stronger provisioned provenance implied by that helper. -UPDATE identity_bindings successor -SET binding_provenance = 'provisioned' -FROM identity_binding_lineage lineage -WHERE lineage.community_id = successor.community_id - AND lineage.successor_binding_id = successor.binding_id - AND successor.source = 'db_binding'; - 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_o3_replacement_fk + ADD CONSTRAINT identity_bindings_replacement_fk FOREIGN KEY (community_id, replacement_binding_id) REFERENCES identity_bindings (community_id, binding_id) - DEFERRABLE INITIALLY DEFERRED; + 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), @@ -524,6 +539,7 @@ WHERE terminal.revoked_at IS NOT NULL 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 ( @@ -566,12 +582,12 @@ CREATE TABLE identity_binding_history ( CHECK (length(issuer) > 0), CHECK (length(subject) > 0), CHECK (length(pubkey) = 32), - CHECK (binding_state IN ('active', 'revoked', 'rotated')), + 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' + 'recover', 'enable_identity', 'archive' )), CHECK (actor IS NULL OR length(actor) = 32), CHECK (length(reason) > 0) @@ -601,7 +617,7 @@ SELECT FROM identity_bindings; -- Idempotency and local state history only. Authorization and complete --- operator audit authority remain outside O3. +-- 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, @@ -614,8 +630,9 @@ CREATE TABLE identity_lifecycle_operations ( binding_id UUID, replacement_binding_id UUID, binding_version BIGINT, + replacement_binding_version BIGINT, selector_version BIGINT, - actor BYTEA, + actor BYTEA NOT NULL, reason TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (community_id, operation_id), @@ -626,7 +643,7 @@ CREATE TABLE identity_lifecycle_operations ( CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::UUID), CHECK (operation_kind IN ( 'provision', 'retire_pair', 'disable_identity', 'revoke_key', - 'rotate', 'recover', 'enable_identity' + 'rotate', 'recover', 'enable_identity', 'archive' )), CHECK (length(request_fingerprint) = 32), CHECK (issuer IS NULL OR length(issuer) > 0), @@ -634,8 +651,9 @@ CREATE TABLE identity_lifecycle_operations ( 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 (actor IS NULL OR length(actor) = 32), + CHECK (length(actor) = 32), CHECK (length(reason) > 0) ); diff --git a/schema/schema.sql b/schema/schema.sql index 7537df165..9f228b727 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -187,9 +187,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), @@ -217,6 +251,11 @@ CREATE TABLE identity_bindings ( 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), @@ -234,32 +273,63 @@ CREATE TABLE identity_bindings ( AND rotation_reason IS NOT NULL AND length(rotation_reason) > 0) ), - CONSTRAINT identity_bindings_o3_id_unique UNIQUE (community_id, binding_id), - CONSTRAINT identity_bindings_o3_principal_version_unique - UNIQUE (community_id, issuer, uid, binding_version), - CONSTRAINT identity_bindings_o3_replacement_fk + 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_o3_id_not_nil + CONSTRAINT chk_identity_bindings_id_not_nil CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::UUID), - CONSTRAINT chk_identity_bindings_o3_version_positive CHECK (binding_version > 0), - CONSTRAINT chk_identity_bindings_o3_state - CHECK (binding_state IN ('active', 'revoked', 'rotated')), - CONSTRAINT chk_identity_bindings_o3_provenance + 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_o3_created_by_len + CONSTRAINT chk_identity_bindings_created_by_len CHECK (created_by IS NULL OR length(created_by) = 32), - CONSTRAINT chk_identity_bindings_o3_policy_version - CHECK (created_policy_version IS NULL OR length(created_policy_version) > 0) + 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 @@ -422,12 +492,12 @@ CREATE TABLE identity_binding_history ( CHECK (length(issuer) > 0), CHECK (length(subject) > 0), CHECK (length(pubkey) = 32), - CHECK (binding_state IN ('active', 'revoked', 'rotated')), + 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' + 'recover', 'enable_identity', 'archive' )), CHECK (actor IS NULL OR length(actor) = 32), CHECK (length(reason) > 0) @@ -448,8 +518,9 @@ CREATE TABLE identity_lifecycle_operations ( binding_id UUID, replacement_binding_id UUID, binding_version BIGINT, + replacement_binding_version BIGINT, selector_version BIGINT, - actor BYTEA, + actor BYTEA NOT NULL, reason TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (community_id, operation_id), @@ -460,7 +531,7 @@ CREATE TABLE identity_lifecycle_operations ( CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::UUID), CHECK (operation_kind IN ( 'provision', 'retire_pair', 'disable_identity', 'revoke_key', - 'rotate', 'recover', 'enable_identity' + 'rotate', 'recover', 'enable_identity', 'archive' )), CHECK (length(request_fingerprint) = 32), CHECK (issuer IS NULL OR length(issuer) > 0), @@ -468,8 +539,9 @@ CREATE TABLE identity_lifecycle_operations ( 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 (actor IS NULL OR length(actor) = 32), + CHECK (length(actor) = 32), CHECK (length(reason) > 0) );