Extend the Authorizer interface with conditions-aware methods.

This commit is contained in:
Lucas Käldström 2026-03-15 21:51:21 +02:00
parent b1c17fc745
commit 6d78dfd60c
3 changed files with 425 additions and 4 deletions

View file

@ -0,0 +1,200 @@
/*
Copyright The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package authorizer
import (
"errors"
"fmt"
"strings"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
)
// ErrorConditionEvaluationNotSupported is returned by authorizer implementations
// that do not support condition evaluation.
var ErrorConditionEvaluationNotSupported = errors.New("condition evaluation not supported")
// ConditionsAwareDecision models an authorization decision that is conditions-aware.
// It is an enum type of the following five variants:
// - Allow: unconditional Allow.
// - Deny: unconditional Deny.
// - NoOpinion: unconditional NoOpinion.
// - Conditional: conditional on some previously-unseen data.
// - Union: an ordered list of sub-decisions, which forms a tree of decisions.
//
// The zero value (ConditionsAwareDecision{}) is equivalent to ConditionsAwareDecisionDeny().
// A ConditionsAwareDecision is passed by value.
type ConditionsAwareDecision struct {
unconditionalDecision Decision
reason string
err error
}
// ConditionsAwareDecisionDeny constructs a Deny decision with the given reason and error.
func ConditionsAwareDecisionDeny(reason string, err error) ConditionsAwareDecision {
return ConditionsAwareDecision{
// DecisionDeny == int(0) == zero value
// => ConditionsAwareDecision{} == ConditionsAwareDecisionDeny()
unconditionalDecision: DecisionDeny,
reason: reason,
err: err,
}
}
// ConditionsAwareDecisionAllow constructs an Allow decision with the given reason and error.
func ConditionsAwareDecisionAllow(reason string, err error) ConditionsAwareDecision {
return ConditionsAwareDecision{
unconditionalDecision: DecisionAllow,
reason: reason,
err: err,
}
}
// ConditionsAwareDecisionNoOpinion constructs a NoOpinion decision with the given reason and error.
func ConditionsAwareDecisionNoOpinion(reason string, err error) ConditionsAwareDecision {
return ConditionsAwareDecision{
unconditionalDecision: DecisionNoOpinion,
reason: reason,
err: err,
}
}
// ConditionsAwareDecisionFromParts is meant to be used by conditions-unaware Authorizer implementations
// in order to implement Authorizer.ConditionsAwareAuthorize as:
// "return authorizer.ConditionsAwareDecisionFromParts(self.Authorize(ctx, a))"
func ConditionsAwareDecisionFromParts(unconditional Decision, reason string, err error) ConditionsAwareDecision {
switch unconditional {
case DecisionAllow:
return ConditionsAwareDecisionAllow(reason, err)
case DecisionNoOpinion:
return ConditionsAwareDecisionNoOpinion(reason, err)
case DecisionDeny:
return ConditionsAwareDecisionDeny(reason, err)
default:
return ConditionsAwareDecisionDeny(reason, utilerrors.NewAggregate(
[]error{
err,
fmt.Errorf("unknown unconditional decision type: %d", unconditional),
},
))
}
}
// INVARIANT: Exactly one of Is* must return true at all times.
// IsAllowed returns true if the decision is an unconditional Allow.
func (d ConditionsAwareDecision) IsAllowed() bool {
return d.unconditionalDecision == DecisionAllow
}
// IsNoOpinion returns true if the decision is an unconditional NoOpinion.
func (d ConditionsAwareDecision) IsNoOpinion() bool {
return d.unconditionalDecision == DecisionNoOpinion
}
// IsDenied returns true if the decision is an unconditional Deny.
func (d ConditionsAwareDecision) IsDenied() bool {
return d.unconditionalDecision == DecisionDeny // == 0 == zero value
}
// IsUnconditional is true if d is Allowed, Denied or NoOpinion.
func (d ConditionsAwareDecision) IsUnconditional() bool {
return d.IsAllowed() || d.IsDenied() || d.IsNoOpinion()
}
// UnconditionalParts turns a ConditionsAwareDecision into the
// triple that Authorizer.Authorize expects. If the decision is
// conditional, the returned condition is Deny if there were at least
// some Deny condition, otherwise NoOpinion.
// This function is meant to be called when IsUnconditional() == true.
//
// If the authorizer is conditions-aware, it can choose to only implement
// real business logic in the ConditionsAwareAuthorize method, and implement
// Authorize() as "return self.ConditionsAwareAuthorize(ctx, attrs).UnconditionalParts()"
func (d ConditionsAwareDecision) UnconditionalParts() (Decision, string, error) {
switch {
case d.IsAllowed():
return DecisionAllow, d.Reason(), d.Error()
case d.IsDenied():
return DecisionDeny, d.Reason(), d.Error()
case d.IsNoOpinion():
return DecisionNoOpinion, d.Reason(), d.Error()
default:
// An error is not returned here, as that could yield a HTTP response code of 500 instead of 403.
// For the use-case described above with regards to calling this function in Authorize, not returning
// an error is important, as it is valid to always fail closed, as if this happens, no unconditional
// permissions were given the requestor.
return d.FailClosedDecision(), "failed closed: tried to return conditional decision to conditions-unaware authorizer", nil
}
}
// FailClosedDecision returns either a Deny or NoOpinion decision to fail closed
// whenever processing a decision fails. If the decision contains one or
// more Deny decisions or conditions, one must fail closed with Deny, as that could or would
// have been the if the condition evaluation did not error. Otherwise, NoOpinion is returned.
func (d ConditionsAwareDecision) FailClosedDecision() Decision {
if d.IsAllowed() || d.IsNoOpinion() {
return DecisionNoOpinion
}
// TODO(luxas): In the follow-up PR, add the logic for ConditionsMap and Union here, which
// makes this function useful.
// => d.IsDenied() == true
return DecisionDeny
}
// Reason returns the reason associated with the decision.
func (d ConditionsAwareDecision) Reason() string {
return d.reason
}
// Error returns the error associated with the decision.
func (d ConditionsAwareDecision) Error() error {
return d.err
}
// String returns a human-readable representation of the decision.
func (d ConditionsAwareDecision) String() string {
params := []string{}
if len(d.reason) != 0 {
params = append(params, fmt.Sprintf("reason=%q", d.reason))
}
if d.err != nil {
params = append(params, fmt.Sprintf("err=%q", d.err.Error()))
}
paramsStr := func() string {
if len(params) == 0 {
return ""
}
return fmt.Sprintf("(%s)", strings.Join(params, ", "))
}
if d.IsAllowed() {
return fmt.Sprintf("Allow%s", paramsStr())
}
if d.IsNoOpinion() {
return fmt.Sprintf("NoOpinion%s", paramsStr())
}
// Deny is written such that if none of the other modes apply,
// IsDenied() is true.
return fmt.Sprintf("Deny%s", paramsStr())
}
// ConditionsData is an enum type for various evaluation targets conditions
// can be written against.
// TODO(luxas): Implement this in the follow-up PR.
type ConditionsData struct {
}

View file

@ -0,0 +1,194 @@
/*
Copyright The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package authorizer_test
import (
"context"
"errors"
"fmt"
"testing"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
func TestConditionsAwareDecision(t *testing.T) {
unexpectedErr := fmt.Errorf("unexpected things happened")
otherErr := fmt.Errorf("other error")
ctx := t.Context()
sampleAttrs := authorizer.AttributesRecord{}
tests := []struct {
name string
testDecisions []authorizer.ConditionsAwareDecision
wantIsAllowed bool
wantIsNoOpinion bool
wantIsDenied bool
wantIsUnconditional bool
wantFailClosedIsDeny bool
wantReason string
wantAnyError bool
wantErrorIs error
wantString string
}{
{
name: "zero value",
testDecisions: []authorizer.ConditionsAwareDecision{
{},
authorizer.ConditionsAwareDecisionFromParts(0, "", nil),
authorizer.AuthorizerFunc(func(_ context.Context, _ authorizer.Attributes) (named1 authorizer.Decision, named2 string, named3 error) {
return
}).ConditionsAwareAuthorize(ctx, sampleAttrs),
},
wantIsDenied: true,
wantIsUnconditional: true,
wantFailClosedIsDeny: true,
wantReason: "",
wantErrorIs: nil,
wantString: `Deny`,
},
{
name: "deny constructor",
testDecisions: []authorizer.ConditionsAwareDecision{
authorizer.ConditionsAwareDecisionDeny("foo", unexpectedErr),
authorizer.ConditionsAwareDecisionFromParts(authorizer.DecisionDeny, "foo", unexpectedErr),
authorizer.AuthorizerFunc(func(_ context.Context, _ authorizer.Attributes) (authorizer.Decision, string, error) {
return authorizer.DecisionDeny, "foo", unexpectedErr
}).ConditionsAwareAuthorize(ctx, sampleAttrs),
},
wantIsDenied: true,
wantIsUnconditional: true,
wantFailClosedIsDeny: true,
wantReason: "foo",
wantErrorIs: unexpectedErr,
wantString: `Deny(reason="foo", err="unexpected things happened")`,
},
{
name: "allow constructor",
testDecisions: []authorizer.ConditionsAwareDecision{
authorizer.ConditionsAwareDecisionAllow("ok", nil),
authorizer.ConditionsAwareDecisionFromParts(authorizer.DecisionAllow, "ok", nil),
authorizer.AuthorizerFunc(func(_ context.Context, _ authorizer.Attributes) (authorizer.Decision, string, error) {
return authorizer.DecisionAllow, "ok", nil
}).ConditionsAwareAuthorize(ctx, sampleAttrs),
},
wantIsAllowed: true,
wantIsUnconditional: true,
wantReason: "ok",
wantErrorIs: nil,
wantString: `Allow(reason="ok")`,
},
{
name: "noopinion constructor",
testDecisions: []authorizer.ConditionsAwareDecision{
authorizer.ConditionsAwareDecisionNoOpinion("", nil),
authorizer.ConditionsAwareDecisionFromParts(authorizer.DecisionNoOpinion, "", nil),
authorizer.AuthorizerFunc(func(_ context.Context, _ authorizer.Attributes) (authorizer.Decision, string, error) {
return authorizer.DecisionNoOpinion, "", nil
}).ConditionsAwareAuthorize(ctx, sampleAttrs),
},
wantIsNoOpinion: true,
wantIsUnconditional: true,
wantReason: "",
wantErrorIs: nil,
wantString: `NoOpinion`,
},
{
name: "from parts: unsupported mode",
testDecisions: []authorizer.ConditionsAwareDecision{
authorizer.ConditionsAwareDecisionFromParts(42, "", nil),
authorizer.AuthorizerFunc(func(_ context.Context, _ authorizer.Attributes) (authorizer.Decision, string, error) {
return 42, "", nil
}).ConditionsAwareAuthorize(ctx, sampleAttrs),
},
wantIsDenied: true,
wantIsUnconditional: true,
wantFailClosedIsDeny: true,
wantReason: "",
wantAnyError: true,
wantString: `Deny(err="unknown unconditional decision type: 42")`,
},
{
name: "from parts: unsupported mode with other error",
testDecisions: []authorizer.ConditionsAwareDecision{
authorizer.ConditionsAwareDecisionFromParts(42, "foo", otherErr),
authorizer.AuthorizerFunc(func(_ context.Context, _ authorizer.Attributes) (authorizer.Decision, string, error) {
return 42, "foo", otherErr
}).ConditionsAwareAuthorize(ctx, sampleAttrs),
},
wantIsDenied: true,
wantIsUnconditional: true,
wantFailClosedIsDeny: true,
wantReason: "foo",
wantErrorIs: otherErr,
wantString: `Deny(reason="foo", err="[other error, unknown unconditional decision type: 42]")`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for i, d := range tt.testDecisions {
t.Run(fmt.Sprint(i), func(t *testing.T) {
isAllowed := d.IsAllowed()
if isAllowed != tt.wantIsAllowed {
t.Errorf("IsAllowed() = %v, want %v", isAllowed, tt.wantIsAllowed)
}
isNoOpinion := d.IsNoOpinion()
if isNoOpinion != tt.wantIsNoOpinion {
t.Errorf("IsNoOpinion() = %v, want %v", isNoOpinion, tt.wantIsNoOpinion)
}
isDenied := d.IsDenied()
if isDenied != tt.wantIsDenied {
t.Errorf("IsDenied() = %v, want %v", isDenied, tt.wantIsDenied)
}
isUnconditional := d.IsUnconditional()
if isUnconditional != tt.wantIsUnconditional {
t.Errorf("IsUnconditional() = %v, want %v", isUnconditional, tt.wantIsUnconditional)
}
gotReason := d.Reason()
if gotReason != tt.wantReason {
t.Errorf("Reason() = %v, want %v", gotReason, tt.wantReason)
}
gotError := d.Error()
if tt.wantAnyError {
if gotError == nil {
t.Errorf("Error() = %v, want some error", nil)
}
} else {
if !errors.Is(gotError, tt.wantErrorIs) {
t.Errorf("Error() = %v, want %v", gotError, tt.wantErrorIs)
}
}
failClosed := d.FailClosedDecision()
if tt.wantFailClosedIsDeny {
if failClosed != authorizer.DecisionDeny {
t.Errorf("want FailClosedDecision() == Deny; got %s", failClosed)
}
} else {
if failClosed != authorizer.DecisionNoOpinion {
t.Errorf("want FailClosedDecision() == NoOpinion; got %s", failClosed)
}
}
gotString := d.String()
if gotString != tt.wantString {
t.Errorf("String() = %v, want %v", gotString, tt.wantString)
}
})
}
})
}
}

View file

@ -26,8 +26,9 @@ import (
"k8s.io/apiserver/pkg/authentication/user"
)
// Attributes is an interface used by an Authorizer to get information about a request
// that is used to make an authorization decision.
// Attributes is an interface used by an Authorizer to get information about a
// request's metadata, that is used to compute an unconditional or conditional
// authorization decision.
type Attributes interface {
// GetUser returns the user.Info object to authorize
GetUser() user.Info
@ -99,9 +100,24 @@ type UnconditionalAuthorizer interface {
type Authorizer interface {
UnconditionalAuthorizer
// TODO(luxas): Add the conditions-aware methods in a follow-up PR.
// ConditionsAwareAuthorize returns an unconditional, conditional, or unioned
// decision, where the error and reason is part of the Decision struct.
//
// An authorizer who is not conditions-aware MUST implement this function as
// "return authorizer.ConditionsAwareDecisionFromParts(self.Authorize(ctx, a))",
// such that conditions-aware callers to this authorizer get the same output
// as if they called Authorize. Callers are only expected to call one of
// Authorize or ConditionsAwareAuthorize, not both.
ConditionsAwareAuthorize(ctx context.Context, a Attributes) ConditionsAwareDecision
// EvaluateConditions evaluates a conditional or unioned ConditionsAwareDecision against previously-unknown data.
//
// An authorizer who does not support conditions should fail closed and
// return authorizer.DecisionDeny, "", authorizer.ErrorConditionEvaluationNotSupported
EvaluateConditions(ctx context.Context, decision ConditionsAwareDecision, data ConditionsData) (authorized Decision, reason string, err error)
}
// AuthorizerFunc implements Authorizer
var _ Authorizer = AuthorizerFunc(nil)
type AuthorizerFunc func(ctx context.Context, a Attributes) (Decision, string, error)
@ -110,6 +126,14 @@ func (f AuthorizerFunc) Authorize(ctx context.Context, a Attributes) (Decision,
return f(ctx, a)
}
func (f AuthorizerFunc) ConditionsAwareAuthorize(ctx context.Context, a Attributes) ConditionsAwareDecision {
return ConditionsAwareDecisionFromParts(f.Authorize(ctx, a))
}
func (f AuthorizerFunc) EvaluateConditions(_ context.Context, _ ConditionsAwareDecision, _ ConditionsData) (Decision, string, error) {
return DecisionDeny, "", ErrorConditionEvaluationNotSupported
}
// RuleResolver provides a mechanism for resolving the list of rules that apply to a given user within a namespace.
type RuleResolver interface {
// RulesFor get the list of cluster wide rules, the list of rules in the specific namespace, incomplete status and errors.
@ -192,6 +216,8 @@ func (a AttributesRecord) GetLabelSelector() (labels.Requirements, error) {
return a.LabelSelectorRequirements, a.LabelSelectorParsingErr
}
// Decision represents an final, unconditional authorization decision.
// The zero value (0) of Decision is DecisionDeny.
type Decision int
const (
@ -200,7 +226,8 @@ const (
// DecisionAllow means that an authorizer decided to allow the action.
DecisionAllow
// DecisionNoOpinion means that an authorizer has no opinion on whether
// to allow or deny an action.
// to allow or deny an action. If there are multiple unioned authorizers,
// this means that the request can thus get allowed by some later authorizer.
DecisionNoOpinion
)