diff --git a/staging/src/k8s.io/apiserver/pkg/authorization/authorizer/conditions.go b/staging/src/k8s.io/apiserver/pkg/authorization/authorizer/conditions.go new file mode 100644 index 00000000000..58b84b7b9da --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/authorization/authorizer/conditions.go @@ -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 { +} diff --git a/staging/src/k8s.io/apiserver/pkg/authorization/authorizer/conditions_test.go b/staging/src/k8s.io/apiserver/pkg/authorization/authorizer/conditions_test.go new file mode 100644 index 00000000000..e5223f4e0a6 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/authorization/authorizer/conditions_test.go @@ -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) + } + }) + } + }) + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/authorization/authorizer/interfaces.go b/staging/src/k8s.io/apiserver/pkg/authorization/authorizer/interfaces.go index f4763df1c9a..14d77c4f1a8 100644 --- a/staging/src/k8s.io/apiserver/pkg/authorization/authorizer/interfaces.go +++ b/staging/src/k8s.io/apiserver/pkg/authorization/authorizer/interfaces.go @@ -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 )