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 approvals | 1 | //! 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 | ||
| 7 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 8 | use serde_json::{Map, Value, json}; | |
| 9 | use worker::Result; | |
| 10 | ||
| 11 | use crate::operations::Services; | |
| 12 | ||
| 13 | /// One operation. | |
| 14 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 15 | pub enum TokenOp { | |
| 16 | GetTokenPolicy, | |
| 17 | SetTokenPolicy, | |
| 18 | ListMemberTokens, | |
| 19 | ListTokenRequests, | |
| 20 | ReviewTokenRequest, | |
| 21 | RevokeMemberToken, | |
| 22 | } | |
| 23 | ||
| 24 | impl 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 | ||
| 121 | fn 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 | ||
| 128 | fn 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`. | |
| 144 | pub(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 | ||
| 165 | fn 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 | ||
| 172 | pub 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)] | |
| 265 | mod 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 | } |