From d6e54d059df8496060a8308b28a13d87be953d56 Mon Sep 17 00:00:00 2001 From: Peter Matseykanets Date: Mon, 17 Jun 2024 10:23:34 -0400 Subject: [PATCH] [v0.5] s4: Fixes 227 --- README.md | 150 +++++++------- docs.md | 42 ++++ go.mod | 1 + go.sum | 2 + pkg/codegen/main.go | 1 + .../management.cattle.io/v3/objects.go | 53 +++++ .../v3/setting/Setting.md | 19 ++ .../v3/setting/validator.go | 131 +++++++++++++ .../v3/setting/validator_test.go | 184 ++++++++++++++++++ .../v3/userattribute/UserAttribute.md | 17 ++ .../v3/userattribute/validator.go | 116 +++++++++++ .../v3/userattribute/validator_test.go | 172 ++++++++++++++++ pkg/server/handlers.go | 6 +- 13 files changed, 824 insertions(+), 70 deletions(-) create mode 100644 pkg/resources/management.cattle.io/v3/setting/Setting.md create mode 100644 pkg/resources/management.cattle.io/v3/setting/validator.go create mode 100644 pkg/resources/management.cattle.io/v3/setting/validator_test.go create mode 100644 pkg/resources/management.cattle.io/v3/userattribute/UserAttribute.md create mode 100644 pkg/resources/management.cattle.io/v3/userattribute/validator.go create mode 100644 pkg/resources/management.cattle.io/v3/userattribute/validator_test.go diff --git a/README.md b/README.md index 88794767d..1bbece6fb 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ -Rancher Webhook -======== +# Rancher Webhook + Rancher webhook is both a validating admission webhook and a mutating admission webhook for Kubernetes. [Explanation of Webhooks in Kubernetes]( https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/) ## Background + The Rancher Webhook is an instance of a Kubernetes admission controller. Admission controllers are a standard Kubernetes mechanism to intercept requests to a cluster's API server and perform validation or mutation of resources prior to their persistence in @@ -26,62 +27,63 @@ It handles TLS certificates and the management of associated Secrets for secure Documentation on each of the resources that are validated or mutated can be found in `docs.md`. It is recommended to review the [kubernetes docs on CRDs](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/#customresourcedefinitions) as well. -Docs are added by creating a resource-specific readme in the directory of your mutator/validator (e.x. `pkg/resources/$GROUP/$GROUP_VERSION/$RESOURCE/$READABLE_RESOURCE.MD`). +Docs are added by creating a resource-specific readme in the directory of your mutator/validator (e.x. `pkg/resources/$GROUP/$GROUP_VERSION/$RESOURCE/$READABLE_RESOURCE.MD`). These files should be named with a human-readable version of the resource's name. For example, `GlobalRole.md`. Running `go generate` will then aggregate these into the user-facing docs in the `docs.md` file. ## Webhooks -Rancher-Webhook is composed of multiple [WebhookHandlers](pkg/admission/admission.go) which is used when creating [ValidatingWebhooks](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.24/#validatingwebhook-v1-admissionregistration-k8s-io) and [MutatingWebhooks](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.24/#mutatingwebhook-v1-admissionregistration-k8s-io). + +Rancher-Webhook is composed of multiple [WebhookHandlers](pkg/admission/admission.go) which is used when creating [ValidatingWebhooks](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.24/#validatingwebhook-v1-admissionregistration-k8s-io) and [MutatingWebhooks](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.24/#mutatingwebhook-v1-admissionregistration-k8s-io). ``` golang // WebhookHandler base interface for both ValidatingAdmissionHandler and MutatingAdmissionHandler. // WebhookHandler is used for creating new http.HandlerFunc for each Webhook. type WebhookHandler interface { - // GVR returns GroupVersionResource that the Webhook reviews. - // The returned GVR is used to define the route for accessing this webhook as well as creating the Webhooks Name. - // Thus the GVR returned must be unique from other WebhookHandlers of the same type e.g.(Mutating or Validating). - // If a WebhookHandler desires to monitor all resources in a group the Resource defined int he GVR should be "*". - // If a WebhookHandler desires to monitor a core type the Group can be left empty "". - GVR() schema.GroupVersionResource - - // Operations returns list of operations that this WebhookHandler supports. - // Handlers will only be sent request with operations that are contained in the provided list. - Operations() []v1.OperationType - - // Admit handles the webhook admission request sent to this webhook. - // The response returned by the WebhookHandler will be forwarded to the kube-api server. - // If the WebhookHandler can not accurately evaluate the request it should return an error. - Admit(*Request) (*admissionv1.AdmissionResponse, error) + // GVR returns GroupVersionResource that the Webhook reviews. + // The returned GVR is used to define the route for accessing this webhook as well as creating the Webhooks Name. + // Thus the GVR returned must be unique from other WebhookHandlers of the same type e.g.(Mutating or Validating). + // If a WebhookHandler desires to monitor all resources in a group the Resource defined int he GVR should be "*". + // If a WebhookHandler desires to monitor a core type the Group can be left empty "". + GVR() schema.GroupVersionResource + + // Operations returns list of operations that this WebhookHandler supports. + // Handlers will only be sent request with operations that are contained in the provided list. + Operations() []v1.OperationType + + // Admit handles the webhook admission request sent to this webhook. + // The response returned by the WebhookHandler will be forwarded to the kube-api server. + // If the WebhookHandler can not accurately evaluate the request it should return an error. + Admit(*Request) (*admissionv1.AdmissionResponse, error) } // ValidatingAdmissionHandler is a handler used for creating a ValidationAdmission Webhook. type ValidatingAdmissionHandler interface { - WebhookHandler - - // ValidatingWebhook returns a list of configurations to route to this handler. - // - // This functions allows ValidatingAdmissionHandler to perform modifications to the default configuration if needed. - // A default configuration can be made using NewDefaultValidatingWebhook(...) - // Most Webhooks implementing ValidatingWebhook will only return one configuration. - ValidatingWebhook(clientConfig v1.WebhookClientConfig) []v1.ValidatingWebhook + WebhookHandler + + // ValidatingWebhook returns a list of configurations to route to this handler. + // + // This functions allows ValidatingAdmissionHandler to perform modifications to the default configuration if needed. + // A default configuration can be made using NewDefaultValidatingWebhook(...) + // Most Webhooks implementing ValidatingWebhook will only return one configuration. + ValidatingWebhook(clientConfig v1.WebhookClientConfig) []v1.ValidatingWebhook } // MutatingAdmissionHandler is a handler used for creating a MutatingAdmission Webhook. type MutatingAdmissionHandler interface { - WebhookHandler - - // MutatingWebhook returns a list of configurations to route to this handler. - // - // MutatingWebhook functions allows MutatingAdmissionHandler to perform modifications to the default configuration if needed. - // A default configuration can be made using NewDefaultMutatingWebhook(...) - // Most Webhooks implementing MutatingWebhook will only return one configuration. - MutatingWebhook(clientConfig v1.WebhookClientConfig) []v1.MutatingWebhook + WebhookHandler + + // MutatingWebhook returns a list of configurations to route to this handler. + // + // MutatingWebhook functions allows MutatingAdmissionHandler to perform modifications to the default configuration if needed. + // A default configuration can be made using NewDefaultMutatingWebhook(...) + // Most Webhooks implementing MutatingWebhook will only return one configuration. + MutatingWebhook(clientConfig v1.WebhookClientConfig) []v1.MutatingWebhook } - - ``` + Any admission controller, as an app, consists of two main things: + 1. The configuration which describes the resources and actions for which the webhook is active. This configuration also references the Kubernetes service which directs traffic to the actual web-server (this Webhook project) that does the work. The configuration exists as [ValidatingWebhookConfiguration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.24/#validatingwebhookconfiguration-v1-admissionregistration-k8s-io) @@ -92,8 +94,10 @@ _Note: both the ValidatingWebhookConfiguration and MutatingWebhookConfiguration webhook startup, not beforehand._ All objects with custom validation logic exist in the `pkg/resources` package. -### Validation -Both Mutating and Validating webhooks can be used for basic validation of user input. + +### Validation + +Both Mutating and Validating webhooks can be used for basic validation of user input. [A ValidatingAdmissionHandler should be used when validation is needed after all mutations are completed.](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#what-are-admission-webhooks) For example, there is a @@ -105,35 +109,39 @@ ensures that each rule of the GlobalRole has at least one verb. If it does not, changes returns a response value with `Allowed` set to false. ```go - for _, rule := range newGR.Rules { - if len(rule.Verbs) == 0 { - return &admissionv1.AdmissionResponse{ - Result: &metav1.Status{ - Status: "Failure", - Message: "GlobalRole.Rules: PolicyRules must have at least one verb", - Reason: metav1.StatusReasonBadRequest, - Code: http.StatusBadRequest, - }, - Allowed: false, - }, nil - } - } + for _, rule := range newGR.Rules { + if len(rule.Verbs) == 0 { + return &admissionv1.AdmissionResponse{ + Result: &metav1.Status{ + Status: "Failure", + Message: "GlobalRole.Rules: PolicyRules must have at least one verb", + Reason: metav1.StatusReasonBadRequest, + Code: http.StatusBadRequest, + }, + Allowed: false, + }, nil + } + } ``` This logic is the main part of object inspection and admission control. ### Mutation + A MutatingAdmissionHandler should be used when the data being updated needs to be modified. All modifications must be recorded using a [JSONpatch](https://jsonpatch.com/). This can be done easily using the `pkg/patch` library for example the [MutatingAdmissionHandler for secrets](pkg/resources/core/v1/secret/mutator.go) add the creator's username as an annotation then creates a patch that is attached to the response. -```go - newSecret.Annotations[auth.CreatorIDAnn] = request.UserInfo.Username - response := &admissionv1.AdmissionResponse{} - if err := patch.CreatePatch(request.Object.Raw, newSecret, response); err != nil { - return nil, fmt.Errorf("failed to create patch: %w", err) - } - response.Allowed = true - return response, nil + +```go + newSecret.Annotations[auth.CreatorIDAnn] = request.UserInfo.Username + response := &admissionv1.AdmissionResponse{} + if err := patch.CreatePatch(request.Object.Raw, newSecret, response); err != nil { + return nil, fmt.Errorf("failed to create patch: %w", err) + } + response.Allowed = true + return response, nil ``` + ### Creating a WebhookHandler + The `pkg/server` package is the main setup package of the Webhook server itself. The package defines the rules for resources and actions for which the Webhook will be active. These are later brought to life as cluster-wide Kubernetes resources (ValidatingWebhookConfiguration and MutatingWebhookConfiguration). @@ -155,22 +163,26 @@ make ## Development - 1. Get a new address that forwards to `https://localhost:9443` using ngrok. -```bash -ngrok http https://localhost:9443 -``` + + ```bash + ngrok http https://localhost:9443 + ``` + 2. Run the webhook with the given address and the kubeconfig for the cluster hosting Rancher. -``` bash -export KUBECONFIG= -export CATTLE_WEBHOOK_URL="https://.ngrok.io" -./bin/webhook -``` + + ``` bash + export KUBECONFIG= + export CATTLE_WEBHOOK_URL="https://.ngrok.io" + ./bin/webhook + ``` + After 15 seconds the webhook will update the `ValidatingWebhookConfiguration` and `MutatingWebhookConfiguration` in the Kubernetes cluster to point at the locally running instance. > :warning: Kubernetes API server authentication will not work with ngrok. ## License + Copyright (c) 2019-2021 [Rancher Labs, Inc.](http://rancher.com) Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/docs.md b/docs.md index 48956f77d..aef944d0d 100644 --- a/docs.md +++ b/docs.md @@ -301,6 +301,48 @@ If `roletemplates.builtin` is true then all fields are immutable except: RoleTemplate can not be deleted if they are referenced by other RoleTemplates via `roletemplates.roleTemplateNames` or by GlobalRoles via `globalRoles.inheritedClusterRoles` +## Setting + +### Validation Checks + +#### Invalid Fields - Create + +When a Setting is created, the following checks take place: + +- If set, `disable-inactive-user-after` must be zero or a positive duration (e.g. `240h`). +- If set, `delete-inactive-user-after` must be zero or a positive duration (e.g. `240h`). +- If set, `user-last-login-default` must be a date time according to RFC3339 (e.g. `2023-11-29T00:00:00Z`). +- If set, `user-retention-cron` must be a valid standard cron expression (e.g. `0 0 * * 0`). + +#### Invalid Fields - Update + +When a Setting is updated, the following checks take place: + +- If set, `disable-inactive-user-after` must be zero or a positive duration (e.g. `240h`). +- If set, `delete-inactive-user-after` must be zero or a positive duration (e.g. `240h`). +- If set, `user-last-login-default` must be a date time according to RFC3339 (e.g. `2023-11-29T00:00:00Z`). +- If set, `user-retention-cron` must be a valid standard cron expression (e.g. `0 0 * * 1`). + +## UserAttribute + +### Validation Checks + +#### Invalid Fields - Create + +When a UserAttribute is created, the following checks take place: + +- If set, `lastLogin` must be a valid date time according to RFC3339 (e.g. `2023-11-29T00:00:00Z`). +- If set, `disableAfter` must be zero or a positive duration (e.g. `240h`). +- If set, `deleteAfter` must be zero or a positive duration (e.g. `240h`). + +#### Invalid Fields - Update + +When a UserAttribute is updated, the following checks take place: + +- If set, `lastLogin` must be a valid date time according to RFC3339 (e.g. `2023-11-29T00:00:00Z`). +- If set, `disableAfter` must be zero or a positive duration (e.g. `240h`). +- If set, `deleteAfter` must be zero or a positive duration (e.g. `240h`). + # rbac.authorization.k8s.io/v1 ## Role diff --git a/go.mod b/go.mod index e7d88ad02..3c6154ec2 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/rancher/rancher/pkg/apis v0.0.0-20240328110445-91a4620d7e49 github.com/rancher/rke v1.5.7-rc2 github.com/rancher/wrangler/v2 v2.1.4 + github.com/robfig/cron v1.2.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.8.4 golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 diff --git a/go.sum b/go.sum index 29ef7233b..84cd22221 100644 --- a/go.sum +++ b/go.sum @@ -309,6 +309,8 @@ github.com/rancher/wrangler v1.1.1 h1:wmqUwqc2M7ADfXnBCJTFkTB5ZREWpD78rnZMzmxwMv github.com/rancher/wrangler v1.1.1/go.mod h1:ioVbKupzcBOdzsl55MvEDN0R1wdGggj8iNCYGFI5JvM= github.com/rancher/wrangler/v2 v2.1.4 h1:ohov0i6A9dJHHO6sjfsH4Dqv93ZTdm5lIJVJdPzVdQc= github.com/rancher/wrangler/v2 v2.1.4/go.mod h1:af5OaGU/COgreQh1mRbKiUI64draT2NN34uk+PALFY8= +github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= +github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= diff --git a/pkg/codegen/main.go b/pkg/codegen/main.go index 473f9e576..a6a70ba6c 100644 --- a/pkg/codegen/main.go +++ b/pkg/codegen/main.go @@ -70,6 +70,7 @@ func main() { &v3.ProjectRoleTemplateBinding{}, &v3.NodeDriver{}, &v3.Project{}, + &v3.Setting{}, }, }, "provisioning.cattle.io": { diff --git a/pkg/generated/objects/management.cattle.io/v3/objects.go b/pkg/generated/objects/management.cattle.io/v3/objects.go index fd83995b6..7cb9592f3 100644 --- a/pkg/generated/objects/management.cattle.io/v3/objects.go +++ b/pkg/generated/objects/management.cattle.io/v3/objects.go @@ -590,3 +590,56 @@ func ProjectFromRequest(request *admissionv1.AdmissionRequest) (*v3.Project, err return object, nil } + +// SettingOldAndNewFromRequest gets the old and new Setting objects, respectively, from the webhook request. +// If the request is a Delete operation, then the new object is the zero value for Setting. +// Similarly, if the request is a Create operation, then the old object is the zero value for Setting. +func SettingOldAndNewFromRequest(request *admissionv1.AdmissionRequest) (*v3.Setting, *v3.Setting, error) { + if request == nil { + return nil, nil, fmt.Errorf("nil request") + } + + object := &v3.Setting{} + oldObject := &v3.Setting{} + + if request.Operation != admissionv1.Delete { + err := json.Unmarshal(request.Object.Raw, object) + if err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal request object: %w", err) + } + } + + if request.Operation == admissionv1.Create { + return oldObject, object, nil + } + + err := json.Unmarshal(request.OldObject.Raw, oldObject) + if err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal request oldObject: %w", err) + } + + return oldObject, object, nil +} + +// SettingFromRequest returns a Setting object from the webhook request. +// If the operation is a Delete operation, then the old object is returned. +// Otherwise, the new object is returned. +func SettingFromRequest(request *admissionv1.AdmissionRequest) (*v3.Setting, error) { + if request == nil { + return nil, fmt.Errorf("nil request") + } + + object := &v3.Setting{} + raw := request.Object.Raw + + if request.Operation == admissionv1.Delete { + raw = request.OldObject.Raw + } + + err := json.Unmarshal(raw, object) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal request object: %w", err) + } + + return object, nil +} diff --git a/pkg/resources/management.cattle.io/v3/setting/Setting.md b/pkg/resources/management.cattle.io/v3/setting/Setting.md new file mode 100644 index 000000000..f735424a9 --- /dev/null +++ b/pkg/resources/management.cattle.io/v3/setting/Setting.md @@ -0,0 +1,19 @@ +## Validation Checks + +### Invalid Fields - Create + +When a Setting is created, the following checks take place: + +- If set, `disable-inactive-user-after` must be zero or a positive duration (e.g. `240h`). +- If set, `delete-inactive-user-after` must be zero or a positive duration (e.g. `240h`). +- If set, `user-last-login-default` must be a date time according to RFC3339 (e.g. `2023-11-29T00:00:00Z`). +- If set, `user-retention-cron` must be a valid standard cron expression (e.g. `0 0 * * 0`). + +### Invalid Fields - Update + +When a Setting is updated, the following checks take place: + +- If set, `disable-inactive-user-after` must be zero or a positive duration (e.g. `240h`). +- If set, `delete-inactive-user-after` must be zero or a positive duration (e.g. `240h`). +- If set, `user-last-login-default` must be a date time according to RFC3339 (e.g. `2023-11-29T00:00:00Z`). +- If set, `user-retention-cron` must be a valid standard cron expression (e.g. `0 0 * * 1`). diff --git a/pkg/resources/management.cattle.io/v3/setting/validator.go b/pkg/resources/management.cattle.io/v3/setting/validator.go new file mode 100644 index 000000000..da2d436ab --- /dev/null +++ b/pkg/resources/management.cattle.io/v3/setting/validator.go @@ -0,0 +1,131 @@ +package setting + +import ( + "errors" + "fmt" + "time" + + v3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" + "github.com/rancher/webhook/pkg/admission" + objectsv3 "github.com/rancher/webhook/pkg/generated/objects/management.cattle.io/v3" + "github.com/robfig/cron" + admissionv1 "k8s.io/api/admission/v1" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/trace" +) + +// MinDeleteInactiveUserAfter is the minimum duration for delete-inactive-user-after setting. +// This is introduced to minimize the risk of deleting users accidentally by setting a relatively low value. +// The admin can still set a lower value if needed by bypassing the webhook. +const MinDeleteInactiveUserAfter = 24 * 14 * time.Hour // 14 days. + +var gvr = schema.GroupVersionResource{ + Group: "management.cattle.io", + Version: "v3", + Resource: "settings", +} + +// Validator validates settings. +type Validator struct { + admitter admitter +} + +// NewValidator returns a new Validator instance. +func NewValidator() *Validator { + return &Validator{ + admitter: admitter{}, + } +} + +// GVR returns the GroupVersionResource. +func (v *Validator) GVR() schema.GroupVersionResource { + return gvr +} + +// Operations returns list of operations handled by the validator. +func (v *Validator) Operations() []admissionregistrationv1.OperationType { + return []admissionregistrationv1.OperationType{admissionregistrationv1.Update, admissionregistrationv1.Create} +} + +// ValidatingWebhook returns the ValidatingWebhook. +func (v *Validator) ValidatingWebhook(clientConfig admissionregistrationv1.WebhookClientConfig) []admissionregistrationv1.ValidatingWebhook { + return []admissionregistrationv1.ValidatingWebhook{ + *admission.NewDefaultValidatingWebhook(v, clientConfig, admissionregistrationv1.ClusterScope, v.Operations()), + } +} + +// Admitters returns the admitter objects. +func (v *Validator) Admitters() []admission.Admitter { + return []admission.Admitter{&v.admitter} +} + +type admitter struct{} + +// Admit handles the webhook admission requests. +func (a *admitter) Admit(request *admission.Request) (*admissionv1.AdmissionResponse, error) { + listTrace := trace.New("userAttributeValidator Admit", trace.Field{Key: "user", Value: request.UserInfo.Username}) + defer listTrace.LogIfLong(admission.SlowTraceDuration) + + if request.Operation == admissionv1.Create || request.Operation == admissionv1.Update { + setting, err := objectsv3.SettingFromRequest(&request.AdmissionRequest) + if err != nil { + return nil, fmt.Errorf("failed to get Setting from request: %w", err) + } + + err = a.validateSetting(setting) + if err != nil { + return admission.ResponseBadRequest(err.Error()), nil + } + } + + return admission.ResponseAllowed(), nil +} + +func (a *admitter) validateSetting(s *v3.Setting) error { + var err error + + switch s.Name { + case "disable-inactive-user-after": + if s.Value != "" { + _, err = validateDuration(s.Value) + } + case "delete-inactive-user-after": + if s.Value != "" { + var dur time.Duration + dur, err = validateDuration(s.Value) + if err == nil && dur < MinDeleteInactiveUserAfter { + err = fmt.Errorf("must be at least %s", MinDeleteInactiveUserAfter) + } + } + case "user-last-login-default": + if s.Value != "" { + _, err = time.Parse(time.RFC3339, s.Value) + } + case "user-retention-cron": + if s.Value != "" { + _, err = cron.ParseStandard(s.Value) + } + default: + } + + if err != nil { + return field.TypeInvalid(field.NewPath("value"), s.Value, err.Error()) + } + + return nil +} + +func validateDuration(value string) (time.Duration, error) { + dur, err := time.ParseDuration(value) + if err != nil { + return 0, err + } + + if dur < 0 { + return 0, errors.New("negative duration") + } + + return dur, err +} diff --git a/pkg/resources/management.cattle.io/v3/setting/validator_test.go b/pkg/resources/management.cattle.io/v3/setting/validator_test.go new file mode 100644 index 000000000..b13884562 --- /dev/null +++ b/pkg/resources/management.cattle.io/v3/setting/validator_test.go @@ -0,0 +1,184 @@ +package setting_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + v3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" + "github.com/rancher/webhook/pkg/admission" + "github.com/rancher/webhook/pkg/resources/management.cattle.io/v3/setting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + v1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +type SettingSuite struct { + suite.Suite +} + +func TestRetentionFieldsValidation(t *testing.T) { + t.Parallel() + suite.Run(t, new(SettingSuite)) +} + +var ( + gvk = metav1.GroupVersionKind{Group: "management.cattle.io", Version: "v3", Kind: "Setting"} + gvr = metav1.GroupVersionResource{Group: "management.cattle.io", Version: "v3", Resource: "settings"} +) + +type retentionTest struct { + setting string + value string + allowed bool +} + +func (t *retentionTest) name() string { + return t.setting + "_" + t.value +} + +func (t *retentionTest) toSetting() ([]byte, error) { + return json.Marshal(v3.Setting{ + ObjectMeta: metav1.ObjectMeta{ + Name: t.setting, + }, + Value: t.value, + }) +} +func (t *retentionTest) toOldSetting() ([]byte, error) { + return json.Marshal(v3.Setting{ + ObjectMeta: metav1.ObjectMeta{ + Name: t.setting, + }, + }) +} + +var retentionTests = []retentionTest{ + { + setting: "disable-inactive-user-after", + value: "", + allowed: true, + }, + { + setting: "delete-inactive-user-after", + value: "", + allowed: true, + }, + { + setting: "user-last-login-default", + value: "", + allowed: true, + }, + { + setting: "user-retention-cron", + value: "", + allowed: true, + }, + { + setting: "disable-inactive-user-after", + value: "2h30m", + allowed: true, + }, + { + setting: "delete-inactive-user-after", + value: setting.MinDeleteInactiveUserAfter.String(), + allowed: true, + }, + { + setting: "user-last-login-default", + value: "2024-01-08T00:00:00Z", + allowed: true, + }, + { + setting: "user-retention-cron", + value: "* * * * *", + allowed: true, + }, + { + setting: "disable-inactive-user-after", + value: "1w", + }, + { + setting: "delete-inactive-user-after", + value: "2d", + }, + { + setting: "user-last-login-default", + value: "foo", + }, + { + setting: "user-retention-cron", + value: "* * * * * *", + }, + { + setting: "disable-inactive-user-after", + value: "-1h", + }, + { + setting: "delete-inactive-user-after", + value: "-1h", + }, + { + setting: "delete-inactive-user-after", + value: (setting.MinDeleteInactiveUserAfter - time.Second).String(), + }, +} + +func (s *SettingSuite) TestValidateRetentionSettingsOnUpdate() { + s.validate(v1.Update) +} + +func (s *SettingSuite) TestValidateRetentionSettingsOnCreate() { + s.validate(v1.Create) +} + +func (s *SettingSuite) validate(op v1.Operation) { + admitter := s.setup() + + for _, test := range retentionTests { + test := test + s.Run(test.name(), func() { + t := s.T() + t.Parallel() + + oldObjRaw, err := test.toOldSetting() + assert.NoError(t, err, "failed to marshal old Setting") + + objRaw, err := test.toSetting() + assert.NoError(t, err, "failed to marshal Setting") + + resp, err := admitter.Admit(newRequest(op, objRaw, oldObjRaw)) + if assert.NoError(t, err, "Admit failed") { + assert.Equalf(t, test.allowed, resp.Allowed, "expected allowed %v got %v message=%v", test.allowed, resp.Allowed, resp.Result) + } + }) + } +} + +func (s *SettingSuite) setup() admission.Admitter { + validator := setting.NewValidator() + s.Len(validator.Admitters(), 1, "expected 1 admitter") + + return validator.Admitters()[0] +} + +func newRequest(op v1.Operation, obj, oldObj []byte) *admission.Request { + return &admission.Request{ + AdmissionRequest: v1.AdmissionRequest{ + UID: "1", + Kind: gvk, + Resource: gvr, + RequestKind: &gvk, + RequestResource: &gvr, + Operation: op, + UserInfo: authenticationv1.UserInfo{Username: "foo", UID: ""}, + Object: runtime.RawExtension{Raw: obj}, + OldObject: runtime.RawExtension{Raw: oldObj}, + }, + Context: context.Background(), + } +} diff --git a/pkg/resources/management.cattle.io/v3/userattribute/UserAttribute.md b/pkg/resources/management.cattle.io/v3/userattribute/UserAttribute.md new file mode 100644 index 000000000..f19d28d31 --- /dev/null +++ b/pkg/resources/management.cattle.io/v3/userattribute/UserAttribute.md @@ -0,0 +1,17 @@ +## Validation Checks + +### Invalid Fields - Create + +When a UserAttribute is created, the following checks take place: + +- If set, `lastLogin` must be a valid date time according to RFC3339 (e.g. `2023-11-29T00:00:00Z`). +- If set, `disableAfter` must be zero or a positive duration (e.g. `240h`). +- If set, `deleteAfter` must be zero or a positive duration (e.g. `240h`). + +### Invalid Fields - Update + +When a UserAttribute is updated, the following checks take place: + +- If set, `lastLogin` must be a valid date time according to RFC3339 (e.g. `2023-11-29T00:00:00Z`). +- If set, `disableAfter` must be zero or a positive duration (e.g. `240h`). +- If set, `deleteAfter` must be zero or a positive duration (e.g. `240h`). diff --git a/pkg/resources/management.cattle.io/v3/userattribute/validator.go b/pkg/resources/management.cattle.io/v3/userattribute/validator.go new file mode 100644 index 000000000..05cbb9b5a --- /dev/null +++ b/pkg/resources/management.cattle.io/v3/userattribute/validator.go @@ -0,0 +1,116 @@ +package userattribute + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/rancher/webhook/pkg/admission" + admissionv1 "k8s.io/api/admission/v1" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/trace" +) + +var gvr = schema.GroupVersionResource{ + Group: "management.cattle.io", + Version: "v3", + Resource: "userattributes", +} + +// Validator validates userattributes. +type Validator struct { + admitter admitter +} + +// NewValidator returns a new Validator instance. +func NewValidator() *Validator { + return &Validator{ + admitter: admitter{}, + } +} + +// GVR returns the GroupVersionResource. +func (v *Validator) GVR() schema.GroupVersionResource { + return gvr +} + +// Operations returns list of operations handled by the validator. +func (v *Validator) Operations() []admissionregistrationv1.OperationType { + return []admissionregistrationv1.OperationType{admissionregistrationv1.Update, admissionregistrationv1.Create} +} + +// ValidatingWebhook returns the ValidatingWebhook. +func (v *Validator) ValidatingWebhook(clientConfig admissionregistrationv1.WebhookClientConfig) []admissionregistrationv1.ValidatingWebhook { + return []admissionregistrationv1.ValidatingWebhook{ + *admission.NewDefaultValidatingWebhook(v, clientConfig, admissionregistrationv1.ClusterScope, v.Operations()), + } +} + +// Admitters returns the admitter objects. +func (v *Validator) Admitters() []admission.Admitter { + return []admission.Admitter{&v.admitter} +} + +type admitter struct{} + +// Admit handles the webhook admission requests. +func (a *admitter) Admit(request *admission.Request) (*admissionv1.AdmissionResponse, error) { + listTrace := trace.New("userAttributeValidator Admit", trace.Field{Key: "user", Value: request.UserInfo.Username}) + defer listTrace.LogIfLong(admission.SlowTraceDuration) + + if request.Operation == admissionv1.Create || request.Operation == admissionv1.Update { + err := a.validateRetentionFields(request) + if err != nil { + return admission.ResponseBadRequest(err.Error()), nil + } + } + + return admission.ResponseAllowed(), nil +} + +// PartialUserAttribute represents raw values of UserAttribute retention fields. +type PartialUserAttribute struct { + LastLogin *string `json:"lastLogin"` + DisableAfter *string `json:"disableAfter"` + DeleteAfter *string `json:"deleteAfter"` +} + +func (a *admitter) validateRetentionFields(request *admission.Request) error { + var ( + attr PartialUserAttribute + dur time.Duration + ) + + err := json.Unmarshal(request.Object.Raw, &attr) + if err != nil { + return fmt.Errorf("failed to get PartialUserAttribute from request: %w", err) + } + + if attr.LastLogin != nil { + if _, err = time.Parse(time.RFC3339, *attr.LastLogin); err != nil { + return field.TypeInvalid(field.NewPath("lastLogin"), attr.LastLogin, err.Error()) + } + } + + if attr.DisableAfter != nil { + if dur, err = time.ParseDuration(*attr.DisableAfter); err != nil { + return field.TypeInvalid(field.NewPath("disableAfter"), *attr.DisableAfter, err.Error()) + } + if dur < 0 { + return field.Invalid(field.NewPath("disableAfter"), *attr.DisableAfter, "negative duration") + } + } + + if attr.DeleteAfter != nil { + if dur, err = time.ParseDuration(*attr.DeleteAfter); err != nil { + return field.TypeInvalid(field.NewPath("deleteAfter"), *attr.DeleteAfter, err.Error()) + } + if dur < 0 { + return field.Invalid(field.NewPath("deleteAfter"), *attr.DeleteAfter, "negative duration") + } + } + + return nil +} diff --git a/pkg/resources/management.cattle.io/v3/userattribute/validator_test.go b/pkg/resources/management.cattle.io/v3/userattribute/validator_test.go new file mode 100644 index 000000000..9378ab8aa --- /dev/null +++ b/pkg/resources/management.cattle.io/v3/userattribute/validator_test.go @@ -0,0 +1,172 @@ +package userattribute_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/rancher/webhook/pkg/admission" + "github.com/rancher/webhook/pkg/resources/management.cattle.io/v3/userattribute" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + v1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/pointer" +) + +type RetentionFieldsSuite struct { + suite.Suite +} + +func TestRetentionFieldsValidation(t *testing.T) { + t.Parallel() + suite.Run(t, new(RetentionFieldsSuite)) +} + +var ( + gvk = metav1.GroupVersionKind{Group: "management.cattle.io", Version: "v3", Kind: "UserAttribute"} + gvr = metav1.GroupVersionResource{Group: "management.cattle.io", Version: "v3", Resource: "userattributes"} +) + +type retentionFieldsTest struct { + lastLogin *string + disableAfter *string + deleteAfter *string + allowed bool +} + +func (t *retentionFieldsTest) name() string { + return pointer.StringDeref(t.lastLogin, "nil") + "_" + + pointer.StringDeref(t.disableAfter, "nil") + "_" + + pointer.StringDeref(t.deleteAfter, "nil") +} + +func (t *retentionFieldsTest) toUserAttribute() ([]byte, error) { + return json.Marshal(userattribute.PartialUserAttribute{ + LastLogin: t.lastLogin, + DisableAfter: t.disableAfter, + DeleteAfter: t.deleteAfter, + }) +} + +var retentionFieldsTests = []retentionFieldsTest{ + { + allowed: true, + }, + { + disableAfter: pointer.String("0"), + allowed: true, + }, + { + deleteAfter: pointer.String("0"), + allowed: true, + }, + { + disableAfter: pointer.String("1h2m3s"), + allowed: true, + }, + { + deleteAfter: pointer.String("1h2m3s"), + allowed: true, + }, + { + lastLogin: pointer.String(time.Now().Format(time.RFC3339)), + allowed: true, + }, + { + disableAfter: pointer.String("1w"), + }, + { + deleteAfter: pointer.String("1w"), + }, + { + disableAfter: pointer.String("1d"), + }, + { + deleteAfter: pointer.String("1d"), + }, + { + disableAfter: pointer.String(""), + }, + { + deleteAfter: pointer.String(""), + }, + { + disableAfter: pointer.String("-1h"), + }, + { + deleteAfter: pointer.String("-1h"), + }, + { + lastLogin: pointer.String("2024-03-25T21:2:45Z"), // Not a valid RFC3339 time. + }, + { + lastLogin: pointer.String(""), + }, +} + +func (s *RetentionFieldsSuite) TestValidateOnUpdate() { + s.validate(v1.Update) +} + +func (s *RetentionFieldsSuite) TestValidateOnCreate() { + s.validate(v1.Create) +} + +func (s *RetentionFieldsSuite) TestDontValidateOnDelete() { + // Make sure that UserAttribute can be deleted without enforcing validation of user retention fields. + alwaysAllow := true + s.validate(v1.Delete, alwaysAllow) +} + +func (s *RetentionFieldsSuite) validate(op v1.Operation, allowed ...bool) { + admitter := s.setup() + + for _, test := range retentionFieldsTests { + test := test + s.Run(test.name(), func() { + t := s.T() + t.Parallel() + + objRaw, err := test.toUserAttribute() + assert.NoError(t, err, "failed to marshal PartialUserAttribute") + + resp, err := admitter.Admit(newRequest(op, objRaw)) + if assert.NoError(t, err, "Admit failed") { + wantAllowed := test.allowed + if len(allowed) > 0 { + wantAllowed = allowed[0] // Apply the override. + } + + assert.Equalf(t, wantAllowed, resp.Allowed, "expected allowed %v got %v message=%v", test.allowed, resp.Allowed, resp.Result) + } + }) + } +} + +func (s *RetentionFieldsSuite) setup() admission.Admitter { + validator := userattribute.NewValidator() + s.Len(validator.Admitters(), 1, "expected 1 admitter") + + return validator.Admitters()[0] +} + +func newRequest(op v1.Operation, obj []byte) *admission.Request { + return &admission.Request{ + AdmissionRequest: v1.AdmissionRequest{ + UID: "1", + Kind: gvk, + Resource: gvr, + RequestKind: &gvk, + RequestResource: &gvr, + Operation: op, + UserInfo: authenticationv1.UserInfo{Username: "foo", UID: ""}, + Object: runtime.RawExtension{Raw: obj}, + OldObject: runtime.RawExtension{Raw: []byte("{}")}, + }, + Context: context.Background(), + } +} diff --git a/pkg/server/handlers.go b/pkg/server/handlers.go index e4407ee48..053b20a70 100644 --- a/pkg/server/handlers.go +++ b/pkg/server/handlers.go @@ -18,6 +18,8 @@ import ( "github.com/rancher/webhook/pkg/resources/management.cattle.io/v3/project" "github.com/rancher/webhook/pkg/resources/management.cattle.io/v3/projectroletemplatebinding" "github.com/rancher/webhook/pkg/resources/management.cattle.io/v3/roletemplate" + "github.com/rancher/webhook/pkg/resources/management.cattle.io/v3/setting" + "github.com/rancher/webhook/pkg/resources/management.cattle.io/v3/userattribute" provisioningCluster "github.com/rancher/webhook/pkg/resources/provisioning.cattle.io/v1/cluster" "github.com/rancher/webhook/pkg/resources/rbac.authorization.k8s.io/v1/role" "github.com/rancher/webhook/pkg/resources/rbac.authorization.k8s.io/v1/rolebinding" @@ -50,8 +52,10 @@ func Validation(clients *clients.Clients) ([]admission.ValidatingAdmissionHandle projects := project.NewValidator(clients.Management.Cluster().Cache()) roles := role.NewValidator() rolebindings := rolebinding.NewValidator() + setting := setting.NewValidator() + userAttribute := userattribute.NewValidator() - handlers = append(handlers, psact, globalRoles, globalRoleBindings, prtbs, crtbs, roleTemplates, secrets, nodeDriver, projects, roles, rolebindings, clusterProxyConfigs) + handlers = append(handlers, psact, globalRoles, globalRoleBindings, prtbs, crtbs, roleTemplates, secrets, nodeDriver, projects, roles, rolebindings, clusterProxyConfigs, userAttribute, setting) } return handlers, nil }