KEP-5681: Conditional Authorization

Implementation History
ALPHA Implementable
Created 2025-11-05
Latest v1.36
Milestones
Alpha v1.36
Ownership
Owning SIG
SIG Auth

KEP-5681: Conditional Authorization

  • Author: Lucas Käldström, Upbound
  • Contributor: Micah Hausler, AWS

Release Signoff Checklist

  • Enhancement issue in release milestone, which links to KEP dir in kubernetes/enhancements (not the initial KEP PR)
  • KEP approvers have approved the KEP status as implementable
  • Design details are appropriately documented
  • (R) Test plan is in place, giving consideration to SIG Architecture and SIG Testing input (including test refactors)
    • e2e Tests for all Beta API Operations (endpoints)
    • (R) Ensure GA e2e tests meet requirements for Conformance Tests
    • (R) Minimum Two Week Window for GA e2e tests to prove flake free. Not yet applicable for this KEP
  • (R) Graduation criteria is in place
  • Production readiness review completed
  • Production readiness review approved
  • “Implementation History” section is up-to-date for milestone
  • User-facing documentation has been created in kubernetes/website , for publication to kubernetes.io
  • Supporting documentation—e.g., additional design documents, links to mailing list discussions/SIG meetings, relevant PRs/issues, release notes

Abstract

This KEP proposes extending Kubernetes authorization to support conditions, where an authorization decision depends on resource data (labels and fields of object), rather than only metadata (apiGroup, resource, namespace, name). This enables more fine-grained, and most importantly, cohesive access control policies that span both authorization and admission phases, while maintaining backward compatibility with existing authorizers.

The goal of this proposal is to make authorizers able scope down their policies as if the authorizer had access to the resource data directly, through the use of two phases:

  1. compute Allow, Deny, NoOpinion or Conditional response during the authorization phase. If Conditional, return the set of conditions to Kubernetes.
  2. evaluate any conditions on the old/new object(s) during the validating admission phase, and enforce the concrete Allow, Deny or NoOpinion result.

Through this KEP, Kubernetes guarantees to the authorizer that its scoped-down authorization policy will be enforced, without the authorizer having to rely on existence of other specific admission plugins.

Concretely, all SubjectAccessReview (SAR) APIs are extended such that a client can ask for conditions and the authorizer can respond with conditions. The end user thus becomes aware of what restrictions they are subject to through kubectl auth can-i self-lookups. In addition, a new AuthorizationConditionsReview API is added to let out-of-tree authorizers evaluate conditions.

This KEP aims to provide generalized framework for multiple previous features, KEPs and issues:

  • DRA AdminAccess : “Deny creates and updates to ResourceClaims with .spec.devices[*].adminAccess=true, unless namespaceObject.metadata.labels["resource.kubernetes.io/admin-access"] == "true"
  • Fine-grained Kubelet API Authorization : “Allow a node agent to proxy requests to nodes through the API server, but only to scrape readonly information from a path starting with /pods/, not to exec into pods”
  • Constrained Impersonation : “Allow node agent csi-driver-foo to only impersonate the node it is running on to get pods
  • Requiring presence of certain labels or fields: #44703
  • Empowering authorizers to restrict the names of created objects: #54080
  • The tight coupling between the Node authorizer and NodeEnforcement admission controller; with this KEP, the same logic could be modelled through only a conditional authorizer1.
  • Provide an alternative to hard-coded compound authorization (e.g. the secondary CSR SignerName authorization check) where needed (even though the two paradigms are complimentary)
  • Hopefully, provide a feature that is helpful to unblocking the Referential Authorization KEP .
  • “RBAC++” efforts, eventually.

Example Use Cases

A non-exhaustive list, in addition to the ones mentioned above:

  • Allow user Alice to create, update, delete PersistentVolumes, but only whenever spec.storageClassName is “dev”
  • Allow a principal to update an object, but only when a sensitive field is unchanged
  • Allow a principal to create CertificateSigningRequests, but only when using a given signerName
  • Allow a principal to update a resource, but only when a sensitive field is left unchanged
  • Allow a principal to issue ServiceAccount tokens, but only with a given audience
  • Allow a controller to update a resource, but only to add/remove its own finalizer name
  • Allow a node agent to handle a resource, but only when .spec.nodeName=<node-name>
  • Allow a user to create subjectaccessreviews, but only to check permissions of certain other users

Goals

  • Provide a way for an authorizer (and by extension, policy author) to only authorize certain write2 operations, when the payload or stored object satisfies some specific conditions
  • Let users discover what conditions they are subject to through (Self)SubjectAccessReview
  • Initially support enforcement of write and connect requests in the k8s.io/apiserver WithAuthorization HTTP filter, with options to expand coverage to read2 and impersonate verbs later.
    • However, conditions can be returned for any verb in SubjectAccessReview responses, so extensions can be built arbitrarily on top. For instance, aggregated API servers can choose to enforce conditions on whatever custom verbs it wants, and authorizer can return conditions for any verb it likes.
  • Allow any authorizer’s conditions to be expressed in both transparent, analyzable forms (like Cedar or CEL), or opaque ones (like just policy16).
  • Support expressing conditions with either “Allow” and “Deny” effects.
  • Provide the foundational framework on top of which we can build other authorization features, such as Constrained Impersonation, RBAC++ and Referential Authorization.
  • Ensure that a request is evaluated against an atomic set of policies in the authorizer, even in presence of cached authorization decisions

Non-goals

  • Designing or mandating use of a specific policy authoring UX
  • Designing or mandating use of a specific “official” condition syntax
  • Expose the conditions to admission controllers

Background and Major Considered Alternatives

To make the proposal easier to read, context and rationale for commonly-asked-about alternatives is provided already at this stage. The reader deeply familiar with Kubernetes authorization may proceed to the proposal chapter .

Why not just give authorizers access to request and stored objects?

Kubernetes Authorizers today do not have access to the resource data for good reason:

  1. Not all requests have resource data attached to it
  2. The API server must be sure that the request can become authorized according to all data known at the time (even though to reach a final decision, the object must be decoded to check). It would be wasteful, and a DoS vector to use API server CPU and RAM resources to decode a request payload in a request that anyways cannot become authorized.
  3. Authorization decisions must be stateless, i.e. the same authorization query must yield the exact same decision whenever the underlying policy store is the same. The authorizer should in other words be a deterministic function a: Metadata x PolicyStore → Decision. In other words, the initial authorization decision must not depend on the state of objects in etcd .
  4. The request payload might be mutated many times by admission controllers before it reaches the state that can be checked into etcd. In addition, the old object is only available very late in the request handling process, right before an update is actually about to go through in the storage layer (and thus admission is invoked).
  5. Even if it was technically possible, providing the authorizer up-front with the objects could yield a significant hit, as some time would be spent on just propagating the objects to each authorizer, and the authorizer might spend more time examining the object earlier.

Why not just use ValidatingAdmissionPolicies?

The observant reader might notice that some of the use cases can already be achieved today with the ValidatingAdmissionPolicy (VAP) API.

However, the solution is still in some regards sub-optimal:

  1. In the authorization phase, the policy author must “over-grant”, and then remember to (!) “remove” the permissions in the admission phase.
  2. The user needs to understand two different paradigms at once, and coordinate the policies between them.
  3. The principal-matching predicate needs to be duplicated between RBAC and VAP.
  4. The policy author needs permission to both author RBAC rules and VAP objects. VAP objects do not have privilege escalation prevention, which means that anyone that can create a VAP can author a static false condition for operation=* and resources=*, which blocks all writes to the cluster, until an admin removes the offending VAP. Thus should not “create VAP” permissions be handed out to lower-privileged principals, e.g. namespaced administrators, who otherwise legitimately would need to write conditionally authorized policies.
  5. Strict ordering of creates and deletes: In order to not at any point leak data, must the VAP deny rule be authored and successfully activated before the RBAC allow rules are, and vice versa for deletes.
  6. The conditions do not show up in a (Self)SubjectAccessReview, but are only noticed by a user subject to the policy upon performing a request.
  7. Status quo with ValidatingAdmissionPolicy does not offer a tangible path forward for providing a unified experience for writing fine-grained authorization policies for reads.
  8. The authorizer cannot let the policy author express conditional policies of the form “allow create persistentvolumes, only when storageClassName==‘foo’”, as the authorizer cannot mandate or control the admission plugins or cluster setup process of the cluster it serves authorization decisions to.
  9. If the policy could not be modelled as a ValidatingAdmissionPolicy, but an admission webhook would be needed, that webhook might need to be sent for every request in the worst case, as opposed to only those requests that are conditionally authorized (as in this proposal).

Over-grant in RBAC, deny in VAP

This proposal solves all of these mentioned issues through a two-phase model:

  1. the authorizer partially evaluates its authorization policies into Allow, Deny, NoOpinion, or a set of conditions on the at the authorization-stage unknown data: the request and stored object.
  2. Kubernetes or the authorizer evaluates the conditions into a concrete response, during the admission phase.

What is partial evaluation?

Partial evaluation is the process of evaluating expressions as far as possible with incomplete data. A crucial consequence of this is that some unknown data might turn out to not be needed at all to assign a fixed value to the expression! This effective form of pruning can take place if some sub-expression is independent of the unknown data.

In the KEP context, the unknown data at authorization time is the request and stored objects, and the request options.

Consider a ValidatingAdmissionPolicy/CEL-like expression syntax, and how the following two policies would be partially evaluated for the two example users Alice and Bob:

  • Allow Policy 1: request.apiGroup == "" && request.userInfo.username == "bob"

  • Allow Policy 2:

    request.apiGroup == ""
    && request.resource == "persistentvolumeclaims"
    && request.verb == "create"
    && request.userInfo.username == "alice"
    && object.spec.storageClassName == "dev"
    

Now, if Alice performs a create persistentvolumeclaims, what will the policies partially evaluate to?

  • Allow Policy 1: true && false => false
  • Allow Policy 2: true && true && true && true && object.spec.storageClassName == "dev" => object.spec.storageClassName == "dev"

In these examples, the result of each sub-expression is shown for clarity. Policy 1 clearly evaluates to false, without knowing the value of object. Policy 2 produces a residual expression. Without knowing the object, it is impossible to assign a truth value to the residual, and thus is this our condition in the authorizer response.

Next, let’s consider what happens if Bob performs create persistentvolumeclaims in the same setting:

  • Allow Policy 1: true && true => true
  • Allow Policy 2: true && true && true && false && object.spec.storageClassName == "dev" => false

Now, Policy 1 returned an unconditional (concrete) allow, and Policy 2 can never be true, no matter what the value of the object is. If the authorizer follows “at least one matching Allow policy yields Allow”, a concrete Allow can be returned instantly.

The authorizer might also do positive pruning, that is, if one Allow policy evaluates to true, and another Allow policy to a residual, the authorizer concludes that no matter what the object is, their union will yield Allow.

Finally, what would happen if user Eve (who is assigned no permissions) tries to create persistentvolumeclaims:

  • Allow Policy 1: true && false => false
  • Allow Policy 2: true && true && true && false && object.spec.storageClassName == "dev" => false

This gives us both properties we want: Eve is denied access immediately in the authorization stage (without ever decoding the body), while it being possible to express a policy that spans both authorization and admission (policy 2).

Three adjacent systems support partial evaluation: Cedar , CEL and OPA . In particular the Cedar RFC has good argumentation on why partial evaluation only works well in cases where all expressions have a concrete type.

Note that partial evaluation should substitute all known variables with constants, even if the residual cannot be fully evaluated. For example, when a sub-expression is initially object.name == request.userInfo.username and request.userInfo.username is known to be "lucas", but object is unknown, the resulting residual is object.name == "lucas". In other words, the residual does not depend on any variables already known.

Why propagate the conditions with the request?

