//! 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(()) } /// Why a user in good standing would not be, evaluated against one community. /// /// Exists so the predicate set has exactly one definition. Forum writes render /// it as an HTTP 403 with a specific message ([`check_write_access`]); chat /// renders it as a `livechat::DenyReason` on an SSE-backed send path, which /// needs the reason as a value rather than as a `Response`. Two renderings, one /// set of checks: a predicate added here reaches both, and chat cannot drift /// into permitting a write the forum refuses. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum WriteDenial { CommunitySuspended, UserSuspended, Banned, Muted, } impl WriteDenial { /// What the user is told. Distinct per variant: "you are muted" and "you /// are banned" are different facts and a user acts on them differently. pub(crate) fn message(self) -> &'static str { match self { Self::CommunitySuspended => "This community has been suspended.", Self::UserSuspended => "Your account has been suspended.", Self::Banned => "You are banned from this community.", Self::Muted => "You are muted in this community.", } } } /// Evaluate community suspension + platform suspension + user ban + user mute. /// /// `Ok(None)` means the user is in good standing. Checks run cheapest-first and /// short-circuit, so a suspended community costs no queries at all. #[tracing::instrument(skip_all)] pub(crate) async fn evaluate_write_access( db: &sqlx::PgPool, community_id: Uuid, user_id: Uuid, community_suspended: bool, ) -> Result, sqlx::Error> { if community_suspended { return Ok(Some(WriteDenial::CommunitySuspended)); } if mt_db::queries::is_user_suspended(db, user_id).await? { return Ok(Some(WriteDenial::UserSuspended)); } if mt_db::queries::is_user_banned(db, community_id, user_id).await? { return Ok(Some(WriteDenial::Banned)); } if mt_db::queries::is_user_muted(db, community_id, user_id).await? { return Ok(Some(WriteDenial::Muted)); } Ok(None) } /// 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> { let denial = evaluate_write_access(db, community_id, user_id, community_suspended) .await .map_err(|e| { tracing::error!(error = ?e, "db error checking write access"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; match denial { None => Ok(()), Some(d) => Err((StatusCode::FORBIDDEN, d.message()).into_response()), } } /// 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. 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 cannot drift from it. 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. Wiki note `mt-moderation-policy`, section /// "Implementation Status", tracks what enforcement is built and what is not. 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); } }