//! `CommunityScope`, the single, sealed way to load a resource together with //! the community its URL slug names, proving the two belong together. //! //! ## The chronic this closes (ultra-fuzz C1, 3 consecutive runs) //! //! Handlers reached via `/p/{slug}/…/{resource_id}` must serve a resource whose //! `community_id` equals the community resolved from `{slug}`, and *all* access //! predicates (ban/mute/suspension, role) must evaluate against that one //! community, not a mix of the slug's community and the resource's real one. //! //! For three runs the guard was a per-call-site convention: a hand-copied //! `if resource.community_id != community.id { 404 }` line. It drifted every //! time a new handler was written without it (run #5: `edit_thread`/ //! `delete_thread` split their predicates across two different communities). //! Copying the line onto two more handlers is a fourth instance of the failed //! fix class, not a resolution. //! //! ## The structural fix //! //! [`CommunityScope::resolve`] is the *only* way to obtain a community-scoped //! resource. It loads both the slug's community and the resource by id, returns //! 404 unless their `community_id`s match, and yields one value carrying the //! verified community alongside the resource. Access checks //! ([`CommunityScope::require_write_access`], [`CommunityScope::role`]) read the //! community *from that value*, so they cannot be handed a divergent community //! id. The raw by-id loaders (`get_thread_with_breadcrumb`, `get_post_for_edit`) //! return their resource wrapped in [`mt_db::queries::Unscoped`]: the inner value //! is private and only [`Unscoped::in_community`] yields it, and only when the //! resource's community matches the one the caller names. A handler therefore //! cannot read a by-id community resource without proving it belongs to the URL's //! community — the C1 equality check *is* the unwrap. The seal is by type, not a //! lint, so it fails closed: a new by-id loader returning `Unscoped` is bound //! by the same rule automatically. use axum::response::{IntoResponse, Response}; use uuid::Uuid; use mt_core::types::CommunityRole; use mt_db::queries::{CommunityRow, PostForEdit, ThreadWithBreadcrumb, Unscoped}; use super::{check_write_access, db_error, get_community, get_role, is_mod_or_owner, parse_uuid}; /// A resource that lives inside a community and can be loaded by id. /// /// Implemented only for the resource types that hang off a `/p/{slug}/…` path. /// `load` returns the resource still wrapped in [`Unscoped`]; only /// [`CommunityScope::resolve`] unwraps it, against the slug's community. pub(crate) trait ScopedResource: Sized + Send { fn load( db: &sqlx::PgPool, id: Uuid, ) -> impl std::future::Future>, sqlx::Error>> + Send; } impl ScopedResource for ThreadWithBreadcrumb { async fn load(db: &sqlx::PgPool, id: Uuid) -> Result>, sqlx::Error> { mt_db::queries::get_thread_with_breadcrumb(db, id).await } } impl ScopedResource for PostForEdit { async fn load(db: &sqlx::PgPool, id: Uuid) -> Result>, sqlx::Error> { mt_db::queries::get_post_for_edit(db, id).await } } /// A resource verified to belong to the community its URL slug names. /// /// Construct only via [`CommunityScope::resolve`]. The fields are public to the /// crate so handlers can read `resource` and `community`, but there is no way to /// build the struct with a mismatched pair. pub(crate) struct CommunityScope { pub(crate) community: CommunityRow, pub(crate) resource: T, } impl CommunityScope { /// Load the slug's community and the resource by id, returning 404 unless the /// resource belongs to that community. This single check is the C1 invariant. #[allow(clippy::result_large_err)] pub(crate) async fn resolve( db: &sqlx::PgPool, slug: &str, resource_id_str: &str, ) -> Result { let community = get_community(db, slug).await?; let id = parse_uuid(resource_id_str)?; // `in_community` is the C1 check: it yields the resource only if it lives // in the slug's community. A resource that exists in a *different* // community unwraps to `None`, indistinguishable from "not found" to this // URL, exactly as a missing id is. let resource = T::load(db, id) .await .map_err(db_error)? .and_then(|scoped| scoped.in_community(community.id)) .ok_or_else(crate::error_page::not_found)?; Ok(Self { community, resource, }) } /// Enforce write access (suspension/ban/mute) against the verified community. #[allow(clippy::result_large_err)] pub(crate) async fn require_write_access( &self, db: &sqlx::PgPool, user_id: Uuid, ) -> Result<(), Response> { check_write_access( db, self.community.id, user_id, self.community.suspended_at.is_some(), ) .await } /// The caller's role in the verified community. #[allow(clippy::result_large_err)] pub(crate) async fn role( &self, db: &sqlx::PgPool, user_id: Uuid, ) -> Result, Response> { get_role(db, user_id, self.community.id).await } /// Gate a moderation *mutation* on a scoped resource: the caller must be a /// mod or owner AND the community must not be suspended. Returns the role so /// callers that need it for finer checks (e.g. mod-vs-owner) don't re-query. /// /// This is the sealed equivalent of [`require_mod_or_owner`] for the /// resource-scoped handlers (pin/lock/mod-remove): folding the suspension /// check in beside the role check means a suspended community is frozen to /// its own mods here too, and a new scoped moderation handler can't drift /// back to a role-only gate. #[allow(clippy::result_large_err)] pub(crate) async fn require_mod_write( &self, db: &sqlx::PgPool, user_id: Uuid, ) -> Result, Response> { let role = self.role(db, user_id).await?; if !is_mod_or_owner(role) { return Err(axum::http::StatusCode::FORBIDDEN.into_response()); } if self.community.suspended_at.is_some() { return Err(( axum::http::StatusCode::FORBIDDEN, "This community has been suspended.", ) .into_response()); } Ok(role) } }