diff --git a/NOTICE.txt b/NOTICE.txt index 476babfd21..9863ffe60c 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -570,6 +570,36 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +Module : github.com/gobuffalo/flect +Version : v0.1.5 +Time : 2019-06-13 21:51:46 +0000 UTC + +Contents of probable licence file $GOMODCACHE/github.com/gobuffalo/flect@v0.1.5/LICENSE: + +The MIT License (MIT) + +Copyright (c) 2019 Mark Bates + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + -------------------------------------------------------------------------------- Module : github.com/google/go-cmp Version : v0.3.1 diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 3f856dbc1f..b98c883a1f 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -48,6 +48,7 @@ import ( "github.com/elastic/cloud-on-k8s/pkg/dev/portforward" licensing "github.com/elastic/cloud-on-k8s/pkg/license" "github.com/elastic/cloud-on-k8s/pkg/utils/net" + "github.com/elastic/cloud-on-k8s/pkg/utils/rbac" "k8s.io/apimachinery/pkg/util/wait" ) @@ -63,6 +64,8 @@ const ( CertValidityFlag = "cert-validity" CertRotateBeforeFlag = "cert-rotate-before" + EnforceRbacOnRefs = "enforce-rbac-on-refs" + OperatorNamespaceFlag = "operator-namespace" ManageWebhookCertsFlag = "manage-webhook-certs" @@ -142,6 +145,11 @@ func init() { "", "K8s namespace the operator runs in", ) + Cmd.Flags().Bool( + EnforceRbacOnRefs, + false, // Set to false for backward compatibility + "Restrict cross-namespace resource association through RBAC (eg. referencing Elasticsearch from Kibana)", + ) Cmd.Flags().String( WebhookSecretFlag, "", @@ -302,6 +310,15 @@ func execute() { setupWebhook(mgr, params.CertRotation, clientset) } + enforceRbacOnRefs := viper.GetBool(EnforceRbacOnRefs) + + var accessReviewer rbac.AccessReviewer + if enforceRbacOnRefs { + accessReviewer = rbac.NewSubjectAccessReviewer(clientset) + } else { + accessReviewer = rbac.NewPermissiveAccessReviewer() + } + if operator.HasRole(operator.NamespaceOperator, roles) { if err = apmserver.Add(mgr, params); err != nil { log.Error(err, "unable to create controller", "controller", "ApmServer") @@ -315,11 +332,11 @@ func execute() { log.Error(err, "unable to create controller", "controller", "Kibana") os.Exit(1) } - if err = asesassn.Add(mgr, params); err != nil { + if err = asesassn.Add(mgr, accessReviewer, params); err != nil { log.Error(err, "unable to create controller", "controller", "ApmServerElasticsearchAssociation") os.Exit(1) } - if err = kbassn.Add(mgr, params); err != nil { + if err = kbassn.Add(mgr, accessReviewer, params); err != nil { log.Error(err, "unable to create controller", "controller", "KibanaAssociation") os.Exit(1) } diff --git a/config/crds/all-crds.yaml b/config/crds/all-crds.yaml index 92874cd83f..a19325ea73 100644 --- a/config/crds/all-crds.yaml +++ b/config/crds/all-crds.yaml @@ -364,6 +364,11 @@ spec: - secretName type: object type: array + serviceAccountName: + description: ServiceAccountName is used to check access from the current + resource to a resource (eg. Elasticsearch) in a different namespace. + Can only be used if ECK is enforcing RBAC on references. + type: string version: description: Version of the APM Server. type: string @@ -1075,6 +1080,11 @@ spec: - secretName type: object type: array + serviceAccountName: + description: ServiceAccountName is used to check access from the current + resource to a resource (eg. a remote Elasticsearch cluster) in a different + namespace. Can only be used if ECK is enforcing RBAC on references. + type: string updateStrategy: description: UpdateStrategy specifies how updates to the cluster should be performed. @@ -1505,6 +1515,11 @@ spec: - secretName type: object type: array + serviceAccountName: + description: ServiceAccountName is used to check access from the current + resource to a resource (eg. Elasticsearch) in a different namespace. + Can only be used if ECK is enforcing RBAC on references. + type: string version: description: Version of Kibana. type: string diff --git a/config/crds/bases/apm.k8s.elastic.co_apmservers.yaml b/config/crds/bases/apm.k8s.elastic.co_apmservers.yaml index af97e16e3a..66e812014e 100644 --- a/config/crds/bases/apm.k8s.elastic.co_apmservers.yaml +++ b/config/crds/bases/apm.k8s.elastic.co_apmservers.yaml @@ -6183,6 +6183,11 @@ spec: - secretName type: object type: array + serviceAccountName: + description: ServiceAccountName is used to check access from the current + resource to a resource (eg. Elasticsearch) in a different namespace. + Can only be used if ECK is enforcing RBAC on references. + type: string version: description: Version of the APM Server. type: string diff --git a/config/crds/bases/elasticsearch.k8s.elastic.co_elasticsearches.yaml b/config/crds/bases/elasticsearch.k8s.elastic.co_elasticsearches.yaml index 9f57850408..e4a30f0b62 100644 --- a/config/crds/bases/elasticsearch.k8s.elastic.co_elasticsearches.yaml +++ b/config/crds/bases/elasticsearch.k8s.elastic.co_elasticsearches.yaml @@ -6896,6 +6896,12 @@ spec: - secretName type: object type: array + serviceAccountName: + description: ServiceAccountName is used to check access from the current + resource to a resource (eg. a remote Elasticsearch cluster) in a + different namespace. Can only be used if ECK is enforcing RBAC on + references. + type: string updateStrategy: description: UpdateStrategy specifies how updates to the cluster should be performed. diff --git a/config/crds/bases/kibana.k8s.elastic.co_kibanas.yaml b/config/crds/bases/kibana.k8s.elastic.co_kibanas.yaml index cad1de4c06..66d3b10289 100644 --- a/config/crds/bases/kibana.k8s.elastic.co_kibanas.yaml +++ b/config/crds/bases/kibana.k8s.elastic.co_kibanas.yaml @@ -6181,6 +6181,11 @@ spec: - secretName type: object type: array + serviceAccountName: + description: ServiceAccountName is used to check access from the current + resource to a resource (eg. Elasticsearch) in a different namespace. + Can only be used if ECK is enforcing RBAC on references. + type: string version: description: Version of Kibana. type: string diff --git a/config/operator/all-in-one/cluster_role.template.yaml b/config/operator/all-in-one/cluster_role.template.yaml index b6fd325d9d..5673989c4a 100644 --- a/config/operator/all-in-one/cluster_role.template.yaml +++ b/config/operator/all-in-one/cluster_role.template.yaml @@ -6,6 +6,12 @@ kind: ClusterRole metadata: name: elastic-operator rules: +- apiGroups: + - "authorization.k8s.io" + resources: + - subjectaccessreviews + verbs: + - create - apiGroups: - "" resources: diff --git a/config/samples/associations-rbac/apm_es_kibana_rbac.yaml b/config/samples/associations-rbac/apm_es_kibana_rbac.yaml new file mode 100644 index 0000000000..28b68e09d7 --- /dev/null +++ b/config/samples/associations-rbac/apm_es_kibana_rbac.yaml @@ -0,0 +1,120 @@ +# This file contains an example of Roles, RoleBindings and ServiceAccount which allow the associations to be established +# between resources living in different namespaces if the access control between resources across namespaces is enabled. +# This example is only valid if ECK is started with the related option. +# See https://www.elastic.co/guide/en/cloud-on-k8s/master/k8s-operator-config.html. +--- +apiVersion: v1 +kind: Namespace +metadata: + name: kibana-ns +--- +apiVersion: v1 +kind: Namespace +metadata: + name: elasticsearch-ns +--- +apiVersion: v1 +kind: Namespace +metadata: + name: apmserver-ns +--- +# Create a Role at the cluster level to access some Elasticsearch clusters. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: elasticsearch-association +rules: + - apiGroups: + - elasticsearch.k8s.elastic.co + resources: + - elasticsearches + # It is also possible to do some fine grain filtering with some per cluster roles + # resourceNames: + # - elasticsearch-sample + # - an-other-elasticsearch-cluster + verbs: + - get # association is allowed if a resource can "get" the remote one +--- +# This is the service account used by Kibana +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kibana-user + namespace: kibana-ns +--- +# This RoleBinding gives the permission to Kibana to access the Elasticsearch cluster +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: allow-kibana-from-remote-namespace + namespace: elasticsearch-ns +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: elasticsearch-association +subjects: + - kind: ServiceAccount + name: kibana-user + namespace: kibana-ns +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: apmserver-user + namespace: apmserver-ns +--- +# This RoleBinding gives the permission to ApmServer to access the Elasticsearch cluster +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: allow-apmserver-from-remote-namespace + namespace: elasticsearch-ns +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: elasticsearch-association +subjects: + - kind: ServiceAccount + name: apmserver-user + namespace: apmserver-ns +--- +apiVersion: elasticsearch.k8s.elastic.co/v1 +kind: Elasticsearch +metadata: + name: elasticsearch-sample + namespace: elasticsearch-ns +spec: + version: 7.5.2 + nodeSets: + - name: default + count: 1 + config: + node.store.allow_mmap: false +--- +apiVersion: kibana.k8s.elastic.co/v1 +kind: Kibana +metadata: + name: kibana-sample + namespace: kibana-ns +spec: + version: 7.5.2 + count: 1 + elasticsearchRef: + name: "elasticsearch-sample" + namespace: "elasticsearch-ns" + # Service account used by Kibana to get access to the Elasticsearch cluster + serviceAccountName: kibana-user +--- +apiVersion: apm.k8s.elastic.co/v1 +kind: ApmServer +metadata: + name: apm-apm-sample + namespace: apmserver-ns +spec: + version: 7.5.2 + count: 1 + elasticsearchRef: + name: "elasticsearch-sample" + namespace: "elasticsearch-ns" + # Service account used by the APM Server to get access to the Elasticsearch cluster + serviceAccountName: apmserver-user diff --git a/docs/api-docs.asciidoc b/docs/api-docs.asciidoc index 4ed31a3312..077bbd7a41 100644 --- a/docs/api-docs.asciidoc +++ b/docs/api-docs.asciidoc @@ -70,6 +70,10 @@ PodTemplate provides customisation options (labels, annotations, affinity rules, *`secureSettings`* _xref:common-k8s-elastic-co-v1-secretsource[$$[]SecretSource$$]_:: SecureSettings is a list of references to Kubernetes secrets containing sensitive configuration options for APM Server. See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-apm-server.html#k8s-apm-secure-settings +*`serviceAccountName`* _string_:: +_(Optional)_ +ServiceAccountName is used to check access from the current resource to a resource (eg. Elasticsearch) in a different namespace. +Can only be used if ECK is enforcing RBAC on references. |=== [id="apm-k8s-elastic-co-v1-apmserverspec"] @@ -120,6 +124,12 @@ _xref:common-k8s-elastic-co-v1-secretsource[$$[]SecretSource$$]_ | SecureSettings is a list of references to Kubernetes secrets containing sensitive configuration options for APM Server. See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-apm-server.html#k8s-apm-secure-settings +| *`serviceAccountName`* + +_string_ +| +_(Optional)_ +ServiceAccountName is used to check access from the current resource to a resource (eg. Elasticsearch) in a different namespace. +Can only be used if ECK is enforcing RBAC on references. |=== [id="{p}-apm-k8s-elastic-co-v1beta1"] === apm.k8s.elastic.co/v1beta1 @@ -1119,6 +1129,10 @@ to the empty value (`{}` in YAML). *`secureSettings`* _xref:common-k8s-elastic-co-v1-secretsource[$$[]SecretSource$$]_:: SecureSettings is a list of references to Kubernetes secrets containing sensitive configuration options for Elasticsearch. See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-es-secure-settings.html +*`serviceAccountName`* _string_:: +_(Optional)_ +ServiceAccountName is used to check access from the current resource to a resource (eg. a remote Elasticsearch cluster) in a different namespace. +Can only be used if ECK is enforcing RBAC on references. |=== [id="elasticsearch-k8s-elastic-co-v1-changebudget"] @@ -1197,6 +1211,12 @@ _xref:common-k8s-elastic-co-v1-secretsource[$$[]SecretSource$$]_ | SecureSettings is a list of references to Kubernetes secrets containing sensitive configuration options for Elasticsearch. See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-es-secure-settings.html +| *`serviceAccountName`* + +_string_ +| +_(Optional)_ +ServiceAccountName is used to check access from the current resource to a resource (eg. a remote Elasticsearch cluster) in a different namespace. +Can only be used if ECK is enforcing RBAC on references. |=== [id="elasticsearch-k8s-elastic-co-v1-nodeset"] @@ -1510,6 +1530,10 @@ PodTemplate provides customisation options (labels, annotations, affinity rules, *`secureSettings`* _xref:common-k8s-elastic-co-v1-secretsource[$$[]SecretSource$$]_:: SecureSettings is a list of references to Kubernetes secrets containing sensitive configuration options for Kibana. See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-kibana.html#k8s-kibana-secure-settings +*`serviceAccountName`* _string_:: +_(Optional)_ +ServiceAccountName is used to check access from the current resource to a resource (eg. Elasticsearch) in a different namespace. +Can only be used if ECK is enforcing RBAC on references. |=== [id="kibana-k8s-elastic-co-v1-kibanaspec"] @@ -1560,6 +1584,12 @@ _xref:common-k8s-elastic-co-v1-secretsource[$$[]SecretSource$$]_ | SecureSettings is a list of references to Kubernetes secrets containing sensitive configuration options for Kibana. See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-kibana.html#k8s-kibana-secure-settings +| *`serviceAccountName`* + +_string_ +| +_(Optional)_ +ServiceAccountName is used to check access from the current resource to a resource (eg. Elasticsearch) in a different namespace. +Can only be used if ECK is enforcing RBAC on references. |=== [id="{p}-kibana-k8s-elastic-co-v1beta1"] === kibana.k8s.elastic.co/v1beta1 diff --git a/go.mod b/go.mod index 17dd6d556e..25de7da73d 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/ghodss/yaml v1.0.0 github.com/go-logr/logr v0.1.0 github.com/go-test/deep v1.0.3 + github.com/gobuffalo/flect v0.1.5 github.com/gogo/protobuf v1.3.1 // indirect github.com/golang/groupcache v0.0.0-20191002201903-404acd9df4cc // indirect github.com/google/go-cmp v0.3.1 diff --git a/pkg/apis/apm/v1/apmserver_types.go b/pkg/apis/apm/v1/apmserver_types.go index 97f73da3cb..d3b75e59d8 100644 --- a/pkg/apis/apm/v1/apmserver_types.go +++ b/pkg/apis/apm/v1/apmserver_types.go @@ -40,6 +40,11 @@ type ApmServerSpec struct { // SecureSettings is a list of references to Kubernetes secrets containing sensitive configuration options for APM Server. // See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-apm-server.html#k8s-apm-secure-settings SecureSettings []commonv1.SecretSource `json:"secureSettings,omitempty"` + + // ServiceAccountName is used to check access from the current resource to a resource (eg. Elasticsearch) in a different namespace. + // Can only be used if ECK is enforcing RBAC on references. + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` } // ApmServerHealth expresses the status of the Apm Server instances. @@ -118,6 +123,10 @@ func (as *ApmServer) AssociationConf() *commonv1.AssociationConf { return as.assocConf } +func (as *ApmServer) ServiceAccountName() string { + return as.Spec.ServiceAccountName +} + func (as *ApmServer) SetAssociationConf(assocConf *commonv1.AssociationConf) { as.assocConf = assocConf } diff --git a/pkg/apis/common/v1/association.go b/pkg/apis/common/v1/association.go index e0c05956d7..c6a3fc26f1 100644 --- a/pkg/apis/common/v1/association.go +++ b/pkg/apis/common/v1/association.go @@ -29,6 +29,7 @@ type Associated interface { runtime.Object ElasticsearchRef() ObjectSelector AssociationConf() *AssociationConf + ServiceAccountName() string } // Associator describes an object that allows its association to be set. diff --git a/pkg/apis/elasticsearch/v1/elasticsearch_types.go b/pkg/apis/elasticsearch/v1/elasticsearch_types.go index 73587f98a4..5cb8462e45 100644 --- a/pkg/apis/elasticsearch/v1/elasticsearch_types.go +++ b/pkg/apis/elasticsearch/v1/elasticsearch_types.go @@ -45,6 +45,11 @@ type ElasticsearchSpec struct { // See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-es-secure-settings.html // +kubebuilder:validation:Optional SecureSettings []commonv1.SecretSource `json:"secureSettings,omitempty"` + + // ServiceAccountName is used to check access from the current resource to a resource (eg. a remote Elasticsearch cluster) in a different namespace. + // Can only be used if ECK is enforcing RBAC on references. + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` } // NodeCount returns the total number of nodes of the Elasticsearch cluster diff --git a/pkg/apis/kibana/v1/kibana_types.go b/pkg/apis/kibana/v1/kibana_types.go index daff1cda5d..8125ba71eb 100644 --- a/pkg/apis/kibana/v1/kibana_types.go +++ b/pkg/apis/kibana/v1/kibana_types.go @@ -40,6 +40,11 @@ type KibanaSpec struct { // SecureSettings is a list of references to Kubernetes secrets containing sensitive configuration options for Kibana. // See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-kibana.html#k8s-kibana-secure-settings SecureSettings []commonv1.SecretSource `json:"secureSettings,omitempty"` + + // ServiceAccountName is used to check access from the current resource to a resource (eg. Elasticsearch) in a different namespace. + // Can only be used if ECK is enforcing RBAC on references. + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` } // KibanaHealth expresses the status of the Kibana instances. @@ -77,6 +82,10 @@ func (k *Kibana) SecureSettings() []commonv1.SecretSource { return k.Spec.SecureSettings } +func (k *Kibana) ServiceAccountName() string { + return k.Spec.ServiceAccountName +} + func (k *Kibana) AssociationConf() *commonv1.AssociationConf { return k.assocConf } diff --git a/pkg/controller/apmserverelasticsearchassociation/apmserverelasticsearchassociation_controller.go b/pkg/controller/apmserverelasticsearchassociation/apmserverelasticsearchassociation_controller.go index ae01b52d37..4ae03bfb81 100644 --- a/pkg/controller/apmserverelasticsearchassociation/apmserverelasticsearchassociation_controller.go +++ b/pkg/controller/apmserverelasticsearchassociation/apmserverelasticsearchassociation_controller.go @@ -8,6 +8,8 @@ import ( "reflect" "time" + "github.com/elastic/cloud-on-k8s/pkg/controller/common/reconciler" + "github.com/elastic/cloud-on-k8s/pkg/utils/rbac" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -52,8 +54,8 @@ var ( // Add creates a new ApmServerElasticsearchAssociation Controller and adds it to the Manager with default RBAC. The Manager will set fields on the Controller // and Start it when the Manager is Started. -func Add(mgr manager.Manager, params operator.Parameters) error { - r := newReconciler(mgr, params) +func Add(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { + r := newReconciler(mgr, accessReviewer, params) c, err := add(mgr, r) if err != nil { return err @@ -62,14 +64,15 @@ func Add(mgr manager.Manager, params operator.Parameters) error { } // newReconciler returns a new reconcile.Reconciler -func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileApmServerElasticsearchAssociation { +func newReconciler(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) *ReconcileApmServerElasticsearchAssociation { client := k8s.WrapClient(mgr.GetClient()) return &ReconcileApmServerElasticsearchAssociation{ - Client: client, - scheme: mgr.GetScheme(), - watches: watches.NewDynamicWatches(), - recorder: mgr.GetEventRecorderFor(name), - Parameters: params, + Client: client, + accessReviewer: accessReviewer, + scheme: mgr.GetScheme(), + watches: watches.NewDynamicWatches(), + recorder: mgr.GetEventRecorderFor(name), + Parameters: params, } } @@ -115,9 +118,10 @@ var _ reconcile.Reconciler = &ReconcileApmServerElasticsearchAssociation{} // ReconcileApmServerElasticsearchAssociation reconciles a ApmServerElasticsearchAssociation object type ReconcileApmServerElasticsearchAssociation struct { k8s.Client - scheme *runtime.Scheme - recorder record.EventRecorder - watches watches.DynamicWatches + accessReviewer rbac.AccessReviewer + scheme *runtime.Scheme + recorder record.EventRecorder + watches watches.DynamicWatches operator.Parameters // iteration is the number of times this controller has run its Reconcile method iteration uint64 @@ -167,7 +171,11 @@ func (r *ReconcileApmServerElasticsearchAssociation) Reconcile(request reconcile return reconcile.Result{}, err } + results := reconciler.Results{} newStatus, err := r.reconcileInternal(&apmServer) + if err != nil { + results.WithError(err) + } oldStatus := apmServer.Status.Association if !reflect.DeepEqual(oldStatus, newStatus) { apmServer.Status.Association = newStatus @@ -181,7 +189,11 @@ func (r *ReconcileApmServerElasticsearchAssociation) Reconcile(request reconcile "Association status changed from [%s] to [%s]", oldStatus, newStatus) } - return resultFromStatus(newStatus), err + + return results. + WithResult(association.RequeueRbacCheck(r.accessReviewer)). + WithResult(resultFromStatus(newStatus)). + Aggregate() } func elasticsearchWatchName(assocKey types.NamespacedName) string { @@ -252,6 +264,17 @@ func (r *ReconcileApmServerElasticsearchAssociation) reconcileInternal(apmServer return commonv1.AssociationFailed, err } + // Check if reference to Elasticsearch is allowed to be established + if allowed, err := association.CheckAndUnbind( + r.accessReviewer, + apmServer, + &es, + r, + r.recorder, + ); err != nil || !allowed { + return commonv1.AssociationPending, err + } + if err := association.ReconcileEsUser( r.Client, r.scheme, @@ -301,6 +324,17 @@ func (r *ReconcileApmServerElasticsearchAssociation) reconcileInternal(apmServer return commonv1.AssociationEstablished, nil } +// Unbind removes the association resources +func (r *ReconcileApmServerElasticsearchAssociation) Unbind(apm commonv1.Associated) error { + apmKey := k8s.ExtractNamespacedName(apm) + // Ensure that user in Elasticsearch is deleted to prevent illegitimate access + if err := user.DeleteUser(r.Client, NewUserLabelSelector(apmKey)); err != nil { + return err + } + // Also remove the association configuration + return association.RemoveAssociationConf(r.Client, apm) +} + func (r *ReconcileApmServerElasticsearchAssociation) reconcileElasticsearchCA(apm *apmv1.ApmServer, es types.NamespacedName) (association.CASecret, error) { apmKey := k8s.ExtractNamespacedName(apm) // watch ES CA secret to reconcile on any change diff --git a/pkg/controller/common/association/rbac.go b/pkg/controller/common/association/rbac.go new file mode 100644 index 0000000000..3e17244056 --- /dev/null +++ b/pkg/controller/common/association/rbac.go @@ -0,0 +1,73 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package association + +import ( + "time" + + commonv1 "github.com/elastic/cloud-on-k8s/pkg/apis/common/v1" + "github.com/elastic/cloud-on-k8s/pkg/controller/common/events" + "github.com/elastic/cloud-on-k8s/pkg/utils/rbac" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +type Unbinder interface { + Unbind(associated commonv1.Associated) error +} + +// CheckAndUnbind checks if a reference is allowed and unbinds the association if it is not the case +func CheckAndUnbind( + accessReviewer rbac.AccessReviewer, + associated commonv1.Associated, + referencedObject runtime.Object, + unbinder Unbinder, + eventRecorder record.EventRecorder, +) (bool, error) { + allowed, err := accessReviewer.AccessAllowed(associated.ServiceAccountName(), associated.GetNamespace(), referencedObject) + if err != nil { + return false, err + } + if !allowed { + metaObject, err := meta.Accessor(referencedObject) + if err != nil { + return false, nil + } + log.Info("Association not allowed", + "associated_kind", associated.GetObjectKind().GroupVersionKind().Kind, + "associated_name", associated.GetName(), + "associated_namespace", associated.GetNamespace(), + "service_account", associated.ServiceAccountName(), + "remote_type", referencedObject.GetObjectKind().GroupVersionKind().Kind, + "remote_namespace", metaObject.GetNamespace(), + "remote_name", metaObject.GetName(), + ) + eventRecorder.Eventf( + associated, + corev1.EventTypeWarning, + events.EventAssociationError, + "Association not allowed: %s/%s to %s/%s", + associated.GetNamespace(), associated.GetName(), metaObject.GetNamespace(), metaObject.GetName(), + ) + return false, unbinder.Unbind(associated) + } + return true, nil +} + +// RequeueRbacCheck returns a reconcile result depending on the implementation of the AccessReviewer. +// It is mostly used when using the subjectAccessReviewer implementation in which case a next reconcile loop should be +// triggered later to keep the association in sync with the RBAC roles and bindings. +// See https://github.com/elastic/cloud-on-k8s/issues/2468#issuecomment-579157063 +func RequeueRbacCheck(accessReviewer rbac.AccessReviewer) reconcile.Result { + switch accessReviewer.(type) { + case *rbac.SubjectAccessReviewer: + return reconcile.Result{RequeueAfter: 15 * time.Minute} + default: + return reconcile.Result{} + } +} diff --git a/pkg/controller/common/association/rbac_test.go b/pkg/controller/common/association/rbac_test.go new file mode 100644 index 0000000000..24a1c7b6a0 --- /dev/null +++ b/pkg/controller/common/association/rbac_test.go @@ -0,0 +1,157 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package association + +import ( + "reflect" + "testing" + + apmv1 "github.com/elastic/cloud-on-k8s/pkg/apis/apm/v1" + commonv1 "github.com/elastic/cloud-on-k8s/pkg/apis/common/v1" + esv1 "github.com/elastic/cloud-on-k8s/pkg/apis/elasticsearch/v1" + "github.com/elastic/cloud-on-k8s/pkg/utils/rbac" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/tools/record" +) + +type fakeAccessReviewer struct { + allowed bool + err error +} + +func (f *fakeAccessReviewer) AccessAllowed(_ string, _ string, _ runtime.Object) (bool, error) { + return f.allowed, f.err +} + +type fakeUnbinder struct { + called bool +} + +func (f *fakeUnbinder) Unbind(associated commonv1.Associated) error { + f.called = true + return nil +} + +var ( + fetchEvent = func(recorder *record.FakeRecorder) string { + select { + case event := <-recorder.Events: + return event + default: + return "" + } + } +) + +func TestCheckAndUnbind(t *testing.T) { + apmServer := &apmv1.ApmServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "apm-server-sample", + Namespace: "apmserver-ns", + }, + Spec: apmv1.ApmServerSpec{}, + } + es := &esv1.Elasticsearch{ + ObjectMeta: metav1.ObjectMeta{ + Name: "es-sample", + Namespace: "es-ns", + }, + } + + type args struct { + accessReviewer rbac.AccessReviewer + associated commonv1.Associated + object runtime.Object + unbinder fakeUnbinder + recorder *record.FakeRecorder + } + tests := []struct { + name string + args args + want, wantErr, wantEvent, wantFakeUnbinderCalled bool + }{ + { + name: "Association not allowed, ensure unbinder is called", + args: args{ + associated: apmServer, + object: es, + accessReviewer: &fakeAccessReviewer{ + allowed: false, + }, + unbinder: fakeUnbinder{}, + recorder: record.NewFakeRecorder(10), + }, + wantFakeUnbinderCalled: true, + wantEvent: true, + want: false, + }, + { + name: "Association allowed, ensure unbinder is not called", + args: args{ + associated: apmServer, + object: es, + accessReviewer: &fakeAccessReviewer{ + allowed: true, + }, + unbinder: fakeUnbinder{}, + recorder: record.NewFakeRecorder(10), + }, + wantFakeUnbinderCalled: false, + wantEvent: false, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CheckAndUnbind(tt.args.accessReviewer, tt.args.associated, tt.args.object, &tt.args.unbinder, tt.args.recorder) + if (err != nil) != tt.wantErr { + t.Errorf("CheckAndUnbind() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("CheckAndUnbind() = %v, want %v", got, tt.want) + } + if tt.args.unbinder.called != tt.wantFakeUnbinderCalled { + t.Errorf("fakeUnbinder.called = %v, want %v", tt.args.unbinder.called, tt.wantFakeUnbinderCalled) + } + event := fetchEvent(tt.args.recorder) + if len(event) > 0 != tt.wantEvent { + t.Errorf("emitted event = %v, want %v", len(event) > 0, tt.wantEvent) + } + + }) + } +} + +func TestNextReconciliation(t *testing.T) { + type args struct { + accessReviewer rbac.AccessReviewer + } + tests := []struct { + name string + args args + wantNonZeroDuration bool + }{ + { + name: "Schedule a requeue if there's some access control", + args: args{accessReviewer: rbac.NewSubjectAccessReviewer(fake.NewSimpleClientset())}, + wantNonZeroDuration: true, + }, + { + name: "No requeue if there is no access control", + args: args{accessReviewer: rbac.NewPermissiveAccessReviewer()}, + wantNonZeroDuration: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := RequeueRbacCheck(tt.args.accessReviewer); !reflect.DeepEqual(got.RequeueAfter > 0, tt.wantNonZeroDuration) { + t.Errorf("NextReconciliation() = %v, wantNonZeroDuration: %v", got, tt.wantNonZeroDuration) + } + }) + } +} diff --git a/pkg/controller/kibanaassociation/association_controller.go b/pkg/controller/kibanaassociation/association_controller.go index ffd0e711f6..75a258fb22 100644 --- a/pkg/controller/kibanaassociation/association_controller.go +++ b/pkg/controller/kibanaassociation/association_controller.go @@ -8,6 +8,8 @@ import ( "reflect" "time" + "github.com/elastic/cloud-on-k8s/pkg/controller/common/reconciler" + "github.com/elastic/cloud-on-k8s/pkg/utils/rbac" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -71,8 +73,8 @@ var ( // Add creates a new Association Controller and adds it to the Manager with default RBAC. The Manager will set fields on the Controller // and Start it when the Manager is Started. -func Add(mgr manager.Manager, params operator.Parameters) error { - r := newReconciler(mgr, params) +func Add(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { + r := newReconciler(mgr, accessReviewer, params) c, err := add(mgr, r) if err != nil { return err @@ -81,14 +83,15 @@ func Add(mgr manager.Manager, params operator.Parameters) error { } // newReconciler returns a new reconcile.Reconciler -func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileAssociation { +func newReconciler(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) *ReconcileAssociation { client := k8s.WrapClient(mgr.GetClient()) return &ReconcileAssociation{ - Client: client, - scheme: mgr.GetScheme(), - watches: watches.NewDynamicWatches(), - recorder: mgr.GetEventRecorderFor(name), - Parameters: params, + Client: client, + accessReviewer: accessReviewer, + scheme: mgr.GetScheme(), + watches: watches.NewDynamicWatches(), + recorder: mgr.GetEventRecorderFor(name), + Parameters: params, } } @@ -107,9 +110,10 @@ var _ reconcile.Reconciler = &ReconcileAssociation{} // ReconcileAssociation reconciles a Kibana resource for association with Elasticsearch type ReconcileAssociation struct { k8s.Client - scheme *runtime.Scheme - recorder record.EventRecorder - watches watches.DynamicWatches + accessReviewer rbac.AccessReviewer + scheme *runtime.Scheme + recorder record.EventRecorder + watches watches.DynamicWatches operator.Parameters // iteration is the number of times this controller has run its Reconcile method iteration uint64 @@ -156,8 +160,10 @@ func (r *ReconcileAssociation) Reconcile(request reconcile.Request) (reconcile.R return reconcile.Result{}, err } + results := reconciler.Results{} newStatus, err := r.reconcileInternal(&kibana) if err != nil { + results.WithError(err) k8s.EmitErrorEvent(r.recorder, err, &kibana, events.EventReconciliationError, "Reconciliation error: %v", err) } @@ -180,7 +186,11 @@ func (r *ReconcileAssociation) Reconcile(request reconcile.Request) (reconcile.R events.EventAssociationStatusChange, "Association status changed from [%s] to [%s]", oldStatus, newStatus) } - return resultFromStatus(newStatus), err + + return results. + WithResult(association.RequeueRbacCheck(r.accessReviewer)). + WithResult(resultFromStatus(newStatus)). + Aggregate() } func resultFromStatus(status commonv1.AssociationStatus) reconcile.Result { @@ -264,6 +274,17 @@ func (r *ReconcileAssociation) reconcileInternal(kibana *kbv1.Kibana) (commonv1. return commonv1.AssociationFailed, err } + // Check if reference to Elasticsearch is allowed to be established + if allowed, err := association.CheckAndUnbind( + r.accessReviewer, + kibana, + &es, + r, + r.recorder, + ); err != nil || !allowed { + return commonv1.AssociationPending, err + } + if err := association.ReconcileEsUser( r.Client, r.scheme, @@ -309,6 +330,17 @@ func (r *ReconcileAssociation) reconcileInternal(kibana *kbv1.Kibana) (commonv1. return commonv1.AssociationEstablished, nil } +// Unbind removes the association resources +func (r *ReconcileAssociation) Unbind(kibana commonv1.Associated) error { + kibanaKey := k8s.ExtractNamespacedName(kibana) + // Ensure that user in Elasticsearch is deleted to prevent illegitimate access + if err := user.DeleteUser(r.Client, NewUserLabelSelector(kibanaKey)); err != nil { + return err + } + // Also remove the association configuration + return association.RemoveAssociationConf(r.Client, kibana) +} + func (r *ReconcileAssociation) reconcileElasticsearchCA(kibana *kbv1.Kibana, es types.NamespacedName) (association.CASecret, error) { kibanaKey := k8s.ExtractNamespacedName(kibana) // watch ES CA secret to reconcile on any change diff --git a/pkg/utils/rbac/access_review.go b/pkg/utils/rbac/access_review.go new file mode 100644 index 0000000000..fc765bb901 --- /dev/null +++ b/pkg/utils/rbac/access_review.go @@ -0,0 +1,117 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package rbac + +import ( + "strings" + + "github.com/gobuffalo/flect" + authorizationapi "k8s.io/api/authorization/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/client-go/kubernetes" + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + ServiceAccountUsernamePrefix = "system:serviceaccount:" +) + +var log = logf.Log.WithName("access-review") + +type AccessReviewer interface { + // AccessAllowed checks that the given ServiceAccount is allowed to get an other object. + AccessAllowed(serviceAccount string, sourceNamespace string, object runtime.Object) (bool, error) +} + +type SubjectAccessReviewer struct { + client kubernetes.Interface +} + +var _ AccessReviewer = &SubjectAccessReviewer{} + +func NewSubjectAccessReviewer(client kubernetes.Interface) AccessReviewer { + return &SubjectAccessReviewer{ + client: client, + } +} + +func NewPermissiveAccessReviewer() AccessReviewer { + return &permissiveAccessReviewer{} +} + +func (s *SubjectAccessReviewer) AccessAllowed(serviceAccount string, sourceNamespace string, object runtime.Object) (bool, error) { + metaObject, err := meta.Accessor(object) + if err != nil { + return false, nil + } + // For convenience we still allow association between objects in a same namespace + if sourceNamespace == metaObject.GetNamespace() { + return true, nil + } + + if len(serviceAccount) == 0 { + serviceAccount = "default" + } + + allErrs := field.ErrorList{} + // This validation could be done in other places but it is important to be sure that it is done before any access review. + for _, msg := range validation.IsDNS1123Subdomain(serviceAccount) { + allErrs = append(allErrs, &field.Error{Type: field.ErrorTypeInvalid, Field: "serviceAccount", BadValue: serviceAccount, Detail: msg}) + } + if len(allErrs) > 0 { + return false, allErrs.ToAggregate() + } + + sar := newSubjectAccessReview(metaObject, object, serviceAccount, sourceNamespace) + + sar, err = s.client.AuthorizationV1().SubjectAccessReviews().Create(sar) + if err != nil { + return false, err + } + log.V(1).Info( + "Access review", "result", sar.Status, + "service_account", serviceAccount, + "source_namespace", sourceNamespace, + "remote_kind", object.GetObjectKind().GroupVersionKind().Kind, + "remote_namespace", metaObject.GetNamespace(), + "remote_name", metaObject.GetName(), + ) + if sar.Status.Denied { + return false, nil + } + return sar.Status.Allowed, nil +} + +func newSubjectAccessReview( + metaObject metav1.Object, + object runtime.Object, + serviceAccount, sourceNamespace string, +) *authorizationapi.SubjectAccessReview { + return &authorizationapi.SubjectAccessReview{ + Spec: authorizationapi.SubjectAccessReviewSpec{ + ResourceAttributes: &authorizationapi.ResourceAttributes{ + Namespace: metaObject.GetNamespace(), + Verb: "get", + Resource: strings.ToLower(flect.Pluralize(object.GetObjectKind().GroupVersionKind().Kind)), + Group: strings.ToLower(object.GetObjectKind().GroupVersionKind().Group), + Version: strings.ToLower(object.GetObjectKind().GroupVersionKind().Version), + Name: metaObject.GetName(), + }, + User: ServiceAccountUsernamePrefix + sourceNamespace + ":" + serviceAccount, + }, + } +} + +type permissiveAccessReviewer struct{} + +var _ AccessReviewer = &permissiveAccessReviewer{} + +func (s *permissiveAccessReviewer) AccessAllowed(_ string, _ string, _ runtime.Object) (bool, error) { + return true, nil +} diff --git a/pkg/utils/rbac/access_review_test.go b/pkg/utils/rbac/access_review_test.go new file mode 100644 index 0000000000..e8979b3b0a --- /dev/null +++ b/pkg/utils/rbac/access_review_test.go @@ -0,0 +1,273 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package rbac + +import ( + "fmt" + "reflect" + "testing" + + esv1 "github.com/elastic/cloud-on-k8s/pkg/apis/elasticsearch/v1" + authorizationapi "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" +) + +type fakeClientProvider func() kubernetes.Interface + +func Test_subjectAccessReviewer_AccessAllowed(t *testing.T) { + + es := &esv1.Elasticsearch{ + TypeMeta: metav1.TypeMeta{ + Kind: "Elasticsearch", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "es", + Namespace: "elasticsearch-ns", + }, + } + + type fields struct { + clientProvider fakeClientProvider + } + type args struct { + serviceAccount string + sourceNamespace string + object runtime.Object + } + tests := []struct { + name string + fields fields + args args + want bool + wantErr bool + }{ + { + name: "allowed", + args: args{ + sourceNamespace: "kibana-ns", + object: es, + }, + fields: fields{ + clientProvider: func() kubernetes.Interface { + fakeClient := fake.NewSimpleClientset() + fakeClient.PrependReactor( + "create", + "subjectaccessreviews", + func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + object := action.(k8stesting.CreateAction).GetObject().DeepCopyObject() + if t, ok := object.(*authorizationapi.SubjectAccessReview); ok { + t.Status.Allowed = true + t.Status.Denied = false + } + return true, object, nil + }, + ) + return fakeClient + }, + }, + want: true, + }, + { + name: "allowed if in the same namespace", + args: args{ + sourceNamespace: "kibana-ns", + object: &esv1.Elasticsearch{ + TypeMeta: metav1.TypeMeta{ + Kind: "Elasticsearch", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "es", + Namespace: "kibana-ns", + }, + }, + }, + fields: fields{ + clientProvider: func() kubernetes.Interface { + fakeClient := fake.NewSimpleClientset() + fakeClient.PrependReactor( + "create", + "subjectaccessreviews", + func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + object := action.(k8stesting.CreateAction).GetObject().DeepCopyObject() + if t, ok := object.(*authorizationapi.SubjectAccessReview); ok { + t.Status.Denied = true + t.Status.Allowed = true + } + return true, object, nil + }, + ) + return fakeClient + }, + }, + want: true, + }, + { + name: "not allowed", + args: args{ + sourceNamespace: "kibana-ns", + object: es, + }, + fields: fields{ + clientProvider: func() kubernetes.Interface { + fakeClient := fake.NewSimpleClientset() + fakeClient.PrependReactor( + "create", + "subjectaccessreviews", + func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + object := action.(k8stesting.CreateAction).GetObject().DeepCopyObject() + if t, ok := object.(*authorizationapi.SubjectAccessReview); ok { + t.Status.Allowed = false + t.Status.Denied = false + } + return true, object, nil + }, + ) + return fakeClient + }, + }, + want: false, + }, + { + name: "not allowed in case of an error", + args: args{ + sourceNamespace: "kibana-ns", + object: es, + }, + fields: fields{ + clientProvider: func() kubernetes.Interface { + fakeClient := fake.NewSimpleClientset() + fakeClient.PrependReactor( + "create", + "subjectaccessreviews", + func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + object := action.(k8stesting.CreateAction).GetObject().DeepCopyObject() + if t, ok := object.(*authorizationapi.SubjectAccessReview); ok { + t.Status.Allowed = false + t.Status.Denied = false + } + return true, object, fmt.Errorf("not allowed") + }, + ) + return fakeClient + }, + }, + want: false, + wantErr: true, + }, + { + name: "explicitly denied", + args: args{ + sourceNamespace: "kibana-ns", + object: es, + }, + fields: fields{ + clientProvider: func() kubernetes.Interface { + fakeClient := fake.NewSimpleClientset() + fakeClient.PrependReactor( + "create", + "subjectaccessreviews", + func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + object := action.(k8stesting.CreateAction).GetObject().DeepCopyObject() + if t, ok := object.(*authorizationapi.SubjectAccessReview); ok { + t.Status.Denied = true + t.Status.Allowed = true + } + return true, object, nil + }, + ) + return fakeClient + }, + }, + want: false, + }, + { + name: "badly formatted service account", + args: args{ + sourceNamespace: "kibana-ns", + object: es, + serviceAccount: "system:serviceaccount:foo:bar", + }, + fields: fields{ + clientProvider: func() kubernetes.Interface { + return fake.NewSimpleClientset() + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &SubjectAccessReviewer{ + client: tt.fields.clientProvider(), + } + got, err := s.AccessAllowed(tt.args.serviceAccount, tt.args.sourceNamespace, tt.args.object) + if (err != nil) != tt.wantErr { + t.Errorf("SubjectAccessReviewer.AccessAllowed() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SubjectAccessReviewer.AccessAllowed() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_newSubjectAccessReview(t *testing.T) { + es := &esv1.Elasticsearch{ + TypeMeta: metav1.TypeMeta{ + Kind: "Elasticsearch", + APIVersion: "elasticsearch.k8s.elastic.co/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "es-sample", + Namespace: "es-ns", + }, + } + type args struct { + metaObject metav1.Object + object runtime.Object + serviceAccount string + sourceNamespace string + } + tests := []struct { + name string + args args + want *authorizationapi.SubjectAccessReview + }{ + { + name: "Simple SubjectAccessReview generation", + args: args{ + object: es, + metaObject: es, + serviceAccount: "foo", + sourceNamespace: "apmserver-ns", + }, + want: &authorizationapi.SubjectAccessReview{ + Spec: authorizationapi.SubjectAccessReviewSpec{ + ResourceAttributes: &authorizationapi.ResourceAttributes{ + Namespace: "es-ns", + Verb: "get", + Resource: "elasticsearches", + Group: "elasticsearch.k8s.elastic.co", + Version: "v1", + Name: "es-sample", + }, + User: ServiceAccountUsernamePrefix + "apmserver-ns" + ":" + "foo", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := newSubjectAccessReview(tt.args.metaObject, tt.args.object, tt.args.serviceAccount, tt.args.sourceNamespace); !reflect.DeepEqual(got, tt.want) { + t.Errorf("newSubjectAccessReview() = %v, want %v", got, tt.want) + } + }) + } +}