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