diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index a9cd69863..282cb462f 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -304,7 +304,7 @@ func run() error { return err } tokenGetter := authentication.NewTokenGetter(coreClient, authentication.WithExpirationDuration(1*time.Hour)) - clientRestConfigMapper := action.ServiceAccountRestConfigMapper(tokenGetter) + clientRestConfigMapper := action.ClusterExtensionUserRestConfigMapper(tokenGetter) cfgGetter, err := helmclient.NewActionConfigGetter(mgr.GetConfig(), mgr.GetRESTMapper(), helmclient.StorageDriverMapper(action.ChunkedStorageDriverMapper(coreClient, mgr.GetAPIReader(), cfg.systemNamespace)), diff --git a/config/overlays/featuregate/synthetic-user-permissions/kustomization.yaml b/config/overlays/featuregate/synthetic-user-permissions/kustomization.yaml new file mode 100644 index 000000000..01e3a6d0e --- /dev/null +++ b/config/overlays/featuregate/synthetic-user-permissions/kustomization.yaml @@ -0,0 +1,19 @@ +# kustomization file for secure OLMv1 +# DO NOT ADD A NAMESPACE HERE +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../../../base/operator-controller + - ../../../base/common +components: + - ../../../components/tls/operator-controller + +patches: + - target: + kind: Deployment + name: operator-controller-controller-manager + path: patches/enable-featuregate.yaml + - target: + kind: ClusterRole + name: operator-controller-manager-role + path: patches/impersonate-perms.yaml diff --git a/config/overlays/featuregate/synthetic-user-permissions/patches/enable-featuregate.yaml b/config/overlays/featuregate/synthetic-user-permissions/patches/enable-featuregate.yaml new file mode 100644 index 000000000..fb6c84fa4 --- /dev/null +++ b/config/overlays/featuregate/synthetic-user-permissions/patches/enable-featuregate.yaml @@ -0,0 +1,4 @@ +# enable synthetic-user feature gate +- op: add + path: /spec/template/spec/containers/0/args/- + value: "--feature-gates=SyntheticPermissions=true" diff --git a/config/overlays/featuregate/synthetic-user-permissions/patches/impersonate-perms.yaml b/config/overlays/featuregate/synthetic-user-permissions/patches/impersonate-perms.yaml new file mode 100644 index 000000000..f3854ea2a --- /dev/null +++ b/config/overlays/featuregate/synthetic-user-permissions/patches/impersonate-perms.yaml @@ -0,0 +1,11 @@ +# enable synthetic-user feature gate +- op: add + path: /rules/- + value: + apiGroups: + - "" + resources: + - groups + - users + verbs: + - impersonate diff --git a/docs/draft/howto/use-synthetic-permissions.md b/docs/draft/howto/use-synthetic-permissions.md new file mode 100644 index 000000000..15f9c2c20 --- /dev/null +++ b/docs/draft/howto/use-synthetic-permissions.md @@ -0,0 +1,133 @@ +## Synthetic User Permissions + +!!! note +This feature is still in *alpha* the `SyntheticPermissions` feature-gate must be enabled to make use of it. +See the instructions below on how to enable it. + +Synthetic user permissions enables fine-grained configuration of ClusterExtension management client RBAC permissions. +User can not only configure RBAC permissions governing the management across all ClusterExtensions, but also on a +case-by-case basis. + +### Update OLM to enable Feature + +```terminal title=Enable SyntheticPermissions feature +kubectl kustomize config/overlays/featuregate/synthetic-user-permissions | kubectl apply -f - +``` + +```terminal title=Wait for rollout to complete +kubectl rollout status -n olmv1-system deployment/operator-controller-controller-manager +``` + +### How does it work? + +When managing a ClusterExtension, OLM will assume the identity of user "olm:clusterextensions:" +and group "olm:clusterextensions" limiting Kubernetes API access scope to those defined for this user and group. These +users and group do not exist beyond being defined in Cluster/RoleBinding(s) and can only be impersonated by clients with + `impersonate` verb permissions on the `users` and `groups` resources. + +### Demo + +[![asciicast](https://asciinema.org/a/Jbtt8nkV8Dm7vriHxq7sxiVvi.svg)](https://asciinema.org/a/Jbtt8nkV8Dm7vriHxq7sxiVvi) + +#### Examples: + +##### ClusterExtension management as cluster-admin + +To enable ClusterExtensions management as cluster-admin, bind the `cluster-admin` cluster role to the `olm:clusterextensions` +group: + +``` +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: clusterextensions-group-admin-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: +- kind: Group + name: "olm:clusterextensions" +``` + +##### Scoped olm:clusterextension group + Added perms on specific extensions + +Give ClusterExtension management group broad permissions to manage ClusterExtensions denying potentially dangerous +permissions such as being able to read cluster wide secrets: + +``` +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: clusterextension-installer +rules: + - apiGroups: [ olm.operatorframework.io ] + resources: [ clusterextensions/finalizers ] + verbs: [ update ] + - apiGroups: [ apiextensions.k8s.io ] + resources: [ customresourcedefinitions ] + verbs: [ create, list, watch, get, update, patch, delete ] + - apiGroups: [ rbac.authorization.k8s.io ] + resources: [ clusterroles, roles, clusterrolebindings, rolebindings ] + verbs: [ create, list, watch, get, update, patch, delete ] + - apiGroups: [""] + resources: [configmaps, endpoints, events, pods, pod/logs, serviceaccounts, services, services/finalizers, namespaces, persistentvolumeclaims] + verbs: ['*'] + - apiGroups: [apps] + resources: [ '*' ] + verbs: ['*'] + - apiGroups: [ batch ] + resources: [ '*' ] + verbs: [ '*' ] + - apiGroups: [ networking.k8s.io ] + resources: [ '*' ] + verbs: [ '*' ] + - apiGroups: [authentication.k8s.io] + resources: [tokenreviews, subjectaccessreviews] + verbs: [create] +``` + +``` +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: clusterextension-installer-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: clusterextension-installer +subjects: +- kind: Group + name: "olm:clusterextensions" +``` + +Give a specific ClusterExtension secrets access, maybe even on specific namespaces: + +``` +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: clusterextension-privileged +rules: +- apiGroups: [""] + resources: [secrets] + verbs: ['*'] +``` + +``` +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: clusterextension-privileged-binding + namespace: +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: clusterextension-privileged +subjects: +- kind: User + name: "olm:clusterextensions:argocd-operator" +``` + +Note: In this example the ClusterExtension user (or group) will still need to be updated to be able to manage +the CRs coming from the argocd operator. Some look ahead and RBAC permission wrangling will still be required. diff --git a/hack/demo/resources/synthetic-user-perms/argocd-clusterextension.yaml b/hack/demo/resources/synthetic-user-perms/argocd-clusterextension.yaml new file mode 100644 index 000000000..7eb5a7082 --- /dev/null +++ b/hack/demo/resources/synthetic-user-perms/argocd-clusterextension.yaml @@ -0,0 +1,13 @@ +apiVersion: olm.operatorframework.io/v1 +kind: ClusterExtension +metadata: + name: argocd-operator +spec: + namespace: argocd-system + serviceAccount: + name: "olm.synthetic-user" + source: + sourceType: Catalog + catalog: + packageName: argocd-operator + version: 0.6.0 diff --git a/hack/demo/resources/synthetic-user-perms/cegroup-admin-binding.yaml b/hack/demo/resources/synthetic-user-perms/cegroup-admin-binding.yaml new file mode 100644 index 000000000..d0ab570f7 --- /dev/null +++ b/hack/demo/resources/synthetic-user-perms/cegroup-admin-binding.yaml @@ -0,0 +1,11 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: clusterextensions-group-admin-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: Group + name: "olm:clusterextensions" diff --git a/hack/demo/synthetic-user-cluster-admin-demo.sh b/hack/demo/synthetic-user-cluster-admin-demo.sh new file mode 100755 index 000000000..4790e46e7 --- /dev/null +++ b/hack/demo/synthetic-user-cluster-admin-demo.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +# +# Welcome to the SingleNamespace install mode demo +# +trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT + +# enable 'SyntheticPermissions' feature +kubectl kustomize config/overlays/featuregate/synthetic-user-permissions | kubectl apply -f - + +# wait for operator-controller to become available +kubectl rollout status -n olmv1-system deployment/operator-controller-controller-manager + +# create install namespace +kubectl create ns argocd-system + +# give cluster extension group cluster admin privileges - all cluster extensions installer users will be cluster admin +bat --style=plain ${DEMO_RESOURCE_DIR}/synthetic-user-perms/cegroup-admin-binding.yaml + +# apply cluster role binding +kubectl apply -f ${DEMO_RESOURCE_DIR}/synthetic-user-perms/cegroup-admin-binding.yaml + +# install cluster extension - for now .spec.serviceAccount = "olm.synthetic-user" +bat --style=plain ${DEMO_RESOURCE_DIR}/synthetic-user-perms/argocd-clusterextension.yaml + +# apply cluster extension +kubectl apply -f ${DEMO_RESOURCE_DIR}/synthetic-user-perms/argocd-clusterextension.yaml + +# wait for cluster extension installation to succeed +kubectl wait --for=condition=Installed clusterextension/argocd-operator --timeout="60s" diff --git a/internal/operator-controller/action/restconfig.go b/internal/operator-controller/action/restconfig.go index 6e0121281..b83696621 100644 --- a/internal/operator-controller/action/restconfig.go +++ b/internal/operator-controller/action/restconfig.go @@ -6,13 +6,52 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" + "k8s.io/client-go/transport" "sigs.k8s.io/controller-runtime/pkg/client" ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/authentication" + "github.com/operator-framework/operator-controller/internal/operator-controller/features" ) -func ServiceAccountRestConfigMapper(tokenGetter *authentication.TokenGetter) func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) { +const syntheticServiceAccountName = "olm.synthetic-user" + +type clusterExtensionRestConfigMapper struct { + saRestConfigMapper func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) + synthUserRestConfigMapper func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) +} + +func (m *clusterExtensionRestConfigMapper) mapper() func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) { + synthAuthFeatureEnabled := features.OperatorControllerFeatureGate.Enabled(features.SyntheticPermissions) + return func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) { + cExt := o.(*ocv1.ClusterExtension) + if synthAuthFeatureEnabled && cExt.Spec.ServiceAccount.Name == syntheticServiceAccountName { + return m.synthUserRestConfigMapper(ctx, o, c) + } + return m.saRestConfigMapper(ctx, o, c) + } +} + +func ClusterExtensionUserRestConfigMapper(tokenGetter *authentication.TokenGetter) func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) { + m := &clusterExtensionRestConfigMapper{ + saRestConfigMapper: serviceAccountRestConfigMapper(tokenGetter), + synthUserRestConfigMapper: syntheticUserRestConfigMapper(), + } + return m.mapper() +} + +func syntheticUserRestConfigMapper() func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) { + return func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) { + cExt := o.(*ocv1.ClusterExtension) + cc := rest.CopyConfig(c) + cc.Wrap(func(rt http.RoundTripper) http.RoundTripper { + return transport.NewImpersonatingRoundTripper(authentication.SyntheticImpersonationConfig(*cExt), rt) + }) + return cc, nil + } +} + +func serviceAccountRestConfigMapper(tokenGetter *authentication.TokenGetter) func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) { return func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) { cExt := o.(*ocv1.ClusterExtension) saKey := types.NamespacedName{ diff --git a/internal/operator-controller/action/restconfig_test.go b/internal/operator-controller/action/restconfig_test.go new file mode 100644 index 000000000..94e9bf807 --- /dev/null +++ b/internal/operator-controller/action/restconfig_test.go @@ -0,0 +1,92 @@ +package action + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/client-go/rest" + featuregatetesting "k8s.io/component-base/featuregate/testing" + "sigs.k8s.io/controller-runtime/pkg/client" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/features" +) + +const ( + saAccountWrapper = "service account wrapper" + synthUserWrapper = "synthetic user wrapper" +) + +func fakeRestConfigWrapper() clusterExtensionRestConfigMapper { + // The rest config's host field is artificially used to differentiate between the wrappers + return clusterExtensionRestConfigMapper{ + saRestConfigMapper: func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) { + return &rest.Config{ + Host: saAccountWrapper, + }, nil + }, + synthUserRestConfigMapper: func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) { + return &rest.Config{ + Host: synthUserWrapper, + }, nil + }, + } +} + +func TestMapper_SyntheticPermissionsEnabled(t *testing.T) { + featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.SyntheticPermissions, true) + + for _, tc := range []struct { + description string + serviceAccountName string + expectedMapper string + fgEnabled bool + }{ + { + description: "user service account wrapper if extension service account is _not_ called olm.synthetic-user", + serviceAccountName: "not.olm.synthetic-user", + expectedMapper: saAccountWrapper, + fgEnabled: true, + }, { + description: "user synthetic user wrapper is extension service account is called olm.synthetic-user", + serviceAccountName: "olm.synthetic-user", + expectedMapper: synthUserWrapper, + fgEnabled: true, + }, + } { + t.Run(tc.description, func(t *testing.T) { + m := fakeRestConfigWrapper() + mapper := m.mapper() + ext := &ocv1.ClusterExtension{ + Spec: ocv1.ClusterExtensionSpec{ + ServiceAccount: ocv1.ServiceAccountReference{ + Name: tc.serviceAccountName, + }, + }, + } + cfg, err := mapper(context.Background(), ext, &rest.Config{}) + require.NoError(t, err) + + // The rest config's host field is artificially used to differentiate between the wrappers + require.Equal(t, tc.expectedMapper, cfg.Host) + }) + } +} + +func TestMapper_SyntheticPermissionsDisabled(t *testing.T) { + m := fakeRestConfigWrapper() + mapper := m.mapper() + ext := &ocv1.ClusterExtension{ + Spec: ocv1.ClusterExtensionSpec{ + ServiceAccount: ocv1.ServiceAccountReference{ + Name: "olm.synthetic-user", + }, + }, + } + cfg, err := mapper(context.Background(), ext, &rest.Config{}) + require.NoError(t, err) + + // The rest config's host field is artificially used to differentiate between the wrappers + require.Equal(t, saAccountWrapper, cfg.Host) +} diff --git a/internal/operator-controller/authentication/synthetic.go b/internal/operator-controller/authentication/synthetic.go new file mode 100644 index 000000000..710f2885e --- /dev/null +++ b/internal/operator-controller/authentication/synthetic.go @@ -0,0 +1,26 @@ +package authentication + +import ( + "fmt" + + "k8s.io/client-go/transport" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +func syntheticUserName(ext ocv1.ClusterExtension) string { + return fmt.Sprintf("olm:clusterextension:%s", ext.Name) +} + +func syntheticGroups(_ ocv1.ClusterExtension) []string { + return []string{ + "olm:clusterextensions", + } +} + +func SyntheticImpersonationConfig(ext ocv1.ClusterExtension) transport.ImpersonationConfig { + return transport.ImpersonationConfig{ + UserName: syntheticUserName(ext), + Groups: syntheticGroups(ext), + } +} diff --git a/internal/operator-controller/authentication/synthetic_test.go b/internal/operator-controller/authentication/synthetic_test.go new file mode 100644 index 000000000..2e3f17a07 --- /dev/null +++ b/internal/operator-controller/authentication/synthetic_test.go @@ -0,0 +1,25 @@ +package authentication_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/authentication" +) + +func TestSyntheticImpersonationConfig(t *testing.T) { + config := authentication.SyntheticImpersonationConfig(ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ext", + }, + }) + require.Equal(t, "olm:clusterextension:my-ext", config.UserName) + require.Equal(t, []string{ + "olm:clusterextensions", + }, config.Groups) + require.Empty(t, config.UID) + require.Empty(t, config.Extra) +} diff --git a/internal/operator-controller/features/features.go b/internal/operator-controller/features/features.go index e8faa07f0..2e9083735 100644 --- a/internal/operator-controller/features/features.go +++ b/internal/operator-controller/features/features.go @@ -13,6 +13,7 @@ const ( // Ex: SomeFeature featuregate.Feature = "SomeFeature" PreflightPermissions featuregate.Feature = "PreflightPermissions" SingleOwnNamespaceInstallSupport featuregate.Feature = "SingleOwnNamespaceInstallSupport" + SyntheticPermissions featuregate.Feature = "SyntheticPermissions" ) var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ @@ -32,6 +33,14 @@ var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.Feature PreRelease: featuregate.Alpha, LockToDefault: false, }, + + // SyntheticPermissions enables support for a synthetic user permission + // model to manage operator permission boundaries + SyntheticPermissions: { + Default: false, + PreRelease: featuregate.Alpha, + LockToDefault: false, + }, } var OperatorControllerFeatureGate featuregate.MutableFeatureGate = featuregate.NewFeatureGate()