From bf2c6eb4ca5efb9c19a56f3671cee8a80e2f6e5b Mon Sep 17 00:00:00 2001 From: seiler Date: Tue, 6 Oct 2026 15:17:31 +0000 Subject: [PATCH] refactor(invites): remove obsolete admission wrappers Move policy evidence coverage to fenced invite admissions and document mixed-version ban retry behavior. Co-authored-by: Codex Signed-off-by: seiler --- ARCHITECTURE.md | 21 +-- crates/buzz-db/src/store/relay_invite.rs | 149 ++++++++++++++-------- crates/buzz-db/src/store/relay_members.rs | 89 ------------- crates/buzz-relay/src/api/invites.rs | 30 ++++- 4 files changed, 135 insertions(+), 154 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 516eaf3be1..ac918198f4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -773,14 +773,19 @@ check can make still-valid v1 links redeemable again; a rollback-capable version that can mint v1 also restarts the drain clock if it serves traffic. For the first rollout, pause invite claims and ban changes while relay versions -that do not participate in the community admission lock are serving. Apply the -migration and bring the updated lock-aware fleet up before resuming those -operations; mixed versions can bypass the ordering guarantee. The migration -also backfills v2 revocation from active bans and retained community/deployment -ban audit rows; an older ban that was lifted without any retained audit record -cannot be reconstructed from current state alone. Because legacy v1 tokens lack -issuer identity, the related security finding remains open until the drain is -complete or its scope is explicitly revised. +that do not participate in the community admission lock are serving. An older +claim racing a new ban can acquire locks in the inverse order and cause +PostgreSQL to abort either transaction, including the ban. Treat a failed ban +transaction as unapplied: observe the failure and retry the ban after the +conflicting transaction has ended. Keep those operations paused until rollout +is complete and every serving relay is lock-aware; only then resume them. Mixed +versions can bypass the ordering guarantee. The migration also backfills v2 +revocation from active bans and retained community/deployment ban audit rows; an +older ban that was lifted without any retained audit record cannot be +reconstructed from current state alone. Because legacy v1 tokens lack issuer +identity, the related security finding remains open until the natural drain +completes or an operator-selected cutoff is enforced by every serving and +rollback-capable version, or its scope is explicitly revised. **Constants:** diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index d17c5ef9b1..e0548b48bb 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -120,28 +120,6 @@ fn validate_mint_inputs(ttl_secs: u64, max_uses: Option) -> Result<()> { Ok(()) } -/// Mint a v2 invite: generate a 32-byte random secret, hash it, persist the -/// row, and return the plaintext code plus metadata. -/// -/// `ttl_secs` must be in the shared invite lifetime range. -/// `max_uses` must be `None` (unlimited) or `Some(1..=10000)`. -pub async fn mint_relay_invite( - pool: &PgPool, - community: CommunityId, - created_by: &str, - ttl_secs: u64, - max_uses: Option, -) -> Result { - match mint_relay_invite_with_owner(pool, community, created_by, None, ttl_secs, max_uses) - .await? - { - MintOutcome::Minted(invite) => Ok(invite), - MintOutcome::Restricted => Err(crate::error::DbError::AccessDenied( - "a banned principal cannot mint relay invites".into(), - )), - } -} - /// Mint a v2 invite while atomically checking the issuer and optional NIP-OA owner. /// /// `owner_pubkey` must be a previously verified owner credential from the @@ -690,22 +668,6 @@ pub async fn claim_legacy_relay_membership_with_owner( } impl Db { - /// Mints a v2 use-limited relay invite. The plaintext code is returned - /// exactly once; only its SHA-256 hash is persisted. - /// - /// `max_uses` is `None` for unlimited or `Some(1..=10000)`. - /// `ttl_secs` must be in the shared invite lifetime range. - #[datastore_span(name = "mint_relay_invite", system = "postgresql")] - pub async fn mint_relay_invite( - &self, - community: CommunityId, - created_by: &str, - ttl_secs: u64, - max_uses: Option, - ) -> Result { - mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await - } - /// Mints an invite after atomically checking the issuer and optional /// verified NIP-OA owner against current community bans. #[datastore_span(name = "mint_relay_invite_with_owner", system = "postgresql")] @@ -897,6 +859,22 @@ mod postgres_tests { format!("{:064x}", Uuid::new_v4().as_u128()) } + async fn mint_unrestricted_invite( + pool: &PgPool, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, + ) -> MintedInvite { + match mint_relay_invite_with_owner(pool, community, created_by, None, ttl_secs, max_uses) + .await + .expect("mint test invite") + { + MintOutcome::Minted(invite) => invite, + MintOutcome::Restricted => panic!("test issuer unexpectedly restricted"), + } + } + async fn use_count(pool: &PgPool, community: CommunityId, invite_id: Uuid) -> i32 { sqlx::query_scalar( "SELECT use_count FROM relay_invites WHERE community_id = $1 AND id = $2", @@ -993,7 +971,7 @@ mod postgres_tests { .await .expect("begin quiescing"); - let error = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let error = mint_relay_invite_with_owner(&pool, community, "owner", None, 3600, Some(1)) .await .expect_err("quiescing must reject invite minting"); assert!(matches!(error, crate::error::DbError::AccessDenied(_))); @@ -1018,7 +996,7 @@ mod postgres_tests { let community = make_test_community(&pool).await; let first = test_pubkey(); let second = test_pubkey(); - let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let invite = mint_unrestricted_invite(&pool, community, "owner", 3600, Some(1)) .await .expect("mint bounded invite"); let hash = hash_v2_code(&invite.code); @@ -1064,7 +1042,7 @@ mod postgres_tests { let community = make_test_community(&pool).await; let first = test_pubkey(); let second = test_pubkey(); - let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let invite = mint_unrestricted_invite(&pool, community, "owner", 3600, Some(1)) .await .expect("mint bounded invite"); let hash = hash_v2_code(&invite.code); @@ -1108,7 +1086,7 @@ mod postgres_tests { let pool = setup_pool().await; let community_a = make_test_community(&pool).await; let community_b = make_test_community(&pool).await; - let invite = mint_relay_invite(&pool, community_a, "owner", 3600, Some(2)) + let invite = mint_unrestricted_invite(&pool, community_a, "owner", 3600, Some(2)) .await .expect("mint invite"); let hash = hash_v2_code(&invite.code); @@ -1145,10 +1123,10 @@ mod postgres_tests { async fn retention_sweep_deletes_only_invites_older_than_cutoff() { let pool = setup_pool().await; let community = make_test_community(&pool).await; - let old = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let old = mint_unrestricted_invite(&pool, community, "owner", 3600, Some(1)) .await .expect("mint old invite"); - let recent = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let recent = mint_unrestricted_invite(&pool, community, "owner", 3600, Some(1)) .await .expect("mint recent invite"); let cutoff = Utc::now() - chrono::Duration::days(30); @@ -1250,7 +1228,7 @@ mod postgres_tests { async fn unlimited_invites_count_each_new_member() { let pool = setup_pool().await; let community = make_test_community(&pool).await; - let invite = mint_relay_invite(&pool, community, "owner", 3600, None) + let invite = mint_unrestricted_invite(&pool, community, "owner", 3600, None) .await .expect("mint unlimited invite"); let hash = hash_v2_code(&invite.code); @@ -1276,7 +1254,7 @@ mod postgres_tests { let pool = setup_pool().await; let community = make_test_community(&pool).await; let pubkey = test_pubkey(); - let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let invite = mint_unrestricted_invite(&pool, community, "owner", 3600, Some(1)) .await .expect("mint bounded invite"); let hash = hash_v2_code(&invite.code); @@ -1299,6 +1277,75 @@ mod postgres_tests { delete_test_community(&pool, community).await; } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn v2_and_legacy_claims_keep_policy_evidence_on_fenced_paths() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let policy_member = test_pubkey(); + let legacy_member = test_pubkey(); + let version = "a".repeat(64); + let invite = mint_unrestricted_invite(&pool, community, "owner", 3600, Some(1)).await; + let hash = hash_v2_code(&invite.code); + + assert_eq!( + claim_relay_invite_with_owner( + &pool, + community, + &hash, + &policy_member, + None, + Some(&version), + ) + .await + .expect("v2 invite claim"), + ClaimOutcome::Joined { + use_count: 1, + uses_remaining: Some(0), + } + ); + assert!(crate::relay_members::has_join_policy_acceptance( + &pool, + community, + &policy_member, + &version, + ) + .await + .expect("v2 policy acceptance lookup")); + assert!(is_relay_member(&pool, community, &policy_member) + .await + .expect("v2 member lookup")); + + let expires_at = chrono::Utc::now().timestamp().max(0) as u64 + 3600; + assert_eq!( + claim_legacy_relay_membership_with_owner( + &pool, + community, + &legacy_member, + None, + expires_at, + None, + None, + ) + .await + .expect("legacy invite claim"), + LegacyClaimOutcome::Joined + ); + assert!(!crate::relay_members::has_join_policy_acceptance( + &pool, + community, + &legacy_member, + &version, + ) + .await + .expect("legacy policy acceptance lookup")); + assert!(is_relay_member(&pool, community, &legacy_member) + .await + .expect("legacy member lookup")); + + delete_test_community(&pool, community).await; + } + #[tokio::test] #[ignore = "requires Postgres"] async fn bans_permanently_revoke_only_the_issuer_tenant_invites() { @@ -1309,10 +1356,10 @@ mod postgres_tests { let issuer_bytes = hex::decode(&issuer).expect("issuer hex"); let actor = [42; 32]; - let invite_a = mint_relay_invite(&pool, community_a, &issuer, 3600, Some(1)) + let invite_a = mint_unrestricted_invite(&pool, community_a, &issuer, 3600, Some(1)) .await .expect("mint tenant A invite"); - let invite_b = mint_relay_invite(&pool, community_b, &issuer, 3600, Some(1)) + let invite_b = mint_unrestricted_invite(&pool, community_b, &issuer, 3600, Some(1)) .await .expect("mint tenant B invite"); let hash_a = hash_v2_code(&invite_a.code); @@ -1357,7 +1404,7 @@ mod postgres_tests { "unban must not restore the old invitation" ); - let replacement = mint_relay_invite(&pool, community_a, &issuer, 3600, Some(1)) + let replacement = mint_unrestricted_invite(&pool, community_a, &issuer, 3600, Some(1)) .await .expect("mint replacement after unban"); assert!(matches!( @@ -1453,7 +1500,7 @@ mod postgres_tests { MintOutcome::Minted(invite) => invite, MintOutcome::Restricted => panic!("unrestricted agent should mint"), }; - let unrelated_invite = mint_relay_invite(&pool, community, &issuer, 3600, Some(1)) + let unrelated_invite = mint_unrestricted_invite(&pool, community, &issuer, 3600, Some(1)) .await .expect("mint unrelated invite"); let agent_hash = hash_v2_code(&agent_invite.code); @@ -1537,7 +1584,7 @@ mod postgres_tests { let issuer_bytes = hex::decode(&issuer).expect("issuer hex"); let actor = [44_u8; 32]; let claimer = test_pubkey(); - let invite = mint_relay_invite(&pool, community, &issuer, 3600, Some(1)) + let invite = mint_unrestricted_invite(&pool, community, &issuer, 3600, Some(1)) .await .expect("mint race invite"); let token_hash = hash_v2_code(&invite.code); diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index 5062d809cc..ebbe3fa986 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -165,50 +165,6 @@ pub async fn add_relay_member( Ok(result.rows_affected() > 0) } -/// Claims relay membership via an invite and atomically persists policy evidence. -/// -/// Returns `true` when membership was inserted, or `false` when the pubkey was -/// already a member. A configured `policy_version` is recorded in the same -/// transaction, so membership cannot be granted without its acceptance record. -pub async fn claim_relay_membership( - pool: &PgPool, - community: CommunityId, - pubkey: &str, - role: &str, - policy_version: Option<&str>, -) -> Result { - let connection = - observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; - let inserted = sqlx::query( - "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ - VALUES ($1, $2, $3, 'invite') \ - ON CONFLICT (community_id, pubkey) DO NOTHING", - ) - .bind(community.as_uuid()) - .bind(pubkey) - .bind(role) - .execute(&mut *tx) - .await? - .rows_affected() - > 0; - - if let Some(version) = policy_version { - sqlx::query( - "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ - VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", - ) - .bind(community.as_uuid()) - .bind(pubkey) - .bind(version) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - Ok(inserted) -} - /// Returns whether a member has persisted acceptance evidence for a policy version. pub async fn has_join_policy_acceptance( pool: &PgPool, @@ -930,19 +886,6 @@ impl Db { add_relay_member(&self.pool, community, pubkey, role, added_by).await } - /// Claims relay membership via an invite and atomically persists the - /// accepted policy version when a policy is configured. - #[datastore_span(name = "claim_relay_membership", system = "postgresql")] - pub async fn claim_relay_membership( - &self, - community: CommunityId, - pubkey: &str, - role: &str, - policy_version: Option<&str>, - ) -> Result { - claim_relay_membership(&self.pool, community, pubkey, role, policy_version).await - } - /// Returns whether a member has persisted acceptance evidence for a policy version. #[datastore_span(name = "has_join_policy_acceptance", system = "postgresql")] pub async fn has_join_policy_acceptance( @@ -1393,38 +1336,6 @@ mod postgres_tests { (community, owner) } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn invite_claim_persists_policy_version_and_legacy_claim_does_not() { - let pool = setup_pool().await; - let community = make_test_community(&pool).await; - let policy_member = test_pubkey(); - let legacy_member = test_pubkey(); - let version = "a".repeat(64); - - assert!( - claim_relay_membership(&pool, community, &policy_member, "member", Some(&version),) - .await - .expect("claim membership with policy") - ); - assert!( - has_join_policy_acceptance(&pool, community, &policy_member, &version) - .await - .expect("policy acceptance lookup") - ); - - assert!( - claim_relay_membership(&pool, community, &legacy_member, "member", None) - .await - .expect("legacy claim membership") - ); - assert!( - !has_join_policy_acceptance(&pool, community, &legacy_member, &version) - .await - .expect("legacy acceptance lookup") - ); - } - /// NIP-43 admission confinement: a pubkey admitted to community A is *not* /// admitted to community B. This is the exact mutation #1285 targets — a /// `WHERE pubkey = $1` membership check (no community predicate) would let an diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index b1f723a1f0..43f744b1a2 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -1961,10 +1961,22 @@ mod postgres_tests { .await .expect("create test community") .id; - let invite = normal_db - .mint_relay_invite(community, &issuer.public_key().to_hex(), 3600, Some(1)) + let invite = match normal_db + .mint_relay_invite_with_owner( + community, + &issuer.public_key().to_hex(), + None, + 3600, + Some(1), + ) .await - .expect("seed invite"); + .expect("seed invite") + { + buzz_db::relay_invite::MintOutcome::Minted(invite) => invite, + buzz_db::relay_invite::MintOutcome::Restricted => { + panic!("unrestricted issuer should mint") + } + }; let token_hash = buzz_core::invite::hash_v2_code(&invite.code); let state = invite_test_state_with_db(&host, failing_db.clone(), admin.clone()) .await @@ -2109,11 +2121,17 @@ mod postgres_tests { .await .expect("lookup") .expect("community exists"); - let invite = state + let invite = match state .db - .mint_relay_invite(community.id, "owner", 3600, Some(1)) + .mint_relay_invite_with_owner(community.id, "owner", None, 3600, Some(1)) .await - .expect("mint v2 invite"); + .expect("mint v2 invite") + { + buzz_db::relay_invite::MintOutcome::Minted(invite) => invite, + buzz_db::relay_invite::MintOutcome::Restricted => { + panic!("unrestricted issuer should mint") + } + }; let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) .unwrap_or_else(|_| TEST_DB_URL.to_string());