Skip to main content

isb_server/auth/http/
spec.rs

1//! The identity endpoints as data: every route [`super::AuthApi`] answers,
2//! with who may call it, its body and its answer, and the MCP tool that
3//! does the same (or why there is none). The OpenAPI document is built from
4//! this table, and a test holds it to the router's own `match` arms, so the
5//! two cannot drift apart.
6
7use serde_json::{Value, json};
8
9/// Where the same capability is for agents.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Agents {
12    /// This tool does the same, with the same rules.
13    Tool(&'static str),
14    /// Deliberately not a tool, and why.
15    BrowserOnly(&'static str),
16}
17
18/// One identity endpoint.
19#[derive(Debug, Clone, Copy)]
20pub struct Route {
21    pub method: &'static str,
22    /// Under `/api/v1/auth/`, with `{param}`s.
23    pub path: &'static str,
24    pub summary: &'static str,
25    /// Who may call it.
26    pub who: &'static str,
27    /// The JSON body, when it takes one.
28    pub body: Option<fn() -> Value>,
29    /// The success status.
30    pub ok: u16,
31    /// The success answer (`None`: no body, or a redirect).
32    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}
190/// Who reaches every org without a mapping: a count for any member, the
191/// names for owners and admins only.
192fn 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
305/// Every identity endpoint.
306pub 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
353/// The schemas the identity endpoints answer with, by name.
354pub 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
444/// The schemas of the sign-in answers: sessions, identities, passkeys,
445/// providers and `me`.
446fn sign_in_schemas() -> Value {
447    let superadmin_via = json!({"type": "object", "properties": {"kind": {"type": "string", "enum": ["token", "tailnet", "access"]}}, "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
493/// The OpenAPI path items for the identity endpoints, keyed by full path.
494pub 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
542/// `auth_get_orgs_org_members` and the like: unique and stable.
543fn 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    /// The router's `("METHOD", ["seg", var, ...])` arms, as `METHOD a/{}/b`.
562    fn router_arms() -> Vec<String> {
563        let arm = regex_lite_arms("");
564        let mut out = Vec::new();
565        // The org endpoints are in their own file: every arm there is under
566        // `orgs/{org}/`.
567        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    /// A tiny matcher for `("GET", ["a", b, "c"])`, without a regex crate.
588    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    /// The hand-written schemas name exactly the fields the types serialize.
658    #[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}