Skip to main content

max / makenotwork

16.0 KB · 457 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 can't reinstate the drift the fuzz found (the
178 /// 403 previously lived only on the settings/moderation *GET* pages while every
179 /// write handler gated on role alone). The platform admin acts on a suspended
180 /// community through the `_admin` routes / `require_mod_or_superadmin`, never
181 /// this helper (a non-member admin's role is `None` and fails `is_mod_or_owner`
182 /// regardless), so nothing here can lock the admin out.
183 #[tracing::instrument(skip_all)]
184 pub(crate) async fn require_mod_or_owner(
185 state: &AppState,
186 slug: &str,
187 user: &auth::SessionUser,
188 ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> {
189 let community = get_community(&state.db, slug).await?;
190 let role = get_role(&state.db, user.user_id, community.id).await?;
191 if !is_mod_or_owner(role) {
192 return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
193 }
194 if community.suspended_at.is_some() {
195 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
196 }
197 Ok((community, role))
198 }
199
200 // Superadmin authorization
201
202 /// Whether `user` is the configured platform admin.
203 ///
204 /// Platform admin is a single user (env var `PLATFORM_ADMIN_ID`); a real
205 /// permissions system is deferred. Wiki note `mt-moderation-policy`, section
206 /// "Implementation Status", tracks what enforcement is built and what is not.
207 pub(crate) fn is_platform_admin(state: &AppState, user: &auth::SessionUser) -> bool {
208 state
209 .config
210 .platform_admin_id
211 .is_some_and(|id| id == user.user_id)
212 }
213
214 /// True if the user can perform mod actions in this community: either a
215 /// community Owner/Moderator, or the platform admin (who can act on any
216 /// community). Used by [`check_community_state`] and by the state-change route.
217 pub(crate) fn is_mod_or_superadmin(
218 state: &AppState,
219 user: &auth::SessionUser,
220 role: Option<CommunityRole>,
221 ) -> bool {
222 is_mod_or_owner(role) || is_platform_admin(state, user)
223 }
224
225 /// Fetch community + verify the user is a mod, owner, or platform admin.
226 ///
227 /// Returns `(community, role)`, `role` is `None` when the user is the platform
228 /// admin but holds no role in this specific community.
229 #[tracing::instrument(skip_all)]
230 pub(crate) async fn require_mod_or_superadmin(
231 state: &AppState,
232 slug: &str,
233 user: &auth::SessionUser,
234 ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> {
235 let community = get_community(&state.db, slug).await?;
236 let role = get_role(&state.db, user.user_id, community.id).await?;
237 if !is_mod_or_superadmin(state, user, role) {
238 return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
239 }
240 Ok((community, role))
241 }
242
243 // Community state enforcement
244
245 /// Whether a write attempt is starting a new thread or extending an existing
246 /// one. Restricted communities block `NewThread` for non-mods but still accept
247 /// `ContinueExisting` writes.
248 #[derive(Debug, Clone, Copy)]
249 pub(crate) enum WriteScope {
250 NewThread,
251 ContinueExisting,
252 }
253
254 /// Convenience: combine role lookup with [`check_community_state`]. Use this
255 /// in write handlers that don't already need the role for other purposes.
256 #[tracing::instrument(skip_all)]
257 pub(crate) async fn check_write_state(
258 state: &AppState,
259 community: &mt_db::queries::CommunityRow,
260 user: &auth::SessionUser,
261 scope: WriteScope,
262 ) -> Result<(), Response> {
263 let role = get_role(&state.db, user.user_id, community.id).await?;
264 let is_mod_or_super = is_mod_or_superadmin(state, user, role);
265 check_community_state(community.state, scope, is_mod_or_super)
266 }
267
268 /// Pure decision for [`check_community_state`]: returns `None` when the write
269 /// is allowed, `Some(message)` when denied (message is what the user sees).
270 ///
271 /// Mods/owners and the platform admin bypass restrictions. Members go through
272 /// the state's `allows_*` predicates.
273 pub(crate) fn community_state_denial_message(
274 community_state: CommunityState,
275 scope: WriteScope,
276 is_mod_or_super: bool,
277 ) -> Option<&'static str> {
278 if is_mod_or_super {
279 return None;
280 }
281 let allowed = match scope {
282 WriteScope::NewThread => community_state.allows_new_threads_for_members(),
283 WriteScope::ContinueExisting => community_state.allows_writes_for_members(),
284 };
285 if allowed {
286 return None;
287 }
288 Some(match (community_state, scope) {
289 (CommunityState::Restricted, WriteScope::NewThread) => {
290 "New threads are restricted in this community."
291 }
292 (CommunityState::Frozen, _) => "This community is frozen.",
293 (CommunityState::Archived, _) => "This community is archived.",
294 _ => "Action not allowed in the community's current state.",
295 })
296 }
297
298 /// Gate a write against the community's [`CommunityState`].
299 ///
300 /// Mods/owners and the platform admin bypass all state restrictions. Members
301 /// follow the state's `allows_*` predicates. Returns 403 with a state-specific
302 /// message on denial. Independent of [`check_write_access`] (suspension/ban/mute);
303 /// call both in write handlers.
304 #[allow(clippy::result_large_err)]
305 pub(crate) fn check_community_state(
306 community_state: CommunityState,
307 scope: WriteScope,
308 is_mod_or_super: bool,
309 ) -> Result<(), Response> {
310 match community_state_denial_message(community_state, scope, is_mod_or_super) {
311 None => Ok(()),
312 Some(msg) => Err((StatusCode::FORBIDDEN, msg).into_response()),
313 }
314 }
315
316 #[cfg(test)]
317 mod authz_tests {
318 use super::*;
319
320 // --- is_mod_or_owner / is_owner Option wrappers
321
322 #[test]
323 fn is_mod_or_owner_none_role_is_false() {
324 assert!(!is_mod_or_owner(None));
325 }
326
327 #[test]
328 fn is_mod_or_owner_some_roles() {
329 assert!(is_mod_or_owner(Some(CommunityRole::Owner)));
330 assert!(is_mod_or_owner(Some(CommunityRole::Moderator)));
331 assert!(!is_mod_or_owner(Some(CommunityRole::Member)));
332 }
333
334 #[test]
335 fn is_owner_none_role_is_false() {
336 assert!(!is_owner(None));
337 }
338
339 #[test]
340 fn is_owner_some_roles() {
341 assert!(is_owner(Some(CommunityRole::Owner)));
342 assert!(!is_owner(Some(CommunityRole::Moderator)));
343 assert!(!is_owner(Some(CommunityRole::Member)));
344 }
345
346 #[test]
347 fn state_denial_mod_bypasses_everything() {
348 // Pins the `if is_mod_or_super { return None; }` early return, a mod
349 // can write to Archived/Frozen/Restricted communities for any scope.
350 for state in [
351 CommunityState::Active,
352 CommunityState::Restricted,
353 CommunityState::Frozen,
354 CommunityState::Archived,
355 ] {
356 for scope in [WriteScope::NewThread, WriteScope::ContinueExisting] {
357 assert_eq!(
358 community_state_denial_message(state, scope, true),
359 None,
360 "mod must bypass: state={state:?} scope={scope:?}"
361 );
362 }
363 }
364 }
365
366 #[test]
367 fn state_denial_active_allows_members_both_scopes() {
368 assert_eq!(
369 community_state_denial_message(CommunityState::Active, WriteScope::NewThread, false),
370 None
371 );
372 assert_eq!(
373 community_state_denial_message(
374 CommunityState::Active,
375 WriteScope::ContinueExisting,
376 false
377 ),
378 None
379 );
380 }
381
382 #[test]
383 fn state_denial_restricted_blocks_new_thread_only() {
384 // Restricted: members can reply but not start threads.
385 assert_eq!(
386 community_state_denial_message(
387 CommunityState::Restricted,
388 WriteScope::NewThread,
389 false
390 ),
391 Some("New threads are restricted in this community.")
392 );
393 assert_eq!(
394 community_state_denial_message(
395 CommunityState::Restricted,
396 WriteScope::ContinueExisting,
397 false
398 ),
399 None,
400 "Restricted must allow replies"
401 );
402 }
403
404 #[test]
405 fn state_denial_frozen_blocks_all_member_writes() {
406 assert_eq!(
407 community_state_denial_message(CommunityState::Frozen, WriteScope::NewThread, false),
408 Some("This community is frozen.")
409 );
410 assert_eq!(
411 community_state_denial_message(
412 CommunityState::Frozen,
413 WriteScope::ContinueExisting,
414 false
415 ),
416 Some("This community is frozen.")
417 );
418 }
419
420 #[test]
421 fn state_denial_archived_blocks_all_member_writes() {
422 assert_eq!(
423 community_state_denial_message(CommunityState::Archived, WriteScope::NewThread, false),
424 Some("This community is archived.")
425 );
426 assert_eq!(
427 community_state_denial_message(
428 CommunityState::Archived,
429 WriteScope::ContinueExisting,
430 false
431 ),
432 Some("This community is archived.")
433 );
434 }
435
436 #[test]
437 fn state_denial_message_distinct_per_state() {
438 // Distinct error text per state, mutations that swap arms (e.g.
439 // Frozen → Archived) would surface here.
440 let frozen =
441 community_state_denial_message(CommunityState::Frozen, WriteScope::NewThread, false)
442 .unwrap();
443 let archived =
444 community_state_denial_message(CommunityState::Archived, WriteScope::NewThread, false)
445 .unwrap();
446 let restricted = community_state_denial_message(
447 CommunityState::Restricted,
448 WriteScope::NewThread,
449 false,
450 )
451 .unwrap();
452 assert_ne!(frozen, archived);
453 assert_ne!(frozen, restricted);
454 assert_ne!(archived, restricted);
455 }
456 }
457