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 <noreply@openai.com>
Signed-off-by: seiler <seiler@squareup.com>
This commit is contained in:
seiler
2026-10-06 15:17:31 +00:00
committed by Local Builder
parent f755e49854
commit bf2c6eb4ca
4 changed files with 135 additions and 154 deletions

View File

@@ -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:**

View File

@@ -120,28 +120,6 @@ fn validate_mint_inputs(ttl_secs: u64, max_uses: Option<i32>) -> 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<i32>,
) -> Result<MintedInvite> {
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<i32>,
) -> Result<MintedInvite> {
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<i32>,
) -> 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);

View File

@@ -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<bool> {
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<bool> {
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

View File

@@ -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());