Skip to content

Commit

Permalink
Replace Kyverno namespace/project policies (#116)
Browse files Browse the repository at this point in the history
  • Loading branch information
bastjan authored Aug 27, 2024
1 parent bfd7d3e commit 8503d2c
Show file tree
Hide file tree
Showing 6 changed files with 679 additions and 2 deletions.
42 changes: 42 additions & 0 deletions config/webhook/manifests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,48 @@ webhooks:
resources:
- pods
sideEffects: None
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-service
namespace: system
path: /mutate-namespace-project-organization
failurePolicy: Fail
matchPolicy: Equivalent
name: namespace.namespace-project-organization-mutator.appuio.io
rules:
- apiGroups:
- ""
apiVersions:
- v1
operations:
- CREATE
- UPDATE
resources:
- namespaces
sideEffects: None
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-service
namespace: system
path: /mutate-namespace-project-organization
failurePolicy: Fail
matchPolicy: Equivalent
name: project.namespace-project-organization-mutator.appuio.io
rules:
- apiGroups:
- project.openshift.io
apiVersions:
- v1
operations:
- CREATE
- UPDATE
resources:
- projects
sideEffects: None
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
Expand Down
18 changes: 18 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ func main() {
var cloudscaleLoadbalancerValidationEnabled bool
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.")

var namespaceProjectOrganizationMutatorEnabled bool
flag.BoolVar(&namespaceProjectOrganizationMutatorEnabled, "namespace-project-organization-mutator-enabled", false, "Enable the NamespaceProjectOrganizationMutator webhook. Adds the organization label to namespace and project create requests.")

var qps, burst int
flag.IntVar(&qps, "qps", 20, "QPS to use for the controller-runtime client")
flag.IntVar(&burst, "burst", 100, "Burst to use for the controller-runtime client")
Expand Down Expand Up @@ -255,6 +258,21 @@ func main() {
},
})

mgr.GetWebhookServer().Register("/mutate-namespace-project-organization", &webhook.Admission{
Handler: &webhooks.NamespaceProjectOrganizationMutator{
Decoder: admission.NewDecoder(mgr.GetScheme()),
Client: mgr.GetClient(),

Skipper: skipper.NewMultiSkipper(
skipper.StaticSkipper{ShouldSkip: false},
psk,
),

OrganizationLabel: conf.OrganizationLabel,
UserDefaultOrganizationAnnotation: conf.UserDefaultOrganizationAnnotation,
},
})

if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to setup health endpoint")
os.Exit(1)
Expand Down
175 changes: 175 additions & 0 deletions webhooks/namespace_project_organization_mutator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package webhooks

import (
"context"
"fmt"
"net/http"
"slices"
"strings"

"github.com/appuio/appuio-cloud-agent/skipper"
userv1 "github.com/openshift/api/user/v1"
"gomodules.xyz/jsonpatch/v2"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)

//+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
//+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

// +kubebuilder:rbac:groups=user.openshift.io,resources=users;groups,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch

// NamespaceProjectOrganizationMutator adds the OrganizationLabel to namespace and project create requests.
type NamespaceProjectOrganizationMutator struct {
Decoder admission.Decoder

Client client.Reader

Skipper skipper.Skipper

// OrganizationLabel is the label used to mark namespaces to belong to an organization
OrganizationLabel string

// UserDefaultOrganizationAnnotation is the annotation the default organization setting for a user is stored in.
UserDefaultOrganizationAnnotation string
}

const OpenShiftProjectRequesterAnnotation = "openshift.io/requester"

// Handle handles the admission requests
//
// If the requestor is a service account:
// - Project requests are denied.
// - Namespace requests are checked against the organization of the service account's namespace.
// - If the organization is not set in the request, the organization of the service account's namespace is added.
// - If the service account's namespace has no organization set, the request is denied.
//
// If the requestor is an OpenShift user:
// - 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.
// - Namespace requests use the username of the requests user info.
// - Project requests use the annotation `openshift.io/requester` on the project object. If the annotation is not set, the request is allowed.
// - 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.
func (m *NamespaceProjectOrganizationMutator) Handle(ctx context.Context, req admission.Request) admission.Response {
ctx = log.IntoContext(ctx, log.FromContext(ctx).
WithName("webhook.namespace-project-organization-mutator.appuio.io").
WithValues("id", req.UID, "user", req.UserInfo.Username).
WithValues("namespace", req.Namespace, "name", req.Name,
"group", req.Kind.Group, "version", req.Kind.Version, "kind", req.Kind.Kind))

return logAdmissionResponse(ctx, m.handle(ctx, req))
}