It was already concluded that the authorizer needs to be able to depend on the API server to “call the authorizer back” with the resource data, whenever a conditional decision is returned. However, instead of the authorizer returning the set of conditions to Kubernetes, one could imagine two other methods, as follows:

  1. The authorizer does not return a ConditionSet, but relies on Kubernetes to send an AdmissionReview to the authorizer whenever a conditional decision was made. The authorizer then re-evaluates all policies against the AdmissionReview with complete data. This approach has many drawbacks:
    1. Two full evaluations needed: During the authorization phase, the worst-case runtime is O(nk), where n is the number of policies, and k is the maximum policy evaluation time. The admission-time evaluation would also be O(nk) in this case.
      1. With this proposal, only O(k) time would be required in admission, given that the amount of conditions is O(1) for a typical request.
    2. Non-atomicity: For a given authorizer, a request should be authorized from exactly one policy store snapshot. If two full re-evaluations were done, the latter (admission-time) policy store semantics would apply, if the policy store changed between the request performed authorization and admission.
      1. With this proposal, the conditions are computed at authorization time by partial evaluation and unmodified enforced at admission, exactly and only the authorization-time policy store semantics apply.
    3. Tight coupling between conditions authoring and evaluation: The authorizer would be the only entity which would be able to evaluate the conditional response in the admission stage, which forms a forced tight coupling. Two webhooks per authorizer per request is necessary.
      1. With this proposal, builtin conditions enforcers might evaluate and enforce the conditions in-process, without a need for another webhook in admission. One such builtin enforcer is proposed to be CEL-based. This is faster and more reliable.
    4. Not observable through (Self)SubjectAccessReview: As for admission today, a user subject to a policy would not know what policy they are subject to before they execute a request that violates it (hopefully with a nice error message).
      1. With this proposal, a user can see the conditions serialized in the (Self)SubjectAccessReview. Some of the conditions might be opaque (like policy16), yes, but at least the user might know where to look next.
  2. The authorizer does not return a ConditionSet, but instead caches the conditions in a memory. The authorizer relies on Kubernetes to generate a random “request ID”, which is passed to both SubjectAccessReview and AdmissionReview webhooks, so the authorizer can know which conditions to apply to which request.
    1. This approach does not have the “Two full evaluations needed” and “Non-atomicity” problems of the first alternative approach, as only the conditions need to be atomically evaluated against the resource data. However, this approach is subject to the “Tight coupling” and “Not observable through (Self)SubjectAccessReview” problems. In addition, the following problems arise:
    2. A stateful authorizer is complex and hard to scale: The authorizer must be way more complex, as it needs to keep a lookup table of request ID to condition set internally. If the authorizer needs to be horizontally scaled, the load balancer in front of the horizontally scaled authorizers would somehow need to know which authorizer replica has what requests’ conditions stored in memory.
      1. With this proposal, the authorizer is allowed to be stateless and thus simpler. Therefore, also the horizontal scaling can be done in a straightforward manner, from this perspective.
    3. Unclear caching semantics: The authorizer would need to cache the conditions in memory for at least as long as SubjectAccessReview requests can be cached, for the above atomicity invariant to hold. However, the authorizer does not (generally) know the API server configuration, and thus does not know how long to cache the conditions, or if at all.

Glossary

  • Concrete/Unconditional (authorization) decision: one of Allow, Deny, NoOpinion.
  • Residual: Expression which is a deterministic function of data that was unknown during partial evaluation.
  • Conditional Allow: A Conditional decision with a ConditionSet that has at least one effect=Allow condition. In other words, the conditional decision can turn into a concrete Allow, Deny or NoOpinion when evaluated.
  • Conditional Deny: A Conditional decision with a ConditionSet that has no effect=Allow condition, in other words, just effect=NoOpinion or effect=Deny conditions. When evaluated, this ConditionSet can thus only turn into concrete Deny or NoOpinion decisions.

Proposal

To achieve the above mentioned goals, at a high level, the following changes are proposed:

  • The authorizer.Authorizer interface and SubjectAccessReview API are extended to support:
    • The client indicating it supports conditional authorization
    • The authorizer returning, in addition to existing unconditional Allow, Deny or NoOpinion decisions, a set of conditions
  • The WithAuthorization HTTP filter lets a supported request proceed in the request chain if the decision is:
    • A unconditional Allow, just like before, or
    • A ConditionSet which can evaluate to Allow
  • A new, always-on k8s.io/apiserver-built-in AuthorizationConditionsEnforcer validating admission plugin (ordered before other validating webhooks) which enforces that the set of conditions (if any) evaluate into Allow, or denies the request.
    • To empower out-of-tree/webhook authorizers to evaluate their (opaque) conditions, a new AuthorizationConditionsReview API is added.
    • Any conditional authorizer must serve this API, which means that also kube-apiserver must serve it.3
  • A k8s.io/apiserver-built-in CEL condition evaluator, which allows evaluating conditions expressed in CEL format to be evaluated in-process without another webhook.

Notably, this design achieves its goal of unified authorization expressions across authorization and admission, without the breaking the reasons why authorizers do not have direct access to the request body in the authorization stage:

  1. Conditional Authorization is only supported for certain requests, namely whenever admission is invoked (verbs create, update, patch, delete, deletecollection and connect requests).
  2. Any request that cannot become authorized, regardless of the value of the resource data, is rejected already at the authorization stage, thanks to partial evaluation.
  3. The conditions are part of the returned authorization decision, and partial evaluation is a deterministic function, i.e. the same output (which includes the conditions) is guaranteed for the same inputs (metadata and policy store content).
  4. The API server enforces the conditions in the validating admission stage, where access to the objects is available with the correct consistency guarantees.
  5. Authorizers process the object data only when really needed, which minimizes the performance hit.

The following picture summarizes how, with this feature, a webhook authorizer can expose a unified policy authoring experience (e.g. through Cedar or CEL) by returning conditions that are propagated with the request chain until validating admission, where the AuthorizationConditionsEnforcer plugin “calls the authorizer back” with the conditions it gave, and the rest of the data.

Conditional Authorization Overview

In function syntax, an authorizer is a deterministic function authorize: Metadata x PolicyStore → Decision. A ConditionSet, returned by some authorizer, is a map from an authorizer-scoped identifier to a condition. With this proposal, the Decision logical enum gets a new Conditional variant, that has a ConditionSet associated with it.

Let ConditionData be the term for the data unknown at authorization time (request, stored object and request options). A condition is a deterministic function condition: ConditionData → Boolean. Note that the condition is only a function of the unknown data; already-known data should be constants of the condition (see the partial evaluation section for an example). A condition also has an effect, which controls if evaluation to true should be treated as producing a Allow, Deny, or NoOpinion decision.

Note that even though the “full” new and old objects are given as inputs to the condition in this model, the authorizer is free to choose how much of that API surface is exposed to policy authors. Some authorizer might decide to e.g. only expose field-selectable fields in the expression model given to the policy author.

Evaluating a ConditionSet is a deterministic function evaluate: ConditionSet x ConditionData → (Decision - {Conditional}). Note that conditions evaluation should not have access to the policy store; this is by design, as it makes this two-stage mechanism atomic, just like it would have been if it could have been evaluated directly.

Technical Requirements

  • The final decision must always be the same in this two-phase model as in a one-phase model (that is, if the request / stored object were given directly to the authorizer). This for example implies that the order of the authorizers must be preserved.
  • Capabilities and decision logic must be exactly the same for both in-tree and out-of-tree authorizers.
  • Only proceed to decode the object if the request can become authorized, to avoid Denial-of-Service attack vectors.
  • Must work for connectible resources (see this section for more details)
  • Keep backwards compatibility within supported version skew, as always.
  • Consider that a patch or update in authorization can turn into a create in admission, patch in authorization can turn into an update in admission, and deletecollection in authorization turns into a delete in admission.
  • Must work with aggregated API servers.
  • Must work with any authorizer chain that is formed as a DAG.

Core interface changes

This KEP proposes to change authorizer.Decision to be a struct (as Go does not support enums with attached data :/), change the authorizer.Authorizer interface accordingly, and let the caller signal its conditions-readiness:

package authorizer // k8s.io/apiserver/pkg/authorization/authorizer

// Authorizer makes an authorization decision based on information gained by making
// zero or more calls to methods of the Attributes interface. It might return
// an error together with any decision. It is then up to the caller to decide
// whether that error is critical or not.
type Authorizer interface {
    Authorize(ctx context.Context, a Attributes) (Decision, error)

    // All authorizers are required to implement the conditions evaluation method,
    // even though they just return an error saying "unsupported".
    // This ensures composite authorizers do not "forget" to implement the
    // EvaluateConditions method, even if it'd wrap a conditions-aware authorizer
    ConditionSetEvaluator
}

type ConditionSetEvaluator interface {
    // EvaluateConditions evaluates a condition set into
    // a concrete decision (Allow, Deny, NoOpinion), given full information
    // about the request (ConditionData, which includes e.g. the old and new objects).
    // The returned Decision must be concrete.
    EvaluateConditions(ctx context.Context, conditionSet *ConditionSet, data ConditionData) (Decision, error)
}

type Attributes interface {
    // ... everything else as before, plus:

    // ConditionsMode indicates how, if any, the client wants conditions to be returned.
    ConditionsMode() ConditionsMode
}

// ConditionsMode specifies the client's request of how conditions should be returned
// by the authorizer (or not at all). ConditionsMode name TBD.
type ConditionsMode string

const (
    // ConditionsModeNone indicates that the client does not support conditions.
    ConditionsModeNone ConditionsMode = ""

    // ConditionsModeHumanReadable indicates that the client wants a
    // human-readable condition and description, if possible.
    ConditionsModeHumanReadable ConditionsMode = "HumanReadable"

    // ConditionsModeOptimized indicates that the client wants an
    // optimized conditions encoding without description, if possible.
    ConditionsModeOptimized ConditionsMode = "Optimized"
)

// Decision constructors

func DecisionAllow(reason string) Decision {}
func DecisionDeny(reason string) Decision {}
func DecisionNoOpinion(reason string) Decision {}
func DecisionConditional(conditionSets *ConditionSet) Decision {}

// ChainedDecisions combines an ordered list of computed decisions of an authorizer
// chain, and registers a set of lazily-evaluated authorizers in the chain to call
// in AllConditionSets() or if needed in Evaluate()
// In case of nested unioned Decisions, the hierarchy is flattened.
func ChainedDecisions(decision []Decision, authorizerChain ...Authorizer) Decision

// Decision models an enum that can be Allow, Deny, NoOpinion or
// Conditional([]*ConditionSet, []Authorizer)
// The Decision struct is passed by value, and the empty struct == Deny.
type Decision struct {
    // only private fields
}

// Replaces the old enum d == authorizer.DecisionAllowed, etc.
func (d Decision) IsAllowed() bool {}
func (d Decision) IsDenied() bool {}
func (d Decision) IsNoOpinion() bool {}
func (d Decision) IsConditional() bool {}

func (d Decision) IsConcrete() bool {return d.IsAllowed() || d.IsDenied() || d.IsNoOpinion()}

func (d Decision) Reason() string {}

// CanBecomeAllowed returns true if d.IsAllowed() or any ConditionSet can become
// allowed.
func (d Decision) CanBecomeAllowed() bool {}

// Evaluate evaluates all ordered ConditionSets of d into a concrete decision.
//
// Might be an expensive operation, as authorizers might webhook to evaluate
// its conditions.
func (d Decision) Evaluate(ctx context.Context, data ConditionData, builtinConditionSetEvaluators ...BuiltinConditionSetEvaluator) (Decision, error)

// AllConditionSets returns an ordered list of all (including lazily-evaluated)
// authorizers' decisions, until a concrete decision is reached.
//
// Might be an expensive operation, as webhook authorizers might be called.
func (d Decision) AllConditionSets(ctx context.Context) ([]*ConditionSet, error)

