Skip to content

Commit 8503d2c

Browse files
authored
Replace Kyverno namespace/project policies (#116)
1 parent bfd7d3e commit 8503d2c

File tree

6 files changed

+679
-2
lines changed

6 files changed

+679
-2
lines changed

config/webhook/manifests.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,48 @@ webhooks:
2525
resources:
2626
- pods
2727
sideEffects: None
28+
- admissionReviewVersions:
29+
- v1
30+
clientConfig:
31+
service:
32+
name: webhook-service
33+
namespace: system
34+
path: /mutate-namespace-project-organization
35+
failurePolicy: Fail
36+
matchPolicy: Equivalent
37+
name: namespace.namespace-project-organization-mutator.appuio.io
38+
rules:
39+
- apiGroups:
40+
- ""
41+
apiVersions:
42+
- v1
43+
operations:
44+
- CREATE
45+
- UPDATE
46+
resources:
47+
- namespaces
48+
sideEffects: None
49+
- admissionReviewVersions:
50+
- v1
51+
clientConfig:
52+
service:
53+
name: webhook-service
54+
namespace: system
55+
path: /mutate-namespace-project-organization
56+
failurePolicy: Fail
57+
matchPolicy: Equivalent
58+
name: project.namespace-project-organization-mutator.appuio.io
59+
rules:
60+
- apiGroups:
61+
- project.openshift.io
62+
apiVersions:
63+
- v1
64+
operations:
65+
- CREATE
66+
- UPDATE
67+
resources:
68+
- projects
69+
sideEffects: None
2870
---
2971
apiVersion: admissionregistration.k8s.io/v1
3072
kind: ValidatingWebhookConfiguration

main.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ func main() {
8080
var cloudscaleLoadbalancerValidationEnabled bool
8181
flag.BoolVar(&cloudscaleLoadbalancerValidationEnabled, "cloudscale-loadbalancer-validation-enabled", false, "Enable Cloudscale Loadbalancer validation. Validates that the k8s.cloudscale.ch/loadbalancer-uuid annotation cannot be changed by unprivileged users.")
8282

83+
var namespaceProjectOrganizationMutatorEnabled bool
84+
flag.BoolVar(&namespaceProjectOrganizationMutatorEnabled, "namespace-project-organization-mutator-enabled", false, "Enable the NamespaceProjectOrganizationMutator webhook. Adds the organization label to namespace and project create requests.")
85+
8386
var qps, burst int
8487
flag.IntVar(&qps, "qps", 20, "QPS to use for the controller-runtime client")
8588
flag.IntVar(&burst, "burst", 100, "Burst to use for the controller-runtime client")
@@ -255,6 +258,21 @@ func main() {
255258
},
256259
})
257260

