Skip to content
1,358 linesCodeBlameRaw
1//! Making and changing access tokens, what each one reaches, and the rules
2//! a workspace sets for the personal tokens that reach it. See
3//! `g1t_contracts::tokens`.
4//!
5//! There is one kind of token. Its **permissions** are a level for each
6//! resource, stored as scopes (the highest of each resource), which every
7//! check reads. Its **reach** is where they apply: a person's token is made
8//! for every workspace its owner belongs to, for one workspace (all,
9//! selected or none of its private repositories), or for no workspace (its
10//! owner's account and public repositories only); a workspace's token for
11//! its own workspace, all of its repositories or the ones selected.
12//!
13//! Migration 0041 made the tokens of both earlier kinds this one: classic
14//! tokens became tokens for every workspace, with the permissions their
15//! scopes already were; tokens with a resource owner became tokens for that
16//! workspace (or for no workspace), keeping their scopes.
17//!
18//! Each time a token is used, [`Identity::apply_reach`] cuts the person it
19//! resolves to down to what it reaches: a token made for one workspace
20//! keeps only that membership and its grants (none while it waits for
21//! approval), a token made for no workspace keeps none, and a token made
22//! for every workspace loses those whose rules keep it out (not allowing
23//! such tokens, a lifetime past their limit, or an owner revoking it
24//! there). Services then decide as for anyone, and `access::granted` holds
25//! a token to its selected repositories.
26//!
27//! **Approval.** A token made for a workspace that asks for approval
28//! starts pending, unless its owner is an owner there. The workspace's
29//! owners hear of it in their inbox (`token.approval_requested`) and
30//! approve or deny it; its owner hears back (`token.approval_reviewed`).
31//! Changing its repositories or permissions asks again.
32
33use std::collections::{HashMap, HashSet};
34
35use g1t_contracts::audit::Surface;
36use g1t_contracts::events::{NewEvent, Publish};
37use g1t_contracts::identity::{AccessToken, CreatedAccessToken};
38use g1t_contracts::repos::RepoPath;
39use g1t_contracts::scopes::{RepositorySelection, Scope, TokenReach, permissions_of, resolve_permissions, scopes_text};
40use g1t_contracts::time::{parse_rfc3339, rfc3339};
41use g1t_contracts::tokens::*;
42use g1t_contracts::{FailureCode, Outcome, Role, User, Viewer};
43use g1t_kit::now_ms;
44use serde::{Deserialize, Serialize};
45use worker::Result;
46use worker::wasm_bindgen::JsValue;
47
48use crate::Identity;
49use crate::security::is_person;
50use crate::tokens::{Grant, MAX_TOKENS_PER_WORKSPACE, Owner};
51
52const DAY_SECONDS: u64 = 86_400;
53
54/// Why a workspace's token cannot use the website: it acts as no person.
55const WORKSPACE_TOKEN_NO_WEBSITE: &str = "Only a person's token can use the website: a workspace's token acts as no one who can sign in.";
56
57/// What a token row says about its reach, read with it when it is used.
58/// Numbers arrive from D1 as floats.
59#[derive(Clone, Debug, Default, Deserialize)]
60pub(crate) struct Facts {
61 #[serde(default)]
62 created_at: Option<String>,
63 #[serde(default)]
64 expires_at: Option<String>,
65 #[serde(default)]
66 owner_workspace_id: Option<String>,
67 #[serde(default)]
68 repository_selection: Option<String>,
69 #[serde(default)]
70 status: Option<String>,
71 #[serde(default)]
72 admin: Option<f64>,
73}
74
75impl Facts {
76 fn selection(&self) -> RepositorySelection {
77 self.repository_selection.as_deref().and_then(RepositorySelection::parse).unwrap_or_default()
78 }
79
80 /// Made for one workspace.
81 fn made_for_one(&self) -> bool {
82 self.owner_workspace_id.is_some()
83 }
84
85 /// Made for no workspace: its owner's account and public repositories.
86 fn account_only(&self) -> bool {
87 self.owner_workspace_id.is_none() && self.selection() == RepositorySelection::Public
88 }
89
90 fn lifetime(&self) -> (u64, Option<u64>) {
91 let created = self.created_at.as_deref().and_then(parse_rfc3339).unwrap_or(0);
92 (created, self.expires_at.as_deref().and_then(parse_rfc3339))
93 }
94}
95
96/// What a listed token's row adds, for showing it.
97#[derive(Clone, Debug, Default, Deserialize)]
98pub(crate) struct TokenRowMore {
99 #[serde(default)]
100 description: Option<String>,
101 #[serde(default)]
102 admin: Option<f64>,
103 #[serde(default)]
104 workspace_id: Option<String>,
105 #[serde(default)]
106 repository_selection: Option<String>,
107 #[serde(default)]
108 status: Option<String>,
109 #[serde(default)]
110 review_reason: Option<String>,
111 #[serde(default)]
112 owner_workspace: Option<String>,
113 /// 1 when its owner let it use the website (migration 0043).
114 #[serde(default)]
115 website: Option<f64>,
116}
117
118impl TokenRowMore {
119 /// Fills in what it adds to a token's details. Selected repositories
120 /// are named later, by [`Identity::name_repositories`].
121 pub(crate) fn describe(&self, info: &mut AccessToken) {
122 info.description = self.description.clone();
123 info.admin = self.admin.is_some_and(|admin| admin >= 1.0);
124 info.workspace_owned = self.workspace_id.is_some();
125 info.workspace = self.owner_workspace.clone();
126 info.repository_selection = self.repository_selection.as_deref().and_then(RepositorySelection::parse).unwrap_or_default();
127 info.status = TokenStatus::parse(self.status.as_deref().unwrap_or("active"));
128 info.review_reason = self.review_reason.clone();
129 info.website = self.website.is_some_and(|on| on >= 1.0) && self.workspace_id.is_none();
130 }
131}
132
133/// A workspace whose rules apply to a token, as read for it.
134#[derive(Clone, Debug, Default, Deserialize)]
135struct RuleRow {
136 id: String,
137 slug: String,
138 /// Stored as `allow_classic`: tokens made for every workspace.
139 #[serde(default)]
140 allow_classic: Option<f64>,
141 /// Stored as `allow_fine_grained`: tokens made for this one.
142 #[serde(default)]
143 allow_fine_grained: Option<f64>,
144 #[serde(default)]
145 require_approval: Option<f64>,
146 #[serde(default)]
147 max_lifetime_days: Option<f64>,
148 #[serde(default)]
149 forbid_no_expiry: Option<f64>,
150 #[serde(default)]
151 updated_by: Option<String>,
152 #[serde(default)]
153 updated_at: Option<String>,
154 /// 1 when an owner revoked this token here.
155 #[serde(default)]
156 revoked: Option<f64>,
157}
158
159impl RuleRow {
160 fn policy(&self) -> TokenPolicy {
161 let flag = |value: Option<f64>, default: bool| value.map_or(default, |value| value >= 1.0);
162 TokenPolicy {
163 allow_tokens_for_all_workspaces: flag(self.allow_classic, true),
164 allow_tokens_for_this_workspace: flag(self.allow_fine_grained, true),
165 require_approval: flag(self.require_approval, true),
166 max_lifetime_days: self.max_lifetime_days.filter(|days| *days >= 1.0).map(|days| days as u32),
167 forbid_no_expiry: flag(self.forbid_no_expiry, false),
168 updated_by: self.updated_by.clone(),
169 updated_at: self.updated_at.clone(),
170 }
171 }
172}
173
174/// Why a token does not reach a workspace, as its owners are told; `None`
175/// when it does. `this_workspace` is whether the token is made for this
176/// workspace alone, with `status`.
177pub(crate) fn blocked_by(
178 policy: &TokenPolicy,
179 this_workspace: bool,
180 status: TokenStatus,
181 revoked: bool,
182 created_ms: u64,
183 expires_ms: Option<u64>,
184) -> Option<&'static str> {
185 if revoked || status == TokenStatus::Revoked {
186 return Some("revoked");
187 }
188 if this_workspace {
189 match status {
190 TokenStatus::Pending => return Some("pending approval"),
191 TokenStatus::Denied => return Some("denied"),
192 _ => {}
193 }
194 if !policy.allow_tokens_for_this_workspace {
195 return Some("tokens made for this workspace not allowed");
196 }
197 } else if !policy.allow_tokens_for_all_workspaces {
198 return Some("tokens for all workspaces not allowed");
199 }
200 if !policy.lifetime_allowed(created_ms, expires_ms) {
201 return Some(if expires_ms.is_none() { "never expires" } else { "lasts too long" });
202 }
203 None
204}
205
206fn text(value: Option<&str>) -> JsValue {
207 value.map_or(JsValue::NULL, JsValue::from)
208}
209
210/// A description as kept: trimmed, at most 500 characters, none if empty.
211fn tidy(text: Option<&str>) -> Option<String> {
212 text.map(str::trim).filter(|text| !text.is_empty()).map(|text| text.chars().take(500).collect())
213}
214
215/// `token.approval_requested` and `token.approval_reviewed`: told in the
216/// inbox of the people named in `notify`, with a link (events' inbox.rs).
217#[derive(Serialize)]
218#[serde(rename_all = "camelCase")]
219struct TokenNotice<'a> {
220 workspace: &'a str,
221 token_id: &'a str,
222 token_name: &'a str,
223 notify: Vec<String>,
224 title: String,
225 body: String,
226 link: String,
227}
228
229#[derive(Deserialize)]
230struct Owned {
231 id: String,
232 user_id: Option<String>,
233 workspace_id: Option<String>,
234 name: String,
235 owner_workspace_id: Option<String>,
236 status: Option<String>,
237}
238
239impl Identity {
240 /// Cuts `user`, resolved from the token `token_id`, down to what the
241 /// token reaches. `personal` is whether it is a person's token (not a
242 /// workspace's or a job's). See the module docs.
243 pub(crate) async fn apply_reach(&self, user: &mut User, token_id: &str, personal: bool, facts: &Facts) -> Result<()> {
244 if user.token.is_none() {
245 return Ok(());
246 }
247 if !personal {
248 // A workspace's own token: Write on its repositories, or Admin
249 // when it holds Repositories: admin; the selected ones only
250 // when it has a selection.
251 let selected = facts.selection() == RepositorySelection::Selected;
252 let repo_ids = if selected { self.token_repo_ids(token_id).await? } else { Vec::new() };
253 let slug = user.username.clone();
254 if let Some(access) = user.token.as_deref_mut() {
255 access.admin = facts.admin.is_some_and(|admin| admin >= 1.0);
256 if selected {
257 access.reach = Some(TokenReach { workspace: Some(slug), repositories: RepositorySelection::Selected, repo_ids });
258 }
259 }
260 return Ok(());
261 }
262 if facts.account_only() {
263 user.workspaces.clear();
264 user.grants.clear();
265 if let Some(access) = user.token.as_deref_mut() {
266 access.reach = Some(TokenReach { workspace: None, repositories: RepositorySelection::Public, repo_ids: Vec::new() });
267 }
268 return Ok(());
269 }
270 let one = facts.made_for_one();
271 // The workspaces it could reach, with their rules for it.
272 let mut slugs: Vec<String> = user.workspaces.iter().map(|membership| membership.slug.clone()).collect();
273 slugs.extend(user.grants.iter().map(|grant| grant.workspace.to_lowercase()));
274 slugs.sort();
275 slugs.dedup();
276 if slugs.is_empty() && !one {
277 return Ok(());
278 }
279 let rules = self.rules_for(&slugs, token_id).await?;
280 let (created, expires) = facts.lifetime();
281 let status = TokenStatus::parse(facts.status.as_deref().unwrap_or("active"));
282 let mut keep: HashSet<String> = HashSet::new();
283 let mut made_for: Option<String> = None;
284 for rule in &rules {
285 if one && facts.owner_workspace_id.as_deref() != Some(rule.id.as_str()) {
286 continue;
287 }
288 if one {
289 made_for = Some(rule.slug.clone());
290 }
291 let revoked = rule.revoked.is_some_and(|revoked| revoked >= 1.0);
292 if blocked_by(&rule.policy(), one, status, revoked, created, expires).is_none() {
293 keep.insert(rule.slug.clone());
294 }
295 }
296 // Workspaces without a rules row: the defaults, which let in a
297 // token for every workspace, and one made for them once active.
298 for slug in &slugs {
299 if rules.iter().any(|rule| &rule.slug == slug) {
300 continue;
301 }
302 if !one && blocked_by(&TokenPolicy::default(), false, status, false, created, expires).is_none() {
303 keep.insert(slug.clone());
304 }
305 }
306 user.workspaces.retain(|membership| keep.contains(&membership.slug));
307 user.grants.retain(|grant| keep.contains(&grant.workspace.to_lowercase()));
308 if one {
309 let selection = facts.selection();
310 let reaches = made_for.as_ref().is_some_and(|slug| keep.contains(slug));
311 let repo_ids = if reaches && selection == RepositorySelection::Selected { self.token_repo_ids(token_id).await? } else { Vec::new() };
312 if let Some(access) = user.token.as_deref_mut() {
313 access.reach = Some(TokenReach {
314 workspace: made_for,
315 // Until it reaches its workspace, public repositories only.
316 repositories: if reaches { selection } else { RepositorySelection::Public },
317 repo_ids,
318 });
319 }
320 }
321 Ok(())
322 }
323
324 /// The workspaces named by `slugs` that have rules, or that `token_id`
325 /// was revoked in, with both. For a token made for one workspace, that
326 /// workspace too, whatever its rules.
327 async fn rules_for(&self, slugs: &[String], token_id: &str) -> Result<Vec<RuleRow>> {
328 let mut binds: Vec<JsValue> = vec![token_id.into()];
329 binds.extend(slugs.iter().map(|slug| JsValue::from(slug.as_str())));
330 let marks = vec!["?"; slugs.len()].join(", ");
331 let in_slugs = if slugs.is_empty() { "0".to_owned() } else { format!("w.slug IN ({marks})") };
332 let sql = format!(
333 "SELECT w.id, w.slug, p.allow_classic, p.allow_fine_grained, p.require_approval, p.max_lifetime_days,
334 p.forbid_no_expiry, p.updated_by, p.updated_at,
335 (SELECT 1 FROM token_workspace_revocations r WHERE r.token_id = ?1 AND r.workspace_id = w.id) AS revoked
336 FROM workspaces w LEFT JOIN token_policies p ON p.workspace_id = w.id
337 WHERE w.deleted_at IS NULL
338 AND (({in_slugs}) AND (p.workspace_id IS NOT NULL
339 OR EXISTS (SELECT 1 FROM token_workspace_revocations r WHERE r.token_id = ?1 AND r.workspace_id = w.id))
340 OR w.id = (SELECT owner_workspace_id FROM access_tokens WHERE id = ?1))"
341 );
342 self.db.prepare(sql).bind(&binds)?.all().await?.results::<RuleRow>()
343 }
344
345 async fn token_repo_ids(&self, token_id: &str) -> Result<Vec<String>> {
346 #[derive(Deserialize)]
347 struct Row {
348 repo_id: String,
349 }
350 Ok(self
351 .db
352 .prepare("SELECT repo_id FROM token_repositories WHERE token_id = ? ORDER BY repo_id")
353 .bind(&[token_id.into()])?
354 .all()
355 .await?
356 .results::<Row>()?
357 .into_iter()
358 .map(|row| row.repo_id)
359 .collect())
360 }
361
362 /// A workspace's rules for tokens, by slug: the defaults when it has
363 /// none. `None` when there is no such workspace.
364 async fn policy_of(&self, slug: &str) -> Result<Option<(String, TokenPolicy)>> {
365 let row = self
366 .db
367 .prepare(
368 "SELECT w.id, w.slug, p.allow_classic, p.allow_fine_grained, p.require_approval, p.max_lifetime_days,
369 p.forbid_no_expiry, p.updated_by, p.updated_at, NULL AS revoked
370 FROM workspaces w LEFT JOIN token_policies p ON p.workspace_id = w.id
371 WHERE w.slug = ? AND w.deleted_at IS NULL",
372 )
373 .bind(&[slug.to_lowercase().into()])?
374 .first::<RuleRow>(None)
375 .await?;
376 Ok(row.map(|row| (row.id.clone(), row.policy())))
377 }
378
379 // --- Making and changing tokens --------------------------------------------
380
381 /// Repositories as asked for (`owner/name`, or a name in `slug`), as
382 /// the person can see them: their ids, or why one cannot be chosen.
383 async fn chosen_repositories(&self, user: &User, slug: &str, names: &[String]) -> Result<std::result::Result<Vec<String>, String>> {
384 if names.is_empty() {
385 return Ok(Err("Choose at least one repository, or all repositories.".to_owned()));
386 }
387 if names.len() > MAX_SELECTED_REPOSITORIES {
388 return Ok(Err(format!("A token can reach at most {MAX_SELECTED_REPOSITORIES} selected repositories.")));
389 }
390 let mut ids = Vec::new();
391 for name in names {
392 let name = name.trim().trim_start_matches('/');
393 let (namespace, repo) = name.split_once('/').unwrap_or((slug, name));
394 if !namespace.eq_ignore_ascii_case(slug) {
395 return Ok(Err(format!("{name} is not a repository of {slug}, the workspace the token is made for.")));
396 }
397 let path = RepoPath { namespace: slug.to_owned(), name: repo.to_owned() };
398 match self.repo_for(&path, &Some(user.clone())).await? {
399 Some(found) => ids.push(found.id),
400 None => return Ok(Err(format!("There is no repository {slug}/{repo} that you can see."))),
401 }
402 }
403 ids.sort();
404 ids.dedup();
405 Ok(Ok(ids))
406 }
407
408 /// Whether `user` owns the workspace `slug`.
409 fn owns(user: &User, slug: &str) -> bool {
410 user.role_in(&slug.to_lowercase()) == Some(Role::Owner)
411 }
412
413 pub async fn create_token(&self, a: CreateTokenArgs) -> Result<Outcome<CreatedAccessToken>> {
414 if !is_person(&a.actor) || a.actor.token.is_some() {
415 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person, signed in on g1t.sh, can make an access token."));
416 }
417 if !a.actor.verified {
418 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address before making a token."));
419 }
420 if a.name.trim().is_empty() {
421 return Ok(Outcome::fail(FailureCode::Invalid, "Name the token after what will use it."));
422 }
423 if a.ttl_seconds.is_some_and(|ttl| !(DAY_SECONDS..=u64::from(MAX_LIFETIME_DAYS) * DAY_SECONDS).contains(&ttl)) {
424 return Ok(Outcome::fail(
425 FailureCode::Invalid,
426 format!("A token lasts between 1 and {MAX_LIFETIME_DAYS} days, or does not expire."),
427 ));
428 }
429 match a.owner.as_deref().map(|slug| slug.trim().to_lowercase()).filter(|slug| !slug.is_empty()) {
430 Some(slug) => self.create_workspace_owned(&a, &slug).await,
431 None => self.create_personal(&a).await,
432 }
433 }
434
435 /// A workspace's own token, made by an owner.
436 async fn create_workspace_owned(&self, a: &CreateTokenArgs, slug: &str) -> Result<Outcome<CreatedAccessToken>> {
437 let workspace_id = match self.owned_workspace(&a.actor, slug).await? {
438 Outcome::Ok(id) => id,
439 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
440 };
441 if a.website {
442 return Ok(Outcome::fail(FailureCode::Invalid, WORKSPACE_TOKEN_NO_WEBSITE));
443 }
444 let scopes = match resolve_permissions(&a.permissions, false) {
445 Ok(scopes) => scopes,
446 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
447 };
448 if scopes.is_empty() {
449 return Ok(Outcome::fail(FailureCode::Invalid, "Give the token at least one permission."));
450 }
451 let selection = a.repository_selection;
452 if selection == RepositorySelection::Public {
453 return Ok(Outcome::fail(FailureCode::Invalid, "A workspace's token reaches all of its repositories, or the ones you select."));
454 }
455 if self.tokens_where("access_tokens.workspace_id = ?", &workspace_id).await?.len() >= MAX_TOKENS_PER_WORKSPACE {
456 return Ok(Outcome::fail(FailureCode::Conflict, "This workspace has the maximum number of access tokens. Delete one first."));
457 }
458 let repo_ids = if selection == RepositorySelection::Selected {
459 match self.chosen_repositories(&a.actor, slug, &a.repositories).await? {
460 Ok(ids) => ids,
461 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
462 }
463 } else {
464 Vec::new()
465 };
466 // Repositories: admin makes it an admin of the workspace's
467 // repositories; without it, it has Write, as a member does.
468 let admin = scopes.contains(&Scope::RepoAdmin);
469 let grant = Grant { scopes: Some(scopes) };
470 let mut created = self.mint(Owner::Workspace { id: &workspace_id, created_by: Some(&a.actor.id) }, &a.name, a.ttl_seconds, &grant, true).await?;
471 let description = tidy(a.description.as_deref());
472 let mut statements = vec![self
473 .db
474 .prepare("UPDATE access_tokens SET repository_selection = ?, description = ?, admin = ? WHERE id = ?")
475 .bind(&[
476 selection.as_str().into(),
477 text(description.as_deref()),
478 JsValue::from(u8::from(admin)),
479 created.info.id.as_str().into(),
480 ])?];
481 statements.extend(self.repository_rows(&created.info.id, &repo_ids)?);
482 self.db.batch(statements).await?;
483 created.info.created_by = Some(a.actor.username.clone());
484 created.info.description = description;
485 created.info.admin = admin;
486 created.info.repository_selection = selection;
487 created.info.repositories = qualified_all(Some(slug), &repo_ids, &a.repositories);
488 self.audit_workspace(
489 &a.actor,
490 "workspace_token.created",
491 slug,
492 Surface::Web,
493 format!("Created workspace access token {}{}", created.info.name, if admin { " with Repositories: admin" } else { "" }),
494 )
495 .await;
496 Ok(Outcome::Ok(created))
497 }
498
499 /// A person's own token.
500 async fn create_personal(&self, a: &CreateTokenArgs) -> Result<Outcome<CreatedAccessToken>> {
501 let scopes = match resolve_permissions(&a.permissions, true) {
502 Ok(scopes) => scopes,
503 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
504 };
505 if scopes.is_empty() {
506 return Ok(Outcome::fail(FailureCode::Invalid, "Give the token at least one permission."));
507 }
508 let slug = a.workspace.as_deref().map(|slug| slug.trim().to_lowercase()).filter(|slug| !slug.is_empty());
509 let selection = a.repository_selection;
510 let mut status = TokenStatus::Active;
511 let mut workspace_id = None;
512 let mut repo_ids = Vec::new();
513 match &slug {
514 Some(slug) => {
515 if !a.actor.is_member(slug) {
516 return Ok(Outcome::fail(FailureCode::Forbidden, format!("You can only make a token for a workspace you belong to, and {slug} is not one.")));
517 }
518 let Some((id, policy)) = self.policy_of(slug).await? else {
519 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
520 };
521 if let Some(refusal) = policy.refusal(slug, true, a.ttl_seconds) {
522 return Ok(Outcome::fail(FailureCode::Forbidden, refusal));
523 }
524 if policy.require_approval && !Self::owns(&a.actor, slug) {
525 status = TokenStatus::Pending;
526 }
527 if selection == RepositorySelection::Selected {
528 repo_ids = match self.chosen_repositories(&a.actor, slug, &a.repositories).await? {
529 Ok(ids) => ids,
530 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
531 };
532 }
533 workspace_id = Some(id);
534 }
535 None if selection == RepositorySelection::Selected => {
536 return Ok(Outcome::fail(FailureCode::Invalid, "Choose the workspace whose repositories the token reaches."));
537 }
538 None => {}
539 }
540 let grant = Grant { scopes: Some(scopes) };
541 let mut created = self.mint(Owner::User(&a.actor.id), &a.name, a.ttl_seconds, &grant, true).await?;
542 let description = tidy(a.description.as_deref());
543 let mut statements = vec![self
544 .db
545 .prepare("UPDATE access_tokens SET owner_workspace_id = ?, repository_selection = ?, description = ?, status = ?, website = ? WHERE id = ?")
546 .bind(&[
547 text(workspace_id.as_deref()),
548 selection.as_str().into(),
549 text(description.as_deref()),
550 status.as_str().into(),
551 JsValue::from(u8::from(a.website)),
552 created.info.id.as_str().into(),
553 ])?];
554 statements.extend(self.repository_rows(&created.info.id, &repo_ids)?);
555 self.db.batch(statements).await?;
556 created.info.description = description;
557 created.info.workspace = slug.clone();
558 created.info.repository_selection = selection;
559 created.info.repositories = qualified_all(slug.as_deref(), &repo_ids, &a.repositories);
560 created.info.status = status;
561 created.info.website = a.website;
562 // In the person's security log and their workspaces' audit logs.
563 self.log_security(&a.actor.id, "token_created", Some(&created.info.name), None).await;
564 let website = if a.website { " that can use the website" } else { "" };
565 self.audit_account(&a.actor, "token.created", &format!("Created access token {}{website}", created.info.name)).await;
566 if let (Some(slug), TokenStatus::Pending) = (&slug, status) {
567 self.ask_owners(&a.actor, slug, &created.info).await?;
568 }
569 Ok(Outcome::Ok(created))
570 }
571
572 /// Statements that set a token's selected repositories.
573 fn repository_rows(&self, token_id: &str, repo_ids: &[String]) -> Result<Vec<worker::D1PreparedStatement>> {
574 repo_ids
575 .iter()
576 .map(|repo_id| {
577 self.db
578 .prepare("INSERT OR IGNORE INTO token_repositories (token_id, repo_id) VALUES (?, ?)")
579 .bind(&[token_id.into(), repo_id.as_str().into()])
580 })
581 .collect()
582 }
583
584 pub async fn update_token(&self, a: UpdateTokenArgs) -> Result<Outcome<AccessToken>> {
585 if !is_person(&a.actor) || a.actor.token.is_some() {
586 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person, signed in on g1t.sh, can change an access token."));
587 }
588 let owner_slug = a.owner.as_deref().map(|slug| slug.trim().to_lowercase()).filter(|slug| !slug.is_empty());
589 let found = self.owned_token(&a.id).await?;
590 let found = match (&owner_slug, found) {
591 (Some(slug), Some(token)) => {
592 let workspace_id = match self.owned_workspace(&a.actor, slug).await? {
593 Outcome::Ok(id) => id,
594 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
595 };
596 if token.workspace_id.as_deref() != Some(workspace_id.as_str()) {
597 return Ok(Outcome::fail(FailureCode::NotFound, "No such token."));
598 }
599 token
600 }
601 (None, Some(token)) if token.user_id.as_deref() == Some(a.actor.id.as_str()) => token,
602 _ => return Ok(Outcome::fail(FailureCode::NotFound, "No such token.")),
603 };
604 let personal = found.user_id.is_some();
605 // The workspace a personal token is made for; for a workspace's
606 // token, its own. Its repositories are chosen there.
607 let made_for = match &found.owner_workspace_id {
608 Some(id) => self.slug_of(id).await?,
609 None => None,
610 };
611 let repo_workspace = if personal { made_for.clone() } else { owner_slug.clone() };
612 let mut sets: Vec<(&str, JsValue)> = Vec::new();
613 if let Some(name) = a.name.as_deref().map(str::trim).filter(|name| !name.is_empty()) {
614 sets.push(("name", name.chars().take(100).collect::<String>().into()));
615 }
616 if let Some(description) = &a.description {
617 sets.push(("description", text(tidy(Some(description)).as_deref())));
618 }
619 let mut widened = false;
620 if let Some(asked) = &a.permissions {
621 let scopes = match resolve_permissions(asked, personal) {
622 Ok(scopes) => scopes,
623 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
624 };
625 if scopes.is_empty() {
626 return Ok(Outcome::fail(FailureCode::Invalid, "Give the token at least one permission."));
627 }
628 if !personal {
629 sets.push(("admin", JsValue::from(u8::from(scopes.contains(&Scope::RepoAdmin)))));
630 }
631 sets.push(("scopes", scopes_text(&scopes).into()));
632 widened = true;
633 }
634 // Using the website: a person's token only. Turning it on is
635 // asking for more; turning it off is not.
636 if let Some(website) = a.website {
637 if website && !personal {
638 return Ok(Outcome::fail(FailureCode::Invalid, WORKSPACE_TOKEN_NO_WEBSITE));
639 }
640 if personal {
641 sets.push(("website", JsValue::from(u8::from(website))));
642 widened |= website;
643 }
644 }
645 if let Some(selection) = a.repository_selection {
646 if selection == RepositorySelection::Selected && repo_workspace.is_none() {
647 return Ok(Outcome::fail(FailureCode::Invalid, "This token is not made for one workspace, so it cannot select repositories."));
648 }
649 if selection == RepositorySelection::Public && !personal {
650 return Ok(Outcome::fail(FailureCode::Invalid, "A workspace's token reaches all of its repositories, or the ones you select."));
651 }
652 sets.push(("repository_selection", selection.as_str().into()));
653 widened = true;
654 }
655 let mut repo_ids: Option<Vec<String>> = None;
656 if let Some(slug) = &repo_workspace {
657 let selection = a.repository_selection;
658 let selected = selection == Some(RepositorySelection::Selected) || (selection.is_none() && a.repositories.is_some());
659 if selected {
660 let names = a.repositories.clone().unwrap_or_default();
661 repo_ids = Some(match self.chosen_repositories(&a.actor, slug, &names).await? {
662 Ok(ids) => ids,
663 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
664 });
665 widened = true;
666 } else if selection.is_some() {
667 repo_ids = Some(Vec::new());
668 }
669 }
670 // Asking for more, of a workspace that approves tokens, asks again.
671 let mut ask = false;
672 if widened && personal && let Some(slug) = &made_for {
673 let policy = self.policy_of(slug).await?.map(|(_, policy)| policy).unwrap_or_default();
674 if policy.require_approval && !Self::owns(&a.actor, slug) && found.status.as_deref() != Some("revoked") {
675 sets.push(("status", TokenStatus::Pending.as_str().into()));
676 ask = true;
677 }
678 }
679 let mut statements = Vec::new();
680 if !sets.is_empty() {
681 let assignments: Vec<String> = sets.iter().map(|(column, _)| format!("{column} = ?")).collect();
682 let mut binds: Vec<JsValue> = sets.into_iter().map(|(_, value)| value).collect();
683 binds.push(a.id.as_str().into());
684 statements.push(self.db.prepare(format!("UPDATE access_tokens SET {} WHERE id = ?", assignments.join(", "))).bind(&binds)?);
685 }
686 if let Some(ids) = &repo_ids {
687 statements.push(self.db.prepare("DELETE FROM token_repositories WHERE token_id = ?").bind(&[a.id.as_str().into()])?);
688 statements.extend(self.repository_rows(&a.id, ids)?);
689 }
690 if !statements.is_empty() {
691 self.db.batch(statements).await?;
692 }
693 let Some(mut info) = self.token_info(&a.id).await? else {
694 return Ok(Outcome::fail(FailureCode::NotFound, "No such token."));
695 };
696 self.name_repositories(std::slice::from_mut(&mut info), &Some(a.actor.clone())).await?;
697 if widened {
698 if personal {
699 self.log_security(&a.actor.id, "token_rescoped", Some(&info.name), None).await;
700 self.audit_account(&a.actor, "token.rescoped", &format!("Changed the permissions of access token {}", info.name)).await;
701 } else if let Some(slug) = &owner_slug {
702 self.audit_workspace(
703 &a.actor,
704 "workspace_token.changed",
705 slug,
706 Surface::Web,
707 format!("Changed the permissions of workspace access token {}", info.name),
708 )
709 .await;
710 }
711 }
712 if ask && let Some(slug) = &made_for {
713 self.ask_owners(&a.actor, slug, &info).await?;
714 }
715 Ok(Outcome::Ok(info))
716 }
717
718 /// A new secret for a token, everything else kept: for rotating one
719 /// without remaking it. See `RegenerateTokenArgs`.
720 pub async fn regenerate_token(&self, a: RegenerateTokenArgs) -> Result<Outcome<CreatedAccessToken>> {
721 if !is_person(&a.actor) || a.actor.token.is_some() {
722 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person, signed in on g1t.sh, can regenerate an access token."));
723 }
724 if a.ttl_seconds.is_some_and(|ttl| !(DAY_SECONDS..=u64::from(MAX_LIFETIME_DAYS) * DAY_SECONDS).contains(&ttl)) {
725 return Ok(Outcome::fail(
726 FailureCode::Invalid,
727 format!("A token lasts between 1 and {MAX_LIFETIME_DAYS} days, or does not expire."),
728 ));
729 }
730 let owner_slug = a.owner.as_deref().map(|slug| slug.trim().to_lowercase()).filter(|slug| !slug.is_empty());
731 let found = match (&owner_slug, self.owned_token(&a.id).await?) {
732 (Some(slug), Some(token)) => {
733 let workspace_id = match self.owned_workspace(&a.actor, slug).await? {
734 Outcome::Ok(id) => id,
735 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
736 };
737 if token.workspace_id.as_deref() != Some(workspace_id.as_str()) {
738 return Ok(Outcome::fail(FailureCode::NotFound, "No such token."));
739 }
740 token
741 }
742 (None, Some(token)) if token.user_id.as_deref() == Some(a.actor.id.as_str()) => token,
743 _ => return Ok(Outcome::fail(FailureCode::NotFound, "No such token.")),
744 };
745 if found.status.as_deref() == Some(TokenStatus::Revoked.as_str()) {
746 return Ok(Outcome::fail(FailureCode::Conflict, "This token was revoked. Make a new one instead."));
747 }
748 // A personal token made for one workspace keeps to its rules on lifetime.
749 if found.user_id.is_some()
750 && let Some(id) = &found.owner_workspace_id
751 && let Some(slug) = self.slug_of(id).await?
752 && let Some((_, policy)) = self.policy_of(&slug).await?
753 && let Some(refusal) = policy.refusal(&slug, true, a.ttl_seconds)
754 {
755 return Ok(Outcome::fail(FailureCode::Forbidden, refusal));
756 }
757 let token = format!("{}{}", crate::tokens::TOKEN_PREFIX, crate::crypto::random_hex(20));
758 let expires_at = a.ttl_seconds.map(|ttl| rfc3339(now_ms() + ttl * 1000));
759 self.db
760 .prepare("UPDATE access_tokens SET token_hash = ?, expires_at = ? WHERE id = ?")
761 .bind(&[crate::crypto::sha256_hex(&token).into(), text(expires_at.as_deref()), a.id.as_str().into()])?
762 .run()
763 .await?;
764 let Some(mut info) = self.token_info(&a.id).await? else {
765 return Ok(Outcome::fail(FailureCode::NotFound, "No such token."));
766 };
767 self.name_repositories(std::slice::from_mut(&mut info), &Some(a.actor.clone())).await?;
768 if found.user_id.is_some() {
769 self.log_security(&a.actor.id, "token_regenerated", Some(&info.name), None).await;
770 self.audit_account(&a.actor, "token.regenerated", &format!("Regenerated access token {}", info.name)).await;
771 } else if let Some(slug) = &owner_slug {
772 self.audit_workspace(&a.actor, "workspace_token.regenerated", slug, Surface::Web, format!("Regenerated workspace access token {}", info.name)).await;
773 }
774 Ok(Outcome::Ok(CreatedAccessToken { token, info }))
775 }
776
777 /// A token a person or workspace made on purpose, by id: never an
778 /// agent's or a workflow job's.
779 async fn owned_token(&self, id: &str) -> Result<Option<Owned>> {
780 self.db
781 .prepare(
782 "SELECT id, user_id, workspace_id, name, owner_workspace_id, status FROM access_tokens
783 WHERE id = ? AND agent_scope IS NULL AND job_id IS NULL",
784 )
785 .bind(&[id.into()])?
786 .first::<Owned>(None)
787 .await
788 }
789
790 async fn slug_of(&self, workspace_id: &str) -> Result<Option<String>> {
791 self.db
792 .prepare("SELECT slug FROM workspaces WHERE id = ? AND deleted_at IS NULL")
793 .bind(&[workspace_id.into()])?
794 .first::<String>(Some("slug"))
795 .await
796 }
797
798 /// One token's details, as listings show them.
799 async fn token_info(&self, id: &str) -> Result<Option<AccessToken>> {
800 let row = self
801 .db
802 .prepare(format!(
803 "SELECT {} FROM access_tokens LEFT JOIN users ON users.id = access_tokens.created_by WHERE access_tokens.id = ?",
804 crate::tokens::TOKEN_COLUMNS
805 ))
806 .bind(&[id.into()])?
807 .first::<crate::tokens::TokenRow>(None)
808 .await?;
809 Ok(row.map(Identity::info))
810 }
811
812 /// Names the selected repositories of tokens, as `viewer` can see them.
813 pub(crate) async fn name_repositories(&self, tokens: &mut [AccessToken], viewer: &Viewer) -> Result<()> {
814 let selected: Vec<String> = tokens
815 .iter()
816 .filter(|token| token.repository_selection == RepositorySelection::Selected)
817 .map(|token| token.id.clone())
818 .collect();
819 if selected.is_empty() {
820 return Ok(());
821 }
822 #[derive(Deserialize)]
823 struct Row {
824 token_id: String,
825 repo_id: String,
826 }
827 let marks = vec!["?"; selected.len()].join(", ");
828 let binds: Vec<JsValue> = selected.iter().map(|id| JsValue::from(id.as_str())).collect();
829 let rows = self
830 .db
831 .prepare(format!("SELECT token_id, repo_id FROM token_repositories WHERE token_id IN ({marks})"))
832 .bind(&binds)?
833 .all()
834 .await?
835 .results::<Row>()?;
836 let ids: Vec<String> = rows.iter().map(|row| row.repo_id.clone()).collect::<HashSet<_>>().into_iter().collect();
837 let readable: Vec<g1t_contracts::repos::Repo> = if ids.is_empty() {
838 Vec::new()
839 } else {
840 g1t_kit::call(&self.env.service("REPOS")?, "readable", &g1t_contracts::repos::ReadableArgs { ids, viewer: viewer.clone() }).await?
841 };
842 let names: HashMap<&str, String> = readable.iter().map(|repo| (repo.id.as_str(), format!("{}/{}", repo.namespace, repo.name))).collect();
843 for token in tokens.iter_mut().filter(|token| token.repository_selection == RepositorySelection::Selected) {
844 token.repositories = rows
845 .iter()
846 .filter(|row| row.token_id == token.id)
847 .filter_map(|row| names.get(row.repo_id.as_str()).cloned())
848 .collect();
849 token.repositories.sort();
850 }
851 Ok(())
852 }
853
854 // --- A workspace's rules ----------------------------------------------------
855
856 pub async fn get_token_policy(&self, a: GetTokenPolicyArgs) -> Result<Outcome<TokenPolicy>> {
857 let slug = a.slug.to_lowercase();
858 if !a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&slug)) {
859 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's rules for tokens."));
860 }
861 Ok(match self.policy_of(&slug).await? {
862 Some((_, policy)) => Outcome::Ok(policy),
863 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
864 })
865 }
866
867 /// The workspace's id, if `actor` is a person who owns it.
868 async fn owner_of(&self, actor: &User, slug: &str) -> Result<Outcome<String>> {
869 let slug = slug.to_lowercase();
870 if !is_person(actor) || !Self::owns(actor, &slug) {
871 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner of the workspace can manage its personal access tokens."));
872 }
873 Ok(match self.policy_of(&slug).await? {
874 Some((id, _)) => Outcome::Ok(id),
875 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
876 })
877 }
878
879 pub async fn set_token_policy(&self, a: SetTokenPolicyArgs) -> Result<Outcome<TokenPolicy>> {
880 let slug = a.slug.to_lowercase();
881 let workspace_id = match self.owner_of(&a.actor, &slug).await? {
882 Outcome::Ok(id) => id,
883 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
884 };
885 let current = self.policy_of(&slug).await?.map(|(_, policy)| policy).unwrap_or_default();
886 if a.max_lifetime_days.is_some_and(|days| days > 3650) {
887 return Ok(Outcome::fail(FailureCode::Invalid, "The longest lifetime a workspace can set is 3650 days; leave it empty for no limit."));
888 }
889 let policy = TokenPolicy {
890 allow_tokens_for_all_workspaces: a.allow_tokens_for_all_workspaces.unwrap_or(current.allow_tokens_for_all_workspaces),
891 allow_tokens_for_this_workspace: a.allow_tokens_for_this_workspace.unwrap_or(current.allow_tokens_for_this_workspace),
892 require_approval: a.require_approval.unwrap_or(current.require_approval),
893 max_lifetime_days: match a.max_lifetime_days {
894 Some(0) => None,
895 Some(days) => Some(days),
896 None => current.max_lifetime_days,
897 },
898 forbid_no_expiry: a.forbid_no_expiry.unwrap_or(current.forbid_no_expiry),
899 updated_by: Some(a.actor.username.clone()),
900 updated_at: Some(rfc3339(now_ms())),
901 };
902 self.db
903 .prepare(
904 "INSERT INTO token_policies (workspace_id, allow_classic, allow_fine_grained, require_approval, max_lifetime_days,
905 forbid_no_expiry, updated_by, updated_at)
906 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
907 ON CONFLICT (workspace_id) DO UPDATE SET allow_classic = ?2, allow_fine_grained = ?3, require_approval = ?4,
908 max_lifetime_days = ?5, forbid_no_expiry = ?6, updated_by = ?7, updated_at = ?8",
909 )
910 .bind(&[
911 workspace_id.as_str().into(),
912 JsValue::from(u8::from(policy.allow_tokens_for_all_workspaces)),
913 JsValue::from(u8::from(policy.allow_tokens_for_this_workspace)),
914 JsValue::from(u8::from(policy.require_approval)),
915 policy.max_lifetime_days.map_or(JsValue::NULL, JsValue::from),
916 JsValue::from(u8::from(policy.forbid_no_expiry)),
917 a.actor.username.as_str().into(),
918 text(policy.updated_at.as_deref()),
919 ])?
920 .run()
921 .await?;
922 self.audit_workspace(&a.actor, "token.policy_changed", &slug, a.surface.unwrap_or(Surface::Web), describe_policy(&policy)).await;
923 Ok(Outcome::Ok(policy))
924 }
925
926 // --- Members' tokens ------------------------------------------------------
927
928 pub async fn list_member_tokens(&self, a: ListMemberTokensArgs) -> Result<Outcome<Vec<MemberToken>>> {
929 let slug = a.slug.to_lowercase();
930 let workspace_id = match self.owner_of(&a.actor, &slug).await? {
931 Outcome::Ok(id) => id,
932 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
933 };
934 let policy = self.policy_of(&slug).await?.map(|(_, policy)| policy).unwrap_or_default();
935 #[derive(Deserialize)]
936 struct Row {
937 owner: String,
938 #[serde(default)]
939 revoked: Option<f64>,
940 #[serde(flatten)]
941 token: crate::tokens::TokenRow,
942 }
943 // Tokens made for the workspace, and the tokens for every workspace
944 // of its members and outside collaborators, that have not expired.
945 let rows = self
946 .db
947 .prepare(format!(
948 "SELECT owners.username AS owner,
949 (SELECT 1 FROM token_workspace_revocations r WHERE r.token_id = access_tokens.id AND r.workspace_id = ?1) AS revoked,
950 {}
951 FROM access_tokens
952 JOIN users owners ON owners.id = access_tokens.user_id
953 LEFT JOIN users ON users.id = access_tokens.created_by
954 WHERE access_tokens.agent_scope IS NULL AND access_tokens.workspace_id IS NULL
955 AND (access_tokens.expires_at IS NULL OR access_tokens.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
956 AND (
957 access_tokens.owner_workspace_id = ?1
958 OR (access_tokens.owner_workspace_id IS NULL AND COALESCE(access_tokens.repository_selection, 'all') <> 'public'
959 AND (access_tokens.expires_at IS NULL OR access_tokens.listed = 1)
960 AND (access_tokens.user_id IN (SELECT user_id FROM workspace_members WHERE workspace_id = ?1)
961 OR access_tokens.user_id IN (SELECT principal_id FROM repo_grants WHERE workspace_id = ?1 AND principal_kind = 'user')))
962 )
963 ORDER BY access_tokens.id DESC
964 LIMIT 500",
965 crate::tokens::TOKEN_COLUMNS
966 ))
967 .bind(&[workspace_id.as_str().into()])?
968 .all()
969 .await?
970 .results::<Row>()?;
971 let mut listed: Vec<MemberToken> = Vec::new();
972 let mut infos: Vec<AccessToken> = Vec::new();
973 let mut owners: Vec<(String, bool)> = Vec::new();
974 for row in rows {
975 owners.push((row.owner, row.revoked.is_some_and(|revoked| revoked >= 1.0)));
976 infos.push(Identity::info(row.token));
977 }
978 self.name_repositories(&mut infos, &Some(a.actor.clone())).await?;
979 for ((owner, revoked), token) in owners.into_iter().zip(infos) {
980 if a.status.is_some_and(|wanted| wanted != token.status) {
981 continue;
982 }
983 let created = parse_rfc3339(&token.created_at).unwrap_or(0);
984 let expires = token.expires_at.as_deref().and_then(parse_rfc3339);
985 let blocked = blocked_by(&policy, token.workspace.is_some(), token.status, revoked, created, expires);
986 listed.push(MemberToken { owner, token, reaches: blocked.is_none(), blocked_by: blocked.map(str::to_owned) });
987 }
988 Ok(Outcome::Ok(listed))
989 }
990
991 /// One member token, as `list_member_tokens` shows it.
992 async fn member_token(&self, actor: &User, slug: &str, id: &str) -> Result<Option<MemberToken>> {
993 let listed = self.list_member_tokens(ListMemberTokensArgs { actor: actor.clone(), slug: slug.to_owned(), status: None }).await?;
994 Ok(match listed {
995 Outcome::Ok(tokens) => tokens.into_iter().find(|member| member.token.id == id),
996 Outcome::Fail(_) => None,
997 })
998 }
999
1000 pub async fn review_token_request(&self, a: ReviewTokenRequestArgs) -> Result<Outcome<MemberToken>> {
1001 let slug = a.slug.to_lowercase();
1002 let workspace_id = match self.owner_of(&a.actor, &slug).await? {
1003 Outcome::Ok(id) => id,
1004 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1005 };
1006 let Some(found) = self.owned_token(&a.id).await?.filter(|token| token.owner_workspace_id.as_deref() == Some(workspace_id.as_str())) else {
1007 return Ok(Outcome::fail(FailureCode::NotFound, "There is no such token request in this workspace."));
1008 };
1009 if found.status.as_deref() != Some("pending") {
1010 return Ok(Outcome::fail(FailureCode::Conflict, "This token is not waiting for approval."));
1011 }
1012 let status = if a.approve { TokenStatus::Active } else { TokenStatus::Denied };
1013 let reason = tidy(a.reason.as_deref());
1014 self.db
1015 .prepare("UPDATE access_tokens SET status = ?, reviewed_by = ?, reviewed_at = ?, review_reason = ? WHERE id = ? AND status = 'pending'")
1016 .bind(&[
1017 status.as_str().into(),
1018 a.actor.id.as_str().into(),
1019 rfc3339(now_ms()).into(),
1020 text(reason.as_deref()),
1021 a.id.as_str().into(),
1022 ])?
1023 .run()
1024 .await?;
1025 let owner = match &found.user_id {
1026 Some(id) => self.usernames_of(std::slice::from_ref(id)).await?.into_iter().next(),
1027 None => None,
1028 };
1029 let verdict = if a.approve { "approved" } else { "denied" };
1030 self.audit_workspace(
1031 &a.actor,
1032 if a.approve { "token.approved" } else { "token.denied" },
1033 &slug,
1034 a.surface.unwrap_or(Surface::Web),
1035 format!(
1036 "{} the access token {} of {}{}",
1037 if a.approve { "Approved" } else { "Denied" },
1038 found.name,
1039 owner.as_deref().unwrap_or("a former member"),
1040 reason.as_deref().map(|reason| format!(": {reason}")).unwrap_or_default()
1041 ),
1042 )
1043 .await;
1044 if let Some(owner) = &owner {
1045 self.notify(
1046 "token.approval_reviewed",
1047 &a.actor,
1048 TokenNotice {
1049 workspace: &slug,
1050 token_id: &found.id,
1051 token_name: &found.name,
1052 notify: vec![owner.clone()],
1053 title: format!("Your token {} was {verdict} for {slug}", found.name),
1054 body: reason.clone().unwrap_or_else(|| {
1055 if a.approve { "It now reaches the workspace.".to_owned() } else { "It reaches public repositories only.".to_owned() }
1056 }),
1057 link: format!("/settings/tokens/{}", found.id),
1058 },
1059 )
1060 .await;
1061 }
1062 Ok(match self.member_token(&a.actor, &slug, &a.id).await? {
1063 Some(member) => Outcome::Ok(member),
1064 None => Outcome::fail(FailureCode::NotFound, "There is no such token request in this workspace."),
1065 })
1066 }
1067
1068 pub async fn revoke_member_token(&self, a: RevokeMemberTokenArgs) -> Result<Outcome<bool>> {
1069 let slug = a.slug.to_lowercase();
1070 let workspace_id = match self.owner_of(&a.actor, &slug).await? {
1071 Outcome::Ok(id) => id,
1072 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1073 };
1074 let Some(member) = self.member_token(&a.actor, &slug, &a.id).await? else {
1075 return Ok(Outcome::fail(FailureCode::NotFound, "No token of a member reaches this workspace with that id."));
1076 };
1077 let reason = tidy(a.reason.as_deref());
1078 let now = rfc3339(now_ms());
1079 let made_for_it = member.token.workspace.is_some();
1080 if made_for_it {
1081 self.db
1082 .prepare("UPDATE access_tokens SET status = 'revoked', reviewed_by = ?, reviewed_at = ?, review_reason = ? WHERE id = ? AND owner_workspace_id = ?")
1083 .bind(&[a.actor.id.as_str().into(), now.as_str().into(), text(reason.as_deref()), a.id.as_str().into(), workspace_id.as_str().into()])?
1084 .run()
1085 .await?;
1086 } else {
1087 self.db
1088 .prepare(
1089 "INSERT INTO token_workspace_revocations (token_id, workspace_id, revoked_by, revoked_at, reason) VALUES (?, ?, ?, ?, ?)
1090 ON CONFLICT (token_id, workspace_id) DO NOTHING",
1091 )
1092 .bind(&[a.id.as_str().into(), workspace_id.as_str().into(), a.actor.id.as_str().into(), now.as_str().into(), text(reason.as_deref())])?
1093 .run()
1094 .await?;
1095 }
1096 self.audit_workspace(
1097 &a.actor,
1098 "token.revoked",
1099 &slug,
1100 a.surface.unwrap_or(Surface::Web),
1101 format!(
1102 "Revoked {}'s access token {} in {slug}{}",
1103 member.owner,
1104 member.token.name,
1105 reason.as_deref().map(|reason| format!(": {reason}")).unwrap_or_default()
1106 ),
1107 )
1108 .await;
1109 self.notify(
1110 "token.approval_reviewed",
1111 &a.actor,
1112 TokenNotice {
1113 workspace: &slug,
1114 token_id: &a.id,
1115 token_name: &member.token.name,
1116 notify: vec![member.owner.clone()],
1117 title: format!("Your token {} was revoked for {slug}", member.token.name),
1118 body: reason.unwrap_or_else(|| "An owner of the workspace revoked it there.".to_owned()),
1119 link: format!("/settings/tokens/{}", a.id),
1120 },
1121 )
1122 .await;
1123 Ok(Outcome::Ok(true))
1124 }
1125
1126 // --- Telling people -------------------------------------------------------
1127
1128 /// Tells the workspace's owners that `requester`'s token waits for them.
1129 async fn ask_owners(&self, requester: &User, slug: &str, token: &AccessToken) -> Result<()> {
1130 #[derive(Deserialize)]
1131 struct Row {
1132 username: String,
1133 }
1134 let owners: Vec<String> = self
1135 .db
1136 .prepare(
1137 "SELECT users.username FROM workspace_members
1138 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
1139 JOIN users ON users.id = workspace_members.user_id
1140 WHERE workspaces.slug = ? AND workspace_members.role = 'owner'",
1141 )
1142 .bind(&[slug.into()])?
1143 .all()
1144 .await?
1145 .results::<Row>()?
1146 .into_iter()
1147 .map(|row| row.username)
1148 .collect();
1149 self.audit_workspace(requester, "token.approval_requested", slug, Surface::Web, format!("Asked for approval of the access token {}", token.name)).await;
1150 if owners.is_empty() {
1151 return Ok(());
1152 }
1153 let permissions = token.permissions.iter().map(|(name, level)| format!("{name}: {level}")).collect::<Vec<_>>().join(", ");
1154 self.notify(
1155 "token.approval_requested",
1156 requester,
1157 TokenNotice {
1158 workspace: slug,
1159 token_id: &token.id,
1160 token_name: &token.name,
1161 notify: owners,
1162 title: format!("{} asks to use an access token in {slug}", requester.username),
1163 body: format!("{}: {permissions}", token.name),
1164 link: format!("/{slug}/-/personal-access-tokens"),
1165 },
1166 )
1167 .await;
1168 Ok(())
1169 }
1170
1171 async fn notify(&self, kind: &'static str, actor: &User, notice: TokenNotice<'_>) {
1172 let Ok(events) = self.env.service("EVENTS") else {
1173 return;
1174 };
1175 let publish = Publish {
1176 events: vec![NewEvent { kind, source: "identity", repo_id: None, actor: Some(actor.id.clone()), data: notice }],
1177 };
1178 if let Err(error) = g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await {
1179 worker::console_error!("{kind} not published: {error}");
1180 }
1181 }
1182
1183 async fn usernames_of(&self, ids: &[String]) -> Result<Vec<String>> {
1184 #[derive(Deserialize)]
1185 struct Row {
1186 username: String,
1187 }
1188 if ids.is_empty() {
1189 return Ok(Vec::new());
1190 }
1191 let marks = vec!["?"; ids.len()].join(", ");
1192 let binds: Vec<JsValue> = ids.iter().map(|id| JsValue::from(id.as_str())).collect();
1193 Ok(self
1194 .db
1195 .prepare(format!("SELECT username FROM users WHERE id IN ({marks})"))
1196 .bind(&binds)?
1197 .all()
1198 .await?
1199 .results::<Row>()?
1200 .into_iter()
1201 .map(|row| row.username)
1202 .collect())
1203 }
1204}
1205
1206/// `owner/name` for a repository asked for by name in `slug`.
1207fn qualified(slug: Option<&str>, name: &str) -> String {
1208 let name = name.trim().trim_start_matches('/');
1209 match (name.contains('/'), slug) {
1210 (false, Some(slug)) => format!("{slug}/{name}"),
1211 _ => name.to_lowercase(),
1212 }
1213}
1214
1215/// The repositories a new token selected, by name, when it selected any.
1216fn qualified_all(slug: Option<&str>, repo_ids: &[String], names: &[String]) -> Vec<String> {
1217 if repo_ids.is_empty() {
1218 return Vec::new();
1219 }
1220 let mut names: Vec<String> = names.iter().map(|name| qualified(slug, name)).collect();
1221 names.sort();
1222 names.dedup();
1223 names
1224}
1225
1226/// The policy in a sentence, for the audit log.
1227fn describe_policy(policy: &TokenPolicy) -> String {
1228 let yes = |on: bool| if on { "allowed" } else { "not allowed" };
1229 format!(
1230 "Tokens for all of a member's workspaces {}; tokens made for this workspace {}{}; lifetime {}{}",
1231 yes(policy.allow_tokens_for_all_workspaces),
1232 yes(policy.allow_tokens_for_this_workspace),
1233 if policy.require_approval { ", with approval" } else { ", without approval" },
1234 policy.max_lifetime_days.map_or_else(|| "unlimited".to_owned(), |days| format!("at most {days} days")),
1235 if policy.forbid_no_expiry { "; tokens must expire" } else { "" },
1236 )
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241 use super::*;
1242 use std::collections::BTreeMap;
1243
1244 const DAY: u64 = 86_400_000;
1245
1246 #[test]
1247 fn tokens_for_every_workspace_follow_the_workspace_rules() {
1248 let open = TokenPolicy::default();
1249 assert_eq!(blocked_by(&open, false, TokenStatus::Active, false, 0, None), None);
1250 let closed = TokenPolicy { allow_tokens_for_all_workspaces: false, ..TokenPolicy::default() };
1251 assert_eq!(blocked_by(&closed, false, TokenStatus::Active, false, 0, Some(DAY)), Some("tokens for all workspaces not allowed"));
1252 let capped = TokenPolicy { max_lifetime_days: Some(30), ..TokenPolicy::default() };
1253 assert_eq!(blocked_by(&capped, false, TokenStatus::Active, false, 0, Some(90 * DAY)), Some("lasts too long"));
1254 assert_eq!(blocked_by(&capped, false, TokenStatus::Active, false, 0, None), Some("never expires"));
1255 assert_eq!(blocked_by(&capped, false, TokenStatus::Active, false, 0, Some(7 * DAY)), None);
1256 assert_eq!(blocked_by(&open, false, TokenStatus::Active, true, 0, None), Some("revoked"));
1257 }
1258
1259 #[test]
1260 fn tokens_made_for_a_workspace_reach_it_once_active_and_allowed() {
1261 let open = TokenPolicy::default();
1262 assert_eq!(blocked_by(&open, true, TokenStatus::Pending, false, 0, Some(DAY)), Some("pending approval"));
1263 assert_eq!(blocked_by(&open, true, TokenStatus::Denied, false, 0, Some(DAY)), Some("denied"));
1264 assert_eq!(blocked_by(&open, true, TokenStatus::Revoked, false, 0, Some(DAY)), Some("revoked"));
1265 assert_eq!(blocked_by(&open, true, TokenStatus::Active, false, 0, Some(DAY)), None);
1266 let closed = TokenPolicy { allow_tokens_for_this_workspace: false, ..TokenPolicy::default() };
1267 assert_eq!(blocked_by(&closed, true, TokenStatus::Active, false, 0, Some(DAY)), Some("tokens made for this workspace not allowed"));
1268 // Keeping out tokens for every workspace does not touch these.
1269 let no_broad = TokenPolicy { allow_tokens_for_all_workspaces: false, ..TokenPolicy::default() };
1270 assert_eq!(blocked_by(&no_broad, true, TokenStatus::Active, false, 0, Some(DAY)), None);
1271 }
1272
1273 #[test]
1274 fn rules_rows_read_with_defaults() {
1275 let row = RuleRow { id: "wsp_1".into(), slug: "acme".into(), ..RuleRow::default() };
1276 assert_eq!(row.policy(), TokenPolicy::default());
1277 let set = RuleRow { allow_classic: Some(0.0), require_approval: Some(0.0), max_lifetime_days: Some(90.0), forbid_no_expiry: Some(1.0), ..row };
1278 let policy = set.policy();
1279 assert!(!policy.allow_tokens_for_all_workspaces && policy.allow_tokens_for_this_workspace && !policy.require_approval && policy.forbid_no_expiry);
1280 assert_eq!(policy.max_lifetime_days, Some(90));
1281 }
1282
1283 #[test]
1284 fn a_listed_row_describes_its_reach() {
1285 let more = TokenRowMore {
1286 repository_selection: Some("selected".into()),
1287 status: Some("pending".into()),
1288 owner_workspace: Some("acme".into()),
1289 ..TokenRowMore::default()
1290 };
1291 let mut info = AccessToken::default();
1292 more.describe(&mut info);
1293 assert_eq!(info.workspace.as_deref(), Some("acme"));
1294 assert_eq!(info.repository_selection, RepositorySelection::Selected);
1295 assert_eq!(info.status, TokenStatus::Pending);
1296 assert!(!info.workspace_owned);
1297 // A row from before reaches (null) reads as every workspace, active.
1298 let mut classic = AccessToken::default();
1299 TokenRowMore::default().describe(&mut classic);
1300 assert_eq!((classic.workspace.as_deref(), classic.repository_selection, classic.status), (None, RepositorySelection::All, TokenStatus::Active));
1301 let mut workspace = AccessToken::default();
1302 TokenRowMore { workspace_id: Some("wsp_1".into()), admin: Some(1.0), ..TokenRowMore::default() }.describe(&mut workspace);
1303 assert!(workspace.workspace_owned && workspace.admin);
1304 }
1305
1306 #[test]
1307 fn a_rows_reach_is_read_from_its_columns() {
1308 let broad = Facts::default();
1309 assert!(!broad.made_for_one() && !broad.account_only());
1310 let account = Facts { repository_selection: Some("public".into()), ..Facts::default() };
1311 assert!(account.account_only());
1312 let one = Facts { owner_workspace_id: Some("wsp_1".into()), repository_selection: Some("public".into()), ..Facts::default() };
1313 assert!(one.made_for_one() && !one.account_only(), "public in a workspace is that workspace's settings, no private repositories");
1314 }
1315
1316 #[test]
1317 fn repositories_are_named_in_the_workspace() {
1318 assert_eq!(qualified(Some("acme"), "web"), "acme/web");
1319 assert_eq!(qualified(Some("acme"), "Acme/Web"), "acme/web");
1320 assert_eq!(qualified_all(Some("acme"), &["rep_1".into()], &["web".into(), "acme/web".into()]), vec!["acme/web".to_owned()]);
1321 assert!(qualified_all(Some("acme"), &[], &["web".into()]).is_empty());
1322 assert!(describe_policy(&TokenPolicy::default()).contains("with approval"));
1323 }
1324
1325 /// Migration 0041 writes full access out as every permission: the
1326 /// same lists the code made then, before Artifacts, Chat and
1327 /// Observability were offered, so tokens from before keep exactly
1328 /// what they had.
1329 #[test]
1330 fn the_migration_sets_full_access_out_as_every_permission() {
1331 use g1t_contracts::scopes::{Resource, ResourceGroup, everything};
1332 let sql = include_str!("../migrations/0041_one_kind_of_token.sql");
1333 let then: Vec<Scope> = everything()
1334 .into_iter()
1335 .filter(|scope| !matches!(scope.resource(), Resource::Artifacts | Resource::Chat | Resource::Observability))
1336 .collect();
1337 assert!(sql.contains(&format!("SET scopes = '{}'", scopes_text(&then))));
1338 let workspace: Vec<Scope> = then.into_iter().filter(|scope| scope.resource().group() != ResourceGroup::Account).collect();
1339 assert!(sql.contains(&format!("SET scopes = '{}'", scopes_text(&workspace))));
1340 let write: Vec<Scope> = workspace
1341 .iter()
1342 .map(|scope| match scope {
1343 Scope::RepoAdmin => Scope::RepoWrite,
1344 Scope::AccessAdmin => Scope::AccessRead,
1345 other => *other,
1346 })
1347 .collect();
1348 assert!(sql.contains(&format!("SET scopes = '{}'", scopes_text(&write))));
1349 }
1350
1351 #[test]
1352 fn permissions_are_stored_as_the_scopes_every_check_reads() {
1353 let asked: BTreeMap<String, String> = [("code".to_owned(), "read".to_owned()), ("repo".to_owned(), "read".to_owned())].into();
1354 let scopes = resolve_permissions(&asked, true).unwrap();
1355 assert_eq!(scopes_text(&scopes), "repo:read code:read");
1356 assert_eq!(permissions_of(&scopes), asked);
1357 }
1358}