Instead of return authorizer.DecisionAllow, "some reason", nil, all in-tree authorizers’ return statements need to be rewritten to return authorizer.DecisionAllow("some reason"), nil. The change is not huge, but could be tedious. One good side-effect is that before it was possible to use values beyond the enum, e.g. like return -100, "", nil, but not anymore.

The behavior of the zero value was previously 0 == DecisionDeny, and is preserved as Decision{} == DecisionDeny("").

The authorizer.Attributes package is augmented with ConditionsMode, to let the client indicate whether slower-to-execute, human-readable conditions with description or optimized (e.g. binary AST) conditions are more important. The default is an empty string, which is treated as “the client does not support conditions”. In this case, an authorizer is advised to return Deny if it would have returned one or more effect=Deny conditions, otherwise the authorizer should NoOpinion.

Evaluating a conditional decision into a concrete one is done using the Evaluate() method, and this function requires supplying at least all the data that was not present earlier, but which authorization policies are allowed to use. If the partial evaluation process was done correctly, the condition should be a pure function of this data:

package authorizer // k8s.io/apiserver/pkg/authorization/authorizer

// TODO: This interface might need to change to something more generic,
// as e.g. constrained impersonation might use other contextual data 
// (or we bake that data into GetObject(), or add another field)
type ConditionData interface {
    // GetOperation is the operation being performed
    // TODO: For impersonation requests, we might make this IMPERSONATE, if we want/need.
    GetOperation() string
    // GetOperationOptions is the options for the operation being performed
    GetOperationOptions() runtime.Object
    // GetObject is the object from the incoming request prior to default values
    // being applied.
    // Only populated for CREATE and UPDATE requests.
    GetObject() runtime.Object
    // GetOldObject is the existing object.
    // Only populated for UPDATE and DELETE requests.
    GetOldObject() runtime.Object
}

Condition and ConditionSet data model

Next, the observant reader noticed that the ConditionSet struct was referred to above. Next, let’s walk through the detailed model of a condition and its set:

package authorizer

// ConditionEffect specifies how a condition evaluating to true should be handled.
type ConditionEffect string

const (
    // If any Deny condition evaluates to true, the ConditionSet 
    // necessarily evaluates to Deny. No further authorizers 
    // are consulted.
    ConditionEffectDeny ConditionEffect = "Deny"
    // If a NoOpinion condition evaluates to true, the given 
    // authorizer's ConditionSet cannot evaluate to Allow anymore, but 
    // necessarily Deny or NoOpinion, depending on whether there are any
    // true EffectDeny conditions. 
    // However, later authorizers in the chain can still Allow or Deny.
    // It is effectively a softer deny that just overrides the 
    // authorizer's own allow policies. It can be used if an authorizer  
    // does not consider itself or the principal authoritative for a given request.
    // TODO: Talk about error handling; what happens if any of these 
    // conditions fail to evaluate.
    ConditionEffectNoOpinion ConditionEffect = "NoOpinion"
    // If an Allow condition evaluates to true, the ConditionSet evaluates
    // to Allow, unless any Deny/NoOpinion condition also evaluates to 
    // true (in which case the Deny/NoOpinion conditions have precedence).
    ConditionEffectAllow ConditionEffect = "Allow"
)

// A condition to be evaluated
type Condition struct {
    // An alphanumeric string, validated as a Kubernetes label, that is
    // (<DNS1123 subdomain>/)[-A-Za-z0-9_.]{1,63}.
    // Users must not use IDs with the 'k8s.io/' prefix.
    // Uniquely identifies the condition within the scope of the
    // authorizer that authored the condition. Acts as a key for a 
    // slice of conditions, such that it can be used as a map. 
    // The FailureMode of the ConditionalAuthorizer determines how to
    // handle invalid ID values.
    // Used for error messages, e.g.
    // "condition 'company.com/no-pod-exec' denied the request"
    ID string
    // An opaque string that represents the condition that should be
    // evaluated. A condition is evaluated after mutation.
    // A pure, deterministic function from ConditionData to a Boolean.
    // Might or might not be human-readable (could e.g. be 
    // base64-encoded), but max 1024 bytes.
    // The FailureMode of the ConditionalAuthorizer determines how to
    // handle too long Condition values.
    // TODO: we could consider supporting also byte-encoded data using a
    // mutually-exclusive ConditionBytes field, for more efficient
    // AST encoding and execution of e.g. CEL conditions.
    Condition string
    // How should the condition evaluating to "true" be treated.
    // The FailureMode of the ConditionalAuthorizer determines how to
    // handle unknown Effect values.
    Effect ConditionEffect

    // Optional human-friendly description that can be shown as an error 
    // message or for debugging.
    Description string

    // TODO: Do we need per-condition failure modes? Most likely not initially.
}

// ConditionSet represents a conditional response from an authorizer.
// TODO: Decide on a maximum amount of conditions?
type ConditionSet struct {
    // private fields only, must be constructed through a constructor

    // Some authorizers that are later in the chain than an authorizer that
    // returned a conditional response, might return unconditional responses.
    // Capture this in the ConditionSet.
    // Mutually exclusive with set
    unconditionalDecision *Decision
    // Private field so constructor function can validate the conditions before
    // adding them to the set.
    set []Condition
}

// The format/encoding/language the conditions in this set.
// Any type starting with `k8s.io/` is reserved for Kubernetes
// condition types to be added in the future.
// An authorizer must be able to evaluate any conditions it authors.
// Validated as a label key, i.e. an alphanumeric string with an
// optional DNS1123 subdomain prefix, and a key name of max 63 chars.
// The FailureMode of the ConditionalAuthorizer determines how to
// handle invalid Type values.
func (c *ConditionSet) Type() string {}

Computing a concrete decision from a ConditionSet

How should a ConditionSetEvaluator evaluate the conditions in the given set? The process is two-fold:

  1. Evaluate each condition function to a boolean value, or error
  2. Compute the individual truth values of the conditions, along with their desired effect into an aggregate, concrete decision (Allow/Deny/NoOpinion) at the authorizer level, according to the following logic:

If there is at least one condition with effect=Deny that evaluates to true, return Deny.

If there is at least one condition with effect=Deny that evaluates to an error, return an error. The FailureMode of the ConditionalAuthorizer controls whether to treat the error as decision Deny or NoOpinion.

Otherwise, it is known that all effect=Deny conditions evaluate to false. Then, if there is at least one condition with effect=NoOpinion that evaluates to true, return NoOpinion.

If there is at least one condition with effect=NoOpinion that evaluates to an error, return NoOpinion to fail closed (as if the condition evaluated to true) along with the error for logging/diagnostics.

Otherwise, it is known that all effect=NoOpinion conditions evaluate to false. Then, if there is at least one condition with effect=Allow that evaluates to true, return Allow.

Any effect=Allow condition that evaluates to an error is ignored. If no effect=Allow condition evaluates to true, return NoOpinion.

How a decision is computed from an evaluated ConditionSet

One quite tricky technical detail about partial evaluation is the short-circuiting of e.g. the common && and || operators, especially with regards to errors. Clearly, false && <residual> can be simplified to false. However, <residual> && false can either be false or <error>, if evaluating <residual> can produce an error. Thus are the && and || operators not commutative.

The authorizer contract is such that the authorizer should only return a set of conditions that could evaluate to Allow. Returning a set of conditions that always evaluate to NoOpinion or Deny is a waste of resources. Concretely, the authorizer should not return conditions of form <residual> && false with Allow effect, as such conditions are either false or <error> and thus never contribute to an Allow decision. However, the same pruning cannot be done for effect=Deny or effect=NoOpinion conditions, as an evaluation error would trigger fail-closed short circuiting to Deny or NoOpinion.

Computing a concrete decision from a conditional authorization chain

It is now known how to evaluate a single ConditionSet together with the ConditionData into a single, aggregate concrete decision, the same decision that the authorizer would have immediately returned, if it had direct access to the ConditionData. Next, we discuss the semantics of multiple authorizers chained after each other (i.e. the union authorizer), in the light of conditional authorization.

To begin with, it is good to state that the semantics of the existing modes Allow, Deny and NoOpinion do not change. Whenever a NoOpinion is returned by an authorizer, that decision is ignored (even if an error is returned), and the next authorizer in the chain is consulted. Thus must any safety-critical errors be turned into Deny decisions if failing closed is needed. A chain with the decision prefix NoOpinion, …, NoOpinion, Allow still short-circuits and returns a concrete Allow. Vice versa for a chain with the prefix NoOpinion, …, NoOpinion, Deny => Deny.

A ConditionSet with at least one effect=Allow condition is considered a “conditional allow”. The union authorizer short-circuits when seeing such a decision in a “lazy” way, as now the request can become allowed. Crucially, however, the rest of the authorizer chain (that was not yet considered) must be saved in the Decision for later, lazy evaluation, in case the conditional allow would evaluate into a NoOpinion.

The WithAuthorization HTTP filter makes sure that the current request supports conditional authorization, and that the decision can become allowed (that is, the decision is a concrete or conditional allow) before proceeding. The returned Decision is propagated using the context to the validating admission phase, just like UserInfo and RequestInfo are today. The HTTP filter signature is augmented with a function that determines whether the request supports conditions or not:

func WithAuthorization(hhandler http.Handler, auth authorizer.Authorizer, s runtime.NegotiatedSerializer, supportsAuthorizationsConditions func(ctx context.Context))

If an authorizer returns a conditional response for a request that does not support conditions (such as list requests, for now), WithAuthorization fails closed. The function ensures that there is always a “safety net” behind the authorization filter, if the request is let to proceed. Aggregated API servers that use the WithAuthorization function can themselves choose when conditions are applicable. Initially, the kube-apiserver supports conditional responses for the following classes of requests:

  • When verb is create, update, delete or deletecollection, the API object is served by the same API server, and the GVR doesn’t contain wildcards.
  • When the request maps to a Connect handler instead of normal CRUD.
    • Without the supportsAuthorizationsConditions function, WithAuthorization has no way to know that get pods/exec is actually covered by admission, and thus safe to authorize conditionally.
    • Note that other get requests are not necessarily covered by admission (get pods/log is a counterexample)
  • When the request belongs to an API group that is served by an aggregated API server.
    • Warning: Any aggregated API server MUST use kube-apiserver as its first authorizer; any other behavior is unsafe and with undefined behavior.
    • When the aggregated API server uses the kube-apiserver (acting as an authenticating front proxy) as its first webhook authorizer, the kube-apiserver will return the applicable conditions (if any) to the aggregated API server.

However, if no effect=Allow condition is present in a returned ConditionSet, the decision is considered like a “conditional deny”. In this case, later authorizers need to be consulted to find out if this request can become authorized. If a later authorizer returns a concrete Deny, clearly the request cannot become allowed; it is either conditionally or concretely denied.4 However, if a later authorizer returns a concrete Allow, the request is conditionally allowed; if the deny conditions in the beginning all evaluate to false, that first authorizer would have returned NoOpinion, and the next authorizer then returns a concrete Allow.

The DRA AdminAccess feature is a good example of a feature that could be modelled as an authorizer in the beginning of the chain that returns NoOpinion for most requests, but conditional denies for some requests (namely, creates and updates of ResourceClaim(Template)s). In contrast to using ValidatingAdmissionPolicy for that purpose, an authorizer does not need to allow for its policies to be deleted. In contrast to the existing DRA AdminAccess implementation at the storage layer, the condition shows up in SubjectAccessReviews.

What is proposed in this KEP is thus lazy evaluation, that allows a request to proceed to admission whenever a conditional allow is seen at authorization time, and the rest of the chain is lazily evaluated only if needed (if the previous authorizer evaluated to a concrete NoOpinion).

Another considered alternative is the eager variant, that would call each authorizer in the chain already in the authorization stage, until a concrete Allow or Deny is reached. However, this approach might be wasteful and call later authorizers, whose response is never considered in the evaluation phase in admission. Thus is the lazy approach proposed.

