Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ authorizer: allow configuring the order of authorizers #3281

Merged
merged 9 commits into from
Feb 7, 2025
213 changes: 127 additions & 86 deletions pkg/server/options/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ package options

import (
"context"
"fmt"

kcpkubernetesinformers "github.com/kcp-dev/client-go/informers"
"github.com/spf13/pflag"

"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/authorization/authorizerfactory"
Expand All @@ -48,15 +50,37 @@ type Authorization struct {
// Webhook contains flags to enable an external HTTPS webhook to perform
// authorization against. Note that not all built-in options are supported by kcp.
Webhook *kubeoptions.BuiltInAuthorizationOptions

// AuthorizationOrder is the list of authorizers that allows to rearrange the default order.
// The default is four authorizers in a union: AlwaysAllowGroups, AlwaysAllowPaths, Webhook and RBAC.
AuthorizationOrder []string
}

const (
// authorizerAlwaysAllowGroups is used to authorize one of the configured always-allow groups, by default system:masters.
authorizerAlwaysAllowGroups string = "AlwaysAllowGroups"
// authorizerAlwaysAllowPaths is used to authorize one of the preconfigured paths that do not require authorization, like /healthz, /readyz and /livez.
authorizerAlwaysAllowPaths string = "AlwaysAllowPaths"
// authorizerWebhook is the authorizer to make an external webhook call.
authorizerWebhook string = "Webhook"
// authorizerRBAC is the authorizer to use Role Based Access Control.
authorizerRBAC string = "RBAC"
)

var defaultAuthorizers = []string{authorizerAlwaysAllowGroups, authorizerAlwaysAllowPaths, authorizerWebhook, authorizerRBAC}

func isValidAuthorizer(authorizer string) bool {
return sets.NewString(defaultAuthorizers...).Has(authorizer)
}

func NewAuthorization() *Authorization {
return &Authorization{
// This allows the kubelet to always get health and readiness without causing an authorization check.
// This field can be cleared by callers if they don't want this behavior.
AlwaysAllowPaths: []string{"/healthz", "/readyz", "/livez"},
AlwaysAllowGroups: []string{user.SystemPrivilegedGroup},
Webhook: kubeoptions.NewBuiltInAuthorizationOptions(),
AlwaysAllowPaths: []string{"/healthz", "/readyz", "/livez"},
AlwaysAllowGroups: []string{user.SystemPrivilegedGroup},
Webhook: kubeoptions.NewBuiltInAuthorizationOptions(),
AuthorizationOrder: defaultAuthorizers,
}
}

Expand Down Expand Up @@ -101,6 +125,14 @@ func (s *Authorization) Validate() []error {
}
}

if len(s.AuthorizationOrder) > 0 {
for _, authz := range s.AuthorizationOrder {
if !isValidAuthorizer(authz) {
allErrors = append(allErrors, fmt.Errorf("invalid authorizer: %q", authz))
}
}
}

return allErrors
}

Expand All @@ -113,6 +145,10 @@ func (s *Authorization) AddFlags(fs *pflag.FlagSet) {
"A list of HTTP paths to skip during authorization, i.e. these are authorized without "+
"contacting the 'core' kubernetes server.")

fs.StringSliceVar(&s.AuthorizationOrder, "authorization-order", s.AuthorizationOrder,
"A list of authorizers that should be enabled, allowing administrator rearrange the default order."+
"The default order is: AlwaysAllowGroups, AlwaysAllowPaths, Webhook, RBAC")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the kube variant of that flag? Doesn't that also exist?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would have expected --authorization-order, if we are not following some kube legacy here. Hence the question whether we do.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kube's flag is --authorization-mode.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initially went with the issue proposed --authorization-steps, however modes or types feels much better in my opinion

Copy link
Contributor Author

@cnvergence cnvergence Feb 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻 for --authorization-order


// Only surface selected, webhook-related CLI flags

