Skip to content
278 linesCodeBlameRaw
1//! A workspace's rules for personal access tokens, over REST and MCP: the
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
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 personal access tokens waiting for approval",
54 TokenOp::ReviewTokenRequest => "Approve or deny a personal access 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_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.",
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 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.",
67 }
68 }
69
70 /// Whether it changes anything: checked against the scope table in tests.
71 #[cfg(test)]
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 {
81 TokenOp::GetTokenPolicy | TokenOp::ListTokenRequests | TokenOp::ListMemberTokens => (json!({ "workspace": workspace }), &["workspace"]),
82 TokenOp::SetTokenPolicy => (
83 json!({
84 "workspace": workspace,
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." },
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
115fn 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
122fn 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
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`.
137pub(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 {
141 if key != "legacy" && key != "workspaceOwned" && key != "admin" {
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
152fn 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
159pub 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,
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"),
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");
197 let members: Outcome<Value> = g1t_kit::call(identity, "list_member_tokens", &json!({ "actor": actor, "slug": workspace, "status": status })).await?;
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)]
246mod 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",
258 "permissions": { "code": "read", "repo": "read" },
259 "workspace": "acme", "repositorySelection": "selected", "repositories": ["acme/web"], "status": "pending",
260 },
261 }));
262 assert_eq!(view["owner"], "ana");
263 assert_eq!(view["workspace"], "acme");
264 assert_eq!(view["repositorySelection"], "selected");
265 assert_eq!(view["permissions"]["code"], "read");
266 assert_eq!(view["status"], "pending");
267 assert_eq!(view["blockedBy"], "pending approval");
268 assert!(view.get("legacy").is_none());
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}