Skip to main content

max / makenotwork

15.9 KB · 456 lines History Blame Raw
1 //! Authorization + enforcement: roles, ban/mute/suspension gates, community
2 //! state machine, and the platform-admin (superadmin) checks.
3
4 use axum::{
5 http::StatusCode,
6 response::{IntoResponse, Response},
7 };
8 use uuid::Uuid;
9
10 use mt_core::types::{CommunityRole, CommunityState};
11
12 use super::get_community;
13 use crate::AppState;
14 use crate::auth;
15
16 /// Fetch a user's role in a community, returning 500 on DB error.
17 #[tracing::instrument(skip_all)]
18 pub(crate) async fn get_role(
19 db: &sqlx::PgPool,
20 user_id: Uuid,
21 community_id: Uuid,
22 ) -> Result<Option<CommunityRole>, Response> {
23 mt_db::queries::get_user_role(db, user_id, community_id)
24 .await
25 .map_err(|e| {
26 tracing::error!(error = ?e, "db error fetching role");
27 crate::error_page::internal_error()
28 })
29 }
30
31 // Permission helpers
32
33 /// Is this user a moderator or owner in the community?
34 pub(crate) fn is_mod_or_owner(role: Option<CommunityRole>) -> bool {
35 role.is_some_and(mt_core::types::CommunityRole::is_mod_or_owner)
36 }
37
38 /// Is this user an owner of the community?
39 pub(crate) fn is_owner(role: Option<CommunityRole>) -> bool {
40 role.is_some_and(mt_core::types::CommunityRole::is_owner)
41 }
42
43 // Enforcement helpers
44
45 /// Check community suspension + user ban. For read handlers.
46 #[tracing::instrument(skip_all)]
47 pub(crate) async fn check_community_access(
48 db: &sqlx::PgPool,
49 community: &mt_db::queries::CommunityRow,
50 user_id: Option<Uuid>,
51 ) -> Result<(), Response> {
52 if community.suspended_at.is_some() {
53 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
54 }
55 if let Some(uid) = user_id {
56 let banned = mt_db::queries::is_user_banned(db, community.id, uid)
57 .await
58 .map_err(|e| {
59 tracing::error!(error = ?e, "db error checking ban status");
60 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
61 })?;
62 if banned {
63 return Err(
64 (StatusCode::FORBIDDEN, "You are banned from this community.").into_response(),
65 );
66 }
67 }
68 Ok(())
69 }
70
71 /// Why a user in good standing would not be, evaluated against one community.
72 ///
73 /// Exists so the predicate set has exactly one definition. Forum writes render
74 /// it as an HTTP 403 with a specific message ([`check_write_access`]); chat
75 /// renders it as a `livechat::DenyReason` on an SSE-backed send path, which
76 /// needs the reason as a value rather than as a `Response`. Two renderings, one
77 /// set of checks: a predicate added here reaches both, and chat cannot drift
78 /// into permitting a write the forum refuses.
79 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
80 pub(crate) enum WriteDenial {
81 CommunitySuspended,
82 UserSuspended,
83 Banned,
84 Muted,
85 }
86
87 impl WriteDenial {
88 /// What the user is told. Distinct per variant: "you are muted" and "you
89 /// are banned" are different facts and a user acts on them differently.
90 pub(crate) fn message(self) -> &'static str {
91 match self {
92 Self::CommunitySuspended => "This community has been suspended.",
93 Self::UserSuspended => "Your account has been suspended.",
94 Self::Banned => "You are banned from this community.",
95 Self::Muted => "You are muted in this community.",
96 }
97 }
98 }
99
100 /// Evaluate community suspension + platform suspension + user ban + user mute.
101 ///
102 /// `Ok(None)` means the user is in good standing. Checks run cheapest-first and
103 /// short-circuit, so a suspended community costs no queries at all.
104 #[tracing::instrument(skip_all)]
105 pub(crate) async fn evaluate_write_access(
106 db: &sqlx::PgPool,
107 community_id: Uuid,
108 user_id: Uuid,
109 community_suspended: bool,
110 ) -> Result<Option<WriteDenial>, sqlx::Error> {
111 if community_suspended {
112 return Ok(Some(WriteDenial::CommunitySuspended));
113 }
114 if mt_db::queries::is_user_suspended(db, user_id).await? {
115 return Ok(Some(WriteDenial::UserSuspended));
116 }
117 if mt_db::queries::is_user_banned(db, community_id, user_id).await? {
118 return Ok(Some(WriteDenial::Banned));
119 }
120 if mt_db::queries::is_user_muted(db, community_id, user_id).await? {
121 return Ok(Some(WriteDenial::Muted));
122 }
123 Ok(None)
124 }
125
126 /// Check community suspension + platform suspension + user ban + user mute. For write handlers.
127 #[tracing::instrument(skip_all)]
128 pub(crate) async fn check_write_access(
129 db: &sqlx::PgPool,
130 community_id: Uuid,
131 user_id: Uuid,
132 community_suspended: bool,
133 ) -> Result<(), Response> {
134 let denial = evaluate_write_access(db, community_id, user_id, community_suspended)
135 .await
136 .map_err(|e| {
137 tracing::error!(error = ?e, "db error checking write access");
138 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
139 })?;
140
141 match denial {
142 None => Ok(()),
143 Some(d) => Err((StatusCode::FORBIDDEN, d.message()).into_response()),
144 }
145 }
146
147 /// Helper: fetch community + verify owner role, returning 403 if not owner.
148 ///
149 /// Also rejects a suspended community (403): a suspended-community owner must not
150 /// be able to reach any settings route, GET or POST. All `require_owner` callers
151 /// are owner-only settings handlers, so the suspension gate belongs here rather
152 /// than duplicated per handler. The platform admin acts on suspended communities
153 /// via the `_admin` routes, not these.
154 #[tracing::instrument(skip_all)]
155 pub(crate) async fn require_owner(
156 state: &AppState,
157 slug: &str,
158 user: &auth::SessionUser,
159 ) -> Result<mt_db::queries::CommunityRow, Response> {
160 let community = get_community(&state.db, slug).await?;
161 let role = get_role(&state.db, user.user_id, community.id).await?;
162 if !is_owner(role) {
163 return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
164 }
165 if community.suspended_at.is_some() {
166 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
167 }
168 Ok(community)
169 }
170
171 /// Helper: fetch community + verify mod_or_owner role, returning 403 if not.
172 ///
173 /// Also rejects a suspended community (403). A suspended community is frozen to
174 /// its own owner and mods: this gate fronts every mod-mutation handler
175 /// (ban/unban/mute/unmute, flag dismiss/remove) plus the moderation page views,
176 /// so folding the suspension check in here, rather than per handler, means a
177 /// newly-added moderation route cannot drift from it. The platform admin acts
178 /// on a suspended community through the `_admin` routes /
179 /// `require_mod_or_superadmin`, never
180 /// this helper (a non-member admin's role is `None` and fails `is_mod_or_owner`
181 /// regardless), so nothing here can lock the admin out.
182 #[tracing::instrument(skip_all)]
183 pub(crate) async fn require_mod_or_owner(
184 state: &AppState,
185 slug: &str,
186 user: &auth::SessionUser,
187 ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> {
188 let community = get_community(&state.db, slug).await?;
189 let role = get_role(&state.db, user.user_id, community.id).await?;
190 if !is_mod_or_owner(role) {
191 return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
192 }
193 if community.suspended_at.is_some() {
194 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
195 }
196 Ok((community, role))
197 }
198
199 // Superadmin authorization
200
201 /// Whether `user` is the configured platform admin.
202 ///
203 /// Platform admin is a single user (env var `PLATFORM_ADMIN_ID`); a real
204 /// permissions system is deferred. Wiki note `mt-moderation-policy`, section
205 /// "Implementation Status", tracks what enforcement is built and what is not.
206 pub(crate) fn is_platform_admin(state: &AppState, user: &auth::SessionUser) -> bool {
207 state
208 .config
209 .platform_admin_id
210 .is_some_and(|id| id == user.user_id)
211 }
212
213 /// True if the user can perform mod actions in this community: either a
214 /// community Owner/Moderator, or the platform admin (who can act on any
215 /// community). Used by [`check_community_state`] and by the state-change route.
216 pub(crate) fn is_mod_or_superadmin(
217 state: &AppState,
218 user: &auth::SessionUser,
219 role: Option<CommunityRole>,
220 ) -> bool {
221 is_mod_or_owner(role) || is_platform_admin(state, user)
222 }
223
224 /// Fetch community + verify the user is a mod, owner, or platform admin.
225 ///
226 /// Returns `(community, role)`, `role` is `None` when the user is the platform
227 /// admin but holds no role in this specific community.
228 #[tracing::instrument(skip_all)]
229 pub(crate) async fn require_mod_or_superadmin(
230 state: &AppState,
231 slug: &str,
232 user: &auth::SessionUser,
233 ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> {
234 let community = get_community(&state.db, slug).await?;
235 let role = get_role(&state.db, user.user_id, community.id).await?;
236 if !is_mod_or_superadmin(state, user, role) {
237 return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
238 }
239 Ok((community, role))
240 }
241
242 // Community state enforcement
243
244 /// Whether a write attempt is starting a new thread or extending an existing
245 /// one. Restricted communities block `NewThread` for non-mods but still accept
246 /// `ContinueExisting` writes.
247 #[derive(Debug, Clone, Copy)]
248 pub(crate) enum WriteScope {
249 NewThread,
250 ContinueExisting,
251 }
252
253 /// Convenience: combine role lookup with [`check_community_state`]. Use this
254 /// in write handlers that don't already need the role for other purposes.
255 #[tracing::instrument(skip_all)]
256 pub(crate) async fn check_write_state(
257 state: &AppState,
258 community: &mt_db::queries::CommunityRow,
259 user: &auth::SessionUser,
260 scope: WriteScope,
261 ) -> Result<(), Response> {
262 let role = get_role(&state.db, user.user_id, community.id).await?;
263 let is_mod_or_super = is_mod_or_superadmin(state, user, role);
264 check_community_state(community.state, scope, is_mod_or_super)
265 }
266
267 /// Pure decision for [`check_community_state`]: returns `None` when the write
268 /// is allowed, `Some(message)` when denied (message is what the user sees).
269 ///
270 /// Mods/owners and the platform admin bypass restrictions. Members go through
271 /// the state's `allows_*` predicates.
272 pub(crate) fn community_state_denial_message(
273 community_state: CommunityState,
274 scope: WriteScope,
275 is_mod_or_super: bool,
276 ) -> Option<&'static str> {
277 if is_mod_or_super {
278 return None;
279 }
280 let allowed = match scope {
281 WriteScope::NewThread => community_state.allows_new_threads_for_members(),
282 WriteScope::ContinueExisting => community_state.allows_writes_for_members(),
283 };
284 if allowed {
285 return None;
286 }
287 Some(match (community_state, scope) {
288 (CommunityState::Restricted, WriteScope::NewThread) => {
289 "New threads are restricted in this community."
290 }
291 (CommunityState::Frozen, _) => "This community is frozen.",
292 (CommunityState::Archived, _) => "This community is archived.",
293 _ => "Action not allowed in the community's current state.",
294 })
295 }
296
297 /// Gate a write against the community's [`CommunityState`].
298 ///
299 /// Mods/owners and the platform admin bypass all state restrictions. Members
300 /// follow the state's `allows_*` predicates. Returns 403 with a state-specific
301 /// message on denial. Independent of [`check_write_access`] (suspension/ban/mute);
302 /// call both in write handlers.
303 #[allow(clippy::result_large_err)]
304 pub(crate) fn check_community_state(
305 community_state: CommunityState,
306 scope: WriteScope,
307 is_mod_or_super: bool,
308 ) -> Result<(), Response> {
309 match community_state_denial_message(community_state, scope, is_mod_or_super) {
310 None => Ok(()),
311 Some(msg) => Err((StatusCode::FORBIDDEN, msg).into_response()),
312 }
313 }
314
315 #[cfg(test)]
316 mod authz_tests {
317 use super::*;
318
319 // --- is_mod_or_owner / is_owner Option wrappers
320
321 #[test]
322 fn is_mod_or_owner_none_role_is_false() {
323 assert!(!is_mod_or_owner(None));
324 }
325
326 #[test]
327 fn is_mod_or_owner_some_roles() {
328 assert!(is_mod_or_owner(Some(CommunityRole::Owner)));
329 assert!(is_mod_or_owner(Some(CommunityRole::Moderator)));
330 assert!(!is_mod_or_owner(Some(CommunityRole::Member)));
331 }
332
333 #[test]
334 fn is_owner_none_role_is_false() {
335 assert!(!is_owner(None));
336 }
337
338 #[test]
339 fn is_owner_some_roles() {
340 assert!(is_owner(Some(CommunityRole::Owner)));
341 assert!(!is_owner(Some(CommunityRole::Moderator)));
342 assert!(!is_owner(Some(CommunityRole::Member)));
343 }
344
345 #[test]
346 fn state_denial_mod_bypasses_everything() {
347 // Pins the `if is_mod_or_super { return None; }` early return, a mod
348 // can write to Archived/Frozen/Restricted communities for any scope.
349 for state in [
350 CommunityState::Active,
351 CommunityState::Restricted,
352 CommunityState::Frozen,
353 CommunityState::Archived,
354 ] {
355 for scope in [WriteScope::NewThread, WriteScope::ContinueExisting] {
356 assert_eq!(
357 community_state_denial_message(state, scope, true),
358 None,
359 "mod must bypass: state={state:?} scope={scope:?}"
360 );
361 }
362 }
363 }
364
365 #[test]
366 fn state_denial_active_allows_members_both_scopes() {
367 assert_eq!(
368 community_state_denial_message(CommunityState::Active, WriteScope::NewThread, false),
369 None
370 );
371 assert_eq!(
372 community_state_denial_message(
373 CommunityState::Active,
374 WriteScope::ContinueExisting,
375 false
376 ),
377 None
378 );
379 }
380
381 #[test]
382 fn state_denial_restricted_blocks_new_thread_only() {
383 // Restricted: members can reply but not start threads.
384 assert_eq!(
385 community_state_denial_message(
386 CommunityState::Restricted,
387 WriteScope::NewThread,
388 false
389 ),
390 Some("New threads are restricted in this community.")
391 );
392 assert_eq!(
393 community_state_denial_message(
394 CommunityState::Restricted,
395 WriteScope::ContinueExisting,
396 false
397 ),
398 None,
399 "Restricted must allow replies"
400 );
401 }
402
403 #[test]
404 fn state_denial_frozen_blocks_all_member_writes() {
405 assert_eq!(
406 community_state_denial_message(CommunityState::Frozen, WriteScope::NewThread, false),
407 Some("This community is frozen.")
408 );
409 assert_eq!(
410 community_state_denial_message(
411 CommunityState::Frozen,
412 WriteScope::ContinueExisting,
413 false
414 ),
415 Some("This community is frozen.")
416 );
417 }
418
419 #[test]
420 fn state_denial_archived_blocks_all_member_writes() {
421 assert_eq!(
422 community_state_denial_message(CommunityState::Archived, WriteScope::NewThread, false),
423 Some("This community is archived.")
424 );
425 assert_eq!(
426 community_state_denial_message(
427 CommunityState::Archived,
428 WriteScope::ContinueExisting,
429 false
430 ),
431 Some("This community is archived.")
432 );
433 }
434
435 #[test]
436 fn state_denial_message_distinct_per_state() {
437 // Distinct error text per state, mutations that swap arms (e.g.
438 // Frozen → Archived) would surface here.
439 let frozen =
440 community_state_denial_message(CommunityState::Frozen, WriteScope::NewThread, false)
441 .unwrap();
442 let archived =
443 community_state_denial_message(CommunityState::Archived, WriteScope::NewThread, false)
444 .unwrap();
445 let restricted = community_state_denial_message(
446 CommunityState::Restricted,
447 WriteScope::NewThread,
448 false,
449 )
450 .unwrap();
451 assert_ne!(frozen, archived);
452 assert_ne!(frozen, restricted);
453 assert_ne!(archived, restricted);
454 }
455 }
456