Skip to content

Restrict what an admin caller can do

Without a policy every admin caller would carry the same authority. A token that can list budgets could also mint an unmetered key and drop a node out of the raft configuration. This guide splits that up, so a CI pipeline that only needs to mint keys cannot remove a cluster member, and a dashboard that only reads cannot write anything at all.

admin.policy_file is required as soon as you configure an issuer. There is no unpoliced mode to fall back to, and no authority that depends on whether a file happens to be present. A local admin plane with no issuer is the one case that needs no policy. That is day one, before there is anything to write a policy about.

Every caller over the network presents a JWT. There is no admin.token, no shared string to rotate, and nothing to leak into a CI variable or a terminal history.

That leaves two ways in, and they are different in kind:

  • An issuer, for everything ongoing, a person, a pipeline, a workload. It says who is calling, so the trail names them and policy can sort them by role. A pipeline and a workload already hold a token their own platform issued. A person gets one with pistra login, which is PKCE against your provider and puts no secret anywhere.
  • admin.local_socket, for day one and for the day the identity provider is the thing that broke. It says where the caller is, holding this host, and names them by the uid the kernel recorded. Reach it with pistra admin, or kubectl exec pistra-0 -- pistra admin on Kubernetes.

The second is a stronger claim than any shared secret. The secret is gone rather than replaced. A token travels, copied into a pipeline, pasted into a ticket, kept after someone leaves. Host access does not.

They are two listeners, not two modes. Configuring an issuer adds the network door rather than replacing the local one. That matters more than it sounds, because a break-glass path that only exists in deployments which never finished being set up is not a break-glass path. See Reach the admin API without your identity provider.

A loopback admin.listen with no issuer still works and is the older spelling of the same idea. Prefer the socket. Loopback TCP is reachable by every process and every user on the box, and by anything that lands a request forgery. Every caller on it is the same anonymous principal, so the trail cannot tell two of them apart.

Let an identity provider say who is calling

Section titled “Let an identity provider say who is calling”

Connect your identity provider is the task page for this, the OAuth client, both redirect URIs, and checking what the gateway resolved. This section covers the part a policy author needs: how a token becomes a principal with roles, and which claims are safe to read them from.

A person should sign in. A workload already holds a token its own platform issued and needs no secret handed to it. Both arrive as a JWT, and both become a principal with roles:

# Who signs, and how this node reaches them. Written once per issuer;
# each plane binds to it with an audience of its own.
issuers:
- name: corp
url: https://login.example.com
- name: cluster
url: https://kubernetes.default.svc.cluster.local
ca_file: /etc/pistra/kube-ca/ca.crt
token_file: /etc/pistra/kube-sa/token
admin:
issuers:
# People, from the directory. Their groups become roles.
- issuer: corp
audience: pistra-admin
roles:
claim: groups
map:
"CN=pistra-admins,OU=Groups": [keyadmin]
"CN=platform-sre,OU=Groups": [viewer, clusteradmin]
# Workloads, from the cluster. There is no groups claim in a
# service account token, so the subject is what carries identity.
- issuer: cluster
audience: pistra-admin
roles:
claim: sub
map:
"system:serviceaccount:platform:ci": [keyadmin]

On Kubernetes the chart writes the cluster issuer and its admin binding for you. Set admin.workloadIdentity.enabled and give it the map, because the two file paths are not optional and not obvious. See Reading a Kubernetes API server’s keys below.

Nothing in the policy file changes. Role::"keyadmin" is the same role whether a person or a pod is holding it.

Why a list. The two populations do not share an issuer, and each has its own keys, its own audience and its own idea of what a subject looks like. A token is routed to one issuer by its iss claim, and that issuer alone checks the signature, so a forged iss reaches keys that did not sign it.

Why map. It keeps the directory’s naming out of the policy file, so renaming a group is not a policy change. When a map is present, a value it does not mention grants nothing. A map is a statement about which groups matter, and passing the rest through would let a group named keyadmin in some unrelated part of the directory grant by accident.

claim must name something the issuer assigns. Roles come from whatever claim you point at, so a claim the caller can set is a claim the caller can use to choose their own roles. With no map it is worse. The value becomes the role name, so somebody who can set preferred_username to keyadmin is a key administrator.

The gateway refuses to start on the ones it knows are usually self-asserted: email, preferred_username, name, nickname, upn, unique_name, phone_number.

pistra: admin.issuers[0] (corp): roles.claim is "email", which is a profile
field, mutable and often self-asserted. A caller who can set it can choose
their own roles [...] or set roles.trust_claim: true if callers at this
issuer cannot set email themselves

That is a list of the known-soft claims rather than the known-safe ones, so a provider-specific claim nobody anticipated is allowed. Read roles from what the issuer assigns, groups, roles, wids, sub, oid, and never from what the caller fills in.

