diff --git a/Makefile b/Makefile index b252fb6c..37c4e6ea 100644 --- a/Makefile +++ b/Makefile @@ -151,6 +151,7 @@ dev-setup: --create-namespace \ --set 'crds.install=true' \ --set 'crds.exclusive=true'\ + --set 'crds.createConfig=true'\ --set "webhooks.exclusive=true"\ --set "webhooks.service.url=$${WEBHOOK_URL}" \ --set "webhooks.service.caBundle=$${CA_BUNDLE}" \ diff --git a/api/v1beta2/capsuleconfiguration_types.go b/api/v1beta2/capsuleconfiguration_types.go index d7cf9f76..65d2c686 100644 --- a/api/v1beta2/capsuleconfiguration_types.go +++ b/api/v1beta2/capsuleconfiguration_types.go @@ -19,6 +19,11 @@ type CapsuleConfigurationSpec struct { // Define groups which when found in the request of a user will be ignored by the Capsule // this might be useful if you have one group where all the users are in, but you want to separate administrators from normal users with additional groups. IgnoreUserWithGroups []string `json:"ignoreUserWithGroups,omitempty"` + // ServiceAccounts within tenant namespaces can be promoted to owners of the given tenant + // this can be achieved by labeling the serviceaccount and then they are considered owners. This can only be done by other owners of the tenant. + // However ServiceAccounts which have been promoted to owner can not promote further serviceAccounts. + // +kubebuilder:default=false + AllowServiceAccountPromotion bool `json:"allowServiceAccountPromotion,omitempty"` // Enforces the Tenant owner, during Namespace creation, to name it using the selected Tenant name as prefix, // separated by a dash. This is useful to avoid Namespace name collision in a public CaaS environment. // +kubebuilder:default=false diff --git a/charts/capsule/README.md b/charts/capsule/README.md index ca71a169..1c590fa6 100644 --- a/charts/capsule/README.md +++ b/charts/capsule/README.md @@ -26,6 +26,7 @@ The following Values have changed key or Value: | Key | Type | Default | Description | |-----|------|---------|-------------| | crds.annnotations | object | `{}` | Extra Annotations for CRDs | +| crds.createConfig | bool | `false` | Create additionally CapsuleConfiguration even if CRDs are exclusive | | crds.exclusive | bool | `false` | Only install the CRDs, no other primitives | | crds.install | bool | `true` | Install the CustomResourceDefinitions (This also manages the lifecycle of the CRDs for update operations) | | crds.labels | object | `{}` | Extra Labels for CRDs | @@ -105,6 +106,7 @@ The following Values have changed key or Value: | manager.image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | | manager.kind | string | `"Deployment"` | Set the controller deployment mode as `Deployment` or `DaemonSet`. | | manager.livenessProbe | object | `{"httpGet":{"path":"/healthz","port":10080}}` | Configure the liveness probe using Deployment probe spec | +| manager.options.allowServiceAccountPromotion | bool | `false` | ServiceAccounts within tenant namespaces can be promoted to owners of the given tenant this can be achieved by labeling the serviceaccount and then they are considered owners. This can only be done by other owners of the tenant. However ServiceAccounts which have been promoted to owner can not promote further serviceAccounts. | | manager.options.capsuleConfiguration | string | `"default"` | Change the default name of the capsule configuration name | | manager.options.capsuleUserGroups | list | `["projectcapsule.dev"]` | Names of the groups considered as Capsule users. | | manager.options.forceTenantPrefix | bool | `false` | Boolean, enforces the Tenant owner, during Namespace creation, to name it using the selected Tenant name as prefix, separated by a dash | @@ -228,6 +230,12 @@ The following Values have changed key or Value: | webhooks.hooks.resourcepools.pools.matchPolicy | string | `"Equivalent"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | | webhooks.hooks.resourcepools.pools.namespaceSelector | object | `{}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) | | webhooks.hooks.resourcepools.pools.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | +| webhooks.hooks.serviceaccounts.enabled | bool | `true` | Enable the Hook | +| webhooks.hooks.serviceaccounts.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | +| webhooks.hooks.serviceaccounts.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | +| webhooks.hooks.serviceaccounts.matchPolicy | string | `"Exact"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | +| webhooks.hooks.serviceaccounts.namespaceSelector | object | `{"matchExpressions":[{"key":"capsule.clastix.io/tenant","operator":"Exists"}]}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) | +| webhooks.hooks.serviceaccounts.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | | webhooks.hooks.services.enabled | bool | `true` | Enable the Hook | | webhooks.hooks.services.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | | webhooks.hooks.services.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index 4e8defb6..2046d1ee 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -40,6 +40,13 @@ spec: spec: description: CapsuleConfigurationSpec defines the Capsule configuration. properties: + allowServiceAccountPromotion: + default: false + description: |- + ServiceAccounts within tenant namespaces can be promoted to owners of the given tenant + this can be achieved by labeling the serviceaccount and then they are considered owners. This can only be done by other owners of the tenant. + However ServiceAccounts which have been promoted to owner can not promote further serviceAccounts. + type: boolean enableTLSReconciler: default: true description: |- diff --git a/charts/capsule/templates/configuration-default.yaml b/charts/capsule/templates/configuration-default.yaml index 9a5a6c32..792257d9 100644 --- a/charts/capsule/templates/configuration-default.yaml +++ b/charts/capsule/templates/configuration-default.yaml @@ -1,4 +1,4 @@ -{{- if not $.Values.crds.exclusive }} +{{- if or (not $.Values.crds.exclusive) ($.Values.crds.createConfig) }} apiVersion: capsule.clastix.io/v1beta2 kind: CapsuleConfiguration metadata: @@ -16,6 +16,7 @@ spec: TLSSecretName: {{ include "capsule.secretTlsName" . }} validatingWebhookConfigurationName: {{ include "capsule.fullname" . }}-validating-webhook-configuration forceTenantPrefix: {{ .Values.manager.options.forceTenantPrefix }} + allowServiceAccountPromotion: {{ .Values.manager.options.allowServiceAccountPromotion }} userGroups: {{- toYaml .Values.manager.options.capsuleUserGroups | nindent 4 }} userNames: diff --git a/charts/capsule/templates/validatingwebhookconfiguration.yaml b/charts/capsule/templates/validatingwebhookconfiguration.yaml index fa56d82f..06ba9241 100644 --- a/charts/capsule/templates/validatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/validatingwebhookconfiguration.yaml @@ -525,4 +525,41 @@ webhooks: timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} {{- end }} {{- end }} +{{- with .Values.webhooks.hooks.serviceaccounts }} + {{- if .enabled }} +- name: serviceaccounts.tenant.projectcapsule.dev + admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + {{- include "capsule.webhooks.service" (dict "path" "/serviceaccounts" "ctx" $) | nindent 4 }} + failurePolicy: {{ .failurePolicy }} + matchPolicy: {{ .matchPolicy }} + {{- with .namespaceSelector }} + namespaceSelector: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .objectSelector }} + objectSelector: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .matchConditions }} + matchConditions: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - apiGroups: + - '*' + apiVersions: + - '*' + operations: + - CREATE + - UPDATE + resources: + - 'serviceaccounts' + scope: Namespaced + sideEffects: None + timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} + {{- end }} +{{- end }} {{- end }} diff --git a/charts/capsule/values.schema.json b/charts/capsule/values.schema.json index 91110265..f165889e 100644 --- a/charts/capsule/values.schema.json +++ b/charts/capsule/values.schema.json @@ -26,6 +26,10 @@ "description": "Extra Annotations for CRDs", "type": "object" }, + "createConfig": { + "description": "Create additionally CapsuleConfiguration even if CRDs are exclusive", + "type": "boolean" + }, "exclusive": { "description": "Only install the CRDs, no other primitives", "type": "boolean" @@ -289,6 +293,10 @@ "options": { "type": "object", "properties": { + "allowServiceAccountPromotion": { + "description": "ServiceAccounts within tenant namespaces can be promoted to owners of the given tenant this can be achieved by labeling the serviceaccount and then they are considered owners. This can only be done by other owners of the tenant. However ServiceAccounts which have been promoted to owner can not promote further serviceAccounts.", + "type": "boolean" + }, "capsuleConfiguration": { "description": "Change the default name of the capsule configuration name", "type": "string" @@ -1194,6 +1202,51 @@ } } }, + "serviceaccounts": { + "type": "object", + "properties": { + "enabled": { + "description": "Enable the Hook", + "type": "boolean" + }, + "failurePolicy": { + "description": "[FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy)", + "type": "string" + }, + "matchConditions": { + "description": "[MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)", + "type": "array" + }, + "matchPolicy": { + "description": "[MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)", + "type": "string" + }, + "namespaceSelector": { + "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", + "type": "object", + "properties": { + "matchExpressions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + } + } + } + } + } + }, + "objectSelector": { + "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", + "type": "object" + } + } + }, "services": { "type": "object", "properties": { diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index 769e7b15..bfc44812 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -65,6 +65,8 @@ crds: install: true # -- Only install the CRDs, no other primitives exclusive: false + # -- Create additionally CapsuleConfiguration even if CRDs are exclusive + createConfig: false # -- Extra Labels for CRDs labels: {} # -- Extra Annotations for CRDs @@ -160,8 +162,6 @@ manager: capsuleConfiguration: default # -- Set the log verbosity of the capsule with a value from 1 to 10 logLevel: '4' - # -- Boolean, enforces the Tenant owner, during Namespace creation, to name it using the selected Tenant name as prefix, separated by a dash - forceTenantPrefix: false # -- Names of the users considered as Capsule users. userNames: [] # -- Names of the groups considered as Capsule users. @@ -169,6 +169,12 @@ manager: # -- Define groups which when found in the request of a user will be ignored by the Capsule # this might be useful if you have one group where all the users are in, but you want to separate administrators from normal users with additional groups. ignoreUserWithGroups: [] + # -- ServiceAccounts within tenant namespaces can be promoted to owners of the given tenant + # this can be achieved by labeling the serviceaccount and then they are considered owners. This can only be done by other owners of the tenant. + # However ServiceAccounts which have been promoted to owner can not promote further serviceAccounts. + allowServiceAccountPromotion: false + # -- Boolean, enforces the Tenant owner, during Namespace creation, to name it using the selected Tenant name as prefix, separated by a dash + forceTenantPrefix: false # -- If specified, disallows creation of namespaces matching the passed regexp protectedNamespaceRegex: "" # -- Specifies whether capsule webhooks certificates should be generated by capsule operator @@ -218,9 +224,6 @@ imagePullSecrets: [] # -- Labels to add to the capsule pod. podLabels: {} -# The following annotations guarantee scheduling for critical add-on pods -# podAnnotations: -# scheduler.alpha.kubernetes.io/critical-pod: '' # -- Annotations to add to the capsule pod. podAnnotations: {} @@ -621,6 +624,23 @@ webhooks: # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchConditions: [] + serviceaccounts: + # -- Enable the Hook + enabled: true + # -- [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) + failurePolicy: Fail + # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) + matchPolicy: Exact + # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) + objectSelector: {} + # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) + namespaceSelector: + matchExpressions: + - key: capsule.clastix.io/tenant + operator: Exists + # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) + matchConditions: [] + # -- Deprecated, use webhooks.hooks.namespaces instead namespaceOwnerReference: {} diff --git a/cmd/main.go b/cmd/main.go index e8ba60a3..786541fe 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -56,6 +56,7 @@ import ( "github.com/projectcapsule/capsule/pkg/webhook/resourcepool" "github.com/projectcapsule/capsule/pkg/webhook/route" "github.com/projectcapsule/capsule/pkg/webhook/service" + "github.com/projectcapsule/capsule/pkg/webhook/serviceaccounts" "github.com/projectcapsule/capsule/pkg/webhook/tenant" tntresource "github.com/projectcapsule/capsule/pkg/webhook/tenantresource" "github.com/projectcapsule/capsule/pkg/webhook/utils" @@ -227,7 +228,7 @@ func main() { webhooksList := append( make([]webhook.Webhook, 0), route.Pod(pod.ImagePullPolicy(), pod.ContainerRegistry(), pod.PriorityClass(), pod.RuntimeClass()), - route.Namespace(utils.InCapsuleGroups(cfg, namespacevalidation.PatchHandler(), namespacevalidation.QuotaHandler(), namespacevalidation.FreezeHandler(cfg), namespacevalidation.PrefixHandler(cfg), namespacevalidation.UserMetadataHandler())), + route.Namespace(utils.InCapsuleGroups(cfg, namespacevalidation.PatchHandler(cfg), namespacevalidation.QuotaHandler(), namespacevalidation.FreezeHandler(cfg), namespacevalidation.PrefixHandler(cfg), namespacevalidation.UserMetadataHandler())), route.Ingress(ingress.Class(cfg, kubeVersion), ingress.Hostnames(cfg), ingress.Collision(cfg), ingress.Wildcard()), route.PVC(pvc.Validating(), pvc.PersistentVolumeReuse()), route.Service(service.Handler()), @@ -236,6 +237,7 @@ func main() { route.Tenant(tenant.NameHandler(), tenant.RoleBindingRegexHandler(), tenant.IngressClassRegexHandler(), tenant.StorageClassRegexHandler(), tenant.ContainerRegistryRegexHandler(), tenant.HostnameRegexHandler(), tenant.FreezedEmitter(), tenant.ServiceAccountNameHandler(), tenant.ForbiddenAnnotationsRegexHandler(), tenant.ProtectedHandler(), tenant.MetaHandler()), route.Cordoning(tenant.CordoningHandler(cfg)), route.Node(utils.InCapsuleGroups(cfg, node.UserMetadataHandler(cfg, kubeVersion))), + route.ServiceAccounts(serviceaccounts.Handler(cfg)), route.NamespacePatch(utils.InCapsuleGroups(cfg, namespacemutation.CordoningLabelHandler(cfg), namespacemutation.OwnerReferenceHandler(cfg), namespacemutation.MetadataHandler(cfg))), route.CustomResources(tenant.ResourceCounterHandler(manager.GetClient())), route.Gateway(gateway.Class(cfg)), diff --git a/controllers/rbac/manager.go b/controllers/rbac/manager.go index ece97187..621864ab 100644 --- a/controllers/rbac/manager.go +++ b/controllers/rbac/manager.go @@ -9,9 +9,11 @@ import ( "fmt" "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" "k8s.io/client-go/util/workqueue" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -23,6 +25,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/controllers/utils" "github.com/projectcapsule/capsule/pkg/configuration" + "github.com/projectcapsule/capsule/pkg/meta" ) type Manager struct { @@ -47,12 +50,26 @@ func (r *Manager) SetupWithManager(ctx context.Context, mgr ctrl.Manager, config Watches(&capsulev1beta2.CapsuleConfiguration{}, handler.Funcs{ UpdateFunc: func(ctx context.Context, updateEvent event.TypedUpdateEvent[client.Object], limitingInterface workqueue.TypedRateLimitingInterface[reconcile.Request]) { if updateEvent.ObjectNew.GetName() == configurationName { - if crbErr := r.EnsureClusterRoleBindings(ctx); crbErr != nil { + if crbErr := r.EnsureClusterRoleBindingsProvisioner(ctx); crbErr != nil { r.Log.Error(err, "cannot update ClusterRoleBinding upon CapsuleConfiguration update") } } }, - }).Complete(r) + }). + Watches(&corev1.ServiceAccount{}, handler.Funcs{ + CreateFunc: func(ctx context.Context, e event.TypedCreateEvent[client.Object], q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + r.handleSAChange(ctx, e.Object) + }, + UpdateFunc: func(ctx context.Context, e event.TypedUpdateEvent[client.Object], q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + if promotionLabelsChanged(e.ObjectOld.GetLabels(), e.ObjectNew.GetLabels()) { + r.handleSAChange(ctx, e.ObjectNew) + } + }, + DeleteFunc: func(ctx context.Context, e event.TypedDeleteEvent[client.Object], q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + r.handleSAChange(ctx, e.Object) + }, + }). + Complete(r) if crbErr != nil { err = errors.Join(err, crbErr) } @@ -71,8 +88,8 @@ func (r *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res break } - if err = r.EnsureClusterRoleBindings(ctx); err != nil { - r.Log.Error(err, "Reconciliation for ClusterRoleBindings failed") + if err = r.EnsureClusterRoleBindingsProvisioner(ctx); err != nil { + r.Log.Error(err, "Reconciliation for ClusterRoleBindings (Provisioner) failed") break } @@ -85,36 +102,52 @@ func (r *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res return } -func (r *Manager) EnsureClusterRoleBindings(ctx context.Context) (err error) { +func (r *Manager) EnsureClusterRoleBindingsProvisioner(ctx context.Context) error { crb := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: ProvisionerRoleName, - }, + ObjectMeta: metav1.ObjectMeta{Name: ProvisionerRoleName}, } - _, err = controllerutil.CreateOrUpdate(ctx, r.Client, crb, func() (err error) { - crb.RoleRef = provisionerClusterRoleBinding.RoleRef + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, crb, func() error { + crb.RoleRef = provisionerClusterRoleBinding.RoleRef + crb.Subjects = nil - crb.Subjects = []rbacv1.Subject{} + for _, group := range r.Configuration.UserGroups() { + crb.Subjects = append(crb.Subjects, rbacv1.Subject{ + Kind: rbacv1.GroupKind, + Name: group, + }) + } - for _, group := range r.Configuration.UserGroups() { - crb.Subjects = append(crb.Subjects, rbacv1.Subject{ - Kind: "Group", - Name: group, - }) - } + for _, user := range r.Configuration.UserNames() { + crb.Subjects = append(crb.Subjects, rbacv1.Subject{ + Kind: rbacv1.UserKind, + Name: user, + }) + } - for _, user := range r.Configuration.UserNames() { - crb.Subjects = append(crb.Subjects, rbacv1.Subject{ - Kind: "User", - Name: user, - }) - } + if r.Configuration.AllowServiceAccountPromotion() { + saList := &corev1.ServiceAccountList{} + if err := r.Client.List(ctx, saList, client.MatchingLabels{ + meta.OwnerPromotionLabel: meta.OwnerPromotionLabelTrigger, + }); err != nil { + return err + } - return + for _, sa := range saList.Items { + crb.Subjects = append(crb.Subjects, rbacv1.Subject{ + Kind: rbacv1.ServiceAccountKind, + Name: sa.Name, + Namespace: sa.Namespace, + }) + } + } + + return nil + }) + + return err }) - - return } func (r *Manager) EnsureClusterRole(ctx context.Context, roleName string) (err error) { @@ -156,7 +189,7 @@ func (r *Manager) Start(ctx context.Context) error { r.Log.Info("setting up ClusterRoleBindings") - if err := r.EnsureClusterRoleBindings(ctx); err != nil { + if err := r.EnsureClusterRoleBindingsProvisioner(ctx); err != nil { if apierrors.IsAlreadyExists(err) { return nil } @@ -166,3 +199,30 @@ func (r *Manager) Start(ctx context.Context) error { return nil } + +func (r *Manager) handleSAChange(ctx context.Context, obj client.Object) { + if !r.Configuration.AllowServiceAccountPromotion() { + return + } + + if err := r.EnsureClusterRoleBindingsProvisioner(ctx); err != nil { + r.Log.Error(err, "cannot update ClusterRoleBinding upon ServiceAccount event") + } +} + +func promotionLabelsChanged(oldLabels, newLabels map[string]string) bool { + keys := []string{ + meta.OwnerPromotionLabel, + } + + for _, key := range keys { + oldVal, oldOK := oldLabels[key] + newVal, newOK := newLabels[key] + + if oldOK != newOK || oldVal != newVal { + return true + } + } + + return false +} diff --git a/e2e/sa_owner_promotion_test.go b/e2e/sa_owner_promotion_test.go new file mode 100644 index 00000000..42d7d2de --- /dev/null +++ b/e2e/sa_owner_promotion_test.go @@ -0,0 +1,353 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + otypes "github.com/onsi/gomega/types" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + ctrlrbac "github.com/projectcapsule/capsule/controllers/rbac" + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/meta" +) + +var _ = Describe("Promoting ServiceAccounts to Owners", Label("config"), Label("promotion"), func() { + originConfig := &capsulev1beta2.CapsuleConfiguration{} + + tnt := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tenant-owner-promotion", + }, + Spec: capsulev1beta2.TenantSpec{ + Owners: capsulev1beta2.OwnerListSpec{ + { + Name: "alice", + Kind: "User", + }, + }, + AdditionalRoleBindings: []api.AdditionalRoleBindingsSpec{ + { + ClusterRoleName: "cluster-admin", + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: "default", + }, + { + Kind: "User", + Name: "bob", + }, + }, + }, + }, + }, + } + + JustBeforeEach(func() { + Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: defaultConfigurationName}, originConfig)).To(Succeed()) + + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + return k8sClient.Create(context.TODO(), tnt) + }).Should(Succeed()) + }) + JustAfterEach(func() { + Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed()) + + // Restore Configuration + Eventually(func() error { + c := &capsulev1beta2.CapsuleConfiguration{} + if err := k8sClient.Get(context.Background(), client.ObjectKey{Name: originConfig.Name}, c); err != nil { + return err + } + // Apply the initial configuration from originConfig to c + c.Spec = originConfig.Spec + return k8sClient.Update(context.Background(), c) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + + It("Deny Owner promotion even when feature is disabled", func() { + ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) { + configuration.Spec.AllowServiceAccountPromotion = false + }) + + ns := NewNamespace("") + NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed()) + time.Sleep(250 * time.Millisecond) + + // Create a ServiceAccount inside the tenant namespace + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sa", + Namespace: ns.Name, + }, + } + Expect(k8sClient.Create(context.TODO(), sa)).Should(Succeed()) + + // Table of personas: client + expected result + personas := map[string]struct { + client client.Client + matcher otypes.GomegaMatcher + }{ + "owner": {client: impersonationClient(tnt.Spec.Owners[0].Name, withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Not(Succeed())}, + "rb-user": {client: impersonationClient("bob", withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Not(Succeed())}, + "rb-sa": {client: impersonationClient("system:serviceaccount:"+sa.GetNamespace()+":default", withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Not(Succeed())}, + } + + for name, tc := range personas { + By(fmt.Sprintf("trying to promote SA as %s (Setting Trigger)", name)) + + Eventually(func() error { + saCopy := &corev1.ServiceAccount{} + Expect(tc.client.Get(context.TODO(), client.ObjectKeyFromObject(sa), saCopy)).To(Succeed()) + + if saCopy.Labels == nil { + saCopy.Labels = map[string]string{} + } + saCopy.Labels[meta.OwnerPromotionLabel] = meta.OwnerPromotionLabelTrigger + + return tc.client.Update(context.TODO(), saCopy) + }, defaultTimeoutInterval, defaultPollInterval).Should(tc.matcher, "persona=%s", name) + } + + for name, tc := range personas { + By(fmt.Sprintf("trying to promote SA as %s (Setting Any Value)", name)) + + Eventually(func() error { + saCopy := &corev1.ServiceAccount{} + Expect(tc.client.Get(context.TODO(), client.ObjectKeyFromObject(sa), saCopy)).To(Succeed()) + + if saCopy.Labels == nil { + saCopy.Labels = map[string]string{} + } + saCopy.Labels[meta.OwnerPromotionLabel] = "false" + + return tc.client.Update(context.TODO(), saCopy) + }, defaultTimeoutInterval, defaultPollInterval).Should(tc.matcher, "persona=%s", name) + } + + for name, tc := range personas { + By(fmt.Sprintf("trying to allow deletion SA as %s (Setting Any Value)", name)) + + Eventually(func() error { + saCopy := &corev1.ServiceAccount{} + Expect(tc.client.Get(context.TODO(), client.ObjectKeyFromObject(sa), saCopy)).To(Succeed()) + + if saCopy.Labels == nil { + saCopy.Labels = map[string]string{} + } + saCopy.Labels[meta.OwnerPromotionLabel] = "false" + + return tc.client.Update(context.TODO(), saCopy) + }, defaultTimeoutInterval, defaultPollInterval).Should(tc.matcher, "persona=%s", name) + } + + for name, tc := range personas { + By(fmt.Sprintf("trying to allow deletion SA as %s (Setting Any Value)", name)) + + Eventually(func() error { + saCopy := &corev1.ServiceAccount{} + Expect(tc.client.Get(context.TODO(), client.ObjectKeyFromObject(sa), saCopy)).To(Succeed()) + + if saCopy.Labels == nil { + saCopy.Labels = map[string]string{} + } + saCopy.Labels[meta.OwnerPromotionLabel] = "false" + + return tc.client.Update(context.TODO(), saCopy) + }, defaultTimeoutInterval, defaultPollInterval).Should(tc.matcher, "persona=%s", name) + } + }) + + It("Allow Owner promotion by Owners", func() { + ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) { + configuration.Spec.AllowServiceAccountPromotion = true + }) + + ns := NewNamespace("") + NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed()) + time.Sleep(250 * time.Millisecond) + + // Create a ServiceAccount inside the tenant namespace + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sa", + Namespace: ns.Name, + }, + } + Expect(k8sClient.Create(context.TODO(), sa)).Should(Succeed()) + + // Table of personas: client + expected result + personas := map[string]struct { + client client.Client + matcher otypes.GomegaMatcher + }{ + "rb-user": {client: impersonationClient("bob", withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Not(Succeed())}, + "rb-sa": {client: impersonationClient("system:serviceaccount:"+sa.GetNamespace()+":default", withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Not(Succeed())}, + "owner": {client: impersonationClient(tnt.Spec.Owners[0].Name, withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Succeed()}, + } + + for name, tc := range personas { + By(fmt.Sprintf("trying to promote SA as %s (Setting Trigger)", name)) + + Eventually(func() error { + saCopy := &corev1.ServiceAccount{} + Expect(tc.client.Get(context.TODO(), client.ObjectKeyFromObject(sa), saCopy)).To(Succeed()) + + if saCopy.Labels == nil { + saCopy.Labels = map[string]string{} + } + saCopy.Labels[meta.OwnerPromotionLabel] = meta.OwnerPromotionLabelTrigger + + return tc.client.Update(context.TODO(), saCopy) + }, defaultTimeoutInterval, defaultPollInterval).Should(tc.matcher, "persona=%s", name) + } + + for name, tc := range personas { + By(fmt.Sprintf("trying to promote SA as %s (Setting Generic)", name)) + + Eventually(func() error { + saCopy := &corev1.ServiceAccount{} + Expect(tc.client.Get(context.TODO(), client.ObjectKeyFromObject(sa), saCopy)).To(Succeed()) + + if saCopy.Labels == nil { + saCopy.Labels = map[string]string{} + } + saCopy.Labels[meta.OwnerPromotionLabel] = "false" + + return tc.client.Update(context.TODO(), saCopy) + }, defaultTimeoutInterval, defaultPollInterval).Should(tc.matcher, "persona=%s", name) + } + }) + + It("Allow Promoted ServiceAccount to interact with Tenant Namespaces", func() { + ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) { + configuration.Spec.AllowServiceAccountPromotion = true + }) + + ns := NewNamespace("") + NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed()) + time.Sleep(250 * time.Millisecond) + + // Create a ServiceAccount inside the tenant namespace + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sa", + Namespace: ns.Name, + }, + } + Expect(k8sClient.Create(context.TODO(), sa)).Should(Succeed()) + + // Table of personas: client + expected result + personas := map[string]struct { + client client.Client + matcher otypes.GomegaMatcher + }{ + "owner": {client: impersonationClient(tnt.Spec.Owners[0].Name, withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Succeed()}, + } + + for name, tc := range personas { + By(fmt.Sprintf("trying to promote SA as %s", name)) + + Eventually(func() error { + saCopy := &corev1.ServiceAccount{} + Expect(tc.client.Get(context.TODO(), client.ObjectKeyFromObject(sa), saCopy)).To(Succeed()) + + if saCopy.Labels == nil { + saCopy.Labels = map[string]string{} + } + saCopy.Labels[meta.OwnerPromotionLabel] = meta.OwnerPromotionLabelTrigger + + return tc.client.Update(context.TODO(), saCopy) + }, defaultTimeoutInterval, defaultPollInterval).Should(tc.matcher, "persona=%s", name) + } + + time.Sleep(250 * time.Millisecond) + + Eventually(func(g Gomega) []rbacv1.Subject { + crb := &rbacv1.ClusterRoleBinding{} + err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: ctrlrbac.ProvisionerRoleName}, crb) + g.Expect(err).NotTo(HaveOccurred()) + + return crb.Subjects + }, defaultTimeoutInterval, defaultPollInterval).Should(ContainElement(rbacv1.Subject{ + Kind: rbacv1.ServiceAccountKind, + Name: "test-sa", + Namespace: ns.Name, + }), "expected ServiceAccount test-sa to be present in CRB subjects") + + saClient := impersonationClient( + fmt.Sprintf("system:serviceaccount:%s:%s", ns.Name, sa.Name), + nil, + k8sClient.Scheme(), + ) + + newNs := NewNamespace("") + Expect(saClient.Create(context.TODO(), newNs)).To(Succeed()) + TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElements(ns.GetName())) + + Expect(saClient.Delete(context.TODO(), newNs)).To(Not(Succeed())) + + for name, tc := range personas { + By(fmt.Sprintf("trying to promote SA as %s", name)) + + Eventually(func() error { + saCopy := &corev1.ServiceAccount{} + Expect(tc.client.Get(context.TODO(), client.ObjectKeyFromObject(sa), saCopy)).To(Succeed()) + + if saCopy.Labels == nil { + saCopy.Labels = map[string]string{} + } + saCopy.Labels[meta.OwnerPromotionLabel] = "false" + + return tc.client.Update(context.TODO(), saCopy) + }, defaultTimeoutInterval, defaultPollInterval).Should(tc.matcher, "persona=%s", name) + + Eventually(func() (string, error) { + latest := &corev1.ServiceAccount{} + if err := k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(sa), latest); err != nil { + return "", err + } + return latest.Labels[meta.OwnerPromotionLabel], nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Equal("false"), "expected label to be set for persona=%s", name) + + } + + Eventually(func(g Gomega) []rbacv1.Subject { + crb := &rbacv1.ClusterRoleBinding{} + err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: ctrlrbac.ProvisionerRoleName}, crb) + g.Expect(err).NotTo(HaveOccurred()) + + return crb.Subjects + }, defaultTimeoutInterval, defaultPollInterval).Should(Not(ContainElement(rbacv1.Subject{ + Kind: rbacv1.ServiceAccountKind, + Name: "test-sa", + Namespace: ns.Name, + })), "expected ServiceAccount test-sa not to be present in CRB subjects") + + time.Sleep(250 * time.Millisecond) + + secondNs := NewNamespace("") + Eventually(func() error { + return saClient.Create(context.TODO(), secondNs) + }, defaultTimeoutInterval, defaultPollInterval).ShouldNot(Succeed()) + + TenantNamespaceList(tnt, defaultTimeoutInterval).Should(Not(ContainElements(secondNs.GetName()))) + + Expect(saClient.Delete(context.TODO(), secondNs)).To(Not(Succeed())) + + }) + +}) diff --git a/e2e/suite_test.go b/e2e/suite_test.go index 669faeb8..b0610b5a 100644 --- a/e2e/suite_test.go +++ b/e2e/suite_test.go @@ -8,6 +8,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" @@ -73,3 +74,19 @@ func ownerClient(owner capsulev1beta2.OwnerSpec) (cs kubernetes.Interface) { return cs } + +func impersonationClient(user string, groups []string, scheme *runtime.Scheme) client.Client { + c, err := config.GetConfig() + Expect(err).ToNot(HaveOccurred()) + c.Impersonate = rest.ImpersonationConfig{ + UserName: user, + Groups: groups, + } + cl, err := client.New(c, client.Options{Scheme: scheme}) + Expect(err).ToNot(HaveOccurred()) + return cl +} + +func withDefaultGroups(groups []string) []string { + return append([]string{"projectcapsule.dev"}, groups...) +} diff --git a/go.mod b/go.mod index 64b0b8f9..9e50c11f 100644 --- a/go.mod +++ b/go.mod @@ -15,11 +15,12 @@ require ( github.com/valyala/fasttemplate v1.2.2 go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.27.0 - golang.org/x/sync v0.16.0 - k8s.io/api v0.34.0 - k8s.io/apiextensions-apiserver v0.34.0 - k8s.io/apimachinery v0.34.0 - k8s.io/client-go v0.34.0 + golang.org/x/sync v0.17.0 + k8s.io/api v0.34.1 + k8s.io/apiextensions-apiserver v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/apiserver v0.34.1 + k8s.io/client-go v0.34.1 k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d sigs.k8s.io/cluster-api v1.11.1 sigs.k8s.io/controller-runtime v0.22.1 @@ -59,7 +60,6 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.9.0 // indirect @@ -73,24 +73,23 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/term v0.34.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/time v0.12.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/oauth2 v0.31.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.35.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/time v0.13.0 // indirect golang.org/x/tools v0.36.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/protobuf v1.36.9 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250902184714-7fc278399c7f // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/go.sum b/go.sum index 5b18efc7..45559b8e 100644 --- a/go.sum +++ b/go.sum @@ -98,8 +98,6 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= -github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= @@ -134,8 +132,6 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.25.1 h1:Fwp6crTREKM+oA6Cz4MsO8RhKQzs2/gOIVOUscMAfZY= -github.com/onsi/ginkgo/v2 v2.25.1/go.mod h1:ppTWQ1dh9KM/F1XgpeRqelR+zHVwV81DGRSDnFxK7Sk= github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw= github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= @@ -149,18 +145,10 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= -github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= -github.com/prometheus/client_golang v1.23.1 h1:w6gXMLQGgd0jXXlote9lRHMe0nG01EbnJT+C0EJru2Y= -github.com/prometheus/client_golang v1.23.1/go.mod h1:br8j//v2eg2K5Vvna5klK8Ku5pcU5r4ll73v6ik5dIQ= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/common v0.66.0 h1:K/rJPHrG3+AoQs50r2+0t7zMnMzek2Vbv31OFVsMeVY= -github.com/prometheus/common v0.66.0/go.mod h1:Ux6NtV1B4LatamKE63tJBntoxD++xmtI/lK0VtEplN4= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= @@ -224,6 +212,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -231,6 +221,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -241,26 +232,40 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= +golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -281,6 +286,8 @@ google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -288,41 +295,46 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc= k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= +k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/apiserver v0.34.0 h1:Z51fw1iGMqN7uJ1kEaynf2Aec1Y774PqU+FVWCFV3Jg= k8s.io/apiserver v0.34.0/go.mod h1:52ti5YhxAvewmmpVRqlASvaqxt0gKJxvCeW7ZrwgazQ= +k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA= +k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= k8s.io/cluster-bootstrap v0.33.3 h1:u2NTxJ5CFSBFXaDxLQoOWMly8eni31psVso+caq6uwI= k8s.io/cluster-bootstrap v0.33.3/go.mod h1:p970f8u8jf273zyQ5raD8WUu2XyAl0SAWOY82o7i/ds= k8s.io/component-base v0.34.0 h1:bS8Ua3zlJzapklsB1dZgjEJuJEeHjj8yTu1gxE2zQX8= k8s.io/component-base v0.34.0/go.mod h1:RSCqUdvIjjrEm81epPcjQ/DS+49fADvGSCkIP3IC6vg= +k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250814151709-d7b6acb124c3 h1:liMHz39T5dJO1aOKHLvwaCjDbf07wVh6yaUlTpunnkE= -k8s.io/kube-openapi v0.0.0-20250814151709-d7b6acb124c3/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/kube-openapi v0.0.0-20250902184714-7fc278399c7f h1:wyRlmLgBSXi3kgawro8klrMRljXeRo1HFkQRs+meYfs= k8s.io/kube-openapi v0.0.0-20250902184714-7fc278399c7f/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/cluster-api v1.11.0 h1:4ZqKxjhdP3F/vvHMd675rGsDrT/siggnFPt5eKQ8nkI= -sigs.k8s.io/cluster-api v1.11.0/go.mod h1:gGmNlHrtJe3z0YV3J6JRy5Rwh9SfzokjQaS+Fv3DBPE= sigs.k8s.io/cluster-api v1.11.1 h1:7CyGCTxv1p3Y2kRe1ljTj/w4TcdIdWNj0CTBc4i1aBo= sigs.k8s.io/cluster-api v1.11.1/go.mod h1:zyrjgJ5RbXhwKcAdUlGPNK5YOHpcmxXvur+5I8lkMUQ= -sigs.k8s.io/controller-runtime v0.22.0 h1:mTOfibb8Hxwpx3xEkR56i7xSjB+nH4hZG37SrlCY5e0= -sigs.k8s.io/controller-runtime v0.22.0/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY= sigs.k8s.io/controller-runtime v0.22.1 h1:Ah1T7I+0A7ize291nJZdS1CabF/lB4E++WizgV24Eqg= sigs.k8s.io/controller-runtime v0.22.1/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY= sigs.k8s.io/gateway-api v1.3.0 h1:q6okN+/UKDATola4JY7zXzx40WO4VISk7i9DIfOvr9M= diff --git a/pkg/configuration/client.go b/pkg/configuration/client.go index a4868dc0..8b507a3d 100644 --- a/pkg/configuration/client.go +++ b/pkg/configuration/client.go @@ -69,6 +69,10 @@ func (c *capsuleConfiguration) EnableTLSConfiguration() bool { return c.retrievalFn().Spec.EnableTLSReconciler } +func (c *capsuleConfiguration) AllowServiceAccountPromotion() bool { + return c.retrievalFn().Spec.AllowServiceAccountPromotion +} + func (c *capsuleConfiguration) MutatingWebhookConfigurationName() (name string) { return c.retrievalFn().Spec.CapsuleResources.MutatingWebhookConfigurationName } diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index f66ad2a6..0f109f19 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -19,6 +19,7 @@ type Configuration interface { // EnableTLSConfiguration enabled the TLS reconciler, responsible for creating CA and TLS certificate required // for the CRD conversion and webhooks. EnableTLSConfiguration() bool + AllowServiceAccountPromotion() bool TLSSecretName() string MutatingWebhookConfigurationName() string ValidatingWebhookConfigurationName() string diff --git a/pkg/meta/labels.go b/pkg/meta/labels.go index a021f4ef..5347cdae 100644 --- a/pkg/meta/labels.go +++ b/pkg/meta/labels.go @@ -12,6 +12,9 @@ import ( const ( FreezeLabel = "projectcapsule.dev/freeze" FreezeLabelTrigger = "true" + + OwnerPromotionLabel = "owner.projectcapsule.dev/promote" + OwnerPromotionLabelTrigger = "true" ) func FreezeLabelTriggers(obj client.Object) bool { @@ -22,6 +25,14 @@ func FreezeLabelRemove(obj client.Object) { labelRemove(obj, FreezeLabel) } +func OwnerPromotionLabelTriggers(obj client.Object) bool { + return labelTriggers(obj, OwnerPromotionLabel, OwnerPromotionLabelTrigger) +} + +func OwnerPromotionLabelRemove(obj client.Object) { + labelRemove(obj, OwnerPromotionLabel) +} + func labelRemove(obj client.Object, anno string) { annotations := obj.GetLabels() diff --git a/pkg/meta/labels_test.go b/pkg/meta/labels_test.go new file mode 100644 index 00000000..3016f7bc --- /dev/null +++ b/pkg/meta/labels_test.go @@ -0,0 +1,61 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package meta + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestFreezeLabel(t *testing.T) { + ns := &corev1.Namespace{} + ns.SetLabels(map[string]string{}) + + // absent + if FreezeLabelTriggers(ns) { + t.Errorf("expected FreezeLabelTriggers to be false when label is absent") + } + + // set to trigger + ns.Labels[FreezeLabel] = FreezeLabelTrigger + if !FreezeLabelTriggers(ns) { + t.Errorf("expected FreezeLabelTriggers to be true when label is set to trigger") + } + + ns.Labels[FreezeLabel] = "false" + if FreezeLabelTriggers(ns) { + t.Errorf("expected FreezeLabelTriggers to be false when label is not set to trigger") + } + + // remove + FreezeLabelRemove(ns) + if _, ok := ns.Labels[FreezeLabel]; ok { + t.Errorf("expected FreezeLabel to be removed") + } +} + +func TestOwnerPromotionLabel(t *testing.T) { + ns := &corev1.Namespace{} + ns.SetLabels(map[string]string{}) + + if OwnerPromotionLabelTriggers(ns) { + t.Errorf("expected OwnerPromotionLabelTriggers to be false when label is absent") + } + + ns.Labels[OwnerPromotionLabel] = OwnerPromotionLabelTrigger + if !OwnerPromotionLabelTriggers(ns) { + t.Errorf("expected OwnerPromotionLabelTriggers to be true when label is set to trigger") + } + + ns.Labels[OwnerPromotionLabel] = "false" + if OwnerPromotionLabelTriggers(ns) { + t.Errorf("expected OwnerPromotionLabelTriggers to be false when label is not set to trigger") + } + + OwnerPromotionLabelRemove(ns) + if _, ok := ns.Labels[OwnerPromotionLabel]; ok { + t.Errorf("expected OwnerPromotionLabel to be removed") + } +} diff --git a/pkg/webhook/namespace/mutation/ownerreference.go b/pkg/webhook/namespace/mutation/ownerreference.go index c51ab022..2ae7c492 100644 --- a/pkg/webhook/namespace/mutation/ownerreference.go +++ b/pkg/webhook/namespace/mutation/ownerreference.go @@ -60,7 +60,12 @@ func (h *ownerReferenceHandler) OnUpdate(c client.Client, decoder admission.Deco return utils.ErroredResponse(err) } - if !h.namespaceIsOwned(oldNs, tntList, req) { + ok, err := h.namespaceIsOwned(ctx, c, oldNs, tntList, req) + if err != nil { + return utils.ErroredResponse(err) + } + + if !ok { recorder.Eventf(oldNs, corev1.EventTypeWarning, "OfflimitNamespace", "Namespace %s can not be patched", oldNs.GetName()) response := admission.Denied("Denied patch request for this namespace") @@ -109,20 +114,25 @@ func (h *ownerReferenceHandler) OnUpdate(c client.Client, decoder admission.Deco } } -func (h *ownerReferenceHandler) namespaceIsOwned(ns *corev1.Namespace, tenantList *capsulev1beta2.TenantList, req admission.Request) bool { +func (h *ownerReferenceHandler) namespaceIsOwned(ctx context.Context, c client.Client, ns *corev1.Namespace, tenantList *capsulev1beta2.TenantList, req admission.Request) (bool, error) { for _, tenant := range tenantList.Items { for _, ownerRef := range ns.OwnerReferences { if !capsuleutils.IsTenantOwnerReference(ownerRef) { continue } - if ownerRef.UID == tenant.UID && utils.IsTenantOwner(tenant.Spec.Owners, req.UserInfo) { - return true + ok, err := utils.IsTenantOwner(ctx, c, &tenant, req.UserInfo, h.cfg.AllowServiceAccountPromotion()) + if err != nil { + return false, err + } + + if ownerRef.UID == tenant.UID && ok { + return true, nil } } } - return false + return false, nil } func (h *ownerReferenceHandler) setOwnerRef(ctx context.Context, req admission.Request, client client.Client, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response { diff --git a/pkg/webhook/namespace/mutation/utils.go b/pkg/webhook/namespace/mutation/utils.go index 1c13b6df..e48c813b 100644 --- a/pkg/webhook/namespace/mutation/utils.go +++ b/pkg/webhook/namespace/mutation/utils.go @@ -12,6 +12,8 @@ import ( v1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/client" @@ -19,6 +21,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/configuration" + "github.com/projectcapsule/capsule/pkg/meta" capsuleutils "github.com/projectcapsule/capsule/pkg/utils" "github.com/projectcapsule/capsule/pkg/webhook/utils" ) @@ -46,7 +49,7 @@ func getNamespaceTenant( cfg configuration.Configuration, recorder record.EventRecorder, ) (*capsulev1beta2.Tenant, *admission.Response) { - tenant, errResponse := getTenantByLabels(ctx, client, ns, req, recorder) + tenant, errResponse := getTenantByLabels(ctx, client, ns, req, cfg, recorder) if errResponse != nil { return nil, errResponse } @@ -67,6 +70,7 @@ func getTenantByLabels( client client.Client, ns *corev1.Namespace, req admission.Request, + cfg configuration.Configuration, recorder record.EventRecorder, ) (*capsulev1beta2.Tenant, *admission.Response) { ln, err := capsuleutils.GetTypeLabel(&capsulev1beta2.Tenant{}) @@ -85,8 +89,15 @@ func getTenantByLabels( return nil, &response } - // Tenant owner must adhere to user that asked for NS creation - if !utils.IsTenantOwner(tnt.Spec.Owners, req.UserInfo) { + + ok, err := utils.IsTenantOwner(ctx, client, tnt, req.UserInfo, cfg.AllowServiceAccountPromotion()) + if err != nil { + response := admission.Errored(http.StatusBadRequest, err) + + return nil, &response + } + + if !ok { recorder.Eventf(tnt, corev1.EventTypeWarning, "NonOwnedTenant", "Namespace %s cannot be assigned to the current Tenant", ns.GetName()) response := admission.Denied("Cannot assign the desired namespace to a non-owned Tenant") @@ -102,6 +113,8 @@ func getTenantByLabels( } // getTenantByUserInfo returns tenant list associated with admission request userinfo. +// +//nolint:nestif func getTenantByUserInfo( ctx context.Context, ns *corev1.Namespace, @@ -141,6 +154,16 @@ func getTenantByUserInfo( } tenants = append(tenants, saTntList.Items...) + + if cfg.AllowServiceAccountPromotion() { + if tnt, err := resolveServiceAccountActor(ctx, ns, userInfo, clt, cfg); err != nil { + response := admission.Errored(http.StatusBadRequest, err) + + return nil, &response + } else if tnt != nil { + tenants = append(tenants, *tnt) + } + } } // Group tenants. @@ -195,6 +218,47 @@ func getTenantByUserInfo( return nil, nil } +func resolveServiceAccountActor( + ctx context.Context, + ns *corev1.Namespace, + userInfo v1.UserInfo, + clt client.Client, + cfg configuration.Configuration, +) (tnt *capsulev1beta2.Tenant, err error) { + parts := strings.Split(userInfo.Username, ":") + if len(parts) != 4 { + return + } + + namespace, saName := parts[2], parts[3] + + sa := &corev1.ServiceAccount{} + if err = clt.Get(ctx, client.ObjectKey{Namespace: namespace, Name: saName}, sa); err != nil { + if apierrors.IsNotFound(err) { + return + } + + return + } + + if meta.OwnerPromotionLabelTriggers(ns) { + return + } + + tntList := &capsulev1beta2.TenantList{} + if err = clt.List(ctx, tntList, client.MatchingFieldsSelector{ + Selector: fields.OneTermEqualSelector(".status.namespaces", namespace), + }); err != nil { + return + } + + if len(tntList.Items) > 0 { + tnt = &tntList.Items[0] + } + + return +} + func validateNamespacePrefix(ns *corev1.Namespace, tenant *capsulev1beta2.Tenant) bool { // Check if ForceTenantPrefix is true if tenant.Spec.ForceTenantPrefix != nil && *tenant.Spec.ForceTenantPrefix { diff --git a/pkg/webhook/namespace/validation/patch.go b/pkg/webhook/namespace/validation/patch.go index 26a0dfcb..5531ea39 100644 --- a/pkg/webhook/namespace/validation/patch.go +++ b/pkg/webhook/namespace/validation/patch.go @@ -15,15 +15,18 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/configuration" capsuleutils "github.com/projectcapsule/capsule/pkg/utils" capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook" "github.com/projectcapsule/capsule/pkg/webhook/utils" ) -type patchHandler struct{} +type patchHandler struct { + configuration configuration.Configuration +} -func PatchHandler() capsulewebhook.Handler { - return &patchHandler{} +func PatchHandler(configuration configuration.Configuration) capsulewebhook.Handler { + return &patchHandler{configuration: configuration} } func (r *patchHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func { @@ -66,7 +69,14 @@ func (r *patchHandler) OnUpdate(c client.Client, decoder admission.Decoder, reco return &response } - if utils.IsTenantOwner(tnt.Spec.Owners, req.UserInfo) { + ok, err := utils.IsTenantOwner(ctx, c, tnt, req.UserInfo, r.configuration.AllowServiceAccountPromotion()) + if err != nil { + response := admission.Errored(http.StatusBadRequest, err) + + return &response + } + + if ok { return nil } } diff --git a/pkg/webhook/route/serviceaccounts.go b/pkg/webhook/route/serviceaccounts.go new file mode 100644 index 00000000..bb8c30ca --- /dev/null +++ b/pkg/webhook/route/serviceaccounts.go @@ -0,0 +1,24 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package route + +import ( + capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook" +) + +type serviceaccounts struct { + handlers []capsulewebhook.Handler +} + +func ServiceAccounts(handler ...capsulewebhook.Handler) capsulewebhook.Webhook { + return &serviceaccounts{handlers: handler} +} + +func (w *serviceaccounts) GetHandlers() []capsulewebhook.Handler { + return w.handlers +} + +func (w *serviceaccounts) GetPath() string { + return "/serviceaccounts" +} diff --git a/pkg/webhook/serviceaccounts/validating.go b/pkg/webhook/serviceaccounts/validating.go new file mode 100644 index 00000000..43e77e37 --- /dev/null +++ b/pkg/webhook/serviceaccounts/validating.go @@ -0,0 +1,93 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package serviceaccounts + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/configuration" + "github.com/projectcapsule/capsule/pkg/meta" + capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook" + "github.com/projectcapsule/capsule/pkg/webhook/utils" +) + +type handler struct { + cfg configuration.Configuration +} + +func Handler(cfg configuration.Configuration) capsulewebhook.Handler { + return &handler{cfg: cfg} +} + +func (r *handler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + return r.handle(ctx, client, decoder, req, recorder) + } +} + +func (r *handler) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + return r.handle(ctx, client, decoder, req, recorder) + } +} + +func (r *handler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func { + return func(context.Context, admission.Request) *admission.Response { + return nil + } +} + +func (r *handler) handle(ctx context.Context, clt client.Client, decoder admission.Decoder, req admission.Request, recorder record.EventRecorder) *admission.Response { + sa := &corev1.ServiceAccount{} + if err := decoder.Decode(req, sa); err != nil { + return utils.ErroredResponse(err) + } + + tntList := &capsulev1beta2.TenantList{} + if err := clt.List(ctx, tntList, client.MatchingFieldsSelector{ + Selector: fields.OneTermEqualSelector(".status.namespaces", sa.GetNamespace()), + }); err != nil { + return utils.ErroredResponse(err) + } + + if len(tntList.Items) == 0 { + return nil + } + + _, hasOwnerPromotion := sa.Labels[meta.OwnerPromotionLabel] + if !hasOwnerPromotion { + return nil + } + + if !r.cfg.AllowServiceAccountPromotion() { + response := admission.Denied( + "service account owner promotion is disabled. Contact your system administrators", + ) + + return &response + } + + // We don't want to allow promoted serviceaccounts to promote other serviceaccounts + allowed, err := utils.IsTenantOwner(ctx, clt, &tntList.Items[0], req.UserInfo, false) + if err != nil { + return utils.ErroredResponse(err) + } + + if allowed { + return nil + } + + response := admission.Denied( + "not permitted to promote serviceaccounts as owners", + ) + + return &response +} diff --git a/pkg/webhook/utils/is_tenant_owner.go b/pkg/webhook/utils/is_tenant_owner.go index 9174a07a..6ad615d1 100644 --- a/pkg/webhook/utils/is_tenant_owner.go +++ b/pkg/webhook/utils/is_tenant_owner.go @@ -4,26 +4,64 @@ package utils import ( + "context" + "strings" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apiserver/pkg/authentication/serviceaccount" + "sigs.k8s.io/controller-runtime/pkg/client" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/meta" ) -func IsTenantOwner(owners capsulev1beta2.OwnerListSpec, userInfo authenticationv1.UserInfo) bool { - for _, owner := range owners { +func IsTenantOwner( + ctx context.Context, + c client.Client, + tenant *capsulev1beta2.Tenant, + userInfo authenticationv1.UserInfo, + promotedServiceAccountOwners bool, +) (bool, error) { + for _, owner := range tenant.Spec.Owners { switch owner.Kind { case capsulev1beta2.UserOwner, capsulev1beta2.ServiceAccountOwner: if userInfo.Username == owner.Name { - return true + return true, nil } case capsulev1beta2.GroupOwner: for _, group := range userInfo.Groups { if group == owner.Name { - return true + return true, nil } } } } - return false + if promotedServiceAccountOwners { + parts := strings.Split(userInfo.Username, ":") + + if len(parts) != 4 { + return false, nil + } + + saList := &corev1.ServiceAccountList{} + if err := c.List(ctx, saList, + client.InNamespace(parts[2]), + client.MatchingLabels{ + meta.OwnerPromotionLabel: meta.OwnerPromotionLabelTrigger, + }); err != nil { + return false, err + } + + for _, sa := range saList.Items { + saName := serviceaccount.ServiceAccountUsernamePrefix + sa.Namespace + ":" + sa.Name + + if userInfo.Username == saName { + return true, nil + } + } + } + + return false, nil }