How conditions are propagated in the API server request chain

A high-level picture of the request flow with conditional authorization. The chain of authorizer decisions can be lazily evaluated, such that the third authorizer in the picture is not evaluated directly in the authorization stage, as already the second one might yield an Allow. However, in admission, if the second authorizer ends up evaluating to NoOpinion, the third authorizer is evaluated (and in this example evaluates first to a conditional allow, then concrete Allow).

A diagram to summarize what the request chain looks like:

How various authorizer chain decisions are computed into one

AuthorizationConditionsEnforcer admission controller

Whenever the ConditionalAuthorization feature gate is enabled in the API server, there is an AuthorizationConditionsEnforcer validating admission controller whose job it is to evaluate the conditions, and enforce the decision that the condition set evaluated to. If the ConditionalAuthorization feature gate is enabled, but the user disables the AuthorizationConditionsEnforcer admission controller, k8s.io/apiserver options validation errors, and thus won’t the API server start in this setting. This is critical, as there must not be a case where the feature would be enabled, but there would be no enforcement.

The validating admission controller operates on a fully-mutated request object just like other validating admission controllers, by design.

It is proposed that the AuthorizationConditionsEnforcer is the first validating admission plugin to run; such that e.g. no validating webhooks need to execute unnecessarily.

Changes to (Self)SubjectAccessReview

One of the core goals of this KEP is to make it easier also for users subject to authorization policies that span authorization and admission understand what policies they are subject to. This in practice means that the conditions should be shown in (Self)SubjectAccessReview (SAR) responses, as is logical when the authorizer response area grows. However, there are some details to pay attention to:

  • The same request might be subject to multiple conditional authorizers in the authorizer chain. Consider a chain of two authorizers both returning a Conditional decision. The first authorizer’s returned ConditionSet will have precedence over the second, and thus cannot be merged into one. Instead, the SubjectAccessReview response must retain the ordering of the two ConditionSets, so the user can reason about them.
  • Consider a two-authorizer chain, where the first returns a Conditional decision, and the second Allow. As the Conditional response could evaluate to Deny (if that there are effect=Deny conditions), the structure must be able to model both conditional and concrete decisions.

The SubjectAccessReviewStatus API is thus augmented with the following field and types:

type SubjectAccessReviewStatus struct {
    // ... Allowed, Denied, Reason and EvaluationError here as normal

    // ConditionSetChain is an ordered list of condition sets, where every item
    // of the list represents one authorizer's ConditionSet response.
    // When evaluating the conditions, the first condition set must be evaluated
    // as a whole first, and only if that condition set
    // evaluates to NoOpinion, can the subsequent condition sets be evaluated.
    //
    // When ConditionSetChain is non-null, Allowed and Denied must be false.
    //
    // +optional
    // +listType=atomic
    ConditionSetChain []SubjectAccessReviewConditionSet `json:"conditionSetChain,omitempty"`
}

type SubjectAccessReviewConditionSet struct {
    // Allowed specifies whether this condition set is unconditionally allowed.
    // Mutually exclusive with Denied, Conditions, and ConditionSetChain.
    Allowed bool `json:"allowed,omitempty"`
    // Denied specifies whether this condition set is unconditionally denied.
    // Mutually exclusive with Allowed, Conditions, and ConditionSetChain.
    Denied bool `json:"denied,omitempty"`

    // FailureMode specifies the failure mode for this condition set.
    // Only relevant if the conditions are non-null.
    FailureMode string `json:"failureMode,omitempty"`

    // AuthorizerName specifies the authorizer name, unique within the server,
    // that authored these conditions. This is used by kube-apiserver to correlate
    // conditions that need to be evaluated through the AuthorizationConditionsReview API.
    AuthorizerName string `json:"authorizerName"`

    // ConditionsType describes the type of all conditions in the Conditions slice.
    // It does not apply at all to nested conditions in ConditionSetChain.
    //
    // Mutually exclusive with Allowed, Denied, and ConditionSetChain.
    ConditionsType string `json:"authorizerName,omitempty"`

    // Conditions is an unordered set of conditions that should be evaluated
    // against admission attributes, to determine
    // whether this authorizer allows the request.
    //
    // Mutually exclusive with Allowed, Denied, and ConditionSetChain.
    //
    // +listType=map
    // +listMapKey=id
    // +optional
    Conditions []SubjectAccessReviewCondition `json:"conditions,omitempty"`

    // ConditionSetChain is an ordered list of condition sets, where every item
    // of the list represents one authorizer's ConditionSet response.
    // When evaluating the conditions, the first condition set must be evaluated
    // as a whole first, and only if that condition set
    // evaluates to NoOpinion, can the subsequent condition sets be evaluated.
    //
    // This field is used by composite authorizers, such as the kube-apiserver,
    // that in turn delegate their decisions to other sub-authorizers.
    //
    // Mutually exclusive with Allowed, Denied, and Conditions.
    //
    // +optional
    // +listType=atomic
    ConditionSetChain []SubjectAccessReviewConditionSet `json:"conditionSetChain,omitempty"`
}

type SubjectAccessReviewCondition struct {
    ID string                                       `json:"id"`
    Effect      SubjectAccessReviewConditionEffect  `json:"effect"`
    Condition   string                              `json:"condition"`
    Description string                              `json:"description,omitempty"`
}

type SubjectAccessReviewConditionEffect string

const (
    SubjectAccessReviewConditionEffectAllow     SubjectAccessReviewConditionEffect = "Allow"
    SubjectAccessReviewConditionEffectDeny      SubjectAccessReviewConditionEffect = "Deny"
    SubjectAccessReviewConditionEffectNoOpinion SubjectAccessReviewConditionEffect = "NoOpinion"
)

Status.ConditionSetChain is mutually exclusive with Status.Allowed and Status.Denied. A conditional response is characterized by Status.ConditionSetChain != null. Old implementers that do not recognize Status.ConditionSetChain will just safely assume it was a NoOpinion.

The spec field is augmented to add the ConditionsMode, as described above:

type SubjectAccessReviewSpec struct {
    // ConditionalAuthorization specifies caller-specified configuration related
    // to conditional authorization. If unset, conditions are not supported.
    ConditionalAuthorization *ConditionalAuthorizationConfiguration `json:"conditionalAuthorization,omitempty"`

    // ... other field as usual.
}

// ConditionalAuthorizationConfiguration is its own struct/field, to allow
// possible future expansion of caller-provided knobs (e.g. for version skew).
type ConditionalAuthorizationConfiguration struct {
    // Mode describes
    // a) if the caller supports or wants conditions to be returned, and
    // b) if supported, how (preferably) conditions should be returned.
    // To indicate no support for conditional authorization, leave this field empty.
    // An authorizer must never return conditions when this field is empty.
    // However, respecting the caller's wish of presentation mode="HumanReadable"
    // or "Optimized" is voluntary for the authorizer.
    Mode ConditionsMode `json:"mode,omitempty"`
}

Supporting webhooks through the AuthorizationConditionsReview API

The webhook authorizer is augmented to support webhooks returning an ordered list (chain) of authorizer decisions, not just one decision. Supporting multiple returned decisions is required e.g. by aggregated API servers, that consult kube-apiserver as a webhook, which in turn can return more than one conditional decision. Note that aggregate API servers’ evaluation is thus always in practice eager (not lazy). This is considered acceptable though.

How should the webhook authorizer evaluate potentially opaque conditions? Unless the API server can evaluate the conditions returned by the webhook natively, another webhook needs to be made. To facilitate this, a new AuthorizationConditionsReview API, very similar to AdmissionReview is added. Because kube-apiserver acts as a webhook server, kube-apiserver must also serve this API. The AuthorizationConditionsReview API implementation is not subject to admission in kube-apiserver. A sketch of the new API is as follows:

// AuthorizationConditionsReview describes a request to evaluate authorization conditions.
type AuthorizationConditionsReview struct {
    metav1.TypeMeta `json:",inline"`
    // Request describes the attributes for the authorization conditions request.
    // +optional
    Request *AuthorizationConditionsRequest `json:"request,omitempty"`
    // Response describes the attributes for the authorization conditions response.
    // +optional
    Response *AuthorizationConditionsResponse `json:"response,omitempty"`
}

// AuthorizationConditionsRequest describes the authorization conditions request.
type AuthorizationConditionsRequest struct {
    // TODO: Do we want UID like AdmissionReview here? I guess we don't need it.

    // ConditionSetChain is an ordered list of condition sets, where every item
    // of the list represents one authorizer's ConditionSet response.
    // When evaluating the conditions, the first condition set must be evaluated
    // as a whole first, and only if that condition set
    // evaluates to NoOpinion, can the subsequent condition sets be evaluated.
    //
    // Composite authorizers (such as the kube-apiserver), which delegate their
    // decisions to other sub-authorizers, might return a ConditionSetChain in
    // SubjectAccessReview. That ConditionSetChain must be sent back unmodified
    // by the client in this field.
    //
    // This list contains exactly one element for non-composite authorizers,
    // which returned a simple list of their own conditions in SubjectAccessReview.
    //
    // +optional
    // +listType=atomic
    ConditionSetChain []SubjectAccessReviewConditionSet `json:"conditionSetChain,omitempty"`

    // All fields present in the ConditionData interface, not exhaustively listed
    // in this KEP for brevity.
}

// AuthorizationConditionsResponse describes an authorization conditions response.
type AuthorizationConditionsResponse struct {
    // TODO: Do we want UID like AdmissionReview here? I guess we don't need it.

    // Allowed indicates whether or not the request is authorized according to
    // the authorization conditions.
    // Mutually exclusive with Denied.
    // Allowed=false and Denied=false means that the authorizer has no NoOpinion on the request.
    Allowed bool `json:"allowed"`

    // Denied indicates whether or not the request is denied according to the authorization conditions.
    // Mutually exclusive with Allowed.
    // Allowed=false and Denied=false means that the authorizer has no NoOpinion on the request.
    Denied bool `json:"denied,omitempty"`

    // Reason describes a reason for the concrete decision
    Reason string `json:"reason,omitempty"`

    // EvaluationError describes a possible error that happened during evaluation.
    EvaluationError string `json:"evaluationError,omitempty"`

    // ConditionSetChain is an ordered list of condition sets, where every item
    // of the list represents one authorizer's ConditionSet response.
    // When evaluating the conditions, the first condition set must be evaluated
    // as a whole first, and only if that condition set
    // evaluates to NoOpinion, can the subsequent condition sets be evaluated.
    //
    // In order to support constrained impersonation that is also conditional
    // on the object, evaluating a ConditionSet might yield another ConditionSet.
    //
    // When ConditionSetChain is non-null, Allowed and Denied must be false.
    //
    // +optional
    // +listType=atomic
    ConditionSetChain []SubjectAccessReviewConditionSet `json:"conditionSetChain,omitempty"`

    // TODO: Add AuditAnnotations and/or Warnings as in AdmissionReview?
}

In the aggregated API server case, there is automatic configuration to evaluate the conditions through a POST /apis/authorization.k8s.io/v1alpha1/admissionconditionsreviews. User-configured webhooks supply the URL to call the evaluation endpoint through a dedicated context in the supplied kubeconfig:

apiVersion: apiserver.config.k8s.io/v1
kind: AuthorizationConfiguration
authorizers:
 - type: Webhook
   name: webhook
   webhook:
    # New: Encode the endpoint for resolving the conditions as a KubeConfig 
    # context. If unset, conditional authorization is not supported.
    # The authentication info and service hostname can be the same, but most 
    # likely the HTTP endpoint of the authorizer service is different.
    # The authorizer MUST support evaluating any condition type it returns
    # in the SubjectAccessReview.
    conditionsEndpointKubeConfigContext: authorization-conditions
    # New: What version of the AuthorizationConditionsReview to use.
    # This field has no default.
    authorizationConditionsReviewVersion: v1alpha1
    # Existing struct, pointer to KubeConfig file where the context exists
    connectionInfo:
      type: KubeConfigFile
      kubeConfigFile: /kube-system-authz-webhook.yaml

