diff --git a/pkg/api/v1/status/cloud_provider_access.go b/pkg/api/v1/status/cloud_provider_access.go index 04dfd85b75..ed7cf0f2d6 100644 --- a/pkg/api/v1/status/cloud_provider_access.go +++ b/pkg/api/v1/status/cloud_provider_access.go @@ -1,9 +1,5 @@ package status -import ( - "go.mongodb.org/atlas/mongodbatlas" -) - type CloudProviderAccessRole struct { AtlasAWSAccountArn string `json:"atlasAWSAccountArn,omitempty"` AtlasAssumedRoleExternalID string `json:"atlasAssumedRoleExternalId"` @@ -23,6 +19,14 @@ type FeatureUsage struct { } const ( + CloudProviderAccessStatusNew = "NEW" + CloudProviderAccessStatusCreated = "CREATED" + CloudProviderAccessStatusAuthorized = "AUTHORIZED" + CloudProviderAccessStatusDeAuthorize = "DEAUTHORIZE" + CloudProviderAccessStatusFailedToCreate = "FAILED_TO_CREATE" + CloudProviderAccessStatusFailedToAuthorize = "FAILED_TO_AUTHORIZE" + CloudProviderAccessStatusFailedToDeAuthorize = "FAILED_TO_DEAUTHORIZE" + StatusFailed = "FAILED" StatusCreated = "CREATED" StatusReady = "READY" @@ -30,62 +34,9 @@ const ( ) func NewCloudProviderAccessRole(providerName, assumedRoleArn string) CloudProviderAccessRole { - if assumedRoleArn == "" { - return CloudProviderAccessRole{ - ProviderName: providerName, - Status: StatusEmptyARN, - } - } return CloudProviderAccessRole{ ProviderName: providerName, IamAssumedRoleArn: assumedRoleArn, - Status: StatusCreated, - } -} - -func (c *CloudProviderAccessRole) IsEmptyARN() bool { - return c.Status == StatusEmptyARN -} - -func (c *CloudProviderAccessRole) Failed(errorMessage string) { - c.Status = StatusFailed - c.ErrorMessage = errorMessage -} - -func (c *CloudProviderAccessRole) FailedToAuthorise(errorMessage string) { - c.ErrorMessage = errorMessage -} - -func (c *CloudProviderAccessRole) Update(role mongodbatlas.CloudProviderAccessRole, isEmptyArn bool) { - c.RoleID = role.RoleID - c.AtlasAssumedRoleExternalID = role.AtlasAssumedRoleExternalID - c.AtlasAWSAccountArn = role.AtlasAWSAccountARN - c.AuthorizedDate = role.AuthorizedDate - c.CreatedDate = role.CreatedDate - for _, featureUsage := range role.FeatureUsages { - if featureUsage != nil { - featureUsageID, ok := featureUsage.FeatureID.(string) - if ok { - c.FeatureUsages = append(c.FeatureUsages, FeatureUsage{ - FeatureType: featureUsage.FeatureType, - FeatureID: featureUsageID, - }) - } - } - } - - if isEmptyArn { - c.Status = StatusEmptyARN - } else { - switch role.IAMAssumedRoleARN { - case "": - c.Status = StatusCreated - case c.IamAssumedRoleArn: - c.Status = StatusReady - c.ErrorMessage = "" - default: - c.Status = StatusFailed - c.ErrorMessage = "IAMAssumedRoleARN is different from the previous one" - } + Status: CloudProviderAccessStatusNew, } } diff --git a/pkg/controller/atlasproject/cloud_provider_access.go b/pkg/controller/atlasproject/cloud_provider_access.go index ef7a6893c7..c62cedbb11 100644 --- a/pkg/controller/atlasproject/cloud_provider_access.go +++ b/pkg/controller/atlasproject/cloud_provider_access.go @@ -3,24 +3,25 @@ package atlasproject import ( "context" "encoding/json" + "errors" "fmt" + "sort" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/customresource" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/set" "go.mongodb.org/atlas/mongodbatlas" - "go.uber.org/zap" v1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/workflow" ) -func ensureProviderAccessStatus(ctx context.Context, customContext *workflow.Context, project *v1.AtlasProject, protected bool) workflow.Result { - canReconcile, err := canCloudProviderAccessReconcile(ctx, customContext.Client, protected, project) +func ensureProviderAccessStatus(ctx context.Context, workflowCtx *workflow.Context, project *v1.AtlasProject, protected bool) workflow.Result { + canReconcile, err := canCloudProviderAccessReconcile(ctx, workflowCtx.Client, protected, project) if err != nil { result := workflow.Terminate(workflow.Internal, fmt.Sprintf("unable to resolve ownership for deletion protection: %s", err)) - customContext.SetConditionFromResult(status.CloudProviderAccessReadyType, result) + workflowCtx.SetConditionFromResult(status.CloudProviderAccessReadyType, result) return result } @@ -30,7 +31,7 @@ func ensureProviderAccessStatus(ctx context.Context, customContext *workflow.Con workflow.AtlasDeletionProtection, "unable to reconcile Cloud Provider Access due to deletion protection being enabled. see https://dochub.mongodb.org/core/ako-deletion-protection for further information", ) - customContext.SetConditionFromResult(status.CloudProviderAccessReadyType, result) + workflowCtx.SetConditionFromResult(status.CloudProviderAccessReadyType, result) return result } @@ -39,235 +40,268 @@ func ensureProviderAccessStatus(ctx context.Context, customContext *workflow.Con roleSpecs := project.Spec.DeepCopy().CloudProviderAccessRoles if len(roleSpecs) == 0 && len(roleStatuses) == 0 { - customContext.UnsetCondition(status.CloudProviderAccessReadyType) + workflowCtx.UnsetCondition(status.CloudProviderAccessReadyType) return workflow.OK() } - result, condition := syncProviderAccessStatus(ctx, customContext, roleSpecs, roleStatuses, project.ID()) - if result != workflow.OK() { - customContext.SetConditionFromResult(condition, result) + allAuthorized, err := syncCloudProviderAccess(ctx, workflowCtx, project.ID(), project.Spec.CloudProviderAccessRoles) + if err != nil { + result := workflow.Terminate(workflow.ProjectCloudAccessRolesIsNotReadyInAtlas, err.Error()) + workflowCtx.SetConditionFromResult(status.CloudProviderAccessReadyType, result) + return result } - customContext.SetConditionTrue(status.CloudProviderAccessReadyType) - return result -} -func syncProviderAccessStatus(ctx context.Context, customContext *workflow.Context, specs []v1.CloudProviderAccessRole, statuses []status.CloudProviderAccessRole, groupID string) (workflow.Result, status.ConditionType) { - client := customContext.Client - logger := customContext.Log - specToStatusMap, haveDuplicate, cantMatch := checkStatuses(specs, statuses) - if haveDuplicate { - return workflow.Terminate(workflow.ProjectCloudAccessRolesIsNotReadyInAtlas, "some roles contains same ARN value"), status.CloudProviderAccessReadyType - } - if cantMatch { - return workflow.Terminate(workflow.ProjectCloudAccessRolesIsNotReadyInAtlas, "More than one new role"+ - " with ARN may correspond to an existing empty role. Keep only one new role containing data from the status "+ - "field and delete all other roles. You can add them again after authorization is complete."), status.CloudProviderAccessReadyType - } - defer func() { - SetNewStatuses(customContext, specToStatusMap) - }() + if !allAuthorized { + workflowCtx.SetConditionFalse(status.CloudProviderAccessReadyType) - diff, err := sortAccessRoles(ctx, client.CloudProviderAccess, logger, specToStatusMap, groupID) - if err != nil { - return workflow.Terminate(workflow.ProjectCloudAccessRolesIsNotReadyInAtlas, fmt.Sprintf("failed to sort access roles: %s", err)), - status.CloudProviderAccessReadyType - } - err = deleteAccessRoles(ctx, client.CloudProviderAccess, logger, diff.toDelete, groupID) - if err != nil { - return workflow.Terminate(workflow.ProjectCloudAccessRolesIsNotReadyInAtlas, fmt.Sprintf("failed to delete access roles: %s", err)), - status.CloudProviderAccessReadyType - } - err = createAccessRoles(ctx, client.CloudProviderAccess, logger, diff.toCreate, specToStatusMap, groupID) - if err != nil { - return workflow.Terminate(workflow.ProjectCloudAccessRolesIsNotReadyInAtlas, fmt.Sprintf("failed to create access roles: %s", err)), - status.CloudProviderAccessReadyType + return workflow.InProgress(workflow.ProjectCloudAccessRolesIsNotReadyInAtlas, "not all entries are authorized") } - tryToAuthorize(ctx, client.CloudProviderAccess, logger, specToStatusMap, groupID) - updateAccessRoles(diff.toUpdate, specToStatusMap) - return ensureCloudProviderAccessStatus(specToStatusMap) + workflowCtx.SetConditionTrue(status.CloudProviderAccessReadyType) + return workflow.OK() } -func tryToAuthorize(ctx context.Context, access mongodbatlas.CloudProviderAccessService, logger *zap.SugaredLogger, statusMap map[v1.CloudProviderAccessRole]status.CloudProviderAccessRole, groupID string) { - for spec, roleStatus := range statusMap { - if roleStatus.Status == status.StatusCreated { - request := mongodbatlas.CloudProviderAccessRoleRequest{ - ProviderName: spec.ProviderName, - IAMAssumedRoleARN: &spec.IamAssumedRoleArn, - } - role, _, err := access.AuthorizeRole(ctx, groupID, roleStatus.RoleID, &request) - if err != nil { - roleStatus.FailedToAuthorise(fmt.Sprintf("cant authorize role. %s", err)) - logger.Errorw("cant authorize role", "role", roleStatus.RoleID, "error", err) - statusMap[spec] = roleStatus - continue +func syncCloudProviderAccess(ctx context.Context, workflowCtx *workflow.Context, projectID string, cpaSpecs []v1.CloudProviderAccessRole) (bool, error) { + atlasCPAs, _, err := workflowCtx.Client.CloudProviderAccess.ListRoles(ctx, projectID) + if err != nil { + return false, fmt.Errorf("unable to fetch cloud provider access from Atlas: %w", err) + } + + AWSRoles := sortAtlasCPAsByRoleID(atlasCPAs.AWSIAMRoles) + cpaStatuses := enrichStatuses(initiateStatuses(cpaSpecs), AWSRoles) + cpaStatusesToUpdate := make([]status.CloudProviderAccessRole, 0, len(cpaStatuses)) + withError := false + + for _, cpaStatus := range cpaStatuses { + switch cpaStatus.Status { + case status.CloudProviderAccessStatusNew, status.CloudProviderAccessStatusFailedToCreate: + createCloudProviderAccess(ctx, workflowCtx, projectID, cpaStatus) + cpaStatusesToUpdate = append(cpaStatusesToUpdate, *cpaStatus) + case status.CloudProviderAccessStatusCreated, status.CloudProviderAccessStatusFailedToAuthorize: + if cpaStatus.IamAssumedRoleArn != "" { + authorizeCloudProviderAccess(ctx, workflowCtx, projectID, cpaStatus) } - roleStatus.Update(*role, roleStatus.IsEmptyARN()) - statusMap[spec] = roleStatus + cpaStatusesToUpdate = append(cpaStatusesToUpdate, *cpaStatus) + case status.CloudProviderAccessStatusDeAuthorize, status.CloudProviderAccessStatusFailedToDeAuthorize: + deleteCloudProviderAccess(ctx, workflowCtx, projectID, cpaStatus) + case status.CloudProviderAccessStatusAuthorized: + cpaStatusesToUpdate = append(cpaStatusesToUpdate, *cpaStatus) + } + + if cpaStatus.ErrorMessage != "" { + withError = true } } -} -func SetNewStatuses(customContext *workflow.Context, specToStatus map[v1.CloudProviderAccessRole]status.CloudProviderAccessRole) { - newRoleStatuses := make([]status.CloudProviderAccessRole, 0, len(specToStatus)) - for _, roleStatus := range specToStatus { - newRoleStatuses = append(newRoleStatuses, roleStatus) + workflowCtx.EnsureStatusOption(status.AtlasProjectCloudAccessRolesOption(cpaStatusesToUpdate)) + + if withError { + return false, errors.New("not all items were synchronized successfully") } - customContext.EnsureStatusOption(status.AtlasProjectCloudAccessRolesOption(newRoleStatuses)) -} -func ensureCloudProviderAccessStatus(statusMap map[v1.CloudProviderAccessRole]status.CloudProviderAccessRole) (workflow.Result, status.ConditionType) { - ok := true - for _, roleStatus := range statusMap { - if roleStatus.Status != status.StatusReady { - ok = false + for _, capStatus := range cpaStatusesToUpdate { + if capStatus.Status != status.CloudProviderAccessStatusAuthorized { + return false, nil } } - if !ok { - return workflow.Terminate(workflow.ProjectCloudAccessRolesIsNotReadyInAtlas, "not all roles are ready"), - status.CloudProviderAccessReadyType + + return true, nil +} + +func initiateStatuses(cpaSpecs []v1.CloudProviderAccessRole) []*status.CloudProviderAccessRole { + cpaStatuses := make([]*status.CloudProviderAccessRole, 0, len(cpaSpecs)) + + for _, cpaSpec := range cpaSpecs { + newStatus := status.NewCloudProviderAccessRole(cpaSpec.ProviderName, cpaSpec.IamAssumedRoleArn) + cpaStatuses = append(cpaStatuses, &newStatus) } - return workflow.OK(), status.CloudProviderAccessReadyType + + return cpaStatuses } -func updateAccessRoles(toUpdate []mongodbatlas.CloudProviderAccessRole, specToStatus map[v1.CloudProviderAccessRole]status.CloudProviderAccessRole) { - for _, role := range toUpdate { - for spec, roleStatus := range specToStatus { - if role.RoleID == roleStatus.RoleID { - roleStatus.Update(role, roleStatus.IsEmptyARN()) - specToStatus[spec] = roleStatus +func enrichStatuses(cpaStatuses []*status.CloudProviderAccessRole, atlasCPAs []mongodbatlas.CloudProviderAccessRole) []*status.CloudProviderAccessRole { + // find configured matches: containing IAM Assumed Role ARN + for _, cpaStatus := range cpaStatuses { + for _, atlasCPA := range atlasCPAs { + cpa := atlasCPA + + if isMatch(cpaStatus, &cpa) { + copyCloudProviderAccessData(cpaStatus, &cpa) + + continue } } } -} -func createAccessRoles(ctx context.Context, accessClient mongodbatlas.CloudProviderAccessService, logger *zap.SugaredLogger, - toCreate []v1.CloudProviderAccessRole, specToStatus map[v1.CloudProviderAccessRole]status.CloudProviderAccessRole, groupID string) error { - for _, spec := range toCreate { - role, _, err := accessClient.CreateRole(ctx, groupID, &mongodbatlas.CloudProviderAccessRoleRequest{ - ProviderName: spec.ProviderName, - }) - if err != nil { - logger.Error("failed to create access role", zap.Error(err)) - roleStatus, ok := specToStatus[spec] - if !ok { - logger.Error("failed to find status for access role") - } - roleStatus.Failed(err.Error()) - specToStatus[spec] = roleStatus - return err + // Separate created but not authorized entries: when having empty IAM Assumed Role ARN + noMatch := make([]*mongodbatlas.CloudProviderAccessRole, 0, len(cpaStatuses)) + for _, atlasCPA := range atlasCPAs { + cpa := atlasCPA + + if cpa.IAMAssumedRoleARN == "" { + noMatch = append(noMatch, &cpa) } - roleStatus, ok := specToStatus[spec] - if !ok { - logger.Error("failed to find status for access role") - roleStatus.Failed("failed to find status for access role") - specToStatus[spec] = roleStatus + } + + // find not configured matches: when having empty IAM Assumed Role ARN + for _, cpaStatus := range cpaStatuses { + if cpaStatus.IamAssumedRoleArn != "" && cpaStatus.RoleID != "" { continue } - roleStatus.Update(*role, roleStatus.IsEmptyARN()) - specToStatus[spec] = roleStatus + + if len(noMatch) == 0 { + break + } + + copyCloudProviderAccessData(cpaStatus, noMatch[0]) + noMatch = noMatch[1:] } - return nil -} -func deleteAccessRoles(ctx context.Context, accessClient mongodbatlas.CloudProviderAccessService, logger *zap.SugaredLogger, toDelete map[string]string, groupID string) error { - for roleID, providerName := range toDelete { - request := mongodbatlas.CloudProviderDeauthorizationRequest{ - ProviderName: providerName, - GroupID: groupID, - RoleID: roleID, + cpaKey := "%s.%s" + cpaStatusesMap := map[string]*status.CloudProviderAccessRole{} + for _, cpaStatus := range cpaStatuses { + if cpaStatus.IamAssumedRoleArn != "" { + cpaStatusesMap[fmt.Sprintf(cpaKey, cpaStatus.ProviderName, cpaStatus.IamAssumedRoleArn)] = cpaStatus + } + } + + // find removals: configured roles matches that are not on spec + for _, atlasCPA := range atlasCPAs { + cpa := atlasCPA + + if cpa.IAMAssumedRoleARN == "" { + continue } - _, err := accessClient.DeauthorizeRole(ctx, &request) - if err != nil { - logger.Error("failed to deauthorize role", zap.Error(err)) - return err + + if _, ok := cpaStatusesMap[fmt.Sprintf(cpaKey, cpa.ProviderName, cpa.IAMAssumedRoleARN)]; !ok { + deleteStatus := status.NewCloudProviderAccessRole(cpa.ProviderName, cpa.IAMAssumedRoleARN) + copyCloudProviderAccessData(&deleteStatus, &cpa) + deleteStatus.Status = status.CloudProviderAccessStatusDeAuthorize + cpaStatuses = append(cpaStatuses, &deleteStatus) } } - return nil + + for _, cpa := range noMatch { + deleteStatus := status.NewCloudProviderAccessRole(cpa.ProviderName, cpa.IAMAssumedRoleARN) + copyCloudProviderAccessData(&deleteStatus, cpa) + deleteStatus.Status = status.CloudProviderAccessStatusDeAuthorize + cpaStatuses = append(cpaStatuses, &deleteStatus) + } + + return cpaStatuses } -func checkStatuses(specs []v1.CloudProviderAccessRole, statuses []status.CloudProviderAccessRole) (map[v1.CloudProviderAccessRole]status.CloudProviderAccessRole, bool, bool) { - result := make(map[v1.CloudProviderAccessRole]status.CloudProviderAccessRole) - existStatusWithEmptyARN := false - emptyRoleIsAssign := false - var emptyArnRoleStatus status.CloudProviderAccessRole - for _, spec := range specs { - isCreated := false - for _, existedStatus := range statuses { - if spec.ProviderName == existedStatus.ProviderName && spec.IamAssumedRoleArn == existedStatus.IamAssumedRoleArn { - isCreated = true - if _, ok := result[spec]; !ok { - result[spec] = existedStatus - } else { - return nil, true, false - } - break - } - if existedStatus.IsEmptyARN() { - existStatusWithEmptyARN = true - emptyArnRoleStatus = existedStatus - } - } - if !isCreated { - if emptyRoleIsAssign { - return nil, false, true +func sortAtlasCPAsByRoleID(atlasCPAs []mongodbatlas.CloudProviderAccessRole) []mongodbatlas.CloudProviderAccessRole { + fmt.Println(atlasCPAs) + sort.Slice(atlasCPAs, func(i, j int) bool { + return atlasCPAs[i].RoleID < atlasCPAs[j].RoleID + }) + fmt.Println(atlasCPAs) + return atlasCPAs +} + +func isMatch(cpaSpec *status.CloudProviderAccessRole, atlasCPA *mongodbatlas.CloudProviderAccessRole) bool { + return atlasCPA.IAMAssumedRoleARN != "" && cpaSpec.IamAssumedRoleArn != "" && + atlasCPA.ProviderName == cpaSpec.ProviderName && + atlasCPA.IAMAssumedRoleARN == cpaSpec.IamAssumedRoleArn +} + +func copyCloudProviderAccessData(cpaStatus *status.CloudProviderAccessRole, atlasCPA *mongodbatlas.CloudProviderAccessRole) { + cpaStatus.AtlasAWSAccountArn = atlasCPA.AtlasAWSAccountARN + cpaStatus.AtlasAssumedRoleExternalID = atlasCPA.AtlasAssumedRoleExternalID + cpaStatus.RoleID = atlasCPA.RoleID + cpaStatus.CreatedDate = atlasCPA.CreatedDate + cpaStatus.AuthorizedDate = atlasCPA.AuthorizedDate + cpaStatus.Status = status.CloudProviderAccessStatusCreated + + if atlasCPA.AuthorizedDate != "" { + cpaStatus.Status = status.CloudProviderAccessStatusAuthorized + } + + if len(atlasCPA.FeatureUsages) > 0 { + cpaStatus.FeatureUsages = make([]status.FeatureUsage, 0, len(atlasCPA.FeatureUsages)) + + for _, feature := range atlasCPA.FeatureUsages { + if feature == nil { + continue } - if existStatusWithEmptyARN { - emptyRoleIsAssign = true - if spec.IamAssumedRoleArn != "" { - emptyArnRoleStatus.Status = status.StatusCreated - emptyArnRoleStatus.IamAssumedRoleArn = spec.IamAssumedRoleArn - result[spec] = emptyArnRoleStatus - } else { - result[spec] = emptyArnRoleStatus - } - } else { - newStatus := status.NewCloudProviderAccessRole(spec.ProviderName, spec.IamAssumedRoleArn) - result[spec] = newStatus - statuses = append(statuses, newStatus) + + id := "" + + if feature.FeatureID != nil { + id = feature.FeatureID.(string) } + + cpaStatus.FeatureUsages = append( + cpaStatus.FeatureUsages, + status.FeatureUsage{ + FeatureID: id, + FeatureType: feature.FeatureType, + }, + ) } } - return result, false, false } -type accessRoleDiff struct { - toCreate []v1.CloudProviderAccessRole - toUpdate []mongodbatlas.CloudProviderAccessRole - toDelete map[string]string // roleId -> providerName +func createCloudProviderAccess(ctx context.Context, workflowCtx *workflow.Context, projectID string, cpaStatus *status.CloudProviderAccessRole) *status.CloudProviderAccessRole { + cpa, _, err := workflowCtx.Client.CloudProviderAccess.CreateRole( + ctx, + projectID, + &mongodbatlas.CloudProviderAccessRoleRequest{ + ProviderName: cpaStatus.ProviderName, + }, + ) + if err != nil { + workflowCtx.Log.Errorf("failed to start new cloud provider access: %s", err) + cpaStatus.Status = status.CloudProviderAccessStatusFailedToCreate + cpaStatus.ErrorMessage = err.Error() + + return cpaStatus + } + + copyCloudProviderAccessData(cpaStatus, cpa) + + return cpaStatus } -func sortAccessRoles(ctx context.Context, accessClient mongodbatlas.CloudProviderAccessService, logger *zap.SugaredLogger, expectedRoles map[v1.CloudProviderAccessRole]status.CloudProviderAccessRole, groupID string) (accessRoleDiff, error) { - roleList, _, err := accessClient.ListRoles(ctx, groupID) +func authorizeCloudProviderAccess(ctx context.Context, workflowCtx *workflow.Context, projectID string, cpaStatus *status.CloudProviderAccessRole) *status.CloudProviderAccessRole { + cpa, _, err := workflowCtx.Client.CloudProviderAccess.AuthorizeRole( + ctx, + projectID, + cpaStatus.RoleID, + &mongodbatlas.CloudProviderAccessRoleRequest{ + ProviderName: cpaStatus.ProviderName, + IAMAssumedRoleARN: &cpaStatus.IamAssumedRoleArn, + }, + ) if err != nil { - logger.Error("failed to list access roles", zap.Error(err)) - return accessRoleDiff{}, err - } - logger.Debugf("found %d access roles", len(roleList.AWSIAMRoles)) - existedRoles := roleList.AWSIAMRoles - diff := accessRoleDiff{} - diff.toDelete = make(map[string]string) - for _, existedRole := range existedRoles { - toDelete := true - for _, status := range expectedRoles { - if status.RoleID == existedRole.RoleID { - toDelete = false - diff.toUpdate = append(diff.toUpdate, existedRole) - break - } - } - if toDelete { - diff.toDelete[existedRole.RoleID] = existedRole.ProviderName - } + workflowCtx.Log.Errorf(fmt.Sprintf("failed to authorize cloud provider access: %s", err)) + cpaStatus.Status = status.CloudProviderAccessStatusFailedToAuthorize + cpaStatus.ErrorMessage = err.Error() + + return cpaStatus } - for spec, existedStatus := range expectedRoles { - if existedStatus.RoleID == "" { - diff.toCreate = append(diff.toCreate, spec) - } + copyCloudProviderAccessData(cpaStatus, cpa) + + return cpaStatus +} + +func deleteCloudProviderAccess(ctx context.Context, workflowCtx *workflow.Context, projectID string, cpaStatus *status.CloudProviderAccessRole) { + _, err := workflowCtx.Client.CloudProviderAccess.DeauthorizeRole( + ctx, + &mongodbatlas.CloudProviderDeauthorizationRequest{ + ProviderName: cpaStatus.ProviderName, + GroupID: projectID, + RoleID: cpaStatus.RoleID, + }, + ) + if err != nil { + workflowCtx.Log.Errorf(fmt.Sprintf("failed to delete cloud provider access: %s", err)) + cpaStatus.Status = status.CloudProviderAccessStatusFailedToDeAuthorize + cpaStatus.ErrorMessage = err.Error() } - return diff, nil } func canCloudProviderAccessReconcile(ctx context.Context, atlasClient mongodbatlas.Client, protected bool, akoProject *v1.AtlasProject) (bool, error) { diff --git a/pkg/controller/atlasproject/cloud_provider_access_test.go b/pkg/controller/atlasproject/cloud_provider_access_test.go index 47058a883e..2706425fd5 100644 --- a/pkg/controller/atlasproject/cloud_provider_access_test.go +++ b/pkg/controller/atlasproject/cloud_provider_access_test.go @@ -5,6 +5,10 @@ import ( "errors" "testing" + "go.uber.org/zap/zaptest" + + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status" + mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1" "github.com/stretchr/testify/require" @@ -15,7 +19,10 @@ import ( ) type cloudProviderAccessClient struct { - ListRolesFunc func(projectID string) (*mongodbatlas.CloudProviderAccessRoles, *mongodbatlas.Response, error) + ListRolesFunc func(projectID string) (*mongodbatlas.CloudProviderAccessRoles, *mongodbatlas.Response, error) + CreateRoleFunc func(projectID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) + AuthorizeRoleFunc func(projectID, roleID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) + DeauthorizeRoleFunc func(cpa *mongodbatlas.CloudProviderDeauthorizationRequest) (*mongodbatlas.Response, error) } func (c *cloudProviderAccessClient) ListRoles(_ context.Context, projectID string) (*mongodbatlas.CloudProviderAccessRoles, *mongodbatlas.Response, error) { @@ -26,15 +33,938 @@ func (c *cloudProviderAccessClient) GetRole(_ context.Context, _ string, _ strin return nil, nil, nil } -func (c *cloudProviderAccessClient) CreateRole(_ context.Context, _ string, _ *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { - return nil, nil, nil +func (c *cloudProviderAccessClient) CreateRole(_ context.Context, projectID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + return c.CreateRoleFunc(projectID, cpa) } -func (c *cloudProviderAccessClient) AuthorizeRole(_ context.Context, _ string, _ string, _ *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { - return nil, nil, nil +func (c *cloudProviderAccessClient) AuthorizeRole(_ context.Context, projectID string, roleID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + return c.AuthorizeRoleFunc(projectID, roleID, cpa) +} +func (c *cloudProviderAccessClient) DeauthorizeRole(_ context.Context, cpa *mongodbatlas.CloudProviderDeauthorizationRequest) (*mongodbatlas.Response, error) { + return c.DeauthorizeRoleFunc(cpa) +} + +func TestSyncCloudProviderAccess(t *testing.T) { + t.Run("should fail when atlas is unavailable", func(t *testing.T) { + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + ListRolesFunc: func(projectID string) (*mongodbatlas.CloudProviderAccessRoles, *mongodbatlas.Response, error) { + return nil, &mongodbatlas.Response{}, errors.New("service unavailable") + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + } + result, err := syncCloudProviderAccess(context.TODO(), workflowCtx, "projectID", []mdbv1.CloudProviderAccessRole{}) + require.EqualError(t, err, "unable to fetch cloud provider access from Atlas: service unavailable") + require.False(t, result) + }) + + t.Run("should synchronize all operations without reach ready status", func(t *testing.T) { + cpas := []mdbv1.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + }, + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-2", + }, + { + ProviderName: "AWS", + }, + } + atlasCPAs := []mongodbatlas.CloudProviderAccessRole{ + { + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IAMAssumedRoleARN: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IAMAssumedRoleARN: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-4", + AtlasAssumedRoleExternalID: "atlas-external-role-id-4", + IAMAssumedRoleARN: "aws:arn/my-role-4", + CreatedDate: "created-date-4", + ProviderName: "AWS", + RoleID: "role-4", + }, + } + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + ListRolesFunc: func(projectID string) (*mongodbatlas.CloudProviderAccessRoles, *mongodbatlas.Response, error) { + return &mongodbatlas.CloudProviderAccessRoles{ + AWSIAMRoles: atlasCPAs, + }, &mongodbatlas.Response{}, nil + }, + CreateRoleFunc: func(projectID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + return &mongodbatlas.CloudProviderAccessRole{ + AtlasAWSAccountARN: "atlas-account-arn-3", + AtlasAssumedRoleExternalID: "atlas-external-role-id-3", + CreatedDate: "created-date-3", + ProviderName: "AWS", + RoleID: "role-3", + }, &mongodbatlas.Response{}, nil + }, + AuthorizeRoleFunc: func(projectID, roleID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + atlasCPA := atlasCPAs[1] + atlasCPA.AuthorizedDate = "authorized-date-2" + + return &atlasCPA, &mongodbatlas.Response{}, nil + }, + DeauthorizeRoleFunc: func(cpa *mongodbatlas.CloudProviderDeauthorizationRequest) (*mongodbatlas.Response, error) { + return &mongodbatlas.Response{}, nil + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + } + + result, err := syncCloudProviderAccess(context.TODO(), workflowCtx, "projectID", cpas) + require.NoError(t, err) + require.False(t, result) + }) + + t.Run("should synchronize all operations and reach ready status", func(t *testing.T) { + cpas := []mdbv1.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + }, + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-2", + }, + } + atlasCPAs := []mongodbatlas.CloudProviderAccessRole{ + { + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IAMAssumedRoleARN: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IAMAssumedRoleARN: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + }, + } + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + ListRolesFunc: func(projectID string) (*mongodbatlas.CloudProviderAccessRoles, *mongodbatlas.Response, error) { + return &mongodbatlas.CloudProviderAccessRoles{ + AWSIAMRoles: atlasCPAs, + }, &mongodbatlas.Response{}, nil + }, + AuthorizeRoleFunc: func(projectID, roleID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + atlasCPA := atlasCPAs[1] + atlasCPA.AuthorizedDate = "authorized-date-2" + + return &atlasCPA, &mongodbatlas.Response{}, nil + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + } + + result, err := syncCloudProviderAccess(context.TODO(), workflowCtx, "projectID", cpas) + require.NoError(t, err) + require.True(t, result) + }) + + t.Run("should synchronize operations with errors", func(t *testing.T) { + cpas := []mdbv1.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + }, + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-2", + }, + } + atlasCPAs := []mongodbatlas.CloudProviderAccessRole{ + { + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IAMAssumedRoleARN: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IAMAssumedRoleARN: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + }, + } + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + ListRolesFunc: func(projectID string) (*mongodbatlas.CloudProviderAccessRoles, *mongodbatlas.Response, error) { + return &mongodbatlas.CloudProviderAccessRoles{ + AWSIAMRoles: atlasCPAs, + }, &mongodbatlas.Response{}, nil + }, + AuthorizeRoleFunc: func(projectID, roleID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + return nil, &mongodbatlas.Response{}, errors.New("service unavailable") + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + Log: zaptest.NewLogger(t).Sugar(), + } + + result, err := syncCloudProviderAccess(context.TODO(), workflowCtx, "projectID", cpas) + require.EqualError(t, err, "not all items were synchronized successfully") + require.False(t, result) + }) } -func (c *cloudProviderAccessClient) DeauthorizeRole(_ context.Context, _ *mongodbatlas.CloudProviderDeauthorizationRequest) (*mongodbatlas.Response, error) { - return nil, nil + +func TestInitiateStatus(t *testing.T) { + t.Run("should create a cloud provider status as new", func(t *testing.T) { + expected := []*status.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + } + spec := []mdbv1.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role", + }, + { + ProviderName: "AWS", + }, + } + + require.Equal(t, expected, initiateStatuses(spec)) + }) +} + +func TestEnrichStatuses(t *testing.T) { + t.Run("all statuses are new", func(t *testing.T) { + expected := []*status.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + } + statuses := []*status.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + } + require.Equal(t, expected, enrichStatuses(statuses, []mongodbatlas.CloudProviderAccessRole{})) + }) + + t.Run("one new and one authorized statuses", func(t *testing.T) { + expected := []*status.CloudProviderAccessRole{ + { + AtlasAWSAccountArn: "atlas-account-arn", + AtlasAssumedRoleExternalID: "atlas-external-role-id", + AuthorizedDate: "authorized-date", + CreatedDate: "created-date", + IamAssumedRoleArn: "aws:arn/my_role", + ProviderName: "AWS", + RoleID: "role-1", + Status: status.CloudProviderAccessStatusAuthorized, + ErrorMessage: "", + }, + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + } + statuses := []*status.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + } + atlasCPAs := []mongodbatlas.CloudProviderAccessRole{ + { + AtlasAWSAccountARN: "atlas-account-arn", + AtlasAssumedRoleExternalID: "atlas-external-role-id", + AuthorizedDate: "authorized-date", + CreatedDate: "created-date", + IAMAssumedRoleARN: "aws:arn/my_role", + ProviderName: "AWS", + RoleID: "role-1", + }, + } + require.Equal(t, expected, enrichStatuses(statuses, atlasCPAs)) + }) + + t.Run("one new, one created and one authorized statuses", func(t *testing.T) { + expected := []*status.CloudProviderAccessRole{ + { + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IamAssumedRoleArn: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + Status: status.CloudProviderAccessStatusAuthorized, + ErrorMessage: "", + }, + { + AtlasAWSAccountArn: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IamAssumedRoleArn: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + Status: status.CloudProviderAccessStatusCreated, + ErrorMessage: "", + }, + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + } + statuses := []*status.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-2", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + } + atlasCPAs := []mongodbatlas.CloudProviderAccessRole{ + { + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IAMAssumedRoleARN: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IAMAssumedRoleARN: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + }, + } + require.Equal(t, expected, enrichStatuses(statuses, atlasCPAs)) + }) + + t.Run("one new, one created, one authorized, and one authorized to remove statuses", func(t *testing.T) { + expected := []*status.CloudProviderAccessRole{ + { + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IamAssumedRoleArn: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + Status: status.CloudProviderAccessStatusAuthorized, + ErrorMessage: "", + }, + { + AtlasAWSAccountArn: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IamAssumedRoleArn: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + Status: status.CloudProviderAccessStatusCreated, + ErrorMessage: "", + }, + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + { + AtlasAWSAccountArn: "atlas-account-arn-3", + AtlasAssumedRoleExternalID: "atlas-external-role-id-3", + AuthorizedDate: "authorized-date-3", + CreatedDate: "created-date-3", + IamAssumedRoleArn: "aws:arn/my_role-3", + ProviderName: "AWS", + RoleID: "role-3", + Status: status.CloudProviderAccessStatusDeAuthorize, + ErrorMessage: "", + }, + } + statuses := []*status.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-2", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + } + atlasCPAs := []mongodbatlas.CloudProviderAccessRole{ + { + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IAMAssumedRoleARN: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-3", + AtlasAssumedRoleExternalID: "atlas-external-role-id-3", + AuthorizedDate: "authorized-date-3", + CreatedDate: "created-date-3", + IAMAssumedRoleARN: "aws:arn/my_role-3", + ProviderName: "AWS", + RoleID: "role-3", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IAMAssumedRoleARN: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + }, + } + require.Equal(t, expected, enrichStatuses(statuses, atlasCPAs)) + }) + + t.Run("one created with empty ARN, one created, and one authorized statuses", func(t *testing.T) { + expected := []*status.CloudProviderAccessRole{ + { + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IamAssumedRoleArn: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + Status: status.CloudProviderAccessStatusAuthorized, + ErrorMessage: "", + }, + { + AtlasAWSAccountArn: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IamAssumedRoleArn: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + Status: status.CloudProviderAccessStatusCreated, + ErrorMessage: "", + }, + { + AtlasAWSAccountArn: "atlas-account-arn-3", + AtlasAssumedRoleExternalID: "atlas-external-role-id-3", + CreatedDate: "created-date-3", + ProviderName: "AWS", + RoleID: "role-3", + Status: status.CloudProviderAccessStatusCreated, + ErrorMessage: "", + }, + } + statuses := []*status.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-2", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + } + atlasCPAs := []mongodbatlas.CloudProviderAccessRole{ + { + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IAMAssumedRoleARN: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-3", + AtlasAssumedRoleExternalID: "atlas-external-role-id-3", + CreatedDate: "created-date-3", + ProviderName: "AWS", + RoleID: "role-3", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IAMAssumedRoleARN: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + }, + } + require.Equal(t, expected, enrichStatuses(statuses, atlasCPAs)) + }) + + t.Run("one created with empty ARN, one created, one authorized, and one to be removed statuses", func(t *testing.T) { + expected := []*status.CloudProviderAccessRole{ + { + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IamAssumedRoleArn: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + Status: status.CloudProviderAccessStatusAuthorized, + ErrorMessage: "", + }, + { + AtlasAWSAccountArn: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IamAssumedRoleArn: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + Status: status.CloudProviderAccessStatusCreated, + ErrorMessage: "", + }, + { + AtlasAWSAccountArn: "atlas-account-arn-3", + AtlasAssumedRoleExternalID: "atlas-external-role-id-3", + CreatedDate: "created-date-3", + ProviderName: "AWS", + RoleID: "role-3", + Status: status.CloudProviderAccessStatusCreated, + ErrorMessage: "", + }, + { + AtlasAWSAccountArn: "atlas-account-arn-4", + AtlasAssumedRoleExternalID: "atlas-external-role-id-4", + CreatedDate: "created-date-4", + ProviderName: "AWS", + RoleID: "role-4", + Status: status.CloudProviderAccessStatusDeAuthorize, + ErrorMessage: "", + }, + } + statuses := []*status.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-2", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + } + atlasCPAs := []mongodbatlas.CloudProviderAccessRole{ + { + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IAMAssumedRoleARN: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-3", + AtlasAssumedRoleExternalID: "atlas-external-role-id-3", + CreatedDate: "created-date-3", + ProviderName: "AWS", + RoleID: "role-3", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IAMAssumedRoleARN: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-4", + AtlasAssumedRoleExternalID: "atlas-external-role-id-4", + CreatedDate: "created-date-4", + ProviderName: "AWS", + RoleID: "role-4", + }, + } + require.Equal(t, expected, enrichStatuses(statuses, atlasCPAs)) + }) + + t.Run("match two status with empty ARN and two existing on Atlas", func(t *testing.T) { + expected := []*status.CloudProviderAccessRole{ + { + ProviderName: "AWS", + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + RoleID: "role-1", + CreatedDate: "created-date-1", + Status: status.CloudProviderAccessStatusCreated, + }, + { + ProviderName: "AWS", + AtlasAWSAccountArn: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + RoleID: "role-2", + CreatedDate: "created-date-2", + Status: status.CloudProviderAccessStatusCreated, + }, + } + statuses := []*status.CloudProviderAccessRole{ + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + Status: status.CloudProviderAccessStatusNew, + }, + } + atlasCPAs := []mongodbatlas.CloudProviderAccessRole{ + { + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + CreatedDate: "created-date-1", + ProviderName: "AWS", + RoleID: "role-1", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + ProviderName: "AWS", + RoleID: "role-2", + }, + } + require.Equal(t, expected, enrichStatuses(statuses, atlasCPAs)) + }) + + t.Run("match two status with empty ARN and update them with ARN", func(t *testing.T) { + expected := []*status.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "was:arn/role-1", + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + RoleID: "role-1", + CreatedDate: "created-date-1", + Status: status.CloudProviderAccessStatusCreated, + }, + { + ProviderName: "AWS", + IamAssumedRoleArn: "was:arn/role-2", + AtlasAWSAccountArn: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + RoleID: "role-2", + CreatedDate: "created-date-2", + Status: status.CloudProviderAccessStatusCreated, + }, + } + statuses := []*status.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "was:arn/role-1", + Status: status.CloudProviderAccessStatusNew, + }, + { + ProviderName: "AWS", + IamAssumedRoleArn: "was:arn/role-2", + Status: status.CloudProviderAccessStatusNew, + }, + } + atlasCPAs := []mongodbatlas.CloudProviderAccessRole{ + { + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + CreatedDate: "created-date-1", + ProviderName: "AWS", + RoleID: "role-1", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + ProviderName: "AWS", + RoleID: "role-2", + }, + } + + require.Equal(t, expected, enrichStatuses(statuses, atlasCPAs)) + }) +} + +func TestCreateCloudProviderAccess(t *testing.T) { + t.Run("should create cloud provider access successfully", func(t *testing.T) { + expected := &status.CloudProviderAccessRole{ + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + CreatedDate: "created-date-1", + IamAssumedRoleArn: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + Status: status.CloudProviderAccessStatusCreated, + ErrorMessage: "", + } + cpa := &status.CloudProviderAccessRole{ + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + Status: status.CloudProviderAccessStatusNew, + } + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + CreateRoleFunc: func(projectID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + return &mongodbatlas.CloudProviderAccessRole{ + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + CreatedDate: "created-date-1", + ProviderName: "AWS", + RoleID: "role-1", + }, &mongodbatlas.Response{}, nil + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + } + + require.Equal(t, expected, createCloudProviderAccess(context.TODO(), workflowCtx, "projectID", cpa)) + }) + + t.Run("should fail to create cloud provider access", func(t *testing.T) { + expected := &status.CloudProviderAccessRole{ + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + Status: status.CloudProviderAccessStatusFailedToCreate, + ErrorMessage: "service unavailable", + } + cpa := &status.CloudProviderAccessRole{ + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + Status: status.CloudProviderAccessStatusNew, + } + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + CreateRoleFunc: func(projectID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + return nil, &mongodbatlas.Response{}, errors.New("service unavailable") + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + Log: zaptest.NewLogger(t).Sugar(), + } + + require.Equal(t, expected, createCloudProviderAccess(context.TODO(), workflowCtx, "projectID", cpa)) + }) +} + +func TestAuthorizeCloudProviderAccess(t *testing.T) { + t.Run("should authorize cloud provider access successfully", func(t *testing.T) { + expected := &status.CloudProviderAccessRole{ + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + CreatedDate: "created-date-1", + AuthorizedDate: "authorized-date-1", + IamAssumedRoleArn: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + Status: status.CloudProviderAccessStatusAuthorized, + ErrorMessage: "", + } + cpa := &status.CloudProviderAccessRole{ + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + CreatedDate: "created-date-1", + IamAssumedRoleArn: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + Status: status.CloudProviderAccessStatusNew, + } + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + AuthorizeRoleFunc: func(projectID, roleID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + return &mongodbatlas.CloudProviderAccessRole{ + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + CreatedDate: "created-date-1", + AuthorizedDate: "authorized-date-1", + ProviderName: "AWS", + RoleID: "role-1", + }, &mongodbatlas.Response{}, nil + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + } + + require.Equal(t, expected, authorizeCloudProviderAccess(context.TODO(), workflowCtx, "projectID", cpa)) + }) + + t.Run("should fail to authorize cloud provider access", func(t *testing.T) { + expected := &status.CloudProviderAccessRole{ + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + CreatedDate: "created-date-1", + IamAssumedRoleArn: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + Status: status.CloudProviderAccessStatusFailedToAuthorize, + ErrorMessage: "service unavailable", + } + cpa := &status.CloudProviderAccessRole{ + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + CreatedDate: "created-date-1", + IamAssumedRoleArn: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + Status: status.CloudProviderAccessStatusCreated, + } + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + AuthorizeRoleFunc: func(projectID, roleID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + return nil, &mongodbatlas.Response{}, errors.New("service unavailable") + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + Log: zaptest.NewLogger(t).Sugar(), + } + + require.Equal(t, expected, authorizeCloudProviderAccess(context.TODO(), workflowCtx, "projectID", cpa)) + }) +} + +func TestDeleteCloudProviderAccess(t *testing.T) { + t.Run("should delete cloud provider access successfully", func(t *testing.T) { + cpa := &status.CloudProviderAccessRole{ + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + CreatedDate: "created-date-1", + AuthorizedDate: "authorized-date-1", + IamAssumedRoleArn: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + Status: status.CloudProviderAccessStatusFailedToDeAuthorize, + ErrorMessage: "", + } + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + DeauthorizeRoleFunc: func(cpa *mongodbatlas.CloudProviderDeauthorizationRequest) (*mongodbatlas.Response, error) { + return &mongodbatlas.Response{}, nil + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + } + + deleteCloudProviderAccess(context.TODO(), workflowCtx, "projectID", cpa) + require.Empty(t, cpa.ErrorMessage) + }) + + t.Run("should fail to delete cloud provider access", func(t *testing.T) { + cpa := &status.CloudProviderAccessRole{ + AtlasAWSAccountArn: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + CreatedDate: "created-date-1", + ProviderName: "AWS", + RoleID: "role-1", + Status: status.CloudProviderAccessStatusFailedToDeAuthorize, + ErrorMessage: "", + } + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + DeauthorizeRoleFunc: func(cpa *mongodbatlas.CloudProviderDeauthorizationRequest) (*mongodbatlas.Response, error) { + return &mongodbatlas.Response{}, errors.New("service unavailable") + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + Log: zaptest.NewLogger(t).Sugar(), + } + + deleteCloudProviderAccess(context.TODO(), workflowCtx, "projectID", cpa) + require.Equal(t, "service unavailable", cpa.ErrorMessage) + }) } func TestCanCloudProviderAccessReconcile(t *testing.T) { @@ -297,4 +1227,191 @@ func TestEnsureCloudProviderAccess(t *testing.T) { result, ) }) + + t.Run("should return earlier when there are not items to operate", func(t *testing.T) { + akoProject := &mdbv1.AtlasProject{} + workflowCtx := &workflow.Context{} + result := ensureProviderAccessStatus(context.TODO(), workflowCtx, akoProject, false) + require.Equal( + t, + workflow.OK(), + result, + ) + }) + + t.Run("should fail to reconcile", func(t *testing.T) { + akoProject := &mdbv1.AtlasProject{ + Spec: mdbv1.AtlasProjectSpec{ + CloudProviderAccessRoles: []mdbv1.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + }, + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-2", + }, + }, + }, + } + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + ListRolesFunc: func(projectID string) (*mongodbatlas.CloudProviderAccessRoles, *mongodbatlas.Response, error) { + return nil, nil, errors.New("failed to retrieve data") + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + } + result := ensureProviderAccessStatus(context.TODO(), workflowCtx, akoProject, false) + require.Equal( + t, + workflow.Terminate(workflow.ProjectCloudAccessRolesIsNotReadyInAtlas, "unable to fetch cloud provider access from Atlas: failed to retrieve data"), + result, + ) + }) + + t.Run("should reconcile without reach ready status", func(t *testing.T) { + akoProject := &mdbv1.AtlasProject{ + Spec: mdbv1.AtlasProjectSpec{ + CloudProviderAccessRoles: []mdbv1.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + }, + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-2", + }, + { + ProviderName: "AWS", + }, + }, + }, + } + atlasCPAs := []mongodbatlas.CloudProviderAccessRole{ + { + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IAMAssumedRoleARN: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IAMAssumedRoleARN: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-4", + AtlasAssumedRoleExternalID: "atlas-external-role-id-4", + IAMAssumedRoleARN: "aws:arn/my-role-4", + CreatedDate: "created-date-4", + ProviderName: "AWS", + RoleID: "role-4", + }, + } + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + ListRolesFunc: func(projectID string) (*mongodbatlas.CloudProviderAccessRoles, *mongodbatlas.Response, error) { + return &mongodbatlas.CloudProviderAccessRoles{ + AWSIAMRoles: atlasCPAs, + }, &mongodbatlas.Response{}, nil + }, + CreateRoleFunc: func(projectID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + return &mongodbatlas.CloudProviderAccessRole{ + AtlasAWSAccountARN: "atlas-account-arn-3", + AtlasAssumedRoleExternalID: "atlas-external-role-id-3", + CreatedDate: "created-date-3", + ProviderName: "AWS", + RoleID: "role-3", + }, &mongodbatlas.Response{}, nil + }, + AuthorizeRoleFunc: func(projectID, roleID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + atlasCPA := atlasCPAs[1] + atlasCPA.AuthorizedDate = "authorized-date-2" + + return &atlasCPA, &mongodbatlas.Response{}, nil + }, + DeauthorizeRoleFunc: func(cpa *mongodbatlas.CloudProviderDeauthorizationRequest) (*mongodbatlas.Response, error) { + return &mongodbatlas.Response{}, nil + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + } + result := ensureProviderAccessStatus(context.TODO(), workflowCtx, akoProject, false) + require.Equal( + t, + workflow.InProgress(workflow.ProjectCloudAccessRolesIsNotReadyInAtlas, "not all entries are authorized"), + result, + ) + }) + + t.Run("should reconcile and reach ready status", func(t *testing.T) { + akoProject := &mdbv1.AtlasProject{ + Spec: mdbv1.AtlasProjectSpec{ + CloudProviderAccessRoles: []mdbv1.CloudProviderAccessRole{ + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-1", + }, + { + ProviderName: "AWS", + IamAssumedRoleArn: "aws:arn/my_role-2", + }, + }, + }, + } + atlasCPAs := []mongodbatlas.CloudProviderAccessRole{ + { + AtlasAWSAccountARN: "atlas-account-arn-1", + AtlasAssumedRoleExternalID: "atlas-external-role-id-1", + AuthorizedDate: "authorized-date-1", + CreatedDate: "created-date-1", + IAMAssumedRoleARN: "aws:arn/my_role-1", + ProviderName: "AWS", + RoleID: "role-1", + }, + { + AtlasAWSAccountARN: "atlas-account-arn-2", + AtlasAssumedRoleExternalID: "atlas-external-role-id-2", + CreatedDate: "created-date-2", + IAMAssumedRoleARN: "aws:arn/my_role-2", + ProviderName: "AWS", + RoleID: "role-2", + }, + } + atlasClient := mongodbatlas.Client{ + CloudProviderAccess: &cloudProviderAccessClient{ + ListRolesFunc: func(projectID string) (*mongodbatlas.CloudProviderAccessRoles, *mongodbatlas.Response, error) { + return &mongodbatlas.CloudProviderAccessRoles{ + AWSIAMRoles: atlasCPAs, + }, &mongodbatlas.Response{}, nil + }, + AuthorizeRoleFunc: func(projectID, roleID string, cpa *mongodbatlas.CloudProviderAccessRoleRequest) (*mongodbatlas.CloudProviderAccessRole, *mongodbatlas.Response, error) { + atlasCPA := atlasCPAs[1] + atlasCPA.AuthorizedDate = "authorized-date-2" + + return &atlasCPA, &mongodbatlas.Response{}, nil + }, + }, + } + workflowCtx := &workflow.Context{ + Client: atlasClient, + } + result := ensureProviderAccessStatus(context.TODO(), workflowCtx, akoProject, false) + require.Equal( + t, + workflow.OK(), + result, + ) + }) }