API and MCP for a workspace's personal access token rules, members' tokens and approvals
11 files+497−10/11 viewed
| 30 | 30 | mod runners; | |
| 31 | 31 | mod security; | |
| 32 | 32 | mod tools; | |
| 33 | + | mod token_policy; | |
| 33 | 34 | mod toolkit; | |
| 34 | 35 | ||
| 35 | 36 | use g1t_contracts::billing::{FinishRunArgs, RunTokens}; |
| 11 | 11 | use crate::artifacts::ArtifactsOp; | |
| 12 | 12 | use crate::deployments::DeploymentsOp; | |
| 13 | 13 | use crate::protection::ProtectionOp; | |
| 14 | + | use crate::token_policy::TokenOp; | |
| 14 | 15 | use crate::operations::Op; | |
| 15 | 16 | use crate::checks::ChecksOp; | |
| 16 | 17 | use crate::rules::RulesOp; | |
| ⋯ | |||
| 61 | 62 | &[Op::GetWorkspace, Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace], | |
| 62 | 63 | ), | |
| 63 | 64 | ( | |
| 65 | + | "Personal access tokens", | |
| 66 | + | "A workspace's rules for its members' personal access tokens: whether classic and fine-grained tokens reach it, whether fine-grained tokens wait for an owner's approval, and how long a token may last; the tokens that reach it, approving or denying the ones that wait, and revoking one there. Owners only, as people.", | |
| 67 | + | &[ | |
| 68 | + | Op::Tokens(TokenOp::GetTokenPolicy), | |
| 69 | + | Op::Tokens(TokenOp::SetTokenPolicy), | |
| 70 | + | Op::Tokens(TokenOp::ListMemberTokens), | |
| 71 | + | Op::Tokens(TokenOp::ListTokenRequests), | |
| 72 | + | Op::Tokens(TokenOp::ReviewTokenRequest), | |
| 73 | + | Op::Tokens(TokenOp::RevokeMemberToken), | |
| 74 | + | ], | |
| 75 | + | ), | |
| 76 | + | ( | |
| 64 | 77 | "Invites", | |
| 65 | 78 | "While g1t is invite-only, every new account needs an invite. Your invites, and inviting people into a workspace by email.", | |
| 66 | 79 | &[ | |
| ⋯ | |||
| 661 | 674 | Op::About(op) => op.title(), | |
| 662 | 675 | Op::Deployments(op) => op.title(), | |
| 663 | 676 | Op::Protection(op) => op.title(), | |
| 677 | + | Op::Tokens(op) => op.title(), | |
| 664 | 678 | Op::Artifacts(op) => op.title(), | |
| 665 | 679 | } | |
| 666 | 680 | } | |
| 31 | 31 | use crate::artifacts::ArtifactsOp; | |
| 32 | 32 | use crate::deployments::DeploymentsOp; | |
| 33 | 33 | use crate::protection::ProtectionOp; | |
| 34 | + | use crate::token_policy::TokenOp; | |
| 34 | 35 | use crate::rules::RulesOp; | |
| 35 | 36 | use crate::security::SecurityOp; | |
| 36 | 37 | use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel}; | |
| ⋯ | |||
| 288 | 289 | /// Environments' protection rules, approving runs, the token's default | |
| 289 | 290 | /// permissions and repository dispatch: protection.rs. | |
| 290 | 291 | Protection(ProtectionOp), | |
| 292 | + | /// A workspace's rules for personal access tokens, its members' | |
| 293 | + | /// tokens and approving them: token_policy.rs. | |
| 294 | + | Tokens(TokenOp), | |
| 291 | 295 | /// Workflow run artifacts, and how long they are kept: artifacts.rs. | |
| 292 | 296 | Artifacts(ArtifactsOp), | |
| 293 | 297 | } | |
| ⋯ | |||
| 652 | 656 | } | |
| 653 | 657 | ||
| 654 | 658 | impl Op { | |
| 655 | − | pub const ALL: [Op; 276] = [ | |
| 659 | + | pub const ALL: [Op; 282] = [ | |
| 656 | 660 | Op::Whoami, | |
| 657 | 661 | Op::GetWorkspace, | |
| 658 | 662 | Op::CreateWorkspace, | |
| ⋯ | |||
| 929 | 933 | Op::Protection(ProtectionOp::CreateRepositoryDispatch), | |
| 930 | 934 | Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), | |
| 931 | 935 | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 936 | + | Op::Tokens(TokenOp::GetTokenPolicy), | |
| 937 | + | Op::Tokens(TokenOp::SetTokenPolicy), | |
| 938 | + | Op::Tokens(TokenOp::ListMemberTokens), | |
| 939 | + | Op::Tokens(TokenOp::ListTokenRequests), | |
| 940 | + | Op::Tokens(TokenOp::ReviewTokenRequest), | |
| 941 | + | Op::Tokens(TokenOp::RevokeMemberToken), | |
| 932 | 942 | ]; | |
| 933 | 943 | ||
| 934 | 944 | pub fn by_name(name: &str) -> Option<Op> { | |
| ⋯ | |||
| 1123 | 1133 | Op::About(op) => op.name(), | |
| 1124 | 1134 | Op::Deployments(op) => op.name(), | |
| 1125 | 1135 | Op::Protection(op) => op.name(), | |
| 1136 | + | Op::Tokens(op) => op.name(), | |
| 1126 | 1137 | Op::Artifacts(op) => op.name(), | |
| 1127 | 1138 | } | |
| 1128 | 1139 | } | |
| ⋯ | |||
| 1638 | 1649 | Op::About(op) => op.description(), | |
| 1639 | 1650 | Op::Deployments(op) => op.description(), | |
| 1640 | 1651 | Op::Protection(op) => op.description(), | |
| 1652 | + | Op::Tokens(op) => op.description(), | |
| 1641 | 1653 | Op::Artifacts(op) => op.description(), | |
| 1642 | 1654 | } | |
| 1643 | 1655 | } | |
| ⋯ | |||
| 3011 | 3023 | Op::About(op) => op.input(), | |
| 3012 | 3024 | Op::Deployments(op) => op.input(), | |
| 3013 | 3025 | Op::Protection(op) => op.input(), | |
| 3026 | + | Op::Tokens(op) => op.input(), | |
| 3014 | 3027 | Op::Artifacts(op) => op.input(), | |
| 3015 | 3028 | } | |
| 3016 | 3029 | } | |
| ⋯ | |||
| 3083 | 3096 | if let Op::Protection(op) = self { | |
| 3084 | 3097 | return op.needs_repo(); | |
| 3085 | 3098 | } | |
| 3099 | + | // A workspace's, never one repository's. | |
| 3100 | + | if let Op::Tokens(_) = self { | |
| 3101 | + | return false; | |
| 3102 | + | } | |
| 3086 | 3103 | !matches!( | |
| 3087 | 3104 | self, | |
| 3088 | 3105 | Op::Whoami | |
| ⋯ | |||
| 5082 | 5099 | Op::About(op) => crate::about::run(op, services, viewer, input).await, | |
| 5083 | 5100 | Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await, | |
| 5084 | 5101 | Op::Protection(op) => crate::protection::run(op, services, viewer, input).await, | |
| 5102 | + | Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await, | |
| 5085 | 5103 | Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await, | |
| 5086 | 5104 | Op::ReopenSecurityAlert => { | |
| 5087 | 5105 | let changed: Outcome<AlertChange> = call( | |
| 10192 | 10192 | }, | |
| 10193 | 10193 | "notes": "A maximum of read holds every repository's default to read-only, and makes the workspace's default read too." | |
| 10194 | 10194 | }, | |
| 10195 | + | "get_token_policy": { | |
| 10196 | + | "params": { | |
| 10197 | + | "workspace": "flagon-io" | |
| 10198 | + | }, | |
| 10199 | + | "response": { | |
| 10200 | + | "allow_classic": true, | |
| 10201 | + | "allow_fine_grained": true, | |
| 10202 | + | "require_approval": true, | |
| 10203 | + | "max_lifetime_days": null, | |
| 10204 | + | "forbid_no_expiry": false, | |
| 10205 | + | "updated_by": null, | |
| 10206 | + | "updated_at": null | |
| 10207 | + | } | |
| 10208 | + | }, | |
| 10209 | + | "set_token_policy": { | |
| 10210 | + | "params": { | |
| 10211 | + | "workspace": "flagon-io" | |
| 10212 | + | }, | |
| 10213 | + | "request": { | |
| 10214 | + | "allow_classic": false, | |
| 10215 | + | "max_lifetime_days": 90 | |
| 10216 | + | }, | |
| 10217 | + | "response": { | |
| 10218 | + | "allow_classic": false, | |
| 10219 | + | "allow_fine_grained": true, | |
| 10220 | + | "require_approval": true, | |
| 10221 | + | "max_lifetime_days": 90, | |
| 10222 | + | "forbid_no_expiry": false, | |
| 10223 | + | "updated_by": "ada", | |
| 10224 | + | "updated_at": "2026-10-08T09:30:00.000Z" | |
| 10225 | + | }, | |
| 10226 | + | "notes": "From each token's next request, classic tokens no longer reach the workspace, and neither does a token that lasts longer than 90 days or never expires. They keep working everywhere else." | |
| 10227 | + | }, | |
| 10228 | + | "list_member_tokens": { | |
| 10229 | + | "params": { | |
| 10230 | + | "workspace": "flagon-io" | |
| 10231 | + | }, | |
| 10232 | + | "response": [ | |
| 10233 | + | { | |
| 10234 | + | "id": "tok_01HZX3K2M9V7Q4N8B6D5C3A2E1", | |
| 10235 | + | "name": "release-bot", | |
| 10236 | + | "owner": "ada", | |
| 10237 | + | "kind": "fine_grained", | |
| 10238 | + | "description": "Publishes releases from CI", | |
| 10239 | + | "created_at": "2026-10-08T09:00:00.000Z", | |
| 10240 | + | "created_by": null, | |
| 10241 | + | "last_used_at": null, | |
| 10242 | + | "expires_at": "2026-11-07T09:00:00.000Z", | |
| 10243 | + | "scopes": ["repo:read", "repo:write", "code:read", "code:write"], | |
| 10244 | + | "resource_owner": "flagon-io", | |
| 10245 | + | "repository_selection": "selected", | |
| 10246 | + | "repositories": ["flagon-io/hello"], | |
| 10247 | + | "permissions": { "contents": "write", "metadata": "read" }, | |
| 10248 | + | "status": "pending", | |
| 10249 | + | "review_reason": null, | |
| 10250 | + | "reaches": false, | |
| 10251 | + | "blocked_by": "pending approval" | |
| 10252 | + | } | |
| 10253 | + | ] | |
| 10254 | + | }, | |
| 10255 | + | "list_token_requests": { | |
| 10256 | + | "params": { | |
| 10257 | + | "workspace": "flagon-io" | |
| 10258 | + | }, | |
| 10259 | + | "response": [ | |
| 10260 | + | { | |
| 10261 | + | "id": "tok_01HZX3K2M9V7Q4N8B6D5C3A2E1", | |
| 10262 | + | "name": "release-bot", | |
| 10263 | + | "owner": "ada", | |
| 10264 | + | "kind": "fine_grained", | |
| 10265 | + | "description": "Publishes releases from CI", | |
| 10266 | + | "created_at": "2026-10-08T09:00:00.000Z", | |
| 10267 | + | "created_by": null, | |
| 10268 | + | "last_used_at": null, | |
| 10269 | + | "expires_at": "2026-11-07T09:00:00.000Z", | |
| 10270 | + | "scopes": ["repo:read", "repo:write", "code:read", "code:write"], | |
| 10271 | + | "resource_owner": "flagon-io", | |
| 10272 | + | "repository_selection": "selected", | |
| 10273 | + | "repositories": ["flagon-io/hello"], | |
| 10274 | + | "permissions": { "contents": "write", "metadata": "read" }, | |
| 10275 | + | "status": "pending", | |
| 10276 | + | "review_reason": null, | |
| 10277 | + | "reaches": false, | |
| 10278 | + | "blocked_by": "pending approval" | |
| 10279 | + | } | |
| 10280 | + | ] | |
| 10281 | + | }, | |
| 10282 | + | "review_token_request": { | |
| 10283 | + | "params": { | |
| 10284 | + | "workspace": "flagon-io", | |
| 10285 | + | "id": "tok_01HZX3K2M9V7Q4N8B6D5C3A2E1" | |
| 10286 | + | }, | |
| 10287 | + | "request": { | |
| 10288 | + | "decision": "approve" | |
| 10289 | + | }, | |
| 10290 | + | "response": { | |
| 10291 | + | "id": "tok_01HZX3K2M9V7Q4N8B6D5C3A2E1", | |
| 10292 | + | "name": "release-bot", | |
| 10293 | + | "owner": "ada", | |
| 10294 | + | "kind": "fine_grained", | |
| 10295 | + | "description": "Publishes releases from CI", | |
| 10296 | + | "created_at": "2026-10-08T09:00:00.000Z", | |
| 10297 | + | "created_by": null, | |
| 10298 | + | "last_used_at": null, | |
| 10299 | + | "expires_at": "2026-11-07T09:00:00.000Z", | |
| 10300 | + | "scopes": ["repo:read", "repo:write", "code:read", "code:write"], | |
| 10301 | + | "resource_owner": "flagon-io", | |
| 10302 | + | "repository_selection": "selected", | |
| 10303 | + | "repositories": ["flagon-io/hello"], | |
| 10304 | + | "permissions": { "contents": "write", "metadata": "read" }, | |
| 10305 | + | "status": "active", | |
| 10306 | + | "review_reason": null, | |
| 10307 | + | "reaches": true, | |
| 10308 | + | "blocked_by": null | |
| 10309 | + | }, | |
| 10310 | + | "notes": "The token's owner hears of it in their inbox. A denied token reaches public repositories only." | |
| 10311 | + | }, | |
| 10312 | + | "revoke_member_token": { | |
| 10313 | + | "params": { | |
| 10314 | + | "workspace": "flagon-io", | |
| 10315 | + | "id": "tok_01HZX3K2M9V7Q4N8B6D5C3A2E1" | |
| 10316 | + | }, | |
| 10317 | + | "request": { | |
| 10318 | + | "reason": "Rotated after the laptop was lost" | |
| 10319 | + | }, | |
| 10320 | + | "response": { | |
| 10321 | + | "revoked": true | |
| 10322 | + | } | |
| 10323 | + | }, | |
| 10195 | 10324 | "get_languages": { | |
| 10196 | 10325 | "response": { | |
| 10197 | 10326 | "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", |
| 6 | 6 | use crate::artifacts::ArtifactsOp; | |
| 7 | 7 | use crate::deployments::DeploymentsOp; | |
| 8 | 8 | use crate::protection::ProtectionOp; | |
| 9 | + | use crate::token_policy::TokenOp; | |
| 9 | 10 | use crate::operations::Op; | |
| 10 | 11 | use crate::checks::ChecksOp; | |
| 11 | 12 | use crate::rules::RulesOp; | |
| ⋯ | |||
| 47 | 48 | route("POST", "/user/invites", Op::CreateInvite, &[]), | |
| 48 | 49 | route("DELETE", "/user/invites/:id", Op::RevokeInvite, &[]), | |
| 49 | 50 | route("GET", "/workspaces/:workspace/invitations", Op::ListWorkspaceInvites, &[]), | |
| 51 | + | // A workspace's rules for personal access tokens, and its members' tokens. | |
| 52 | + | route("GET", "/workspaces/:workspace/personal-access-token-policy", Op::Tokens(TokenOp::GetTokenPolicy), &[]), | |
| 53 | + | route("PATCH", "/workspaces/:workspace/personal-access-token-policy", Op::Tokens(TokenOp::SetTokenPolicy), &[]), | |
| 54 | + | route("GET", "/workspaces/:workspace/personal-access-tokens", Op::Tokens(TokenOp::ListMemberTokens), &[("kind", "kind")]), | |
| 55 | + | route("POST", "/workspaces/:workspace/personal-access-tokens/:id", Op::Tokens(TokenOp::RevokeMemberToken), &[]), | |
| 56 | + | route("GET", "/workspaces/:workspace/personal-access-token-requests", Op::Tokens(TokenOp::ListTokenRequests), &[]), | |
| 57 | + | route("POST", "/workspaces/:workspace/personal-access-token-requests/:id", Op::Tokens(TokenOp::ReviewTokenRequest), &[]), | |
| 50 | 58 | route("POST", "/workspaces/:workspace/invitations", Op::InviteMember, &[]), | |
| 51 | 59 | route("DELETE", "/workspaces/:workspace/invitations/:id", Op::RevokeWorkspaceInvite, &[]), | |
| 52 | 60 | // Who has access. GitHub's addresses, but for adding someone, which | |
| 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 | + | } |
| 22 | 22 | use crate::artifacts::ArtifactsOp; | |
| 23 | 23 | use crate::deployments::DeploymentsOp; | |
| 24 | 24 | use crate::protection::ProtectionOp; | |
| 25 | + | use crate::token_policy::TokenOp; | |
| 25 | 26 | use crate::operations::Op; | |
| 26 | 27 | use crate::checks::ChecksOp; | |
| 27 | 28 | use crate::rules::RulesOp; | |
| ⋯ | |||
| 363 | 364 | a("update_ruleset", Op::Rules(RulesOp::UpdateWorkspaceRuleset), "Change one of its rulesets"), | |
| 364 | 365 | a("delete_ruleset", Op::Rules(RulesOp::DeleteWorkspaceRuleset), "Delete one of its rulesets"), | |
| 365 | 366 | a("rule_evaluations", Op::Rules(RulesOp::ListWorkspaceRuleEvaluations), "How rules judged changes across its repositories"), | |
| 367 | + | a("get_token_policy", Op::Tokens(TokenOp::GetTokenPolicy), "Its rules for personal access tokens"), | |
| 368 | + | a("set_token_policy", Op::Tokens(TokenOp::SetTokenPolicy), "Change them: kinds allowed, approval, lifetime"), | |
| 369 | + | a("list_member_tokens", Op::Tokens(TokenOp::ListMemberTokens), "Members' personal access tokens that reach it"), | |
| 370 | + | a("list_token_requests", Op::Tokens(TokenOp::ListTokenRequests), "Fine-grained tokens waiting for approval"), | |
| 371 | + | a("review_token_request", Op::Tokens(TokenOp::ReviewTokenRequest), "Approve or deny one"), | |
| 372 | + | a("revoke_member_token", Op::Tokens(TokenOp::RevokeMemberToken), "Revoke a member's token in it"), | |
| 366 | 373 | ], | |
| 367 | 374 | }, | |
| 368 | 375 | Tool { | |
| ⋯ | |||
| 472 | 479 | | Op::Rules(RulesOp::DeleteRepoRuleset | RulesOp::DeleteWorkspaceRuleset) | |
| 473 | 480 | | Op::DeleteWorkspace | |
| 474 | 481 | | Op::UpdateWorkspace | |
| 482 | + | | Op::Tokens(TokenOp::RevokeMemberToken | TokenOp::SetTokenPolicy) | |
| 475 | 483 | | Op::DeleteRepo | |
| 476 | 484 | | Op::PurgeRepo | |
| 477 | 485 | | Op::TransferRepo | |
| 627 | 627 | | [`update_ruleset`](/reference/api/rules/update-workspace-ruleset/) | Change one. Owners only. | `workspace`, `id` | `workspace:admin` | | |
| 628 | 628 | | [`delete_ruleset`](/reference/api/rules/delete-workspace-ruleset/) | Delete one. Owners only. | `workspace`, `id` | `workspace:admin` | | |
| 629 | 629 | | [`rule_evaluations`](/reference/api/rules/list-workspace-rule-evaluations/) | How rules judged changes across its repositories, with insights. Members only. | `workspace` | `workspace:read` | | |
| 630 | + | | [`get_token_policy`](/reference/api/personal-access-tokens/get-token-policy/) | Its [rules for personal access tokens](/guides/authentication/#a-workspaces-rules-for-tokens): `allow_classic`, `allow_fine_grained`, `require_approval`, `max_lifetime_days` and `forbid_no_expiry`. Members only. | `workspace` | `workspace:read` | | |
| 631 | + | | [`set_token_policy`](/reference/api/personal-access-tokens/set-token-policy/) | Change them; fields left out stay. `max_lifetime_days` of 0 removes the limit. Owners only, as people. | `workspace` | `workspace:admin` | | |
| 632 | + | | [`list_member_tokens`](/reference/api/personal-access-tokens/list-member-tokens/) | The personal access tokens of its members and outside collaborators that can reach it, with their owner, permissions or scopes, last use, expiry, and whether each reaches it now (`reaches`, `blocked_by`). `kind` narrows to `classic` or `fine_grained`. Never the token itself. Owners only, as people. | `workspace` | `access:read` | | |
| 633 | + | | [`list_token_requests`](/reference/api/personal-access-tokens/list-token-requests/) | Fine-grained tokens naming it that wait for approval. Owners only, as people. | `workspace` | `access:read` | | |
| 634 | + | | [`review_token_request`](/reference/api/personal-access-tokens/review-token-request/) | Approve or deny one: `decision` is `approve` or `deny`, with an optional `reason` its owner is shown. Owners only, as people. | `workspace`, `id`, `decision` | `access:admin` | | |
| 635 | + | | [`revoke_member_token`](/reference/api/personal-access-tokens/revoke-member-token/) | Take a member's token out of the workspace, with an optional `reason`. A fine-grained token naming it stops reaching it; a classic one keeps working elsewhere. Owners only, as people. | `workspace`, `id` | `access:admin` | | |
| 630 | 636 | ||
| 631 | 637 | ## `billing` | |
| 632 | 638 |
Binary or large file; its contents are not shown.
| 963 | 963 | // A workspace's policy for its repositories' tokens. | |
| 964 | 964 | ("get_workspace_workflow_permissions", Scope::WorkspaceRead), | |
| 965 | 965 | ("set_workspace_workflow_permissions", Scope::WorkspaceAdmin), | |
| 966 | + | // A workspace's rules for personal access tokens, and the members' | |
| 967 | + | // tokens that reach it: who has access. | |
| 968 | + | ("get_token_policy", Scope::WorkspaceRead), | |
| 969 | + | ("set_token_policy", Scope::WorkspaceAdmin), | |
| 970 | + | ("list_member_tokens", Scope::AccessRead), | |
| 971 | + | ("list_token_requests", Scope::AccessRead), | |
| 972 | + | ("review_token_request", Scope::AccessAdmin), | |
| 973 | + | ("revoke_member_token", Scope::AccessAdmin), | |
| 966 | 974 | // Memory and the context hub. | |
| 967 | 975 | ("recall", Scope::MemoryRead), | |
| 968 | 976 | ("search_context", Scope::MemoryRead), |
| 438 | 438 | // A workspace's policy for its repositories' tokens. | |
| 439 | 439 | ["get_workspace_workflow_permissions", "workspace:read"], | |
| 440 | 440 | ["set_workspace_workflow_permissions", "workspace:admin"], | |
| 441 | + | // A workspace's rules for personal access tokens, and its members' tokens. | |
| 442 | + | ["get_token_policy", "workspace:read"], | |
| 443 | + | ["set_token_policy", "workspace:admin"], | |
| 444 | + | ["list_member_tokens", "access:read"], | |
| 445 | + | ["list_token_requests", "access:read"], | |
| 446 | + | ["review_token_request", "access:admin"], | |
| 447 | + | ["revoke_member_token", "access:admin"], | |
| 441 | 448 | ["recall", "memory:read"], | |
| 442 | 449 | ["search_context", "memory:read"], | |
| 443 | 450 | ["get_entity", "memory:read"], |