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.
There is no admin secret
Section titled “There is no admin secret”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 withpistra admin, orkubectl exec pistra-0 -- pistra adminon 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 profilefield, mutable and often self-asserted. A caller who can set it can choosetheir own roles [...] or set roles.trust_claim: true if callers at thisissuer cannot set email themselvesThat 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: 3600Reading 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.
Write the policy
Section titled “Write the policy”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
permitmatches is refused. You never write the closing “and nothing else”. forbidbeatspermit. 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.
What you can name
Section titled “What you can name”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.
What the gateway refuses to start on
Section titled “What the gateway refuses to start on”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/unlessclause - 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
contextread, 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.
What a refusal looks like
Section titled “What a refusal looks like”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.
Changing it
Section titled “Changing it”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.
Why Cedar here and CEL everywhere else
Section titled “Why Cedar here and CEL everywhere else”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.