261+
mgr.GetWebhookServer().Register("/mutate-namespace-project-organization", &webhook.Admission{
262+
Handler: &webhooks.NamespaceProjectOrganizationMutator{
263+
Decoder: admission.NewDecoder(mgr.GetScheme()),
264+
Client: mgr.GetClient(),
265+
266+
Skipper: skipper.NewMultiSkipper(
267+
skipper.StaticSkipper{ShouldSkip: false},
268+
psk,
269+
),
270+
271+
OrganizationLabel: conf.OrganizationLabel,
272+
UserDefaultOrganizationAnnotation: conf.UserDefaultOrganizationAnnotation,
273+
},
274+
})
275+
258276
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
259277
setupLog.Error(err, "unable to setup health endpoint")
260278
os.Exit(1)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package webhooks
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"slices"
8+
"strings"
9+
10+
"github.com/appuio/appuio-cloud-agent/skipper"
11+
userv1 "github.com/openshift/api/user/v1"
12+
"gomodules.xyz/jsonpatch/v2"
13+
corev1 "k8s.io/api/core/v1"
14+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
15+
"k8s.io/apimachinery/pkg/types"
16+
"sigs.k8s.io/controller-runtime/pkg/client"
17+
"sigs.k8s.io/controller-runtime/pkg/log"
18+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
19+
)
20+
21+
//+kubebuilder:webhook:path=/mutate-namespace-project-organization,name=namespace.namespace-project-organization-mutator.appuio.io,admissionReviewVersions=v1,sideEffects=none,mutating=true,failurePolicy=Fail,groups="",resources=namespaces,verbs=create;update,versions=v1,matchPolicy=equivalent
22+
//+kubebuilder:webhook:path=/mutate-namespace-project-organization,name=project.namespace-project-organization-mutator.appuio.io,admissionReviewVersions=v1,sideEffects=none,mutating=true,failurePolicy=Fail,groups=project.openshift.io,resources=projects,verbs=create;update,versions=v1,matchPolicy=equivalent
23+
24+
// +kubebuilder:rbac:groups=user.openshift.io,resources=users;groups,verbs=get;list;watch
25+
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
26+
27+
// NamespaceProjectOrganizationMutator adds the OrganizationLabel to namespace and project create requests.
28+
type NamespaceProjectOrganizationMutator struct {
29+
Decoder admission.Decoder
30+
31+
Client client.Reader
32+
33+
Skipper skipper.Skipper
34+
35+
// OrganizationLabel is the label used to mark namespaces to belong to an organization
36+
OrganizationLabel string
37+
38+
// UserDefaultOrganizationAnnotation is the annotation the default organization setting for a user is stored in.
39+
UserDefaultOrganizationAnnotation string
40+
}
41+
42+
const OpenShiftProjectRequesterAnnotation = "openshift.io/requester"
43+
44+
// Handle handles the admission requests
45+
//
46+
// If the requestor is a service account:
47+
// - Project requests are denied.
48+
// - Namespace requests are checked against the organization of the service account's namespace.
49+
// - If the organization is not set in the request, the organization of the service account's namespace is added.
50+
// - If the service account's namespace has no organization set, the request is denied.
51+
//
52+
// If the requestor is an OpenShift user:
53+
// - If there is no OrganizationLabel set on the object, the default organization of the user is used; if there is no default organization set for the user, the request is denied.
54+
// - Namespace requests use the username of the requests user info.
55+
// - Project requests use the annotation `openshift.io/requester` on the project object. If the annotation is not set, the request is allowed.
56+
// - If the user is not a member of the organization, the request is denied; this is done by checking for an OpenShift group with the same name as the organization.
57+
func (m *NamespaceProjectOrganizationMutator) Handle(ctx context.Context, req admission.Request) admission.Response {
58+
ctx = log.IntoContext(ctx, log.FromContext(ctx).
59+
WithName("webhook.namespace-project-organization-mutator.appuio.io").
60+
WithValues("id", req.UID, "user", req.UserInfo.Username).
61+
WithValues("namespace", req.Namespace, "name", req.Name,
62+
"group", req.Kind.Group, "version", req.Kind.Version, "kind", req.Kind.Kind))
63+
64+
return logAdmissionResponse(ctx, m.handle(ctx, req))
65+
}
66+
67+
func (m *NamespaceProjectOrganizationMutator) handle(ctx context.Context, req admission.Request) admission.Response {
68+
skip, err := m.Skipper.Skip(ctx, req)
69+
if err != nil {
70+
return admission.Errored(http.StatusInternalServerError, fmt.Errorf("error while checking skipper: %w", err))
71+
}
72+
if skip {
73+
return admission.Allowed("skipped")
74+
}
75+
76+
var rawObject unstructured.Unstructured
77+
if err := m.Decoder.Decode(req, &rawObject); err != nil {
78+
return admission.Errored(http.StatusBadRequest, fmt.Errorf("failed to decode object from request: %w", err))
79+
}
80+
81+
if req.Kind.Kind == "Project" {
82+
return m.handleProject(ctx, rawObject)
83+
}
84+
85+
return m.handleNamespace(ctx, req, rawObject)
86+
}
87+
88+
func (m *NamespaceProjectOrganizationMutator) handleProject(ctx context.Context, rawObject unstructured.Unstructured) admission.Response {
89+
userName := rawObject.GetAnnotations()[OpenShiftProjectRequesterAnnotation]
90+
if userName == "" {
91+
// https://github.com/appuio/component-appuio-cloud/blob/196f76ede357a73b88f9314bf1d1bccc097cb6b7/component/namespace-policies.jsonnet#L54
92+
return admission.Allowed("Skipped: no requester annotation found")
93+
}
94+
if strings.HasPrefix(userName, "system:serviceaccount:") {
95+
return admission.Denied("Service accounts are not allowed to create projects")
96+
}
97+
98+
return m.handleUserRequested(ctx, userName, rawObject)
99+
}
100+
101+
func (m *NamespaceProjectOrganizationMutator) handleNamespace(ctx context.Context, req admission.Request, rawObject unstructured.Unstructured) admission.Response {
102+
if strings.HasPrefix(req.UserInfo.Username, "system:serviceaccount:") {
103+
return m.handleServiceAccountNamespace(ctx, req, rawObject)
104+
}
105+
106+
return m.handleUserRequested(ctx, req.UserInfo.Username, rawObject)
107+
}
108+
109+
func (m *NamespaceProjectOrganizationMutator) handleUserRequested(ctx context.Context, userName string, rawObject unstructured.Unstructured) admission.Response {
110+
var user userv1.User
111+
if err := m.Client.Get(ctx, client.ObjectKey{Name: userName}, &user); err != nil {
112+
return admission.Errored(http.StatusInternalServerError, fmt.Errorf("failed to get user %s: %w", userName, err))
113+
}
114+
115+
org := rawObject.GetLabels()[m.OrganizationLabel]
116+
defaultOrgAdded := false
117+
if org == "" {
118+
org = user.Annotations[m.UserDefaultOrganizationAnnotation]
119+
defaultOrgAdded = true
120+
}
121+
if org == "" {
122+
return admission.Denied("No organization label found and no default organization set")
123+
}
124+
125+
var group userv1.Group
126+
if err := m.Client.Get(ctx, types.NamespacedName{Name: org}, &group); client.IgnoreNotFound(err) != nil {
127+
return admission.Errored(http.StatusInternalServerError, fmt.Errorf("failed to get group: %w", err))
128+
}
129+
130+
if !slices.Contains(group.Users, userName) {
131+
return admission.Denied("Requester is not a member of the organization")
132+
}
133+
134+
if defaultOrgAdded {
135+
return admission.Patched("added default organization", m.orgLabelPatch(org))
136+
}
137+
138+
return admission.Allowed("Requester is member of organization")
139+
}
140+
141+
func (m *NamespaceProjectOrganizationMutator) handleServiceAccountNamespace(ctx context.Context, req admission.Request, rawObject unstructured.Unstructured) admission.Response {
142+
p := strings.Split(req.UserInfo.Username, ":")
143+
if len(p) != 4 {
144+
return admission.Errored(http.StatusUnprocessableEntity, fmt.Errorf("invalid service account name: %s, expected 4 segments", req.UserInfo.Username))
145+
}
146+
nsName := p[2]
147+
var ns corev1.Namespace
148+
if err := m.Client.Get(ctx, client.ObjectKey{Name: nsName}, &ns); err != nil {
149+
return admission.Errored(http.StatusInternalServerError, fmt.Errorf("failed to get namespace %s for service account %s: %w", nsName, req.UserInfo.Username, err))
150+
}
151+
nsOrg := ns.Labels[m.OrganizationLabel]
152+
if nsOrg == "" {
153+
return admission.Denied("No organization label found for the service accounts namespace")
154+
}
155+
156+
requestedOrg := rawObject.GetLabels()[m.OrganizationLabel]
157+
if requestedOrg != "" && requestedOrg != nsOrg {
158+
return admission.Denied("Service accounts are not allowed to use organizations other than the one of their namespace.")
159+
}
160+
161+
if requestedOrg == "" {
162+
return admission.Patched("added organization label", m.orgLabelPatch(nsOrg))
163+
}
164+
165+
return admission.Allowed("service account may use the organization of its namespace")
166+
}
167+
168+
// orgLabelPatch returns a JSON patch operation to add the `OrganizationLabel` with value `org` to an object.
169+
func (m *NamespaceProjectOrganizationMutator) orgLabelPatch(org string) jsonpatch.Operation {
170+
return jsonpatch.Operation{
171+
Operation: "add",
172+
Path: "/" + strings.Join([]string{"metadata", "labels", escapeJSONPointerSegment(m.OrganizationLabel)}, "/"),
173+
Value: org,
174+
}
175+
}

0 commit comments

Comments
 (0)