//! Authorization + enforcement: roles, ban/mute/suspension gates, community //! state machine, and the platform-admin (superadmin) checks. use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; use uuid::Uuid; use mt_core::types::{CommunityRole, CommunityState}; use super::get_community; use crate::AppState; use crate::auth; /// Fetch a user's role in a community, returning 500 on DB error. #[tracing::instrument(skip_all)] pub(crate) async fn get_role( db: &sqlx::PgPool, user_id: Uuid, community_id: Uuid, ) -> Result, Response> { mt_db::queries::get_user_role(db, user_id, community_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching role"); crate::error_page::internal_error() }) } // Permission helpers /// Is this user a moderator or owner in the community? pub(crate) fn is_mod_or_owner(role: Option) -> bool { role.is_some_and(mt_core::types::CommunityRole::is_mod_or_owner) } /// Is this user an owner of the community? pub(crate) fn is_owner(role: Option) -> bool { role.is_some_and(mt_core::types::CommunityRole::is_owner) } // Enforcement helpers /// Check community suspension + user ban. For read handlers. #[tracing::instrument(skip_all)] pub(crate) async fn check_community_access( db: &sqlx::PgPool, community: &mt_db::queries::CommunityRow, user_id: Option, ) -> Result<(), Response> { if community.suspended_at.is_some() { return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); } if let Some(uid) = user_id { let banned = mt_db::queries::is_user_banned(db, community.id, uid) .await .map_err(|e| { tracing::error!(error = ?e, "db error checking ban status"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; if banned { return Err( (StatusCode::FORBIDDEN, "You are banned from this community.").into_response(), ); } } Ok(()) } /// Check community suspension + platform suspension + user ban + user mute. For write handlers. #[tracing::instrument(skip_all)] pub(crate) async fn check_write_access( db: &sqlx::PgPool, community_id: Uuid, user_id: Uuid, community_suspended: bool, ) -> Result<(), Response> { if community_suspended { return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); } let suspended = mt_db::queries::is_user_suspended(db, user_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error checking user suspension"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; if suspended { return Err((StatusCode::FORBIDDEN, "Your account has been suspended.").into_response()); } let banned = mt_db::queries::is_user_banned(db, community_id, user_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error checking ban status"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; if banned { return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response()); } let muted = mt_db::queries::is_user_muted(db, community_id, user_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error checking mute status"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; if muted { return Err((StatusCode::FORBIDDEN, "You are muted in this community.").into_response()); } Ok(()) } /// Helper: fetch community + verify owner role, returning 403 if not owner. /// /// Also rejects a suspended community (403): a suspended-community owner must not /// be able to reach any settings route, GET or POST. All `require_owner` callers /// are owner-only settings handlers, so the suspension gate belongs here rather /// than duplicated per handler, see `docs/audit_review.md` (settings suspended /// authz gap). The platform admin acts on suspended communities via the `_admin` /// routes, not these. #[tracing::instrument(skip_all)] pub(crate) async fn require_owner( state: &AppState, slug: &str, user: &auth::SessionUser, ) -> Result { let community = get_community(&state.db, slug).await?; let role = get_role(&state.db, user.user_id, community.id).await?; if !is_owner(role) { return Err((StatusCode::FORBIDDEN, "Forbidden").into_response()); } if community.suspended_at.is_some() { return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); } Ok(community) } /// Helper: fetch community + verify mod_or_owner role, returning 403 if not. /// /// Also rejects a suspended community (403). A suspended community is frozen to /// its own owner and mods: this gate fronts every mod-mutation handler /// (ban/unban/mute/unmute, flag dismiss/remove) plus the moderation page views, /// so folding the suspension check in here, rather than per handler, means a /// newly-added moderation route can't reinstate the drift the fuzz found (the /// 403 previously lived only on the settings/moderation *GET* pages while every /// write handler gated on role alone). The platform admin acts on a suspended /// community through the `_admin` routes / `require_mod_or_superadmin`, never /// this helper (a non-member admin's role is `None` and fails `is_mod_or_owner` /// regardless), so nothing here can lock the admin out. #[tracing::instrument(skip_all)] pub(crate) async fn require_mod_or_owner( state: &AppState, slug: &str, user: &auth::SessionUser, ) -> Result<(mt_db::queries::CommunityRow, Option), Response> { let community = get_community(&state.db, slug).await?; let role = get_role(&state.db, user.user_id, community.id).await?; if !is_mod_or_owner(role) { return Err((StatusCode::FORBIDDEN, "Forbidden").into_response()); } if community.suspended_at.is_some() { return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); } Ok((community, role)) } // Superadmin authorization /// Whether `user` is the configured platform admin. /// /// Platform admin is a single user (env var `PLATFORM_ADMIN_ID`); a real /// permissions system is deferred. See `docs/todo.md` § Community Moderation /// Enforcement. pub(crate) fn is_platform_admin(state: &AppState, user: &auth::SessionUser) -> bool { state .config .platform_admin_id .is_some_and(|id| id == user.user_id) } /// True if the user can perform mod actions in this community: either a /// community Owner/Moderator, or the platform admin (who can act on any /// community). Used by [`check_community_state`] and by the state-change route. pub(crate) fn is_mod_or_superadmin( state: &AppState, user: &auth::SessionUser, role: Option, ) -> bool { is_mod_or_owner(role) || is_platform_admin(state, user) } /// Fetch community + verify the user is a mod, owner, or platform admin. /// /// Returns `(community, role)`, `role` is `None` when the user is the platform /// admin but holds no role in this specific community. #[tracing::instrument(skip_all)] pub(crate) async fn require_mod_or_superadmin( state: &AppState, slug: &str, user: &auth::SessionUser, ) -> Result<(mt_db::queries::CommunityRow, Option), Response> { let community = get_community(&state.db, slug).await?; let role = get_role(&state.db, user.user_id, community.id).await?; if !is_mod_or_superadmin(state, user, role) { return Err((StatusCode::FORBIDDEN, "Forbidden").into_response()); } Ok((community, role)) } // Community state enforcement /// Whether a write attempt is starting a new thread or extending an existing /// one. Restricted communities block `NewThread` for non-mods but still accept /// `ContinueExisting` writes. #[derive(Debug, Clone, Copy)] pub(crate) enum WriteScope { NewThread, ContinueExisting, } /// Convenience: combine role lookup with [`check_community_state`]. Use this /// in write handlers that don't already need the role for other purposes. #[tracing::instrument(skip_all)] pub(crate) async fn check_write_state( state: &AppState, community: &mt_db::queries::CommunityRow, user: &auth::SessionUser, scope: WriteScope, ) -> Result<(), Response> { let role = get_role(&state.db, user.user_id, community.id).await?; let is_mod_or_super = is_mod_or_superadmin(state, user, role); check_community_state(community.state, scope, is_mod_or_super) } /// Pure decision for [`check_community_state`]: returns `None` when the write /// is allowed, `Some(message)` when denied (message is what the user sees). /// /// Mods/owners and the platform admin bypass restrictions. Members go through /// the state's `allows_*` predicates. pub(crate) fn community_state_denial_message( community_state: CommunityState, scope: WriteScope, is_mod_or_super: bool, ) -> Option<&'static str> { if is_mod_or_super { return None; } let allowed = match scope { WriteScope::NewThread => community_state.allows_new_threads_for_members(), WriteScope::ContinueExisting => community_state.allows_writes_for_members(), }; if allowed { return None; } Some(match (community_state, scope) { (CommunityState::Restricted, WriteScope::NewThread) => { "New threads are restricted in this community." } (CommunityState::Frozen, _) => "This community is frozen.", (CommunityState::Archived, _) => "This community is archived.", _ => "Action not allowed in the community's current state.", }) } /// Gate a write against the community's [`CommunityState`]. /// /// Mods/owners and the platform admin bypass all state restrictions. Members /// follow the state's `allows_*` predicates. Returns 403 with a state-specific /// message on denial. Independent of [`check_write_access`] (suspension/ban/mute); /// call both in write handlers. #[allow(clippy::result_large_err)] pub(crate) fn check_community_state( community_state: CommunityState, scope: WriteScope, is_mod_or_super: bool, ) -> Result<(), Response> { match community_state_denial_message(community_state, scope, is_mod_or_super) { None => Ok(()), Some(msg) => Err((StatusCode::FORBIDDEN, msg).into_response()), } } #[cfg(test)] mod authz_tests { use super::*; // --- is_mod_or_owner / is_owner Option wrappers #[test] fn is_mod_or_owner_none_role_is_false() { assert!(!is_mod_or_owner(None)); } #[test] fn is_mod_or_owner_some_roles() { assert!(is_mod_or_owner(Some(CommunityRole::Owner))); assert!(is_mod_or_owner(Some(CommunityRole::Moderator))); assert!(!is_mod_or_owner(Some(CommunityRole::Member))); } #[test] fn is_owner_none_role_is_false() { assert!(!is_owner(None)); } #[test] fn is_owner_some_roles() { assert!(is_owner(Some(CommunityRole::Owner))); assert!(!is_owner(Some(CommunityRole::Moderator))); assert!(!is_owner(Some(CommunityRole::Member))); } #[test] fn state_denial_mod_bypasses_everything() { // Pins the `if is_mod_or_super { return None; }` early return, a mod // can write to Archived/Frozen/Restricted communities for any scope. for state in [ CommunityState::Active, CommunityState::Restricted, CommunityState::Frozen, CommunityState::Archived, ] { for scope in [WriteScope::NewThread, WriteScope::ContinueExisting] { assert_eq!( community_state_denial_message(state, scope, true), None, "mod must bypass: state={state:?} scope={scope:?}" ); } } } #[test] fn state_denial_active_allows_members_both_scopes() { assert_eq!( community_state_denial_message(CommunityState::Active, WriteScope::NewThread, false), None ); assert_eq!( community_state_denial_message( CommunityState::Active, WriteScope::ContinueExisting, false ), None ); } #[test] fn state_denial_restricted_blocks_new_thread_only() { // Restricted: members can reply but not start threads. assert_eq!( community_state_denial_message( CommunityState::Restricted, WriteScope::NewThread, false ), Some("New threads are restricted in this community.") ); assert_eq!( community_state_denial_message( CommunityState::Restricted, WriteScope::ContinueExisting, false ), None, "Restricted must allow replies" ); } #[test] fn state_denial_frozen_blocks_all_member_writes() { assert_eq!( community_state_denial_message(CommunityState::Frozen, WriteScope::NewThread, false), Some("This community is frozen.") ); assert_eq!( community_state_denial_message( CommunityState::Frozen, WriteScope::ContinueExisting, false ), Some("This community is frozen.") ); } #[test] fn state_denial_archived_blocks_all_member_writes() { assert_eq!( community_state_denial_message(CommunityState::Archived, WriteScope::NewThread, false), Some("This community is archived.") ); assert_eq!( community_state_denial_message( CommunityState::Archived, WriteScope::ContinueExisting, false ), Some("This community is archived.") ); } #[test] fn state_denial_message_distinct_per_state() { // Distinct error text per state, mutations that swap arms (e.g. // Frozen → Archived) would surface here. let frozen = community_state_denial_message(CommunityState::Frozen, WriteScope::NewThread, false) .unwrap(); let archived = community_state_denial_message(CommunityState::Archived, WriteScope::NewThread, false) .unwrap(); let restricted = community_state_denial_message( CommunityState::Restricted, WriteScope::NewThread, false, ) .unwrap(); assert_ne!(frozen, archived); assert_ne!(frozen, restricted); assert_ne!(archived, restricted); } }