fix: add serviceaccount validation for tenantowners and tenants (#1966)

* fix(controller): decode old object for delete requests

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

* chore: modernize golang

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

* chore: modernize golang

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

* chore: modernize golang

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

* fix: preserve ca-bundles injected from external providers

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* fix: add serviceaccount validation for tenantowners and tenants

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* fix: add serviceaccount validation for tenantowners and tenants

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* fix(sec): add namespace termination interception

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* fix(sec): add namespace termination interception

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: correct sa check

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* fix(sec): add namespace termination interception

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
This commit is contained in:
Oliver Bähler
2026-06-17 06:32:02 +02:00
committed by GitHub
parent 775115d9ea
commit af2f37793e
21 changed files with 948 additions and 156 deletions
+8
View File
@@ -299,6 +299,14 @@ The following Values have changed key or Value:
| webhooks.hooks.nodes.namespaceSelector | object | `{}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) |
| webhooks.hooks.nodes.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) |
| webhooks.hooks.nodes.opts | object | `{}` | Capsule Hook Options |
| webhooks.hooks.owners.enabled | bool | `true` | Enable the Hook |
| webhooks.hooks.owners.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) |
| webhooks.hooks.owners.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
| webhooks.hooks.owners.matchPolicy | string | `"Exact"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
| webhooks.hooks.owners.namespaceSelector | object | `{}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) |
| webhooks.hooks.owners.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) |
| webhooks.hooks.owners.opts | object | `{}` | Capsule Hook Options |
| webhooks.hooks.owners.reinvocationPolicy | string | `"Never"` | [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) |
| webhooks.hooks.persistentvolumeclaims.enabled | bool | `true` | Enable the Hook |
| webhooks.hooks.persistentvolumeclaims.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) |
| webhooks.hooks.persistentvolumeclaims.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
+43 -1
View File
@@ -102,7 +102,7 @@ spec:
resources:
- namespaces
- namespaces/status
- namespace/finalize
- namespaces/finalize
scope: '*'
sideEffects: None
timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }}
@@ -577,6 +577,48 @@ spec:
timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }}
{{- end }}
{{- end }}
{{- with .Values.webhooks.hooks.owners }}
{{- if .enabled }}
{{- $any = true }}
- name: owners.validating.projectcapsule.dev
{{- with .opts }}
opts:
{{- toYaml . | nindent 10 }}
{{- end }}
admissionReviewVersions:
- v1
- v1beta1
path: "/tenantowners/validating"
failurePolicy: {{ .failurePolicy }}
matchPolicy: {{ .matchPolicy }}
{{- with .namespaceSelector }}
namespaceSelector:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- with .objectSelector }}
objectSelector:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- with .matchConditions }}
matchConditions:
{{- toYaml . | nindent 10 }}
{{- end }}
rules:
- apiGroups:
- capsule.clastix.io
apiVersions:
- v1beta2
operations:
- CREATE
- UPDATE
- DELETE
resources:
- tenantowners
scope: 'Cluster'
sideEffects: None
timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }}
{{- end }}
{{- end }}
{{- with .Values.webhooks.hooks.resourcepools.pools }}
{{- if .enabled }}
{{- $any = true }}
+2
View File
@@ -232,6 +232,8 @@ rules:
- ""
resources:
- "namespaces"
- "namespaces/status"
- "namespaces/finalize"
verbs:
- "get"
- "list"
+37
View File
@@ -1739,6 +1739,43 @@
}
}
},
"owners": {
"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"
},
"objectSelector": {
"description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)",
"type": "object"
},
"opts": {
"description": "Capsule Hook Options",
"type": "object"
},
"reinvocationPolicy": {
"description": "[ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy)",
"type": "string"
}
}
},
"persistentvolumeclaims": {
"type": "object",
"properties": {
+20
View File
@@ -869,6 +869,26 @@ webhooks:
# -- [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy)
reinvocationPolicy: Never
owners:
# -- Enable the Hook
enabled: true
# -- Capsule Hook Options
opts: {}
# -- [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: {}
# -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)
matchConditions: []
# -- [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy)
reinvocationPolicy: Never
config:
# -- Enable the Hook
enabled: true
+6 -1
View File
@@ -69,6 +69,7 @@ import (
namespacemutation "github.com/projectcapsule/capsule/internal/webhook/namespace/mutation"
namespacevalidation "github.com/projectcapsule/capsule/internal/webhook/namespace/validation"
"github.com/projectcapsule/capsule/internal/webhook/node"
"github.com/projectcapsule/capsule/internal/webhook/owners"
"github.com/projectcapsule/capsule/internal/webhook/pod"
"github.com/projectcapsule/capsule/internal/webhook/pvc"
"github.com/projectcapsule/capsule/internal/webhook/resourcepool"
@@ -611,13 +612,16 @@ func main() {
tenantvalidation.RuleHandler(),
tenantvalidation.HostnameRegexHandler(),
tenantvalidation.FreezedEmitter(),
tenantvalidation.ServiceAccountNameHandler(),
tenantvalidation.OwnersHandler(),
tenantvalidation.ForbiddenAnnotationsRegexHandler(),
tenantvalidation.ProtectedHandler(),
tenantvalidation.RequiredMetadataHandler(),
tenantvalidation.WarningHandler(cfg),
),
),
route.TenantOwnersValidation(
owners.UserMetadataHandler(),
),
route.NamespaceValidation(
namespacevalidation.NamespaceHandler(
cfg,
@@ -661,6 +665,7 @@ func main() {
cfgvalidation.Handler(cfg,
cfgvalidation.WarningHandler(),
cfgvalidation.ServiceAccountHandler(),
cfgvalidation.OwnerHandler(),
),
),
)
+260
View File
@@ -104,6 +104,95 @@ var _ = Describe("creating several Namespaces for a Tenant", Ordered, Label("nam
},
}
grantNamespaceSubresourceUpdate := func(name string, subject rbacv1.Subject) {
clusterRole := &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{""},
Resources: []string{
"namespaces/status",
"namespaces/finalize",
},
Verbs: []string{
"get",
"update",
"patch",
},
},
{
APIGroups: []string{""},
Resources: []string{
"namespaces",
},
Verbs: []string{
"get",
"update",
"patch",
},
},
},
}
clusterRoleBinding := &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
Subjects: []rbacv1.Subject{
subject,
},
RoleRef: rbacv1.RoleRef{
APIGroup: rbacv1.GroupName,
Kind: "ClusterRole",
Name: name,
},
}
EventuallyCreation(func() error {
return k8sClient.Create(context.TODO(), clusterRole)
}).Should(Succeed())
EventuallyCreation(func() error {
return k8sClient.Create(context.TODO(), clusterRoleBinding)
}).Should(Succeed())
}
cleanupNamespaceSubresourceGrant := func(name string) {
Eventually(func() error {
return k8sClient.Delete(context.TODO(), &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
})
}).Should(SatisfyAny(Succeed(), WithTransform(apierrors.IsNotFound, BeTrue())))
Eventually(func() error {
return k8sClient.Delete(context.TODO(), &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
})
}).Should(SatisfyAny(Succeed(), WithTransform(apierrors.IsNotFound, BeTrue())))
}
expectNoTenantHijackPersisted := func(nsName string, originalTenant, attackerTenant *capsulev1beta2.Tenant) {
Eventually(func(g Gomega) {
current := &corev1.Namespace{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: nsName}, current)).Should(Succeed())
g.Expect(current.Labels).To(HaveKeyWithValue(meta.TenantLabel, originalTenant.GetName()))
g.Expect(current.Labels).NotTo(HaveKeyWithValue(meta.TenantLabel, attackerTenant.GetName()))
g.Expect(hasTenantOwnerReferenceByNameAndUID(current, originalTenant.GetName(), originalTenant.GetUID())).
To(BeTrue(), "namespace should keep original tenant ownerReference")
g.Expect(hasTenantOwnerReferenceByNameAndUID(current, attackerTenant.GetName(), attackerTenant.GetUID())).
To(BeFalse(), "namespace must not gain attacker tenant ownerReference")
}).Should(Succeed())
}
kubeSystem := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "kube-system",
@@ -198,6 +287,177 @@ var _ = Describe("creating several Namespaces for a Tenant", Ordered, Label("nam
EventuallyDeletion(t3)
})
It("Owners can not hijack Tenant ownership through namespaces/status", func() {
tenantA := getTenant(t1.Name)
tenantB := getTenant(t2.Name)
owner := t1.Spec.Owners[0].UserSpec
cs := ownerClient(owner)
grantName := "e2e-ns-status-hijack"
grantNamespaceSubresourceUpdate(grantName, rbacv1.Subject{
APIGroup: rbacv1.GroupName,
Kind: rbacv1.UserKind,
Name: owner.Name,
})
DeferCleanup(func() {
cleanupNamespaceSubresourceGrant(grantName)
})
ns := NewNamespace("", map[string]string{
meta.TenantLabel: tenantA.GetName(),
})
NamespaceCreation(ns, owner, defaultTimeoutInterval).Should(Succeed())
NamespaceIsPartOfTenant(t1, ns).Should(Succeed())
current, err := cs.CoreV1().Namespaces().Get(
context.TODO(),
ns.Name,
metav1.GetOptions{},
)
Expect(err).ToNot(HaveOccurred())
hijacked := current.DeepCopy()
if hijacked.Labels == nil {
hijacked.Labels = map[string]string{}
}
hijacked.Labels[meta.TenantLabel] = tenantB.GetName()
replaced := false
for i := range hijacked.OwnerReferences {
ref := &hijacked.OwnerReferences[i]
if ref.APIVersion != capsulev1beta2.GroupVersion.String() {
continue
}
if ref.Kind != "Tenant" {
continue
}
if ref.Name != tenantA.GetName() {
continue
}
if ref.UID != tenantA.GetUID() {
continue
}
ref.Name = tenantB.GetName()
ref.UID = tenantB.GetUID()
replaced = true
break
}
Expect(replaced).To(BeTrue(), "expected test namespace to contain Tenant ownerReference for %q", tenantA.GetName())
Expect(hasTenantOwnerReferenceByNameAndUID(hijacked, tenantA.GetName(), tenantA.GetUID())).
To(BeFalse(), "hijack payload should no longer contain original Tenant ownerReference")
Expect(hasTenantOwnerReferenceByNameAndUID(hijacked, tenantB.GetName(), tenantB.GetUID())).
To(BeTrue(), "hijack payload should contain attacker Tenant ownerReference")
_, err = cs.CoreV1().Namespaces().UpdateStatus(
context.TODO(),
hijacked,
metav1.UpdateOptions{},
)
if err != nil {
By(fmt.Sprintf("namespaces/status hijack attempt was rejected: %v", err))
}
expectNoTenantHijackPersisted(ns.Name, tenantA, tenantB)
})
It("Owners can not hijack Tenant ownership through namespaces/finalize", func() {
tenantA := getTenant(t1.Name)
tenantB := getTenant(t2.Name)
owner := t1.Spec.Owners[0].UserSpec
cs := ownerClient(owner)
grantName := "e2e-ns-finalize-hijack"
grantNamespaceSubresourceUpdate(grantName, rbacv1.Subject{
APIGroup: rbacv1.GroupName,
Kind: rbacv1.UserKind,
Name: owner.Name,
})
DeferCleanup(func() {
cleanupNamespaceSubresourceGrant(grantName)
})
ns := NewNamespace("", map[string]string{
meta.TenantLabel: tenantA.GetName(),
})
NamespaceCreation(ns, owner, defaultTimeoutInterval).Should(Succeed())
NamespaceIsPartOfTenant(t1, ns).Should(Succeed())
current, err := cs.CoreV1().Namespaces().Get(
context.TODO(),
ns.Name,
metav1.GetOptions{},
)
Expect(err).ToNot(HaveOccurred())
hijacked := current.DeepCopy()
if hijacked.Labels == nil {
hijacked.Labels = map[string]string{}
}
hijacked.Labels[meta.TenantLabel] = tenantB.GetName()
replaced := false
for i := range hijacked.OwnerReferences {
ref := &hijacked.OwnerReferences[i]
if ref.APIVersion != capsulev1beta2.GroupVersion.String() {
continue
}
if ref.Kind != "Tenant" {
continue
}
if ref.Name != tenantA.GetName() {
continue
}
if ref.UID != tenantA.GetUID() {
continue
}
ref.Name = tenantB.GetName()
ref.UID = tenantB.GetUID()
replaced = true
break
}
Expect(replaced).To(BeTrue(), "expected test namespace to contain Tenant ownerReference for %q", tenantA.GetName())
Expect(hasTenantOwnerReferenceByNameAndUID(hijacked, tenantA.GetName(), tenantA.GetUID())).
To(BeFalse(), "hijack payload should no longer contain original Tenant ownerReference")
Expect(hasTenantOwnerReferenceByNameAndUID(hijacked, tenantB.GetName(), tenantB.GetUID())).
To(BeTrue(), "hijack payload should contain attacker Tenant ownerReference")
_, err = cs.CoreV1().Namespaces().Finalize(
context.TODO(),
hijacked,
metav1.UpdateOptions{},
)
if err != nil {
By(fmt.Sprintf("namespaces/finalize hijack attempt was rejected: %v", err))
}
expectNoTenantHijackPersisted(ns.Name, tenantA, tenantB)
})
It("Owners can not add a second Tenant ownerReference to a managed namespace", func() {
tenantA := getTenant(t1.Name)
tenantB := getTenant(t2.Name)
+32
View File
@@ -1202,3 +1202,35 @@ func holdNamespaceTerminating(ctx context.Context, name string) func() {
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
}
}
func hasTenantOwnerReferenceByNameAndUID(
obj metav1.Object,
tenantName string,
tenantUID types.UID,
) bool {
if obj == nil {
return false
}
for _, ref := range obj.GetOwnerReferences() {
if ref.APIVersion != capsulev1beta2.GroupVersion.String() {
continue
}
if ref.Kind != "Tenant" {
continue
}
if ref.Name != tenantName {
continue
}
if ref.UID != tenantUID {
continue
}
return true
}
return false
}
@@ -1,6 +1,16 @@
---
apiVersion: capsule.clastix.io/v1beta2
kind: TenantOwner
metadata:
labels:
team: devops
name: devops-user
spec:
kind: User
name: "some@user.com"
---
apiVersion: capsule.clastix.io/v1beta2
kind: TenantOwner
metadata:
labels:
team: devops
+11 -1
View File
@@ -20,6 +20,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
@@ -107,6 +108,8 @@ func (r *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res
}
func (r *Manager) EnsureClusterRoleBindingsProvisioner(ctx context.Context) error {
log := log.FromContext(ctx)
cfg := r.Configuration.RBAC()
crb := &rbacv1.ClusterRoleBinding{
@@ -152,7 +155,14 @@ func (r *Manager) EnsureClusterRoleBindingsProvisioner(ctx context.Context) erro
case rbac.ServiceAccountOwner:
namespace, name, err := serviceaccount.SplitUsername(entity.Name)
if err != nil {
return err
log.Error(
err,
"can not parse serviceaccount reference",
"subject", entity.Name,
"kind", entity.Kind,
)
continue
}
crb.Subjects = append(crb.Subjects, rbacv1.Subject{
+12 -1
View File
@@ -627,7 +627,18 @@ func (r *globalResourceController) updateReconcilingStatus(ctx context.Context,
latest.Status.Conditions.UpdateConditionByType(meta.NewReadyConditionReconcilingReason(instance))
return r.client.Status().Update(ctx, latest)
if err := r.client.Status().Update(ctx, latest); err != nil {
if apierrors.IsNotFound(err) {
return nil
}
return err
}
// Keep the in-memory object aligned with what we just wrote.
instance.Status = latest.Status
return nil
})
}
+12 -1
View File
@@ -635,7 +635,18 @@ func (r *namespacedResourceController) updateReconcilingStatus(ctx context.Conte
latest.Status.Conditions.UpdateConditionByType(meta.NewReadyConditionReconcilingReason(instance))
return r.client.Status().Update(ctx, latest)
if err := r.client.Status().Update(ctx, latest); err != nil {
if apierrors.IsNotFound(err) {
return nil
}
return err
}
// Keep the in-memory object aligned with what we just wrote.
instance.Status = latest.Status
return nil
})
}
+143 -42
View File
@@ -25,28 +25,136 @@ import (
)
// Ensuring all annotations are applied to each Namespace handled by the Tenant.
func (r *Manager) reconcileNamespaces(ctx context.Context, log logr.Logger, tnt *capsulev1beta2.Tenant) (err error) {
func (r *Manager) reconcileNamespaces(
ctx context.Context,
log logr.Logger,
tnt *capsulev1beta2.Tenant,
) error {
if tnt.DeletionTimestamp != nil {
for _, ns := range tnt.Status.Spaces {
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: ns.Name,
},
}
if err := r.Delete(ctx, ns, &client.DeleteOptions{
PropagationPolicy: ptr.To(metav1.DeletePropagationBackground),
}); err != nil && !apierrors.IsNotFound(err) {
log.Error(err, "unable to delete tenant namespace",
"tenant", tnt.GetName(),
"namespace", ns.Name,
)
return err
}
}
return r.reconcileDeletingTenantNamespaces(ctx, log, tnt)
}
return r.reconcileActiveTenantNamespaces(ctx, log, tnt)
}
func (r *Manager) reconcileDeletingTenantNamespaces(
ctx context.Context,
log logr.Logger,
tnt *capsulev1beta2.Tenant,
) error {
group, ctx := errgroup.WithContext(ctx)
group.SetLimit(8)
results := make(chan *capsulev1beta2.TenantStatusNamespaceItem, len(tnt.Status.Spaces))
removed := make(chan string, len(tnt.Status.Spaces))
errs := make(chan error, len(tnt.Status.Spaces))
for i := range tnt.Status.Spaces {
statusNamespace := tnt.Status.Spaces[i]
group.Go(func() error {
namespace := &corev1.Namespace{}
err := r.Get(ctx, client.ObjectKey{Name: statusNamespace.Name}, namespace)
if apierrors.IsNotFound(err) {
removed <- statusNamespace.Name
return nil
}
if err != nil {
errs <- fmt.Errorf("get namespace %q: %w", statusNamespace.Name, err)
return nil
}
if namespace.DeletionTimestamp == nil {
if err := r.Delete(ctx, namespace, &client.DeleteOptions{
PropagationPolicy: ptr.To(metav1.DeletePropagationBackground),
}); err != nil && !apierrors.IsNotFound(err) {
log.Error(err, "unable to delete tenant namespace",
"tenant", tnt.GetName(),
"namespace", namespace.GetName(),
)
errs <- fmt.Errorf("delete namespace %q: %w", namespace.Name, err)
return nil
}
latest := &corev1.Namespace{}
err := r.Get(ctx, client.ObjectKey{Name: namespace.Name}, latest)
if apierrors.IsNotFound(err) {
removed <- statusNamespace.Name
return nil
}
if err != nil {
errs <- fmt.Errorf("get namespace %q after delete: %w", namespace.Name, err)
return nil
}
namespace = latest
}
stat, err := r.reconcileNamespace(ctx, log, namespace, tnt)
if stat != nil {
results <- stat
}
if err != nil {
log.Error(err, "failed to reconcile deleting namespace",
"tenant", tnt.GetName(),
"namespace", namespace.GetName(),
)
errs <- fmt.Errorf("namespace %q: %w", namespace.Name, err)
}
return nil
})
}
_ = group.Wait()
close(results)
close(removed)
close(errs)
for name := range removed {
r.Metrics.DeleteAllMetricsForNamespace(name)
tnt.Status.RemoveInstance(&capsulev1beta2.TenantStatusNamespaceItem{
Name: name,
})
}
for stat := range results {
if stat == nil {
continue
}
tnt.Status.UpdateInstance(stat)
}
var joined []error
for itemErr := range errs {
joined = append(joined, itemErr)
}
tnt.Status.Size = uint(len(tnt.Status.Spaces))
return errors.Join(joined...)
}
func (r *Manager) reconcileActiveTenantNamespaces(
ctx context.Context,
log logr.Logger,
tnt *capsulev1beta2.Tenant,
) error {
list := &corev1.NamespaceList{}
if err := r.List(ctx, list, client.MatchingFields{".metadata.ownerReferences[*].capsule": tnt.GetName()}); err != nil {
return err
@@ -95,7 +203,7 @@ func (r *Manager) reconcileNamespaces(ctx context.Context, log logr.Logger, tnt
joined = append(joined, itemErr)
}
err = errors.Join(joined...)
err := errors.Join(joined...)
desiredStatus := make(map[string]struct{}, len(list.Items))
@@ -126,7 +234,12 @@ func (r *Manager) reconcileNamespaces(ctx context.Context, log logr.Logger, tnt
return err
}
func (r *Manager) reconcileNamespace(ctx context.Context, log logr.Logger, namespace *corev1.Namespace, tnt *capsulev1beta2.Tenant) (
func (r *Manager) reconcileNamespace(
ctx context.Context,
log logr.Logger,
namespace *corev1.Namespace,
tnt *capsulev1beta2.Tenant,
) (
stat *capsulev1beta2.TenantStatusNamespaceItem,
err error,
) {
@@ -144,18 +257,8 @@ func (r *Manager) reconcileNamespace(ctx context.Context, log logr.Logger, names
stat = instance
}
dropFromStatus := false
// Always update tenant status condition after reconciliation
// Always update tenant status condition after reconciliation.
defer func() {
if dropFromStatus {
stat = nil
r.Metrics.DeleteAllMetricsForNamespace(namespace.GetName())
return
}
readCondition := meta.NewReadyCondition(namespace)
switch {
@@ -192,7 +295,7 @@ func (r *Manager) reconcileNamespace(ctx context.Context, log logr.Logger, names
r.syncNamespaceStatusMetrics(tnt, namespace)
}()
// Verify if namespace is still active or terminating
// Verify if namespace is still active or terminating.
if namespace.DeletionTimestamp != nil {
terminating = true
@@ -236,17 +339,15 @@ func (r *Manager) reconcileNamespace(ctx context.Context, log logr.Logger, names
return stat, nil
}
terminatingState.Message = "removed managed resources"
terminatingState.Reason = meta.TerminatingReason
terminatingState.Status = metav1.ConditionFalse
terminatingState.Message = "waiting for namespace finalization"
stat.Conditions.UpdateConditionByType(terminatingState)
r.Metrics.DeleteAllMetricsForNamespace(namespace.GetName())
dropFromStatus = true
return nil, nil
return stat, nil
}
// Collect Rules for namespace
// Collect Rules for namespace.
err = r.reconcileRuleStatus(ctx, log, tnt, namespace)
if err != nil {
return stat, err
@@ -292,7 +393,7 @@ func (r *Manager) reconcileNamespaceMetadata(
managedMetadataOnly := tnt.Spec.NamespaceOptions != nil && tnt.Spec.NamespaceOptions.ManagedMetadataOnly
// Handle User-Defined Metadata, if allowed
// Handle User-Defined Metadata, if allowed.
if !managedMetadataOnly {
if originLabels != nil {
maps.Copy(originLabels, managedLabels)
@@ -302,7 +403,7 @@ func (r *Manager) reconcileNamespaceMetadata(
maps.Copy(originAnnotations, managedAnnotations)
}
// Cleanup old Metadata
// Cleanup old Metadata.
instance := tnt.Status.GetInstance(stat)
if instance != nil && instance.Metadata != nil {
for label := range instance.Metadata.Labels {
@@ -1,11 +1,10 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
package cfg
import (
"context"
"regexp"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -14,32 +13,31 @@ import (
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
ad "github.com/projectcapsule/capsule/pkg/runtime/admission"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
"github.com/projectcapsule/capsule/pkg/tenant"
)
var compiler *regexp.Regexp = regexp.MustCompile(`^.*:.*:.*(:.*)?$`)
type ownerHandler struct{}
type saNameHandler struct{}
func ServiceAccountNameHandler() handlers.TypedHandler[*capsulev1beta2.Tenant] {
return &saNameHandler{}
func OwnerHandler() handlers.TypedHandler[*capsulev1beta2.CapsuleConfiguration] {
return &ownerHandler{}
}
func (h *saNameHandler) OnCreate(
func (h *ownerHandler) OnCreate(
_ client.Client,
_ client.Reader,
tnt *capsulev1beta2.Tenant,
cfg *capsulev1beta2.CapsuleConfiguration,
_ admission.Decoder,
_ events.EventRecorder,
) handlers.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
return h.validateServiceAccountName(tnt, req)
return h.handle(cfg, req)
}
}
func (h *saNameHandler) OnDelete(
func (h *ownerHandler) OnDelete(
client.Client,
client.Reader,
*capsulev1beta2.Tenant,
*capsulev1beta2.CapsuleConfiguration,
admission.Decoder,
events.EventRecorder,
) handlers.Func {
@@ -48,27 +46,25 @@ func (h *saNameHandler) OnDelete(
}
}
func (h *saNameHandler) OnUpdate(
func (h *ownerHandler) OnUpdate(
_ client.Client,
_ client.Reader,
tnt *capsulev1beta2.Tenant,
old *capsulev1beta2.Tenant,
cfg *capsulev1beta2.CapsuleConfiguration,
old *capsulev1beta2.CapsuleConfiguration,
_ admission.Decoder,
_ events.EventRecorder,
) handlers.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
return h.validateServiceAccountName(tnt, req)
return h.handle(cfg, req)
}
}
func (h *saNameHandler) validateServiceAccountName(tnt *capsulev1beta2.Tenant, req admission.Request) *admission.Response {
for _, owner := range tnt.Spec.Owners {
if owner.Kind != "ServiceAccount" {
continue
}
if !compiler.MatchString(owner.Name) {
return ad.Denyf("owner name %s is not a valid Service Account name", owner.Name)
func (h *ownerHandler) handle(config *capsulev1beta2.CapsuleConfiguration, req admission.Request) *admission.Response {
for _, owner := range config.Spec.Users {
if err := tenant.ValidateTenantOwner(owner); err != nil {
return ad.Deny(
err.Error(),
)
}
}
@@ -0,0 +1,78 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package owners
import (
"context"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
ad "github.com/projectcapsule/capsule/pkg/runtime/admission"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
"github.com/projectcapsule/capsule/pkg/tenant"
)
type ownerSubjectHandler struct{}
func UserMetadataHandler() handlers.Handler {
return &ownerSubjectHandler{}
}
func (r *ownerSubjectHandler) OnCreate(
_ client.Client,
_ client.Reader,
decoder admission.Decoder,
_ events.EventRecorder,
) handlers.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
owner := &capsulev1beta2.TenantOwner{}
if err := decoder.Decode(req, owner); err != nil {
return ad.ErroredResponse(err)
}
return r.handle(owner)
}
}
func (r *ownerSubjectHandler) OnUpdate(
_ client.Client,
_ client.Reader,
decoder admission.Decoder,
recorder events.EventRecorder,
) handlers.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
owner := &capsulev1beta2.TenantOwner{}
if err := decoder.Decode(req, owner); err != nil {
return ad.ErroredResponse(err)
}
return r.handle(owner)
}
}
func (r *ownerSubjectHandler) OnDelete(
client.Client,
client.Reader,
admission.Decoder,
events.EventRecorder,
) handlers.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *ownerSubjectHandler) handle(
owner *capsulev1beta2.TenantOwner,
) *admission.Response {
if err := tenant.ValidateTenantOwner(owner.Spec.UserSpec); err != nil {
return ad.Deny(
err.Error(),
)
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package route
import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
type tenantOwnersValidating struct {
handlers []handlers.Handler
}
func TenantOwnersValidation(handler ...handlers.Handler) handlers.Webhook {
return &tenantOwnersValidating{handlers: handler}
}
func (w *tenantOwnersValidating) GetHandlers() []handlers.Handler {
return w.handlers
}
func (w *tenantOwnersValidating) GetPath() string {
return "/tenantowners/validating"
}
@@ -0,0 +1,78 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"context"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
ad "github.com/projectcapsule/capsule/pkg/runtime/admission"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
"github.com/projectcapsule/capsule/pkg/tenant"
)
type ownersHandler struct{}
func OwnersHandler() handlers.TypedHandler[*capsulev1beta2.Tenant] {
return &ownersHandler{}
}
func (h *ownersHandler) OnCreate(
_ client.Client,
_ client.Reader,
tnt *capsulev1beta2.Tenant,
_ admission.Decoder,
_ events.EventRecorder,
) handlers.Func {
return func(context.Context, admission.Request) *admission.Response {
return h.handle(tnt)
}
}
func (h *ownersHandler) OnDelete(
_ client.Client,
_ client.Reader,
tnt *capsulev1beta2.Tenant,
_ admission.Decoder,
_ events.EventRecorder,
) handlers.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
if tnt.Spec.PreventDeletion {
return ad.Deny("tenant is protected and cannot be deleted")
}
return nil
}
}
func (h *ownersHandler) OnUpdate(
_ client.Client,
_ client.Reader,
tnt *capsulev1beta2.Tenant,
_ *capsulev1beta2.Tenant,
_ admission.Decoder,
_ events.EventRecorder,
) handlers.Func {
return func(context.Context, admission.Request) *admission.Response {
return h.handle(tnt)
}
}
func (h *ownersHandler) handle(
tnt *capsulev1beta2.Tenant,
) *admission.Response {
for _, owner := range tnt.Spec.Owners {
if err := tenant.ValidateTenantOwner(owner.UserSpec); err != nil {
return ad.Deny(
err.Error(),
)
}
}
return nil
}
@@ -1,85 +0,0 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"context"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
ad "github.com/projectcapsule/capsule/pkg/runtime/admission"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
namespaceindex "github.com/projectcapsule/capsule/pkg/runtime/indexers/namespace"
)
type remainingNamespaceHandler struct{}
func RemainingNamespaceHandler() handlers.TypedHandler[*capsulev1beta2.Tenant] {
return &remainingNamespaceHandler{}
}
func (h *remainingNamespaceHandler) OnCreate(
client.Client,
client.Reader,
*capsulev1beta2.Tenant,
admission.Decoder,
events.EventRecorder,
) handlers.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
// This happens when a tenant has not yet reconciled it's namespaces but is deleted
// and in the meantime a new namespace was created referencing the same tenant.
func (h *remainingNamespaceHandler) OnDelete(
c client.Client,
_ client.Reader,
tnt *capsulev1beta2.Tenant,
_ admission.Decoder,
_ events.EventRecorder,
) handlers.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
list := &corev1.NamespaceList{}
err := c.List(ctx, list, client.MatchingFields{namespaceindex.OwnerReferenceIndex: tnt.GetName()})
if err != nil {
return ad.ErroredResponse(err)
}
if len(list.Items) == 0 {
return nil
}
for _, ns := range list.Items {
instance := tnt.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{
Name: ns.GetName(),
UID: ns.GetUID(),
})
if instance == nil {
return ad.Deny("tenant has remaining namespace referencing it (" + ns.GetName() + ")")
}
}
return nil
}
}
func (h *remainingNamespaceHandler) OnUpdate(
client.Client,
client.Reader,
*capsulev1beta2.Tenant,
*capsulev1beta2.Tenant,
admission.Decoder,
events.EventRecorder,
) handlers.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
+13
View File
@@ -14,6 +14,7 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/authentication/serviceaccount"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -164,6 +165,18 @@ func (c *capsuleConfiguration) Users() rbac.UserListSpec {
out := rbac.UserListSpec{}
for _, user := range c.UserNames() {
// Old Spec.UserNames may contain ServiceAccount usernames.
// If SplitUsername succeeds, the value is a ServiceAccount.
_, _, err := serviceaccount.SplitUsername(user)
if err == nil {
out.Upsert(rbac.UserSpec{
Kind: rbac.ServiceAccountOwner,
Name: user,
})
continue
}
out.Upsert(rbac.UserSpec{
Kind: rbac.UserOwner,
Name: user,
+11
View File
@@ -86,3 +86,14 @@ func GetOwnersWithKinds(tenant *capsulev1beta2.Tenant) (owners []string) {
func OwnerKindIndexKey(kind, name string) string {
return fmt.Sprintf("%s:%s", kind, name)
}
func ValidateTenantOwner(owner rbac.UserSpec) error {
if owner.Kind == rbac.ServiceAccountOwner {
_, _, err := serviceaccount.SplitUsername(owner.Name)
if err != nil {
return err
}
}
return nil
}
+130
View File
@@ -97,3 +97,133 @@ func TestGetOwnersWithKinds_EmptyNameStillIncluded(t *testing.T) {
t.Fatalf("unexpected owners:\nwant=%v\ngot =%v", want, owners)
}
}
func TestValidateTenantOwner(t *testing.T) {
t.Parallel()
tests := []struct {
name string
owner rbac.CoreOwnerSpec
wantErr bool
}{
{
name: "valid service account owner",
owner: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Kind: rbac.ServiceAccountOwner,
Name: "system:serviceaccount:tenant-a:builder",
},
},
wantErr: false,
},
{
name: "invalid service account owner without serviceaccount prefix",
owner: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Kind: rbac.ServiceAccountOwner,
Name: "tenant-a:builder",
},
},
wantErr: true,
},
{
name: "invalid service account owner with missing namespace",
owner: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Kind: rbac.ServiceAccountOwner,
Name: "system:serviceaccount::builder",
},
},
wantErr: true,
},
{
name: "invalid service account owner with missing name",
owner: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Kind: rbac.ServiceAccountOwner,
Name: "system:serviceaccount:tenant-a:",
},
},
wantErr: true,
},
{
name: "invalid service account owner with plain name",
owner: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Kind: rbac.ServiceAccountOwner,
Name: "builder",
},
},
wantErr: true,
},
{
name: "user owner is not validated as service account",
owner: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Kind: rbac.UserOwner,
Name: "alice",
},
},
wantErr: false,
},
{
name: "group owner is not validated as service account",
owner: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Kind: rbac.GroupOwner,
Name: "developers",
},
},
wantErr: false,
},
{
name: "empty user owner name is currently accepted",
owner: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Kind: rbac.UserOwner,
Name: "",
},
},
wantErr: false,
},
{
name: "empty group owner name is currently accepted",
owner: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Kind: rbac.GroupOwner,
Name: "",
},
},
wantErr: false,
},
{
name: "empty service account owner name is rejected",
owner: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Kind: rbac.ServiceAccountOwner,
Name: "",
},
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tenant.ValidateTenantOwner(tt.owner.UserSpec)
if tt.wantErr && err == nil {
t.Fatalf("ValidateTenantOwner() expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Fatalf("ValidateTenantOwner() unexpected error: %v", err)
}
})
}
}