Finally, recall that the webhook authorizer by default caches requests. Any authorizer that utilizes caching, must also cache all conditions of the Conditional decision. If that advice is followed, evaluation is always done against a specific revision of the authorizers’ underlying policy store, without the authorizer needing to implement snapshot reads.

If Kubernetes supports evaluating the conditions in-process with a builtin ConditionsEvaluator, e.g. the proposed CEL one, a AuthorizationConditionsReview webhook is not needed, as per the following table:

Webhooks during phase:Authorization response not cachedAuthorization response cached
Condition Type Not Supported by Builtin Condition EvaluatorsAuthorize() + EvaluateConditions()EvaluateConditions()
Condition Type SupportedAuthorize()Neither

Composite / Union Authorizer Support

Some authorizers, like kube-apiserver itself, do not perform authorization logic themselves, but instead delegates actual authorization decisions to a set of ordered sub-authorizers. As long as there are no clearly circular dependencies in the authorizer call chain, this is supported. Consider the following example:

Directed Acyclic Graph

  1. A user sends a conditionally-authorized request (e.g. create) to an aggregated API server.

  2. The aggregated API server, as per our contract, must be configured with the kube-apiserver as its first webhook authorizer, and thus sends a SubjectAccessReview to it.

  3. kube-apiserver in turn is configured with a webhook authorizer foo, to which it sends another SubjectAccessReview. foo responds with two ConditionSets (each ConditionSet which maps to an authorizer-internal concept of “tiers”, modelled as a composite authorizer of two smaller authorizers: system and user). The response is:

    kind: SubjectAccessReview
    status:
      allowed: false
      conditionSetChain:
      - authorizerName: system
        # Supported by both the kube-apiserver and the aggregated API server
        conditionsType: k8s.io/cel
        conditions:
        - id: foo-system-1
          effect: Deny
          condition: <something>
      - authorizerName: user
        conditionsType: foo-opaque-type
        conditions:
        - id: foo-user-1
          effect: NoOpinion
          condition: <something>
        - id: foo-user-2
          effect: Allow
          condition: <something>
    
  4. Although it is at this stage known that the request can be authorized (if the Deny and NoOpinion conditions are false, and the Allow condition is true), the evaluation of the authorizer chain proceeds eagerly until an unconditional response is found, or the end of the chain is reached. (See this section for a comparison of eager and lazy evaluation, lazy evaluation is only available when the authorizer resides in the same process as the request)

  5. Thus, kube-apiserver performs a webhook to authorizer bar, which responds with one ConditionSet of one effect=Allow condition.

    kind: SubjectAccessReview
    status:
      allowed: false
      conditionSetChain:
      - authorizerName: whatever
        # Supported by both the kube-apiserver and the aggregated API server
        conditionsType: k8s.io/cel
        conditions:
        - id: bar-1
          effect: Allow
          condition: <something>
    
  6. As bar also responded with a conditional allow, the authorizer queries next the Node authorizer, which responds with NoOpinion, and finally, the RBAC authorizer responds with NoOpinion.

  7. If an unconditional response would have been found, kube-apiserver would have been able to soundly short-circuit the evaluation. Now it reached the end of the authorizer chain, and thus returned the following aggregated response to the aggregated API server is:

    kind: SubjectAccessReview
    status:
      allowed: false
      conditionSetChain:
      - authorizerName: foo
        conditionSetChain: # nested composite authorizer
        - authorizerName: system
          conditionsType: k8s.io/cel
          conditions:
          - id: foo-system-1
            effect: Deny
            condition: <something>
        - authorizerName: user
          conditionsType: foo-opaque-type
          conditions:
          - id: foo-user-1
            effect: NoOpinion
            condition: <something>
          - id: foo-user-2
            effect: Allow
            condition: <something>
      - authorizerName: bar # authorizer name in kube-apiserver, "whatever" was ignored.
        conditionsType: k8s.io/cel
        conditions:
        - id: bar-1
          effect: Allow
          condition: <something>
      # node authorizer ignored, as it responded NoOpinion
      # - authorizerName: node
      #  denied: true # unconditional deny
      # rbac authorizer ignored, as it responded NoOpinion
      # if it would have answered Allow, it would have been communicated as:
      # - authorizerName: rbac
      #  allowed: true # unconditional allow
    
  8. The aggregated API server sees that given this aggregate conditional response from kube-apiserver, the request can become authorized, if certain conditions are met, so it saves the conditions in the request context, and proceeds with the request.

  9. Next, the AuthorizationConditionsEnforcer admission controller enforces that the conditions hold. It walks the ConditionSets from top to bottom. First up is the foo authorizer’s system ConditionSet, which uses the condition type k8s.io/cel which is supported by the aggregated API server. Evaluation of the foo-system-1 condition is thus directly done against the object, which yields false, in other words, the Deny condition does not apply, and thus can the evaluation proceed.

  10. The foo authorizer’s user ConditionSet uses the opaque condition type foo-opaque-type which cannot readily be evaluated by the aggregated API server, and thus does the aggregated API server send the following request to kube-apiserver:

    kind: AuthorizationConditionsReview
    spec:
      conditionSetChain:
      - authorizerName: foo
        conditionSetChain:
        # The "system" ConditionSet was already evaluated to NoOpinion,
        # and is thus omitted
        - authorizerName: user
          conditionsType: foo-opaque-type
          conditions:
          - id: foo-user-1
            effect: NoOpinion
            condition: <something>
          - id: foo-user-2
            effect: Allow
            condition: <something>
      - authorizerName: bar
        conditionsType: k8s.io/cel
        conditions:
        - id: bar-1
          effect: Allow
          condition: <something>
      object: {} # request object
      # ... + other metadata
    
  11. kube-apiserver can correlate what authorizer authored what condition through the authorizerName field, and thus calls the foo authorizer’s EvaluateConditions method. In the webhook authorizer case, this yields another webhook to the foo authorizer:

    kind: AuthorizationConditionsReview
    spec:
      conditionSetChain:
      # The "system" ConditionSet was already evaluated to NoOpinion,
        # and is thus omitted
      - authorizerName: user
        conditionsType: foo-opaque-type
        conditions:
        - id: foo-user-1
          effect: NoOpinion
          condition: <something>
        - id: foo-user-2
          effect: Allow
          condition: <something>
      object: {} # request object
      # ... + other metadata
    
  12. Authorizer foo authorizer responds with a NoOpinion, as both the foo-user-1 and foo-user-2 conditions evaluated to false:

    kind: AuthorizationConditionsReview
    spec: {}
    status:
      allowed: false
      denied: false
    
  13. Next, kube-apiserver evaluates the ConditionSet from bar. These conditions are of the built-in CEL condition type, so the kube-apiserver tries to directly evaluate them. However, the condition used a CEL function cidr, which was introduced in, say v1.37, and kube-apiserver is of v1.36. As the “fast-path” of in-tree evaluation failed, the kube-apiserver falls back to webhook evaluation and sends this to authorizer bar:

    kind: AuthorizationConditionsReview
    spec:
      conditionSetChain:
      - authorizerName: bar
        conditionsType: k8s.io/cel
        conditions:
        - id: bar-1
          effect: Allow
          condition: <something>
      object: {} # request object
      # ... + other metadata
    
  14. The bar authorizer who built a CEL condition using the cidr function, naturally also has a CEL environment that is capable of evaluating it, which means that evaluation succeeds. The result is again false (so that we can illustrate the whole end-to-end flow), which means the ConditionSet as a whole evaluates to NoOpinion, in the same way as foo.

  15. As both the ConditionSet of foo and bar evaluated to NoOpinion, does the kube-apiserver also return NoOpinion to the aggregated API server.

  16. As evaluation of the aggregated API server’s first authorizer’s conditional allow turned out to be NoOpinion, evaluation of the other authorizers in the chain is lazily resumed.

  17. Thus, is the Authorize method on the RBAC authorizer called. Say it also returns NoOpinion.

  18. Finally, Authorize on webhook baz is called, which sends a SubjectAccessReview to baz. Say the response is the following:

    kind: SubjectAccessReview
    status:
      allowed: false
      conditionSetChain:
      - authorizerName: baz
        conditionsType: k8s.io/cel
        conditions:
        - id: baz-1
          effect: Allow
          condition: <something>
        - id: baz-2
          effect: Deny
          condition: <something>
        - id: baz-3
          effect: Allow
          condition: <something>
    
  19. Because the aggregated API server supports evaluating k8s.io/cel, it evaluates the conditions in order of importance, that is, Deny conditions (baz-2) first. As baz-2 returns false, Allow conditions baz-1 and baz-3 are evaluated to false and true respectively. As either of them returned true (order of conditions within a set does not matter, only ordering of the sets themselves), the baz ConditionSet evaluates to an Allow. Thus is the request allowed, and can proceed to other validating admission controllers.

Built-in CEL conditions evaluator

The most logical alternative for Kubernetes to provide as a builtin primitive is a CEL conditions evaluator. Such a conditions evaluator could re-use most of the CEL infrastructure that Kubernetes already has, and provide a unified model for those that already are familiar with ValidatingAdmissionPolicies. This means that a wide variety of authorizers could author CEL-typed conditions, and let the API server evaluate them without a need for a second webhook. RBAC++ could use this as well.

However, this evaluator could evolve with distinct maturity guarantees than the core conditional authorization feature.

The observant reader noticed that Decision.Evaluate takes a list of BuiltinConditionSetEvaluator as input, which allow evaluating the conditions in-process, without potentially sending webhooks back to the authorizer. A BuiltinConditionSetEvaluator is just a normal ConditionSetEvaluator, but scoped to just a set of supported types:

package authorizer

type BuiltinConditionSetEvaluator interface {
    ConditionSetEvaluator
    // SupportedConditionTypes defines the condition types that the builtin
    // evaluator can assign truth values to in-process.
    SupportedConditionTypes() sets.Set[ConditionType]
}

To avoid having to parse the AST from a string (which is relatively expensive), there could be an optimized mode in which the CEL evaluator can evaluate a binary-encoded AST directly, to get performance on par with e.g. ValidatingAdmissionPolicy, which also executes pre-compiled CEL programs.

The built-in CEL condition environment would be similar to that of ValidatingAdmissionPolicy, including the ability to perform secondary authorization checks through the builtin authorizer function. This allows an authorizer at any point in the authorizer chain to respect other authorizers in their configured order for secondary authorization checks. This also makes the authorization layer aware of API author-designated secondary checks, e.g. the designer of the CertificateSigningRequest API can require any writer of its objects to also have the sign permission of some signer resource.

One important point of note is that the authorizer returning conditions might not know what the caller’s (enforcement point’s) CEL capabilities are. Consider that the authorizer that wants to return a condition, which can be encoded in CEL form. However, if there would be two k8s-supported CEL condition types k8s.io/authorization-cel-v1 and k8s.io/authorization-cel-v2, the authorizer needs to naturally choose to encode its condition in either form. However, if the API server supports only v1, and the authorizer returned v2, or vice versa, the API server cannot necessarily evaluate those conditions in-process (if the formats do not round-trip between each other), but might have to “call out” to the authorizer again (which can be either a webhook or simple function call). This means that even in this case, the evaluation won’t fail, it might just be slightly slower. If we decide it is worth it, we can add other caller-provided knobs to SubjectAccessReview.spec.conditionalAuthorization in the future.

