Skip to content
1,269 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1//! 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`.
Fine-grained personal tokens, workspace token rules and approvals in identity4//!
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers5//! 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.
Fine-grained personal tokens, workspace token rules and approvals in identity12//!
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers13//! 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//!
Fine-grained personal tokens, workspace token rules and approvals in identity18//! Each time a token is used, [`Identity::apply_reach`] cuts the person it
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers19//! 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.
Fine-grained personal tokens, workspace token rules and approvals in identity26//!
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers27//! **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.
Fine-grained personal tokens, workspace token rules and approvals in identity32
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers33use std::collections::{HashMap, HashSet};
Fine-grained personal tokens, workspace token rules and approvals in identity34
35use g1t_contracts::audit::Surface;
36use g1t_contracts::events::{NewEvent, Publish};
37use g1t_contracts::identity::{AccessToken, CreatedAccessToken};
38use g1t_contracts::repos::RepoPath;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers39use g1t_contracts::scopes::{RepositorySelection, Scope, TokenReach, permissions_of, resolve_permissions, scopes_text};
Fine-grained personal tokens, workspace token rules and approvals in identity40use 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;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers50use crate::tokens::{Grant, MAX_TOKENS_PER_WORKSPACE, Owner};
Fine-grained personal tokens, workspace token rules and approvals in identity51
52const DAY_SECONDS: u64 = 86_400;
53
54/// What a token row says about its reach, read with it when it is used.
55/// Numbers arrive from D1 as floats.
56#[derive(Clone, Debug, Default, Deserialize)]
57pub(crate) struct Facts {
58 #[serde(default)]
59 created_at: Option<String>,
60 #[serde(default)]
61 expires_at: Option<String>,
62 #[serde(default)]
63 owner_workspace_id: Option<String>,
64 #[serde(default)]
65 repository_selection: Option<String>,
66 #[serde(default)]
67 status: Option<String>,
68 #[serde(default)]
69 admin: Option<f64>,
70}
71
72impl Facts {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers73 fn selection(&self) -> RepositorySelection {
74 self.repository_selection.as_deref().and_then(RepositorySelection::parse).unwrap_or_default()
75 }
76
77 /// Made for one workspace.
78 fn made_for_one(&self) -> bool {
79 self.owner_workspace_id.is_some()
Fine-grained personal tokens, workspace token rules and approvals in identity80 }
81
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers82 /// Made for no workspace: its owner's account and public repositories.
83 fn account_only(&self) -> bool {
84 self.owner_workspace_id.is_none() && self.selection() == RepositorySelection::Public
85 }
86
Fine-grained personal tokens, workspace token rules and approvals in identity87 fn lifetime(&self) -> (u64, Option<u64>) {
88 let created = self.created_at.as_deref().and_then(parse_rfc3339).unwrap_or(0);
89 (created, self.expires_at.as_deref().and_then(parse_rfc3339))
90 }
91}
92
93/// What a listed token's row adds, for showing it.
94#[derive(Clone, Debug, Default, Deserialize)]
95pub(crate) struct TokenRowMore {
96 #[serde(default)]
97 description: Option<String>,
98 #[serde(default)]
99 admin: Option<f64>,
100 #[serde(default)]
101 workspace_id: Option<String>,
102 #[serde(default)]
103 repository_selection: Option<String>,
104 #[serde(default)]
105 status: Option<String>,
106 #[serde(default)]
107 review_reason: Option<String>,
108 #[serde(default)]
109 owner_workspace: Option<String>,
110}
111
112impl TokenRowMore {
113 /// Fills in what it adds to a token's details. Selected repositories
114 /// are named later, by [`Identity::name_repositories`].
115 pub(crate) fn describe(&self, info: &mut AccessToken) {
116 info.description = self.description.clone();
117 info.admin = self.admin.is_some_and(|admin| admin >= 1.0);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers118 info.workspace_owned = self.workspace_id.is_some();
119 info.workspace = self.owner_workspace.clone();
120 info.repository_selection = self.repository_selection.as_deref().and_then(RepositorySelection::parse).unwrap_or_default();
121 info.status = TokenStatus::parse(self.status.as_deref().unwrap_or("active"));
122 info.review_reason = self.review_reason.clone();
Fine-grained personal tokens, workspace token rules and approvals in identity123 }
124}
125
126/// A workspace whose rules apply to a token, as read for it.
127#[derive(Clone, Debug, Default, Deserialize)]
128struct RuleRow {
129 id: String,
130 slug: String,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers131 /// Stored as `allow_classic`: tokens made for every workspace.
Fine-grained personal tokens, workspace token rules and approvals in identity132 #[serde(default)]
133 allow_classic: Option<f64>,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers134 /// Stored as `allow_fine_grained`: tokens made for this one.
Fine-grained personal tokens, workspace token rules and approvals in identity135 #[serde(default)]
136 allow_fine_grained: Option<f64>,
137 #[serde(default)]
138 require_approval: Option<f64>,
139 #[serde(default)]
140 max_lifetime_days: Option<f64>,
141 #[serde(default)]
142 forbid_no_expiry: Option<f64>,
143 #[serde(default)]
144 updated_by: Option<String>,
145 #[serde(default)]
146 updated_at: Option<String>,
147 /// 1 when an owner revoked this token here.
148 #[serde(default)]
149 revoked: Option<f64>,
150}
151
152impl RuleRow {
153 fn policy(&self) -> TokenPolicy {
154 let flag = |value: Option<f64>, default: bool| value.map_or(default, |value| value >= 1.0);
155 TokenPolicy {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers156 allow_tokens_for_all_workspaces: flag(self.allow_classic, true),
157 allow_tokens_for_this_workspace: flag(self.allow_fine_grained, true),
Fine-grained personal tokens, workspace token rules and approvals in identity158 require_approval: flag(self.require_approval, true),
159 max_lifetime_days: self.max_lifetime_days.filter(|days| *days >= 1.0).map(|days| days as u32),
160 forbid_no_expiry: flag(self.forbid_no_expiry, false),
161 updated_by: self.updated_by.clone(),
162 updated_at: self.updated_at.clone(),
163 }
164 }
165}
166
167/// Why a token does not reach a workspace, as its owners are told; `None`
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers168/// when it does. `this_workspace` is whether the token is made for this
169/// workspace alone, with `status`.
Fine-grained personal tokens, workspace token rules and approvals in identity170pub(crate) fn blocked_by(
171 policy: &TokenPolicy,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers172 this_workspace: bool,
Fine-grained personal tokens, workspace token rules and approvals in identity173 status: TokenStatus,
174 revoked: bool,
175 created_ms: u64,
176 expires_ms: Option<u64>,
177) -> Option<&'static str> {
178 if revoked || status == TokenStatus::Revoked {
179 return Some("revoked");
180 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers181 if this_workspace {
Fine-grained personal tokens, workspace token rules and approvals in identity182 match status {
183 TokenStatus::Pending => return Some("pending approval"),
184 TokenStatus::Denied => return Some("denied"),
185 _ => {}
186 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers187 if !policy.allow_tokens_for_this_workspace {
188 return Some("tokens made for this workspace not allowed");
Fine-grained personal tokens, workspace token rules and approvals in identity189 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers190 } else if !policy.allow_tokens_for_all_workspaces {
191 return Some("tokens for all workspaces not allowed");
Fine-grained personal tokens, workspace token rules and approvals in identity192 }
193 if !policy.lifetime_allowed(created_ms, expires_ms) {
194 return Some(if expires_ms.is_none() { "never expires" } else { "lasts too long" });
195 }
196 None
197}
198
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers199fn text(value: Option<&str>) -> JsValue {
200 value.map_or(JsValue::NULL, JsValue::from)
Fine-grained personal tokens, workspace token rules and approvals in identity201}
202
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers203/// A description as kept: trimmed, at most 500 characters, none if empty.
204fn tidy(text: Option<&str>) -> Option<String> {
205 text.map(str::trim).filter(|text| !text.is_empty()).map(|text| text.chars().take(500).collect())
Fine-grained personal tokens, workspace token rules and approvals in identity206}
207
208/// `token.approval_requested` and `token.approval_reviewed`: told in the
209/// inbox of the people named in `notify`, with a link (events' inbox.rs).
210#[derive(Serialize)]
211#[serde(rename_all = "camelCase")]
212struct TokenNotice<'a> {
213 workspace: &'a str,
214 token_id: &'a str,
215 token_name: &'a str,
216 notify: Vec<String>,
217 title: String,
218 body: String,
219 link: String,
220}
221
222#[derive(Deserialize)]
223struct Owned {
224 id: String,
225 user_id: Option<String>,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers226 workspace_id: Option<String>,
Fine-grained personal tokens, workspace token rules and approvals in identity227 name: String,
228 owner_workspace_id: Option<String>,
229 status: Option<String>,
230}
231
232impl Identity {
233 /// Cuts `user`, resolved from the token `token_id`, down to what the
234 /// token reaches. `personal` is whether it is a person's token (not a
235 /// workspace's or a job's). See the module docs.
236 pub(crate) async fn apply_reach(&self, user: &mut User, token_id: &str, personal: bool, facts: &Facts) -> Result<()> {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers237 if user.token.is_none() {
Fine-grained personal tokens, workspace token rules and approvals in identity238 return Ok(());
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers239 }
Fine-grained personal tokens, workspace token rules and approvals in identity240 if !personal {
241 // A workspace's own token: Write on its repositories, or Admin
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers242 // when it holds Repositories: admin; the selected ones only
243 // when it has a selection.
244 let selected = facts.selection() == RepositorySelection::Selected;
245 let repo_ids = if selected { self.token_repo_ids(token_id).await? } else { Vec::new() };
246 let slug = user.username.clone();
247 if let Some(access) = user.token.as_deref_mut() {
248 access.admin = facts.admin.is_some_and(|admin| admin >= 1.0);
249 if selected {
250 access.reach = Some(TokenReach { workspace: Some(slug), repositories: RepositorySelection::Selected, repo_ids });
251 }
252 }
Fine-grained personal tokens, workspace token rules and approvals in identity253 return Ok(());
254 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers255 if facts.account_only() {
256 user.workspaces.clear();
257 user.grants.clear();
258 if let Some(access) = user.token.as_deref_mut() {
259 access.reach = Some(TokenReach { workspace: None, repositories: RepositorySelection::Public, repo_ids: Vec::new() });
260 }
261 return Ok(());
262 }
263 let one = facts.made_for_one();
Fine-grained personal tokens, workspace token rules and approvals in identity264 // The workspaces it could reach, with their rules for it.
265 let mut slugs: Vec<String> = user.workspaces.iter().map(|membership| membership.slug.clone()).collect();
266 slugs.extend(user.grants.iter().map(|grant| grant.workspace.to_lowercase()));
267 slugs.sort();
268 slugs.dedup();
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers269 if slugs.is_empty() && !one {
Fine-grained personal tokens, workspace token rules and approvals in identity270 return Ok(());
271 }
272 let rules = self.rules_for(&slugs, token_id).await?;
273 let (created, expires) = facts.lifetime();
274 let status = TokenStatus::parse(facts.status.as_deref().unwrap_or("active"));
275 let mut keep: HashSet<String> = HashSet::new();
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers276 let mut made_for: Option<String> = None;
Fine-grained personal tokens, workspace token rules and approvals in identity277 for rule in &rules {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers278 if one && facts.owner_workspace_id.as_deref() != Some(rule.id.as_str()) {
Fine-grained personal tokens, workspace token rules and approvals in identity279 continue;
280 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers281 if one {
282 made_for = Some(rule.slug.clone());
Fine-grained personal tokens, workspace token rules and approvals in identity283 }
284 let revoked = rule.revoked.is_some_and(|revoked| revoked >= 1.0);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers285 if blocked_by(&rule.policy(), one, status, revoked, created, expires).is_none() {
Fine-grained personal tokens, workspace token rules and approvals in identity286 keep.insert(rule.slug.clone());
287 }
288 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers289 // Workspaces without a rules row: the defaults, which let in a
290 // token for every workspace, and one made for them once active.
Fine-grained personal tokens, workspace token rules and approvals in identity291 for slug in &slugs {
292 if rules.iter().any(|rule| &rule.slug == slug) {
293 continue;
294 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers295 if !one && blocked_by(&TokenPolicy::default(), false, status, false, created, expires).is_none() {
Fine-grained personal tokens, workspace token rules and approvals in identity296 keep.insert(slug.clone());
297 }
298 }
299 user.workspaces.retain(|membership| keep.contains(&membership.slug));
300 user.grants.retain(|grant| keep.contains(&grant.workspace.to_lowercase()));
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers301 if one {
302 let selection = facts.selection();
303 let reaches = made_for.as_ref().is_some_and(|slug| keep.contains(slug));
Fine-grained personal tokens, workspace token rules and approvals in identity304 let repo_ids = if reaches && selection == RepositorySelection::Selected { self.token_repo_ids(token_id).await? } else { Vec::new() };
305 if let Some(access) = user.token.as_deref_mut() {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers306 access.reach = Some(TokenReach {
307 workspace: made_for,
Fine-grained personal tokens, workspace token rules and approvals in identity308 // Until it reaches its workspace, public repositories only.
309 repositories: if reaches { selection } else { RepositorySelection::Public },
310 repo_ids,
311 });
312 }
313 }
314 Ok(())
315 }
316
317 /// The workspaces named by `slugs` that have rules, or that `token_id`
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers318 /// was revoked in, with both. For a token made for one workspace, that
319 /// workspace too, whatever its rules.
Fine-grained personal tokens, workspace token rules and approvals in identity320 async fn rules_for(&self, slugs: &[String], token_id: &str) -> Result<Vec<RuleRow>> {
321 let mut binds: Vec<JsValue> = vec![token_id.into()];
322 binds.extend(slugs.iter().map(|slug| JsValue::from(slug.as_str())));
323 let marks = vec!["?"; slugs.len()].join(", ");
324 let in_slugs = if slugs.is_empty() { "0".to_owned() } else { format!("w.slug IN ({marks})") };
325 let sql = format!(
326 "SELECT w.id, w.slug, p.allow_classic, p.allow_fine_grained, p.require_approval, p.max_lifetime_days,
327 p.forbid_no_expiry, p.updated_by, p.updated_at,
328 (SELECT 1 FROM token_workspace_revocations r WHERE r.token_id = ?1 AND r.workspace_id = w.id) AS revoked
329 FROM workspaces w LEFT JOIN token_policies p ON p.workspace_id = w.id
330 WHERE w.deleted_at IS NULL
331 AND (({in_slugs}) AND (p.workspace_id IS NOT NULL
332 OR EXISTS (SELECT 1 FROM token_workspace_revocations r WHERE r.token_id = ?1 AND r.workspace_id = w.id))
333 OR w.id = (SELECT owner_workspace_id FROM access_tokens WHERE id = ?1))"
334 );
335 self.db.prepare(sql).bind(&binds)?.all().await?.results::<RuleRow>()
336 }
337
338 async fn token_repo_ids(&self, token_id: &str) -> Result<Vec<String>> {
339 #[derive(Deserialize)]
340 struct Row {
341 repo_id: String,
342 }
343 Ok(self
344 .db
345 .prepare("SELECT repo_id FROM token_repositories WHERE token_id = ? ORDER BY repo_id")
346 .bind(&[token_id.into()])?
347 .all()
348 .await?
349 .results::<Row>()?
350 .into_iter()
351 .map(|row| row.repo_id)
352 .collect())
353 }
354
355 /// A workspace's rules for tokens, by slug: the defaults when it has
356 /// none. `None` when there is no such workspace.
357 async fn policy_of(&self, slug: &str) -> Result<Option<(String, TokenPolicy)>> {
358 let row = self
359 .db
360 .prepare(
361 "SELECT w.id, w.slug, p.allow_classic, p.allow_fine_grained, p.require_approval, p.max_lifetime_days,
362 p.forbid_no_expiry, p.updated_by, p.updated_at, NULL AS revoked
363 FROM workspaces w LEFT JOIN token_policies p ON p.workspace_id = w.id
364 WHERE w.slug = ? AND w.deleted_at IS NULL",
365 )
366 .bind(&[slug.to_lowercase().into()])?
367 .first::<RuleRow>(None)
368 .await?;
369 Ok(row.map(|row| (row.id.clone(), row.policy())))
370 }
371
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers372 // --- Making and changing tokens --------------------------------------------
Fine-grained personal tokens, workspace token rules and approvals in identity373
374 /// Repositories as asked for (`owner/name`, or a name in `slug`), as
375 /// the person can see them: their ids, or why one cannot be chosen.
376 async fn chosen_repositories(&self, user: &User, slug: &str, names: &[String]) -> Result<std::result::Result<Vec<String>, String>> {
377 if names.is_empty() {
378 return Ok(Err("Choose at least one repository, or all repositories.".to_owned()));
379 }
380 if names.len() > MAX_SELECTED_REPOSITORIES {
381 return Ok(Err(format!("A token can reach at most {MAX_SELECTED_REPOSITORIES} selected repositories.")));
382 }
383 let mut ids = Vec::new();
384 for name in names {
385 let name = name.trim().trim_start_matches('/');
386 let (namespace, repo) = name.split_once('/').unwrap_or((slug, name));
387 if !namespace.eq_ignore_ascii_case(slug) {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers388 return Ok(Err(format!("{name} is not a repository of {slug}, the workspace the token is made for.")));
Fine-grained personal tokens, workspace token rules and approvals in identity389 }
390 let path = RepoPath { namespace: slug.to_owned(), name: repo.to_owned() };
391 match self.repo_for(&path, &Some(user.clone())).await? {
392 Some(found) => ids.push(found.id),
393 None => return Ok(Err(format!("There is no repository {slug}/{repo} that you can see."))),
394 }
395 }
396 ids.sort();
397 ids.dedup();
398 Ok(Ok(ids))
399 }
400
401 /// Whether `user` owns the workspace `slug`.
402 fn owns(user: &User, slug: &str) -> bool {
403 user.role_in(&slug.to_lowercase()) == Some(Role::Owner)
404 }
405
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers406 pub async fn create_token(&self, a: CreateTokenArgs) -> Result<Outcome<CreatedAccessToken>> {
407 if !is_person(&a.actor) || a.actor.token.is_some() {
408 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person, signed in on g1t.sh, can make an access token."));
Fine-grained personal tokens, workspace token rules and approvals in identity409 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers410 if !a.actor.verified {
Fine-grained personal tokens, workspace token rules and approvals in identity411 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address before making a token."));
412 }
413 if a.name.trim().is_empty() {
414 return Ok(Outcome::fail(FailureCode::Invalid, "Name the token after what will use it."));
415 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers416 if a.ttl_seconds.is_some_and(|ttl| !(DAY_SECONDS..=u64::from(MAX_LIFETIME_DAYS) * DAY_SECONDS).contains(&ttl)) {
Fine-grained personal tokens, workspace token rules and approvals in identity417 return Ok(Outcome::fail(
418 FailureCode::Invalid,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers419 format!("A token lasts between 1 and {MAX_LIFETIME_DAYS} days, or does not expire."),
Fine-grained personal tokens, workspace token rules and approvals in identity420 ));
421 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers422 match a.owner.as_deref().map(|slug| slug.trim().to_lowercase()).filter(|slug| !slug.is_empty()) {
423 Some(slug) => self.create_workspace_owned(&a, &slug).await,
424 None => self.create_personal(&a).await,
425 }
426 }
427
428 /// A workspace's own token, made by an owner.
429 async fn create_workspace_owned(&self, a: &CreateTokenArgs, slug: &str) -> Result<Outcome<CreatedAccessToken>> {
430 let workspace_id = match self.owned_workspace(&a.actor, slug).await? {
431 Outcome::Ok(id) => id,
432 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
433 };
434 let scopes = match resolve_permissions(&a.permissions, false) {
435 Ok(scopes) => scopes,
436 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
437 };
438 if scopes.is_empty() {
439 return Ok(Outcome::fail(FailureCode::Invalid, "Give the token at least one permission."));
440 }
441 let selection = a.repository_selection;
442 if selection == RepositorySelection::Public {
443 return Ok(Outcome::fail(FailureCode::Invalid, "A workspace's token reaches all of its repositories, or the ones you select."));
444 }
445 if self.tokens_where("access_tokens.workspace_id = ?", &workspace_id).await?.len() >= MAX_TOKENS_PER_WORKSPACE {
446 return Ok(Outcome::fail(FailureCode::Conflict, "This workspace has the maximum number of access tokens. Delete one first."));
447 }
448 let repo_ids = if selection == RepositorySelection::Selected {
449 match self.chosen_repositories(&a.actor, slug, &a.repositories).await? {
450 Ok(ids) => ids,
451 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
452 }
453 } else {
454 Vec::new()
455 };
456 // Repositories: admin makes it an admin of the workspace's
457 // repositories; without it, it has Write, as a member does.
458 let admin = scopes.contains(&Scope::RepoAdmin);
459 let grant = Grant { scopes: Some(scopes) };
460 let mut created = self.mint(Owner::Workspace { id: &workspace_id, created_by: Some(&a.actor.id) }, &a.name, a.ttl_seconds, &grant, true).await?;
461 let description = tidy(a.description.as_deref());
462 let mut statements = vec![self
463 .db
464 .prepare("UPDATE access_tokens SET repository_selection = ?, description = ?, admin = ? WHERE id = ?")
465 .bind(&[
466 selection.as_str().into(),
467 text(description.as_deref()),
468 JsValue::from(u8::from(admin)),
469 created.info.id.as_str().into(),
470 ])?];
471 statements.extend(self.repository_rows(&created.info.id, &repo_ids)?);
472 self.db.batch(statements).await?;
473 created.info.created_by = Some(a.actor.username.clone());
474 created.info.description = description;
475 created.info.admin = admin;
476 created.info.repository_selection = selection;
477 created.info.repositories = qualified_all(Some(slug), &repo_ids, &a.repositories);
478 self.audit_workspace(
479 &a.actor,
480 "workspace_token.created",
481 slug,
482 Surface::Web,
483 format!("Created workspace access token {}{}", created.info.name, if admin { " with Repositories: admin" } else { "" }),
484 )
485 .await;
486 Ok(Outcome::Ok(created))
487 }
488
489 /// A person's own token.
490 async fn create_personal(&self, a: &CreateTokenArgs) -> Result<Outcome<CreatedAccessToken>> {
491 let scopes = match resolve_permissions(&a.permissions, true) {
492 Ok(scopes) => scopes,
Fine-grained personal tokens, workspace token rules and approvals in identity493 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
494 };
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers495 if scopes.is_empty() {
Fine-grained personal tokens, workspace token rules and approvals in identity496 return Ok(Outcome::fail(FailureCode::Invalid, "Give the token at least one permission."));
497 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers498 let slug = a.workspace.as_deref().map(|slug| slug.trim().to_lowercase()).filter(|slug| !slug.is_empty());
499 let selection = a.repository_selection;
Fine-grained personal tokens, workspace token rules and approvals in identity500 let mut status = TokenStatus::Active;
501 let mut workspace_id = None;
502 let mut repo_ids = Vec::new();
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers503 match &slug {
504 Some(slug) => {
505 if !a.actor.is_member(slug) {
506 return Ok(Outcome::fail(FailureCode::Forbidden, format!("You can only make a token for a workspace you belong to, and {slug} is not one.")));
507 }
508 let Some((id, policy)) = self.policy_of(slug).await? else {
509 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
510 };
511 if let Some(refusal) = policy.refusal(slug, true, a.ttl_seconds) {
512 return Ok(Outcome::fail(FailureCode::Forbidden, refusal));
513 }
514 if policy.require_approval && !Self::owns(&a.actor, slug) {
515 status = TokenStatus::Pending;
516 }
517 if selection == RepositorySelection::Selected {
518 repo_ids = match self.chosen_repositories(&a.actor, slug, &a.repositories).await? {
519 Ok(ids) => ids,
520 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
521 };
522 }
523 workspace_id = Some(id);
Fine-grained personal tokens, workspace token rules and approvals in identity524 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers525 None if selection == RepositorySelection::Selected => {
526 return Ok(Outcome::fail(FailureCode::Invalid, "Choose the workspace whose repositories the token reaches."));
Fine-grained personal tokens, workspace token rules and approvals in identity527 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers528 None => {}
Fine-grained personal tokens, workspace token rules and approvals in identity529 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers530 let grant = Grant { scopes: Some(scopes) };
531 let mut created = self.mint(Owner::User(&a.actor.id), &a.name, a.ttl_seconds, &grant, true).await?;
532 let description = tidy(a.description.as_deref());
Fine-grained personal tokens, workspace token rules and approvals in identity533 let mut statements = vec![self
534 .db
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers535 .prepare("UPDATE access_tokens SET owner_workspace_id = ?, repository_selection = ?, description = ?, status = ? WHERE id = ?")
Fine-grained personal tokens, workspace token rules and approvals in identity536 .bind(&[
537 text(workspace_id.as_deref()),
538 selection.as_str().into(),
539 text(description.as_deref()),
540 status.as_str().into(),
541 created.info.id.as_str().into(),
542 ])?];
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers543 statements.extend(self.repository_rows(&created.info.id, &repo_ids)?);
Fine-grained personal tokens, workspace token rules and approvals in identity544 self.db.batch(statements).await?;
545 created.info.description = description;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers546 created.info.workspace = slug.clone();
547 created.info.repository_selection = selection;
548 created.info.repositories = qualified_all(slug.as_deref(), &repo_ids, &a.repositories);
549 created.info.status = status;
550 // In the person's security log and their workspaces' audit logs.
551 self.log_security(&a.actor.id, "token_created", Some(&created.info.name), None).await;
552 self.audit_account(&a.actor, "token.created", &format!("Created access token {}", created.info.name)).await;
Fine-grained personal tokens, workspace token rules and approvals in identity553 if let (Some(slug), TokenStatus::Pending) = (&slug, status) {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers554 self.ask_owners(&a.actor, slug, &created.info).await?;
Fine-grained personal tokens, workspace token rules and approvals in identity555 }
556 Ok(Outcome::Ok(created))
557 }
558
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers559 /// Statements that set a token's selected repositories.
560 fn repository_rows(&self, token_id: &str, repo_ids: &[String]) -> Result<Vec<worker::D1PreparedStatement>> {
561 repo_ids
562 .iter()
563 .map(|repo_id| {
564 self.db
565 .prepare("INSERT OR IGNORE INTO token_repositories (token_id, repo_id) VALUES (?, ?)")
566 .bind(&[token_id.into(), repo_id.as_str().into()])
567 })
568 .collect()
569 }
570
571 pub async fn update_token(&self, a: UpdateTokenArgs) -> Result<Outcome<AccessToken>> {
572 if !is_person(&a.actor) || a.actor.token.is_some() {
573 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person, signed in on g1t.sh, can change an access token."));
Fine-grained personal tokens, workspace token rules and approvals in identity574 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers575 let owner_slug = a.owner.as_deref().map(|slug| slug.trim().to_lowercase()).filter(|slug| !slug.is_empty());
576 let found = self.owned_token(&a.id).await?;
577 let found = match (&owner_slug, found) {
578 (Some(slug), Some(token)) => {
579 let workspace_id = match self.owned_workspace(&a.actor, slug).await? {
580 Outcome::Ok(id) => id,
581 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
582 };
583 if token.workspace_id.as_deref() != Some(workspace_id.as_str()) {
584 return Ok(Outcome::fail(FailureCode::NotFound, "No such token."));
585 }
586 token
587 }
588 (None, Some(token)) if token.user_id.as_deref() == Some(a.actor.id.as_str()) => token,
589 _ => return Ok(Outcome::fail(FailureCode::NotFound, "No such token.")),
Fine-grained personal tokens, workspace token rules and approvals in identity590 };
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers591 let personal = found.user_id.is_some();
592 // The workspace a personal token is made for; for a workspace's
593 // token, its own. Its repositories are chosen there.
594 let made_for = match &found.owner_workspace_id {
Fine-grained personal tokens, workspace token rules and approvals in identity595 Some(id) => self.slug_of(id).await?,
596 None => None,
597 };
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers598 let repo_workspace = if personal { made_for.clone() } else { owner_slug.clone() };
Fine-grained personal tokens, workspace token rules and approvals in identity599 let mut sets: Vec<(&str, JsValue)> = Vec::new();
600 if let Some(name) = a.name.as_deref().map(str::trim).filter(|name| !name.is_empty()) {
601 sets.push(("name", name.chars().take(100).collect::<String>().into()));
602 }
603 if let Some(description) = &a.description {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers604 sets.push(("description", text(tidy(Some(description)).as_deref())));
Fine-grained personal tokens, workspace token rules and approvals in identity605 }
606 let mut widened = false;
607 if let Some(asked) = &a.permissions {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers608 let scopes = match resolve_permissions(asked, personal) {
609 Ok(scopes) => scopes,
Fine-grained personal tokens, workspace token rules and approvals in identity610 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
611 };
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers612 if scopes.is_empty() {
Fine-grained personal tokens, workspace token rules and approvals in identity613 return Ok(Outcome::fail(FailureCode::Invalid, "Give the token at least one permission."));
614 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers615 if !personal {
616 sets.push(("admin", JsValue::from(u8::from(scopes.contains(&Scope::RepoAdmin)))));
617 }
Fine-grained personal tokens, workspace token rules and approvals in identity618 sets.push(("scopes", scopes_text(&scopes).into()));
619 widened = true;
620 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers621 if let Some(selection) = a.repository_selection {
622 if selection == RepositorySelection::Selected && repo_workspace.is_none() {
623 return Ok(Outcome::fail(FailureCode::Invalid, "This token is not made for one workspace, so it cannot select repositories."));
624 }
625 if selection == RepositorySelection::Public && !personal {
626 return Ok(Outcome::fail(FailureCode::Invalid, "A workspace's token reaches all of its repositories, or the ones you select."));
627 }
628 sets.push(("repository_selection", selection.as_str().into()));
629 widened = true;
630 }
Fine-grained personal tokens, workspace token rules and approvals in identity631 let mut repo_ids: Option<Vec<String>> = None;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers632 if let Some(slug) = &repo_workspace {
Fine-grained personal tokens, workspace token rules and approvals in identity633 let selection = a.repository_selection;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers634 let selected = selection == Some(RepositorySelection::Selected) || (selection.is_none() && a.repositories.is_some());
Fine-grained personal tokens, workspace token rules and approvals in identity635 if selected {
636 let names = a.repositories.clone().unwrap_or_default();
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers637 repo_ids = Some(match self.chosen_repositories(&a.actor, slug, &names).await? {
Fine-grained personal tokens, workspace token rules and approvals in identity638 Ok(ids) => ids,
639 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
640 });
641 widened = true;
642 } else if selection.is_some() {
643 repo_ids = Some(Vec::new());
644 }
645 }
646 // Asking for more, of a workspace that approves tokens, asks again.
647 let mut ask = false;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers648 if widened && personal && let Some(slug) = &made_for {
Fine-grained personal tokens, workspace token rules and approvals in identity649 let policy = self.policy_of(slug).await?.map(|(_, policy)| policy).unwrap_or_default();
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers650 if policy.require_approval && !Self::owns(&a.actor, slug) && found.status.as_deref() != Some("revoked") {
Fine-grained personal tokens, workspace token rules and approvals in identity651 sets.push(("status", TokenStatus::Pending.as_str().into()));
652 ask = true;
653 }
654 }
655 let mut statements = Vec::new();
656 if !sets.is_empty() {
657 let assignments: Vec<String> = sets.iter().map(|(column, _)| format!("{column} = ?")).collect();
658 let mut binds: Vec<JsValue> = sets.into_iter().map(|(_, value)| value).collect();
659 binds.push(a.id.as_str().into());
660 statements.push(self.db.prepare(format!("UPDATE access_tokens SET {} WHERE id = ?", assignments.join(", "))).bind(&binds)?);
661 }
662 if let Some(ids) = &repo_ids {
663 statements.push(self.db.prepare("DELETE FROM token_repositories WHERE token_id = ?").bind(&[a.id.as_str().into()])?);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers664 statements.extend(self.repository_rows(&a.id, ids)?);
Fine-grained personal tokens, workspace token rules and approvals in identity665 }
666 if !statements.is_empty() {
667 self.db.batch(statements).await?;
668 }
669 let Some(mut info) = self.token_info(&a.id).await? else {
670 return Ok(Outcome::fail(FailureCode::NotFound, "No such token."));
671 };
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers672 self.name_repositories(std::slice::from_mut(&mut info), &Some(a.actor.clone())).await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens673 if widened {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers674 if personal {
675 self.log_security(&a.actor.id, "token_rescoped", Some(&info.name), None).await;
676 self.audit_account(&a.actor, "token.rescoped", &format!("Changed the permissions of access token {}", info.name)).await;
677 } else if let Some(slug) = &owner_slug {
678 self.audit_workspace(
679 &a.actor,
680 "workspace_token.changed",
681 slug,
682 Surface::Web,
683 format!("Changed the permissions of workspace access token {}", info.name),
684 )
685 .await;
686 }
Merge main (membership, two-factor, GitHub repo roles) into tokens687 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers688 if ask && let Some(slug) = &made_for {
689 self.ask_owners(&a.actor, slug, &info).await?;
Fine-grained personal tokens, workspace token rules and approvals in identity690 }
691 Ok(Outcome::Ok(info))
692 }
693
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers694 /// A token a person or workspace made on purpose, by id: never an
695 /// agent's or a workflow job's.
Fine-grained personal tokens, workspace token rules and approvals in identity696 async fn owned_token(&self, id: &str) -> Result<Option<Owned>> {
697 self.db
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers698 .prepare(
699 "SELECT id, user_id, workspace_id, name, owner_workspace_id, status FROM access_tokens
700 WHERE id = ? AND agent_scope IS NULL AND job_id IS NULL",
701 )
Fine-grained personal tokens, workspace token rules and approvals in identity702 .bind(&[id.into()])?
703 .first::<Owned>(None)
704 .await
705 }
706
707 async fn slug_of(&self, workspace_id: &str) -> Result<Option<String>> {
708 self.db
709 .prepare("SELECT slug FROM workspaces WHERE id = ? AND deleted_at IS NULL")
710 .bind(&[workspace_id.into()])?
711 .first::<String>(Some("slug"))
712 .await
713 }
714
715 /// One token's details, as listings show them.
716 async fn token_info(&self, id: &str) -> Result<Option<AccessToken>> {
717 let row = self
718 .db
719 .prepare(format!(
720 "SELECT {} FROM access_tokens LEFT JOIN users ON users.id = access_tokens.created_by WHERE access_tokens.id = ?",
721 crate::tokens::TOKEN_COLUMNS
722 ))
723 .bind(&[id.into()])?
724 .first::<crate::tokens::TokenRow>(None)
725 .await?;
726 Ok(row.map(Identity::info))
727 }
728
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers729 /// Names the selected repositories of tokens, as `viewer` can see them.
Fine-grained personal tokens, workspace token rules and approvals in identity730 pub(crate) async fn name_repositories(&self, tokens: &mut [AccessToken], viewer: &Viewer) -> Result<()> {
731 let selected: Vec<String> = tokens
732 .iter()
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers733 .filter(|token| token.repository_selection == RepositorySelection::Selected)
Fine-grained personal tokens, workspace token rules and approvals in identity734 .map(|token| token.id.clone())
735 .collect();
736 if selected.is_empty() {
737 return Ok(());
738 }
739 #[derive(Deserialize)]
740 struct Row {
741 token_id: String,
742 repo_id: String,
743 }
744 let marks = vec!["?"; selected.len()].join(", ");
745 let binds: Vec<JsValue> = selected.iter().map(|id| JsValue::from(id.as_str())).collect();
746 let rows = self
747 .db
748 .prepare(format!("SELECT token_id, repo_id FROM token_repositories WHERE token_id IN ({marks})"))
749 .bind(&binds)?
750 .all()
751 .await?
752 .results::<Row>()?;
753 let ids: Vec<String> = rows.iter().map(|row| row.repo_id.clone()).collect::<HashSet<_>>().into_iter().collect();
754 let readable: Vec<g1t_contracts::repos::Repo> = if ids.is_empty() {
755 Vec::new()
756 } else {
757 g1t_kit::call(&self.env.service("REPOS")?, "readable", &g1t_contracts::repos::ReadableArgs { ids, viewer: viewer.clone() }).await?
758 };
759 let names: HashMap<&str, String> = readable.iter().map(|repo| (repo.id.as_str(), format!("{}/{}", repo.namespace, repo.name))).collect();
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers760 for token in tokens.iter_mut().filter(|token| token.repository_selection == RepositorySelection::Selected) {
761 token.repositories = rows
762 .iter()
763 .filter(|row| row.token_id == token.id)
764 .filter_map(|row| names.get(row.repo_id.as_str()).cloned())
765 .collect();
766 token.repositories.sort();
Fine-grained personal tokens, workspace token rules and approvals in identity767 }
768 Ok(())
769 }
770
771 // --- A workspace's rules ----------------------------------------------------
772
773 pub async fn get_token_policy(&self, a: GetTokenPolicyArgs) -> Result<Outcome<TokenPolicy>> {
774 let slug = a.slug.to_lowercase();
775 if !a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&slug)) {
776 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's rules for tokens."));
777 }
778 Ok(match self.policy_of(&slug).await? {
779 Some((_, policy)) => Outcome::Ok(policy),
780 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
781 })
782 }
783
784 /// The workspace's id, if `actor` is a person who owns it.
785 async fn owner_of(&self, actor: &User, slug: &str) -> Result<Outcome<String>> {
786 let slug = slug.to_lowercase();
787 if !is_person(actor) || !Self::owns(actor, &slug) {
788 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner of the workspace can manage its personal access tokens."));
789 }
790 Ok(match self.policy_of(&slug).await? {
791 Some((id, _)) => Outcome::Ok(id),
792 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
793 })
794 }
795
796 pub async fn set_token_policy(&self, a: SetTokenPolicyArgs) -> Result<Outcome<TokenPolicy>> {
797 let slug = a.slug.to_lowercase();
798 let workspace_id = match self.owner_of(&a.actor, &slug).await? {
799 Outcome::Ok(id) => id,
800 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
801 };
802 let current = self.policy_of(&slug).await?.map(|(_, policy)| policy).unwrap_or_default();
803 if a.max_lifetime_days.is_some_and(|days| days > 3650) {
804 return Ok(Outcome::fail(FailureCode::Invalid, "The longest lifetime a workspace can set is 3650 days; leave it empty for no limit."));
805 }
806 let policy = TokenPolicy {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers807 allow_tokens_for_all_workspaces: a.allow_tokens_for_all_workspaces.unwrap_or(current.allow_tokens_for_all_workspaces),
808 allow_tokens_for_this_workspace: a.allow_tokens_for_this_workspace.unwrap_or(current.allow_tokens_for_this_workspace),
Fine-grained personal tokens, workspace token rules and approvals in identity809 require_approval: a.require_approval.unwrap_or(current.require_approval),
810 max_lifetime_days: match a.max_lifetime_days {
811 Some(0) => None,
812 Some(days) => Some(days),
813 None => current.max_lifetime_days,
814 },
815 forbid_no_expiry: a.forbid_no_expiry.unwrap_or(current.forbid_no_expiry),
816 updated_by: Some(a.actor.username.clone()),
817 updated_at: Some(rfc3339(now_ms())),
818 };
819 self.db
820 .prepare(
821 "INSERT INTO token_policies (workspace_id, allow_classic, allow_fine_grained, require_approval, max_lifetime_days,
822 forbid_no_expiry, updated_by, updated_at)
823 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
824 ON CONFLICT (workspace_id) DO UPDATE SET allow_classic = ?2, allow_fine_grained = ?3, require_approval = ?4,
825 max_lifetime_days = ?5, forbid_no_expiry = ?6, updated_by = ?7, updated_at = ?8",
826 )
827 .bind(&[
828 workspace_id.as_str().into(),
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers829 JsValue::from(u8::from(policy.allow_tokens_for_all_workspaces)),
830 JsValue::from(u8::from(policy.allow_tokens_for_this_workspace)),
Fine-grained personal tokens, workspace token rules and approvals in identity831 JsValue::from(u8::from(policy.require_approval)),
832 policy.max_lifetime_days.map_or(JsValue::NULL, JsValue::from),
833 JsValue::from(u8::from(policy.forbid_no_expiry)),
834 a.actor.username.as_str().into(),
835 text(policy.updated_at.as_deref()),
836 ])?
837 .run()
838 .await?;
839 self.audit_workspace(&a.actor, "token.policy_changed", &slug, a.surface.unwrap_or(Surface::Web), describe_policy(&policy)).await;
840 Ok(Outcome::Ok(policy))
841 }
842
843 // --- Members' tokens ------------------------------------------------------
844
845 pub async fn list_member_tokens(&self, a: ListMemberTokensArgs) -> Result<Outcome<Vec<MemberToken>>> {
846 let slug = a.slug.to_lowercase();
847 let workspace_id = match self.owner_of(&a.actor, &slug).await? {
848 Outcome::Ok(id) => id,
849 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
850 };
851 let policy = self.policy_of(&slug).await?.map(|(_, policy)| policy).unwrap_or_default();
852 #[derive(Deserialize)]
853 struct Row {
854 owner: String,
855 #[serde(default)]
856 revoked: Option<f64>,
857 #[serde(flatten)]
858 token: crate::tokens::TokenRow,
859 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers860 // Tokens made for the workspace, and the tokens for every workspace
861 // of its members and outside collaborators, that have not expired.
Fine-grained personal tokens, workspace token rules and approvals in identity862 let rows = self
863 .db
864 .prepare(format!(
865 "SELECT owners.username AS owner,
866 (SELECT 1 FROM token_workspace_revocations r WHERE r.token_id = access_tokens.id AND r.workspace_id = ?1) AS revoked,
867 {}
868 FROM access_tokens
869 JOIN users owners ON owners.id = access_tokens.user_id
870 LEFT JOIN users ON users.id = access_tokens.created_by
871 WHERE access_tokens.agent_scope IS NULL AND access_tokens.workspace_id IS NULL
872 AND (access_tokens.expires_at IS NULL OR access_tokens.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
873 AND (
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers874 access_tokens.owner_workspace_id = ?1
875 OR (access_tokens.owner_workspace_id IS NULL AND COALESCE(access_tokens.repository_selection, 'all') <> 'public'
876 AND (access_tokens.expires_at IS NULL OR access_tokens.listed = 1)
Fine-grained personal tokens, workspace token rules and approvals in identity877 AND (access_tokens.user_id IN (SELECT user_id FROM workspace_members WHERE workspace_id = ?1)
878 OR access_tokens.user_id IN (SELECT principal_id FROM repo_grants WHERE workspace_id = ?1 AND principal_kind = 'user')))
879 )
880 ORDER BY access_tokens.id DESC
881 LIMIT 500",
882 crate::tokens::TOKEN_COLUMNS
883 ))
884 .bind(&[workspace_id.as_str().into()])?
885 .all()
886 .await?
887 .results::<Row>()?;
888 let mut listed: Vec<MemberToken> = Vec::new();
889 let mut infos: Vec<AccessToken> = Vec::new();
890 let mut owners: Vec<(String, bool)> = Vec::new();
891 for row in rows {
892 owners.push((row.owner, row.revoked.is_some_and(|revoked| revoked >= 1.0)));
893 infos.push(Identity::info(row.token));
894 }
895 self.name_repositories(&mut infos, &Some(a.actor.clone())).await?;
896 for ((owner, revoked), token) in owners.into_iter().zip(infos) {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers897 if a.status.is_some_and(|wanted| wanted != token.status) {
Fine-grained personal tokens, workspace token rules and approvals in identity898 continue;
899 }
900 let created = parse_rfc3339(&token.created_at).unwrap_or(0);
901 let expires = token.expires_at.as_deref().and_then(parse_rfc3339);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers902 let blocked = blocked_by(&policy, token.workspace.is_some(), token.status, revoked, created, expires);
Fine-grained personal tokens, workspace token rules and approvals in identity903 listed.push(MemberToken { owner, token, reaches: blocked.is_none(), blocked_by: blocked.map(str::to_owned) });
904 }
905 Ok(Outcome::Ok(listed))
906 }
907
908 /// One member token, as `list_member_tokens` shows it.
909 async fn member_token(&self, actor: &User, slug: &str, id: &str) -> Result<Option<MemberToken>> {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers910 let listed = self.list_member_tokens(ListMemberTokensArgs { actor: actor.clone(), slug: slug.to_owned(), status: None }).await?;
Fine-grained personal tokens, workspace token rules and approvals in identity911 Ok(match listed {
912 Outcome::Ok(tokens) => tokens.into_iter().find(|member| member.token.id == id),
913 Outcome::Fail(_) => None,
914 })
915 }
916
917 pub async fn review_token_request(&self, a: ReviewTokenRequestArgs) -> Result<Outcome<MemberToken>> {
918 let slug = a.slug.to_lowercase();
919 let workspace_id = match self.owner_of(&a.actor, &slug).await? {
920 Outcome::Ok(id) => id,
921 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
922 };
923 let Some(found) = self.owned_token(&a.id).await?.filter(|token| token.owner_workspace_id.as_deref() == Some(workspace_id.as_str())) else {
924 return Ok(Outcome::fail(FailureCode::NotFound, "There is no such token request in this workspace."));
925 };
926 if found.status.as_deref() != Some("pending") {
927 return Ok(Outcome::fail(FailureCode::Conflict, "This token is not waiting for approval."));
928 }
929 let status = if a.approve { TokenStatus::Active } else { TokenStatus::Denied };
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers930 let reason = tidy(a.reason.as_deref());
Fine-grained personal tokens, workspace token rules and approvals in identity931 self.db
932 .prepare("UPDATE access_tokens SET status = ?, reviewed_by = ?, reviewed_at = ?, review_reason = ? WHERE id = ? AND status = 'pending'")
933 .bind(&[
934 status.as_str().into(),
935 a.actor.id.as_str().into(),
936 rfc3339(now_ms()).into(),
937 text(reason.as_deref()),
938 a.id.as_str().into(),
939 ])?
940 .run()
941 .await?;
942 let owner = match &found.user_id {
943 Some(id) => self.usernames_of(std::slice::from_ref(id)).await?.into_iter().next(),
944 None => None,
945 };
946 let verdict = if a.approve { "approved" } else { "denied" };
947 self.audit_workspace(
948 &a.actor,
949 if a.approve { "token.approved" } else { "token.denied" },
950 &slug,
951 a.surface.unwrap_or(Surface::Web),
952 format!(
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers953 "{} the access token {} of {}{}",
Fine-grained personal tokens, workspace token rules and approvals in identity954 if a.approve { "Approved" } else { "Denied" },
955 found.name,
956 owner.as_deref().unwrap_or("a former member"),
957 reason.as_deref().map(|reason| format!(": {reason}")).unwrap_or_default()
958 ),
959 )
960 .await;
961 if let Some(owner) = &owner {
962 self.notify(
963 "token.approval_reviewed",
964 &a.actor,
965 TokenNotice {
966 workspace: &slug,
967 token_id: &found.id,
968 token_name: &found.name,
969 notify: vec![owner.clone()],
970 title: format!("Your token {} was {verdict} for {slug}", found.name),
971 body: reason.clone().unwrap_or_else(|| {
972 if a.approve { "It now reaches the workspace.".to_owned() } else { "It reaches public repositories only.".to_owned() }
973 }),
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers974 link: format!("/settings/tokens/{}", found.id),
Fine-grained personal tokens, workspace token rules and approvals in identity975 },
976 )
977 .await;
978 }
979 Ok(match self.member_token(&a.actor, &slug, &a.id).await? {
980 Some(member) => Outcome::Ok(member),
981 None => Outcome::fail(FailureCode::NotFound, "There is no such token request in this workspace."),
982 })
983 }
984
985 pub async fn revoke_member_token(&self, a: RevokeMemberTokenArgs) -> Result<Outcome<bool>> {
986 let slug = a.slug.to_lowercase();
987 let workspace_id = match self.owner_of(&a.actor, &slug).await? {
988 Outcome::Ok(id) => id,
989 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
990 };
991 let Some(member) = self.member_token(&a.actor, &slug, &a.id).await? else {
992 return Ok(Outcome::fail(FailureCode::NotFound, "No token of a member reaches this workspace with that id."));
993 };
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers994 let reason = tidy(a.reason.as_deref());
Fine-grained personal tokens, workspace token rules and approvals in identity995 let now = rfc3339(now_ms());
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers996 let made_for_it = member.token.workspace.is_some();
997 if made_for_it {
Fine-grained personal tokens, workspace token rules and approvals in identity998 self.db
999 .prepare("UPDATE access_tokens SET status = 'revoked', reviewed_by = ?, reviewed_at = ?, review_reason = ? WHERE id = ? AND owner_workspace_id = ?")
1000 .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()])?
1001 .run()
1002 .await?;
1003 } else {
1004 self.db
1005 .prepare(
1006 "INSERT INTO token_workspace_revocations (token_id, workspace_id, revoked_by, revoked_at, reason) VALUES (?, ?, ?, ?, ?)
1007 ON CONFLICT (token_id, workspace_id) DO NOTHING",
1008 )
1009 .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())])?
1010 .run()
1011 .await?;
1012 }
1013 self.audit_workspace(
1014 &a.actor,
1015 "token.revoked",
1016 &slug,
1017 a.surface.unwrap_or(Surface::Web),
1018 format!(
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1019 "Revoked {}'s access token {} in {slug}{}",
Fine-grained personal tokens, workspace token rules and approvals in identity1020 member.owner,
1021 member.token.name,
1022 reason.as_deref().map(|reason| format!(": {reason}")).unwrap_or_default()
1023 ),
1024 )
1025 .await;
1026 self.notify(
1027 "token.approval_reviewed",
1028 &a.actor,
1029 TokenNotice {
1030 workspace: &slug,
1031 token_id: &a.id,
1032 token_name: &member.token.name,
1033 notify: vec![member.owner.clone()],
1034 title: format!("Your token {} was revoked for {slug}", member.token.name),
1035 body: reason.unwrap_or_else(|| "An owner of the workspace revoked it there.".to_owned()),
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1036 link: format!("/settings/tokens/{}", a.id),
Fine-grained personal tokens, workspace token rules and approvals in identity1037 },
1038 )
1039 .await;
1040 Ok(Outcome::Ok(true))
1041 }
1042
1043 // --- Telling people -------------------------------------------------------
1044
1045 /// Tells the workspace's owners that `requester`'s token waits for them.
1046 async fn ask_owners(&self, requester: &User, slug: &str, token: &AccessToken) -> Result<()> {
1047 #[derive(Deserialize)]
1048 struct Row {
1049 username: String,
1050 }
1051 let owners: Vec<String> = self
1052 .db
1053 .prepare(
1054 "SELECT users.username FROM workspace_members
1055 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
1056 JOIN users ON users.id = workspace_members.user_id
1057 WHERE workspaces.slug = ? AND workspace_members.role = 'owner'",
1058 )
1059 .bind(&[slug.into()])?
1060 .all()
1061 .await?
1062 .results::<Row>()?
1063 .into_iter()
1064 .map(|row| row.username)
1065 .collect();
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1066 self.audit_workspace(requester, "token.approval_requested", slug, Surface::Web, format!("Asked for approval of the access token {}", token.name)).await;
Fine-grained personal tokens, workspace token rules and approvals in identity1067 if owners.is_empty() {
1068 return Ok(());
1069 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1070 let permissions = token.permissions.iter().map(|(name, level)| format!("{name}: {level}")).collect::<Vec<_>>().join(", ");
Fine-grained personal tokens, workspace token rules and approvals in identity1071 self.notify(
1072 "token.approval_requested",
1073 requester,
1074 TokenNotice {
1075 workspace: slug,
1076 token_id: &token.id,
1077 token_name: &token.name,
1078 notify: owners,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1079 title: format!("{} asks to use an access token in {slug}", requester.username),
Fine-grained personal tokens, workspace token rules and approvals in identity1080 body: format!("{}: {permissions}", token.name),
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules1081 link: format!("/{slug}/-/personal-access-tokens"),
Fine-grained personal tokens, workspace token rules and approvals in identity1082 },
1083 )
1084 .await;
1085 Ok(())
1086 }
1087
1088 async fn notify(&self, kind: &'static str, actor: &User, notice: TokenNotice<'_>) {
1089 let Ok(events) = self.env.service("EVENTS") else {
1090 return;
1091 };
1092 let publish = Publish {
1093 events: vec![NewEvent { kind, source: "identity", repo_id: None, actor: Some(actor.id.clone()), data: notice }],
1094 };
1095 if let Err(error) = g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await {
1096 worker::console_error!("{kind} not published: {error}");
1097 }
1098 }
1099
1100 async fn usernames_of(&self, ids: &[String]) -> Result<Vec<String>> {
1101 #[derive(Deserialize)]
1102 struct Row {
1103 username: String,
1104 }
1105 if ids.is_empty() {
1106 return Ok(Vec::new());
1107 }
1108 let marks = vec!["?"; ids.len()].join(", ");
1109 let binds: Vec<JsValue> = ids.iter().map(|id| JsValue::from(id.as_str())).collect();
1110 Ok(self
1111 .db
1112 .prepare(format!("SELECT username FROM users WHERE id IN ({marks})"))
1113 .bind(&binds)?
1114 .all()
1115 .await?
1116 .results::<Row>()?
1117 .into_iter()
1118 .map(|row| row.username)
1119 .collect())
1120 }
1121}
1122
1123/// `owner/name` for a repository asked for by name in `slug`.
1124fn qualified(slug: Option<&str>, name: &str) -> String {
1125 let name = name.trim().trim_start_matches('/');
1126 match (name.contains('/'), slug) {
1127 (false, Some(slug)) => format!("{slug}/{name}"),
1128 _ => name.to_lowercase(),
1129 }
1130}
1131
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1132/// The repositories a new token selected, by name, when it selected any.
1133fn qualified_all(slug: Option<&str>, repo_ids: &[String], names: &[String]) -> Vec<String> {
1134 if repo_ids.is_empty() {
1135 return Vec::new();
1136 }
1137 let mut names: Vec<String> = names.iter().map(|name| qualified(slug, name)).collect();
1138 names.sort();
1139 names.dedup();
1140 names
1141}
1142
Fine-grained personal tokens, workspace token rules and approvals in identity1143/// The policy in a sentence, for the audit log.
1144fn describe_policy(policy: &TokenPolicy) -> String {
1145 let yes = |on: bool| if on { "allowed" } else { "not allowed" };
1146 format!(
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1147 "Tokens for all of a member's workspaces {}; tokens made for this workspace {}{}; lifetime {}{}",
1148 yes(policy.allow_tokens_for_all_workspaces),
1149 yes(policy.allow_tokens_for_this_workspace),
Fine-grained personal tokens, workspace token rules and approvals in identity1150 if policy.require_approval { ", with approval" } else { ", without approval" },
1151 policy.max_lifetime_days.map_or_else(|| "unlimited".to_owned(), |days| format!("at most {days} days")),
1152 if policy.forbid_no_expiry { "; tokens must expire" } else { "" },
1153 )
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158 use super::*;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1159 use std::collections::BTreeMap;
Fine-grained personal tokens, workspace token rules and approvals in identity1160
1161 const DAY: u64 = 86_400_000;
1162
1163 #[test]
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1164 fn tokens_for_every_workspace_follow_the_workspace_rules() {
Fine-grained personal tokens, workspace token rules and approvals in identity1165 let open = TokenPolicy::default();
1166 assert_eq!(blocked_by(&open, false, TokenStatus::Active, false, 0, None), None);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1167 let closed = TokenPolicy { allow_tokens_for_all_workspaces: false, ..TokenPolicy::default() };
1168 assert_eq!(blocked_by(&closed, false, TokenStatus::Active, false, 0, Some(DAY)), Some("tokens for all workspaces not allowed"));
Fine-grained personal tokens, workspace token rules and approvals in identity1169 let capped = TokenPolicy { max_lifetime_days: Some(30), ..TokenPolicy::default() };
1170 assert_eq!(blocked_by(&capped, false, TokenStatus::Active, false, 0, Some(90 * DAY)), Some("lasts too long"));
1171 assert_eq!(blocked_by(&capped, false, TokenStatus::Active, false, 0, None), Some("never expires"));
1172 assert_eq!(blocked_by(&capped, false, TokenStatus::Active, false, 0, Some(7 * DAY)), None);
1173 assert_eq!(blocked_by(&open, false, TokenStatus::Active, true, 0, None), Some("revoked"));
1174 }
1175
1176 #[test]
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1177 fn tokens_made_for_a_workspace_reach_it_once_active_and_allowed() {
Fine-grained personal tokens, workspace token rules and approvals in identity1178 let open = TokenPolicy::default();
1179 assert_eq!(blocked_by(&open, true, TokenStatus::Pending, false, 0, Some(DAY)), Some("pending approval"));
1180 assert_eq!(blocked_by(&open, true, TokenStatus::Denied, false, 0, Some(DAY)), Some("denied"));
1181 assert_eq!(blocked_by(&open, true, TokenStatus::Revoked, false, 0, Some(DAY)), Some("revoked"));
1182 assert_eq!(blocked_by(&open, true, TokenStatus::Active, false, 0, Some(DAY)), None);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1183 let closed = TokenPolicy { allow_tokens_for_this_workspace: false, ..TokenPolicy::default() };
1184 assert_eq!(blocked_by(&closed, true, TokenStatus::Active, false, 0, Some(DAY)), Some("tokens made for this workspace not allowed"));
1185 // Keeping out tokens for every workspace does not touch these.
1186 let no_broad = TokenPolicy { allow_tokens_for_all_workspaces: false, ..TokenPolicy::default() };
1187 assert_eq!(blocked_by(&no_broad, true, TokenStatus::Active, false, 0, Some(DAY)), None);
Fine-grained personal tokens, workspace token rules and approvals in identity1188 }
1189
1190 #[test]
1191 fn rules_rows_read_with_defaults() {
1192 let row = RuleRow { id: "wsp_1".into(), slug: "acme".into(), ..RuleRow::default() };
1193 assert_eq!(row.policy(), TokenPolicy::default());
1194 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 };
1195 let policy = set.policy();
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1196 assert!(!policy.allow_tokens_for_all_workspaces && policy.allow_tokens_for_this_workspace && !policy.require_approval && policy.forbid_no_expiry);
Fine-grained personal tokens, workspace token rules and approvals in identity1197 assert_eq!(policy.max_lifetime_days, Some(90));
1198 }
1199
1200 #[test]
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1201 fn a_listed_row_describes_its_reach() {
Fine-grained personal tokens, workspace token rules and approvals in identity1202 let more = TokenRowMore {
1203 repository_selection: Some("selected".into()),
1204 status: Some("pending".into()),
1205 owner_workspace: Some("acme".into()),
1206 ..TokenRowMore::default()
1207 };
1208 let mut info = AccessToken::default();
1209 more.describe(&mut info);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1210 assert_eq!(info.workspace.as_deref(), Some("acme"));
1211 assert_eq!(info.repository_selection, RepositorySelection::Selected);
1212 assert_eq!(info.status, TokenStatus::Pending);
1213 assert!(!info.workspace_owned);
1214 // A row from before reaches (null) reads as every workspace, active.
1215 let mut classic = AccessToken::default();
1216 TokenRowMore::default().describe(&mut classic);
1217 assert_eq!((classic.workspace.as_deref(), classic.repository_selection, classic.status), (None, RepositorySelection::All, TokenStatus::Active));
Fine-grained personal tokens, workspace token rules and approvals in identity1218 let mut workspace = AccessToken::default();
1219 TokenRowMore { workspace_id: Some("wsp_1".into()), admin: Some(1.0), ..TokenRowMore::default() }.describe(&mut workspace);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1220 assert!(workspace.workspace_owned && workspace.admin);
1221 }
1222
1223 #[test]
1224 fn a_rows_reach_is_read_from_its_columns() {
1225 let broad = Facts::default();
1226 assert!(!broad.made_for_one() && !broad.account_only());
1227 let account = Facts { repository_selection: Some("public".into()), ..Facts::default() };
1228 assert!(account.account_only());
1229 let one = Facts { owner_workspace_id: Some("wsp_1".into()), repository_selection: Some("public".into()), ..Facts::default() };
1230 assert!(one.made_for_one() && !one.account_only(), "public in a workspace is that workspace's settings, no private repositories");
Fine-grained personal tokens, workspace token rules and approvals in identity1231 }
1232
1233 #[test]
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1234 fn repositories_are_named_in_the_workspace() {
Fine-grained personal tokens, workspace token rules and approvals in identity1235 assert_eq!(qualified(Some("acme"), "web"), "acme/web");
1236 assert_eq!(qualified(Some("acme"), "Acme/Web"), "acme/web");
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1237 assert_eq!(qualified_all(Some("acme"), &["rep_1".into()], &["web".into(), "acme/web".into()]), vec!["acme/web".to_owned()]);
1238 assert!(qualified_all(Some("acme"), &[], &["web".into()]).is_empty());
Fine-grained personal tokens, workspace token rules and approvals in identity1239 assert!(describe_policy(&TokenPolicy::default()).contains("with approval"));
1240 }
1241
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1242 /// Migration 0041 writes full access out as every permission: the
1243 /// same lists the code makes.
1244 #[test]
1245 fn the_migration_sets_full_access_out_as_every_permission() {
1246 use g1t_contracts::scopes::{ResourceGroup, everything};
1247 let sql = include_str!("../migrations/0041_one_kind_of_token.sql");
1248 assert!(sql.contains(&format!("SET scopes = '{}'", scopes_text(&everything()))));
1249 let workspace: Vec<Scope> = everything().into_iter().filter(|scope| scope.resource().group() != ResourceGroup::Account).collect();
1250 assert!(sql.contains(&format!("SET scopes = '{}'", scopes_text(&workspace))));
1251 let write: Vec<Scope> = workspace
1252 .iter()
1253 .map(|scope| match scope {
1254 Scope::RepoAdmin => Scope::RepoWrite,
1255 Scope::AccessAdmin => Scope::AccessRead,
1256 other => *other,
1257 })
1258 .collect();
1259 assert!(sql.contains(&format!("SET scopes = '{}'", scopes_text(&write))));
1260 }
1261
Fine-grained personal tokens, workspace token rules and approvals in identity1262 #[test]
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1263 fn permissions_are_stored_as_the_scopes_every_check_reads() {
1264 let asked: BTreeMap<String, String> = [("code".to_owned(), "read".to_owned()), ("repo".to_owned(), "read".to_owned())].into();
1265 let scopes = resolve_permissions(&asked, true).unwrap();
Fine-grained personal tokens, workspace token rules and approvals in identity1266 assert_eq!(scopes_text(&scopes), "repo:read code:read");
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1267 assert_eq!(permissions_of(&scopes), asked);
Fine-grained personal tokens, workspace token rules and approvals in identity1268 }
1269}

This file's history is long; its oldest lines are credited to the oldest commit read.