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 |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 2 | //! 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 approvals | 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", | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 53 | 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 approvals | 55 | 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 managers | 61 | 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 approvals | 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.", |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 63 | 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 approvals | 67 | } |
| 68 | } | |
| 69 | ||
| API: writes() only for tests | 70 | /// 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 approvals | 72 | 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 managers | 81 | TokenOp::GetTokenPolicy | TokenOp::ListTokenRequests | TokenOp::ListMemberTokens => (json!({ "workspace": workspace }), &["workspace"]), |
| API and MCP for a workspace's personal access token rules, members' tokens and approvals | 82 | 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 managers | 85 | "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 approvals | 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::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 | ||
| 115 | fn 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 | ||
| 122 | fn 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 managers | 134 | /// 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 approvals | 137 | pub(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 managers | 141 | if key != "legacy" && key != "workspaceOwned" && key != "admin" { |
| API and MCP for a workspace's personal access token rules, members' tokens and approvals | 142 | 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 | ||
| 152 | fn 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 | ||
| 159 | pub 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 managers | 185 | "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 approvals | 187 | "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 managers | 197 | 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 approvals | 198 | 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)] | |
| 246 | mod 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 managers | 258 | "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 approvals | 260 | }, |
| 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 managers | 263 | assert_eq!(view["workspace"], "acme"); |
| API and MCP for a workspace's personal access token rules, members' tokens and approvals | 264 | 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 managers | 265 | assert_eq!(view["permissions"]["code"], "read"); |
| API and MCP for a workspace's personal access token rules, members' tokens and approvals | 266 | 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 managers | 268 | assert!(view.get("legacy").is_none()); |
| API and MCP for a workspace's personal access token rules, members' tokens and approvals | 269 | } |
| 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 | } |