However, if there was a change in the semantics of evaluating a certain condition type that did not lead to a “major version bump”, that is, change of condition type entirely, there might be risk that an authorizer returns a condition that cannot be evaluated in-process. For example, in Kubernetes v1.36, say Kubernetes would support evaluating conditions of form k8s.io/authorization-cel in-process. If in v1.37, a new function was added to the CEL environment (say, datetime or similar), a new v1.37 authorizer could return a CEL condition referencing the new datetime function to an old v1.36 API server, which would error upon evaluation with “no such function exists”. If this happens, the in-process optimized evaluation is ignored, and the API server asks the authorizer to evaluate the conditions directly instead, which will lead to the correct result, as the authorizer is new.

Feature availability and version skew

Conditional authorization is available when all of the following criteria are met:

  • The authorizer implementation supports conditions, which can be done in two ways:
    • In-tree authorizer: through implementing the authorizer.ConditionSetEvaluator interface, and
    • Webhook authorizer: when needed, responds with a non-null .status.conditionSetChain array, along with .status.allowed=false and .status.denied=false.
  • The ConditionalAuthorization feature gate is enabled AND the AuthorizationConditionsEnforcer admission plugin is enabled
    • The AuthorizationConditionsEnforcer plugin could be enabled by default, as it returns Handles=<featureGateEnabled>.
    • However, to avoid the problematic configuration of a server being set up with the feature gate enabled, but not the admission plugin, the proposal is that AdmissionOptions.Validate will error, such that the API server can never start up in such a misconfigured state.
  • The SubjectAccessReview’s apiGroup, resource and apiVersion selects exactly one GVR (no wildcards allowed), which is served by the current API server, and the verb is one of create, update, patch, delete, or deletecollection. In the future, one could consider conditional authorization for reads as well (see below).
Version skew matrixOld API serverNew API server
Old webhookConditions never returnedConditions never returned from webhooks
New webhookWebhook respects ConditionsModeNone and never returns a conditional responseConditions respected if asked for

Other Kubernetes authorization enforcement points, with and without conditions-awareness

In the following section, relevant applications of the conditional authorization feature are listed. Existing Authorize calls that not mentioned here to specifically support conditional authorization, do not support it, and will fail closed upon seeing on any conditions.

One thing that needs to be taken into account for secondary authorization checks: today some of the checks set APIVersion="*" (for unknown reason) when there is no logical API version at hand. If such checks would need to start supporting conditional authorization, we’d need to propagate a concrete, logical API version instead, as conditional authorization requires API version to be concrete (not a wildcard).

Compound Authorization for Connectible Resources

After the move to WebSockets (KEP 4006 ), connect requests are initially authorized as e.g. get pods/exec, which can lead someone thinking that giving get * gives only read-only access, and not also write access. To mitigate this privilege-escalation vector, when the AuthorizePodWebsocketUpgradeCreatePermission feature gate is enabled (beta and on by default in 1.35), currently pods/attach, pods/exec and pods/portforward are subject to compound authorization, where effectively it is made sure that the requestor also is authorized to create the corresponding connectible resource. However, this check is not added (yet at least) for pods/proxy, services/proxy and nodes/proxy.

In relation to these two workstreams, it is proposed that we uniformly and generally require the requestor to have the create verb using compound authorization in the ConnectResource handler, whenever the feature gate (or a new one) is enabled. Both the initial (get) and compound (create) check would support conditional authorization, with operation == CONNECT, object == <connect-data> (e.g. PodExecOptions), oldobject == null, and options == null, just like connect admission today.

Such a check thus becomes a generalization of KEP-2862: Fine-grained Kubelet API Authorization , as now an authorizer can say “allow lucas to create nodes/proxy, but only when options.path == "/configz"”, or any other such policy that the administrator might fancy.

Compound Authorization for update/patch → create

If an update or patch turns into a create, the API server performs compound authorization to make sure the requestor also has the privilege to create the resource. This KEP also applies conditional authorization support for this compound authorization check.

Constrained Impersonation through Conditional Authorization

KEP-5284: Constrained Impersonation proposes a way to restrict impersonation such that the requestor both needs the permission to impersonate the specified user, but the permission to impersonate certain types of requests, e.g. lucas can only impersonate node foo, but only to get pods. This is a perfect example of where conditional authorization shines; the request that is being performed is the initially-unknown data that an authorizer might want to specify conditions on.

Consider the example of lucas can only impersonate node foo, but only to get pods. The authorizer policy (in pseudo-code) is of form:

request.userInfo.username == "lucas" &&  
request.verb == "impersonate" &&  
request.resource == "nodes" &&  
request.apiGroup == "authentication.k8s.io" &&  
request.name == "foo" &&  
impersonatedRequest.apiGroup == "" &&  
impersonatedRequest.resource == "pods" &&  
impersonatedRequest.verb == "get"

The first five ANDed expressions can be evaluated to true directly, just based on the data that is available in the normal impersonation SubjectAccessReview. However, impersonatedRequest is unknown, and thus does the residual expression yield conditions in the SubjectAccessReview response, e.g. as follows:

apiVersion: authorization.k8s.io/v1
kind: SubjectAccessReview
status:
  allowed: false
  conditionSetChain:
  - conditionsSet:
    - type: k8s.io/authorization-cel
      id: "lucas-only-impersonate-node-get-pods"
      condition: |
        impersonatedRequest.apiGroup == "" &&
        impersonatedRequest.resource == "pods" &&
        impersonatedRequest.verb == "get"        
      effect: Allow

Now, the impersonation filter can evaluate the condition, either through the builtin CEL evaluator (if applicable), or by calling the Authorizer’s EvaluateConditions endpoint with the missing data (the information about the impersonated request).

This approach supports the use-cases of the existing Constrained Impersonation KEP, but also other types of expressions, for instance:

  • “A ServiceAccount ai-agent-foo can only impersonate user lucas if it also at the same time impersonates group ai-agent-foo
    • This allows attenuating what an impersonator can do through generic deny rules for the given additional group.
  • “ServiceAccount ai-agent-foo can impersonate alice only for read requests, but the same ServiceAccount can impersonate bob for any action”
    • The current Constrained Impersonation KEP does not allow distinguishing what the impersonator can do for what target user.

In the future, it would be possible to even restrict impersonation based on the object and oldObject. For example, consider the following abstract policy which allows lucas to impersonate any user for create and update requests, but only if lucas annotates changed resources with the lucas-impersonated=true label:

request.userInfo.username == "lucas" &&  
request.verb == "impersonate" && 
request.resource == "users" &&  
request.apiGroup == "" &&
impersonatedRequest.verb in ["create", "update"] &&
has(object.metadata.labels["lucas-impersonated"]) &&
object.metadata.labels["lucas-impersonated"] == "true"

For fully evaluating this request, three stages are needed.

  1. First, in the normal SubjectAccessReview, only metadata about the requesting user, and the user to be impersonated can be populated in the SAR fields. The user to be impersonated is modelled as the “resource”, and the verb is impersonate. In the CEL environment, this data corresponds to the request variable. For an applicable request for the policy above, the condition produced look like:

    impersonatedRequest.verb in ["create", "update"] &&
    has(object.metadata.labels["lucas-impersonated"]) &&
    object.metadata.labels["lucas-impersonated"] == "true"
    
  2. The impersonation filter does know up front, however, the metadata of the request that is being performed, and if some authorizer returned a conditional response in the first stage, the impersonation filter could directly afterwards run EvaluateConditions() / AdmissionConditionsReview with more information: namely the impersonatedRequest metadata. After this step, the condition produced by partial evaluation with this additional information yields:

    has(object.metadata.labels["lucas-impersonated"]) &&
    object.metadata.labels["lucas-impersonated"] == "true"
    
  3. As impersonation might happen in an authenticating front proxy (e.g. kube-apiserver), but object decoding and admission run in another process (e.g. an aggregated API server), the impersonation filter running in the former process does not have access to the request object. Thus, if we allowed impersonation expressing conditions on the request/stored object, the condition residual shown in 2. needs to be propagated from the front proxy to the aggregated API server.

    1. One backwards-compatible way to do this in practice, is to reserve one userinfo extra key (e.g. k8s.io/impersonation-conditions) to propagate the conditions. As we already require and assume that the aggregated API server uses the front proxy kube-apiserver as its first webhook authorizer, kube-apiserver can treat the conditions found in the userextra as the first deny-only conditional authorizer, and thus return these object-scoped conditions to the aggregated API server like usual.
    2. The conditions-aware impersonation authorizer would need to either cache the conditions internally using a context key, or pass the conditions transparently to the client, so that it can evaluate those later itself when passed back.

Initially, however, we do not need to go this far as to implement object-level constraints during impersonation, but this design should be future-proof as keeps the option open.

Node authorizer

The Node authorizer was the first conditional authorizer in that it had both an authorization and admission part that always were designed and evolved in tandem. This proposal generalizes this; now the Node authorizer could return conditional responses with type e.g. k8s.io/node-authorizer and either transparent conditions written in CEL, if possible, or opaque ones, e.g. condition: '{"condition": "require-pod-node-name", "nodeName": "foo"}'.

In the opaque condition case, the Node authorizer will get a callback on its then-added EvaluateConditions() function to, even in native code, enforce e.g. a Pod’s spec.nodeName actually matches what it should be. If this were the case, all logic is centralized in the authorizer, instead of being split between two components, and SubjectAccessReview shows what policies apply.

ValidatingAdmissionPolicies

ValidatingAdmissionPolicies support secondary authorization checks through the authorizer function in the CEL environment. This could be used, for example, to check that the requestor also has the sign certificates.k8s.io signers <signerName> permission, for the signer specified in the CertificateSigningRequest API.

Secondary checks in VAP could support conditionally-authorized requests too, given that the secondary check using the authorizer also supplies the relevant object and/or oldobject against which conditions could be written. However, this does not seem to be a major use-case, as most secondary checks that have been seen in the wild check permissions against some resource, subresource or verb that is not served by the API server.

The loss of power of not supporting conditions in secondary VAP checks is minor. The VAP authorizer can perform secondary permission checks for permissions independently from each other, e.g. “this write request of the VM resource is only allowed if the requestor also has the backup privilege on the referenced storage bucket (when backups are enabled) and when the requestor can use the referenced network”. Without conditional authorization in this context, the authorizer cannot express an intersection of the two, e.g. “the requestor can only backup VM objects using this storage bucket when the VM is using this given network”.

If conditions were supported in VAP, it would be possible to decouple the authorization policy (in the authorizer) from the enforcement code (in VAP), as long as the data exchanged between them is consistent. Without conditions supported in VAP, the enforcement code there needs to choose based on what attributes to perform secondary checks.

To give users more information about what they can do (for debugging), the SelfSubjectAccessReview endpoint could also show partially-evaluated CEL from ValidatingAdmissionPolicy objects. However, if this is done, the user could know better how to create an object that passes authorization and admission, for better and worse.

deletecollection support

Although not immediately obvious, conditional authorization would also work for verb=deletecollection requests. In this case, the condition is written just as it would for verb=delete, the same admission chain (which has the conditions enforcement as the first validating admission plugin) is run once for all objects.

