From e4e8f8c585b102ce18491cdfaedf1651e956d9a9 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:42:00 -0400 Subject: [PATCH] Phase-1 community moderation authorization seam (L2). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `authorize_moderation_action` (the capability helper, plan §1), the single seam every moderation decision routes through so a future Moderator tier is a policy change, not a rewrite (decision 1). Authority model: - Community owner/admin (tenant-scoped `relay_members.role`) authorize every action in any channel of their community — the bridge `validate_admin_event` is missing today. - Channel owner/admin keep channel-local authority for DeleteMessage/Kick within `channel_id`. No current call site passes a `channel_id` (every L6 handler + the queue bridge pass `None`), so this branch is the contract seam for the future `validate_admin_event` wiring — building it to contract now makes that a one-line consult, not a helper rewrite. - Guard rail: an admin cannot ban/timeout the community owner or a fellow admin; only the owner may action an admin. Scoped to ban/timeout only — restriction-lifting (unban/untimeout) is unguarded because a banned admin can't self-unban (blocked at the auth seam before any command runs), so the only reachable case is lifting a fellow admin's restriction, which is benign, audited, and owner-reversible. The guard trips on a target *role* of owner/admin, never on a missing row, so a drive-by spammer who already left is still bannable. Tenant fence: both role reads (`get_relay_member`, `get_member_role`) filter on `tenant.community()` in SQL — authority never crosses tenants. The policy is factored into a pure `decide_authority` from resolved roles, exhaustively unit-tested (7 tests: owner all-actions, admin against non-privileged and privileged targets, non-member bannable, guard scope, channel-role delete/kick-only, member/stranger denied). The async wrapper is thin I/O glue that reads only the roles a given path needs. Co-authored-by: Dawn (sprout agent) Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- .../src/handlers/moderation_authz.rs | 258 +++++++++++++++++- 1 file changed, 251 insertions(+), 7 deletions(-) diff --git a/crates/buzz-relay/src/handlers/moderation_authz.rs b/crates/buzz-relay/src/handlers/moderation_authz.rs index 9047c0acc88..2f2781dd1d3 100644 --- a/crates/buzz-relay/src/handlers/moderation_authz.rs +++ b/crates/buzz-relay/src/handlers/moderation_authz.rs @@ -81,12 +81,256 @@ pub enum ModerationAuthority { /// Returns the matched authority for the audit row, or `Err` with a /// client-safe denial message. pub async fn authorize_moderation_action( - _tenant: &TenantContext, - _state: &Arc, - _actor_pubkey: &[u8], - _channel_id: Option, - _target: ModerationTarget<'_>, - _action: ModerationAction, + tenant: &TenantContext, + state: &Arc, + actor_pubkey: &[u8], + channel_id: Option, + target: ModerationTarget<'_>, + action: ModerationAction, ) -> anyhow::Result { - todo!("L2 (Mari): relay_members role + channel role lookup, owner>admin guard rails") + let community = tenant.community(); + + // Community role: `relay_members` stores pubkeys as 64-char hex, fenced to + // `community` in the query itself. This is the primary authority — owner and + // admin can moderate any channel in their community. + let actor_role = state + .db + .get_relay_member(community, &hex::encode(actor_pubkey)) + .await? + .map(|m| m.role); + + // The target's community role is read only for the admin guard rail — i.e. + // an admin actioning a pubkey with ban/timeout — so the owner and + // channel-role paths stay at a single query. + let target_role = match (actor_role.as_deref(), action, target) { + (Some("admin"), ModerationAction::Ban | ModerationAction::Timeout, target) => { + match target { + ModerationTarget::Pubkey(pk) => state + .db + .get_relay_member(community, &hex::encode(pk)) + .await? + .map(|m| m.role), + _ => None, + } + } + _ => None, + }; + + // The channel role is read only when community authority does not apply and + // the action is channel-local (DeleteMessage/Kick within `channel_id`). + let channel_role = match (actor_role.as_deref(), action, channel_id) { + (Some("owner") | Some("admin"), _, _) => None, + (_, ModerationAction::DeleteMessage | ModerationAction::Kick, Some(channel_id)) => { + state + .db + .get_member_role(community, channel_id, actor_pubkey) + .await? + } + _ => None, + }; + + decide_authority( + actor_role.as_deref(), + target_role.as_deref(), + channel_role.as_deref(), + action, + ) +} + +/// Pure authorization decision from resolved roles — the policy, factored out +/// of the I/O so it is exhaustively unit-testable. +/// +/// - `actor_role` / `target_role`: community `relay_members` role, if any. +/// - `channel_role`: the actor's channel role, resolved by the caller only when +/// community authority does not apply and the action is channel-local. +fn decide_authority( + actor_role: Option<&str>, + target_role: Option<&str>, + channel_role: Option<&str>, + action: ModerationAction, +) -> anyhow::Result { + match actor_role { + // Owner holds every capability, community-wide, with no guard rail. + Some("owner") => Ok(ModerationAuthority::CommunityOwner), + // Admin holds every capability, but cannot ban/timeout the owner or a + // fellow admin — only the owner may action an admin. The guard trips only + // on a target *role* of owner/admin: a target with no `relay_members` row + // (a drive-by spammer who already left) is bannable. Unban/Untimeout lift + // a restriction and are intentionally unguarded — a banned admin can't + // self-unban (banned means blocked at the auth seam before any command + // runs), so the only reachable case is an admin lifting a fellow admin's + // restriction, which is benign, audited, and owner-reversible; guarding it + // would instead strand a wrongly-banned admin behind an owner-only unlock. + Some("admin") => { + if matches!(action, ModerationAction::Ban | ModerationAction::Timeout) + && matches!(target_role, Some("owner") | Some("admin")) + { + anyhow::bail!("an admin cannot ban or time out a community owner or fellow admin"); + } + Ok(ModerationAuthority::CommunityAdmin) + } + // Not a community owner/admin: channel owner/admin keep channel-local + // authority for DeleteMessage/Kick only. + _ => match (action, channel_role) { + ( + ModerationAction::DeleteMessage | ModerationAction::Kick, + Some("owner") | Some("admin"), + ) => Ok(ModerationAuthority::ChannelRole), + _ => anyhow::bail!("moderator access required"), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every community-wide action a community owner can take. Channel-local + /// actions (DeleteMessage/Kick) are included — the owner holds them too. + const ALL_ACTIONS: [ModerationAction; 8] = [ + ModerationAction::DeleteMessage, + ModerationAction::Kick, + ModerationAction::Ban, + ModerationAction::Unban, + ModerationAction::Timeout, + ModerationAction::Untimeout, + ModerationAction::ResolveReport, + ModerationAction::ViewQueue, + ]; + + fn ok(r: anyhow::Result) -> ModerationAuthority { + r.expect("expected authorization") + } + + #[test] + fn community_owner_authorized_for_everything() { + for action in ALL_ACTIONS { + // Even against another owner/admin target: the owner has no guard rail. + assert_eq!( + ok(decide_authority(Some("owner"), Some("admin"), None, action)), + ModerationAuthority::CommunityOwner, + "owner must be authorized for {action:?}" + ); + } + } + + #[test] + fn community_admin_authorized_against_non_privileged_targets() { + for action in ALL_ACTIONS { + // Target is a plain member (or unknown) — admin holds every capability. + assert_eq!( + ok(decide_authority( + Some("admin"), + Some("member"), + None, + action + )), + ModerationAuthority::CommunityAdmin, + "admin must be authorized for {action:?} against a member" + ); + assert_eq!( + ok(decide_authority(Some("admin"), None, None, action)), + ModerationAuthority::CommunityAdmin, + "admin must be authorized for {action:?} against a non-member" + ); + } + } + + #[test] + fn admin_cannot_ban_or_timeout_owner_or_fellow_admin() { + for target in ["owner", "admin"] { + for action in [ModerationAction::Ban, ModerationAction::Timeout] { + assert!( + decide_authority(Some("admin"), Some(target), None, action).is_err(), + "admin must not {action:?} a community {target}" + ); + } + } + } + + #[test] + fn admin_can_ban_or_timeout_a_non_member_target() { + // A target with no `relay_members` row (e.g. a drive-by spammer who + // already left) must still be bannable — the guard trips on a privileged + // *role*, never on a missing row. + for action in [ModerationAction::Ban, ModerationAction::Timeout] { + assert_eq!( + ok(decide_authority(Some("admin"), None, None, action)), + ModerationAuthority::CommunityAdmin, + "admin must be able to {action:?} a non-member target" + ); + // A plain member target is likewise fair game. + assert_eq!( + ok(decide_authority( + Some("admin"), + Some("member"), + None, + action + )), + ModerationAuthority::CommunityAdmin, + "admin must be able to {action:?} a plain member" + ); + } + } + + #[test] + fn admin_guard_rail_is_scoped_to_ban_and_timeout() { + // Reversals and non-restriction actions against an admin target are allowed — + // the guard rail protects against *applying* a restriction, not lifting one. + for action in [ + ModerationAction::Unban, + ModerationAction::Untimeout, + ModerationAction::DeleteMessage, + ModerationAction::Kick, + ModerationAction::ResolveReport, + ModerationAction::ViewQueue, + ] { + assert_eq!( + ok(decide_authority(Some("admin"), Some("admin"), None, action)), + ModerationAuthority::CommunityAdmin, + "admin must be authorized for {action:?} even against an admin target" + ); + } + } + + #[test] + fn channel_role_covers_only_delete_and_kick() { + for role in ["owner", "admin"] { + for action in [ModerationAction::DeleteMessage, ModerationAction::Kick] { + assert_eq!( + ok(decide_authority(None, None, Some(role), action)), + ModerationAuthority::ChannelRole, + "channel {role} must be authorized for {action:?}" + ); + } + // No community authority: channel role does NOT grant community actions. + for action in [ + ModerationAction::Ban, + ModerationAction::Timeout, + ModerationAction::Unban, + ModerationAction::Untimeout, + ModerationAction::ResolveReport, + ModerationAction::ViewQueue, + ] { + assert!( + decide_authority(None, None, Some(role), action).is_err(), + "channel {role} must NOT be authorized for community action {action:?}" + ); + } + } + } + + #[test] + fn plain_channel_member_and_stranger_are_denied() { + for action in ALL_ACTIONS { + assert!( + decide_authority(None, None, Some("member"), action).is_err(), + "channel member must be denied {action:?}" + ); + assert!( + decide_authority(None, None, None, action).is_err(), + "user with no role must be denied {action:?}" + ); + } + } }