//! Authorization rules. Pure, no IO. use crate::model::{Repo, Role, User, Visibility}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { Read, /// Edit anyone's issue or comment, close/reopen, labels, assignees, milestones. /// Not blocked by `archived` (the pages handle archived repos). Triage, Write, Admin, /// Delete the repo. Owner and instance admin only; a collaborator role never grants it. Delete, } /// `NotFound` when the actor cannot even see the repo; `Forbidden` when they can see it but lack the right. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Denied { NotFound, Forbidden, } /// The higher of two optional roles. `Read < Triage < Write < Maintain < Admin`. pub fn max_role(a: Option, b: Option) -> Option { fn rank(role: Role) -> u8 { match role { Role::Read => 1, Role::Triage => 2, Role::Write => 3, Role::Maintain => 4, Role::Admin => 5, } } match (a, b) { (Some(x), Some(y)) => Some(if rank(y) > rank(x) { y } else { x }), (x, None) => x, (None, y) => y, } } /// Owner or instance admin: all actions. Public repo: Read for anyone. /// Private repo with no role: NotFound. Read/Triage: Read. Write/Maintain: Read+Write. Admin: all. /// Triage is granted to Triage, Write, Maintain and Admin. /// Archived repos deny Write with Forbidden. /// /// `org_role` is the role the actor gets through an organization that owns the repo (see /// `Store::org_repo_role`). The effective role is `max_role(collaborator_role, org_role)`. A /// collaborator Admin never deletes a repo; an org-derived Admin (org owner, or a team with /// the admin role) does, because org-owned repos have no single owner person. pub fn authorize( actor: Option<&User>, repo: &Repo, collaborator_role: Option, org_role: Option, action: Action, ) -> Result<(), Denied> { // A role only belongs to a signed-in actor. let role = if actor.is_some() { max_role(collaborator_role, org_role) } else { None }; if action == Action::Write && repo.archived { return Err(Denied::Forbidden); } let privileged = actor.is_some_and(|u| u.is_admin || u.id == repo.owner_id); if privileged { return Ok(()); } let can_see = repo.visibility == Visibility::Public || role.is_some(); if !can_see { return Err(Denied::NotFound); } let granted = match action { Action::Read => true, Action::Triage => matches!( role, Some(Role::Triage | Role::Write | Role::Maintain | Role::Admin) ), Action::Write => matches!(role, Some(Role::Write | Role::Maintain | Role::Admin)), Action::Admin => matches!(role, Some(Role::Admin)), Action::Delete => actor.is_some() && org_role == Some(Role::Admin), }; if granted { Ok(()) } else { Err(Denied::Forbidden) } } #[cfg(test)] mod tests { use super::*; use crate::model::AccountKind; const ACTIONS: [Action; 5] = [ Action::Read, Action::Triage, Action::Write, Action::Admin, Action::Delete, ]; const ROLES: [Option; 6] = [ None, Some(Role::Read), Some(Role::Triage), Some(Role::Write), Some(Role::Maintain), Some(Role::Admin), ]; fn user(id: &str, is_admin: bool) -> User { User { id: id.into(), username: id.into(), email: None, password_hash: String::new(), is_admin, created_at: String::new(), about: String::new(), timezone: "UTC".into(), avatar_hash: None, theme: "auto".into(), language: "auto".into(), kind: AccountKind::User, email_notifications: true, } } fn repo(visibility: Visibility, archived: bool) -> Repo { Repo { id: "repo1".into(), owner_id: "owner".into(), owner_name: "owner".into(), name: "r".into(), description: String::new(), visibility, default_branch: "main".into(), archived, created_at: String::new(), fork_of: None, } } /// Expected result from the contract, written independently of the implementation. fn expected( actor: Option<&User>, r: &Repo, role: Option, action: Action, ) -> Result<(), Denied> { let role = if actor.is_some() { role } else { None }; if action == Action::Write && r.archived { return Err(Denied::Forbidden); } if actor.is_some_and(|u| u.is_admin || u.id == r.owner_id) { return Ok(()); } if action == Action::Delete { // Only owner or instance admin: hidden repos are 404, visible ones 403. return if r.visibility == Visibility::Public || role.is_some() { Err(Denied::Forbidden) } else { Err(Denied::NotFound) }; } if r.visibility == Visibility::Public { let ok = match action { Action::Read => true, Action::Triage => matches!( role, Some(Role::Triage | Role::Write | Role::Maintain | Role::Admin) ), Action::Write => matches!(role, Some(Role::Write | Role::Maintain | Role::Admin)), Action::Admin => role == Some(Role::Admin), Action::Delete => false, }; return if ok { Ok(()) } else { Err(Denied::Forbidden) }; } match role { None => Err(Denied::NotFound), Some(r) => { let ok = match action { Action::Read => true, Action::Triage => { matches!(r, Role::Triage | Role::Write | Role::Maintain | Role::Admin) } Action::Write => matches!(r, Role::Write | Role::Maintain | Role::Admin), Action::Admin => r == Role::Admin, Action::Delete => false, }; if ok { Ok(()) } else { Err(Denied::Forbidden) } } } } #[test] fn exhaustive_matrix_matches_contract() { let stranger = user("stranger", false); let owner = user("owner", false); let admin = user("root", true); let actors: [Option<&User>; 4] = [None, Some(&stranger), Some(&owner), Some(&admin)]; for visibility in [Visibility::Public, Visibility::Private] { for archived in [false, true] { let r = repo(visibility, archived); for actor in actors { for role in ROLES { for action in ACTIONS { assert_eq!( authorize(actor, &r, role, None, action), expected(actor, &r, role, action), "vis={visibility:?} archived={archived} actor={:?} role={role:?} action={action:?}", actor.map(|u| u.id.as_str()), ); } } } } } } #[test] fn anonymous_on_public_repo_can_read_only() { let r = repo(Visibility::Public, false); assert_eq!(authorize(None, &r, None, None, Action::Read), Ok(())); assert_eq!( authorize(None, &r, None, None, Action::Write), Err(Denied::Forbidden) ); assert_eq!( authorize(None, &r, None, None, Action::Admin), Err(Denied::Forbidden) ); } #[test] fn anonymous_on_private_repo_gets_not_found() { let r = repo(Visibility::Private, false); for action in ACTIONS { assert_eq!( authorize(None, &r, None, None, action), Err(Denied::NotFound) ); } } #[test] fn owner_and_instance_admin_can_do_everything() { for visibility in [Visibility::Public, Visibility::Private] { let r = repo(visibility, false); let owner = user("owner", false); let admin = user("root", true); for action in ACTIONS { assert_eq!(authorize(Some(&owner), &r, None, None, action), Ok(())); assert_eq!(authorize(Some(&admin), &r, None, None, action), Ok(())); } } } #[test] fn each_role_on_private_repo() { let r = repo(Visibility::Private, false); let u = user("collab", false); let cases = [ ( Role::Read, [ Ok(()), Err(Denied::Forbidden), Err(Denied::Forbidden), Err(Denied::Forbidden), Err(Denied::Forbidden), ], ), ( Role::Triage, [ Ok(()), Ok(()), Err(Denied::Forbidden), Err(Denied::Forbidden), Err(Denied::Forbidden), ], ), ( Role::Write, [ Ok(()), Ok(()), Ok(()), Err(Denied::Forbidden), Err(Denied::Forbidden), ], ), ( Role::Maintain, [ Ok(()), Ok(()), Ok(()), Err(Denied::Forbidden), Err(Denied::Forbidden), ], ), ( Role::Admin, [Ok(()), Ok(()), Ok(()), Ok(()), Err(Denied::Forbidden)], ), ]; for (role, results) in cases { for (action, want) in ACTIONS.into_iter().zip(results) { assert_eq!( authorize(Some(&u), &r, Some(role), None, action), want, "role {role:?} {action:?}" ); } } } #[test] fn each_role_on_public_repo_keeps_read() { let r = repo(Visibility::Public, false); let u = user("collab", false); assert_eq!(authorize(Some(&u), &r, None, None, Action::Read), Ok(())); assert_eq!( authorize(Some(&u), &r, None, None, Action::Write), Err(Denied::Forbidden) ); assert_eq!( authorize(Some(&u), &r, Some(Role::Read), None, Action::Read), Ok(()) ); assert_eq!( authorize(Some(&u), &r, Some(Role::Maintain), None, Action::Write), Ok(()) ); assert_eq!( authorize(Some(&u), &r, Some(Role::Maintain), None, Action::Admin), Err(Denied::Forbidden) ); assert_eq!( authorize(Some(&u), &r, Some(Role::Admin), None, Action::Admin), Ok(()) ); } #[test] fn stranger_on_private_repo_gets_not_found_not_forbidden() { let r = repo(Visibility::Private, false); let u = user("stranger", false); for action in ACTIONS { assert_eq!( authorize(Some(&u), &r, None, None, action), Err(Denied::NotFound) ); } } #[test] fn archived_denies_write_even_for_owner_and_admin_but_not_read_or_admin() { let r = repo(Visibility::Public, true); let owner = user("owner", false); let root = user("root", true); for actor in [&owner, &root] { assert_eq!( authorize(Some(actor), &r, None, None, Action::Write), Err(Denied::Forbidden) ); assert_eq!(authorize(Some(actor), &r, None, None, Action::Read), Ok(())); assert_eq!( authorize(Some(actor), &r, None, None, Action::Admin), Ok(()) ); } let maintainer = user("m", false); assert_eq!( authorize( Some(&maintainer), &r, Some(Role::Maintain), None, Action::Write ), Err(Denied::Forbidden) ); } #[test] fn delete_is_owner_and_instance_admin_only() { let stranger = user("stranger", false); let owner = user("owner", false); let root = user("root", true); for visibility in [Visibility::Public, Visibility::Private] { let r = repo(visibility, false); assert_eq!( authorize(Some(&owner), &r, None, None, Action::Delete), Ok(()) ); assert_eq!( authorize(Some(&root), &r, None, None, Action::Delete), Ok(()) ); // A collaborator with the top role still cannot delete. assert_eq!( authorize(Some(&stranger), &r, Some(Role::Admin), None, Action::Delete), Err(Denied::Forbidden) ); assert_eq!( authorize(None, &r, Some(Role::Admin), None, Action::Delete), if visibility == Visibility::Public { Err(Denied::Forbidden) } else { Err(Denied::NotFound) } ); } // Visible to a stranger on a public repo: 403. Hidden: 404. let public = repo(Visibility::Public, false); assert_eq!( authorize(Some(&stranger), &public, None, None, Action::Delete), Err(Denied::Forbidden) ); let private = repo(Visibility::Private, false); assert_eq!( authorize(Some(&stranger), &private, None, None, Action::Delete), Err(Denied::NotFound) ); // Archiving does not stop an owner from deleting. let archived = repo(Visibility::Private, true); assert_eq!( authorize(Some(&owner), &archived, None, None, Action::Delete), Ok(()) ); } #[test] fn triage_is_granted_to_owner_admin_and_triage_plus_write_roles() { let stranger = user("stranger", false); let owner = user("owner", false); let root = user("root", true); let private = repo(Visibility::Private, false); assert_eq!( authorize(Some(&owner), &private, None, None, Action::Triage), Ok(()) ); assert_eq!( authorize(Some(&root), &private, None, None, Action::Triage), Ok(()) ); assert_eq!( authorize(Some(&stranger), &private, None, None, Action::Triage), Err(Denied::NotFound) ); let public = repo(Visibility::Public, false); assert_eq!( authorize( Some(&stranger), &public, Some(Role::Read), None, Action::Triage ), Err(Denied::Forbidden) ); assert_eq!( authorize(None, &public, None, None, Action::Triage), Err(Denied::Forbidden) ); assert_eq!( authorize(None, &private, None, None, Action::Triage), Err(Denied::NotFound) ); } #[test] fn triage_is_not_blocked_by_archived() { let archived = repo(Visibility::Public, true); let owner = user("owner", false); let triager = user("t", false); assert_eq!( authorize(Some(&owner), &archived, None, None, Action::Triage), Ok(()) ); assert_eq!( authorize( Some(&triager), &archived, Some(Role::Triage), None, Action::Triage ), Ok(()) ); } #[test] fn role_is_ignored_without_an_actor() { let r = repo(Visibility::Private, false); assert_eq!( authorize(None, &r, Some(Role::Admin), None, Action::Read), Err(Denied::NotFound) ); } #[test] fn max_role_takes_the_higher_of_two() { assert_eq!(max_role(None, None), None); assert_eq!(max_role(Some(Role::Read), None), Some(Role::Read)); assert_eq!(max_role(None, Some(Role::Write)), Some(Role::Write)); assert_eq!( max_role(Some(Role::Maintain), Some(Role::Triage)), Some(Role::Maintain) ); assert_eq!( max_role(Some(Role::Read), Some(Role::Admin)), Some(Role::Admin) ); } #[test] fn org_role_grants_what_the_collaborator_role_would() { let r = repo(Visibility::Private, false); let member = user("member", false); // Read-only collaborator, but the org gives Write. assert_eq!( authorize( Some(&member), &r, Some(Role::Read), Some(Role::Write), Action::Write ), Ok(()) ); // An org role alone makes a private repo visible. assert_eq!( authorize(Some(&member), &r, None, Some(Role::Read), Action::Read), Ok(()) ); assert_eq!( authorize(Some(&member), &r, None, None, Action::Read), Err(Denied::NotFound) ); } #[test] fn org_admin_may_delete_but_collaborator_admin_may_not() { let r = repo(Visibility::Private, false); let member = user("member", false); assert_eq!( authorize(Some(&member), &r, None, Some(Role::Admin), Action::Delete), Ok(()) ); assert_eq!( authorize(Some(&member), &r, Some(Role::Admin), None, Action::Delete), Err(Denied::Forbidden) ); assert_eq!( authorize(None, &r, None, Some(Role::Admin), Action::Delete), Err(Denied::NotFound) ); } }