diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index a9cd69863..592d52507 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -305,6 +305,9 @@ func run() error { } tokenGetter := authentication.NewTokenGetter(coreClient, authentication.WithExpirationDuration(1*time.Hour)) clientRestConfigMapper := action.ServiceAccountRestConfigMapper(tokenGetter) + if features.OperatorControllerFeatureGate.Enabled(features.SyntheticPermissions) { + clientRestConfigMapper = action.SyntheticUserRestConfigMapper(clientRestConfigMapper) + } 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..05e25f707 100644 --- a/internal/operator-controller/action/restconfig.go +++ b/internal/operator-controller/action/restconfig.go @@ -2,31 +2,73 @@ package action import ( "context" + "fmt" "net/http" "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" ) +const syntheticServiceAccountName = "olm.synthetic-user" + +// SyntheticUserRestConfigMapper returns an AuthConfigMapper that that impersonates synthetic users and groups for Object o. +// o is expected to be a ClusterExtension. If the service account defined in o is different from 'olm.synthetic-user', the +// defaultAuthMapper will be used +func SyntheticUserRestConfigMapper(defaultAuthMapper func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error)) 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, err := validate(o, c) + if err != nil { + return nil, err + } + if cExt.Spec.ServiceAccount.Name != syntheticServiceAccountName { + return defaultAuthMapper(ctx, cExt, c) + } + cc := rest.CopyConfig(c) + cc.Wrap(func(rt http.RoundTripper) http.RoundTripper { + return transport.NewImpersonatingRoundTripper(authentication.SyntheticImpersonationConfig(*cExt), rt) + }) + return cc, nil + } +} + +// ServiceAccountRestConfigMapper returns an AuthConfigMapper scoped to the service account defined in o, which is expected to +// be a ClusterExtension 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{ - Name: cExt.Spec.ServiceAccount.Name, - Namespace: cExt.Spec.Namespace, + cExt, err := validate(o, c) + if err != nil { + return nil, err } saConfig := rest.AnonymousClientConfig(c) saConfig.Wrap(func(rt http.RoundTripper) http.RoundTripper { return &authentication.TokenInjectingRoundTripper{ Tripper: rt, TokenGetter: tokenGetter, - Key: saKey, + Key: types.NamespacedName{ + Name: cExt.Spec.ServiceAccount.Name, + Namespace: cExt.Spec.Namespace, + }, } }) return saConfig, nil } } + +func validate(o client.Object, c *rest.Config) (*ocv1.ClusterExtension, error) { + if c == nil { + return nil, fmt.Errorf("rest config is nil") + } + if o == nil { + return nil, fmt.Errorf("object is nil") + } + cExt, ok := o.(*ocv1.ClusterExtension) + if !ok { + return nil, fmt.Errorf("object is not a ClusterExtension") + } + return cExt, nil +} diff --git a/internal/operator-controller/action/restconfig_test.go b/internal/operator-controller/action/restconfig_test.go new file mode 100644 index 000000000..4c9f78671 --- /dev/null +++ b/internal/operator-controller/action/restconfig_test.go @@ -0,0 +1,177 @@ +package action_test + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "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/action" + "github.com/operator-framework/operator-controller/internal/operator-controller/authentication" +) + +func Test_ServiceAccountRestConfigMapper(t *testing.T) { + for _, tc := range []struct { + description string + obj client.Object + cfg *rest.Config + expectedError error + }{ + { + description: "return error if object is nil", + cfg: &rest.Config{}, + expectedError: errors.New("object is nil"), + }, { + description: "return error if cfg is nil", + obj: &ocv1.ClusterExtension{}, + expectedError: errors.New("rest config is nil"), + }, { + description: "return error if object is not a ClusterExtension", + obj: &corev1.Secret{}, + cfg: &rest.Config{}, + expectedError: errors.New("object is not a ClusterExtension"), + }, { + description: "succeeds if object is not a ClusterExtension", + obj: &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-clusterextension", + }, + Spec: ocv1.ClusterExtensionSpec{ + ServiceAccount: ocv1.ServiceAccountReference{ + Name: "my-service-account", + }, + Namespace: "my-namespace", + }, + }, + cfg: &rest.Config{}, + }, + } { + t.Run(tc.description, func(t *testing.T) { + tokenGetter := &authentication.TokenGetter{} + saMapper := action.ServiceAccountRestConfigMapper(tokenGetter) + actualCfg, err := saMapper(context.Background(), tc.obj, tc.cfg) + if tc.expectedError != nil { + require.Nil(t, actualCfg) + require.EqualError(t, err, tc.expectedError.Error()) + } else { + require.NoError(t, err) + transport, err := rest.TransportFor(actualCfg) + require.NoError(t, err) + require.NotNil(t, transport) + tokenInjectionRoundTripper, ok := transport.(*authentication.TokenInjectingRoundTripper) + require.True(t, ok) + require.Equal(t, tokenGetter, tokenInjectionRoundTripper.TokenGetter) + require.Equal(t, types.NamespacedName{Name: "my-service-account", Namespace: "my-namespace"}, tokenInjectionRoundTripper.Key) + } + }) + } +} + +func Test_SyntheticUserRestConfigMapper_Fails(t *testing.T) { + for _, tc := range []struct { + description string + obj client.Object + cfg *rest.Config + expectedError error + }{ + { + description: "return error if object is nil", + cfg: &rest.Config{}, + expectedError: errors.New("object is nil"), + }, { + description: "return error if cfg is nil", + obj: &ocv1.ClusterExtension{}, + expectedError: errors.New("rest config is nil"), + }, { + description: "return error if object is not a ClusterExtension", + obj: &corev1.Secret{}, + cfg: &rest.Config{}, + expectedError: errors.New("object is not a ClusterExtension"), + }, + } { + t.Run(tc.description, func(t *testing.T) { + tokenGetter := &authentication.TokenGetter{} + saMapper := action.ServiceAccountRestConfigMapper(tokenGetter) + actualCfg, err := saMapper(context.Background(), tc.obj, tc.cfg) + if tc.expectedError != nil { + require.Nil(t, actualCfg) + require.EqualError(t, err, tc.expectedError.Error()) + } else { + require.NoError(t, err) + transport, err := rest.TransportFor(actualCfg) + require.NoError(t, err) + require.NotNil(t, transport) + tokenInjectionRoundTripper, ok := transport.(*authentication.TokenInjectingRoundTripper) + require.True(t, ok) + require.Equal(t, tokenGetter, tokenInjectionRoundTripper.TokenGetter) + require.Equal(t, types.NamespacedName{Name: "my-service-account", Namespace: "my-namespace"}, tokenInjectionRoundTripper.Key) + } + }) + } +} +func Test_SyntheticUserRestConfigMapper_UsesDefaultConfigMapper(t *testing.T) { + isDefaultRequestMapperUsed := false + defaultServiceMapper := func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) { + isDefaultRequestMapperUsed = true + return c, nil + } + syntheticAuthServiceMapper := action.SyntheticUserRestConfigMapper(defaultServiceMapper) + obj := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-clusterextension", + }, + Spec: ocv1.ClusterExtensionSpec{ + ServiceAccount: ocv1.ServiceAccountReference{ + Name: "my-service-account", + }, + Namespace: "my-namespace", + }, + } + actualCfg, err := syntheticAuthServiceMapper(context.Background(), obj, &rest.Config{}) + require.NoError(t, err) + require.NotNil(t, actualCfg) + require.True(t, isDefaultRequestMapperUsed) +} + +func Test_SyntheticUserRestConfigMapper_UsesSyntheticAuthMapper(t *testing.T) { + syntheticAuthServiceMapper := action.SyntheticUserRestConfigMapper(func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) { + return c, nil + }) + obj := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-clusterextension", + }, + Spec: ocv1.ClusterExtensionSpec{ + ServiceAccount: ocv1.ServiceAccountReference{ + Name: "olm.synthetic-user", + }, + Namespace: "my-namespace", + }, + } + actualCfg, err := syntheticAuthServiceMapper(context.Background(), obj, &rest.Config{}) + require.NoError(t, err) + require.NotNil(t, actualCfg) + + // test that the impersonation headers are appropriately injected into the request + // by wrapping a fake round tripper around the returned configurations transport + // nolint:bodyclose + _, _ = actualCfg.WrapTransport(fakeRoundTripper(func(req *http.Request) (*http.Response, error) { + require.Equal(t, "olm:clusterextension:my-clusterextension", req.Header.Get("Impersonate-User")) + require.Equal(t, "olm:clusterextensions", req.Header.Get("Impersonate-Group")) + return &http.Response{}, nil + })).RoundTrip(&http.Request{}) +} + +type fakeRoundTripper func(req *http.Request) (*http.Response, error) + +func (f fakeRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} 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()