Skip to content
278 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.

API and MCP for a workspace's personal access token rules, members' tokens and approvals1//! A workspace's rules for personal access tokens, over REST and MCP: the
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers2//! policy (which tokens reach it, approval, lifetime), the members' tokens
3//! that reach it, approving or denying tokens made for it that wait for
API and MCP for a workspace's personal access token rules, members' tokens and approvals4//! approval, and revoking a token there. Identity decides and keeps all of
5//! it (services/identity/src/token_reach.rs); owners only, as people.
6
7use g1t_contracts::{FailureCode, Outcome, Viewer};
8use serde_json::{Map, Value, json};
9use worker::Result;
10
11use crate::operations::Services;
12
13/// One operation.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum TokenOp {
16 GetTokenPolicy,
17 SetTokenPolicy,
18 ListMemberTokens,
19 ListTokenRequests,
20 ReviewTokenRequest,
21 RevokeMemberToken,
22}
23
24impl TokenOp {
25 /// Every one: `Op::ALL` lists each as `Op::Tokens(…)`, which a test
26 /// checks against this.
27 #[cfg(test)]
28 pub const ALL: [TokenOp; 6] = [
29 TokenOp::GetTokenPolicy,
30 TokenOp::SetTokenPolicy,
31 TokenOp::ListMemberTokens,
32 TokenOp::ListTokenRequests,
33 TokenOp::ReviewTokenRequest,
34 TokenOp::RevokeMemberToken,
35 ];
36
37 pub fn name(self) -> &'static str {
38 match self {
39 TokenOp::GetTokenPolicy => "get_token_policy",
40 TokenOp::SetTokenPolicy => "set_token_policy",
41 TokenOp::ListMemberTokens => "list_member_tokens",
42 TokenOp::ListTokenRequests => "list_token_requests",
43 TokenOp::ReviewTokenRequest => "review_token_request",
44 TokenOp::RevokeMemberToken => "revoke_member_token",
45 }
46 }
47
48 pub fn title(self) -> &'static str {
49 match self {
50 TokenOp::GetTokenPolicy => "Get a workspace's personal access token policy",
51 TokenOp::SetTokenPolicy => "Set a workspace's personal access token policy",
52 TokenOp::ListMemberTokens => "List the personal access tokens that reach a 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 managers53 TokenOp::ListTokenRequests => "List personal access tokens waiting for approval",
54 TokenOp::ReviewTokenRequest => "Approve or deny a personal access token",
API and MCP for a workspace's personal access token rules, members' tokens and approvals55 TokenOp::RevokeMemberToken => "Revoke a member's token in a workspace",
56 }
57 }
58
59 pub fn description(self) -> &'static str {
60 match self {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers61 TokenOp::GetTokenPolicy => "A workspace's rules for its members' personal access tokens: allow_tokens_for_all_workspaces (a token made for every workspace of its owner reaches this one), allow_tokens_for_this_workspace (a token may be made for this workspace alone), require_approval (a token made for this workspace waits for an owner's approval; true unless an owner says, and never for an owner's own token), max_lifetime_days (the longest a token reaching it may last; null for no limit, and a token with an expiry lasts at most 366 days anyway) and forbid_no_expiry (a token that never expires does not reach it). A token outside the rules keeps working elsewhere and reaches the workspace's public repositories only. Members only.",
API and MCP for a workspace's personal access token rules, members' tokens and approvals62 TokenOp::SetTokenPolicy => "Change a workspace's rules for personal access tokens; fields left out stay as they are. max_lifetime_days of 0 removes the limit. The rules apply from each token's next request, to tokens made before them too. Owners only, as people.",
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers63 TokenOp::ListMemberTokens => "The personal access tokens of the workspace's members and outside collaborators that can reach it and have not expired: every token made for this workspace, whatever its status, and every token made for all of its owner's workspaces. Each with its owner, name, description, permissions (each resource at its level, such as {\"issues\": \"write\"}), scopes, workspace (the one it is made for; null for all of its owner's), repository_selection (all, selected or public), repositories, status (active, pending, denied or revoked), when it was made, last used and expires, and whether it reaches the workspace now (reaches, and blocked_by when not: pending approval, denied, revoked, tokens for all workspaces not allowed, tokens made for this workspace not allowed, lasts too long, never expires). Never the token itself. Owners only, as people.",
64 TokenOp::ListTokenRequests => "The tokens made for the workspace that wait for an owner's approval, as list_member_tokens shows them. Until approved, a token reaches public repositories only. Owners only, as people.",
65 TokenOp::ReviewTokenRequest => "Approve or deny a token waiting for approval: decision is approve or deny, and reason, if given, is shown to the token's owner, who hears of it in their inbox. An approved token reaches the workspace from its next request; a denied one reaches public repositories only. Recorded in the audit log as token.approved or token.denied. Owners only, as people.",
66 TokenOp::RevokeMemberToken => "Take a member's token out of the workspace, with an optional reason its owner is shown. A token made for this workspace stops reaching it for good; a token made for all of its owner's workspaces keeps working everywhere else but never reaches this one again. Recorded in the audit log as token.revoked. Owners only, as people.",
API and MCP for a workspace's personal access token rules, members' tokens and approvals67 }
68 }
69
API: writes() only for tests70 /// Whether it changes anything: checked against the scope table in tests.
71 #[cfg(test)]
API and MCP for a workspace's personal access token rules, members' tokens and approvals72 pub fn writes(self) -> bool {
73 matches!(self, TokenOp::SetTokenPolicy | TokenOp::ReviewTokenRequest | TokenOp::RevokeMemberToken)
74 }
75
76 pub fn input(self) -> Value {
77 let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." });
78 let id = json!({ "type": "string", "description": "The token's id, tok_…." });
79 let reason = json!({ "type": "string", "description": "Why, shown to the token's owner." });
80 let (properties, required): (Value, &[&str]) = match self {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers81 TokenOp::GetTokenPolicy | TokenOp::ListTokenRequests | TokenOp::ListMemberTokens => (json!({ "workspace": workspace }), &["workspace"]),
API and MCP for a workspace's personal access token rules, members' tokens and approvals82 TokenOp::SetTokenPolicy => (
83 json!({
84 "workspace": 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 managers85 "allow_tokens_for_all_workspaces": { "type": "boolean", "description": "A token made for every workspace of its owner reaches this one." },
86 "allow_tokens_for_this_workspace": { "type": "boolean", "description": "A token may be made for this workspace alone." },
87 "require_approval": { "type": "boolean", "description": "A token made for this workspace waits for an owner's approval." },
API and MCP for a workspace's personal access token rules, members' tokens and approvals88 "max_lifetime_days": { "type": "integer", "description": "The longest a token reaching it may last, in days, 1 to 3650; 0 for no limit." },
89 "forbid_no_expiry": { "type": "boolean", "description": "A token that never expires does not reach the workspace." },
90 }),
91 &["workspace"],
92 ),
93 TokenOp::ReviewTokenRequest => (
94 json!({
95 "workspace": workspace,
96 "id": id,
97 "decision": { "type": "string", "enum": ["approve", "deny"], "description": "approve or deny. A request body shaped as `{\"action\": \"approve\"}` is read the same way." },
98 "reason": reason,
99 }),
100 &["workspace", "id", "decision"],
101 ),
102 TokenOp::RevokeMemberToken => (
103 json!({
104 "workspace": workspace,
105 "id": id,
106 "reason": reason,
107 }),
108 &["workspace", "id"],
109 ),
110 };
111 json!({ "type": "object", "properties": properties, "required": required })
112 }
113}
114
115fn text(input: &Value, key: &str) -> Option<String> {
116 match &input[key] {
117 Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()),
118 _ => None,
119 }
120}
121
122fn flag(input: &Value, key: &str) -> Option<bool> {
123 match &input[key] {
124 Value::Bool(value) => Some(*value),
125 Value::String(text) => match text.trim() {
126 "true" | "1" => Some(true),
127 "false" | "0" => Some(false),
128 _ => None,
129 },
130 _ => None,
131 }
132}
133
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/// A member's token in one flat shape: the token's fields, and its owner
135/// and whether it reaches the workspace. Keys stay as identity sends them
136/// (`camelCase`); the API's converter writes them out in `snake_case`.
API and MCP for a workspace's personal access token rules, members' tokens and approvals137pub(crate) fn member_view(member: &Value) -> Value {
138 let mut out = Map::new();
139 if let Some(token) = member["token"].as_object() {
140 for (key, value) in token {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers141 if key != "legacy" && key != "workspaceOwned" && key != "admin" {
API and MCP for a workspace's personal access token rules, members' tokens and approvals142 out.insert(key.clone(), value.clone());
143 }
144 }
145 }
146 out.insert("owner".into(), member["owner"].clone());
147 out.insert("reaches".into(), member["reaches"].clone());
148 out.insert("blockedBy".into(), member["blockedBy"].clone());
149 Value::Object(out)
150}
151
152fn map(outcome: Outcome<Value>, f: impl Fn(&Value) -> Value) -> Outcome<Value> {
153 match outcome {
154 Outcome::Ok(value) => Outcome::Ok(f(&value)),
155 Outcome::Fail(failure) => Outcome::Fail(failure),
156 }
157}
158
159pub async fn run(op: TokenOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
160 let Some(actor) = viewer.clone() else {
161 return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token."));
162 };
163 let Some(workspace) = text(input, "workspace") else {
164 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace."));
165 };
166 let identity = &services.identity;
167 let surface = services.audit.surface;
168 let list = |members: &Value| Value::Array(members.as_array().map(|members| members.iter().map(member_view).collect()).unwrap_or_default());
169 Ok(match op {
170 TokenOp::GetTokenPolicy => g1t_kit::call(identity, "get_token_policy", &json!({ "viewer": viewer, "slug": workspace })).await?,
171 TokenOp::SetTokenPolicy => {
172 let days = match input.get("max_lifetime_days").filter(|value| !value.is_null()) {
173 None => None,
174 Some(value) => match value.as_u64().or_else(|| value.as_str().and_then(|text| text.trim().parse().ok())) {
175 Some(days) => Some(days),
176 None => return Ok(Outcome::fail(FailureCode::Invalid, "max_lifetime_days is a whole number of days; 0 for no limit.")),
177 },
178 };
179 g1t_kit::call(
180 identity,
181 "set_token_policy",
182 &json!({
183 "actor": actor,
184 "slug": 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 managers185 "allow_tokens_for_all_workspaces": flag(input, "allow_tokens_for_all_workspaces"),
186 "allow_tokens_for_this_workspace": flag(input, "allow_tokens_for_this_workspace"),
API and MCP for a workspace's personal access token rules, members' tokens and approvals187 "require_approval": flag(input, "require_approval"),
188 "max_lifetime_days": days,
189 "forbid_no_expiry": flag(input, "forbid_no_expiry"),
190 "surface": surface,
191 }),
192 )
193 .await?
194 }
195 TokenOp::ListMemberTokens | TokenOp::ListTokenRequests => {
196 let status = (op == TokenOp::ListTokenRequests).then_some("pending");
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers197 let members: Outcome<Value> = g1t_kit::call(identity, "list_member_tokens", &json!({ "actor": actor, "slug": workspace, "status": status })).await?;
API and MCP for a workspace's personal access token rules, members' tokens and approvals198 map(members, list)
199 }
200 TokenOp::ReviewTokenRequest => {
201 let approve = match text(input, "decision").or_else(|| text(input, "action")).as_deref() {
202 Some("approve") => true,
203 Some("deny") => false,
204 _ => return Ok(Outcome::fail(FailureCode::Invalid, "decision is approve or deny.")),
205 };
206 let reviewed: Outcome<Value> = g1t_kit::call(
207 identity,
208 "review_token_request",
209 &json!({
210 "actor": actor,
211 "slug": workspace,
212 "id": text(input, "id").unwrap_or_default(),
213 "approve": approve,
214 "reason": text(input, "reason"),
215 "surface": surface,
216 }),
217 )
218 .await?;
219 map(reviewed, member_view)
220 }
221 TokenOp::RevokeMemberToken => {
222 if text(input, "action").is_some_and(|action| action != "revoke") {
223 return Ok(Outcome::fail(FailureCode::Invalid, "action is revoke."));
224 }
225 let revoked: Outcome<bool> = g1t_kit::call(
226 identity,
227 "revoke_member_token",
228 &json!({
229 "actor": actor,
230 "slug": workspace,
231 "id": text(input, "id").unwrap_or_default(),
232 "reason": text(input, "reason"),
233 "surface": surface,
234 }),
235 )
236 .await?;
237 match revoked {
238 Outcome::Ok(_) => Outcome::Ok(json!({ "revoked": true })),
239 Outcome::Fail(failure) => Outcome::Fail(failure),
240 }
241 }
242 })
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn a_member_token_reads_flat() {
251 let view = member_view(&json!({
252 "owner": "ana",
253 "reaches": false,
254 "blockedBy": "pending approval",
255 "token": {
256 "id": "tok_1", "name": "ci", "createdAt": "2026-10-08T00:00:00.000Z", "lastUsedAt": null,
257 "createdBy": null, "scopes": ["repo:read", "code:read"], "legacy": false, "expiresAt": "2026-11-07T00:00:00.000Z",
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers258 "permissions": { "code": "read", "repo": "read" },
259 "workspace": "acme", "repositorySelection": "selected", "repositories": ["acme/web"], "status": "pending",
API and MCP for a workspace's personal access token rules, members' tokens and approvals260 },
261 }));
262 assert_eq!(view["owner"], "ana");
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers263 assert_eq!(view["workspace"], "acme");
API and MCP for a workspace's personal access token rules, members' tokens and approvals264 assert_eq!(view["repositorySelection"], "selected");
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers265 assert_eq!(view["permissions"]["code"], "read");
API and MCP for a workspace's personal access token rules, members' tokens and approvals266 assert_eq!(view["status"], "pending");
267 assert_eq!(view["blockedBy"], "pending approval");
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers268 assert!(view.get("legacy").is_none());
API and MCP for a workspace's personal access token rules, members' tokens and approvals269 }
270
271 #[test]
272 fn only_changes_write() {
273 for op in TokenOp::ALL {
274 assert_eq!(op.writes(), op.name().starts_with("set_") || op.name().starts_with("review_") || op.name().starts_with("revoke_"), "{}", op.name());
275 assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")));
276 }
277 }
278}