Merge pull request #121705 from liggitt/authz-config-webhook-test

Add multi-webhook integration test
This commit is contained in:
Kubernetes Prow Robot 2023-11-03 01:29:52 +01:00 committed by GitHub
commit fb9c94b3a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 404 additions and 33 deletions

View File

@ -118,11 +118,21 @@ func (config Config) New() (authorizer.Authorizer, authorizer.RuleResolver, erro
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
var decisionOnError authorizer.Decision
switch configuredAuthorizer.Webhook.FailurePolicy {
case authzconfig.FailurePolicyNoOpinion:
decisionOnError = authorizer.DecisionNoOpinion
case authzconfig.FailurePolicyDeny:
decisionOnError = authorizer.DecisionDeny
default:
return nil, nil, fmt.Errorf("unknown failurePolicy %q", configuredAuthorizer.Webhook.FailurePolicy)
}
webhookAuthorizer, err := webhook.New(clientConfig, webhookAuthorizer, err := webhook.New(clientConfig,
configuredAuthorizer.Webhook.SubjectAccessReviewVersion, configuredAuthorizer.Webhook.SubjectAccessReviewVersion,
configuredAuthorizer.Webhook.AuthorizedTTL.Duration, configuredAuthorizer.Webhook.AuthorizedTTL.Duration,
configuredAuthorizer.Webhook.UnauthorizedTTL.Duration, configuredAuthorizer.Webhook.UnauthorizedTTL.Duration,
*config.WebhookRetryBackoff, *config.WebhookRetryBackoff,
decisionOnError,
configuredAuthorizer.Webhook.MatchConditions, configuredAuthorizer.Webhook.MatchConditions,
) )
if err != nil { if err != nil {

View File

@ -512,7 +512,9 @@ func ValidateWebhookConfiguration(fldPath *field.Path, c *api.WebhookConfigurati
switch c.MatchConditionSubjectAccessReviewVersion { switch c.MatchConditionSubjectAccessReviewVersion {
case "": case "":
allErrs = append(allErrs, field.Required(fldPath.Child("matchConditionSubjectAccessReviewVersion"), "")) if len(c.MatchConditions) > 0 {
allErrs = append(allErrs, field.Required(fldPath.Child("matchConditionSubjectAccessReviewVersion"), "required if match conditions are specified"))
}
case "v1": case "v1":
_ = &v1.SubjectAccessReview{} _ = &v1.SubjectAccessReview{}
default: default:

View File

@ -1438,6 +1438,7 @@ func TestValidateAuthorizationConfiguration(t *testing.T) {
ConnectionInfo: api.WebhookConnectionInfo{ ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig", Type: "InClusterConfig",
}, },
MatchConditions: []api.WebhookMatchCondition{{Expression: "true"}},
}, },
}, },
}, },

View File

