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.

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