fs.StringVar(&s.Webhook.WebhookConfigFile, "authorization-webhook-config-file", s.Webhook.WebhookConfigFile,
Expand All @@ -134,93 +170,98 @@ func (s *Authorization) ApplyTo(ctx context.Context, config *genericapiserver.Co
localLogicalClusterLister := kcpInformers.Core().V1alpha1().LogicalClusters().Lister()
globalLogicalClusterLister := globalKcpInformers.Core().V1alpha1().LogicalClusters().Lister()

// group authorizer
if len(s.AlwaysAllowGroups) > 0 {
privGroups := authorizerfactory.NewPrivilegedGroups(s.AlwaysAllowGroups...)
authorizers = append(authorizers, privGroups)
}

// path authorizer
if len(s.AlwaysAllowPaths) > 0 {
a, err := path.NewAuthorizer(s.AlwaysAllowPaths)
if err != nil {
return err
}
authorizers = append(authorizers, a)
}

// Re-use the authorizer from the generic control plane (this is only set for webhooks);
// make sure this is added *after* the alwaysAllow* authorizers, or else the webhook could prevent
// healthcheck endpoints from working.
// NB: Due to the inner workings of Kubernetes' webhook authorizer, this authorizer will actually
// always be a union of a privilegedGroupAuthorizer for system:masters and the webhook itself,
// ensuring the webhook isn't called for that privileged group.
if webhook := s.Webhook; webhook != nil && webhook.WebhookConfigFile != "" {
authorizationConfig, err := webhook.ToAuthorizationConfig(informerfactoryhack.Wrap(kubeInformers))
if err != nil {
return err
}

if config.EgressSelector != nil {
egressDialer, err := config.EgressSelector.Lookup(egressselector.ControlPlane.AsNetworkContext())
if err != nil {
return err
for _, authorizer := range s.AuthorizationOrder {
switch authorizer {
case authorizerAlwaysAllowGroups:
// group authorizer
if len(s.AlwaysAllowGroups) > 0 {
privGroups := authorizerfactory.NewPrivilegedGroups(s.AlwaysAllowGroups...)
authorizers = append(authorizers, privGroups)
}
authorizationConfig.CustomDial = egressDialer
}

authorizer, _, err := authorizationConfig.New(ctx, config.APIServerID)
if err != nil {
return err
case authorizerAlwaysAllowPaths:
// path authorizer
if len(s.AlwaysAllowPaths) > 0 {
a, err := path.NewAuthorizer(s.AlwaysAllowPaths)
if err != nil {
return err
}
authorizers = append(authorizers, a)
}
case authorizerRBAC:
// kcp authorizers, these are evaluated in reverse order
// TODO: link the markdown

// bootstrap rules defined once for every workspace
bootstrapAuth, bootstrapRules := authz.NewBootstrapPolicyAuthorizer(kubeInformers)
bootstrapAuth = authz.NewDecorator("05-bootstrap", bootstrapAuth).AddAuditLogging().AddAnonymization().AddReasonAnnotation()

// resolves RBAC resources in the workspace
localAuth, localResolver := authz.NewLocalAuthorizer(kubeInformers)
localAuth = authz.NewDecorator("05-local", localAuth).AddAuditLogging().AddAnonymization().AddReasonAnnotation()

globalAuth, _ := authz.NewGlobalAuthorizer(kubeInformers, globalKubeInformers)
globalAuth = authz.NewDecorator("05-global", globalAuth).AddAuditLogging().AddAnonymization().AddReasonAnnotation()

chain := union.New(bootstrapAuth, localAuth, globalAuth)

// everything below - skipped for Deep SAR

// enforce maximal permission policy
chain = authz.NewMaximalPermissionPolicyAuthorizer(kubeInformers, globalKubeInformers, kcpInformers, globalKcpInformers)(chain)
chain = authz.NewDecorator("04-maxpermissionpolicy", chain).AddAuditLogging().AddAnonymization().AddReasonAnnotation()

// protect status updates to apiexport and apibinding
chain = authz.NewSystemCRDAuthorizer(chain)
chain = authz.NewDecorator("03-systemcrd", chain).AddAuditLogging().AddAnonymization().AddReasonAnnotation()

// content auth deteremines if users have access to the workspace itself - by default, in Kube there is a set
// of default permissions given even to system:authenticated (like access to discovery) - this authorizer allows
// kcp to make workspaces entirely invisible to users that have not been given access, by making system:authenticated
// mean nothing unless they also have `verb=access` on `/`
chain = authz.NewWorkspaceContentAuthorizer(kubeInformers, globalKubeInformers, localLogicalClusterLister, globalLogicalClusterLister)(chain)
chain = authz.NewDecorator("02-content", chain).AddAuditLogging().AddAnonymization().AddReasonAnnotation()

// workspaces are annotated to list the groups required on users wishing to access the workspace -
// this is mostly useful when adding a core set of groups to an org workspace and having them inherited
// by child workspaces; this gives administrators of an org control over which users can be given access
// to content in sub-workspaces
chain = authz.NewRequiredGroupsAuthorizer(localLogicalClusterLister, globalLogicalClusterLister)(chain)
chain = authz.NewDecorator("01-requiredgroups", chain).AddAuditLogging().AddAnonymization()
authorizers = append(authorizers, chain)
config.RuleResolver = union.NewRuleResolvers(bootstrapRules, localResolver)
case authorizerWebhook:
// Re-use the authorizer from the generic control plane (this is only set for webhooks);
// make sure this is added *after* the alwaysAllow* authorizers, or else the webhook could prevent
// healthcheck endpoints from working.
// NB: Due to the inner workings of Kubernetes' webhook authorizer, this authorizer will actually
// always be a union of a privilegedGroupAuthorizer for system:masters and the webhook itself,
// ensuring the webhook isn't called for that privileged group.
if webhook := s.Webhook; webhook != nil && webhook.WebhookConfigFile != "" {
authorizationConfig, err := webhook.ToAuthorizationConfig(informerfactoryhack.Wrap(kubeInformers))
if err != nil {
return err
}

if config.EgressSelector != nil {
egressDialer, err := config.EgressSelector.Lookup(egressselector.ControlPlane.AsNetworkContext())
if err != nil {
return err
}
authorizationConfig.CustomDial = egressDialer
}

authorizer, _, err := authorizationConfig.New(ctx, config.APIServerID)
if err != nil {
return err
}
authorizer = authz.WithWarrantsAndScopes(authorizer)
authorizers = append(authorizers, authorizer)
}
default:
return fmt.Errorf("invalid authorizer: %q", authorizer)
}
authorizer = authz.WithWarrantsAndScopes(authorizer)

authorizers = append(authorizers, authorizer)
}

// kcp authorizers, these are evaluated in reverse order
// TODO: link the markdown

// bootstrap rules defined once for every workspace
bootstrapAuth, bootstrapRules := authz.NewBootstrapPolicyAuthorizer(kubeInformers)
bootstrapAuth = authz.NewDecorator("05-bootstrap", bootstrapAuth).AddAuditLogging().AddAnonymization().AddReasonAnnotation()

// resolves RBAC resources in the workspace
localAuth, localResolver := authz.NewLocalAuthorizer(kubeInformers)
localAuth = authz.NewDecorator("05-local", localAuth).AddAuditLogging().AddAnonymization().AddReasonAnnotation()

globalAuth, _ := authz.NewGlobalAuthorizer(kubeInformers, globalKubeInformers)
globalAuth = authz.NewDecorator("05-global", globalAuth).AddAuditLogging().AddAnonymization().AddReasonAnnotation()

chain := union.New(bootstrapAuth, localAuth, globalAuth)

// everything below - skipped for Deep SAR

// enforce maximal permission policy
chain = authz.NewMaximalPermissionPolicyAuthorizer(kubeInformers, globalKubeInformers, kcpInformers, globalKcpInformers)(chain)
chain = authz.NewDecorator("04-maxpermissionpolicy", chain).AddAuditLogging().AddAnonymization().AddReasonAnnotation()

// protect status updates to apiexport and apibinding
chain = authz.NewSystemCRDAuthorizer(chain)
chain = authz.NewDecorator("03-systemcrd", chain).AddAuditLogging().AddAnonymization().AddReasonAnnotation()

// content auth deteremines if users have access to the workspace itself - by default, in Kube there is a set
// of default permissions given even to system:authenticated (like access to discovery) - this authorizer allows
// kcp to make workspaces entirely invisible to users that have not been given access, by making system:authenticated
// mean nothing unless they also have `verb=access` on `/`
chain = authz.NewWorkspaceContentAuthorizer(kubeInformers, globalKubeInformers, localLogicalClusterLister, globalLogicalClusterLister)(chain)
chain = authz.NewDecorator("02-content", chain).AddAuditLogging().AddAnonymization().AddReasonAnnotation()

// workspaces are annotated to list the groups required on users wishing to access the workspace -
// this is mostly useful when adding a core set of groups to an org workspace and having them inherited
// by child workspaces; this gives administrators of an org control over which users can be given access
// to content in sub-workspaces
chain = authz.NewRequiredGroupsAuthorizer(localLogicalClusterLister, globalLogicalClusterLister)(chain)
chain = authz.NewDecorator("01-requiredgroups", chain).AddAuditLogging().AddAnonymization()

authorizers = append(authorizers, chain)

config.RuleResolver = union.NewRuleResolvers(bootstrapRules, localResolver)
config.Authorization.Authorizer = union.New(authorizers...)
return nil
}
99 changes: 99 additions & 0 deletions test/e2e/authorizer/authorizationorder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
Copyright 2025 The KCP 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 (
"context"
"testing"

kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes"
"github.com/kcp-dev/logicalcluster/v3"
"github.com/stretchr/testify/require"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kubernetesscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"

kcpclientset "github.com/kcp-dev/kcp/sdk/client/clientset/versioned/cluster"
"github.com/kcp-dev/kcp/test/e2e/framework"
)

func TestAuthorizationOrder(t *testing.T) {
framework.Suite(t, "control-plane")
webhookPort := "8081"
ctx, cancelFunc := context.WithCancel(context.Background())
t.Cleanup(cancelFunc)
// start a webhook that allows kcp to boot up
webhookStop := RunWebhook(ctx, t, webhookPort, "kubernetes:authz:allow")
t.Cleanup(webhookStop)

server := framework.PrivateKcpServer(t, framework.WithCustomArguments(
"--authorization-order",
"Webhook,AlwaysAllowPaths,AlwaysAllowGroups,RBAC",
"--authorization-webhook-config-file",
"authzorder.kubeconfig",
))

// create clients
kcpConfig := server.BaseConfig(t)
kubeClusterClient, err := kcpkubernetesclientset.NewForConfig(kcpConfig)
require.NoError(t, err, "failed to construct client for server")
kcpClusterClient, err := kcpclientset.NewForConfig(kcpConfig)
require.NoError(t, err, "failed to construct client for server")

// access to health endpoints should not be granted, as webhook is first
// in the order of authorizers and rejects the request
rootShardCfg := server.RootShardSystemMasterBaseConfig(t)
if rootShardCfg.NegotiatedSerializer == nil {
rootShardCfg.NegotiatedSerializer = kubernetesscheme.Codecs.WithoutConversion()
}
// Ensure the request is unauthenticated, as Kubernetes' webhook authorizer is wrapped
// in a reloadable authorizer that also always injects a privilegedGroup authorizer
// that lets system:masters users in.
rootShardCfg.BearerToken = ""
restClient, err := rest.UnversionedRESTClientFor(rootShardCfg)
require.NoError(t, err)

t.Log("Verify that you are allowed to access one of AllowAllPaths endpoints.")
req := rest.NewRequest(restClient).RequestURI("/livez")
t.Logf("%s should not be accessible.", req.URL().String())
_, err = req.Do(ctx).Raw()
require.NoError(t, err)

t.Log("Admin should be allowed now to list Workspaces.")
_, err = kcpClusterClient.Cluster(logicalcluster.NewPath("root")).TenancyV1alpha1().Workspaces().List(ctx, metav1.ListOptions{})
require.NoError(t, err)

webhookStop()
// run the webhook with deny policy
webhookStop = RunWebhook(ctx, t, webhookPort, "kubernetes:authz:deny")
t.Cleanup(webhookStop)

t.Log("Admin should not be allowed now to list Logical clusters.")
_, err = kcpClusterClient.Cluster(logicalcluster.NewPath("root")).CoreV1alpha1().LogicalClusters().List(ctx, metav1.ListOptions{})
require.Error(t, err)

t.Log("Admin should not be allowed to list Services.")
_, err = kubeClusterClient.Cluster(logicalcluster.NewPath("root")).CoreV1().Services("default").List(ctx, metav1.ListOptions{})
require.Error(t, err)

t.Log("Verify that it is not allowed to access AllowAllPaths endpoints.")
req = rest.NewRequest(restClient).RequestURI("/healthz")
t.Logf("%s should not be accessible.", req.URL().String())
_, err = req.Do(ctx).Raw()
require.Error(t, err)
}
12 changes: 12 additions & 0 deletions test/e2e/authorizer/authzorder.kubeconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Config
clusters:
- name: httest
cluster:
certificate-authority: .TestAuthorizationOrder/ca.crt
server: https://localhost:8081/
current-context: webhook
contexts:
- name: webhook
context:
cluster: httest
Loading