//! `ChatAuthz`: who may read a room and who may send to it. //! //! # Why this is not a hand-rolled predicate set //! //! `src/routes/scope.rs` documents the C1 chronic: for three ultra-fuzz runs, //! handlers reached through `/p/{slug}/…` evaluated access predicates against a //! community that was not always the one the slug named, because the guard was //! a copied line rather than a structure. Chat is a new surface on those same //! URLs and is the obvious place for a fourth instance. //! //! Two things prevent it, neither of which is a convention: //! //! 1. **There is one community, and it is not passed in separately.** A gate //! holds the `Room` resolved from the slug by [`MtChatRooms`], every //! predicate reads `room.id`, and no method takes a community id. The //! divergence C1 describes cannot be expressed: there is no second community //! to accidentally check against. //! 2. **The predicate set has one definition.** Good standing is //! [`evaluate_write_access`], the same function `check_write_access` renders //! as a 403 for forum writes. Chat translates the denial to a //! `DenyReason` rather than restating the checks, so a predicate added for //! the forum reaches chat and the two cannot disagree about who is muted. //! //! # Why the gate is per-request //! //! `fan_plus` gates on `UserPerks::effective_plus()`, and perks live on the //! session rather than in the database (`src/auth.rs`): they are a cached //! snapshot of MNW's userinfo. A gate built from a `UserId` alone could not see //! them. So a gate is constructed per request for one viewer and carries that //! viewer's perks, and [`MtChatAuthz::can_write`] refuses outright if it is //! asked about anybody else, rather than answering with the wrong user's perks. use async_trait::async_trait; use livechat::{ChatAuthz, ChatError, DenyReason, Room, UserId, WriteAccess}; use mt_core::types::ChatPolicy; use sqlx::PgPool; use uuid::Uuid; use super::host_error; use crate::auth::UserPerks; use crate::routes::{WriteDenial, evaluate_write_access}; /// One viewer's authority in one room, for the length of one request. pub struct MtChatAuthz { db: PgPool, policy: ChatPolicy, /// The signed-in user this gate was built for, if any. viewer: Option, /// `viewer`'s perks, from the session. Meaningless for anybody else, which /// is why `can_write` refuses to answer about anybody else. perks: UserPerks, } impl MtChatAuthz { pub fn new(db: PgPool, policy: ChatPolicy, viewer: Option, perks: UserPerks) -> Self { Self { db, policy, viewer, perks, } } } /// How a good-standing failure reads to a chat client. fn deny_reason(denial: WriteDenial) -> DenyReason { match denial { // The room is gone as far as the sender is concerned. `room_state` maps // a suspended community to `Closed`, so the send path refuses before // reaching authz and this arm is belt and braces. WriteDenial::CommunitySuspended | WriteDenial::UserSuspended => DenyReason::Suspended, WriteDenial::Banned => DenyReason::Banned, WriteDenial::Muted => DenyReason::Muted, } } #[async_trait] impl ChatAuthz for MtChatAuthz { async fn can_read(&self, viewer: Option, room: &Room) -> Result { if !self.policy.is_enabled() { return Ok(false); } let Some(UserId(user)) = viewer else { // Only `public_read` shows the room to a logged-out visitor. The // other modes read as "anyone who can view the forum", and viewing // the forum means holding an account. return Ok(self.policy.allows_logged_out_read()); }; // A banned user loses the room, not just the ability to send. Reading // the room you were thrown out of is the thing a ban is for. let banned = mt_db::queries::is_user_banned(&self.db, room.id.0, user) .await .map_err(host_error)?; Ok(!banned) } async fn can_write(&self, user: UserId, room: &Room) -> Result { // This gate holds one viewer's session perks. Answering about a // different user would silently consult the wrong perks, so refuse // instead. Unreachable through the routes, which build a gate per // request from the session; a seal, not a check. if self.viewer != Some(user.0) { return Err(ChatError::host(std::io::Error::other( "chat authz gate asked about a user it was not built for", ))); } // Good standing: suspension, ban, mute. The same function the forum // write path renders as a 403. // // `false` for community suspension because the room already carries // that: `room_state` maps a suspended community to `Closed` and the // crate refuses the send before authz runs. Passing `true` here would // be re-deriving state the room already proved. if let Some(denial) = evaluate_write_access(&self.db, room.id.0, user.0, false) .await .map_err(host_error)? { return Ok(WriteAccess::Deny(deny_reason(denial))); } // Membership. Every write mode is "members in good standing", so a // logged-in non-member reads (subject to policy) and cannot send. let role = mt_db::queries::get_user_role(&self.db, user.0, room.id.0) .await .map_err(host_error)?; if role.is_none() { return Ok(WriteAccess::Deny(DenyReason::NotAMember)); } // Fan+ on top, for that one mode. if self.policy.requires_fan_plus_to_write() && !self.perks.effective_plus() { return Ok(WriteAccess::Deny(DenyReason::TierRequired)); } Ok(WriteAccess::Allow) } async fn is_moderator(&self, user: UserId, room: &Room) -> Result { let role = mt_db::queries::get_user_role(&self.db, user.0, room.id.0) .await .map_err(host_error)?; Ok(role.is_some_and(mt_core::types::CommunityRole::is_mod_or_owner)) } } #[cfg(test)] mod tests { use super::*; #[test] fn suspension_of_either_kind_reads_as_suspended() { assert_eq!( deny_reason(WriteDenial::CommunitySuspended), DenyReason::Suspended ); assert_eq!( deny_reason(WriteDenial::UserSuspended), DenyReason::Suspended ); } #[test] fn a_ban_and_a_mute_stay_distinct() { // Collapsing these would tell a muted user they were banned, which is a // different fact and one they would act on differently. assert_eq!(deny_reason(WriteDenial::Banned), DenyReason::Banned); assert_eq!(deny_reason(WriteDenial::Muted), DenyReason::Muted); } }