Skip to content
Scope and identity

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: remediator

The 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-secret

kslate 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 as

You 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:

HTTP 401
{ "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:

request
POST /v1/records/incidents/inc-4471/fields/customer_updates/entries
{"entry":"we are investigating","evidence":"status page","author":"sa:kagent/diagnoser"}
HTTP 403
{ "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.

PatternResult
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:

HTTP 403
{ "error": "NO_ROLE", "record": "incidents/inc-4471" }

Neither UNAUTHENTICATED nor NO_ROLE leaves a history row.

Writes outside your role

request
PUT /v1/records/incidents/inc-4471/fields/status      (as communicator)
{"value":"open","expected_version":3}
HTTP 403
{ "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:

request
GET /v1/records/scope-test/s-2      (as narrow)
HTTP 200
{
  "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:

request
GET /v1/records/scope-test/s-2?fields=secret_note      (as narrow)
HTTP 403
{ "error": "SCOPE_DENIED", "field": "secret_note", "record": "scope-test/s-2" }

Read scope applies everywhere a value could leak:

  • A STALE rejection on a field you may write but not read has its current_value, current_author, current_evidence and current_updated_at set to null and an empty diff. You still get current_version, which you need to retry.
  • A based_on entry for a field you may not read is SCOPE_DENIED.
  • History and blame show only fields you may read.
  • GET /v1/classes lists 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 get that names a field your role may not read returns 403 whether or not the record exists, so that path leaks nothing. An unfiltered get on a missing record still returns 404.

Neither reveals a value.