Files
buzz/crates/buzz-core/src/tenant.rs
Will Pfleger 020193f8e3 feat(relay): add direct staff ban/timeout/delete with staff guard (#7883)
Relay staff (the `RELAY_OPERATOR_PUBKEYS` config roster, the owner
fallback, and DB operator/moderator rows) can now ban, time out, and
delete without a report through the admin API. Staff never join the
community transport: these are admin-host, NIP-98 signed routes that
drive the existing report enforcement state machine. Community
admission, bans, and right-click menus are unchanged from `main`.

Stack: this PR → #7904

## Routes

| Route | Body |
|---|---|
| `POST /api/admin/v1/members/{pubkey}/ban?communityHost=` |
`{requestId, reason?}` |
| `POST /api/admin/v1/members/{pubkey}/timeout?communityHost=` |
`{requestId, expirationSecs, reason?}` |
| `POST /api/admin/v1/events/{eventId}/delete?communityHost=` |
`{requestId, reason?}` |

Responses: `200 {actionId, state: "succeeded", replayed}`, `202 {state:
"pending"}` when the action is accepted but not yet converged (the
recovery worker finishes it; the same `requestId` replays), `409
request_id_conflict`, `409 target_is_staff`, `404
event_not_in_community`, `400 invalid_community_host` /
`unknown_community_host`, and `422 enforcement_failed`. `422` is now the
single failed-enforcement code for both the report and direct paths
(shared `enforcement_failed()` mapping).

## Design

- **Migration 0055.** `relay_admin_actions.report_id` becomes nullable.
A direct row has `report_id NULL`, and `report_community_id` stays the
tenant. New columns: `enforcement_target_event_id` and `timeout_secs`.
The `relay_admin_actions_direct_shape` CHECK spells out each direct
action: timeout needs both `timeout_secs` and `timeout_until`, ban and
delete need neither, and delete needs both the event id and the author.
A partial unique index on `(report_community_id, request_id) WHERE
report_id IS NULL` makes direct actions idempotent. `pgschema` drops
multi-column CHECKs, so `scripts/reconcile-schema-after-pgschema.sql`
re-adds `relay_admin_actions_direct_shape` on desired-state databases.
- **Atomic acceptance.** `claim_direct_action` inserts the action row
and its `moderation_actions` audit row (`ban` / `timeout` /
`delete_message`, `relay_operator` / `relay_moderator`) in one
transaction. `ON CONFLICT DO NOTHING` sends a concurrent same-key
request to the comparison. Retries compare actor, action, reason,
`timeout_secs`, and the client-named target: the event id for delete,
the pubkey otherwise. The event author, channel, and `timeout_until` are
derived at acceptance and never compared.
- **Retry racing its own first attempt.** Handler order: stored-action
lookup → validation → staff guard → accept. If validation fails (for
example B's event lookup runs after A already deleted the event), the
handler re-reads the stored action. It returns a replay or 409 rather
than 404.
- **Report-less enforcement.** `finalize_success(report_id:
Option<Uuid>)` skips the report CAS and the `reporter_notice` for direct
actions. Tombstone and affected-user notices are unchanged.
`recover_one` drives a direct action from its persisted row.
`deliver_reporter_notice` treats a missing `report_id` as a terminal
error instead of panicking.
- **Permanent bans clear old expiry.** The ban upsert shared by direct
and report-backed bans sets `ban_expires_at = NULL`, matching the
kind-9040 path, so a permanent ban over an active or lapsed timed ban is
in effect with no deadline. Timeouts are untouched.
- **Community purge removes admin-action rows.** `purge_postgres`
deletes the community's `relay_admin_outbox` rows (joined through their
action), then its `relay_admin_actions` rows by `report_community_id`,
before `moderation_reports`. That order satisfies both FKs and covers
report-backed and direct (`report_id NULL`) rows in one pass. The same
keyed row sources (`RELAY_ADMIN_TABLES`) feed the deletion inventory,
which now records row counts for both tables, and post-purge logical
verification, which now fails if either table still has a row for the
community. Before this, a community with any admin-resolved report could
never finish deletion (23503 on
`relay_admin_actions_report_community_id_report_id_fkey`, stuck at
`bindings_removed`). The `_operator_global_tables` registry reasons for
both tables (seeded in migration 0036) are updated in 0055 and
`schema.sql` to say community deletion purges them explicitly.
- **Staff bans disconnect clusterwide.** The shared enforcement driver
(`drive_enforcement`, used by the report and direct paths, fresh and
recovery) calls `disconnect_pubkey_clusterwide` for `ban` before
finalizing, matching the kind-9040 ban. A crash before finalize leaves
the action non-terminal, so recovery re-runs the disconnect (idempotent)
even when the ban step marker is already set. Timeouts do not
disconnect, matching in-community timeout.
- **Staff guard (D2).** `lookup_admin_principal` returns
`Result<Option<AdminPrincipal>>`, with the same precedence as
`resolve_admin_principal` (now a wrapper). `Ok(None)` means not staff. A
roster lookup error is a 500, so the guard fails closed. Ban and timeout
(direct) and ban/timeout/kick (via `resolve_report`) refuse any
effective-roster target with `409 target_is_staff`, including the actor.
The report path refuses before claim, so the report stays `open`. Delete
is not guarded on either path. The check runs only when an action is
first accepted: a replay or crash recovery completes an accepted action
even if its target became staff afterwards.
- **`communityHost` grammar (D4).**
`buzz_core::tenant::validate_community_host` accepts only DNS labels and
an optional port, and rejects schemes, paths, queries, userinfo, IP
brackets, and whitespace before tenant binding.

## Tests binding fixes (red under mutation → green)

Postgres tests are in `api/admin/mod.rs` `postgres_tests`, run against a
DB migrated through 0055. Every rejection test seeds an existing
restriction and asserts, through the shared `assert_no_effects` helper,
that restriction values and action/audit/outbox/restriction/event counts
are unchanged.

| Fix | Test | Mutation → red |
|---|---|---|
| Racing retry replays |
`direct_delete_retry_racing_first_attempt_replays_not_404`
(barrier-paused HTTP interleaving; same-intent → 200 replay, conflicting
reason → 409; the retry is checked with `assert_no_effects` against a
snapshot taken after the first attempt, with the event author's
restriction seeded) | drop the post-failure re-read → `left: 404 right:
200`; conflicting retry updates a restriction on the re-read path →
`raced retry` snapshot mismatch (`ban_reason: "mutated"` vs `"seeded
ban"`) |
| Per-action retry comparison |
`direct_ban_replays_same_request_and_conflicts_on_changed_intent` | drop
`reason` from the comparison → `left: (200, None) right: (409,
Some("request_id_conflict"))` |
| Lookup fails closed (helper-level, calls `refuse_staff_target`
directly) | `staff_lookup_failure_fails_closed` | `.unwrap_or(None)` on
lookup error → panics on `unwrap_err` (guard passed) |
| Direct staff guard | `direct_ban_and_timeout_refuse_staff_targets`
(config/self and DB staff over HTTP with seeded restrictions unchanged;
owner fallback checked at helper level via `refuse_staff_target`) |
remove `refuse_staff_target` → `left: (200, None) right: (409,
Some("target_is_staff"))` |
| Report-path guard incl. kick |
`resolve_report_refuses_kick_and_ban_of_staff_target` | remove
report-path guard → `left: (200, None) right: (409,
Some("target_is_staff"))` |
| Accept-time-only check |
`recovery_completes_accepted_ban_after_target_became_staff` | re-check
staff in `drive_direct_action` → `left: "pending" right: "succeeded"` |
| Timeout CHECK | `migration_0055_rejects_half_filled_direct_timeout`
(buzz-db, migrated schema) and
`direct_timeout_shape_check_holds_on_desired_state_schema` (buzz-relay,
`pgschema` + reconcile schema) | loosen timeout branch of the CHECK, or
drop the reconcile block → half-filled insert accepted |
| No reporter notice for direct | `direct_ban_replays_…` (outbox
assertion) | enqueue `reporter_notice` unconditionally → assertion at
the `reporter_notice` check fails |
| Delete unguarded | `direct_delete_removes_staff_authored_event` | —
(behavioral control) |
| Unauthorized direct requests have no effect |
`direct_routes_refuse_unauthorized_requests_without_effects` (nonstaff,
community owner, disabled auth, wrong host, wrong URL, altered body;
restrictions on the targeted member and on the delete target's author,
plus action/audit/outbox rows, unchanged) | roster bypass / drop
mutation-principal check / admit owner / skip host check → `200 vs 403`;
drop URL binding / payload hash → `200 vs 401` |
| Foreign-community delete |
`direct_delete_of_foreign_community_event_has_no_effect` | unscoped
event lookup → `(200, None) vs (404, "event_not_in_community")` |
| Banned staff actor still acts |
`community_banned_staff_actor_still_acts_under_membership_enforcement`
(membership enforcement on) | gate the actor on community bans → `403 vs
200` |
| Timeout idempotency |
`direct_timeout_keeps_expiry_on_retry_and_conflicts_on_changed_intent` |
compare derived `timeout_until` → `409 vs 200`; compare action only →
code `None vs Some("request_id_conflict")` |
| Competing claims | `competing_direct_claims_accept_once` (identical →
existing, conflicting → conflict; one action + one audit row each) |
join skips compare → `[claimed, existing] vs [claimed, conflict]`; join
always conflicts → the reverse |
| Aborted acceptance rolls back | `aborted_direct_acceptance_rolls_back`
| accept outside the transaction → effects `(1,0,0,0,0) vs (0,0,0,0,0)`
|
| Recovery exactly once |
`recovery_after_committed_mutation_finalizes_exactly_once` (one
recovery, `succeeded`, one `affected_user_notice`; each `recover_one`
bounded by a 30s timeout) | ignore the enforcement step marker →
`recovery did not converge: Elapsed(())` after 30s |
| Permanent ban over a timed ban | `direct_ban_clears_prior_ban_expiry`
(active and expired prior ban; asserts `ban_expires_at` NULL, admission
`moderation_restriction_state` banned, timeout unchanged) | drop
`ban_expires_at = NULL` → active: `left: Some(+1 day) right: None`;
expired: `left: Some(-1 day) right: None` |
| Rejections never rewrite restrictions |
`direct_ban_and_timeout_refuse_staff_targets`,
`resolve_report_refuses_kick_and_ban_of_staff_target` (value snapshot
via `assert_no_effects`) | staff guard updates the target's existing
restriction before returning 409 → counts equal but `ban_reason:
"mutated"` vs `"seeded ban"` |
| Purge with admin-action rows |
`purge_postgres_succeeds_with_relay_admin_action_rows` (buzz-db;
report-backed and direct rows with outbox rows; inventory counts both
tables; deleted community left with none, second community untouched; a
straggler direct row fails logical verification) | remove the new purge
deletes → `23503 … violates foreign key constraint
"relay_admin_actions_report_community_id_report_id_fkey"`; drop the
relay admin tables from verification → `straggler relay admin action
must fail verification` |
| Ban disconnects, timeout doesn't |
`direct_ban_disconnects_target_and_timeout_does_not` | remove the
disconnect call (run alone) → `ban must close the target's socket` |
| Recovery still disconnects |
`recovery_past_ban_step_still_disconnects_target` | remove the
disconnect call → `recovery must disconnect the banned target` |
| Purge result keyed by table |
`checkpointed_resume_is_idempotent_and_tombstone_blocks_name_reuse`
compares returned keys to the scoped tables plus the two relay admin
tables | — (existing regression, updated) |
| Failed replay |
`direct_replay_of_failed_action_returns_422_without_effects` | —
(coverage) |
| Pending under contention |
`direct_action_under_contention_returns_202_pending` | — (coverage) |
| Timeout/delete recovery |
`recovery_completes_accepted_direct_timeout_and_delete` (test-only
`claim_stranded` acquires just the requested action through
`acquire_action_lease`, then runs `recover_one`, so tests don't claim
each other's rows) | — (coverage) |

## Rollout note

Before rolling the relay back to a binary without this PR, drain
non-terminal direct rows (`report_id IS NULL AND state IN
('pending','enforcing')`). An older binary cannot decode `report_id
NULL`, and recovery batches containing such rows stall.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-30 14:23:54 -04:00

353 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Tenant identity: the server-resolved community key carried on every scoped path.
//!
//! These types live in `buzz-core` (zero I/O deps) so the DB, auth, pub/sub,
//! search, audit, media, and relay-wiring layers all name a community the same
//! way without depending on each other.
//!
//! ## The fence
//!
//! The whole multi-tenant safety story rests on one invariant from the formal
//! model (conformance "row zero"): a request's community is *resolved from the
//! connection host by the server*, never supplied or influenced by the client.
//!
//! [`TenantContext`] expresses that invariant in the type system as far as the
//! type system can carry it: there is no `Default`, no `Deserialize`, and no
//! way to *parse* a community from client input. A `CommunityId` only ever
//! comes from host resolution or from a DB row the server already scoped.
//!
//! This is a **lint-and-review fence, not a compiler fence.**
//! [`TenantContext::resolved`] and [`CommunityId::from_uuid`] are public so the
//! host-resolution path (in another crate) can call them — which means a
//! determined caller elsewhere *could* call them too. The migration-lint
//! harness forbids constructing a `TenantContext` outside host resolution and
//! tests; the type only removes the *accidental* path (deserializing a
//! client-chosen community), and review/lint closes the deliberate one. We say
//! this plainly rather than overclaim a guarantee the `pub` API doesn't give.
use std::fmt;
use uuid::Uuid;
/// A community: the first-class tenant key on every scoped row.
///
/// Opaque UUID newtype. Equality and ordering are the underlying UUID's.
/// There is deliberately no `community_id` parsed from client input anywhere;
/// a `CommunityId` only ever originates from host resolution or from a DB row
/// the server already scoped.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CommunityId(Uuid);
impl CommunityId {
/// Wrap a UUID that the server has already established as a community id
/// (e.g. read back from the `communities` table during host resolution).
///
/// This is intentionally not a parse-from-client entry point: callers must
/// already hold a server-trusted UUID.
pub const fn from_uuid(id: Uuid) -> Self {
Self(id)
}
/// The underlying UUID, for DB binds and Redis key construction.
pub const fn as_uuid(&self) -> &Uuid {
&self.0
}
}
impl fmt::Display for CommunityId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
/// The resolved tenant of an in-flight request, bound once at connection /
/// request establishment before any handler observes tenant data.
///
/// Carried by reference (`&TenantContext`) through every scoped call. This is
/// the *only* way to name a community downstream, and it cannot be constructed
/// from client input — see the module-level "fence" note.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TenantContext {
community: CommunityId,
host: String,
}
impl TenantContext {
/// Construct a context from a completed host resolution.
///
/// Call this *only* from the host-resolution path (the function that maps a
/// connection's host to a `communities` row). Everywhere else takes
/// `&TenantContext` and reads it; nothing else mints one.
pub fn resolved(community: CommunityId, host: impl Into<String>) -> Self {
Self {
community,
host: host.into(),
}
}
/// The community every scoped operation under this request must use.
pub const fn community(&self) -> CommunityId {
self.community
}
/// The host that resolved to this community.
///
/// Authoritative for the NIP-05 domain and audit labelling; never re-derive
/// the community from it downstream — the community is already fixed.
pub fn host(&self) -> &str {
&self.host
}
}
/// Normalize a connection `Host` into the canonical form used as the community
/// lookup key.
///
/// This is the *one* normalization rule shared by both sides of the fence:
/// the `communities.host` column is stored already-normalized, and host
/// resolution normalizes the incoming `Host` header with this same function
/// before looking it up. Because both sides agree by construction,
/// `Relay.Example`, `relay.example.`, and `relay.example:443` all resolve to
/// the one community — they can never split into distinct tenants.
///
/// Rules (host only — the caller has already split off any path/scheme):
/// - ASCII-lowercase (hosts are case-insensitive per RFC 3986);
/// - strip a single trailing dot (the FQDN root label);
/// - strip a default port suffix (`:80`, `:443`) — non-default ports are kept,
/// since a deployment may legitimately serve different communities on
/// different ports of the same name.
///
/// The input is trimmed of surrounding whitespace. An empty result (e.g. the
/// caller passed `""`) is returned as-is; resolution treats an empty or
/// unmapped host as a fail-closed rejection, never a default tenant.
#[must_use]
pub fn normalize_host(host: &str) -> String {
let host = host.trim();
let mut host = host.to_ascii_lowercase();
// Strip default ports. We only touch a `:port` suffix that is exactly a
// default port, so IPv6 literals like `[::1]` (which contain colons but no
// trailing `:80`/`:443`) are left intact.
if let Some(stripped) = host
.strip_suffix(":443")
.or_else(|| host.strip_suffix(":80"))
{
host = stripped.to_string();
}
// Strip a single trailing FQDN-root dot.
if let Some(stripped) = host.strip_suffix('.') {
host = stripped.to_string();
}
host
}
/// Validate a client-typed community host (`name[:port]`) and return its
/// normalized form. Accepts only DNS labels of `[a-z0-9-]` (1–63 chars, no
/// leading or trailing hyphen, ≤ 253 total) and an optional port 1–65535, so
/// schemes, paths, queries, userinfo, IP-literal brackets and whitespace are
/// all rejected rather than normalized away.
pub fn validate_community_host(raw: &str) -> Result<String, &'static str> {
let lower = raw.to_ascii_lowercase();
let (name, port) = match lower.split_once(':') {
Some((name, port)) => (name, Some(port)),
None => (lower.as_str(), None),
};
if let Some(port) = port {
let valid = !port.is_empty()
&& port.bytes().all(|b| b.is_ascii_digit())
&& port.parse::<u16>().is_ok_and(|p| p > 0);
if !valid {
return Err("invalid port");
}
}
if name.is_empty() || name.len() > 253 {
return Err("invalid host length");
}
let label_ok = |l: &str| {
(1..=63).contains(&l.len())
&& !l.starts_with('-')
&& !l.ends_with('-')
&& l.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
};
if !name.split('.').all(label_ok) {
return Err("invalid host");
}
Ok(normalize_host(&lower))
}
/// Extract the authority (host plus an explicit non-default port, if present)
/// from a relay URL in the same normalized shape as request `Host` headers and
/// `communities.host`.
///
/// Shared by the relay's host-resolution seam (startup community seeding and
/// the deployment-community bind), the relay's `bind_deployment_community`, and
/// the `buzz-admin` CLI's tenant resolution. All of these must derive the
/// *byte-identical* authority that live request resolution
/// ([`crate::tenant::normalize_host`]) produces from an inbound `Host`, or a
/// bootstrapped/looked-up community lands under a host no request resolves to.
///
/// In particular this preserves an explicit non-default port (`relay:8443` →
/// `relay:8443`) and IPv6 brackets (`[::1]:3000`) — both of which a naive
/// `Url::host_str()` drops. Returns the empty string when `relay_url` has no
/// parseable host (the caller fails closed on empty).
#[must_use]
pub fn relay_url_authority(relay_url: &str) -> String {
let Ok(url) = url::Url::parse(relay_url) else {
return String::new();
};
let Some(host) = url.host() else {
return String::new();
};
let host = match host {
url::Host::Domain(domain) => domain.to_string(),
url::Host::Ipv4(addr) => addr.to_string(),
url::Host::Ipv6(addr) => format!("[{addr}]"),
};
let authority = match url.port() {
Some(port) => format!("{host}:{port}"),
None => host,
};
normalize_host(&authority)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_community_host_accepts_only_bare_host_and_port() {
for (input, expected) in [
("buzz.example", "buzz.example"),
("Buzz.Example", "buzz.example"),
("buzz.example:443", "buzz.example"),
("localhost:3000", "localhost:3000"),
("a-1.b2", "a-1.b2"),
] {
assert_eq!(
validate_community_host(input).as_deref(),
Ok(expected),
"{input}"
);
}
for input in [
"",
" buzz.example",
"https://buzz.example",
"buzz.example/path",
"buzz.example?x=1",
"buzz.example#f",
"user@buzz.example",
"[::1]",
"buzz.example:",
"buzz.example:0",
"buzz.example:65536",
"buzz.example:+1",
"-buzz.example",
"buzz-.example",
"buzz..example",
"buzz.example.",
"bü.example",
&format!("{}.example", "a".repeat(64)),
] {
assert!(
validate_community_host(input).is_err(),
"{input:?} must be rejected"
);
}
}
#[test]
fn community_id_roundtrips_uuid() {
let u = Uuid::from_u128(0x1234_5678_9abc_def0_1122_3344_5566_7788);
let c = CommunityId::from_uuid(u);
assert_eq!(c.as_uuid(), &u);
assert_eq!(c.to_string(), u.to_string());
}
#[test]
fn tenant_context_exposes_resolution_inputs() {
let u = Uuid::from_u128(1);
let ctx = TenantContext::resolved(CommunityId::from_uuid(u), "relay.example");
assert_eq!(ctx.community().as_uuid(), &u);
assert_eq!(ctx.host(), "relay.example");
}
#[test]
fn normalize_host_collapses_tenant_split_variants() {
// All of these are the SAME tenant and must normalize identically —
// this is the property that stops accidental split-tenant.
let canonical = "relay.example";
for variant in [
"relay.example",
"Relay.Example",
"RELAY.EXAMPLE",
"relay.example.", // trailing FQDN root dot
"relay.example:443", // default https port
"relay.example:80", // default http port
"Relay.Example.:443",
" relay.example ", // surrounding whitespace
] {
assert_eq!(normalize_host(variant), canonical, "variant {variant:?}");
}
}
#[test]
fn normalize_host_keeps_nondefault_port() {
// A non-default port is a legitimate distinct selector — keep it.
assert_eq!(normalize_host("relay.example:8443"), "relay.example:8443");
assert_eq!(normalize_host("relay.example:3000"), "relay.example:3000");
}
#[test]
fn normalize_host_leaves_ipv6_literal_intact() {
// IPv6 literals contain colons but no trailing default-port suffix.
assert_eq!(normalize_host("[::1]"), "[::1]");
assert_eq!(normalize_host("[::1]:443"), "[::1]");
}
#[test]
fn normalize_host_empty_stays_empty() {
// Empty / whitespace-only resolves to empty; resolution fails closed.
assert_eq!(normalize_host(""), "");
assert_eq!(normalize_host(" "), "");
}
#[test]
fn relay_url_authority_keeps_explicit_nondefault_port() {
// The default dev seed: startup, bind_deployment_community, and
// buzz-admin must all derive `localhost:3000` (NOT bare `localhost`),
// or the admin lookup misses the community startup seeded.
assert_eq!(relay_url_authority("ws://localhost:3000"), "localhost:3000");
assert_eq!(
relay_url_authority("wss://relay.example:8443"),
"relay.example:8443"
);
}
#[test]
fn relay_url_authority_collapses_default_ports() {
// Default ports collapse to the bare host, matching how an inbound
// `Host` header for the same deployment normalizes.
assert_eq!(
relay_url_authority("wss://relay.example:443"),
"relay.example"
);
assert_eq!(
relay_url_authority("ws://relay.example:80"),
"relay.example"
);
assert_eq!(relay_url_authority("wss://relay.example"), "relay.example");
}
#[test]
fn relay_url_authority_preserves_ipv6_brackets() {
// `host_str()` strips IPv6 brackets and the port; `relay_url_authority`
// must keep both so the authority matches `communities.host`.
assert_eq!(relay_url_authority("ws://[::1]:3000"), "[::1]:3000");
}
#[test]
fn relay_url_authority_unparseable_is_empty() {
// No parseable host → empty authority; callers fail closed.
assert_eq!(relay_url_authority("not a url"), "");
assert_eq!(relay_url_authority(""), "");
}
}