@ -54,6 +54,7 @@ func (c DelegatingAuthorizerConfig) New() (authorizer.Authorizer, error) {
c.AllowCacheTTL, c.AllowCacheTTL,
c.DenyCacheTTL, c.DenyCacheTTL,
*c.WebhookRetryBackoff, *c.WebhookRetryBackoff,
authorizer.DecisionNoOpinion,
webhook.AuthorizerMetrics{ webhook.AuthorizerMetrics{
RecordRequestTotal: RecordRequestTotal, RecordRequestTotal: RecordRequestTotal,
RecordRequestLatency: RecordRequestLatency, RecordRequestLatency: RecordRequestLatency,

View File

@ -22,6 +22,7 @@ import (
"github.com/google/cel-go/cel" "github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/common/types/ref"
authorizationv1 "k8s.io/api/authorization/v1"
"k8s.io/apimachinery/pkg/util/version" "k8s.io/apimachinery/pkg/util/version"
apiservercel "k8s.io/apiserver/pkg/cel" apiservercel "k8s.io/apiserver/pkg/cel"
"k8s.io/apiserver/pkg/cel/environment" "k8s.io/apiserver/pkg/cel/environment"
@ -143,6 +144,7 @@ func mustBuildEnv(baseEnv *environment.EnvSet) *environment.EnvSet {
} }
// buildRequestType generates a DeclType for SubjectAccessReviewSpec. // buildRequestType generates a DeclType for SubjectAccessReviewSpec.
// if attributes are added here, also add to convertObjectToUnstructured.
func buildRequestType(field func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField, fields func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField) *apiservercel.DeclType { func buildRequestType(field func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField, fields func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField) *apiservercel.DeclType {
resourceAttributesType := buildResourceAttributesType(field, fields) resourceAttributesType := buildResourceAttributesType(field, fields)
nonResourceAttributesType := buildNonResourceAttributesType(field, fields) nonResourceAttributesType := buildNonResourceAttributesType(field, fields)
@ -157,6 +159,7 @@ func buildRequestType(field func(name string, declType *apiservercel.DeclType, r
} }
// buildResourceAttributesType generates a DeclType for ResourceAttributes. // buildResourceAttributesType generates a DeclType for ResourceAttributes.
// if attributes are added here, also add to convertObjectToUnstructured.
func buildResourceAttributesType(field func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField, fields func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField) *apiservercel.DeclType { func buildResourceAttributesType(field func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField, fields func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField) *apiservercel.DeclType {
return apiservercel.NewObjectType("kubernetes.ResourceAttributes", fields( return apiservercel.NewObjectType("kubernetes.ResourceAttributes", fields(
field("namespace", apiservercel.StringType, false), field("namespace", apiservercel.StringType, false),
@ -170,9 +173,42 @@ func buildResourceAttributesType(field func(name string, declType *apiservercel.
} }
// buildNonResourceAttributesType generates a DeclType for NonResourceAttributes. // buildNonResourceAttributesType generates a DeclType for NonResourceAttributes.
// if attributes are added here, also add to convertObjectToUnstructured.
func buildNonResourceAttributesType(field func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField, fields func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField) *apiservercel.DeclType { func buildNonResourceAttributesType(field func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField, fields func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField) *apiservercel.DeclType {
return apiservercel.NewObjectType("kubernetes.NonResourceAttributes", fields( return apiservercel.NewObjectType("kubernetes.NonResourceAttributes", fields(
field("path", apiservercel.StringType, false), field("path", apiservercel.StringType, false),
field("verb", apiservercel.StringType, false), field("verb", apiservercel.StringType, false),
)) ))
} }
func convertObjectToUnstructured(obj *authorizationv1.SubjectAccessReviewSpec) map[string]interface{} {
// Construct version containing every SubjectAccessReview user and string attribute field, even omitempty ones, for evaluation by CEL
extra := obj.Extra
if extra == nil {
extra = map[string]authorizationv1.ExtraValue{}
}
ret := map[string]interface{}{
"user": obj.User,
"groups": obj.Groups,
"uid": string(obj.UID),
"extra": extra,
}
if obj.ResourceAttributes != nil {
ret["resourceAttributes"] = map[string]string{
"namespace": obj.ResourceAttributes.Namespace,
"verb": obj.ResourceAttributes.Verb,
"group": obj.ResourceAttributes.Group,
"version": obj.ResourceAttributes.Version,
"resource": obj.ResourceAttributes.Resource,
"subresource": obj.ResourceAttributes.Subresource,
"name": obj.ResourceAttributes.Name,
}
}
if obj.NonResourceAttributes != nil {
ret["nonResourceAttributes"] = map[string]string{
"verb": obj.NonResourceAttributes.Verb,
"path": obj.NonResourceAttributes.Path,
}
}
return ret
}

View File

@ -21,8 +21,8 @@ import (
"fmt" "fmt"
celgo "github.com/google/cel-go/cel" celgo "github.com/google/cel-go/cel"
authorizationv1 "k8s.io/api/authorization/v1" authorizationv1 "k8s.io/api/authorization/v1"
"k8s.io/apimachinery/pkg/runtime"
utilerrors "k8s.io/apimachinery/pkg/util/errors" utilerrors "k8s.io/apimachinery/pkg/util/errors"
) )
@ -33,12 +33,8 @@ type CELMatcher struct {
// eval evaluates the given SubjectAccessReview against all cel matchCondition expression // eval evaluates the given SubjectAccessReview against all cel matchCondition expression
func (c *CELMatcher) Eval(ctx context.Context, r *authorizationv1.SubjectAccessReview) (bool, error) { func (c *CELMatcher) Eval(ctx context.Context, r *authorizationv1.SubjectAccessReview) (bool, error) {
var evalErrors []error var evalErrors []error
specValObject, err := convertObjectToUnstructured(&r.Spec)
if err != nil {
return false, fmt.Errorf("authz celMatcher eval error: convert SubjectAccessReviewSpec object to unstructured failed: %w", err)
}
va := map[string]interface{}{ va := map[string]interface{}{
"request": specValObject, "request": convertObjectToUnstructured(&r.Spec),
} }
for _, compilationResult := range c.CompilationResults { for _, compilationResult := range c.CompilationResults {
evalResult, _, err := compilationResult.Program.ContextEval(ctx, va) evalResult, _, err := compilationResult.Program.ContextEval(ctx, va)
@ -68,14 +64,3 @@ func (c *CELMatcher) Eval(ctx context.Context, r *authorizationv1.SubjectAccessR
// return ALL matchConditions evaluate to TRUE successfully without error // return ALL matchConditions evaluate to TRUE successfully without error
return true, nil return true, nil
} }
func convertObjectToUnstructured(obj *authorizationv1.SubjectAccessReviewSpec) (map[string]interface{}, error) {
if obj == nil {
return nil, nil
}
ret, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
if err != nil {
return nil, err
}
return ret, nil
}

View File

@ -75,8 +75,8 @@ type WebhookAuthorizer struct {
} }
// NewFromInterface creates a WebhookAuthorizer using the given subjectAccessReview client // NewFromInterface creates a WebhookAuthorizer using the given subjectAccessReview client
func NewFromInterface(subjectAccessReview authorizationv1client.AuthorizationV1Interface, authorizedTTL, unauthorizedTTL time.Duration, retryBackoff wait.Backoff, metrics AuthorizerMetrics) (*WebhookAuthorizer, error) { func NewFromInterface(subjectAccessReview authorizationv1client.AuthorizationV1Interface, authorizedTTL, unauthorizedTTL time.Duration, retryBackoff wait.Backoff, decisionOnError authorizer.Decision, metrics AuthorizerMetrics) (*WebhookAuthorizer, error) {
return newWithBackoff(&subjectAccessReviewV1Client{subjectAccessReview.RESTClient()}, authorizedTTL, unauthorizedTTL, retryBackoff, nil, metrics) return newWithBackoff(&subjectAccessReviewV1Client{subjectAccessReview.RESTClient()}, authorizedTTL, unauthorizedTTL, retryBackoff, decisionOnError, nil, metrics)
} }
// New creates a new WebhookAuthorizer from the provided kubeconfig file. // New creates a new WebhookAuthorizer from the provided kubeconfig file.
@ -98,19 +98,19 @@ func NewFromInterface(subjectAccessReview authorizationv1client.AuthorizationV1I
// //
// For additional HTTP configuration, refer to the kubeconfig documentation // For additional HTTP configuration, refer to the kubeconfig documentation
// https://kubernetes.io/docs/user-guide/kubeconfig-file/. // https://kubernetes.io/docs/user-guide/kubeconfig-file/.
func New(config *rest.Config, version string, authorizedTTL, unauthorizedTTL time.Duration, retryBackoff wait.Backoff, matchConditions []apiserver.WebhookMatchCondition) (*WebhookAuthorizer, error) { func New(config *rest.Config, version string, authorizedTTL, unauthorizedTTL time.Duration, retryBackoff wait.Backoff, decisionOnError authorizer.Decision, matchConditions []apiserver.WebhookMatchCondition) (*WebhookAuthorizer, error) {
subjectAccessReview, err := subjectAccessReviewInterfaceFromConfig(config, version, retryBackoff) subjectAccessReview, err := subjectAccessReviewInterfaceFromConfig(config, version, retryBackoff)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return newWithBackoff(subjectAccessReview, authorizedTTL, unauthorizedTTL, retryBackoff, matchConditions, AuthorizerMetrics{ return newWithBackoff(subjectAccessReview, authorizedTTL, unauthorizedTTL, retryBackoff, decisionOnError, matchConditions, AuthorizerMetrics{
RecordRequestTotal: noopMetrics{}.RecordRequestTotal, RecordRequestTotal: noopMetrics{}.RecordRequestTotal,
RecordRequestLatency: noopMetrics{}.RecordRequestLatency, RecordRequestLatency: noopMetrics{}.RecordRequestLatency,
}) })
} }
// newWithBackoff allows tests to skip the sleep. // newWithBackoff allows tests to skip the sleep.
func newWithBackoff(subjectAccessReview subjectAccessReviewer, authorizedTTL, unauthorizedTTL time.Duration, retryBackoff wait.Backoff, matchConditions []apiserver.WebhookMatchCondition, metrics AuthorizerMetrics) (*WebhookAuthorizer, error) { func newWithBackoff(subjectAccessReview subjectAccessReviewer, authorizedTTL, unauthorizedTTL time.Duration, retryBackoff wait.Backoff, decisionOnError authorizer.Decision, matchConditions []apiserver.WebhookMatchCondition, metrics AuthorizerMetrics) (*WebhookAuthorizer, error) {
// compile all expressions once in validation and save the results to be used for eval later // compile all expressions once in validation and save the results to be used for eval later
cm, fieldErr := apiservervalidation.ValidateAndCompileMatchConditions(matchConditions) cm, fieldErr := apiservervalidation.ValidateAndCompileMatchConditions(matchConditions)
if err := fieldErr.ToAggregate(); err != nil { if err := fieldErr.ToAggregate(); err != nil {
@ -122,7 +122,7 @@ func newWithBackoff(subjectAccessReview subjectAccessReviewer, authorizedTTL, un
authorizedTTL: authorizedTTL, authorizedTTL: authorizedTTL,
unauthorizedTTL: unauthorizedTTL, unauthorizedTTL: unauthorizedTTL,
retryBackoff: retryBackoff, retryBackoff: retryBackoff,
decisionOnError: authorizer.DecisionNoOpinion, decisionOnError: decisionOnError,
metrics: metrics, metrics: metrics,
celMatcher: cm, celMatcher: cm,
}, nil }, nil

View File

@ -37,6 +37,7 @@ import (
utiltesting "k8s.io/client-go/util/testing" utiltesting "k8s.io/client-go/util/testing"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
authorizationv1 "k8s.io/api/authorization/v1" authorizationv1 "k8s.io/api/authorization/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait" "k8s.io/apimachinery/pkg/util/wait"
@ -209,7 +210,7 @@ current-context: default
if err != nil { if err != nil {
return fmt.Errorf("error building sar client: %v", err) return fmt.Errorf("error building sar client: %v", err)
} }
_, err = newWithBackoff(sarClient, 0, 0, testRetryBackoff, []apiserver.WebhookMatchCondition{}, noopAuthorizerMetrics()) _, err = newWithBackoff(sarClient, 0, 0, testRetryBackoff, authorizer.DecisionNoOpinion, []apiserver.WebhookMatchCondition{}, noopAuthorizerMetrics())
return err return err
}() }()
if err != nil && !tt.wantErr { if err != nil && !tt.wantErr {
@ -352,7 +353,7 @@ func newV1Authorizer(callbackURL string, clientCert, clientKey, ca []byte, cache
if err != nil { if err != nil {
return nil, fmt.Errorf("error building sar client: %v", err) return nil, fmt.Errorf("error building sar client: %v", err)
} }
return newWithBackoff(sarClient, cacheTime, cacheTime, testRetryBackoff, expressions, metrics) return newWithBackoff(sarClient, cacheTime, cacheTime, testRetryBackoff, authorizer.DecisionNoOpinion, expressions, metrics)
} }
func TestV1TLSConfig(t *testing.T) { func TestV1TLSConfig(t *testing.T) {
@ -703,6 +704,7 @@ func TestStructuredAuthzConfigFeatureEnablement(t *testing.T) {
Name: "alice", Name: "alice",
UID: "1", UID: "1",
Groups: []string{"group1", "group2"}, Groups: []string{"group1", "group2"},
Extra: map[string][]string{"key1": {"a", "b", "c"}},
}, },
ResourceRequest: true, ResourceRequest: true,
Namespace: "kittensandponies", Namespace: "kittensandponies",
@ -797,6 +799,7 @@ func TestV1WebhookMatchConditions(t *testing.T) {
Name: "alice", Name: "alice",
UID: "1", UID: "1",
Groups: []string{"group1", "group2"}, Groups: []string{"group1", "group2"},
Extra: map[string][]string{"key1": {"a", "b", "c"}},
}, },
ResourceRequest: true, ResourceRequest: true,
Namespace: "kittensandponies", Namespace: "kittensandponies",
@ -847,6 +850,18 @@ func TestV1WebhookMatchConditions(t *testing.T) {
{ {
Expression: "('group1' in request.groups)", Expression: "('group1' in request.groups)",
}, },
{
Expression: "('key1' in request.extra)",
},
{
Expression: "!('key2' in request.extra)",
},
{
Expression: "('a' in request.extra['key1'])",
},
{
Expression: "!('z' in request.extra['key1'])",
},
{ {
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kittensandponies'", Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kittensandponies'",
}, },
@ -1027,13 +1042,10 @@ func TestV1WebhookMatchConditions(t *testing.T) {
expectedCompileErr: "", expectedCompileErr: "",
// default decisionOnError in newWithBackoff to skip // default decisionOnError in newWithBackoff to skip
expectedDecision: authorizer.DecisionNoOpinion, expectedDecision: authorizer.DecisionNoOpinion,
expectedEvalErr: "[cel evaluation error: expression 'request.user == 'bob'' resulted in error: no such key: user, cel evaluation error: expression 'has(request.nonResourceAttributes) && request.nonResourceAttributes.verb == 'get'' resulted in error: no such key: verb]", expectedEvalErr: "cel evaluation error: expression 'request.resourceAttributes.verb == 'get'' resulted in error: no such key: resourceAttributes",
expressions: []apiserver.WebhookMatchCondition{ expressions: []apiserver.WebhookMatchCondition{
{ {
Expression: "request.user == 'bob'", Expression: "request.resourceAttributes.verb == 'get'",
},
{
Expression: "has(request.nonResourceAttributes) && request.nonResourceAttributes.verb == 'get'",
}, },
}, },
}, },

View File

@ -35,6 +35,7 @@ import (
"time" "time"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
authorizationv1beta1 "k8s.io/api/authorization/v1beta1" authorizationv1beta1 "k8s.io/api/authorization/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
authzconfig "k8s.io/apiserver/pkg/apis/apiserver" authzconfig "k8s.io/apiserver/pkg/apis/apiserver"
@ -196,7 +197,7 @@ current-context: default
if err != nil { if err != nil {
return fmt.Errorf("error building sar client: %v", err) return fmt.Errorf("error building sar client: %v", err)
} }
_, err = newWithBackoff(sarClient, 0, 0, testRetryBackoff, []authzconfig.WebhookMatchCondition{}, noopAuthorizerMetrics()) _, err = newWithBackoff(sarClient, 0, 0, testRetryBackoff, authorizer.DecisionNoOpinion, []authzconfig.WebhookMatchCondition{}, noopAuthorizerMetrics())
return err return err
}() }()
if err != nil && !tt.wantErr { if err != nil && !tt.wantErr {
@ -339,7 +340,7 @@ func newV1beta1Authorizer(callbackURL string, clientCert, clientKey, ca []byte,
if err != nil { if err != nil {
return nil, fmt.Errorf("error building sar client: %v", err) return nil, fmt.Errorf("error building sar client: %v", err)
} }
return newWithBackoff(sarClient, cacheTime, cacheTime, testRetryBackoff, []authzconfig.WebhookMatchCondition{}, noopAuthorizerMetrics()) return newWithBackoff(sarClient, cacheTime, cacheTime, testRetryBackoff, authorizer.DecisionNoOpinion, []authzconfig.WebhookMatchCondition{}, noopAuthorizerMetrics())
} }
func TestV1beta1TLSConfig(t *testing.T) { func TestV1beta1TLSConfig(t *testing.T) {

View File

@ -18,9 +18,15 @@ package auth
import ( import (
"context" "context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"sync/atomic"
"testing" "testing"
"time"
authorizationv1 "k8s.io/api/authorization/v1" authorizationv1 "k8s.io/api/authorization/v1"
rbacv1 "k8s.io/api/rbac/v1" rbacv1 "k8s.io/api/rbac/v1"
@ -93,3 +99,320 @@ authorizers:
t.Fatal("expected allowed, got denied") t.Fatal("expected allowed, got denied")
} }
} }
func TestMultiWebhookAuthzConfig(t *testing.T) {
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthorizationConfiguration, true)()
dir := t.TempDir()
kubeconfigTemplate := `
apiVersion: v1
kind: Config
clusters:
- name: integration
cluster:
server: %q
insecure-skip-tls-verify: true
contexts:
- name: default-context
context:
cluster: integration
user: test
current-context: default-context
users:
- name: test
`
// returns malformed responses when called
serverErrorCalled := atomic.Int32{}
serverError := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
serverErrorCalled.Add(1)
sar := &authorizationv1.SubjectAccessReview{}
if err := json.NewDecoder(req.Body).Decode(sar); err != nil {
t.Error(err)
}
t.Log("serverError", sar)
if _, err := w.Write([]byte(`error response`)); err != nil {
t.Error(err)
}
}))
defer serverError.Close()
serverErrorKubeconfigName := filepath.Join(dir, "serverError.yaml")
if err := os.WriteFile(serverErrorKubeconfigName, []byte(fmt.Sprintf(kubeconfigTemplate, serverError.URL)), os.FileMode(0644)); err != nil {
t.Fatal(err)
}
// hangs for 2 seconds when called
serverTimeoutCalled := atomic.Int32{}
serverTimeout := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
serverTimeoutCalled.Add(1)
sar := &authorizationv1.SubjectAccessReview{}
if err := json.NewDecoder(req.Body).Decode(sar); err != nil {
t.Error(err)
}
t.Log("serverTimeout", sar)
time.Sleep(2 * time.Second)
}))
defer serverTimeout.Close()
serverTimeoutKubeconfigName := filepath.Join(dir, "serverTimeout.yaml")
if err := os.WriteFile(serverTimeoutKubeconfigName, []byte(fmt.Sprintf(kubeconfigTemplate, serverTimeout.URL)), os.FileMode(0644)); err != nil {
t.Fatal(err)
}
// returns a deny response when called
serverDenyCalled := atomic.Int32{}
serverDeny := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
serverDenyCalled.Add(1)
sar := &authorizationv1.SubjectAccessReview{}
if err := json.NewDecoder(req.Body).Decode(sar); err != nil {
t.Error(err)
}
t.Log("serverDeny", sar)
sar.Status.Allowed = false
sar.Status.Denied = true
sar.Status.Reason = "denied by webhook"
if err := json.NewEncoder(w).Encode(sar); err != nil {
t.Error(err)
}
}))
defer serverDeny.Close()
serverDenyKubeconfigName := filepath.Join(dir, "serverDeny.yaml")
if err := os.WriteFile(serverDenyKubeconfigName, []byte(fmt.Sprintf(kubeconfigTemplate, serverDeny.URL)), os.FileMode(0644)); err != nil {
t.Fatal(err)
}
// returns a no opinion response when called
serverNoOpinionCalled := atomic.Int32{}
serverNoOpinion := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
serverNoOpinionCalled.Add(1)
sar := &authorizationv1.SubjectAccessReview{}
if err := json.NewDecoder(req.Body).Decode(sar); err != nil {
t.Error(err)
}
t.Log("serverNoOpinion", sar)
sar.Status.Allowed = false
sar.Status.Denied = false
if err := json.NewEncoder(w).Encode(sar); err != nil {
t.Error(err)
}
}))
defer serverNoOpinion.Close()
serverNoOpinionKubeconfigName := filepath.Join(dir, "serverNoOpinion.yaml")
if err := os.WriteFile(serverNoOpinionKubeconfigName, []byte(fmt.Sprintf(kubeconfigTemplate, serverNoOpinion.URL)), os.FileMode(0644)); err != nil {
t.Fatal(err)
}
// returns an allow response when called
serverAllowCalled := atomic.Int32{}
serverAllow := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
serverAllowCalled.Add(1)
sar := &authorizationv1.SubjectAccessReview{}
if err := json.NewDecoder(req.Body).Decode(sar); err != nil {
t.Error(err)
}
t.Log("serverAllow", sar)
sar.Status.Allowed = true
sar.Status.Reason = "allowed by webhook"
if err := json.NewEncoder(w).Encode(sar); err != nil {
t.Error(err)
}
}))
defer serverAllow.Close()
serverAllowKubeconfigName := filepath.Join(dir, "serverAllow.yaml")
if err := os.WriteFile(serverAllowKubeconfigName, []byte(fmt.Sprintf(kubeconfigTemplate, serverAllow.URL)), os.FileMode(0644)); err != nil {
t.Fatal(err)
}
resetCounts := func() {
serverErrorCalled.Store(0)
serverTimeoutCalled.Store(0)
serverDenyCalled.Store(0)
serverNoOpinionCalled.Store(0)
serverAllowCalled.Store(0)
}
assertCounts := func(errorCount, timeoutCount, denyCount, noOpinionCount, allowCount int32) {
t.Helper()
if e, a := errorCount, serverErrorCalled.Load(); e != a {
t.Errorf("expected fail webhook calls: %d, got %d", e, a)
}
if e, a := timeoutCount, serverTimeoutCalled.Load(); e != a {
t.Errorf("expected timeout webhook calls: %d, got %d", e, a)
}
if e, a := denyCount, serverDenyCalled.Load(); e != a {
t.Errorf("expected deny webhook calls: %d, got %d", e, a)
}
if e, a := noOpinionCount, serverNoOpinionCalled.Load(); e != a {
t.Errorf("expected noOpinion webhook calls: %d, got %d", e, a)
}
if e, a := allowCount, serverAllowCalled.Load(); e != a {
t.Errorf("expected allow webhook calls: %d, got %d", e, a)
}
resetCounts()
}
configFileName := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(configFileName, []byte(`
apiVersion: apiserver.config.k8s.io/v1alpha1
kind: AuthorizationConfiguration
authorizers:
- type: Webhook
name: error.example.com
webhook:
timeout: 5s
failurePolicy: Deny
subjectAccessReviewVersion: v1
matchConditionSubjectAccessReviewVersion: v1
connectionInfo:
type: KubeConfigFile
kubeConfigFile: `+serverErrorKubeconfigName+`
matchConditions:
- expression: has(request.resourceAttributes)
- expression: 'request.resourceAttributes.namespace == "fail"'
- expression: 'request.resourceAttributes.name == "error"'
- type: Webhook
name: timeout.example.com
webhook:
timeout: 1s
failurePolicy: Deny
subjectAccessReviewVersion: v1
matchConditionSubjectAccessReviewVersion: v1
connectionInfo:
type: KubeConfigFile
kubeConfigFile: `+serverTimeoutKubeconfigName+`
matchConditions:
- expression: has(request.resourceAttributes)
- expression: 'request.resourceAttributes.namespace == "fail"'
- expression: 'request.resourceAttributes.name == "timeout"'
- type: Webhook
name: deny.example.com
webhook:
timeout: 5s
failurePolicy: NoOpinion
subjectAccessReviewVersion: v1
matchConditionSubjectAccessReviewVersion: v1
connectionInfo:
type: KubeConfigFile
kubeConfigFile: `+serverDenyKubeconfigName+`
matchConditions:
- expression: has(request.resourceAttributes)
- expression: 'request.resourceAttributes.namespace == "fail"'
- type: Webhook
name: noopinion.example.com
webhook:
timeout: 5s
failurePolicy: Deny
subjectAccessReviewVersion: v1
connectionInfo:
type: KubeConfigFile
kubeConfigFile: `+serverNoOpinionKubeconfigName+`
- type: Webhook
name: allow.example.com
webhook:
timeout: 5s
failurePolicy: Deny
subjectAccessReviewVersion: v1
connectionInfo:
type: KubeConfigFile
kubeConfigFile: `+serverAllowKubeconfigName+`
`), os.FileMode(0644)); err != nil {
t.Fatal(err)
}
server := kubeapiservertesting.StartTestServerOrDie(
t,
nil,
[]string{"--authorization-config=" + configFileName},
framework.SharedEtcd(),
)
t.Cleanup(server.TearDownFn)
adminClient := clientset.NewForConfigOrDie(server.ClientConfig)
// malformed webhook short circuits
t.Log("checking error")
if result, err := adminClient.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), &authorizationv1.SubjectAccessReview{Spec: authorizationv1.SubjectAccessReviewSpec{
User: "alice",
ResourceAttributes: &authorizationv1.ResourceAttributes{
Verb: "get",
Group: "",
Version: "v1",
Resource: "configmaps",
Namespace: "fail",
Name: "error",
},
}}, metav1.CreateOptions{}); err != nil {
t.Fatal(err)
} else if result.Status.Allowed {
t.Fatal("expected denied, got allowed")
} else {
t.Log(result.Status.Reason)
assertCounts(1, 0, 0, 0, 0)
}
// timeout webhook short circuits
t.Log("checking timeout")
if result, err := adminClient.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), &authorizationv1.SubjectAccessReview{Spec: authorizationv1.SubjectAccessReviewSpec{
User: "alice",
ResourceAttributes: &authorizationv1.ResourceAttributes{
Verb: "get",
Group: "",
Version: "v1",
Resource: "configmaps",
Namespace: "fail",
Name: "timeout",
},
}}, metav1.CreateOptions{}); err != nil {
t.Fatal(err)
} else if result.Status.Allowed {
t.Fatal("expected denied, got allowed")
} else {
t.Log(result.Status.Reason)
assertCounts(0, 1, 0, 0, 0)
}
// deny webhook short circuits
t.Log("checking deny")
if result, err := adminClient.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), &authorizationv1.SubjectAccessReview{Spec: authorizationv1.SubjectAccessReviewSpec{
User: "alice",
ResourceAttributes: &authorizationv1.ResourceAttributes{
Verb: "list",
Group: "",
Version: "v1",
Resource: "configmaps",
Namespace: "fail",
Name: "",
},
}}, metav1.CreateOptions{}); err != nil {
t.Fatal(err)
} else if result.Status.Allowed {
t.Fatal("expected denied, got allowed")
} else {
t.Log(result.Status.Reason)
assertCounts(0, 0, 1, 0, 0)
}
// no-opinion webhook passes through, allow webhook allows
t.Log("checking allow")
if result, err := adminClient.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), &authorizationv1.SubjectAccessReview{Spec: authorizationv1.SubjectAccessReviewSpec{
User: "alice",
ResourceAttributes: &authorizationv1.ResourceAttributes{
Verb: "list",
Group: "",
Version: "v1",
Resource: "configmaps",
Namespace: "allow",
Name: "",
},
}}, metav1.CreateOptions{}); err != nil {
t.Fatal(err)
} else if !result.Status.Allowed {
t.Fatal("expected allowed, got denied")
} else {
t.Log(result.Status.Reason)
assertCounts(0, 0, 0, 1, 1)
}
}