Skip to content
349 linesCodeBlameRaw
1//! Access tokens, and a workspace's rules for the personal tokens that
2//! reach it: the identity methods for both.
3//!
4//! There is one kind of access token. It belongs to a person (and acts as
5//! them) or to a workspace (and acts as it), and it has:
6//!
7//! - **permissions**: a level for each resource, such as issues: write or
8//! repositories: read ([`crate::scopes::resolve_permissions`]). They are
9//! stored as scopes, the highest of each resource, and every check (the
10//! API, the MCP server, git, the registries) reads those scopes.
11//! - **a reach**: a person's token reaches every workspace they belong to,
12//! or one workspace they choose, and in it all of its repositories, the
13//! ones chosen, or none of the private ones. With no workspace and no
14//! repositories it reaches its owner's account and public repositories
15//! only. A workspace's token reaches its own workspace: all of its
16//! repositories or the ones chosen.
17//! - **an expiry**: up to [`MAX_LIFETIME_DAYS`] days, or none where the
18//! workspaces it reaches allow that.
19//!
20//! A workspace's owners decide whether tokens made for every workspace of
21//! their owner reach it, whether tokens made for it alone do, whether those
22//! wait for their approval, and how long a token reaching it may last; they
23//! see every member's token that reaches it, and can revoke one there.
24//!
25//! Each `*Args` struct is the argument of the identity method of the same
26//! name, served at `POST /rpc/<method>`.
27
28use std::collections::BTreeMap;
29
30use serde::{Deserialize, Serialize};
31
32use crate::identity::AccessToken;
33use crate::scopes::RepositorySelection;
34use crate::{User, Viewer};
35
36/// The longest a token with an expiry may last, in days.
37pub const MAX_LIFETIME_DAYS: u32 = 366;
38
39/// The most repositories a token may select.
40pub const MAX_SELECTED_REPOSITORIES: usize = 50;
41
42/// Whether a token made for one workspace may be used there.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum TokenStatus {
46 #[default]
47 Active,
48 /// Waiting for an owner of the workspace to approve it. Until then it
49 /// reads public repositories only.
50 Pending,
51 /// An owner turned it down.
52 Denied,
53 /// An owner took it out of the workspace.
54 Revoked,
55}
56
57impl TokenStatus {
58 pub fn as_str(self) -> &'static str {
59 match self {
60 TokenStatus::Active => "active",
61 TokenStatus::Pending => "pending",
62 TokenStatus::Denied => "denied",
63 TokenStatus::Revoked => "revoked",
64 }
65 }
66
67 pub fn parse(text: &str) -> TokenStatus {
68 match text {
69 "pending" => TokenStatus::Pending,
70 "denied" => TokenStatus::Denied,
71 "revoked" => TokenStatus::Revoked,
72 _ => TokenStatus::Active,
73 }
74 }
75}
76
77/// `create_token`: a person makes an access token, for themselves or, as
78/// an owner, for a workspace. People only, signed in (not with a token),
79/// with a confirmed address. Returns `Outcome<CreatedAccessToken>`; a
80/// personal token made for a workspace that asks for approval starts
81/// pending unless its owner is an owner there.
82#[derive(Debug, Default, Serialize, Deserialize)]
83pub struct CreateTokenArgs {
84 pub actor: User,
85 /// Who it belongs to: null for the actor, or the slug of a workspace
86 /// the actor owns, whose token it then is.
87 #[serde(default)]
88 pub owner: Option<String>,
89 pub name: String,
90 #[serde(default)]
91 pub description: Option<String>,
92 /// How long it lasts: 1 to [`MAX_LIFETIME_DAYS`] days; null for no
93 /// expiry, where the workspaces it reaches allow that.
94 #[serde(default)]
95 pub ttl_seconds: Option<u64>,
96 /// A personal token's reach: null for every workspace its owner
97 /// belongs to, or the slug of one. Ignored for a workspace's token,
98 /// which reaches its own workspace.
99 #[serde(default)]
100 pub workspace: Option<String>,
101 /// Which repositories of that workspace: all, the selected ones, or
102 /// public ones only. With no workspace, `public` makes a token for its
103 /// owner's account and public repositories only; `selected` needs one.
104 #[serde(default)]
105 pub repository_selection: RepositorySelection,
106 /// With `selected`: the repositories, as `owner/name` or a name in the
107 /// workspace. At most [`MAX_SELECTED_REPOSITORIES`].
108 #[serde(default)]
109 pub repositories: Vec<String>,
110 /// Each resource's level by name, such as `{"issues": "write"}`; left
111 /// out or `none` is no access. See [`crate::scopes::resolve_permissions`].
112 #[serde(default)]
113 pub permissions: BTreeMap<String, String>,
114 /// Whether it may be used on the website as its owner: a person's token
115 /// only. Never part of its permissions, so full access does not include it.
116 #[serde(default)]
117 pub website: bool,
118}
119
120/// `update_token`: a person changes a token of theirs, or, as an owner,
121/// a workspace's token: its name, description, repositories or
122/// permissions. What is left out stays; the token itself, its reach's
123/// workspace and its expiry do not change. A personal token made for a
124/// workspace that asks for approval waits for it again when its
125/// repositories or permissions change, unless its owner is an owner there.
126/// Returns `Outcome<AccessToken>`.
127#[derive(Debug, Default, Serialize, Deserialize)]
128pub struct UpdateTokenArgs {
129 pub actor: User,
130 pub id: String,
131 /// The workspace whose token it is; null for one of the actor's own.
132 #[serde(default)]
133 pub owner: Option<String>,
134 #[serde(default)]
135 pub name: Option<String>,
136 #[serde(default)]
137 pub description: Option<String>,
138 #[serde(default)]
139 pub repository_selection: Option<RepositorySelection>,
140 #[serde(default)]
141 pub repositories: Option<Vec<String>>,
142 #[serde(default)]
143 pub permissions: Option<BTreeMap<String, String>>,
144 /// Whether it may be used on the website; a person's token only.
145 #[serde(default)]
146 pub website: Option<bool>,
147}
148
149/// A workspace's rules for personal access tokens.
150#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct TokenPolicy {
153 /// Whether a token made for every workspace of its owner reaches this
154 /// one. Off: it still works everywhere else.
155 pub allow_tokens_for_all_workspaces: bool,
156 /// Whether a token may be made for this workspace alone.
157 pub allow_tokens_for_this_workspace: bool,
158 /// Whether a token made for this workspace alone waits for an owner's
159 /// approval. Owners' own tokens never wait.
160 pub require_approval: bool,
161 /// The longest a token reaching it may last, in days. Null: no limit
162 /// (a token with an expiry still lasts at most 366 days).
163 pub max_lifetime_days: Option<u32>,
164 /// Whether a token that never expires is kept out.
165 pub forbid_no_expiry: bool,
166 /// Who changed it last, and when; null for the defaults.
167 #[serde(default)]
168 pub updated_by: Option<String>,
169 #[serde(default)]
170 pub updated_at: Option<String>,
171}
172
173impl Default for TokenPolicy {
174 /// What a workspace has until an owner changes it.
175 fn default() -> Self {
176 TokenPolicy {
177 allow_tokens_for_all_workspaces: true,
178 allow_tokens_for_this_workspace: true,
179 require_approval: true,
180 max_lifetime_days: None,
181 forbid_no_expiry: false,
182 updated_by: None,
183 updated_at: None,
184 }
185 }
186}
187
188impl TokenPolicy {
189 /// Whether a token made at `created_ms` that expires at `expires_ms`
190 /// (none: never) lasts no longer than the policy allows.
191 pub fn lifetime_allowed(&self, created_ms: u64, expires_ms: Option<u64>) -> bool {
192 match (expires_ms, self.max_lifetime_days) {
193 (None, _) if self.forbid_no_expiry => false,
194 (None, Some(_)) => false,
195 (None, None) => true,
196 (Some(_), None) => true,
197 // A day's grace, for clocks and for "30 days" picked at 23:59.
198 (Some(expires), Some(days)) => expires.saturating_sub(created_ms) <= (u64::from(days) + 1) * 86_400_000,
199 }
200 }
201
202 /// Why a token that lasts `ttl_seconds` (none: forever) cannot be made
203 /// to reach the workspace `slug`, if it cannot. `this_workspace` is
204 /// whether it is made for that workspace alone, rather than for every
205 /// workspace of its owner.
206 pub fn refusal(&self, slug: &str, this_workspace: bool, ttl_seconds: Option<u64>) -> Option<String> {
207 if this_workspace && !self.allow_tokens_for_this_workspace {
208 return Some(format!("{slug} does not allow personal access tokens made for it."));
209 }
210 if !this_workspace && !self.allow_tokens_for_all_workspaces {
211 return Some(format!("{slug} does not allow tokens made for all of a member's workspaces: make one for {slug} alone."));
212 }
213 if !self.lifetime_allowed(0, ttl_seconds.map(|ttl| ttl * 1000)) {
214 return Some(match self.max_lifetime_days {
215 Some(days) => format!("{slug} allows tokens that last at most {days} days."),
216 None => format!("{slug} does not allow tokens that never expire."),
217 });
218 }
219 None
220 }
221}
222
223/// `get_token_policy`: a workspace's rules. Members may read them, so the
224/// token form can say what a workspace allows. Returns
225/// `Outcome<TokenPolicy>`.
226#[derive(Debug, Serialize, Deserialize)]
227pub struct GetTokenPolicyArgs {
228 pub viewer: Viewer,
229 pub slug: String,
230}
231
232/// `set_token_policy`: an owner, as a person, changes the rules. What is
233/// left out stays. Returns `Outcome<TokenPolicy>`.
234#[derive(Debug, Default, Serialize, Deserialize)]
235pub struct SetTokenPolicyArgs {
236 pub actor: User,
237 pub slug: String,
238 #[serde(default)]
239 pub allow_tokens_for_all_workspaces: Option<bool>,
240 #[serde(default)]
241 pub allow_tokens_for_this_workspace: Option<bool>,
242 #[serde(default)]
243 pub require_approval: Option<bool>,
244 /// Zero clears the limit.
245 #[serde(default)]
246 pub max_lifetime_days: Option<u32>,
247 #[serde(default)]
248 pub forbid_no_expiry: Option<bool>,
249 #[serde(default)]
250 pub surface: Option<crate::audit::Surface>,
251}
252
253/// A member's personal token that reaches a workspace, as its owners see
254/// it: never the token itself.
255#[derive(Clone, Debug, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct MemberToken {
258 /// The person it belongs to.
259 pub owner: String,
260 pub token: AccessToken,
261 /// Whether it reaches the workspace now: active, allowed by the
262 /// policy, and not revoked here.
263 pub reaches: bool,
264 /// Why not, when it does not.
265 #[serde(default)]
266 pub blocked_by: Option<String>,
267}
268
269/// `list_member_tokens`: owners only. The personal tokens of the
270/// workspace's members and outside collaborators that could reach it:
271/// those made for it alone (`status` to narrow them), and those made for
272/// every workspace of their owner. Returns `Outcome<Vec<MemberToken>>`.
273#[derive(Debug, Serialize, Deserialize)]
274pub struct ListMemberTokensArgs {
275 pub actor: User,
276 pub slug: String,
277 /// `pending` for approval requests only.
278 #[serde(default)]
279 pub status: Option<TokenStatus>,
280}
281
282/// `review_token_request`: an owner approves or denies a token waiting
283/// for approval. Its owner is told. Returns `Outcome<MemberToken>`.
284#[derive(Debug, Serialize, Deserialize)]
285pub struct ReviewTokenRequestArgs {
286 pub actor: User,
287 pub slug: String,
288 pub id: String,
289 pub approve: bool,
290 #[serde(default)]
291 pub reason: Option<String>,
292 #[serde(default)]
293 pub surface: Option<crate::audit::Surface>,
294}
295
296/// `revoke_member_token`: an owner takes a member's token out of the
297/// workspace. A token made for it alone stops working; one made for every
298/// workspace of its owner keeps working everywhere else. Returns
299/// `Outcome<bool>`.
300#[derive(Debug, Serialize, Deserialize)]
301pub struct RevokeMemberTokenArgs {
302 pub actor: User,
303 pub slug: String,
304 pub id: String,
305 #[serde(default)]
306 pub reason: Option<String>,
307 #[serde(default)]
308 pub surface: Option<crate::audit::Surface>,
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
316 fn lifetimes_are_checked_against_the_policy() {
317 let day = 86_400_000;
318 let open = TokenPolicy::default();
319 assert!(open.lifetime_allowed(0, None));
320 assert!(open.lifetime_allowed(0, Some(900 * day)));
321 let capped = TokenPolicy { max_lifetime_days: Some(90), ..TokenPolicy::default() };
322 assert!(capped.lifetime_allowed(0, Some(90 * day)));
323 assert!(capped.lifetime_allowed(0, Some(90 * day + day / 2)), "a day's grace");
324 assert!(!capped.lifetime_allowed(0, Some(92 * day)));
325 assert!(!capped.lifetime_allowed(0, None), "a limit keeps out tokens that never expire");
326 let no_forever = TokenPolicy { forbid_no_expiry: true, ..TokenPolicy::default() };
327 assert!(!no_forever.lifetime_allowed(0, None));
328 assert!(no_forever.lifetime_allowed(0, Some(900 * day)));
329 }
330
331 #[test]
332 fn refusals_name_the_rule() {
333 let closed = TokenPolicy { allow_tokens_for_all_workspaces: false, allow_tokens_for_this_workspace: false, ..TokenPolicy::default() };
334 assert!(closed.refusal("acme", true, Some(60)).unwrap().contains("made for it"));
335 assert!(closed.refusal("acme", false, Some(60)).unwrap().contains("all of a member's workspaces"));
336 let capped = TokenPolicy { max_lifetime_days: Some(30), ..TokenPolicy::default() };
337 assert!(capped.refusal("acme", true, Some(31 * 86_400 + 86_400)).unwrap().contains("30 days"));
338 assert_eq!(capped.refusal("acme", true, Some(7 * 86_400)), None);
339 assert!(TokenPolicy::default().require_approval, "tokens made for a workspace wait for approval unless an owner says otherwise");
340 }
341
342 #[test]
343 fn statuses_read_as_words() {
344 for status in [TokenStatus::Active, TokenStatus::Pending, TokenStatus::Denied, TokenStatus::Revoked] {
345 assert_eq!(TokenStatus::parse(status.as_str()), status);
346 assert_eq!(serde_json::to_value(status).unwrap(), status.as_str());
347 }
348 }
349}