Skip to main content

max / makenotwork

6.8 KB · 174 lines History Blame Raw
1 //! `ChatAuthz`: who may read a room and who may send to it.
2 //!
3 //! # Why this is not a hand-rolled predicate set
4 //!
5 //! `src/routes/scope.rs` documents the C1 chronic: for three ultra-fuzz runs,
6 //! handlers reached through `/p/{slug}/…` evaluated access predicates against a
7 //! community that was not always the one the slug named, because the guard was
8 //! a copied line rather than a structure. Chat is a new surface on those same
9 //! URLs and is the obvious place for a fourth instance.
10 //!
11 //! Two things prevent it, neither of which is a convention:
12 //!
13 //! 1. **There is one community, and it is not passed in separately.** A gate
14 //! holds the `Room` resolved from the slug by [`MtChatRooms`], every
15 //! predicate reads `room.id`, and no method takes a community id. The
16 //! divergence C1 describes cannot be expressed: there is no second community
17 //! to accidentally check against.
18 //! 2. **The predicate set has one definition.** Good standing is
19 //! [`evaluate_write_access`], the same function `check_write_access` renders
20 //! as a 403 for forum writes. Chat translates the denial to a
21 //! `DenyReason` rather than restating the checks, so a predicate added for
22 //! the forum reaches chat and the two cannot disagree about who is muted.
23 //!
24 //! # Why the gate is per-request
25 //!
26 //! `fan_plus` gates on `UserPerks::effective_plus()`, and perks live on the
27 //! session rather than in the database (`src/auth.rs`): they are a cached
28 //! snapshot of MNW's userinfo. A gate built from a `UserId` alone could not see
29 //! them. So a gate is constructed per request for one viewer and carries that
30 //! viewer's perks, and [`MtChatAuthz::can_write`] refuses outright if it is
31 //! asked about anybody else, rather than answering with the wrong user's perks.
32
33 use async_trait::async_trait;
34 use livechat::{ChatAuthz, ChatError, DenyReason, Room, UserId, WriteAccess};
35 use mt_core::types::ChatPolicy;
36 use sqlx::PgPool;
37 use uuid::Uuid;
38
39 use super::host_error;
40 use crate::auth::UserPerks;
41 use crate::routes::{WriteDenial, evaluate_write_access};
42
43 /// One viewer's authority in one room, for the length of one request.
44 pub struct MtChatAuthz {
45 db: PgPool,
46 policy: ChatPolicy,
47 /// The signed-in user this gate was built for, if any.
48 viewer: Option<Uuid>,
49 /// `viewer`'s perks, from the session. Meaningless for anybody else, which
50 /// is why `can_write` refuses to answer about anybody else.
51 perks: UserPerks,
52 }
53
54 impl MtChatAuthz {
55 pub fn new(db: PgPool, policy: ChatPolicy, viewer: Option<Uuid>, perks: UserPerks) -> Self {
56 Self {
57 db,
58 policy,
59 viewer,
60 perks,
61 }
62 }
63 }
64
65 /// How a good-standing failure reads to a chat client.
66 fn deny_reason(denial: WriteDenial) -> DenyReason {
67 match denial {
68 // The room is gone as far as the sender is concerned. `room_state` maps
69 // a suspended community to `Closed`, so the send path refuses before
70 // reaching authz and this arm is belt and braces.
71 WriteDenial::CommunitySuspended | WriteDenial::UserSuspended => DenyReason::Suspended,
72 WriteDenial::Banned => DenyReason::Banned,
73 WriteDenial::Muted => DenyReason::Muted,
74 }
75 }
76
77 #[async_trait]
78 impl ChatAuthz for MtChatAuthz {
79 async fn can_read(&self, viewer: Option<UserId>, room: &Room) -> Result<bool, ChatError> {
80 if !self.policy.is_enabled() {
81 return Ok(false);
82 }
83
84 let Some(UserId(user)) = viewer else {
85 // Only `public_read` shows the room to a logged-out visitor. The
86 // other modes read as "anyone who can view the forum", and viewing
87 // the forum means holding an account.
88 return Ok(self.policy.allows_logged_out_read());
89 };
90
91 // A banned user loses the room, not just the ability to send. Reading
92 // the room you were thrown out of is the thing a ban is for.
93 let banned = mt_db::queries::is_user_banned(&self.db, room.id.0, user)
94 .await
95 .map_err(host_error)?;
96
97 Ok(!banned)
98 }
99
100 async fn can_write(&self, user: UserId, room: &Room) -> Result<WriteAccess, ChatError> {
101 // This gate holds one viewer's session perks. Answering about a
102 // different user would silently consult the wrong perks, so refuse
103 // instead. Unreachable through the routes, which build a gate per
104 // request from the session; a seal, not a check.
105 if self.viewer != Some(user.0) {
106 return Err(ChatError::host(std::io::Error::other(
107 "chat authz gate asked about a user it was not built for",
108 )));
109 }
110
111 // Good standing: suspension, ban, mute. The same function the forum
112 // write path renders as a 403.
113 //
114 // `false` for community suspension because the room already carries
115 // that: `room_state` maps a suspended community to `Closed` and the
116 // crate refuses the send before authz runs. Passing `true` here would
117 // be re-deriving state the room already proved.
118 if let Some(denial) = evaluate_write_access(&self.db, room.id.0, user.0, false)
119 .await
120 .map_err(host_error)?
121 {
122 return Ok(WriteAccess::Deny(deny_reason(denial)));
123 }
124
125 // Membership. Every write mode is "members in good standing", so a
126 // logged-in non-member reads (subject to policy) and cannot send.
127 let role = mt_db::queries::get_user_role(&self.db, user.0, room.id.0)
128 .await
129 .map_err(host_error)?;
130 if role.is_none() {
131 return Ok(WriteAccess::Deny(DenyReason::NotAMember));
132 }
133
134 // Fan+ on top, for that one mode.
135 if self.policy.requires_fan_plus_to_write() && !self.perks.effective_plus() {
136 return Ok(WriteAccess::Deny(DenyReason::TierRequired));
137 }
138
139 Ok(WriteAccess::Allow)
140 }
141
142 async fn is_moderator(&self, user: UserId, room: &Room) -> Result<bool, ChatError> {
143 let role = mt_db::queries::get_user_role(&self.db, user.0, room.id.0)
144 .await
145 .map_err(host_error)?;
146 Ok(role.is_some_and(mt_core::types::CommunityRole::is_mod_or_owner))
147 }
148 }
149
150 #[cfg(test)]
151 mod tests {
152 use super::*;
153
154 #[test]
155 fn suspension_of_either_kind_reads_as_suspended() {
156 assert_eq!(
157 deny_reason(WriteDenial::CommunitySuspended),
158 DenyReason::Suspended
159 );
160 assert_eq!(
161 deny_reason(WriteDenial::UserSuspended),
162 DenyReason::Suspended
163 );
164 }
165
166 #[test]
167 fn a_ban_and_a_mute_stay_distinct() {
168 // Collapsing these would tell a muted user they were banned, which is a
169 // different fact and one they would act on differently.
170 assert_eq!(deny_reason(WriteDenial::Banned), DenyReason::Banned);
171 assert_eq!(deny_reason(WriteDenial::Muted), DenyReason::Muted);
172 }
173 }
174