mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-19 04:26:45 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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"))),
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user