From 031b84e255480bd842a9b7b1b69d97d8cd3c7ee4 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:37:00 -0400 Subject: [PATCH] fix(db): scope archived identities by community Archived identity state is tenant-local; a pubkey archived in one community must not read as archived in another. Thread CommunityId through the archived identity queries and DB wrappers, and bind the composite key used by the migration. Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- crates/buzz-db/src/archived_identities.rs | 155 ++++++++++++++++++---- crates/buzz-db/src/lib.rs | 25 ++-- schema/schema.sql | 6 +- 3 files changed, 151 insertions(+), 35 deletions(-) diff --git a/crates/buzz-db/src/archived_identities.rs b/crates/buzz-db/src/archived_identities.rs index 00237ccf84..728f8bd0a9 100644 --- a/crates/buzz-db/src/archived_identities.rs +++ b/crates/buzz-db/src/archived_identities.rs @@ -1,10 +1,11 @@ -//! Relay-scoped archived identity persistence (NIP-IA). +//! Community-scoped archived identity persistence (NIP-IA). //! -//! The `archived_identities` table stores a relay-local UI visibility hint for +//! The `archived_identities` table stores a community-local UI visibility hint for //! identity pubkeys. Archiving is not a ban: it does not affect membership, //! relay access, or repository permissions. //! All pubkey and event ID values are lowercase hex strings. +use buzz_core::CommunityId; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; @@ -29,21 +30,25 @@ pub struct ArchivedIdentity { pub archived_at: DateTime, } -/// Returns `true` if `pubkey` (64-char hex) is currently archived. -pub async fn is_archived(pool: &PgPool, pubkey: &str) -> Result { - let row = sqlx::query("SELECT 1 FROM archived_identities WHERE pubkey = $1") - .bind(pubkey) - .fetch_optional(pool) - .await?; +/// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. +pub async fn is_archived(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + let row = + sqlx::query("SELECT 1 FROM archived_identities WHERE community_id = $1 AND pubkey = $2") + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(pool) + .await?; Ok(row.is_some()) } -/// Archives an identity. +/// Archives an identity in `community_id`. /// /// Returns `true` if the row was inserted, `false` if the identity was already -/// archived. Re-archiving is idempotent and does not mutate the existing row. +/// archived in that community. Re-archiving is idempotent and does not mutate +/// the existing row. pub async fn archive( pool: &PgPool, + community_id: CommunityId, pubkey: &str, consent_path: &str, actor: &str, @@ -53,10 +58,11 @@ pub async fn archive( ) -> Result { let result = sqlx::query( "INSERT INTO archived_identities \ - (pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ - VALUES ($1, $2, $3, $4, $5, $6) \ - ON CONFLICT (pubkey) DO NOTHING", + (community_id, pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + ON CONFLICT (community_id, pubkey) DO NOTHING", ) + .bind(community_id.as_uuid()) .bind(pubkey) .bind(consent_path) .bind(actor) @@ -69,24 +75,31 @@ pub async fn archive( Ok(result.rows_affected() > 0) } -/// Unarchives an identity. +/// Unarchives an identity from `community_id`. /// -/// Returns `true` if a row was deleted, `false` if the identity was not archived. -pub async fn unarchive(pool: &PgPool, pubkey: &str) -> Result { - let result = sqlx::query("DELETE FROM archived_identities WHERE pubkey = $1") - .bind(pubkey) - .execute(pool) - .await?; +/// Returns `true` if a row was deleted, `false` if the identity was not archived +/// in that community. +pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + let result = + sqlx::query("DELETE FROM archived_identities WHERE community_id = $1 AND pubkey = $2") + .bind(community_id.as_uuid()) + .bind(pubkey) + .execute(pool) + .await?; Ok(result.rows_affected() > 0) } -/// Returns all archived identities ordered by archive time ascending. -pub async fn list_archived(pool: &PgPool) -> Result> { +/// Returns all identities archived in `community_id`, ordered by archive time ascending. +pub async fn list_archived( + pool: &PgPool, + community_id: CommunityId, +) -> Result> { let rows = sqlx::query( "SELECT pubkey, consent_path, actor, reason, replaced_by, request_event_id, archived_at \ - FROM archived_identities ORDER BY archived_at ASC", + FROM archived_identities WHERE community_id = $1 ORDER BY archived_at ASC", ) + .bind(community_id.as_uuid()) .fetch_all(pool) .await?; @@ -109,3 +122,99 @@ fn row_to_archived_identity( archived_at: row.try_get("archived_at")?, }) } + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + PgPool::connect(TEST_DB_URL) + .await + .expect("connect to test DB") + } + + async fn make_community(pool: &PgPool) -> CommunityId { + let id = uuid::Uuid::new_v4(); + let host = format!("archive-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn archived_identity_state_is_community_scoped() { + let pool = setup_pool().await; + let community_a = make_community(&pool).await; + let community_b = make_community(&pool).await; + let pubkey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let actor = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let event_a = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let event_b = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + + assert!(archive( + &pool, + community_a, + pubkey, + "self", + actor, + Some("community A"), + None, + event_a, + ) + .await + .expect("archive in community A")); + + assert!(is_archived(&pool, community_a, pubkey) + .await + .expect("is_archived in A")); + assert!(!is_archived(&pool, community_b, pubkey) + .await + .expect("is_archived in B")); + assert_eq!( + list_archived(&pool, community_a) + .await + .expect("list A") + .len(), + 1 + ); + assert!(list_archived(&pool, community_b) + .await + .expect("list B") + .is_empty()); + assert!(!unarchive(&pool, community_b, pubkey) + .await + .expect("unarchive absent B")); + assert!(is_archived(&pool, community_a, pubkey) + .await + .expect("B unarchive must not affect A")); + + assert!(archive( + &pool, + community_b, + pubkey, + "self", + actor, + Some("community B"), + None, + event_b, + ) + .await + .expect("archive same pubkey in community B")); + assert!(unarchive(&pool, community_a, pubkey) + .await + .expect("unarchive A")); + assert!(!is_archived(&pool, community_a, pubkey) + .await + .expect("A removed")); + assert!(is_archived(&pool, community_b, pubkey) + .await + .expect("A unarchive must not affect B")); + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index efd96f49d8..d5325bb5f7 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1748,14 +1748,15 @@ impl Db { relay_members::backfill_from_allowlist(&self.pool).await } - /// Returns `true` if `pubkey` (64-char hex) is currently archived. - pub async fn is_archived(&self, pubkey: &str) -> Result { - archived_identities::is_archived(&self.pool, pubkey).await + /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. + pub async fn is_archived(&self, community_id: CommunityId, pubkey: &str) -> Result { + archived_identities::is_archived(&self.pool, community_id, pubkey).await } - /// Archives an identity. Returns `true` if inserted, `false` if already archived. + /// Archives an identity in `community_id`. Returns `true` if inserted, `false` if already archived. pub async fn archive( &self, + community_id: CommunityId, pubkey: &str, consent_path: &str, actor: &str, @@ -1765,6 +1766,7 @@ impl Db { ) -> Result { archived_identities::archive( &self.pool, + community_id, pubkey, consent_path, actor, @@ -1775,14 +1777,17 @@ impl Db { .await } - /// Unarchives an identity. Returns `true` if deleted, `false` if absent. - pub async fn unarchive(&self, pubkey: &str) -> Result { - archived_identities::unarchive(&self.pool, pubkey).await + /// Unarchives an identity from `community_id`. Returns `true` if deleted, `false` if absent. + pub async fn unarchive(&self, community_id: CommunityId, pubkey: &str) -> Result { + archived_identities::unarchive(&self.pool, community_id, pubkey).await } - /// Returns all archived identities ordered by archive time ascending. - pub async fn list_archived(&self) -> Result> { - archived_identities::list_archived(&self.pool).await + /// Returns all identities archived in `community_id`, ordered by archive time ascending. + pub async fn list_archived( + &self, + community_id: CommunityId, + ) -> Result> { + archived_identities::list_archived(&self.pool, community_id).await } /// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey. diff --git a/schema/schema.sql b/schema/schema.sql index 2a839adf6b..cc30730117 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -351,11 +351,13 @@ CREATE INDEX IF NOT EXISTS idx_relay_members_role ON relay_members(role); -- ── Archived identities (NIP-IA) ────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS archived_identities ( - pubkey TEXT PRIMARY KEY, + community_id UUID NOT NULL REFERENCES communities(id), + pubkey TEXT NOT NULL, consent_path TEXT NOT NULL CHECK (consent_path IN ('self', 'owner', 'admin')), actor TEXT NOT NULL, reason TEXT, replaced_by TEXT, request_event_id TEXT NOT NULL, - archived_at TIMESTAMPTZ NOT NULL DEFAULT now() + archived_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, pubkey) );