Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,54 @@ impl Db {
}))
}

/// Hard-deletes a community when `owner_pubkey` is still its current owner.
///
/// The community and matching owner row are locked before deletion so an
/// owner rotation cannot race authorization. All tenant-scoped rows are
/// removed by the community foreign keys' `ON DELETE CASCADE` actions.
/// Returns the deleted community, or `None` when the host does not exist or
/// the asserted pubkey is not its owner.
pub async fn delete_community_owned_by(
&self,
normalized_host: &str,
owner_pubkey: &str,
) -> Result<Option<CommunityRecord>> {
let mut tx = self.pool.begin().await?;
let row = sqlx::query(
r#"
SELECT c.id, c.host
FROM communities c
JOIN relay_members rm ON rm.community_id = c.id
WHERE lower(c.host) = lower($1)
AND lower(rm.pubkey) = lower($2)
AND rm.role = 'owner'
FOR UPDATE OF c, rm
"#,
)
.bind(normalized_host)
.bind(owner_pubkey)
.fetch_optional(&mut *tx)
.await?;

let Some(row) = row else {
tx.rollback().await?;
return Ok(None);
};
let id: Uuid = row.try_get("id")?;
let host: String = row.try_get("host")?;

sqlx::query("DELETE FROM communities WHERE id = $1")
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;

Ok(Some(CommunityRecord {
id: CommunityId::from_uuid(id),
host,
}))
}

/// Returns the community that owns a channel, if the channel exists.
///
/// Internal relay producers use this to derive tenant context from the row
Expand Down Expand Up @@ -3055,6 +3103,73 @@ mod tests {
assert!(post_rotation_retry.is_none());
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn hard_delete_requires_current_owner_and_cascades_tenant_rows() {
let db = setup_db().await;
let host = format!("hard-delete-{}.example", Uuid::new_v4().simple());
let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";

let original = db
.create_community_with_owner(&host, owner)
.await
.expect("create community")
.expect("new host");
sqlx::query(
"INSERT INTO pubkey_allowlist (community_id, pubkey) VALUES ($1, decode($2, 'hex'))",
)
.bind(original.id.as_uuid())
.bind(other)
.execute(&db.pool)
.await
.expect("insert tenant child row");

assert!(db
.delete_community_owned_by(&host, other)
.await
.expect("reject non-owner delete")
.is_none());
assert!(db
.lookup_community_by_host(&host)
.await
.expect("lookup retained community")
.is_some());

let deleted = db
.delete_community_owned_by(&host.to_ascii_uppercase(), owner)
.await
.expect("delete owned community")
.expect("community deleted");
assert_eq!(deleted.id, original.id);
assert_eq!(deleted.host, host);
assert!(db
.lookup_community_by_host(&host)
.await
.expect("lookup deleted community")
.is_none());

let child_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM pubkey_allowlist WHERE community_id = $1")
.bind(original.id.as_uuid())
.fetch_one(&db.pool)
.await
.expect("count cascaded children");
assert_eq!(child_count, 0);
assert!(db
.delete_community_owned_by(&host, owner)
.await
.expect("repeat delete")
.is_none());

let recreated = db
.create_community_with_owner(&host, owner)
.await
.expect("recreate community")
.expect("deleted host reusable");
assert_ne!(recreated.id, original.id);
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn concurrent_same_owner_create_returns_the_winning_row_to_both_callers() {
Expand Down
31 changes: 30 additions & 1 deletion crates/buzz-db/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ mod tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);

assert_eq!(migrations.len(), 6);
assert_eq!(migrations.len(), 7);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
Expand Down Expand Up @@ -555,6 +555,18 @@ mod tests {
);
}
assert!(!migrations[0].sql.as_str().contains("moderation_reports"));

assert_eq!(migrations[6].version, 7);
assert_eq!(&*migrations[6].description, "community cascade delete");
assert_eq!(
migrations[6]
.sql
.as_str()
.matches("REFERENCES communities(id) ON DELETE CASCADE")
.count(),
22,
"migration 0007 must replace all 22 parent-table community foreign keys"
);
}

#[test]
Expand Down Expand Up @@ -769,5 +781,22 @@ mod tests {
);
assert!(exists, "migration should create {table}");
}

