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