Complete list of all Authorize calls in kube-apiserver

  • k8s.io/kubernetes/pkg/certauthorization.IsAuthorizedForSignerName: Secondary authorization check for the sign/approve/attest virtual verbs on the certificates.k8s.io signers resource.
    • Does not support conditions (for now at least), for the same reasoning as VAP.
  • k8s.io/kubernetes/pkg/kubelet/server.InstallAuthFilter: Primary authorization for the kubelet server.
    • Would support conditions, so that conditions can apply to the path called of the nodes/proxy resource.
  • k8s.io/kubernetes/pkg/registry/admissionregistration/{validating,mutating}admissionpolicy{,binding} Performs secondary checks of the requestor being able to get the referenced parameter resource object or all objects.
    • Does not support conditions, verb is get
  • k8s.io/kubernetes/pkg/registry/authorization/{local,self,}subjectaccessreview: Serving the SAR endpoints
    • Would be conditions-aware, so conditions can be propagated further (e.g. to aggregated API servers)
  • k8s.io/kubernetes/pkg/registry/core/pod/rest.ensureAuthorizedForVerb: Ensures that the requestor also has the create verb on certain connectible subresources for pods, as discussed above.
    • Would be conditions-aware, as discussed above .
  • k8s.io/kubernetes/pkg/registry/rbac: Verifies that a user cannot privilege-escalate their permissions when creating roles and bindings. If the user indeed would try to privilege-escalate, allow them to do so if they have the escalate or bind verbs. This check would not support conditions.
  • k8s.io/kubernetes/plugin/pkg/admission/gc: Verifies that ownerReferences are correctly set. Someone that updates an object’s ownerReferences, need to be able to delete that object. In addition, if an owner reference of an create or update is blocking, the requestor either needs permission to update */finalizers, or on the owner resource. These checks would not support conditions.
  • k8s.io/kubernetes/plugin/pkg/admission/noderestriction: Ensures that a node can only issue ServiceAccount tokens for allowed audiences. If the requested audiences are not found in the PodSpec, a secondary check to request-serviceaccounts-token-audience <audience> of the ServiceAccount’s name/ns is issued. These checks would not support conditions.
  • k8s.io/apiserver/plugin/pkg/admission/plugin/authorizer/caching_authorizer.go: This authorizer caches responses from SAR requests issued from e.g. ValidatingAdmissionPolicy secondary checks. This could eventually support caching conditional checks, but initially, it is not needed, as long as all such secondary checks do not support conditions.
  • k8s.io/apiserver/pkg/cel/library: Implements the CEL functions for VAP secondary checks. These do not support conditions initially at least, but this could be expanded in the future as discussed above.
  • k8s.io/apiserver/pkg/endpoints/filters: This is the main WithAuthorization entrypoint to make conditions-aware as part of this proposal. It also contains the impersonation code that could be made conditional.
  • k8s.io/apiserver/pkg/endpoints/handlers/delete.go: Authorizes use of unsafe deletion without reading the object, as a special case. This would not support conditions.
  • k8s.io/apiserver/pkg/endpoints/handlers/update.go: Secondary check for update requests turning into a create request.

Authorizer requirements

To recap, the authorizer must adhere to the following requirements to be considered functional:

  • If the ConditionsMode is None (that is, unset), no conditions must be returned, and it is up to the authorizer if it should fold a response it wanted to be conditional to either NoOpinion or Deny.

  • If any of the conditions that the authorizer would have returned would have been of effect=Deny, it is recommended for the authorizer to fold to decision=Deny.

  • Only ever produce a conditional response if producing an unconditional response is not possible.

    • The effect of authorizer-internal policies determines this. If there is an authorizer which has effect=Allow|NoOpinion (soft deny)|Deny (hard deny) policies, then the strength of policies are ordered as Deny (unconditional) > Deny (conditional) > NoOpinion (unconditional) > NoOpinion (conditional) > Allow (unconditional) > Allow (conditional).
    • For example, if an unconditional Deny policy matches, the output is always an unconditional Deny, regardless of other matches.
    • If no Deny or NoOpinion policies match, only a conditional and unconditional Allow, the unconditional Allow takes precedence.
    • However, if a conditional Deny policy matches together with an unconditional Allow, the response needs to be conditional, as before producing a final response, one needs to know whether the conditional Deny will override the unconditional Allow.
  • The authorizer can only return a conditional response with an effect=Allow if there is a path for the request to become authorized. All pruning that is possible to do with the initial authorizer Attributes MUST be used.

    • For example, a policy of form object.metadata.labels.foo == "bar" && request.verb == "create" MUST not yield a conditional response for a verb="update" request, as the LHS of && is then always false.
  • An authorizer must be API version-aware, and should only let a policy author refer to a field in a version-dependent manner, and/or validate that the policy applies successfully to all known versions.

    • The request object version might not equal the storage version, and the API server cannot necessarily convert between the versions (due to CRD conversion webhooks failing). For in-tree, one can always convert without errors and reasonably fast. The new (request) object is always the request API version. The authorizer could ask the API server to convert, if we want, but this is not necessarily error-free.
      • TODO(Lucas): See what happens for a CRD + VAP, if the request version != storage version, or if the CRD schema changes.
    • For example, VAP policies today use the latter technique, which rejects expressions that do not compile under all possible API version-specific CEL environments.
    • Another technique could be to expose the object through a version-specific fieldpath, e.g. v1.spec.foo and v2.spec.bar could refer to logically the same value of a field that was renamed from foo in v1 to bar in v2.
  • To fail closed when new API versions are added, the authorizer could automatically insert restrictions that only API versions that are referenced in the policy can yield an allowed response. In the following example, write requests fail closed for API version v3 until the policy author has had time to add the restriction specific to that version, as follows:

    request.verb in ["create", "update"] &&
    if has(v1) then
      v1.spec.foo == "baz"
    else if has(v2) then
      v2.spec.bar == "baz"
    else
      false
    
  • An authorizer must be able to evalute any condition they authored, such that the API server always can call the authorizer to evaluate the condition (regardless of in-process evaluation capabilities). An authorizer only ever evaluates its own conditions.

  • The authorizer must make sure their conditions are safe and performant to execute. In particular, any CEL condition that is returned to Kubernetes must be within reasonable CEL cost limits. The authorizer should reject policies that would be too costly to execute in the request path.

    • Towards this end, it is highly recommended that the authorizer uses a non-Turing complete language, to avoid e.g. the [Halting problem] and remote code execution.
    • As a worst-case example, if an authorizer accepted Python code as conditions, then any Kubernetes user with create authorizationconditionsreview would be able to send malicious conditions that remotely executes arbitrary code through a “condition” of form import os; os.system("sudo nmap ...")

Halting problem

Risks and Mitigations

Showing the users what authorization conditions that they are subject to through SelfSubjectAccessReview means that badly-written access control policies which follow the pattern of [Security Through Obscurity] would be “broken”. For example, if a user can only create pods conditioned on metadata.labels.foo == "42", the latter condition would be returned in the SelfSAR, whereas without this information the user would need to “blindly” guess the number. However, these kind of practices are highly discouraged, as they rarely are effective in practice. For example, if the user could list pods otherwise (which in that case would be a reasonable permission to have), they could most likely reverse-engineer the conditions they are subject to, by seeing data that “got through”.

This risk could be mitigated by the authorizer returning opaque conditions (which could not be efficiently evaluated in the API server), instead of transparent (e.g. CEL) ones. It is up to the authorizer’s discretion to know whether conditions are “secret” or can be shown to the user. For clarity to the user, it is recommended that conditions are shown when possible and reasonable.

Security Through Obscurity

Test Plan

[x] I/we understand the owners of the involved components may require updates to existing tests to make this code solid enough prior to committing the changes necessary to implement this enhancement.

Prerequisite testing updates

The update of the authorizer interface will need to update any test which uses the authorizer, which could be quite many.

Unit tests

Existing code that will be affected by the change (along with existing unit test coverage):

  • k8s.io/apiserver/pkg/authorization/union: 2026-02-10 - 88
  • k8s.io/apiserver/plugin/pkg/authorizer/webhook: 2026-02-10 - 86
  • k8s.io/apiserver/pkg/endpoints/filters/authorization.go: 2026-02-10 - 91

Testing will in addition be added as appropriate to new packages.

Integration tests

An integration test will be added for this feature in k8s.io/kubernetes/test/integration/apiserver/cel/conditionalauthz, both when the feature is enabled and disabled.

e2e tests

When the feature is enabled, tests will be added for user-facing functionality such as the SubjectAccessReview API, and sending request objects which are allowed and denied. In addition, one end to end test should make sure that the feature also works as expected in a scenario with aggregated API servers.

Graduation Criteria

Alpha

  • Refactoring of the authorizer interface is completed
  • Feature implemented behind a feature flag
  • Initial integration tests completed and enabled

Beta

  • There is in-core use of this feature, e.g. the node authorizer and/or constrained impersonation.
  • Gather feedback from developers and surveys
  • Additional tests are in Testgrid and linked in KEP
  • More rigorous forms of testing—e.g., downgrade tests and scalability tests
  • All functionality completed
  • All security enforcement completed
  • All monitoring requirements completed
  • All testing requirements completed
  • All known pre-release issues and gaps resolved

Note: Beta criteria must include all functional, security, monitoring, and testing requirements along with resolving all issues and gaps identified

GA

  • N (to be determined later) examples of real-world usage
  • N installs
  • Allowing time for feedback
  • All issues and gaps identified as feedback during beta are resolved

Note: GA criteria must not include any functional, security, monitoring, or testing requirements. Those must be beta requirements.

Note: Generally we also wait at least two releases between beta and GA/stable, because there’s no opportunity for user feedback, or even bug reports, in back-to-back releases.

For non-optional features moving to GA, the graduation criteria must include conformance tests .

Version Skew Strategy

In general, old clients can safely talk to new conditional authorizers, as the authorizer will notice that the old client did not explicitly opt into the new behavior, and thus will the authorizer fold to NoOpinion or Deny as appropriate.

However, rollouts of new AuthorizationConfig configurations in multi-API server scenarios (or similar) such as shown in this picture might need some special care.

Possible failure scenario of AuthorizationConfig rollout

For this reason, it is recommended that conditions-aware authorizers with a loadbalancer in between itself and the client either:

  • (preferred) configure the load balancer to perform a blue-green rollout of the API server configuration, initially only sending requests to old API servers (without the API server configured), and then, at once, sending requests to new API servers.
    • If the rollout is such that a conditional authorizer is removed in the new configuration, the authorizer should send only NoOpinion decisions before the blue-green rollout, to make sure there are no AuthorizationConfigReview requests pending to be sent to it.
  • make the authorizer respond with NoOpinion until it is known that the rollout of all intermediate kube-apiservers to the new configuration has completed.

Production Readiness Review Questionnaire

Feature Enablement and Rollback

How can this feature be enabled / disabled in a live cluster?
  • Feature gate
    • Feature gate name: ConditionalAuthorization
    • Components depending on the feature gate: kube-apiserver
  • Opt-in on an authorizer basis for webhook authorizers in StructuredAuthorizationConfig
    • This can be done with no API server downtime.
Does enabling the feature change any default behavior?

No.

Can the feature be disabled once it has been enabled (i.e. can we roll back the enablement)?

Yes.

What happens if we reenable the feature if it was previously rolled back?

Nothing special, feature enablement is stateless.

Are there any tests for feature enablement/disablement?

Yes, there will be integration and/or end to end tests covering both the feature when enabled and disabled.

Rollout, Upgrade and Rollback Planning

How can a rollout or rollback fail? Can it impact already running workloads?
  • If an administrator configures a webhook authorizer to support conditions on the endpoint /conditions, but the authorizer server for some reason does not serve this endpoint (correctly), this misconfiguration can fail conditionally authorized requests that otherwise could succeed. Before pointing the API server at a conditional endpoint, all replicas of the authorizer must support conditions.
What specific metrics should inform a rollback?
  • High p50 and p99 latency of authorizer-handled and in-process conditions evaluation
  • We could add a counter for how many authorizer requests (out of a total count) that produced an error, and then users can make sure this metric is not growing at a higher pace
Were upgrade and rollback tested? Was the upgrade->downgrade->upgrade path tested?

This has not been tested yet, but should not be a problem as all code is feature-gated.

Is the rollout accompanied by any deprecations and/or removals of features, APIs, fields of API types, flags, etc.?

No.

Monitoring Requirements

How can an operator determine if the feature is in use by workloads?

Audit annotations can surface information that a request was initially conditionally authorized.

How can someone using this feature know that it is working for their instance?

Submit a (Self)SubjectAccessReview whose response is known to be conditional, and check if conditions are returned.

