@@ -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.
8383pub 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