feat: add administrator capabilities (#2049)

* feat: implement performance optimizations

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

* feat: add administrator capabilities

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

* feat: add administrator capabilities

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

* chore

* feat: add administrator capabilities

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

---------

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
This commit is contained in:
Oliver Bähler
2026-07-23 00:09:23 +02:00
committed by GitHub
parent 84e8dca31e
commit e8ac32d364
23 changed files with 1441 additions and 196 deletions
+1 -1
View File
@@ -243,7 +243,7 @@ dev-setup-argocd: dev-setup-fluxcd
@printf " \033[1mkubectl get secret -n argocd argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d\033[0m\n\n"
@printf " \033[1mkubectl port-forward svc/argocd-server 9091:80 -n argocd\033[0m\n\n"
dev-setup-cert-manager:
dev-setup-cert-manager: dev-setup-fluxcd
@$(KUBECTL) kustomize --load-restrictor='LoadRestrictionsNone' hack/distro/cert-manager | envsubst | kubectl apply -f -
dev-setup-fluxcd:
-1
View File
@@ -773,7 +773,6 @@ func main() {
namespacemutation.MetadataHandler(cfg),
// Tenant metadata must be resolved before applying namespace rules.
namespacemutation.RulesMetadataHandler(cfg),
namespacemutation.NamespacePatchGuardHandler(cfg),
),
),
route.ResourcePoolMutation(resourcepool.PoolMutationHandler(ctrl.Log.WithName("webhooks").WithName("resourcepool"))),
+8 -6
View File
@@ -195,19 +195,21 @@ var _ = Describe("Administrators", Ordered, Label("namespace", "permissions", "a
By("creating namespace with no label", func() {
ns := NewNamespace("")
NamespaceCreation(ns, admin, defaultTimeoutInterval).Should(Succeed())
DeferCleanup(func() { EventuallyDeletion(ns) })
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
Expect(len(ns.OwnerReferences)).To(Equal(0))
ExpectNamespaceNotAssignedToTenant(context.Background(), ns.Name)
})
By("creating namespace with no label", func() {
By("joining an existing unassigned namespace", func() {
ns := NewNamespace("")
NamespaceCreation(ns, admin, defaultTimeoutInterval).Should(Succeed())
DeferCleanup(func() { EventuallyDeletion(ns) })
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
Expect(len(ns.OwnerReferences)).To(Equal(0))
ExpectNamespaceNotAssignedToTenant(context.Background(), ns.Name)
PatchTenantLabelForNamespace(tnt1, ns, ownerClient(admin), defaultTimeoutInterval).Should(Succeed())
Eventually(func() error {
return PatchTenantAssignmentForNamespace(tnt1, ns, ownerClient(admin))
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
NamespaceIsPartOfTenant(tnt1, ns).Should(Succeed())
})
})
+130
View File
@@ -27,6 +27,13 @@ import (
)
var _ = Describe("creating several Namespaces for a Tenant", Ordered, Label("config", "namespace", "hijack"), func() {
administrator := rbac.UserSpec{
Name: "e2e-namespace-hijacking-admin",
Kind: rbac.UserOwner,
}
var originalConfigurationSpec *capsulev1beta2.CapsuleConfigurationSpec
t1 := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "e2e-ns-attack-1",
@@ -290,6 +297,27 @@ var _ = Describe("creating several Namespaces for a Tenant", Ordered, Label("con
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
}
BeforeAll(func() {
configuration := &capsulev1beta2.CapsuleConfiguration{}
Expect(k8sClient.Get(
context.Background(),
types.NamespacedName{Name: defaultConfigurationName},
configuration,
)).To(Succeed())
originalConfigurationSpec = configuration.Spec.DeepCopy()
ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) {
configuration.Spec.Administrators = append(configuration.Spec.Administrators, administrator)
})
})
AfterAll(func() {
ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) {
configuration.Spec = *originalConfigurationSpec
})
})
JustBeforeEach(func() {
waitForTenantNamespacesDeletion(t1.Name, t2.Name, t3.Name)
@@ -1096,6 +1124,96 @@ var _ = Describe("creating several Namespaces for a Tenant", Ordered, Label("con
}
})
It("Administrators can migrate managed namespaces to another Tenant", func() {
tenantA := getTenant(t1.Name)
tenantB := getTenant(t2.Name)
ns := NewNamespace("", map[string]string{
meta.TenantLabel: tenantA.GetName(),
})
NamespaceCreation(ns, t1.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
NamespaceIsPartOfTenant(t1, ns).Should(Succeed())
DeferCleanup(func() { EventuallyDeletion(ns) })
ref, err := GetTenantOwnerReferenceAsPatch(tenantB)
Expect(err).NotTo(HaveOccurred())
patch := map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]interface{}{
meta.TenantLabel: tenantB.GetName(),
},
"ownerReferences": []map[string]interface{}{ref},
},
}
Eventually(func() error {
return PatchNamespace(ns, ownerClient(administrator), patch)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
Eventually(func(g Gomega) {
current := getNamespace(ns.Name)
g.Expect(current.Labels).To(HaveKeyWithValue(meta.TenantLabel, tenantB.GetName()))
g.Expect(tenantOwnerReferences(current)).To(Equal([]string{tenantB.GetName()}))
g.Expect(hasTenantOwnerReference(current, tenantB)).To(BeTrue())
g.Expect(hasTenantOwnerReference(current, tenantA)).To(BeFalse())
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
NamespaceIsNotPartOfTenant(t1, ns).Should(Succeed())
NamespaceIsPartOfTenant(t2, ns).Should(Succeed())
})
It("Administrators can remove complete Tenant ownership while partial removal is reverted", func() {
tenant := getTenant(t1.Name)
ns := NewNamespace("", map[string]string{
meta.TenantLabel: tenant.GetName(),
})
NamespaceCreation(ns, t1.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
NamespaceIsPartOfTenant(t1, ns).Should(Succeed())
DeferCleanup(func() { EventuallyDeletion(ns) })
adminClient := ownerClient(administrator)
Eventually(func() error {
_, err := adminClient.CoreV1().Namespaces().Patch(
context.TODO(),
ns.Name,
types.MergePatchType,
[]byte(`{"metadata":{"annotations":{"e2e.capsule.clastix.io/admin":"true"}}}`),
metav1.PatchOptions{},
)
return err
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
Eventually(func() error {
_, err := adminClient.CoreV1().Namespaces().Patch(
context.TODO(),
ns.Name,
types.MergePatchType,
[]byte(fmt.Sprintf(`{"metadata":{"labels":{"%s":null}}}`, meta.TenantLabel)),
metav1.PatchOptions{},
)
return err
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
expectOriginalTenantOwnership(ns.Name, tenant)
patch := map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]interface{}{
meta.TenantLabel: nil,
},
"ownerReferences": []interface{}{},
},
}
Expect(PatchNamespace(ns, adminClient, patch)).To(Succeed())
ExpectNamespaceNotAssignedToTenant(context.TODO(), ns.Name)
NamespaceIsNotPartOfTenant(t1, ns).Should(Succeed())
})
It("Owners can not migrate managed namespaces to another Tenant", func() {
tenantA := getTenant(t1.Name)
tenantB := getTenant(t2.Name)
@@ -1157,6 +1275,18 @@ var _ = Describe("creating several Namespaces for a Tenant", Ordered, Label("con
Expect(err).ToNot(HaveOccurred())
expectOriginalTenantOwnership(ns.Name, tenant)
patchRemoveOwnership := map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]interface{}{
meta.TenantLabel: nil,
},
"ownerReferences": []interface{}{},
},
}
Expect(PatchNamespace(ns, cs, patchRemoveOwnership)).ToNot(Succeed())
expectOriginalTenantOwnership(ns.Name, tenant)
}
})
@@ -0,0 +1,290 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"context"
"fmt"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
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/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes"
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/api/rbac"
"github.com/projectcapsule/capsule/pkg/utils"
)
var _ = Describe("Capsule administrators changing existing Namespace tenant assignment", Ordered, Serial,
Label("config", "namespace", "administrators", "assignment", "rolebindings"), func() {
administrator := rbac.UserSpec{
Name: "e2e-namespace-assignment-administrator",
Kind: rbac.UserOwner,
}
unprivilegedAdmin := rbac.UserSpec{
Name: "admin",
Kind: rbac.UserOwner,
}
ownerA := rbac.OwnerSpec{CoreOwnerSpec: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Name: "e2e-namespace-assignment-owner-a",
Kind: rbac.UserOwner,
},
ClusterRoles: []string{"admin"},
}}
ownerB := rbac.OwnerSpec{CoreOwnerSpec: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Name: "e2e-namespace-assignment-owner-b",
Kind: rbac.UserOwner,
},
ClusterRoles: []string{"view"},
}}
tenantA := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "e2e-namespace-assignment-a",
Labels: map[string]string{"env": "e2e"},
},
Spec: capsulev1beta2.TenantSpec{Owners: rbac.OwnerListSpec{ownerA}},
}
tenantB := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "e2e-namespace-assignment-b",
Labels: map[string]string{"env": "e2e"},
},
Spec: capsulev1beta2.TenantSpec{Owners: rbac.OwnerListSpec{ownerB}},
}
bindingA := ownerA.CoreOwnerSpec.ToAdditionalRolebindings()[0]
bindingB := ownerB.CoreOwnerSpec.ToAdditionalRolebindings()[0]
var originalConfigurationSpec *capsulev1beta2.CapsuleConfigurationSpec
BeforeAll(func() {
configuration := &capsulev1beta2.CapsuleConfiguration{}
Expect(k8sClient.Get(
context.Background(),
client.ObjectKey{Name: defaultConfigurationName},
configuration,
)).To(Succeed())
originalConfigurationSpec = configuration.Spec.DeepCopy()
})
JustBeforeEach(func() {
ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) {
configuration.Spec.Administrators = rbac.UserListSpec{administrator}
})
for _, tnt := range []*capsulev1beta2.Tenant{tenantA, tenantB} {
EventuallyCreation(func() error {
tnt.ResourceVersion = ""
return k8sClient.Create(context.Background(), tnt)
}).Should(Succeed())
TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval)
}
})
JustAfterEach(func() {
EventuallyDeletion(tenantA)
EventuallyDeletion(tenantB)
ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) {
configuration.Spec = *originalConfigurationSpec
})
})
newUnassignedNamespace := func() *corev1.Namespace {
ns := NewNamespace("")
NamespaceCreation(ns, administrator, defaultTimeoutInterval).Should(Succeed())
DeferCleanup(func() { EventuallyDeletion(ns) })
ExpectNamespaceNotAssignedToTenant(context.Background(), ns.Name)
return ns
}
patchAssignment := func(
ns *corev1.Namespace,
tnt *capsulev1beta2.Tenant,
cs kubernetes.Interface,
) error {
return PatchTenantAssignmentForNamespace(tnt, ns, cs)
}
patchDetachment := func(ns *corev1.Namespace, cs kubernetes.Interface) error {
return PatchNamespace(ns, cs, map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]interface{}{
meta.TenantLabel: nil,
},
"ownerReferences": []interface{}{},
},
})
}
expectAssignment := func(
ns *corev1.Namespace,
assigned, unassigned *capsulev1beta2.Tenant,
) {
Eventually(func(g Gomega) {
current := &corev1.Namespace{}
g.Expect(k8sClient.Get(
context.Background(),
types.NamespacedName{Name: ns.Name},
current,
)).To(Succeed())
g.Expect(current.Labels).To(HaveKeyWithValue(meta.TenantLabel, assigned.Name))
g.Expect(tenantOwnerReferences(current)).To(Equal([]string{assigned.Name}))
g.Expect(hasTenantOwnerReferenceByNameAndUID(current, assigned.Name, assigned.UID)).To(BeTrue())
g.Expect(hasTenantOwnerReferenceByNameAndUID(current, unassigned.Name, unassigned.UID)).To(BeFalse())
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
NamespaceIsPartOfTenant(assigned, ns).Should(Succeed())
NamespaceIsNotPartOfTenant(unassigned, ns).Should(Succeed())
}
expectRoleBindings := func(
ns *corev1.Namespace,
present map[*capsulev1beta2.Tenant]rbac.AdditionalRoleBindingsSpec,
absent ...rbac.AdditionalRoleBindingsSpec,
) {
Eventually(func(g Gomega) {
for tnt, binding := range present {
name := meta.NameForManagedRoleBindings(utils.RoleBindingHashFunc(binding))
roleBinding := &rbacv1.RoleBinding{}
err := k8sClient.Get(context.Background(), client.ObjectKey{
Namespace: ns.Name,
Name: name,
}, roleBinding)
g.Expect(err).NotTo(HaveOccurred(),
"expected managed RoleBinding %s/%s for Tenant %s", ns.Name, name, tnt.Name)
g.Expect(roleBinding.RoleRef).To(Equal(rbacv1.RoleRef{
APIGroup: rbacv1.GroupName,
Kind: "ClusterRole",
Name: binding.ClusterRoleName,
}))
g.Expect(roleBinding.Subjects).To(ConsistOf(binding.Subjects))
g.Expect(roleBinding.Labels).To(HaveKeyWithValue(meta.NewTenantLabel, tnt.Name))
g.Expect(roleBinding.Labels).To(HaveKeyWithValue(
meta.NewManagedByCapsuleLabel,
meta.ValueController,
))
}
for _, binding := range absent {
name := meta.NameForManagedRoleBindings(utils.RoleBindingHashFunc(binding))
roleBinding := &rbacv1.RoleBinding{}
err := k8sClient.Get(context.Background(), client.ObjectKey{
Namespace: ns.Name,
Name: name,
}, roleBinding)
g.Expect(apierrors.IsNotFound(err)).To(BeTrue(),
"expected managed RoleBinding %s/%s to be absent, got %v", ns.Name, name, err)
}
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
}
expectUnassigned := func(ns *corev1.Namespace) {
ExpectNamespaceNotAssignedToTenant(context.Background(), ns.Name)
NamespaceIsNotPartOfTenant(tenantA, ns).Should(Succeed())
NamespaceIsNotPartOfTenant(tenantB, ns).Should(Succeed())
}
It("allows a configured administrator to join, migrate, and unjoin an existing Namespace", func() {
ns := newUnassignedNamespace()
adminClient := ownerClient(administrator)
By("starting without either Tenant's managed RoleBinding")
expectRoleBindings(ns, nil, bindingA, bindingB)
By("joining the existing Namespace to the first Tenant")
Eventually(func() error {
return patchAssignment(ns, tenantA, adminClient)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
expectAssignment(ns, tenantA, tenantB)
expectRoleBindings(ns, map[*capsulev1beta2.Tenant]rbac.AdditionalRoleBindingsSpec{
tenantA: bindingA,
}, bindingB)
By("migrating the Namespace from the first Tenant to the second Tenant")
Eventually(func() error {
return patchAssignment(ns, tenantB, adminClient)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
expectAssignment(ns, tenantB, tenantA)
expectRoleBindings(ns, map[*capsulev1beta2.Tenant]rbac.AdditionalRoleBindingsSpec{
tenantB: bindingB,
}, bindingA)
By("unjoining the Namespace from the second Tenant")
Eventually(func() error {
return patchDetachment(ns, adminClient)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
expectUnassigned(ns)
expectRoleBindings(ns, nil, bindingA, bindingB)
})
It("rejects join, migration, and unjoin attempts from other users", func() {
actors := []struct {
name string
user rbac.UserSpec
}{
{name: "an unconfigured admin user", user: unprivilegedAdmin},
{name: "a Tenant owner", user: ownerA.UserSpec},
}
for _, actor := range actors {
By(fmt.Sprintf("checking all assignment transitions for %s", actor.name))
ns := newUnassignedNamespace()
actorClient := ownerClient(actor.user)
adminClient := ownerClient(administrator)
expectRoleBindings(ns, nil, bindingA, bindingB)
By(fmt.Sprintf("rejecting a join by %s", actor.name))
Expect(patchAssignment(ns, tenantA, actorClient)).To(HaveOccurred())
expectUnassigned(ns)
expectRoleBindings(ns, nil, bindingA, bindingB)
By("preparing an administrator-owned transition baseline")
Eventually(func() error {
return patchAssignment(ns, tenantA, adminClient)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
expectAssignment(ns, tenantA, tenantB)
expectRoleBindings(ns, map[*capsulev1beta2.Tenant]rbac.AdditionalRoleBindingsSpec{
tenantA: bindingA,
}, bindingB)
By(fmt.Sprintf("rejecting a migration by %s", actor.name))
Expect(patchAssignment(ns, tenantB, actorClient)).To(HaveOccurred())
expectAssignment(ns, tenantA, tenantB)
expectRoleBindings(ns, map[*capsulev1beta2.Tenant]rbac.AdditionalRoleBindingsSpec{
tenantA: bindingA,
}, bindingB)
By(fmt.Sprintf("rejecting an unjoin by %s", actor.name))
Expect(patchDetachment(ns, actorClient)).To(HaveOccurred())
expectAssignment(ns, tenantA, tenantB)
expectRoleBindings(ns, map[*capsulev1beta2.Tenant]rbac.AdditionalRoleBindingsSpec{
tenantA: bindingA,
}, bindingB)
By("detaching with the configured administrator before cleanup")
Eventually(func() error {
return patchDetachment(ns, adminClient)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
expectUnassigned(ns)
expectRoleBindings(ns, nil, bindingA, bindingB)
}
})
})
@@ -144,18 +144,20 @@ var _ = Describe("creating a Namespace with Tenant selector when user owns multi
patch := map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]string{},
"labels": map[string]interface{}{
meta.TenantLabel: nil,
},
"ownerReferences": []map[string]interface{}{ref},
},
}
err = PatchNamespace(ns, ownerClient(t2.Spec.Owners[0].UserSpec), patch)
Expect(err).NotTo(HaveOccurred())
Expect(err).To(HaveOccurred())
new := &corev1.Namespace{}
k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, new)
NamespaceIsPartOfTenant(t1, ns).Should(Succeed())
NamespaceIsPartOfTenant(t1, new).Should(Succeed())
})
By("assigning to the Namespace the Capsule Tenant label (Empty Ownerreferences)", func() {
@@ -169,7 +171,7 @@ var _ = Describe("creating a Namespace with Tenant selector when user owns multi
}
err := PatchNamespace(ns, ownerClient(t2.Spec.Owners[0].UserSpec), patch)
Expect(err).NotTo(HaveOccurred())
Expect(err).To(HaveOccurred())
new := &corev1.Namespace{}
k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, new)
@@ -178,22 +180,22 @@ var _ = Describe("creating a Namespace with Tenant selector when user owns multi
})
By("assigning to the Namespace the Capsule Tenant label (Empty Ownerreferences) - Without Label", func() {
ns.Labels = map[string]string{}
patch := map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]string{},
"labels": map[string]interface{}{
meta.TenantLabel: nil,
},
"ownerReferences": []string{},
},
}
err := PatchNamespace(ns, ownerClient(t2.Spec.Owners[0].UserSpec), patch)
Expect(err).NotTo(HaveOccurred())
Expect(err).To(HaveOccurred())
new := &corev1.Namespace{}
k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, new)
NamespaceIsPartOfTenant(t1, ns).Should(Succeed())
NamespaceIsPartOfTenant(t1, new).Should(Succeed())
})
By("assigning to the Namespace the Capsule Tenant label (2nd Tenant Label + Ownerreference)", func() {
@@ -210,7 +212,7 @@ var _ = Describe("creating a Namespace with Tenant selector when user owns multi
}
err = PatchNamespace(ns, ownerClient(t2.Spec.Owners[0].UserSpec), patch)
Expect(err).NotTo(HaveOccurred())
Expect(err).To(HaveOccurred())
new := &corev1.Namespace{}
k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, new)
+17 -11
View File
@@ -366,18 +366,24 @@ func GetTenantOwnerReferenceAsPatch(
}
func PatchTenantLabelForNamespace(tnt *capsulev1beta2.Tenant, ns *corev1.Namespace, cs kubernetes.Interface, timeout time.Duration) AsyncAssertion {
return Eventually(func() (err error) {
patch := map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]interface{}{
meta.TenantLabel: tnt.GetName(),
},
},
}
func PatchTenantAssignmentForNamespace(
tnt *capsulev1beta2.Tenant,
ns *corev1.Namespace,
cs kubernetes.Interface,
) error {
ref, err := GetTenantOwnerReferenceAsPatch(tnt)
if err != nil {
return err
}
return PatchNamespace(ns, cs, patch)
}, timeout, defaultPollInterval)
return PatchNamespace(ns, cs, map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]interface{}{
meta.TenantLabel: tnt.GetName(),
},
"ownerReferences": []map[string]interface{}{ref},
},
})
}
func PatchNamespace(ns *corev1.Namespace, cs kubernetes.Interface, patch map[string]interface{}) error {
@@ -23,6 +23,10 @@ import (
//
func (r *Manager) syncLimitRanges(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) error {
if err := r.runGarbageCollection(ctx, tenant, &corev1.LimitRange{}); err != nil {
return err
}
// getting requested LimitRange keys
//nolint:staticcheck
keys := make([]string, 0, len(tenant.Spec.LimitRanges.Items))
+4 -1
View File
@@ -37,7 +37,10 @@ func (r *Manager) reconcileNamespaces(
return r.reconcileDeletingTenantNamespaces(ctx, log, tnt)
}
return r.reconcileActiveTenantNamespaces(ctx, log, tnt)
reconcileErr := r.reconcileActiveTenantNamespaces(ctx, log, tnt)
garbageCollectionErr := r.runGarbageCollection(ctx, tnt, &capsulev1beta2.RuleStatus{})
return errors.Join(reconcileErr, garbageCollectionErr)
}
func (r *Manager) reconcileDeletingTenantNamespaces(
@@ -24,6 +24,10 @@ import (
//
func (r *Manager) syncNetworkPolicies(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) error {
if err := r.runGarbageCollection(ctx, tenant, &networkingv1.NetworkPolicy{}); err != nil {
return err
}
//nolint:staticcheck
keys := make([]string, 0, len(tenant.Spec.NetworkPolicies.Items))
+7 -17
View File
@@ -45,8 +45,11 @@ import (
//
// In case of Namespace-scoped Resource Budget, we're just replicating the resources across all registered Namespaces.
//nolint:cyclop
func (r *Manager) syncResourceQuotas(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) (err error) { //nolint:gocognit
if err := r.runGarbageCollection(ctx, tenant, &corev1.ResourceQuota{}); err != nil {
return err
}
// Remove prior metrics, to avoid cleaning up for metrics of deleted ResourceQuotas
r.Metrics.DeleteTenantResourceMetrics(tenant.Name)
// Expose the namespace quota and usage as metrics for the tenant
@@ -204,22 +207,9 @@ func (r *Manager) syncResourceQuotas(ctx context.Context, log logr.Logger, tenan
keys = append(keys, strconv.Itoa(i))
}
group := new(errgroup.Group)
for _, ns := range tenant.Status.Spaces {
namespace := ns.Name
cond := ns.Conditions.GetConditionByType(meta.ReadyCondition)
if cond != nil && cond.Reason == meta.TerminatingReason {
continue
}
group.Go(func() error {
return r.syncResourceQuota(ctx, log, tenant, namespace, keys)
})
}
return group.Wait()
return runForTenantNamespaces(ctx, tenant, func(ctx context.Context, namespace string) error {
return r.syncResourceQuota(ctx, log, tenant, namespace, keys)
})
}
func (r *Manager) syncResourceQuota(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant, namespace string, keys []string) (err error) {
@@ -25,6 +25,10 @@ import (
// Sync the dynamic Tenant Owner specific cluster-roles and additional Role Bindings, which can be used in many ways:
// applying Pod Security Policies or giving access to CRDs or specific API groups.
func (r *Manager) syncRoleBindings(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) (err error) {
if err := r.runGarbageCollection(ctx, tenant, &rbacv1.RoleBinding{}); err != nil {
return err
}
namespaceBindings := map[string]map[string]rbac.AdditionalRoleBindingsSpec{}
for _, ns := range tenant.Status.Spaces {
@@ -61,6 +61,7 @@ func (r *Manager) ensureRuleStatus(
}
labels[meta.NewManagedByCapsuleLabel] = meta.ValueController
labels[meta.NewTenantLabel] = tnt.Name
labels[meta.CapsuleNameLabel] = rule.GetName()
rule.SetLabels(labels)
+118 -2
View File
@@ -49,11 +49,19 @@ func runForTenantNamespaces(
tnt *capsulev1beta2.Tenant,
fn func(context.Context, string) error,
) error {
errs := make(chan error, len(tnt.Status.Spaces))
return runForNamespaces(ctx, readyTenantNamespaces(tnt), fn)
}
func runForNamespaces(
ctx context.Context,
namespaces []string,
fn func(context.Context, string) error,
) error {
errs := make(chan error, len(namespaces))
group := new(errgroup.Group)
group.SetLimit(8)
for _, namespace := range readyTenantNamespaces(tnt) {
for _, namespace := range namespaces {
group.Go(func() error {
if err := fn(ctx, namespace); err != nil {
errs <- fmt.Errorf("namespace %q: %w", namespace, err)
@@ -75,6 +83,84 @@ func runForTenantNamespaces(
return errors.Join(joined...)
}
// runGarbageCollection removes resources managed by a Tenant from live
// namespaces which are no longer assigned to that Tenant. Resources in
// terminating namespaces are left to Kubernetes' namespace garbage collector.
func (r *Manager) runGarbageCollection(
ctx context.Context,
tnt *capsulev1beta2.Tenant,
obj client.Object,
) error {
list, err := managedObjectList(obj)
if err != nil {
return err
}
selector := labels.SelectorFromSet(labels.Set{
meta.NewManagedByCapsuleLabel: meta.ValueController,
meta.NewTenantLabel: tnt.Name,
})
if err := r.List(ctx, list, &client.ListOptions{LabelSelector: selector}); err != nil {
return err
}
tenantNamespaces := make(map[string]struct{}, len(tnt.Status.Spaces))
for _, namespace := range tnt.Status.Spaces {
tenantNamespaces[namespace.Name] = struct{}{}
}
garbageNamespaces := make(map[string]struct{})
for _, namespace := range managedObjectNamespaces(list) {
if _, assigned := tenantNamespaces[namespace]; !assigned {
garbageNamespaces[namespace] = struct{}{}
}
}
namespaces := make([]string, 0, len(garbageNamespaces))
for namespace := range garbageNamespaces {
namespaces = append(namespaces, namespace)
}
reader := r.reader
if reader == nil {
reader = r.Client
}
return runForNamespaces(ctx, namespaces, func(ctx context.Context, namespace string) error {
ns := &corev1.Namespace{}
if err := reader.Get(ctx, client.ObjectKey{Name: namespace}, ns); err != nil {
if apierrors.IsNotFound(err) {
return nil
}
return err
}
if ns.DeletionTimestamp != nil {
return nil
}
deleteTarget, ok := obj.DeepCopyObject().(client.Object)
if !ok {
return fmt.Errorf("unsupported managed resource type %T", obj)
}
err := r.DeleteAllOf(ctx, deleteTarget, &client.DeleteAllOfOptions{
ListOptions: client.ListOptions{
LabelSelector: selector,
Namespace: namespace,
},
})
if apierrors.IsNotFound(err) || apierrors.HasStatusCause(err, corev1.NamespaceTerminatingCause) {
return nil
}
return err
})
}
// pruningResources is taking care of removing the no more requested sub-resources as LimitRange, ResourceQuota or
// NetworkPolicy using the "exists" and "notin" LabelSelector to perform an outer-join removal.
func (r *Manager) pruningResources(ctx context.Context, ns string, keys []string, obj client.Object) (err error) {
@@ -159,6 +245,8 @@ func managedObjectList(obj client.Object) (client.ObjectList, error) {
return &corev1.ResourceQuotaList{}, nil
case *rbacv1.RoleBinding:
return &rbacv1.RoleBindingList{}, nil
case *capsulev1beta2.RuleStatus:
return &capsulev1beta2.RuleStatusList{}, nil
default:
return nil, fmt.Errorf("unsupported managed resource type %T", obj)
}
@@ -174,7 +262,35 @@ func managedObjectListLength(list client.ObjectList) int {
return len(typed.Items)
case *rbacv1.RoleBindingList:
return len(typed.Items)
case *capsulev1beta2.RuleStatusList:
return len(typed.Items)
default:
return 0
}
}
func managedObjectNamespaces(list client.ObjectList) []string {
switch typed := list.(type) {
case *networkingv1.NetworkPolicyList:
return namespacesForItems(typed.Items, func(item networkingv1.NetworkPolicy) string { return item.Namespace })
case *corev1.LimitRangeList:
return namespacesForItems(typed.Items, func(item corev1.LimitRange) string { return item.Namespace })
case *corev1.ResourceQuotaList:
return namespacesForItems(typed.Items, func(item corev1.ResourceQuota) string { return item.Namespace })
case *rbacv1.RoleBindingList:
return namespacesForItems(typed.Items, func(item rbacv1.RoleBinding) string { return item.Namespace })
case *capsulev1beta2.RuleStatusList:
return namespacesForItems(typed.Items, func(item capsulev1beta2.RuleStatus) string { return item.Namespace })
default:
return nil
}
}
func namespacesForItems[T any](items []T, namespaceFor func(T) string) []string {
namespaces := make([]string, 0, len(items))
for _, item := range items {
namespaces = append(namespaces, namespaceFor(item))
}
return namespaces
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package tenant
import (
"context"
"testing"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
func TestReadyTenantNamespaces(t *testing.T) {
t.Parallel()
tenant := &capsulev1beta2.Tenant{
Status: capsulev1beta2.TenantStatus{
Spaces: []*capsulev1beta2.TenantStatusNamespaceItem{
{Name: "without-condition"},
{
Name: "ready",
Conditions: meta.ConditionList{{
Type: meta.ReadyCondition,
Status: metav1.ConditionTrue,
}},
},
{
Name: "not-ready",
Conditions: meta.ConditionList{{
Type: meta.ReadyCondition,
Status: metav1.ConditionFalse,
}},
},
{
Name: "terminating",
Conditions: meta.ConditionList{{
Type: meta.TerminatingCondition,
Status: metav1.ConditionTrue,
}},
},
},
},
}
got := readyTenantNamespaces(tenant)
if len(got) != 2 || got[0] != "without-condition" || got[1] != "ready" {
t.Fatalf("readyTenantNamespaces() = %v, want [without-condition ready]", got)
}
}
func TestRunGarbageCollection(t *testing.T) {
t.Parallel()
tests := []struct {
name string
new func(string, string, map[string]string) client.Object
}{
{
name: "LimitRange",
new: func(name, namespace string, labels map[string]string) client.Object {
return &corev1.LimitRange{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: labels}}
},
},
{
name: "NetworkPolicy",
new: func(name, namespace string, labels map[string]string) client.Object {
return &networkingv1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: labels}}
},
},
{
name: "ResourceQuota",
new: func(name, namespace string, labels map[string]string) client.Object {
return &corev1.ResourceQuota{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: labels}}
},
},
{
name: "RoleBinding",
new: func(name, namespace string, labels map[string]string) client.Object {
return &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: labels}}
},
},
{
name: "RuleStatus",
new: func(name, namespace string, labels map[string]string) client.Object {
return &capsulev1beta2.RuleStatus{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: labels}}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := networkingv1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := rbacv1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := capsulev1beta2.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
now := metav1.Now()
namespaces := []client.Object{
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "current"}},
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "departed"}},
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
Name: "terminating",
DeletionTimestamp: &now,
Finalizers: []string{"test.projectcapsule.dev/finalizer"},
}},
}
managedLabels := map[string]string{
meta.NewManagedByCapsuleLabel: meta.ValueController,
meta.NewTenantLabel: "green",
}
objects := []client.Object{
tt.new("managed", "current", managedLabels),
tt.new("managed", "departed", managedLabels),
tt.new("managed", "terminating", managedLabels),
tt.new("unmanaged", "departed", map[string]string{meta.NewTenantLabel: "green"}),
tt.new("other-tenant", "departed", map[string]string{
meta.NewManagedByCapsuleLabel: meta.ValueController,
meta.NewTenantLabel: "blue",
}),
}
allObjects := append(namespaces, objects...)
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(allObjects...).Build()
manager := &Manager{Client: cl, reader: cl}
tenant := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{Name: "green"},
Status: capsulev1beta2.TenantStatus{Spaces: []*capsulev1beta2.TenantStatusNamespaceItem{{
Name: "current",
Conditions: meta.ConditionList{{
Type: meta.ReadyCondition,
Status: metav1.ConditionFalse,
}},
}}},
}
if err := manager.runGarbageCollection(context.Background(), tenant, tt.new("", "", nil)); err != nil {
t.Fatal(err)
}
assertObjectExists(t, cl, tt.new("managed", "current", nil), true)
assertObjectExists(t, cl, tt.new("managed", "departed", nil), false)
assertObjectExists(t, cl, tt.new("managed", "terminating", nil), true)
assertObjectExists(t, cl, tt.new("unmanaged", "departed", nil), true)
assertObjectExists(t, cl, tt.new("other-tenant", "departed", nil), true)
})
}
}
func assertObjectExists(t *testing.T, cl client.Client, obj client.Object, want bool) {
t.Helper()
err := cl.Get(context.Background(), client.ObjectKeyFromObject(obj), obj)
if want && err != nil {
t.Fatalf("expected %T %s to exist: %v", obj, client.ObjectKeyFromObject(obj), err)
}
if !want && !apierrors.IsNotFound(err) {
t.Fatalf("expected %T %s to be deleted, got: %v", obj, client.ObjectKeyFromObject(obj), err)
}
}
+143 -25
View File
@@ -98,9 +98,9 @@ func (h *ownerReferenceHandler) OnUpdate(
recorder events.EventRecorder,
) handlers.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, err := resolveTenantForNamespaceUpdate(ctx, reader, user, h.cfg, oldNs, newNs)
if err != nil {
return ad.ErroredResponse(err)
tnt, errResponse := h.tenantForUpdate(ctx, reader, user, oldNs, newNs, req, recorder)
if errResponse != nil {
return errResponse
}
if tnt == nil {
@@ -124,6 +124,10 @@ func (h *ownerReferenceHandler) OnUpdate(
}
labels := newNs.GetLabels()
if labels == nil {
labels = make(map[string]string)
}
tenant.AddNamespaceNameLabels(labels, newNs)
tenant.AddTenantNameLabel(labels, tnt)
newNs.SetLabels(labels)
@@ -132,34 +136,148 @@ func (h *ownerReferenceHandler) OnUpdate(
}
}
func resolveTenantForNamespaceUpdate(
func (h *ownerReferenceHandler) tenantForUpdate(
ctx context.Context,
c client.Reader,
reader client.Reader,
user users.AdmissionUser,
cfg configuration.Configuration,
oldNs, newNs *corev1.Namespace,
) (*capsulev1beta2.Tenant, error) {
// 1) try old ownerRefs
if tnt, err := tenant.GetTenantByOwnerreferences(ctx, c, oldNs.OwnerReferences); err != nil {
return nil, err
} else if tnt != nil {
return tnt, nil
}
// 2) try new ownerRefs
if tnt, err := tenant.GetTenantByOwnerreferences(ctx, c, newNs.OwnerReferences); err != nil {
return nil, err
} else if tnt != nil {
return tnt, nil
}
// 3) Controller/admin is allowed to resolve by label only.
req admission.Request,
recorder events.EventRecorder,
) (*capsulev1beta2.Tenant, *admission.Response) {
if user.IsAdmin() {
return tenant.GetTenantByLabels(ctx, c, newNs)
requestedTenant, err := requestedNamespaceTenant(ctx, reader, newNs)
if err != nil {
return nil, denyNamespacePatch(ctx, req, oldNs, recorder, err.Error())
}
return requestedTenant, nil
}
// 4) fall back to labels + user
return tenant.GetTenantByLabelsAndUser(ctx, c, cfg, newNs, user)
oldTenant, err := tenant.ResolveNamespaceTenant(ctx, reader, oldNs)
if err != nil {
return nil, ad.ErroredResponse(err)
}
if oldTenant == nil {
requestedTenant, requestedErr := requestedNamespaceTenant(ctx, reader, newNs)
if requestedErr != nil {
return nil, denyNamespacePatch(ctx, req, oldNs, recorder, requestedErr.Error())
}
if requestedTenant != nil {
return nil, denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not be patched into a tenant")
}
return nil, denyNamespacePatch(ctx, req, oldNs, recorder, "namespace is not owned by any tenant")
}
if namespaceReferencesTenant(newNs, oldTenant) {
if !tenant.NamespaceIsOwned(ctx, reader, h.cfg, oldNs, oldTenant, user) {
return nil, denyNamespacePatch(ctx, req, oldNs, recorder, "denied patch request for this namespace")
}
return oldTenant, nil
}
requestedTenant, requestedErr := requestedNamespaceTenant(ctx, reader, newNs)
if requestedErr != nil {
return nil, denyNamespacePatch(ctx, req, oldNs, recorder, requestedErr.Error())
}
if requestedTenant == nil {
return nil, denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not remove tenant ownership")
}
if oldTenant.GetName() != requestedTenant.GetName() || oldTenant.GetUID() != requestedTenant.GetUID() {
return nil, denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not be migrated between tenants")
}
if !tenant.NamespaceIsOwned(ctx, reader, h.cfg, oldNs, oldTenant, user) {
return nil, denyNamespacePatch(ctx, req, oldNs, recorder, "denied patch request for this namespace")
}
// Tenant owners may patch managed namespaces, but only administrators can
// change their Tenant assignment. Returning the old Tenant lets this handler
// normalize partial or attempted assignment changes back to the current one.
return oldTenant, nil
}
func namespaceReferencesTenant(ns *corev1.Namespace, tnt *capsulev1beta2.Tenant) bool {
if tenant.TenanLabelValue(ns) == tnt.GetName() {
return true
}
for _, ref := range tenant.TenantOwnerReferences(ns) {
if tenant.IsTenantOwnerReferenceForTenant(ref, tnt) {
return true
}
}
return false
}
func requestedNamespaceTenant(
ctx context.Context,
reader client.Reader,
ns *corev1.Namespace,
) (*capsulev1beta2.Tenant, error) {
label := tenant.TenanLabelValue(ns)
refs := tenant.TenantOwnerReferences(ns)
if len(refs) > 1 {
return nil, fmt.Errorf("namespace can not have multiple Tenant ownerReferences")
}
name := label
if len(refs) == 1 {
if name != "" && name != refs[0].Name {
return nil, fmt.Errorf("namespace label %q does not match owner reference %q", name, refs[0].Name)
}
name = refs[0].Name
}
if name == "" {
return nil, nil
}
tnt := &capsulev1beta2.Tenant{}
if err := reader.Get(ctx, client.ObjectKey{Name: name}, tnt); err != nil {
return nil, err
}
if len(refs) == 1 && refs[0].UID != "" && refs[0].UID != tnt.GetUID() {
return nil, fmt.Errorf(
"tenant ownerReference UID mismatch for %q: namespace references UID %q but tenant has UID %q",
name,
refs[0].UID,
tnt.GetUID(),
)
}
return tnt, nil
}
func denyNamespacePatch(
ctx context.Context,
req admission.Request,
ns *corev1.Namespace,
recorder events.EventRecorder,
message string,
) *admission.Response {
if ns != nil {
recorder.LabeledEvent(
ns,
corev1.EventTypeWarning,
events.ReasonNamespaceHijack,
events.ActionValidationDenied,
"namespace disallows patching relevant metadata",
).
WithRequestAnnotations(req).
Emit(ctx)
}
return ad.Deny(message)
}
func assignToTenant(
@@ -0,0 +1,264 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package mutation
import (
"context"
"testing"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/api/rbac"
capevents "github.com/projectcapsule/capsule/pkg/runtime/events"
"github.com/projectcapsule/capsule/pkg/tenant"
"github.com/projectcapsule/capsule/pkg/users"
)
func TestOwnerReferenceHandlerAllowsAdminTenantMigration(t *testing.T) {
t.Parallel()
green := testTenant("green", "green-uid")
blue := testTenant("blue", "blue-uid")
oldNs := testTenantNamespace("workloads", green)
newNs := testTenantNamespace("workloads", blue)
c := testClient(t, green, blue)
response := (&ownerReferenceHandler{}).OnUpdate(
c,
c,
users.AdmissionUser{Type: users.AdmissionUserAdmin},
newNs,
oldNs,
nil,
nil,
)(context.Background(), admission.Request{})
if response != nil {
t.Fatalf("expected migration to proceed, got response %#v", response)
}
assertTenantAssignment(t, newNs, blue)
}
func TestOwnerReferenceHandlerAllowsAdminTenantDetachment(t *testing.T) {
t.Parallel()
green := testTenant("green", "green-uid")
oldNs := testTenantNamespace("workloads", green)
newNs := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: oldNs.GetName()}}
c := testClient(t, green)
response := (&ownerReferenceHandler{}).OnUpdate(
c,
c,
users.AdmissionUser{Type: users.AdmissionUserAdmin},
newNs,
oldNs,
nil,
nil,
)(context.Background(), admission.Request{})
if response != nil {
t.Fatalf("expected detachment to proceed, got response %#v", response)
}
if tenant.HasTenantReference(newNs) {
t.Fatalf("detached namespace still has tenant ownership: %#v", newNs.ObjectMeta)
}
}
func TestOwnerReferenceHandlerRejectsTenantOwnerAssignmentChanges(t *testing.T) {
t.Parallel()
owner := rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Name: "alice", Kind: rbac.UserOwner}}
green := testTenant("green", "green-uid")
green.Status.Owners = rbac.OwnerStatusListSpec{owner}
blue := testTenant("blue", "blue-uid")
oldNs := testTenantNamespace("workloads", green)
recorder := capevents.NewEventRecorder(nil, logr.Discard(), nil, nil)
tests := []struct {
name string
new *corev1.Namespace
}{
{name: "migration", new: testTenantNamespace("workloads", blue)},
{
name: "label migration with empty ownerReferences",
new: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
Name: "workloads",
Labels: map[string]string{meta.TenantLabel: blue.Name},
}},
},
{name: "detachment", new: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "workloads"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := testClient(t, green, blue)
newNs := tt.new.DeepCopy()
response := (&ownerReferenceHandler{}).OnUpdate(
c,
c,
users.AdmissionUser{Type: users.AdmissionUserCapsule, Username: owner.Name},
newNs,
oldNs.DeepCopy(),
nil,
recorder,
)(context.Background(), admission.Request{})
if response == nil || response.Allowed {
t.Fatalf("expected assignment change to be denied, got %#v", response)
}
})
}
}
func TestOwnerReferenceHandlerRepairsTenantOwnerReferences(t *testing.T) {
t.Parallel()
owner := rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Name: "alice", Kind: rbac.UserOwner}}
green := testTenant("green", "green-uid")
green.Status.Owners = rbac.OwnerStatusListSpec{owner}
oldNs := testTenantNamespace("workloads", green)
recorder := capevents.NewEventRecorder(nil, logr.Discard(), nil, nil)
tests := []struct {
name string
new *corev1.Namespace
}{
{
name: "missing reference",
new: func() *corev1.Namespace {
ns := oldNs.DeepCopy()
ns.OwnerReferences = nil
return ns
}(),
},
{
name: "additional reference",
new: func() *corev1.Namespace {
ns := oldNs.DeepCopy()
ns.OwnerReferences = append(ns.OwnerReferences, metav1.OwnerReference{
APIVersion: capsulev1beta2.GroupVersion.String(),
Kind: tenant.ObjectReferenceTenantKind,
Name: "blue",
UID: "blue-uid",
})
return ns
}(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := testClient(t, green)
newNs := tt.new.DeepCopy()
response := (&ownerReferenceHandler{}).OnUpdate(
c,
c,
users.AdmissionUser{Type: users.AdmissionUserCapsule, Username: owner.Name},
newNs,
oldNs.DeepCopy(),
nil,
recorder,
)(context.Background(), admission.Request{})
if response != nil {
t.Fatalf("expected ownerReferences to be normalized, got %#v", response)
}
assertTenantAssignment(t, newNs, green)
})
}
}
func TestOwnerReferenceHandlerRejectsNonOwnerJoin(t *testing.T) {
t.Parallel()
green := testTenant("green", "green-uid")
c := testClient(t, green)
recorder := capevents.NewEventRecorder(nil, logr.Discard(), nil, nil)
response := (&ownerReferenceHandler{}).OnUpdate(
c,
c,
users.AdmissionUser{Type: users.AdmissionUserCapsule, Username: "alice"},
testTenantNamespace("workloads", green),
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "workloads"}},
nil,
recorder,
)(context.Background(), admission.Request{})
if response == nil || response.Allowed {
t.Fatalf("expected unmanaged namespace join to be denied, got %#v", response)
}
}
func testTenant(name string, uid types.UID) *capsulev1beta2.Tenant {
return &capsulev1beta2.Tenant{ObjectMeta: metav1.ObjectMeta{Name: name, UID: uid}}
}
func testTenantNamespace(name string, tnt *capsulev1beta2.Tenant) *corev1.Namespace {
return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
Name: name,
Labels: map[string]string{meta.TenantLabel: tnt.GetName()},
OwnerReferences: []metav1.OwnerReference{{
APIVersion: capsulev1beta2.GroupVersion.String(),
Kind: tenant.ObjectReferenceTenantKind,
Name: tnt.GetName(),
UID: tnt.GetUID(),
}},
}}
}
func assertTenantAssignment(t *testing.T, ns *corev1.Namespace, tnt *capsulev1beta2.Tenant) {
t.Helper()
if got := tenant.TenanLabelValue(ns); got != tnt.GetName() {
t.Fatalf("tenant label = %q, want %q", got, tnt.GetName())
}
refs := tenant.TenantOwnerReferences(ns)
if len(refs) != 1 || !tenant.IsTenantOwnerReferenceForTenant(refs[0], tnt) {
t.Fatalf("tenant ownerReferences = %#v, want only tenant %q", refs, tnt.GetName())
}
}
func testClient(t *testing.T, tenants ...*capsulev1beta2.Tenant) client.Client {
t.Helper()
objects := make([]client.Object, 0, len(tenants))
for _, tnt := range tenants {
objects = append(objects, tnt)
}
return fake.NewClientBuilder().
WithScheme(testScheme(t)).
WithObjects(objects...).
Build()
}
func testScheme(t *testing.T) *runtime.Scheme {
t.Helper()
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatalf("add core API to scheme: %v", err)
}
if err := capsulev1beta2.AddToScheme(scheme); err != nil {
t.Fatalf("add Capsule API to scheme: %v", err)
}
return scheme
}
@@ -1,121 +0,0 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package mutation
import (
"context"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
ad "github.com/projectcapsule/capsule/pkg/runtime/admission"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
"github.com/projectcapsule/capsule/pkg/runtime/events"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
"github.com/projectcapsule/capsule/pkg/tenant"
"github.com/projectcapsule/capsule/pkg/users"
)
type namespacePatchGuardHandler struct {
cfg configuration.Configuration
}
func NamespacePatchGuardHandler(cfg configuration.Configuration) handlers.TypedHandlerWithUser[*corev1.Namespace] {
return &namespacePatchGuardHandler{cfg: cfg}
}
func (h *namespacePatchGuardHandler) OnCreate(
client.Client,
client.Reader,
users.AdmissionUser,
*corev1.Namespace,
admission.Decoder,
events.EventRecorder,
) handlers.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *namespacePatchGuardHandler) OnDelete(
client.Client,
client.Reader,
users.AdmissionUser,
*corev1.Namespace,
admission.Decoder,
events.EventRecorder,
) handlers.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *namespacePatchGuardHandler) OnUpdate(
_ client.Client,
reader client.Reader,
user users.AdmissionUser,
newNs *corev1.Namespace,
oldNs *corev1.Namespace,
_ admission.Decoder,
recorder events.EventRecorder,
) handlers.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
if user.IsAdmin() {
return nil
}
oldTenant, err := tenant.ResolveNamespaceTenant(ctx, reader, oldNs)
if err != nil {
return ad.ErroredResponse(err)
}
newTenant, err := tenant.ResolveNamespaceTenant(ctx, reader, newNs)
if err != nil {
return ad.ErroredResponse(err)
}
switch {
case oldTenant == nil && newTenant == nil:
return denyNamespacePatch(ctx, req, oldNs, recorder, "namespace is not owned by any tenant")
case oldTenant == nil && newTenant != nil:
return denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not be patched into a tenant")
case oldTenant != nil && newTenant == nil:
return denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not remove tenant ownership")
case oldTenant.GetName() != newTenant.GetName() || oldTenant.GetUID() != newTenant.GetUID():
return denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not be migrated between tenants")
}
if !tenant.NamespaceIsOwned(ctx, reader, h.cfg, oldNs, oldTenant, user) {
return denyNamespacePatch(ctx, req, oldNs, recorder, "denied patch request for this namespace")
}
return nil
}
}
func denyNamespacePatch(
ctx context.Context,
req admission.Request,
ns *corev1.Namespace,
recorder events.EventRecorder,
message string,
) *admission.Response {
if ns != nil {
recorder.LabeledEvent(
ns,
corev1.EventTypeWarning,
events.ReasonNamespaceHijack,
events.ActionValidationDenied,
"namespace disallows patching relevant metadata",
).
WithRequestAnnotations(req).
Emit(ctx)
}
return ad.Deny(message)
}
@@ -0,0 +1,138 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package mutation
import (
"context"
"encoding/json"
"testing"
"github.com/go-logr/logr"
admissionv1 "k8s.io/api/admission/v1"
authenticationv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/api/rbac"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
capevents "github.com/projectcapsule/capsule/pkg/runtime/events"
)
func TestNamespaceHandlerDoesNotInterceptUnlabelledAdministratorCreate(t *testing.T) {
t.Parallel()
ctx := context.Background()
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := capsulev1beta2.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
const (
configurationName = "capsule"
administratorName = "configured-administrator"
)
configurationObject := &capsulev1beta2.CapsuleConfiguration{
ObjectMeta: metav1.ObjectMeta{Name: configurationName},
Spec: capsulev1beta2.CapsuleConfigurationSpec{
Administrators: rbac.UserListSpec{{
Name: administratorName,
Kind: rbac.UserOwner,
}},
},
}
cl := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(configurationObject).
Build()
cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, nil, configurationName)
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
Name: "unassigned",
Labels: map[string]string{"example.com/label": "value"},
}}
raw, err := json.Marshal(ns)
if err != nil {
t.Fatal(err)
}
response := NamespaceHandler(cfg).OnCreate(
cl,
cl,
admission.NewDecoder(scheme),
nil,
)(ctx, admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{
Object: runtime.RawExtension{Raw: raw},
UserInfo: authenticationv1.UserInfo{
Username: administratorName,
},
}})
if response != nil {
t.Fatalf("expected unlabelled administrator create not to be intercepted, got %#v", response)
}
}
func TestNamespaceHandlerRejectsTenantOwnerLabelMigrationWithEmptyOwnerReferences(t *testing.T) {
t.Parallel()
ctx := context.Background()
scheme := testScheme(t)
owner := rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Name: "alice", Kind: rbac.UserOwner}}
green := testTenant("green", "green-uid")
green.Status.Owners = rbac.OwnerStatusListSpec{owner}
blue := testTenant("blue", "blue-uid")
configurationObject := &capsulev1beta2.CapsuleConfiguration{
ObjectMeta: metav1.ObjectMeta{Name: "capsule"},
Status: capsulev1beta2.CapsuleConfigurationStatus{
Users: rbac.UserListSpec{owner.UserSpec},
},
}
cl := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(configurationObject, green, blue).
Build()
cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, nil, configurationObject.Name)
recorder := capevents.NewEventRecorder(nil, logr.Discard(), nil, nil)
oldNs := testTenantNamespace("workloads", green)
newNs := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
Name: oldNs.Name,
Labels: map[string]string{meta.TenantLabel: blue.Name},
}}
oldRaw, err := json.Marshal(oldNs)
if err != nil {
t.Fatal(err)
}
newRaw, err := json.Marshal(newNs)
if err != nil {
t.Fatal(err)
}
response := NamespaceHandler(cfg, OwnerReferenceHandler(cfg)).OnUpdate(
cl,
cl,
admission.NewDecoder(scheme),
recorder,
)(ctx, admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{
Object: runtime.RawExtension{Raw: newRaw},
OldObject: runtime.RawExtension{Raw: oldRaw},
UserInfo: authenticationv1.UserInfo{
Username: owner.Name,
},
}})
if response == nil || response.Allowed {
t.Fatalf("expected label migration patch to be denied, got %#v", response)
}
}
@@ -122,6 +122,7 @@ func (h *handler) OnDelete(
}
}
//nolint:cyclop
func (h *handler) OnUpdate(
c client.Client,
reader client.Reader,
@@ -142,8 +143,13 @@ func (h *handler) OnUpdate(
}
oldHasTenantReference := tenant.HasTenantReference(oldNs)
newHasTenantReference := tenant.HasTenantReference(ns)
if user.IsAdmin() && !tenant.HasConsistentTenantReference(ns) {
return ad.Deny("tenant label and ownerReference must both be set consistently or both be absent")
}
if !user.IsAdmin() {
switch {
case !oldHasTenantReference && newHasTenantReference:
@@ -189,6 +195,8 @@ func (h *handler) OnUpdate(
}
}
adminTenantTransition := user.IsAdmin() && namespaceTenantChanged(oldTenant, newTenant)
tnt := newTenant
if !user.IsAdmin() {
tnt = oldTenant
@@ -202,6 +210,10 @@ func (h *handler) OnUpdate(
return terminating
}
if adminTenantTransition {
return nil
}
for _, hndl := range h.handlers {
if response := hndl.OnUpdate(c, reader, user, ns, oldNs, decoder, recorder, tnt)(ctx, req); response != nil {
return response
@@ -212,6 +224,14 @@ func (h *handler) OnUpdate(
}
}
func namespaceTenantChanged(oldTenant, newTenant *capsulev1beta2.Tenant) bool {
if oldTenant == nil || newTenant == nil {
return oldTenant != newTenant
}
return oldTenant.GetName() != newTenant.GetName() || oldTenant.GetUID() != newTenant.GetUID()
}
func (h *handler) rejectOnTermination(
ctx context.Context,
c client.Reader,
@@ -243,7 +243,7 @@ func (h *genericRules) controlledMetadataEntries(
for key, value := range annotations {
matched, err := h.matchesMetadataKey(selector, key)
if err != nil {
return nil, fmt.Errorf("invalid label selector %q: %w", selector, err)
return nil, fmt.Errorf("invalid annotation selector %q: %w", selector, err)
}
if !matched {
+73
View File
@@ -8,6 +8,7 @@ import (
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
@@ -81,6 +82,78 @@ func TestAddTenantNameLabel(t *testing.T) {
}
}
func TestHasConsistentTenantReference(t *testing.T) {
t.Parallel()
tenantRef := func(name string) metav1.OwnerReference {
return metav1.OwnerReference{
APIVersion: capsulev1beta2.GroupVersion.String(),
Kind: tenant.ObjectReferenceTenantKind,
Name: name,
}
}
tests := []struct {
name string
ns *corev1.Namespace
want bool
}{
{name: "nil namespace", want: true},
{name: "unmanaged namespace", ns: &corev1.Namespace{}, want: true},
{
name: "matching label and owner reference",
ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{meta.TenantLabel: "green"},
OwnerReferences: []metav1.OwnerReference{tenantRef("green")},
}},
want: true,
},
{
name: "label only",
ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{meta.TenantLabel: "green"},
}},
want: false,
},
{
name: "owner reference only",
ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
OwnerReferences: []metav1.OwnerReference{tenantRef("green")},
}},
want: false,
},
{
name: "mismatched label and owner reference",
ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{meta.TenantLabel: "green"},
OwnerReferences: []metav1.OwnerReference{tenantRef("blue")},
}},
want: false,
},
{
name: "multiple tenant owner references",
ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{meta.TenantLabel: "green"},
OwnerReferences: []metav1.OwnerReference{
tenantRef("green"),
tenantRef("blue"),
},
}},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tenant.HasConsistentTenantReference(tt.ns); got != tt.want {
t.Fatalf("HasConsistentTenantReference() = %t, want %t", got, tt.want)
}
})
}
}
func TestBuildInstanceMetadataForNamespace_NoInstance(t *testing.T) {
t.Parallel()
+17
View File
@@ -39,6 +39,23 @@ func HasTenantReference(ns *corev1.Namespace) bool {
return false
}
// HasConsistentTenantReference reports whether Tenant ownership is either
// completely absent or represented by one matching label and ownerReference.
func HasConsistentTenantReference(ns *corev1.Namespace) bool {
if ns == nil {
return true
}
label := TenanLabelValue(ns)
refs := TenantOwnerReferences(ns)
if label == "" {
return len(refs) == 0
}
return len(refs) == 1 && refs[0].Name == label
}
func TenantOwnerReferences(ns *corev1.Namespace) []metav1.OwnerReference {
if ns == nil {
return nil