From 18706edb4ea52e5c799df554e35681b4ebae0e09 Mon Sep 17 00:00:00 2001 From: Igor Karpukhin Date: Thu, 7 Sep 2023 16:27:18 +0200 Subject: [PATCH 1/3] Added Federated Authentication Configuration --- .github/workflows/test-e2e.yml | 2 +- .github/workflows/test-int.yml | 2 +- cmd/manager/main.go | 17 ++ ...atlas.mongodb.com_atlasfederatedauths.yaml | 172 ++++++++++++++++ config/crd/kustomization.yaml | 23 +-- .../cainjection_in_atlasfederatedauths.yaml | 8 + .../webhook_in_atlasfederatedauths.yaml | 17 ++ ...tlas-kubernetes.clusterserviceversion.yaml | 118 ++++++----- .../rbac/atlasfederatedauth_editor_role.yaml | 24 +++ .../rbac/atlasfederatedauth_viewer_role.yaml | 20 ++ .../samples/atlas_v1_atlasfederatedauth.yaml | 6 + go.mod | 1 + helm-charts | 2 +- pkg/api/v1/atlascustomresource.go | 1 + pkg/api/v1/atlasfederatedauth_types.go | 148 ++++++++++++++ pkg/api/v1/atlasfederatedauth_types_test.go | 107 ++++++++++ pkg/api/v1/status/atlasfederatedauth.go | 9 + pkg/api/v1/status/condition.go | 6 + pkg/api/v1/status/zz_generated.deepcopy.go | 16 ++ pkg/api/v1/zz_generated.deepcopy.go | 137 +++++++++++++ .../atlasfederatedauth/atlasfederated_auth.go | 119 +++++++++++ .../atlasfederated_auth_controller.go | 191 ++++++++++++++++++ .../customresource/customresource.go | 7 +- pkg/controller/workflow/reason.go | 9 + test/int/federated_auth_test.go | 160 +++++++++++++++ test/int/integration_suite_test.go | 13 ++ 26 files changed, 1256 insertions(+), 79 deletions(-) create mode 100644 config/crd/bases/atlas.mongodb.com_atlasfederatedauths.yaml create mode 100644 config/crd/patches/cainjection_in_atlasfederatedauths.yaml create mode 100644 config/crd/patches/webhook_in_atlasfederatedauths.yaml create mode 100644 config/rbac/atlasfederatedauth_editor_role.yaml create mode 100644 config/rbac/atlasfederatedauth_viewer_role.yaml create mode 100644 config/samples/atlas_v1_atlasfederatedauth.yaml create mode 100644 pkg/api/v1/atlasfederatedauth_types.go create mode 100644 pkg/api/v1/atlasfederatedauth_types_test.go create mode 100644 pkg/api/v1/status/atlasfederatedauth.go create mode 100644 pkg/controller/atlasfederatedauth/atlasfederated_auth.go create mode 100644 pkg/controller/atlasfederatedauth/atlasfederated_auth_controller.go create mode 100644 test/int/federated_auth_test.go diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index c94d88bf58..c519ba80cc 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -203,7 +203,7 @@ jobs: run: | kubectl version - name: Install CRDs if needed - if: ${{ !( matrix.test == 'helm-update' || matrix.test == 'helm-wide' || matrix.test == 'bundle-test' ) }} + if: ${{ !( matrix.test == 'helm-update' || matrix.test == 'helm-wide' || matrix.test == 'helm-ns' || matrix.test == 'bundle-test' ) }} run: | kubectl apply -f deploy/crds - name: Run e2e test diff --git a/.github/workflows/test-int.yml b/.github/workflows/test-int.yml index 0b504d6ae5..bd402df2f9 100644 --- a/.github/workflows/test-int.yml +++ b/.github/workflows/test-int.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - test: ["AtlasProject", "AtlasDeployment", "AtlasDatabaseUser", "AtlasDataFederation"] + test: ["AtlasProject", "AtlasDeployment", "AtlasDatabaseUser", "AtlasDataFederation", "AtlasFederatedAuth"] path: [ "./test/int" ] nodes: [12] include: diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 23f057ab4c..8bce016125 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -46,6 +46,7 @@ import ( "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlasdatabaseuser" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlasdatafederation" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlasdeployment" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlasfederatedauth" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlasproject" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/connectionsecret" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/watch" @@ -196,6 +197,22 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "AtlasDataFederation") os.Exit(1) } + + if err = (&atlasfederatedauth.AtlasFederatedAuthReconciler{ + Client: mgr.GetClient(), + Log: logger.Named("controllers").Named("AtlasFederatedAuth").Sugar(), + Scheme: mgr.GetScheme(), + AtlasDomain: config.AtlasDomain, + ResourceWatcher: watch.NewResourceWatcher(), + GlobalPredicates: globalPredicates, + EventRecorder: mgr.GetEventRecorderFor("AtlasFederatedAuth"), + ObjectDeletionProtection: config.ObjectDeletionProtection, + SubObjectDeletionProtection: config.SubObjectDeletionProtection, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "AtlasFederatedAuth") + os.Exit(1) + } + // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("health", healthz.Ping); err != nil { diff --git a/config/crd/bases/atlas.mongodb.com_atlasfederatedauths.yaml b/config/crd/bases/atlas.mongodb.com_atlasfederatedauths.yaml new file mode 100644 index 0000000000..0c0679f166 --- /dev/null +++ b/config/crd/bases/atlas.mongodb.com_atlasfederatedauths.yaml @@ -0,0 +1,172 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.9.2 + creationTimestamp: null + name: atlasfederatedauths.atlas.mongodb.com +spec: + group: atlas.mongodb.com + names: + kind: AtlasFederatedAuth + listKind: AtlasFederatedAuthList + plural: atlasfederatedauths + singular: atlasfederatedauth + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: AtlasFederatedAuth is the Schema for the Atlasfederatedauth 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: + properties: + connectionSecretRef: + description: Connection secret with API credentials for configuring + the federation. These credentials must have OrganizationOwner permissions. + properties: + name: + description: Name is the name of the Kubernetes Resource + type: string + namespace: + description: Namespace is the namespace of the Kubernetes Resource + type: string + required: + - name + type: object + domainAllowList: + description: Approved domains that restrict users who can join the + organization based on their email address. + items: + type: string + type: array + domainRestrictionEnabled: + default: false + description: Prevent users in the federation from accessing organizations + outside of the federation, and creating new organizations. This + option applies to the entire federation. See more information at + https://www.mongodb.com/docs/atlas/security/federation-advanced-options/#restrict-user-membership-to-the-federation + type: boolean + enabled: + default: false + type: boolean + postAuthRoleGrants: + description: Atlas roles that are granted to a user in this organization + after authenticating. + items: + type: string + type: array + roleMappings: + description: Map IDP groups to Atlas roles. + items: + description: RoleMapping maps an external group from an identity + provider to roles within Atlas. + properties: + externalGroupName: + description: ExternalGroupName is the name of the IDP group + to which this mapping applies. + maxLength: 200 + minLength: 1 + type: string + roleAssignments: + description: RoleAssignments define the roles within projects + that should be given to members of the group. + items: + properties: + projectName: + description: The Atlas project in the same org in which + the role should be given. + type: string + role: + description: The role in Atlas that should be given to + group members. + enum: + - ORG_MEMBER + - ORG_READ_ONLY + - ORG_BILLING_ADMIN + - ORG_GROUP_CREATOR + - ORG_OWNER + - ORG_BILLING_READ_ONLY + - ORG_TEAM_MEMBERS_ADMIN + - GROUP_AUTOMATION_ADMIN + - GROUP_BACKUP_ADMIN + - GROUP_MONITORING_ADMIN + - GROUP_OWNER + - GROUP_READ_ONLY + - GROUP_USER_ADMIN + - GROUP_BILLING_ADMIN + - GROUP_DATA_ACCESS_ADMIN + - GROUP_DATA_ACCESS_READ_ONLY + - GROUP_DATA_ACCESS_READ_WRITE + - GROUP_CHARTS_ADMIN + - GROUP_CLUSTER_MANAGER + - GROUP_SEARCH_INDEX_EDITOR + type: string + type: object + type: array + type: object + type: array + ssoDebugEnabled: + default: false + type: boolean + type: object + status: + properties: + conditions: + description: Conditions is the list of statuses showing the current + state of the Atlas Custom Resource + items: + description: Condition describes the state of an Atlas Custom Resource + at a certain point. + properties: + lastTransitionTime: + description: Last time the condition transitioned from one status + to another. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of Atlas Custom Resource condition. + type: string + required: + - status + - type + type: object + type: array + observedGeneration: + description: ObservedGeneration indicates the generation of the resource + specification that the Atlas Operator is aware of. The Atlas Operator + updates this field to the 'metadata.generation' as soon as it starts + reconciliation of the resource. + format: int64 + type: integer + required: + - conditions + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 48079fb216..a00e41916a 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -9,27 +9,6 @@ resources: - bases/atlas.mongodb.com_atlasbackuppolicies.yaml - bases/atlas.mongodb.com_atlasbackupschedules.yaml - bases/atlas.mongodb.com_atlasteams.yaml -# +kubebuilder:scaffold:crdkustomizeresource - -patchesStrategicMerge: -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. -# patches here are for enabling the conversion webhook for each CRD -#- patches/webhook_in_atlasclusters.yaml -#- patches/webhook_in_atlasprojects.yaml -#- patches/webhook_in_atlasbackuppolicies.yaml -#- patches/webhook_in_atlasbackupschedules.yaml -#- patches/webhook_in_atlasteams.yaml -# +kubebuilder:scaffold:crdkustomizewebhookpatch - -# [CERTMANAGER] To enable webhook, uncomment all the sections with [CERTMANAGER] prefix. -# patches here are for enabling the CA injection for each CRD -#- patches/cainjection_in_atlasclusters.yaml -#- patches/cainjection_in_atlasprojects.yaml -#- patches/cainjection_in_atlasbackuppolicies.yaml -#- patches/cainjection_in_atlasbackupschedules.yaml -#- patches/cainjection_in_atlasteams.yaml -# +kubebuilder:scaffold:crdkustomizecainjectionpatch - -# the following config is for teaching kustomize how to do kustomization for CRDs. + - bases/atlas.mongodb.com_atlasfederatedauths.yaml configurations: - kustomizeconfig.yaml diff --git a/config/crd/patches/cainjection_in_atlasfederatedauths.yaml b/config/crd/patches/cainjection_in_atlasfederatedauths.yaml new file mode 100644 index 0000000000..8db1460aaf --- /dev/null +++ b/config/crd/patches/cainjection_in_atlasfederatedauths.yaml @@ -0,0 +1,8 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +# CRD conversion requires k8s 1.13 or later. +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: atlasfederatedauths.atlas.mongodb.com diff --git a/config/crd/patches/webhook_in_atlasfederatedauths.yaml b/config/crd/patches/webhook_in_atlasfederatedauths.yaml new file mode 100644 index 0000000000..f75927ab66 --- /dev/null +++ b/config/crd/patches/webhook_in_atlasfederatedauths.yaml @@ -0,0 +1,17 @@ +# The following patch enables conversion webhook for CRD +# CRD conversion requires k8s 1.13 or later. +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: atlasfederatedauths.atlas.mongodb.com +spec: + conversion: + strategy: Webhook + webhookClientConfig: + # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank, + # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager) + caBundle: Cg== + service: + namespace: system + name: webhook-service + path: /convert diff --git a/config/manifests/bases/mongodb-atlas-kubernetes.clusterserviceversion.yaml b/config/manifests/bases/mongodb-atlas-kubernetes.clusterserviceversion.yaml index 6f7b2680d9..50075e4483 100644 --- a/config/manifests/bases/mongodb-atlas-kubernetes.clusterserviceversion.yaml +++ b/config/manifests/bases/mongodb-atlas-kubernetes.clusterserviceversion.yaml @@ -7,46 +7,58 @@ metadata: categories: Database description: The MongoDB Atlas Kubernetes Operator enables easy management of Clusters in MongoDB Atlas - name: mongodb-atlas-kubernetes.v0.0.0 - namespace: placeholder labels: - operatorframework.io/os.linux: supported operatorframework.io/arch.amd64: supported operatorframework.io/arch.arm64: supported + operatorframework.io/os.linux: supported + name: mongodb-atlas-kubernetes.v0.0.0 + namespace: placeholder spec: - apiservicedefinitions: { } + apiservicedefinitions: {} customresourcedefinitions: owned: - - description: AtlasDeployment is the Schema for the atlasclusters API - displayName: Atlas Deployment - kind: AtlasDeployment - name: atlasdeployments.atlas.mongodb.com - version: v1 - - description: AtlasDatabaseUser is the Schema for the Atlas Database User API - displayName: Atlas Database User - kind: AtlasDatabaseUser - name: atlasdatabaseusers.atlas.mongodb.com - version: v1 - - description: AtlasProject is the Schema for the atlasprojects API - displayName: Atlas Project - kind: AtlasProject - name: atlasprojects.atlas.mongodb.com - version: v1 - - description: AtlasBackupSchedule is the Schema for the atlasbackupschedule API - displayName: Atlas Backup Schedule - kind: AtlasBackupSchedule - name: atlasbackupschedules.atlas.mongodb.com - version: v1 - - description: AtlasBackupPolicy is the Schema for the atlasbackuppolicy API - displayName: Atlas Backup Policy - kind: AtlasBackupPolicy - name: atlasbackuppolicies.atlas.mongodb.com - version: v1 - - description: AtlasTeam is the Schema for the Atlas Teams API - displayName: Atlas Team - kind: AtlasTeam - name: atlasteams.atlas.mongodb.com - version: v1 + - description: AtlasFederatedAuth is the Schema for the Atlasfederatedauth API + displayName: Atlas Federated Auth + kind: AtlasFederatedAuth + name: atlasfederatedauths.atlas.mongodb.com + version: v1 + - description: AtlasBackupPolicy is the Schema for the atlasbackuppolicies API + displayName: Atlas Backup Policy + kind: AtlasBackupPolicy + name: atlasbackuppolicies.atlas.mongodb.com + version: v1 + - description: AtlasBackupSchedule is the Schema for the atlasbackupschedules + API + displayName: Atlas Backup Schedule + kind: AtlasBackupSchedule + name: atlasbackupschedules.atlas.mongodb.com + version: v1 + - description: AtlasDatabaseUser is the Schema for the Atlas Database User API + displayName: Atlas Database User + kind: AtlasDatabaseUser + name: atlasdatabaseusers.atlas.mongodb.com + version: v1 + - description: AtlasDataFederation is the Schema for the Atlas Data Federation + API + displayName: Atlas Data Federation + kind: AtlasDataFederation + name: atlasdatafederations.atlas.mongodb.com + version: v1 + - description: AtlasDeployment is the Schema for the atlasdeployments API + displayName: Atlas Deployment + kind: AtlasDeployment + name: atlasdeployments.atlas.mongodb.com + version: v1 + - description: AtlasProject is the Schema for the atlasprojects API + displayName: Atlas Project + kind: AtlasProject + name: atlasprojects.atlas.mongodb.com + version: v1 + - description: AtlasTeam is the Schema for the Atlas Teams API + displayName: Atlas Team + kind: AtlasTeam + name: atlasteams.atlas.mongodb.com + version: v1 description: | The MongoDB Atlas Operator provides a native integration between the Kubernetes orchestration platform and MongoDB Atlas — the only multi-cloud document database service that gives you the versatility you need to build sophisticated and resilient applications that can adapt to changing customer demands and market trends. @@ -158,33 +170,33 @@ spec: ``` displayName: MongoDB Atlas Operator icon: - - base64data: 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mrIHXO4A/U+GbMqu5Kcp5HLA+xl9UwbovQWCq/XeGvs4fQBS1FXiBjg4BIbzlL3m8e7O1t7+PdPo7wdVb3KbaWTEXAAADRxpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+Cjx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDQuNC4wLUV4aXYyIj4KIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIKICAgIHhtbG5zOkdJTVA9Imh0dHA6Ly93d3cuZ2ltcC5vcmcveG1wLyIKICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICB4bXBNTTpEb2N1bWVudElEPSJnaW1wOmRvY2lkOmdpbXA6ZDk1YjhmMjctMWM0NS00YjU1LWEwZTMtNmNmMjM0Yzk1ZWVkIgogICB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmVhMGY5MTI5LWJlMDItNDVjOS1iNGU4LTU3N2MxZTBiZGJhNyIKICAgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjcyNmY4ZGFlLTM4ZTYtNGQ4Ni1hNTI4LWM0NTc4ZGE4ODA0NSIKICAgZGM6Rm9ybWF0PSJpbWFnZS9wbmciCiAgIEdJTVA6QVBJPSIyLjAiCiAgIEdJTVA6UGxhdGZvcm09Ik1hYyBPUyIKICAgR0lNUDpUaW1lU3RhbXA9IjE2MzQ4MzgwMTYyMTQ2MTMiCiAgIEdJTVA6VmVyc2lvbj0iMi4xMC4yNCIKICAgdGlmZjpPcmllbnRhdGlvbj0iMSIKICAgeG1wOkNyZWF0b3JUb29sPSJHSU1QIDIuMTAiPgogICA8eG1wTU06SGlzdG9yeT4KICAgIDxyZGY6U2VxPgogICAgIDxyZGY6bGkKICAgICAgc3RFdnQ6YWN0aW9uPSJzYXZlZCIKICAgICAgc3RFdnQ6Y2hhbmdlZD0iLyIKICAgICAgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo1YWNhZmVhMC0xZmY5LTRiMmUtYmY0NC02NTM3MzYwMGQzNjEiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkdpbXAgMi4xMCAoTWFjIE9TKSIKICAgICAgc3RFdnQ6d2hlbj0iMjAyMS0xMC0yMVQxODo0MDoxNiswMTowMCIvPgogICAgPC9yZGY6U2VxPgogICA8L3htcE1NOkhpc3Rvcnk+CiAgPC9yZGY6RGVzY3JpcHRpb24+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz6528V0AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAB3RJTUUH5QoVESgQ+iToFAAAA8xJREFUeNrlW01PU0EUPTPV+oqb4h+wENYKXbmzsjLEKPAHwB1xQ6N7adiboBtrSAT5AaQmBpuYSN25MS17k5Zf0MemFGznungttCkf782bmTels2w6mbnnnnPv3DvzYrBhrMytIT01gz9/f5temkVv/NMUwKsg1MFEGvlizeTy3ALj9zuuGAf4T2QzydEBACwHINXzwwSOE29N7iAWqe7BsoOYsEdITx2ZigcsIupnzqh/8SC0/6Wx+aNy8yTg6X7rWsfEbu96/71JAGQzyY7n/Rg2AcZ3dQdFswA0Exs+je8KYUZ3UDQXA1bmlgFsScwkMFrEx++F4QXgPN/LaZpQR6IxiY2SO6QSGMj3Qd00jpPE5+FkgDz1B3kAMYt8sTQ8AGQzSTTHyqG83z+qcBpplVLQK4Hm2KpC473U2BzLDgcDwgY+QwFRIwP4knLjuwFRIQv0MGB5PgnntKwFAMUs0MMA53Rem/Ge25I4ufvCXgkQVrVXsSSW7JTAq7lpCJQNnK4IEJNhW2jqGdDGsrH6QrB5GyXwWMKXLoi5gdnL8dwuCXjRvy4xs0vjVGDonMa9MNlALQPiJxlJOcvruOlM2yMBzuQ3Q3Al44BFADA8lJ9LrtSKnD2wBwAhe/hhIVIZpWxiQJgG5qHkohYBoPP4q6tks2Qfh1GBzu3xhWQckM0eWgAIfprrBE+SN4LZBACTNIQzF4KO5EAnmxgQwhtckj2WMeBA8gARpqQ9sAcAAfnrbLk4QGBUsQcAHmIzXFLLrbZFDMgXS1KZoN2W1DHVwj6iUH8O4FQKPCcWc3t6AkGCTin0dpUDQPhq6OREgNixD4BmvBBYBlKNTaqpuChVD8B2wQWj98EnOrVA3hf4YHExJLb1l3FUsBeAfLEG0Bef//Y8H28FqSW2VT2p1VgNUi5QLKC4z1qCqoBYt78fkC/WfMWCwMUM21H5oFrzA4n4xrUt724xQy0fxRRVkd/LKQ0lWgHYLrgAvfQXN1vXSYAAmlUeS7VH63yxBMIVUvDdB1jX8S2BmZbYp70scNkRmXtXaQkOXN4b3FJNfbMAAEDzzoLcFRhV4TReaztOGAPAiwdPLgDh8OqUR7M6XoiaB6CbGtts4cLzwbtv1N8Z7hiv+Rsi823xzb0KRB8T7gMA3jxj59dcZoz3snBUY+VpCmD7nautXGcva2Aog8Siqa/Hov1sbuAxJZXgHC/o1Hz0Ehgsmn71/FIxaXz0AAwS8sj0ihYAcBb5CVJ9weFnwLnR1K6PHgC9FyJsFCVwq+9afAQlIITbnxXMjv+6222dh4/VtAAAAABJRU5ErkJggg== - mediatype: image/png + - base64data: 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mrIHXO4A/U+GbMqu5Kcp5HLA+xl9UwbovQWCq/XeGvs4fQBS1FXiBjg4BIbzlL3m8e7O1t7+PdPo7wdVb3KbaWTEXAAADRxpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+Cjx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDQuNC4wLUV4aXYyIj4KIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIKICAgIHhtbG5zOkdJTVA9Imh0dHA6Ly93d3cuZ2ltcC5vcmcveG1wLyIKICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICB4bXBNTTpEb2N1bWVudElEPSJnaW1wOmRvY2lkOmdpbXA6ZDk1YjhmMjctMWM0NS00YjU1LWEwZTMtNmNmMjM0Yzk1ZWVkIgogICB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmVhMGY5MTI5LWJlMDItNDVjOS1iNGU4LTU3N2MxZTBiZGJhNyIKICAgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjcyNmY4ZGFlLTM4ZTYtNGQ4Ni1hNTI4LWM0NTc4ZGE4ODA0NSIKICAgZGM6Rm9ybWF0PSJpbWFnZS9wbmciCiAgIEdJTVA6QVBJPSIyLjAiCiAgIEdJTVA6UGxhdGZvcm09Ik1hYyBPUyIKICAgR0lNUDpUaW1lU3RhbXA9IjE2MzQ4MzgwMTYyMTQ2MTMiCiAgIEdJTVA6VmVyc2lvbj0iMi4xMC4yNCIKICAgdGlmZjpPcmllbnRhdGlvbj0iMSIKICAgeG1wOkNyZWF0b3JUb29sPSJHSU1QIDIuMTAiPgogICA8eG1wTU06SGlzdG9yeT4KICAgIDxyZGY6U2VxPgogICAgIDxyZGY6bGkKICAgICAgc3RFdnQ6YWN0aW9uPSJzYXZlZCIKICAgICAgc3RFdnQ6Y2hhbmdlZD0iLyIKICAgICAgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo1YWNhZmVhMC0xZmY5LTRiMmUtYmY0NC02NTM3MzYwMGQzNjEiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkdpbXAgMi4xMCAoTWFjIE9TKSIKICAgICAgc3RFdnQ6d2hlbj0iMjAyMS0xMC0yMVQxODo0MDoxNiswMTowMCIvPgogICAgPC9yZGY6U2VxPgogICA8L3htcE1NOkhpc3Rvcnk+CiAgPC9yZGY6RGVzY3JpcHRpb24+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz6528V0AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAB3RJTUUH5QoVESgQ+iToFAAAA8xJREFUeNrlW01PU0EUPTPV+oqb4h+wENYKXbmzsjLEKPAHwB1xQ6N7adiboBtrSAT5AaQmBpuYSN25MS17k5Zf0MemFGznungttCkf782bmTels2w6mbnnnnPv3DvzYrBhrMytIT01gz9/f5temkVv/NMUwKsg1MFEGvlizeTy3ALj9zuuGAf4T2QzydEBACwHINXzwwSOE29N7iAWqe7BsoOYsEdITx2ZigcsIupnzqh/8SC0/6Wx+aNy8yTg6X7rWsfEbu96/71JAGQzyY7n/Rg2AcZ3dQdFswA0Exs+je8KYUZ3UDQXA1bmlgFsScwkMFrEx++F4QXgPN/LaZpQR6IxiY2SO6QSGMj3Qd00jpPE5+FkgDz1B3kAMYt8sTQ8AGQzSTTHyqG83z+qcBpplVLQK4Hm2KpC473U2BzLDgcDwgY+QwFRIwP4knLjuwFRIQv0MGB5PgnntKwFAMUs0MMA53Rem/Ge25I4ufvCXgkQVrVXsSSW7JTAq7lpCJQNnK4IEJNhW2jqGdDGsrH6QrB5GyXwWMKXLoi5gdnL8dwuCXjRvy4xs0vjVGDonMa9MNlALQPiJxlJOcvruOlM2yMBzuQ3Q3Al44BFADA8lJ9LrtSKnD2wBwAhe/hhIVIZpWxiQJgG5qHkohYBoPP4q6tks2Qfh1GBzu3xhWQckM0eWgAIfprrBE+SN4LZBACTNIQzF4KO5EAnmxgQwhtckj2WMeBA8gARpqQ9sAcAAfnrbLk4QGBUsQcAHmIzXFLLrbZFDMgXS1KZoN2W1DHVwj6iUH8O4FQKPCcWc3t6AkGCTin0dpUDQPhq6OREgNixD4BmvBBYBlKNTaqpuChVD8B2wQWj98EnOrVA3hf4YHExJLb1l3FUsBeAfLEG0Bef//Y8H28FqSW2VT2p1VgNUi5QLKC4z1qCqoBYt78fkC/WfMWCwMUM21H5oFrzA4n4xrUt724xQy0fxRRVkd/LKQ0lWgHYLrgAvfQXN1vXSYAAmlUeS7VH63yxBMIVUvDdB1jX8S2BmZbYp70scNkRmXtXaQkOXN4b3FJNfbMAAEDzzoLcFRhV4TReaztOGAPAiwdPLgDh8OqUR7M6XoiaB6CbGtts4cLzwbtv1N8Z7hiv+Rsi823xzb0KRB8T7gMA3jxj59dcZoz3snBUY+VpCmD7nautXGcva2Aog8Siqa/Hov1sbuAxJZXgHC/o1Hz0Ehgsmn71/FIxaXz0AAwS8sj0ihYAcBb5CVJ9weFnwLnR1K6PHgC9FyJsFCVwq+9afAQlIITbnxXMjv+6222dh4/VtAAAAABJRU5ErkJggg== + mediatype: image/png install: spec: deployments: null strategy: "" installModes: - - supported: true - type: OwnNamespace - - supported: true - type: SingleNamespace - - supported: true - type: MultiNamespace - - supported: true - type: AllNamespaces + - supported: true + type: OwnNamespace + - supported: true + type: SingleNamespace + - supported: true + type: MultiNamespace + - supported: true + type: AllNamespaces keywords: - - MongoDB - - Atlas - - Database - - Replica Set - - Cluster + - MongoDB + - Atlas + - Database + - Replica Set + - Cluster links: - - name: MongoDB Atlas Kubernetes - url: https://github.com/mongodb/mongodb-atlas-kubernetes + - name: MongoDB Atlas Kubernetes + url: https://github.com/mongodb/mongodb-atlas-kubernetes maintainers: - - email: support@mongodb.com - name: MongoDB, Inc + - email: support@mongodb.com + name: MongoDB, Inc maturity: beta provider: name: MongoDB, Inc diff --git a/config/rbac/atlasfederatedauth_editor_role.yaml b/config/rbac/atlasfederatedauth_editor_role.yaml new file mode 100644 index 0000000000..9ee4b06875 --- /dev/null +++ b/config/rbac/atlasfederatedauth_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit atlasfederatedauths. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: atlasfederatedauth-editor-role +rules: +- apiGroups: + - atlas.mongodb.com + resources: + - atlasfederatedauths + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - atlas.mongodb.com + resources: + - atlasfederatedauths/status + verbs: + - get diff --git a/config/rbac/atlasfederatedauth_viewer_role.yaml b/config/rbac/atlasfederatedauth_viewer_role.yaml new file mode 100644 index 0000000000..002ed92aa1 --- /dev/null +++ b/config/rbac/atlasfederatedauth_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view atlasfederatedauths. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: atlasfederatedauth-viewer-role +rules: +- apiGroups: + - atlas.mongodb.com + resources: + - atlasfederatedauths + verbs: + - get + - list + - watch +- apiGroups: + - atlas.mongodb.com + resources: + - atlasfederatedauths/status + verbs: + - get diff --git a/config/samples/atlas_v1_atlasfederatedauth.yaml b/config/samples/atlas_v1_atlasfederatedauth.yaml new file mode 100644 index 0000000000..a0fd4b5249 --- /dev/null +++ b/config/samples/atlas_v1_atlasfederatedauth.yaml @@ -0,0 +1,6 @@ +apiVersion: atlas.mongodb.com/v1 +kind: AtlasFederatedAuth +metadata: + name: atlasfederatedauth-sample +spec: + # TODO(user): Add fields here diff --git a/go.mod b/go.mod index 6e31b5f298..ea3daa8e6d 100644 --- a/go.mod +++ b/go.mod @@ -76,6 +76,7 @@ require ( github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.1 // indirect github.com/go-openapi/swag v0.22.3 // indirect + github.com/go-test/deep v1.1.0 github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect diff --git a/helm-charts b/helm-charts index 0bf4037373..2354b53044 160000 --- a/helm-charts +++ b/helm-charts @@ -1 +1 @@ -Subproject commit 0bf40373735d2d475246f8a1d9d2d13b45ac3f21 +Subproject commit 2354b53044f93d204c6a249c860e6128e852af79 diff --git a/pkg/api/v1/atlascustomresource.go b/pkg/api/v1/atlascustomresource.go index 9f0985d667..8e7a17361b 100644 --- a/pkg/api/v1/atlascustomresource.go +++ b/pkg/api/v1/atlascustomresource.go @@ -25,3 +25,4 @@ var _ AtlasCustomResource = &AtlasDatabaseUser{} var _ AtlasCustomResource = &AtlasDataFederation{} var _ AtlasCustomResource = &AtlasBackupSchedule{} var _ AtlasCustomResource = &AtlasBackupPolicy{} +var _ AtlasCustomResource = &AtlasFederatedAuth{} diff --git a/pkg/api/v1/atlasfederatedauth_types.go b/pkg/api/v1/atlasfederatedauth_types.go new file mode 100644 index 0000000000..1f1419c5fa --- /dev/null +++ b/pkg/api/v1/atlasfederatedauth_types.go @@ -0,0 +1,148 @@ +package v1 + +import ( + "errors" + "fmt" + + "go.mongodb.org/atlas/mongodbatlas" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/kube" +) + +func init() { + SchemeBuilder.Register(&AtlasFederatedAuth{}, &AtlasFederatedAuthList{}) +} + +type AtlasFederatedAuthSpec struct { + // +kubebuilder:default:=false + Enabled bool `json:"enabled,omitempty"` + // Connection secret with API credentials for configuring the federation. + // These credentials must have OrganizationOwner permissions. + ConnectionSecretRef common.ResourceRefNamespaced `json:"connectionSecretRef,omitempty"` + // Approved domains that restrict users who can join the organization based on their email address. + // +optional + DomainAllowList []string `json:"domainAllowList,omitempty"` + // Prevent users in the federation from accessing organizations outside of the federation, and creating new organizations. + // This option applies to the entire federation. + // See more information at https://www.mongodb.com/docs/atlas/security/federation-advanced-options/#restrict-user-membership-to-the-federation + // +kubebuilder:default:=false + DomainRestrictionEnabled *bool `json:"domainRestrictionEnabled,omitempty"` + // +kubebuilder:default:=false + // +optional + SSODebugEnabled *bool `json:"ssoDebugEnabled,omitempty"` + // Atlas roles that are granted to a user in this organization after authenticating. + // +optional + PostAuthRoleGrants []string `json:"postAuthRoleGrants,omitempty"` + // Map IDP groups to Atlas roles. + // +optional + RoleMappings []RoleMapping `json:"roleMappings,omitempty"` +} + +func (f *AtlasFederatedAuthSpec) ToAtlas(orgID, idpID string, projectNameToID map[string]string) (*mongodbatlas.FederatedSettingsConnectedOrganization, error) { + var errs []error + atlasRoleMappings := make([]*mongodbatlas.RoleMappings, 0, len(f.RoleMappings)) + + for i := range f.RoleMappings { + roleMapping := &f.RoleMappings[i] + atlasRoleAssignments := make([]*mongodbatlas.RoleAssignments, 0, len(roleMapping.RoleAssignments)) + for j := range roleMapping.RoleAssignments { + atlasRoleAssignment := &mongodbatlas.RoleAssignments{} + roleAssignment := &roleMapping.RoleAssignments[j] + if roleAssignment.ProjectName != "" { + id, ok := projectNameToID[roleAssignment.ProjectName] + if !ok { + errs = append(errs, fmt.Errorf("project name '%s' doesn't exists in the organization", roleAssignment.ProjectName)) + continue + } + atlasRoleAssignment.GroupID = id + } else { + atlasRoleAssignment.OrgID = orgID + } + atlasRoleAssignment.Role = roleAssignment.Role + atlasRoleAssignments = append(atlasRoleAssignments, atlasRoleAssignment) + } + atlasRoleMappings = append(atlasRoleMappings, &mongodbatlas.RoleMappings{ + ExternalGroupName: roleMapping.ExternalGroupName, + ID: idpID, + RoleAssignments: atlasRoleAssignments, + }) + } + + result := &mongodbatlas.FederatedSettingsConnectedOrganization{ + DomainAllowList: f.DomainAllowList, + DomainRestrictionEnabled: f.DomainRestrictionEnabled, + IdentityProviderID: idpID, + OrgID: orgID, + PostAuthRoleGrants: f.PostAuthRoleGrants, + RoleMappings: atlasRoleMappings, + } + + return result, errors.Join(errs...) +} + +// RoleMapping maps an external group from an identity provider to roles within Atlas. +type RoleMapping struct { + // ExternalGroupName is the name of the IDP group to which this mapping applies. + // +kubebuilder:validation:MinLength:=1 + // +kubebuilder:validation:MaxLength:=200 + ExternalGroupName string `json:"externalGroupName,omitempty"` + // RoleAssignments define the roles within projects that should be given to members of the group. + RoleAssignments []RoleAssignment `json:"roleAssignments,omitempty"` +} + +type RoleAssignment struct { + // The Atlas project in the same org in which the role should be given. + ProjectName string `json:"projectName,omitempty"` + // The role in Atlas that should be given to group members. + // +kubebuilder:validation:Enum=ORG_MEMBER;ORG_READ_ONLY;ORG_BILLING_ADMIN;ORG_GROUP_CREATOR;ORG_OWNER;ORG_BILLING_READ_ONLY;ORG_TEAM_MEMBERS_ADMIN;GROUP_AUTOMATION_ADMIN;GROUP_BACKUP_ADMIN;GROUP_MONITORING_ADMIN;GROUP_OWNER;GROUP_READ_ONLY;GROUP_USER_ADMIN;GROUP_BILLING_ADMIN;GROUP_DATA_ACCESS_ADMIN;GROUP_DATA_ACCESS_READ_ONLY;GROUP_DATA_ACCESS_READ_WRITE;GROUP_CHARTS_ADMIN;GROUP_CLUSTER_MANAGER;GROUP_SEARCH_INDEX_EDITOR + Role string `json:"role,omitempty"` +} + +// AtlasFederatedAuth is the Schema for the Atlasfederatedauth API +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +type AtlasFederatedAuth struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AtlasFederatedAuthSpec `json:"spec,omitempty"` + Status status.AtlasFederatedAuthStatus `json:"status,omitempty"` +} + +func (f *AtlasFederatedAuth) ConnectionSecretObjectKey() *client.ObjectKey { + var key client.ObjectKey + if f.Spec.ConnectionSecretRef.Namespace != "" { + key = kube.ObjectKey(f.Spec.ConnectionSecretRef.Namespace, f.Spec.ConnectionSecretRef.Name) + } else { + key = kube.ObjectKey(f.Namespace, f.Spec.ConnectionSecretRef.Name) + } + return &key +} + +func (f *AtlasFederatedAuth) GetStatus() status.Status { + return f.Status +} + +func (f *AtlasFederatedAuth) UpdateStatus(conditions []status.Condition, options ...status.Option) { + f.Status.Conditions = conditions + f.Status.ObservedGeneration = f.ObjectMeta.Generation + + for _, o := range options { + // This will fail if the Option passed is incorrect - which is expected + v := o.(status.AtlasFederatedAuthStatusOption) + v(&f.Status) + } +} + +// AtlasFederatedAuthList contains a list of AtlasFederatedAuth +// +kubebuilder:object:root=true +type AtlasFederatedAuthList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AtlasFederatedAuth `json:"items"` +} diff --git a/pkg/api/v1/atlasfederatedauth_types_test.go b/pkg/api/v1/atlasfederatedauth_types_test.go new file mode 100644 index 0000000000..f10d97c994 --- /dev/null +++ b/pkg/api/v1/atlasfederatedauth_types_test.go @@ -0,0 +1,107 @@ +package v1 + +import ( + "testing" + + "github.com/go-test/deep" + "github.com/stretchr/testify/assert" + "go.mongodb.org/atlas/mongodbatlas" + + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/toptr" +) + +func Test_FederatedAuthSpec_ToAtlas(t *testing.T) { + t.Run("Can convert valid spec to Atlas", func(t *testing.T) { + orgID := "test-org" + idpID := "test-idp" + projectName := "test-project" + + projectNameToID := map[string]string{ + projectName: "test-project-id", + } + + spec := &AtlasFederatedAuthSpec{ + Enabled: true, + ConnectionSecretRef: common.ResourceRefNamespaced{}, + DomainAllowList: []string{"test.com"}, + DomainRestrictionEnabled: toptr.MakePtr(true), + SSODebugEnabled: toptr.MakePtr(true), + PostAuthRoleGrants: []string{"role-3", "role-4"}, + RoleMappings: []RoleMapping{ + { + ExternalGroupName: "test-group", + RoleAssignments: []RoleAssignment{ + { + ProjectName: projectName, + Role: "test-role", + }, + }, + }, + }, + } + + result, err := spec.ToAtlas(orgID, idpID, projectNameToID) + + assert.NoError(t, err, "ToAtlas() failed") + assert.NotNil(t, result, "ToAtlas() result is nil") + + expected := &mongodbatlas.FederatedSettingsConnectedOrganization{ + DomainAllowList: spec.DomainAllowList, + DomainRestrictionEnabled: spec.DomainRestrictionEnabled, + IdentityProviderID: idpID, + OrgID: orgID, + PostAuthRoleGrants: spec.PostAuthRoleGrants, + RoleMappings: []*mongodbatlas.RoleMappings{ + { + ExternalGroupName: spec.RoleMappings[0].ExternalGroupName, + ID: idpID, + RoleAssignments: []*mongodbatlas.RoleAssignments{ + { + GroupID: projectNameToID[projectName], + OrgID: "", + Role: spec.RoleMappings[0].RoleAssignments[0].Role, + }, + }, + }, + }, + UserConflicts: nil, + } + + diff := deep.Equal(expected, result) + assert.Nil(t, diff, diff) + }) + + t.Run("Should return an error when project is not available", func(t *testing.T) { + orgID := "test-org" + idpID := "test-idp" + projectName := "test-project" + + projectNameToID := map[string]string{} + + spec := &AtlasFederatedAuthSpec{ + Enabled: true, + ConnectionSecretRef: common.ResourceRefNamespaced{}, + DomainAllowList: []string{"test.com"}, + DomainRestrictionEnabled: toptr.MakePtr(true), + SSODebugEnabled: toptr.MakePtr(true), + PostAuthRoleGrants: []string{"role-3", "role-4"}, + RoleMappings: []RoleMapping{ + { + ExternalGroupName: "test-group", + RoleAssignments: []RoleAssignment{ + { + ProjectName: projectName, + Role: "test-role", + }, + }, + }, + }, + } + + result, err := spec.ToAtlas(orgID, idpID, projectNameToID) + + assert.Error(t, err, "ToAtlas() should fail") + assert.NotNil(t, result, "ToAtlas() result should not be nil") + }) +} diff --git a/pkg/api/v1/status/atlasfederatedauth.go b/pkg/api/v1/status/atlasfederatedauth.go new file mode 100644 index 0000000000..d6799312f7 --- /dev/null +++ b/pkg/api/v1/status/atlasfederatedauth.go @@ -0,0 +1,9 @@ +package status + +type AtlasFederatedAuthStatus struct { + Common `json:",inline"` +} + +// +k8s:deepcopy-gen=false + +type AtlasFederatedAuthStatusOption func(s *AtlasFederatedAuthStatus) diff --git a/pkg/api/v1/status/condition.go b/pkg/api/v1/status/condition.go index b8dc6b6ab8..c82806938a 100644 --- a/pkg/api/v1/status/condition.go +++ b/pkg/api/v1/status/condition.go @@ -69,6 +69,12 @@ const ( DataFederationPEReadyType ConditionType = "DataFederationPrivateEndpointsReady" ) +// Atlas Federated Auth condition types +const ( + FederatedAuthReadyType ConditionType = "FederatedAuthReady" + FederatedAuthRolesReadyType ConditionType = "RolesReady" +) + // Generic condition type const ( ResourceVersionStatus ConditionType = "ResourceVersionIsValid" diff --git a/pkg/api/v1/status/zz_generated.deepcopy.go b/pkg/api/v1/status/zz_generated.deepcopy.go index ad684bab3e..e9634ce69d 100644 --- a/pkg/api/v1/status/zz_generated.deepcopy.go +++ b/pkg/api/v1/status/zz_generated.deepcopy.go @@ -124,6 +124,22 @@ func (in *AtlasDeploymentStatus) DeepCopy() *AtlasDeploymentStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AtlasFederatedAuthStatus) DeepCopyInto(out *AtlasFederatedAuthStatus) { + *out = *in + in.Common.DeepCopyInto(&out.Common) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AtlasFederatedAuthStatus. +func (in *AtlasFederatedAuthStatus) DeepCopy() *AtlasFederatedAuthStatus { + if in == nil { + return nil + } + out := new(AtlasFederatedAuthStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AtlasNetworkPeer) DeepCopyInto(out *AtlasNetworkPeer) { *out = *in diff --git a/pkg/api/v1/zz_generated.deepcopy.go b/pkg/api/v1/zz_generated.deepcopy.go index 8893b9edfe..45514c8286 100644 --- a/pkg/api/v1/zz_generated.deepcopy.go +++ b/pkg/api/v1/zz_generated.deepcopy.go @@ -710,6 +710,108 @@ func (in *AtlasDeploymentSpec) DeepCopy() *AtlasDeploymentSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AtlasFederatedAuth) DeepCopyInto(out *AtlasFederatedAuth) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AtlasFederatedAuth. +func (in *AtlasFederatedAuth) DeepCopy() *AtlasFederatedAuth { + if in == nil { + return nil + } + out := new(AtlasFederatedAuth) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AtlasFederatedAuth) 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 *AtlasFederatedAuthList) DeepCopyInto(out *AtlasFederatedAuthList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]AtlasFederatedAuth, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AtlasFederatedAuthList. +func (in *AtlasFederatedAuthList) DeepCopy() *AtlasFederatedAuthList { + if in == nil { + return nil + } + out := new(AtlasFederatedAuthList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AtlasFederatedAuthList) 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 *AtlasFederatedAuthSpec) DeepCopyInto(out *AtlasFederatedAuthSpec) { + *out = *in + out.ConnectionSecretRef = in.ConnectionSecretRef + if in.DomainAllowList != nil { + in, out := &in.DomainAllowList, &out.DomainAllowList + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.DomainRestrictionEnabled != nil { + in, out := &in.DomainRestrictionEnabled, &out.DomainRestrictionEnabled + *out = new(bool) + **out = **in + } + if in.SSODebugEnabled != nil { + in, out := &in.SSODebugEnabled, &out.SSODebugEnabled + *out = new(bool) + **out = **in + } + if in.PostAuthRoleGrants != nil { + in, out := &in.PostAuthRoleGrants, &out.PostAuthRoleGrants + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.RoleMappings != nil { + in, out := &in.RoleMappings, &out.RoleMappings + *out = make([]RoleMapping, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AtlasFederatedAuthSpec. +func (in *AtlasFederatedAuthSpec) DeepCopy() *AtlasFederatedAuthSpec { + if in == nil { + return nil + } + out := new(AtlasFederatedAuthSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AtlasProject) DeepCopyInto(out *AtlasProject) { *out = *in @@ -1943,6 +2045,41 @@ func (in *Role) DeepCopy() *Role { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoleAssignment) DeepCopyInto(out *RoleAssignment) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoleAssignment. +func (in *RoleAssignment) DeepCopy() *RoleAssignment { + if in == nil { + return nil + } + out := new(RoleAssignment) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoleMapping) DeepCopyInto(out *RoleMapping) { + *out = *in + if in.RoleAssignments != nil { + in, out := &in.RoleAssignments, &out.RoleAssignments + *out = make([]RoleAssignment, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoleMapping. +func (in *RoleMapping) DeepCopy() *RoleMapping { + if in == nil { + return nil + } + out := new(RoleMapping) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RoleSpec) DeepCopyInto(out *RoleSpec) { *out = *in diff --git a/pkg/controller/atlasfederatedauth/atlasfederated_auth.go b/pkg/controller/atlasfederatedauth/atlasfederated_auth.go new file mode 100644 index 0000000000..4ca7948b1e --- /dev/null +++ b/pkg/controller/atlasfederatedauth/atlasfederated_auth.go @@ -0,0 +1,119 @@ +package atlasfederatedauth + +import ( + "context" + "errors" + "fmt" + + "go.mongodb.org/atlas/mongodbatlas" + + "github.com/google/go-cmp/cmp" + + mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/workflow" +) + +func (r *AtlasFederatedAuthReconciler) ensureFederatedAuth(service *workflow.Context, fedauth *mdbv1.AtlasFederatedAuth) workflow.Result { + // If disabled, skip with no error + if !fedauth.Spec.Enabled { + return workflow.OK().WithMessage(string(workflow.FederatedAuthIsNotEnabledInCR)) + } + + orgID := service.Connection.OrgID + + // Get current IDP for the ORG + atlasFedSettings, _, err := service.Client.FederatedSettings.Get(context.Background(), orgID) + if err != nil { + return workflow.Terminate(workflow.FederatedAuthNotAvailable, err.Error()) + } + + atlasFedSettingsID := atlasFedSettings.ID + + // Get current Org config + orgConfig, _, err := service.Client.FederatedSettings.GetConnectedOrg(context.Background(), atlasFedSettingsID, orgID) + if err != nil { + return workflow.Terminate(workflow.FederatedAuthOrgNotConnected, err.Error()) + } + + idpID := orgConfig.IdentityProviderID + + projectList, err := prepareProjectList(&service.Client) + if err != nil { + return workflow.Terminate(workflow.Internal, fmt.Sprintf("Can not list projects for org ID %s. %s", orgID, err.Error())) + } + + operatorConf, err := fedauth.Spec.ToAtlas(orgID, idpID, projectList) + if err != nil { + return workflow.Terminate(workflow.Internal, fmt.Sprintln("Can not convert Federated Auth spec to Atlas", err.Error())) + } + + if result := r.ensureIDPSettings(atlasFedSettingsID, idpID, fedauth, &service.Client); !result.IsOk() { + return result + } + + if federatedSettingsAreEqual(operatorConf, orgConfig) { + return workflow.OK() + } + + updatedSettings, _, err := service.Client.FederatedSettings.UpdateConnectedOrg(context.Background(), atlasFedSettingsID, orgID, operatorConf) + if err != nil { + return workflow.Terminate(workflow.Internal, fmt.Sprintln("Can not update federation settings", err.Error())) + } + + if updatedSettings.UserConflicts != nil && len(*updatedSettings.UserConflicts) != 0 { + users := make([]string, 0, len(*updatedSettings.UserConflicts)) + for i := range *updatedSettings.UserConflicts { + users = append(users, (*updatedSettings.UserConflicts)[i].EmailAddress) + } + + return workflow.Terminate(workflow.FederatedAuthUsersConflict, + fmt.Sprintln("The following users are in conflict", users)) + } + + return workflow.OK() +} + +func prepareProjectList(client *mongodbatlas.Client) (map[string]string, error) { + if client == nil { + return nil, errors.New("client is not created") + } + + projects, _, err := client.Projects.GetAllProjects(context.Background(), nil) + if err != nil { + return nil, err + } + + result := make(map[string]string, len(projects.Results)) + for i := range projects.Results { + result[projects.Results[i].Name] = projects.Results[i].ID + } + return result, nil +} + +func (r *AtlasFederatedAuthReconciler) ensureIDPSettings(federationSettingsID, idpID string, fedauth *mdbv1.AtlasFederatedAuth, client *mongodbatlas.Client) workflow.Result { + idpSettings, _, err := client.FederatedSettings.GetIdentityProvider(context.Background(), federationSettingsID, idpID) + if err != nil { + return workflow.Terminate(workflow.Internal, err.Error()) + } + + if fedauth.Spec.SSODebugEnabled != nil { + if idpSettings.SsoDebugEnabled != nil && *idpSettings.SsoDebugEnabled == *fedauth.Spec.SSODebugEnabled { + return workflow.OK() + } + + *idpSettings.SsoDebugEnabled = *fedauth.Spec.SSODebugEnabled + _, _, err := client.FederatedSettings.UpdateIdentityProvider(context.Background(), federationSettingsID, idpID, idpSettings) + if err != nil { + return workflow.Terminate(workflow.Internal, err.Error()) + } + } + + // TODO: Add more IDP settings + return workflow.OK() +} + +func federatedSettingsAreEqual(operator, atlas *mongodbatlas.FederatedSettingsConnectedOrganization) bool { + operator.UserConflicts = nil + atlas.UserConflicts = nil + return cmp.Diff(operator, atlas) == "" +} diff --git a/pkg/controller/atlasfederatedauth/atlasfederated_auth_controller.go b/pkg/controller/atlasfederatedauth/atlasfederated_auth_controller.go new file mode 100644 index 0000000000..0af85c2d71 --- /dev/null +++ b/pkg/controller/atlasfederatedauth/atlasfederated_auth_controller.go @@ -0,0 +1,191 @@ +package atlasfederatedauth + +import ( + "context" + "errors" + "fmt" + + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/statushandler" + + "go.mongodb.org/atlas/mongodbatlas" + "go.uber.org/zap" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/customresource" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/watch" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/workflow" + + ctrl "sigs.k8s.io/controller-runtime" + + corev1 "k8s.io/api/core/v1" + + mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status" +) + +// AtlasFederatedAuthReconciler reconciles an AtlasFederatedAuth object +type AtlasFederatedAuthReconciler struct { + watch.ResourceWatcher + Client client.Client + Log *zap.SugaredLogger + Scheme *runtime.Scheme + AtlasDomain string + GlobalPredicates []predicate.Predicate + EventRecorder record.EventRecorder + ObjectDeletionProtection bool + SubObjectDeletionProtection bool +} + +// +kubebuilder:rbac:groups=atlas.mongodb.com,resources=atlasfederatedauths,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=atlas.mongodb.com,resources=atlasfederatedauths/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=atlas.mongodb.com,namespace=default,resources=atlasfederatedauths,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=atlas.mongodb.com,namespace=default,resources=atlasfederatedauths/status,verbs=get;update;patch +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch + +func (r *AtlasFederatedAuthReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := r.Log.With("atlasfederatedauth", req.NamespacedName) + + fedauth := &mdbv1.AtlasFederatedAuth{} + result := customresource.PrepareResource(r.Client, req, fedauth, log) + if !result.IsOk() { + return result.ReconcileResult(), nil + } + + if customresource.ReconciliationShouldBeSkipped(fedauth) { + log.Infow(fmt.Sprintf("-> Skipping AtlasFederatedAuth reconciliation as annotation %s=%s", customresource.ReconciliationPolicyAnnotation, customresource.ReconciliationPolicySkip), "spec", fedauth.Spec) + if !fedauth.GetDeletionTimestamp().IsZero() { + if err := customresource.ManageFinalizer(ctx, r.Client, fedauth, customresource.UnsetFinalizer); err != nil { + result = workflow.Terminate(workflow.Internal, err.Error()) + log.Errorw("Failed to remove finalizer", "error", err) + return result.ReconcileResult(), nil + } + } + return workflow.OK().ReconcileResult(), nil + } + + workflowCtx := customresource.MarkReconciliationStarted(r.Client, fedauth, log) + log.Infow("-> Starting AtlasFederatedAuth reconciliation") + + defer statushandler.Update(workflowCtx, r.Client, r.EventRecorder, fedauth) + + resourceVersionIsValid := customresource.ValidateResourceVersion(workflowCtx, fedauth, r.Log) + if !resourceVersionIsValid.IsOk() { + r.Log.Debugf("federated auth validation result: %v", resourceVersionIsValid) + return resourceVersionIsValid.ReconcileResult(), nil + } + + if !customresource.IsResourceSupportedInDomain(fedauth, r.AtlasDomain) { + result := workflow.Terminate(workflow.AtlasGovUnsupported, "the AtlasFederatedAuth is not supported by Atlas for government"). + WithoutRetry() + setCondition(workflowCtx, status.FederatedAuthReadyType, result) + return result.ReconcileResult(), nil + } + + connection, err := atlas.ReadConnection(log, r.Client, types.NamespacedName{}, + fedauth.ConnectionSecretObjectKey()) + if err != nil { + result = workflow.Terminate(workflow.AtlasCredentialsNotProvided, err.Error()) + setCondition(workflowCtx, status.FederatedAuthReadyType, result) + if errRm := customresource.ManageFinalizer(ctx, r.Client, fedauth, customresource.UnsetFinalizer); errRm != nil { + result = workflow.Terminate(workflow.Internal, errRm.Error()) + return result.ReconcileResult(), nil + } + return result.ReconcileResult(), nil + } + + workflowCtx.Connection = connection + + atlasClient, err := atlas.Client(r.AtlasDomain, connection, log) + if err != nil { + result := workflow.Terminate(workflow.Internal, err.Error()) + setCondition(workflowCtx, status.FederatedAuthReadyType, result) + return result.ReconcileResult(), nil + } + workflowCtx.Client = atlasClient + + owner, err := customresource.IsOwner(fedauth, r.ObjectDeletionProtection, customresource.IsResourceManagedByOperator, managedByAtlas(ctx, atlasClient, workflowCtx.Connection.OrgID)) + if err != nil { + result = workflow.Terminate(workflow.Internal, fmt.Sprintf("unable to resolve ownership for deletion protection: %s", err)) + workflowCtx.SetConditionFromResult(status.FederatedAuthReadyType, result) + log.Error(result.GetMessage()) + + return result.ReconcileResult(), nil + } + + if !owner { + result = workflow.Terminate( + workflow.AtlasDeletionProtection, + "unable to reconcile FederatedAuthConfig due to deletion protection being enabled. see https://dochub.mongodb.org/core/ako-deletion-protection for further information", + ) + workflowCtx.SetConditionFromResult(status.FederatedAuthReadyType, result) + log.Error(result.GetMessage()) + + return result.ReconcileResult(), nil + } + + result = r.ensureFederatedAuth(workflowCtx, fedauth) + if !result.IsOk() { + workflowCtx.SetConditionFromResult(status.FederatedAuthReadyType, result) + } + workflowCtx.SetConditionTrue(status.ReadyType) + + return ctrl.Result{}, nil +} + +func (r *AtlasFederatedAuthReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + Named("AtlasFederatedAuth"). + For(&mdbv1.AtlasFederatedAuth{}, builder.WithPredicates(r.GlobalPredicates...)). + Watches(&source.Kind{Type: &corev1.Secret{}}, watch.NewSecretHandler(r.WatchedResources)). + Complete(r) +} + +func setCondition(ctx *workflow.Context, condition status.ConditionType, result workflow.Result) { + ctx.SetConditionFromResult(condition, result) + logIfWarning(ctx, result) +} + +func logIfWarning(ctx *workflow.Context, result workflow.Result) { + if result.IsWarning() { + ctx.Log.Warnw(result.GetMessage()) + } +} + +func managedByAtlas(ctx context.Context, atlasClient mongodbatlas.Client, orgID string) customresource.AtlasChecker { + return func(resource mdbv1.AtlasCustomResource) (bool, error) { + fedauth, ok := resource.(*mdbv1.AtlasFederatedAuth) + if !ok { + return false, errors.New("failed to match resource type as AtlasFederatedAuth") + } + + atlasFedSettings, _, err := atlasClient.FederatedSettings.Get(ctx, orgID) + if err != nil { + return false, err + } + + atlasFedAuth, _, err := atlasClient.FederatedSettings.GetConnectedOrg(ctx, atlasFedSettings.ID, orgID) + if err != nil { + return false, err + } + + projectlist, err := prepareProjectList(&atlasClient) + if err != nil { + return false, err + } + + convertedAuth, err := fedauth.Spec.ToAtlas(orgID, atlasFedAuth.IdentityProviderID, projectlist) + if err != nil { + return false, err + } + + return !federatedSettingsAreEqual(convertedAuth, atlasFedAuth), nil + } +} diff --git a/pkg/controller/customresource/customresource.go b/pkg/controller/customresource/customresource.go index cf3b0483d1..a01bb7beae 100644 --- a/pkg/controller/customresource/customresource.go +++ b/pkg/controller/customresource/customresource.go @@ -163,7 +163,12 @@ func IsResourceSupportedInDomain(resource mdbv1.AtlasCustomResource, domain stri } switch atlasResource := resource.(type) { - case *mdbv1.AtlasProject, *mdbv1.AtlasTeam, *mdbv1.AtlasBackupSchedule, *mdbv1.AtlasBackupPolicy, *mdbv1.AtlasDatabaseUser: + case *mdbv1.AtlasProject, + *mdbv1.AtlasTeam, + *mdbv1.AtlasBackupSchedule, + *mdbv1.AtlasBackupPolicy, + *mdbv1.AtlasDatabaseUser, + *mdbv1.AtlasFederatedAuth: return true case *mdbv1.AtlasDataFederation: return false diff --git a/pkg/controller/workflow/reason.go b/pkg/controller/workflow/reason.go index 12a560e07e..8698d311be 100644 --- a/pkg/controller/workflow/reason.go +++ b/pkg/controller/workflow/reason.go @@ -78,6 +78,7 @@ const ( DataFederationUpdating ConditionReason = "DataFederationUpdating" ) +// Atlas Teams reasons const ( TeamNotCreatedInAtlas ConditionReason = "TeamNotCreatedInAtlas" TeamNotUpdatedInAtlas ConditionReason = "TeamNotUpdatedInAtlas" @@ -85,3 +86,11 @@ const ( TeamUsersNotReady ConditionReason = "TeamUsersNotReady" TeamDoesNotExist ConditionReason = "TeamDoesNotExist" ) + +// Atlas Federated Auth reasons +const ( + FederatedAuthNotAvailable ConditionReason = "FederatedAuthNotAvailable" + FederatedAuthIsNotEnabledInCR ConditionReason = "FederatedAuthNotEnabledInCR" + FederatedAuthOrgNotConnected ConditionReason = "FederatedAuthOrgIsNotConnected" + FederatedAuthUsersConflict ConditionReason = "FederatedAuthUsersConflict" +) diff --git a/test/int/federated_auth_test.go b/test/int/federated_auth_test.go new file mode 100644 index 0000000000..ba3959286a --- /dev/null +++ b/test/int/federated_auth_test.go @@ -0,0 +1,160 @@ +package int + +import ( + "context" + "fmt" + "time" + + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/toptr" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "go.mongodb.org/atlas/mongodbatlas" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/testutil" +) + +var _ = Describe("AtlasFederatedAuth test", Label("AtlasFederatedAuth", "federated-auth"), func() { + var testNamespace *corev1.Namespace + var stopManager context.CancelFunc + var connectionSecret corev1.Secret + + var originalConnectedOrgConfig *mongodbatlas.FederatedSettingsConnectedOrganization + var originalFederationSettings *mongodbatlas.FederatedSettings + var originalIdp *mongodbatlas.FederatedSettingsIdentityProvider + + resourceName := "fed-auth-test" + ctx := context.Background() + + BeforeEach(func() { + By("Checking if Federation Settings enabled for the org", func() { + federationSettings, _, err := atlasClient.FederatedSettings.Get(ctx, connection.OrgID) + Expect(err).ToNot(HaveOccurred()) + Expect(federationSettings).ShouldNot(BeNil()) + + originalFederationSettings = federationSettings + }) + + By("Getting original IDP", func() { + idp, _, err := atlasClient.FederatedSettings.GetIdentityProvider(ctx, originalFederationSettings.ID, originalFederationSettings.IdentityProviderID) + Expect(err).ToNot(HaveOccurred()) + Expect(idp).ShouldNot(BeNil()) + + originalIdp = idp + }) + + By("Getting existing org config", func() { + connectedOrgConfig, _, err := atlasClient.FederatedSettings.GetConnectedOrg(ctx, originalFederationSettings.ID, connection.OrgID) + Expect(err).ToNot(HaveOccurred()) + Expect(connectedOrgConfig).ShouldNot(BeNil()) + + originalConnectedOrgConfig = connectedOrgConfig + }) + + By("Starting the operator with protection OFF", func() { + testNamespace, stopManager = prepareControllers(false) + Expect(testNamespace).ShouldNot(BeNil()) + Expect(stopManager).ShouldNot(BeNil()) + }) + + By("Creating project connection secret", func() { + connectionSecret = buildConnectionSecret(fmt.Sprintf("%s-atlas-key", testNamespace.Name)) + Expect(k8sClient.Create(ctx, &connectionSecret)).To(Succeed()) + }) + }) + + It("Should be able to update existing Organization's federations settings", func() { + By("Creating a FederatedAuthConfig resource", func() { + roles := []mdbv1.RoleMapping{} + + for i := range originalConnectedOrgConfig.RoleMappings { + atlasRole := *(originalConnectedOrgConfig.RoleMappings[i]) + newRole := mdbv1.RoleMapping{ + ExternalGroupName: atlasRole.ExternalGroupName, + RoleAssignments: []mdbv1.RoleAssignment{}, + } + + for j := range atlasRole.RoleAssignments { + atlasRS := atlasRole.RoleAssignments[j] + project, _, err := atlasClient.Projects.GetOneProject(ctx, atlasRS.GroupID) + Expect(err).NotTo(HaveOccurred()) + Expect(project).NotTo(BeNil()) + + newRS := mdbv1.RoleAssignment{ + ProjectName: project.Name, + Role: atlasRS.Role, + } + newRole.RoleAssignments = append(newRole.RoleAssignments, newRS) + } + roles = append(roles, newRole) + } + + fedAuth := &mdbv1.AtlasFederatedAuth{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: testNamespace.Name, + }, + Spec: mdbv1.AtlasFederatedAuthSpec{ + Enabled: true, + ConnectionSecretRef: common.ResourceRefNamespaced{ + Name: connectionSecret.Name, + Namespace: connectionSecret.Namespace, + }, + DomainAllowList: append(originalConnectedOrgConfig.DomainAllowList, "cloud-qa.mongodb.com"), + DomainRestrictionEnabled: toptr.MakePtr(true), + SSODebugEnabled: toptr.MakePtr(false), + PostAuthRoleGrants: []string{"ORG_MEMBER"}, + RoleMappings: roles, + }, + } + + Expect(k8sClient.Create(ctx, fedAuth)).NotTo(HaveOccurred()) + }) + + By("Federated Auth is ready", func() { + Eventually(func(g Gomega) { + fedAuth := &mdbv1.AtlasFederatedAuth{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: resourceName, Namespace: testNamespace.Name}, fedAuth)).To(Succeed()) + g.Expect(testutil.CheckCondition(k8sClient, fedAuth, status.TrueCondition(status.ReadyType))).To(BeTrue()) + }).WithTimeout(10 * time.Minute).WithPolling(PollingInterval).Should(Succeed()) + }) + + By("Set initial config back", func() { + fedAuth := &mdbv1.AtlasFederatedAuth{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: resourceName, Namespace: testNamespace.Name}, fedAuth)).To(Succeed()) + + fedAuth.Spec.DomainAllowList = originalConnectedOrgConfig.DomainAllowList + fedAuth.Spec.DomainRestrictionEnabled = originalConnectedOrgConfig.DomainRestrictionEnabled + fedAuth.Spec.SSODebugEnabled = originalIdp.SsoDebugEnabled + fedAuth.Spec.PostAuthRoleGrants = originalConnectedOrgConfig.PostAuthRoleGrants + + Expect(k8sClient.Update(ctx, fedAuth)).NotTo(HaveOccurred()) + }) + + By("Federated Auth is ready", func() { + Eventually(func(g Gomega) { + fedAuth := &mdbv1.AtlasFederatedAuth{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: resourceName, Namespace: testNamespace.Name}, fedAuth)).To(Succeed()) + g.Expect(testutil.CheckCondition(k8sClient, fedAuth, status.TrueCondition(status.ReadyType))).To(BeTrue()) + }).WithTimeout(10 * time.Minute).WithPolling(PollingInterval).Should(Succeed()) + }) + }) + + AfterEach(func() { + By("Should delete connection secret", func() { + Expect(k8sClient.Delete(ctx, &connectionSecret)).To(Succeed()) + }) + + By("Should stop the operator", func() { + stopManager() + Expect(k8sClient.Delete(ctx, testNamespace)).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/test/int/integration_suite_test.go b/test/int/integration_suite_test.go index 310c2d75c0..b6d7a11080 100644 --- a/test/int/integration_suite_test.go +++ b/test/int/integration_suite_test.go @@ -28,6 +28,7 @@ import ( "time" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlasdatafederation" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlasfederatedauth" "github.com/mongodb/mongodb-atlas-kubernetes/test/helper" "sigs.k8s.io/controller-runtime/pkg/client/config" @@ -278,6 +279,18 @@ func prepareControllers(deletionProtection bool) (*corev1.Namespace, context.Can }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) + err = (&atlasfederatedauth.AtlasFederatedAuthReconciler{ + Client: k8sManager.GetClient(), + Log: logger.Named("controllers").Named("AtlasFederatedAuth").Sugar(), + AtlasDomain: atlasDomain, + ResourceWatcher: watch.NewResourceWatcher(), + GlobalPredicates: globalPredicates, + EventRecorder: k8sManager.GetEventRecorderFor("AtlasFederatedAuth"), + ObjectDeletionProtection: deletionProtection, + SubObjectDeletionProtection: deletionProtection, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + By("Starting controllers") var ctx context.Context From 2885525fb1b85c81d063d3dbe28a498daa2f441e Mon Sep 17 00:00:00 2001 From: Helder Santana Date: Fri, 6 Oct 2023 13:05:53 +0200 Subject: [PATCH 2/3] fix helm-ns test --- test/e2e/helm_chart_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/helm_chart_test.go b/test/e2e/helm_chart_test.go index 1635748d80..c38a1a29fb 100644 --- a/test/e2e/helm_chart_test.go +++ b/test/e2e/helm_chart_test.go @@ -101,6 +101,7 @@ var _ = Describe("HELM charts", func() { } }) deleteDeploymentAndOperator(&data) + helm.UninstallCRD(data.Resources) }, Entry("Several actions with helm update", Label("helm-ns-flow"), model.DataProviderWithResources( From a8ba9800e128b4e44e393ccebdead5b99ce00efd Mon Sep 17 00:00:00 2001 From: Helder Santana Date: Fri, 6 Oct 2023 13:55:05 +0200 Subject: [PATCH 3/3] fix helm-ns test --- test/e2e/cli/helm/helm.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/cli/helm/helm.go b/test/e2e/cli/helm/helm.go index 9c98d78c92..f4d276bd50 100644 --- a/test/e2e/cli/helm/helm.go +++ b/test/e2e/cli/helm/helm.go @@ -97,7 +97,7 @@ func InstallCRD(input model.UserInputs) { } func UninstallCRD(input model.UserInputs) { - Uninstall("mongodb-atlas-operator-crds", "default") + Uninstall("mongodb-atlas-operator-crds"+input.TestID, input.Namespace) } func InstallOperatorWideSubmodule(input model.UserInputs) {