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