Skip to content
297 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
2//! policy (which kinds reach it, approval, lifetime), the members' tokens
3//! that reach it, approving or denying fine-grained tokens that wait for
4//! 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",
53 TokenOp::ListTokenRequests => "List fine-grained tokens waiting for approval",
54 TokenOp::ReviewTokenRequest => "Approve or deny a fine-grained token",
55 TokenOp::RevokeMemberToken => "Revoke a member's token in a workspace",
56 }
57 }
58
59 pub fn description(self) -> &'static str {
60 match self {
61 TokenOp::GetTokenPolicy => "A workspace's rules for its members' personal access tokens: allow_classic (classic tokens reach it), allow_fine_grained (fine-grained tokens may name it as their resource owner), require_approval (a fine-grained token naming it 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 fine-grained token 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.",
62 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.",
63 TokenOp::ListMemberTokens => "The personal access tokens of the workspace's members and outside collaborators that can reach it: every fine-grained token naming it as its resource owner, whatever its status, and every classic token that has not expired. Each with its owner, kind, name, scopes, a fine-grained token's permissions, repository_selection, repositories and 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, classic tokens not allowed, lasts too long, never expires). Never the token itself. kind narrows it to classic or fine_grained. Owners only, as people.",
64 TokenOp::ListTokenRequests => "The fine-grained tokens naming 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 fine-grained 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 fine-grained token naming the workspace stops reaching it for good; a classic token keeps working everywhere else but never reaches this workspace again. Recorded in the audit log as token.revoked. Owners only, as people.",
67 }
68 }
69
70 /// Whether it changes anything.
71 pub fn writes(self) -> bool {
72 matches!(self, TokenOp::SetTokenPolicy | TokenOp::ReviewTokenRequest | TokenOp::RevokeMemberToken)
73 }
74
75 pub fn input(self) -> Value {
76 let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." });
77 let id = json!({ "type": "string", "description": "The token's id, tok_…." });
78 let reason = json!({ "type": "string", "description": "Why, shown to the token's owner." });
79 let (properties, required): (Value, &[&str]) = match self {
80 TokenOp::GetTokenPolicy | TokenOp::ListTokenRequests => (json!({ "workspace": workspace }), &["workspace"]),
81 TokenOp::SetTokenPolicy => (
82 json!({
83 "workspace": workspace,
84 "allow_classic": { "type": "boolean", "description": "Classic tokens reach the workspace." },
85 "allow_fine_grained": { "type": "boolean", "description": "Fine-grained tokens may name the workspace as their resource owner." },
86 "require_approval": { "type": "boolean", "description": "A fine-grained token naming the workspace waits for an owner's approval." },
87 "max_lifetime_days": { "type": "integer", "description": "The longest a token reaching it may last, in days, 1 to 3650; 0 for no limit." },
88 "forbid_no_expiry": { "type": "boolean", "description": "A token that never expires does not reach the workspace." },
89 }),
90 &["workspace"],
91 ),
92 TokenOp::ListMemberTokens => (
93 json!({
94 "workspace": workspace,
95 "kind": { "type": "string", "enum": ["classic", "fine_grained"], "description": "Only tokens of this kind." },
96 }),
97 &["workspace"],
98 ),
99 TokenOp::ReviewTokenRequest => (
100 json!({
101 "workspace": workspace,
102 "id": id,
103 "decision": { "type": "string", "enum": ["approve", "deny"], "description": "approve or deny. A request body shaped as `{\"action\": \"approve\"}` is read the same way." },
104 "reason": reason,
105 }),
106 &["workspace", "id", "decision"],
107 ),
108 TokenOp::RevokeMemberToken => (
109 json!({
110 "workspace": workspace,
111 "id": id,
112 "reason": reason,
113 }),
114 &["workspace", "id"],
115 ),
116 };
117 json!({ "type": "object", "properties": properties, "required": required })
118 }
119}
120
121fn text(input: &Value, key: &str) -> Option<String> {
122 match &input[key] {
123 Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()),
124 _ => None,
125 }
126}
127
128fn flag(input: &Value, key: &str) -> Option<bool> {
129 match &input[key] {
130 Value::Bool(value) => Some(*value),
131 Value::String(text) => match text.trim() {
132 "true" | "1" => Some(true),
133 "false" | "0" => Some(false),
134 _ => None,
135 },
136 _ => None,
137 }
138}
139
140/// A member's token in one flat shape: the token's fields, a fine-grained
141/// token's beside them, and its owner and whether it reaches the workspace.
142/// Keys stay as identity sends them (`camelCase`); the API's converter
143/// writes them out in `snake_case`.
144pub(crate) fn member_view(member: &Value) -> Value {
145 let mut out = Map::new();
146 if let Some(token) = member["token"].as_object() {
147 for (key, value) in token {
148 if key != "fineGrained" && key != "legacy" {
149 out.insert(key.clone(), value.clone());
150 }
151 }
152 if let Some(details) = token.get("fineGrained").and_then(Value::as_object) {
153 for (key, value) in details {
154 let key = if key == "workspace" { "resourceOwner".to_owned() } else { key.clone() };
155 out.insert(key, value.clone());
156 }
157 }
158 }
159 out.insert("owner".into(), member["owner"].clone());
160 out.insert("reaches".into(), member["reaches"].clone());
161 out.insert("blockedBy".into(), member["blockedBy"].clone());
162 Value::Object(out)
163}
164
165fn map(outcome: Outcome<Value>, f: impl Fn(&Value) -> Value) -> Outcome<Value> {
166 match outcome {
167 Outcome::Ok(value) => Outcome::Ok(f(&value)),
168 Outcome::Fail(failure) => Outcome::Fail(failure),
169 }
170}
171
172pub async fn run(op: TokenOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
173 let Some(actor) = viewer.clone() else {
174 return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token."));
175 };
176 let Some(workspace) = text(input, "workspace") else {
177 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace."));
178 };
179 let identity = &services.identity;
180 let surface = services.audit.surface;
181 let list = |members: &Value| Value::Array(members.as_array().map(|members| members.iter().map(member_view).collect()).unwrap_or_default());
182 Ok(match op {
183 TokenOp::GetTokenPolicy => g1t_kit::call(identity, "get_token_policy", &json!({ "viewer": viewer, "slug": workspace })).await?,
184 TokenOp::SetTokenPolicy => {
185 let days = match input.get("max_lifetime_days").filter(|value| !value.is_null()) {
186 None => None,
187 Some(value) => match value.as_u64().or_else(|| value.as_str().and_then(|text| text.trim().parse().ok())) {
188 Some(days) => Some(days),
189 None => return Ok(Outcome::fail(FailureCode::Invalid, "max_lifetime_days is a whole number of days; 0 for no limit.")),
190 },
191 };
192 g1t_kit::call(
193 identity,
194 "set_token_policy",
195 &json!({
196 "actor": actor,
197 "slug": workspace,
198 "allow_classic": flag(input, "allow_classic"),
199 "allow_fine_grained": flag(input, "allow_fine_grained"),
200 "require_approval": flag(input, "require_approval"),
201 "max_lifetime_days": days,
202 "forbid_no_expiry": flag(input, "forbid_no_expiry"),
203 "surface": surface,
204 }),
205 )
206 .await?
207 }
208 TokenOp::ListMemberTokens | TokenOp::ListTokenRequests => {
209 let kind = text(input, "kind");
210 if kind.as_deref().is_some_and(|kind| kind != "classic" && kind != "fine_grained") {
211 return Ok(Outcome::fail(FailureCode::Invalid, "kind is classic or fine_grained."));
212 }
213 let status = (op == TokenOp::ListTokenRequests).then_some("pending");
214 let kind = if op == TokenOp::ListTokenRequests { Some("fine_grained".to_owned()) } else { kind };
215 let members: Outcome<Value> =
216 g1t_kit::call(identity, "list_member_tokens", &json!({ "actor": actor, "slug": workspace, "status": status, "kind": kind })).await?;
217 map(members, list)
218 }
219 TokenOp::ReviewTokenRequest => {
220 let approve = match text(input, "decision").or_else(|| text(input, "action")).as_deref() {
221 Some("approve") => true,
222 Some("deny") => false,
223 _ => return Ok(Outcome::fail(FailureCode::Invalid, "decision is approve or deny.")),
224 };
225 let reviewed: Outcome<Value> = g1t_kit::call(
226 identity,
227 "review_token_request",
228 &json!({
229 "actor": actor,
230 "slug": workspace,
231 "id": text(input, "id").unwrap_or_default(),
232 "approve": approve,
233 "reason": text(input, "reason"),
234 "surface": surface,
235 }),
236 )
237 .await?;
238 map(reviewed, member_view)
239 }
240 TokenOp::RevokeMemberToken => {
241 if text(input, "action").is_some_and(|action| action != "revoke") {
242 return Ok(Outcome::fail(FailureCode::Invalid, "action is revoke."));
243 }
244 let revoked: Outcome<bool> = g1t_kit::call(
245 identity,
246 "revoke_member_token",
247 &json!({
248 "actor": actor,
249 "slug": workspace,
250 "id": text(input, "id").unwrap_or_default(),
251 "reason": text(input, "reason"),
252 "surface": surface,
253 }),
254 )
255 .await?;
256 match revoked {
257 Outcome::Ok(_) => Outcome::Ok(json!({ "revoked": true })),
258 Outcome::Fail(failure) => Outcome::Fail(failure),
259 }
260 }
261 })
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn a_member_token_reads_flat() {
270 let view = member_view(&json!({
271 "owner": "ana",
272 "reaches": false,
273 "blockedBy": "pending approval",
274 "token": {
275 "id": "tok_1", "name": "ci", "createdAt": "2026-10-08T00:00:00.000Z", "lastUsedAt": null,
276 "createdBy": null, "scopes": ["repo:read", "code:read"], "legacy": false, "expiresAt": "2026-11-07T00:00:00.000Z",
277 "kind": "fine_grained",
278 "fineGrained": { "workspace": "acme", "repositorySelection": "selected", "repositories": ["acme/web"], "permissions": { "contents": "read", "metadata": "read" }, "status": "pending" },
279 },
280 }));
281 assert_eq!(view["owner"], "ana");
282 assert_eq!(view["resourceOwner"], "acme");
283 assert_eq!(view["repositorySelection"], "selected");
284 assert_eq!(view["permissions"]["contents"], "read");
285 assert_eq!(view["status"], "pending");
286 assert_eq!(view["blockedBy"], "pending approval");
287 assert!(view.get("fineGrained").is_none() && view.get("legacy").is_none());
288 }
289
290 #[test]
291 fn only_changes_write() {
292 for op in TokenOp::ALL {
293 assert_eq!(op.writes(), op.name().starts_with("set_") || op.name().starts_with("review_") || op.name().starts_with("revoke_"), "{}", op.name());
294 assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")));
295 }
296 }
297}