fabrica

hanna/fabrica

4222 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! API token scopes.
6//!
7//! A token carries a comma-separated scope list, stored verbatim in
8//! `api_tokens.scopes` and echoed inside the JWT. The wire spelling
9//! (`repo:read`, …) is the single source of truth; this module is the only place
10//! that maps those strings to and from the [`Scope`] enum.
11
12use crate::AuthError;
13
14/// A capability a token may grant. The string forms match the spec exactly and
15/// are what appears in config, the CLI, and the JWT payload.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum Scope {
18 /// Read repository contents (clone, browse, read API).
19 RepoRead,
20 /// Push to repositories and mutate their contents.
21 RepoWrite,
22 /// Administer repositories (settings, collaborators, deletion).
23 RepoAdmin,
24 /// Read user and account information.
25 UserRead,
26 /// Mutate user and account information.
27 UserWrite,
28 /// Full administrative access, equivalent to a site admin.
29 Admin,
30}
31
32impl Scope {
33 /// The canonical wire spelling.
34 #[must_use]
35 pub fn as_str(self) -> &'static str {
36 match self {
37 Self::RepoRead => "repo:read",
38 Self::RepoWrite => "repo:write",
39 Self::RepoAdmin => "repo:admin",
40 Self::UserRead => "user:read",
41 Self::UserWrite => "user:write",
42 Self::Admin => "admin",
43 }
44 }
45
46 /// Parse a single scope token.
47 ///
48 /// # Errors
49 ///
50 /// Returns [`AuthError::UnknownScope`] if `s` is not a known scope. Leading
51 /// and trailing whitespace is tolerated so a hand-written `--scopes a, b`
52 /// list parses.
53 pub fn parse(s: &str) -> Result<Self, AuthError> {
54 match s.trim() {
55 "repo:read" => Ok(Self::RepoRead),
56 "repo:write" => Ok(Self::RepoWrite),
57 "repo:admin" => Ok(Self::RepoAdmin),
58 "user:read" => Ok(Self::UserRead),
59 "user:write" => Ok(Self::UserWrite),
60 "admin" => Ok(Self::Admin),
61 other => Err(AuthError::UnknownScope(other.to_string())),
62 }
63 }
64}
65
66/// Parse a comma-separated scope list. Empty entries (a trailing comma, or an
67/// entirely blank string) are skipped rather than rejected.
68///
69/// # Errors
70///
71/// Returns [`AuthError::UnknownScope`] on the first unrecognized entry.
72pub fn parse_scopes(list: &str) -> Result<Vec<Scope>, AuthError> {
73 list.split(',')
74 .map(str::trim)
75 .filter(|s| !s.is_empty())
76 .map(Scope::parse)
77 .collect()
78}
79
80/// Render scopes as the canonical comma-separated string stored in the database.
81#[must_use]
82pub fn format_scopes(scopes: &[Scope]) -> String {
83 scopes
84 .iter()
85 .map(|s| s.as_str())
86 .collect::<Vec<_>>()
87 .join(",")
88}
89
90#[cfg(test)]
91mod tests {
92 #![allow(clippy::unwrap_used)]
93
94 use super::*;
95
96 #[test]
97 fn every_scope_round_trips() {
98 for scope in [
99 Scope::RepoRead,
100 Scope::RepoWrite,
101 Scope::RepoAdmin,
102 Scope::UserRead,
103 Scope::UserWrite,
104 Scope::Admin,
105 ] {
106 assert_eq!(Scope::parse(scope.as_str()).unwrap(), scope);
107 }
108 }
109
110 #[test]
111 fn list_parses_and_reformats_canonically() {
112 let scopes = parse_scopes(" repo:read, repo:write ,admin ").unwrap();
113 assert_eq!(scopes, [Scope::RepoRead, Scope::RepoWrite, Scope::Admin]);
114 assert_eq!(format_scopes(&scopes), "repo:read,repo:write,admin");
115 }
116
117 #[test]
118 fn blank_and_trailing_commas_are_ignored() {
119 assert!(parse_scopes("").unwrap().is_empty());
120 assert_eq!(
121 parse_scopes("repo:read,,").unwrap(),
122 [Scope::RepoRead],
123 "empty entries dropped, not rejected"
124 );
125 }
126
127 #[test]
128 fn unknown_scope_is_rejected() {
129 let err = parse_scopes("repo:read,repo:destroy").unwrap_err();
130 assert!(
131 matches!(&err, AuthError::UnknownScope(s) if s == "repo:destroy"),
132 "got {err:?}"
133 );
134 }
135}