1use serde_json::{Value, json};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Agents {
12 Tool(&'static str),
14 BrowserOnly(&'static str),
16}
17
18#[derive(Debug, Clone, Copy)]
20pub struct Route {
21 pub method: &'static str,
22 pub path: &'static str,
24 pub summary: &'static str,
25 pub who: &'static str,
27 pub body: Option<fn() -> Value>,
29 pub ok: u16,
31 pub answer: Option<fn() -> Value>,
33 pub agents: Agents,
34}
35
36const SIGN_IN: &str =
37 "a browser sign-in flow: it sets the session cookie a person's browser carries";
38const WAYS_IN: &str = "a way into the account: changed from a signed-in browser session only, so a leaked token cannot lock its owner out or let an attacker in";
39const NO_CREDENTIAL: &str =
40 "for someone who holds no credential yet: the token or address they bring is the credential";
41
42fn r(name: &str) -> Value {
43 json!({"$ref": format!("#/components/schemas/{name}")})
44}
45
46fn obj(props: Value, required: &[&str]) -> Value {
47 json!({"type": "object", "properties": props, "required": required})
48}
49
50fn list(key: &str, item: &str) -> Value {
51 obj(json!({key: {"type": "array", "items": r(item)}}), &[key])
52}
53
54fn session_answer() -> Value {
55 r("SessionAnswer")
56}
57fn me() -> Value {
58 r("Me")
59}
60fn edge_or_null() -> Value {
61 json!({"oneOf": [{"type": "null"}, r("EdgeIdentity")]})
62}
63fn setup_get() -> Value {
64 obj(
65 json!({"needed": {"type": "boolean"}, "edge": edge_or_null()}),
66 &["needed", "edge"],
67 )
68}
69fn setup_body() -> Value {
70 obj(
71 json!({
72 "setup_token": {"type": "string", "description": "From the setup link the daemon logs. Omit it to claim setup as the edge identity (GET setup's `edge`)."},
73 "email": {"type": "string", "description": "Required with a setup token; with an edge identity, defaults to the email it vouches for."},
74 "name": {"type": "string"},
75 "password": {"type": "string", "description": "Required with a setup token; optional for an edge identity."},
76 }),
77 &[],
78 )
79}
80fn edge_get() -> Value {
81 obj(json!({"edge": edge_or_null()}), &["edge"])
82}
83fn login_body() -> Value {
84 obj(
85 json!({"email": {"type": "string"}, "password": {"type": "string"}}),
86 &["email", "password"],
87 )
88}
89fn sessions() -> Value {
90 list("sessions", "Session")
91}
92fn invite_body() -> Value {
93 obj(
94 json!({"org": {"type": "string"}, "email": {"type": "string"}, "role": r("Role")}),
95 &["org", "email"],
96 )
97}
98fn invite_answer() -> Value {
99 obj(
100 json!({"invitation": r("Invitation"), "token": {"type": "string"}, "link": {"type": ["string", "null"]}}),
101 &["invitation", "token", "link"],
102 )
103}
104fn token_body() -> Value {
105 obj(json!({"token": {"type": "string"}}), &["token"])
106}
107fn invitation_info() -> Value {
108 r("InvitationInfo")
109}
110fn accept_body() -> Value {
111 obj(
112 json!({"token": {"type": "string"}, "name": {"type": "string"}, "password": {"type": "string"}}),
113 &["token"],
114 )
115}
116fn accept_answer() -> Value {
117 obj(
118 json!({"user": r("User"), "membership": r("Membership"), "created": {"type": "boolean"}}),
119 &["user", "membership", "created"],
120 )
121}
122fn tokens() -> Value {
123 list("tokens", "ApiToken")
124}
125fn new_token_body() -> Value {
126 obj(
127 json!({
128 "name": {"type": "string"},
129 "org": {"type": ["string", "null"]},
130 "expires": {"type": ["string", "null"], "description": "90d, 12h; absent: never."},
131 "scopes": {"type": "array", "items": {"type": "string"}, "description": "read, deploy, admin, tool:GLOB."},
132 }),
133 &["name"],
134 )
135}
136fn new_token_answer() -> Value {
137 obj(
138 json!({"token": {"type": "string"}, "info": r("ApiToken")}),
139 &["token", "info"],
140 )
141}
142fn ssh_keys() -> Value {
143 list("ssh_keys", "SshKey")
144}
145fn ssh_key_body() -> Value {
146 obj(
147 json!({"public_key": {"type": "string"}, "name": {"type": "string"}}),
148 &["public_key"],
149 )
150}
151fn ssh_key_answer() -> Value {
152 obj(json!({"ssh_key": r("SshKey")}), &["ssh_key"])
153}
154fn password_body() -> Value {
155 obj(
156 json!({"current_password": {"type": "string"}, "new_password": {"type": "string"}}),
157 &["current_password", "new_password"],
158 )
159}
160fn email_body() -> Value {
161 obj(json!({"email": {"type": "string"}}), &["email"])
162}
163fn ok_answer() -> Value {
164 obj(json!({"ok": {"type": "boolean"}}), &["ok"])
165}
166fn reset_body() -> Value {
167 obj(
168 json!({"token": {"type": "string"}, "password": {"type": "string"}}),
169 &["token", "password"],
170 )
171}
172fn members() -> Value {
173 list("members", "Member")
174}
175fn role_body() -> Value {
176 obj(json!({"role": r("Role")}), &["role"])
177}
178fn role_answer() -> Value {
179 obj(
180 json!({"user_id": {"type": "integer"}, "role": r("Role")}),
181 &["user_id", "role"],
182 )
183}
184fn invitations() -> Value {
185 list("invitations", "Invitation")
186}
187fn org_tokens() -> Value {
188 list("tokens", "OrgToken")
189}
190fn reach(you: bool) -> Value {
193 let mut props = json!({
194 "count": {"type": "integer"},
195 "who": {"type": "array", "items": {"type": "string"}, "description": "Emails, logins or client ids; empty unless the caller owns or administers the org."},
196 });
197 let mut req = vec!["count", "who"];
198 if you {
199 props["you"] = json!({"type": "boolean", "description": "The caller is one of them."});
200 req.push("you");
201 }
202 obj(props, &req)
203}
204fn agent_identities() -> Value {
205 obj(
206 json!({
207 "identities": {"type": "array", "items": r("AgentIdentity")},
208 "available": obj(
209 json!({
210 "tailnet_listen": {"type": "array", "items": {"type": "string"}, "description": "The tailnet --listen addresses; empty when no tailnet peer can reach the server."},
211 "access": {"type": "boolean", "description": "Cloudflare Access guards a listener."},
212 "public_url": {"type": ["string", "null"], "description": "The server's --public-url, null when unset."},
213 "reach": obj(
214 json!({
215 "platform_admins": reach(false),
216 "access_superadmins": reach(true),
217 "tailnet_superadmins": reach(true),
218 }),
219 &["platform_admins", "access_superadmins", "tailnet_superadmins"],
220 ),
221 }),
222 &["tailnet_listen", "access", "public_url", "reach"],
223 ),
224 }),
225 &["identities", "available"],
226 )
227}
228fn agent_identity_body() -> Value {
229 obj(
230 json!({
231 "kind": {"type": "string", "enum": ["tailnet", "access"]},
232 "subject": {"type": "string", "description": "Tailnet: a login name or tag:name. Access: the email of someone who is not an isb user, or a service token's client id."},
233 "role": {"type": "string", "enum": ["admin", "member", "viewer"]},
234 "note": {"type": "string"},
235 }),
236 &["kind", "subject", "role"],
237 )
238}
239fn agent_identity_answer() -> Value {
240 obj(json!({"identity": r("AgentIdentity")}), &["identity"])
241}
242fn users() -> Value {
243 list("users", "AdminUser")
244}
245fn user_change() -> Value {
246 json!({"type": "object", "properties": {"disabled": {"type": "boolean"}, "platform_admin": {"type": "boolean"}}, "additionalProperties": false})
247}
248fn user_answer() -> Value {
249 obj(json!({"user": r("User")}), &["user"])
250}
251fn providers() -> Value {
252 r("Providers")
253}
254fn oauth_body() -> Value {
255 obj(
256 json!({"next": {"type": "string"}, "invite": {"type": "string"}, "intent": {"type": "string", "enum": ["login", "link"]}}),
257 &[],
258 )
259}
260fn url_answer() -> Value {
261 obj(json!({"url": {"type": "string"}}), &["url"])
262}
263fn identities() -> Value {
264 list("identities", "Identity")
265}
266fn passkeys() -> Value {
267 list("passkeys", "Passkey")
268}
269fn public_key_options() -> Value {
270 obj(
271 json!({"publicKey": {"type": "object", "additionalProperties": true, "description": "WebAuthn options, in the JSON form of PublicKeyCredential.parse*OptionsFromJSON()."}}),
272 &["publicKey"],
273 )
274}
275fn passkey_login_body() -> Value {
276 obj(json!({"email": {"type": "string"}}), &[])
277}
278fn credential_body() -> Value {
279 obj(
280 json!({"name": {"type": "string"}, "credential": {"type": "object", "additionalProperties": true, "description": "The browser's credential.toJSON()."}}),
281 &["credential"],
282 )
283}
284fn passkey_answer() -> Value {
285 obj(json!({"passkey": r("Passkey")}), &["passkey"])
286}
287
288macro_rules! route {
289 ($m:literal $p:literal, $summary:literal, $who:literal, $body:expr, $ok:literal, $answer:expr, $agents:expr) => {
290 Route {
291 method: $m,
292 path: $p,
293 summary: $summary,
294 who: $who,
295 body: $body,
296 ok: $ok,
297 answer: $answer,
298 agents: $agents,
299 }
300 };
301}
302
303use Agents::{BrowserOnly as B, Tool as T};
304
305pub const ROUTES: &[Route] = &[
307 route!("GET" "setup", "Is first-run setup needed", "anyone", None, 200, Some(setup_get), B("first-run setup happens once, in a browser, as the tailnet or Access identity, or with the setup link from the host")),
308 route!("POST" "setup", "Create the first platform admin", "an edge identity, or anyone with the setup token", Some(setup_body), 201, Some(session_answer), B("first-run setup happens once, in a browser, as the tailnet or Access identity, or with the setup link from the host")),
309 route!("GET" "edge", "Who the tailnet or Cloudflare Access says is calling", "anyone", None, 200, Some(edge_get), B(SIGN_IN)),
310 route!("POST" "edge", "Sign in as the tailnet or Cloudflare Access identity", "an edge identity", None, 200, Some(session_answer), B(SIGN_IN)),
311 route!("POST" "login", "Sign in with a password", "anyone", Some(login_body), 200, Some(session_answer), B(SIGN_IN)),
312 route!("POST" "logout", "Sign out", "anyone", None, 204, None, B(SIGN_IN)),
313 route!("GET" "me", "Who is calling", "signed in", None, 200, Some(me), T("whoami")),
314 route!("GET" "sessions", "Your sessions", "signed in", None, 200, Some(sessions), T("session_list")),
315 route!("DELETE" "sessions/{id}", "End a session", "signed in", None, 204, None, T("session_revoke")),
316 route!("POST" "invitations", "Invite someone to an org", "org owners and admins", Some(invite_body), 201, Some(invite_answer), T("invitation_create")),
317 route!("POST" "invitations/inspect", "What an invitation is for", "anyone with the token", Some(token_body), 200, Some(invitation_info), B(NO_CREDENTIAL)),
318 route!("POST" "invitations/accept", "Accept an invitation", "anyone with the token", Some(accept_body), 200, Some(accept_answer), B(NO_CREDENTIAL)),
319 route!("GET" "tokens", "Your API tokens", "signed in", None, 200, Some(tokens), T("token_list")),
320 route!("POST" "tokens", "Create an API token", "signed in with a session or an Access or tailnet identity; never with a token", Some(new_token_body), 201, Some(new_token_answer), T("token_create")),
321 route!("DELETE" "tokens/{id}", "Revoke an API token", "its user, or the org's owners and admins", None, 204, None, T("token_revoke")),
322 route!("GET" "ssh-keys", "Your SSH keys", "signed in", None, 200, Some(ssh_keys), T("ssh_key_list")),
323 route!("POST" "ssh-keys", "Add an SSH key", "signed in, with an account", Some(ssh_key_body), 201, Some(ssh_key_answer), T("ssh_key_add")),
324 route!("DELETE" "ssh-keys/{id}", "Remove an SSH key", "signed in, with an account", None, 204, None, T("ssh_key_remove")),
325 route!("POST" "password", "Change your password", "signed in with a session", Some(password_body), 204, None, B(WAYS_IN)),
326 route!("POST" "password-reset/request", "Ask for a password reset", "anyone", Some(email_body), 202, Some(ok_answer), B(NO_CREDENTIAL)),
327 route!("POST" "password-reset/confirm", "Set a password with a reset token", "anyone with the token", Some(reset_body), 204, None, B(NO_CREDENTIAL)),
328 route!("GET" "providers", "How one can sign in here", "anyone", None, 200, Some(providers), B(SIGN_IN)),
329 route!("GET" "oauth/{provider}/start", "Start a provider sign-in (redirect)", "anyone", None, 303, None, B(SIGN_IN)),
330 route!("POST" "oauth/{provider}/start", "Start a provider sign-in", "anyone", Some(oauth_body), 200, Some(url_answer), B(SIGN_IN)),
331 route!("GET" "oauth/{provider}/callback", "The provider's redirect back", "the provider's redirect", None, 303, None, B(SIGN_IN)),
332 route!("GET" "identities", "Your linked sign-in identities", "signed in", None, 200, Some(identities), B(WAYS_IN)),
333 route!("DELETE" "identities/{id}", "Unlink a sign-in identity", "signed in with a session", None, 204, None, B(WAYS_IN)),
334 route!("GET" "passkeys", "Your passkeys", "signed in", None, 200, Some(passkeys), B(WAYS_IN)),
335 route!("DELETE" "passkeys/{id}", "Remove a passkey", "signed in with a session", None, 204, None, B(WAYS_IN)),
336 route!("POST" "passkeys/register/options", "Begin adding a passkey", "signed in with a session", None, 200, Some(public_key_options), B(WAYS_IN)),
337 route!("POST" "passkeys/register/verify", "Finish adding a passkey", "signed in with a session", Some(credential_body), 201, Some(passkey_answer), B(WAYS_IN)),
338 route!("POST" "passkeys/login/options", "Begin a passkey sign-in", "anyone", Some(passkey_login_body), 200, Some(public_key_options), B(SIGN_IN)),
339 route!("POST" "passkeys/login/verify", "Finish a passkey sign-in", "anyone", Some(credential_body), 200, Some(session_answer), B(SIGN_IN)),
340 route!("GET" "admin/users", "Every user", "platform admins", None, 200, Some(users), T("user_list")),
341 route!("PATCH" "admin/users/{id}", "Disable a user, or grant platform admin", "platform admins", Some(user_change), 200, Some(user_answer), T("user_update")),
342 route!("GET" "orgs/{org}/members", "An org's members", "org members", None, 200, Some(members), T("member_list")),
343 route!("PUT" "orgs/{org}/members/{user_id}", "Change a member's role", "org owners and admins", Some(role_body), 200, Some(role_answer), T("member_update")),
344 route!("DELETE" "orgs/{org}/members/{user_id}", "Remove a member (or leave)", "org owners and admins, or the member leaving", None, 204, None, T("member_remove")),
345 route!("GET" "orgs/{org}/invitations", "An org's pending invitations", "org owners and admins", None, 200, Some(invitations), T("invitation_list")),
346 route!("DELETE" "orgs/{org}/invitations/{id}", "Revoke an invitation", "org owners and admins", None, 204, None, T("invitation_revoke")),
347 route!("GET" "orgs/{org}/agent-identities", "An org's tailnet and Access agent identities", "org members", None, 200, Some(agent_identities), T("agent_identity_list")),
348 route!("PUT" "orgs/{org}/agent-identities", "Map a tailnet or Access identity to a role in the org", "org owners and admins", Some(agent_identity_body), 200, Some(agent_identity_answer), T("agent_identity_set")),
349 route!("DELETE" "orgs/{org}/agent-identities/{id}", "Remove an agent identity", "org owners and admins", None, 204, None, T("agent_identity_remove")),
350 route!("GET" "orgs/{org}/tokens", "Every API token in an org", "org owners and admins", None, 200, Some(org_tokens), T("token_list")),
351];
352
353pub fn schemas() -> Value {
355 let user = obj(
356 json!({
357 "id": {"type": "integer"}, "email": {"type": "string"}, "name": {"type": "string"},
358 "platform_admin": {"type": "boolean"}, "created_at": {"type": "integer"},
359 "disabled": {"type": "boolean"}, "has_password": {"type": "boolean"},
360 }),
361 &[
362 "id",
363 "email",
364 "name",
365 "platform_admin",
366 "created_at",
367 "disabled",
368 "has_password",
369 ],
370 );
371 let token_props = json!({
372 "id": {"type": "integer"}, "name": {"type": "string"}, "user_id": {"type": "integer"},
373 "org": {"type": ["string", "null"]}, "created_at": {"type": "integer"},
374 "last_used": {"type": ["integer", "null"]}, "expires_at": {"type": ["integer", "null"]},
375 "scopes": {"type": "array", "items": {"type": "string"}},
376 });
377 let token_req = [
378 "id",
379 "name",
380 "user_id",
381 "org",
382 "created_at",
383 "last_used",
384 "expires_at",
385 "scopes",
386 ];
387 let mut org_token_props = token_props.clone();
388 org_token_props["user"] = obj(
389 json!({"id": {"type": "integer"}, "email": {"type": "string"}, "name": {"type": "string"}}),
390 &["id", "email", "name"],
391 );
392 let mut org_token_req = token_req.to_vec();
393 org_token_req.push("user");
394 let mut admin_user = user.clone();
395 admin_user["properties"]["memberships"] = json!({"type": "array", "items": r("Membership")});
396 admin_user["properties"]["last_active"] = json!({"type": ["integer", "null"]});
397 for k in ["memberships", "last_active"] {
398 admin_user["required"]
399 .as_array_mut()
400 .expect("required")
401 .push(json!(k));
402 }
403 let mut out = json!({
404 "Role": {"type": "string", "enum": ["owner", "admin", "member", "viewer"]},
405 "AuthError": obj(json!({"error": {"type": "string"}, "message": {"type": "string"}}), &["error", "message"]),
406 "User": user,
407 "AdminUser": admin_user,
408 "Membership": obj(json!({"org": {"type": "string"}, "role": r("Role")}), &["org", "role"]),
409 "Member": obj(json!({"user": r("User"), "role": r("Role"), "last_active": {"type": ["integer", "null"]}}), &["user", "role", "last_active"]),
410 "AgentIdentity": obj(json!({
411 "id": {"type": "integer"}, "org": {"type": "string"},
412 "kind": {"type": "string", "enum": ["tailnet", "access"]},
413 "subject": {"type": "string"}, "role": r("Role"), "note": {"type": "string"},
414 "created_at": {"type": "integer"}, "created_by": {"type": "string"},
415 }), &["id", "org", "kind", "subject", "role", "note", "created_at", "created_by"]),
416 "Session": obj(json!({
417 "id": {"type": "integer"}, "user_id": {"type": "integer"}, "created_at": {"type": "integer"},
418 "last_seen": {"type": "integer"}, "expires_at": {"type": "integer"}, "idle_expires_at": {"type": "integer"},
419 "user_agent": {"type": ["string", "null"]}, "ip": {"type": ["string", "null"]}, "current": {"type": "boolean"},
420 }), &["id", "user_id", "created_at", "last_seen", "expires_at", "idle_expires_at", "user_agent", "ip", "current"]),
421 "ApiToken": obj(token_props, &token_req),
422 "OrgToken": obj(org_token_props, &org_token_req),
423 "Invitation": obj(json!({
424 "id": {"type": "integer"}, "org": {"type": "string"}, "email": {"type": "string"}, "role": r("Role"),
425 "invited_by": {"type": ["integer", "null"]}, "created_at": {"type": "integer"},
426 "expires_at": {"type": "integer"}, "accepted_at": {"type": ["integer", "null"]},
427 }), &["id", "org", "email", "role", "invited_by", "created_at", "expires_at", "accepted_at"]),
428 "InvitationInfo": obj(json!({
429 "org": {"type": "string"}, "email": {"type": "string"}, "role": r("Role"),
430 "expires_at": {"type": "integer"}, "account_exists": {"type": "boolean"},
431 }), &["org", "email", "role", "expires_at", "account_exists"]),
432 "SshKey": obj(json!({
433 "id": {"type": "integer"}, "user_id": {"type": "integer"}, "name": {"type": "string"},
434 "algorithm": {"type": "string"}, "public_key": {"type": "string"}, "fingerprint": {"type": "string"},
435 "created_at": {"type": "integer"}, "last_used": {"type": ["integer", "null"]},
436 }), &["id", "user_id", "name", "algorithm", "public_key", "fingerprint", "created_at", "last_used"]),
437 });
438 if let (Some(s), Value::Object(more)) = (out.as_object_mut(), sign_in_schemas()) {
439 s.extend(more);
440 }
441 out
442}
443
444fn sign_in_schemas() -> Value {
447 let superadmin_via = json!({"type": "object", "properties": {"kind": {"type": "string", "enum": ["token", "tailnet", "access", "dev"]}}, "required": ["kind"], "additionalProperties": true});
448 json!({
449 "SessionAnswer": obj(json!({
450 "user": r("User"),
451 "memberships": {"type": "array", "items": r("Membership")},
452 "session": obj(json!({"id": {"type": "integer"}, "expires_at": {"type": "integer"}, "idle_expires_at": {"type": "integer"}}), &["id", "expires_at", "idle_expires_at"]),
453 }), &["user", "memberships", "session"]),
454 "Identity": obj(json!({
455 "id": {"type": "integer"}, "user_id": {"type": "integer"}, "provider": {"type": "string"},
456 "provider_id": {"type": ["string", "null"]}, "label": {"type": "string"}, "subject": {"type": "string"},
457 "email": {"type": ["string", "null"]}, "email_verified": {"type": "boolean"},
458 "created_at": {"type": "integer"}, "last_used": {"type": ["integer", "null"]},
459 }), &["id", "user_id", "provider", "provider_id", "label", "subject", "email", "email_verified", "created_at", "last_used"]),
460 "EdgeIdentity": obj(json!({
461 "kind": {"type": "string", "enum": ["tailnet", "access"]},
462 "subject": {"type": "string", "description": "The tailnet login, or the Access subject."},
463 "name": {"type": "string", "description": "The tailnet login, or the Access email."},
464 "email": {"type": ["string", "null"], "description": "An email the front door vouches for."},
465 "node": {"type": "string", "description": "The tailnet node."},
466 "can_claim": {"type": "boolean", "description": "May claim first-run setup."},
467 }), &["kind", "subject", "name", "email", "can_claim"]),
468 "Passkey": obj(json!({
469 "id": {"type": "integer"}, "user_id": {"type": "integer"}, "credential_id": {"type": "string"},
470 "name": {"type": "string"}, "alg": {"type": "integer"}, "sign_count": {"type": "integer"},
471 "transports": {"type": "array", "items": {"type": "string"}}, "aaguid": {"type": ["string", "null"]},
472 "created_at": {"type": "integer"}, "last_used": {"type": ["integer", "null"]},
473 }), &["id", "user_id", "credential_id", "name", "alg", "sign_count", "transports", "aaguid", "created_at", "last_used"]),
474 "Provider": obj(json!({
475 "id": {"type": "string"}, "label": {"type": "string"},
476 "kind": {"type": "string", "enum": ["oauth2", "oidc"]}, "start": {"type": "string"},
477 }), &["id", "label", "kind", "start"]),
478 "Providers": obj(json!({
479 "providers": {"type": "array", "items": r("Provider")}, "password": {"type": "boolean"},
480 "passkeys": {"type": "boolean"}, "open_signup": {"type": "boolean"},
481 }), &["providers", "password", "passkeys", "open_signup"]),
482 "Me": obj(json!({
483 "user": r("User"),
484 "platform_admin": {"type": "boolean"},
485 "memberships": {"type": "array", "items": r("Membership")},
486 "orgs": {"type": "array", "items": {"type": "string"}, "description": "Every org this caller can open."},
487 "auth": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["session", "api_token", "access", "superadmin", "workspace", "agent"]}}, "required": ["kind"], "additionalProperties": true, "description": "How the caller signed in: {kind: session, id}, {kind: api_token, id, org, name, scopes?}, {kind: access}, {kind: superadmin, source}, {kind: workspace, org, name}, {kind: agent, label}."},
488 "superadmin": {"oneOf": [{"type": "null"}, obj(json!({"source": {"type": "string"}, "via": superadmin_via, "account": {"type": "boolean"}}), &["source", "via", "account"])]},
489 }), &["user", "platform_admin", "memberships", "orgs", "auth", "superadmin"]),
490 })
491}
492
493pub fn paths() -> serde_json::Map<String, Value> {
495 let mut out = serde_json::Map::new();
496 for rt in ROUTES {
497 let full = format!("{}{}", super::PREFIX, rt.path);
498 let params: Vec<Value> = rt
499 .path
500 .split('/')
501 .filter_map(|s| s.strip_prefix('{').and_then(|s| s.strip_suffix('}')))
502 .map(|p| {
503 let ty = if matches!(p, "id" | "user_id") {
504 "integer"
505 } else {
506 "string"
507 };
508 json!({"name": p, "in": "path", "required": true, "schema": {"type": ty}})
509 })
510 .collect();
511 let mut op = json!({
512 "operationId": operation_id(rt),
513 "tags": ["identity"],
514 "summary": rt.summary,
515 "description": format!("Who: {}.", rt.who),
516 "responses": {"default": {"description": "An error", "content": {"application/json": {"schema": r("AuthError")}}}},
517 });
518 match rt.agents {
519 Agents::Tool(t) => op["x-isb-tool"] = json!(t),
520 Agents::BrowserOnly(why) => op["x-isb-browser-only"] = json!(why),
521 }
522 if !params.is_empty() {
523 op["parameters"] = json!(params);
524 }
525 if let Some(b) = rt.body {
526 op["requestBody"] =
527 json!({"required": true, "content": {"application/json": {"schema": b()}}});
528 }
529 op["responses"][rt.ok.to_string()] = match rt.answer {
530 Some(a) => {
531 json!({"description": "OK", "content": {"application/json": {"schema": a()}}})
532 }
533 None if rt.ok == 303 => json!({"description": "A redirect (Location)"}),
534 None => json!({"description": "No content"}),
535 };
536 let item = out.entry(full).or_insert_with(|| json!({}));
537 item[rt.method.to_ascii_lowercase()] = op;
538 }
539 out
540}
541
542fn operation_id(rt: &Route) -> String {
544 let path: String = rt
545 .path
546 .chars()
547 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
548 .collect();
549 let path = path
550 .split('_')
551 .filter(|s| !s.is_empty())
552 .collect::<Vec<_>>()
553 .join("_");
554 format!("auth_{}_{path}", rt.method.to_ascii_lowercase())
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 fn router_arms() -> Vec<String> {
563 let arm = regex_lite_arms("");
564 let mut out = Vec::new();
565 for (src, org) in [
568 (include_str!("../http.rs"), false),
569 (include_str!("org.rs"), true),
570 ] {
571 for line in src.lines() {
572 for (m, segs) in arm(line) {
573 let p = segs.join("/");
574 out.push(if org {
575 format!("{m} orgs/{{}}/{p}")
576 } else {
577 format!("{m} {p}")
578 });
579 }
580 }
581 }
582 out.sort();
583 out.dedup();
584 out
585 }
586
587 fn regex_lite_arms(_src: &str) -> impl Fn(&str) -> Vec<(String, Vec<String>)> {
589 |line: &str| {
590 let mut found = Vec::new();
591 let mut rest = line;
592 while let Some(i) = rest.find("(\"") {
593 let after = &rest[i + 2..];
594 let Some(q) = after.find('"') else { break };
595 let method = &after[..q];
596 let tail = &after[q + 1..];
597 rest = tail;
598 if !matches!(method, "GET" | "POST" | "PUT" | "PATCH" | "DELETE") {
599 continue;
600 }
601 let Some(tail) = tail.strip_prefix(", [") else {
602 continue;
603 };
604 let Some(end) = tail.find("])") else { continue };
605 let segs: Vec<String> = tail[..end]
606 .split(',')
607 .map(str::trim)
608 .filter(|s| !s.is_empty())
609 .map(
610 |s| match s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
611 Some(lit) => lit.to_string(),
612 None => "{}".to_string(),
613 },
614 )
615 .collect();
616 found.push((method.to_string(), segs));
617 }
618 found
619 }
620 }
621
622 #[test]
623 fn the_table_is_the_router() {
624 let mut table: Vec<String> = ROUTES
625 .iter()
626 .map(|r| {
627 let p: Vec<&str> = r
628 .path
629 .split('/')
630 .map(|s| if s.starts_with('{') { "{}" } else { s })
631 .collect();
632 format!("{} {}", r.method, p.join("/"))
633 })
634 .collect();
635 table.sort();
636 let before = table.len();
637 table.dedup();
638 assert_eq!(before, table.len(), "a route is listed twice");
639 assert_eq!(table, router_arms());
640 }
641
642 #[test]
643 fn paths_and_operations_are_unique() {
644 let p = paths();
645 let ops: Vec<String> = ROUTES.iter().map(operation_id).collect();
646 let mut sorted = ops.clone();
647 sorted.sort();
648 sorted.dedup();
649 assert_eq!(sorted.len(), ops.len());
650 assert!(p["/api/v1/auth/orgs/{org}/members/{user_id}"]["put"].is_object());
651 assert_eq!(
652 p["/api/v1/auth/tokens"]["post"]["x-isb-tool"],
653 "token_create"
654 );
655 }
656
657 #[test]
659 fn schemas_match_the_types() {
660 use crate::auth::{ApiToken, Invitation, Membership, Role, Session, User};
661 use crate::org::OrgId;
662 let s = schemas();
663 let keys = |v: Value| -> Vec<String> {
664 let mut k: Vec<String> = v.as_object().unwrap().keys().cloned().collect();
665 k.sort();
666 k
667 };
668 let props = |name: &str| -> Vec<String> {
669 let mut k: Vec<String> = s[name]["properties"]
670 .as_object()
671 .unwrap()
672 .keys()
673 .cloned()
674 .collect();
675 k.sort();
676 k
677 };
678 let user = User {
679 id: 1,
680 email: "a@x.io".into(),
681 name: "A".into(),
682 platform_admin: false,
683 created_at: 0,
684 disabled: false,
685 has_password: true,
686 };
687 assert_eq!(keys(serde_json::to_value(&user).unwrap()), props("User"));
688 let org = OrgId::new("acme").unwrap();
689 let m = Membership {
690 org: org.clone(),
691 role: Role::Admin,
692 };
693 assert_eq!(keys(serde_json::to_value(&m).unwrap()), props("Membership"));
694 let t = ApiToken {
695 id: 1,
696 name: "t".into(),
697 user_id: 1,
698 org: Some(org.clone()),
699 created_at: 0,
700 last_used: None,
701 expires_at: None,
702 scopes: vec![],
703 };
704 assert_eq!(keys(serde_json::to_value(&t).unwrap()), props("ApiToken"));
705 let i = Invitation {
706 id: 1,
707 org,
708 email: "a@x.io".into(),
709 role: Role::Member,
710 invited_by: None,
711 created_at: 0,
712 expires_at: 0,
713 accepted_at: None,
714 };
715 assert_eq!(keys(serde_json::to_value(&i).unwrap()), props("Invitation"));
716 let sess = Session {
717 id: 1,
718 user_id: 1,
719 created_at: 0,
720 last_seen: 0,
721 expires_at: 0,
722 idle_expires_at: 0,
723 user_agent: None,
724 ip: None,
725 };
726 let mut v = serde_json::to_value(&sess).unwrap();
727 v["current"] = json!(true);
728 assert_eq!(keys(v), props("Session"));
729 let k = crate::auth::ssh_keys::SshKey {
730 id: 1,
731 user_id: 1,
732 name: "k".into(),
733 algorithm: "ssh-ed25519".into(),
734 public_key: "x".into(),
735 fingerprint: "SHA256:x".into(),
736 created_at: 0,
737 last_used: None,
738 };
739 assert_eq!(keys(serde_json::to_value(&k).unwrap()), props("SshKey"));
740 let pk = crate::auth::external::Passkey {
741 id: 1,
742 user_id: 1,
743 credential_id: "c".into(),
744 name: "n".into(),
745 alg: -7,
746 sign_count: 0,
747 transports: vec![],
748 aaguid: "00".into(),
749 created_at: 0,
750 last_used: None,
751 };
752 assert_eq!(keys(serde_json::to_value(&pk).unwrap()), props("Passkey"));
753 }
754}