let non_cascading_community_fks: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM pg_constraint
WHERE contype = 'f'
AND confrelid = 'communities'::regclass
AND confdeltype <> 'c'
"#,
)
.fetch_one(&pool)
.await
.expect("count non-cascading community foreign keys");
assert_eq!(
non_cascading_community_fks, 0,
"every direct community foreign key, including partition clones, must cascade"
);
}
}
115 changes: 113 additions & 2 deletions crates/buzz-relay/src/api/operator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@
use std::sync::Arc;

use axum::{
extract::{Query, RawQuery, State},
extract::{Path, Query, RawQuery, State},
http::{HeaderMap, StatusCode},
response::Json,
};
use serde::Deserialize;
use serde_json::Value;

use crate::handlers::community_provisioning::{
normalize_candidate_host, validate_pubkey_hex, ProvisionCommunityRequest,
normalize_candidate_host, validate_pubkey_hex, DeleteCommunityRequest,
ProvisionCommunityRequest,
};
use crate::state::AppState;

Expand Down Expand Up @@ -171,6 +172,51 @@ pub async fn provision_community(
}
}

/// Permanently delete a community after validating an operator-proxied owner request.
pub async fn delete_community(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(host): Path<String>,
RawQuery(raw_query): RawQuery,
Query(request): Query<DeleteCommunityRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let path = format!("/operator/communities/{host}");
let pubkey = authorize_operator_request(
&state,
&headers,
"DELETE",
&path,
raw_query.as_deref(),
None,
)
.await?;

match crate::handlers::community_provisioning::delete_community(
&state,
&pubkey,
&host,
&request.owner_pubkey,
)
.await
{
Ok(response) => Ok(Json(serde_json::to_value(response).map_err(|e| {
tracing::error!("failed to serialize delete-community response: {e}");
internal_error("operator delete response serialization failed")
})?)),
Err(msg) if msg.starts_with("actor not authorized") => {
Err(api_error(StatusCode::FORBIDDEN, &msg))
}
Err(msg) if msg == "community not found or owner does not match" => {
Err(api_error(StatusCode::NOT_FOUND, &msg))
}
Err(msg) if msg.starts_with("failed to delete community:") => {
tracing::error!(error = %msg, "operator community deletion failed");
Err(internal_error("operator community deletion failed"))
}
Err(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)),
}
}

/// List communities where a pubkey currently holds the `owner` role.
pub async fn list_owned_communities(
State(state): State<Arc<AppState>>,
Expand Down Expand Up @@ -500,6 +546,71 @@ mod tests {
);
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn delete_requires_matching_owner_and_removes_community() {
let operator = Keys::generate();
let owner = Keys::generate();
let outsider = Keys::generate();
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
return;
};
let host = format!("delete-community-{}.example", Uuid::new_v4().simple());
let original = state
.db
.create_community_with_owner(&host, &owner.public_key().to_hex())
.await
.expect("create community")
.expect("new community");

let outsider_query = format!("owner_pubkey={}", outsider.public_key().to_hex());
let outsider_url =
format!("http://{INGRESS_HOST}/operator/communities/{host}?{outsider_query}");
let outsider_auth = nip98_auth_header(&operator, &outsider_url, "DELETE", None);
let response = build_router(state.clone())
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/operator/communities/{host}?{outsider_query}"))
.header(header::HOST, INGRESS_HOST)
.header(header::AUTHORIZATION, outsider_auth)
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);

let owner_query = format!("owner_pubkey={}", owner.public_key().to_hex());
let owner_url = format!("http://{INGRESS_HOST}/operator/communities/{host}?{owner_query}");
let owner_auth = nip98_auth_header(&operator, &owner_url, "DELETE", None);
let response = build_router(state.clone())
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/operator/communities/{host}?{owner_query}"))
.header(header::HOST, INGRESS_HOST)
.header(header::AUTHORIZATION, owner_auth)
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let json = read_json(response).await;
assert_eq!(json.get("status").and_then(Value::as_str), Some("deleted"));
assert_eq!(
json.get("community_id").and_then(Value::as_str),
Some(original.id.to_string().as_str())
);
assert!(state
.db
.lookup_community_by_host(&host)
.await
.expect("lookup community")
.is_none());
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn happy_path_create_returns_created_and_bootstraps_owner() {
Expand Down
Loading
Loading