HTTP API¶
All the HTTP routes kubelatch serves, who can call them, and what they do. The web interface uses this same API; you can call it with curl using a session cookie.
Who can call¶
| Access | What's needed |
|---|---|
| Anonymous | Nothing. |
| Join cookie | The cookie the OIDC callback leaves for ten minutes when someone without an account signs in: __Host-kubelatch_join with an https base URL, kubelatch_join on plain http. Only on /api/join (Onboarding). |
| Session | The session cookie of a person with an enabled account: __Host-kubelatch_session with an https base URL, kubelatch_session on plain http. Without it, 401. |
| Admin | Session of a person with the administrator role. Without the role, 403. |
Bearer klt_ |
Authorization: Bearer klt_…, a kubelatch credential. |
| CLI token | Authorization: Bearer klt_… with the credential of a kubelatch login (kind cli). Only on the routes marked "CLI token"; any other /api route answers 403 cli.scope. It never counts as an admin. |
| Agent token | Authorization: Bearer klt_… with the access token a delegated AI agent gets by signing in (kind agent). Valid only at /mcp: the proxy and /api answer 401. |
| Actions OIDC token | The id_token of a GitHub Actions job, in the body. |
| GitHub signature | X-Hub-Signature-256 header with the HMAC of the body and GITHUB_WEBHOOK_SECRET. |
/api conventions¶
- JSON bodies up to 64 KiB. Unknown fields and extra data are rejected (
400); a larger body is413. - Every response carries
Cache-Control: no-store. - Errors are
{"error": "<English text>", "code": "<code>", "params": {…}}(see Errors). - An unknown route under
/api/is404; a known route with another method is405with anAllowheader. - Modifying requests (
POST,PUT,PATCH,DELETE) sent by a browser from another origin are rejected with403.curland other non-browser clients pass. - Every response outside the proxy carries an
X-Request-Idheader with a fresh UUIDv7. A value sent by the client is ignored. The same id is in the server's access log line: see The server log.
Health¶
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/healthz |
Anonymous | 200 ok if the process is alive. |
GET |
/readyz |
Anonymous | 200 ok if Postgres responds within 2 s; otherwise, 503 database unavailable. |
They only accept GET and HEAD; any other method is 405.
Proxy¶
| Method | Route | Access | What it does |
|---|---|---|---|
| Any | /clusters/<id>/<Kubernetes API route> |
Bearer klt_ |
Forwards the request to cluster <id>'s API server, acting as the credential's owner (impersonation). /clusters/<id> and /clusters/<id>/ are equivalent to /. Ignores cookies. See The journey of a request. |
Proxy errors are Kubernetes Status objects, so kubectl shows them well (Proxy errors). /clusters without a trailing slash responds 404.
CI and GitHub (outside /api)¶
These two routes use no session, cookies or origin protection: authentication travels in the request itself. They only accept POST (405 with Allow for anything else).
| Method | Route | Access | What it does |
|---|---|---|---|
POST |
/v1/ci/github-actions/token |
Actions OIDC token | Exchanges {"token": "<id_token>"} for a bot credential of kind ci according to the matching trust rule. Responds 201 with credential, token, kubeconfig, clusters and expires_at, once only. Limit: 60 requests per minute per IP. See Credentials for GitHub Actions. |
POST |
/v1/github/webhook |
GitHub signature | Receives the GitHub App's events. organization/member_removed instantly disables the linked account; installation/deleted or suspend are logged; the rest are accepted and ignored. Requires Content-Type: application/json and a body up to 1 MiB. Without GITHUB_WEBHOOK_SECRET it responds 404. |
MCP and agent sign-in (outside /api)¶
No session, cookie or origin protection of /api applies here. They exist while KUBELATCH_MCP_ENABLED is true; otherwise 404. MCP server describes them in full.
| Method | Route | Access | What it does |
|---|---|---|---|
POST |
/mcp |
Bot klt_ or agent token |
The MCP server (Streamable HTTP, one JSON-RPC message per request). Every 401 carries WWW-Authenticate: Bearer resource_metadata="…". |
GET |
/.well-known/oauth-protected-resource/mcp |
Anonymous | Protected resource metadata (RFC 9728). |
GET |
/.well-known/oauth-authorization-server |
Anonymous | Authorization server metadata (RFC 8414). |
POST |
/oauth/register |
Anonymous | Dynamic client registration (RFC 7591). 20 per minute per IP. |
GET |
/oauth/authorize |
Anonymous | Starts a sign-in and sends the browser to the consent page. 30 per minute per IP. |
POST |
/oauth/token |
Anonymous | Code exchange and refresh. 120 per minute per IP, counted together with /oauth/revoke. |
POST |
/oauth/revoke |
Anonymous | Token revocation (RFC 7009). Counts against the same 120 per minute per IP as /oauth/token. |
Any other path under /.well-known/ or /oauth/ is a JSON 404.
Session and own account¶
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/api/auth/methods |
Anonymous | What the login page offers, always 200: {"password": false, "github": true, "org": "acme", "oidc": true, "oidc_name": "Google", "oidc_icon": "google", "access_contact": "mailto:it@acme.com"}. password is true only while neither GitHub nor OIDC login is on; oidc_name is OIDC_DISPLAY_NAME, "" with OIDC off; oidc_icon is the logo of its button (google, microsoft, okta, auth0 or generic), from OIDC_ICON or deduced from OIDC_ISSUER, "" with OIDC off; access_contact is KUBELATCH_ACCESS_CONTACT, or ""; join is true with OIDC login on, when someone without an account can sign in and ask to join. |
POST |
/api/auth/login |
Anonymous | Password login: {"login", "password"}. Opens a session and returns the account. |
POST |
/api/auth/logout |
Session | Closes all of the person's sessions. 204. |
POST |
/api/auth/link |
Anonymous | Looks up an invitation or linking link without consuming it: {"token": "kli_…"} → login, display_name, purpose, expires_at, password_allowed (the account can use a password) and the external logins that are on, which the link page offers: github, oidc and oidc_name. |
POST |
/api/auth/link/complete |
Anonymous | Completes a link by setting the password: {"token", "password"}. Opens a session. |
GET |
/api/me |
Session, CLI token | Your own account: login, name, email, is_admin, whether it has and can use a password, break-glass status, GitHub link, oidc (its identity of the configured OIDC provider: issuer, subject, email and linked_at, or null), locale (the interface language, null until the person picks one), edition (free or pro), features (the edition's features in force: ["agent_insights", "webhooks", "environment_rules", "cluster_sets", "access_requests", "scope_owners"] in Pro, agent_insights showing what each agent did in the last day and which ones deserve a look, webhooks the webhooks of the notices, environment_rules setting and changing the rules of an environment, cluster_sets granting and extending permissions on a set and access_requests asking for access, and the link a cluster's 403 carries (403 — no permission), and scope_owners naming the owners of an environment, a cluster, a namespace or a group, and their acting as owners; [] in Free; e-mailed links, the old email_links, are Free) and mail_configured (e-mail is set up on the server; always false for someone who isn't an admin), notify_grant_email (whether you get the e-mail before one of your permissions expires; PATCH /api/me/notifications changes it), provider_logos (which provider logos the interface draws: published, all or none, as KUBELATCH_PROVIDER_LOGOS sets) and last_activity_at (when the person's last request went through the proxy, null before the first). notice_email is where your notices go, "" for nowhere, and notice_email_source where it comes from: account (your e-mail), oidc (the e-mail of your OIDC identity, without one of your own) or "" (The account's e-mail). oidc also carries linked_via: email, link, me, domain or join. decides is true while an access request waits for your decision. owns lists the scopes you own now, yourself or through a group, as an owner's scope; [] when none and without scope_owners. Under a CLI token is_admin and decides are always false and owns is []. |
PATCH |
/api/me/notifications |
Session | Turns on or off the e-mail before one of your permissions expires (Notifications): {"grant_email": false} → the updated me, whose notify_grant_email says the value. 400 request.bad_json without a boolean grant_email. |
GET |
/api/me/notices |
Session | The bell's warnings for you: {"notices": [...]}, each with a kind. grant_expiring: {"kind": "grant_expiring", "grant_id", "cluster", "cluster_name", "role", "scope", "expires_at", "direct"}, your active permissions that end within 24 hours, soonest first; direct is true for a permission of your own and false for one that comes from a group or a set, which a request can't extend. After them, access_request_decided: {"kind": "access_request_decided", "request_id", "status", "cluster", "cluster_name", "role", "scope", "decided_by": {"id", "login", "name"}, "granted_until", "note", "at"}, one per access request of yours that a person approved or denied in the last 24 hours, the latest decision first (granted_until is null for a denial, note is what the person who decided wrote you, at when they did); an approval by an environment's rule, a cancellation and an expiry get none. Then access_requests_waiting: {"kind": "access_requests_waiting", "count"}, how many requests wait for your decision now, while there is one. Then, for an administrator, break_glass: {"kind": "break_glass", "subject_id", "login", "at", "until"}, one per break-glass account, for 12 hours after its latest password sign-in (until is at plus 12 hours). Then, for an administrator outside Pro with active webhooks, webhooks_paused: {"kind": "webhooks_paused", "count"}, how many of them deliver nothing (When Pro ends). Then, for an administrator in Pro, agent_worth_a_look: {"kind": "agent_worth_a_look", "session_id", "agent": {"mode", "client_name", "subject_login"}, "look", "count", "at"}, one per agent with an active session that deserves a look, the most recent first. look (flood, sensitive, insistence or denied), count and at are the agent's most recent motive, as worth_a_look lists it in GET /api/agents/sessions, and session_id is the agent's active session used last, the one the line opens. It is worked out on each read and goes when the motive does; in Free only the Pro lines are missing, and anyone who isn't an admin gets none of these (Notifications). |
PATCH |
/api/me/locale |
Session | Saves your interface language: {"locale": "en"} or {"locale": "es"} → the updated me. 400 locale.invalid for anything else. |
POST |
/api/me/password |
Session | Changes your own password: {"current_password", "new_password"}. Closes the other sessions. |
GitHub login¶
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/api/auth/github/login |
Anonymous | Sets the state cookie and redirects (302) to GitHub to authorize the App. The optional ?next= is the page the callback lands on: a same-origin path only, anything else lands on /. |
GET |
/api/auth/github/callback |
Anonymous with state cookie | Return from GitHub. Redirects with a session to the login's next (or /; always / after an invitation or linking link), to /account?github=linked after linking from My account, or to /login?error=<code> (/account?error=<code>) if it fails. |
POST |
/api/auth/github/link |
Anonymous | Starts completing an invitation or linking link with GitHub: {"token": "kli_…"} → {"url"} to navigate to. |
POST |
/api/me/github/link |
Session | Starts linking your own account, if not already linked (409 if it already is) → {"url"}. |
GET |
/api/github/status |
Admin | Configuration (configured, org, webhook, require_org_2fa) and the last member sync (last_sync: at, the counts members, linked, disabled and renamed, skipped, two_factor_required, and error, a cause code: list_members, no_members, database or disable_failed, empty when it succeeded). Absent until the first sync. |
Without GitHub configured, /api/auth/github/login, /api/auth/github/callback, /api/auth/github/link and /api/me/github/link respond 404.
OIDC login¶
The routes of OIDC login. Without OIDC configured, all of them but /api/oidc/status respond 404 oidc.disabled.
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/api/auth/oidc/login |
Anonymous | Sets the state cookie and redirects (302) to the provider's authorization endpoint. The optional ?next= is the page the callback lands on: a same-origin path only, anything else lands on /. ?select=1 asks the provider for its account picker. If the provider's discovery document can't be read, it redirects (303) to /login?error=provider_unavailable&via=oidc. It is also the URL to put in a tile of the provider's app launcher, since kubelatch accepts no sign-in started by the provider (No sign-in started by the provider). |
GET |
/api/auth/oidc/callback |
Anonymous with state cookie | Return from the provider; it always clears the state cookie. Redirects (303) with a session to the login's next (or /; always / after an invitation or reset link), to /account?oidc=linked after linking from My account, or to /login?error=<code>&via=oidc (/account?error=<code>&via=oidc) if it fails: the codes are in Errors. A sign-in whose e-mail no account has, and that no domain rule signs up, goes to /join instead, with the join cookie (Onboarding). |
POST |
/api/auth/oidc/link |
Anonymous | Starts completing an invitation or linking link with the provider: {"token": "kli_…"} → {"url"} to navigate to, with the state cookie. Rate-limited per IP like POST /api/auth/link. Besides the link's errors (404 link.invalid, 410 link.gone), 503 oidc.unavailable while the discovery document can't be read. |
POST |
/api/me/oidc/link |
Session | Starts linking your own account → {"url"}, with the state cookie. 409 oidc.already_linked if the account already has an identity of the configured provider; 503 oidc.unavailable while the discovery document can't be read. |
GET |
/api/oidc/status |
Admin | Always 200. With OIDC off, {"configured": false}. On: issuer, name, domains, autolink, subject_claim, email_verified_claim, public_client, discovery (fetched_at, null until the first success, and error, the last failed fetch, "" once a later one succeeds), jwks_fetched_at (null until the first success) recent_email_links, the accounts linked by themselves in the last seven days, newest first, at most 50 (subject_id, login, display_name, email, linked_at and linked_via: email, domain for a domain sign-up or join for an approved request), and signup_domains, how many domain rules there are. Each replica answers with its own discovery and jwks_fetched_at. |
Onboarding¶
Domain access and join requests. /api/join has no session: its only proof of who is asking is the join cookie (__Host-kubelatch_join over https, kubelatch_join on plain http, ten minutes), which the OIDC callback leaves for a sign-in whose e-mail no account has. Both of its methods share a per-IP limit of their own, 30 a minute and apart from the login's (429 auth.too_many_attempts). The rest needs the administrator role. Without OIDC configured, /api/join answers 401 join.no_identity.
The join requests share /api/access-requests with the access requests of people who have an account. A join request exists only for an administrator: anyone else who names one gets 404 access_request.not_found, and so does an unknown or malformed id.
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/api/join |
Join cookie | Where the cookie's identity stands: {"status", "provider_name", "email", "name", "reason", "requested_at", "expires_at", "decided_at", "access_contact"}. status is new, pending, denied, expired, approved or cancelled; reason is why the callback signed nobody up (none, seat_limit or login_taken); the dates a status doesn't have are null. The admin's note is never in it. 401 join.no_identity without a valid cookie, with one already used, or once an account holds the identity. |
POST |
/api/join |
Join cookie | Asks to join: {"reason"}, optional, up to 500 characters without control characters → 201 with {"status": "pending", "requested_at", "expires_at"}, 30 days later. It consumes the cookie. 409 join.pending if the identity already has a pending request, 429 join.too_many with 100 pending, 400 join.bad_reason, 401 join.no_identity. |
GET |
/api/access-requests |
Admin | Without view, a bare array of join requests: ?status=pending (the default), oldest first, or ?status=decided, newest first, at most 200 (400 query.bad_request_status otherwise); ?kind=join&view=waiting and ?kind=join&view=decided are the same two lists. Each one: id, kind (join), status, email, name, issuer, reason, created_at, expires_at, suggested_login (for a pending one, the first login the e-mail gives that no account has; "" when both are taken), decided_by_login, decided_at, decision_note and result_subject_id (null or empty while pending). |
POST |
/api/access-requests/{id}/approve |
Admin | Creates the account, links its identity and gives its first access, all or nothing: {"login", "access": {"kind": "group", "group_id"}}, {"login", "access": {"kind": "grant", "grant": {"cluster_id", "role", "scope", "expires_at", "never_expires", "note", "ticket"}}} (the expiry as in POST /api/grants) or {"login", "access": {"kind": "none"}} → the new account, as GET /api/subjects/{id}. 400 join.bad_access, 400 subject.invalid_login, 409 subject.login_taken, 409 join.email_taken (an account has that e-mail or identity by now), 409 join.decided, 410 join.expired, 404 access_request.not_found, 422 edition.limit, and the group's or the permission's own errors as their routes answer them. |
POST |
/api/access-requests/{id}/deny |
Admin | Denies it: {"note"}, an internal note up to 500 characters (400 join.bad_note) → the request as the list shows it. 409 join.decided, 410 join.expired, 404 access_request.not_found. |
GET |
/api/signup-domains |
Admin | A bare array of the domain rules: domain, group_id, group_name, member_ttl_seconds (null for a membership without expiry), allowed (false once the domain left OIDC_ALLOWED_DOMAINS: the rule does nothing), signups (accounts it created), members (its live memberships), created_by_login and created_at. |
POST |
/api/signup-domains |
Admin | Creates a rule: {"domain", "group_id", "member_ttl_seconds"}, or with a new group, {"domain", "new_group": {"id", "name", "grant": {"cluster_id", "role", "scope", "expires_at", "never_expires", "note", "ticket"}}, "member_ttl_seconds"} (grant optional, its expiry as in POST /api/grants, note and ticket as there: 400 grant.reason_required or grant.ticket_required when the cluster's environment asks for them), the group, its permission and the rule in one transaction → 201 with the rule. 422 signup_domain.not_allowed for a domain outside OIDC_ALLOWED_DOMAINS, 409 signup_domain.exists, 400 signup_domain.bad_group without exactly one of group_id and new_group, 400 signup_domain.bad_ttl outside 1 minute to 3650 days, 404 group.not_found, and the group's own errors. |
PATCH |
/api/signup-domains/{domain} |
Admin | Changes the membership expiry of the people the rule signs up from now on: {"member_ttl_seconds": 2592000}, required, null for no expiry → the rule. 404 signup_domain.not_found, 400 signup_domain.bad_ttl. |
DELETE |
/api/signup-domains/{domain} |
Admin | Removes the rule and the live memberships it added, with their derived permissions → 204. The accounts stay enabled. 404 signup_domain.not_found. |
Users and bots¶
All require admin.
| Method | Route | What it does |
|---|---|---|
GET |
/api/subjects |
Lists people and bots. Each one carries access: {"groups", "direct"}, the active groups it belongs to and its active direct permissions; link_expires_at, when its pending invitation or reset link expires (null without one); last_activity_at, the latest of its last login, its last credential use and its last audited request (null if it never did anything); pending_link_delivery, how its pending link went out, link or email (null without one); and oidc, its identity of the configured OIDC provider (issuer, subject, email, linked_at and linked_via, how it was linked: email, link, me, domain or join; null without one, or with OIDC off). notice_email and notice_email_source say where its notices go, as in GET /api/me. Other responses that return a subject carry access at zero and link_expires_at, last_activity_at, pending_link_delivery and oidc null, and a notice_email that is the account's own e-mail, except PATCH /api/subjects/{id}'s, which also looks at the identity. |
GET |
/api/subjects/{id} |
One person or bot, with the same access, link_expires_at, last_activity_at, pending_link_delivery and oidc as the list, plus github_linked_at, when the account linked its GitHub account, and password_changed_at, when its password last changed (null for never). 404 subject.not_found for an unknown id or one that isn't a uuid. |
POST |
/api/subjects |
Creates a person ({"login", "display_name", "email", "is_admin", "delivery"}) and returns subject and its invite_link once only, with link_expires_at, when that link expires (RFC 3339). With "delivery": "email" the link goes to the account's e-mail instead, and the response carries delivered_to in place of invite_link: see E-mailed invitations for its errors. 409 subject.email_taken when another account has the e-mail. Or creates a bot ({"kind": "bot", "login", "display_name"}) → subject. |
PATCH |
/api/subjects/{id} |
Changes a person's display_name, email or is_admin → the account, with revoked_links, how many links that went out by e-mail the change of e-mail cancelled (absent at 0). A new email is a single address or "" to remove it (400 subject.email_invalid), no other account's (409 subject.email_taken), and, when a first OIDC sign-in with it could open the account, now or after an unlink, needs "confirm_autolink": true (409 subject.email_autolinks, with params.domain): see The account's e-mail. On a bot, only {"agent_writes_need_approval": true\|false}, the switch of its AI agents (a profile field on a bot is 409; the switch on a person, 400 subject.not_bot). |
POST |
/api/subjects/{id}/reset-link |
Generates a reset or linking link (24 h) and returns it once as reset_link, with link_expires_at, when it expires (RFC 3339). With the optional body {"delivery": "email"} it e-mails the link and answers delivered_to in place of reset_link. Revokes pending ones and instantly closes that person's sessions. 409 if the account is disabled. |
POST |
/api/subjects/{id}/disable |
Disables a person or bot: closes sessions and revokes credentials. An account already disabled is answered as it is. |
POST |
/api/subjects/disable |
Disables from 1 to 500 people or bots at once: {"ids": [...], "reason": ""} → 200 with {"batch_id", "subjects": [...], "unchanged": [...]}, as in bulk operations. Each account is disabled as in the single disable: its sessions, credentials and agent sessions end at once. subjects holds the accounts disabled, as the list shows them. 409 subject.last_admin, with params.index and params.id, for the account that would leave no administrator able to sign in, counting those the same batch disables before it: nothing is disabled. 404 subject.not_found for an id that isn't an account. An account already disabled comes back in unchanged. |
POST |
/api/subjects/{id}/enable |
Re-enables them. Revoked credentials stay revoked. |
POST |
/api/subjects/{id}/logout |
Closes all of a person's sessions. |
DELETE |
/api/subjects/{id}/github |
Unlinks a person's GitHub account and closes their sessions. |
DELETE |
/api/subjects/{id}/oidc |
Unlinks a person's OIDC identity and closes their sessions → the account. Without an identity it answers 200 and changes nothing. 409 subject.last_admin for the last administrator who can sign in, 409 subject.not_user for a bot, 404 oidc.disabled without OIDC configured. |
POST |
/api/mail/test |
Sends you the test e-mail at your own notice_email, with no body (a field is 400 request.bad_json) → {"to"}. 409 mail.not_configured, 422 mail.no_address, 429 mail.rate_limited (5 an hour, one at a time), 502 mail.test_failed with params.status (Resend's, "0" when it didn't answer) and params.name (the name of Resend's error, "" when unknown). |
Clusters¶
All require admin, except GET /api/clusters/{id}/namespaces, which the owner of the whole cluster reads too.
| Method | Route | What it does |
|---|---|---|
GET |
/api/clusters |
Lists clusters with status, last reconciliation, last error, last_error_at (when that error happened; null unless the status is error), bootstrap_version (1 or 2: the bootstrap manifest the last reconciliation found; custom and native roles need 2), user_template, the cluster's identity template (user:{login} by default), and unnamed_logins, the accounts with an active permission that the template could not name in the last reconciliation (a list, [] when it named everyone): a warning, not an error, since the cluster stays ready and those accounts get a 403 until they have the identity fact the template needs. Each cluster also carries its provider: provider, the one shown (eks, gke, aks, doks, openshift, k3s, rke2, kapsule, sks, ovh-mks, aruba-mks, talos, kind or kubernetes: the one set by hand, else the one deduced, else kubernetes), provider_detected, what the last reconciliation deduced ("" until the first one; never kind) and provider_manual, true when provider was set by hand. Where it sits: environment, the id of its environment (null in none), production, whether that environment is marked as production (false in none), and labels, its labels as an object ({} with none, never null). Three booleans say what the cluster's health needs: k8s_unsupported (its Kubernetes version is out of support), everyone_privileged (the cluster gives privileged access to everyone it authenticates) and pass_overdue (enrolled, and its latest reconciliation, successful or not, is more than 30 minutes old). Finally problems: what the last failed reconciliation found wrong, one entry per problem and none cut ([] when there is none, never null; last_error holds the same lines, cut at 1,000 characters). Each has code and text, the English line; the rest depends on the code: privileged (a name the cluster gives privileged access that a permission may not give: name, name_kind group or user, reason as in rbac-subjects, culprits, victims, and more_culprits and more_victims when a list is cut at 100), collision (name, logins), bootstrap_incomplete, bootstrap_outdated (what: custom or native), role_refused (role_id), identity_template (template), apply_failed (object: {"verb", "kind", "name", "namespace", "status"}, status the cluster's HTTP status or 0), reconciler_client, unreachable (the reconciler could not reach the API server: cause is network for a connection that got no answer, unauthorized for a 401 to its token) and generic (text alone). A culprit is {"kind", "id", …}: a grant (login, role, scope), a group_grant, a group's permission (group_id, group_name, role, scope) or an agent_session (login). victims are the ids of the accounts the refusal locks out of the cluster. |
POST |
/api/clusters |
Registers a cluster: {"id", "name"}. The id is the proxy URL's slug. |
GET |
/api/clusters/{id} |
A cluster, with the same fields as the list, last_error_at and problems included. |
PATCH |
/api/clusters/{id} |
Saves the identity template and the provider: {"user_template": "oidc:{email}", "provider": "k3s"} → the cluster. It takes these two fields, each optional, and at least one; any other, or none, answers 400. Both are checked first and saved together: if either is refused, nothing is saved. An invalid template answers 400 with its code (cluster.template_variables, cluster.template_bad_literal, cluster.template_reserved_prefix, cluster.template_too_long), and {email} or {oidc_subject} without OIDC login configured, 409 cluster.template_needs_oidc. If another change holds the cluster for more than two seconds, 409 cluster.busy: try again. The template records cluster.identity.update and reconciles the cluster. provider is one of eks, gke, aks, doks, openshift, k3s, rke2, kapsule, sks, ovh-mks, aruba-mks, talos, kind or kubernetes and sets the provider by hand; "" hands it back to the deduction and leaving it out changes nothing; any other value answers 400 cluster.provider_invalid. It records cluster.provider.update (before and after, "" for automatic) only when the value changes, and starts no reconciliation. |
PUT |
/api/clusters/{id}/placement |
Sets the cluster's environment and labels at once: {"environment": "prod", "labels": {"team": "payments", "region": "eu"}} → the cluster, with cut, lost and gained as in PATCH /api/environments/{id}. It replaces both: environment is an environment's id, or null for none, and labels all its labels, {} for none. Both fields are required: a body without one, with another field or with a value of another type answers 400 request.bad_json and changes nothing. A label off the format answers 400 cluster.label_invalid, with the label's key in params.key; the key environment or entorno, 400 cluster.label_reserved; more than 20 labels, 400 cluster.labels_too_many. An environment that doesn't exist is 400 environment.not_found, and a cluster that doesn't, 404 cluster.not_found. If another change holds the cluster or the environment for more than two seconds, 409 cluster.busy: nothing is written; try again. An optional base, with the same two fields, is the placement the change was built from: when the cluster holds another one, because someone changed it since it was read, the answer is 409 cluster.placement_changed and nothing is written; the web interface always sends it. It records cluster.placement (from and to, each with environment and labels, cut, how many permissions it cut, and lost and gained, the permissions on a set that stopped or started reaching the cluster) even when nothing changes. Into an environment with a maximum lifetime, it shortens on the cluster what passes it, as PATCH /api/environments/{id} does; the permissions on a set that follow the environment or the labels it enters reach the cluster, and those of the ones it leaves stop reaching it. It reconciles the cluster when any of this changes a permission; any other placement changes none and starts no reconciliation. |
POST |
/api/clusters/{id}/placement/preview |
What PUT /api/clusters/{id}/placement with {"environment", "labels"} (both required, no base) would cut, writing nothing: {"cut", "lost", "gained"}, as in POST /api/environments/{id}/preview. It refuses what the PUT refuses, with the same codes. |
GET |
/api/cluster-labels |
The label keys in use, each with its values: {"labels": [{"key": "region", "values": ["eu", "us"]}]}, keys and values sorted, each once. What Group by and the Label filter offer. |
DELETE |
/api/clusters/{id} |
Cleans up the RBAC kubelatch manages in the cluster and deletes the cluster, its permissions and the credentials restricted to it. Returns rbac_cleanup with status (ok, error or skipped), error, bindings_deleted, roles_deleted and lock_held. |
GET |
/api/clusters/{id}/bootstrap.yaml |
Downloads the bootstrap manifest to apply in the cluster. |
POST |
/api/clusters/{id}/tokens |
Pastes the bootstrap JSON: {"server", "ca", "proxyToken", "reconcilerToken"}. Validates, encrypts, and kicks off the first reconciliation. |
POST |
/api/clusters/{id}/enrollment |
Creates a single-use enrollment link, valid for one hour: {"api_url"?, "in_cluster"?, "rotate"?} → 201 with {"id", "command", "expires_at"}. The kle_ token appears only inside command, once. in_cluster fixes https://kubernetes.default.svc and conflicts with api_url (400 enrollment.url_conflict). A new link replaces the previous one. |
GET |
/api/clusters/{id}/enrollment |
The cluster's last enrollment: {"id"?, "state": "none"\|"pending"\|"running"\|"used"\|"expired"\|"revoked", "expires_at"?, "last_failure", "method", "in_cluster_ca", "apiserver_user"?, "apiserver_groups"?, "api_url"?, "ca"?, "created_by"?}. id is absent with state none. last_failure is null, or the latest post of the script that kubelatch refused, or why an enrollment with an identity failed: {"at", "code", "params", "reason"}. code and params are the refusal's coded form, which the interface translates (code is empty for an internal error), and reason is its English text. method is script or identity (empty with state none); running is an enrollment with an identity whose callback is applying the manifest. in_cluster_ca says whether the server has the CA of the cluster it runs in. apiserver_user and apiserver_groups are who the API server saw in an enrollment with an identity that reached it. api_url, ca and created_by come only with an enrollment with an identity: where it pointed (neither is a secret), so a retry starts filled in, and the id of the account that started it; only that account gets api_url and ca. It never carries a token. |
DELETE |
/api/clusters/{id}/enrollment |
Invalidates the current link, or a pending enrollment with an identity, if any. 204 either way; 409 enrollment.in_progress while an enrollment with an identity runs, which POST answers too. |
POST |
/api/clusters/{id}/enrollment/identity |
Starts an enrollment with your identity: {"api_url", "in_cluster", "ca", "rotate"}, any other field a 400 → 200 with {"url"}, the provider's authorization URL, and the state cookie. in_cluster uses https://kubernetes.default.svc and, without ca, the CA the server has mounted (400 enrollment.in_cluster_ca_missing when it has none). 409 enrollment.identity_unlinked for an account without an identity of the provider, 409 enrollment.in_progress, 404 oidc.disabled, 503 oidc.unavailable. The callback applies the manifest and comes back to /admin/clusters/{id}#enrollment. A browser session only. |
GET |
/api/clusters/{id}/namespaces |
The cluster's namespaces: {"namespaces": [{"name", "psa_enforce"}], "require_psa", "psa_label"}. An administrator, or an owner of the whole cluster (of the cluster or of its environment); anyone else gets 403 session.admin_only, whether the cluster exists or not. |
POST |
/api/clusters/{id}/reconcile |
Reconciles now, synchronously. If the cluster side fails, 502 cluster.reconcile_failed with params.count, the number of problems, which the cluster's problems lists; 409 if another pass is in progress or tokens are missing. |
GET |
/api/clusters/{id}/api-resources |
The cluster's API, for composing custom roles: {"groups": [{"group", "version", "resources": [{"name", "namespaced", "verbs", "subresources"}]}]}, sorted by group. Kept for five minutes. 502 cluster.unreachable if the cluster does not answer. |
GET |
/api/clusters/{id}/rbac-subjects |
What the cluster's own RBAC names: {"groups": [...], "users": [...], "truncated", "fetched_at", "privileged_namespaces", "everyone_privileged"}. Each subject has name, bindings ([{"kind": "RoleBinding"\|"ClusterRoleBinding", "namespace", "name", "role_ref": {"kind", "name"}}]), privileged (the cluster gives it a privileged capability), privilege (why: a list of reasons, never null), system (it starts with system:) and managed (every binding that names it is kubelatch's). A reason is {"capability", "namespace", "binding", "role"}, the last two as {"kind", "namespace", "name"}: capability is one of all, escalate, bind, impersonate, nodes_proxy, certificates, admission, persistent_volumes, namespaces, secrets_read, secrets_write, workloads, token_request, impersonate_serviceaccounts and configmaps_write, and an empty namespace means the whole cluster, or every namespace for a namespace capability. Reasons come sorted by binding and then by capability. privileged_namespaces lists the privileged namespaces, sorted by name, the protected ones included: [{"namespace", "protected", "subject"?, "reason"?}]; subject (a ServiceAccount, a User or a Group, as {"kind", "namespace", "name"}) and reason come only with the namespaces that a privileged ServiceAccount makes so. everyone_privileged holds the reasons of system:authenticated, system:unauthenticated and system:serviceaccounts, and is empty when the cluster gives them nothing privileged. The classification reads the cluster's whole RBAC; only the lists of names are cut, sorted by name, at most 500 groups and 500 users (truncated says whether it was cut), and ServiceAccounts are left out. Kept for five minutes; ?refresh=1 (or true) reads the cluster again, and any other value but 0 or false is 400 query.bad_filter. 502 cluster.unreachable if the cluster does not answer, 409 cluster.not_ready before its enrollment. |
GET |
/api/clusters/{id}/identity-preview |
Renders ?template= (the saved template when absent) for everyone with an active permission on the cluster, writing nothing: {"rows": [{"login", "display_name", "username", "missing", "privileged"}], "collisions": [{"username", "logins"}], "reachable"}. username is null when the account can't be named, and missing then says why: the variable it lacks (email, oidc_subject, github_login) or invalid. privileged is true when the cluster gives that name privileged access. A cluster that can't be read still answers 200, with reachable: false and no privileged marks. An invalid template answers as PATCH does. |
GET |
/api/clusters/{id}/group-refusals |
The native roles a group permission can't take on the cluster, to know before a role is chosen: {"reachable": true, "refusals": [{"role", "groups", "reasons"}]}. A role is listed when it can be granted on the cluster and the cluster gives one of its groups privileged access; groups are those groups, reasons gives each its first reason as /api/grants/preview does ({"kind": "group_privileged", "group", "privilege"}), and a group permission of the role is refused with 422 group.privileged, the same rule as /api/grants/preview. Sorted by role id; both lists are never null. Reads the cluster's RBAC like the preview, keeping what it read for five seconds, and only when some native role can be granted there. A cluster that can't be read still answers 200, with reachable: false and no refusals: which roles it refuses is unknown then. 404 cluster.not_found. Writes nothing. |
Enrollment¶
The enrollment command calls these two routes. The kle_ token in the path is the only credential.
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/api/enroll/{token} |
Anonymous | The enrollment script for sh; it doesn't consume the link. An unknown, used, expired or invalidated link gets 404 with a script that prints the error. |
POST |
/api/enroll/{token} |
Anonymous | The same JSON as /tokens. Validates and saves the tokens, and consumes the link only if everything goes well, answering {"id", "name", "status"}. A failed validation leaves the link usable, so the command can be run again; the tenth failure revokes it. 404 enrollment.invalid for a link that no longer works. |
Environments¶
All require admin. An environment puts clusters under one name, says whether they are production and carries the rules for people's permissions on them; a cluster sits in one at most, set with PUT /api/clusters/{id}/placement.
The rules are max_ttl_seconds, the maximum lifetime, and default_ttl_seconds, the default expiry: each a whole number of seconds from 3600 to 31536000, or null for none (400 environment.ttl_invalid, with the rule in params.field), and the default never longer than the maximum (400 environment.default_over_max). Setting a rule to a value it doesn't hold needs Pro, 403 edition.feature; removing one, keeping them as they are and the production mark work in any edition.
| Method | Route | What it does |
|---|---|---|
GET |
/api/environments |
{"environments": [...], "unassigned": 2}: every environment, those marked as production first and then by id, each with id, production, clusters (how many it holds), created_at, max_ttl_seconds, default_ttl_seconds, require_reason, require_ticket and auto_approve_roles; unassigned counts the clusters in none. |
POST |
/api/environments |
Creates one: {"id": "prod", "production": true, "max_ttl_seconds": 28800, "default_ttl_seconds": 7200} → 201 with it. The id is its name and never changes: 1 to 63 lowercase letters, digits or hyphens, starting with a letter or a digit (400 environment.bad_id). production and the rules are optional: false and none by default. 409 environment.exists for an id another environment has. Records environment.create with the mark and the rules. |
GET |
/api/environments/{id} |
{"environment", "clusters", "permissions"}: the environment, its clusters as GET /api/clusters shows them, and permissions, {"grants", "people", "via_sets"}: the active permissions of enabled accounts on those clusters, how many people (not bots) hold one and how many of the permissions come from a set. 404 environment.not_found. |
PATCH |
/api/environments/{id} |
Changes the mark, the rules, the requirements, the roles granted without approval, or several: {"production": true}, {"rules": {"max_ttl_seconds": 28800, "default_ttl_seconds": null}}, {"requirements": {"require_reason": true, "require_ticket": false}} or {"auto_approve_roles": ["viewer", "developer"]}, at least one. production is a boolean; rules carries both keys and replaces both rules; requirements carries both keys; auto_approve_roles is the whole list of role ids that an access request gets without approval ([] for none, stored sorted), each a tier other than cluster-admin or a custom role, with no duplicates and at most 50 (400 environment.auto_approve_role, with the role in params.role); adding a role to it needs Pro (403 edition.feature), removing one never does, and the list cuts nothing. Any other body is 400 request.bad_json. With a maximum, it shortens to now plus the maximum every active permission of a person or a group on its clusters that ends later or never, a bot's own never, and reconciles each cluster it touched; nothing is lengthened. → the environment, cut and, for permissions on a set, lost and gained. cut is {"to", "grants", "people", "items", "truncated"}: to is now plus the maximum (null with no maximum), grants how many permissions it cut (a person's own, a group's and what a permission on a set put on a cluster; the rows of a group's members follow theirs), people how many people they reach, items the first 500 by cluster, each {"kind", "id", "cluster_id", "subject_id", "login", "subject_disabled", "group_id", "group_name", "role", "scope", "from"} (kind is grant for a person's own permission, group_grant for a group's and set_row for what a set put on the cluster, which also carries set_grant_id and selector; from is its end before, null for none), and truncated whether there are more. lost and gained list what the save took away from clusters and gave them, as lost and gained do in POST /api/cluster-set-grants/{id}/preview: a relaxed rule can bring in a permission on a label that the maximum refused. If another change holds what it changes for more than two seconds, 409 cluster.busy: nothing is written. 404 environment.not_found. Records environment.update even when nothing changes; each permission cut writes its grant.expiry or group.grant.update, all with one batch. |
POST |
/api/environments/{id}/preview |
What PATCH with the same body would do, writing nothing and with no Pro needed: {"cut", "lost", "gained"}, each as in the PATCH answer. With {"delete": true} alone it says what DELETE would do: no cut, the permissions on a set that follow the environment, which it would revoke, in lost, and in gained what its clusters would gain; anything beside delete is 400 request.bad_json. It refuses the body and the rules as PATCH does; 404 environment.not_found. |
DELETE |
/api/environments/{id} |
Deletes it → 204. Its clusters are left without an environment and keep their labels; the permissions on a set that follow it are revoked, and the clusters gain what its rules kept off them. If another change holds one of them for more than two seconds, 409 cluster.busy: nothing is deleted; try again. 404 environment.not_found. Records environment.delete with the clusters it left without one, and lost and gained. |
Permissions¶
An administrator writes every permission. In Pro, the owners of a place grant, extend, preview and revoke the permissions of people and bots there too, within their limits: outside what they own 403 owner.out_of_scope (with params.index, and params.id where the route takes ids, in a batch), giving themselves access 403 owner.self, and what only an administrator grants or changes (cluster-admin, a native role, a permission the cluster makes privileged, a bot given access) 403 owner.admin_only. Anyone else gets 403 session.admin_only before the body is read. Their events carry "as": "owner" and owner_scope.
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/api/grants |
Session | Lists permissions. Filters: subject, cluster, include_revoked=true, and active=true for only those active now (not revoked or expired; include_revoked then changes nothing). Without the admin role, only your own (403 if you ask for another subject); each grant's created_by_login is filled only for an admin, empty otherwise. managed=1, without the admin role, lists instead the permissions on the places you own, whoever holds them, each with subject_login, subject_name and subject_kind; subject is then a free filter, and owning nothing lists none. An admin gets the same list with it or without it. Any other value is 400 query.bad_managed. cluster_name is the cluster's name, readable by any session (/api/clusters is admin-only). group is what the cluster receives: the Kubernetes group the permission binds or, for a native role, the role's groups joined by ,; groups is the same as a list, one element per group. subject_disabled is true when the subject's account is disabled: the grant is kept but does not apply. Each permission carries source: {"kind": "direct"}, {"kind": "group", "group_id", "group_name", "member_id", "group_grant_id"} when it is derived from a group, or {"kind": "set", "set_grant_id", "selector"} when a permission on a set put it on the cluster (a group's permission on a set gives its members' permissions kind group, with set_grant_id and selector besides); without the admin role, your own permissions carry it too, the group's name included. group=<id> lists only the permissions derived from that group (400 query.bad_group for an invalid id). For an admin, an active permission that a problem of its cluster leaves without effect carries effect: {"kind": "ineffective", "code", "name", "name_kind", "reason", "victims"} on the permission that causes it (code is the problem's, victims how many accounts it locks out of the cluster), or {"kind": "blocked", "name", "by"} on another permission of one of those accounts there (by is the problem's first culprit, as in the cluster's problems). Without the admin role effect never appears. Every view of a grant carries request_id: the access request whose approval created it or last extended it, null for one granted by hand. With it come request_decided_by (the login of whoever approved the request; empty when an environment's rule granted it), request_decided_as (admin, owner or policy) and request_extended (true when that request extended the permission instead of creating it); all three are empty or false without a request_id, and whoever can read the permission reads them. |
POST |
/api/grants |
Admin, owner | Grants: {"subject_id", "cluster_id", "role", "scope", "expires_at", "never_expires", "note", "ticket"}. Without expires_at, or with "expires_at": null, the permission gets the default expiry: 30 days, or for a person the default of the cluster's environment; "never_expires": true gives it no end date, and sending it with expires_at is 400 grant.expiry_conflict. On a cluster whose environment has a maximum lifetime, a person's permission that ends later than now plus it, or never, is 400 grant.environment_max, with params.environment and params.max. cluster-admin takes neither the default nor never_expires: it needs an expires_at within 8 h (400 grant.expiry_required). ticket is optional, at most 200 characters without control or formatting characters (400 grant.ticket_too_long, 400 grant.ticket_invalid): an https:// URL with an ASCII host name, a key such as INC-123 (typed in any case, stored upper-case) or a number such as #512. A cluster whose environment asks for a reason or a ticket refuses a person's or a group's permission without it, with 400 grant.reason_required or 400 grant.ticket_required and params.environment (and params.index in a batch); a bot's permission, extending and revoking never ask. Every view of a grant carries ticket beside note, "" when none. The response's created_by_login is the caller's own login. tier is accepted as a deprecated alias of role, and every grant in a response carries both with the same value. |
POST |
/api/grants/preview |
Admin, owner | What creating a permission would meet, writing nothing: {"subject_id" or "group_id", "cluster_id", "role", "scope", "expires_at"?, "never_expires"?} (exactly one of subject_id and group_id; the expiry is resolved as in POST /api/grants, with the environment's default for a person or a group) → {"username", "missing_fact", "invalid_name", "requires_expiry", "max_ttl_seconds", "reasons", "violations"}. username is the name the person would enter the cluster with (null for a group permission, or when it can't be rendered: then missing_fact names the variable the account lacks, or invalid_name is true). requires_expiry and max_ttl_seconds say whether the 8-hour rule asks for an expiry and how long it may be; reasons says why, each with privilege, the first reason the cluster gives (as in rbac-subjects): {"kind": "group_privileged", "group", "privilege"} (a group of a native role), {"kind": "user_privileged", "username", "privilege"} (the name the cluster renders for the person), {"kind": "role_privileged", "group", "privilege"} (a role kubelatch writes, on its scope: group is kubelatch's group for it, and privilege.namespace is the scope) or {"kind": "group_permission_refused", "group", "privilege"}. violations are the coded refusals the real request would answer, each with code, params and field. It reads the cluster like the real request (502 cluster.unreachable when it can't), keeping what it read for five seconds. |
POST |
/api/grants/batch |
Admin, owner | Grants from 1 to 200 permissions at once: {"grants": [{"subject_id", "cluster_id", "role", "scope", "expires_at", "never_expires", "note", "ticket"}], "reason": ""} → 201 with {"batch_id", "grants": [...]}, each expiry as in POST /api/grants. The entries may name several clusters. All or nothing; a failing entry answers with its own code (grant.exists, account.disabled, cluster.not_ready, an unreachable cluster…) and its position in params.index. A namespace without PSA is 400 grant.psa_required. 400 grant.batch_size outside the limits. The body may be up to 512 KiB, room for 200 entries each with a 500-character note (every other route takes 64 KiB); past it, 413 request.body_too_large with the limit in params.limit, in KiB. The request reads each cluster once, whatever the number of entries: one list of its namespaces and one read of its RBAC. reason is optional, at most 500 characters (400 batch.reason_too_long); each grant.create row carries batch (the batch_id), batch_size (the entries) and reason when one was given, as in bulk operations. |
DELETE |
/api/grants/{id} |
Admin, owner | Revokes a permission; the response's created_by_login is the original creator's login, empty for an owner. 409 grant.derived for a permission derived from a group, with the group's id in params.group_id: remove the member or revoke the group permission instead. 409 grant.set_derived for one a permission on a set put there, with params.selector and params.set_grant_id: revoke the permission on the set instead, or take the cluster out of the set. A derived permission already revoked answers 404 grant.not_found. |
POST |
/api/grants/revoke |
Admin, owner | Revokes from 1 to 500 permissions at once: {"ids": [...], "reason": ""} → 200 with {"batch_id", "grants": [...], "unchanged": [...]}, as in bulk operations. The ids are those of direct permissions: 409 grant.derived, with the group's id in params.group_id, for one derived from a group (remove the member or revoke the group permission instead), 409 grant.set_derived, with params.selector and params.set_grant_id, for one a permission on a set put there, 404 grant.not_found for one that doesn't exist. A permission already revoked is not a failure: its id comes back in unchanged. |
PATCH |
/api/grants/{id} |
Admin, owner | Extends a direct permission with one of {"extend_by_seconds": n} (adds n seconds, more than 0, to its expiry), {"expires_at": "<RFC 3339>"} (a new date, later or earlier) and {"never_expires": true} (no end date); none or more than one is 400 batch.extend_choice. → the permission, as the list shows it. The new expiry passes the checks of a new permission (grant.expiry_past, grant.expiry_required, grant.expiry_too_far, grant.environment_max, the namespace and its PSA label…), and the 8-hour rule counts the permission's life from when it was granted: more than 8 hours of life on a privileged name is 409 grant.privileged_lifetime, with params.created_at; revoke it and grant it again. 409 batch.no_expiry for a length on a permission without an end date; 409 grant.expired, 409 grant.revoked, 409 grant.derived (with params.group_id) or 409 grant.set_derived (with params.selector and params.set_grant_id) for one that isn't an active direct permission; 409 account.disabled for a disabled account's, as granting it is; 404 grant.not_found. Writes a grant.expiry row with from and to, and reconciles the cluster. |
POST |
/api/grants/extend |
Admin, owner | Extends from 1 to 50 permissions with one decision: {"ids": [...], "reason": ""} plus one of extend_by_seconds, expires_at and never_expires, as in the single PATCH → 200 with {"batch_id", "grants": [...], "changes": [{"id", "from", "to"}]}, as in bulk operations. extend_by_seconds adds to each permission's own expiry, so it never shortens one; expires_at sets one date for all and may shorten some; changes holds each one's expiry before and after (null for none). A failing permission answers the single PATCH's code with params.index and params.id. |
GET |
/api/tiers |
Session | Catalog of tiers, protected namespaces, whether PSA is required, the longest credential a person (user) and a bot may get (credential_max_ttl_seconds), access_contact, who to ask for access: the value of KUBELATCH_ACCESS_CONTACT or, when it is unset, mailto: and the address of the oldest enabled administrator who has one, "" when none has; access_admin, that administrator's login when access_contact is "" (else ""), and ticket_url, the template that links a ticket key or number to the tracker (KUBELATCH_TICKET_URL, with {key} once), empty when unset. |
Permissions on a set¶
Every route needs the administrator role. A permission on a set gives an account or a group a role on every cluster of an environment or with a label, and follows the set. On each cluster it holds, it's a permission of /api/grants (source.kind set). Granting and extending need Pro (403 edition.feature, nothing written); previews, checking again and revoking work in every edition.
A set is {"kind": "environment", "key": "", "value": "staging"} or {"kind": "label", "key": "team", "value": "payments"}. In a list's filter, an environment is environment=staging and a label label=team:payments. A permission on a set is {"id", "subject_id", "login", "subject_kind", "subject_disabled", "group_id", "group_name", "selector", "role", "scope", "expires_at", "note", "ticket", "created_by", "created_by_login", "created_at", "revoked_at", "revoked_by", "active", "clusters"} (subject_id or group_id, the other null), and clusters has one entry per cluster of the set today: {"cluster_id", "environment", "row", "refusal"}, where row is {"id", "expires_at"} when the cluster holds the permission and refusal is {"code", "params", "kind", "decided_at"} when it doesn't (kind is transient or rule; When kubelatch decides again). While a cluster waits to be asked again after an extension, its row.expires_at can be earlier than the permission's. What lost, gained and pending list are changes: {"set_grant_id", "selector", "cluster_id", "subject_id", "login", "subject_kind", "group_id", "group_name", "role", "scope", "expires_at"}, plus code and params when a rule, not the set, took the permission away.
| Method | Route | What it does |
|---|---|---|
GET |
/api/cluster-set-grants |
{"set_grants": [...]}, newest first. Filters: subject (an account's own and those of the groups it belongs to), group, role (a role id; one that no permission uses matches none), environment or label (not both) and include_revoked=true. 400 query.subject_not_uuid, 400 query.bad_group, 400 set_grant.selector_invalid. |
POST |
/api/cluster-set-grants |
Grants: {"subject_ids": [...], "selector", "role", "scopes": [...], "expires_at", "never_expires", "note", "ticket"}, with group_id in place of subject_ids for a group → 201 with {"batch_id", "set_grants": [...]}. One permission per account (or the group) and scope, from 1 to 50, all or nothing (400 grant.batch_size); a failing entry answers with its own code and its position in params.index. Exactly one of subject_ids and group_id (400 set_grant.target). The expiry is resolved as in POST /api/grants, with the environment's default for a person or a group, and an environment's maximum applies to a permission on that environment (400 grant.environment_max). The scopes are * or namespaces. 400 set_grant.cluster_admin, 400 set_grant.selector_invalid, 400 environment.not_found, 409 set_grant.exists, 409 account.disabled, 409 role.stale_revision for a custom role edited meanwhile. Each cluster of the set is asked, and one that doesn't pass is a refusal of the answer: the request doesn't fail for it. Records set.grant.create for each permission, with the grant.create or group.grant.create of every cluster it reaches, all under one batch. |
POST |
/api/cluster-set-grants/preview |
What POST would do with the same body, writing nothing and with no Pro needed: {"clusters": [...], "entries": [{"index", "subject_id", "group_id", "scope", "reaches": [...], "refused": [{"cluster_id", "code", "params", "kind"}]}]}. It refuses what POST refuses before asking any cluster, with the same codes. |
GET |
/api/cluster-set-grants/{id} |
One permission on a set, revoked or not. 404 set_grant.not_found. |
PATCH |
/api/cluster-set-grants/{id} |
Extends it with one of {"extend_by_seconds": n}, {"expires_at": "<RFC 3339>"} and {"never_expires": true}, as PATCH /api/grants/{id} does → {"set_grant", "lost", "pending"}. Each cluster of the set is asked at the new expiry: lost lists the permissions that stop reaching a cluster because its rules don't take the date, and pending the clusters that couldn't be asked (or joined the set meanwhile), whose permission keeps its date until kubelatch tries again. 400 batch.extend_choice, 400 grant.environment_max, 409 batch.no_expiry, 409 grant.expired, 409 grant.revoked, 409 account.disabled, 409 role.stale_revision, 404 set_grant.not_found. Records set.grant.expiry. |
POST |
/api/cluster-set-grants/{id}/preview |
What PATCH with the same body would do, writing nothing and with no Pro needed: {"to", "lost", "pending"}. |
POST |
/api/cluster-set-grants/{id}/recheck |
Checks again the clusters of the set that hold no permission, or a shorter one → {"set_grant", "gained"}, gained listing what it brought. Records set.grant.recheck even when it brought nothing. 409 grant.revoked, 409 grant.expired, 409 account.disabled (a disabled account's permission can only be revoked), 404 set_grant.not_found. |
POST |
/api/cluster-set-grants/{id}/recheck/preview |
What checking again would bring, writing nothing: {"gained"}; it refuses what checking again refuses, with the same codes. |
DELETE |
/api/cluster-set-grants/{id} |
Revokes the permission and what it put on each cluster → 200 with it. 404 set_grant.not_found for one that doesn't exist, is malformed or was revoked already. Records set.grant.revoke, and one grant.revoke per cluster for an account's permission; a group's members' permissions are revoked by the group's sync. It takes no body and records no reason. |
POST |
/api/cluster-set-grants/{id}/revoke |
The same revoke with the reason of an offboarding: {"reason": ""}, optional (no body, or an empty reason, is the DELETE), 500 characters at most (400 batch.reason_too_long). Every event it records (set.grant.revoke, and the grant.revoke or group.grant.revoke of each cluster) carries one batch and the reason, as in bulk operations. Same answers as the DELETE. |
The errors are in Errors.
Access requests¶
A person asks for a role on a cluster or a namespace for a while, with a reason and maybe a ticket, and an administrator approves or denies it; a cluster's environment can grant some roles without approval (auto_approve_roles, in Environments). The permission an approval gives lasts from the approval, and its grant.create carries request_id (Audit). Filing a request is Pro (403 edition.feature in Free, and with a lapsed key); deciding what waits is not. Any enabled administrator other than the person who asked decides. The same routes serve the join requests of people without an account (Onboarding), which only an administrator reaches.
A request for a role is {"id", "kind": "access", "status", "subject_id", "login", "name", "cluster_id", "cluster_name", "role", "scope", "duration_seconds", "reason", "ticket", "origin", "origin_detail", "needs_admin", "extends_grant_id", "created_at", "expires_at", "decided_by", "decided_as", "decided_at", "decision_note", "result_grant_id", "granted_until"}:
statusispending,approved,denied,expiredorcancelled. A request waits 24 hours (expires_at), and one past them readsexpiredat once.scopeis a namespace or*for the whole cluster;duration_secondsis what it asks for, counted from the approval.originis where it was filed:web,cli,forbidden(the link of a cluster's403, with the refused request inorigin_detail, printable ASCII only) ornotice.needs_adminsays only an administrator may decide it;extends_grant_idnames the direct permission of the same role and scope that an approval extends instead of granting anew (nullotherwise).decided_byis{"id", "login", "name"},nulluntil a person decides;decided_asisadmin, orpolicyfor the environment's rule, withdecided_bynull.decision_noteis what the person who decided wrote for the person who asked,result_grant_idthe permission an approval created or extended andgranted_untilits end.
| Method | Route | Access | What it does |
|---|---|---|---|
POST |
/api/access-requests |
Session, CLI token | Asks for a role: {"cluster_id", "role", "scope", "duration_seconds", "reason", "ticket", "origin", "origin_detail"}. role is a tier, a custom role the cluster takes or a native role of the cluster, and scope fits the role as in POST /api/grants. The duration goes from 15 minutes to the shortest of the environment's maximum, the role's (8 hours for cluster-admin) and 30 days (400 access_request.duration, with params.max). reason is required, 1 to 500 characters (400 access_request.reason_required, 400 grant.note_too_long), with no control or formatting characters but line breaks and tabs (400 grant.note_invalid); ticket is optional, as in POST /api/grants, unless the environment asks for one (400 grant.ticket_required). origin is web when absent (400 request.bad_json for another value). → 201 with {"request", "grant": null, "existing": false}; when the environment grants the role without approval, request.status is approved and grant is the permission. The same request already waiting answers 200 with {"request", "grant": null, "existing": true}. 400 access_request.not_requestable (a protected namespace, a role the cluster doesn't take, a bot), the role's shape errors as in POST /api/grants (400 grant.cluster_only…), 404 cluster.not_found (unknown or not enrolled), 409 access_request.pending_limit (20 waiting), 429 access_request.too_many (30 filed in the last hour), 409 access_request.no_decider (nobody else can decide it), 409 grant.exists (you hold it with no end), and the permission's own errors when the environment's rule grants it. Records access_request.create. |
GET |
/api/access-requests |
Session, CLI token (view=mine) |
A bare array of requests for a role, at most 200: ?view=mine, yours, newest first; ?view=waiting, those you can decide now, oldest first, never your own; ?view=decided, those you decided or, for an administrator, every decision of the last 30 days, newest first. kind=access is the default; kind=join gives the join lists (Onboarding). 400 query.bad_view, 400 query.bad_kind; a CLI token gets 403 cli.scope for anything but view=mine. |
GET |
/api/access-requests/{id} |
Session, CLI token (your own) | One request, to the person who asked, to an administrator and to an owner of its place, with can_decide (false for an owner when only an administrator decides it), deciders ([{"login", "name"}], who is asked to decide it, at most 50 by login), deciders_more (how many more there are), decider_scope (the place whose owners deciders are: {"kind": "namespace", "cluster_id", "cluster_name", "namespace"}, {"kind": "cluster", "cluster_id", "cluster_name"} or {"kind": "environment", "environment_id"}; null when the administrators decide), held (the active permissions of the person who asked on that cluster, for an owner only those on what it manages: [{"id", "role", "scope", "expires_at", "source"}], source being direct, group or set), max_seconds (the longest an approval may give now; 0 when you can't decide it) and extends_until (for an extension of an active permission, the end it asks for; else null). 404 access_request.not_found for anyone else and for a join request's id. |
POST |
/api/access-requests/{id}/approve |
Session (who decides) | Approves a request for a role: {"duration_seconds", "note"}, both optional. The duration may only shorten the one asked for (400 access_request.longer; under 15 minutes, 400 access_request.duration); the note, up to 500 characters (400 grant.note_too_long) with no control or formatting characters but line breaks and tabs (400 grant.note_invalid), is for the person who asked. → {"request", "grant"}: the permission, with the person who asked as its subject, the reason as its note and the request's ticket, ending duration_seconds from now; an extension extends the permission in place, or grants anew when it ended. 403 access_request.self, 404 access_request.not_found (someone who can't decide it, before the body is read), 403 access_request.admin_only (an owner, on a request only an administrator decides or a permission the cluster makes privileged now; the request is then marked so), 409 access_request.closed (decided, cancelled or past its 24 hours), 403 access_request.not_decider, and the permission's own errors (400 grant.psa_required, 422 grant.privileged_ttl…), which leave the request waiting. Records access_request.approve. A join request takes the body of Onboarding. |
POST |
/api/access-requests/{id}/deny |
Session (who decides) | Denies a request for a role: {"note"}, up to 500 characters, for the person who asked → the request. The same refusals as approving. Records access_request.deny. A join request as in Onboarding. |
POST |
/api/access-requests/approve |
Session (who decides) | Approves several requests for a role at once, all or nothing: {"ids", "note"}, 1 to 50 distinct ids (a repeated one counts once; 400 access_request.batch_size, with params.max) and one note for all of them, with the rules of a single approval's. Each request is approved for the duration it asks for. → {"batch_id", "requests"}: the requests as decided, in the order asked, each as a single approval answers its request. Every id passes the checks of a single approval first, and then one transaction approves them all: the first that fails answers the error its single approval would, with params.index, its position in ids, and nothing is decided. A join request is 400 access_request.batch_kind for an administrator. Someone who decides nothing gets 403 session.admin_only before the body is read. Records one access_request.approve per request and each permission's own grant.create or grant.expiry, all with batch and batch_size. |
POST |
/api/access-requests/deny |
Session (who decides) | Denies several requests at once, all or nothing: {"ids", "note"}, 1 to 100 distinct ids (400 access_request.batch_size, with params.max), requests for a role and, for an administrator, join requests too. The note follows the rule of each kind in the batch (400 grant.note_too_long, 400 grant.note_invalid, 400 join.bad_note): the person who asked for a role reads it, someone who asked to join never does. → {"batch_id", "requests"}, in the order asked, each request as its single denial answers it, told apart by kind. The first that fails answers the error its single denial would, with params.index, and nothing is decided; a join request named by someone who is not an administrator is 404 access_request.not_found, as an unknown id is. Someone who decides nothing gets 403 session.admin_only before the body is read. Records one access_request.deny per request, with batch and batch_size. |
POST |
/api/access-requests/{id}/cancel |
Session, CLI token | Withdraws a request of yours while it waits; no body, or {} → the request, cancelled. 404 access_request.not_found for anyone else's, 409 access_request.closed. Records access_request.cancel. |
GET |
/api/request-options |
Session, CLI token | What a request can name: {"requests_enabled", "contact", "contact_admin", "clusters": [{"id", "name", "environment", "production"}]}, the enrolled clusters, environment being the id of the cluster's environment or "". contact is who to ask, as access_contact in GET /api/tiers, and contact_admin the administrator to name when it is "". With ?cluster=<id> it adds cluster: the same four fields, rules ({"max_seconds", "default_seconds", "require_reason", "require_ticket"}, from the environment, max_seconds null without a maximum), roles ([{"id", "name", "description", "kind", "scopes", "auto_approve", "max_seconds", "proposed_seconds"}], kind being tier, custom or native, scopes holding namespace and/or cluster, proposed_seconds the duration the form proposes), deciders, deciders_more, decider_scope and held, as in a request's detail (each held entry adds extend_max_seconds: the longest a request can extend that permission now, in seconds, 0 when nothing fits, and null when a request can't extend it, because it comes from a group or a set or has no end), for a request on the whole cluster. With &namespace=<name> the three decider fields answer for a request on that namespace: its own owners when it has some, else the cluster's. A request only an administrator decides asks the administrators whatever these say. In Free, requests_enabled is false, clusters is empty and there is no cluster. 400 query.bad_cluster, 400 query.bad_namespace, 404 cluster.not_found. |
The errors are in Errors.
Owners¶
An administrator names the owners of an environment, a cluster, a namespace of a cluster or a group. Naming one is Pro (403 edition.feature in Free and with a lapsed key); listing and removing them work in every edition.
A scope is {"kind": "environment", "environment_id"}, {"kind": "cluster", "cluster_id", "cluster_name"}, {"kind": "namespace", "cluster_id", "cluster_name", "namespace"} or {"kind": "group", "group_id", "group_name"}. An owner is {"id", "scope", "owner", "inherited", "created_by_login", "created_at"}, where owner is a person, {"subject_id", "login", "display_name", "disabled"}, or a group, {"group_id", "group_name", "members"}, and inherited marks an environment's owner listed on one of its clusters.
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/api/owners |
Admin | The owners of ?environment=<id>, ?cluster=<id> or ?group=<id>, exactly one (400 query.bad_owner_scope) → {"owners": [...]}, oldest first. A cluster lists its own, its namespaces' and, inherited, its environment's. An id off the format is 400 query.bad_environment, 400 query.bad_cluster or 400 query.bad_group; an unknown one, 404 environment.not_found, 404 cluster.not_found or 404 group.not_found. |
POST |
/api/owners |
Admin | Names an owner: {"scope": {"kind", "environment_id" \| "cluster_id" [, "namespace"] \| "group_id"}, "owner": {"subject_id"} \| {"group_id"}} → 201 with the owner. 400 owner.invalid_scope, 400 owner.not_person, 409 owner.exists, and 404 subject.not_found, 404 group.not_found, 404 environment.not_found or 404 cluster.not_found for what doesn't exist. Records owner.add. |
DELETE |
/api/owners/{id} |
Admin | Removes an owner → 204; 404 owner.not_found. Records owner.remove. |
GET |
/api/managed |
Session | What you manage as an owner: {"sites", "groups", "people", "all_groups", "group_grants"}. sites are the clusters you own something in, by id: {"cluster_id", "cluster_name", "environment", "production", "whole", "namespaces", "max_ttl_seconds", "default_ttl_seconds", "require_reason", "require_ticket"}, with whole for the cluster's or its environment's owner (namespaces then []), else the namespaces you own; the last four are the rules of the cluster's environment, null and false for a cluster in none. groups are the groups you own, {"id", "name"}. people are the enabled people, {"id", "login", "display_name"}, by login. To an owner of a site, all_groups lists every group by name and group_grants the group permissions in force on your sites, {"id", "group_id", "group_name", "cluster_id", "cluster_name", "role", "scope", "expires_at", "note", "ticket", "created_at"}. An administrator, and anyone who owns nothing, gets five empty lists. |
The errors are in Errors.
Groups¶
A group is a named set of people and bots with permissions of its own; each member gets those permissions as derived rows of /api/grants (source.kind group). An administrator uses every route. In Pro, the owners of a group read it (GET /api/groups lists only the groups they own, without member; another group's GET /api/groups/{id} is 404 group.not_found) and add, extend, move and remove its members; the owners of a place add, extend and revoke group permissions there. Within their limits: outside what they manage 403 owner.out_of_scope, giving themselves access (adding or moving themselves, lengthening their own membership, a group permission for a group they are a member of) 403 owner.self, a bot added, moved or lengthened and a native role's group permission 403 owner.admin_only, and the members of a group that owns something 409 owner.group_owns with params.scope. Anyone else gets 403 session.admin_only before the body is read. Creating, renaming and deleting a group stay an administrator's. Owners' events carry "as": "owner" and owner_scope.
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/api/groups |
Admin, owner | {"groups": [...]}, by name. Each group: id, name, description, members and grants (counts of those not expired), clusters (where it has active permissions), created_at and updated_at. ?member=<subject id> lists only the groups that subject belongs to (400 query.subject_not_uuid for an id that isn't a uuid), and each carries membership: {"id", "expires_at"}, that subject's own row in the group (expires_at is null for never). Its id is the {mid} of the member routes below. Without member, membership is absent. ?members=1 (or true) adds members_list to every group: its live memberships, expired ones included, in the shape of GET /api/groups/{id} and oldest first ([] for a group with none); it combines with member, and without it members_list is absent. 0, false and an empty value turn it off; any other value is a 400 query.bad_filter. GET /api/groups?grants=1 (or true) adds grants_list to every group: its active permissions, in the shape of GET /api/groups/{id} and by cluster, scope and id ([] for a group with none); it combines with members and member, and without it grants_list is absent. It takes the same values as members. |
POST |
/api/groups |
Admin | Creates a group: {"id", "name", "description"} → 201 with the group. The id (1 to 63 lowercase letters, digits or hyphens) is never reused. |
GET |
/api/groups/{id} |
Admin, owner | The group plus members_list (id, subject_id, login, kind, display_name, disabled, expires_at, note, ticket, created_by_login, created_at, source and signup_domain; source is manual, or domain for a member a domain rule added, whose signup_domain names the rule and whose created_by_login is null) and grants_list (id, cluster_id, role, scope, group, groups, expires_at, note, ticket, created_by_login, created_at, and set, {"set_grant_id", "selector"}, on a permission that a permission on a set put on the group; group is what the cluster receives: the Kubernetes group the permission binds or, for a native role, the role's groups joined by ,, and groups is the same as a list), and owns, the scopes the group owns as an owner, each as an owner's scope ([] when none). 404 group.not_found. |
PATCH |
/api/groups/{id} |
Admin | Changes name and description; a field left out stays. |
DELETE |
/api/groups/{id} |
Admin | Deletes the group and revokes every permission derived from it, and its permissions on a set → 204. Its owners, and the scopes it owned as an owner, go with it (owners_removed in group.delete). Its id is not reused. 409 signup_domain.group_in_use while a domain rule points at it. |
POST |
/api/groups/{id}/members |
Admin, owner | Adds members: {"members": [{"subject_id", "expires_at", "note", "ticket"}]}, from 1 to 100 → 201 with {"members": [...]}. All or nothing. Each member receives the group's permissions at once. Before adding anyone, every active group permission is checked again as a direct grant of it would be; if one no longer passes, the call answers with that check's code, params.group_grant_id and params.index 0. 409 group.member_exists, 409 group.subject_disabled. |
PATCH |
/api/groups/{id}/members/{mid} |
Admin, owner | Changes a member's expiry: {"expires_at": "<RFC 3339>"}. An absent or null expires_at means never. → the member. Every derived permission of the member takes the new expiry, capped by its group permission's. Bringing an expired membership back checks the group's permissions first, as adding a member does. 400 group.expiry_past; 409 account.disabled for a disabled account's membership, as adding it is refused. |
DELETE |
/api/groups/{id}/members/{mid} |
Admin, owner | Removes a member and revokes the permissions derived for them → 204. |
POST |
/api/groups/{id}/members/remove |
Admin, owner | Removes from 1 to 500 members at once: {"ids": [...], "reason": ""} → 200 with {"batch_id", "members": [...], "unchanged": [...]}, as in bulk operations. The ids are the id of each entry of members_list, not the account's. Each member goes as in the single DELETE, with the permissions derived for them. 404 group.member_not_found for an id that isn't a member of the group. A member already removed comes back in unchanged. |
POST |
/api/groups/{id}/members/extend |
Admin, owner | Extends from 1 to 50 members with one decision: {"ids": [...], "reason": ""} plus one of extend_by_seconds, expires_at and never_expires, as in POST /api/grants/extend → 200 with {"batch_id", "members": [...], "changes": [{"id", "from", "to"}]}, as in bulk operations. The ids are the id of each entry of members_list. Each member changes as in the single PATCH, with the permissions derived for them; bringing an expired membership back checks the group's permissions first. A failing member answers the single PATCH's code with params.index and params.id: 404 group.member_not_found for an id that isn't a member of the group, 409 batch.no_expiry for a length on a member without an end date, 400 group.expiry_past, 409 account.disabled. |
POST |
/api/groups/{id}/members/move |
Admin, owner | Moves from 1 to 500 members to another group in one step: {"ids": [...], "to_group_id", "reason": ""} plus exactly one of expires_at (the expiry of every new membership, null for none) and "keep_expiry": true (each keeps its own) → 200 with {"batch_id", "moved": [...], "unchanged": [...]}, as in bulk operations. The ids are the id of each entry of members_list. Each member leaves this group as in the single DELETE and joins to_group_id as in POST /api/groups/{id}/members, with that group's checks: its active permissions are checked again first (a failure answers that check's code with params.group_grant_id and params.index 0), and a disabled account answers 409 group.subject_disabled. moved holds the new memberships, as the destination's members_list shows them. A member who is in the destination already only leaves this group, keeps that membership as it is, and comes back in unchanged. 400 group.move_choice without exactly one of the two, 400 group.move_same when to_group_id is this group, 404 group.not_found for either group, 404 group.member_not_found for an id that isn't a member of this group, 400 group.expiry_past for a date in the past or a kept expiry that has passed. |
POST |
/api/groups/{id}/grants |
Admin, owner | Gives the group permissions: {"grants": [{"cluster_id", "role", "scope", "expires_at", "never_expires", "note", "ticket"}]}, from 1 to 50 → 201 with {"grants": [...]}, each expiry as in POST /api/grants: without expires_at, the default of the cluster's environment or 30 days; none with "never_expires": true. On a cluster whose environment has a maximum lifetime, an expiry past it, or none, is 400 grant.environment_max, whoever the group's members are. All or nothing. Every active member receives them. cluster-admin is personal: 400 group.cluster_admin; a native role whose group the cluster gives privileged access, or a role kubelatch writes on a privileged namespace, 422 group.privileged. |
PATCH |
/api/groups/{id}/grants/{gid} |
Admin, owner | Changes a group permission's expiry: {"expires_at": "<RFC 3339>"}, absent or null for never. → the permission. Every row derived from it takes the new expiry, capped by its membership's; an expired permission comes back. The target is validated again as a new grant would be (grant.* codes). 400 group.expiry_past, 404 group.grant_not_found, 409 grant.set_derived for one a permission on a set put there. |
DELETE |
/api/groups/{id}/grants/{gid} |
Admin, owner | Revokes a group permission and the rows derived from it → 204. 409 grant.set_derived for one a permission on a set put there: revoke that permission instead. |
POST |
/api/groups/{id}/grants/revoke |
Admin, owner | Revokes from 1 to 500 group permissions at once: {"ids": [...], "reason": ""} → 200 with {"batch_id", "grants": [...], "unchanged": [...]}, as in bulk operations. The ids are the id of each entry of grants_list. Each permission goes as in the single DELETE, with the rows derived from it. 404 group.grant_not_found for an id that isn't a permission of the group. A permission already revoked comes back in unchanged. |
POST |
/api/groups/{id}/grants/extend |
Admin, owner | Extends from 1 to 50 group permissions with one decision, with the body of POST /api/groups/{id}/members/extend → 200 with {"batch_id", "grants": [...], "changes": [{"id", "from", "to"}]}. The ids are the id of each entry of grants_list. Each permission changes as in the single PATCH, with the rows derived from it, and its target is validated again as a new grant would be (grant.* codes). A failing permission answers the single PATCH's code with params.index and params.id: 404 group.grant_not_found for an id that isn't a permission of the group, 409 batch.no_expiry, 400 group.expiry_past. |
A failing entry of a batch answers with its position in params.index; a permission that a set put on a group, named in a group batch, is refused with 409 grant.set_derived.
Bulk operations¶
Revoking, removing and disabling in bulk share one contract. Each route takes {"ids": [...], "reason": "..."}:
ids: from 1 to 500, none repeated. They are the ids of the rows the list shows: the permission, the membership (idinmembers_list, not the account's), the group permission or the account.reason: optional, at most 500 characters.
The answer is 200 with {"batch_id", "<items>", "unchanged": [...]}. batch_id is a uuid. <items> is grants, members or subjects, each as its list returns it, in the order asked and without the unchanged ones. unchanged holds the ids that were already done (a permission already revoked, a member already removed, an account already disabled): they are not a failure, and each still writes its row in the control log.
A batch is all or nothing. A request with the wrong shape is 400 before anything is read: batch.size for no ids or more than 500, batch.duplicate for a repeated id (params.index is the position of its second occurrence and params.id the id) and batch.reason_too_long. An item that fails answers with the status and the code its single operation answers, plus params.index (its position in the request, from 0) and params.id, and nothing changes. The policies are the single operation's: a batch refuses what one at a time would refuse.
Every item writes a row in the control plane log with the action its single operation writes, plus batch (the uuid), batch_size (the ids asked) and reason when one was given; a row of an unchanged item carries unchanged: true. After the commit, each affected cluster is reconciled once.
Extending in bulk (POST /api/grants/extend, and a group's members/extend and grants/extend) follows the same contract with three differences. It takes from 1 to 50 ids. Besides ids and reason it takes one decision for all, exactly one of extend_by_seconds, expires_at and never_expires: otherwise 400 batch.extend_choice, after the checks above. Its answer carries changes, each item's expiry before and after, instead of unchanged.
Moving members to another group (a group's members/move) follows the contract of removals, with moved, the new memberships, for <items>, and in unchanged the members who were in the destination already. Each member writes two rows: group.member.remove in the group it leaves, with to_group_id, and group.member.add in the one it joins, with from_group_id; a member who was there already writes its group.member.add row with unchanged: true. The control plane log shows the batch in one line, as a move between groups.
Roles¶
A role is what a permission grants: one of the six system roles (the tiers), a custom role or a native role. Custom roles are composed from the capability catalogue plus advanced rules ({"apiGroups", "resources", "verbs", "resourceNames"?}). A native role names groups the cluster already knows, in groups, and nothing else.
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/api/roles |
Session | {"roles": [...]}: the system roles in their order, then the custom ones by name. Each role: id, display_name, description, kind (custom or native; custom for the system roles too), groups (a native role's cluster groups; always an array), system, duplicable, modules, rules, namespace_scope, cluster_scope, cluster_ids (null = every cluster), max_ttl_seconds (0 = no limit), sensitive, requires_psa, revision, usage ({"grants", "clusters", "people", "bots", "permanent", "groups", "empty_groups", "sample"}: active permissions of enabled accounts, the people and bots holding the role, those with a permission that never ends, the live groups with an active permission that names it (each group counts once, whatever its clusters) and how many of those have no live member, and up to three holders as {"login", "kind", "display_name"}, people first; sample is always an array), covers (the system roles it makes redundant on the same scope: a tier lists the ones it includes, a custom role has none; always an array), created_at, updated_at and updated_by (the login of whoever created or last edited a custom role; null for a system role). A deleted role is not listed. Without the admin role, usage is always zero and cluster_ids is null, here and in GET /api/roles/{id}. |
GET |
/api/role-activity |
Admin | GET /api/role-activity: each role's requests over the last 30 days, read from the groups the proxy sent: {"since", "floor", "roles": {"<id>": {"last_at", "daily"}}}, one entry per role (system roles included). daily has 30 counts, one per UTC day, oldest first, ending today; a request counts once for a role however many of its groups it carried, and a namespace group counts only for a request in that namespace. When the audit is shorter than 30 days (Free serves 24 hours), floor is the earliest moment read, last_at counts from it and daily is null; otherwise floor is null. |
GET |
/api/roles/{id} |
Session | A role, plus effective_rules and yaml (the ClusterRole kubelatch writes; empty for admin and cluster-admin, and a native role has neither: [] and ""). 404 role.not_found, also for a deleted role. |
GET |
/api/roles/{id}/clusters |
Admin | What each ready cluster knows of a native role's groups, as an array: [{"cluster_id", "reachable", "groups": [{"name", "known", "privileged", "bindings"}]}], one per cluster of the role's cluster_ids (every cluster when null), with bindings as in rbac-subjects. A cluster that can't be read comes with reachable: false and no groups. From the five-minute read of each cluster; ?refresh=1 reads them again, as on rbac-subjects. A custom role answers []; a system role, 404 role.not_found. |
POST |
/api/roles |
Admin | Creates a custom role: {"id", "display_name", "description", "modules", "rules", "cluster_scope", "cluster_ids"} → 201 with the role. For a native role, "kind": "native" and groups, with modules and rules empty and cluster_scope true; without kind, the role is custom. 422 with the first rule it breaks (role.* codes); 409 role.exists if the id belongs to a role, a deleted role or a system role. |
PUT |
/api/roles/{id} |
Admin | Edits a custom role: the same fields without id, plus the revision you read → the role with revision + 1. 409 role.stale_revision if someone saved it first; 409 role.cluster_scope_in_use or 409 role.cluster_in_use if the change would leave active permissions without effect; 409 role.psa_in_use if the change makes the role require Pod Security while it has active permissions on namespaces (revoke them and grant again, which checks each namespace's label); 400 role.system for a system role; 409 role.kind_immutable if kind changes. Clusters where the role has active permissions are reconciled at once. |
DELETE |
/api/roles/{id} |
Admin | Deletes a custom role → 204. 409 role.in_use while it has active permissions; with ?revoke_grants=true it revokes them in the same step. The id is never reused. |
POST |
/api/roles/preview |
Admin | The same body as POST, without saving: {"effective_rules", "violations", "sensitive", "requires_psa", "yaml", "impact", "diff"}. An invalid draft is not an error: its problems come in violations, each with code, params, module, rule (-1 for the role itself) and field (the field at fault, "" when none). impact and diff compare with the saved role of the same id. For a native draft, effective_rules is empty, sensitive true, requires_psa false and yaml "", and impact also carries native_clusters: [{"cluster_id", "reachable", "privileged_groups"}], the draft's groups that each cluster gives privileged access, from the five-minute read. |
GET |
/api/role-modules |
Admin | {"modules": [...]}: the capability catalogue, each with id, domain, rules, sensitive, requires_psa, cluster_only and reads (the read capability a write one presumes, "" when none). |
A permission can name a custom role in POST /api/grants. Besides the checks of a system role: 422 grant.unknown_role for an unknown or deleted role, 422 grant.role_not_on_cluster if the role is limited to other clusters, 422 grant.role_namespace_only for scope * on a role that cannot be granted to a whole cluster, and 409 grant.cluster_bootstrap_v1 if the cluster still has the previous bootstrap manifest. A native role also answers 422 grant.role_cluster_only for a namespace scope. When the role is native, can be privileged on its own (developer, debugger, admin, secrets-reader or a custom role that runs workloads or reads or writes Secrets), or the cluster's identity template isn't the default, granting reads the cluster's RBAC live: 422 grant.privileged_ttl when a name the permission gives has privileged access (a group of the role, the person's name, or kubelatch's group of the role on its scope) and the expiry is missing or beyond 8 hours, 409 grant.identity_collision when the template gives the person a name another account already has there, and 502 cluster.unreachable when the cluster can't be read. The same applies to POST /api/grants/batch.
Credentials¶
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/api/credentials |
Session, CLI token | Inventory: an admin sees all of them; a person, their own. Filters: subject (a uuid; an admin picks anyone, anyone else only their own: 403 for another subject) and active=true for only those active now (not revoked or expired). Each one carries its kind: standard, cli (a kubelatch login session) or ci (issued by the GitHub Actions exchange). A ci credential carries trust_rule_id, the trust rule it was issued under (null for the others, and once the rule is deleted). With a CLI token, only your own, even for an admin. |
POST |
/api/credentials |
Session | Issues a credential: {"ttl", "name", "note", "cluster_id", "subject_id"}. ttl is required (7d, 12h). Without cluster_id, it's valid in every cluster where the subject has permissions. Only an admin issues one for another subject. Returns token and kubeconfig once only. |
POST |
/api/credentials/{id}/revoke |
Session (owner or admin), CLI token | Revokes a credential. Optional body {"reason"}. |
POST |
/api/credentials/lookup |
Admin | Finds the credential a whole token belongs to: {"token": "klt_…"} → the credential, in any state (active, expired or revoked). A delegated agent's token matches too, with kind agent, which no list returns. The token goes in the body, never in the URL; it is hashed and never stored or logged. The control event credential.lookup carries matched: true and names the credential on a hit; a miss leaves one too, with matched: false and no target. 400 credential.bad_token if it isn't a kubelatch token, 404 credential.unknown if no credential of this instance matches, 429 auth.too_many_attempts beyond 10 lookups per minute per IP. |
kubelatch CLI¶
| Method | Route | Access | What it does |
|---|---|---|---|
POST |
/api/cli/authorizations |
Anonymous | Starts a kubelatch login: {"kind": "loopback", "code_challenge", "port", "host"} → {"id", "expires_in"}, or {"kind": "device", "host"} → {"id", "device_code", "user_code", "verification_url", "expires_in", "interval"}. 10 per minute per IP. |
POST |
/api/cli/token |
Anonymous | The exchange: {"id", "code", "code_verifier"} or {"device_code"} → {"token", "credential_id", "expires_at", "login"}, once. Before the approval, 400 cli.pending; polling faster than interval, 429 cli.slow_down; expired, refused or used, 410 cli.expired. 60 per minute per IP; beyond that, 429 auth.too_many_attempts. |
GET |
/api/cli/authorizations/{id} |
Session | A pending browser request, for its approval page: {"id", "kind", "host", "request_ip", "created_at", "expires_at", "session_ttl_seconds", "same_network"}. same_network is true when the CLI asked from the approving browser's own address or from the same /24 (IPv4) or /64 (IPv6): the one fact a person can check against a phishing attempt. It is false when the browser's request carries X-Forwarded-For or Forwarded from a proxy that KUBELATCH_TRUSTED_PROXIES does not list, because both addresses would then be that proxy's. |
POST |
/api/cli/authorizations/{id}/approve |
Session | Approves it: {"state"} → {"redirect"}, always http://127.0.0.1:<port>/callback?code=…&state=…. |
POST |
/api/cli/authorizations/{id}/deny |
Session | Refuses it: {"state"} → {"redirect"} (…?error=access_denied, or "" for a device request). |
POST |
/api/cli/authorizations/lookup |
Session | A pending device request by its code: {"user_code"} → the same view, same_network included. Wrong codes are limited per person and per IP. |
POST |
/api/cli/authorizations/approve |
Session | Approves a device request: {"user_code"} → 204. |
GET |
/api/cli/access |
Session, CLI token | Your clusters for the kubeconfig: {"server", "ca", "clusters": [{"id", "name", "server", "grants": [{"role", "tier", "scope", "expires_at"}]}]}. tier is the deprecated alias of role. |
POST |
/api/cli/logout |
CLI token | Revokes the credential it's called with → 204. |
AI agents¶
The sessions and approvals of AI agents (the routes and fields call an approval a request), and a person's consent to a delegated agent. An administrator sees every account's; anyone else, their own (404 for someone else's). None of these routes takes a CLI token: deciding and consenting need a browser session. See AI agents.
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/api/agents/sessions |
Session | Agent sessions you may see: an administrator, every account's, or one's with subject (a uuid); anyone else, their own (403 agent.own_only if they filter by another account). Without state, or with state=active, all the active sessions, the most recently used first: {"sessions": [...], "ended_count", "truncated"}, where ended_count is how many ended sessions you may see and truncated is true when there are more than 1,000 active ones and the list stops there. With state=ended, a page of the ended ones, the latest to end first: {"sessions": [...], "page", "page_size", "has_more", "total"}, 50 at a time (page, 1 to 10000), total being all the ended sessions you may see; any other state is 400 query.state. Each session has id, mode (bot or delegated), subject, client_name, client_id, writes_need_approval, created_at, expires_at, last_used_at, revoked_at, revoked_by_login, state (active, revoked, expired), credential_prefix, pending_requests, can_cut, follow (none, viewer or same: what the session takes of the permissions its person receives later; none for a bot's) and, for a delegated session, ceiling. In Pro each active session also carries activity, what it did in the last 24 hours: calls (its audit rows), denied (those answered 401 or 403), hourly (its calls per UTC hour: 24 numbers, the oldest first and the last one the current hour; the partial hour before them counts in calls only) and last_call (at, tool, verb, resource, subresource, namespace, cluster_id and status, 0 while the request is in flight; null without a call). For administrators only, each also carries worth_a_look, why its agent deserves a look (an agent is a bot, or one client a person authorized; each of its active sessions carries the same list, [] when nothing deserves it), the most recent first: {"kind", "at", "count", …}, with at when the motive last fed and count its figure. kind is flood (20 requests or more in the last hour, refused ones included: baseline is what the agent usually asks per hour, left out without history, and refused), sensitive (in the last day, a clearly sensitive access asked and not granted, one per cluster_id, namespace and role: denied, expired and refused say how the requests ended), insistence (in the last day, three requests or more equal to one that failed, one per request: cluster_id, namespace and role for an access, or write with method, path, namespace, resource and name; also denied, expired and refused) or denied (20 calls or more the cluster denied in the last hour: calls is all its calls in that hour). A flood, sensitive or insistence motive also carries refusal (code and params, as in an approval's) when kubelatch refused requests behind it: the latest one, and in a flood only if they all share the code. The fields that don't belong to a kind are left out. activity and worth_a_look are null in Free (worth_a_look also for someone who isn't an admin), on an ended session and on the routes that return a single session. |
GET |
/api/agents/sessions/{id} |
Session (owner or admin) | One session, with grants: the permissions in effect now. |
GET |
/api/agents/sessions/{id}/events |
Session (owner or admin) | Its timeline: the audit rows of the session, 50 at a time, newest first (page). The only route where someone who isn't an admin reads audit rows, and only their own sessions'. In Free and lapsed instances it is served 24 hours back, and the page carries floor like the audit listings. |
DELETE |
/api/agents/sessions/{id} |
Session (owner or admin) | Cuts the session: revokes its credentials and refresh tokens, closes its pending approvals and revokes the permissions it got on request. 200 with the session. |
POST |
/api/agents/sessions/cut |
Session (owner or admin) | Cuts several sessions at once, all or none: {"ids": [...]}, 1 to 50 ids (400 agent.cut_batch_size otherwise; a repeated id is cut once). Each session is cut as DELETE cuts it, in one transaction, and its agent.session.revoke event carries the batch. Answers {"sessions": [...]}, each as DELETE answers, in the order asked. 404 agent.session_not_found if any id is unknown, isn't a uuid or isn't a session you may see, whichever the others are: it names no id and never tells that one exists. 409 agent.cut_batch, with params.session, for the first session that is no longer active (already cut, or expired): nothing is cut. |
GET |
/api/agents/requests |
Session | Approvals, at most 200, pending first: id, kind (write or access), status (pending, approved, denied, expired, executed, refused), session_id, session_mode, session_client_name (the agent's own name for itself, empty when it gave none), subject, cluster_id, tool, reason, created_at, expires_at, decided_by_login, decided_at, executed_at, execution_status, execution_audit_id, result_grant_id, can_decide, refusal, and write (method, path, namespace, resource, name) or access (namespace, role, minutes). refused is a request that kubelatch's policy turned down when the agent made it (an access above its person's role, a role that can't be requested, a write the bot's rules don't allow, or a session with 20 approvals already waiting): no person decides it, it is never pending, and the agent got the same error as always. refusal is that error's {"code", "params"}, which the interface translates as it does an API error; null unless status is refused. Filters: status=pending, session (a uuid). |
GET |
/api/agents/requests/{id} |
Session (owner or admin) | One approval, with the list's fields; a write's also carries preview (current and result, Secret values hidden: each shows its size and, in result, whether the write changes it). |
POST |
/api/agents/requests/{id}/approve |
Session (who decides) | Approves it. A bot's: any admin; a delegated session's: its person only (403 agent.not_decider for anyone else who can see it). Closed: 409 agent.request_closed. |
POST |
/api/agents/requests/{id}/deny |
Session (who decides) | Denies it. |
POST |
/api/agents/requests/approve |
Session (who decides) | Approves several at once, all or none. Body {"ids": [...]}, 1 to 20 ids of pending writes of one shape: the same agent session, cluster, tool, resource and namespace (422 agent.batch_shape otherwise, also for an access request; 400 agent.batch_size for the count). Each id goes through the single approval's checks; the first that fails is named by params.index with that error (404, 403 agent.not_decider, 409 agent.request_closed) and nothing is decided. Answers {"requests": [...]}, each as GET /api/agents/requests/{id} would, in the order asked. |
POST |
/api/agents/requests/deny |
Session (who decides) | Denies several at once, all or none: the same body and errors, for any pending approvals the person may decide, writes and access requests alike. |
GET |
/api/agents/authorizations/{id} |
Session | A pending sign-in of a delegated agent, for the consent page: id, client (name, origin, verified_host, redirect_uri, redirect_host, redirect_remote, loopback_only), grants (each with its choices and its source, as in GET /api/grants: direct, or the group it comes from), default_ttl_seconds, max_ttl_seconds, expires_at. redirect_remote is true unless this request's answer goes to a loopback address on this computer (http or https on localhost, 127.0.0.0/8 or [::1]): any other scheme counts, since the app that opens it can pass the answer on. |
POST |
/api/agents/authorizations/{id}/approve |
Session | {"grants": [{"grant_id", "role"}], "ttl_seconds", "writes_need_approval", "follow"} → {"redirect"}: where the browser goes back to the agent, with the code. follow (none, viewer or same) is what the session takes of the permissions the person receives later; absent, none, so an older client fails closed; anything else, 400 oauth.bad_follow. |
POST |
/api/agents/authorizations/{id}/deny |
Session | → {"redirect"} with error=access_denied. |
The three authorizations routes exist only while KUBELATCH_MCP_ENABLED is true.
Edition¶
All require admin.
| Method | Route | What it does |
|---|---|---|
GET |
/api/edition |
The edition in force: edition (free or pro), status (free, trial, pro, pro_grace, lapsed), source of the key (none, env, ui), invalid (why the key does not verify, empty when it does), customer, license_id, seats (0 is no limit), trial, expires_at, grace_until, over_seats (more people enabled than the key has seats; one more is refused), trial_until (when the one trial of this instance ends or ended; null before any), limits (clusters, people, custom_roles, retention_days; null is no limit, and for retention_days it means as configured), usage, audit_retention_hours, warnings (expiring, grace, lapsed, over_seats, invalid_env_license, license_invalid, people_at_limit, clusters_at_limit) and version. Never the key. audit_retention_hours is how long the retention job keeps the audit in force now: KUBELATCH_AUDIT_RETENTION, or 24 in an instance that never had a key when that is longer or unlimited; 0 deletes nothing. version is what the daily update check knows: {"current", "latest", "latest_url", "checked_at", "enabled", "update_available"}, with latest, latest_url and checked_at empty (null for the date) until a check succeeds, and enabled false when the check is off. It is null on a server without the checker. |
PUT |
/api/edition/license |
Installs a key ({"token"}), stored encrypted; answers the new edition, version included. 422 edition.license_invalid with the reason; 422 edition.trial_used for a trial key from another subscription than the one trial this instance had (params.until is the day that trial ends); 409 edition.license_from_env while KUBELATCH_LICENSE is set. |
DELETE |
/api/edition/license |
Removes the installed key (204); 409 edition.license_from_env while KUBELATCH_LICENSE is set. |
POST |
/api/edition/version-check |
Runs the update check at once and answers the version object of GET /api/edition. 409 edition.update_check_off when the check is off, 502 edition.update_check_failed when the site doesn't answer (the previous result is kept), 404 on a server without the checker. Only the replica that answers checks. |
GET /api/me carries edition (free or pro) for everyone. A registration beyond a limit (POST /api/subjects for a person, POST /api/subjects/{id}/enable on a person, POST /api/clusters, POST /api/roles and PUT /api/roles/{id}, except for a native role) answers 422 edition.limit with resource, max and edition. In Free and lapsed instances, GET /api/audit, GET /api/audit/summary and GET /api/control-events serve the last 24 hours and carry floor, the earliest moment served (null otherwise); GET /api/audit/{id} answers 404 for an older event, the same as for an unknown id.
Webhooks¶
All require admin. Webhooks are Pro: outside it, creating, changing and testing one answer 403 edition.feature, while listing them, their deliveries and deleting them work in any edition.
| Method | Route | What it does |
|---|---|---|
GET |
/api/webhooks |
{"webhooks": [...]}, oldest first. Each: id, name, origin (the URL's scheme and host, https://hooks.slack.com: the URL itself can't be read back, since its path and query carry the credential of Slack and Teams), format (json, slack or teams), locale (en or es), events (in the order grant.expiring, break_glass.login, join_request.created, version.available), active, created_at, updated_at and last_delivery, its newest delivery as /deliveries gives it (null before the first). Never the URL or the secret, in this answer or any other: both are write-only, and the secret comes once, in the creation's answer. |
POST |
/api/webhooks |
Creates one: {"name", "url", "format", "locale", "events"} → 201 with {"webhook", "secret"}. secret (whsec_…, the key of the signature) is in this answer alone. An empty locale is en; a repeated event counts once. |
PATCH |
/api/webhooks/{id} |
Changes any of name, url, format, locale, events and active (false pauses it, true resumes it); a field left out stays → {"webhook"}. url replaces the address, which is checked again then; leave it out to keep the current one. |
DELETE |
/api/webhooks/{id} |
Deletes it with its deliveries → 204. |
POST |
/api/webhooks/{id}/test |
Sends a ping at once, paused or not, and tries it once → 200 with {"delivery"}, delivered or failed: a receiver that fails still gets a 200 here. |
GET |
/api/webhooks/{id}/deliveries |
{"deliveries": [...]}, newest first, at most 50. Each: id (the Kubelatch-Delivery header), event (one of the four, or ping), status (pending, delivered, failed, skipped or skipped_edition, which is skipped because Pro ended), attempts (the POSTs made, the successful one included), response_code (null without an answer), last_error, created_at, next_attempt_at (only while pending; null otherwise) and delivered_at. Never the body sent. |
404 webhook.not_found for an unknown id. A body that doesn't pass answers 400: webhook.bad_name (1 to 80 characters without control characters, params.max), webhook.bad_url (not an https URL with a host, without a user or a fragment, of at most 2,048 bytes), webhook.private_url (the host is localhost or an address that isn't public, unless KUBELATCH_WEBHOOK_ALLOW_PRIVATE is true), webhook.bad_format, webhook.bad_locale or webhook.bad_events (none, or one that isn't one of the four).
Audit¶
| Method | Route | Access | What it does |
|---|---|---|---|
GET |
/api/audit |
Session | Requests to the proxy, 50 at a time. Filters: subject, cluster, namespace, verb, resource, credential, token_prefix (klt_ followed by 8 characters; also matches requests rejected before they resolved to a credential id), agent_session (an agent session id) and mcp_tool (the MCP tool that made the request), from and to (RFC 3339), include_discovery=true, outcome, access, environment and production (administrators only: see below), q (part of the name the request entered the cluster as, impersonated_user; at most 253 characters), page (1 to 10000). Without the admin role, only your own. |
GET |
/api/audit/summary |
Session | Counts of what /api/audit would list with the same filters (page is ignored): total, denied, errors, writes, agent_calls (requests an MCP tool made), by_cluster (cluster_id, total, denied, errors; most requests first) and buckets (start, total, denied, errors; oldest first, every bucket of the period present, at zero when empty). Without from and to it counts the last 24 hours; with from alone, up to now; with to alone, the 24 hours before it. Buckets are UTC hours up to 48 hours and UTC days past that. A period over 3660 days is cut to the 3660 days before to. Returns from, to and floor (RFC 3339; floor as in the listings), so the period counted is always explicit. Without the admin role, only your own. |
GET |
/api/audit/{id} |
Session | One request by its Audit-ID. Someone else's responds 404 if you're not an admin. In Free and lapsed instances, one older than floor responds 404 to anyone, admins included, exactly as an unknown id does. Besides the row's fields it carries grants: the permissions of the request's account on its cluster that were in force when it was made (created before it, neither revoked nor expired at that moment) and whose Kubernetes groups the request carried, each {"id", "role", "scope", "note", "ticket", "source": "direct"\|"group"\|"set", "group_id"?, "set_grant_id"?}; [] for a request with no account. Without the admin role you only see your own, so only your own permissions. |
GET |
/api/control-events |
Admin | Control plane actions and login attempts, 50 at a time. Filters: actor (uuid), action (exact match, a code of lowercase letters, _ and ., at most 64 characters, e.g. user.create), cluster (a cluster id: its own events and those of the permissions on it), from and to (RFC 3339), page (1 to 10000). |
GET |
/api/insights |
Admin | What an administrator's Home and the Operations page read beyond their lists. sensitive: the exec and attach (exec), Secret reads with get, list or watch (secrets) and port-forwards (portforward) of the last 24 hours, allowed and denied, per kind and account (kind, subject_id, login, count; by kind, most first). sensitive_scope says which clusters they count: all of them, or production from the moment an environment is marked as production, when they count the clusters of the environments marked alone (one with no cluster counts nothing) and GET /api/audit?production=true lists the rows behind each count. GET /api/insights/operations counts every cluster either way. unused_grants: the active permissions created over 30 days ago whose account sent no request to their cluster (and namespace, unless *) in the last 30 days, one row each, a group's per member (grant_id, last_used_at: its last request before them, null when the audit keeps none). expiring_share: per UTC day of the period, the permissions active at its end (now, for today) and how many of them have an end (day, active, with_end), rebuilt from the permissions' dates. sensitive_share: the same now for the roles marked sensitive (active, with_end). account_access: the enabled accounts holding a permission in force, by the kind of credential they used in the period, each in one group, long-lived first: people by a long-lived standard credential (long), a CLI session (cli), a delegated agent acting for them (agent) or nothing (none); bots by a standard credential (long), a CI token (ci) or nothing (none); agents, one per account and client name with a session active now or used in the period, by a bot's session (bot), a person's delegated session (delegated) or none used (none). It reads credentials and sessions, not the audit, so floor does not cut it. period is 7, 30 (the default) or 90 days and moves expiring_share and account_access alone; any other value is 400 query.bad_period. Returns from and to (the period) and floor, the later of the edition's floor and what the audit retention keeps (null when neither cuts); when it falls inside the 30 days, unused_grants is empty and unused_grants_floor carries it (null otherwise). |
GET |
/api/insights/operations |
Admin | What the Operations page shows of the clusters' operations over the period. A request counts when it has a resource or was refused (401 or 403). accounts: one row per account, most requests first (kind: person, bot, agent, or none for a token that is unknown, revoked or expired; subject_id and login, for an agent those of its person or bot, with its client; requests, denied, sensitive, deletes, last_at), cut at 500 rows but keeping every anomaly (anomaly: denied, sensitive or empty; score: how far past its threshold); accounts_total, sensitive_accounts and denied (every refused request of the period) count before the cut. by_day: the requests per kind of account and UTC day, or per hour when hourly is true (start, person, bot, agent, none, and denied: how many of them were refused, as an account's denied counts them). by_hour: the requests per UTC hour, only the hours with any (start, requests). sensitive: the period's exec, secrets and portforward, as access counts them in /api/audit. clusters: every registered cluster with its requests, denied and sensitive and its signals (status, everyone_privileged, bootstrap_version, k8s_version, and k8s_unsupported: a minor version more than three behind the one kubelatch is built against). period is as in GET /api/insights. from is the later of the period's start and floor, rounded up to a whole hour when that leaves 48 hours or less (hourly), or else to a whole UTC day; to is now. |
By default /api/audit hides rows with no resource (API discovery and authentication failures); include_discovery=true shows them. outcome keeps one class of result: denied (401 or 403), error (5xx, or an error after a success status, such as a stream the cluster reset) or write (create, update, patch, delete, deletecollection, or an exec, attach or port-forward). Any other value is 400 query.bad_filter. The summary's denied, errors and writes count exactly the rows these three values select. access keeps one kind of sensitive access, allowed and denied alike: exec (the exec and attach subresources), secrets (get, list or watch on Secrets) or portforward; any other value is 400 query.bad_access. It is the definition behind the sensitive counts of GET /api/insights, so each of those counts is the number of rows its value selects. environment (an environment's id) keeps the requests of the clusters that environment holds today, not the ones it held when the request was made, and production=true those of every environment marked as production (none marked, nothing; a production environment with no cluster keeps nothing, never every cluster). Both listings take them; anyone without the admin role gets 403 session.admin_only for either, whatever its value. An environment that is not the id of an existing environment, a production other than true, or both together is 400 query.bad_environment. Both listings return events, page, page_size, has_more and floor (Edition). Each request carries impersonated_user, the name the proxy sent the cluster for it (Impersonate-User), empty for a request refused before that name was worked out.
CI trust rules¶
All require admin.
| Method | Route | What it does |
|---|---|---|
GET |
/api/ci/trust-rules |
Lists the rules plus the audience, issuer, token lifetime, and exchange route a workflow should use. Each rule carries repository_label (the owner/repo it was written with; empty when only the numeric ids were given) and last_redeemed_at (when a workflow last exchanged a token through it; null if none ever did). |
POST |
/api/ci/trust-rules |
Creates a rule: {"name", "bot_subject_id", "repository", "repository_owner_id", "repository_id", "repository_label", "ref", "environment"}. Name the repository with repository ("owner/repo"), which the GitHub App resolves to the ids and the label, or with the numeric repository_owner_id and repository_id, plus an optional repository_label (owner/repo, at most 140 characters) for the list to show. With the GitHub App configured, a repository_label given next to the ids is resolved too and must name those ids. 400 ci.repository_needs_github if you give repository without the GitHub App configured, 404 ci.repository_not_found if GitHub doesn't know the repository or the App can't see it, 400 ci.repository_moved if GitHub answers a redirect because the repository was renamed or transferred (use its current owner/repo), 400 ci.github_not_installed if the App is configured but not installed in the organization, 400 ci.bad_repository for a name or label that isn't owner/repo, or a label that doesn't name the given ids. Empty ref and environment match any. |
DELETE |
/api/ci/trust-rules/{id} |
Deletes a rule. Does not revoke credentials already issued. |
Web interface¶
Any other route serves the embedded SPA: the file if it exists, or index.html for interface routes (/login, /account-link, /account, /activity, /cli/…, /admin/…, /agents/…). A missing file under /assets/ is 404. It only accepts GET and HEAD.