When the claim is safe anyway. Plenty of corporate directories own their mailboxes and forbid profile edits, and email there is as good as a group. Say so:

roles:
claim: email
trust_claim: true
map:
ada@example.com: [keyadmin]

trust_claim asserts one thing: that callers at this issuer cannot set this claim. It is true of a directory that owns the mailboxes and false of any provider federating a social login upstream. Nobody outside your directory can tell which yours is, so pistra asks rather than guesses. Setting it on a claim that needs none is also refused, because an exemption nothing needs reads as live and outlives whatever it was written for.

It does not override the issuer itself. A token carrying email_verified: false is refused whatever trust_claim says. The assertion is that callers cannot set the claim, and such a token is the issuer reporting that one did. A token with no email_verified at all is fine, Entra ID sends none, and refusing on silence would make the exemption useless on the provider that most needs it.

Granting one person. trust_claim with email is how, if you must. Prefer a group with one member in it. The policy file is read at startup, so adding somebody to it is a config change and a restart, while adding them to a group takes effect with the gateway untouched.

Workload identity is not a special case. A GitHub Actions token identifies a repository and a branch, a SPIFFE JWT-SVID identifies a service, a Kubernetes token identifies a service account:

roles:
claim: sub
map:
"repo:acme/infra:ref:refs/heads/main": [keyadmin]
"spiffe://acme.example/ns/platform/sa/ci": [keyadmin]

The branch is part of the GitHub subject, so a pull request from a fork is a different principal and gets nothing. That is the property a static token in a CI secret cannot have.

What the caller is. A policy names a principal by its issuer and its subject together, Admin::"corp/0oa1b2c3", because a subject is only unique within its issuer. The audit trail records that subject and a readable name from preferred_username or email. A display name can be reassigned to somebody else, and an opaque id is a trail nobody checks. principal.issuer is readable in policy, so a rule can require that a caller came from the cluster’s own issuer and not the directory.

Audience is required. A token minted for a different application must not work here. Accepting one is the confused deputy in its original form. For a Kubernetes caller the audience is requested when the token is projected, in the pod spec:

volumes:
- name: pistra-token
projected:
sources:
- serviceAccountToken:
path: token
audience: pistra-admin
expirationSeconds: 3600

Reading a Kubernetes API server’s keys. Most issuers publish their signing keys to anyone. An API server does not. The right is bound to system:serviceaccounts, so an anonymous fetch is refused with a 403. The token being verified cannot be used for it either, because the API server rejects a token minted for any audience but its own. So the gateway needs a service account token of its own, at the default audience, named by token_file and re-read on each fetch because a projected token rotates. That token buys one non-resource URL and nothing else: no Role, no RoleBinding, no Kubernetes client.

Discovery is deferred. jwks_url is optional. When it is absent the endpoint is discovered on the first token rather than at startup, because an identity provider that is unreachable when the config loads must not stop the gateway from serving. A failure is retried rather than cached.

What it does not do. There is no login flow here. The gateway verifies a token that names it in aud. Deprovisioning is only as fast as the token expires. Remove someone from a group and their current token keeps working until it does. Configure short access token lifetimes in the identity provider. That mitigation is theirs, not the gateway’s.

Use this policy:

// Anyone who can watch, can watch everything.
@id("viewer-reads")
permit(principal in Role::"viewer", action in Action::"read", resource);
// CI mints and revokes keys, and touches nothing else.
@id("ci-owns-keys")
permit(principal in Role::"keyadmin", action, resource in Resource::"keys");
// Changing raft membership needs its own role, whoever else you are.
@id("cluster-needs-its-own-role")
forbid(principal, action, resource in Resource::"cluster")
unless { principal in Role::"clusteradmin" };

That is the whole file. Three properties are doing the work:

  • Deny by default. A request no permit matches is refused. You never write the closing “and nothing else”.
  • forbid beats permit. The third rule holds even against a blanket grant, which is how you write an exception without editing the rule you are excepting.
  • Groups, not lists. Action::"read" covers every read the gateway has, including the ones added after you wrote the line.

Give each policy an @id. It is the name the audit trail reports when that policy decides, and it is how you find the line that refused a colleague at 3am. Without one the policy is reported by file and line number, which moves when you insert a policy above it.

Actions are the operation IDs, the same strings the OpenAPI document publishes, so a policy, a generated client and the audit trail all spell an operation the same way. There are 45, grouped into 12 resource classes, and they are listed with what each one does in the policy reference. That list is generated from the vocabulary the gateway compiles your file against. An action it does not have is one the gateway refuses at startup rather than one you find out about later.

Every action is in exactly one of the groups Action::"read" and Action::"write", and acts on exactly one resource class. That lets a policy cover an area without an endpoint list that goes stale:

