Skip to content

Commit d62f585

Browse files

File tree

7 files changed

+349
-2
lines changed

7 files changed

+349
-2
lines changed

config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ type Config struct {
5353
// DefaultOrganizationClusterRoles is a map containing the configuration for rolebindings that are created by default in each organization namespace.
5454
// The keys are the name of default rolebindings to create and the values are the names of the clusterroles they bind to.
5555
DefaultOrganizationClusterRoles map[string]string
56+
57+
// ReservedNamespaces is a list of namespaces that are reserved and can't be created by users.
58+
// Supports '*' and '?' wildcards.
59+
ReservedNamespaces []string
60+
// AllowedAnnotations is a list of annotations that are allowed on namespaces.
61+
// Supports '*' and '?' wildcards.
62+
AllowedAnnotations []string
63+
// AllowedLabels is a list of labels that are allowed on namespaces.
64+
// Supports '*' and '?' wildcards.
65+
AllowedLabels []string
5666
}
5767

5868
func ConfigFromFile(path string) (c Config, warn []string, err error) {

config.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,13 @@ DefaultNodeSelector:
3737
# The keys are the name of default rolebindings to create and the values are the names of the clusterroles they bind to.
3838
DefaultOrganizationClusterRoles:
3939
admin: admin
40+
41+
# ReservedNamespaces is a list of namespaces that are reserved and can't be created by users.
42+
# Supports '*' and '?' wildcards.
43+
ReservedNamespaces: [default, kube-*, openshift-*]
44+
# AllowedAnnotations is a list of annotations that are allowed on namespaces.
45+
# Supports '*' and '?' wildcards.
46+
AllowedAnnotations: [appuio.io/default-node-selector]
47+
# AllowedLabels is a list of labels that are allowed on namespaces.
48+
# Supports '*' and '?' wildcards.
49+
AllowedLabels: [appuio.io/organization]

config/webhook/manifests.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,48 @@ kind: ValidatingWebhookConfiguration
7373
metadata:
7474
name: validating-webhook-configuration
7575
webhooks:
76+
- admissionReviewVersions:
77+
- v1
78+
clientConfig:
79+
service:
80+
name: webhook-service
81+
namespace: system
82+
path: /validate-namespace-metadata
83+
failurePolicy: Fail
84+
matchPolicy: Equivalent
85+
name: validate-namespace-metadata-projectrequests.appuio.io
86+
rules:
87+
- apiGroups:
88+
- project.openshift.io
89+
apiVersions:
90+
- v1
91+
operations:
92+
- CREATE
93+
- UPDATE
94+
resources:
95+
- projectrequests
96+
sideEffects: None
97+
- admissionReviewVersions:
98+
- v1
99+
clientConfig:
100+
service:
101+
name: webhook-service
102+
namespace: system
103+
path: /validate-namespace-metadata
104+
failurePolicy: Fail
105+
matchPolicy: Equivalent
106+
name: validate-namespace-metadata.appuio.io
107+
rules:
108+
- apiGroups:
109+
- ""
110+
apiVersions:
111+
- v1
112+
operations:
113+
- CREATE
114+
- UPDATE
115+
resources:
116+
- namespaces
117+
sideEffects: None
76118
- admissionReviewVersions:
77119
- v1
78120
clientConfig:

main.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ func main() {
8383
var namespaceProjectOrganizationMutatorEnabled bool
8484
flag.BoolVar(&namespaceProjectOrganizationMutatorEnabled, "namespace-project-organization-mutator-enabled", false, "Enable the NamespaceProjectOrganizationMutator webhook. Adds the organization label to namespace and project create requests.")
8585

86+
var namespaceMetadataValidatorEnabled bool
87+
flag.BoolVar(&namespaceMetadataValidatorEnabled, "namespace-metadata-validator-enabled", false, "Enable the NamespaceMetadataValidator webhook. Validates the metadata of a namespace.")
88+
8689
var qps, burst int
8790
flag.IntVar(&qps, "qps", 20, "QPS to use for the controller-runtime client")
8891
flag.IntVar(&burst, "burst", 100, "Burst to use for the controller-runtime client")
@@ -264,7 +267,7 @@ func main() {
264267
Client: mgr.GetClient(),
265268

266269
Skipper: skipper.NewMultiSkipper(
267-
skipper.StaticSkipper{ShouldSkip: false},
270+
skipper.StaticSkipper{ShouldSkip: !namespaceProjectOrganizationMutatorEnabled},
268271
psk,
269272
),
270273

@@ -273,6 +276,20 @@ func main() {
273276
},
274277
})
275278

279+
mgr.GetWebhookServer().Register("/validate-namespace-metadata", &webhook.Admission{
280+
Handler: &webhooks.NamespaceMetadataValidator{
281+
Decoder: admission.NewDecoder(mgr.GetScheme()),
282+
Skipper: skipper.NewMultiSkipper(
283+
skipper.StaticSkipper{ShouldSkip: !namespaceMetadataValidatorEnabled},
284+
psk,
285+
),
286+
287+
ReservedNamespaces: conf.ReservedNamespaces,
288+
AllowedAnnotations: conf.AllowedAnnotations,
289+
AllowedLabels: conf.AllowedLabels,
290+
},
291+
})
292+
276293
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
277294
setupLog.Error(err, "unable to setup health endpoint")
278295
os.Exit(1)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package webhooks
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"slices"
8+
9+
"github.com/minio/pkg/wildcard"
10+
"go.uber.org/multierr"
11+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
12+
"k8s.io/apimachinery/pkg/util/sets"
13+
"sigs.k8s.io/controller-runtime/pkg/log"
14+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
15+
16+
"github.com/appuio/appuio-cloud-agent/skipper"
17+
)
18+
19+
// +kubebuilder:webhook:path=/validate-namespace-metadata,name=validate-namespace-metadata.appuio.io,admissionReviewVersions=v1,sideEffects=none,mutating=false,failurePolicy=Fail,groups="",resources=namespaces,verbs=create;update,versions=v1,matchPolicy=equivalent
20+
// +kubebuilder:webhook:path=/validate-namespace-metadata,name=validate-namespace-metadata-projectrequests.appuio.io,admissionReviewVersions=v1,sideEffects=none,mutating=false,failurePolicy=Fail,groups=project.openshift.io,resources=projectrequests,verbs=create;update,versions=v1,matchPolicy=equivalent
21+
22+
// NamespaceMetadataValidator validates the metadata of a namespace.
23+
type NamespaceMetadataValidator struct {
24+
Decoder admission.Decoder
25+
26+
Skipper skipper.Skipper
27+
28+
// ReservedNamespace is a list of namespaces that are reserved and do not count towards the quota.
29+
// Supports '*' and '?' wildcards.
30+
ReservedNamespaces []string
31+
// AllowedAnnotations is a list of annotations that are allowed on the namespace.
32+
// Supports '*' and '?' wildcards.
33+
AllowedAnnotations []string
34+
// AllowedLabels is a list of labels that are allowed on the namespace.
35+
// Supports '*' and '?' wildcards.
36+
AllowedLabels []string
37+
}
38+
39+
// Handle handles the admission requests
40+
func (v *NamespaceMetadataValidator) Handle(ctx context.Context, req admission.Request) admission.Response {
41+
ctx = log.IntoContext(ctx, log.FromContext(ctx).
42+
WithName("webhook.validate-namespace-metadata.appuio.io").
43+
WithValues("id", req.UID, "user", req.UserInfo.Username).
44+
WithValues("namespace", req.Namespace, "name", req.Name,
45+
"group", req.Kind.Group, "version", req.Kind.Version, "kind", req.Kind.Kind))
46+
47+
return logAdmissionResponse(ctx, v.handle(ctx, req))
48+
}
49+
50+
func (v *NamespaceMetadataValidator) handle(ctx context.Context, req admission.Request) admission.Response {
51+
skip, err := v.Skipper.Skip(ctx, req)
52+
if err != nil {
53+
return admission.Errored(http.StatusInternalServerError, err)
54+
}
55+
if skip {
56+
return admission.Allowed("skipped")
57+
}
58+
59+
for _, ns := range v.ReservedNamespaces {
60+
if wildcard.Match(ns, req.Name) {
61+
return admission.Denied("Changing or creating reserved namespaces is not allowed.")
62+
}
63+
}
64+
65+
var oldObj unstructured.Unstructured
66+
if len(req.OldObject.Raw) > 0 {
67+
if err := v.Decoder.DecodeRaw(req.OldObject, &oldObj); err != nil {
68+
return admission.Errored(http.StatusBadRequest, fmt.Errorf("failed to decode old object: %w", err))
69+
}
70+
}
71+
72+
var newObj unstructured.Unstructured
73+
if err := v.Decoder.Decode(req, &newObj); err != nil {
74+
return admission.Errored(http.StatusBadRequest, fmt.Errorf("failed to decode object from request: %w", err))
75+
}
76+
77+
if err := validateChangedMap(oldObj.GetAnnotations(), newObj.GetAnnotations(), v.AllowedAnnotations, "annotation"); err != nil {
78+
return admission.Denied(formatDeniedMessage(err, "annotations", v.AllowedAnnotations, newObj.GetAnnotations(), oldObj.GetAnnotations()))
79+
}
80+
if err := validateChangedMap(oldObj.GetLabels(), newObj.GetLabels(), v.AllowedLabels, "label"); err != nil {
81+
return admission.Denied(formatDeniedMessage(err, "labels", v.AllowedLabels, newObj.GetLabels(), oldObj.GetLabels()))
82+
}
83+
84+
return admission.Allowed("allowed")
85+
}
86+
87+
func formatDeniedMessage(err error, errMapRef string, allowed []string, newMap, oldMap map[string]string) string {
88+
msg := `The request was denied:
89+
%v
90+
The following %s can be modified:
91+
%s
92+
%s given:
93+
%s
94+
%s before modification:
95+
%s
96+
`
97+
98+
return fmt.Sprintf(msg, err, errMapRef, allowed, errMapRef, newMap, errMapRef, oldMap)
99+
}
100+
101+
func validateChangedMap(old, new map[string]string, allowedKeys []string, errObjectRef string) error {
102+
changed := changedKeys(old, new)
103+
errs := make([]error, 0, len(changed))
104+
for _, k := range changed {
105+
allowed := slices.ContainsFunc(allowedKeys, func(a string) bool { return wildcard.Match(a, k) })
106+
if !allowed {
107+
errs = append(errs, fmt.Errorf("%s %q is not allowed to be changed", errObjectRef, k))
108+
}
109+
}
110+
111+
return multierr.Combine(errs...)
112+
}
113+
114+
func changedKeys(a, b map[string]string) []string {
115+
changed := sets.New[string]()
116+
117+
for k, v := range a {
118+
if bV, ok := b[k]; !ok || v != bV {
119+
changed.Insert(k)
120+
}
121+
}
122+
for k, v := range b {
123+
if aV, ok := a[k]; !ok || v != aV {
124+
changed.Insert(k)
125+
}
126+
}
127+
128+
return sets.List(changed)
129+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package webhooks
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
"sigs.k8s.io/controller-runtime/pkg/client"
9+
10+
"github.com/appuio/appuio-cloud-agent/skipper"
11+
)
12+
13+
func Test_NamespaceMetadataValidator_Handle(t *testing.T) {
14+
15+
testCases := []struct {
16+
name string
17+
18+
reservedNamespaces []string
19+
allowedAnnotations []string
20+
allowedLabels []string
21+
22+
object client.Object
23+
oldObj client.Object
24+
25+
allowed bool
26+
}{
27+
{
28+
name: "new namespace with allowed name",
29+
object: newNamespace("test-namespace", nil, nil),
30+
31+
reservedNamespaces: []string{"appuio*"},
32+
33+
allowed: true,
34+
},
35+
{
36+
name: "new project with allowed name",
37+
object: newProjectRequest("test-project", nil, nil),
38+
39+
reservedNamespaces: []string{"appuio*"},
40+
41+
allowed: true,
42+
},
43+
{
44+
name: "new namespace with reserved name",
45+
object: newNamespace("appuio-blub", nil, nil),
46+
47+
reservedNamespaces: []string{"test", "appuio*"},
48+
49+
allowed: false,
50+
},
51+
{
52+
name: "new project with reserved name",
53+
object: newProjectRequest("appuio-blub", nil, nil),
54+
55+
reservedNamespaces: []string{"test", "appuio*"},
56+
57+
allowed: false,
58+
},
59+
{
60+
name: "new namespace with allowed annotation",
61+
object: newNamespace("test-namespace", nil, map[string]string{"allowed": ""}),
62+
63+
allowedAnnotations: []string{"allowed"},
64+
allowed: true,
65+
},
66+
{
67+
name: "new namespace with disallowed annotation",
68+
object: newNamespace("test-namespace", nil, map[string]string{"disallowed": ""}),
69+
70+
allowedAnnotations: []string{"allowed"},
71+
allowed: false,
72+
},
73+
{
74+
name: "new namespace with allowed label",
75+
object: newNamespace("test-namespace", map[string]string{"allowed-kajshd": "", "custom/x": "asd"}, nil),
76+
77+
allowedLabels: []string{"allowed*", "custom/*"},
78+
allowed: true,
79+
},
80+
{
81+
name: "new namespace with disallowed label",
82+
object: newNamespace("test-namespace", map[string]string{"disallowed": ""}, nil),
83+
84+
allowedLabels: []string{"allowed"},
85+
allowed: false,
86+
},
87+
{
88+
name: "update namespace with allowed annotation",
89+
object: newNamespace("test-namespace", nil, map[string]string{"pre-existing": "s", "allowed": ""}),
90+
oldObj: newNamespace("test-namespace", nil, map[string]string{"pre-existing": "s"}),
91+
92+
allowedAnnotations: []string{"allowed"},
93+
allowed: true,
94+
},
95+
{
96+
name: "update namespace with disallowed annotation",
97+
object: newNamespace("test-namespace", nil, map[string]string{"pre-existing": "s", "disallowed": "a"}),
98+
oldObj: newNamespace("test-namespace", nil, map[string]string{"pre-existing": "s", "disallowed": "b"}),
99+
allowedAnnotations: []string{"allowed"},
100+
allowed: false,
101+
},
102+
{
103+
name: "remove disallowed annotation",
104+
object: newNamespace("test-namespace", nil, map[string]string{"pre-existing": "s"}),
105+
oldObj: newNamespace("test-namespace", nil, map[string]string{"pre-existing": "s", "disallowed": "b", "disallowed2": "", "allowed": ""}),
106+
allowedAnnotations: []string{"allowed"},
107+
allowed: false,
108+
},
109+
{
110+
name: "remove disallowed annotation",
111+
object: newNamespace("test-namespace", nil, map[string]string{"pre-existing": "s"}),
112+
oldObj: newNamespace("test-namespace", nil, map[string]string{"pre-existing": "s", "disallowed": ""}),
113+
allowedAnnotations: []string{"allowed"},
114+
allowed: false,
115+
},
116+
}
117+
118+
_, scheme, decoder := prepareClient(t)
119+
120+
for _, tc := range testCases {
121+
t.Run(tc.name, func(t *testing.T) {
122+
t.Parallel()
123+
124+
subject := &NamespaceMetadataValidator{
125+
Decoder: decoder,
126+
Skipper: skipper.StaticSkipper{},
127+
ReservedNamespaces: tc.reservedNamespaces,
128+
AllowedAnnotations: tc.allowedAnnotations,
129+
AllowedLabels: tc.allowedLabels,
130+
}
131+
132+
amr := admissionRequestForObjectWithOldObject(t, tc.object, tc.oldObj, scheme)
133+
134+
resp := subject.Handle(context.Background(), amr)
135+
t.Log("Response:", resp.Result.Reason, resp.Result.Message)
136+
require.Equal(t, tc.allowed, resp.Allowed)
137+
})
138+
}
139+
}

webhooks/service_cloudscale_lb_validator.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func (v *ServiceCloudscaleLBValidator) handle(ctx context.Context, req admission
5555
}
5656

5757
var oldService corev1.Service
58-
if req.OldObject.Raw != nil {
58+
if len(req.OldObject.Raw) > 0 {
5959
if err := v.Decoder.DecodeRaw(req.OldObject, &oldService); err != nil {
6060
l.Error(err, "failed to decode old object")
6161
return admission.Errored(http.StatusBadRequest, err)

0 commit comments

Comments
 (0)