// 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/. //! API token scopes. //! //! A token carries a comma-separated scope list, stored verbatim in //! `api_tokens.scopes` and echoed inside the JWT. The wire spelling //! (`repo:read`, …) is the single source of truth; this module is the only place //! that maps those strings to and from the [`Scope`] enum. use crate::AuthError; /// A capability a token may grant. The string forms match the spec exactly and /// are what appears in config, the CLI, and the JWT payload. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Scope { /// Read repository contents (clone, browse, read API). RepoRead, /// Push to repositories and mutate their contents. RepoWrite, /// Administer repositories (settings, collaborators, deletion). RepoAdmin, /// Read user and account information. UserRead, /// Mutate user and account information. UserWrite, /// Full administrative access, equivalent to a site admin. Admin, } impl Scope { /// The canonical wire spelling. #[must_use] pub fn as_str(self) -> &'static str { match self { Self::RepoRead => "repo:read", Self::RepoWrite => "repo:write", Self::RepoAdmin => "repo:admin", Self::UserRead => "user:read", Self::UserWrite => "user:write", Self::Admin => "admin", } } /// Parse a single scope token. /// /// # Errors /// /// Returns [`AuthError::UnknownScope`] if `s` is not a known scope. Leading /// and trailing whitespace is tolerated so a hand-written `--scopes a, b` /// list parses. pub fn parse(s: &str) -> Result { match s.trim() { "repo:read" => Ok(Self::RepoRead), "repo:write" => Ok(Self::RepoWrite), "repo:admin" => Ok(Self::RepoAdmin), "user:read" => Ok(Self::UserRead), "user:write" => Ok(Self::UserWrite), "admin" => Ok(Self::Admin), other => Err(AuthError::UnknownScope(other.to_string())), } } } /// Parse a comma-separated scope list. Empty entries (a trailing comma, or an /// entirely blank string) are skipped rather than rejected. /// /// # Errors /// /// Returns [`AuthError::UnknownScope`] on the first unrecognized entry. pub fn parse_scopes(list: &str) -> Result, AuthError> { list.split(',') .map(str::trim) .filter(|s| !s.is_empty()) .map(Scope::parse) .collect() } /// Render scopes as the canonical comma-separated string stored in the database. #[must_use] pub fn format_scopes(scopes: &[Scope]) -> String { scopes .iter() .map(|s| s.as_str()) .collect::>() .join(",") } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; #[test] fn every_scope_round_trips() { for scope in [ Scope::RepoRead, Scope::RepoWrite, Scope::RepoAdmin, Scope::UserRead, Scope::UserWrite, Scope::Admin, ] { assert_eq!(Scope::parse(scope.as_str()).unwrap(), scope); } } #[test] fn list_parses_and_reformats_canonically() { let scopes = parse_scopes(" repo:read, repo:write ,admin ").unwrap(); assert_eq!(scopes, [Scope::RepoRead, Scope::RepoWrite, Scope::Admin]); assert_eq!(format_scopes(&scopes), "repo:read,repo:write,admin"); } #[test] fn blank_and_trailing_commas_are_ignored() { assert!(parse_scopes("").unwrap().is_empty()); assert_eq!( parse_scopes("repo:read,,").unwrap(), [Scope::RepoRead], "empty entries dropped, not rejected" ); } #[test] fn unknown_scope_is_rejected() { let err = parse_scopes("repo:read,repo:destroy").unwrap_err(); assert!( matches!(&err, AuthError::UnknownScope(s) if s == "repo:destroy"), "got {err:?}" ); } }