Authorization
There are two authorization systems here, and confusing them is the mistake worth spending a page to prevent. They protect different things, authenticate different callers, and are written in different languages, and neither can reach the other.
| admin plane | data plane | |
|---|---|---|
| protects | changing the deployment | using it |
| caller is | a person or a workload | an application holding a virtual key |
| authenticated by | a JWT from your identity provider, or peer credentials on a local socket | the key it presents |
| identity is | Admin::"<issuer>/<subject>", carrying roles |
a key name, its profile and its metadata |
| resource is | an operation’s class, one instance in it, or one configuration object | the request |
| decided by | a Cedar policy file | CEL conditions in the configuration |
| default | deny | allow, until a rule denies |
| frequency | rare, deliberate | every request |
A Cedar policy cannot read a request body. A CEL condition cannot authorize an API call. That is not an omission to be fixed later. The separation keeps each one small enough to be right.
The admin plane decides who may change things
Section titled “The admin plane decides who may change things”Every request to the admin API is one question, a principal, an action, a resource, and the answer is deny unless a policy permits it and no policy forbids it.
Identity arrives from an identity provider you already run. pistra verifies tokens and issues none. There is no user database, no password, and no session on the server. What the provider asserts about somebody’s group membership becomes a role, and policies test those roles. Connect your identity provider is how.
Two properties are worth naming, because they make this defensible rather than merely present.
The default is deny, and the vocabulary is closed. A request no
policy permits is refused. An action name no operation defines is
refused when the file is compiled, not when it is first used, because
Cedar skips a policy that errors. A typo inside a forbid would mean
that forbid silently stops forbidding, and the request it should have
caught would look ordinary in the log. That is the worst
failure this component can have and it is the quiet one, so it is moved
to startup where it is loud. The same check refuses an attribute the
gateway does not set, a resource class that does not exist, and two
policies sharing an @id.
A configuration write is decided per object. The stored configuration is a set of named source documents, and a document is a set of objects: providers, profiles, rules, the blocks with one owner. Each object is a resource under two containers, its kind and the source that declares it, so a policy can say “team-a may change profiles named team-a-*” without knowing which document holds them, and “the platform owns its source” without listing what is in it. A document write is one decision per object it adds, removes or changes, and a refusal names the object.
The source stays the unit of writing. A tool renders a whole document at the revision it read, and two tools never contend for one document. What the object view removes is the source from the policy author’s vocabulary and from the object endpoints, where it is a fact the API reports rather than an address the caller must know.
Roles come from what the issuer assigns. They do not come from a
claim the caller can edit. A gateway that read roles from
preferred_username would let anybody who can rename themselves choose
their own authority, so the
configuration refuses the claims known to work that way and says which
to use instead.
The data plane decides what a request may do
Section titled “The data plane decides what a request may do”Nothing on this side is an OIDC principal. An application presents a virtual key, and four things then decide what happens to its request.
A key is an identity and nothing else. It holds its name, the hash it is found by, whatever metadata it was minted with, an expiry and a revocation, and the name of a profile. It carries no limits of its own, and there is deliberately no per-key override. A key that needs its own limit gets its own profile, which is more configuration than an override would be. An override is a second place the answer can live, and profiles exist to remove that second place.
A profile holds everything a key may do. Everything governing a key’s traffic that is not the key’s own identity lives there, which models, which capabilities a channel must keep, which model servers, which ledger the spend lands in, this team’s own access rules, and which detectors inspect this traffic.
Two edge states follow from the split and are worth knowing. A key naming no profile is unrestricted except for the deployment-wide layer, which is the right default for a single-tenant deployment that has no teams to separate. A key naming a profile that no longer exists is refused outright rather than served unrestricted, because deleting a profile would otherwise grant everything it withheld. That is the opposite of what deleting it meant.
The layers are ordered by what they cost
Section titled “The layers are ordered by what they cost”| gate | asks | costs |
|---|---|---|
| admit | is the key real, live, unsuspended, and is its profile still there | a map lookup and some field reads |
| profile shape | allowed_models, require_caps, pool_subset |
string comparisons |
| access rules | CEL over the key, the model, the provider and the route | microseconds |
| guardrails | rules over what the detectors found in the content | milliseconds, and a model resident in memory |
Each one can refuse without paying for the next. These are four layers and not one policy language for that reason. A deployment that expressed “this key is revoked” in the same place as “this message contains a credit card” would run a transformer to find out the key was revoked. The ordering is arithmetic, not taste. The same reason puts request guardrails after access rules but before the provider is chosen, so every later stage sees the redacted text.
Access rules layer. Guardrail rules do not.
Section titled “Access rules layer. Guardrail rules do not.”Access rules exist in two lists, and they compose like this:
- The deployment-wide list runs first, then the profile’s.
- Within a list, first match wins, so an allow ends that list. That is what an exception rule relies on.
- Across lists an allow does not carry: an allow in the deployment-wide list still leaves the profile’s list to run.
- A deny in either is final.
Concatenating the two would break it. A profile could then be written to allow past a deployment-wide deny, and the mandatory layer would not be mandatory. Running them in sequence gives the asymmetry wanted. A profile can only narrow.
Guardrail rules, by contrast, exist in one place, the deployment. A
profile does not get its own list. It changes only which detectors run
on its traffic, because a guardrail rule’s condition can already read
key.profile. A per-profile rule is expressible in the one list, and a
second list would buy nothing while costing a second place to look.
The asymmetry is worth stating plainly. A team can be delegated its own access rules and cannot be delegated its own guardrail rules. Guardrails are the deployment’s floor.
Why the detector selection is gated
Section titled “Why the detector selection is gated”Letting a profile pick a cheaper set of detectors is a cost decision. The NLP tier is a model in memory and a tokenizer run per segment, and a deployment often wants it on contractor traffic and not on its staff’s. But a selection can silently mute a rule that nobody edited. Scores rise when a second detector corroborates the first, so dropping one lowers what the other scores and a threshold rule goes quiet.
So a rule declares what it reads, in requires, and a profile that
drops a detector some rule requires is refused at load with both names.
A rule that declares nothing depends on everything, which means a
deployment that has annotated no rules permits no selection at all.
That is the safe direction. The cost of the strictness is a config
error, and the cost of the laxity would be a policy that stopped
applying without anybody noticing.
Why two languages
Section titled “Why two languages”This is the part that looks like an inconsistency and is not.
The admin plane is a policy set: many independent rules, written by
different people at different times, that have to be read together and
reasoned about. “Can anyone outside this role reach the cluster
endpoints?” is a question you want answered by analysis rather than by
testing. Cedar is built for that: policies are data, forbid
beats permit whatever the order, and the whole set can be checked
against a vocabulary before anything runs.
The data plane is a predicate over a payload, evaluated on every
request at a latency that shows up in a percentile. It needs to read
into a body, do it fast, and stop when it costs too much. CEL is built
for that, and it is already in front of the same operators through
Kubernetes admission policy. Somebody who already writes
has(object.spec.foo) writes has(key.metadata.team) here in the same
language.
Using one language for both would mean either a policy set with no analysis or a per-request evaluator carrying a policy engine’s machinery. The vocabularies are closed in both cases, and that is the property that matters. Closure is enforced differently in each because the failure modes differ.
Where a decision is recorded
Section titled “Where a decision is recorded”Neither engine decides quietly.
An admin decision is an admin.authz audit record naming the
principal, the action, the outcome and the @id of the policy that
decided. When rule is absent, nothing decided. The request was
denied by default, which almost always means a principal is missing a
role rather than that a forbid caught somebody. Those are two
different problems, and the trail keeps them apart.
An authentication failure is a separate admin.auth record. “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.
A data-plane denial names the rule in the refusal the client reads and
in the metrics. A rule may also carry controls, the framework control
identifiers it enforces, onto the record of every decision it takes, so
a governance platform can read the evidence back against the name it
already uses. See the audit trail for what those
records guarantee and what they deliberately do not.
The door that does not depend on any of this
Section titled “The door that does not depend on any of this”Everything above assumes an identity provider that answers. The Unix socket is the door for when it does not. It has no issuer, no bearer token and no secret anywhere. The credential is filesystem access, which is a stronger claim than any shared secret because a secret travels and host access does not.
It is not an exemption. The caller is a named principal (unix:501) in
Role::"local", and the same policy file decides what it may do. It has
to be open beforehand to be a break-glass door at all, since turning
it on is a config change, and somebody locked out of the admin API
cannot make one. See
Reach the admin API without your identity provider.
Related
Section titled “Related”- Give a team its own policy, the same layering as a task: declaring profiles, minting keys into them, and the two rule layers in worked YAML
- Mint, rotate and revoke a virtual key, the identity half as a task, and the four ways a key stops working
- Connect your identity provider, the admin plane’s half
- Policy reference, every action and every condition variable, in both languages
- Architecture, where these gates sit among the eleven steps a request takes
What this does not cover
Section titled “What this does not cover”This page is about who decides and how it is written down. The threat model covers what an attacker can and cannot do against a deployment, which of these claims hold under which shape, and where the boundary is.