Skip to content

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.

An object sits under its kind and under its source; a write is decided on 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 names a profile; the profile supplies four gates, cheapest first

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.

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.

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.

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.

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.

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.