What are the reasonable SLOs (Service Level Objectives) for the enhancement?

Evaluation time of authorization builtin CEL conditions is of the same magnitude as for ValidatingAdmissionPolicy.

What are the SLIs (Service Level Indicators) an operator can use to determine the health of the service?
  • Metrics
    • Metric name: Authorization latency and error rate (both should be low and steady)
    • Components exposing the metric: kube-apiserver
  • Instrumentation of conditional authorizer implementations external to Kubernetes
Are there any missing metrics that would be useful to have to improve observability of this feature?

N/A

Dependencies

Existing CEL libraries of Kubernetes.

Does this feature depend on any specific services running in the cluster?

Not in particular, but until there is a built-in authorizer that is conditional, the cluster administrator needs to make use of some webhook authorizer to enable conditionally-authorized responses.

Scalability

Will enabling / using this feature result in any new API calls?

There might be a slight increase in traffic between aggregated API servers and the kube-apiserver, due to aggregated API servers potentially asking the kube-apiserver to resolve authorization conditions.

Will enabling / using this feature result in introducing new API types?

A new API type AuthorizationConditionsReview is introduced, but it is not stored in etcd.

Will enabling / using this feature result in any new calls to the cloud provider?

No.

Will enabling / using this feature result in increasing size or count of the existing API objects?

No.

Will enabling / using this feature result in increasing time taken by any operations covered by existing SLIs/SLOs?

Authorization latency could increase.

Will enabling / using this feature result in non-negligible increase of resource usage (CPU, RAM, disk, IO, …) in any components?

No.

Can enabling / using this feature result in resource exhaustion of some node resources (PIDs, sockets, inodes, etc.)?

No.

Troubleshooting

How does this feature react if the API server and/or etcd is unavailable?

Not applicable, the feature resides completely within the API server. However, as before, if the kube-apiserver is down, so are all aggregated API servers (unable to properly authorize requests).

What are other known failure modes?

Not known.

What steps should be taken if SLOs are not being met to determine the problem?
  • Check the AuthorizationConfiguration
  • Troubleshoot any webhook authorizers’ responses
  • Check audit logs and metrics

TODOs

  • TODO: Expand on this point of conditional vs composite authorization
  • TODO: Add more wording on ReferenceGrants
  • TODO: One might be able to infer the admission-time operation through whether only the request object is available (create), or both the stored and request object is (update)?

Alternatives Considered

Expose all conditions in AdmissionReview, and have admission plugins “acknowledge” the conditions

The SIG Auth meeting of September 4, 2025 concluded that this feature should support also condition types that are not built into Kubernetes. Thus does there need to be some way to evaluate the not-natively-supported conditions in the admission phase. The most logical way, would be to add some fields to AdmissionReview, and thus let admission webhooks let the API server know (besides the AdmissionReview’s primary response.allowed field) what the conditions evaluated to.

However, this turned out to be unnecessarily complicated in practice, when taking the idea further. Should all conditions from potentially every authorizer in the chain be sent to every admission webhook? Probably not.

Can an admission webhook choose to evaluate individual conditions of some specific authorizer, or does the admission webhook need to evaluate all conditions produced by a certain authorizer at once, returning the result of the whole condition set according to the defined semantics? The latter solution is much simpler for both users and implementers to understand, so probably the latter.

However, then, how can one know that a certain admission webhook has the right to acknowledge a certain authorizer’s conditions? What if the conditional authorizer is controlled by the cloud provider or infrastructure team, but a (malicious) user dynamically registers its own admission webhook that wants to acknowledge the conditions from the cloud provider’s authorizer? What happens if there are multiple (dynamically registered) admission webhooks that evaluated the same input data (conditions+request body) to two different outputs?

These questions led us to realize that the safest initial plan is to require a 1:1 mapping between the authorizer (registered through AuthorizationConfiguration) and the authorizer’s condition enforcer. As normal users anyways cannot dynamically register authorizers, there is no need to dynamically register authorizer condition enforcers either for normal users. Thus is the most logical place to register the authorizer’s condition enforcer, in the same place the authorizer is defined in AuthorizationConfiguration.

In other words, only the authorizer itself can evaluate its own conditions in the admission phase, and all at once only (as a set), not partially.

Propagate an API server-generated request UID to both authorization and admission

This would have helped solve the atomicity concern, but it is not a full long-term solution, as it still relies on people setting up webhooks.

Only one ConditionSet exposed as part of SubjectAccessReview status

However, if only one condition set is exposed, it might be impossible for a user to understand what conditions it is subject to for a given request through a (Self/Local/Standard) SubjectAccessReview, as the first conditional response might be just a “deny dangerous operations”-type of conditional response.

The user should thus see all conditional allows and denies until there is an unconditional response.

Require the client to annotate its write request with field or label selectors

This would be a breaking change for clients, as whenever conditional authorizers would hit production usage, every client would need to annotate its every request with all selectors “just in case” some authorizer would make use of it, to higher the chances of getting authorized. This could duplicate a fair amount of the request data.

The other problem is updates: would the selector apply only to the request object, only to the stored one, or both at once.

Extract label and field selectors from the request and current object in etcd, and supply that to the authorization process

If the client was not required to send all this data, but the API server would decode the object to extract “just” label and field selectors, the DoS vector occurs, where a malicious party could send huge requests with bogus data, that the API server would decode before authorization takes place. In addition, would this make the authorization process state-dependent (if the selector would need to apply to both the request and stored object), something which is considered an explicit anti-pattern.

Do nothing, force implementers to implement all of this out of tree

Pros:

  • No extra code is added to Kubernetes.

Drawbacks:

  • Authorizers would need to fold a conditional allow into a concrete Allow in authorization responses. This is confusing, and could easily be misunderstood, for example composite authorization of update requests turning into creates would not respect conditions, which could be unsafe and unexpected.
  • Likewise, Constrained Impersonation could not be made more expressive.
  • Requires the cluster administrator to install a validating webhook that never can become deleted (which normal admission webhooks can).
  • Users would not see their conditions in the SelfSAR, and e.g. conditional read policies (see below) would be more or less impossible to discover.
  • Two webhooks are always needed for every request, even if the condition could be expressed in CEL, which in theory would not require a webhook.
  • Evaluation of policies is not atomic across authorization and admission phases. A full re-evaluation of all policies need to be done twice.
  • Only one conditional authorizer could effectively be supported, instead of many in this framework.
  • Without a clear framework on how to do this (with reference implementations), the risk of wrong or incompatible implementations is higher.
  • Kubernetes could not use this itself, even though it could be useful.

Drawbacks

  • Added complexity to core (complexity which today is either at the cost of expressiveness, or for users to deal with)

Appendix A: Further resources

Appendix B: Future addition sketch: Conditional Reads

Together with the Authorize with Selectors KEP , it becomes possible to provide policy authors to write unified policies across both authorization and admission, and reads and writes, at least whenever operating on field-selectable fields. A practical example would be “allow user Alice to perform any verb on PersistentVolumes, but only when spec.storageClassName is “dev”” (assuming storageClassName is/would become field-selectable).

Consider that before this KEP, a user might need to use two or even three different paradigms to protect both data and metadata across reads and writes:

Read/write and data/metadata consistency before this KEP

But after this KEP, it is possible to use a unified paradigm for all types:

Read/write and data/metadata consistency after this KEP

For a practical example of what this unified interface can look like, take a look at Lucas’ proof of concept at upbound/kubernetes-cedar-authorizer , in particular the getting started guide . If this project proves generally useful, it can be donated to a fitting place in the CNCF ecosystem (e.g. kubernetes-sigs or Cedar, which is now a CNCF Sandbox project). For more detailed information about the project and the philosophy behind it, take a look at Lucas’ Master’s thesis (written at Aalto University).

A concrete example of how a future version of Kubernetes could integrate this would be that authorizers are allowed to return conditions also on read requests. The syntax of the condition must be a generalized selector (most likely a subset of CEL) of a well-known condition type. Note that extraction of values from an object does not need to change, we can limit expressiveness to labels and existing simple JSONpath-based extractors.

Consider the following fictional authorizer chain decisions:

  • Authorizer 1:
    • effect=Deny condition: metadata.labels.owner != "lucas"
    • effect=NoOpinion condition: metadata.labels.visible != "true"
    • effect=Allow condition: object.type == "k8s.io/basic-auth"
    • effect=Allow condition: metadata.labels.public == "true"
  • Authorizer 2:
    • effect=Allow condition: metadata.labels.env == "dev"

These conditions turn into the following boolean predicate:

isAuthorized(object) = !(object.metadata.labels.owner != "lucas") AND (
  (
    !(object.metadata.labels.visible != "true") AND
    (
      (object.type == "k8s.io/basic-auth") OR
      (object.metadata.labels.public == "true")
    )
  ) OR
  (
    (object.metadata.labels.env == "dev")
  )
)

which could also be written in Disjunctive Normal Form (DNF) as follows:

isAuthorized(object) = (
  (object.metadata.labels.owner == "lucas") AND
  (object.metadata.labels.visible == "true") AND
  (object.type == "k8s.io/basic-auth")
) OR
(
  (object.metadata.labels.owner == "lucas") AND
  (object.metadata.labels.visible == "true") AND
  (object.metadata.labels.public == "true")
) OR
(
  (object.metadata.labels.owner == "lucas") AND
  (object.metadata.labels.env == "dev")
)

Note that the authorizer 1’s effect=Deny condition must evaluate to false for an object to be matched. However, the effect=NoOpinion is scoped only to authorizer 1, if an object was such that metadata.labels.owner == "lucas" and metadata.labels.env == "dev", it is authorized by authorizer 2, even though metadata.labels.visible == "false" (which yields a NoOpinion response from authorizer 1).

The API server must make sure that every object that is returned from storage is authorized. The API server cannot know what objects are in storage (as one of the authorization requirements is to be stateless with regards to the data store), but it can prove something stronger: for every possible object that could be constructed, that matches the given objectSelected(object) selector, isAuthorized(object) is true.

This equation can be resolved with a SAT/SMT solver as follows:

(forall object: objectSelected(object) => isAuthorized(object)) == TRUE
=== (forall object: (not objectSelected(object)) OR isAuthorized(object)) == TRUE
=== (exists object: objectSelected(object) AND (not isAuthorized(object))) == FALSE

A client who wants to ask “show me all instances of resource X that I can see” can thus perform a SelfSAR, construct a selector objectSelected which is equal to isAuthorized (and thus correct-by-construction), and thus see all objects that it can, without having to know its permissions up front, or issue n different requests (e.g. for each namespace). This would work for controllers/watches as well. Even more conveniently, the API server could provide the client with a mode that “downgrades” an unconstrained request (e.g. GET /api/v1/pods) by the server adding the selector that the client is authorized to see.

However, note that Conditional Reads are NOT part of this proposal right now, another KEP is expected for that eventually (if people like the idea), but I felt it is good to mention the sketch up-front here so that reviewers have an idea how conditional authorization can become usable for both reads and writes, eventually.


  1. Note that this proposal does not directly propose any relation/graph-based authorization mechanism, but that a request might be conditionally allowed on relation-based conditions. ↩︎

  2. This KEP focuses on write requests, but another KEP that would add a generalized selector syntax is anticipated. That KEP would add a CEL-based, Kubernetes-specific, selector syntax that form conditions for read requests. Thus could a generic, conditions-aware “list me what I can see”-client be written that first issues a SelfSAR, and then adds the returned (authorized) conditions to the list/watch request, such that the request is by definition authorized. ↩︎ ↩︎

  3. As kube-apiserver serves as a webhook authorizer for aggregated API servers. ↩︎

  4. Note: As we fold ConditionalDeny + Deny into Deny directly, the audit log just tells that one of the authorizers (in this case, the latter) denied it, not necessarily the first one. ↩︎