func (m *NamespaceProjectOrganizationMutator) handle(ctx context.Context, req admission.Request) admission.Response {
skip, err := m.Skipper.Skip(ctx, req)
if err != nil {
return admission.Errored(http.StatusInternalServerError, fmt.Errorf("error while checking skipper: %w", err))
}
if skip {
return admission.Allowed("skipped")
}

var rawObject unstructured.Unstructured
if err := m.Decoder.Decode(req, &rawObject); err != nil {
return admission.Errored(http.StatusBadRequest, fmt.Errorf("failed to decode object from request: %w", err))
}

if req.Kind.Kind == "Project" {
return m.handleProject(ctx, rawObject)
}

return m.handleNamespace(ctx, req, rawObject)
}

func (m *NamespaceProjectOrganizationMutator) handleProject(ctx context.Context, rawObject unstructured.Unstructured) admission.Response {
userName := rawObject.GetAnnotations()[OpenShiftProjectRequesterAnnotation]
if userName == "" {
// https://github.com/appuio/component-appuio-cloud/blob/196f76ede357a73b88f9314bf1d1bccc097cb6b7/component/namespace-policies.jsonnet#L54
return admission.Allowed("Skipped: no requester annotation found")
}
if strings.HasPrefix(userName, "system:serviceaccount:") {
return admission.Denied("Service accounts are not allowed to create projects")
}

return m.handleUserRequested(ctx, userName, rawObject)
}

func (m *NamespaceProjectOrganizationMutator) handleNamespace(ctx context.Context, req admission.Request, rawObject unstructured.Unstructured) admission.Response {
if strings.HasPrefix(req.UserInfo.Username, "system:serviceaccount:") {
return m.handleServiceAccountNamespace(ctx, req, rawObject)
}

return m.handleUserRequested(ctx, req.UserInfo.Username, rawObject)
}

func (m *NamespaceProjectOrganizationMutator) handleUserRequested(ctx context.Context, userName string, rawObject unstructured.Unstructured) admission.Response {
var user userv1.User
if err := m.Client.Get(ctx, client.ObjectKey{Name: userName}, &user); err != nil {
return admission.Errored(http.StatusInternalServerError, fmt.Errorf("failed to get user %s: %w", userName, err))
}

org := rawObject.GetLabels()[m.OrganizationLabel]
defaultOrgAdded := false
if org == "" {
org = user.Annotations[m.UserDefaultOrganizationAnnotation]
defaultOrgAdded = true
}
if org == "" {
return admission.Denied("No organization label found and no default organization set")
}

var group userv1.Group
if err := m.Client.Get(ctx, types.NamespacedName{Name: org}, &group); client.IgnoreNotFound(err) != nil {
return admission.Errored(http.StatusInternalServerError, fmt.Errorf("failed to get group: %w", err))
}

if !slices.Contains(group.Users, userName) {
return admission.Denied("Requester is not a member of the organization")
}

if defaultOrgAdded {
return admission.Patched("added default organization", m.orgLabelPatch(org))
}

return admission.Allowed("Requester is member of organization")
}

func (m *NamespaceProjectOrganizationMutator) handleServiceAccountNamespace(ctx context.Context, req admission.Request, rawObject unstructured.Unstructured) admission.Response {
p := strings.Split(req.UserInfo.Username, ":")
if len(p) != 4 {
return admission.Errored(http.StatusUnprocessableEntity, fmt.Errorf("invalid service account name: %s, expected 4 segments", req.UserInfo.Username))
}
nsName := p[2]
var ns corev1.Namespace
if err := m.Client.Get(ctx, client.ObjectKey{Name: nsName}, &ns); err != nil {
return admission.Errored(http.StatusInternalServerError, fmt.Errorf("failed to get namespace %s for service account %s: %w", nsName, req.UserInfo.Username, err))
}
nsOrg := ns.Labels[m.OrganizationLabel]
if nsOrg == "" {
return admission.Denied("No organization label found for the service accounts namespace")
}

requestedOrg := rawObject.GetLabels()[m.OrganizationLabel]
if requestedOrg != "" && requestedOrg != nsOrg {
return admission.Denied("Service accounts are not allowed to use organizations other than the one of their namespace.")
}

if requestedOrg == "" {
return admission.Patched("added organization label", m.orgLabelPatch(nsOrg))
}

return admission.Allowed("service account may use the organization of its namespace")
}

// orgLabelPatch returns a JSON patch operation to add the `OrganizationLabel` with value `org` to an object.
func (m *NamespaceProjectOrganizationMutator) orgLabelPatch(org string) jsonpatch.Operation {
return jsonpatch.Operation{
Operation: "add",
Path: "/" + strings.Join([]string{"metadata", "labels", escapeJSONPointerSegment(m.OrganizationLabel)}, "/"),
Value: org,
}
}
Loading

0 comments on commit 8503d2c

Please sign in to comment.