diff --git a/internal/kubernetes/operator/config_exporter.go b/internal/kubernetes/operator/config_exporter.go index 524dea96d4..6873effa3b 100644 --- a/internal/kubernetes/operator/config_exporter.go +++ b/internal/kubernetes/operator/config_exporter.go @@ -1,4 +1,4 @@ -// Copyright 2022 MongoDB Inc +// Copyright 2024 MongoDB Inc // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import ( "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/kubernetes/operator/dbusers" "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/kubernetes/operator/deployment" "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/kubernetes/operator/features" + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/kubernetes/operator/federatedauthentication" "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/kubernetes/operator/project" "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/kubernetes/operator/resources" "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/kubernetes/operator/streamsprocessing" @@ -38,6 +39,7 @@ const ( yamlSeparator = "---\r\n" maxClusters = 500 DefaultClustersCount = 10 + InactiveStatus = "INACTIVE" ) type ConfigExporter struct { @@ -143,6 +145,12 @@ func (e *ConfigExporter) Run() (string, error) { } r = append(r, dataFederationResource...) + federatedAuthResource, err := e.exportAtlasFederatedAuth(projectName) + if err != nil { + return "", err + } + r = append(r, federatedAuthResource...) + streamProcessingResources, err := e.exportAtlasStreamProcessing(projectName) if err != nil { return "", err @@ -400,3 +408,38 @@ func (e *ConfigExporter) exportAtlasStreamProcessing(projectName string) ([]runt return result, nil } + +func (e *ConfigExporter) exportAtlasFederatedAuth(projectName string) ([]runtime.Object, error) { + if !e.featureValidator.IsResourceSupported(features.ResourceAtlasFederatedAuth) { + return nil, nil + } + result := make([]runtime.Object, 0) + // Gets the FederationAuthSetting + federatedAuthentificationSetting, err := e.dataProvider.FederationSetting(&admin.GetFederationSettingsApiParams{OrgId: e.orgID}) + if err != nil { + return nil, fmt.Errorf("failed to retrieve federation settings: %w", err) + } + // Does not have an IdenityProvider set then no need to generate + if !federatedAuthentificationSetting.HasIdentityProviderStatus() || federatedAuthentificationSetting.GetIdentityProviderStatus() == InactiveStatus { + return nil, nil + } + // Does have an IdentityProvider and then we can generate the config + federatedAuthentification, err := federatedauthentication.BuildAtlasFederatedAuth(&federatedauthentication.AtlasFederatedAuthBuildRequest{ + IncludeSecret: e.includeSecretsData, + IdentityProviderLister: e.dataProvider, + ConnectedOrgConfigsDescriber: e.dataProvider, + IdentityProviderDescriber: e.dataProvider, + ProjectStore: e.dataProvider, + ProjectID: e.projectID, + OrgID: e.orgID, + TargetNamespace: e.targetNamespace, + Version: e.operatorVersion, + Dictionary: e.dictionaryForAtlasNames, + ProjectName: projectName, + FederatedSettings: federatedAuthentificationSetting, + }) + if err != nil { + return nil, fmt.Errorf("failed to export federated authentication: %w", err) + } + return append(result, federatedAuthentification), nil +} diff --git a/internal/kubernetes/operator/config_exporter_test.go b/internal/kubernetes/operator/config_exporter_test.go index e25c31e0d5..327f4a5a0e 100644 --- a/internal/kubernetes/operator/config_exporter_test.go +++ b/internal/kubernetes/operator/config_exporter_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 MongoDB Inc +// Copyright 2024 MongoDB Inc // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -416,3 +416,246 @@ func TestExportAtlasStreamProcessing(t *testing.T) { ) }) } + +const ( + legacyTestIdentityProviderID = "LegacyTestIdentityProviderID" + testIdentityProviderID = "TestIdentityProviderID" +) + +var ( + testProjectID = []string{"test-project-1", "test-project-2"} + secondTestProjectID = []string{"test-project-3", "test-project-4"} + testRoleProject = []string{"GROUP_OWNER", "GROUP_OWNER"} + testRoleOrganization = []string{"ORG_OWNER", "ORG_OWNER"} + testExternalGroupName = []string{"org-admin", "dev-team"} +) + +func Test_ExportFederatedAuth(t *testing.T) { + testCases := []struct { + name string + setupMocks func(*mocks.MockOperatorGenericStore, *mocks.MockFeatureValidator) + expected []runtime.Object + expectedError error + }{ + { + name: "should return exported resources", + setupMocks: func(store *mocks.MockOperatorGenericStore, featureValidator *mocks.MockFeatureValidator) { + legacyTestIdentityProviderID := "LegacyTestIdentityProviderID" + federationSettings := &admin.OrgFederationSettings{ + Id: pointer.Get("TestFederationSettingID"), + IdentityProviderId: &legacyTestIdentityProviderID, + IdentityProviderStatus: pointer.Get("ACTIVE"), + HasRoleMappings: pointer.Get(true), + } + + featureValidator.EXPECT(). + IsResourceSupported(features.ResourceAtlasFederatedAuth). + Return(true) + store.EXPECT().FederationSetting(&admin.GetFederationSettingsApiParams{OrgId: orgID}). + Return(federationSettings, nil) + orgConfig := &admin.ConnectedOrgConfig{ + DomainAllowList: &[]string{"example.com"}, + PostAuthRoleGrants: &[]string{"ORG_OWNER"}, + DomainRestrictionEnabled: true, + RoleMappings: pointer.Get(setupAuthRoleMappings(testProjectID, secondTestProjectID, testRoleProject, testRoleOrganization, testExternalGroupName, "testOrganizationID")), + IdentityProviderId: federationSettings.IdentityProviderId, + } + store.EXPECT().GetConnectedOrgConfig(&admin.GetConnectedOrgConfigApiParams{FederationSettingsId: *federationSettings.Id, OrgId: orgID}). + Return(orgConfig, nil) + + identityProvider := &admin.FederationIdentityProvider{ + SsoDebugEnabled: pointer.Get(true), + OktaIdpId: *federationSettings.IdentityProviderId, + Id: "TestIdentityProviderID", + } + paginatedResult := &admin.PaginatedFederationIdentityProvider{ + Results: &[]admin.FederationIdentityProvider{ + *identityProvider, + }, + TotalCount: pointer.Get(1), + } + store.EXPECT().IdentityProviders(&admin.ListIdentityProvidersApiParams{FederationSettingsId: *federationSettings.Id}). + Return(paginatedResult, nil) + + firstProject := &admin.Group{Id: pointer.Get("test-project-1"), Name: "test-project-name-1", OrgId: orgID} + secondProject := &admin.Group{Id: pointer.Get("test-project-1"), Name: "test-project-name-2", OrgId: orgID} + + store.EXPECT().Project("test-project-1").Return(firstProject, nil) + store.EXPECT().Project("test-project-2").Return(secondProject, nil) + store.EXPECT().Project("test-project-3").Return(firstProject, nil) + store.EXPECT().Project("test-project-4").Return(secondProject, nil) + }, + expected: []runtime.Object{ + &akov2.AtlasFederatedAuth{ + TypeMeta: metav1.TypeMeta{ + Kind: "AtlasFederatedAuth", + APIVersion: "atlas.mongodb.com/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project-testfederationsettingid", + Namespace: "test", + }, + Spec: akov2.AtlasFederatedAuthSpec{ + ConnectionSecretRef: akov2common.ResourceRefNamespaced{ + Name: "my-project-credentials", + Namespace: "test", + }, + Enabled: true, + DomainAllowList: []string{"example.com"}, + PostAuthRoleGrants: []string{"ORG_OWNER"}, + DomainRestrictionEnabled: pointer.Get(true), + SSODebugEnabled: pointer.Get(true), + RoleMappings: []akov2.RoleMapping{ + { + ExternalGroupName: "org-admin", + RoleAssignments: []akov2.RoleAssignment{ + { + ProjectName: "test-project-name-1", + Role: "GROUP_OWNER", + }, + }, + }, + { + ExternalGroupName: "dev-team", + RoleAssignments: []akov2.RoleAssignment{ + { + ProjectName: "test-project-name-2", + Role: "GROUP_OWNER", + }, + }, + }, + { + ExternalGroupName: "org-admin", + RoleAssignments: []akov2.RoleAssignment{ + { + Role: "ORG_OWNER", + }, + { + ProjectName: "test-project-name-1", + Role: "GROUP_OWNER", + }, + }, + }, + { + ExternalGroupName: "dev-team", + RoleAssignments: []akov2.RoleAssignment{ + { + Role: "ORG_OWNER", + }, + { + ProjectName: "test-project-name-2", + Role: "GROUP_OWNER", + }, + }, + }, + }, + }, + Status: akov2status.AtlasFederatedAuthStatus{ + Common: akoapi.Common{ + Conditions: []akoapi.Condition{}, + }, + }, + }, + }, + expectedError: nil, + }, + { + name: "should return nothing because the IDP is not active", + setupMocks: func(store *mocks.MockOperatorGenericStore, featureValidator *mocks.MockFeatureValidator) { + federationSettings := &admin.OrgFederationSettings{ + Id: pointer.Get("TestFederationSettingID"), + IdentityProviderStatus: pointer.Get("INACTIVE"), + IdentityProviderId: pointer.Get(legacyTestIdentityProviderID), + HasRoleMappings: pointer.Get(false), + } + featureValidator.EXPECT(). + IsResourceSupported(features.ResourceAtlasFederatedAuth). + Return(true) + store.EXPECT().FederationSetting(&admin.GetFederationSettingsApiParams{OrgId: orgID}). + Return(federationSettings, nil) + }, + expected: nil, + expectedError: nil, + }, + { + name: "should return nothing because the IDP is not present", + setupMocks: func(store *mocks.MockOperatorGenericStore, featureValidator *mocks.MockFeatureValidator) { + federationSettings := &admin.OrgFederationSettings{ + Id: pointer.Get("TestFederationSettingID"), + HasRoleMappings: pointer.Get(false), + } + featureValidator.EXPECT(). + IsResourceSupported(features.ResourceAtlasFederatedAuth). + Return(true) + store.EXPECT().FederationSetting(&admin.GetFederationSettingsApiParams{OrgId: orgID}). + Return(federationSettings, nil) + }, + expected: nil, + expectedError: nil, + }, + { + name: "should return nil when resource is not supported", + setupMocks: func(_ *mocks.MockOperatorGenericStore, featureValidator *mocks.MockFeatureValidator) { + featureValidator.EXPECT(). + IsResourceSupported(features.ResourceAtlasFederatedAuth). + Return(false) + }, + expected: nil, + expectedError: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctl := gomock.NewController(t) + atlasOperatorGenericStore := mocks.NewMockOperatorGenericStore(ctl) + featureValidator := mocks.NewMockFeatureValidator(ctl) + ce := defaultTestConfigExporter(t, atlasOperatorGenericStore, featureValidator) + defer ctl.Finish() + tc.setupMocks(atlasOperatorGenericStore, featureValidator) + + resources, err := ce.exportAtlasFederatedAuth("my-project") + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expected, resources) + }) + } +} +func defaultTestConfigExporter(t *testing.T, genStore *mocks.MockOperatorGenericStore, featureValidator *mocks.MockFeatureValidator) *ConfigExporter { + t.Helper() + return NewConfigExporter(genStore, nil, projectID, orgID). + WithTargetNamespace("test"). + WithFeatureValidator(featureValidator). + WithTargetOperatorVersion("2.4.0"). + WithSecretsData(true) +} + +func setupAuthRoleMappings(testProjectID, secondTestProjectID, testRoleProject, testRoleOrganization, testExternalGroupName []string, testOrganizationID string) []admin.AuthFederationRoleMapping { + AuthRoleMappings := make([]admin.AuthFederationRoleMapping, len(testRoleProject)+len(testRoleOrganization)) + for i := range testProjectID { + AuthRoleMappings[i] = admin.AuthFederationRoleMapping{ + ExternalGroupName: testExternalGroupName[i], + RoleAssignments: &[]admin.RoleAssignment{ + { + GroupId: &testProjectID[i], + Role: &testRoleProject[i], + }, + }, + } + } + for i := range testRoleOrganization { + AuthRoleMappings[len(testProjectID)+i] = admin.AuthFederationRoleMapping{ + ExternalGroupName: testExternalGroupName[i], + RoleAssignments: &[]admin.RoleAssignment{ + { + OrgId: &testOrganizationID, + Role: &testRoleOrganization[i], + }, + { + GroupId: &secondTestProjectID[i], + Role: &testRoleProject[i], + }, + }, + } + } + return AuthRoleMappings +} diff --git a/internal/kubernetes/operator/federatedauthentication/federatedauthentication.go b/internal/kubernetes/operator/federatedauthentication/federatedauthentication.go new file mode 100644 index 0000000000..e3b3133fe7 --- /dev/null +++ b/internal/kubernetes/operator/federatedauthentication/federatedauthentication.go @@ -0,0 +1,174 @@ +// Copyright 2024 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package federatedauthentication + +import ( + "errors" + "fmt" + + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/kubernetes/operator/resources" + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/pointer" + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/store" + akoapi "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1" + akov2common "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1/common" + akov2status "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1/status" + atlasv2 "go.mongodb.org/atlas-sdk/v20240530005/admin" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + ErrNoMatchingSAMLProvider = errors.New("failed to retrieve the SAML identity provider matching the legacy ID") +) + +type AtlasFederatedAuthBuildRequest struct { + IncludeSecret bool + IdentityProviderLister store.IdentityProviderLister + ConnectedOrgConfigsDescriber store.ConnectedOrgConfigsDescriber + ProjectStore store.OperatorProjectStore + IdentityProviderDescriber store.IdentityProviderDescriber + ProjectName string + OrgID string + ProjectID string + FederatedSettings *atlasv2.OrgFederationSettings + Version string + TargetNamespace string + Dictionary map[string]string +} + +const credSecretFormat = "%s-credentials" + +// BuildAtlasFederatedAuth builds an AtlasFederatedAuth resource. +func BuildAtlasFederatedAuth(br *AtlasFederatedAuthBuildRequest) (*akov2.AtlasFederatedAuth, error) { + orgConfig, err := getOrgConfig(br) + if err != nil { + return nil, err + } + + spec, err := atlasFederatedAuthSpec(*br, orgConfig) + if err != nil { + return nil, err + } + return &akov2.AtlasFederatedAuth{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "atlas.mongodb.com/v1", + Kind: "AtlasFederatedAuth", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: resources.NormalizeAtlasName(fmt.Sprintf("%s-%s", br.ProjectName, br.FederatedSettings.GetId()), br.Dictionary), + Namespace: br.TargetNamespace, + }, + Spec: *spec, + Status: akov2status.AtlasFederatedAuthStatus{ + Common: akoapi.Common{ + Conditions: []akoapi.Condition{}, + }, + }, + }, nil +} + +// getOrgConfig retrieves the organization configuration for the AtlasFederatedAuth resource. +func getOrgConfig(br *AtlasFederatedAuthBuildRequest) (*atlasv2.ConnectedOrgConfig, error) { + return br.ConnectedOrgConfigsDescriber.GetConnectedOrgConfig(&atlasv2.GetConnectedOrgConfigApiParams{ + FederationSettingsId: br.FederatedSettings.GetId(), + OrgId: br.OrgID, + }) +} + +// atlasFederatedAuthSpec returns the spec for AtlasFederatedAuth. +func atlasFederatedAuthSpec(br AtlasFederatedAuthBuildRequest, orgConfig *atlasv2.ConnectedOrgConfig) (*akov2.AtlasFederatedAuthSpec, error) { + idp, err := GetIdentityProviderForFederatedSettings(br.IdentityProviderLister, br.FederatedSettings.GetId(), br.FederatedSettings.GetIdentityProviderId()) + if err != nil { + return nil, fmt.Errorf("failed to retrieve the federated authentication spec: %w", err) + } + authSpec := akov2.AtlasFederatedAuthSpec{ + Enabled: true, + DomainAllowList: orgConfig.GetDomainAllowList(), + ConnectionSecretRef: getSecretRef(br), + DomainRestrictionEnabled: pointer.Get(orgConfig.GetDomainRestrictionEnabled()), + PostAuthRoleGrants: orgConfig.GetPostAuthRoleGrants(), + SSODebugEnabled: pointer.Get(idp.GetSsoDebugEnabled()), + } + if br.FederatedSettings.HasHasRoleMappings() && orgConfig.HasRoleMappings() { + authSpec.RoleMappings, err = getRoleMappings(orgConfig.GetRoleMappings(), br.ProjectStore) + if err != nil { + return nil, fmt.Errorf("failed to retrieve the role mappings: %w", err) + } + } + + return &authSpec, nil +} + +// getSecretRef generates a secret reference for the AtlasFederatedAuthSpec. +func getSecretRef(br AtlasFederatedAuthBuildRequest) akov2common.ResourceRefNamespaced { + secretRef := &akov2common.ResourceRefNamespaced{} + if br.IncludeSecret { + secretRef.Name = resources.NormalizeAtlasName(fmt.Sprintf(credSecretFormat, br.ProjectName), br.Dictionary) + secretRef.Namespace = br.TargetNamespace + } + return *secretRef +} + +// getRoleMappings converts AuthFederationRoleMapping to RoleMapping. +func getRoleMappings(mappings []atlasv2.AuthFederationRoleMapping, projectStore store.OperatorProjectStore) ([]akov2.RoleMapping, error) { + roleMappings := make([]akov2.RoleMapping, 0, len(mappings)) + for _, mapping := range mappings { + if mapping.HasRoleAssignments() { + roleAssignemnts, err := getRoleAssignments(mapping.GetRoleAssignments(), projectStore) + if err != nil { + return nil, fmt.Errorf("failed to retrieve the role assignments: %w", err) + } + roleMappings = append(roleMappings, akov2.RoleMapping{ + ExternalGroupName: mapping.GetExternalGroupName(), + RoleAssignments: roleAssignemnts, + }) + } + } + return roleMappings, nil +} + +// getRoleAssignments converts RoleAssignments from AuthFederationRoleMapping. +func getRoleAssignments(assignments []atlasv2.RoleAssignment, projectStore store.OperatorProjectStore) ([]akov2.RoleAssignment, error) { + roleAssignments := make([]akov2.RoleAssignment, 0, len(assignments)) + for _, ra := range assignments { + roleAssignment := akov2.RoleAssignment{Role: ra.GetRole()} + if ra.HasGroupId() { + project, err := projectStore.Project(ra.GetGroupId()) + if err != nil { + return nil, fmt.Errorf("failed to retrieve the project: %w", err) + } + roleAssignment.ProjectName = project.GetName() + } + roleAssignments = append(roleAssignments, roleAssignment) + } + return roleAssignments, nil +} + +// GetIdentityProviderForFederatedSettings retrieves the requested identityprovider from a list of the identity provider for the given federation settings. +func GetIdentityProviderForFederatedSettings(st store.IdentityProviderLister, federationSettingsID string, identityProviderID string) (*atlasv2.FederationIdentityProvider, error) { + identityProviders, err := st.IdentityProviders(&atlasv2.ListIdentityProvidersApiParams{ + FederationSettingsId: federationSettingsID, + }) + if err != nil { + return nil, fmt.Errorf("failed to retrieve the federation setting's identity providers: %w", err) + } + + for _, identityProvider := range identityProviders.GetResults() { + if identityProvider.GetOktaIdpId() == identityProviderID { + return &identityProvider, nil + } + } + return nil, ErrNoMatchingSAMLProvider +} diff --git a/internal/kubernetes/operator/federatedauthentication/federatedauthentication_test.go b/internal/kubernetes/operator/federatedauthentication/federatedauthentication_test.go new file mode 100644 index 0000000000..633278ba04 --- /dev/null +++ b/internal/kubernetes/operator/federatedauthentication/federatedauthentication_test.go @@ -0,0 +1,347 @@ +// Copyright 2024 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///go:build unit + +package federatedauthentication + +import ( + "errors" + "testing" + + "github.com/golang/mock/gomock" + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/kubernetes/operator/resources" + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/mocks" + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/pointer" + akoapi "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1" + akov2status "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1/status" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/atlas-sdk/v20240530005/admin" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + testOrganizationID = "TestOrgID" + legacyTestIdentityProviderID = "LegacyTestIdentityProviderID" + testIdentityProviderID = "TestIdentityProviderID" + testFederationSettingID = "TestFederationSettingID" + testProjectID = "TestProjectID" + targetNamespace = "test" + version = "2.3.1" + projectName = "my-project" +) + +var ( + testProjectIDs = []string{"test-project-1", "test-project-2"} + secondTestProjectIDs = []string{"test-project-3", "test-project-4"} + testRoleProjects = []string{"GROUP_OWNER", "GROUP_OWNER"} + testRoleOrganizations = []string{"ORG_OWNER", "ORG_OWNER"} + testExternalGroupNames = []string{"org-admin", "dev-team"} +) +var ( + ErrGetProvidersList = errors.New("problem when fetching the list of the identity providers") + ErrGetProjectUnauthorized = errors.New("you are not authorized to get this project details") +) + +func Test_BuildAtlasFederatedAuth(t *testing.T) { + testCases := []struct { + name string + federationSettings *admin.OrgFederationSettings + setupMocks func(*mocks.MockOperatorGenericStore) + expected *akov2.AtlasFederatedAuth + expectedError error + }{ + { + name: "should build the atlas federation auth custom resource", + federationSettings: &admin.OrgFederationSettings{ + Id: pointer.Get(testFederationSettingID), + IdentityProviderId: pointer.Get(legacyTestIdentityProviderID), + IdentityProviderStatus: pointer.Get("ACTIVE"), + HasRoleMappings: pointer.Get(true), + }, + setupMocks: func(store *mocks.MockOperatorGenericStore) { + authRoleMappings := setupAuthRoleMappings(testProjectIDs, secondTestProjectIDs, testRoleProjects, testRoleOrganizations, testExternalGroupNames) + + orgConfig := &admin.ConnectedOrgConfig{ + DomainAllowList: &[]string{"example.com"}, + PostAuthRoleGrants: &[]string{"ORG_OWNER"}, + DomainRestrictionEnabled: true, + RoleMappings: &authRoleMappings, + IdentityProviderId: pointer.Get(legacyTestIdentityProviderID), + } + + store.EXPECT(). + GetConnectedOrgConfig(&admin.GetConnectedOrgConfigApiParams{FederationSettingsId: *pointer.Get(testFederationSettingID), OrgId: testOrganizationID}). + Return(orgConfig, nil) + + identityProvider := &admin.FederationIdentityProvider{ + SsoDebugEnabled: pointer.Get(true), + OktaIdpId: *pointer.Get(legacyTestIdentityProviderID), + Id: testIdentityProviderID, + } + paginatedResult := &admin.PaginatedFederationIdentityProvider{ + Results: &[]admin.FederationIdentityProvider{*identityProvider}, + TotalCount: pointer.Get(1), + } + + store.EXPECT(). + IdentityProviders(&admin.ListIdentityProvidersApiParams{FederationSettingsId: *pointer.Get(testFederationSettingID)}). + Return(paginatedResult, nil) + + // Mocking projects + firstProject := &admin.Group{Id: pointer.Get("test-project-1"), Name: "test-project-name-1", OrgId: testOrganizationID} + secondProject := &admin.Group{Id: pointer.Get("test-project-2"), Name: "test-project-name-2", OrgId: testOrganizationID} + + store.EXPECT().Project("test-project-1").Return(firstProject, nil) + store.EXPECT().Project("test-project-2").Return(secondProject, nil) + store.EXPECT().Project("test-project-3").Return(firstProject, nil) + store.EXPECT().Project("test-project-4").Return(secondProject, nil) + }, + expected: &akov2.AtlasFederatedAuth{ + TypeMeta: metav1.TypeMeta{ + Kind: "AtlasFederatedAuth", + APIVersion: "atlas.mongodb.com/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project-testfederationsettingid", + Namespace: targetNamespace, + }, + Spec: akov2.AtlasFederatedAuthSpec{ + Enabled: true, + DomainAllowList: []string{"example.com"}, + PostAuthRoleGrants: []string{"ORG_OWNER"}, + DomainRestrictionEnabled: pointer.Get(true), + SSODebugEnabled: pointer.Get(true), + RoleMappings: []akov2.RoleMapping{ + { + ExternalGroupName: "org-admin", + RoleAssignments: []akov2.RoleAssignment{ + { + ProjectName: "test-project-name-1", + Role: "GROUP_OWNER", + }, + }, + }, + { + ExternalGroupName: "dev-team", + RoleAssignments: []akov2.RoleAssignment{ + { + ProjectName: "test-project-name-2", + Role: "GROUP_OWNER", + }, + }, + }, + { + ExternalGroupName: "org-admin", + RoleAssignments: []akov2.RoleAssignment{ + { + Role: "ORG_OWNER", + }, + { + ProjectName: "test-project-name-1", + Role: "GROUP_OWNER", + }, + }, + }, + { + ExternalGroupName: "dev-team", + RoleAssignments: []akov2.RoleAssignment{ + { + Role: "ORG_OWNER", + }, + { + ProjectName: "test-project-name-2", + Role: "GROUP_OWNER", + }, + }, + }, + }, + }, + Status: akov2status.AtlasFederatedAuthStatus{ + Common: akoapi.Common{ + Conditions: []akoapi.Condition{}, + }, + }, + }, + expectedError: nil, + }, + { + name: "should return error because lack of project permissions", + federationSettings: &admin.OrgFederationSettings{ + Id: pointer.Get(testFederationSettingID), + IdentityProviderId: pointer.Get(legacyTestIdentityProviderID), + IdentityProviderStatus: pointer.Get("ACTIVE"), + HasRoleMappings: pointer.Get(true), + }, + setupMocks: func(store *mocks.MockOperatorGenericStore) { + authRoleMappings := setupAuthRoleMappings(testProjectIDs, secondTestProjectIDs, testRoleProjects, testRoleOrganizations, testExternalGroupNames) + + orgConfig := &admin.ConnectedOrgConfig{ + DomainAllowList: &[]string{"example.com"}, + PostAuthRoleGrants: &[]string{"ORG_OWNER"}, + DomainRestrictionEnabled: true, + RoleMappings: &authRoleMappings, + IdentityProviderId: pointer.Get(legacyTestIdentityProviderID), + } + + store.EXPECT(). + GetConnectedOrgConfig(&admin.GetConnectedOrgConfigApiParams{FederationSettingsId: *pointer.Get(testFederationSettingID), OrgId: testOrganizationID}). + Return(orgConfig, nil) + + identityProvider := &admin.FederationIdentityProvider{ + SsoDebugEnabled: pointer.Get(true), + OktaIdpId: *pointer.Get(legacyTestIdentityProviderID), + Id: testIdentityProviderID, + } + paginatedResult := &admin.PaginatedFederationIdentityProvider{ + Results: &[]admin.FederationIdentityProvider{*identityProvider}, + TotalCount: pointer.Get(1), + } + + store.EXPECT(). + IdentityProviders(&admin.ListIdentityProvidersApiParams{FederationSettingsId: *pointer.Get(testFederationSettingID)}). + Return(paginatedResult, nil) + + store.EXPECT().Project("test-project-1").Return(nil, ErrGetProjectUnauthorized) + }, + expected: nil, + expectedError: ErrGetProjectUnauthorized, + }, + { + name: "should return error because an error where fetching the identity providers of the fedetaion", + federationSettings: &admin.OrgFederationSettings{ + Id: pointer.Get(testFederationSettingID), + IdentityProviderId: pointer.Get(legacyTestIdentityProviderID), + IdentityProviderStatus: pointer.Get("ACTIVE"), + HasRoleMappings: pointer.Get(true), + }, + setupMocks: func(store *mocks.MockOperatorGenericStore) { + authRoleMappings := setupAuthRoleMappings(testProjectIDs, secondTestProjectIDs, testRoleProjects, testRoleOrganizations, testExternalGroupNames) + + orgConfig := &admin.ConnectedOrgConfig{ + DomainAllowList: &[]string{"example.com"}, + PostAuthRoleGrants: &[]string{"ORG_OWNER"}, + DomainRestrictionEnabled: true, + RoleMappings: &authRoleMappings, + IdentityProviderId: pointer.Get(legacyTestIdentityProviderID), + } + + store.EXPECT(). + GetConnectedOrgConfig(&admin.GetConnectedOrgConfigApiParams{FederationSettingsId: *pointer.Get(testFederationSettingID), OrgId: testOrganizationID}). + Return(orgConfig, nil) + + store.EXPECT(). + IdentityProviders(&admin.ListIdentityProvidersApiParams{FederationSettingsId: *pointer.Get(testFederationSettingID)}). + Return(nil, ErrGetProvidersList) + }, + expected: nil, + expectedError: ErrGetProvidersList, + }, + { + name: "no identity provider present matching the legacy identityproviderID", + federationSettings: &admin.OrgFederationSettings{ + Id: pointer.Get(testFederationSettingID), + IdentityProviderId: pointer.Get(legacyTestIdentityProviderID), + IdentityProviderStatus: pointer.Get("ACTIVE"), + HasRoleMappings: pointer.Get(true), + }, + setupMocks: func(store *mocks.MockOperatorGenericStore) { + authRoleMappings := setupAuthRoleMappings(testProjectIDs, secondTestProjectIDs, testRoleProjects, testRoleOrganizations, testExternalGroupNames) + + orgConfig := &admin.ConnectedOrgConfig{ + DomainAllowList: &[]string{"example.com"}, + PostAuthRoleGrants: &[]string{"ORG_OWNER"}, + DomainRestrictionEnabled: true, + RoleMappings: &authRoleMappings, + IdentityProviderId: pointer.Get(legacyTestIdentityProviderID), + } + + store.EXPECT(). + GetConnectedOrgConfig(&admin.GetConnectedOrgConfigApiParams{FederationSettingsId: *pointer.Get(testFederationSettingID), OrgId: testOrganizationID}). + Return(orgConfig, nil) + + paginatedResult := &admin.PaginatedFederationIdentityProvider{ + Results: &[]admin.FederationIdentityProvider{}, + TotalCount: pointer.Get(0), + } + + store.EXPECT(). + IdentityProviders(&admin.ListIdentityProvidersApiParams{FederationSettingsId: *pointer.Get(testFederationSettingID)}). + Return(paginatedResult, nil) + }, + expected: nil, + expectedError: ErrNoMatchingSAMLProvider, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctl := gomock.NewController(t) + defer ctl.Finish() + + store := mocks.NewMockOperatorGenericStore(ctl) + tc.setupMocks(store) + + resources, err := BuildAtlasFederatedAuth(&AtlasFederatedAuthBuildRequest{ + IncludeSecret: false, + ProjectStore: store, + ConnectedOrgConfigsDescriber: store, + IdentityProviderLister: store, + IdentityProviderDescriber: store, + ProjectID: "TestProjectID", + OrgID: testOrganizationID, + TargetNamespace: "test", + Version: "2.3.1", + Dictionary: resources.AtlasNameToKubernetesName(), + ProjectName: "my-project", + FederatedSettings: tc.federationSettings, + }) + + require.ErrorIs(t, err, tc.expectedError) + assert.Equal(t, tc.expected, resources) + }) + } +} +func setupAuthRoleMappings(testProjectID, secondTestProjectID, testRoleProject, testRoleOrganization, testExternalGroupName []string) []admin.AuthFederationRoleMapping { + AuthRoleMappings := make([]admin.AuthFederationRoleMapping, len(testRoleProject)+len(testRoleOrganization)) + for i := range testProjectID { + AuthRoleMappings[i] = admin.AuthFederationRoleMapping{ + ExternalGroupName: testExternalGroupName[i], + RoleAssignments: &[]admin.RoleAssignment{ + { + GroupId: &testProjectID[i], + Role: &testRoleProject[i], + }, + }, + } + } + for i := range testRoleOrganization { + AuthRoleMappings[len(testProjectID)+i] = admin.AuthFederationRoleMapping{ + ExternalGroupName: testExternalGroupName[i], + RoleAssignments: &[]admin.RoleAssignment{ + { + OrgId: pointer.Get(testOrganizationID), + Role: &testRoleOrganization[i], + }, + { + GroupId: &secondTestProjectID[i], + Role: &testRoleProject[i], + }, + }, + } + } + return AuthRoleMappings +} diff --git a/internal/mocks/mock_atlas_generic_store.go b/internal/mocks/mock_atlas_generic_store.go index a5940e2d31..15d33a3724 100644 --- a/internal/mocks/mock_atlas_generic_store.go +++ b/internal/mocks/mock_atlas_generic_store.go @@ -274,6 +274,36 @@ func (mr *MockOperatorGenericStoreMockRecorder) EncryptionAtRest(arg0 interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EncryptionAtRest", reflect.TypeOf((*MockOperatorGenericStore)(nil).EncryptionAtRest), arg0) } +// FederationSetting mocks base method. +func (m *MockOperatorGenericStore) FederationSetting(arg0 *admin.GetFederationSettingsApiParams) (*admin.OrgFederationSettings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FederationSetting", arg0) + ret0, _ := ret[0].(*admin.OrgFederationSettings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FederationSetting indicates an expected call of FederationSetting. +func (mr *MockOperatorGenericStoreMockRecorder) FederationSetting(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FederationSetting", reflect.TypeOf((*MockOperatorGenericStore)(nil).FederationSetting), arg0) +} + +// GetConnectedOrgConfig mocks base method. +func (m *MockOperatorGenericStore) GetConnectedOrgConfig(arg0 *admin.GetConnectedOrgConfigApiParams) (*admin.ConnectedOrgConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetConnectedOrgConfig", arg0) + ret0, _ := ret[0].(*admin.ConnectedOrgConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetConnectedOrgConfig indicates an expected call of GetConnectedOrgConfig. +func (mr *MockOperatorGenericStoreMockRecorder) GetConnectedOrgConfig(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConnectedOrgConfig", reflect.TypeOf((*MockOperatorGenericStore)(nil).GetConnectedOrgConfig), arg0) +} + // GetOrgProjects mocks base method. func (m *MockOperatorGenericStore) GetOrgProjects(arg0 string, arg1 *store.ListOptions) (*admin.PaginatedAtlasGroup, error) { m.ctrl.T.Helper() @@ -319,6 +349,36 @@ func (mr *MockOperatorGenericStoreMockRecorder) GlobalCluster(arg0, arg1 interfa return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GlobalCluster", reflect.TypeOf((*MockOperatorGenericStore)(nil).GlobalCluster), arg0, arg1) } +// IdentityProvider mocks base method. +func (m *MockOperatorGenericStore) IdentityProvider(arg0 *admin.GetIdentityProviderApiParams) (*admin.FederationIdentityProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IdentityProvider", arg0) + ret0, _ := ret[0].(*admin.FederationIdentityProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IdentityProvider indicates an expected call of IdentityProvider. +func (mr *MockOperatorGenericStoreMockRecorder) IdentityProvider(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IdentityProvider", reflect.TypeOf((*MockOperatorGenericStore)(nil).IdentityProvider), arg0) +} + +// IdentityProviders mocks base method. +func (m *MockOperatorGenericStore) IdentityProviders(arg0 *admin.ListIdentityProvidersApiParams) (*admin.PaginatedFederationIdentityProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IdentityProviders", arg0) + ret0, _ := ret[0].(*admin.PaginatedFederationIdentityProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IdentityProviders indicates an expected call of IdentityProviders. +func (mr *MockOperatorGenericStoreMockRecorder) IdentityProviders(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IdentityProviders", reflect.TypeOf((*MockOperatorGenericStore)(nil).IdentityProviders), arg0) +} + // Integrations mocks base method. func (m *MockOperatorGenericStore) Integrations(arg0 string) (*admin.PaginatedIntegration, error) { m.ctrl.T.Helper() diff --git a/internal/store/operator.go b/internal/store/operator.go index c4c7ec3706..0be2d362c8 100644 --- a/internal/store/operator.go +++ b/internal/store/operator.go @@ -94,4 +94,8 @@ type OperatorGenericStore interface { OperatorDBUsersStore DataFederationStore StreamProcessingStore + FederationSettingsDescriber + IdentityProviderLister + ConnectedOrgConfigsDescriber + IdentityProviderDescriber } diff --git a/test/e2e/atlas/kubernetes_config_generate_test.go b/test/e2e/atlas/kubernetes_config_generate_test.go index b309ac8bb5..66c3de5047 100644 --- a/test/e2e/atlas/kubernetes_config_generate_test.go +++ b/test/e2e/atlas/kubernetes_config_generate_test.go @@ -51,7 +51,12 @@ import ( ) const targetNamespace = "importer-namespace" +const credSuffixTest = "-credentials" +const activeStatus = "ACTIVE" +var federationSettingsID string +var identityProviderStatus string +var samlIdentityProviderID string var expectedLabels = map[string]string{ features.ResourceVersion: features.LatestOperatorMajorVersion, } @@ -126,6 +131,182 @@ func InitialSetup(t *testing.T) KubernetesConfigGenerateProjectSuite { return s } +func TestFederatedAuthTest(t *testing.T) { + t.Run("PreRequisite Get the federation setting ID", func(t *testing.T) { + s := InitialSetup(t) + cliPath := s.cliPath + cmd := exec.Command(cliPath, + federatedAuthenticationEntity, + federationSettingsEntity, + "describe", + "-o=json", + ) + + cmd.Env = os.Environ() + resp, err := e2e.RunAndGetStdOut(cmd) + require.NoError(t, err, string(resp)) + + var settings atlasv2.OrgFederationSettings + require.NoError(t, json.Unmarshal(resp, &settings)) + + a := assert.New(t) + a.NotEmpty(settings) + federationSettingsID = settings.GetId() + a.NotEmpty(federationSettingsID, "no federation settings was present") + identityProviderStatus = settings.GetIdentityProviderStatus() + }) + t.Run("List SAML IdPs", func(_ *testing.T) { + if identityProviderStatus != activeStatus { + s := InitialSetup(t) + cliPath := s.cliPath + cmd := exec.Command(cliPath, + federatedAuthenticationEntity, + federationSettingsEntity, + identityProviderEntity, + "list", + "--federationSettingsId", + federationSettingsID, + "--protocol", + "SAML", + "-o=json", + ) + + cmd.Env = os.Environ() + resp, err := e2e.RunAndGetStdOut(cmd) + require.NoError(t, err, string(resp)) + + var providers atlasv2.PaginatedFederationIdentityProvider + require.NoError(t, json.Unmarshal(resp, &providers)) + a := assert.New(t) + a.True(providers.HasResults()) + providersList := providers.GetResults() + samlIdentityProviderID = providersList[0].GetOktaIdpId() + } + }) + t.Run("PreRequisite Connect SAML IdP", func(t *testing.T) { + if identityProviderStatus != activeStatus && samlIdentityProviderID != "" { + s := InitialSetup(t) + cliPath := s.cliPath + cmd := exec.Command(cliPath, + federatedAuthenticationEntity, + federationSettingsEntity, + connectedOrgsConfigsEntity, + "connect", + "--identityProviderId", + samlIdentityProviderID, + "--federationSettingsId", + federationSettingsID, + "--protocol", + "SAML", + "-o=json", + ) + + cmd.Env = os.Environ() + resp, err := e2e.RunAndGetStdOut(cmd) + require.NoError(t, err, string(resp)) + + var config atlasv2.ConnectedOrgConfig + require.NoError(t, json.Unmarshal(resp, &config)) + assert.NotNil(t, config.GetIdentityProviderId()) + } + }) + t.Run("Prerequisite Check active SAML configuration", func(t *testing.T) { + if identityProviderStatus != activeStatus { + s := InitialSetup(t) + cliPath := s.cliPath + cmd := exec.Command(cliPath, + federatedAuthenticationEntity, + federationSettingsEntity, + "describe", + "-o=json", + ) + + cmd.Env = os.Environ() + resp, err := e2e.RunAndGetStdOut(cmd) + require.NoError(t, err, string(resp)) + + var settings atlasv2.OrgFederationSettings + require.NoError(t, json.Unmarshal(resp, &settings)) + + a := assert.New(t) + a.NotEmpty(settings) + federationSettingsID = settings.GetId() + a.NotEmpty(federationSettingsID, "no federation settings was present") + a.NotEmpty(settings.IdentityProviderId, "no SAML IdP was found") + a.Equal(activeStatus, settings.GetIdentityProviderStatus(), "no active SAML IdP present for this federation") + identityProviderStatus = settings.GetIdentityProviderStatus() + } + }) + t.Run("Config generate for federated auth", func(t *testing.T) { + if identityProviderStatus != activeStatus { + t.Fatalf("There is no need to check this test since there is no SAML IdP configured and active") + } + dictionary := resources.AtlasNameToKubernetesName() + s := InitialSetup(t) + cliPath := s.cliPath + generator := s.generator + cmd := exec.Command(cliPath, + "kubernetes", + "config", + "generate", + "--projectId", + generator.projectID, + "--targetNamespace", + targetNamespace, + "--includeSecrets") + cmd.Env = os.Environ() + resp, err := e2e.RunAndGetStdOut(cmd) + t.Log(string(resp)) + require.NoError(t, err, string(resp)) + var objects []runtime.Object + objects, err = getK8SEntities(resp) + + a := assert.New(t) + a.Equal(&akov2.AtlasFederatedAuth{ + TypeMeta: metav1.TypeMeta{ + Kind: "AtlasFederatedAuth", + APIVersion: "atlas.mongodb.com/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: resources.NormalizeAtlasName(fmt.Sprintf("%s-%s", s.generator.projectName, federationSettingsID), dictionary), + Namespace: targetNamespace, + }, + Spec: akov2.AtlasFederatedAuthSpec{ + ConnectionSecretRef: akov2common.ResourceRefNamespaced{ + Name: resources.NormalizeAtlasName(s.generator.projectName+credSuffixTest, dictionary), + Namespace: targetNamespace, + }, + Enabled: true, + DomainAllowList: []string{"iam-test-domain-dev.com"}, + PostAuthRoleGrants: []string{"ORG_OWNER"}, + DomainRestrictionEnabled: pointer.Get(false), + SSODebugEnabled: pointer.Get(true), + RoleMappings: nil, + }, + Status: akov2status.AtlasFederatedAuthStatus{ + Common: akoapi.Common{ + Conditions: []akoapi.Condition{}, + }, + }, + }, federatedAuthentification(objects)[0]) + require.NoError(t, err, "should not fail on decode") + require.NotEmpty(t, objects) + secret, found := findSecret(objects) + require.True(t, found, "secret is not found in results") + a.Equal(targetNamespace, secret.Namespace) + }) +} +func federatedAuthentification(objects []runtime.Object) []*akov2.AtlasFederatedAuth { + var ds []*akov2.AtlasFederatedAuth + for i := range objects { + d, ok := objects[i].(*akov2.AtlasFederatedAuth) + if ok { + ds = append(ds, d) + } + } + return ds +} + func TestEmptyProject(t *testing.T) { s := InitialSetup(t) cliPath := s.cliPath @@ -529,7 +710,7 @@ func TestProjectWithIntegration(t *testing.T) { require.NotEmpty(t, objects) checkProject(t, objects, expectedProject) - assert.Len(t, objects, 3, "should have 3 objects in the output: project, integration secret, atlas secret") + assert.Len(t, objects, 4, "should have 4 objects in the output: project, integration secret, atlas secret, federated-auth secret") integrationSecret := objects[1].(*corev1.Secret) password, ok := integrationSecret.Data["password"] assert.True(t, ok, "should have password field in the integration secret")