Skip to content

Commit 8059413

Browse files
author
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
committed
Merge L2: authorize_moderation_action capability helper (#1604)
* commit 'e4e8f8c5': Phase-1 community moderation authorization seam (L2).
2 parents 4e48fe2 + e4e8f8c commit 8059413

1 file changed

Lines changed: 251 additions & 7 deletions

File tree

‎crates/buzz-relay/src/handlers/moderation_authz.rs‎

Lines changed: 251 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,256 @@ pub enum ModerationAuthority {
8181
/// Returns the matched authority for the audit row, or `Err` with a
8282
/// client-safe denial message.
8383
pub async fn authorize_moderation_action(
84-
_tenant: &TenantContext,
85-
_state: &Arc<AppState>,
86-
_actor_pubkey: &[u8],
87-
_channel_id: Option<Uuid>,
88-
_target: ModerationTarget<'_>,
89-
_action: ModerationAction,
84+
tenant: &TenantContext,
85+
state: &Arc<AppState>,
86+
actor_pubkey: &[u8],
87+
channel_id: Option<Uuid>,
88+
target: ModerationTarget<'_>,
89+
action: ModerationAction,
9090
) -> anyhow::Result<ModerationAuthority> {
91-
todo!("L2 (Mari): relay_members role + channel role lookup, owner>admin guard rails")
91+
let community = tenant.community();
92+
93+
// Community role: `relay_members` stores pubkeys as 64-char hex, fenced to
94+
// `community` in the query itself. This is the primary authority — owner and
95+
// admin can moderate any channel in their community.
96+
let actor_role = state
97+
.db
98+
.get_relay_member(community, &hex::encode(actor_pubkey))
99+
.await?
100+
.map(|m| m.role);
101+
102+
// The target's community role is read only for the admin guard rail — i.e.
103+
// an admin actioning a pubkey with ban/timeout — so the owner and
104+
// channel-role paths stay at a single query.
105+
let target_role = match (actor_role.as_deref(), action, target) {
106+
(Some("admin"), ModerationAction::Ban | ModerationAction::Timeout, target) => {
107+
match target {
108+
ModerationTarget::Pubkey(pk) => state
109+
.db
110+
.get_relay_member(community, &hex::encode(pk))
111+
.await?
112+
.map(|m| m.role),
113+
_ => None,
114+
}
115+
}
116+
_ => None,
117+
};
118+
119+
// The channel role is read only when community authority does not apply and
120+
// the action is channel-local (DeleteMessage/Kick within `channel_id`).
121+
let channel_role = match (actor_role.as_deref(), action, channel_id) {
122+
(Some("owner") | Some("admin"), _, _) => None,
123+
(_, ModerationAction::DeleteMessage | ModerationAction::Kick, Some(channel_id)) => {
124+
state
125+
.db
126+
.get_member_role(community, channel_id, actor_pubkey)
127+
.await?
128+
}
129+
_ => None,
130+
};
131+
132+
decide_authority(
133+
actor_role.as_deref(),
134+
target_role.as_deref(),
135+
channel_role.as_deref(),
136+
action,
137+
)
138+
}
139+
140+
/// Pure authorization decision from resolved roles — the policy, factored out
141+
/// of the I/O so it is exhaustively unit-testable.
142+
///
143+
/// - `actor_role` / `target_role`: community `relay_members` role, if any.
144+
/// - `channel_role`: the actor's channel role, resolved by the caller only when
145+
/// community authority does not apply and the action is channel-local.
146+
fn decide_authority(
147+
actor_role: Option<&str>,
148+
target_role: Option<&str>,
149+
channel_role: Option<&str>,
150+
action: ModerationAction,
151+
) -> anyhow::Result<ModerationAuthority> {
152+
match actor_role {
153+
// Owner holds every capability, community-wide, with no guard rail.
154+
Some("owner") => Ok(ModerationAuthority::CommunityOwner),
155+
// Admin holds every capability, but cannot ban/timeout the owner or a
156+
// fellow admin — only the owner may action an admin. The guard trips only
157+
// on a target *role* of owner/admin: a target with no `relay_members` row
158+
// (a drive-by spammer who already left) is bannable. Unban/Untimeout lift
159+
// a restriction and are intentionally unguarded — a banned admin can't
160+
// self-unban (banned means blocked at the auth seam before any command
161+
// runs), so the only reachable case is an admin lifting a fellow admin's
162+
// restriction, which is benign, audited, and owner-reversible; guarding it
163+
// would instead strand a wrongly-banned admin behind an owner-only unlock.
164+
Some("admin") => {
165+
if matches!(action, ModerationAction::Ban | ModerationAction::Timeout)
166+
&& matches!(target_role, Some("owner") | Some("admin"))
167+
{
168+
anyhow::bail!("an admin cannot ban or time out a community owner or fellow admin");
169+
}
170+
Ok(ModerationAuthority::CommunityAdmin)
171+
}
172+
// Not a community owner/admin: channel owner/admin keep channel-local
173+
// authority for DeleteMessage/Kick only.
174+
_ => match (action, channel_role) {
175+
(
176+
ModerationAction::DeleteMessage | ModerationAction::Kick,
177+
Some("owner") | Some("admin"),
178+
) => Ok(ModerationAuthority::ChannelRole),
179+
_ => anyhow::bail!("moderator access required"),
180+
},
181+
}
182+
}
183+
184+
#[cfg(test)]
185+
mod tests {
186+
use super::*;
187+
188+
/// Every community-wide action a community owner can take. Channel-local
189+
/// actions (DeleteMessage/Kick) are included — the owner holds them too.
190+
const ALL_ACTIONS: [ModerationAction; 8] = [
191+
ModerationAction::DeleteMessage,
192+
ModerationAction::Kick,
193+
ModerationAction::Ban,
194+
ModerationAction::Unban,
195+
ModerationAction::Timeout,
196+
ModerationAction::Untimeout,
197+
ModerationAction::ResolveReport,
198+
ModerationAction::ViewQueue,
199+
];
200+
201+
fn ok(r: anyhow::Result<ModerationAuthority>) -> ModerationAuthority {
202+
r.expect("expected authorization")
203+
}
204+
205+
#[test]
206+
fn community_owner_authorized_for_everything() {
207+
for action in ALL_ACTIONS {
208+
// Even against another owner/admin target: the owner has no guard rail.
209+
assert_eq!(
210+
ok(decide_authority(Some("owner"), Some("admin"), None, action)),
211+
ModerationAuthority::CommunityOwner,
212+
"owner must be authorized for {action:?}"
213+
);
214+
}
215+
}
216+
217+
#[test]
218+
fn community_admin_authorized_against_non_privileged_targets() {
219+
for action in ALL_ACTIONS {
220+
// Target is a plain member (or unknown) — admin holds every capability.
221+
assert_eq!(
222+
ok(decide_authority(
223+
Some("admin"),
224+
Some("member"),
225+
None,
226+
action
227+
)),
228+
ModerationAuthority::CommunityAdmin,
229+
"admin must be authorized for {action:?} against a member"
230+
);
231+
assert_eq!(
232+
ok(decide_authority(Some("admin"), None, None, action)),
233+
ModerationAuthority::CommunityAdmin,
234+
"admin must be authorized for {action:?} against a non-member"
235+
);
236+
}
237+
}
238+
239+
#[test]
240+
fn admin_cannot_ban_or_timeout_owner_or_fellow_admin() {
241+
for target in ["owner", "admin"] {
242+
for action in [ModerationAction::Ban, ModerationAction::Timeout] {
243+
assert!(
244+
decide_authority(Some("admin"), Some(target), None, action).is_err(),
245+
"admin must not {action:?} a community {target}"
246+
);
247+
}
248+
}
249+
}
250+
251+
#[test]
252+
fn admin_can_ban_or_timeout_a_non_member_target() {
253+
// A target with no `relay_members` row (e.g. a drive-by spammer who
254+
// already left) must still be bannable — the guard trips on a privileged
255+
// *role*, never on a missing row.
256+
for action in [ModerationAction::Ban, ModerationAction::Timeout] {
257+
assert_eq!(
258+
ok(decide_authority(Some("admin"), None, None, action)),
259+
ModerationAuthority::CommunityAdmin,
260+
"admin must be able to {action:?} a non-member target"
261+
);
262+
// A plain member target is likewise fair game.
263+
assert_eq!(
264+
ok(decide_authority(
265+
Some("admin"),
266+
Some("member"),
267+
None,
268+
action
269+
)),
270+
ModerationAuthority::CommunityAdmin,
271+
"admin must be able to {action:?} a plain member"
272+
);
273+
}
274+
}
275+
276+
#[test]
277+
fn admin_guard_rail_is_scoped_to_ban_and_timeout() {
278+
// Reversals and non-restriction actions against an admin target are allowed —
279+
// the guard rail protects against *applying* a restriction, not lifting one.
280+
for action in [
281+
ModerationAction::Unban,
282+
ModerationAction::Untimeout,
283+
ModerationAction::DeleteMessage,
284+
ModerationAction::Kick,
285+
ModerationAction::ResolveReport,
286+
ModerationAction::ViewQueue,
287+
] {
288+
assert_eq!(
289+
ok(decide_authority(Some("admin"), Some("admin"), None, action)),
290+
ModerationAuthority::CommunityAdmin,
291+
"admin must be authorized for {action:?} even against an admin target"
292+
);
293+
}
294+
}
295+
296+
#[test]
297+
fn channel_role_covers_only_delete_and_kick() {
298+
for role in ["owner", "admin"] {
299+
for action in [ModerationAction::DeleteMessage, ModerationAction::Kick] {
300+
assert_eq!(
301+
ok(decide_authority(None, None, Some(role), action)),
302+
ModerationAuthority::ChannelRole,
303+
"channel {role} must be authorized for {action:?}"
304+
);
305+
}
306+
// No community authority: channel role does NOT grant community actions.
307+
for action in [
308+
ModerationAction::Ban,
309+
ModerationAction::Timeout,
310+
ModerationAction::Unban,
311+
ModerationAction::Untimeout,
312+
ModerationAction::ResolveReport,
313+
ModerationAction::ViewQueue,
314+
] {
315+
assert!(
316+
decide_authority(None, None, Some(role), action).is_err(),
317+
"channel {role} must NOT be authorized for community action {action:?}"
318+
);
319+
}
320+
}
321+
}
322+
323+
#[test]
324+
fn plain_channel_member_and_stranger_are_denied() {
325+
for action in ALL_ACTIONS {
326+
assert!(
327+
decide_authority(None, None, Some("member"), action).is_err(),
328+
"channel member must be denied {action:?}"
329+
);
330+
assert!(
331+
decide_authority(None, None, None, action).is_err(),
332+
"user with no role must be denied {action:?}"
333+
);
334+
}
335+
}
92336
}

0 commit comments

Comments
 (0)