Skip to content
366 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/// `regenerate_token`: a person gives a token of theirs, or, as an
150/// owner, a workspace's token, a new secret. Everything else about it
151/// stays: its name, permissions, reach and repositories. The old secret
152/// stops working at once. `ttl_seconds` is its new lifetime from now, as
153/// when it was made (null: it does not expire), within its workspace's
154/// rules. Returns `Outcome<CreatedAccessToken>`, the new secret shown once.
155#[derive(Debug, Default, Serialize, Deserialize)]
156pub struct RegenerateTokenArgs {
157 pub actor: User,
158 pub id: String,
159 /// The workspace whose token it is; null for one of the actor's own.
160 #[serde(default)]
161 pub owner: Option<String>,
162 #[serde(default)]
163 pub ttl_seconds: Option<u64>,
164}
165
166/// A workspace's rules for personal access tokens.
167#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct TokenPolicy {
170 /// Whether a token made for every workspace of its owner reaches this
171 /// one. Off: it still works everywhere else.
172 pub allow_tokens_for_all_workspaces: bool,
173 /// Whether a token may be made for this workspace alone.
174 pub allow_tokens_for_this_workspace: bool,
175 /// Whether a token made for this workspace alone waits for an owner's
176 /// approval. Owners' own tokens never wait.
177 pub require_approval: bool,
178 /// The longest a token reaching it may last, in days. Null: no limit
179 /// (a token with an expiry still lasts at most 366 days).
180 pub max_lifetime_days: Option<u32>,
181 /// Whether a token that never expires is kept out.
182 pub forbid_no_expiry: bool,
183 /// Who changed it last, and when; null for the defaults.
184 #[serde(default)]
185 pub updated_by: Option<String>,
186 #[serde(default)]
187 pub updated_at: Option<String>,
188}
189
190impl Default for TokenPolicy {
191 /// What a workspace has until an owner changes it.
192 fn default() -> Self {
193 TokenPolicy {
194 allow_tokens_for_all_workspaces: true,
195 allow_tokens_for_this_workspace: true,
196 require_approval: true,
197 max_lifetime_days: None,
198 forbid_no_expiry: false,
199 updated_by: None,
200 updated_at: None,
201 }
202 }
203}
204
205impl TokenPolicy {
206 /// Whether a token made at `created_ms` that expires at `expires_ms`
207 /// (none: never) lasts no longer than the policy allows.
208 pub fn lifetime_allowed(&self, created_ms: u64, expires_ms: Option<u64>) -> bool {
209 match (expires_ms, self.max_lifetime_days) {
210 (None, _) if self.forbid_no_expiry => false,
211 (None, Some(_)) => false,
212 (None, None) => true,
213 (Some(_), None) => true,
214 // A day's grace, for clocks and for "30 days" picked at 23:59.
215 (Some(expires), Some(days)) => expires.saturating_sub(created_ms) <= (u64::from(days) + 1) * 86_400_000,
216 }
217 }
218
219 /// Why a token that lasts `ttl_seconds` (none: forever) cannot be made
220 /// to reach the workspace `slug`, if it cannot. `this_workspace` is
221 /// whether it is made for that workspace alone, rather than for every
222 /// workspace of its owner.
223 pub fn refusal(&self, slug: &str, this_workspace: bool, ttl_seconds: Option<u64>) -> Option<String> {
224 if this_workspace && !self.allow_tokens_for_this_workspace {
225 return Some(format!("{slug} does not allow personal access tokens made for it."));
226 }
227 if !this_workspace && !self.allow_tokens_for_all_workspaces {
228 return Some(format!("{slug} does not allow tokens made for all of a member's workspaces: make one for {slug} alone."));
229 }
230 if !self.lifetime_allowed(0, ttl_seconds.map(|ttl| ttl * 1000)) {
231 return Some(match self.max_lifetime_days {
232 Some(days) => format!("{slug} allows tokens that last at most {days} days."),
233 None => format!("{slug} does not allow tokens that never expire."),
234 });
235 }
236 None
237 }
238}
239
240/// `get_token_policy`: a workspace's rules. Members may read them, so the
241/// token form can say what a workspace allows. Returns
242/// `Outcome<TokenPolicy>`.
243#[derive(Debug, Serialize, Deserialize)]
244pub struct GetTokenPolicyArgs {
245 pub viewer: Viewer,
246 pub slug: String,
247}
248
249/// `set_token_policy`: an owner, as a person, changes the rules. What is
250/// left out stays. Returns `Outcome<TokenPolicy>`.
251#[derive(Debug, Default, Serialize, Deserialize)]
252pub struct SetTokenPolicyArgs {
253 pub actor: User,
254 pub slug: String,
255 #[serde(default)]
256 pub allow_tokens_for_all_workspaces: Option<bool>,
257 #[serde(default)]
258 pub allow_tokens_for_this_workspace: Option<bool>,
259 #[serde(default)]
260 pub require_approval: Option<bool>,
261 /// Zero clears the limit.
262 #[serde(default)]
263 pub max_lifetime_days: Option<u32>,
264 #[serde(default)]
265 pub forbid_no_expiry: Option<bool>,
266 #[serde(default)]
267 pub surface: Option<crate::audit::Surface>,
268}
269
270/// A member's personal token that reaches a workspace, as its owners see
271/// it: never the token itself.
272#[derive(Clone, Debug, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct MemberToken {
275 /// The person it belongs to.
276 pub owner: String,
277 pub token: AccessToken,
278 /// Whether it reaches the workspace now: active, allowed by the
279 /// policy, and not revoked here.
280 pub reaches: bool,
281 /// Why not, when it does not.
282 #[serde(default)]
283 pub blocked_by: Option<String>,
284}
285
286/// `list_member_tokens`: owners only. The personal tokens of the
287/// workspace's members and outside collaborators that could reach it:
288/// those made for it alone (`status` to narrow them), and those made for
289/// every workspace of their owner. Returns `Outcome<Vec<MemberToken>>`.
290#[derive(Debug, Serialize, Deserialize)]
291pub struct ListMemberTokensArgs {
292 pub actor: User,
293 pub slug: String,
294 /// `pending` for approval requests only.
295 #[serde(default)]
296 pub status: Option<TokenStatus>,
297}
298
299/// `review_token_request`: an owner approves or denies a token waiting
300/// for approval. Its owner is told. Returns `Outcome<MemberToken>`.
301#[derive(Debug, Serialize, Deserialize)]
302pub struct ReviewTokenRequestArgs {
303 pub actor: User,
304 pub slug: String,
305 pub id: String,
306 pub approve: bool,
307 #[serde(default)]
308 pub reason: Option<String>,
309 #[serde(default)]
310 pub surface: Option<crate::audit::Surface>,
311}
312
313/// `revoke_member_token`: an owner takes a member's token out of the
314/// workspace. A token made for it alone stops working; one made for every
315/// workspace of its owner keeps working everywhere else. Returns
316/// `Outcome<bool>`.
317#[derive(Debug, Serialize, Deserialize)]
318pub struct RevokeMemberTokenArgs {
319 pub actor: User,
320 pub slug: String,
321 pub id: String,
322 #[serde(default)]
323 pub reason: Option<String>,
324 #[serde(default)]
325 pub surface: Option<crate::audit::Surface>,
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn lifetimes_are_checked_against_the_policy() {
334 let day = 86_400_000;
335 let open = TokenPolicy::default();
336 assert!(open.lifetime_allowed(0, None));
337 assert!(open.lifetime_allowed(0, Some(900 * day)));
338 let capped = TokenPolicy { max_lifetime_days: Some(90), ..TokenPolicy::default() };
339 assert!(capped.lifetime_allowed(0, Some(90 * day)));
340 assert!(capped.lifetime_allowed(0, Some(90 * day + day / 2)), "a day's grace");
341 assert!(!capped.lifetime_allowed(0, Some(92 * day)));
342 assert!(!capped.lifetime_allowed(0, None), "a limit keeps out tokens that never expire");
343 let no_forever = TokenPolicy { forbid_no_expiry: true, ..TokenPolicy::default() };
344 assert!(!no_forever.lifetime_allowed(0, None));
345 assert!(no_forever.lifetime_allowed(0, Some(900 * day)));
346 }
347
348 #[test]
349 fn refusals_name_the_rule() {
350 let closed = TokenPolicy { allow_tokens_for_all_workspaces: false, allow_tokens_for_this_workspace: false, ..TokenPolicy::default() };
351 assert!(closed.refusal("acme", true, Some(60)).unwrap().contains("made for it"));
352 assert!(closed.refusal("acme", false, Some(60)).unwrap().contains("all of a member's workspaces"));
353 let capped = TokenPolicy { max_lifetime_days: Some(30), ..TokenPolicy::default() };
354 assert!(capped.refusal("acme", true, Some(31 * 86_400 + 86_400)).unwrap().contains("30 days"));
355 assert_eq!(capped.refusal("acme", true, Some(7 * 86_400)), None);
356 assert!(TokenPolicy::default().require_approval, "tokens made for a workspace wait for approval unless an owner says otherwise");
357 }
358
359 #[test]
360 fn statuses_read_as_words() {
361 for status in [TokenStatus::Active, TokenStatus::Pending, TokenStatus::Denied, TokenStatus::Revoked] {
362 assert_eq!(TokenStatus::parse(status.as_str()), status);
363 assert_eq!(serde_json::to_value(status).unwrap(), status.as_str());
364 }
365 }
366}