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..72d8ab9c74 --- /dev/null +++ b/config/crd/bases/atlas.mongodb.com_atlasfederatedauths.yaml @@ -0,0 +1,190 @@ +--- +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. + enum: + - ORG_MEMBER + - ORG_READ_ONLY + - ORG_BILLING_ADMIN + - ORG_GROUP_CREATOR + - ORG_OWNER + - ORG_BILLING_READ_ONLY + - ORG_TEAM_MEMBERS_ADMIN + 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: + projectRef: + description: The Atlas project in which the role should + be given. + 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 + 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/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 88d3ad0b15..d57d30632b 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/go.sum b/go.sum index 5fb258281e..fec46342a3 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,7 @@ cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZ cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go v0.110.6 h1:8uYAkj3YHTP/1iwReuHPxLSbdcyc+dSBbzFMrVwDR6Q= +cloud.google.com/go v0.110.6/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= @@ -49,6 +50,7 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQ github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 h1:sXr+ck84g/ZlZUOZiNELInmMgOsuGwdjjVkEIde0OtY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2 h1:mLY+pNLjCUeKhgnAJWAKhEUQM+RJQo2H1fuGSw1Ky1E= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2/go.mod h1:FbdwsQ2EzwvXxOPcMFYO8ogEc9uMMIj3YkmCdXdAFmk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.2.0 h1:8d4U82r7ItT1Es91x3eUcAQweih36KWvUha8AZ9X0Rs= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.2.0/go.mod h1:/1bkGperHinQbAHMWivoec/Ucu6//iXo6jn5mhmqCVU= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2 v2.2.1 h1:bWh0Z2rOEDfB/ywv/l0iHN1JgyazE6kW/aIA89+CEK0= @@ -123,6 +125,7 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE= github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= @@ -168,6 +171,7 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= +github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -417,6 +421,7 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= @@ -473,6 +478,7 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/pkg/api/v1/atlascustomresource.go b/pkg/api/v1/atlascustomresource.go index c4ae4bb7bd..6ba4bfd0b2 100644 --- a/pkg/api/v1/atlascustomresource.go +++ b/pkg/api/v1/atlascustomresource.go @@ -21,3 +21,4 @@ var _ AtlasCustomResource = &AtlasProject{} var _ AtlasCustomResource = &AtlasDeployment{} var _ AtlasCustomResource = &AtlasDatabaseUser{} var _ AtlasCustomResource = &AtlasDataFederation{} +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..0e0c134d5c --- /dev/null +++ b/pkg/api/v1/atlasfederatedauth_types.go @@ -0,0 +1,143 @@ +package v1 + +import ( + "context" + "errors" + "fmt" + + "go.mongodb.org/atlas/mongodbatlas" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status" +) + +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. + // +kubebuilder:validation:Enum=ORG_MEMBER;ORG_READ_ONLY;ORG_BILLING_ADMIN;ORG_GROUP_CREATOR;ORG_OWNER;ORG_BILLING_READ_ONLY;ORG_TEAM_MEMBERS_ADMIN + // +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, client *mongodbatlas.Client) (*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 != "" { + projectData, _, err := client.Projects.GetOneProjectByName(context.Background(), roleAssignment.ProjectName) + if err != nil { + errs = append(errs, err) + continue + } + + if projectData == nil { + errs = append(errs, fmt.Errorf("projectData is not available for projectName '%s'. %w", roleAssignment.ProjectName, err)) + continue + } + atlasRoleAssignment.GroupID = projectData.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) 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..8b640c1fce --- /dev/null +++ b/pkg/api/v1/atlasfederatedauth_types_test.go @@ -0,0 +1,189 @@ +package v1 + +import ( + "context" + "net/http" + "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/controller/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/toptr" +) + +type projectClient struct { + GetProject func() (*mongodbatlas.Project, *mongodbatlas.Response, error) +} + +func (projectClient) GetAllProjects(_ context.Context, _ *mongodbatlas.ListOptions) (*mongodbatlas.Projects, *mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) GetOneProject(_ context.Context, _ string) (*mongodbatlas.Project, *mongodbatlas.Response, error) { + panic("not implemented") +} +func (pc *projectClient) GetOneProjectByName(_ context.Context, _ string) (*mongodbatlas.Project, *mongodbatlas.Response, error) { + return pc.GetProject() +} +func (projectClient) Create(_ context.Context, _ *mongodbatlas.Project, _ *mongodbatlas.CreateProjectOptions) (*mongodbatlas.Project, *mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) Update(_ context.Context, _ string, _ *mongodbatlas.ProjectUpdateRequest) (*mongodbatlas.Project, *mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) Delete(_ context.Context, _ string) (*mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) GetProjectTeamsAssigned(_ context.Context, _ string) (*mongodbatlas.TeamsAssigned, *mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) AddTeamsToProject(_ context.Context, _ string, _ []*mongodbatlas.ProjectTeam) (*mongodbatlas.TeamsAssigned, *mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) RemoveUserFromProject(_ context.Context, _ string, _ string) (*mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) Invitations(_ context.Context, _ string, _ *mongodbatlas.InvitationOptions) ([]*mongodbatlas.Invitation, *mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) Invitation(_ context.Context, _ string, _ string) (*mongodbatlas.Invitation, *mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) InviteUser(_ context.Context, _ string, _ *mongodbatlas.Invitation) (*mongodbatlas.Invitation, *mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) UpdateInvitation(_ context.Context, _ string, _ *mongodbatlas.Invitation) (*mongodbatlas.Invitation, *mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) UpdateInvitationByID(_ context.Context, _ string, _ string, _ *mongodbatlas.Invitation) (*mongodbatlas.Invitation, *mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) DeleteInvitation(_ context.Context, _ string, _ string) (*mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) GetProjectSettings(_ context.Context, _ string) (*mongodbatlas.ProjectSettings, *mongodbatlas.Response, error) { + panic("not implemented") +} +func (projectClient) UpdateProjectSettings(_ context.Context, _ string, _ *mongodbatlas.ProjectSettings) (*mongodbatlas.ProjectSettings, *mongodbatlas.Response, error) { + panic("not implemented") +} + +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" + + projectID := "test-project-id" + + pc := &projectClient{ + GetProject: func() (*mongodbatlas.Project, *mongodbatlas.Response, error) { + return &mongodbatlas.Project{ + ID: projectID, + OrgID: orgID, + }, &mongodbatlas.Response{ + Response: &http.Response{ + Status: "", + StatusCode: http.StatusOK, + }, + }, nil + }, + } + + spec := &AtlasFederatedAuthSpec{ + Enabled: toptr.MakePtr(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: "test-project", + Role: "test-role", + }, + }, + }, + }, + } + + result, err := spec.ToAtlas(orgID, idpID, &mongodbatlas.Client{ + Projects: pc, + }) + + 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: projectID, + 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" + + pc := &projectClient{ + GetProject: func() (*mongodbatlas.Project, *mongodbatlas.Response, error) { + return nil, &mongodbatlas.Response{ + Response: &http.Response{ + Status: atlas.NotInGroup, + StatusCode: http.StatusNotFound, + }, + }, nil + }, + } + + spec := &AtlasFederatedAuthSpec{ + Enabled: toptr.MakePtr(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: "test-project", + Role: "test-role", + }, + }, + }, + }, + } + + result, err := spec.ToAtlas(orgID, idpID, &mongodbatlas.Client{ + Projects: pc, + }) + + 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..e53f8d491e 100644 --- a/pkg/api/v1/zz_generated.deepcopy.go +++ b/pkg/api/v1/zz_generated.deepcopy.go @@ -710,6 +710,113 @@ 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 + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(bool) + **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 +2050,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/atlasfederatedauth.go b/pkg/controller/atlasfederatedauth/atlasfederatedauth.go new file mode 100644 index 0000000000..1ccf8d22c2 --- /dev/null +++ b/pkg/controller/atlasfederatedauth/atlasfederatedauth.go @@ -0,0 +1,75 @@ +package atlasfederatedauth + +import ( + "context" + "fmt" + "reflect" + + "go.mongodb.org/atlas/mongodbatlas" + + mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/workflow" +) + +const ( + ErrInvalidProvider = "INVALID_PROVIDER" + ErrNotOrgGroupCreator = "NOT_ORG_GROUP_CREATOR" +) + +func (r *AtlasFederatedAuthReconciler) ensureFederatedAuth(service *workflow.Context, fedauth *mdbv1.AtlasFederatedAuth) workflow.Result { + // If disabled, skip with no error + if fedauth.Spec.Enabled == nil || (fedauth.Spec.Enabled != nil && !*fedauth.Spec.Enabled) { + return workflow.OK().WithMessage(string(workflow.FederatedAuthIsNotEnabledInCR)) + } + + orgID := service.Connection.OrgID + + atlasFedSettings, _, err := service.Client.FederatedSettings.Get(context.Background(), orgID) + if err != nil { + return workflow.Terminate(workflow.FederatedAuthNotAvailable, err.Error()) + } + + atlasFedSettingsID := atlasFedSettings.ID + + orgConfig, _, err := service.Client.FederatedSettings.GetConnectedOrg(context.Background(), atlasFedSettingsID, orgID) + if err != nil { + return workflow.Terminate(workflow.FederatedAuthOrgNotConnected, err.Error()) + } + + operatorConf, err := fedauth.Spec.ToAtlas(orgID, orgConfig.IdentityProviderID, &service.Client) + if err != nil { + return workflow.Terminate(workflow.Internal, fmt.Sprintln("Can not convert Federated Auth spec to Atlas", err.Error())) + } + + connectedOrgSettings, _, err := service.Client.FederatedSettings.GetConnectedOrg(context.Background(), atlasFedSettingsID, orgID) + if err != nil { + return workflow.Terminate(workflow.Internal, err.Error()) + } + + if federatedSettingsAreEqual(operatorConf, connectedOrgSettings) { + 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 federatedSettingsAreEqual(operator, atlas *mongodbatlas.FederatedSettingsConnectedOrganization) bool { + operator.UserConflicts = nil + atlas.UserConflicts = nil + return reflect.DeepEqual(operator, atlas) +} diff --git a/pkg/controller/atlasfederatedauth/atlasfederatedauth_controller.go b/pkg/controller/atlasfederatedauth/atlasfederatedauth_controller.go new file mode 100644 index 0000000000..c4e9297bf0 --- /dev/null +++ b/pkg/controller/atlasfederatedauth/atlasfederatedauth_controller.go @@ -0,0 +1,179 @@ +package atlasfederatedauth + +import ( + "context" + "errors" + "fmt" + + "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.ResourceShouldBeLeftInAtlas(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") + + resourceVersionIsValid := customresource.ValidateResourceVersion(workflowCtx, fedauth, r.Log) + if !resourceVersionIsValid.IsOk() { + r.Log.Debugf("federated auth validation result: %v", resourceVersionIsValid) + return resourceVersionIsValid.ReconcileResult(), nil + } + + connection, err := atlas.ReadConnection(log, r.Client, types.NamespacedName{}, + &types.NamespacedName{ + Name: fedauth.Spec.ConnectionSecretRef.Name, + Namespace: fedauth.Spec.ConnectionSecretRef.Namespace, + }, + ) + if err != nil { + result = workflow.Terminate(workflow.AtlasCredentialsNotProvided, err.Error()) + setCondition(workflowCtx, status.ProjectReadyType, 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 + } + + convertedAuth, err := fedauth.Spec.ToAtlas(orgID, atlasFedAuth.IdentityProviderID, &atlasClient) + if err != nil { + return false, err + } + + return !federatedSettingsAreEqual(convertedAuth, atlasFedAuth), nil + } +} diff --git a/pkg/controller/workflow/reason.go b/pkg/controller/workflow/reason.go index 36095e4648..8e744f5fb1 100644 --- a/pkg/controller/workflow/reason.go +++ b/pkg/controller/workflow/reason.go @@ -77,6 +77,7 @@ const ( DataFederationUpdating ConditionReason = "DataFederationUpdating" ) +// Atlas Teams reasons const ( TeamNotCreatedInAtlas ConditionReason = "TeamNotCreatedInAtlas" TeamNotUpdatedInAtlas ConditionReason = "TeamNotUpdatedInAtlas" @@ -84,3 +85,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" +)