Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! Authorization (spec §6).
2//!
3//! ```text
4//! Role read comment push manage-changes settings delete
5//! read ✓ ✓ · · · ·
6//! write ✓ ✓ ✓ · · ·
7//! maintain ✓ ✓ ✓ ✓ ✓ ·
8//! admin ✓ ✓ ✓ ✓ ✓ ✓
9//! ```
10//!
11//! The whole point of this module is that permission logic exists in exactly one
12//! place. Spec §6: "Do not scatter permission checks through handlers — one
13//! function, one call site per request, and a default-deny fallthrough."
14//!
15//! [`resolve`] is pure — it takes facts already loaded from the database and
16//! returns a decision. That makes the interesting cases testable without a
17//! database, which matters because a mistake here is the private-repo leakage
18//! class of bug that §9 calls "the most common forge vulnerability".
19
20use df_db::models::{OrgRole, RepoRole, Visibility};
21
22/// What a request is allowed to do with a repository.
23///
24/// Constructed only by [`resolve`]. There is deliberately no way to build one
25/// with arbitrary permissions outside this module.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct RepoAccess {
28 role: Option<RepoRole>,
29}
30
31impl RepoAccess {
32 /// No access at all. The repo must be rendered as if it does not exist —
33 /// see the note on 404s below.
34 pub const DENIED: RepoAccess = RepoAccess { role: None };
35
36 pub fn role(&self) -> Option<RepoRole> {
37 self.role
38 }
39
40 /// Whether the repo may be seen at all.
41 ///
42 /// When this is false the handler must return the *same* 404 a nonexistent
43 /// repo returns (spec §9), never a 403 — a 403 confirms the repo exists,
44 /// which is exactly the leak.
45 pub fn can_read(&self) -> bool {
46 self.role.is_some()
47 }
48
49 pub fn can_comment(&self) -> bool {
50 self.at_least(RepoRole::Read)
51 }
52
53 pub fn can_push(&self) -> bool {
54 self.at_least(RepoRole::Write)
55 }
56
57 /// Merge, abandon, retarget, edit others' changes.
58 pub fn can_manage_changes(&self) -> bool {
59 self.at_least(RepoRole::Maintain)
60 }
61
62 pub fn can_change_settings(&self) -> bool {
63 self.at_least(RepoRole::Maintain)
64 }
65
66 pub fn can_delete(&self) -> bool {
67 self.at_least(RepoRole::Admin)
68 }
69
70 fn at_least(&self, needed: RepoRole) -> bool {
71 matches!(self.role, Some(r) if r >= needed)
72 }
73}
74
75/// Everything [`resolve`] needs to know, loaded once per request.
76#[derive(Debug, Clone, Copy)]
77pub struct AccessInputs {
78 pub visibility: Visibility,
79 /// Set when the viewer is signed in.
80 pub viewer: Option<Viewer>,
81 /// The repo's owning user, when owned by a user.
82 pub owner_user_id: Option<uuid::Uuid>,
83 /// Direct collaborator role, if any.
84 pub collaborator_role: Option<RepoRole>,
85 /// The viewer's role in the owning org, when the repo is org-owned.
86 pub org_role: Option<OrgRole>,
87}
88
89#[derive(Debug, Clone, Copy)]
90pub struct Viewer {
91 pub user_id: uuid::Uuid,
92 pub is_site_admin: bool,
93}
94
95/// Resolve effective access.
96///
97/// Effective role is the maximum of: direct collaborator role, org role (org
98/// admins get `admin` on org repos), repo ownership, and site admin. Public
99/// repos additionally grant implicit `read` to everyone including anonymous
100/// users; private repos grant nothing implicitly.
101pub fn resolve(input: AccessInputs) -> RepoAccess {
102 // Default deny. Every grant below is additive, and anything that falls
103 // through this function unmatched ends up denied.
104 let mut role: Option<RepoRole> = None;
105
106 /// Raise `role` to at least `candidate`.
107 fn raise(role: &mut Option<RepoRole>, candidate: RepoRole) {
108 *role = Some(match *role {
109 Some(existing) => existing.max(candidate),
110 None => candidate,
111 });
112 }
113
114 // Public repos are readable by anyone, including anonymous requests.
115 if matches!(input.visibility, Visibility::Public) {
116 raise(&mut role, RepoRole::Read);
117 }
118
119 if let Some(viewer) = input.viewer {
120 // Site admins have full access everywhere.
121 if viewer.is_site_admin {
122 raise(&mut role, RepoRole::Admin);
123 }
124
125 // Owning a repo grants admin on it.
126 if input.owner_user_id == Some(viewer.user_id) {
127 raise(&mut role, RepoRole::Admin);
128 }
129
130 if let Some(direct) = input.collaborator_role {
131 raise(&mut role, direct);
132 }
133
134 // Org membership. Org admins get repo admin; plain members get read,
135 // which is what makes private org repos visible to the org.
136 match input.org_role {
137 Some(OrgRole::Admin) => raise(&mut role, RepoRole::Admin),
138 Some(OrgRole::Member) => raise(&mut role, RepoRole::Read),
139 None => {}
140 }
141 }
142
143 RepoAccess { role }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use uuid::Uuid;
150
151 fn viewer() -> Viewer {
152 Viewer { user_id: Uuid::from_u128(1), is_site_admin: false }
153 }
154
155 fn base() -> AccessInputs {
156 AccessInputs {
157 visibility: Visibility::Private,
158 viewer: None,
159 owner_user_id: None,
160 collaborator_role: None,
161 org_role: None,
162 }
163 }
164
165 // ─── the leakage cases (spec §9) ─────────────────────────────────────────
166
167 #[test]
168 fn anonymous_cannot_read_a_private_repo() {
169 let a = resolve(base());
170 assert!(!a.can_read(), "anonymous access to a private repo must be denied");
171 assert_eq!(a.role(), None);
172 }
173
174 #[test]
175 fn a_signed_in_stranger_cannot_read_a_private_repo() {
176 let a = resolve(AccessInputs { viewer: Some(viewer()), ..base() });
177 assert!(
178 !a.can_read(),
179 "being authenticated is not authorization — this is the private-repo leak"
180 );
181 }
182
183 #[test]
184 fn org_membership_alone_does_not_grant_access_to_a_non_org_repo() {
185 // org_role is only ever populated for org-owned repos, but if a bug ever
186 // populated it for a user-owned repo the grant would still be read-only,
187 // never write.
188 let a = resolve(AccessInputs {
189 viewer: Some(viewer()),
190 org_role: Some(OrgRole::Member),
191 ..base()
192 });
193 assert!(a.can_read());
194 assert!(!a.can_push(), "org membership must never imply push");
195 }
196
197 // ─── public repos ────────────────────────────────────────────────────────
198
199 #[test]
200 fn anonymous_can_read_but_not_write_a_public_repo() {
201 let a = resolve(AccessInputs { visibility: Visibility::Public, ..base() });
202 assert!(a.can_read());
203 assert!(a.can_comment(), "read implies comment per the §6 table");
204 assert!(!a.can_push());
205 assert!(!a.can_change_settings());
206 assert!(!a.can_delete());
207 }
208
209 // ─── the role table, exactly as specified in §6 ──────────────────────────
210
211 #[test]
212 fn role_table_matches_the_spec() {
213 let cases = [
214 // read, comment, push, manage, settings, delete
215 (RepoRole::Read, true, true, false, false, false, false),
216 (RepoRole::Write, true, true, true, false, false, false),
217 (RepoRole::Maintain, true, true, true, true, true, false),
218 (RepoRole::Admin, true, true, true, true, true, true),
219 ];
220 for (role, read, comment, push, manage, settings, delete) in cases {
221 let a = resolve(AccessInputs {
222 viewer: Some(viewer()),
223 collaborator_role: Some(role),
224 ..base()
225 });
226 assert_eq!(a.can_read(), read, "{role:?} read");
227 assert_eq!(a.can_comment(), comment, "{role:?} comment");
228 assert_eq!(a.can_push(), push, "{role:?} push");
229 assert_eq!(a.can_manage_changes(), manage, "{role:?} manage-changes");
230 assert_eq!(a.can_change_settings(), settings, "{role:?} settings");
231 assert_eq!(a.can_delete(), delete, "{role:?} delete");
232 }
233 }
234
235 // ─── the maximum rule ────────────────────────────────────────────────────
236
237 #[test]
238 fn effective_role_is_the_maximum_not_the_last_match() {
239 // A read collaborator who is also an org admin gets admin, not read.
240 let a = resolve(AccessInputs {
241 viewer: Some(viewer()),
242 collaborator_role: Some(RepoRole::Read),
243 org_role: Some(OrgRole::Admin),
244 ..base()
245 });
246 assert_eq!(a.role(), Some(RepoRole::Admin));
247 }
248
249 #[test]
250 fn a_lower_org_role_never_downgrades_a_higher_collaborator_role() {
251 let a = resolve(AccessInputs {
252 viewer: Some(viewer()),
253 collaborator_role: Some(RepoRole::Admin),
254 org_role: Some(OrgRole::Member),
255 ..base()
256 });
257 assert_eq!(
258 a.role(),
259 Some(RepoRole::Admin),
260 "org membership must not reduce an explicit collaborator grant"
261 );
262 }
263
264 #[test]
265 fn site_admin_has_full_access_to_private_repos() {
266 let a = resolve(AccessInputs {
267 viewer: Some(Viewer { user_id: Uuid::from_u128(9), is_site_admin: true }),
268 ..base()
269 });
270 assert!(a.can_delete());
271 }
272
273 #[test]
274 fn owner_has_admin_on_their_own_repo() {
275 let v = viewer();
276 let a = resolve(AccessInputs {
277 viewer: Some(v),
278 owner_user_id: Some(v.user_id),
279 ..base()
280 });
281 assert_eq!(a.role(), Some(RepoRole::Admin));
282 }
283
284 #[test]
285 fn a_different_users_repo_is_not_owned() {
286 let a = resolve(AccessInputs {
287 viewer: Some(viewer()),
288 owner_user_id: Some(Uuid::from_u128(999)),
289 ..base()
290 });
291 assert!(!a.can_read());
292 }
293
294 #[test]
295 fn denied_is_the_default() {
296 assert!(!RepoAccess::DENIED.can_read());
297 assert_eq!(RepoAccess::DENIED.role(), None);
298 }
299}

299 lines · Rust