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