Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
1//! Repository resolution and authorization.
2//!
3//! Spec §6: "Resolve permissions once per request in middleware into a
4//! `RepoContext` and pass it down. Do not scatter permission checks through
5//! handlers — one function, one call site per request, and a default-deny
6//! fallthrough."
7//!
8//! [`RepoContext::load`] is that one call site. Every repository handler starts
9//! by calling it and gets back either a context that has *already* been
10//! authorized for read, or a 404.
11//!
12//! The 404 is deliberate and load-bearing. Spec §9: "Return an identical 404
13//! for a private repo and a nonexistent repo." A 403 would confirm the
14//! repository exists, which is the leak.
15
16use df_auth::permissions::{self, AccessInputs, RepoAccess, Viewer};
17use df_db::models::{OrgRole, OwnerKind, Repo, RepoRole, User, Visibility};
18use df_store::RepoId;
19use uuid::Uuid;
20
21use crate::error::{AppError, AppResult};
22use crate::state::AppState;
23
24pub struct RepoContext {
25 pub repo: Repo,
26 /// Display handle of the owning user or org.
27 pub owner: String,
28 pub access: RepoAccess,
29 /// Counts for the repository sub-bar.
30 pub nav: RepoNav,
31}
32
33/// The numbers on the repository sub-bar, which is on every page inside a
34/// repository.
35///
36/// Resolved once per request alongside permissions, for the same reason: the
37/// bar is chrome, so every handler needs it, and making each one remember to
38/// fetch it is how a page ends up with a bar that says something different
39/// from the page under it.
40#[derive(Debug, Clone, Copy, Default, sqlx::FromRow)]
41pub struct RepoNav {
42 pub open_changes: i64,
43 /// Open changes that are currently conflicted. A subset of `open_changes` —
44 /// a conflict is a state a change is *in*, not a state it is instead of.
45 pub conflicted: i64,
46 pub open_issues: i64,
47 pub bookmarks: i64,
48}
49
50impl RepoContext {
51 /// Resolve `{owner}/{repo}` and authorize the viewer for read.
52 ///
53 /// Returns [`AppError::NotFound`] when the repository does not exist *or*
54 /// the viewer may not see it — the two cases are indistinguishable to the
55 /// client by design.
56 pub async fn load(
57 state: &AppState,
58 owner: &str,
59 name: &str,
60 viewer: Option<&User>,
61 ) -> AppResult<RepoContext> {
62 // One query loads the repo, its owner handle, and the viewer's
63 // collaborator and org roles. Doing it in one statement keeps the
64 // authorization decision atomic and avoids a check-then-use gap.
65 let viewer_id = viewer.map(|u| u.id);
66
67 let row: Option<RepoRow> = sqlx::query_as::<_, RepoRow>(
68 r#"
69 SELECT r.id, r.owner_kind, r.owner_user_id, r.owner_org_id, r.name::text AS name,
70 r.description, r.visibility, r.default_bookmark, r.fork_of_repo_id,
71 r.size_bytes, r.pushed_at, r.archived, r.created_at,
72 COALESCE(ou.handle, og.handle)::text AS owner_handle,
73 c.role AS collaborator_role,
74 m.role AS org_role
75 FROM repos r
76 LEFT JOIN users ou ON ou.id = r.owner_user_id
77 LEFT JOIN orgs og ON og.id = r.owner_org_id
78 LEFT JOIN repo_collaborators c
79 ON c.repo_id = r.id AND c.user_id = $3
80 LEFT JOIN org_members m
81 ON m.org_id = r.owner_org_id AND m.user_id = $3
82 WHERE COALESCE(ou.handle, og.handle) = $1 AND r.name = $2
83 "#,
84 )
85 .bind(owner)
86 .bind(name)
87 .bind(viewer_id)
88 .fetch_optional(&state.db)
89 .await?;
90
91 // Nonexistent repository: 404.
92 let Some(row) = row else {
93 return Err(AppError::NotFound);
94 };
95
96 let access = permissions::resolve(AccessInputs {
97 visibility: row.visibility,
98 viewer: viewer.map(|u| Viewer {
99 user_id: u.id,
100 is_site_admin: u.is_admin,
101 }),
102 owner_user_id: row.owner_user_id,
103 collaborator_role: row.collaborator_role,
104 org_role: row.org_role,
105 });
106
107 // Exists but not visible: the *same* 404, so the response does not
108 // distinguish the two cases.
109 if !access.can_read() {
110 tracing::debug!(
111 repo = %row.id,
112 viewer = ?viewer_id,
113 "denying repository read; responding 404"
114 );
115 return Err(AppError::NotFound);
116 }
117
118 // Four scalar subqueries in one round trip. Deliberately *after* the
119 // read check: an unauthorized viewer must not cost us the counts, and
120 // must not be able to time the difference.
121 let nav: RepoNav = sqlx::query_as(
122 r#"
123 SELECT
124 (SELECT count(*) FROM changes
125 WHERE repo_id = $1 AND state = 'open') AS open_changes,
126 (SELECT count(*) FROM changes
127 WHERE repo_id = $1 AND state = 'open' AND conflicted) AS conflicted,
128 (SELECT count(*) FROM issues
129 WHERE repo_id = $1 AND state = 'open') AS open_issues,
130 (SELECT count(*) FROM bookmarks WHERE repo_id = $1) AS bookmarks
131 "#,
132 )
133 .bind(row.id)
134 .fetch_one(&state.db)
135 .await?;
136
137 Ok(RepoContext {
138 owner: row.owner_handle,
139 access,
140 nav,
141 repo: Repo {
142 id: row.id,
143 owner_kind: row.owner_kind,
144 owner_user_id: row.owner_user_id,
145 owner_org_id: row.owner_org_id,
146 name: row.name,
147 description: row.description,
148 visibility: row.visibility,
149 default_bookmark: row.default_bookmark,
150 fork_of_repo_id: row.fork_of_repo_id,
151 size_bytes: row.size_bytes,
152 pushed_at: row.pushed_at,
153 archived: row.archived,
154 created_at: row.created_at,
155 },
156 })
157 }
158
159 pub fn store_id(&self) -> RepoId {
160 RepoId(self.repo.id)
161 }
162
163 /// URL prefix for this repository.
164 pub fn base(&self) -> String {
165 format!("/{}/{}", self.owner, self.repo.name)
166 }
167
168 /// Require a permission, or fail.
169 ///
170 /// Used *after* `load` has already authorized read, so a 403 here does not
171 /// disclose anything the viewer cannot already see.
172 pub fn require_push(&self) -> AppResult<()> {
173 self.access
174 .can_push()
175 .then_some(())
176 .ok_or(AppError::Forbidden)
177 }
178
179 pub fn require_settings(&self) -> AppResult<()> {
180 self.access
181 .can_change_settings()
182 .then_some(())
183 .ok_or(AppError::Forbidden)
184 }
185}
186
187#[derive(sqlx::FromRow)]
188struct RepoRow {
189 id: Uuid,
190 owner_kind: OwnerKind,
191 owner_user_id: Option<Uuid>,
192 owner_org_id: Option<Uuid>,
193 name: String,
194 description: Option<String>,
195 visibility: Visibility,
196 default_bookmark: String,
197 fork_of_repo_id: Option<Uuid>,
198 size_bytes: i64,
199 pushed_at: Option<chrono::DateTime<chrono::Utc>>,
200 archived: bool,
201 created_at: chrono::DateTime<chrono::Utc>,
202 owner_handle: String,
203 collaborator_role: Option<RepoRole>,
204 org_role: Option<OrgRole>,
205}

205 lines · Rust