From 7eb40fdb56c36df216d9f936a4cbffc8a6c8897f Mon Sep 17 00:00:00 2001 From: Simon Murray Date: Mon, 12 Aug 2024 08:52:51 +0100 Subject: [PATCH] Add a Client Trust Anchor We're using RFC8705 to issue oauth2 access tokens to services (e.g. kubernetes) so they can talk to other APIs (e.g. region). This requires the use of mTLS, and for that we need a trust anchor to pass to the TLS termination. On top of all that we need to rationalize client TLS handling, remove all the identity specific bits from core - it's just a pain in arse, allow reconcilers to register and use CLI flags. --- charts/core/Chart.yaml | 4 +- charts/core/templates/client-certificate.yaml | 22 +++ .../core/templates/client-clusterissuer.yaml | 11 ++ charts/core/values.yaml | 15 +- pkg/authorization/accesstoken/context.go | 35 ---- pkg/authorization/userinfo/context.go | 35 ---- pkg/authorization/userinfo/types.go | 23 --- pkg/client/http.go | 163 +++++++++++++++++ pkg/constants/constants.go | 4 - pkg/errors/errors.go | 6 + pkg/manager/manager.go | 28 ++- pkg/manager/reconcile.go | 10 +- pkg/manager/reconcile_test.go | 18 +- pkg/provisioners/util/util.go | 26 +++ pkg/server/conversion/conversion.go | 73 +++----- pkg/server/middleware/audit/logging.go | 164 ------------------ pkg/server/middleware/audit/types.go | 39 ----- 17 files changed, 304 insertions(+), 372 deletions(-) create mode 100644 charts/core/templates/client-certificate.yaml create mode 100644 charts/core/templates/client-clusterissuer.yaml delete mode 100644 pkg/authorization/accesstoken/context.go delete mode 100644 pkg/authorization/userinfo/context.go delete mode 100644 pkg/authorization/userinfo/types.go create mode 100644 pkg/client/http.go delete mode 100644 pkg/server/middleware/audit/logging.go delete mode 100644 pkg/server/middleware/audit/types.go diff --git a/charts/core/Chart.yaml b/charts/core/Chart.yaml index e760164..31a87d6 100644 --- a/charts/core/Chart.yaml +++ b/charts/core/Chart.yaml @@ -4,8 +4,8 @@ description: A Helm chart for deploying Unikorn Core type: application -version: v0.1.64 -appVersion: v0.1.64 +version: v0.1.65 +appVersion: v0.1.65 icon: https://assets.unikorn-cloud.org/images/logos/dark-on-light/icon.svg diff --git a/charts/core/templates/client-certificate.yaml b/charts/core/templates/client-certificate.yaml new file mode 100644 index 0000000..8472923 --- /dev/null +++ b/charts/core/templates/client-certificate.yaml @@ -0,0 +1,22 @@ +{{- if (and .Values.clientCA .Values.clientCA.enabled .Values.clientCA.generate) }} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: unikorn-client-ca + namespace: {{ .Values.certManager.namespace }} + labels: + {{- include "unikorn.labels" . | nindent 4 }} +spec: + issuerRef: + group: cert-manager.io + kind: Issuer + name: unikorn-self-signed-issuer + privateKey: + algorithm: RSA + encoding: PKCS8 + size: 4096 + secretName: unikorn-client-ca + isCA: true + commonName: Unikorn Client CA + duration: 87600h +{{- end }} diff --git a/charts/core/templates/client-clusterissuer.yaml b/charts/core/templates/client-clusterissuer.yaml new file mode 100644 index 0000000..7241650 --- /dev/null +++ b/charts/core/templates/client-clusterissuer.yaml @@ -0,0 +1,11 @@ +{{- if (and .Values.ca .Values.ca.enabled) }} +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: unikorn-client-issuer + labels: + {{- include "unikorn.labels" . | nindent 4 }} +spec: + ca: + secretName: unikorn-client-ca +{{- end }} diff --git a/charts/core/values.yaml b/charts/core/values.yaml index 1e60c8a..ee4b45c 100644 --- a/charts/core/values.yaml +++ b/charts/core/values.yaml @@ -19,5 +19,18 @@ ca: # If generate is false, then you must specify a certificate and key, which can be # sourced from mkcert which will automatically install it in the system trust store. # These must be base64 encoded strings. - # certificate: SSBhbSBjb21wbGV0ZSBub25zZW5zZS4gIFRoYW5rIHlvdSBmb3IgcmVhZGluZyB0aGlzLiAgR2V0IGEgbGlmZSE= + # certificate: SSBhbSBjb21wbGV0ZSBub25zZW5zZS4gIFRoYW5rIHlvdSBmb3IgcmVhZGluZyB0aGlzLiAgR2V0IGEgbGlmZSE= # privateKey: SSBhbSBjb21wbGV0ZSBub25zZW5zZS4gIFRoYW5rIHlvdSBmb3IgcmVhZGluZyB0aGlzLiAgR2V0IGEgbGlmZSE= + +# Unikorn uses mTLS for credentialless authentication between componets. This is +# only used in asynchronous controllers where a user access token is not availabile. +clientCA: + # Enable CA and issuer creation. + enabled: true + + # Generate a self signed CA. + # This is typically used at a single site to act as the trust root. + # You will need to (somehow) distribute this to other sites so that services + # can issue certificates as the CA is rotated. The other option is to just + # issue them here at the root and distribute then to the services themselves. + generate: true diff --git a/pkg/authorization/accesstoken/context.go b/pkg/authorization/accesstoken/context.go deleted file mode 100644 index 885a896..0000000 --- a/pkg/authorization/accesstoken/context.go +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2024 the Unikorn Authors. - -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 accesstoken - -import ( - "context" -) - -type keyType int - -//nolint:gochecknoglobals -var key keyType - -func NewContext(ctx context.Context, accessToken string) context.Context { - return context.WithValue(ctx, key, accessToken) -} - -func FromContext(ctx context.Context) string { - //nolint:forcetypeassert - return ctx.Value(key).(string) -} diff --git a/pkg/authorization/userinfo/context.go b/pkg/authorization/userinfo/context.go deleted file mode 100644 index bac2ed6..0000000 --- a/pkg/authorization/userinfo/context.go +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2024 the Unikorn Authors. - -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 userinfo - -import ( - "context" -) - -type keyType int - -//nolint:gochecknoglobals -var key keyType - -func NewContext(ctx context.Context, userinfo *UserInfo) context.Context { - return context.WithValue(ctx, key, userinfo) -} - -func FromContext(ctx context.Context) *UserInfo { - //nolint:forcetypeassert - return ctx.Value(key).(*UserInfo) -} diff --git a/pkg/authorization/userinfo/types.go b/pkg/authorization/userinfo/types.go deleted file mode 100644 index a22984d..0000000 --- a/pkg/authorization/userinfo/types.go +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 the Unikorn Authors. - -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 userinfo - -import ( - "github.com/go-jose/go-jose/v3/jwt" -) - -type UserInfo jwt.Claims diff --git a/pkg/client/http.go b/pkg/client/http.go new file mode 100644 index 0000000..5b45d63 --- /dev/null +++ b/pkg/client/http.go @@ -0,0 +1,163 @@ +/* +Copyright 2024 the Unikorn Authors. + +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 client + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + + "github.com/spf13/pflag" + + "github.com/unikorn-cloud/core/pkg/errors" + + corev1 "k8s.io/api/core/v1" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// HTTPOptions are generic options for HTTP clients. +type HTTPOptions struct { + // service determines the CLI flag prefix. + service string + // host is the identity Host name. + host string + // secretNamespace tells us where to source the CA secret. + secretNamespace string + // secretName is the root CA secret of the identity endpoint. + secretName string +} + +func NewHTTPOptions(service string) *HTTPOptions { + return &HTTPOptions{ + service: service, + } +} + +func (o *HTTPOptions) Host() string { + return o.host +} + +// AddFlags adds the options to the CLI flags. +func (o *HTTPOptions) AddFlags(f *pflag.FlagSet) { + f.StringVar(&o.host, o.service+"-host", "", "Identity endpoint URL.") + f.StringVar(&o.secretNamespace, o.service+"-ca-secret-namespace", "", "Identity endpoint CA certificate secret namespace.") + f.StringVar(&o.secretName, o.service+"-ca-secret-name", "", "Identity endpoint CA certificate secret.") +} + +// ApplyTLSConfig adds CA certificates to the TLS configuration if one is specified. +func (o *HTTPOptions) ApplyTLSConfig(ctx context.Context, cli client.Client, config *tls.Config) error { + if o.secretName == "" { + return nil + } + + secret := &corev1.Secret{} + + if err := cli.Get(ctx, client.ObjectKey{Namespace: o.secretNamespace, Name: o.secretName}, secret); err != nil { + return err + } + + if secret.Type != corev1.SecretTypeTLS { + return fmt.Errorf("%w: issuer CA not of type kubernetes.io/tls", errors.ErrSecretFormatError) + } + + cert, ok := secret.Data[corev1.TLSCertKey] + if !ok { + return fmt.Errorf("%w: issuer CA missing tls.crt", errors.ErrSecretFormatError) + } + + certPool := x509.NewCertPool() + + if ok := certPool.AppendCertsFromPEM(cert); !ok { + return fmt.Errorf("%w: failed to load identity CA certificate", errors.ErrSecretFormatError) + } + + config.RootCAs = certPool + + return nil +} + +// HTTPClientOptions allows generic options to be passed to all HTTP clients. +type HTTPClientOptions struct { + // secretNamespace tells us where to source the client certificate. + secretNamespace string + // secretName is the client certificate for the service. + secretName string +} + +// AddFlags adds the options to the CLI flags. +func (o *HTTPClientOptions) AddFlags(f *pflag.FlagSet) { + f.StringVar(&o.secretNamespace, "client-certificate-namespace", o.secretNamespace, "Client certificate secret namespace.") + f.StringVar(&o.secretName, "client-certificate-name", o.secretName, "Client certificate secret name.") +} + +// ApplyTLSClientConfig loads op a client certificate if one is configured and applies +// it to the provided TLS configuration. +func (o *HTTPClientOptions) ApplyTLSClientConfig(ctx context.Context, cli client.Client, config *tls.Config) error { + if o.secretNamespace == "" || o.secretName == "" { + return nil + } + + secret := &corev1.Secret{} + + if err := cli.Get(ctx, client.ObjectKey{Namespace: o.secretNamespace, Name: o.secretName}, secret); err != nil { + return err + } + + if secret.Type != corev1.SecretTypeTLS { + return fmt.Errorf("%w: certificate not of type kubernetes.io/tls", errors.ErrSecretFormatError) + } + + cert, ok := secret.Data[corev1.TLSCertKey] + if !ok { + return fmt.Errorf("%w: certificate missing tls.crt", errors.ErrSecretFormatError) + } + + key, ok := secret.Data[corev1.TLSPrivateKeyKey] + if !ok { + return fmt.Errorf("%w: certifcate missing tls.key", errors.ErrSecretFormatError) + } + + certificate, err := tls.X509KeyPair(cert, key) + if err != nil { + return err + } + + config.Certificates = []tls.Certificate{ + certificate, + } + + return nil +} + +// TLSClientConfig is a helper to create a TLS client configuration. +func TLSClientConfig(ctx context.Context, cli client.Client, options *HTTPOptions, clientOptions *HTTPClientOptions) (*tls.Config, error) { + config := &tls.Config{ + MinVersion: tls.VersionTLS13, + } + + if err := options.ApplyTLSConfig(ctx, cli, config); err != nil { + return nil, err + } + + if err := clientOptions.ApplyTLSClientConfig(ctx, cli, config); err != nil { + return nil, err + } + + return config, nil +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 0deac47..0432030 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -93,10 +93,6 @@ const ( // the region controller) that a resource owns. CloudIdentityAnnotation = "unikorn-cloud.org/cloud-identity-id" - // IdentityCleanupReadyEventReason is used to identift asynchronous clean up - // routines. - IdentityCleanupReadyEventReason = "IdentityCleanupReady" - // Finalizer is applied to resources that need to be deleted manually // and do other complex logic. Finalizer = "unikorn" diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 641dfd8..003b690 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -33,4 +33,10 @@ var ( // ErrKubeconfig is raised wne the Kubeconfig isn't correct. ErrKubeconfig = errors.New("kubeconfig error") + + // ErrSecretFormatError is returned when a secret doesn't meet the specification. + ErrSecretFormatError = errors.New("secret incorrectly formatted") + + // ErrAPIStatus is returned when an API status code is unexpected. + ErrAPIStatus = errors.New("api status code unexpected") ) diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 55a7be4..38ef107 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -39,14 +39,25 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" ) +// ControllerOptions abstracts controller specific flags. +type ControllerOptions interface { + // AddFlags adds a set of flags to the flagset. + AddFlags(f *pflag.FlagSet) +} + // ControllerFactory allows creation of a Unikorn controller with // minimal code. type ControllerFactory interface { // Metadata returns the application, version and revision. Metadata() (string, string, string) + // Options may be nil, otherwise it's a controller specific set of + // options that are added to the flagset on start up and passed to the + // reonciler. + Options() ControllerOptions + // Reconciler returns a new reconciler instance. - Reconciler(options *options.Options, manager manager.Manager) reconcile.Reconciler + Reconciler(options *options.Options, controllerOptions ControllerOptions, manager manager.Manager) reconcile.Reconciler // RegisterWatches adds any watches that would trigger a reconcile. RegisterWatches(manager manager.Manager, controller controller.Controller) error @@ -92,20 +103,20 @@ func getManager(f ControllerFactory) (manager.Manager, error) { } // getController returns a generic controller. -func getController(o *options.Options, manager manager.Manager, f ControllerFactory) (controller.Controller, error) { +func getController(o *options.Options, controllerOptions ControllerOptions, manager manager.Manager, f ControllerFactory) (controller.Controller, error) { // This prevents a single bad reconcile from affecting all the rest by // boning the whole container. recoverPanic := true - controllerOptions := controller.Options{ + options := controller.Options{ MaxConcurrentReconciles: o.MaxConcurrentReconciles, RecoverPanic: &recoverPanic, - Reconciler: f.Reconciler(o, manager), + Reconciler: f.Reconciler(o, controllerOptions, manager), } application, _, _ := f.Metadata() - c, err := controller.New(application, manager, controllerOptions) + c, err := controller.New(application, manager, options) if err != nil { return nil, err } @@ -136,6 +147,11 @@ func Run(f ControllerFactory) { o := &options.Options{} o.AddFlags(pflag.CommandLine) + controllerOptions := f.Options() + if controllerOptions != nil { + controllerOptions.AddFlags(pflag.CommandLine) + } + pflag.Parse() logr := zap.New(zap.UseFlagOptions(zapOptions)) @@ -159,7 +175,7 @@ func Run(f ControllerFactory) { os.Exit(1) } - controller, err := getController(o, manager, f) + controller, err := getController(o, controllerOptions, manager, f) if err != nil { logger.Error(err, "controller creation error") os.Exit(1) diff --git a/pkg/manager/reconcile.go b/pkg/manager/reconcile.go index cf054c1..08c6ab9 100644 --- a/pkg/manager/reconcile.go +++ b/pkg/manager/reconcile.go @@ -48,7 +48,7 @@ var ( ) // ProvisionerCreateFunc provides a type agnosic method to create a root provisioner. -type ProvisionerCreateFunc func() provisioners.ManagerProvisioner +type ProvisionerCreateFunc func(ControllerOptions) provisioners.ManagerProvisioner // Reconciler is a generic reconciler for all manager types. type Reconciler struct { @@ -60,14 +60,18 @@ type Reconciler struct { // createProvisioner provides a type agnosic method to create a root provisioner. createProvisioner ProvisionerCreateFunc + + // controllerOptions are options to be passed to the reconciler. + controllerOptions ControllerOptions } // NewReconciler creates a new reconciler. -func NewReconciler(options *options.Options, manager manager.Manager, createProvisioner ProvisionerCreateFunc) *Reconciler { +func NewReconciler(options *options.Options, controllerOptions ControllerOptions, manager manager.Manager, createProvisioner ProvisionerCreateFunc) *Reconciler { return &Reconciler{ options: options, manager: manager, createProvisioner: createProvisioner, + controllerOptions: controllerOptions, } } @@ -88,7 +92,7 @@ func (r *Reconciler) getDriver() (cd.Driver, error) { func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { log := log.FromContext(ctx) - provisioner := r.createProvisioner() + provisioner := r.createProvisioner(r.controllerOptions) object := provisioner.Object() diff --git a/pkg/manager/reconcile_test.go b/pkg/manager/reconcile_test.go index da4f080..a48a071 100644 --- a/pkg/manager/reconcile_test.go +++ b/pkg/manager/reconcile_test.go @@ -157,7 +157,7 @@ func TestReconcileDeleted(t *testing.T) { p := mockprovisioners.NewMockManagerProvisioner(c) p.EXPECT().Object().Return(&unikornv1fake.ManagedResource{}) - reconciler := manager.NewReconciler(managerOptions(), tc.newManager(c), func() provisioners.ManagerProvisioner { return p }) + reconciler := manager.NewReconciler(managerOptions(), nil, tc.newManager(c), func(_ manager.ControllerOptions) provisioners.ManagerProvisioner { return p }) _, err := reconciler.Reconcile(ctx, newRequest(testNamespace, testName)) assert.NoError(t, err) @@ -184,7 +184,7 @@ func TestReconcileCreate(t *testing.T) { p.EXPECT().Object().Return(&unikornv1fake.ManagedResource{}) p.EXPECT().Provision(gomock.Any()).Return(nil) - reconciler := manager.NewReconciler(managerOptions(), tc.newManager(c), func() provisioners.ManagerProvisioner { return p }) + reconciler := manager.NewReconciler(managerOptions(), nil, tc.newManager(c), func(_ manager.ControllerOptions) provisioners.ManagerProvisioner { return p }) _, err := reconciler.Reconcile(ctx, newRequest(testNamespace, testName)) assert.NoError(t, err) @@ -219,7 +219,7 @@ func TestReconcileCreateYield(t *testing.T) { p.EXPECT().Object().Return(&unikornv1fake.ManagedResource{}) p.EXPECT().Provision(gomock.Any()).Return(provisioners.ErrYield) - reconciler := manager.NewReconciler(managerOptions(), tc.newManager(c), func() provisioners.ManagerProvisioner { return p }) + reconciler := manager.NewReconciler(managerOptions(), nil, tc.newManager(c), func(_ manager.ControllerOptions) provisioners.ManagerProvisioner { return p }) _, err := reconciler.Reconcile(ctx, newRequest(testNamespace, testName)) assert.NoError(t, err) @@ -256,7 +256,7 @@ func TestReconcileCreateCancelled(t *testing.T) { p.EXPECT().Object().Return(&unikornv1fake.ManagedResource{}) p.EXPECT().Provision(gomock.Any()).Return(ctx.Err()) - reconciler := manager.NewReconciler(managerOptions(), tc.newManager(c), func() provisioners.ManagerProvisioner { return p }) + reconciler := manager.NewReconciler(managerOptions(), nil, tc.newManager(c), func(_ manager.ControllerOptions) provisioners.ManagerProvisioner { return p }) _, err := reconciler.Reconcile(ctx, newRequest(testNamespace, testName)) assert.NoError(t, err) @@ -291,7 +291,7 @@ func TestReconcileCreateError(t *testing.T) { p.EXPECT().Object().Return(&unikornv1fake.ManagedResource{}) p.EXPECT().Provision(gomock.Any()).Return(errUnhandled) - reconciler := manager.NewReconciler(managerOptions(), tc.newManager(c), func() provisioners.ManagerProvisioner { return p }) + reconciler := manager.NewReconciler(managerOptions(), nil, tc.newManager(c), func(_ manager.ControllerOptions) provisioners.ManagerProvisioner { return p }) _, err := reconciler.Reconcile(ctx, newRequest(testNamespace, testName)) assert.NoError(t, err) @@ -332,7 +332,7 @@ func TestReconcileDelete(t *testing.T) { p.EXPECT().Object().Return(&unikornv1fake.ManagedResource{}) p.EXPECT().Deprovision(gomock.Any()).Return(nil) - reconciler := manager.NewReconciler(managerOptions(), tc.newManager(c), func() provisioners.ManagerProvisioner { return p }) + reconciler := manager.NewReconciler(managerOptions(), nil, tc.newManager(c), func(_ manager.ControllerOptions) provisioners.ManagerProvisioner { return p }) _, err := reconciler.Reconcile(ctx, newRequest(testNamespace, testName)) assert.NoError(t, err) @@ -374,7 +374,7 @@ func TestReconcileDeleteYield(t *testing.T) { p.EXPECT().Object().Return(&unikornv1fake.ManagedResource{}) p.EXPECT().Deprovision(gomock.Any()).Return(provisioners.ErrYield) - reconciler := manager.NewReconciler(managerOptions(), tc.newManager(c), func() provisioners.ManagerProvisioner { return p }) + reconciler := manager.NewReconciler(managerOptions(), nil, tc.newManager(c), func(_ manager.ControllerOptions) provisioners.ManagerProvisioner { return p }) _, err := reconciler.Reconcile(ctx, newRequest(testNamespace, testName)) assert.NoError(t, err) @@ -417,7 +417,7 @@ func TestReconcileDeleteCancelled(t *testing.T) { p.EXPECT().Object().Return(&unikornv1fake.ManagedResource{}) p.EXPECT().Deprovision(gomock.Any()).Return(ctx.Err()) - reconciler := manager.NewReconciler(managerOptions(), tc.newManager(c), func() provisioners.ManagerProvisioner { return p }) + reconciler := manager.NewReconciler(managerOptions(), nil, tc.newManager(c), func(_ manager.ControllerOptions) provisioners.ManagerProvisioner { return p }) _, err := reconciler.Reconcile(ctx, newRequest(testNamespace, testName)) assert.NoError(t, err) @@ -458,7 +458,7 @@ func TestReconcileDeleteError(t *testing.T) { p.EXPECT().Object().Return(&unikornv1fake.ManagedResource{}) p.EXPECT().Deprovision(gomock.Any()).Return(errUnhandled) - reconciler := manager.NewReconciler(managerOptions(), tc.newManager(c), func() provisioners.ManagerProvisioner { return p }) + reconciler := manager.NewReconciler(managerOptions(), nil, tc.newManager(c), func(_ manager.ControllerOptions) provisioners.ManagerProvisioner { return p }) _, err := reconciler.Reconcile(ctx, newRequest(testNamespace, testName)) assert.NoError(t, err) diff --git a/pkg/provisioners/util/util.go b/pkg/provisioners/util/util.go index fe5b236..2654359 100644 --- a/pkg/provisioners/util/util.go +++ b/pkg/provisioners/util/util.go @@ -18,16 +18,42 @@ limitations under the License. package util import ( + "context" "crypto/sha256" "encoding/json" "errors" "fmt" + + clientlib "github.com/unikorn-cloud/core/pkg/client" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + + "sigs.k8s.io/controller-runtime/pkg/client" ) var ( ErrNamespaceLookup = errors.New("unable to lookup namespace") ) +func GetResourceNamespace(ctx context.Context, l labels.Set) (*corev1.Namespace, error) { + c, err := clientlib.ProvisionerClientFromContext(ctx) + if err != nil { + return nil, err + } + + namespaces := &corev1.NamespaceList{} + if err := c.List(ctx, namespaces, &client.ListOptions{LabelSelector: l.AsSelector()}); err != nil { + return nil, err + } + + if len(namespaces.Items) != 1 { + return nil, fmt.Errorf("%w: labels %v", ErrNamespaceLookup, l) + } + + return &namespaces.Items[0], nil +} + // GetConfigurationHash is used to restart badly behaved apps that don't respect configuration // changes. func GetConfigurationHash(config any) (string, error) { diff --git a/pkg/server/conversion/conversion.go b/pkg/server/conversion/conversion.go index c70afeb..6580e8c 100644 --- a/pkg/server/conversion/conversion.go +++ b/pkg/server/conversion/conversion.go @@ -17,14 +17,12 @@ limitations under the License. package conversion import ( - "context" "errors" "fmt" "time" "unicode" unikornv1 "github.com/unikorn-cloud/core/pkg/apis/unikorn/v1alpha1" - "github.com/unikorn-cloud/core/pkg/authorization/userinfo" "github.com/unikorn-cloud/core/pkg/constants" "github.com/unikorn-cloud/core/pkg/openapi" @@ -149,79 +147,52 @@ func generateResourceID() string { } // ObjectMetadata implements a builder pattern. -type ObjectMetadata struct { - metadata *openapi.ResourceWriteMetadata - namespace string - organizationID string - projectID string - labels map[string]string -} +type ObjectMetadata metav1.ObjectMeta // NewObjectMetadata requests the bare minimum to build an object metadata object. -func NewObjectMetadata(metadata *openapi.ResourceWriteMetadata, namespace string) *ObjectMetadata { - return &ObjectMetadata{ - metadata: metadata, - namespace: namespace, +func NewObjectMetadata(metadata *openapi.ResourceWriteMetadata, namespace, actor string) *ObjectMetadata { + o := &ObjectMetadata{ + Namespace: namespace, + Name: generateResourceID(), + Labels: map[string]string{ + constants.NameLabel: metadata.Name, + }, + Annotations: map[string]string{ + constants.CreatorAnnotation: actor, + }, + } + + if metadata.Description != nil { + o.Annotations[constants.DescriptionAnnotation] = *metadata.Description } + + return o } // WithOrganization adds an organization for scoped resources. func (o *ObjectMetadata) WithOrganization(id string) *ObjectMetadata { - o.organizationID = id + o.Labels[constants.OrganizationLabel] = id return o } // WithProject adds a project for scoped resources. func (o *ObjectMetadata) WithProject(id string) *ObjectMetadata { - o.projectID = id + o.Labels[constants.ProjectLabel] = id return o } // WithLabel allows non-generic labels to be attached to a resource. func (o *ObjectMetadata) WithLabel(key, value string) *ObjectMetadata { - if o.labels == nil { - o.labels = map[string]string{} - } - - o.labels[key] = value + o.Labels[key] = value return o } // Get renders the object metadata ready for inclusion into a Kubernetes resource. -func (o *ObjectMetadata) Get(ctx context.Context) metav1.ObjectMeta { - userinfo := userinfo.FromContext(ctx) - - out := metav1.ObjectMeta{ - Namespace: o.namespace, - Name: generateResourceID(), - Labels: map[string]string{ - constants.NameLabel: o.metadata.Name, - }, - Annotations: map[string]string{ - constants.CreatorAnnotation: userinfo.Subject, - }, - } - - if o.organizationID != "" { - out.Labels[constants.OrganizationLabel] = o.organizationID - } - - if o.projectID != "" { - out.Labels[constants.ProjectLabel] = o.projectID - } - - for k, v := range o.labels { - out.Labels[k] = v - } - - if o.metadata.Description != nil { - out.Annotations[constants.DescriptionAnnotation] = *o.metadata.Description - } - - return out +func (o *ObjectMetadata) Get() metav1.ObjectMeta { + return metav1.ObjectMeta(*o) } // UpdateObjectMetadata abstracts away metadata updates. diff --git a/pkg/server/middleware/audit/logging.go b/pkg/server/middleware/audit/logging.go deleted file mode 100644 index 98c1c98..0000000 --- a/pkg/server/middleware/audit/logging.go +++ /dev/null @@ -1,164 +0,0 @@ -/* -Copyright 2024 the Unikorn Authors. - -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 audit - -import ( - "encoding/json" - "net/http" - "regexp" - "strings" - - "github.com/getkin/kin-openapi/routers" - - "github.com/unikorn-cloud/core/pkg/authorization/userinfo" - "github.com/unikorn-cloud/core/pkg/openapi" - "github.com/unikorn-cloud/core/pkg/server/errors" - "github.com/unikorn-cloud/core/pkg/server/middleware" - - "sigs.k8s.io/controller-runtime/pkg/log" -) - -type Logger struct { - // next defines the next HTTP handler in the chain. - next http.Handler - - // openapi caches the Schema schema. - openapi *openapi.Schema - - // application is the application name. - application string - - // version is the application version. - version string -} - -// Ensure this implements the required interfaces. -var _ http.Handler = &Logger{} - -// New returns an initialized middleware. -func New(next http.Handler, openapi *openapi.Schema, application, version string) *Logger { - return &Logger{ - next: next, - openapi: openapi, - application: application, - version: version, - } -} - -// getResource will resolve to a resource type. -func getResource(w *middleware.LoggingResponseWriter, r *http.Request, route *routers.Route, params map[string]string) *Resource { - // Creates rely on the response containing the resource ID in the response metadata. - if r.Method == http.MethodPost { - // Nothing written, possibly a bug somewhere? - if w.Body() == nil { - return nil - } - - var metadata struct { - Metadata openapi.ResourceReadMetadata `json:"metadata"` - } - - // Not a canonical API resource, possibly a bug somewhere? - if err := json.Unmarshal(w.Body().Bytes(), &metadata); err != nil { - return nil - } - - segments := strings.Split(route.Path, "/") - - return &Resource{ - Type: segments[len(segments)-1], - ID: metadata.Metadata.Id, - } - } - - // Read, updates and deletes you can get the information from the route. - matches := regexp.MustCompile(`/([^/]+)/{([^/}]+)}$`).FindStringSubmatch(route.Path) - if matches == nil { - return nil - } - - return &Resource{ - Type: matches[1], - ID: params[matches[2]], - } -} - -// ServeHTTP implements the http.Handler interface. -func (l *Logger) ServeHTTP(w http.ResponseWriter, r *http.Request) { - route, params, err := l.openapi.FindRoute(r) - if err != nil { - errors.HandleError(w, r, errors.OAuth2ServerError("route lookup failure").WithError(err)) - - return - } - - writer := middleware.NewLoggingResponseWriter(w) - - l.next.ServeHTTP(writer, r) - - // Users and auditors care about things coming, going and changing, who did - // those things and when? Certainly not periodic polling that is par for the - // course. Failures of reads may be indicative of someone trying to do - // something they shouldn't via the API (or indeed a bug in a UI leeting them - // attempt something they are forbidden to do). - if r.Method == http.MethodGet { - return - } - - // If there is not accountibility e.g. a global call, it's not worth logging. - userinfo := userinfo.FromContext(r.Context()) - if userinfo == nil { - return - } - - // If there's no scope, then discard also. - if len(params) == 0 { - return - } - - // If you cannot derive the resource, then discard. - resource := getResource(writer, r, route, params) - if resource == nil { - return - } - - logParams := []any{ - "component", &Component{ - Name: l.application, - Version: l.version, - }, - "actor", &Actor{ - Subject: userinfo.Subject, - }, - "operation", &Operation{ - Verb: r.Method, - }, - "scope", params, - "resource", resource, - "result", &Result{ - Status: writer.StatusCode(), - }, - } - - log.FromContext(r.Context()).Info("audit", logParams...) -} - -func Middleware(openapi *openapi.Schema, application, version string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return New(next, openapi, application, version) - } -} diff --git a/pkg/server/middleware/audit/types.go b/pkg/server/middleware/audit/types.go deleted file mode 100644 index 1704a7e..0000000 --- a/pkg/server/middleware/audit/types.go +++ /dev/null @@ -1,39 +0,0 @@ -/* -Copyright 2024 the Unikorn Authors. - -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 audit - -type Component struct { - Name string `json:"name"` - Version string `json:"version"` -} - -type Actor struct { - Subject string `json:"subject"` -} - -type Resource struct { - Type string `json:"type"` - ID string `json:"id,omitempty"` -} - -type Operation struct { - Verb string `json:"verb"` -} - -type Result struct { - Status int `json:"status"` -}