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