@id("support-may-look-at-everything")
permit(principal in Role::"support", action in Action::"read", resource);
@id("platform-owns-keys")
permit(principal in Role::"platform", action, resource in Resource::"keys");

Resources are those 12 classes, and the instances inside them. A policy scoped to Resource::"keys" covers every action on keys. One scoped to Resource::"keys/break-glass" covers that single key:

@id("protect-the-break-glass-key")
forbid(principal, action == Action::"revokeKey",
resource == Resource::"keys/break-glass");

Instances are named by whatever the endpoint’s path parameter holds: a key name, an MCP server name, a raft node id.

Configuration objects are resources too. A provider, a profile, a rule or a block is Resource::"<kind>/<name>", under its kind and under the source that declares it, and a write to a source is decided once per object it adds, removes or changes:

@id("team-a-may-shape-its-profiles")
permit(principal in Role::"team-a", action in Action::"write",
resource in Resource::"profiles")
when { resource.id like "team-a-*" };

The policy reference lists every kind.

Principals are Admin::"<issuer>/<subject>" for a JWT, Admin::"unix:<uid>" on the local socket, and Admin::"loopback" on an issuerless loopback listener. Their roles are their Cedar parents, and a local caller holds Role::"local" and nothing else. Policies normally test the role, but you can name a caller directly when one deserves a rule of its own:

@id("the-release-pipeline-alone")
permit(principal == Admin::"cluster/system:serviceaccount:ci:release",
action, resource in Resource::"keys");

Attributes are few and fixed: principal.kind, .name, .issuer, .subject; resource.class, .id, .source. Reading anything else is refused when the file is compiled. There is no context, no entity carries tags, and an action carries no attributes: its group and class are membership, action in Action::"read". The policy reference has them in a table, and says which are safe to decide on.

@id("the-local-door-may-look-and-suspend-only")
permit(principal in Role::"local", action in Action::"read", resource);
@id("and-take-a-provider-out")
permit(principal in Role::"local", action == Action::"suspend", resource)
when { resource.id like "provider:*" };

On the local socket principal.subject is the caller’s pid, recorded for the trail. Do not decide on it. A pid is reused, and nothing stops the next process inheriting one you named.

Cedar skips a policy that fails to evaluate. A forbid that errors stops forbidding, so the request it should have refused succeeds and nothing in the log looks unusual. That is the worst failure this feature can have, and it is the silent one. So the checking is done at startup instead. The gateway builds every entity in the request itself, so it knows which reads can succeed:

  • a misspelled action or resource class, in a scope or inside a when/unless clause
  • an entity type that clause can never hold, resource in Role::"x" parses cleanly and matches nothing forever
  • an attribute that does not exist, a context read, or a tag read
  • two policies sharing an @id, which would make the trail ambiguous
  • a policy file that declares nothing, which is nearly always a path pointing at the wrong file

A type error inside an expression survives, and no check short of a full validator catches it. Those are logged at ERROR naming the policy, whether or not they changed the decision.

The caller gets a 403 in the same RFC 7807 shape as every other error, naming the action and nothing else:

{"title":"Forbidden","status":403,"detail":"not authorized to createKey"}

The trail gets the part the caller does not:

{"msg":"admin.authz","outcome":"denied","actor.kind":"oidc",
"actor.name":"dashboard@corp.example","path":"/admin/v1/keys","status":403,
"rule":"cluster-needs-its-own-role","reason":"forbidden_by_policy"}

rule is the @id of the policy that decided, and reason says which of the three refusals this was. forbidden_by_policy is a forbid that caught somebody. no_policy_permits is the deny-by-default answer, with rule absent because nothing decided, and it usually means the principal is missing a role rather than that anybody was forbidden. no_policy is a deployment that has loaded no policy, where the fix is to install one.

Authentication failures stay under admin.auth. “We do not know who you are” and “we know exactly who you are and you may not” are different events, and filing them together hides the more urgent one.

The policy is read once at startup, like the credentials and issuers it decides over, so changing any of them needs a restart. Group membership does not need one. It comes from the token on every request, so moving someone between groups in the directory takes effect without touching the gateway. The split is intended. The policy is static operator intent, and who is in which role is not. A policy that does not compile stops the gateway from starting rather than letting it serve without one. That is the same all-or-nothing rule a config reload follows.

A caller whose group you forget to map authenticates fine and is refused everything. Check the trail after a rollout. A burst of admin.authz records with no rule is a credential missing a role, not a policy catching someone.

Access rules, MCP access rules and guardrail conditions are CEL. This is not. An admin plane is a policy set to be analysed, and a data plane is a predicate over a payload to be evaluated on every request. The split is intentional. Authorization makes the argument and sets the two planes side by side. The policy reference has both vocabularies. Keep it open while writing either.