Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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