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