feat(manager): add forbidden annotations, forbidden labels to service options

Signed-off-by: Siarhei Rasiukevich <s.rasiukevich@gmail.com>
This commit is contained in:
Siarhei Rasiukevich
2023-11-24 12:30:59 +01:00
committed by Dario Tranchitella
parent 8695dfb7a2
commit b27780d74c
12 changed files with 658 additions and 104 deletions
+63 -1
View File
@@ -4,13 +4,21 @@
package api
import (
"fmt"
"reflect"
"regexp"
"sort"
"strings"
)
// +kubebuilder:object:generate=true
const (
// ForbiddenLabelReason used as reason string to deny forbidden labels.
ForbiddenLabelReason = "ForbiddenLabel"
// ForbiddenAnnotationReason used as reason string to deny forbidden annotations.
ForbiddenAnnotationReason = "ForbiddenAnnotation"
)
// +kubebuilder:object:generate=true
type ForbiddenListSpec struct {
Exact []string `json:"denied,omitempty"`
Regex string `json:"deniedRegex,omitempty"`
@@ -37,3 +45,57 @@ func (in ForbiddenListSpec) RegexMatch(value string) (ok bool) {
return
}
type ForbiddenError struct {
key string
spec ForbiddenListSpec
}
func NewForbiddenError(key string, forbiddenSpec ForbiddenListSpec) error {
return &ForbiddenError{
key: key,
spec: forbiddenSpec,
}
}
//nolint:predeclared
func (f *ForbiddenError) appendForbiddenError() (append string) {
append += "Forbidden are "
if len(f.spec.Exact) > 0 {
append += fmt.Sprintf("one of the following (%s)", strings.Join(f.spec.Exact, ", "))
if len(f.spec.Regex) > 0 {
append += " or "
}
}
if len(f.spec.Regex) > 0 {
append += fmt.Sprintf("matching the regex %s", f.spec.Regex)
}
return
}
func (f ForbiddenError) Error() string {
return fmt.Sprintf("%s is forbidden for the current Tenant. %s", f.key, f.appendForbiddenError())
}
func ValidateForbidden(metadata map[string]string, forbiddenList ForbiddenListSpec) error {
if reflect.DeepEqual(ForbiddenListSpec{}, forbiddenList) {
return nil
}
for key := range metadata {
var forbidden, matched bool
forbidden = forbiddenList.ExactMatch(key)
matched = forbiddenList.RegexMatch(key)
if forbidden || matched {
return NewForbiddenError(
key,
forbiddenList,
)
}
}
return nil
}
+47
View File
@@ -72,3 +72,50 @@ func TestForbiddenListSpec_RegexMatch(t *testing.T) {
}
}
}
func TestValidateForbidden(t *testing.T) {
type tc struct {
Keys map[string]string
ForbiddenSpec ForbiddenListSpec
HasError bool
}
for _, tc := range []tc{
{
Keys: map[string]string{"foobar": "", "thesecondkey": "", "anotherkey": ""},
ForbiddenSpec: ForbiddenListSpec{
Exact: []string{"foobar", "somelabelkey1"},
},
HasError: true,
},
{
Keys: map[string]string{"foobar": ""},
ForbiddenSpec: ForbiddenListSpec{
Exact: []string{"foobar.io", "somelabelkey1", "test-exact"},
},
HasError: false,
},
{
Keys: map[string]string{"foobar": "", "barbaz": ""},
ForbiddenSpec: ForbiddenListSpec{
Regex: "foo.*",
},
HasError: true,
},
{
Keys: map[string]string{"foobar": "", "another-annotation-key": ""},
ForbiddenSpec: ForbiddenListSpec{
Regex: "foo1111",
},
HasError: false,
},
} {
if tc.HasError {
assert.Error(t, ValidateForbidden(tc.Keys, tc.ForbiddenSpec))
}
if !tc.HasError {
assert.NoError(t, ValidateForbidden(tc.Keys, tc.ForbiddenSpec))
}
}
}
+4
View File
@@ -12,4 +12,8 @@ type ServiceOptions struct {
AllowedServices *AllowedServices `json:"allowedServices,omitempty"`
// Specifies the external IPs that can be used in Services with type ClusterIP. An empty list means no IPs are allowed. Optional.
ExternalServiceIPs *ExternalServiceIPsSpec `json:"externalIPs,omitempty"`
// Define the labels that a Tenant Owner cannot set for their Service resources.
ForbiddenLabels ForbiddenListSpec `json:"forbiddenLabels,omitempty"`
// Define the annotations that a Tenant Owner cannot set for their Service resources.
ForbiddenAnnotations ForbiddenListSpec `json:"forbiddenAnnotations,omitempty"`
}
+2
View File
@@ -290,6 +290,8 @@ func (in *ServiceOptions) DeepCopyInto(out *ServiceOptions) {
*out = new(ExternalServiceIPsSpec)
(*in).DeepCopyInto(*out)
}
in.ForbiddenLabels.DeepCopyInto(&out.ForbiddenLabels)
in.ForbiddenAnnotations.DeepCopyInto(&out.ForbiddenAnnotations)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceOptions.
-56
View File
@@ -3,30 +3,6 @@
package namespace
import (
"fmt"
"strings"
capsuleapi "github.com/projectcapsule/capsule/pkg/api"
)
//nolint:predeclared
func appendForbiddenError(spec *capsuleapi.ForbiddenListSpec) (append string) {
append += "Forbidden are "
if len(spec.Exact) > 0 {
append += fmt.Sprintf("one of the following (%s)", strings.Join(spec.Exact, ", "))
if len(spec.Regex) > 0 {
append += " or "
}
}
if len(spec.Regex) > 0 {
append += fmt.Sprintf("matching the regex %s", spec.Regex)
}
return
}
type namespaceQuotaExceededError struct{}
func NewNamespaceQuotaExceededError() error {
@@ -36,35 +12,3 @@ func NewNamespaceQuotaExceededError() error {
func (namespaceQuotaExceededError) Error() string {
return "Cannot exceed Namespace quota: please, reach out to the system administrators"
}
type namespaceLabelForbiddenError struct {
label string
spec *capsuleapi.ForbiddenListSpec
}
func NewNamespaceLabelForbiddenError(label string, forbiddenSpec *capsuleapi.ForbiddenListSpec) error {
return &namespaceLabelForbiddenError{
label: label,
spec: forbiddenSpec,
}
}
func (f namespaceLabelForbiddenError) Error() string {
return fmt.Sprintf("Label %s is forbidden for namespaces in the current Tenant. %s", f.label, appendForbiddenError(f.spec))
}
type namespaceAnnotationForbiddenError struct {
annotation string
spec *capsuleapi.ForbiddenListSpec
}
func NewNamespaceAnnotationForbiddenError(annotation string, forbiddenSpec *capsuleapi.ForbiddenListSpec) error {
return &namespaceAnnotationForbiddenError{
annotation: annotation,
spec: forbiddenSpec,
}
}
func (f namespaceAnnotationForbiddenError) Error() string {
return fmt.Sprintf("Annotation %s is forbidden for namespaces in the current Tenant. %s", f.annotation, appendForbiddenError(f.spec))
}
+43 -47
View File
@@ -5,8 +5,8 @@ package namespace
import (
"context"
"fmt"
"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
@@ -14,6 +14,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
@@ -24,48 +25,6 @@ func UserMetadataHandler() capsulewebhook.Handler {
return &userMetadataHandler{}
}
func (r *userMetadataHandler) validateUserMetadata(tnt *capsulev1beta2.Tenant, recorder record.EventRecorder, labels map[string]string, annotations map[string]string) *admission.Response {
if tnt.Spec.NamespaceOptions != nil {
forbiddenLabels := tnt.Spec.NamespaceOptions.ForbiddenLabels
for label := range labels {
var forbidden, matched bool
forbidden = forbiddenLabels.ExactMatch(label)
matched = forbiddenLabels.RegexMatch(label)
if forbidden || matched {
recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenNamespaceLabel", fmt.Sprintf("Label %s is forbidden for a namespaces of the current Tenant ", label))
response := admission.Denied(NewNamespaceLabelForbiddenError(label, &forbiddenLabels).Error())
return &response
}
}
}
if tnt.Spec.NamespaceOptions == nil {
return nil
}
forbiddenAnnotations := tnt.Spec.NamespaceOptions.ForbiddenLabels
for annotation := range annotations {
var forbidden, matched bool
forbidden = forbiddenAnnotations.ExactMatch(annotation)
matched = forbiddenAnnotations.RegexMatch(annotation)
if forbidden || matched {
recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenNamespaceAnnotation", fmt.Sprintf("Annotation %s is forbidden for a namespaces of the current Tenant ", annotation))
response := admission.Denied(NewNamespaceAnnotationForbiddenError(annotation, &forbiddenAnnotations).Error())
return &response
}
}
return nil
}
func (r *userMetadataHandler) OnCreate(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
ns := &corev1.Namespace{}
@@ -81,10 +40,27 @@ func (r *userMetadataHandler) OnCreate(client client.Client, decoder *admission.
}
}
labels := ns.GetLabels()
annotations := ns.GetAnnotations()
if tnt.Spec.NamespaceOptions != nil {
err := api.ValidateForbidden(ns.ObjectMeta.Annotations, tnt.Spec.NamespaceOptions.ForbiddenAnnotations)
if err != nil {
err = errors.Wrap(err, "namespace annotations validation failed")
recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenAnnotationReason, err.Error())
response := admission.Denied(err.Error())
return r.validateUserMetadata(tnt, recorder, labels, annotations)
return &response
}
err = api.ValidateForbidden(ns.ObjectMeta.Labels, tnt.Spec.NamespaceOptions.ForbiddenLabels)
if err != nil {
err = errors.Wrap(err, "namespace labels validation failed")
recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenLabelReason, err.Error())
response := admission.Denied(err.Error())
return &response
}
}
return nil
}
}
@@ -173,6 +149,26 @@ func (r *userMetadataHandler) OnUpdate(client client.Client, decoder *admission.
delete(annotations, key)
}
return r.validateUserMetadata(tnt, recorder, labels, annotations)
if tnt.Spec.NamespaceOptions != nil {
err := api.ValidateForbidden(annotations, tnt.Spec.NamespaceOptions.ForbiddenAnnotations)
if err != nil {
err = errors.Wrap(err, "namespace annotations validation failed")
recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenAnnotationReason, err.Error())
response := admission.Denied(err.Error())
return &response
}
err = api.ValidateForbidden(labels, tnt.Spec.NamespaceOptions.ForbiddenLabels)
if err != nil {
err = errors.Wrap(err, "namespace labels validation failed")
recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenLabelReason, err.Error())
response := admission.Denied(err.Error())
return &response
}
}
return nil
}
}
+22
View File
@@ -8,6 +8,7 @@ import (
"net"
"strings"
"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/client-go/tools/record"
@@ -15,6 +16,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
@@ -68,6 +70,26 @@ func (r *handler) handleService(ctx context.Context, clt client.Client, decoder
return &response
}
if tnt.Spec.ServiceOptions != nil {
err := api.ValidateForbidden(svc.Annotations, tnt.Spec.ServiceOptions.ForbiddenAnnotations)
if err != nil {
err = errors.Wrap(err, "service annotations validation failed")
recorder.Eventf(&tnt, corev1.EventTypeWarning, api.ForbiddenAnnotationReason, err.Error())
response := admission.Denied(err.Error())
return &response
}
err = api.ValidateForbidden(svc.Labels, tnt.Spec.ServiceOptions.ForbiddenLabels)
if err != nil {
err = errors.Wrap(err, "service labels validation failed")
recorder.Eventf(&tnt, corev1.EventTypeWarning, api.ForbiddenLabelReason, err.Error())
response := admission.Denied(err.Error())
return &response
}
}
if svc.Spec.ExternalIPs == nil || (tnt.Spec.ServiceOptions == nil || tnt.Spec.ServiceOptions.ExternalServiceIPs == nil) {
return nil
}