forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
18.1 KiB
1//! Authorization rules. Pure, no IO.
2
3use crate::model::{Repo, Role, User, Visibility};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum Action {
7 Read,
8 /// Edit anyone's issue or comment, close/reopen, labels, assignees, milestones.
9 /// Not blocked by `archived` (the pages handle archived repos).
10 Triage,
11 Write,
12 Admin,
13 /// Delete the repo. Owner and instance admin only; a collaborator role never grants it.
14 Delete,
15}
16
17/// `NotFound` when the actor cannot even see the repo; `Forbidden` when they can see it but lack the right.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Denied {
20 NotFound,
21 Forbidden,
22}
23
24/// The higher of two optional roles. `Read < Triage < Write < Maintain < Admin`.
25pub fn max_role(a: Option<Role>, b: Option<Role>) -> Option<Role> {
26 fn rank(role: Role) -> u8 {
27 match role {
28 Role::Read => 1,
29 Role::Triage => 2,
30 Role::Write => 3,
31 Role::Maintain => 4,
32 Role::Admin => 5,
33 }
34 }
35 match (a, b) {
36 (Some(x), Some(y)) => Some(if rank(y) > rank(x) { y } else { x }),
37 (x, None) => x,
38 (None, y) => y,
39 }
40}
41
42/// Owner or instance admin: all actions. Public repo: Read for anyone.
43/// Private repo with no role: NotFound. Read/Triage: Read. Write/Maintain: Read+Write. Admin: all.
44/// Triage is granted to Triage, Write, Maintain and Admin.
45/// Archived repos deny Write with Forbidden.
46///
47/// `org_role` is the role the actor gets through an organization that owns the repo (see
48/// `Store::org_repo_role`). The effective role is `max_role(collaborator_role, org_role)`. A
49/// collaborator Admin never deletes a repo; an org-derived Admin (org owner, or a team with
50/// the admin role) does, because org-owned repos have no single owner person.
51pub fn authorize(
52 actor: Option<&User>,
53 repo: &Repo,
54 collaborator_role: Option<Role>,
55 org_role: Option<Role>,
56 action: Action,
57) -> Result<(), Denied> {
58 // A role only belongs to a signed-in actor.
59 let role = if actor.is_some() {
60 max_role(collaborator_role, org_role)
61 } else {
62 None
63 };
64
65 if action == Action::Write && repo.archived {
66 return Err(Denied::Forbidden);
67 }
68
69 let privileged = actor.is_some_and(|u| u.is_admin || u.id == repo.owner_id);
70 if privileged {
71 return Ok(());
72 }
73
74 let can_see = repo.visibility == Visibility::Public || role.is_some();
75 if !can_see {
76 return Err(Denied::NotFound);
77 }
78
79 let granted = match action {
80 Action::Read => true,
81 Action::Triage => matches!(
82 role,
83 Some(Role::Triage | Role::Write | Role::Maintain | Role::Admin)
84 ),
85 Action::Write => matches!(role, Some(Role::Write | Role::Maintain | Role::Admin)),
86 Action::Admin => matches!(role, Some(Role::Admin)),
87 Action::Delete => actor.is_some() && org_role == Some(Role::Admin),
88 };
89 if granted {
90 Ok(())
91 } else {
92 Err(Denied::Forbidden)
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use crate::model::AccountKind;
100
101 const ACTIONS: [Action; 5] = [
102 Action::Read,
103 Action::Triage,
104 Action::Write,
105 Action::Admin,
106 Action::Delete,
107 ];
108 const ROLES: [Option<Role>; 6] = [
109 None,
110 Some(Role::Read),
111 Some(Role::Triage),
112 Some(Role::Write),
113 Some(Role::Maintain),
114 Some(Role::Admin),
115 ];
116
117 fn user(id: &str, is_admin: bool) -> User {
118 User {
119 id: id.into(),
120 username: id.into(),
121 email: None,
122 password_hash: String::new(),
123 is_admin,
124 created_at: String::new(),
125 about: String::new(),
126 timezone: "UTC".into(),
127 avatar_hash: None,
128 theme: "auto".into(),
129 language: "auto".into(),
130 kind: AccountKind::User,
131 email_notifications: true,
132 }
133 }
134
135 fn repo(visibility: Visibility, archived: bool) -> Repo {
136 Repo {
137 id: "repo1".into(),
138 owner_id: "owner".into(),
139 owner_name: "owner".into(),
140 name: "r".into(),
141 description: String::new(),
142 visibility,
143 default_branch: "main".into(),
144 archived,
145 created_at: String::new(),
146 fork_of: None,
147 }
148 }
149
150 /// Expected result from the contract, written independently of the implementation.
151 fn expected(
152 actor: Option<&User>,
153 r: &Repo,
154 role: Option<Role>,
155 action: Action,
156 ) -> Result<(), Denied> {
157 let role = if actor.is_some() { role } else { None };
158 if action == Action::Write && r.archived {
159 return Err(Denied::Forbidden);
160 }
161 if actor.is_some_and(|u| u.is_admin || u.id == r.owner_id) {
162 return Ok(());
163 }
164 if action == Action::Delete {
165 // Only owner or instance admin: hidden repos are 404, visible ones 403.
166 return if r.visibility == Visibility::Public || role.is_some() {
167 Err(Denied::Forbidden)
168 } else {
169 Err(Denied::NotFound)
170 };
171 }
172 if r.visibility == Visibility::Public {
173 let ok = match action {
174 Action::Read => true,
175 Action::Triage => matches!(
176 role,
177 Some(Role::Triage | Role::Write | Role::Maintain | Role::Admin)
178 ),
179 Action::Write => matches!(role, Some(Role::Write | Role::Maintain | Role::Admin)),
180 Action::Admin => role == Some(Role::Admin),
181 Action::Delete => false,
182 };
183 return if ok { Ok(()) } else { Err(Denied::Forbidden) };
184 }
185 match role {
186 None => Err(Denied::NotFound),
187 Some(r) => {
188 let ok = match action {
189 Action::Read => true,
190 Action::Triage => {
191 matches!(r, Role::Triage | Role::Write | Role::Maintain | Role::Admin)
192 }
193 Action::Write => matches!(r, Role::Write | Role::Maintain | Role::Admin),
194 Action::Admin => r == Role::Admin,
195 Action::Delete => false,
196 };
197 if ok { Ok(()) } else { Err(Denied::Forbidden) }
198 }
199 }
200 }
201
202 #[test]
203 fn exhaustive_matrix_matches_contract() {
204 let stranger = user("stranger", false);
205 let owner = user("owner", false);
206 let admin = user("root", true);
207 let actors: [Option<&User>; 4] = [None, Some(&stranger), Some(&owner), Some(&admin)];
208 for visibility in [Visibility::Public, Visibility::Private] {
209 for archived in [false, true] {
210 let r = repo(visibility, archived);
211 for actor in actors {
212 for role in ROLES {
213 for action in ACTIONS {
214 assert_eq!(
215 authorize(actor, &r, role, None, action),
216 expected(actor, &r, role, action),
217 "vis={visibility:?} archived={archived} actor={:?} role={role:?} action={action:?}",
218 actor.map(|u| u.id.as_str()),
219 );
220 }
221 }
222 }
223 }
224 }
225 }
226
227 #[test]
228 fn anonymous_on_public_repo_can_read_only() {
229 let r = repo(Visibility::Public, false);
230 assert_eq!(authorize(None, &r, None, None, Action::Read), Ok(()));
231 assert_eq!(
232 authorize(None, &r, None, None, Action::Write),
233 Err(Denied::Forbidden)
234 );
235 assert_eq!(
236 authorize(None, &r, None, None, Action::Admin),
237 Err(Denied::Forbidden)
238 );
239 }
240
241 #[test]
242 fn anonymous_on_private_repo_gets_not_found() {
243 let r = repo(Visibility::Private, false);
244 for action in ACTIONS {
245 assert_eq!(
246 authorize(None, &r, None, None, action),
247 Err(Denied::NotFound)
248 );
249 }
250 }
251
252 #[test]
253 fn owner_and_instance_admin_can_do_everything() {
254 for visibility in [Visibility::Public, Visibility::Private] {
255 let r = repo(visibility, false);
256 let owner = user("owner", false);
257 let admin = user("root", true);
258 for action in ACTIONS {
259 assert_eq!(authorize(Some(&owner), &r, None, None, action), Ok(()));
260 assert_eq!(authorize(Some(&admin), &r, None, None, action), Ok(()));
261 }
262 }
263 }
264
265 #[test]
266 fn each_role_on_private_repo() {
267 let r = repo(Visibility::Private, false);
268 let u = user("collab", false);
269 let cases = [
270 (
271 Role::Read,
272 [
273 Ok(()),
274 Err(Denied::Forbidden),
275 Err(Denied::Forbidden),
276 Err(Denied::Forbidden),
277 Err(Denied::Forbidden),
278 ],
279 ),
280 (
281 Role::Triage,
282 [
283 Ok(()),
284 Ok(()),
285 Err(Denied::Forbidden),
286 Err(Denied::Forbidden),
287 Err(Denied::Forbidden),
288 ],
289 ),
290 (
291 Role::Write,
292 [
293 Ok(()),
294 Ok(()),
295 Ok(()),
296 Err(Denied::Forbidden),
297 Err(Denied::Forbidden),
298 ],
299 ),
300 (
301 Role::Maintain,
302 [
303 Ok(()),
304 Ok(()),
305 Ok(()),
306 Err(Denied::Forbidden),
307 Err(Denied::Forbidden),
308 ],
309 ),
310 (
311 Role::Admin,
312 [Ok(()), Ok(()), Ok(()), Ok(()), Err(Denied::Forbidden)],
313 ),
314 ];
315 for (role, results) in cases {
316 for (action, want) in ACTIONS.into_iter().zip(results) {
317 assert_eq!(
318 authorize(Some(&u), &r, Some(role), None, action),
319 want,
320 "role {role:?} {action:?}"
321 );
322 }
323 }
324 }
325
326 #[test]
327 fn each_role_on_public_repo_keeps_read() {
328 let r = repo(Visibility::Public, false);
329 let u = user("collab", false);
330 assert_eq!(authorize(Some(&u), &r, None, None, Action::Read), Ok(()));
331 assert_eq!(
332 authorize(Some(&u), &r, None, None, Action::Write),
333 Err(Denied::Forbidden)
334 );
335 assert_eq!(
336 authorize(Some(&u), &r, Some(Role::Read), None, Action::Read),
337 Ok(())
338 );
339 assert_eq!(
340 authorize(Some(&u), &r, Some(Role::Maintain), None, Action::Write),
341 Ok(())
342 );
343 assert_eq!(
344 authorize(Some(&u), &r, Some(Role::Maintain), None, Action::Admin),
345 Err(Denied::Forbidden)
346 );
347 assert_eq!(
348 authorize(Some(&u), &r, Some(Role::Admin), None, Action::Admin),
349 Ok(())
350 );
351 }
352
353 #[test]
354 fn stranger_on_private_repo_gets_not_found_not_forbidden() {
355 let r = repo(Visibility::Private, false);
356 let u = user("stranger", false);
357 for action in ACTIONS {
358 assert_eq!(
359 authorize(Some(&u), &r, None, None, action),
360 Err(Denied::NotFound)
361 );
362 }
363 }
364
365 #[test]
366 fn archived_denies_write_even_for_owner_and_admin_but_not_read_or_admin() {
367 let r = repo(Visibility::Public, true);
368 let owner = user("owner", false);
369 let root = user("root", true);
370 for actor in [&owner, &root] {
371 assert_eq!(
372 authorize(Some(actor), &r, None, None, Action::Write),
373 Err(Denied::Forbidden)
374 );
375 assert_eq!(authorize(Some(actor), &r, None, None, Action::Read), Ok(()));
376 assert_eq!(
377 authorize(Some(actor), &r, None, None, Action::Admin),
378 Ok(())
379 );
380 }
381 let maintainer = user("m", false);
382 assert_eq!(
383 authorize(
384 Some(&maintainer),
385 &r,
386 Some(Role::Maintain),
387 None,
388 Action::Write
389 ),
390 Err(Denied::Forbidden)
391 );
392 }
393
394 #[test]
395 fn delete_is_owner_and_instance_admin_only() {
396 let stranger = user("stranger", false);
397 let owner = user("owner", false);
398 let root = user("root", true);
399 for visibility in [Visibility::Public, Visibility::Private] {
400 let r = repo(visibility, false);
401 assert_eq!(
402 authorize(Some(&owner), &r, None, None, Action::Delete),
403 Ok(())
404 );
405 assert_eq!(
406 authorize(Some(&root), &r, None, None, Action::Delete),
407 Ok(())
408 );
409 // A collaborator with the top role still cannot delete.
410 assert_eq!(
411 authorize(Some(&stranger), &r, Some(Role::Admin), None, Action::Delete),
412 Err(Denied::Forbidden)
413 );
414 assert_eq!(
415 authorize(None, &r, Some(Role::Admin), None, Action::Delete),
416 if visibility == Visibility::Public {
417 Err(Denied::Forbidden)
418 } else {
419 Err(Denied::NotFound)
420 }
421 );
422 }
423 // Visible to a stranger on a public repo: 403. Hidden: 404.
424 let public = repo(Visibility::Public, false);
425 assert_eq!(
426 authorize(Some(&stranger), &public, None, None, Action::Delete),
427 Err(Denied::Forbidden)
428 );
429 let private = repo(Visibility::Private, false);
430 assert_eq!(
431 authorize(Some(&stranger), &private, None, None, Action::Delete),
432 Err(Denied::NotFound)
433 );
434 // Archiving does not stop an owner from deleting.
435 let archived = repo(Visibility::Private, true);
436 assert_eq!(
437 authorize(Some(&owner), &archived, None, None, Action::Delete),
438 Ok(())
439 );
440 }
441
442 #[test]
443 fn triage_is_granted_to_owner_admin_and_triage_plus_write_roles() {
444 let stranger = user("stranger", false);
445 let owner = user("owner", false);
446 let root = user("root", true);
447 let private = repo(Visibility::Private, false);
448 assert_eq!(
449 authorize(Some(&owner), &private, None, None, Action::Triage),
450 Ok(())
451 );
452 assert_eq!(
453 authorize(Some(&root), &private, None, None, Action::Triage),
454 Ok(())
455 );
456 assert_eq!(
457 authorize(Some(&stranger), &private, None, None, Action::Triage),
458 Err(Denied::NotFound)
459 );
460 let public = repo(Visibility::Public, false);
461 assert_eq!(
462 authorize(
463 Some(&stranger),
464 &public,
465 Some(Role::Read),
466 None,
467 Action::Triage
468 ),
469 Err(Denied::Forbidden)
470 );
471 assert_eq!(
472 authorize(None, &public, None, None, Action::Triage),
473 Err(Denied::Forbidden)
474 );
475 assert_eq!(
476 authorize(None, &private, None, None, Action::Triage),
477 Err(Denied::NotFound)
478 );
479 }
480
481 #[test]
482 fn triage_is_not_blocked_by_archived() {
483 let archived = repo(Visibility::Public, true);
484 let owner = user("owner", false);
485 let triager = user("t", false);
486 assert_eq!(
487 authorize(Some(&owner), &archived, None, None, Action::Triage),
488 Ok(())
489 );
490 assert_eq!(
491 authorize(
492 Some(&triager),
493 &archived,
494 Some(Role::Triage),
495 None,
496 Action::Triage
497 ),
498 Ok(())
499 );
500 }
501
502 #[test]
503 fn role_is_ignored_without_an_actor() {
504 let r = repo(Visibility::Private, false);
505 assert_eq!(
506 authorize(None, &r, Some(Role::Admin), None, Action::Read),
507 Err(Denied::NotFound)
508 );
509 }
510
511 #[test]
512 fn max_role_takes_the_higher_of_two() {
513 assert_eq!(max_role(None, None), None);
514 assert_eq!(max_role(Some(Role::Read), None), Some(Role::Read));
515 assert_eq!(max_role(None, Some(Role::Write)), Some(Role::Write));
516 assert_eq!(
517 max_role(Some(Role::Maintain), Some(Role::Triage)),
518 Some(Role::Maintain)
519 );
520 assert_eq!(
521 max_role(Some(Role::Read), Some(Role::Admin)),
522 Some(Role::Admin)
523 );
524 }
525
526 #[test]
527 fn org_role_grants_what_the_collaborator_role_would() {
528 let r = repo(Visibility::Private, false);
529 let member = user("member", false);
530 // Read-only collaborator, but the org gives Write.
531 assert_eq!(
532 authorize(
533 Some(&member),
534 &r,
535 Some(Role::Read),
536 Some(Role::Write),
537 Action::Write
538 ),
539 Ok(())
540 );
541 // An org role alone makes a private repo visible.
542 assert_eq!(
543 authorize(Some(&member), &r, None, Some(Role::Read), Action::Read),
544 Ok(())
545 );
546 assert_eq!(
547 authorize(Some(&member), &r, None, None, Action::Read),
548 Err(Denied::NotFound)
549 );
550 }
551
552 #[test]
553 fn org_admin_may_delete_but_collaborator_admin_may_not() {
554 let r = repo(Visibility::Private, false);
555 let member = user("member", false);
556 assert_eq!(
557 authorize(Some(&member), &r, None, Some(Role::Admin), Action::Delete),
558 Ok(())
559 );
560 assert_eq!(
561 authorize(Some(&member), &r, Some(Role::Admin), None, Action::Delete),
562 Err(Denied::Forbidden)
563 );
564 assert_eq!(
565 authorize(None, &r, None, Some(Role::Admin), Action::Delete),
566 Err(Denied::NotFound)
567 );
568 }
569}