Skip to content

Commit

Fine-grained personal tokens, workspace token rules and approvals in identity

syntaqxcommitted Parent425a1d3Browse files
9 files+1743−160/9 viewed
+18−1
1717 pub created_at: String,
1818 }
1919
20−#[derive(Clone, Debug, Serialize, Deserialize)]
20+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
2121 #[serde(rename_all = "camelCase")]
2222 pub struct AccessToken {
2323 pub id: String,
3838 /// RFC 3339. Null: it does not expire.
3939 #[serde(default)]
4040 pub expires_at: Option<String>,
41+ /// Classic, fine-grained, or a workspace's own.
42+ #[serde(default)]
43+ pub kind: crate::tokens::TokenKind,
44+ /// What it is for, as its owner wrote it.
45+ #[serde(default, skip_serializing_if = "Option::is_none")]
46+ pub description: Option<String>,
47+ /// A fine-grained token's resource owner, repositories, permissions and
48+ /// status.
49+ #[serde(default, skip_serializing_if = "Option::is_none")]
50+ pub fine_grained: Option<crate::tokens::FineGrainedDetails>,
51+ /// A workspace's own token an owner gave Admin when making it.
52+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
53+ pub admin: bool,
4154 }
4255
4356 /// `sign_in`: verifies a username, or any confirmed email address of the
699712 /// listed with the workspace's tokens either way. Null: no expiry.
700713 #[serde(default)]
701714 pub ttl_seconds: Option<u64>,
715+ /// Admin on the workspace's repositories, rather than Write: given by
716+ /// the owner on purpose, when making it.
717+ #[serde(default)]
718+ pub admin: bool,
702719 }
703720
704721 /// `remove_workspace_token`: owners only. Returns `Outcome<bool>`.
+1−0
3737 pub mod teams;
3838 pub mod security_suite;
3939 pub mod time;
40+pub mod tokens;
4041 pub mod updates;
4142 pub mod webhooks;
4243 pub mod work;
+348−0
1+//! Fine-grained personal access tokens, and a workspace's rules for the
2+//! personal tokens that reach it: the identity methods for both.
3+//!
4+//! A **classic** token reaches whatever its owner can, narrowed by its
5+//! scopes. A **fine-grained** token names one resource owner (the person's
6+//! own account, or one workspace they belong to), which of that
7+//! workspace's repositories it reaches, and a level for each permission
8+//! ([`crate::fine_grained`]); it must expire. A workspace's owners decide
9+//! whether either kind reaches the workspace, whether a fine-grained token
10+//! naming it waits for their approval, and how long a token reaching it may
11+//! last; they see every member's token that reaches it, and can revoke one.
12+//!
13+//! Each `*Args` struct is the argument of the identity method of the same
14+//! name, served at `POST /rpc/<method>`.
15+
16+use std::collections::BTreeMap;
17+
18+use serde::{Deserialize, Serialize};
19+
20+use crate::fine_grained::Access;
21+use crate::identity::AccessToken;
22+use crate::scopes::RepositorySelection;
23+use crate::{User, Viewer};
24+
25+/// What kind of token it is.
26+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
27+#[serde(rename_all = "snake_case")]
28+pub enum TokenKind {
29+ /// A personal token with scopes, reaching whatever its owner can.
30+ #[default]
31+ Classic,
32+ /// A personal token with one resource owner and permissions.
33+ FineGrained,
34+ /// A workspace's own token.
35+ Workspace,
36+}
37+
38+/// Whether a fine-grained token may be used on its resource owner.
39+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
40+#[serde(rename_all = "snake_case")]
41+pub enum TokenStatus {
42+ #[default]
43+ Active,
44+ /// Waiting for an owner of the workspace to approve it. Until then it
45+ /// reads public repositories only.
46+ Pending,
47+ /// An owner turned it down.
48+ Denied,
49+ /// An owner took it out of the workspace.
50+ Revoked,
51+}
52+
53+impl TokenStatus {
54+ pub fn as_str(self) -> &'static str {
55+ match self {
56+ TokenStatus::Active => "active",
57+ TokenStatus::Pending => "pending",
58+ TokenStatus::Denied => "denied",
59+ TokenStatus::Revoked => "revoked",
60+ }
61+ }
62+
63+ pub fn parse(text: &str) -> TokenStatus {
64+ match text {
65+ "pending" => TokenStatus::Pending,
66+ "denied" => TokenStatus::Denied,
67+ "revoked" => TokenStatus::Revoked,
68+ _ => TokenStatus::Active,
69+ }
70+ }
71+}
72+
73+/// `create_fine_grained_token`: a person makes a fine-grained token.
74+/// People only, with a confirmed address. Returns
75+/// `Outcome<CreatedAccessToken>`; the token starts pending when its
76+/// workspace asks for approval and the person is not one of its owners.
77+#[derive(Debug, Serialize, Deserialize)]
78+pub struct CreateFineGrainedTokenArgs {
79+ pub user: User,
80+ pub name: String,
81+ #[serde(default)]
82+ pub description: Option<String>,
83+ /// How long it lasts: at most [`crate::fine_grained::MAX_LIFETIME_DAYS`]
84+ /// days, and at most what the workspace allows.
85+ pub ttl_seconds: u64,
86+ /// The resource owner: a workspace's slug, or null for your own account.
87+ #[serde(default)]
88+ pub workspace: Option<String>,
89+ #[serde(default)]
90+ pub repository_selection: RepositorySelection,
91+ /// With `selected`: the repositories, as `owner/name` or a name in the
92+ /// workspace. At most [`MAX_SELECTED_REPOSITORIES`].
93+ #[serde(default)]
94+ pub repositories: Vec<String>,
95+ /// Each permission's level by name, such as `{"contents": "write"}`.
96+ #[serde(default)]
97+ pub permissions: BTreeMap<String, String>,
98+}
99+
100+/// The most repositories a fine-grained token may select.
101+pub const MAX_SELECTED_REPOSITORIES: usize = 50;
102+
103+/// `update_fine_grained_token`: its owner changes its name, description,
104+/// repositories or permissions. What is left out stays. A token aimed at a
105+/// workspace that asks for approval waits for it again when its
106+/// repositories or permissions change, unless its owner is an owner there.
107+/// Returns `Outcome<AccessToken>`.
108+#[derive(Debug, Serialize, Deserialize)]
109+pub struct UpdateFineGrainedTokenArgs {
110+ pub user: User,
111+ pub id: String,
112+ #[serde(default)]
113+ pub name: Option<String>,
114+ #[serde(default)]
115+ pub description: Option<String>,
116+ #[serde(default)]
117+ pub repository_selection: Option<RepositorySelection>,
118+ #[serde(default)]
119+ pub repositories: Option<Vec<String>>,
120+ #[serde(default)]
121+ pub permissions: Option<BTreeMap<String, String>>,
122+}
123+
124+/// A workspace's rules for personal access tokens.
125+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
126+#[serde(rename_all = "camelCase")]
127+pub struct TokenPolicy {
128+ /// Whether classic tokens reach the workspace. Off: they still work
129+ /// everywhere else.
130+ pub allow_classic: bool,
131+ /// Whether fine-grained tokens may name the workspace as their
132+ /// resource owner.
133+ pub allow_fine_grained: bool,
134+ /// Whether a fine-grained token naming it waits for an owner's
135+ /// approval. Owners' own tokens never wait.
136+ pub require_approval: bool,
137+ /// The longest a token reaching it may last, in days. Null: no limit
138+ /// (a fine-grained token still lasts at most 366 days).
139+ pub max_lifetime_days: Option<u32>,
140+ /// Whether a token that never expires is kept out.
141+ pub forbid_no_expiry: bool,
142+ /// Who changed it last, and when; null for the defaults.
143+ #[serde(default)]
144+ pub updated_by: Option<String>,
145+ #[serde(default)]
146+ pub updated_at: Option<String>,
147+}
148+
149+impl Default for TokenPolicy {
150+ /// What a workspace has until an owner changes it.
151+ fn default() -> Self {
152+ TokenPolicy {
153+ allow_classic: true,
154+ allow_fine_grained: true,
155+ require_approval: true,
156+ max_lifetime_days: None,
157+ forbid_no_expiry: false,
158+ updated_by: None,
159+ updated_at: None,
160+ }
161+ }
162+}
163+
164+impl TokenPolicy {
165+ /// Whether a token made at `created_ms` that expires at `expires_ms`
166+ /// (none: never) lasts no longer than the policy allows.
167+ pub fn lifetime_allowed(&self, created_ms: u64, expires_ms: Option<u64>) -> bool {
168+ match (expires_ms, self.max_lifetime_days) {
169+ (None, _) if self.forbid_no_expiry => false,
170+ (None, Some(_)) => false,
171+ (None, None) => true,
172+ (Some(_), None) => true,
173+ // A day's grace, for clocks and for "30 days" picked at 23:59.
174+ (Some(expires), Some(days)) => expires.saturating_sub(created_ms) <= (u64::from(days) + 1) * 86_400_000,
175+ }
176+ }
177+
178+ /// Why a token of `kind` that lasts `ttl_seconds` (none: forever)
179+ /// cannot be made for the workspace `slug`, if it cannot.
180+ pub fn refusal(&self, slug: &str, kind: TokenKind, ttl_seconds: Option<u64>) -> Option<String> {
181+ match kind {
182+ TokenKind::FineGrained if !self.allow_fine_grained => {
183+ return Some(format!("{slug} does not allow fine-grained personal access tokens."));
184+ }
185+ TokenKind::Classic if !self.allow_classic => {
186+ return Some(format!("{slug} does not allow classic personal access tokens."));
187+ }
188+ _ => {}
189+ }
190+ if !self.lifetime_allowed(0, ttl_seconds.map(|ttl| ttl * 1000)) {
191+ return Some(match self.max_lifetime_days {
192+ Some(days) => format!("{slug} allows tokens that last at most {days} days."),
193+ None => format!("{slug} does not allow tokens that never expire."),
194+ });
195+ }
196+ None
197+ }
198+}
199+
200+/// `get_token_policy`: a workspace's rules. Members may read them, so the
201+/// token form can say what a workspace allows. Returns
202+/// `Outcome<TokenPolicy>`.
203+#[derive(Debug, Serialize, Deserialize)]
204+pub struct GetTokenPolicyArgs {
205+ pub viewer: Viewer,
206+ pub slug: String,
207+}
208+
209+/// `set_token_policy`: an owner, as a person, changes the rules. What is
210+/// left out stays. Returns `Outcome<TokenPolicy>`.
211+#[derive(Debug, Default, Serialize, Deserialize)]
212+pub struct SetTokenPolicyArgs {
213+ pub actor: User,
214+ pub slug: String,
215+ #[serde(default)]
216+ pub allow_classic: Option<bool>,
217+ #[serde(default)]
218+ pub allow_fine_grained: Option<bool>,
219+ #[serde(default)]
220+ pub require_approval: Option<bool>,
221+ /// Zero clears the limit.
222+ #[serde(default)]
223+ pub max_lifetime_days: Option<u32>,
224+ #[serde(default)]
225+ pub forbid_no_expiry: Option<bool>,
226+ #[serde(default)]
227+ pub surface: Option<crate::audit::Surface>,
228+}
229+
230+/// A member's personal token that reaches a workspace, as its owners see
231+/// it: never the token itself.
232+#[derive(Clone, Debug, Serialize, Deserialize)]
233+#[serde(rename_all = "camelCase")]
234+pub struct MemberToken {
235+ /// The person it belongs to.
236+ pub owner: String,
237+ pub token: AccessToken,
238+ /// Whether it reaches the workspace now: active, allowed by the
239+ /// policy, and not revoked here.
240+ pub reaches: bool,
241+ /// Why not, when it does not.
242+ #[serde(default)]
243+ pub blocked_by: Option<String>,
244+}
245+
246+/// `list_member_tokens`: owners only. The personal tokens of the
247+/// workspace's members and outside collaborators that could reach it:
248+/// fine-grained ones naming it (`status` to narrow them), and classic ones.
249+/// Returns `Outcome<Vec<MemberToken>>`.
250+#[derive(Debug, Serialize, Deserialize)]
251+pub struct ListMemberTokensArgs {
252+ pub actor: User,
253+ pub slug: String,
254+ /// `pending` for approval requests only.
255+ #[serde(default)]
256+ pub status: Option<TokenStatus>,
257+ /// `classic` or `fine_grained` only.
258+ #[serde(default)]
259+ pub kind: Option<TokenKind>,
260+}
261+
262+/// `review_token_request`: an owner approves or denies a fine-grained
263+/// token waiting for approval. Its owner is told. Returns
264+/// `Outcome<MemberToken>`.
265+#[derive(Debug, Serialize, Deserialize)]
266+pub struct ReviewTokenRequestArgs {
267+ pub actor: User,
268+ pub slug: String,
269+ pub id: String,
270+ pub approve: bool,
271+ #[serde(default)]
272+ pub reason: Option<String>,
273+ #[serde(default)]
274+ pub surface: Option<crate::audit::Surface>,
275+}
276+
277+/// `revoke_member_token`: an owner takes a member's token out of the
278+/// workspace. A fine-grained token naming it stops working; a classic one
279+/// keeps working everywhere else. Returns `Outcome<bool>`.
280+#[derive(Debug, Serialize, Deserialize)]
281+pub struct RevokeMemberTokenArgs {
282+ pub actor: User,
283+ pub slug: String,
284+ pub id: String,
285+ #[serde(default)]
286+ pub reason: Option<String>,
287+ #[serde(default)]
288+ pub surface: Option<crate::audit::Surface>,
289+}
290+
291+/// A fine-grained token's details, as listings show them.
292+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
293+#[serde(rename_all = "camelCase")]
294+pub struct FineGrainedDetails {
295+ /// The resource owner's slug; null for the person's own account.
296+ pub workspace: Option<String>,
297+ pub repository_selection: RepositorySelection,
298+ /// With `selected`: the repositories, as `owner/name`, that the viewer
299+ /// can see.
300+ #[serde(default)]
301+ pub repositories: Vec<String>,
302+ pub permissions: BTreeMap<String, Access>,
303+ pub status: TokenStatus,
304+ /// Why an owner denied or revoked it.
305+ #[serde(default)]
306+ pub review_reason: Option<String>,
307+}
308+
309+#[cfg(test)]
310+mod tests {
311+ use super::*;
312+
313+ #[test]
314+ fn lifetimes_are_checked_against_the_policy() {
315+ let day = 86_400_000;
316+ let open = TokenPolicy::default();
317+ assert!(open.lifetime_allowed(0, None));
318+ assert!(open.lifetime_allowed(0, Some(900 * day)));
319+ let capped = TokenPolicy { max_lifetime_days: Some(90), ..TokenPolicy::default() };
320+ assert!(capped.lifetime_allowed(0, Some(90 * day)));
321+ assert!(capped.lifetime_allowed(0, Some(90 * day + day / 2)), "a day's grace");
322+ assert!(!capped.lifetime_allowed(0, Some(92 * day)));
323+ assert!(!capped.lifetime_allowed(0, None), "a limit keeps out tokens that never expire");
324+ let no_forever = TokenPolicy { forbid_no_expiry: true, ..TokenPolicy::default() };
325+ assert!(!no_forever.lifetime_allowed(0, None));
326+ assert!(no_forever.lifetime_allowed(0, Some(900 * day)));
327+ }
328+
329+ #[test]
330+ fn refusals_name_the_rule() {
331+ let closed = TokenPolicy { allow_classic: false, allow_fine_grained: false, ..TokenPolicy::default() };
332+ assert!(closed.refusal("acme", TokenKind::FineGrained, Some(60)).unwrap().contains("fine-grained"));
333+ assert!(closed.refusal("acme", TokenKind::Classic, Some(60)).unwrap().contains("classic"));
334+ let capped = TokenPolicy { max_lifetime_days: Some(30), ..TokenPolicy::default() };
335+ assert!(capped.refusal("acme", TokenKind::FineGrained, Some(31 * 86_400 + 86_400)).unwrap().contains("30 days"));
336+ assert_eq!(capped.refusal("acme", TokenKind::FineGrained, Some(7 * 86_400)), None);
337+ assert!(TokenPolicy::default().require_approval, "fine-grained tokens wait for approval unless an owner says otherwise");
338+ }
339+
340+ #[test]
341+ fn kinds_and_statuses_read_as_words() {
342+ assert_eq!(serde_json::to_value(TokenKind::FineGrained).unwrap(), "fine_grained");
343+ for status in [TokenStatus::Active, TokenStatus::Pending, TokenStatus::Denied, TokenStatus::Revoked] {
344+ assert_eq!(TokenStatus::parse(status.as_str()), status);
345+ assert_eq!(serde_json::to_value(status).unwrap(), status.as_str());
346+ }
347+ }
348+}
+113−0
754754 if let Err(error) = resolve(db, events).await {
755755 worker::console_error!("inbox: agent threads not closed: {error}");
756756 }
757+ // A workspace's own notices, about no repository (workspace_notices).
758+ for event in events.iter().filter(|event| WORKSPACE_EVENTS.contains(&event.kind.as_str())) {
759+ if let Err(error) = deliver_workspace(db, event).await {
760+ worker::console_error!("inbox: {} {} not delivered: {error}", event.kind, event.id);
761+ }
762+ }
757763 let wanted: Vec<(&Event, Wanted)> = events
758764 .iter()
759765 .filter_map(|event| wants(event).map(|wanted| (event, wanted)))
801807 }
802808 }
803809
810+/// Events about a workspace rather than a repository, each naming who to
811+/// tell (`notify`), what to say (`title`, `body`) and where it is (`link`):
812+/// identity's personal access token approvals.
813+///
814+/// | Event | Who | Reason | Severity |
815+/// | --- | --- | --- | --- |
816+/// | `token.approval_requested` | the workspace's owners | review_requested | warning |
817+/// | `token.approval_reviewed` | the token's owner | author | info |
818+pub const WORKSPACE_EVENTS: [&str; 2] = ["token.approval_requested", "token.approval_reviewed"];
819+
820+/// The notices a workspace event calls for, and the thread they go to.
821+pub fn workspace_notices(event: &Event, actor: Option<&str>) -> (String, Vec<Notice>) {
822+ let data = &event.data;
823+ let text = |key: &str| data[key].as_str().unwrap_or_default().to_owned();
824+ let (reason, severity) = match event.kind.as_str() {
825+ "token.approval_requested" => (Reason::ReviewRequested, Severity::Warning),
826+ _ => (Reason::Author, Severity::Info),
827+ };
828+ let thread = format!("workspace:{}/token/{}", text("workspace"), text("tokenId"));
829+ let notices = names(data, "notify")
830+ .into_iter()
831+ .map(|name| name.to_lowercase())
832+ .filter(|name| actor.is_none_or(|actor| !actor.eq_ignore_ascii_case(name)) && !is_g1t(name))
833+ .collect::<HashSet<_>>()
834+ .into_iter()
835+ .map(|username| Notice { username, reason, severity, title: text("title"), body: text("body") })
836+ .collect();
837+ (thread, notices)
838+}
839+
840+/// Writes a workspace event's notices: a thread each, with no repository.
841+async fn deliver_workspace(db: &D1Database, event: &Event) -> Result<()> {
842+ let (thread, told) = workspace_notices(event, None);
843+ if told.is_empty() {
844+ return Ok(());
845+ }
846+ let now = now_ms();
847+ let workspace = event.data["workspace"].as_str().unwrap_or_default().to_lowercase();
848+ let link = event.data["link"].as_str().filter(|link| link.starts_with('/'));
849+ let mut statements = Vec::new();
850+ for notice in &told {
851+ let username = notice.username.as_str();
852+ statements.push(db.prepare(BUMP).bind(&[
853+ new_id("ntf", now).into(),
854+ username.into(),
855+ thread.as_str().into(),
856+ event.id.as_str().into(),
857+ event.kind.as_str().into(),
858+ notice.reason.as_str().into(),
859+ notice.severity.as_str().into(),
860+ notice.title.as_str().into(),
861+ notice.body.as_str().into(),
862+ workspace.as_str().into(),
863+ JsValue::NULL,
864+ JsValue::NULL,
865+ JsValue::NULL,
866+ JsValue::NULL,
867+ JsValue::NULL,
868+ link.map_or(JsValue::NULL, JsValue::from),
869+ JsValue::NULL,
870+ event.time.as_str().into(),
871+ new_id("ntf", now).into(),
872+ ])?);
873+ statements.push(
874+ db.prepare(
875+ "INSERT OR IGNORE INTO inbox_activity (id, item_id, username, event_id, event, reason, severity, title, body, actor, created_at)
876+ SELECT ?1, id, ?2, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10 FROM inbox_items WHERE username = ?2 AND thread = ?3",
877+ )
878+ .bind(&[
879+ new_id("ntf", now).into(),
880+ username.into(),
881+ thread.as_str().into(),
882+ event.id.as_str().into(),
883+ event.kind.as_str().into(),
884+ notice.reason.as_str().into(),
885+ notice.severity.as_str().into(),
886+ notice.title.as_str().into(),
887+ notice.body.as_str().into(),
888+ event.time.as_str().into(),
889+ ])?,
890+ );
891+ }
892+ db.batch(statements).await?;
893+ Ok(())
894+}
895+
804896 /// Closes what an agent was waiting on a person for once it is over.
805897 async fn resolve(db: &D1Database, events: &[Event]) -> Result<()> {
806898 let now = rfc3339(now_ms());
21722264 }
21732265 assert!(!BUMP.contains("?20"));
21742266 }
2267+
2268+ #[test]
2269+ fn token_approvals_tell_the_people_named_about_no_repository() {
2270+ let mut asked = event(
2271+ "token.approval_requested",
2272+ Some("usr_ana"),
2273+ json!({ "workspace": "acme", "tokenId": "tok_1", "notify": ["Bo", "cy", "bo", "g1t"], "title": "ana asks", "body": "ci: contents: write", "link": "/acme/-/settings/tokens" }),
2274+ );
2275+ asked.repo_id = None;
2276+ let (thread, told) = workspace_notices(&asked, None);
2277+ assert_eq!(thread, "workspace:acme/token/tok_1");
2278+ let mut names: Vec<&str> = told.iter().map(|notice| notice.username.as_str()).collect();
2279+ names.sort();
2280+ assert_eq!(names, ["bo", "cy"], "each once, lowercased, never g1t");
2281+ assert!(told.iter().all(|notice| notice.reason == Reason::ReviewRequested && notice.severity == Severity::Warning));
2282+ assert!(wants(&asked).is_none(), "about no repository");
2283+ let reviewed = event("token.approval_reviewed", None, json!({ "workspace": "acme", "tokenId": "tok_1", "notify": ["ana"], "title": "approved" }));
2284+ let (_, told) = workspace_notices(&reviewed, None);
2285+ assert_eq!(told[0].reason, Reason::Author);
2286+ assert!(WORKSPACE_EVENTS.contains(&"token.approval_reviewed"));
2287+ }
21752288 }
+73−0
1+-- Fine-grained personal access tokens, and each workspace's rules for the
2+-- tokens that reach it. This reverses 0023, which retired a token's reach
3+-- to some workspaces or repositories: the owner chose GitHub's model, with
4+-- classic tokens (reaching whatever their owner can, narrowed by scopes)
5+-- and fine-grained ones (one resource owner, some of its repositories, a
6+-- level for each permission) side by side. See src/token_reach.rs.
7+--
8+-- Nothing existing changes: every column is null on today's tokens, which
9+-- read as classic (personal) or as a workspace's token with Write.
10+
11+-- `fine_grained` for a fine-grained token; null for a classic one, a
12+-- workspace's own token, a job's token and an agent's.
13+ALTER TABLE access_tokens ADD COLUMN kind TEXT;
14+-- A fine-grained token's resource owner: a workspace's id, or null for the
15+-- person's own account.
16+ALTER TABLE access_tokens ADD COLUMN owner_workspace_id TEXT;
17+-- all | selected | public, for a fine-grained token.
18+ALTER TABLE access_tokens ADD COLUMN repository_selection TEXT;
19+-- A fine-grained token's permissions as JSON, `{"contents": "write"}`.
20+-- Its `scopes` are what they map to, and are what every check reads.
21+ALTER TABLE access_tokens ADD COLUMN permissions TEXT;
22+-- What it is for, as its owner wrote it.
23+ALTER TABLE access_tokens ADD COLUMN description TEXT;
24+-- A fine-grained token aimed at a workspace that asks for approval:
25+-- pending until an owner approves it, then active; denied or revoked by an
26+-- owner. Null is active. Only an active one reaches the workspace.
27+ALTER TABLE access_tokens ADD COLUMN status TEXT;
28+-- Who reviewed or revoked it, when, and why.
29+ALTER TABLE access_tokens ADD COLUMN reviewed_by TEXT;
30+ALTER TABLE access_tokens ADD COLUMN reviewed_at TEXT;
31+ALTER TABLE access_tokens ADD COLUMN review_reason TEXT;
32+-- 1 on a workspace's own token an owner gave Admin when making it. Every
33+-- other workspace token has Write on the workspace's repositories.
34+ALTER TABLE access_tokens ADD COLUMN admin INTEGER;
35+CREATE INDEX access_tokens_owner_workspace ON access_tokens (owner_workspace_id) WHERE owner_workspace_id IS NOT NULL;
36+
37+-- The repositories a fine-grained token with `selected` reaches, by id, so
38+-- renames and transfers within the workspace keep them.
39+CREATE TABLE token_repositories (
40+ token_id TEXT NOT NULL,
41+ repo_id TEXT NOT NULL,
42+ PRIMARY KEY (token_id, repo_id)
43+);
44+
45+-- A workspace's rules for personal access tokens. No row: the defaults
46+-- (both kinds allowed, fine-grained tokens need an owner's approval, no
47+-- lifetime limit beyond a fine-grained token's 366 days).
48+CREATE TABLE token_policies (
49+ workspace_id TEXT PRIMARY KEY,
50+ -- Whether classic tokens reach the workspace: 1 or 0.
51+ allow_classic INTEGER NOT NULL DEFAULT 1,
52+ -- Whether fine-grained tokens may name it as their resource owner.
53+ allow_fine_grained INTEGER NOT NULL DEFAULT 1,
54+ -- Whether a fine-grained token naming it waits for an owner's approval.
55+ require_approval INTEGER NOT NULL DEFAULT 1,
56+ -- The longest a token reaching it may last, in days; null: no limit.
57+ max_lifetime_days INTEGER,
58+ -- 1: a token that never expires does not reach it.
59+ forbid_no_expiry INTEGER NOT NULL DEFAULT 0,
60+ updated_by TEXT,
61+ updated_at TEXT
62+);
63+
64+-- A classic token an owner took out of their workspace: it keeps working
65+-- elsewhere, and never reaches this workspace again.
66+CREATE TABLE token_workspace_revocations (
67+ token_id TEXT NOT NULL,
68+ workspace_id TEXT NOT NULL,
69+ revoked_by TEXT,
70+ revoked_at TEXT NOT NULL,
71+ reason TEXT,
72+ PRIMARY KEY (token_id, workspace_id)
73+);
+9−0
2424 mod security;
2525 mod teams;
2626 mod throttle;
27+mod token_reach;
2728 mod tokens;
2829 mod workspaces;
2930
887888 "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?),
888889 "create_access_token" => reply(&identity.create_access_token(args(body)?).await?),
889890 "update_access_token" => reply(&identity.update_access_token(args(body)?).await?),
891+ // Fine-grained tokens and workspaces' rules for tokens; see token_reach.rs.
892+ "create_fine_grained_token" => reply(&identity.create_fine_grained_token(args(body)?).await?),
893+ "update_fine_grained_token" => reply(&identity.update_fine_grained_token(args(body)?).await?),
894+ "get_token_policy" => reply(&identity.get_token_policy(args(body)?).await?),
895+ "set_token_policy" => reply(&identity.set_token_policy(args(body)?).await?),
896+ "list_member_tokens" => reply(&identity.list_member_tokens(args(body)?).await?),
897+ "review_token_request" => reply(&identity.review_token_request(args(body)?).await?),
898+ "revoke_member_token" => reply(&identity.revoke_member_token(args(body)?).await?),
890899 "create_agent_token" => reply(&identity.create_agent_token(args(body)?).await?),
891900 "agent_scope" => reply(&identity.agent_scope(args(body)?).await?),
892901 "create_run_credential" => reply(&identity.create_run_credential(args(body)?).await?),
+11−3
199199 None
200200 }
201201
202−/// The workspace role the viewer has, counting a workspace's own token and
203−/// g1t acting in it as owners.
202+/// The workspace role the viewer has, counting g1t acting in it, and a
203+/// workspace's own token an owner gave Admin, as owners. Any other
204+/// workspace token is a member.
204205 fn role_of(viewer: &User, workspace: &str) -> Option<Role> {
205206 if matches!(viewer.kind, PrincipalKind::Workspace | PrincipalKind::System) && viewer.is_member(workspace) {
207+ if viewer.kind == PrincipalKind::Workspace && viewer.token.as_deref().is_some_and(|token| !token.admin) {
208+ return Some(Role::Member);
209+ }
206210 return Some(Role::Owner);
207211 }
208212 viewer.role_in(workspace)
16061610 workspaces: vec![g1t_contracts::Membership::member("acme")],
16071611 ..User::default()
16081612 };
1609− assert_eq!(role_of(&token, "acme"), Some(Role::Owner));
1613+ assert_eq!(role_of(&token, "acme"), Some(Role::Owner), "the workspace itself, with no token: a service");
16101614 assert_eq!(role_of(&token, "globex"), None);
1615+ let mut writer = User { token: Some(Box::new(g1t_contracts::scopes::TokenAccess::full())), ..token.clone() };
1616+ assert_eq!(role_of(&writer, "acme"), Some(Role::Member), "a workspace token is a member unless given Admin");
1617+ writer.token.as_mut().unwrap().admin = true;
1618+ assert_eq!(role_of(&writer, "acme"), Some(Role::Owner));
16111619 }
16121620 }
+1123−0
1+//! What a personal access token reaches, and the rules a workspace sets for
2+//! the tokens that reach it. See `g1t_contracts::tokens`.
3+//!
4+//! **Classic tokens** reach whatever their owner can, narrowed by their
5+//! scopes. **Fine-grained tokens** (migration 0034) name one resource owner
6+//! (their owner's own account, or one workspace), all, selected or only
7+//! public repositories of it, and a level for each permission, stored as
8+//! the scopes those give. Migration 0023 had retired a token's reach in
9+//! favour of classic tokens only; the owner chose GitHub's model instead,
10+//! with both kinds side by side.
11+//!
12+//! Each time a token is used, [`Identity::apply_reach`] cuts the person it
13+//! resolves to down to what it reaches: a fine-grained token keeps only its
14+//! resource owner's membership and grants (none while it waits for
15+//! approval), and any personal token loses the workspaces whose rules keep
16+//! it out (a kind they do not allow, a lifetime past their limit, or an
17+//! owner revoking it there). Services then decide as for anyone, and
18+//! `access::granted` holds a fine-grained token to its repositories.
19+//!
20+//! **Approval.** A fine-grained token naming a workspace that asks for
21+//! approval starts pending, unless its owner is an owner there. The
22+//! workspace's owners hear of it in their inbox (`token.approval_requested`)
23+//! and approve or deny it; its owner hears back
24+//! (`token.approval_reviewed`). Changing a token's repositories or
25+//! permissions asks again.
26+
27+use std::collections::{BTreeMap, HashMap, HashSet};
28+
29+use g1t_contracts::audit::Surface;
30+use g1t_contracts::events::{NewEvent, Publish};
31+use g1t_contracts::fine_grained::{self, Access, MAX_LIFETIME_DAYS};
32+use g1t_contracts::identity::{AccessToken, CreatedAccessToken};
33+use g1t_contracts::repos::RepoPath;
34+use g1t_contracts::scopes::{FineGrainedReach, RepositorySelection, scopes_text};
35+use g1t_contracts::time::{parse_rfc3339, rfc3339};
36+use g1t_contracts::tokens::*;
37+use g1t_contracts::{FailureCode, Outcome, Role, User, Viewer};
38+use g1t_kit::now_ms;
39+use serde::{Deserialize, Serialize};
40+use worker::Result;
41+use worker::wasm_bindgen::JsValue;
42+
43+use crate::Identity;
44+use crate::security::is_person;
45+use crate::tokens::{Grant, Owner};
46+
47+const FINE_GRAINED: &str = "fine_grained";
48+const DAY_SECONDS: u64 = 86_400;
49+
50+/// What a token row says about its reach, read with it when it is used.
51+/// Numbers arrive from D1 as floats.
52+#[derive(Clone, Debug, Default, Deserialize)]
53+pub(crate) struct Facts {
54+ #[serde(default)]
55+ created_at: Option<String>,
56+ #[serde(default)]
57+ expires_at: Option<String>,
58+ #[serde(default)]
59+ kind: Option<String>,
60+ #[serde(default)]
61+ owner_workspace_id: Option<String>,
62+ #[serde(default)]
63+ repository_selection: Option<String>,
64+ #[serde(default)]
65+ status: Option<String>,
66+ #[serde(default)]
67+ admin: Option<f64>,
68+}
69+
70+impl Facts {
71+ fn fine_grained(&self) -> bool {
72+ self.kind.as_deref() == Some(FINE_GRAINED)
73+ }
74+
75+ fn lifetime(&self) -> (u64, Option<u64>) {
76+ let created = self.created_at.as_deref().and_then(parse_rfc3339).unwrap_or(0);
77+ (created, self.expires_at.as_deref().and_then(parse_rfc3339))
78+ }
79+}
80+
81+/// What a listed token's row adds, for showing it.
82+#[derive(Clone, Debug, Default, Deserialize)]
83+pub(crate) struct TokenRowMore {
84+ #[serde(default)]
85+ kind: Option<String>,
86+ #[serde(default)]
87+ description: Option<String>,
88+ #[serde(default)]
89+ admin: Option<f64>,
90+ #[serde(default)]
91+ workspace_id: Option<String>,
92+ #[serde(default)]
93+ repository_selection: Option<String>,
94+ #[serde(default)]
95+ permissions: Option<String>,
96+ #[serde(default)]
97+ status: Option<String>,
98+ #[serde(default)]
99+ review_reason: Option<String>,
100+ #[serde(default)]
101+ owner_workspace: Option<String>,
102+}
103+
104+impl TokenRowMore {
105+ /// Fills in what it adds to a token's details. Selected repositories
106+ /// are named later, by [`Identity::name_repositories`].
107+ pub(crate) fn describe(&self, info: &mut AccessToken) {
108+ info.description = self.description.clone();
109+ info.admin = self.admin.is_some_and(|admin| admin >= 1.0);
110+ info.kind = if self.kind.as_deref() == Some(FINE_GRAINED) {
111+ TokenKind::FineGrained
112+ } else if self.workspace_id.is_some() {
113+ TokenKind::Workspace
114+ } else {
115+ TokenKind::Classic
116+ };
117+ if info.kind == TokenKind::FineGrained {
118+ info.fine_grained = Some(FineGrainedDetails {
119+ workspace: self.owner_workspace.clone(),
120+ repository_selection: self.repository_selection.as_deref().and_then(RepositorySelection::parse).unwrap_or_default(),
121+ repositories: Vec::new(),
122+ permissions: stored_permissions(self.permissions.as_deref()),
123+ status: TokenStatus::parse(self.status.as_deref().unwrap_or("active")),
124+ review_reason: self.review_reason.clone(),
125+ });
126+ }
127+ }
128+}
129+
130+/// A `permissions` column read back.
131+fn stored_permissions(text: Option<&str>) -> BTreeMap<String, Access> {
132+ text.and_then(|text| serde_json::from_str(text).ok()).unwrap_or_default()
133+}
134+
135+/// A workspace whose rules apply to a token, as read for it.
136+#[derive(Clone, Debug, Default, Deserialize)]
137+struct RuleRow {
138+ id: String,
139+ slug: String,
140+ #[serde(default)]
141+ allow_classic: Option<f64>,
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+
159+impl 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_classic: flag(self.allow_classic, true),
164+ allow_fine_grained: 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. `fine_grained` is whether the token is one aimed at this
176+/// workspace, with `status`.
177+pub(crate) fn blocked_by(
178+ policy: &TokenPolicy,
179+ fine_grained: 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 fine_grained {
189+ match status {
190+ TokenStatus::Pending => return Some("pending approval"),
191+ TokenStatus::Denied => return Some("denied"),
192+ _ => {}
193+ }
194+ if !policy.allow_fine_grained {
195+ return Some("fine-grained tokens not allowed");
196+ }
197+ } else if !policy.allow_classic {
198+ return Some("classic tokens 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+
206+/// `{"contents": "write"}`, as stored.
207+fn permissions_column(permissions: &fine_grained::Permissions) -> String {
208+ serde_json::to_string(permissions).unwrap_or_else(|_| "{}".to_owned())
209+}
210+
211+fn text(value: Option<&str>) -> JsValue {
212+ value.map_or(JsValue::NULL, JsValue::from)
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")]
219+struct 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)]
230+struct Owned {
231+ id: String,
232+ user_id: Option<String>,
233+ name: String,
234+ kind: Option<String>,
235+ owner_workspace_id: Option<String>,
236+ status: Option<String>,
237+}
238+
239+impl 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+ let Some(access) = user.token.as_deref_mut() else {
245+ return Ok(());
246+ };
247+ if !personal {
248+ // A workspace's own token: Write on its repositories, or Admin
249+ // when an owner gave it that.
250+ access.admin = facts.admin.is_some_and(|admin| admin >= 1.0);
251+ return Ok(());
252+ }
253+ let fine = facts.fine_grained();
254+ // The workspaces it could reach, with their rules for it.
255+ let mut slugs: Vec<String> = user.workspaces.iter().map(|membership| membership.slug.clone()).collect();
256+ slugs.extend(user.grants.iter().map(|grant| grant.workspace.to_lowercase()));
257+ slugs.sort();
258+ slugs.dedup();
259+ if slugs.is_empty() && !fine {
260+ return Ok(());
261+ }
262+ let rules = self.rules_for(&slugs, token_id).await?;
263+ let (created, expires) = facts.lifetime();
264+ let status = TokenStatus::parse(facts.status.as_deref().unwrap_or("active"));
265+ let mut keep: HashSet<String> = HashSet::new();
266+ let mut owner_slug: Option<String> = None;
267+ for rule in &rules {
268+ if fine && facts.owner_workspace_id.as_deref() != Some(rule.id.as_str()) {
269+ continue;
270+ }
271+ if fine {
272+ owner_slug = Some(rule.slug.clone());
273+ }
274+ let revoked = rule.revoked.is_some_and(|revoked| revoked >= 1.0);
275+ if blocked_by(&rule.policy(), fine, status, revoked, created, expires).is_none() {
276+ keep.insert(rule.slug.clone());
277+ }
278+ }
279+ // Workspaces without a rules row: the defaults, which let a classic
280+ // token in, and a fine-grained one once it is active.
281+ for slug in &slugs {
282+ if rules.iter().any(|rule| &rule.slug == slug) {
283+ continue;
284+ }
285+ if !fine && blocked_by(&TokenPolicy::default(), false, status, false, created, expires).is_none() {
286+ keep.insert(slug.clone());
287+ }
288+ }
289+ user.workspaces.retain(|membership| keep.contains(&membership.slug));
290+ user.grants.retain(|grant| keep.contains(&grant.workspace.to_lowercase()));
291+ if fine {
292+ let selection = facts.repository_selection.as_deref().and_then(RepositorySelection::parse).unwrap_or_default();
293+ let reaches = owner_slug.as_ref().is_some_and(|slug| keep.contains(slug));
294+ let repo_ids = if reaches && selection == RepositorySelection::Selected { self.token_repo_ids(token_id).await? } else { Vec::new() };
295+ if let Some(access) = user.token.as_deref_mut() {
296+ access.fine_grained = Some(FineGrainedReach {
297+ workspace: owner_slug,
298+ // Until it reaches its workspace, public repositories only.
299+ repositories: if reaches { selection } else { RepositorySelection::Public },
300+ repo_ids,
301+ });
302+ }
303+ }
304+ Ok(())
305+ }
306+
307+ /// The workspaces named by `slugs` that have rules, or that `token_id`
308+ /// was revoked in, with both. For a fine-grained token, its resource
309+ /// owner too, whatever its rules.
310+ async fn rules_for(&self, slugs: &[String], token_id: &str) -> Result<Vec<RuleRow>> {
311+ let mut binds: Vec<JsValue> = vec![token_id.into()];
312+ binds.extend(slugs.iter().map(|slug| JsValue::from(slug.as_str())));
313+ let marks = vec!["?"; slugs.len()].join(", ");
314+ let in_slugs = if slugs.is_empty() { "0".to_owned() } else { format!("w.slug IN ({marks})") };
315+ let sql = format!(
316+ "SELECT w.id, w.slug, p.allow_classic, p.allow_fine_grained, p.require_approval, p.max_lifetime_days,
317+ p.forbid_no_expiry, p.updated_by, p.updated_at,
318+ (SELECT 1 FROM token_workspace_revocations r WHERE r.token_id = ?1 AND r.workspace_id = w.id) AS revoked
319+ FROM workspaces w LEFT JOIN token_policies p ON p.workspace_id = w.id
320+ WHERE w.deleted_at IS NULL
321+ AND (({in_slugs}) AND (p.workspace_id IS NOT NULL
322+ OR EXISTS (SELECT 1 FROM token_workspace_revocations r WHERE r.token_id = ?1 AND r.workspace_id = w.id))
323+ OR w.id = (SELECT owner_workspace_id FROM access_tokens WHERE id = ?1))"
324+ );
325+ self.db.prepare(sql).bind(&binds)?.all().await?.results::<RuleRow>()
326+ }
327+
328+ async fn token_repo_ids(&self, token_id: &str) -> Result<Vec<String>> {
329+ #[derive(Deserialize)]
330+ struct Row {
331+ repo_id: String,
332+ }
333+ Ok(self
334+ .db
335+ .prepare("SELECT repo_id FROM token_repositories WHERE token_id = ? ORDER BY repo_id")
336+ .bind(&[token_id.into()])?
337+ .all()
338+ .await?
339+ .results::<Row>()?
340+ .into_iter()
341+ .map(|row| row.repo_id)
342+ .collect())
343+ }
344+
345+ /// A workspace's rules for tokens, by slug: the defaults when it has
346+ /// none. `None` when there is no such workspace.
347+ async fn policy_of(&self, slug: &str) -> Result<Option<(String, TokenPolicy)>> {
348+ let row = self
349+ .db
350+ .prepare(
351+ "SELECT w.id, w.slug, p.allow_classic, p.allow_fine_grained, p.require_approval, p.max_lifetime_days,
352+ p.forbid_no_expiry, p.updated_by, p.updated_at, NULL AS revoked
353+ FROM workspaces w LEFT JOIN token_policies p ON p.workspace_id = w.id
354+ WHERE w.slug = ? AND w.deleted_at IS NULL",
355+ )
356+ .bind(&[slug.to_lowercase().into()])?
357+ .first::<RuleRow>(None)
358+ .await?;
359+ Ok(row.map(|row| (row.id.clone(), row.policy())))
360+ }
361+
362+ // --- Fine-grained tokens --------------------------------------------------
363+
364+ /// Repositories as asked for (`owner/name`, or a name in `slug`), as
365+ /// the person can see them: their ids, or why one cannot be chosen.
366+ async fn chosen_repositories(&self, user: &User, slug: &str, names: &[String]) -> Result<std::result::Result<Vec<String>, String>> {
367+ if names.is_empty() {
368+ return Ok(Err("Choose at least one repository, or all repositories.".to_owned()));
369+ }
370+ if names.len() > MAX_SELECTED_REPOSITORIES {
371+ return Ok(Err(format!("A token can reach at most {MAX_SELECTED_REPOSITORIES} selected repositories.")));
372+ }
373+ let mut ids = Vec::new();
374+ for name in names {
375+ let name = name.trim().trim_start_matches('/');
376+ let (namespace, repo) = name.split_once('/').unwrap_or((slug, name));
377+ if !namespace.eq_ignore_ascii_case(slug) {
378+ return Ok(Err(format!("{name} is not a repository of {slug}, the token's resource owner.")));
379+ }
380+ let path = RepoPath { namespace: slug.to_owned(), name: repo.to_owned() };
381+ match self.repo_for(&path, &Some(user.clone())).await? {
382+ Some(found) => ids.push(found.id),
383+ None => return Ok(Err(format!("There is no repository {slug}/{repo} that you can see."))),
384+ }
385+ }
386+ ids.sort();
387+ ids.dedup();
388+ Ok(Ok(ids))
389+ }
390+
391+ /// Whether `user` owns the workspace `slug`.
392+ fn owns(user: &User, slug: &str) -> bool {
393+ user.role_in(&slug.to_lowercase()) == Some(Role::Owner)
394+ }
395+
396+ pub async fn create_fine_grained_token(&self, a: CreateFineGrainedTokenArgs) -> Result<Outcome<CreatedAccessToken>> {
397+ if !is_person(&a.user) || a.user.token.is_some() {
398+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person, signed in on g1t.sh, can make a personal access token."));
399+ }
400+ if !a.user.verified {
401+ return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address before making a token."));
402+ }
403+ if a.name.trim().is_empty() {
404+ return Ok(Outcome::fail(FailureCode::Invalid, "Name the token after what will use it."));
405+ }
406+ if a.ttl_seconds < DAY_SECONDS || a.ttl_seconds > u64::from(MAX_LIFETIME_DAYS) * DAY_SECONDS {
407+ return Ok(Outcome::fail(
408+ FailureCode::Invalid,
409+ format!("A fine-grained token lasts between 1 and {MAX_LIFETIME_DAYS} days."),
410+ ));
411+ }
412+ let slug = a.workspace.as_deref().map(|slug| slug.trim().to_lowercase()).filter(|slug| !slug.is_empty());
413+ let (permissions, scopes) = match fine_grained::resolve(&a.permissions, slug.is_some()) {
414+ Ok(resolved) => resolved,
415+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
416+ };
417+ if permissions.is_empty() {
418+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the token at least one permission."));
419+ }
420+ let mut status = TokenStatus::Active;
421+ let mut workspace_id = None;
422+ let mut repo_ids = Vec::new();
423+ let selection = if slug.is_some() { a.repository_selection } else { RepositorySelection::Public };
424+ if let Some(slug) = &slug {
425+ if !a.user.is_member(slug) {
426+ return Ok(Outcome::fail(FailureCode::Forbidden, format!("You can only aim a token at a workspace you belong to, and {slug} is not one.")));
427+ }
428+ let Some((id, policy)) = self.policy_of(slug).await? else {
429+ return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
430+ };
431+ if let Some(refusal) = policy.refusal(slug, TokenKind::FineGrained, Some(a.ttl_seconds)) {
432+ return Ok(Outcome::fail(FailureCode::Forbidden, refusal));
433+ }
434+ if policy.require_approval && !Self::owns(&a.user, slug) {
435+ status = TokenStatus::Pending;
436+ }
437+ if selection == RepositorySelection::Selected {
438+ repo_ids = match self.chosen_repositories(&a.user, slug, &a.repositories).await? {
439+ Ok(ids) => ids,
440+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
441+ };
442+ }
443+ workspace_id = Some(id);
444+ }
445+ let grant = Grant { scopes: Some(scopes.clone()) };
446+ let mut created = self.mint(Owner::User(&a.user.id), &a.name, Some(a.ttl_seconds), &grant, true).await?;
447+ let description = a.description.as_deref().map(str::trim).filter(|text| !text.is_empty()).map(|text| text.chars().take(500).collect::<String>());
448+ let mut statements = vec![self
449+ .db
450+ .prepare(
451+ "UPDATE access_tokens SET kind = ?, owner_workspace_id = ?, repository_selection = ?, permissions = ?,
452+ description = ?, status = ?
453+ WHERE id = ?",
454+ )
455+ .bind(&[
456+ FINE_GRAINED.into(),
457+ text(workspace_id.as_deref()),
458+ selection.as_str().into(),
459+ permissions_column(&permissions).into(),
460+ text(description.as_deref()),
461+ status.as_str().into(),
462+ created.info.id.as_str().into(),
463+ ])?];
464+ for repo_id in &repo_ids {
465+ statements.push(
466+ self.db
467+ .prepare("INSERT OR IGNORE INTO token_repositories (token_id, repo_id) VALUES (?, ?)")
468+ .bind(&[created.info.id.as_str().into(), repo_id.as_str().into()])?,
469+ );
470+ }
471+ self.db.batch(statements).await?;
472+ created.info.kind = TokenKind::FineGrained;
473+ created.info.description = description;
474+ created.info.fine_grained = Some(FineGrainedDetails {
475+ workspace: slug.clone(),
476+ repository_selection: selection,
477+ repositories: if repo_ids.is_empty() { Vec::new() } else { a.repositories.iter().map(|name| qualified(slug.as_deref(), name)).collect() },
478+ permissions,
479+ status,
480+ review_reason: None,
481+ });
482+ if let (Some(slug), TokenStatus::Pending) = (&slug, status) {
483+ self.ask_owners(&a.user, slug, &created.info).await?;
484+ }
485+ Ok(Outcome::Ok(created))
486+ }
487+
488+ pub async fn update_fine_grained_token(&self, a: UpdateFineGrainedTokenArgs) -> Result<Outcome<AccessToken>> {
489+ if !is_person(&a.user) || a.user.token.is_some() {
490+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only you, signed in on g1t.sh, can change your tokens."));
491+ }
492+ let Some(found) = self.owned_token(&a.id).await?.filter(|token| token.user_id.as_deref() == Some(a.user.id.as_str()) && token.kind.as_deref() == Some(FINE_GRAINED)) else {
493+ return Ok(Outcome::fail(FailureCode::NotFound, "No such token."));
494+ };
495+ let slug = match &found.owner_workspace_id {
496+ Some(id) => self.slug_of(id).await?,
497+ None => None,
498+ };
499+ let mut sets: Vec<(&str, JsValue)> = Vec::new();
500+ if let Some(name) = a.name.as_deref().map(str::trim).filter(|name| !name.is_empty()) {
501+ sets.push(("name", name.chars().take(100).collect::<String>().into()));
502+ }
503+ if let Some(description) = &a.description {
504+ let description = description.trim();
505+ sets.push(("description", if description.is_empty() { JsValue::NULL } else { description.chars().take(500).collect::<String>().into() }));
506+ }
507+ let mut widened = false;
508+ if let Some(asked) = &a.permissions {
509+ let (permissions, scopes) = match fine_grained::resolve(asked, found.owner_workspace_id.is_some()) {
510+ Ok(resolved) => resolved,
511+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
512+ };
513+ if permissions.is_empty() {
514+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the token at least one permission."));
515+ }
516+ sets.push(("permissions", permissions_column(&permissions).into()));
517+ sets.push(("scopes", scopes_text(&scopes).into()));
518+ widened = true;
519+ }
520+ let mut repo_ids: Option<Vec<String>> = None;
521+ if let Some(slug) = &slug {
522+ let selection = a.repository_selection;
523+ if let Some(selection) = selection {
524+ sets.push(("repository_selection", selection.as_str().into()));
525+ widened = true;
526+ }
527+ let selected = selection.unwrap_or_default() == RepositorySelection::Selected || (selection.is_none() && a.repositories.is_some());
528+ if selected {
529+ let names = a.repositories.clone().unwrap_or_default();
530+ repo_ids = Some(match self.chosen_repositories(&a.user, slug, &names).await? {
531+ Ok(ids) => ids,
532+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
533+ });
534+ widened = true;
535+ } else if selection.is_some() {
536+ repo_ids = Some(Vec::new());
537+ }
538+ }
539+ // Asking for more, of a workspace that approves tokens, asks again.
540+ let mut ask = false;
541+ if widened && let Some(slug) = &slug {
542+ let policy = self.policy_of(slug).await?.map(|(_, policy)| policy).unwrap_or_default();
543+ if policy.require_approval && !Self::owns(&a.user, slug) && found.status.as_deref() != Some("revoked") {
544+ sets.push(("status", TokenStatus::Pending.as_str().into()));
545+ ask = true;
546+ }
547+ }
548+ let mut statements = Vec::new();
549+ if !sets.is_empty() {
550+ let assignments: Vec<String> = sets.iter().map(|(column, _)| format!("{column} = ?")).collect();
551+ let mut binds: Vec<JsValue> = sets.into_iter().map(|(_, value)| value).collect();
552+ binds.push(a.id.as_str().into());
553+ statements.push(self.db.prepare(format!("UPDATE access_tokens SET {} WHERE id = ?", assignments.join(", "))).bind(&binds)?);
554+ }
555+ if let Some(ids) = &repo_ids {
556+ statements.push(self.db.prepare("DELETE FROM token_repositories WHERE token_id = ?").bind(&[a.id.as_str().into()])?);
557+ for repo_id in ids {
558+ statements.push(
559+ self.db
560+ .prepare("INSERT OR IGNORE INTO token_repositories (token_id, repo_id) VALUES (?, ?)")
561+ .bind(&[a.id.as_str().into(), repo_id.as_str().into()])?,
562+ );
563+ }
564+ }
565+ if !statements.is_empty() {
566+ self.db.batch(statements).await?;
567+ }
568+ let Some(mut info) = self.token_info(&a.id).await? else {
569+ return Ok(Outcome::fail(FailureCode::NotFound, "No such token."));
570+ };
571+ self.name_repositories(std::slice::from_mut(&mut info), &Some(a.user.clone())).await?;
572+ if ask && let Some(slug) = &slug {
573+ self.ask_owners(&a.user, slug, &info).await?;
574+ }
575+ Ok(Outcome::Ok(info))
576+ }
577+
578+ async fn owned_token(&self, id: &str) -> Result<Option<Owned>> {
579+ self.db
580+ .prepare("SELECT id, user_id, name, kind, owner_workspace_id, status FROM access_tokens WHERE id = ? AND agent_scope IS NULL")
581+ .bind(&[id.into()])?
582+ .first::<Owned>(None)
583+ .await
584+ }
585+
586+ async fn slug_of(&self, workspace_id: &str) -> Result<Option<String>> {
587+ self.db
588+ .prepare("SELECT slug FROM workspaces WHERE id = ? AND deleted_at IS NULL")
589+ .bind(&[workspace_id.into()])?
590+ .first::<String>(Some("slug"))
591+ .await
592+ }
593+
594+ /// One token's details, as listings show them.
595+ async fn token_info(&self, id: &str) -> Result<Option<AccessToken>> {
596+ let row = self
597+ .db
598+ .prepare(format!(
599+ "SELECT {} FROM access_tokens LEFT JOIN users ON users.id = access_tokens.created_by WHERE access_tokens.id = ?",
600+ crate::tokens::TOKEN_COLUMNS
601+ ))
602+ .bind(&[id.into()])?
603+ .first::<crate::tokens::TokenRow>(None)
604+ .await?;
605+ Ok(row.map(Identity::info))
606+ }
607+
608+ /// Names the selected repositories of fine-grained tokens, as `viewer`
609+ /// can see them.
610+ pub(crate) async fn name_repositories(&self, tokens: &mut [AccessToken], viewer: &Viewer) -> Result<()> {
611+ let selected: Vec<String> = tokens
612+ .iter()
613+ .filter(|token| token.fine_grained.as_ref().is_some_and(|details| details.repository_selection == RepositorySelection::Selected))
614+ .map(|token| token.id.clone())
615+ .collect();
616+ if selected.is_empty() {
617+ return Ok(());
618+ }
619+ #[derive(Deserialize)]
620+ struct Row {
621+ token_id: String,
622+ repo_id: String,
623+ }
624+ let marks = vec!["?"; selected.len()].join(", ");
625+ let binds: Vec<JsValue> = selected.iter().map(|id| JsValue::from(id.as_str())).collect();
626+ let rows = self
627+ .db
628+ .prepare(format!("SELECT token_id, repo_id FROM token_repositories WHERE token_id IN ({marks})"))
629+ .bind(&binds)?
630+ .all()
631+ .await?
632+ .results::<Row>()?;
633+ let ids: Vec<String> = rows.iter().map(|row| row.repo_id.clone()).collect::<HashSet<_>>().into_iter().collect();
634+ let readable: Vec<g1t_contracts::repos::Repo> = if ids.is_empty() {
635+ Vec::new()
636+ } else {
637+ g1t_kit::call(&self.env.service("REPOS")?, "readable", &g1t_contracts::repos::ReadableArgs { ids, viewer: viewer.clone() }).await?
638+ };
639+ let names: HashMap<&str, String> = readable.iter().map(|repo| (repo.id.as_str(), format!("{}/{}", repo.namespace, repo.name))).collect();
640+ for token in tokens.iter_mut() {
641+ if let Some(details) = token.fine_grained.as_mut() {
642+ details.repositories = rows
643+ .iter()
644+ .filter(|row| row.token_id == token.id)
645+ .filter_map(|row| names.get(row.repo_id.as_str()).cloned())
646+ .collect();
647+ details.repositories.sort();
648+ }
649+ }
650+ Ok(())
651+ }
652+
653+ // --- A workspace's rules ----------------------------------------------------
654+
655+ pub async fn get_token_policy(&self, a: GetTokenPolicyArgs) -> Result<Outcome<TokenPolicy>> {
656+ let slug = a.slug.to_lowercase();
657+ if !a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&slug)) {
658+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's rules for tokens."));
659+ }
660+ Ok(match self.policy_of(&slug).await? {
661+ Some((_, policy)) => Outcome::Ok(policy),
662+ None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
663+ })
664+ }
665+
666+ /// The workspace's id, if `actor` is a person who owns it.
667+ async fn owner_of(&self, actor: &User, slug: &str) -> Result<Outcome<String>> {
668+ let slug = slug.to_lowercase();
669+ if !is_person(actor) || !Self::owns(actor, &slug) {
670+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner of the workspace can manage its personal access tokens."));
671+ }
672+ Ok(match self.policy_of(&slug).await? {
673+ Some((id, _)) => Outcome::Ok(id),
674+ None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
675+ })
676+ }
677+
678+ pub async fn set_token_policy(&self, a: SetTokenPolicyArgs) -> Result<Outcome<TokenPolicy>> {
679+ let slug = a.slug.to_lowercase();
680+ let workspace_id = match self.owner_of(&a.actor, &slug).await? {
681+ Outcome::Ok(id) => id,
682+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
683+ };
684+ let current = self.policy_of(&slug).await?.map(|(_, policy)| policy).unwrap_or_default();
685+ if a.max_lifetime_days.is_some_and(|days| days > 3650) {
686+ return Ok(Outcome::fail(FailureCode::Invalid, "The longest lifetime a workspace can set is 3650 days; leave it empty for no limit."));
687+ }
688+ let policy = TokenPolicy {
689+ allow_classic: a.allow_classic.unwrap_or(current.allow_classic),
690+ allow_fine_grained: a.allow_fine_grained.unwrap_or(current.allow_fine_grained),
691+ require_approval: a.require_approval.unwrap_or(current.require_approval),
692+ max_lifetime_days: match a.max_lifetime_days {
693+ Some(0) => None,
694+ Some(days) => Some(days),
695+ None => current.max_lifetime_days,
696+ },
697+ forbid_no_expiry: a.forbid_no_expiry.unwrap_or(current.forbid_no_expiry),
698+ updated_by: Some(a.actor.username.clone()),
699+ updated_at: Some(rfc3339(now_ms())),
700+ };
701+ self.db
702+ .prepare(
703+ "INSERT INTO token_policies (workspace_id, allow_classic, allow_fine_grained, require_approval, max_lifetime_days,
704+ forbid_no_expiry, updated_by, updated_at)
705+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
706+ ON CONFLICT (workspace_id) DO UPDATE SET allow_classic = ?2, allow_fine_grained = ?3, require_approval = ?4,
707+ max_lifetime_days = ?5, forbid_no_expiry = ?6, updated_by = ?7, updated_at = ?8",
708+ )
709+ .bind(&[
710+ workspace_id.as_str().into(),
711+ JsValue::from(u8::from(policy.allow_classic)),
712+ JsValue::from(u8::from(policy.allow_fine_grained)),
713+ JsValue::from(u8::from(policy.require_approval)),
714+ policy.max_lifetime_days.map_or(JsValue::NULL, JsValue::from),
715+ JsValue::from(u8::from(policy.forbid_no_expiry)),
716+ a.actor.username.as_str().into(),
717+ text(policy.updated_at.as_deref()),
718+ ])?
719+ .run()
720+ .await?;
721+ self.audit_workspace(&a.actor, "token.policy_changed", &slug, a.surface.unwrap_or(Surface::Web), describe_policy(&policy)).await;
722+ Ok(Outcome::Ok(policy))
723+ }
724+
725+ // --- Members' tokens ------------------------------------------------------
726+
727+ pub async fn list_member_tokens(&self, a: ListMemberTokensArgs) -> Result<Outcome<Vec<MemberToken>>> {
728+ let slug = a.slug.to_lowercase();
729+ let workspace_id = match self.owner_of(&a.actor, &slug).await? {
730+ Outcome::Ok(id) => id,
731+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
732+ };
733+ let policy = self.policy_of(&slug).await?.map(|(_, policy)| policy).unwrap_or_default();
734+ #[derive(Deserialize)]
735+ struct Row {
736+ owner: String,
737+ #[serde(default)]
738+ revoked: Option<f64>,
739+ #[serde(flatten)]
740+ token: crate::tokens::TokenRow,
741+ }
742+ // Fine-grained tokens naming the workspace, and the classic tokens of
743+ // its members and outside collaborators that have not expired.
744+ let rows = self
745+ .db
746+ .prepare(format!(
747+ "SELECT owners.username AS owner,
748+ (SELECT 1 FROM token_workspace_revocations r WHERE r.token_id = access_tokens.id AND r.workspace_id = ?1) AS revoked,
749+ {}
750+ FROM access_tokens
751+ JOIN users owners ON owners.id = access_tokens.user_id
752+ LEFT JOIN users ON users.id = access_tokens.created_by
753+ WHERE access_tokens.agent_scope IS NULL AND access_tokens.workspace_id IS NULL
754+ AND (access_tokens.expires_at IS NULL OR access_tokens.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
755+ AND (
756+ (access_tokens.kind = 'fine_grained' AND access_tokens.owner_workspace_id = ?1)
757+ OR (access_tokens.kind IS NULL AND (access_tokens.expires_at IS NULL OR access_tokens.listed = 1)
758+ AND (access_tokens.user_id IN (SELECT user_id FROM workspace_members WHERE workspace_id = ?1)
759+ OR access_tokens.user_id IN (SELECT principal_id FROM repo_grants WHERE workspace_id = ?1 AND principal_kind = 'user')))
760+ )
761+ ORDER BY access_tokens.id DESC
762+ LIMIT 500",
763+ crate::tokens::TOKEN_COLUMNS
764+ ))
765+ .bind(&[workspace_id.as_str().into()])?
766+ .all()
767+ .await?
768+ .results::<Row>()?;
769+ let mut listed: Vec<MemberToken> = Vec::new();
770+ let mut infos: Vec<AccessToken> = Vec::new();
771+ let mut owners: Vec<(String, bool)> = Vec::new();
772+ for row in rows {
773+ owners.push((row.owner, row.revoked.is_some_and(|revoked| revoked >= 1.0)));
774+ infos.push(Identity::info(row.token));
775+ }
776+ self.name_repositories(&mut infos, &Some(a.actor.clone())).await?;
777+ for ((owner, revoked), token) in owners.into_iter().zip(infos) {
778+ let fine = token.kind == TokenKind::FineGrained;
779+ let status = token.fine_grained.as_ref().map_or(TokenStatus::Active, |details| details.status);
780+ if a.status.is_some_and(|wanted| wanted != status) || a.kind.is_some_and(|kind| kind != token.kind) {
781+ continue;
782+ }
783+ let created = parse_rfc3339(&token.created_at).unwrap_or(0);
784+ let expires = token.expires_at.as_deref().and_then(parse_rfc3339);
785+ let blocked = blocked_by(&policy, fine, status, revoked, created, expires);
786+ listed.push(MemberToken { owner, token, reaches: blocked.is_none(), blocked_by: blocked.map(str::to_owned) });
787+ }
788+ Ok(Outcome::Ok(listed))
789+ }
790+
791+ /// One member token, as `list_member_tokens` shows it.
792+ async fn member_token(&self, actor: &User, slug: &str, id: &str) -> Result<Option<MemberToken>> {
793+ let listed = self
794+ .list_member_tokens(ListMemberTokensArgs { actor: actor.clone(), slug: slug.to_owned(), status: None, kind: None })
795+ .await?;
796+ Ok(match listed {
797+ Outcome::Ok(tokens) => tokens.into_iter().find(|member| member.token.id == id),
798+ Outcome::Fail(_) => None,
799+ })
800+ }
801+
802+ pub async fn review_token_request(&self, a: ReviewTokenRequestArgs) -> Result<Outcome<MemberToken>> {
803+ let slug = a.slug.to_lowercase();
804+ let workspace_id = match self.owner_of(&a.actor, &slug).await? {
805+ Outcome::Ok(id) => id,
806+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
807+ };
808+ let Some(found) = self.owned_token(&a.id).await?.filter(|token| token.owner_workspace_id.as_deref() == Some(workspace_id.as_str())) else {
809+ return Ok(Outcome::fail(FailureCode::NotFound, "There is no such token request in this workspace."));
810+ };
811+ if found.status.as_deref() != Some("pending") {
812+ return Ok(Outcome::fail(FailureCode::Conflict, "This token is not waiting for approval."));
813+ }
814+ let status = if a.approve { TokenStatus::Active } else { TokenStatus::Denied };
815+ let reason = a.reason.as_deref().map(str::trim).filter(|reason| !reason.is_empty()).map(|reason| reason.chars().take(500).collect::<String>());
816+ self.db
817+ .prepare("UPDATE access_tokens SET status = ?, reviewed_by = ?, reviewed_at = ?, review_reason = ? WHERE id = ? AND status = 'pending'")
818+ .bind(&[
819+ status.as_str().into(),
820+ a.actor.id.as_str().into(),
821+ rfc3339(now_ms()).into(),
822+ text(reason.as_deref()),
823+ a.id.as_str().into(),
824+ ])?
825+ .run()
826+ .await?;
827+ let owner = match &found.user_id {
828+ Some(id) => self.usernames_of(std::slice::from_ref(id)).await?.into_iter().next(),
829+ None => None,
830+ };
831+ let verdict = if a.approve { "approved" } else { "denied" };
832+ self.audit_workspace(
833+ &a.actor,
834+ if a.approve { "token.approved" } else { "token.denied" },
835+ &slug,
836+ a.surface.unwrap_or(Surface::Web),
837+ format!(
838+ "{} the fine-grained token {} of {}{}",
839+ if a.approve { "Approved" } else { "Denied" },
840+ found.name,
841+ owner.as_deref().unwrap_or("a former member"),
842+ reason.as_deref().map(|reason| format!(": {reason}")).unwrap_or_default()
843+ ),
844+ )
845+ .await;
846+ if let Some(owner) = &owner {
847+ self.notify(
848+ "token.approval_reviewed",
849+ &a.actor,
850+ TokenNotice {
851+ workspace: &slug,
852+ token_id: &found.id,
853+ token_name: &found.name,
854+ notify: vec![owner.clone()],
855+ title: format!("Your token {} was {verdict} for {slug}", found.name),
856+ body: reason.clone().unwrap_or_else(|| {
857+ if a.approve { "It now reaches the workspace.".to_owned() } else { "It reaches public repositories only.".to_owned() }
858+ }),
859+ link: "/settings/tokens".to_owned(),
860+ },
861+ )
862+ .await;
863+ }
864+ Ok(match self.member_token(&a.actor, &slug, &a.id).await? {
865+ Some(member) => Outcome::Ok(member),
866+ None => Outcome::fail(FailureCode::NotFound, "There is no such token request in this workspace."),
867+ })
868+ }
869+
870+ pub async fn revoke_member_token(&self, a: RevokeMemberTokenArgs) -> Result<Outcome<bool>> {
871+ let slug = a.slug.to_lowercase();
872+ let workspace_id = match self.owner_of(&a.actor, &slug).await? {
873+ Outcome::Ok(id) => id,
874+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
875+ };
876+ let Some(member) = self.member_token(&a.actor, &slug, &a.id).await? else {
877+ return Ok(Outcome::fail(FailureCode::NotFound, "No token of a member reaches this workspace with that id."));
878+ };
879+ let reason = a.reason.as_deref().map(str::trim).filter(|reason| !reason.is_empty()).map(|reason| reason.chars().take(500).collect::<String>());
880+ let now = rfc3339(now_ms());
881+ if member.token.kind == TokenKind::FineGrained {
882+ self.db
883+ .prepare("UPDATE access_tokens SET status = 'revoked', reviewed_by = ?, reviewed_at = ?, review_reason = ? WHERE id = ? AND owner_workspace_id = ?")
884+ .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()])?
885+ .run()
886+ .await?;
887+ } else {
888+ self.db
889+ .prepare(
890+ "INSERT INTO token_workspace_revocations (token_id, workspace_id, revoked_by, revoked_at, reason) VALUES (?, ?, ?, ?, ?)
891+ ON CONFLICT (token_id, workspace_id) DO NOTHING",
892+ )
893+ .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())])?
894+ .run()
895+ .await?;
896+ }
897+ self.audit_workspace(
898+ &a.actor,
899+ "token.revoked",
900+ &slug,
901+ a.surface.unwrap_or(Surface::Web),
902+ format!(
903+ "Revoked {} {} token {} in {slug}{}",
904+ member.owner,
905+ if member.token.kind == TokenKind::FineGrained { "fine-grained" } else { "classic" },
906+ member.token.name,
907+ reason.as_deref().map(|reason| format!(": {reason}")).unwrap_or_default()
908+ ),
909+ )
910+ .await;
911+ self.notify(
912+ "token.approval_reviewed",
913+ &a.actor,
914+ TokenNotice {
915+ workspace: &slug,
916+ token_id: &a.id,
917+ token_name: &member.token.name,
918+ notify: vec![member.owner.clone()],
919+ title: format!("Your token {} was revoked for {slug}", member.token.name),
920+ body: reason.unwrap_or_else(|| "An owner of the workspace revoked it there.".to_owned()),
921+ link: "/settings/tokens".to_owned(),
922+ },
923+ )
924+ .await;
925+ Ok(Outcome::Ok(true))
926+ }
927+
928+ // --- Telling people -------------------------------------------------------
929+
930+ /// Tells the workspace's owners that `requester`'s token waits for them.
931+ async fn ask_owners(&self, requester: &User, slug: &str, token: &AccessToken) -> Result<()> {
932+ #[derive(Deserialize)]
933+ struct Row {
934+ username: String,
935+ }
936+ let owners: Vec<String> = self
937+ .db
938+ .prepare(
939+ "SELECT users.username FROM workspace_members
940+ JOIN workspaces ON workspaces.id = workspace_members.workspace_id
941+ JOIN users ON users.id = workspace_members.user_id
942+ WHERE workspaces.slug = ? AND workspace_members.role = 'owner'",
943+ )
944+ .bind(&[slug.into()])?
945+ .all()
946+ .await?
947+ .results::<Row>()?
948+ .into_iter()
949+ .map(|row| row.username)
950+ .collect();
951+ self.audit_workspace(
952+ requester,
953+ "token.approval_requested",
954+ slug,
955+ Surface::Web,
956+ format!("Asked for approval of the fine-grained token {}", token.name),
957+ )
958+ .await;
959+ if owners.is_empty() {
960+ return Ok(());
961+ }
962+ let permissions = token
963+ .fine_grained
964+ .as_ref()
965+ .map(|details| details.permissions.iter().map(|(name, access)| format!("{name}: {}", access.as_str())).collect::<Vec<_>>().join(", "))
966+ .unwrap_or_default();
967+ self.notify(
968+ "token.approval_requested",
969+ requester,
970+ TokenNotice {
971+ workspace: slug,
972+ token_id: &token.id,
973+ token_name: &token.name,
974+ notify: owners,
975+ title: format!("{} asks to use a fine-grained token in {slug}", requester.username),
976+ body: format!("{}: {permissions}", token.name),
977+ link: format!("/{slug}/-/settings/tokens"),
978+ },
979+ )
980+ .await;
981+ Ok(())
982+ }
983+
984+ async fn notify(&self, kind: &'static str, actor: &User, notice: TokenNotice<'_>) {
985+ let Ok(events) = self.env.service("EVENTS") else {
986+ return;
987+ };
988+ let publish = Publish {
989+ events: vec![NewEvent { kind, source: "identity", repo_id: None, actor: Some(actor.id.clone()), data: notice }],
990+ };
991+ if let Err(error) = g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await {
992+ worker::console_error!("{kind} not published: {error}");
993+ }
994+ }
995+
996+ async fn usernames_of(&self, ids: &[String]) -> Result<Vec<String>> {
997+ #[derive(Deserialize)]
998+ struct Row {
999+ username: String,
1000+ }
1001+ if ids.is_empty() {
1002+ return Ok(Vec::new());
1003+ }
1004+ let marks = vec!["?"; ids.len()].join(", ");
1005+ let binds: Vec<JsValue> = ids.iter().map(|id| JsValue::from(id.as_str())).collect();
1006+ Ok(self
1007+ .db
1008+ .prepare(format!("SELECT username FROM users WHERE id IN ({marks})"))
1009+ .bind(&binds)?
1010+ .all()
1011+ .await?
1012+ .results::<Row>()?
1013+ .into_iter()
1014+ .map(|row| row.username)
1015+ .collect())
1016+ }
1017+}
1018+
1019+/// `owner/name` for a repository asked for by name in `slug`.
1020+fn qualified(slug: Option<&str>, name: &str) -> String {
1021+ let name = name.trim().trim_start_matches('/');
1022+ match (name.contains('/'), slug) {
1023+ (false, Some(slug)) => format!("{slug}/{name}"),
1024+ _ => name.to_lowercase(),
1025+ }
1026+}
1027+
1028+/// The policy in a sentence, for the audit log.
1029+fn describe_policy(policy: &TokenPolicy) -> String {
1030+ let yes = |on: bool| if on { "allowed" } else { "not allowed" };
1031+ format!(
1032+ "Classic tokens {}; fine-grained tokens {}{}; lifetime {}{}",
1033+ yes(policy.allow_classic),
1034+ yes(policy.allow_fine_grained),
1035+ if policy.require_approval { ", with approval" } else { ", without approval" },
1036+ policy.max_lifetime_days.map_or_else(|| "unlimited".to_owned(), |days| format!("at most {days} days")),
1037+ if policy.forbid_no_expiry { "; tokens must expire" } else { "" },
1038+ )
1039+}
1040+
1041+#[cfg(test)]
1042+mod tests {
1043+ use super::*;
1044+ use g1t_contracts::scopes::Scope;
1045+
1046+ const DAY: u64 = 86_400_000;
1047+
1048+ #[test]
1049+ fn classic_tokens_follow_the_workspace_rules() {
1050+ let open = TokenPolicy::default();
1051+ assert_eq!(blocked_by(&open, false, TokenStatus::Active, false, 0, None), None);
1052+ let closed = TokenPolicy { allow_classic: false, ..TokenPolicy::default() };
1053+ assert_eq!(blocked_by(&closed, false, TokenStatus::Active, false, 0, Some(DAY)), Some("classic tokens not allowed"));
1054+ let capped = TokenPolicy { max_lifetime_days: Some(30), ..TokenPolicy::default() };
1055+ assert_eq!(blocked_by(&capped, false, TokenStatus::Active, false, 0, Some(90 * DAY)), Some("lasts too long"));
1056+ assert_eq!(blocked_by(&capped, false, TokenStatus::Active, false, 0, None), Some("never expires"));
1057+ assert_eq!(blocked_by(&capped, false, TokenStatus::Active, false, 0, Some(7 * DAY)), None);
1058+ assert_eq!(blocked_by(&open, false, TokenStatus::Active, true, 0, None), Some("revoked"));
1059+ }
1060+
1061+ #[test]
1062+ fn fine_grained_tokens_reach_once_active_and_allowed() {
1063+ let open = TokenPolicy::default();
1064+ assert_eq!(blocked_by(&open, true, TokenStatus::Pending, false, 0, Some(DAY)), Some("pending approval"));
1065+ assert_eq!(blocked_by(&open, true, TokenStatus::Denied, false, 0, Some(DAY)), Some("denied"));
1066+ assert_eq!(blocked_by(&open, true, TokenStatus::Revoked, false, 0, Some(DAY)), Some("revoked"));
1067+ assert_eq!(blocked_by(&open, true, TokenStatus::Active, false, 0, Some(DAY)), None);
1068+ let closed = TokenPolicy { allow_fine_grained: false, ..TokenPolicy::default() };
1069+ assert_eq!(blocked_by(&closed, true, TokenStatus::Active, false, 0, Some(DAY)), Some("fine-grained tokens not allowed"));
1070+ // Classic tokens being off does not touch fine-grained ones.
1071+ let no_classic = TokenPolicy { allow_classic: false, ..TokenPolicy::default() };
1072+ assert_eq!(blocked_by(&no_classic, true, TokenStatus::Active, false, 0, Some(DAY)), None);
1073+ }
1074+
1075+ #[test]
1076+ fn rules_rows_read_with_defaults() {
1077+ let row = RuleRow { id: "wsp_1".into(), slug: "acme".into(), ..RuleRow::default() };
1078+ assert_eq!(row.policy(), TokenPolicy::default());
1079+ 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 };
1080+ let policy = set.policy();
1081+ assert!(!policy.allow_classic && policy.allow_fine_grained && !policy.require_approval && policy.forbid_no_expiry);
1082+ assert_eq!(policy.max_lifetime_days, Some(90));
1083+ }
1084+
1085+ #[test]
1086+ fn a_listed_row_describes_a_fine_grained_token() {
1087+ let more = TokenRowMore {
1088+ kind: Some(FINE_GRAINED.into()),
1089+ repository_selection: Some("selected".into()),
1090+ permissions: Some(r#"{"contents":"write","metadata":"read"}"#.into()),
1091+ status: Some("pending".into()),
1092+ owner_workspace: Some("acme".into()),
1093+ ..TokenRowMore::default()
1094+ };
1095+ let mut info = AccessToken::default();
1096+ more.describe(&mut info);
1097+ assert_eq!(info.kind, TokenKind::FineGrained);
1098+ let details = info.fine_grained.unwrap();
1099+ assert_eq!(details.workspace.as_deref(), Some("acme"));
1100+ assert_eq!(details.repository_selection, RepositorySelection::Selected);
1101+ assert_eq!(details.status, TokenStatus::Pending);
1102+ assert_eq!(details.permissions.get("contents"), Some(&Access::Write));
1103+ let mut workspace = AccessToken::default();
1104+ TokenRowMore { workspace_id: Some("wsp_1".into()), admin: Some(1.0), ..TokenRowMore::default() }.describe(&mut workspace);
1105+ assert_eq!(workspace.kind, TokenKind::Workspace);
1106+ assert!(workspace.admin && workspace.fine_grained.is_none());
1107+ }
1108+
1109+ #[test]
1110+ fn repositories_are_named_in_the_resource_owner() {
1111+ assert_eq!(qualified(Some("acme"), "web"), "acme/web");
1112+ assert_eq!(qualified(Some("acme"), "Acme/Web"), "acme/web");
1113+ assert!(describe_policy(&TokenPolicy::default()).contains("with approval"));
1114+ }
1115+
1116+ #[test]
1117+ fn scopes_are_kept_for_every_check() {
1118+ let asked: BTreeMap<String, String> = [("contents".to_owned(), "read".to_owned())].into();
1119+ let (_, scopes) = fine_grained::resolve(&asked, true).unwrap();
1120+ assert_eq!(scopes_text(&scopes), "repo:read code:read");
1121+ assert!(scopes.contains(&Scope::CodeRead));
1122+ }
1123+}
+47−12
99 use g1t_contracts::identity::*;
1010 use g1t_contracts::scopes::{FULL_ACCESS, JobToken, Scope, TokenAccess, parse_scopes, scopes_text};
1111 use g1t_contracts::time::{SQL_NOW, rfc3339};
12+use g1t_contracts::tokens::TokenKind;
1213 use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, Role, User, Viewer, new_id};
1314 use g1t_kit::now_ms;
1415 use serde::Deserialize;
2324 const MAX_TOKENS_PER_WORKSPACE: usize = 50;
2425 const WORKSPACE_ID_PREFIX: &str = "wsp_";
2526
26−const TOKEN_COLUMNS: &str = "access_tokens.id, access_tokens.name, access_tokens.created_at,
27+pub(crate) const TOKEN_COLUMNS: &str = "access_tokens.id, access_tokens.name, access_tokens.created_at,
2728 access_tokens.last_used_at, users.username AS created_by, access_tokens.scopes,
28− access_tokens.expires_at";
29+ access_tokens.expires_at, access_tokens.kind, access_tokens.description, access_tokens.admin,
30+ access_tokens.workspace_id, access_tokens.repository_selection, access_tokens.permissions,
31+ access_tokens.status, access_tokens.review_reason,
32+ (SELECT slug FROM workspaces WHERE workspaces.id = access_tokens.owner_workspace_id) AS owner_workspace";
2933
3034 /// Who a new token belongs to.
31−enum Owner<'a> {
35+pub(crate) enum Owner<'a> {
3236 User(&'a str),
3337 Workspace {
3438 id: &'a str,
3741 }
3842
3943 #[derive(Deserialize)]
40−struct TokenRow {
44+pub(crate) struct TokenRow {
4145 id: String,
4246 name: String,
4347 created_at: String,
4549 created_by: Option<String>,
4650 scopes: Option<String>,
4751 expires_at: Option<String>,
52+ /// What a fine-grained token, and a workspace's, add (migration 0034;
53+ /// see token_reach.rs).
54+ #[serde(flatten)]
55+ pub(crate) more: crate::token_reach::TokenRowMore,
4856 }
4957
5058 /// What a token or grant may do, as it is to be stored. A token reaches
112120 job_run_id: Option<String>,
113121 #[serde(default)]
114122 job_pulls: Option<u32>,
123+ /// A fine-grained token's resource owner and status, a workspace
124+ /// token's Admin, and when it was made and expires, for the rules of
125+ /// the workspaces it reaches (token_reach.rs).
126+ #[serde(flatten)]
127+ facts: crate::token_reach::Facts,
115128 }
116129
117130 #[derive(Deserialize)]
133146 .db
134147 .prepare(format!(
135148 "SELECT id, user_id, workspace_id, last_used_at, agent_scope, scopes, name,
136− repo, job_id, job_run_id, job_pulls
149+ repo, job_id, job_run_id, job_pulls, created_at, expires_at, kind,
150+ owner_workspace_id, repository_selection, status, admin
137151 FROM access_tokens
138152 WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > {SQL_NOW})"
139153 ))
189203 }),
190204 _ => None,
191205 },
206+ ..TokenAccess::default()
192207 }));
208+ // What it reaches: a fine-grained token's resource owner and
209+ // repositories, the workspaces whose rules let it in, a
210+ // workspace token's role.
211+ self.apply_reach(user, &presented.id, presented.user_id.is_some(), &presented.facts).await?;
193212 }
194213 Ok(viewer)
195214 }
196215
197− fn info(row: TokenRow) -> AccessToken {
216+ pub(crate) fn info(row: TokenRow) -> AccessToken {
198217 let (scopes, legacy) = stored_scopes(row.scopes.as_deref());
199− AccessToken {
218+ let mut info = AccessToken {
200219 id: row.id,
201220 name: row.name,
202221 created_at: row.created_at,
205224 scopes,
206225 legacy,
207226 expires_at: row.expires_at,
208− }
227+ ..AccessToken::default()
228+ };
229+ row.more.describe(&mut info);
230+ info
209231 }
210232
211233 /// A workspace as the actor behind one of its own tokens. It can do
246268 Ok(())
247269 }
248270
249− async fn mint(
271+ pub(crate) async fn mint(
250272 &self,
251273 owner: Owner<'_>,
252274 name: &str,
277299 .map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
278300 legacy: false,
279301 expires_at: expires_at.clone(),
302+ kind: if workspace_id.is_some() { TokenKind::Workspace } else { TokenKind::Classic },
303+ ..AccessToken::default()
280304 };
281305 self.db
282306 .prepare(
348372 .db
349373 .prepare(
350374 "UPDATE access_tokens SET scopes = ?
351− WHERE id = ? AND user_id = ? AND agent_scope IS NULL
375+ WHERE id = ? AND user_id = ? AND agent_scope IS NULL AND kind IS NULL
352376 RETURNING id",
353377 )
354378 .bind(&[
418442 }
419443
420444 pub async fn list_access_tokens(&self, a: UserArgs) -> Result<Vec<AccessToken>> {
421− self.tokens_where("access_tokens.user_id = ?", &a.user.id)
422− .await
445+ let mut tokens = self.tokens_where("access_tokens.user_id = ?", &a.user.id).await?;
446+ // A fine-grained token's selected repositories, by name.
447+ self.name_repositories(&mut tokens, &Some(a.user)).await?;
448+ Ok(tokens)
423449 }
424450
425451 /// The tokens a person or workspace made on purpose: those that do not
516542 )
517543 .await?;
518544 created.info.created_by = Some(a.actor.username);
545+ // Admin on the workspace's repositories, only when the owner says.
546+ if a.admin {
547+ self.db
548+ .prepare("UPDATE access_tokens SET admin = 1 WHERE id = ?")
549+ .bind(&[created.info.id.as_str().into()])?
550+ .run()
551+ .await?;
552+ created.info.admin = true;
553+ }
519554 Ok(Outcome::Ok(created))
520555 }
521556