// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! The permission model: one function, [`access`], that every route funnels //! through. //! //! Keeping authorization in a single pure function is what makes it testable to //! exhaustion (see the matrix test below) and impossible for a route to get //! subtly wrong. The function takes everything it needs by value or reference and //! returns a verdict; the *consequences* of that verdict — in particular that //! [`Access::None`] on a private repo renders `404`, never `403`, so existence //! does not leak — belong to the web and API layers. use model::{Repo, Visibility}; /// The access level a viewer has to a repository, in increasing order of power. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Access { /// No access. On a private repo the caller MUST render `404`, not `403`. None, /// May read: browse, clone, and read-only API. Read, /// May read and push. Write, /// May read, push, and administer (settings, collaborators, deletion). Admin, } /// A stored collaborator permission — the `repo_collaborators.permission` column. /// /// Distinct from [`Access`] because a collaborator row can never say "no access" /// (its absence does); the two convert one way, [`Permission`] into [`Access`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Permission { /// Read-only collaborator. Read, /// Read-write collaborator. Write, /// Administrative collaborator. Admin, } impl Permission { /// The wire/database spelling (`read` | `write` | `admin`). #[must_use] pub fn as_str(self) -> &'static str { match self { Self::Read => "read", Self::Write => "write", Self::Admin => "admin", } } /// Parse a stored permission string, returning `None` for anything /// unrecognized (a corrupt row grants nothing rather than panicking). #[must_use] pub fn parse(s: &str) -> Option { match s { "read" => Some(Self::Read), "write" => Some(Self::Write), "admin" => Some(Self::Admin), _ => None, } } } impl From for Access { fn from(permission: Permission) -> Self { match permission { Permission::Read => Self::Read, Permission::Write => Self::Write, Permission::Admin => Self::Admin, } } } /// The authenticated identity making a request, as far as authorization cares. /// /// Anonymous requests pass `None` in place of a `Viewer`. A disabled account must /// never be turned into a `Viewer` — that check belongs to the authentication /// layer, upstream of every call here. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Viewer { /// The viewer's user id (`users.id`). pub id: String, /// Whether the viewer is a site administrator. pub is_admin: bool, } /// Resolve the access `viewer` has to `repo`. /// /// The rules are applied strictly in order; the first that matches wins: /// /// 1. a site admin gets [`Access::Admin`]; /// 2. the repo's owner gets [`Access::Admin`]; /// 3. an explicit collaborator gets exactly their stored permission; /// 4. otherwise the repo's [`Visibility`] decides: /// - **public**: [`Access::Read`] for any signed-in viewer, and for anonymous /// viewers when the instance allows anonymous access; /// - **internal**: [`Access::Read`] for any signed-in viewer, never anonymous; /// - **private**: [`Access::None`]. /// /// `collaborator` is the viewer's row in `repo_collaborators` for this repo, if /// any; the caller looks it up. `allow_anonymous` is `instance.allow_anonymous` /// and now gates only anonymous access to *public* repos. #[must_use] pub fn access( viewer: Option<&Viewer>, repo: &Repo, collaborator: Option, allow_anonymous: bool, ) -> Access { if let Some(v) = viewer { if v.is_admin { return Access::Admin; } if v.id == repo.owner_id { return Access::Admin; } if let Some(permission) = collaborator { return permission.into(); } } match repo.visibility { Visibility::Public if viewer.is_some() || allow_anonymous => Access::Read, Visibility::Internal if viewer.is_some() => Access::Read, _ => Access::None, } } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; /// A repo owned by `owner`, with the given visibility. Only the fields /// `access` reads are meaningful; the rest are filler. fn repo(owner: &str, visibility: Visibility) -> Repo { Repo { id: "01reporeporeporeporeporepo".to_string(), owner_id: owner.to_string(), group_id: None, name: "r".to_string(), path: "r".to_string(), description: None, visibility, default_branch: "main".to_string(), object_format: "sha1".to_string(), issues_enabled: true, pulls_enabled: true, releases_enabled: true, is_mirror: false, fork_parent_id: None, next_iid: 1, archived_at: None, size_bytes: 0, pushed_at: None, created_at: 0, updated_at: 0, } } /// Every visibility, for exhaustive iteration. const ALL_VIS: [Visibility; 3] = [ Visibility::Public, Visibility::Internal, Visibility::Private, ]; fn viewer(id: &str, is_admin: bool) -> Viewer { Viewer { id: id.to_string(), is_admin, } } #[test] fn permission_strings_round_trip() { for p in [Permission::Read, Permission::Write, Permission::Admin] { assert_eq!(Permission::parse(p.as_str()), Some(p)); } assert_eq!(Permission::parse("owner"), None); } #[test] fn access_is_ordered() { assert!(Access::None < Access::Read); assert!(Access::Read < Access::Write); assert!(Access::Write < Access::Admin); } /// One row of the access matrix: the inputs and the verdict they must yield. struct Case { viewer: Option, collaborator: Option, visibility: Visibility, allow_anonymous: bool, expect: Access, } /// The whole point of this crate: enumerate every combination of viewer kind, /// visibility, collaborator permission, and the anonymous-access flag, and /// assert the exact verdict. Nothing here is sampled — it is the full matrix. #[test] fn access_matrix_is_exhaustive() { // Viewer kinds, expressed relative to a repo owned by "owner". let admin = viewer("boss", true); let owner = viewer("owner", false); let other = viewer("stranger", false); let mut cases = Vec::new(); let mut push = |viewer: Option<&Viewer>, collaborator: Option, visibility: Visibility, allow_anonymous: bool, expect: Access| { cases.push(Case { viewer: viewer.cloned(), collaborator, visibility, allow_anonymous, expect, }); }; for &vis in &ALL_VIS { for &anon in &[true, false] { // Rule 1: a site admin is Admin everywhere, regardless of every // other dimension. for collab in [None, Some(Permission::Read)] { push(Some(&admin), collab, vis, anon, Access::Admin); } // Rule 2: the owner is Admin everywhere, even with a lesser // collaborator row present and anonymous access off. for collab in [None, Some(Permission::Read), Some(Permission::Write)] { push(Some(&owner), collab, vis, anon, Access::Admin); } // Rule 3: an explicit collaborator gets exactly their permission, // beating visibility and the anonymous flag. push( Some(&other), Some(Permission::Read), vis, anon, Access::Read, ); push( Some(&other), Some(Permission::Write), vis, anon, Access::Write, ); push( Some(&other), Some(Permission::Admin), vis, anon, Access::Admin, ); } } // Rule 4: a non-collaborator, by visibility. // Signed-in stranger: reads public and internal always; never private. for &anon in &[true, false] { push(Some(&other), None, Visibility::Public, anon, Access::Read); push(Some(&other), None, Visibility::Internal, anon, Access::Read); push(Some(&other), None, Visibility::Private, anon, Access::None); } // Anonymous: reads public only when allowed; never internal or private. push(None, None, Visibility::Public, true, Access::Read); push(None, None, Visibility::Public, false, Access::None); push(None, None, Visibility::Internal, true, Access::None); push(None, None, Visibility::Internal, false, Access::None); push(None, None, Visibility::Private, true, Access::None); push(None, None, Visibility::Private, false, Access::None); for case in &cases { let repo = repo("owner", case.visibility); let got = access( case.viewer.as_ref(), &repo, case.collaborator, case.allow_anonymous, ); assert_eq!( got, case.expect, "viewer={:?} collab={:?} vis={} anon={}", case.viewer, case.collaborator, case.visibility, case.allow_anonymous ); } } #[test] fn private_repo_never_leaks_to_a_stranger() { // The security-critical invariant: no combination of a non-privileged // stranger on a private repo yields anything but None. Internal repos also // never leak to anonymous visitors. let stranger = viewer("stranger", false); for &anon in &[true, false] { assert_eq!( access( Some(&stranger), &repo("owner", Visibility::Private), None, anon ), Access::None ); assert_eq!( access(None, &repo("owner", Visibility::Private), None, anon), Access::None ); assert_eq!( access(None, &repo("owner", Visibility::Internal), None, anon), Access::None ); } } }