feat(controller): allow owners to promote serviceaccounts within tenant as owners (#1626)

* feat(controller): allow owners to promote serviceaccounts within tenant as owners

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: remove harpoon

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
This commit is contained in:
Oliver Bähler
2025-09-11 23:12:45 +02:00
committed by GitHub
parent a2e4e00724
commit 9fa1abac65
24 changed files with 978 additions and 87 deletions

View File

@@ -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}" \

View File

@@ -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

View File

@@ -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) |

View File

@@ -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: |-

View File

@@ -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:

View File

@@ -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 }}

View File

@@ -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": {

View File

@@ -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: {}

View File

@@ -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)),

View File

@@ -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
}

View File

@@ -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()))
})
})

View File

@@ -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...)
}

31
go.mod
View File

@@ -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

52
go.sum
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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

View File

@@ -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()

61
pkg/meta/labels_test.go Normal file
View File

@@ -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")
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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
}