feat(config): add ignore user groups property (#1586)

* feat(config): add ignore user groups property

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

* feat(config): add ignore user groups property

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

* feat(config): add ignore user groups property

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

* feat(config): add ignore user groups property

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

* feat(config): add ignore user groups property

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

* feat(config): add ignore user groups property

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

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
This commit is contained in:
Oliver Bähler
2025-08-15 00:23:33 +02:00
committed by GitHub
parent 1336ebe9c3
commit 074eb40734
14 changed files with 116 additions and 10 deletions

View File

@@ -23,6 +23,7 @@ linters:
- unparam
- varnamelen
- wrapcheck
- interfacebloat
- noinlineerr
- revive
settings:

View File

@@ -14,6 +14,9 @@ type CapsuleConfigurationSpec struct {
// Names of the groups for Capsule users.
// +kubebuilder:default={capsule.clastix.io}
UserGroups []string `json:"userGroups,omitempty"`
// Define groups which when found in the request of a user will be ignored by the Capsule
// this might be useful if you have one group where all the users are in, but you want to separate administrators from normal users with additional groups.
IgnoreUserWithGroups []string `json:"ignoreUserWithGroups,omitempty"`
// Enforces the Tenant owner, during Namespace creation, to name it using the selected Tenant name as prefix,
// separated by a dash. This is useful to avoid Namespace name collision in a public CaaS environment.
// +kubebuilder:default=false

View File

@@ -122,6 +122,11 @@ func (in *CapsuleConfigurationSpec) DeepCopyInto(out *CapsuleConfigurationSpec)
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.IgnoreUserWithGroups != nil {
in, out := &in.IgnoreUserWithGroups, &out.IgnoreUserWithGroups
*out = make([]string, len(*in))
copy(*out, *in)
}
out.CapsuleResources = in.CapsuleResources
if in.NodeMetadata != nil {
in, out := &in.NodeMetadata, &out.NodeMetadata

View File

@@ -52,6 +52,13 @@ spec:
Enforces the Tenant owner, during Namespace creation, to name it using the selected Tenant name as prefix,
separated by a dash. This is useful to avoid Namespace name collision in a public CaaS environment.
type: boolean
ignoreUserWithGroups:
description: |-
Define groups which when found in the request of a user will be ignored by the Capsule
this might be useful if you have one group where all the users are in, but you want to separate administrators from normal users with additional groups.
items:
type: string
type: array
nodeMetadata:
description: |-
Allows to set the forbidden metadata for the worker nodes that could be patched by a Tenant.

View File

@@ -9,11 +9,14 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
var _ = Describe("creating a Namespace as Tenant owner with custom --capsule-group", Label("config"), func() {
originConfig := &capsulev1beta2.CapsuleConfiguration{}
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant-assigned-custom-group",
@@ -29,6 +32,8 @@ var _ = Describe("creating a Namespace as Tenant owner with custom --capsule-gro
}
JustBeforeEach(func() {
Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: defaultConfigurationName}, originConfig)).To(Succeed())
EventuallyCreation(func() error {
tnt.ResourceVersion = ""
return k8sClient.Create(context.TODO(), tnt)
@@ -36,6 +41,17 @@ var _ = Describe("creating a Namespace as Tenant owner with custom --capsule-gro
})
JustAfterEach(func() {
Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
// Restore Configuration
Eventually(func() error {
c := &capsulev1beta2.CapsuleConfiguration{}
if err := k8sClient.Get(context.Background(), client.ObjectKey{Name: originConfig.Name}, c); err != nil {
return err
}
// Apply the initial configuration from originConfig to c
c.Spec = originConfig.Spec
return k8sClient.Update(context.Background(), c)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
})
It("should fail using a User non matching the capsule-user-group flag", func() {
@@ -68,4 +84,16 @@ var _ = Describe("creating a Namespace as Tenant owner with custom --capsule-gro
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
})
It("should fail when group is ignored", func() {
ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) {
configuration.Spec.UserGroups = []string{"projectcapsule.dev"}
configuration.Spec.IgnoreUserWithGroups = []string{"projectcapsule.dev"}
})
ns := NewNamespace("")
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).ShouldNot(Succeed())
})
})

View File

@@ -12,6 +12,7 @@ import (
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
@@ -19,6 +20,8 @@ import (
)
var _ = Describe("modifying node labels and annotations", Label("config", "nodes"), func() {
originConfig := &capsulev1beta2.CapsuleConfiguration{}
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant-node-user-metadata-forbidden",
@@ -72,6 +75,8 @@ var _ = Describe("modifying node labels and annotations", Label("config", "nodes
Skip(fmt.Sprintf("Node webhook is disabled for current version %s", version.String()))
}
Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: defaultConfigurationName}, originConfig)).To(Succeed())
EventuallyCreation(func() error {
tnt.ResourceVersion = ""
return k8sClient.Create(context.TODO(), tnt)
@@ -110,6 +115,17 @@ var _ = Describe("modifying node labels and annotations", Label("config", "nodes
return k8sClient.Update(context.Background(), node)
})
}).Should(Succeed())
// Restore Configuration
Eventually(func() error {
c := &capsulev1beta2.CapsuleConfiguration{}
if err := k8sClient.Get(context.Background(), client.ObjectKey{Name: originConfig.Name}, c); err != nil {
return err
}
// Apply the initial configuration from originConfig to c
c.Spec = originConfig.Spec
return k8sClient.Update(context.Background(), c)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
})
It("should allow", func() {
@@ -171,6 +187,19 @@ var _ = Describe("modifying node labels and annotations", Label("config", "nodes
})
})
It("should fail", func() {
ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) {
configuration.Spec.NodeMetadata = &capsulev1beta2.NodeMetadata{
ForbiddenLabels: api.ForbiddenListSpec{
Exact: []string{"foo", "bar"},
Regex: "^gatsby-.*$",
},
ForbiddenAnnotations: api.ForbiddenListSpec{
Exact: []string{"foo", "bar"},
Regex: "^gatsby-.*$",
},
}
})
Expect(ModifyNode(func(node *corev1.Node) error {
node.Labels["foo"] = "bar"
node.Labels["gatsby-foo"] = "bar"

View File

@@ -9,11 +9,14 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
var _ = Describe("creating a Namespace with a protected Namespace regex enabled", Label("namespace"), func() {
originConfig := &capsulev1beta2.CapsuleConfiguration{}
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant-protected-namespace",
@@ -29,6 +32,8 @@ var _ = Describe("creating a Namespace with a protected Namespace regex enabled"
}
JustBeforeEach(func() {
Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: defaultConfigurationName}, originConfig)).To(Succeed())
EventuallyCreation(func() error {
tnt.ResourceVersion = ""
return k8sClient.Create(context.TODO(), tnt)
@@ -36,6 +41,17 @@ var _ = Describe("creating a Namespace with a protected Namespace regex enabled"
})
JustAfterEach(func() {
Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
// Restore Configuration
Eventually(func() error {
c := &capsulev1beta2.CapsuleConfiguration{}
if err := k8sClient.Get(context.Background(), client.ObjectKey{Name: originConfig.Name}, c); err != nil {
return err
}
// Apply the initial configuration from originConfig to c
c.Spec = originConfig.Spec
return k8sClient.Update(context.Background(), c)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
})
It("should succeed and be available in Tenant namespaces list", func() {

View File

@@ -27,8 +27,9 @@ import (
)
const (
defaultTimeoutInterval = 40 * time.Second
defaultPollInterval = time.Second
defaultTimeoutInterval = 40 * time.Second
defaultPollInterval = time.Second
defaultConfigurationName = "default"
)
func NewService(svc types.NamespacedName) *corev1.Service {
@@ -100,7 +101,7 @@ func EventuallyCreation(f interface{}) AsyncAssertion {
func ModifyCapsuleConfigurationOpts(fn func(configuration *capsulev1beta2.CapsuleConfiguration)) {
config := &capsulev1beta2.CapsuleConfiguration{}
Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: "default"}, config)).ToNot(HaveOccurred())
Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: defaultConfigurationName}, config)).ToNot(HaveOccurred())
fn(config)

View File

@@ -85,6 +85,10 @@ func (c *capsuleConfiguration) UserGroups() []string {
return c.retrievalFn().Spec.UserGroups
}
func (c *capsuleConfiguration) IgnoreUserWithGroups() []string {
return c.retrievalFn().Spec.IgnoreUserWithGroups
}
func (c *capsuleConfiguration) ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec {
if c.retrievalFn().Spec.NodeMetadata == nil {
return nil

View File

@@ -24,6 +24,7 @@ type Configuration interface {
ValidatingWebhookConfigurationName() string
TenantCRDName() string
UserGroups() []string
IgnoreUserWithGroups() []string
ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec
ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec
}

View File

@@ -74,7 +74,7 @@ func (r *freezedHandler) OnDelete(c client.Client, _ admission.Decoder, recorder
tnt := tntList.Items[0]
if tnt.Spec.Cordoned && utils.IsCapsuleUser(ctx, req, c, r.configuration.UserGroups()) {
if tnt.Spec.Cordoned && utils.IsCapsuleUser(ctx, req, c, r.configuration.UserGroups(), r.configuration.IgnoreUserWithGroups()) {
recorder.Eventf(&tnt, corev1.EventTypeWarning, "TenantFreezed", "Namespace %s cannot be deleted, the current Tenant is freezed", req.Name)
response := admission.Denied("the selected Tenant is freezed")
@@ -106,7 +106,7 @@ func (r *freezedHandler) OnUpdate(c client.Client, decoder admission.Decoder, re
tnt := tntList.Items[0]
if tnt.Spec.Cordoned && utils.IsCapsuleUser(ctx, req, c, r.configuration.UserGroups()) {
if tnt.Spec.Cordoned && utils.IsCapsuleUser(ctx, req, c, r.configuration.UserGroups(), r.configuration.IgnoreUserWithGroups()) {
recorder.Eventf(&tnt, corev1.EventTypeWarning, "TenantFreezed", "Namespace %s cannot be updated, the current Tenant is freezed", ns.GetName())
response := admission.Denied("the selected Tenant is freezed")

View File

@@ -62,7 +62,7 @@ func (h *cordoningHandler) cordonHandler(ctx context.Context, clt client.Client,
}
tnt := tntList.Items[0]
if tnt.Spec.Cordoned && utils.IsCapsuleUser(ctx, req, clt, h.configuration.UserGroups()) {
if tnt.Spec.Cordoned && utils.IsCapsuleUser(ctx, req, clt, h.configuration.UserGroups(), h.configuration.IgnoreUserWithGroups()) {
recorder.Eventf(&tnt, corev1.EventTypeWarning, "TenantFreezed", "%s %s/%s cannot be %sd, current Tenant is freezed", req.Kind.String(), req.Namespace, req.Name, strings.ToLower(string(req.Operation)))
response := admission.Denied(fmt.Sprintf("tenant %s is freezed: please, reach out to the system administrator", tnt.GetName()))

View File

@@ -26,9 +26,10 @@ type handler struct {
handlers []webhook.Handler
}
//nolint:dupl
func (h *handler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
if !IsCapsuleUser(ctx, req, client, h.configuration.UserGroups()) {
if !IsCapsuleUser(ctx, req, client, h.configuration.UserGroups(), h.configuration.IgnoreUserWithGroups()) {
return nil
}
@@ -42,9 +43,10 @@ func (h *handler) OnCreate(client client.Client, decoder admission.Decoder, reco
}
}
//nolint:dupl
func (h *handler) OnDelete(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
if !IsCapsuleUser(ctx, req, client, h.configuration.UserGroups()) {
if !IsCapsuleUser(ctx, req, client, h.configuration.UserGroups(), h.configuration.IgnoreUserWithGroups()) {
return nil
}
@@ -58,9 +60,10 @@ func (h *handler) OnDelete(client client.Client, decoder admission.Decoder, reco
}
}
//nolint:dupl
func (h *handler) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
if !IsCapsuleUser(ctx, req, client, h.configuration.UserGroups()) {
if !IsCapsuleUser(ctx, req, client, h.configuration.UserGroups(), h.configuration.IgnoreUserWithGroups()) {
return nil
}

View File

@@ -16,7 +16,7 @@ import (
"github.com/projectcapsule/capsule/pkg/utils"
)
func IsCapsuleUser(ctx context.Context, req admission.Request, clt client.Client, userGroups []string) bool {
func IsCapsuleUser(ctx context.Context, req admission.Request, clt client.Client, userGroups []string, ignoreGroups []string) bool {
groupList := utils.NewUserGroupList(req.UserInfo.Groups)
// if the user is a ServiceAccount belonging to the kube-system namespace, definitely, it's not a Capsule user
// and we can skip the check in case of Capsule user group assigned to system:authenticated
@@ -44,6 +44,14 @@ func IsCapsuleUser(ctx context.Context, req admission.Request, clt client.Client
for _, group := range userGroups {
if groupList.Find(group) {
if len(ignoreGroups) > 0 {
for _, ignoreGroup := range ignoreGroups {
if groupList.Find(ignoreGroup) {
return false
}
}
}
return true
}
}