Skip to main content

max / makenotwork

14.8 KB · 423 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 /// Check community suspension + platform suspension + user ban + user mute. For write handlers.
72 #[tracing::instrument(skip_all)]
73 pub(crate) async fn check_write_access(
74 db: &sqlx::PgPool,
75 community_id: Uuid,
76 user_id: Uuid,
77 community_suspended: bool,
78 ) -> Result<(), Response> {
79 if community_suspended {
80 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
81 }
82 let suspended = mt_db::queries::is_user_suspended(db, user_id)
83 .await
84 .map_err(|e| {
85 tracing::error!(error = ?e, "db error checking user suspension");
86 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
87 })?;
88 if suspended {
89 return Err((StatusCode::FORBIDDEN, "Your account has been suspended.").into_response());
90 }
91 let banned = mt_db::queries::is_user_banned(db, community_id, user_id)
92 .await
93 .map_err(|e| {
94 tracing::error!(error = ?e, "db error checking ban status");
95 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
96 })?;
97 if banned {
98 return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response());
99 }
100 let muted = mt_db::queries::is_user_muted(db, community_id, user_id)
101 .await
102 .map_err(|e| {
103 tracing::error!(error = ?e, "db error checking mute status");
104 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
105 })?;
106 if muted {
107 return Err((StatusCode::FORBIDDEN, "You are muted in this community.").into_response());
108 }
109 Ok(())
110 }
111
112 /// Helper: fetch community + verify owner role, returning 403 if not owner.
113 ///
114 /// Also rejects a suspended community (403): a suspended-community owner must not
115 /// be able to reach any settings route, GET or POST. All `require_owner` callers
116 /// are owner-only settings handlers, so the suspension gate belongs here rather
117 /// than duplicated per handler, see `docs/audit_review.md` (settings suspended
118 /// authz gap). The platform admin acts on suspended communities via the `_admin`
119 /// routes, not these.
120 #[tracing::instrument(skip_all)]
121 pub(crate) async fn require_owner(
122 state: &AppState,
123 slug: &str,
124 user: &auth::SessionUser,
125 ) -> Result<mt_db::queries::CommunityRow, Response> {
126 let community = get_community(&state.db, slug).await?;
127 let role = get_role(&state.db, user.user_id, community.id).await?;
128 if !is_owner(role) {
129 return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
130 }
131 if community.suspended_at.is_some() {
132 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
133 }
134 Ok(community)
135 }
136
137 /// Helper: fetch community + verify mod_or_owner role, returning 403 if not.
138 ///
139 /// Also rejects a suspended community (403). A suspended community is frozen to
140 /// its own owner and mods: this gate fronts every mod-mutation handler
141 /// (ban/unban/mute/unmute, flag dismiss/remove) plus the moderation page views,
142 /// so folding the suspension check in here, rather than per handler, means a
143 /// newly-added moderation route can't reinstate the drift the fuzz found (the
144 /// 403 previously lived only on the settings/moderation *GET* pages while every
145 /// write handler gated on role alone). The platform admin acts on a suspended
146 /// community through the `_admin` routes / `require_mod_or_superadmin`, never
147 /// this helper (a non-member admin's role is `None` and fails `is_mod_or_owner`
148 /// regardless), so nothing here can lock the admin out.
149 #[tracing::instrument(skip_all)]
150 pub(crate) async fn require_mod_or_owner(
151 state: &AppState,
152 slug: &str,
153 user: &auth::SessionUser,
154 ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> {
155 let community = get_community(&state.db, slug).await?;
156 let role = get_role(&state.db, user.user_id, community.id).await?;
157 if !is_mod_or_owner(role) {
158 return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
159 }
160 if community.suspended_at.is_some() {
161 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
162 }
163 Ok((community, role))
164 }
165
166 // Superadmin authorization
167
168 /// Whether `user` is the configured platform admin.
169 ///
170 /// Platform admin is a single user (env var `PLATFORM_ADMIN_ID`); a real
171 /// permissions system is deferred. See `docs/todo.md` § Community Moderation
172 /// Enforcement.
173 pub(crate) fn is_platform_admin(state: &AppState, user: &auth::SessionUser) -> bool {
174 state
175 .config
176 .platform_admin_id
177 .is_some_and(|id| id == user.user_id)
178 }
179
180 /// True if the user can perform mod actions in this community: either a
181 /// community Owner/Moderator, or the platform admin (who can act on any
182 /// community). Used by [`check_community_state`] and by the state-change route.
183 pub(crate) fn is_mod_or_superadmin(
184 state: &AppState,
185 user: &auth::SessionUser,
186 role: Option<CommunityRole>,
187 ) -> bool {
188 is_mod_or_owner(role) || is_platform_admin(state, user)
189 }
190
191 /// Fetch community + verify the user is a mod, owner, or platform admin.
192 ///
193 /// Returns `(community, role)`, `role` is `None` when the user is the platform
194 /// admin but holds no role in this specific community.
195 #[tracing::instrument(skip_all)]
196 pub(crate) async fn require_mod_or_superadmin(
197 state: &AppState,
198 slug: &str,
199 user: &auth::SessionUser,
200 ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> {
201 let community = get_community(&state.db, slug).await?;
202 let role = get_role(&state.db, user.user_id, community.id).await?;
203 if !is_mod_or_superadmin(state, user, role) {
204 return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
205 }
206 Ok((community, role))
207 }
208
209 // Community state enforcement
210
211 /// Whether a write attempt is starting a new thread or extending an existing
212 /// one. Restricted communities block `NewThread` for non-mods but still accept
213 /// `ContinueExisting` writes.
214 #[derive(Debug, Clone, Copy)]
215 pub(crate) enum WriteScope {
216 NewThread,
217 ContinueExisting,
218 }
219
220 /// Convenience: combine role lookup with [`check_community_state`]. Use this
221 /// in write handlers that don't already need the role for other purposes.
222 #[tracing::instrument(skip_all)]
223 pub(crate) async fn check_write_state(
224 state: &AppState,
225 community: &mt_db::queries::CommunityRow,
226 user: &auth::SessionUser,
227 scope: WriteScope,
228 ) -> Result<(), Response> {
229 let role = get_role(&state.db, user.user_id, community.id).await?;
230 let is_mod_or_super = is_mod_or_superadmin(state, user, role);
231 check_community_state(community.state, scope, is_mod_or_super)
232 }
233
234 /// Pure decision for [`check_community_state`]: returns `None` when the write
235 /// is allowed, `Some(message)` when denied (message is what the user sees).
236 ///
237 /// Mods/owners and the platform admin bypass restrictions. Members go through
238 /// the state's `allows_*` predicates.
239 pub(crate) fn community_state_denial_message(
240 community_state: CommunityState,
241 scope: WriteScope,
242 is_mod_or_super: bool,
243 ) -> Option<&'static str> {
244 if is_mod_or_super {
245 return None;
246 }
247 let allowed = match scope {
248 WriteScope::NewThread => community_state.allows_new_threads_for_members(),
249 WriteScope::ContinueExisting => community_state.allows_writes_for_members(),
250 };
251 if allowed {
252 return None;
253 }
254 Some(match (community_state, scope) {
255 (CommunityState::Restricted, WriteScope::NewThread) => {
256 "New threads are restricted in this community."
257 }
258 (CommunityState::Frozen, _) => "This community is frozen.",
259 (CommunityState::Archived, _) => "This community is archived.",
260 _ => "Action not allowed in the community's current state.",
261 })
262 }
263
264 /// Gate a write against the community's [`CommunityState`].
265 ///
266 /// Mods/owners and the platform admin bypass all state restrictions. Members
267 /// follow the state's `allows_*` predicates. Returns 403 with a state-specific
268 /// message on denial. Independent of [`check_write_access`] (suspension/ban/mute);
269 /// call both in write handlers.
270 #[allow(clippy::result_large_err)]
271 pub(crate) fn check_community_state(
272 community_state: CommunityState,
273 scope: WriteScope,
274 is_mod_or_super: bool,
275 ) -> Result<(), Response> {
276 match community_state_denial_message(community_state, scope, is_mod_or_super) {
277 None => Ok(()),
278 Some(msg) => Err((StatusCode::FORBIDDEN, msg).into_response()),
279 }
280 }
281
282 #[cfg(test)]
283 mod authz_tests {
284 use super::*;
285
286 // --- is_mod_or_owner / is_owner Option wrappers
287
288 #[test]
289 fn is_mod_or_owner_none_role_is_false() {
290 assert!(!is_mod_or_owner(None));
291 }
292
293 #[test]
294 fn is_mod_or_owner_some_roles() {
295 assert!(is_mod_or_owner(Some(CommunityRole::Owner)));
296 assert!(is_mod_or_owner(Some(CommunityRole::Moderator)));
297 assert!(!is_mod_or_owner(Some(CommunityRole::Member)));
298 }
299
300 #[test]
301 fn is_owner_none_role_is_false() {
302 assert!(!is_owner(None));
303 }
304
305 #[test]
306 fn is_owner_some_roles() {
307 assert!(is_owner(Some(CommunityRole::Owner)));
308 assert!(!is_owner(Some(CommunityRole::Moderator)));
309 assert!(!is_owner(Some(CommunityRole::Member)));
310 }
311
312 #[test]
313 fn state_denial_mod_bypasses_everything() {
314 // Pins the `if is_mod_or_super { return None; }` early return, a mod
315 // can write to Archived/Frozen/Restricted communities for any scope.
316 for state in [
317 CommunityState::Active,
318 CommunityState::Restricted,
319 CommunityState::Frozen,
320 CommunityState::Archived,
321 ] {
322 for scope in [WriteScope::NewThread, WriteScope::ContinueExisting] {
323 assert_eq!(
324 community_state_denial_message(state, scope, true),
325 None,
326 "mod must bypass: state={state:?} scope={scope:?}"
327 );
328 }
329 }
330 }
331
332 #[test]
333 fn state_denial_active_allows_members_both_scopes() {
334 assert_eq!(
335 community_state_denial_message(CommunityState::Active, WriteScope::NewThread, false),
336 None
337 );
338 assert_eq!(
339 community_state_denial_message(
340 CommunityState::Active,
341 WriteScope::ContinueExisting,
342 false
343 ),
344 None
345 );
346 }
347
348 #[test]
349 fn state_denial_restricted_blocks_new_thread_only() {
350 // Restricted: members can reply but not start threads.
351 assert_eq!(
352 community_state_denial_message(
353 CommunityState::Restricted,
354 WriteScope::NewThread,
355 false
356 ),
357 Some("New threads are restricted in this community.")
358 );
359 assert_eq!(
360 community_state_denial_message(
361 CommunityState::Restricted,
362 WriteScope::ContinueExisting,
363 false
364 ),
365 None,
366 "Restricted must allow replies"
367 );
368 }
369
370 #[test]
371 fn state_denial_frozen_blocks_all_member_writes() {
372 assert_eq!(
373 community_state_denial_message(CommunityState::Frozen, WriteScope::NewThread, false),
374 Some("This community is frozen.")
375 );
376 assert_eq!(
377 community_state_denial_message(
378 CommunityState::Frozen,
379 WriteScope::ContinueExisting,
380 false
381 ),
382 Some("This community is frozen.")
383 );
384 }
385
386 #[test]
387 fn state_denial_archived_blocks_all_member_writes() {
388 assert_eq!(
389 community_state_denial_message(CommunityState::Archived, WriteScope::NewThread, false),
390 Some("This community is archived.")
391 );
392 assert_eq!(
393 community_state_denial_message(
394 CommunityState::Archived,
395 WriteScope::ContinueExisting,
396 false
397 ),
398 Some("This community is archived.")
399 );
400 }
401
402 #[test]
403 fn state_denial_message_distinct_per_state() {
404 // Distinct error text per state, mutations that swap arms (e.g.
405 // Frozen → Archived) would surface here.
406 let frozen =
407 community_state_denial_message(CommunityState::Frozen, WriteScope::NewThread, false)
408 .unwrap();
409 let archived =
410 community_state_denial_message(CommunityState::Archived, WriteScope::NewThread, false)
411 .unwrap();
412 let restricted = community_state_denial_message(
413 CommunityState::Restricted,
414 WriteScope::NewThread,
415 false,
416 )
417 .unwrap();
418 assert_ne!(frozen, archived);
419 assert_ne!(frozen, restricted);
420 assert_ne!(archived, restricted);
421 }
422 }
423