Errors¶
The codes and messages kubelatch returns, what they mean, and what to do about each one. kubectl and the API get them in English; the interface shows API errors in your language.
Proxy errors¶
The proxy (/clusters/<id>/...) always responds with a Kubernetes Status object, so kubectl shows the message directly:
{"kind": "Status", "apiVersion": "v1", "status": "Failure", "message": "credential revoked", "reason": "Unauthorized", "code": 401}
Every proxy response carries the Audit-ID header. If you open an issue, include that value. API responses carry X-Request-Id instead (see API errors).
| Code | Message | What it means | What to do |
|---|---|---|---|
400 |
invalid path: empty segments, '.' or '..' or escaped characters are not allowed |
The route has //, ., .., an escape or a %. |
Check the kubeconfig's server: it must be https://<kubelatch>/clusters/<id>, nothing else. |
400 |
invalid cluster identifier: … |
The segment after /clusters/ cannot be a cluster's id. |
Use the kubeconfig kubelatch gave you. |
400 |
kubelatch does not accept Impersonate-* headers (kubectl --as is not supported) |
You used --as, --as-group, or a tool that uses impersonation. |
Remove --as. If you need to test someone else's permissions, use the cluster's native admin access. |
401 |
a kubelatch token is required (Authorization: Bearer klt_...) |
There's no token, or it doesn't have the format of a kubelatch token. | Check that the kubeconfig has the full token. |
401 |
unknown credential |
The token doesn't exist in kubelatch (miscopied, from another installation, or its cluster was deleted). | Issue a new credential. |
401 |
credential revoked |
Someone revoked the credential. | Issue a new one; if it wasn't you, tell an admin. |
401 |
credential expired |
It passed its expiry date. | Renew the credential. |
401 |
this credential is only valid on the MCP endpoint |
The token belongs to an agent session; it works only on /mcp. |
Use a credential of your own for kubectl; leave the agent's token to the agent. |
403 |
the account is disabled |
The account that owns the credential is disabled. | Talk to an admin. |
403 |
this credential is restricted to cluster <id> |
The credential was issued for another cluster. | Use the correct context or issue a credential for this cluster. |
403 |
<user> has no active permissions on cluster <id> |
You have no permission currently in effect in that cluster (never had one, it was revoked, or it expired). <user> is the name the cluster gives you: user:<login> by default, or what its identity template renders. The message ends with where to ask for access (403 — no permission). |
Ask for the permission where that sentence says. |
403 |
this cluster identifies people by their verified e-mail and the account `<login>` has none: sign in with the identity provider, or ask an administrator to link it |
The cluster names people by something your account lacks: the verified e-mail of your identity at the OIDC provider, as here, or its subject (their identity provider subject) or your linked GitHub login (their GitHub login). |
Sign in to kubelatch once with the identity provider, or link GitHub; an admin can also send you a linking link (Accounts that can't be named). |
403 |
the name this cluster would give the account `<login>` is not valid: ask an administrator |
The cluster's identity template gives your account a name the cluster must not receive, such as one that starts with system: or holds a space. |
An admin changes the template or the account. |
403 |
the credential is no longer valid: revoked, expired, account disabled or permissions changed |
The request was in progress and got cut off because something changed. | Retry it; if it keeps failing, check the rows above. |
404 |
unknown cluster |
There's no cluster with that id. | Check the kubeconfig's server. |
500 |
internal error |
Unexpected kubelatch failure. | Look up the Audit-ID in kubelatch's log. |
502 |
could not talk to the cluster's API server: timed out |
The API server didn't respond in time. | An admin must check the network between kubelatch and the cluster. |
502 |
could not talk to the cluster's API server: the API server certificate does not match the registered CA |
The API server's certificate changed or isn't the registered one. | An admin re-enrolls the cluster (Re-enroll) with the cluster's current kubeconfig: the enrollment command brings the new CA. |
502 |
could not talk to the cluster's API server: API server unreachable |
Connection refused, name not resolved, or network unreachable. | An admin checks the cluster's URL and network path (see Private clusters). |
502 |
could not talk to the cluster's API server: connection error |
Another connection failure. | The full detail is in kubelatch's log. |
503 |
the cluster has no tokens configured yet |
The cluster is registered but its enrollment command hasn't been run yet (or its bootstrap JSON pasted). | An admin finishes registering the cluster. |
503 |
kubelatch cannot query its database |
Postgres isn't responding, or (if it only happens on one cluster) its tokens can't be decrypted because KUBELATCH_ENCRYPTION_KEY changed; the log says proxy: load cluster. |
An admin checks Postgres or rotates the cluster's tokens. |
503 |
kubelatch cannot record the request |
The audit row couldn't be written; without it, the request isn't forwarded. | An admin checks Postgres. |
503 |
kubelatch is restarting |
The replica is shutting down (a rollout). | Retry: kubectl and client-go do this on their own. |
A 403 with a message like User "user:<login>" cannot list resource "secrets"… is not from kubelatch: it's the cluster's RBAC saying your role doesn't include that action (see Roles and System roles). When kubelatch knows a role that would allow it, it adds a sentence at the end, kubelatch: the <role> role … allows this., and where to ask for it (403 — no permission). With a native role, what the role allows is what the cluster's own bindings give its groups. A 403 saying that kubelatch-proxy cannot impersonate your name or a group means the reconciliation left that name out: two accounts share it, the cluster binds it to cluster-admin and a permission has no expiry of 8 hours at most, or the pass hasn't run yet. The cluster's page says which (Identity in the cluster).
The MCP endpoint¶
/mcp, where AI agents connect, answers its own refusals as plain text, not as a Status. A tool that reaches a cluster goes through the proxy, and the agent gets the proxy's code and message from the table above in the tool's error, with its audit id. Every 401 carries WWW-Authenticate: Bearer resource_metadata="<KUBELATCH_BASE_URL>/.well-known/oauth-protected-resource/mcp", which tells an MCP client where to sign in.
| Code | Message | What it means | What to do |
|---|---|---|---|
400 |
JSON-RPC batches are not accepted |
The client sent several JSON-RPC messages in one array. | Send one message per request, as current MCP clients do. |
401 |
invalid token: … |
The token check refused the request; the rest is the proxy's message, such as invalid token: credential revoked, or invalid token: this agent session was revoked once you or an admin cut the agent's session. |
As for the same message on the proxy. |
401 |
invalid token: the MCP endpoint accepts a bot's credential, or an agent's token from kubelatch's sign-in |
The token is a person's own credential (a kubectl or CLI one), or a CI one. |
Give the agent a bot's credential, or let it sign in through kubelatch so you authorize it. |
401 |
invalid token: this agent session expired |
The session you authorized for the agent reached the end of the duration you chose. | Connect the agent again: it signs in and you authorize it anew. |
401 |
this agent session was revoked |
In a tool's error: an admin cut the agent's session while its request was being served. | Ask an admin; the agent needs a new credential. |
401 |
this agent session expired |
In a tool's error: the agent's session expired while its request was being served. | Connect the agent again. |
401 |
this credential belongs to another agent session |
In a tool's error: kubelatch was handed an agent's token together with a session that isn't its own, and refused to serve it. It points to a bug in kubelatch, not to anything the agent did. | Report it, with the audit id. |
403 |
this agent session has no active permissions on cluster <id> |
In a tool's error: the agent acts on your behalf, and none of the permissions you gave it on that cluster is in effect: your own permission it came from was revoked or expired, or it is a custom role the cluster can't apply yet. Your other permissions never count for the agent. The message ends with kubelatch: this session has no permissions here yet; request_access asks <login> for one. |
Let the agent ask you with request_access, or connect it again and include the permission. |
403 |
cross-origin requests are not accepted |
The request carried an Origin header other than the origin of kubelatch's base URL: an MCP client running inside a web page, which kubelatch doesn't support. |
Use a client that runs outside the browser. |
413 |
request body exceeds 1048576 bytes |
The JSON-RPC message is over 1 MiB. | Send a smaller message; for apply, one object per call. |
429 |
too many requests for this credential |
Over KUBELATCH_MCP_RATE_LIMIT requests in the current minute, for this credential or, for a delegated agent, this session. |
Wait for the next minute; an admin can raise the limit. |
500 |
kubelatch cannot query its database |
Postgres isn't responding. The proxy answers 503 for this; /mcp answers 500 because the token check fixes that code. |
An admin checks Postgres. |
A tool's own refusal carries a code, besides its text (Results). These messages go to the agent, in English:
| Code | Message | What it means |
|---|---|---|
agent.approval_pending |
this write waits for a person's approval at {url} (your client may open it for you). Call the tool again right away with the same arguments and approval_id {id}: the call waits for the decision; repeat it while it answers approval_pending. It expires at {expires_at} |
Not a failure: the write is held for a person to decide. |
agent.approval_denied |
a person denied this write: do not try it another way |
The person who decides said no. |
agent.approval_expired |
the approval expired: call the tool again without approval_id to ask for a new one |
Nobody decided in 15 minutes, or the approved write wasn't run within 15 more. |
agent.approval_executed |
this approval already ran: read the object to see the result |
The approval ran already, such as when a client reconnected as the person approved and a call before it ran the write. |
agent.approval_mismatch |
approval_id does not match this call: use the same arguments as the call that asked for it |
The second call asks for something other than what was approved. |
agent.too_many_pending |
there are already {max} approvals waiting for a decision: wait until they are decided or expire |
The account has 20 approvals waiting. |
agent.access_bad_minutes |
minutes must be a whole number from 1 to {max} |
request_access with minutes outside 1 to 480. |
agent.access_reason |
reason is required, at most {max} characters |
request_access without a reason, or one over 500 characters. |
agent.batch_shape |
a batch approves writes of one shape: the same agent, cluster, tool, resource and namespace |
A batch approval that mixes shapes or holds an access request. params.index names the first entry that breaks the rule; nothing is decided. |
agent.batch_size |
a batch holds between 1 and {max} requests |
A batch decision with no ids, or with more than 20. |
agent.access_beyond_ceiling |
the agent can ask only for a role you hold on that cluster and namespace, and never for cluster-admin |
A delegated agent asked for more than its person has there. |
agent.role_not_requestable |
an agent cannot ask for the role {role}: kubelatch does not know what its groups allow, so a person has to grant it |
request_access for a native role. |
Statuses you'll only see in the audit log¶
| Status | Error | What it means |
|---|---|---|
101 |
An upgraded connection (exec, attach, port-forward, cp). The row is completed when it closes. |
|
101 |
stream cut: the credential is no longer valid |
An open stream that kubelatch cut off when revoking, expiring, disabling, or changing permissions. |
499 |
client disconnected |
The client hung up before receiving the response. |
| any | kubelatch restarting |
The stream was cut because the replica was shutting down. |
403 |
identity.missing_fact: …, identity.bad_username: … |
The cluster's identity template couldn't name the person; the rest is the proxy's message (Proxy errors). The row's Entered as is empty. |
Errors signing in with GitHub¶
When login with GitHub fails, kubelatch redirects to /login?error=<code> (or to /account?error=<code> if you were linking from My account), and the page shows the message from the table. Each code has an anchor #github-<code>.
An unknown code shows Could not sign in with GitHub.. Almost every failure leaves a login.failure with the code in the control plane's log; the state ones from a missing, expired, or other-session cookie, and the server ones, only stay in the log. How to resolve each case is in If something fails and in Troubleshooting.
Errors signing in with OIDC¶
When a sign-in with the OIDC provider fails, kubelatch redirects to /login?error=<code>&via=oidc (or to /account?error=<code>&via=oidc if you were linking from My account), and the page shows the message from the table: via=oidc tells these codes apart from GitHub's codes of the same name. {name} is the provider's name, OIDC_DISPLAY_NAME. Each code has an anchor #oidc-<code>. An enrollment with your identity comes back instead to /admin/clusters/<id>?error=<code>&via=oidc#enrollment, with these codes and two of its own: identity_mismatch (you signed in with an identity other than the one linked to your account) and identity_expired (the enrollment expired, or another one replaced it, before you came back); the cluster's page says them (Troubleshooting).
| Code | When it happens | Message in the interface |
|---|---|---|
rate_limited |
Too many login attempts from the same IP (10 per minute, shared with password and GitHub login). | Too many attempts from this address. Wait a minute and try again. |
state |
The state cookie is missing, expired (10 min) or doesn't match, or when linking from My account the browser session isn't the one that started it. | Your {name} sign-in session has expired or is invalid. Try again. |
denied |
The person canceled at the provider (error=access_denied). |
You canceled the sign-in on {name}. |
oidc |
The provider returned another error, the callback has no code, or the token endpoint refused the exchange. The login.failure event carries the provider's error in idp_error. |
{name} didn't respond as expected. Try again in a moment. |
provider_unavailable |
kubelatch couldn't reach the provider: its discovery document, its token endpoint, its keys or userinfo (a network error or a 5xx). Also when a sign-in starts and the discovery document can't be read; that one leaves no event. |
kubelatch can't reach {name} right now. Try again in a few minutes; if it persists, tell an administrator. |
token |
The id_token failed verification (algorithm, signature, unknown key, iss, aud, azp, exp, iat, nbf, nonce, size) or lacks the claim of OIDC_SUBJECT_CLAIM. |
kubelatch couldn't verify the answer from {name}. Try again; if it persists, tell an administrator. |
no_email |
Neither the token nor a matching userinfo answer carries an e-mail address. |
{name} didn't share your e-mail address with kubelatch. Ask an administrator to check the configuration. |
email_unverified |
The claim of OIDC_EMAIL_VERIFIED_CLAIM isn't true. |
{name} doesn't vouch for your e-mail address, so kubelatch can't match it to an account. |
domain_not_allowed |
The e-mail's domain isn't in OIDC_ALLOWED_DOMAINS, or Google's hd claim is missing, not allowed, or not the e-mail's domain. |
That {name} account isn't in a domain this kubelatch accepts. Did you pick a personal account? |
unknown_account |
OIDC_AUTOLINK didn't allow linking the account that has that e-mail, or the identity was unlinked during the sign-in. When no account has the e-mail, the callback goes to /join instead (Domain access and join requests). |
No kubelatch account has the e-mail address of that {name} account. |
ambiguous_account |
Two or more enabled accounts have that e-mail; the event lists them in candidates. |
More than one kubelatch account has your e-mail address. Ask an administrator to fix it. |
disabled |
The account is disabled, or the only accounts with that e-mail are. | Your account is disabled. |
identity_mismatch |
The account found by e-mail is linked to another identity of this provider. | Your kubelatch account is linked to another {name} identity. Ask an administrator to unlink it. |
oidc_taken |
That identity is already linked to another kubelatch account. | That {name} account is already linked to another kubelatch account. |
already_linked |
From My account: your account already has another identity of this provider. | Your kubelatch account is already linked to another {name} identity. Ask an administrator to unlink it first. |
link_gone |
The invitation or reset link was already used, revoked or expired, or its account was disabled. | The link has expired or was already used; ask an administrator for a new one. |
server |
Internal kubelatch error completing the sign-in. | Internal error completing the {name} sign-in. Try again. |
An unknown code shows Could not sign in with {name}.. With unknown_account, the page adds "To get an account, ask {contact}." when KUBELATCH_ACCESS_CONTACT is set; with unknown_account and domain_not_allowed, it offers Try another account, which opens the provider's account picker. Every failure leaves a login.failure with via: oidc and the code in reason in the control plane's log, except the state ones from a missing, expired or other-session cookie, server, and a sign-in that couldn't start (provider_unavailable before reaching the provider), which leave no event. How to resolve each case is in When a sign-in fails and in Troubleshooting.
API errors¶
Under /api/ and /v1/, errors are JSON with the English text, a stable code and its parameters:
{"error": "namespace kube-system is protected: it takes no permissions of any role", "code": "grant.protected_namespace", "params": {"namespace": "kube-system"}}
error is meant for any client and is always English; the interface translates code with params and falls back to error for a code it does not know. Codes are stable: new ones may appear, existing ones do not change meaning.
| Status | Common codes (error) |
What it means |
|---|---|---|
400 |
request.bad_json (invalid JSON body); the validation that failed, for example grant.protected_namespace (namespace kube-system is protected: it takes no permissions of any role), credential.bad_ttl (invalid ttl: use for example 7d or 12h), locale.invalid (unsupported language: use en or es), enrollment.url_conflict (choose either the in-cluster API server or an API server URL, not both), enrollment.in_place_conflict (with "where it already is" kubelatch uses the stored URL and CA: send neither), enrollment.in_cluster_ca_missing (kubelatch does not have this cluster's CA: paste it) |
Malformed request or one that doesn't meet policy. |
401 |
session.missing (not signed in); session.invalid (session expired or invalid); auth.bad_credentials (incorrect username or password) |
The session is missing, expired, or was revoked; or the login failed (the message is the same for any reason). |
403 |
session.admin_only (administrators only); request.cross_origin (cross-origin request rejected); auth.password_disabled (this account signs in with an external identity provider: it does not use a password); credential.forbidden (only an administrator can act on other people's credentials); grant.own_only (you can only view your own permissions) |
The role is missing, the request comes from another origin, or the action isn't allowed for that account. |
404 |
request.not_found (not found); enrollment.invalid (this enrollment link is invalid or expired; generate a new one in Clusters); subject.not_found (user not found); cluster.not_found (cluster not found); credential.not_found (credential not found or already revoked); grant.not_found (permission not found or already revoked); link.invalid (invalid link); github.not_configured (GitHub login is not configured); oidc.disabled (OIDC login is not configured) |
The resource doesn't exist (or isn't yours). |
405 |
request.method_not_allowed (method not allowed) |
Known route, unsupported method. The Allow header says which ones are valid. |
409 |
grant.exists (that permission already exists); subject.login_taken (that login already exists (logins are never reused)); subject.last_admin (the last active administrator cannot be removed or disabled); credential.no_grants (no active permissions: there is no cluster to give access to); cluster.reconcile_busy (a reconciliation of this cluster is already running); cluster.not_ready (the cluster has no tokens yet: run its enrollment command); cluster.sealed (the cluster tokens could not be decrypted (did KUBELATCH_ENCRYPTION_KEY change?): rotate the cluster's tokens); account.disabled (the account is disabled); oidc.already_linked (this account is already linked to the identity provider; an administrator can unlink it); enrollment.in_progress (an enrollment with an administrator's identity is running on this cluster; wait for it to finish); enrollment.in_place_unavailable (this cluster has no stored address and CA yet: choose where it is); enrollment.in_place_changed (this cluster's address changed after the page showed it: check where it is and try again); enrollment.identity_unlinked (your account is not linked to the identity provider: link it in My account, or use the script) |
Conflict with the current state: the thing exists already, or the cluster or account isn't in a state that allows the action. |
410 |
link.gone (the link has expired or has already been used; ask an administrator for a new one) |
Invitation or linking link spent, expired, or revoked. |
413 |
request.body_too_large (request body too large: 64 KiB at most) |
The body is over its route's limit, which params.limit gives in KiB: 64, 512 for POST /api/grants/batch, 1024 for the GitHub webhook. |
429 |
auth.too_many_attempts (too many attempts) |
Account locked for 15 minutes after 5 failures, or a limit of 10 attempts per minute per IP. |
500 |
internal (internal error) |
Unexpected failure; the detail is in the log, on the request failed line whose request_id is the response's X-Request-Id. |
502 |
cluster.unreachable (a summary of the cluster's failure); cluster.reconcile_failed (the reconcile failed: the cluster's page lists its problems ({count})) |
Listing a cluster's namespaces, granting a permission, or reconciling needed to talk to the cluster and couldn't. After a failed reconcile, open the cluster: its page lists each problem. |
503 |
ci.not_configured (the CI exchange is not configured); reconciler.unavailable (the reconciler is not available); oidc.unavailable (the identity provider cannot be reached right now: try again in a moment) |
An internal piece isn't available. |
Roles¶
Creating or editing a custom role (POST and PUT /api/roles) answers 422 with the first rule the role breaks; the builder shows them all, next to their capability or rule. What each rule means and why it exists is in Roles.
| Status | code (error) |
What to do |
|---|---|---|
422 |
role.bad_id (the identifier takes 2 to 39 lowercase letters, digits or hyphens, starts with a letter and ends with a letter or digit) |
Change the Identifier. |
422 |
role.id_reserved (viewer is the name of a system role) |
Pick another identifier: the six system role names are reserved. |
422 |
role.bad_name (the name is required and takes at most 80 characters) |
Give the role a name of up to 80 characters. |
422 |
role.description_too_long (the description takes at most 500 characters) |
Shorten the description. |
422 |
role.control_chars (control or formatting characters are not allowed) |
Remove them from the name, the description or the object names; the description is one line. |
422 |
role.empty (the role grants nothing: switch on a capability or add a rule) |
Switch on a capability or add an advanced rule. |
422 |
role.unknown_module (unknown capability pods.shell); role.duplicate_module (capability pods.logs is listed twice) |
Use the catalogue's ids, once each (GET /api/role-modules). |
422 |
role.module_needs_cluster_scope (capability nodes.read needs a role that can be granted to a whole cluster) |
Choose Namespaces and whole cluster under Where it applies, or switch the capability off. |
422 |
role.module_not_cluster_scope (capability secrets.read is not allowed in a role that can be granted to a whole cluster) |
Switch the capability off, or choose Namespaces under Where it applies. |
422 |
role.cluster_scope_read_only (a role that can be granted to a whole cluster is read-only: the verb patch is not allowed); role.cluster_scope_resource (a role that can be granted to a whole cluster cannot name secrets) |
Keep such a role to get, list and watch, without secrets and without subresources other than status, scale and pods/log. |
422 |
role.too_many_rules (a role takes at most 50 advanced rules); role.too_many_names (a rule takes at most 20 object names) |
Up to 50 rules per role and 20 names per rule. Split the role, or use a capability. |
422 |
role.rule_incomplete (a rule needs at least one API group, one resource and one verb) |
Fill in Resources and Verbs; an empty API group is the core group, offered as Core group in the builder. |
422 |
role.wildcard (the wildcard * is not allowed) |
Name the groups, resources and verbs one by one. |
422 |
role.unknown_verb (unknown verb approve); role.forbidden_verb (the verb escalate is never allowed) |
Use only get, list, watch, create, update, patch, delete and deletecollection. |
422 |
role.forbidden_write (a role cannot write to the API group rbac.authorization.k8s.io) |
No role writes RBAC or admission configuration. |
422 |
role.forbidden_resource (the resource serviceaccounts/token is never allowed) |
serviceaccounts/token, nodes/proxy, signers, the approval and status subresources of certificatesigningrequests, and writing namespaces are refused with any verb. |
422 |
role.bad_rule_name ("Apps" is not a valid name) |
A group, a resource or an object name has a character Kubernetes doesn't accept. |
422 |
role.names_verb (object names cannot limit the verb list: only get, update, patch and delete) |
Remove the verb from the rule with names, or put it in a rule without names. |
422 |
role.no_clusters (a role limited to clusters needs at least one); role.bad_cluster_id (a cluster identifier takes 1 to 63 lowercase letters, digits or hyphens and does not start with a hyphen); role.duplicate_cluster (cluster prod is listed twice); role.too_many_clusters (a role can be limited to at most 100 clusters) |
Fix Only some…: one cluster at least, 100 at most, each once. |
422 |
role.too_many_roles (there can be at most 200 custom roles) |
The instance holds 200 custom roles. Delete one you no longer use. |
422 |
role.native_shape (a native role names between 1 and 20 cluster groups and nothing else: no capabilities, no rules, and it is granted on the whole cluster; a group name takes 1 to 253 characters, with no spaces or control characters) |
A native role with capabilities, rules or a namespace scope, with no group or a malformed one; or a custom role with groups. |
422 |
role.native_system_group (the group system:masters is one the Kubernetes API server treats as its own: a native role cannot name groups that start with system:); role.native_managed_group (the group kubelatch:cluster:viewer belongs to kubelatch: a native role cannot name groups that start with kubelatch:) |
Remove the group: those names are never a native role's. |
422 |
role.native_too_many_groups (a native role names at most 20 groups); role.native_duplicate_group (the group oidc:developers is listed twice) |
Up to 20 groups, each once. Split the role if it needs more. |
422 |
role.too_many_native_roles (there can be at most 200 native roles) |
The instance holds 200 native roles, counted apart from the custom ones. Delete one you no longer use. |
409 |
role.kind_immutable (a role cannot change from custom to native or back: create a new one) |
A PUT with another kind. Create a new role. |
409 |
role.exists (a role with that identifier already exists or existed) |
Identifiers are never reused, not even a deleted role's. Pick another. |
409 |
role.stale_revision (someone else changed this role: reload it and try again) |
Read the role again and repeat the edit on its current revision; in the builder, Load the latest version. |
409 |
role.cluster_scope_in_use (the role has 2 active permissions on whole clusters: revoke them before it stops being grantable to a whole cluster) |
Revoke the role's permissions with scope * first. |
409 |
role.cluster_in_use (the role has 2 active permissions on cluster prod: revoke them before removing the cluster) |
Revoke the role's permissions on that cluster first. |
409 |
role.psa_in_use (the role is granted on 3 namespaces: it cannot start requiring Pod Security until those permissions are revoked) |
Revoke those permissions, save, and grant them again: granting checks each namespace's Pod Security label. |
409 |
role.in_use (the role has 4 active permissions: revoke them, or delete it revoking them) |
Revoke them, or delete with ?revoke_grants=true (Revoke all and delete). |
400 |
role.system (system roles cannot be changed or deleted) |
Duplicate the system role and change the copy. |
400 |
query.bad_revoke_grants (revoke_grants must be true or false) |
Use ?revoke_grants=true or false. |
404 |
role.not_found (role not found) |
No role has that identifier, or it was deleted. |
The 409s count every active permission, those of disabled accounts included, while a role's usage counts enabled accounts only. Editing a native role that is a group permission reads that permission's cluster: adding a group the cluster gives privileged access answers 422 group.privileged (Groups), and a cluster that can't be read, 502 cluster.unreachable.
Granting a role (POST /api/grants) adds these to the existing grant.* codes:
| Status | code (error) |
What to do |
|---|---|---|
422 |
grant.unknown_role (unknown role argo-reader) |
No role has that identifier, or it was deleted. A name that can't be a role identifier keeps 400 grant.unknown_tier. |
422 |
grant.role_not_on_cluster (role argo-reader cannot be granted on cluster staging) |
The role is limited to other clusters: grant it on one of them, or edit Where it applies. |
422 |
grant.role_namespace_only (role argo-reader is only granted on a namespace: it cannot be granted to a whole cluster) |
Grant it on a namespace, or make it grantable to a whole cluster (read-only). |
409 |
grant.cluster_bootstrap_v1 (cluster prod has the previous bootstrap: apply its bootstrap manifest again before granting custom roles there) |
Update the bootstrap of that cluster. |
400 |
grant.psa_required (role deployer requires namespace apps to have the label pod-security.kubernetes.io/enforce=baseline or restricted (it has "privileged")) |
Label the namespace with pod-security.kubernetes.io/enforce=baseline or restricted. It applies to the system roles that require it and to custom roles that do (Risk and Pod Security). |
422 |
a role.* code |
The role was stored before a rule it now breaks; edit it in Roles. |
422 |
grant.role_cluster_only (the role oidc-devs is only granted on the whole cluster: its scope is decided by the cluster's own bindings) |
A native role with a namespace scope: grant it with scope *. |
422 |
grant.privileged_ttl (this cluster gives oidc:ops privileged access: workloads on the whole cluster, through ops-edit (edit); the permission needs an expiry of 8 h at most) |
A name the permission gives has privileged access there: a group of the role, the name the cluster gives the person, or kubelatch's group of the role on a privileged namespace. Give the permission an expiry of 8 hours at most. params carry name, capability, binding, role and namespace of its first reason. |
409 |
grant.identity_collision (this cluster would name this account oidc:ana@acme.com, as it already names ana: change the cluster's identity template or one of the accounts first) |
The cluster's identity template gives two accounts the same name. |
502 |
cluster.unreachable |
A native role, a role that can be privileged, or a cluster with an identity template other than the default, reads the cluster's RBAC when granting; a cluster that can't be read refuses the permission. |
Groups¶
The /api/groups routes (and POST /api/grants/batch) answer with these codes. A failing entry of a batch carries its position in params.index, whatever its code, the grant.* ones of a permission included.
Adding members (moving them in from another group too), or bringing an expired membership back, first checks every active permission of the group as a direct grant of it would be. If one no longer passes, for example because its namespace lost the PSA label, the call answers with that check's grant.* code, params.group_grant_id naming the group permission and, when adding or moving, params.index 0. Revoke that group permission or fix its namespace, and try again. When kubelatch can't ask a permission's cluster (its API server doesn't answer, or the cluster has no tokens kubelatch can use), the call answers 502 group.cluster_unreachable, with params.group (the group's name), params.group_id, params.cluster and params.group_grant_id; approving a join request into the group answers the same.
| Status | code (error) |
What to do |
|---|---|---|
400 |
group.bad_id (the id must be 1 to 63 lowercase letters, digits or hyphens, starting with a letter or digit) |
Pick another id. |
400 |
group.bad_name (the name is required and cannot be longer than 80 characters) |
Write a shorter name. |
400 |
group.description_too_long (the description cannot be longer than 200 characters) |
Shorten it. |
400 |
group.control_chars (names, descriptions and notes cannot contain control characters) |
Remove them. |
400 |
group.expiry_past (the expiry must be in the future) |
Choose a later date. |
400 |
group.note_too_long (the note cannot be longer than 500 characters) |
Shorten it. |
400 |
group.cluster_admin (cluster-admin is personal: it cannot be given to a group) |
Grant cluster-admin to a person. |
422 |
group.privileged (the cluster prod gives oidc:platform-admins privileged access: all on the whole cluster, through platform-admins (cluster-admin); a group permission cannot give it; grant it to people one by one, with an expiry of 8 h at most) |
A native role whose group the cluster gives privileged access, or a role kubelatch writes on a privileged namespace, as a group permission or added to a native role that is one. Grant it to each person with an expiry. |
400 |
group.batch_size (a batch holds between 1 and 100 entries) |
Split the request; permission batches hold up to 50. |
400 |
grant.batch_size (a batch holds between 1 and 200 permissions) |
Split the request. |
400 |
batch.size (a batch holds between 1 and 500 items) |
Send at least one id, and split a larger selection into requests of 500 at most. |
400 |
batch.duplicate (the same item is in the batch twice) |
params.index and params.id name the second occurrence: send each id once. |
400 |
batch.reason_too_long (the reason cannot be longer than 500 characters) |
Shorten the reason. |
400 |
group.move_choice (choose the new memberships' expiry, or keep each one's) |
Moving members: send either expires_at (null for none) or "keep_expiry": true, not both. |
400 |
group.move_same (the members are already in that group: choose another one) |
Moving members: to_group_id names the group they are leaving. Choose another one. |
400 |
query.bad_group (the group filter is not a valid group id) |
GET /api/grants?group= takes a group id. |
404 |
group.not_found (group not found) |
The group doesn't exist. |
404 |
group.member_not_found (member not found or already removed) |
Reload the group. |
404 |
group.grant_not_found (group permission not found or already revoked) |
Reload the group. |
409 |
group.exists (that group id already exists (ids are never reused)) |
Pick another id. |
409 |
group.member_exists (that account is already a member) |
Nothing to do. |
409 |
group.subject_disabled (a disabled account cannot join a group) |
Enable the account first. |
409 |
group.grant_exists (the group already has that permission) |
Nothing to do. |
502 |
group.cluster_unreachable (Platform team has permissions on prod and its API server can't be reached: fix the cluster or remove the group's permission there) |
Adding members, bringing a membership back or approving a join request into the group. Fix the cluster (Clusters) or revoke the group's permissions on it, and try again. |
409 |
grant.derived (this permission comes from group pegaso: remove the member or revoke the group permission there) |
DELETE /api/grants/{id} on a derived permission; params.group_id names the group. Remove the member or revoke the group permission in the group instead. |
Extending permissions¶
PATCH /api/grants/{id} and POST /api/grants/extend check the new expiry as granting does, with the grant.* codes above, and answer these too. In a batch, the failing permission carries params.index and params.id. A group's POST /api/groups/{id}/members/extend and POST /api/groups/{id}/grants/extend answer batch.extend_choice and batch.no_expiry as well, beside the codes of their single PATCH.
| Status | code (error) |
What to do |
|---|---|---|
400 |
batch.extend_choice (choose one: a length to add, a date, or never) |
Send exactly one of extend_by_seconds (more than 0), expires_at and "never_expires": true. |
409 |
batch.no_expiry (this item has no end date to extend: choose a date) |
A length can't be added to a permission without an end date: set one date for all, or leave it out. |
409 |
grant.expired (this permission has expired: grant it again) |
An expired permission doesn't come back by extending it: grant it again. |
409 |
grant.revoked (this permission was revoked) |
Reload the list. |
409 |
grant.privileged_lifetime (this permission was created on 2026-10-23T03:00:00Z; a privileged name allows 8 hours of life from then: revoke it and grant it again) |
The permission is cluster-admin, or gives a name with privileged access, and its 8 hours count from when it was granted, not from now. Revoke it and grant it again. params carry those of grant.privileged_ttl plus created_at. |
The cluster's identity template¶
Saving a cluster's identity template (PATCH /api/clusters/{id}) and previewing one (GET /api/clusters/{id}/identity-preview?template=) answer these codes:
| Status | code (error) |
What to do |
|---|---|---|
400 |
cluster.template_variables (the template needs exactly one variable: login, email, oidc_subject or github_login, between braces) |
Use one variable, such as oidc:{email}. |
400 |
cluster.template_bad_literal (the fixed text of the template cannot start with system:, nor contain spaces, control characters or stray braces) |
Change the text around the variable. |
400 |
cluster.template_reserved_prefix (the fixed text of the template cannot start with user: or bot:, the names kubelatch gives its own accounts; only the default template starts with user:) |
Choose another prefix, such as oidc:, or go back to the default template. |
400 |
cluster.template_too_long (the template renders names longer than 253 characters) |
Shorten the fixed text. |
409 |
cluster.busy (the cluster is being changed by another operation: try again in a few seconds) |
Saving waits two seconds at most for the cluster; another change to it is holding it. Try again in a few seconds. |
409 |
cluster.template_needs_oidc (the email and oidc_subject variables need OpenID Connect sign-in to be configured: without it nobody has them) |
Configure OIDC login first, or use {login} or {github_login}. |
The interface says the proxy's two identity refusals in its previews with the codes proxy.identity_missing_fact and proxy.identity_bad_username; the proxy itself answers them as in Proxy errors.
Environments and labels¶
The environments routes (/api/environments and its previews) and PUT /api/clusters/{id}/placement and its preview answer these codes. Granting or extending a permission, a group's included, answers grant.environment_max; GET /api/audit and GET /api/audit/summary answer query.bad_environment for their environment and production filters.
| Status | code (error) |
What to do |
|---|---|---|
400 |
environment.bad_id (the id must be 1 to 63 lowercase letters, digits or hyphens, starting with a letter or digit) |
Pick another id. |
409 |
environment.exists (an environment with that id already exists) |
Pick another id, or use the environment that has it. |
404 |
environment.not_found (environment not found) |
The environment of the path doesn't exist, or was deleted. PUT /api/clusters/{id}/placement answers it as a 400 for the environment its body names: read the environments again and choose one that exists. |
400 |
cluster.label_invalid (the label Team is not valid: keys and values have 1 to 63 characters, lowercase letters, digits and hyphens (values also dots and underscores), starting and ending with a letter or digit) |
Fix the label whose key params.key names: lowercase, with no prefix and no spaces. |
400 |
cluster.label_reserved (environment is reserved and cannot be a label key: the cluster's environment has its own field) |
Put the environment in environment and give the label another key. |
400 |
cluster.labels_too_many (a cluster can carry at most 20 labels) |
Keep 20 labels at most; params.max carries the limit. |
400 |
environment.ttl_invalid (a rule lasts from 1 hour to 365 days) |
Give the rule that params.field names, max_ttl_seconds or default_ttl_seconds, from 3600 to 31536000 seconds, or null for none. |
400 |
environment.default_over_max (the default expiry cannot be longer than the maximum) |
Shorten the default expiry, or lengthen the maximum lifetime. |
400 |
environment.auto_approve_role ({role} cannot be granted without approval) |
A role in auto_approve_roles that is cluster-admin, a native role, a custom role that doesn't exist, a duplicate, or one past the 50th. params.role names it. Remove it from the list. |
403 |
edition.feature (this feature needs Pro: see Edition in the menu) |
Setting or changing a rule, or granting or extending a permission on a set, outside Pro. The rules saved keep applying, and removing them works in any edition (Free and a lapsed key); the permissions on a set keep following their sets (Free and a lapsed key). |
400 |
grant.environment_max (the environment prod allows a person's permission to last at most 8 h) |
A permission of a person or a group that would end after now plus the maximum of its cluster's environment, or never: when granting, extending or approving a join request. params carry environment and max, plus index (and id when extending) in a batch. Choose an expiry within the maximum. A bot's own permission never gets it. |
400 |
grant.reason_required (prod asks for a reason on every permission of a person or a group) |
The environment of the cluster requires a reason and the permission carries none (note). params carry environment, plus index in a batch. Extending and revoking never ask for it, and neither does a bot's permission. |
400 |
grant.ticket_required (prod asks for a ticket on every permission of a person or a group: a link, or a key such as INC-123) |
The same, for the ticket: it must be an https:// URL, a key such as INC-123 or a number. params carry environment, plus index in a batch. |
400 |
grant.ticket_invalid (the ticket cannot hold control or formatting characters) |
The ticket of a permission or a group membership holds a control character or an invisible formatting one, such as U+202E, which reverses the text after it. |
400 |
grant.ticket_too_long (the ticket cannot be longer than 200 characters) |
The ticket is longer than params.max characters. |
400 |
grant.note_invalid (the note cannot hold control or formatting characters) |
The note of a permission, the reason of an access request or the note of a decision on one holds a control character, such as ESC, or an invisible formatting one, such as U+202E. Line breaks and tabs are allowed. |
409 |
cluster.busy (the cluster is being changed by another operation: try again in a few seconds) |
Saving a cluster's environment and labels, saving an environment or deleting one waits two seconds at most for what it changes, and another change is holding it. Nothing was written: try again in a few seconds. |
409 |
cluster.placement_changed (the cluster's environment or labels changed since they were read: check them and try again) |
Someone changed the cluster's environment or labels after the page, or the base of the request, read them. Nothing was written: read the cluster again and make the change from what it has now. |
400 |
query.bad_environment (environment must be the id of an existing environment, production must be true, and only one of them can be given) |
Send environment=<id> of an existing environment or production=true, not both. Without the admin role, either one answers 403 session.admin_only. |
Access requests¶
The requests a person files to be given a role for a while answer these codes, besides the common ones:
| Status | code (error) |
What to do |
|---|---|---|
404 |
access_request.not_found (the request does not exist) |
The id is unknown, or the request was deleted by retention. |
400 |
access_request.not_requestable (that role cannot be asked for there) |
The role is not a tier, a custom role the cluster takes or a native role of that cluster, the scope does not fit the role, or the namespace is protected. |
400 |
access_request.duration (a request asks for between 15 minutes and {max}) |
Ask for a duration from 15 minutes to params.max, the shortest of the environment's maximum, the role's and 30 days. |
400 |
access_request.longer (an approval can shorten a request, never lengthen it) |
The duration an administrator approves is longer than the one asked for. |
400 |
access_request.reason_required (a request needs a reason: it is what the person who decides reads) |
Write a reason of 1 to 500 characters. |
409 |
access_request.pending_limit (you already have {max} requests waiting: cancel one or wait for a decision) |
Cancel one of your pending requests, or wait for a decision. |
429 |
access_request.too_many (you have filed {max} requests in the last hour: try again later) |
A person files at most params.max requests in an hour, cancelled ones included. Wait until the oldest of them is an hour old. |
409 |
access_request.closed (this request is no longer waiting for a decision) |
It was decided, cancelled or expired. Ask again if you still need it. |
409 |
access_request.no_decider (nobody else can decide this request: grant it in Permissions) |
The only administrator is the person asking: grant the role in Permissions. |
403 |
access_request.not_decider (you cannot decide this request) |
The person is not allowed to decide it. |
403 |
access_request.self (nobody decides their own request) |
Another administrator decides it. |
403 |
access_request.admin_only (only an administrator decides this request) |
Ask an administrator. |
400 |
access_request.batch_size (choose between 1 and {max} requests) |
A decision on several requests names 1 to params.max distinct requests: 50 to approve, 100 to deny. |
400 |
access_request.batch_kind (a join request is approved one at a time) |
Approve each join request on its own; several can be denied at once. |
400 |
access_request.extension_room (an extension can now add at most {max} to this permission) |
The request names a permission you hold directly, so it extends it. Ask for params.max or less: the new end stays within the environment's maximum counted from now, and within the role's. |
409 |
access_request.extension_full (this permission takes no more time: ask for a new one once it ends) |
An extension has under 15 minutes of room. Ask again once the permission ends. |
400 |
query.bad_view (view must be mine, waiting or decided) |
GET /api/access-requests with another view, or with kind=access and no view. |
400 |
query.bad_kind (kind must be access or join) |
GET /api/access-requests with another kind. |
400 |
query.bad_namespace (invalid namespace) |
GET /api/request-options takes in namespace a namespace name, together with cluster. |
Owners¶
Naming and removing the owners of an environment, a cluster, a namespace or a group, and what an owner does without being an administrator, answer these codes, besides the common ones. 403 edition.feature is the answer to naming an owner outside Pro.
| Status | code (error) |
What to do |
|---|---|---|
409 |
owner.exists (that owner is already named there) |
That person or group already owns the scope. |
404 |
owner.not_found (the owner does not exist) |
The id is unknown, or the owner was already removed. |
400 |
owner.not_person (an owner is an enabled person or a group, never a bot) |
Name an enabled person or a group. |
400 |
owner.invalid_scope (owners are named on an environment, a cluster, a namespace of a cluster or a group, and a group cannot own itself) |
Name one scope (an environment, a cluster, a namespace that is not protected, or a group) and one owner: a person or a group other than the scope's. |
403 |
owner.self (an owner cannot give access to themselves) |
An owner grants, extends and adds for others, never for themselves or for a group they belong to: ask an administrator or another owner. |
403 |
owner.admin_only (only an administrator can grant or change this) |
cluster-admin, a native role, a permission the cluster makes privileged and access for a bot are an administrator's. |
403 |
owner.out_of_scope (this is outside what you manage) |
The cluster, namespace or group is not one you own. |
409 |
owner.group_owns (this group owns {scope}: only an administrator changes its members) |
While a group owns something, only an administrator changes its members; params.scope says what it owns. |
400 |
query.bad_managed (managed must be 1) |
GET /api/grants with a managed other than 1. |
400 |
query.bad_owner_scope (name exactly one of environment, cluster or group) |
GET /api/owners with none or more than one of environment, cluster and group. |
Permissions on a set¶
The /api/cluster-set-grants routes (HTTP API) answer these codes, besides the grant.* ones of a permission, account.disabled and role.stale_revision. A failing entry of a grant carries its position in params.index. 403 edition.feature is the answer to granting or extending outside Pro.
A cluster that a permission on a set doesn't reach isn't a failed request: the permission's clusters[].refusal carries the code and the params of why, with kind transient or rule (When kubelatch decides again). The codes there are the grant.* ones that granting by hand answers (grant.namespace_missing, grant.psa_required, grant.role_not_on_cluster, grant.cluster_bootstrap_v1, grant.environment_max), cluster.unreachable, cluster.not_ready, cluster.sealed and these two.
| Status | code (error) |
What to do |
|---|---|---|
400 |
set_grant.target (a permission on a set is for one or more accounts or for one group) |
Send subject_ids or group_id, exactly one. |
400 |
set_grant.selector_invalid (the set must be an environment or one label key: value) |
params.field names what is off, kind, key or value. A filter takes environment or label, not both. |
400 |
set_grant.cluster_admin (cluster-admin is personal and per cluster: it cannot be granted on a set) |
Grant cluster-admin to a person on one cluster. |
409 |
set_grant.exists (that permission on the set already exists) |
Nothing to do: params.index names the repeated entry. |
404 |
set_grant.not_found (permission on a set not found) |
It doesn't exist, the id is malformed or it was revoked already. |
| — | set_grant.privileged (the cluster prod gives alice privileged access: workloads on the whole cluster, through ops-edit (edit); a permission on a set can never give it) |
A refusal on one cluster, never a request failure. Privileged access is granted by hand, with an expiry of 8 h at most. params carry cluster, name, capability, namespace, binding and role. |
| — | set_grant.pending (kubelatch has not decided this cluster yet: it does in a moment) |
A refusal that waits for kubelatch to decide the cluster, which it does on its own. |
409 |
grant.set_derived (this permission follows the set team: payments: revoke the permission on the set, or take the cluster out of it) |
DELETE /api/grants/{id}, PATCH /api/grants/{id}, the batches and a group's permission routes on what a set put on a cluster. params.selector and params.set_grant_id name the set: revoke or extend the permission on the set, or change the cluster's environment or labels. |
Edition¶
A registration beyond a limit answers 422 edition.limit; installing or removing a key has its own errors (Editions and license).
| Status | code (error) |
What to do |
|---|---|---|
422 |
edition.limit (the free edition allows at most 5 people: see Edition in the menu) |
A limit of the edition is reached. params carry resource (people, clusters or custom_roles), max and edition. Install a Pro key in Edition, or free a place by disabling a person or deleting a cluster; custom roles are Pro only. |
422 |
edition.license_invalid (the license key is not valid: license: bad signature) |
The key pasted in Edition does not verify; params carry the reason. Paste the whole key from the email again. |
422 |
edition.trial_used (this instance already had its one trial, until 2026-10-22: the paid key arrives when the subscription is charged, or write to pro@kubelatch.com) |
A trial key from another subscription than the one trial this instance had; params carry until, the day that trial ends. An instance has one trial: the new subscription's paid key arrives when its 30 free days end and it is charged; to have it at once, write to pro@kubelatch.com and the trial is ended early. |
409 |
edition.license_from_env (the license key comes from KUBELATCH_LICENSE: change it there) |
While KUBELATCH_LICENSE is set, Edition can neither install nor remove a key. Change the variable and restart kubelatch. |
409 |
edition.update_check_off (the update check is off on this instance) |
Check now was asked with KUBELATCH_UPDATE_CHECK=false, or on a build without a version. Turn the check on to use it. |
502 |
edition.update_check_failed (the update check did not get an answer: try again later) |
The update site, or the mirror in KUBELATCH_UPDATE_CHECK_URL, did not answer. The server log says why. The last result is kept. |
CI exchange¶
POST /v1/ci/github-actions/token uses the same {"error", "code", "params"} format; the workflow's curl prints error:
| Status | error (code) |
What to do |
|---|---|---|
401 |
invalid token (ci.bad_token) |
Any problem with the token itself: signature, expiry, audience, issuer, reuse. The exact reason is in the log and in the ci.exchange.failure event. Check that the audience is your KUBELATCH_BASE_URL. |
403 |
no trust rule matches this workflow (repository_owner_id=… repository_id=… ref=… environment=…): an administrator must create it in kubelatch (ci.no_rule) |
An admin must create the trust rule with those ids. |
403 |
bot <bot> of trust rule "<rule>" is disabled (ci.rule_bot_disabled) |
An admin re-enables the bot. |
403 |
bot <bot> has no active permissions on any cluster: grant it some in kubelatch (ci.bot_no_grants) |
An admin grants the bot permissions. |
429 |
too many attempts (auth.too_many_attempts) |
More than 60 requests per minute from the same IP. |
A token rejected by policy (403) is not spent: the workflow can retry once the admin fixes the configuration.
Trust rules and token lookup¶
Creating a trust rule (POST /api/ci/trust-rules) and finding a credential by its token (POST /api/credentials/lookup) answer these codes, besides the common ones. Both routes are for administrators.
| Status | code (error) |
What to do |
|---|---|---|
400 |
ci.repository_needs_github (naming a repository needs the GitHub App; without it, give its numeric ids) |
Configure the GitHub App, or give repository_owner_id and repository_id instead of the name. |
404 |
ci.repository_not_found (GitHub does not know that repository, or the App cannot see it) |
Check the name; the App must have access to the repository. |
400 |
ci.repository_moved (the repository was renamed or moved: use its current owner/name) |
Write the repository's current owner/repo. |
400 |
ci.github_not_installed (the GitHub App is not installed in the organization: install it, or give the repository's numeric ids) |
Install the App in the organization that owns the repository, or give the ids. |
400 |
ci.bad_repository (the repository must be written as owner/repository) |
Write owner/repo. A label given next to the ids must name those same ids. |
400 |
credential.bad_token (that is not a kubelatch token (they start with klt_)) |
Paste the whole token, klt_ included. |
404 |
credential.unknown (no credential of this instance matches that token) |
The token comes from another instance, was copied wrong, or its credential was deleted (with its cluster, for instance). |
429 |
auth.too_many_attempts (too many attempts) |
More than 10 lookups per minute from the same IP. |
GitHub webhook¶
POST /v1/github/webhook responds 404 without GITHUB_WEBHOOK_SECRET, 401 invalid signature (webhook.bad_signature) if the secret doesn't match, 415 if the content type isn't application/json (webhook.bad_content_type), 413 with more than 1 MiB, and 400 if the body isn't JSON. A member_removed for the last administrator who can sign in responds 200 but doesn't disable anyone: check the log.
OIDC login¶
The routes of OIDC login (HTTP API) answer these codes, besides the common ones and, on POST /api/auth/oidc/link, those of the links (404 link.invalid, 410 link.gone):
| Status | code (error) |
What to do |
|---|---|---|
404 |
oidc.disabled (OIDC login is not configured) |
OIDC login is off on this instance: see OIDC login (SSO). |
409 |
oidc.already_linked (this account is already linked to the identity provider; an administrator can unlink it) |
From My account: the account already has an identity of the provider. An administrator unlinks it in Users. |
503 |
oidc.unavailable (the identity provider cannot be reached right now: try again in a moment) |
kubelatch couldn't read the provider's discovery document. Try again; if it persists, see Troubleshooting. |
Onboarding¶
The routes of Domain access and join requests (HTTP API) answer these codes, besides the common ones and, when approving, those of the group or the permission given, and 422 edition.limit:
| Status | code (error) |
What to do |
|---|---|---|
401 |
join.no_identity (sign in with your identity provider first, then ask to join) |
The person has no valid join cookie, it was already used, or an account holds the identity by now. They sign in again with the provider. |
409 |
join.pending (you already asked to join: an administrator has not decided yet) |
Their request is still pending: an administrator decides it in Users. |
429 |
join.too_many (too many people are waiting to join: try again later or contact an administrator) |
100 requests are pending: decide some in Users. |
400 |
join.bad_reason (the reason can hold up to 500 characters and no control characters) |
The reason is too long or has a control character. |
404 |
join.not_found (join request not found) |
Reload Users: the request isn't there, or isn't a join request. |
410 |
join.expired (this join request expired: the person can ask again) |
The request waited 30 days, or its provider is no longer the configured one: the person asks again. |
409 |
join.decided (this join request was already decided) |
Someone decided it first, or the person got in another way. |
409 |
join.email_taken (an account with this e-mail or identity already exists: link it from Users instead) |
An account has that e-mail or that identity now: link that account instead (Other ways to link) and deny the request. |
400 |
join.bad_access (the initial access must be a group, a grant or none, with what that kind needs) |
Send group with a group_id, grant with a grant, or none alone. |
400 |
join.bad_note (the note can hold up to 500 characters and no control characters) |
Shorten the note, or remove its control characters. |
422 |
signup_domain.not_allowed (acme.com is not in OIDC_ALLOWED_DOMAINS: only an allowed domain can sign people up) |
Add the domain to OIDC_ALLOWED_DOMAINS first; params.domain names it. |
409 |
signup_domain.exists (acme.com already has a sign-up rule) |
The domain has a rule: change its expiry, or remove it and add another. |
404 |
signup_domain.not_found (there is no sign-up rule for that domain) |
Reload Users. |
409 |
signup_domain.group_in_use (the sign-up rule for acme.com points at this group: remove the rule or point it at another group first) |
Deleting a group a rule points at: remove the rule first; params.domain names it. |
400 |
signup_domain.bad_ttl (the membership expiry must be from 1 minute to 3650 days, or none) |
Choose from 1 minute to 3650 days, or no expiry. |
400 |
signup_domain.bad_group (choose an existing group or create a new one, not both) |
Send group_id or new_group, one of them. |
400 |
query.bad_request_status (status must be pending or decided) |
GET /api/access-requests takes pending or decided. |
kubelatch CLI sign-in¶
The CLI prints error; the approval pages show the translated code.
| Status | error (code) |
What it means |
|---|---|---|
400 |
waiting for the approval in the browser (cli.pending) |
Normal while kubelatch login --device waits; the CLI keeps polling. |
429 |
polling too fast: wait longer between requests (cli.slow_down) |
The CLI waits five more seconds between polls. |
400, 404 |
invalid or unknown code (cli.invalid_code) |
A mistyped --device code, or a code, verifier or id that doesn't match. |
410 |
this sign-in request has expired or was already used: run kubelatch login again (cli.expired) |
More than 10 minutes passed, someone cancelled it, or it was already approved. |
403 |
only people with an enabled account can sign in to the CLI (cli.not_user) |
The account was disabled between the approval and the exchange. |
403 |
a CLI token only reaches your own account, access, credentials and requests: use the web interface for anything else (cli.scope) |
A CLI token was used on a route it doesn't reach. |
AI agents¶
The routes under /api/agents/ (HTTP API) answer these codes, besides the common ones:
| Status | Code (error) |
What it means |
|---|---|---|
400 |
agent.batch_size (a batch holds between 1 and 20 requests) |
POST /api/agents/requests/approve or /deny with no ids or more than 20. |
400 |
agent.cut_batch_size (you can cut between 1 and 50 sessions at once) |
POST /api/agents/sessions/cut with no ids or more than 50. |
400 |
query.state (state must be active or ended) |
GET /api/agents/sessions with a state other than active or ended. |
400 |
subject.not_bot (this only works on bots) |
PATCH /api/subjects/{id} with agent_writes_need_approval on a person: the switch is a bot's. |
403 |
agent.own_only (you can only view your own agent sessions and their approvals) |
Someone who isn't an admin filtered by another account. |
403 |
agent.not_decider (you cannot decide this approval: an administrator decides a bot's, and a delegated session's owner decides theirs) |
You can see the approval but not decide it: an admin looking at a delegated session's approval. |
404 |
agent.session_not_found (agent session not found) |
The session doesn't exist, or it isn't yours. |
404 |
agent.request_not_found (approval not found) |
The approval doesn't exist, or it isn't yours. |
409 |
agent.request_closed (this approval was already decided, or it expired) |
Someone decided it first, or its time ran out. |
409 |
agent.cut_batch (session {session} is no longer active: nothing was cut) |
A session of POST /api/agents/sessions/cut was already cut or had expired; params.session names the first one and nothing is cut. |
422 |
agent.batch_shape (a batch approves writes of one shape: the same agent, cluster, tool, resource and namespace) |
A batch approval that mixes shapes or holds an access request. |
Connecting an agent (OAuth)¶
The endpoints an MCP client signs in through (/oauth/register, /oauth/token, /oauth/revoke) answer OAuth errors (RFC 6749), {"error": "<code>", "error_description": "<English text>"}, for the client's developer. Those of /oauth/authorize show in plain text in the browser instead (below).
| Status | error |
What it means |
|---|---|---|
400 |
invalid_request |
A required parameter is missing or malformed: PKCE with S256, state or scope too long, or a form that isn't application/x-www-form-urlencoded of at most 16 KiB. |
400 |
invalid_client |
kubelatch doesn't know the client_id, or couldn't use its metadata document. |
400 |
invalid_grant |
The code or refresh token is unknown, expired, already used, issued to another client, or its agent session ended or its account is disabled: the client signs in again. A refresh token or a code presented a second time also cuts the agent session it belongs to. |
400 |
invalid_target |
The resource isn't <KUBELATCH_BASE_URL>/mcp. |
400 |
unsupported_grant_type |
grant_type is neither authorization_code nor refresh_token. |
400 |
unsupported_response_type |
response_type isn't code. |
400 |
invalid_redirect_uri |
A registration sent no redirect URI, more than allowed, or one that isn't https, http on localhost, 127.0.0.1 or [::1], or an app's own scheme; or it has a fragment; or its scheme is one kubelatch refuses: javascript, file and the like, and the browsers' (googlechrome, firefox, intent…), which open the https address they wrap. The list is in MCP server. |
400 |
invalid_client_metadata |
A registration that isn't a JSON object of at most 64 KiB, or asks for a client secret, a grant type other than authorization_code and refresh_token, or a response type other than code. |
| — | access_denied |
On the redirect address: the person denied the agent on the consent page. |
429 |
temporarily_unavailable |
One address sent too many requests (20 registrations, 30 authorizations, 120 token or revoke requests per minute); Retry-After says when to try again. |
503 |
temporarily_unavailable |
too many registered clients; try again later: kubelatch holds as many clients as it accepts (10,000, self-registered and identified by URL alike) until unused ones are cleaned. A new client identified by URL gets it from /oauth/authorize, in plain text. |
500 |
server_error |
Something failed inside kubelatch; its log has the cause. |
Paths under /oauth/ and /.well-known/ that don't exist answer 404 {"error": "not_found"}. When KUBELATCH_MCP_ENABLED is false, all of them do, /mcp answers 404, and so does the consent API below.
GET /oauth/authorize opens in your browser. It answers its errors in plain text instead of sending you anywhere, first of all these:
| Status | Message | What it means |
|---|---|---|
400 |
invalid_client: unknown client_id, or its metadata document could not be used |
kubelatch doesn't know the client, or couldn't read or accept its metadata document. |
400 |
invalid_request: redirect_uri is not one the client registered |
The address the client wants to return to isn't one it registered, or is of a kind kubelatch now refuses (a browser's scheme that the client registered earlier). |
429 |
too many requests from this address: try again in a minute |
Over 30 authorizations per minute from your address. |
Any other problem shows in plain text too, <error>: <description> with the codes of the table above, and sends you nowhere, whatever the client: anyone can register a client with any address, or publish a metadata document naming one, so kubelatch never sends an error to an address it can't vouch for. Only your answer on the consent page goes back to the client.
The consent page, where you choose what the agent may do, shows these errors:
| Status | error (code) |
What it means |
|---|---|---|
400 |
choose at least one permission for the agent (oauth.nothing_delegated) |
You left out every permission. |
400 |
the agent cannot receive <role> from your permission <grant> (oauth.bad_choice) |
The choice is above your own role there, or the permission is no longer yours or no longer active. An agent never inherits cluster-admin. |
400 |
follow must be none, viewer or same (oauth.bad_follow) |
The choice for the permissions you get later was none of those. |
400 |
the duration must be from 5 minutes to <max> (oauth.bad_duration) |
The session's duration is out of bounds; the maximum is KUBELATCH_AGENT_SESSION_MAX_TTL. |
403 |
only people with an enabled account can authorize an agent (oauth.not_user) |
Your account was disabled meanwhile. |
404 |
authorization request not found (oauth.authorization_not_found) |
The link is wrong, or another person already answered it. |
410 |
this authorization request expired or was already answered: connect the agent again (oauth.authorization_closed) |
More than 10 minutes passed, or it was already approved or denied. |
When you approve an agent's access request, 409 with the agent can ask only for a role you hold on that cluster and namespace, and never for cluster-admin (agent.access_beyond_ceiling) means your own permission it relied on is gone; the request stays pending until it expires.