Scope and identity
Every request to kslate carries a credential. kslate turns it into an identity before doing anything else. The class then maps that identity to a role, and the role says which fields it may read, write and claim. The identity is also what kslate records as the author of every write. A request cannot name its own author.
Four kinds of credential
API key. You send Authorization: Bearer <key>. The gateway maps the
key to a name using a YAML file, and the identity becomes apikey:<name>.
diag-token: diagnoser
rem-token: remediatorThe gateway re-reads the file when it changes. If the new file is invalid, the old keys stay in effect.
Kubernetes ServiceAccount. The pod sends its projected ServiceAccount
token, with audience kslate. The gateway asks the Kubernetes API to
verify it and gets back sa:<namespace>/<name>. Good tokens are cached
for up to five minutes. Bad tokens are remembered for 15 seconds. If the
API server is down, cached tokens keep working and new ones are rejected.
OIDC token. You send a signed JWT from an issuer the operator has
listed in a file. The gateway checks the signature against the issuer’s
published keys, then iss, aud and the expiry. The identity becomes
oidc:<issuer name>/<subject>. A / or % in the subject is
percent-escaped, so the identity always has two segments.
issuers:
- name: ci
issuer: https://token.actions.githubusercontent.com
audience: kslate
jwksUrl: https://token.actions.githubusercontent.com/.well-known/jwks
strength: issuer-declared # optional; issuer-declared (default) or shared-secretkslate accepts asymmetric signing algorithms only. A shared HMAC secret would let anyone who can verify a token also mint one.
A JWT is a bearer credential whoever minted it, so an issuer’s strength is
at most issuer-declared. The stronger values, pod-bound and
sa-bound, name what the Kubernetes API proved about a ServiceAccount
token.
If you run a proxy that verifies tokens and talks to kslate itself, give the proxy’s identities their own role and set the strength floor on the fields that matter.
Client certificate. The gateway terminates TLS itself (--tls-cert,
--tls-key) and verifies the client certificate against a CA the
operator has listed in a file. The identity becomes
mtls:<ca name>/<subject>, where the subject is the leaf’s CN by default,
or a DNS, URI or email SAN if the CA entry says so (SPIFFE puts the
workload identity in a URI SAN). It is percent-escaped like an OIDC
subject.
This provider reads the connection, never a header. Two limits follow. It cannot work behind an ingress that terminates TLS, and it is HTTP-only. gRPC and MCP take a bearer token.
cas:
- name: corp
caFile: /etc/kslate/mtls/corp-ca.pem
subject: uri # optional; cn (default), dns, uri or email
strength: issuer-declared # optional; what this CA's certificates count asYou list the providers with --identity when you start the gateway,
comma-separated. The gateway tries them in order:
--identity k8s-sa,oidc,apikey. oidc also needs
--oidc-issuers <file>, and mtls needs --mtls-cas <file> together
with --tls-cert/--tls-key. A request that carries more than one
Authorization header carries no credential.
Each provider also reports how well the credential was bound. The gateway records that on every write as its identity strength.
The order is about cost, not correctness. The four credentials look
different on the wire, so at most one provider can recognise any given
request. Put the cheapest check first. A provider also reports only the
strength of what it verified, so putting k8s-sa first never makes an
API key count as pod-bound.
A credential kslate does not recognise gets:
{ "error": "UNAUTHENTICATED" }That is the whole body, whichever provider failed and however it failed. The gateway logs the cause for you, and keeps it out of the response: naming the provider that nearly accepted a credential would let someone probe your gateway to map which identity providers you run.
The author is always the caller
If a request body includes author, kslate rejects it, even if the value
is correct:
POST /v1/records/incidents/inc-4471/fields/customer_updates/entries
{"entry":"we are investigating","evidence":"status page","author":"sa:kagent/diagnoser"}{ "error": "IDENTITY_MISMATCH", "field": "customer_updates", "record": "incidents/inc-4471" }The attempt goes into history under the caller’s real identity.
Roles
A class maps identities to roles, and roles to fields:
roles:
detector: { identities: [sa:kagent/detector], read: ["*"], write: [status, affected_service, symptoms] }
diagnoser: { identities: [sa:kagent/diagnoser], read: ["*"], write: [hypotheses, planned_action] }
remediator: { identities: [sa:kagent/remediator], read: ["*"], write: [status, actions_taken], claim: [remediation] }
communicator: { identities: [sa:kagent/communicator], read: ["*"], write: [customer_updates] }
operator: { identities: ["sa:kagent/kslate-cli", "user:*"], read: ["*"], write: [] }read, write and claim are separate lists. ["*"] means every
field. An identity that matches more than one role gets the permissions
of all of them. They are added together rather than the first match
winning, because first-match would make the order you wrote the roles in
change who can do what.
An identity is a scheme and one or more segments, as in
sa:kagent/remediator. A pattern matches when the scheme is the same,
the number of segments is the same, and every segment is the same. The
last segment may be * instead, which matches any one segment. So
sa:kagent/* matches every ServiceAccount in the kagent namespace and
nothing else.
Anything else is an error when the class loads. kslate never falls back to treating a bad pattern as a literal name.
| Pattern | Result |
|---|---|
sa:kagent/* | Every ServiceAccount in kagent |
user:* | Any single-segment user |
apikey:remediator* | Refused. It is a prefix match, so it would also match apikey:remediator-evil |
sa:*/* | Refused. Only the last segment may be * |
An oidc: pattern always has exactly two segments, because a / in the
subject is percent-escaped. Write oidc:ci/repo:org%2Fname.
An identity kslate knows, but that has no role in this class, gets:
{ "error": "NO_ROLE", "record": "incidents/inc-4471" }Neither UNAUTHENTICATED nor NO_ROLE leaves a history row.
Writes outside your role
PUT /v1/records/incidents/inc-4471/fields/status (as communicator)
{"value":"open","expected_version":3}{ "error": "SCOPE_DENIED", "field": "status", "record": "incidents/inc-4471" }A claim by a role without claim permission gets the same error. The
attempt is recorded in history.
Reads outside your role
get and query return only the fields your role may read. Here the
role narrow may read public_note and narrow_log, so secret_note is
left out:
GET /v1/records/scope-test/s-2 (as narrow){
"record": "scope-test/s-2",
"record_version": 2,
"fields": {
"public_note": {
"value": "anyone in the class may read this",
"version": 1,
"author": "apikey:full",
"updated_at": "2026-10-01T22:00:39.287388-07:00"
}
}
}If you ask for a field by name that you may not read, that is an error rather than a silent omission:
GET /v1/records/scope-test/s-2?fields=secret_note (as narrow){ "error": "SCOPE_DENIED", "field": "secret_note", "record": "scope-test/s-2" }Read scope applies everywhere a value could leak:
- A
STALErejection on a field you may write but not read has itscurrent_value,current_author,current_evidenceandcurrent_updated_atset to null and an emptydiff. You still getcurrent_version, which you need to retry. - A
based_onentry for a field you may not read isSCOPE_DENIED. - History and
blameshow only fields you may read. GET /v1/classeslists only classes you have a role in.
Order of checks
If a request fails more than one check, kslate returns the first failure
in a fixed order. The same request always gets the same answer. The order
runs from who you are, to what you asked for, to whether the record has
moved. So UNAUTHENTICATED comes before NO_ROLE, and both come before
SCOPE_DENIED, WEAK_IDENTITY and IDENTITY_MISMATCH. Every check on
this page runs before any version, lease or premise check. The full list
is on the HTTP API page.
On Kubernetes
The class is a SharedContext resource. The controller copies each
class’s roles into a Kubernetes Role and RoleBinding, so
kubectl auth can-i gives the same answer kslate would. The gateway still
does the enforcing. The RBAC objects are there so operators can see the
policy with the tools they already use. The verbs are get, write and
claim. The last two are not API verbs, so nothing can edit the class
through the mirror.
If you delete a class, its RoleBindings go with it. The records stay
until their TTL. The roles the class named can still read them, and every
write is rejected with CLASS_GONE.
Two things kslate reveals on purpose
- An unknown class returns 404. A class that exists returns 403 to a caller with no role in it, which tells that caller the class exists.
- A
getthat names a field your role may not read returns 403 whether or not the record exists, so that path leaks nothing. An unfilteredgeton a missing record still returns 404.
Neither reveals a value.