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>
This commit is contained in:
tlongwell-block
2026-06-26 20:37:00 -04:00
parent ec22cdb117
commit 031b84e255
3 changed files with 151 additions and 35 deletions

View File

@@ -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<Utc>,
}
/// Returns `true` if `pubkey` (64-char hex) is currently archived.
pub async fn is_archived(pool: &PgPool, pubkey: &str) -> Result<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<Vec<ArchivedIdentity>> {
/// Returns all identities archived in `community_id`, ordered by archive time ascending.
pub async fn list_archived(
pool: &PgPool,
community_id: CommunityId,
) -> Result<Vec<ArchivedIdentity>> {
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"));
}
}

View File

@@ -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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<Vec<archived_identities::ArchivedIdentity>> {
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<Vec<archived_identities::ArchivedIdentity>> {
archived_identities::list_archived(&self.pool, community_id).await
}
/// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey.

View File

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