From 71086899dc025728329bc0440a058e308e887747 Mon Sep 17 00:00:00 2001 From: bdumpp <144795224+bdumpp@users.noreply.github.com> Date: Wed, 8 Nov 2023 11:43:36 +0100 Subject: [PATCH] :sparkles: New Library CRD (#203) Added new Library CRD with controller. This makes it possible to deploy Library resources and the controller will create an equivalent Library in Styra. Also fixed flaky tests --- Makefile | 6 +- PROJECT | 13 + api/styra/v1alpha1/library_types.go | 140 +++++ api/styra/v1alpha1/library_webhook.go | 79 +++ api/styra/v1alpha1/zz_generated.deepcopy.go | 184 +++++++ cmd/main.go | 21 + .../bases/styra.bankdata.dk_libraries.yaml | 124 +++++ config/crd/kustomization.yaml | 3 + .../cainjection_in_styra_libraries.yaml | 7 + .../patches/webhook_in_styra_libraries.yaml | 16 + config/default/webhookcainjection_patch.yaml | 29 ++ config/rbac/role.yaml | 26 + config/rbac/styra_library_editor_role.yaml | 31 ++ config/rbac/styra_library_viewer_role.yaml | 27 + config/samples/kustomization.yaml | 1 + config/samples/styra_v1alpha1_library.yaml | 28 + config/webhook/manifests.yaml | 40 ++ docs/apis/styra/v1alpha1.md | 446 +++++++++++++++- docs/apis/styra/v1beta1.md | 2 +- .../controller/styra/library_controller.go | 487 ++++++++++++++++++ pkg/styra/authz.go | 6 + pkg/styra/client.go | 3 + pkg/styra/library.go | 145 ++++++ pkg/styra/library_test.go | 245 +++++++++ pkg/styra/mocks/client_interface.go | 52 ++ .../controller/controller_suite_test.go | 18 + .../globaldatasource_controller_test.go | 300 ++++++----- .../controller/library_controller_test.go | 319 ++++++++++++ .../controller/system_controller_test.go | 19 +- 29 files changed, 2669 insertions(+), 148 deletions(-) create mode 100644 api/styra/v1alpha1/library_types.go create mode 100644 api/styra/v1alpha1/library_webhook.go create mode 100644 config/crd/bases/styra.bankdata.dk_libraries.yaml create mode 100644 config/crd/patches/cainjection_in_styra_libraries.yaml create mode 100644 config/crd/patches/webhook_in_styra_libraries.yaml create mode 100644 config/default/webhookcainjection_patch.yaml create mode 100644 config/rbac/styra_library_editor_role.yaml create mode 100644 config/rbac/styra_library_viewer_role.yaml create mode 100644 config/samples/styra_v1alpha1_library.yaml create mode 100644 internal/controller/styra/library_controller.go create mode 100644 pkg/styra/library.go create mode 100644 pkg/styra/library_test.go create mode 100644 test/integration/controller/library_controller_test.go diff --git a/Makefile b/Makefile index 21ac4be..0370c67 100644 --- a/Makefile +++ b/Makefile @@ -76,15 +76,15 @@ lint: golangci-lint ## Run linters .PHONY: test test: ginkgo manifests generate lint envtest generate-mocks ## Run all tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" $(GINKGO) -r --coverprofile cover.out + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" $(GINKGO) -r -p --output-interceptor-mode=none .PHONY: test-unit test-unit: ginkgo manifests generate lint generate-mocks ## Run unit tests. - $(GINKGO) -r --label-filter "!integration" --coverprofile cover.out + $(GINKGO) -r --label-filter "!integration" --output-interceptor-mode=none .PHONY: test-integration ## Run integration tests. test-integration: ginkgo manifests generate lint envtest generate-mocks ## Run integration tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" $(GINKGO) -r --label-filter "integration" --coverprofile cover.out + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" $(GINKGO) -r -p --label-filter "integration" --output-interceptor-mode=none .PHONY: kind-create kind-create: kind ## Create kind cluster diff --git a/PROJECT b/PROJECT index 7465265..dbac610 100644 --- a/PROJECT +++ b/PROJECT @@ -63,4 +63,17 @@ resources: kind: ProjectConfig path: github.com/bankdata/styra-controller/api/config/v2alpha2 version: v2alpha2 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: bankdata.dk + group: styra + kind: Library + path: github.com/bankdata/styra-controller/api/styra/v1alpha1 + version: v1alpha1 + webhooks: + defaulting: true + validation: true + webhookVersion: v1 version: "3" diff --git a/api/styra/v1alpha1/library_types.go b/api/styra/v1alpha1/library_types.go new file mode 100644 index 0000000..899dc98 --- /dev/null +++ b/api/styra/v1alpha1/library_types.go @@ -0,0 +1,140 @@ +/* +Copyright (C) 2023 Bankdata (bankdata@bankdata.dk) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// LibrarySpec defines the desired state of Library +type LibrarySpec struct { + + // Name is the name the Library will have in Styra DAS + Name string `json:"name"` + + // Description is the description of the Library + Description string `json:"description"` + + // Subjects is the list of subjects which should have access to the system. + Subjects []LibrarySubject `json:"subjects,omitempty"` + + // SourceControl is the sourcecontrol configuration for the Library + SourceControl *SourceControl `json:"sourceControl"` + + // Datasources is the list of datasources in the Library + Datasources []LibraryDatasource `json:"datasources,omitempty"` +} + +// LibraryDatasource contains metadata of a datasource, stored in a library +type LibraryDatasource struct { + // Path is the path within the system where the datasource should reside. + Path string `json:"path"` + + // Description is a description of the datasource + Description string `json:"description,omitempty"` +} + +// SourceControl is a struct from styra where we only use a single field +// but kept for clarity when comparing to the API +type SourceControl struct { + LibraryOrigin *GitRepo `json:"libraryOrigin"` +} + +// LibrarySubjectKind represents a kind of a subject. +type LibrarySubjectKind string + +const ( + // LibrarySubjectKindUser is the subject kind user. + LibrarySubjectKindUser LibrarySubjectKind = "user" + + // LibrarySubjectKindGroup is the subject kind group. + LibrarySubjectKindGroup LibrarySubjectKind = "group" +) + +// LibrarySubject represents a subject which has been granted access to the Library. +// The subject is assigned to the LibraryViewer role. +type LibrarySubject struct { + // Kind is the LibrarySubjectKind of the subject. + //+kubebuilder:validation:Enum=user;group + Kind LibrarySubjectKind `json:"kind,omitempty"` + + // Name is the name of the subject. The meaning of this field depends on the + // SubjectKind. + Name string `json:"name"` +} + +// IsUser returns whether or not the kind of the subject is a user. +func (subject LibrarySubject) IsUser() bool { + return subject.Kind == LibrarySubjectKindUser || subject.Kind == "" +} + +// GitRepo defines the Git configurations a library can be defined by +type GitRepo struct { + // Path is the path in the git repo where the policies are located. + Path string `json:"path,omitempty"` + + // Reference is used to point to a tag or branch. This will be ignored if + // `Commit` is specified. + Reference string `json:"reference,omitempty"` + + // Commit is used to point to a specific commit SHA. This takes precedence + // over `Reference` if both are specified. + Commit string `json:"commit,omitempty"` + + // URL is the URL of the git repo. + URL string `json:"url"` +} + +// LibrarySecretRef defines how to access a k8s secret for the library. +type LibrarySecretRef struct { + // Namespace is the namespace where the secret resides. + Namespace string `json:"namespace"` + // Name is the name of the secret. + Name string `json:"name"` +} + +// LibraryStatus defines the observed state of Library +type LibraryStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status +//+kubebuilder:resource:scope=Cluster + +// Library is the Schema for the libraries API +type Library struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec LibrarySpec `json:"spec,omitempty"` + Status LibraryStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// LibraryList contains a list of Library +type LibraryList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Library `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Library{}, &LibraryList{}) +} diff --git a/api/styra/v1alpha1/library_webhook.go b/api/styra/v1alpha1/library_webhook.go new file mode 100644 index 0000000..ba83b9c --- /dev/null +++ b/api/styra/v1alpha1/library_webhook.go @@ -0,0 +1,79 @@ +/* +Copyright (C) 2023 Bankdata (bankdata@bankdata.dk) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// log is for logging in this package. +var librarylog = logf.Log.WithName("library-resource") + +// SetupWebhookWithManager sets up the Library webhooks with the Manager. +func (r *Library) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(r). + Complete() +} + +// TODO(user): EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! + +//+kubebuilder:webhook:path=/mutate-styra-bankdata-dk-v1alpha1-library,mutating=true,failurePolicy=fail,sideEffects=None,groups=styra.bankdata.dk,resources=libraries,verbs=create;update,versions=v1alpha1,name=mlibrary.kb.io,admissionReviewVersions=v1 + +var _ webhook.Defaulter = &Library{} + +// Default implements webhook.Defaulter so a webhook will be registered for the type +func (r *Library) Default() { + librarylog.Info("default", "name", r.Name) + + if r.Spec.SourceControl.LibraryOrigin.Commit != "" && r.Spec.SourceControl.LibraryOrigin.Reference != "" { + r.Spec.SourceControl.LibraryOrigin.Reference = "" + } +} + +// TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation. +//+kubebuilder:webhook:path=/validate-styra-bankdata-dk-v1alpha1-library,mutating=false,failurePolicy=fail,sideEffects=None,groups=styra.bankdata.dk,resources=libraries,verbs=create;update,versions=v1alpha1,name=vlibrary.kb.io,admissionReviewVersions=v1 + +var _ webhook.Validator = &Library{} + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (r *Library) ValidateCreate() (admission.Warnings, error) { + librarylog.Info("validate create", "name", r.Name) + + // TODO(user): fill in your validation logic upon object creation. + return nil, nil +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (r *Library) ValidateUpdate(_ runtime.Object) (admission.Warnings, error) { + librarylog.Info("validate update", "name", r.Name) + + // TODO(user): fill in your validation logic upon object update. + return nil, nil +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (r *Library) ValidateDelete() (admission.Warnings, error) { + librarylog.Info("validate delete", "name", r.Name) + + // TODO(user): fill in your validation logic upon object deletion. + return nil, nil +} diff --git a/api/styra/v1alpha1/zz_generated.deepcopy.go b/api/styra/v1alpha1/zz_generated.deepcopy.go index 971b716..d340485 100644 --- a/api/styra/v1alpha1/zz_generated.deepcopy.go +++ b/api/styra/v1alpha1/zz_generated.deepcopy.go @@ -24,6 +24,21 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitRepo) DeepCopyInto(out *GitRepo) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitRepo. +func (in *GitRepo) DeepCopy() *GitRepo { + if in == nil { + return nil + } + out := new(GitRepo) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GlobalDatasource) DeepCopyInto(out *GlobalDatasource) { *out = *in @@ -142,3 +157,172 @@ func (in *GlobalDatasourceStatus) DeepCopy() *GlobalDatasourceStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Library) DeepCopyInto(out *Library) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Library. +func (in *Library) DeepCopy() *Library { + if in == nil { + return nil + } + out := new(Library) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Library) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LibraryDatasource) DeepCopyInto(out *LibraryDatasource) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LibraryDatasource. +func (in *LibraryDatasource) DeepCopy() *LibraryDatasource { + if in == nil { + return nil + } + out := new(LibraryDatasource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LibraryList) DeepCopyInto(out *LibraryList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Library, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LibraryList. +func (in *LibraryList) DeepCopy() *LibraryList { + if in == nil { + return nil + } + out := new(LibraryList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LibraryList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LibrarySecretRef) DeepCopyInto(out *LibrarySecretRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LibrarySecretRef. +func (in *LibrarySecretRef) DeepCopy() *LibrarySecretRef { + if in == nil { + return nil + } + out := new(LibrarySecretRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LibrarySpec) DeepCopyInto(out *LibrarySpec) { + *out = *in + if in.Subjects != nil { + in, out := &in.Subjects, &out.Subjects + *out = make([]LibrarySubject, len(*in)) + copy(*out, *in) + } + if in.SourceControl != nil { + in, out := &in.SourceControl, &out.SourceControl + *out = new(SourceControl) + (*in).DeepCopyInto(*out) + } + if in.Datasources != nil { + in, out := &in.Datasources, &out.Datasources + *out = make([]LibraryDatasource, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LibrarySpec. +func (in *LibrarySpec) DeepCopy() *LibrarySpec { + if in == nil { + return nil + } + out := new(LibrarySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LibraryStatus) DeepCopyInto(out *LibraryStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LibraryStatus. +func (in *LibraryStatus) DeepCopy() *LibraryStatus { + if in == nil { + return nil + } + out := new(LibraryStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LibrarySubject) DeepCopyInto(out *LibrarySubject) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LibrarySubject. +func (in *LibrarySubject) DeepCopy() *LibrarySubject { + if in == nil { + return nil + } + out := new(LibrarySubject) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SourceControl) DeepCopyInto(out *SourceControl) { + *out = *in + if in.LibraryOrigin != nil { + in, out := &in.LibraryOrigin, &out.LibraryOrigin + *out = new(GitRepo) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SourceControl. +func (in *SourceControl) DeepCopy() *SourceControl { + if in == nil { + return nil + } + out := new(SourceControl) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/main.go b/cmd/main.go index 39feacf..d5450cf 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -208,6 +208,27 @@ func main() { } } + libraryReconciler := &controllers.LibraryReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Config: ctrlConfig, + Styra: styraClient, + } + if ctrlConfig.NotificationWebhook != nil { + libraryReconciler.WebhookClient = webhook.New(ctrlConfig.NotificationWebhook.Address) + } + + if err = libraryReconciler.SetupWithManager(mgr); err != nil { + log.Error(err, "unable to create controller", "controller", "Library") + os.Exit(1) + } + + if !ctrlConfig.DisableCRDWebhooks { + if err = (&styrav1alpha1.Library{}).SetupWebhookWithManager(mgr); err != nil { + log.Error(err, "unable to create webhook", "webhook", "Library") + os.Exit(1) + } + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { log.Error(err, "unable to set up health check") diff --git a/config/crd/bases/styra.bankdata.dk_libraries.yaml b/config/crd/bases/styra.bankdata.dk_libraries.yaml new file mode 100644 index 0000000..96cb59d --- /dev/null +++ b/config/crd/bases/styra.bankdata.dk_libraries.yaml @@ -0,0 +1,124 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.13.0 + name: libraries.styra.bankdata.dk +spec: + group: styra.bankdata.dk + names: + kind: Library + listKind: LibraryList + plural: libraries + singular: library + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Library is the Schema for the libraries API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: LibrarySpec defines the desired state of Library + properties: + datasources: + description: Datasources is the list of datasources in the Library + items: + description: LibraryDatasource contains metadata of a datasource, + stored in a library + properties: + description: + description: Description is a description of the datasource + type: string + path: + description: Path is the path within the system where the datasource + should reside. + type: string + required: + - path + type: object + type: array + description: + description: Description is the description of the Library + type: string + name: + description: Name is the name the Library will have in Styra DAS + type: string + sourceControl: + description: SourceControl is the sourcecontrol configuration for + the Library + properties: + libraryOrigin: + description: GitRepo defines the Git configurations a library + can be defined by + properties: + commit: + description: Commit is used to point to a specific commit + SHA. This takes precedence over `Reference` if both are + specified. + type: string + path: + description: Path is the path in the git repo where the policies + are located. + type: string + reference: + description: Reference is used to point to a tag or branch. + This will be ignored if `Commit` is specified. + type: string + url: + description: URL is the URL of the git repo. + type: string + required: + - url + type: object + required: + - libraryOrigin + type: object + subjects: + description: Subjects is the list of subjects which should have access + to the system. + items: + description: LibrarySubject represents a subject which has been + granted access to the Library. The subject is assigned to the + LibraryViewer role. + properties: + kind: + description: Kind is the LibrarySubjectKind of the subject. + enum: + - user + - group + type: string + name: + description: Name is the name of the subject. The meaning of + this field depends on the SubjectKind. + type: string + required: + - name + type: object + type: array + required: + - description + - name + - sourceControl + type: object + status: + description: LibraryStatus defines the observed state of Library + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 36c1e13..f3e8d15 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -7,6 +7,7 @@ kind: Kustomization resources: - bases/styra.bankdata.dk_systems.yaml - bases/styra.bankdata.dk_globaldatasources.yaml +- bases/styra.bankdata.dk_libraries.yaml #+kubebuilder:scaffold:crdkustomizeresource patches: @@ -14,12 +15,14 @@ patches: # patches here are for enabling the conversion webhook for each CRD - path: patches/webhook_in_styra_systems.yaml - path: patches/webhook_in_styra_globaldatasources.yaml +- path: patches/webhook_in_styra_libraries.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. # patches here are for enabling the CA injection for each CRD - path: patches/cainjection_in_styra_systems.yaml - path: patches/cainjection_in_styra_globaldatasources.yaml +- path: patches/cainjection_in_styra_libraries.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_styra_libraries.yaml b/config/crd/patches/cainjection_in_styra_libraries.yaml new file mode 100644 index 0000000..f3429e8 --- /dev/null +++ b/config/crd/patches/cainjection_in_styra_libraries.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME + name: libraries.styra.bankdata.dk diff --git a/config/crd/patches/webhook_in_styra_libraries.yaml b/config/crd/patches/webhook_in_styra_libraries.yaml new file mode 100644 index 0000000..cbe5f54 --- /dev/null +++ b/config/crd/patches/webhook_in_styra_libraries.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: libraries.styra.bankdata.dk +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/default/webhookcainjection_patch.yaml b/config/default/webhookcainjection_patch.yaml new file mode 100644 index 0000000..abad6d3 --- /dev/null +++ b/config/default/webhookcainjection_patch.yaml @@ -0,0 +1,29 @@ +# This patch add annotation to admission webhook config and +# CERTIFICATE_NAMESPACE and CERTIFICATE_NAME will be substituted by kustomize +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + labels: + app.kubernetes.io/name: mutatingwebhookconfiguration + app.kubernetes.io/instance: mutating-webhook-configuration + app.kubernetes.io/component: webhook + app.kubernetes.io/created-by: styra-controller + app.kubernetes.io/part-of: styra-controller + app.kubernetes.io/managed-by: kustomize + name: mutating-webhook-configuration + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + labels: + app.kubernetes.io/name: validatingwebhookconfiguration + app.kubernetes.io/instance: validating-webhook-configuration + app.kubernetes.io/component: webhook + app.kubernetes.io/created-by: styra-controller + app.kubernetes.io/part-of: styra-controller + app.kubernetes.io/managed-by: kustomize + name: validating-webhook-configuration + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 7b4c137..54b1652 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -61,6 +61,32 @@ rules: - get - patch - update +- apiGroups: + - styra.bankdata.dk + resources: + - libraries + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - styra.bankdata.dk + resources: + - libraries/finalizers + verbs: + - update +- apiGroups: + - styra.bankdata.dk + resources: + - libraries/status + verbs: + - get + - patch + - update - apiGroups: - styra.bankdata.dk resources: diff --git a/config/rbac/styra_library_editor_role.yaml b/config/rbac/styra_library_editor_role.yaml new file mode 100644 index 0000000..25ade9e --- /dev/null +++ b/config/rbac/styra_library_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit libraries. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: library-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: styra-controller + app.kubernetes.io/part-of: styra-controller + app.kubernetes.io/managed-by: kustomize + name: library-editor-role +rules: +- apiGroups: + - styra.bankdata.dk + resources: + - libraries + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - styra.bankdata.dk + resources: + - libraries/status + verbs: + - get diff --git a/config/rbac/styra_library_viewer_role.yaml b/config/rbac/styra_library_viewer_role.yaml new file mode 100644 index 0000000..f959179 --- /dev/null +++ b/config/rbac/styra_library_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view libraries. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: library-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: styra-controller + app.kubernetes.io/part-of: styra-controller + app.kubernetes.io/managed-by: kustomize + name: library-viewer-role +rules: +- apiGroups: + - styra.bankdata.dk + resources: + - libraries + verbs: + - get + - list + - watch +- apiGroups: + - styra.bankdata.dk + resources: + - libraries/status + verbs: + - get diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index dfc7dde..bcbdc46 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -6,4 +6,5 @@ resources: - test_v1_object.yaml - config_v2alpha1_projectconfig.yaml - config_v2alpha2_projectconfig.yaml +- styra_v1alpha1_library.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/styra_v1alpha1_library.yaml b/config/samples/styra_v1alpha1_library.yaml new file mode 100644 index 0000000..1723da8 --- /dev/null +++ b/config/samples/styra_v1alpha1_library.yaml @@ -0,0 +1,28 @@ +apiVersion: styra.bankdata.dk/v1alpha1 +kind: Library +metadata: + labels: + app.kubernetes.io/name: library + app.kubernetes.io/instance: library-sample + app.kubernetes.io/part-of: styra-controller + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/created-by: styra-controller + name: my-library +spec: + name: mylibrary + description: my library + sourceControl: + libraryOrigin: + url: https://github.com/Bankdata/styra-controller.git + reference: refs/heads/master + commit: f37cc9d87251921cbe49349235d9b5305c833769 + path: path + datasources: + - path: seconds/datasource + description: this is the second datasource + subjects: + - kind: user + name: user1@mail.dk + - kind: group + name: mygroup + \ No newline at end of file diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index c232af4..2267548 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -24,6 +24,26 @@ webhooks: resources: - globaldatasources sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /mutate-styra-bankdata-dk-v1alpha1-library + failurePolicy: Fail + name: mlibrary.kb.io + rules: + - apiGroups: + - styra.bankdata.dk + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - libraries + sideEffects: None - admissionReviewVersions: - v1 clientConfig: @@ -70,6 +90,26 @@ webhooks: resources: - globaldatasources sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-styra-bankdata-dk-v1alpha1-library + failurePolicy: Fail + name: vlibrary.kb.io + rules: + - apiGroups: + - styra.bankdata.dk + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - libraries + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/docs/apis/styra/v1alpha1.md b/docs/apis/styra/v1alpha1.md index c1e211d..eb11640 100644 --- a/docs/apis/styra/v1alpha1.md +++ b/docs/apis/styra/v1alpha1.md @@ -11,6 +11,70 @@ group.

Resource Types: +

GitRepo +

+

+(Appears on:SourceControl) +

+
+

GitRepo defines the Git configurations a library can be defined by

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+path
+ +string + +
+

Path is the path in the git repo where the policies are located.

+
+reference
+ +string + +
+

Reference is used to point to a tag or branch. This will be ignored if +Commit is specified.

+
+commit
+ +string + +
+

Commit is used to point to a specific commit SHA. This takes precedence +over Reference if both are specified.

+
+url
+ +string + +
+

URL is the URL of the git repo.

+

GlobalDatasource

@@ -417,8 +481,388 @@ string

GlobalDatasourceStatus holds the status of the GlobalDatasource resource.

+

Library +

+
+

Library is the Schema for the libraries API

+
+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+metadata
+ + +k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta + + +
+Refer to the Kubernetes API documentation for the fields of the +metadata field. +
+spec
+ + +LibrarySpec + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+name
+ +string + +
+

Name is the name the Library will have in Styra DAS

+
+description
+ +string + +
+

Description is the description of the Library

+
+subjects
+ + +[]LibrarySubject + + +
+

Subjects is the list of subjects which should have access to the system.

+
+sourceControl
+ + +SourceControl + + +
+

SourceControl is the sourcecontrol configuration for the Library

+
+datasources
+ + +[]LibraryDatasource + + +
+

Datasources is the list of datasources in the Library

+
+
+status
+ + +LibraryStatus + + +
+
+

LibraryDatasource +

+

+(Appears on:LibrarySpec) +

+
+

LibraryDatasource contains metadata of a datasource, stored in a library

+
+ + + + + + + + + + + + + + + + + +
FieldDescription
+path
+ +string + +
+

Path is the path within the system where the datasource should reside.

+
+description
+ +string + +
+

Description is a description of the datasource

+
+

LibrarySecretRef +

+
+

LibrarySecretRef defines how to access a k8s secret for the library.

+
+ + + + + + + + + + + + + + + + + +
FieldDescription
+namespace
+ +string + +
+

Namespace is the namespace where the secret resides.

+
+name
+ +string + +
+

Name is the name of the secret.

+
+

LibrarySpec +

+

+(Appears on:Library) +

+
+

LibrarySpec defines the desired state of Library

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+name
+ +string + +
+

Name is the name the Library will have in Styra DAS

+
+description
+ +string + +
+

Description is the description of the Library

+
+subjects
+ + +[]LibrarySubject + + +
+

Subjects is the list of subjects which should have access to the system.

+
+sourceControl
+ + +SourceControl + + +
+

SourceControl is the sourcecontrol configuration for the Library

+
+datasources
+ + +[]LibraryDatasource + + +
+

Datasources is the list of datasources in the Library

+
+

LibraryStatus +

+

+(Appears on:Library) +

+
+

LibraryStatus defines the observed state of Library

+
+

LibrarySubject +

+

+(Appears on:LibrarySpec) +

+
+

LibrarySubject represents a subject which has been granted access to the Library. +The subject is assigned to the LibraryViewer role.

+
+ + + + + + + + + + + + + + + + + +
FieldDescription
+kind
+ + +LibrarySubjectKind + + +
+

Kind is the LibrarySubjectKind of the subject.

+
+name
+ +string + +
+

Name is the name of the subject. The meaning of this field depends on the +SubjectKind.

+
+

LibrarySubjectKind +(string alias)

+

+(Appears on:LibrarySubject) +

+
+

LibrarySubjectKind represents a kind of a subject.

+
+ + + + + + + + + + + + +
ValueDescription

"group"

LibrarySubjectKindGroup is the subject kind group.

+

"user"

LibrarySubjectKindUser is the subject kind user.

+
+

SourceControl +

+

+(Appears on:LibrarySpec) +

+
+

SourceControl is a struct from styra where we only use a single field +but kept for clarity when comparing to the API

+
+ + + + + + + + + + + + + +
FieldDescription
+libraryOrigin
+ + +GitRepo + + +
+

Generated with gen-crd-api-reference-docs -on git commit 534b87f. +on git commit d1f19e4.

diff --git a/docs/apis/styra/v1beta1.md b/docs/apis/styra/v1beta1.md index bc285e8..84e1563 100644 --- a/docs/apis/styra/v1beta1.md +++ b/docs/apis/styra/v1beta1.md @@ -1172,5 +1172,5 @@ System.


Generated with gen-crd-api-reference-docs -on git commit d2179ec. +on git commit d1f19e4.

diff --git a/internal/controller/styra/library_controller.go b/internal/controller/styra/library_controller.go new file mode 100644 index 0000000..7356ec1 --- /dev/null +++ b/internal/controller/styra/library_controller.go @@ -0,0 +1,487 @@ +/* +Copyright (C) 2023 Bankdata (bankdata@bankdata.dk) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package styra + +import ( + "context" + "fmt" + "net/http" + "path" + + ctrlerr "github.com/bankdata/styra-controller/internal/errors" + "github.com/bankdata/styra-controller/internal/webhook" + "github.com/bankdata/styra-controller/pkg/styra" + "github.com/go-logr/logr" + "github.com/pkg/errors" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + configv2alpha2 "github.com/bankdata/styra-controller/api/config/v2alpha2" + styrav1alpha1 "github.com/bankdata/styra-controller/api/styra/v1alpha1" +) + +// LibraryReconciler reconciles a Library object +type LibraryReconciler struct { + client.Client + Scheme *runtime.Scheme + Styra styra.ClientInterface + Config *configv2alpha2.ProjectConfig + WebhookClient webhook.Client +} + +//+kubebuilder:rbac:groups=styra.bankdata.dk,resources=libraries,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=styra.bankdata.dk,resources=libraries/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=styra.bankdata.dk,resources=libraries/finalizers,verbs=update + +// Reconcile implements renconcile.Renconciler and has responsibility of +// ensuring that the current state of the Library resource renconciled +// towards the desired state. +func (r *LibraryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + log.Info("Reconciliation begins") + + var k8sLib styrav1alpha1.Library + if err := r.Get(ctx, req.NamespacedName, &k8sLib); err != nil { + if k8serrors.IsNotFound(err) { + log.Info("Could not find Library") + return ctrl.Result{}, nil + } + return ctrl.Result{}, errors.Wrap(err, "Could not get Library") + } + + if !k8sLib.ObjectMeta.DeletionTimestamp.IsZero() { + log.Info(fmt.Sprintf("Library %s is under deletion in k8s. Ignoring it.", k8sLib.Spec.Name)) + return ctrl.Result{}, nil + } + + log.Info("Reconciling git credentials from default credentials") + gitCredential := r.Config.GetGitCredentialForRepo(k8sLib.Spec.SourceControl.LibraryOrigin.URL) + if gitCredential == nil { + log.Info("Could not find matching credentials", "url", k8sLib.Spec.SourceControl.LibraryOrigin.URL) + } else { + _, err := r.Styra.CreateUpdateSecret( + ctx, + path.Join("libraries", k8sLib.Spec.Name, "git"), + &styra.CreateUpdateSecretsRequest{ + Name: gitCredential.User, + Secret: gitCredential.Password, + }, + ) + if err != nil { + return ctrl.Result{}, ctrlerr.Wrap(err, "Could not update Styra secret") + } + } + + log.Info("Reconciling Library") + update := false + libResp, err := r.Styra.GetLibrary(ctx, k8sLib.Spec.Name) + if err != nil { + var httpErr *styra.HTTPError + if errors.As(err, &httpErr) { + if httpErr.StatusCode == http.StatusNotFound { + update = true + } + } + } else if r.needsUpdate(&k8sLib, libResp.LibraryEntityExpanded) { + update = true + } + + if update { + log.Info("UpsertLibrary") + _, err := r.Styra.UpsertLibrary(ctx, k8sLib.Spec.Name, r.specToUpdate(&k8sLib)) + if err != nil { + return ctrl.Result{}, ctrlerr.Wrap(err, "Could not upsert library") + } + } + + if libResp == nil { + // This is often the case when a library has just been created. + log.Info(fmt.Sprint("Library ", k8sLib.Spec.Name, " could not be fetched. Requeueing...")) + return ctrl.Result{Requeue: true}, nil + } + + if result, err := r.reconcileDatasources(ctx, log, &k8sLib, libResp.LibraryEntityExpanded); err != nil { + return result, ctrlerr.Wrap(err, "Could not reconcile library datasources") + } + + if result, err := r.reconcileSubjects(ctx, log, &k8sLib); err != nil { + return result, err + } + + log.Info("Reconciliation completed") + return ctrl.Result{}, nil +} + +func (r *LibraryReconciler) specToUpdate(k8sLib *styrav1alpha1.Library) *styra.UpsertLibraryRequest { + if k8sLib == nil { + return nil + } + specs := k8sLib.Spec + k8sSourceControl := specs.SourceControl.LibraryOrigin + + sourceControl := styra.LibraryGitRepoConfig{ + Commit: k8sSourceControl.Commit, + Credentials: path.Join("libraries", specs.Name, "git"), + Path: k8sSourceControl.Path, + Reference: k8sSourceControl.Reference, + URL: k8sSourceControl.URL, + } + + req := styra.UpsertLibraryRequest{ + Description: specs.Description, + ReadOnly: true, + SourceControl: &styra.LibrarySourceControlConfig{LibraryOrigin: &sourceControl}, + } + + return &req +} + +func (r *LibraryReconciler) needsUpdate(k8sLib *styrav1alpha1.Library, styraLib *styra.LibraryEntityExpanded) bool { + if k8sLib == nil { + return false + } + + specs := k8sLib.Spec + if styraLib == nil || + specs.Name != styraLib.ID || + specs.Description != styraLib.Description || + !styraLib.ReadOnly || + !sameSourceControl(specs.SourceControl, styraLib.SourceControl) { + return true + } + + if styraLib.SourceControl.LibraryOrigin.Credentials != path.Join("libraries", k8sLib.Spec.Name, "git") { + if r.Config.GetGitCredentialForRepo(specs.SourceControl.LibraryOrigin.URL) != nil { + return true + } + } + + return false +} + +func sameSourceControl(k8sLib *styrav1alpha1.SourceControl, styraLib *styra.LibrarySourceControlConfig) bool { + return k8sLib.LibraryOrigin.Path == styraLib.LibraryOrigin.Path && + k8sLib.LibraryOrigin.Reference == styraLib.LibraryOrigin.Reference && + k8sLib.LibraryOrigin.Commit == styraLib.LibraryOrigin.Commit && + k8sLib.LibraryOrigin.URL == styraLib.LibraryOrigin.URL +} + +// SetupWithManager sets up the controller with the Manager. +func (r *LibraryReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&styrav1alpha1.Library{}). + Complete(r) +} + +func (r *LibraryReconciler) reconcileDatasources(ctx context.Context, log logr.Logger, k8sLib *styrav1alpha1.Library, + styraLib *styra.LibraryEntityExpanded) (ctrl.Result, error) { + log.Info("Reconciling library datasources") + + existingByID := map[string]styra.LibraryDatasourceConfig{} + if styraLib != nil { + for _, ds := range styraLib.DataSources { + if ds.ID != "" { + existingByID[ds.ID] = ds + } + } + } + + expectedByID := map[string]styrav1alpha1.LibraryDatasource{} + if k8sLib.Spec.Datasources != nil { + for _, ds := range k8sLib.Spec.Datasources { + id := path.Join("libraries", k8sLib.Spec.Name, ds.Path) + expectedByID[id] = ds + } + } + + // Create the missing datasources + for id := range expectedByID { + ds, exists := existingByID[id] + if !exists || ds.Category != "rest" { + log := log.WithValues("datasourceID", id) + log.Info("Creating or updating datasource") + request := &styra.UpsertDatasourceRequest{ + Category: "rest", + Enabled: true, + } + _, err := r.Styra.UpsertDatasource(ctx, id, request) + if err != nil { + return ctrl.Result{}, ctrlerr.Wrap(err, "Could not create or update library datasource") + } + + if r.WebhookClient != nil { + log.Info("Calling library datasource changed webhook") + if err := r.WebhookClient.DatasourceChanged(ctx, log, "jwt-library", ""); err != nil { + err = ctrlerr.Wrap(err, "could not call 'library datasource changed' webhook") + log.Error(err, err.Error()) + } + } + } + } + + // Delete the unexpected datasources + if styraLib == nil { + return ctrl.Result{}, nil + } + + for _, ds := range styraLib.DataSources { + if ds.ID == "" { + log.Info("There exists some datasource without id?") + continue + } + + if _, expected := expectedByID[ds.ID]; !expected { + log.Info("Deleting undeclared datasource") + + if _, err := r.Styra.DeleteDatasource(ctx, ds.ID); err != nil { + return ctrl.Result{}, ctrlerr.Wrap(err, "Could not delete library datasource") + } + } + } + + log.Info("Reconciled datasources") + return ctrl.Result{}, nil +} + +func (r *LibraryReconciler) reconcileSubjects( + ctx context.Context, + log logr.Logger, + k8sLib *styrav1alpha1.Library, +) (ctrl.Result, error) { + log.Info("Reconciling subjects for library") + + // Make sure all users already exist in Styra, otherwise create them + if err := r.createUsersIfMissing(ctx, log, k8sLib); err != nil { + return ctrl.Result{}, err + } + + // Delete Rolebindings to other roles then LibraryViewer + if err := r.deleteIncorrectRoleBindings(ctx, log, k8sLib); err != nil { + return ctrl.Result{}, err + } + + // Create rolebinding for LibraryViewer if it is missing + if err := r.createRoleBindingIfMissing(ctx, log, styra.RoleLibraryViewer, k8sLib); err != nil { + return ctrl.Result{}, err + } + + // Make sure the LibraryViewer rolebinding contains correct subjects + if err := r.updateRoleBindingIfNeeded(ctx, log, styra.RoleLibraryViewer, k8sLib); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *LibraryReconciler) createUsersIfMissing( + ctx context.Context, + log logr.Logger, + k8sLib *styrav1alpha1.Library) error { + for _, subject := range k8sLib.Spec.Subjects { + if subject.IsUser() { + log := log.WithValues("user", subject.Name) + log.Info("Checking if user exists") + res, err := r.Styra.GetUser(ctx, subject.Name) + if err != nil { + return ctrlerr.Wrap(err, "Could not get user from Styra API") + } + + if res.StatusCode == http.StatusNotFound { + log.Info("User does not exist in styra. Creating...") + _, err := r.Styra.CreateInvitation(ctx, false, subject.Name) + if err != nil { + return ctrlerr.Wrap(err, "Could not create user in Styra") + } + } + } + } + return nil +} + +func (r *LibraryReconciler) deleteIncorrectRoleBindings( + ctx context.Context, + log logr.Logger, + k8sLib *styrav1alpha1.Library) error { + log.Info("Deleting Rolebindings to roles that are not LibraryViewers") + res, err := r.Styra.ListRoleBindingsV2(ctx, &styra.ListRoleBindingsV2Params{ + ResourceKind: styra.RoleBindingKindLibrary, + ResourceID: k8sLib.Spec.Name, + }) + if err != nil { + return ctrlerr.Wrap(err, "Could not get rolebindings for Library in Styra") + } + + for _, styraRB := range res.Rolebindings { + if styraRB.RoleID != styra.Role("LibraryViewer") { + if _, err := r.Styra.DeleteRoleBindingV2(ctx, styraRB.ID); err != nil { + return err + } + } + } + return nil +} + +func (r *LibraryReconciler) createRoleBindingIfMissing( + ctx context.Context, + log logr.Logger, + role styra.Role, + k8sLib *styrav1alpha1.Library) error { + res, err := r.Styra.ListRoleBindingsV2(ctx, &styra.ListRoleBindingsV2Params{ + ResourceKind: styra.RoleBindingKindLibrary, + ResourceID: k8sLib.Spec.Name, + }) + if err != nil { + return ctrlerr.Wrap(err, "Could not get rolebindings for Library in Styra") + } + + if len(res.Rolebindings) == 0 { + log.Info(fmt.Sprintf("No rolebindings exist for the %s Library. Creating rolebinding.", k8sLib.Spec.Name)) + + k8sRolebindingSubjects := createLibraryRolebindingSubjects( + k8sLib.Spec.Subjects, + r.Config.SSO, + ) + + err := r.createRoleBinding(ctx, log, k8sLib, role, k8sRolebindingSubjects) + if err != nil { + return ctrlerr.Wrap(err, "Could not create rolebinding in Styra") + } + } + return nil +} + +func (r *LibraryReconciler) updateRoleBindingIfNeeded( + ctx context.Context, + log logr.Logger, + role styra.Role, + k8sLib *styrav1alpha1.Library) error { + res, err := r.Styra.ListRoleBindingsV2(ctx, &styra.ListRoleBindingsV2Params{ + ResourceKind: styra.RoleBindingKindLibrary, + ResourceID: k8sLib.Spec.Name, + }) + if err != nil { + return ctrlerr.Wrap(err, "Could not get rolebindings for Library in Styra") + } + + k8sRolebindingSubjects := createLibraryRolebindingSubjects( + k8sLib.Spec.Subjects, + r.Config.SSO, + ) + + // res.Rolebindings should only contain one rolebinding, for the "LibraryViewer" role + // Also, should contain nothing else after deleteIncorrectRoleBindings + for _, rb := range res.Rolebindings { + if rb.RoleID == role { + if !styra.SubjectsAreEqual(k8sRolebindingSubjects, rb.Subjects) { + if err := r.updateRoleBindingSubjects(ctx, log, rb.ID, k8sRolebindingSubjects); err != nil { + return err + } + } + } + } + + return nil +} + +func (r *LibraryReconciler) updateRoleBindingSubjects( + ctx context.Context, + log logr.Logger, + roleBindingID string, + subjects []*styra.Subject, +) error { + log.Info("Updating rolebinding") + + _, err := r.Styra.UpdateRoleBindingSubjects( + ctx, + roleBindingID, + &styra.UpdateRoleBindingSubjectsRequest{Subjects: subjects}, + ) + if err != nil { + return ctrlerr.Wrap(err, "Could not update Styra role binding") + } + + log.Info("Updated rolebinding") + return nil +} + +func createLibraryRolebindingSubjects( + subjects []styrav1alpha1.LibrarySubject, + sso *configv2alpha2.SSOConfig, +) []*styra.Subject { + styraSubjectsByUserID := map[string]struct{}{} + styraSubjectsByClaimValue := map[string]struct{}{} + + styraSubjects := []*styra.Subject{} + + for _, subject := range subjects { + if subject.IsUser() { + if _, ok := styraSubjectsByUserID[subject.Name]; ok { + continue + } + styraSubjects = append(styraSubjects, &styra.Subject{ + Kind: styra.SubjectKindUser, + ID: subject.Name, + }) + styraSubjectsByUserID[subject.Name] = struct{}{} + + } else if subject.Kind == styrav1alpha1.LibrarySubjectKindGroup && sso != nil { + if _, ok := styraSubjectsByClaimValue[subject.Name]; ok { + continue + } + + styraSubjects = append(styraSubjects, &styra.Subject{ + Kind: styra.SubjectKindClaim, + ClaimConfig: &styra.ClaimConfig{ + IdentityProvider: sso.IdentityProvider, + Key: sso.JWTGroupsClaim, + Value: subject.Name, + }, + }) + styraSubjectsByClaimValue[subject.Name] = struct{}{} + } + } + + return styraSubjects +} + +func (r *LibraryReconciler) createRoleBinding( + ctx context.Context, + log logr.Logger, + k8sLib *styrav1alpha1.Library, + role styra.Role, + subjects []*styra.Subject, +) error { + log.Info("Creating rolebinding") + + if _, err := r.Styra.CreateRoleBinding(ctx, &styra.CreateRoleBindingRequest{ + ResourceFilter: &styra.ResourceFilter{ + ID: k8sLib.Spec.Name, + Kind: styra.RoleBindingKindLibrary, + }, + RoleID: role, + Subjects: subjects, + }); err != nil { + return ctrlerr.Wrap(err, "Could not create rolebinding") + } + + log.Info("Created rolebinding") + return nil +} diff --git a/pkg/styra/authz.go b/pkg/styra/authz.go index b23dd08..15e9772 100644 --- a/pkg/styra/authz.go +++ b/pkg/styra/authz.go @@ -40,6 +40,9 @@ const ( // RoleSystemPolicyEditor is the Styra SystemPolicyEditor role. RoleSystemPolicyEditor Role = "SystemPolicyEditor" + + // RoleLibraryViewer is the Styra LibraryViewer role. + RoleLibraryViewer Role = "LibraryViewer" ) // RoleBindingKind is the kind of the role binding. @@ -49,6 +52,9 @@ const ( // RoleBindingKindSystem is a RoleBindingKind used when the role is for a // System. RoleBindingKindSystem RoleBindingKind = "system" + // RoleBindingKindLibrary is a RoleBindingKind used when the role is for a + // Library. + RoleBindingKindLibrary RoleBindingKind = "library" ) // SubjectKind is the kind of a subject. diff --git a/pkg/styra/client.go b/pkg/styra/client.go index 9dc725e..cc2456c 100644 --- a/pkg/styra/client.go +++ b/pkg/styra/client.go @@ -62,6 +62,9 @@ type ClientInterface interface { DeleteDatasource(ctx context.Context, id string) (*DeleteDatasourceResponse, error) + GetLibrary(ctx context.Context, id string) (*GetLibraryResponse, error) + UpsertLibrary(ctx context.Context, id string, request *UpsertLibraryRequest) (*UpsertLibraryResponse, error) + UpdateSystem(ctx context.Context, id string, request *UpdateSystemRequest) (*UpdateSystemResponse, error) DeleteSystem(ctx context.Context, id string) (*DeleteSystemResponse, error) diff --git a/pkg/styra/library.go b/pkg/styra/library.go new file mode 100644 index 0000000..31d362e --- /dev/null +++ b/pkg/styra/library.go @@ -0,0 +1,145 @@ +/* +Copyright (C) 2023 Bankdata (bankdata@bankdata.dk) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package styra + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/pkg/errors" +) + +const ( + endpointV1Libraries = "/v1/libraries" +) + +type getLibraryJSONResponse struct { + Result *LibraryEntityExpanded `json:"result"` +} + +// GetLibraryResponse is the response type for calls to the +// GET /v1/libraries/{id} endpoint in the Styra API. +type GetLibraryResponse struct { + Statuscode int + Body []byte + LibraryEntityExpanded *LibraryEntityExpanded +} + +// LibraryEntityExpanded is the type that defines of a Library +type LibraryEntityExpanded struct { + DataSources []LibraryDatasourceConfig `json:"datasources"` + Description string `json:"description"` + ID string `json:"id"` + ReadOnly bool `json:"read_only"` + SourceControl *LibrarySourceControlConfig `json:"source_control"` +} + +// LibraryDatasourceConfig defines metadata of a datasource +type LibraryDatasourceConfig struct { + Category string `json:"category"` + ID string `json:"id"` +} + +// LibrarySourceControlConfig is a struct from styra where we only use a single field +// but kept for clarity when comparing to the API +type LibrarySourceControlConfig struct { + LibraryOrigin *LibraryGitRepoConfig `json:"library_origin"` +} + +// LibraryGitRepoConfig defines the Git configurations a library can be defined by +type LibraryGitRepoConfig struct { + Commit string `json:"commit"` + Credentials string `json:"credentials"` + Path string `json:"path"` + Reference string `json:"reference"` + URL string `json:"url"` +} + +// UpsertLibraryRequest is the request body for the +// PUT /v1/libraries/{id} endpoint in the Styra API. +type UpsertLibraryRequest struct { + Description string `json:"description"` + ReadOnly bool `json:"read_only"` + SourceControl *LibrarySourceControlConfig `json:"source_control"` +} + +// UpsertLibraryResponse is the response body for the +// PUT /v1/libraries/{id} endpoint in the Styra API. +type UpsertLibraryResponse struct { + StatusCode int + Body []byte +} + +// GetLibrary calls the GET /v1/libraries/{id} endpoint in the +// Styra API. +func (c *Client) GetLibrary(ctx context.Context, id string) (*GetLibraryResponse, error) { + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("%s/%s", endpointV1Libraries, id), nil) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, errors.Wrap(err, "could not read body") + } + + if res.StatusCode != http.StatusOK { + err := NewHTTPError(res.StatusCode, string(body)) + return nil, err + } + + var jsonRes getLibraryJSONResponse + if err := json.Unmarshal(body, &jsonRes); err != nil { + return nil, errors.Wrap(err, "could not unmarshal body") + } + + return &GetLibraryResponse{ + Statuscode: res.StatusCode, + Body: body, + LibraryEntityExpanded: jsonRes.Result, + }, nil +} + +// UpsertLibrary calls the PUT /v1/libraries/{id} endpoint in the +// Styra API. +func (c *Client) UpsertLibrary(ctx context.Context, id string, request *UpsertLibraryRequest, +) (*UpsertLibraryResponse, error) { + res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("%s/%s", endpointV1Libraries, id), request) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, errors.Wrap(err, "could not read body") + } + + if res.StatusCode != http.StatusOK { + err := NewHTTPError(res.StatusCode, string(body)) + return nil, err + } + + resp := UpsertLibraryResponse{ + StatusCode: res.StatusCode, + Body: body, + } + + return &resp, nil +} diff --git a/pkg/styra/library_test.go b/pkg/styra/library_test.go new file mode 100644 index 0000000..dd18d20 --- /dev/null +++ b/pkg/styra/library_test.go @@ -0,0 +1,245 @@ +/* +Copyright (C) 2023 Bankdata (bankdata@bankdata.dk) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package styra_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + + ginkgo "github.com/onsi/ginkgo/v2" + gomega "github.com/onsi/gomega" + + "github.com/bankdata/styra-controller/pkg/styra" +) + +var _ = ginkgo.Describe("GetLibrary", func() { + type test struct { + libraryID string + responseCode int + responseBody string + expectedLibraryEntityExpanded *styra.LibraryEntityExpanded + expectStyraErr bool + } + + ginkgo.DescribeTable("GetLibrary", func(test test) { + c := newTestClient(func(r *http.Request) *http.Response { + gomega.Expect(r.Method).To(gomega.Equal(http.MethodGet)) + gomega.Expect(r.URL.String()).To(gomega.Equal("http://test.com/v1/libraries/" + test.libraryID)) + + return &http.Response{ + Header: make(http.Header), + StatusCode: test.responseCode, + Body: io.NopCloser(bytes.NewBufferString(test.responseBody)), + } + }) + + res, err := c.GetLibrary(context.Background(), test.libraryID) + if test.expectStyraErr { + gomega.Expect(res).To(gomega.BeNil()) + target := &styra.HTTPError{} + gomega.Expect(errors.As(err, &target)).To(gomega.BeTrue()) + } else { + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(res.Statuscode).To(gomega.Equal(test.responseCode)) + gomega.Expect(res.LibraryEntityExpanded).To(gomega.Equal(test.expectedLibraryEntityExpanded)) + } + }, + + ginkgo.Entry("happy path", test{ + libraryID: "test1", + responseCode: http.StatusOK, + responseBody: ` + { + "result": { + "id": "test1", + "description": "test2", + "read_only": true, + "source_control": { + "use_workspace_settings": false, + "origin": { + "url": "https://github.com/test.git", + "reference": "refs/heads/master", + "commit": "", + "credentials": "gitCreds", + "path": "" + }, + "library_origin": { + "url": "https://github.com/test.git", + "reference": "refs/heads/master", + "commit": "", + "credentials": "path/to/creds", + "path": "" + } + }, + "policies": [], + "datasources": [] + } + } + `, + expectedLibraryEntityExpanded: &styra.LibraryEntityExpanded{ + DataSources: []styra.LibraryDatasourceConfig{}, + Description: "test2", + ID: "test1", + ReadOnly: true, + SourceControl: &styra.LibrarySourceControlConfig{ + LibraryOrigin: &styra.LibraryGitRepoConfig{ + Commit: "", + Credentials: "path/to/creds", + Path: "", + Reference: "refs/heads/master", + URL: "https://github.com/test.git", + }, + }, + }, + }), + + ginkgo.Entry("unexpected status code", test{ + libraryID: "test", + responseCode: http.StatusInternalServerError, + expectStyraErr: true, + }), + ) +}) + +var _ = ginkgo.Describe("UpsertLibrary", func() { + + type test struct { + libraryID string + upsertLibraryRequest *styra.UpsertLibraryRequest + responseCode int + responseBody string + expectedBody []byte + expectStyraErr bool + } + + ginkgo.DescribeTable("UpsertLibrary", func(test test) { + c := newTestClient(func(r *http.Request) *http.Response { + // First make sure the body is readable + bs, err := io.ReadAll(r.Body) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + // Then make sure the body is encoded correctly + var b bytes.Buffer + gomega.Expect(json.NewEncoder(&b).Encode(test.upsertLibraryRequest)).To(gomega.Succeed()) + gomega.Expect(bs).To(gomega.Equal(b.Bytes())) + + // Then ensure the correct http request is made + gomega.Expect(r.Method).To(gomega.Equal(http.MethodPut)) + gomega.Expect(r.URL.String()).To(gomega.Equal("http://test.com/v1/libraries/" + test.libraryID)) + + return &http.Response{ + Header: make(http.Header), + StatusCode: test.responseCode, + Body: io.NopCloser(bytes.NewBufferString(test.responseBody)), + } + }) + + res, err := c.UpsertLibrary(context.Background(), test.libraryID, test.upsertLibraryRequest) + if test.expectStyraErr { + gomega.Expect(res).To(gomega.BeNil()) + target := &styra.HTTPError{} + gomega.Expect(errors.As(err, &target)).To(gomega.BeTrue()) + } else { + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(res.StatusCode).To(gomega.Equal(test.responseCode)) + gomega.Expect(res.Body).To(gomega.Equal(test.expectedBody)) + } + }, + + ginkgo.Entry("happy", test{ + libraryID: "test", + responseCode: http.StatusOK, + responseBody: `expected response from styra api`, + upsertLibraryRequest: &styra.UpsertLibraryRequest{ + Description: "test2", + ReadOnly: true, + SourceControl: &styra.LibrarySourceControlConfig{ + LibraryOrigin: &styra.LibraryGitRepoConfig{ + Commit: "", + Credentials: "path/to/creds", + Path: "", + Reference: "refs/heads/master", + URL: "https://github.com/test.git", + }, + }, + }, + expectedBody: []byte(`expected response from styra api`)}, + ), + + ginkgo.Entry("sad", test{ + libraryID: "test", + responseCode: http.StatusInternalServerError, + expectStyraErr: true, + }), + ) +}) + +var _ = ginkgo.Describe("DeleteDatasource", func() { + + type test struct { + datasourceID string + responseCode int + responseBody string + expectedBody []byte + expectStyraErr bool + } + + ginkgo.DescribeTable("DeleteDatasource", func(test test) { + c := newTestClient(func(r *http.Request) *http.Response { + bs, err := io.ReadAll(r.Body) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(bs).To(gomega.Equal([]byte(""))) + gomega.Expect(r.Method).To(gomega.Equal(http.MethodDelete)) + gomega.Expect(r.URL.String()).To(gomega.Equal("http://test.com/v1/datasources/" + test.datasourceID)) + + return &http.Response{ + Header: make(http.Header), + StatusCode: test.responseCode, + Body: io.NopCloser(bytes.NewBufferString(test.responseBody)), + } + }) + + res, err := c.DeleteDatasource(context.Background(), test.datasourceID) + if test.expectStyraErr { + gomega.Expect(res).To(gomega.BeNil()) + target := &styra.HTTPError{} + gomega.Expect(errors.As(err, &target)).To(gomega.BeTrue()) + } else { + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(res.StatusCode).To(gomega.Equal(test.responseCode)) + gomega.Expect(res.Body).To(gomega.Equal(test.expectedBody)) + } + }, + + ginkgo.Entry("something", test{ + datasourceID: "datasourceID", + responseCode: http.StatusOK, + responseBody: `expected response from styra api`, + expectedBody: []byte(`expected response from styra api`)}, + ), + + ginkgo.Entry("styra http error", test{ + datasourceID: "datasourceID", + responseCode: http.StatusInternalServerError, + expectStyraErr: true, + }), + ) +}) diff --git a/pkg/styra/mocks/client_interface.go b/pkg/styra/mocks/client_interface.go index 35b7d90..3343166 100644 --- a/pkg/styra/mocks/client_interface.go +++ b/pkg/styra/mocks/client_interface.go @@ -248,6 +248,32 @@ func (_m *ClientInterface) GetDatasource(ctx context.Context, id string) (*styra return r0, r1 } +// GetLibrary provides a mock function with given fields: ctx, id +func (_m *ClientInterface) GetLibrary(ctx context.Context, id string) (*styra.GetLibraryResponse, error) { + ret := _m.Called(ctx, id) + + var r0 *styra.GetLibraryResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*styra.GetLibraryResponse, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *styra.GetLibraryResponse); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*styra.GetLibraryResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetOPAConfig provides a mock function with given fields: ctx, systemID func (_m *ClientInterface) GetOPAConfig(ctx context.Context, systemID string) (styra.OPAConfig, error) { ret := _m.Called(ctx, systemID) @@ -428,6 +454,32 @@ func (_m *ClientInterface) UpsertDatasource(ctx context.Context, id string, requ return r0, r1 } +// UpsertLibrary provides a mock function with given fields: ctx, id, request +func (_m *ClientInterface) UpsertLibrary(ctx context.Context, id string, request *styra.UpsertLibraryRequest) (*styra.UpsertLibraryResponse, error) { + ret := _m.Called(ctx, id, request) + + var r0 *styra.UpsertLibraryResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, *styra.UpsertLibraryRequest) (*styra.UpsertLibraryResponse, error)); ok { + return rf(ctx, id, request) + } + if rf, ok := ret.Get(0).(func(context.Context, string, *styra.UpsertLibraryRequest) *styra.UpsertLibraryResponse); ok { + r0 = rf(ctx, id, request) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*styra.UpsertLibraryResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, *styra.UpsertLibraryRequest) error); ok { + r1 = rf(ctx, id, request) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // VerifyGitConfiguration provides a mock function with given fields: ctx, request func (_m *ClientInterface) VerifyGitConfiguration(ctx context.Context, request *styra.VerfiyGitConfigRequest) (*styra.VerfiyGitConfigResponse, error) { ret := _m.Called(ctx, request) diff --git a/test/integration/controller/controller_suite_test.go b/test/integration/controller/controller_suite_test.go index 4dfc7c3..ed388ba 100644 --- a/test/integration/controller/controller_suite_test.go +++ b/test/integration/controller/controller_suite_test.go @@ -141,6 +141,24 @@ var _ = ginkgo.BeforeSuite(func() { err = globalDatasourceReconciler.SetupWithManager(k8sManager) gomega.Expect(err).NotTo(gomega.HaveOccurred()) + libraryReconciler := &styractrls.LibraryReconciler{ + Config: &configv2alpha2.ProjectConfig{ + SSO: &configv2alpha2.SSOConfig{ + IdentityProvider: "AzureAD Bankdata", + JWTGroupsClaim: "groups", + }, + GitCredentials: []*configv2alpha2.GitCredential{ + {User: "test-user", Password: "test-secret"}, + }, + }, + Client: k8sClient, + Styra: styraClientMock, + WebhookClient: webhookMock, + } + + err = libraryReconciler.SetupWithManager(k8sManager) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + managerCtx, managerCancel = context.WithCancel(context.Background()) go func() { defer ginkgo.GinkgoRecover() diff --git a/test/integration/controller/globaldatasource_controller_test.go b/test/integration/controller/globaldatasource_controller_test.go index 50023f7..b2eaa1a 100644 --- a/test/integration/controller/globaldatasource_controller_test.go +++ b/test/integration/controller/globaldatasource_controller_test.go @@ -1,3 +1,19 @@ +/* +Copyright (C) 2023 Bankdata (bankdata@bankdata.dk) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package styra import ( @@ -17,164 +33,164 @@ import ( "github.com/bankdata/styra-controller/pkg/styra" ) -var _ = ginkgo.Describe("GlobalDatasourceReconciler", func() { - ginkgo.Describe("Reconcile", ginkgo.Label("integration"), func() { - ginkgo.It("reconciles GlobalDatasource", func() { - key := types.NamespacedName{Name: uuid.NewString()} - - toCreate := &styrav1alpha1.GlobalDatasource{ - ObjectMeta: metav1.ObjectMeta{ - Name: key.Name, - }, - Spec: styrav1alpha1.GlobalDatasourceSpec{ - Name: key.Name, - Category: styrav1alpha1.GlobalDatasourceCategoryGitRego, - URL: "http://test.com/test.git", - }, +var _ = ginkgo.Describe("GlobalDatasourceReconciler.Reconcile", ginkgo.Label("integration"), func() { + ginkgo.It("reconciles GlobalDatasource", func() { + key := types.NamespacedName{Name: uuid.NewString()} + + toCreate := &styrav1alpha1.GlobalDatasource{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + }, + Spec: styrav1alpha1.GlobalDatasourceSpec{ + Name: key.Name, + Category: styrav1alpha1.GlobalDatasourceCategoryGitRego, + URL: "http://test.com/test.git", + }, + } + + ctx := context.Background() + + ginkgo.By("creating the datasource") + + styraClientMock.On( + "CreateUpdateSecret", + mock.Anything, + path.Join("libraries/global", key.Name, "git"), + &styra.CreateUpdateSecretsRequest{ + Name: "test-user", + Secret: "test-secret", + }, + ).Return(&styra.CreateUpdateSecretResponse{ + StatusCode: http.StatusOK, + }, nil) + + styraClientMock.On( + "GetDatasource", + mock.Anything, + path.Join("global", key.Name), + ).Return( + nil, &styra.HTTPError{ + StatusCode: http.StatusNotFound, + }, + ) + + styraClientMock.On( + "UpsertDatasource", + mock.Anything, + path.Join("global", key.Name), + &styra.UpsertDatasourceRequest{ + Category: "git/rego", + Enabled: true, + Credentials: path.Join("libraries/global", key.Name, "git"), + URL: "http://test.com/test.git", + }, + ).Return(&styra.UpsertDatasourceResponse{}, nil) + + gomega.Ω(k8sClient.Create(ctx, toCreate)).To(gomega.Succeed()) + + gomega.Eventually(func() bool { + var gds styrav1alpha1.GlobalDatasource + if err := k8sClient.Get(ctx, key, &gds); err != nil { + return false } + return true + }, timeout, interval).Should(gomega.BeTrue()) + + gomega.Eventually(func() bool { + var ( + createUpdateSecret int + getDatasource int + upsertDatasource int + ) + for _, call := range styraClientMock.Calls { + switch call.Method { + case "CreateUpdateSecret": + createUpdateSecret++ + case "GetDatasource": + getDatasource++ + case "UpsertDatasource": + upsertDatasource++ + } + } + return createUpdateSecret == 1 && + getDatasource == 1 && + upsertDatasource == 1 + }, timeout, interval).Should(gomega.BeTrue()) - ctx := context.Background() + styraClientMock.AssertExpectations(ginkgo.GinkgoT()) + resetMock(&styraClientMock.Mock) - ginkgo.By("creating the datasource") + ginkgo.By("using a git credential from a secret") - styraClientMock.On( - "CreateUpdateSecret", - mock.Anything, - path.Join("libraries/global", key.Name, "git"), - &styra.CreateUpdateSecretsRequest{ - Name: "test-user", - Secret: "test-secret", - }, - ).Return(&styra.CreateUpdateSecretResponse{ + gomega.Ω(k8sClient.Create(ctx, &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: "default", + }, + Data: map[string][]byte{ + "name": []byte("test-user-2"), + "secret": []byte("test-secret-2"), + }, + })).To(gomega.Succeed()) + + gomega.Eventually(func() bool { + var s v1.Secret + return k8sClient.Get(ctx, types.NamespacedName{Name: key.Name, Namespace: "default"}, &s) == nil + }, timeout, interval).Should(gomega.BeTrue()) + + styraClientMock.On( + "CreateUpdateSecret", + mock.Anything, + path.Join("libraries/global", key.Name, "git"), + &styra.CreateUpdateSecretsRequest{ + Name: "test-user-2", + Secret: "test-secret-2", + }, + ).Return(&styra.CreateUpdateSecretResponse{ + StatusCode: http.StatusOK, + }, nil) + + styraClientMock.On( + "GetDatasource", + mock.Anything, + path.Join("global", key.Name), + ).Return( + &styra.GetDatasourceResponse{ StatusCode: http.StatusOK, - }, nil) - - styraClientMock.On( - "GetDatasource", - mock.Anything, - path.Join("global", key.Name), - ).Return( - nil, &styra.HTTPError{ - StatusCode: http.StatusNotFound, - }, - ) - - styraClientMock.On( - "UpsertDatasource", - mock.Anything, - path.Join("global", key.Name), - &styra.UpsertDatasourceRequest{ + DatasourceConfig: &styra.DatasourceConfig{ Category: "git/rego", Enabled: true, Credentials: path.Join("libraries/global", key.Name, "git"), URL: "http://test.com/test.git", }, - ).Return(&styra.UpsertDatasourceResponse{}, nil) - - gomega.Ω(k8sClient.Create(ctx, toCreate)).To(gomega.Succeed()) + }, nil, + ) - gomega.Eventually(func() bool { - var gds styrav1alpha1.GlobalDatasource - if err := k8sClient.Get(ctx, key, &gds); err != nil { - return false - } - return true - }, timeout, interval).Should(gomega.BeTrue()) - - gomega.Eventually(func() bool { - var ( - createUpdateSecret int - getDatasource int - upsertDatasource int - ) - for _, call := range styraClientMock.Calls { - switch call.Method { - case "CreateUpdateSecret": - createUpdateSecret++ - case "GetDatasource": - getDatasource++ - case "UpsertDatasource": - upsertDatasource++ - } - } - return createUpdateSecret == 1 && - getDatasource == 1 && - upsertDatasource == 1 - }, timeout, interval).Should(gomega.BeTrue()) + toCreate.Spec.CredentialsSecretRef = &styrav1alpha1.GlobalDatasourceSecretRef{ + Name: key.Name, + Namespace: "default", + } - styraClientMock.AssertExpectations(ginkgo.GinkgoT()) - resetMock(&styraClientMock.Mock) + gomega.Ω(k8sClient.Update(ctx, toCreate)).To(gomega.Succeed()) - ginkgo.By("using a git credential from a secret") - - gomega.Ω(k8sClient.Create(ctx, &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: key.Name, - Namespace: "default", - }, - Data: map[string][]byte{ - "name": []byte("test-user-2"), - "secret": []byte("test-secret-2"), - }, - })).To(gomega.Succeed()) - - gomega.Eventually(func() bool { - var s v1.Secret - return k8sClient.Get(ctx, types.NamespacedName{Name: key.Name, Namespace: "default"}, &s) == nil - }, timeout, interval).Should(gomega.BeTrue()) - - styraClientMock.On( - "CreateUpdateSecret", - mock.Anything, - path.Join("libraries/global", key.Name, "git"), - &styra.CreateUpdateSecretsRequest{ - Name: "test-user-2", - Secret: "test-secret-2", - }, - ).Return(&styra.CreateUpdateSecretResponse{ - StatusCode: http.StatusOK, - }, nil) - - styraClientMock.On( - "GetDatasource", - mock.Anything, - path.Join("global", key.Name), - ).Return( - &styra.GetDatasourceResponse{ - StatusCode: http.StatusOK, - DatasourceConfig: &styra.DatasourceConfig{ - Category: "git/rego", - Enabled: true, - Credentials: path.Join("libraries/global", key.Name, "git"), - URL: "http://test.com/test.git", - }, - }, nil, + gomega.Eventually(func() bool { + var ( + createUpdateSecret int + getDatasource int ) - - toCreate.Spec.CredentialsSecretRef = &styrav1alpha1.GlobalDatasourceSecretRef{ - Name: key.Name, - Namespace: "default", + for _, call := range styraClientMock.Calls { + switch call.Method { + case "CreateUpdateSecret": + createUpdateSecret++ + case "GetDatasource": + getDatasource++ + } } + return createUpdateSecret == 1 && getDatasource == 1 + }, timeout, interval).Should(gomega.BeTrue()) - gomega.Ω(k8sClient.Update(ctx, toCreate)).To(gomega.Succeed()) - - gomega.Eventually(func() bool { - var ( - createUpdateSecret int - getDatasource int - ) - for _, call := range styraClientMock.Calls { - switch call.Method { - case "CreateUpdateSecret": - createUpdateSecret++ - case "GetDatasource": - getDatasource++ - } - } - return createUpdateSecret == 1 && getDatasource == 1 - }, timeout, interval).Should(gomega.BeTrue()) + styraClientMock.AssertExpectations(ginkgo.GinkgoT()) - styraClientMock.AssertExpectations(ginkgo.GinkgoT()) - }) + resetMock(&styraClientMock.Mock) }) }) diff --git a/test/integration/controller/library_controller_test.go b/test/integration/controller/library_controller_test.go new file mode 100644 index 0000000..49577ac --- /dev/null +++ b/test/integration/controller/library_controller_test.go @@ -0,0 +1,319 @@ +/* +Copyright (C) 2023 Bankdata (bankdata@bankdata.dk) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package styra + +import ( + "context" + "net/http" + "path" + + ginkgo "github.com/onsi/ginkgo/v2" + gomega "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + styrav1alpha1 "github.com/bankdata/styra-controller/api/styra/v1alpha1" + "github.com/bankdata/styra-controller/pkg/styra" +) + +var _ = ginkgo.Describe("LibraryReconciler.Reconcile", ginkgo.Label("integration"), func() { + ginkgo.It("reconciles Library", func() { + key := types.NamespacedName{Name: "uuidewtring", Namespace: "default"} + toCreate := &styrav1alpha1.Library{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Spec: styrav1alpha1.LibrarySpec{ + Name: key.Name, + Subjects: []styrav1alpha1.LibrarySubject{ + { + Kind: styrav1alpha1.LibrarySubjectKindUser, + Name: "user1@mail.com", + }, + { + // kind + Name: "user2@mail.com", + }, + { + Kind: styrav1alpha1.LibrarySubjectKindGroup, + Name: "testGroup", + }, + }, + SourceControl: &styrav1alpha1.SourceControl{ + LibraryOrigin: &styrav1alpha1.GitRepo{ + Path: "libraries/library", + Reference: "refs/heads/master", + Commit: "commit-sha", + URL: "github.com", + }, + }, + Description: "description", + Datasources: []styrav1alpha1.LibraryDatasource{ + { + Path: "oidc/sandbox", + Description: "desc", + }, + { + Path: "oidc/sandbox/2", + Description: "desc2", + }, + }, + }, + } + ctx := context.Background() + ginkgo.By("creating the Library") + + styraClientMock.On("CreateUpdateSecret", + mock.Anything, + path.Join("libraries", key.Name, "git"), + &styra.CreateUpdateSecretsRequest{ + Name: "test-user", + Secret: "test-secret", + }, + ).Return(&styra.CreateUpdateSecretResponse{ + StatusCode: http.StatusOK, + }, nil).Once() + + styraClientMock.On("GetLibrary", mock.Anything, key.Name). + Return(nil, &styra.HTTPError{StatusCode: http.StatusNotFound}).Once() + + defaultSourceControl := &styra.LibrarySourceControlConfig{ + LibraryOrigin: &styra.LibraryGitRepoConfig{ + Commit: "commit-sha", + Credentials: path.Join("libraries", key.Name, "git"), + Path: "libraries/library", + Reference: "refs/heads/master", + URL: "github.com", + }, + } + + styraClientMock.On("UpsertLibrary", mock.Anything, key.Name, &styra.UpsertLibraryRequest{ + Description: "description", + ReadOnly: true, + SourceControl: defaultSourceControl, + }).Return(&styra.UpsertLibraryResponse{}, nil).Once() + + // New reconciliation started + + styraClientMock.On("CreateUpdateSecret", + mock.Anything, + path.Join("libraries", key.Name, "git"), + &styra.CreateUpdateSecretsRequest{ + Name: "test-user", + Secret: "test-secret", + }, + ).Return(&styra.CreateUpdateSecretResponse{ + StatusCode: http.StatusOK, + }, nil).Once() + + styraClientMock.On("GetLibrary", mock.Anything, key.Name).Return(&styra.GetLibraryResponse{ + Statuscode: http.StatusOK, + LibraryEntityExpanded: &styra.LibraryEntityExpanded{ + DataSources: []styra.LibraryDatasourceConfig{}, + Description: "description", + ID: key.Name, + ReadOnly: true, + SourceControl: defaultSourceControl, + }, + }, nil).Once() + + styraClientMock.On("UpsertDatasource", mock.Anything, path.Join("libraries", key.Name, "oidc/sandbox"), + &styra.UpsertDatasourceRequest{ + Category: "rest", + Enabled: true, + }).Return(&styra.UpsertDatasourceResponse{ + StatusCode: http.StatusOK, + }, nil).Once() + + webhookMock.On( + "DatasourceChanged", + mock.Anything, + mock.Anything, + "jwt-library", + "", + ).Return(nil).Once() + + styraClientMock.On("UpsertDatasource", mock.Anything, path.Join("libraries", key.Name, "oidc/sandbox/2"), + &styra.UpsertDatasourceRequest{ + Category: "rest", + Enabled: true, + }).Return(&styra.UpsertDatasourceResponse{ + StatusCode: http.StatusOK, + }, nil).Once() + + webhookMock.On( + "DatasourceChanged", + mock.Anything, + mock.Anything, + "jwt-library", + "", + ).Return(nil).Once() + + // createUsersIfMissing: + styraClientMock.On("GetUser", mock.Anything, "user1@mail.com"). + Return(&styra.GetUserResponse{ + StatusCode: http.StatusNotFound, + }, nil).Once() + + styraClientMock.On("CreateInvitation", mock.Anything, false, "user1@mail.com"). + Return(&styra.CreateInvitationResponse{}, nil).Once() + + styraClientMock.On("GetUser", mock.Anything, "user2@mail.com"). + Return(&styra.GetUserResponse{ + StatusCode: http.StatusOK, + }, nil).Once() + + // deleteIncorrectRoleBindings: + styraClientMock.On("ListRoleBindingsV2", mock.Anything, &styra.ListRoleBindingsV2Params{ + ResourceKind: styra.RoleBindingKindLibrary, + ResourceID: toCreate.Spec.Name, + }).Return(&styra.ListRoleBindingsV2Response{ + Rolebindings: []*styra.RoleBindingConfig{}, + }, nil).Once() //Test deletions also? + + // createRoleBindingIfMissing: + styraClientMock.On("ListRoleBindingsV2", mock.Anything, &styra.ListRoleBindingsV2Params{ + ResourceKind: styra.RoleBindingKindLibrary, + ResourceID: toCreate.Spec.Name, + }).Return(&styra.ListRoleBindingsV2Response{ + Rolebindings: []*styra.RoleBindingConfig{}, + }, nil).Once() + + styraClientMock.On("CreateRoleBinding", mock.Anything, &styra.CreateRoleBindingRequest{ + ResourceFilter: &styra.ResourceFilter{ + ID: toCreate.Spec.Name, + Kind: styra.RoleBindingKindLibrary, + }, + RoleID: styra.RoleLibraryViewer, + Subjects: []*styra.Subject{ + { + ID: "user1@mail.com", + Kind: styra.SubjectKindUser, + }, { + ID: "user2@mail.com", + Kind: styra.SubjectKindUser, + }, { + Kind: styra.SubjectKindClaim, + ClaimConfig: &styra.ClaimConfig{ + IdentityProvider: "AzureAD Bankdata", + Key: "groups", + Value: "testGroup", + }, + }, + }, + }).Return(&styra.CreateRoleBindingResponse{}, nil).Once() + + // updateRoleBindingIfNeeded: + styraClientMock.On("ListRoleBindingsV2", mock.Anything, &styra.ListRoleBindingsV2Params{ + ResourceKind: styra.RoleBindingKindLibrary, + ResourceID: toCreate.Spec.Name, + }).Return(&styra.ListRoleBindingsV2Response{ + Rolebindings: []*styra.RoleBindingConfig{ + { + Subjects: []*styra.Subject{ + { + ID: "user1@mail.com", + Kind: styra.SubjectKindUser, + }, { + ID: "user2@mail.com", + Kind: styra.SubjectKindUser, + }, { + Kind: styra.SubjectKindClaim, + ClaimConfig: &styra.ClaimConfig{ + IdentityProvider: "AzureAD Bankdata", + Key: "groups", + Value: "testGroup", + }, + }, + }, + RoleID: "LibraryViewer", + }, + }, + }, nil).Once() + + gomega.Ω(k8sClient.Create(ctx, toCreate)). + To(gomega.Succeed()) + + gomega.Eventually(func() bool { + var k8sLib styrav1alpha1.Library + if err := k8sClient.Get(ctx, key, &k8sLib); err != nil { + return false + } + return true + }, timeout, interval).Should(gomega.BeTrue()) + + gomega.Eventually(func() bool { + var ( + createUpdateSecret int + getLibrary int + upsertLibrary int + upsertDatasource int + datasourceChanged int + getUser int + createInvitation int + createRoleBinding int + listRoleBindings int + ) + + for _, call := range styraClientMock.Calls { + switch call.Method { + case "CreateUpdateSecret": + createUpdateSecret++ + case "GetLibrary": + getLibrary++ + case "UpsertLibrary": + upsertLibrary++ + case "UpsertDatasource": + upsertDatasource++ + case "GetUser": + getUser++ + case "CreateInvitation": + createInvitation++ + case "CreateRoleBinding": + createRoleBinding++ + case "ListRoleBindingsV2": + listRoleBindings++ + } + } + + for _, call := range webhookMock.Calls { + switch call.Method { + case "DatasourceChanged": + datasourceChanged++ + } + } + + return createUpdateSecret == 2 && + getLibrary == 2 && + upsertLibrary == 1 && + upsertDatasource == 2 && + datasourceChanged == 2 && + getUser == 2 && + createInvitation == 1 && + listRoleBindings == 3 && + createRoleBinding == 1 + }, timeout, interval).Should(gomega.BeTrue()) + + resetMock(&webhookMock.Mock) + resetMock(&styraClientMock.Mock) + + styraClientMock.AssertExpectations(ginkgo.GinkgoT()) + + }) +}) diff --git a/test/integration/controller/system_controller_test.go b/test/integration/controller/system_controller_test.go index 0734a1a..1fbe24b 100644 --- a/test/integration/controller/system_controller_test.go +++ b/test/integration/controller/system_controller_test.go @@ -1,3 +1,19 @@ +/* +Copyright (C) 2023 Bankdata (bankdata@bankdata.dk) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package styra import ( @@ -20,7 +36,6 @@ import ( ) var _ = ginkgo.Describe("SystemReconciler.Reconcile", ginkgo.Label("integration"), func() { - ginkgo.It("should reconcile", func() { spec := styrav1beta1.SystemSpec{ DeletionProtection: ptr.Bool(false), @@ -838,6 +853,7 @@ discovery: }, timeout, interval).Should(gomega.BeTrue()) resetMock(&styraClientMock.Mock) + resetMock(&webhookMock.Mock) ginkgo.By("Setting a datasource") @@ -1193,6 +1209,7 @@ discovery: return k8serrors.IsNotFound(err) }, timeout, interval).Should(gomega.BeTrue()) + resetMock(&styraClientMock.Mock) styraClientMock.AssertExpectations(ginkgo.GinkgoT()) }) })