Skip to main content

max / makenotwork

6.5 KB · 158 lines History Blame Raw
1 //! `CommunityScope`, the single, sealed way to load a resource together with
2 //! the community its URL slug names, proving the two belong together.
3 //!
4 //! ## The chronic this closes (ultra-fuzz C1, 3 consecutive runs)
5 //!
6 //! Handlers reached via `/p/{slug}/…/{resource_id}` must serve a resource whose
7 //! `community_id` equals the community resolved from `{slug}`, and *all* access
8 //! predicates (ban/mute/suspension, role) must evaluate against that one
9 //! community, not a mix of the slug's community and the resource's real one.
10 //!
11 //! For three runs the guard was a per-call-site convention: a hand-copied
12 //! `if resource.community_id != community.id { 404 }` line. It drifted every
13 //! time a new handler was written without it (run #5: `edit_thread`/
14 //! `delete_thread` split their predicates across two different communities).
15 //! Copying the line onto two more handlers is a fourth instance of the failed
16 //! fix class, not a resolution.
17 //!
18 //! ## The structural fix
19 //!
20 //! [`CommunityScope::resolve`] is the *only* way to obtain a community-scoped
21 //! resource. It loads both the slug's community and the resource by id, returns
22 //! 404 unless their `community_id`s match, and yields one value carrying the
23 //! verified community alongside the resource. Access checks
24 //! ([`CommunityScope::require_write_access`], [`CommunityScope::role`]) read the
25 //! community *from that value*, so they cannot be handed a divergent community
26 //! id. The raw by-id loaders (`get_thread_with_breadcrumb`, `get_post_for_edit`)
27 //! return their resource wrapped in [`mt_db::queries::Unscoped`]: the inner value
28 //! is private and only [`Unscoped::in_community`] yields it, and only when the
29 //! resource's community matches the one the caller names. A handler therefore
30 //! cannot read a by-id community resource without proving it belongs to the URL's
31 //! community — the C1 equality check *is* the unwrap. The seal is by type, not a
32 //! lint, so it fails closed: a new by-id loader returning `Unscoped<T>` is bound
33 //! by the same rule automatically.
34
35 use axum::response::{IntoResponse, Response};
36 use uuid::Uuid;
37
38 use mt_core::types::CommunityRole;
39 use mt_db::queries::{CommunityRow, PostForEdit, ThreadWithBreadcrumb, Unscoped};
40
41 use super::{check_write_access, db_error, get_community, get_role, is_mod_or_owner, parse_uuid};
42
43 /// A resource that lives inside a community and can be loaded by id.
44 ///
45 /// Implemented only for the resource types that hang off a `/p/{slug}/…` path.
46 /// `load` returns the resource still wrapped in [`Unscoped`]; only
47 /// [`CommunityScope::resolve`] unwraps it, against the slug's community.
48 pub(crate) trait ScopedResource: Sized + Send {
49 fn load(
50 db: &sqlx::PgPool,
51 id: Uuid,
52 ) -> impl std::future::Future<Output = Result<Option<Unscoped<Self>>, sqlx::Error>> + Send;
53 }
54
55 impl ScopedResource for ThreadWithBreadcrumb {
56 async fn load(db: &sqlx::PgPool, id: Uuid) -> Result<Option<Unscoped<Self>>, sqlx::Error> {
57 mt_db::queries::get_thread_with_breadcrumb(db, id).await
58 }
59 }
60
61 impl ScopedResource for PostForEdit {
62 async fn load(db: &sqlx::PgPool, id: Uuid) -> Result<Option<Unscoped<Self>>, sqlx::Error> {
63 mt_db::queries::get_post_for_edit(db, id).await
64 }
65 }
66
67 /// A resource verified to belong to the community its URL slug names.
68 ///
69 /// Construct only via [`CommunityScope::resolve`]. The fields are public to the
70 /// crate so handlers can read `resource` and `community`, but there is no way to
71 /// build the struct with a mismatched pair.
72 pub(crate) struct CommunityScope<T> {
73 pub(crate) community: CommunityRow,
74 pub(crate) resource: T,
75 }
76
77 impl<T: ScopedResource> CommunityScope<T> {
78 /// Load the slug's community and the resource by id, returning 404 unless the
79 /// resource belongs to that community. This single check is the C1 invariant.
80 #[allow(clippy::result_large_err)]
81 pub(crate) async fn resolve(
82 db: &sqlx::PgPool,
83 slug: &str,
84 resource_id_str: &str,
85 ) -> Result<Self, Response> {
86 let community = get_community(db, slug).await?;
87 let id = parse_uuid(resource_id_str)?;
88 // `in_community` is the C1 check: it yields the resource only if it lives
89 // in the slug's community. A resource that exists in a *different*
90 // community unwraps to `None`, indistinguishable from "not found" to this
91 // URL, exactly as a missing id is.
92 let resource = T::load(db, id)
93 .await
94 .map_err(db_error)?
95 .and_then(|scoped| scoped.in_community(community.id))
96 .ok_or_else(crate::error_page::not_found)?;
97 Ok(Self {
98 community,
99 resource,
100 })
101 }
102
103 /// Enforce write access (suspension/ban/mute) against the verified community.
104 #[allow(clippy::result_large_err)]
105 pub(crate) async fn require_write_access(
106 &self,
107 db: &sqlx::PgPool,
108 user_id: Uuid,
109 ) -> Result<(), Response> {
110 check_write_access(
111 db,
112 self.community.id,
113 user_id,
114 self.community.suspended_at.is_some(),
115 )
116 .await
117 }
118
119 /// The caller's role in the verified community.
120 #[allow(clippy::result_large_err)]
121 pub(crate) async fn role(
122 &self,
123 db: &sqlx::PgPool,
124 user_id: Uuid,
125 ) -> Result<Option<CommunityRole>, Response> {
126 get_role(db, user_id, self.community.id).await
127 }
128
129 /// Gate a moderation *mutation* on a scoped resource: the caller must be a
130 /// mod or owner AND the community must not be suspended. Returns the role so
131 /// callers that need it for finer checks (e.g. mod-vs-owner) don't re-query.
132 ///
133 /// This is the sealed equivalent of [`require_mod_or_owner`] for the
134 /// resource-scoped handlers (pin/lock/mod-remove): folding the suspension
135 /// check in beside the role check means a suspended community is frozen to
136 /// its own mods here too, and a new scoped moderation handler can't drift
137 /// back to a role-only gate.
138 #[allow(clippy::result_large_err)]
139 pub(crate) async fn require_mod_write(
140 &self,
141 db: &sqlx::PgPool,
142 user_id: Uuid,
143 ) -> Result<Option<CommunityRole>, Response> {
144 let role = self.role(db, user_id).await?;
145 if !is_mod_or_owner(role) {
146 return Err(axum::http::StatusCode::FORBIDDEN.into_response());
147 }
148 if self.community.suspended_at.is_some() {
149 return Err((
150 axum::http::StatusCode::FORBIDDEN,
151 "This community has been suspended.",
152 )
153 .into_response());
154 }
155 Ok(role)
156 }
157 }
158