Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
Matt W1//! Repository resolution and authorization.
Matt W2//!
Matt W3//! Spec §6: "Resolve permissions once per request in middleware into a
Matt W4//! `RepoContext` and pass it down. Do not scatter permission checks through
Matt W5//! handlers — one function, one call site per request, and a default-deny
Matt W6//! fallthrough."
Matt W7//!
Matt W8//! [`RepoContext::load`] is that one call site. Every repository handler starts
Matt W9//! by calling it and gets back either a context that has *already* been
Matt W10//! authorized for read, or a 404.
Matt W11//!
Matt W12//! The 404 is deliberate and load-bearing. Spec §9: "Return an identical 404
Matt W13//! for a private repo and a nonexistent repo." A 403 would confirm the
Matt W14//! repository exists, which is the leak.
Matt W15
Matt W16use df_auth::permissions::{self, AccessInputs, RepoAccess, Viewer};
Matt W17use df_db::models::{OrgRole, OwnerKind, Repo, RepoRole, User, Visibility};
Matt W18use df_store::RepoId;
Matt W19use uuid::Uuid;
Matt W20
Matt W21use crate::error::{AppError, AppResult};
Matt W22use crate::state::AppState;
Matt W23
Matt W24pub struct RepoContext {
Matt W25 pub repo: Repo,
Matt W26 /// Display handle of the owning user or org.
Matt W27 pub owner: String,
Matt W28 pub access: RepoAccess,
Matt W29 /// Counts for the repository sub-bar.
Matt W30 pub nav: RepoNav,
Matt W31}
Matt W32
Matt W33/// The numbers on the repository sub-bar, which is on every page inside a
Matt W34/// repository.
Matt W35///
Matt W36/// Resolved once per request alongside permissions, for the same reason: the
Matt W37/// bar is chrome, so every handler needs it, and making each one remember to
Matt W38/// fetch it is how a page ends up with a bar that says something different
Matt W39/// from the page under it.
Matt W40#[derive(Debug, Clone, Copy, Default, sqlx::FromRow)]
Matt W41pub struct RepoNav {
Matt W42 pub open_changes: i64,
Matt W43 /// Open changes that are currently conflicted. A subset of `open_changes` —
Matt W44 /// a conflict is a state a change is *in*, not a state it is instead of.
Matt W45 pub conflicted: i64,
Matt W46 pub open_issues: i64,
Matt W47 pub bookmarks: i64,
Matt W48}
Matt W49
Matt W50impl RepoContext {
Matt W51 /// Resolve `{owner}/{repo}` and authorize the viewer for read.
Matt W52 ///
Matt W53 /// Returns [`AppError::NotFound`] when the repository does not exist *or*
Matt W54 /// the viewer may not see it — the two cases are indistinguishable to the
Matt W55 /// client by design.
Matt W56 pub async fn load(
Matt W57 state: &AppState,
Matt W58 owner: &str,
Matt W59 name: &str,
Matt W60 viewer: Option<&User>,
Matt W61 ) -> AppResult<RepoContext> {
Matt W62 // One query loads the repo, its owner handle, and the viewer's
Matt W63 // collaborator and org roles. Doing it in one statement keeps the
Matt W64 // authorization decision atomic and avoids a check-then-use gap.
Matt W65 let viewer_id = viewer.map(|u| u.id);
Matt W66
Matt W67 let row: Option<RepoRow> = sqlx::query_as::<_, RepoRow>(
Matt W68 r#"
Matt W69 SELECT r.id, r.owner_kind, r.owner_user_id, r.owner_org_id, r.name::text AS name,
Matt W70 r.description, r.visibility, r.default_bookmark, r.fork_of_repo_id,
Matt W71 r.size_bytes, r.pushed_at, r.archived, r.created_at,
Matt W72 COALESCE(ou.handle, og.handle)::text AS owner_handle,
Matt W73 c.role AS collaborator_role,
Matt W74 m.role AS org_role
Matt W75 FROM repos r
Matt W76 LEFT JOIN users ou ON ou.id = r.owner_user_id
Matt W77 LEFT JOIN orgs og ON og.id = r.owner_org_id
Matt W78 LEFT JOIN repo_collaborators c
Matt W79 ON c.repo_id = r.id AND c.user_id = $3
Matt W80 LEFT JOIN org_members m
Matt W81 ON m.org_id = r.owner_org_id AND m.user_id = $3
Matt W82 WHERE COALESCE(ou.handle, og.handle) = $1 AND r.name = $2
Matt W83 "#,
Matt W84 )
Matt W85 .bind(owner)
Matt W86 .bind(name)
Matt W87 .bind(viewer_id)
Matt W88 .fetch_optional(&state.db)
Matt W89 .await?;
Matt W90
Matt W91 // Nonexistent repository: 404.
Matt W92 let Some(row) = row else {
Matt W93 return Err(AppError::NotFound);
Matt W94 };
Matt W95
Matt W96 let access = permissions::resolve(AccessInputs {
Matt W97 visibility: row.visibility,
Matt W98 viewer: viewer.map(|u| Viewer {
Matt W99 user_id: u.id,
Matt W100 is_site_admin: u.is_admin,
Matt W101 }),
Matt W102 owner_user_id: row.owner_user_id,
Matt W103 collaborator_role: row.collaborator_role,
Matt W104 org_role: row.org_role,
Matt W105 });
Matt W106
Matt W107 // Exists but not visible: the *same* 404, so the response does not
Matt W108 // distinguish the two cases.
Matt W109 if !access.can_read() {
Matt W110 tracing::debug!(
Matt W111 repo = %row.id,
Matt W112 viewer = ?viewer_id,
Matt W113 "denying repository read; responding 404"
Matt W114 );
Matt W115 return Err(AppError::NotFound);
Matt W116 }
Matt W117
Matt W118 // Four scalar subqueries in one round trip. Deliberately *after* the
Matt W119 // read check: an unauthorized viewer must not cost us the counts, and
Matt W120 // must not be able to time the difference.
Matt W121 let nav: RepoNav = sqlx::query_as(
Matt W122 r#"
Matt W123 SELECT
Matt W124 (SELECT count(*) FROM changes
Matt W125 WHERE repo_id = $1 AND state = 'open') AS open_changes,
Matt W126 (SELECT count(*) FROM changes
Matt W127 WHERE repo_id = $1 AND state = 'open' AND conflicted) AS conflicted,
Matt W128 (SELECT count(*) FROM issues
Matt W129 WHERE repo_id = $1 AND state = 'open') AS open_issues,
Matt W130 (SELECT count(*) FROM bookmarks WHERE repo_id = $1) AS bookmarks
Matt W131 "#,
Matt W132 )
Matt W133 .bind(row.id)
Matt W134 .fetch_one(&state.db)
Matt W135 .await?;
Matt W136
Matt W137 Ok(RepoContext {
Matt W138 owner: row.owner_handle,
Matt W139 access,
Matt W140 nav,
Matt W141 repo: Repo {
Matt W142 id: row.id,
Matt W143 owner_kind: row.owner_kind,
Matt W144 owner_user_id: row.owner_user_id,
Matt W145 owner_org_id: row.owner_org_id,
Matt W146 name: row.name,
Matt W147 description: row.description,
Matt W148 visibility: row.visibility,
Matt W149 default_bookmark: row.default_bookmark,
Matt W150 fork_of_repo_id: row.fork_of_repo_id,
Matt W151 size_bytes: row.size_bytes,
Matt W152 pushed_at: row.pushed_at,
Matt W153 archived: row.archived,
Matt W154 created_at: row.created_at,
Matt W155 },
Matt W156 })
Matt W157 }
Matt W158
Matt W159 pub fn store_id(&self) -> RepoId {
Matt W160 RepoId(self.repo.id)
Matt W161 }
Matt W162
Matt W163 /// URL prefix for this repository.
Matt W164 pub fn base(&self) -> String {
Matt W165 format!("/{}/{}", self.owner, self.repo.name)
Matt W166 }
Matt W167
Matt W168 /// Require a permission, or fail.
Matt W169 ///
Matt W170 /// Used *after* `load` has already authorized read, so a 403 here does not
Matt W171 /// disclose anything the viewer cannot already see.
Matt W172 pub fn require_push(&self) -> AppResult<()> {
Matt W173 self.access
Matt W174 .can_push()
Matt W175 .then_some(())
Matt W176 .ok_or(AppError::Forbidden)
Matt W177 }
Matt W178
Matt W179 pub fn require_settings(&self) -> AppResult<()> {
Matt W180 self.access
Matt W181 .can_change_settings()
Matt W182 .then_some(())
Matt W183 .ok_or(AppError::Forbidden)
Matt W184 }
Matt W185}
Matt W186
Matt W187#[derive(sqlx::FromRow)]
Matt W188struct RepoRow {
Matt W189 id: Uuid,
Matt W190 owner_kind: OwnerKind,
Matt W191 owner_user_id: Option<Uuid>,
Matt W192 owner_org_id: Option<Uuid>,
Matt W193 name: String,
Matt W194 description: Option<String>,
Matt W195 visibility: Visibility,
Matt W196 default_bookmark: String,
Matt W197 fork_of_repo_id: Option<Uuid>,
Matt W198 size_bytes: i64,
Matt W199 pushed_at: Option<chrono::DateTime<chrono::Utc>>,
Matt W200 archived: bool,
Matt W201 created_at: chrono::DateTime<chrono::Utc>,
Matt W202 owner_handle: String,
Matt W203 collaborator_role: Option<RepoRole>,
Matt W204 org_role: Option<OrgRole>,
Matt W205}

205 lines · Rust