mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
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:
@@ -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:**
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user