From 4e670265a1e2da936341b9b3c3bfb6adac6c42ad Mon Sep 17 00:00:00 2001 From: sivaram-mongodb Date: Wed, 7 Jan 2026 20:26:49 +0530 Subject: [PATCH 1/7] feat: Add Org-Service-account ClooudFormation resource --- cfn-resources/cfn-testing-helper.sh | 9 +- .../org-service-account/.rpdk-config | 27 ++ cfn-resources/org-service-account/Makefile | 23 ++ cfn-resources/org-service-account/README.md | 19 + cfn-resources/org-service-account/cmd/main.go | 85 ++++ .../cmd/resource/config.go | 19 + .../cmd/resource/mappings.go | 101 +++++ .../cmd/resource/mappings_test.go | 232 +++++++++++ .../org-service-account/cmd/resource/model.go | 26 ++ .../cmd/resource/resource.go | 82 ++++ .../cmd/resource/resource_test.go | 54 +++ .../org-service-account/cmd/resource/share.go | 172 ++++++++ .../cmd/resource/share_test.go | 379 ++++++++++++++++++ .../org-service-account/docs/README.md | 126 ++++++ .../org-service-account/docs/secret.md | 90 +++++ .../mongodb-atlas-orgserviceaccount.json | 136 +++++++ .../org-service-account/resource-role.yaml | 38 ++ .../org-service-account/template.yml | 27 ++ .../org-service-account/test/README.md | 65 +++ .../test/cfn-test-create-inputs.sh | 52 +++ .../test/inputs_1_create.template.json | 11 + .../test/inputs_1_update.template.json | 12 + examples/org-service-account/README.md | 24 ++ .../org-service-account.json | 82 ++++ 24 files changed, 1889 insertions(+), 2 deletions(-) create mode 100644 cfn-resources/org-service-account/.rpdk-config create mode 100644 cfn-resources/org-service-account/Makefile create mode 100644 cfn-resources/org-service-account/README.md create mode 100644 cfn-resources/org-service-account/cmd/main.go create mode 100644 cfn-resources/org-service-account/cmd/resource/config.go create mode 100644 cfn-resources/org-service-account/cmd/resource/mappings.go create mode 100644 cfn-resources/org-service-account/cmd/resource/mappings_test.go create mode 100644 cfn-resources/org-service-account/cmd/resource/model.go create mode 100644 cfn-resources/org-service-account/cmd/resource/resource.go create mode 100644 cfn-resources/org-service-account/cmd/resource/resource_test.go create mode 100644 cfn-resources/org-service-account/cmd/resource/share.go create mode 100644 cfn-resources/org-service-account/cmd/resource/share_test.go create mode 100644 cfn-resources/org-service-account/docs/README.md create mode 100644 cfn-resources/org-service-account/docs/secret.md create mode 100644 cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json create mode 100644 cfn-resources/org-service-account/resource-role.yaml create mode 100644 cfn-resources/org-service-account/template.yml create mode 100644 cfn-resources/org-service-account/test/README.md create mode 100755 cfn-resources/org-service-account/test/cfn-test-create-inputs.sh create mode 100644 cfn-resources/org-service-account/test/inputs_1_create.template.json create mode 100644 cfn-resources/org-service-account/test/inputs_1_update.template.json create mode 100644 examples/org-service-account/README.md create mode 100644 examples/org-service-account/org-service-account.json diff --git a/cfn-resources/cfn-testing-helper.sh b/cfn-resources/cfn-testing-helper.sh index ae4966ecf..749ab8479 100755 --- a/cfn-resources/cfn-testing-helper.sh +++ b/cfn-resources/cfn-testing-helper.sh @@ -157,9 +157,14 @@ done echo "Step 4/4: cleaning up 'cfn test' inputs " SAM_LOG=$(mktemp) for resource in ${resources}; do - cd "${res}" + cd "${resource}" + if [ -f ./test/cfn-test-delete-inputs.sh ]; then chmod +x ./test/cfn-test-delete-inputs.sh - ./test/cfn-test-delete-inputs.sh "${PROJECT_NAME}-${res}" && echo "resource:${res} inputs delete OK" || echo "resource:${res} input delete FAILED" + ./test/cfn-test-delete-inputs.sh "${PROJECT_NAME}-${resource}" && echo "resource:${resource} inputs delete OK" || echo "resource:${resource} input delete FAILED" + else + echo "resource:${resource} - delete script not found, skipping cleanup" + fi + cd - done echo "Clean up project" diff --git a/cfn-resources/org-service-account/.rpdk-config b/cfn-resources/org-service-account/.rpdk-config new file mode 100644 index 000000000..799d5bd2f --- /dev/null +++ b/cfn-resources/org-service-account/.rpdk-config @@ -0,0 +1,27 @@ +{ + "artifact_type": "RESOURCE", + "typeName": "MongoDB::Atlas::OrgServiceAccount", + "language": "go", + "runtime": "provided.al2", + "entrypoint": "bootstrap", + "testEntrypoint": "bootstrap", + "settings": { + "version": false, + "subparser_name": null, + "verbose": 0, + "force": false, + "type_name": null, + "artifact_type": null, + "endpoint_url": null, + "region": null, + "target_schemas": [], + "profile": null, + "import_path": "github.com/mongodb/mongodbatlas-cloudformation-resources/org-service-account", + "protocolVersion": "2.0.0" + }, + "canarySettings": { + "contract_test_file_names": [ + "inputs_1.json" + ] + } +} diff --git a/cfn-resources/org-service-account/Makefile b/cfn-resources/org-service-account/Makefile new file mode 100644 index 000000000..fd9da008a --- /dev/null +++ b/cfn-resources/org-service-account/Makefile @@ -0,0 +1,23 @@ +.PHONY: build test clean debug +tags=logging callback metrics scheduler +cgo=0 +goos=linux +goarch=amd64 +CFNREP_GIT_SHA?=$(shell git rev-parse HEAD) +ldXflags=-s -w -X github.com/mongodb/mongodbatlas-cloudformation-resources/util.defaultLogLevel=info -X github.com/mongodb/mongodbatlas-cloudformation-resources/version.Version=${CFNREP_GIT_SHA} +ldXflagsD=-X github.com/mongodb/mongodbatlas-cloudformation-resources/util.defaultLogLevel=debug -X github.com/mongodb/mongodbatlas-cloudformation-resources/version.Version=${CFNREP_GIT_SHA} + +build: + cfn generate + env GOOS=$(goos) CGO_ENABLED=$(cgo) GOARCH=$(goarch) go build -ldflags="$(ldXflags)" -tags="$(tags)" -o bin/bootstrap cmd/main.go + +debug: + cfn generate + env GOOS=$(goos) CGO_ENABLED=$(cgo) GOARCH=$(goarch) go build -ldflags="$(ldXflagsD)" -tags="$(tags)" -o bin/bootstrap cmd/main.go + +test: + cfn generate + env GOOS=$(goos) go build -ldflags="-s -w" -tags="$(tags)" -o bin/bootstrap cmd/main.go + +clean: + rm -rf bin diff --git a/cfn-resources/org-service-account/README.md b/cfn-resources/org-service-account/README.md new file mode 100644 index 000000000..70049f799 --- /dev/null +++ b/cfn-resources/org-service-account/README.md @@ -0,0 +1,19 @@ +# MongoDB::Atlas::OrgServiceAccount + +## Description + +Resource for managing [Service Accounts](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/group/endpoint-service-accounts) for a MongoDB Atlas organization. Service accounts provide programmatic access to MongoDB Atlas resources and are used for automation, CI/CD pipelines, and service-to-service authentication. + +## Requirements + +To securely give CloudFormation access to your Atlas credentials, you must +set up an [AWS Profile](/README.md#mongodb-atlas-api-keys-credential-management). + +## Attributes and Parameters + +See the [resource docs](docs/README.md). + +## Cloudformation Examples + +See the examples [CFN Template](/examples/org-service-account/README.md) for example resource. + diff --git a/cfn-resources/org-service-account/cmd/main.go b/cfn-resources/org-service-account/cmd/main.go new file mode 100644 index 000000000..cd90f2251 --- /dev/null +++ b/cfn-resources/org-service-account/cmd/main.go @@ -0,0 +1,85 @@ +// Code generated by 'cfn generate', changes will be undone by the next invocation. DO NOT EDIT. +package main + +import ( + "errors" + "fmt" + "log" + + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn" + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/mongodb/mongodbatlas-cloudformation-resources/org-service-account/cmd/resource" +) + +// Handler is a container for the CRUDL actions exported by resources +type Handler struct{} + +// Create wraps the related Create function exposed by the resource code +func (r *Handler) Create(req handler.Request) handler.ProgressEvent { + return wrap(req, resource.Create) +} + +// Read wraps the related Read function exposed by the resource code +func (r *Handler) Read(req handler.Request) handler.ProgressEvent { + return wrap(req, resource.Read) +} + +// Update wraps the related Update function exposed by the resource code +func (r *Handler) Update(req handler.Request) handler.ProgressEvent { + return wrap(req, resource.Update) +} + +// Delete wraps the related Delete function exposed by the resource code +func (r *Handler) Delete(req handler.Request) handler.ProgressEvent { + return wrap(req, resource.Delete) +} + +// List wraps the related List function exposed by the resource code +func (r *Handler) List(req handler.Request) handler.ProgressEvent { + return wrap(req, resource.List) +} + +// main is the entry point of the application. +func main() { + cfn.Start(&Handler{}) +} + +type handlerFunc func(handler.Request, *resource.Model, *resource.Model) (handler.ProgressEvent, error) + +func wrap(req handler.Request, f handlerFunc) (response handler.ProgressEvent) { + defer func() { + // Catch any panics and return a failed ProgressEvent + if r := recover(); r != nil { + err, ok := r.(error) + if !ok { + err = errors.New(fmt.Sprint(r)) + } + + log.Printf("Trapped error in handler: %v", err) + + response = handler.NewFailedEvent(err) + } + }() + + // Populate the previous model + prevModel := &resource.Model{} + if err := req.UnmarshalPrevious(prevModel); err != nil { + log.Printf("Error unmarshaling prev model: %v", err) + return handler.NewFailedEvent(err) + } + + // Populate the current model + currentModel := &resource.Model{} + if err := req.Unmarshal(currentModel); err != nil { + log.Printf("Error unmarshaling model: %v", err) + return handler.NewFailedEvent(err) + } + + response, err := f(req, prevModel, currentModel) + if err != nil { + log.Printf("Error returned from handler function: %v", err) + return handler.NewFailedEvent(err) + } + + return response +} diff --git a/cfn-resources/org-service-account/cmd/resource/config.go b/cfn-resources/org-service-account/cmd/resource/config.go new file mode 100644 index 000000000..4d9eb7831 --- /dev/null +++ b/cfn-resources/org-service-account/cmd/resource/config.go @@ -0,0 +1,19 @@ +// Code generated by 'cfn generate', changes will be undone by the next invocation. DO NOT EDIT. +// Updates to this type are made my editing the schema file and executing the 'generate' command. +package resource + +import "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + +// TypeConfiguration is autogenerated from the json schema +type TypeConfiguration struct { +} + +// Configuration returns a resource's configuration. +func Configuration(req handler.Request) (*TypeConfiguration, error) { + // Populate the type configuration + typeConfig := &TypeConfiguration{} + if err := req.UnmarshalTypeConfig(typeConfig); err != nil { + return typeConfig, err + } + return typeConfig, nil +} diff --git a/cfn-resources/org-service-account/cmd/resource/mappings.go b/cfn-resources/org-service-account/cmd/resource/mappings.go new file mode 100644 index 000000000..01a8e780e --- /dev/null +++ b/cfn-resources/org-service-account/cmd/resource/mappings.go @@ -0,0 +1,101 @@ +// Copyright 2026 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 resource + +import ( + "sort" + + "go.mongodb.org/atlas-sdk/v20250312010/admin" + + "github.com/mongodb/mongodbatlas-cloudformation-resources/util" +) + +func GetOrgServiceAccountModel(account *admin.OrgServiceAccount, currentModel *Model) *Model { + model := new(Model) + + if currentModel != nil { + model = currentModel + } + if account != nil { + if currentModel != nil { + model.OrgId = currentModel.OrgId + model.Profile = currentModel.Profile + } + model.Name = account.Name + model.Description = account.Description + if account.Roles != nil { + roles := *account.Roles + if currentModel != nil && currentModel.Roles != nil && len(currentModel.Roles) > 0 { + model.Roles = currentModel.Roles + } else { + sort.Strings(roles) + model.Roles = roles + } + } + model.ClientId = account.ClientId + model.CreatedAt = util.TimePtrToStringPtr(account.CreatedAt) + + if account.Secrets != nil { + model.Secrets = make([]Secret, len(*account.Secrets)) + for i, s := range *account.Secrets { + createdAt := s.CreatedAt + expiresAt := s.ExpiresAt + model.Secrets[i] = Secret{ + Id: &s.Id, + CreatedAt: util.TimePtrToStringPtr(&createdAt), + ExpiresAt: util.TimePtrToStringPtr(&expiresAt), + LastUsedAt: util.TimePtrToStringPtr(s.LastUsedAt), + MaskedSecretValue: s.MaskedSecretValue, + Secret: s.Secret, + } + } + } + } + return model +} + +func NewOrgServiceAccountCreateReq(model *Model) *admin.OrgServiceAccountRequest { + if model == nil { + return nil + } + secretExpiresAfterHours := *model.SecretExpiresAfterHours + roles := make([]string, len(model.Roles)) + copy(roles, model.Roles) + sort.Strings(roles) + return &admin.OrgServiceAccountRequest{ + Name: *model.Name, + Description: *model.Description, + Roles: roles, + SecretExpiresAfterHours: secretExpiresAfterHours, + } +} + +func NewOrgServiceAccountUpdateReq(model *Model) *admin.OrgServiceAccountUpdateRequest { + if model == nil { + return nil + } + var roles *[]string + if len(model.Roles) > 0 { + sortedRoles := make([]string, len(model.Roles)) + copy(sortedRoles, model.Roles) + sort.Strings(sortedRoles) + roles = &sortedRoles + } + return &admin.OrgServiceAccountUpdateRequest{ + Name: model.Name, + Description: model.Description, + Roles: roles, + } +} diff --git a/cfn-resources/org-service-account/cmd/resource/mappings_test.go b/cfn-resources/org-service-account/cmd/resource/mappings_test.go new file mode 100644 index 000000000..50fd8eeb0 --- /dev/null +++ b/cfn-resources/org-service-account/cmd/resource/mappings_test.go @@ -0,0 +1,232 @@ +// Copyright 2026 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 resource_test + +import ( + "testing" + "time" + + "go.mongodb.org/atlas-sdk/v20250312010/admin" + + "github.com/aws/smithy-go/ptr" + "github.com/mongodb/mongodbatlas-cloudformation-resources/org-service-account/cmd/resource" + "github.com/stretchr/testify/assert" +) + +func TestNewOrgServiceAccountCreateReq(t *testing.T) { + tests := []struct { + input *resource.Model + expected *admin.OrgServiceAccountRequest + name string + }{ + { + name: "Nil Input", + input: nil, + expected: nil, + }, + { + name: "Valid Input - Roles Sorted", + input: &resource.Model{ + Name: ptr.String("test"), + Description: ptr.String("desc"), + Roles: []string{"ORG_MEMBER", "ORG_GROUP_CREATOR"}, + SecretExpiresAfterHours: ptr.Int(720), + }, + expected: &admin.OrgServiceAccountRequest{ + Name: "test", + Description: "desc", + Roles: []string{"ORG_GROUP_CREATOR", "ORG_MEMBER"}, + SecretExpiresAfterHours: 720, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := resource.NewOrgServiceAccountCreateReq(tt.input) + if tt.expected == nil { + assert.Nil(t, actual) + return + } + assert.Equal(t, tt.expected.Name, actual.Name) + assert.Equal(t, tt.expected.Description, actual.Description) + assert.Equal(t, tt.expected.Roles, actual.Roles) + assert.Equal(t, tt.expected.SecretExpiresAfterHours, actual.SecretExpiresAfterHours) + }) + } +} + +func TestNewOrgServiceAccountUpdateReq(t *testing.T) { + tests := []struct { + input *resource.Model + expected *admin.OrgServiceAccountUpdateRequest + name string + }{ + { + name: "Nil Input", + input: nil, + expected: nil, + }, + { + name: "Valid Input - Roles Sorted", + input: &resource.Model{ + Name: ptr.String("test"), + Description: ptr.String("desc"), + Roles: []string{"ORG_MEMBER", "ORG_GROUP_CREATOR"}, + }, + expected: &admin.OrgServiceAccountUpdateRequest{ + Name: ptr.String("test"), + Description: ptr.String("desc"), + Roles: &[]string{"ORG_GROUP_CREATOR", "ORG_MEMBER"}, + }, + }, + { + name: "Empty Roles", + input: &resource.Model{ + Name: ptr.String("test"), + Description: ptr.String("desc"), + Roles: []string{}, + }, + expected: &admin.OrgServiceAccountUpdateRequest{ + Name: ptr.String("test"), + Description: ptr.String("desc"), + Roles: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := resource.NewOrgServiceAccountUpdateReq(tt.input) + if tt.expected == nil { + assert.Nil(t, actual) + return + } + assert.Equal(t, tt.expected.Name, actual.Name) + assert.Equal(t, tt.expected.Description, actual.Description) + if tt.expected.Roles == nil { + assert.Nil(t, actual.Roles) + } else { + assert.Equal(t, *tt.expected.Roles, *actual.Roles) + } + }) + } +} + +func TestGetOrgServiceAccountModel(t *testing.T) { + now := time.Now() + clientID := "mdb_sa_id_123" + orgID := "63350255419cf25e3d511c95" + secretID := "secret-123" + + tests := []struct { + inputSDK *admin.OrgServiceAccount + inputModel *resource.Model + validate func(*testing.T, *resource.Model) + name string + }{ + { + name: "Nil SDK Input", + inputSDK: nil, + inputModel: nil, + validate: func(t *testing.T, result *resource.Model) { + t.Helper() + assert.NotNil(t, result) + }, + }, + { + name: "Valid SDK Input - Preserve OrgId/Profile", + inputSDK: &admin.OrgServiceAccount{ + ClientId: ptr.String(clientID), + Name: ptr.String("test"), + Description: ptr.String("desc"), + Roles: &[]string{"ORG_MEMBER"}, + CreatedAt: &now, + }, + inputModel: &resource.Model{ + OrgId: ptr.String(orgID), + Profile: ptr.String("default"), + }, + validate: func(t *testing.T, result *resource.Model) { + t.Helper() + assert.Equal(t, orgID, *result.OrgId) + assert.Equal(t, "default", *result.Profile) + assert.Equal(t, clientID, *result.ClientId) + }, + }, + { + name: "Valid SDK Input - Preserve Roles Order", + inputSDK: &admin.OrgServiceAccount{ + ClientId: ptr.String(clientID), + Name: ptr.String("test"), + Roles: &[]string{"ORG_MEMBER", "ORG_GROUP_CREATOR"}, + CreatedAt: &now, + }, + inputModel: &resource.Model{ + Roles: []string{"ORG_GROUP_CREATOR", "ORG_MEMBER"}, + }, + validate: func(t *testing.T, result *resource.Model) { + t.Helper() + assert.Equal(t, []string{"ORG_GROUP_CREATOR", "ORG_MEMBER"}, result.Roles) + }, + }, + { + name: "Valid SDK Input - With Secrets", + inputSDK: &admin.OrgServiceAccount{ + ClientId: ptr.String(clientID), + Name: ptr.String("test"), + CreatedAt: &now, + Secrets: &[]admin.ServiceAccountSecret{ + { + Id: secretID, + CreatedAt: now, + ExpiresAt: now.Add(720 * time.Hour), + MaskedSecretValue: ptr.String("****"), + Secret: ptr.String("secret-value"), + }, + }, + }, + inputModel: nil, + validate: func(t *testing.T, result *resource.Model) { + t.Helper() + assert.NotNil(t, result.Secrets) + assert.Len(t, result.Secrets, 1) + assert.Equal(t, secretID, *result.Secrets[0].Id) + assert.Equal(t, "secret-value", *result.Secrets[0].Secret) + }, + }, + { + name: "Nil Secrets", + inputSDK: &admin.OrgServiceAccount{ + ClientId: ptr.String(clientID), + Name: ptr.String("test"), + CreatedAt: &now, + Secrets: nil, + }, + inputModel: nil, + validate: func(t *testing.T, result *resource.Model) { + t.Helper() + assert.Nil(t, result.Secrets) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := resource.GetOrgServiceAccountModel(tt.inputSDK, tt.inputModel) + tt.validate(t, result) + }) + } +} diff --git a/cfn-resources/org-service-account/cmd/resource/model.go b/cfn-resources/org-service-account/cmd/resource/model.go new file mode 100644 index 000000000..483c8c7c5 --- /dev/null +++ b/cfn-resources/org-service-account/cmd/resource/model.go @@ -0,0 +1,26 @@ +// Code generated by 'cfn generate', changes will be undone by the next invocation. DO NOT EDIT. +// Updates to this type are made my editing the schema file and executing the 'generate' command. +package resource + +// Model is autogenerated from the json schema +type Model struct { + Profile *string `json:",omitempty"` + OrgId *string `json:",omitempty"` + Name *string `json:",omitempty"` + Description *string `json:",omitempty"` + Roles []string `json:",omitempty"` + SecretExpiresAfterHours *int `json:",omitempty"` + ClientId *string `json:",omitempty"` + CreatedAt *string `json:",omitempty"` + Secrets []Secret `json:",omitempty"` +} + +// Secret is autogenerated from the json schema +type Secret struct { + Id *string `json:",omitempty"` + CreatedAt *string `json:",omitempty"` + ExpiresAt *string `json:",omitempty"` + LastUsedAt *string `json:",omitempty"` + MaskedSecretValue *string `json:",omitempty"` + Secret *string `json:",omitempty"` +} diff --git a/cfn-resources/org-service-account/cmd/resource/resource.go b/cfn-resources/org-service-account/cmd/resource/resource.go new file mode 100644 index 000000000..29da7184d --- /dev/null +++ b/cfn-resources/org-service-account/cmd/resource/resource.go @@ -0,0 +1,82 @@ +// Copyright 2026 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 resource + +import ( + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util/validator" +) + +var ( + CreateRequiredFields = []string{"OrgId", "Name", "Description", "Roles"} + ReadRequiredFields = []string{"OrgId", "ClientId"} + UpdateRequiredFields = []string{"OrgId", "ClientId"} + DeleteRequiredFields = []string{"OrgId", "ClientId"} + ListRequiredFields = []string{"OrgId"} +) + +var SetupRequest = func(req handler.Request, model *Model, requiredFields []string) (*util.MongoDBClient, *handler.ProgressEvent) { + util.SetupLogger("mongodb-atlas-org-service-account") + if modelValidation := validator.ValidateModel(requiredFields, model); modelValidation != nil { + return nil, modelValidation + } + util.SetDefaultProfileIfNotDefined(&model.Profile) + client, peErr := util.NewAtlasClient(&req, model.Profile) + if peErr != nil { + return nil, peErr + } + return client, nil +} + +func Create(req handler.Request, prevModel *Model, model *Model) (handler.ProgressEvent, error) { + client, setupErr := SetupRequest(req, model, CreateRequiredFields) + if setupErr != nil { + return *setupErr, nil + } + return HandleCreate(&req, client, model), nil +} + +func Read(req handler.Request, prevModel *Model, model *Model) (handler.ProgressEvent, error) { + client, setupErr := SetupRequest(req, model, ReadRequiredFields) + if setupErr != nil { + return *setupErr, nil + } + return HandleRead(&req, client, model), nil +} + +func Update(req handler.Request, prevModel *Model, model *Model) (handler.ProgressEvent, error) { + client, setupErr := SetupRequest(req, model, UpdateRequiredFields) + if setupErr != nil { + return *setupErr, nil + } + return HandleUpdate(&req, client, model), nil +} + +func Delete(req handler.Request, prevModel *Model, model *Model) (handler.ProgressEvent, error) { + client, setupErr := SetupRequest(req, model, DeleteRequiredFields) + if setupErr != nil { + return *setupErr, nil + } + return HandleDelete(&req, client, model), nil +} + +func List(req handler.Request, prevModel *Model, model *Model) (handler.ProgressEvent, error) { + client, setupErr := SetupRequest(req, model, ListRequiredFields) + if setupErr != nil { + return *setupErr, nil + } + return HandleList(&req, client, model), nil +} diff --git a/cfn-resources/org-service-account/cmd/resource/resource_test.go b/cfn-resources/org-service-account/cmd/resource/resource_test.go new file mode 100644 index 000000000..b3a8bfb09 --- /dev/null +++ b/cfn-resources/org-service-account/cmd/resource/resource_test.go @@ -0,0 +1,54 @@ +// Copyright 2026 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 resource_test + +import ( + "testing" + + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/mongodb/mongodbatlas-cloudformation-resources/org-service-account/cmd/resource" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConstants(t *testing.T) { + assert.Equal(t, []string{"OrgId", "Name", "Description", "Roles"}, resource.CreateRequiredFields) + assert.Equal(t, []string{"OrgId", "ClientId"}, resource.ReadRequiredFields) + assert.Equal(t, []string{"OrgId", "ClientId"}, resource.UpdateRequiredFields) + assert.Equal(t, []string{"OrgId", "ClientId"}, resource.DeleteRequiredFields) + assert.Equal(t, []string{"OrgId"}, resource.ListRequiredFields) +} + +func TestValidationErrors(t *testing.T) { + tests := []struct { + operation func(handler.Request, *resource.Model, *resource.Model) (handler.ProgressEvent, error) + currentModel *resource.Model + name string + }{ + {resource.Create, &resource.Model{}, "Create_missingOrgId"}, + {resource.Read, &resource.Model{}, "Read_missingOrgId"}, + {resource.Update, &resource.Model{}, "Update_missingOrgId"}, + {resource.Delete, &resource.Model{}, "Delete_missingOrgId"}, + {resource.List, &resource.Model{}, "List_missingOrgId"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event, err := tt.operation(handler.Request{}, nil, tt.currentModel) + require.NoError(t, err) + assert.Equal(t, handler.Failed, event.OperationStatus) + }) + } +} diff --git a/cfn-resources/org-service-account/cmd/resource/share.go b/cfn-resources/org-service-account/cmd/resource/share.go new file mode 100644 index 000000000..fe71a0ca9 --- /dev/null +++ b/cfn-resources/org-service-account/cmd/resource/share.go @@ -0,0 +1,172 @@ +// Copyright 2026 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 resource + +import ( + "context" + "fmt" + "net/http" + + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util/constants" + progress_events "github.com/mongodb/mongodbatlas-cloudformation-resources/util/progressevent" +) + +func HandleCreate(req *handler.Request, client *util.MongoDBClient, model *Model) handler.ProgressEvent { + ctx := context.Background() + orgID := model.OrgId + serviceAccountReq := NewOrgServiceAccountCreateReq(model) + + serviceAccountResp, apiResp, err := client.AtlasSDK.ServiceAccountsApi.CreateOrgServiceAccount(ctx, *orgID, serviceAccountReq).Execute() + if err != nil { + return HandleError(apiResp, constants.CREATE, err) + } + + resourceModel := GetOrgServiceAccountModel(serviceAccountResp, model) + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: constants.Complete, + ResourceModel: resourceModel, + } +} + +func HandleRead(req *handler.Request, client *util.MongoDBClient, model *Model) handler.ProgressEvent { + ctx := context.Background() + orgID := model.OrgId + clientID := model.ClientId + + serviceAccount, apiResp, err := client.AtlasSDK.ServiceAccountsApi.GetOrgServiceAccount(ctx, *orgID, *clientID).Execute() + if err != nil { + return HandleError(apiResp, constants.READ, err) + } + + resourceModel := GetOrgServiceAccountModel(serviceAccount, model) + if resourceModel.Secrets != nil { + for i := range resourceModel.Secrets { + resourceModel.Secrets[i].Secret = nil + } + } + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: constants.ReadComplete, + ResourceModel: resourceModel, + } +} + +func HandleUpdate(req *handler.Request, client *util.MongoDBClient, model *Model) handler.ProgressEvent { + ctx := context.Background() + orgID := model.OrgId + clientID := model.ClientId + + _, apiResp, err := client.AtlasSDK.ServiceAccountsApi.GetOrgServiceAccount(ctx, *orgID, *clientID).Execute() + if err != nil { + if apiResp != nil && apiResp.StatusCode == http.StatusNotFound { + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + Message: "Resource not found", + HandlerErrorCode: string(types.HandlerErrorCodeNotFound), + } + } + return HandleError(apiResp, constants.UPDATE, err) + } + + serviceAccountReq := NewOrgServiceAccountUpdateReq(model) + serviceAccountResp, apiResp, err := client.AtlasSDK.ServiceAccountsApi.UpdateOrgServiceAccount(ctx, *clientID, *orgID, serviceAccountReq).Execute() + if err != nil { + return HandleError(apiResp, constants.UPDATE, err) + } + + resourceModel := GetOrgServiceAccountModel(serviceAccountResp, model) + if resourceModel.Secrets != nil { + for i := range resourceModel.Secrets { + resourceModel.Secrets[i].Secret = nil + } + } + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: constants.Complete, + ResourceModel: resourceModel, + } +} + +func HandleDelete(req *handler.Request, client *util.MongoDBClient, model *Model) handler.ProgressEvent { + ctx := context.Background() + orgID := model.OrgId + clientID := model.ClientId + + _, resp, err := client.AtlasSDK.ServiceAccountsApi.GetOrgServiceAccount(ctx, *orgID, *clientID).Execute() + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + Message: "Resource not found", + HandlerErrorCode: string(types.HandlerErrorCodeNotFound), + } + } + return HandleError(resp, constants.DELETE, err) + } + + apiResp, err := client.AtlasSDK.ServiceAccountsApi.DeleteOrgServiceAccount(ctx, *clientID, *orgID).Execute() + if err != nil { + return HandleError(apiResp, constants.DELETE, err) + } + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: constants.Complete, + } +} + +func HandleList(req *handler.Request, client *util.MongoDBClient, model *Model) handler.ProgressEvent { + ctx := context.Background() + orgID := model.OrgId + + serviceAccounts, apiResp, err := client.AtlasSDK.ServiceAccountsApi.ListOrgServiceAccounts(ctx, *orgID).Execute() + if err != nil { + return HandleError(apiResp, constants.LIST, err) + } + + response := make([]interface{}, 0) + if serviceAccounts != nil && serviceAccounts.Results != nil { + for i := range *serviceAccounts.Results { + itemModel := &Model{} + resourceModel := GetOrgServiceAccountModel(&(*serviceAccounts.Results)[i], itemModel) + resourceModel.OrgId = model.OrgId + resourceModel.Profile = model.Profile + if resourceModel.Secrets != nil { + for j := range resourceModel.Secrets { + resourceModel.Secrets[j].Secret = nil + } + } + response = append(response, resourceModel) + } + } + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: constants.Complete, + ResourceModels: response, + } +} + +func HandleError(response *http.Response, method constants.CfnFunctions, err error) handler.ProgressEvent { + errMsg := fmt.Sprintf("%s error:%s", method, err.Error()) + return progress_events.GetFailedEventByResponse(errMsg, response) +} diff --git a/cfn-resources/org-service-account/cmd/resource/share_test.go b/cfn-resources/org-service-account/cmd/resource/share_test.go new file mode 100644 index 000000000..f808abc0b --- /dev/null +++ b/cfn-resources/org-service-account/cmd/resource/share_test.go @@ -0,0 +1,379 @@ +// Copyright 2026 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 resource_test + +import ( + "fmt" + "net/http" + "testing" + "time" + + "go.mongodb.org/atlas-sdk/v20250312010/admin" + "go.mongodb.org/atlas-sdk/v20250312010/mockadmin" + + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" + "github.com/aws/smithy-go/ptr" + "github.com/mongodb/mongodbatlas-cloudformation-resources/org-service-account/cmd/resource" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func createTestModel() *resource.Model { + orgID := "63350255419cf25e3d511c95" + name := "test-service-account" + description := "Test description" + roles := []string{"ORG_MEMBER"} + secretExpiresAfterHours := 720 + profile := "default" + + return &resource.Model{ + Profile: &profile, + OrgId: &orgID, + Name: &name, + Description: &description, + Roles: roles, + SecretExpiresAfterHours: &secretExpiresAfterHours, + } +} + +func createTestResponse() *admin.OrgServiceAccount { + now := time.Now() + clientID := "mdb_sa_id_123456789" + name := "test-service-account" + description := "Test description" + roles := []string{"ORG_MEMBER"} + secretID := "secret-id-123" + + return &admin.OrgServiceAccount{ + ClientId: &clientID, + Name: &name, + Description: &description, + Roles: &roles, + CreatedAt: &now, + Secrets: &[]admin.ServiceAccountSecret{ + { + Id: secretID, + CreatedAt: now, + ExpiresAt: now.Add(720 * time.Hour), + MaskedSecretValue: ptr.String("****"), + Secret: ptr.String("secret-value"), + }, + }, + } +} + +func TestCRUDOperations(t *testing.T) { + originalSetupRequest := resource.SetupRequest + defer func() { resource.SetupRequest = originalSetupRequest }() + + tests := []struct { + name string + operation func(handler.Request, *resource.Model, *resource.Model) (handler.ProgressEvent, error) + setupModel func() *resource.Model + mockSetup func(*mockadmin.ServiceAccountsApi) + validate func(*testing.T, handler.ProgressEvent) + expectedStatus handler.Status + }{ + { + name: "Create_Success", + operation: resource.Create, + setupModel: createTestModel, + mockSetup: func(m *mockadmin.ServiceAccountsApi) { + resp := createTestResponse() + m.EXPECT().CreateOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). + Return(admin.CreateOrgServiceAccountApiRequest{ApiService: m}) + m.EXPECT().CreateOrgServiceAccountExecute(mock.Anything). + Return(resp, &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.Success, + validate: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + assert.Equal(t, constants.Complete, event.Message) + require.NotNil(t, event.ResourceModel) + model := event.ResourceModel.(*resource.Model) + assert.NotNil(t, model.ClientId) + if len(model.Secrets) > 0 { + assert.NotNil(t, model.Secrets[0].Secret, "Secret should be present on create") + } + }, + }, + { + name: "Create_Error", + operation: resource.Create, + setupModel: createTestModel, + mockSetup: func(m *mockadmin.ServiceAccountsApi) { + m.EXPECT().CreateOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). + Return(admin.CreateOrgServiceAccountApiRequest{ApiService: m}) + m.EXPECT().CreateOrgServiceAccountExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 500}, fmt.Errorf("API error")) + }, + expectedStatus: handler.Failed, + }, + { + name: "Read_Success", + operation: resource.Read, + setupModel: func() *resource.Model { + model := createTestModel() + clientID := "mdb_sa_id_123456789" + model.ClientId = &clientID + return model + }, + mockSetup: func(m *mockadmin.ServiceAccountsApi) { + resp := createTestResponse() + m.EXPECT().GetOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). + Return(admin.GetOrgServiceAccountApiRequest{ApiService: m}) + m.EXPECT().GetOrgServiceAccountExecute(mock.Anything). + Return(resp, &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.Success, + validate: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + assert.Equal(t, constants.ReadComplete, event.Message) + model := event.ResourceModel.(*resource.Model) + if model.Secrets != nil { + for _, secret := range model.Secrets { + assert.Nil(t, secret.Secret, "Secret should be masked on read") + } + } + }, + }, + { + name: "Read_NotFound", + operation: resource.Read, + setupModel: func() *resource.Model { + model := createTestModel() + clientID := "mdb_sa_id_123456789" + model.ClientId = &clientID + return model + }, + mockSetup: func(m *mockadmin.ServiceAccountsApi) { + m.EXPECT().GetOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). + Return(admin.GetOrgServiceAccountApiRequest{ApiService: m}) + m.EXPECT().GetOrgServiceAccountExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 404}, fmt.Errorf("not found")) + }, + expectedStatus: handler.Failed, + }, + { + name: "Update_Success", + operation: resource.Update, + setupModel: func() *resource.Model { + model := createTestModel() + clientID := "mdb_sa_id_123456789" + model.ClientId = &clientID + return model + }, + mockSetup: func(m *mockadmin.ServiceAccountsApi) { + resp := createTestResponse() + m.EXPECT().GetOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). + Return(admin.GetOrgServiceAccountApiRequest{ApiService: m}) + m.EXPECT().GetOrgServiceAccountExecute(mock.Anything). + Return(resp, &http.Response{StatusCode: 200}, nil) + m.EXPECT().UpdateOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(admin.UpdateOrgServiceAccountApiRequest{ApiService: m}) + m.EXPECT().UpdateOrgServiceAccountExecute(mock.Anything). + Return(resp, &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.Success, + validate: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + model := event.ResourceModel.(*resource.Model) + if model.Secrets != nil { + for _, secret := range model.Secrets { + assert.Nil(t, secret.Secret, "Secret should be masked on update") + } + } + }, + }, + { + name: "Update_NotFound", + operation: resource.Update, + setupModel: func() *resource.Model { + model := createTestModel() + clientID := "mdb_sa_id_123456789" + model.ClientId = &clientID + return model + }, + mockSetup: func(m *mockadmin.ServiceAccountsApi) { + m.EXPECT().GetOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). + Return(admin.GetOrgServiceAccountApiRequest{ApiService: m}) + m.EXPECT().GetOrgServiceAccountExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 404}, fmt.Errorf("not found")) + }, + expectedStatus: handler.Failed, + validate: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + assert.Equal(t, string(types.HandlerErrorCodeNotFound), event.HandlerErrorCode) + }, + }, + { + name: "Delete_Success", + operation: resource.Delete, + setupModel: func() *resource.Model { + model := createTestModel() + clientID := "mdb_sa_id_123456789" + model.ClientId = &clientID + return model + }, + mockSetup: func(m *mockadmin.ServiceAccountsApi) { + resp := createTestResponse() + m.EXPECT().GetOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). + Return(admin.GetOrgServiceAccountApiRequest{ApiService: m}) + m.EXPECT().GetOrgServiceAccountExecute(mock.Anything). + Return(resp, &http.Response{StatusCode: 200}, nil) + m.EXPECT().DeleteOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). + Return(admin.DeleteOrgServiceAccountApiRequest{ApiService: m}) + m.EXPECT().DeleteOrgServiceAccountExecute(mock.Anything). + Return(&http.Response{StatusCode: 204}, nil) + }, + expectedStatus: handler.Success, + }, + { + name: "Delete_NotFound", + operation: resource.Delete, + setupModel: func() *resource.Model { + model := createTestModel() + clientID := "mdb_sa_id_123456789" + model.ClientId = &clientID + return model + }, + mockSetup: func(m *mockadmin.ServiceAccountsApi) { + m.EXPECT().GetOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). + Return(admin.GetOrgServiceAccountApiRequest{ApiService: m}) + m.EXPECT().GetOrgServiceAccountExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 404}, fmt.Errorf("not found")) + }, + expectedStatus: handler.Failed, + }, + { + name: "List_Success", + operation: resource.List, + setupModel: createTestModel, + mockSetup: func(m *mockadmin.ServiceAccountsApi) { + account1 := createTestResponse() + account2 := createTestResponse() + account2Name := "test-service-account-2" + account2ClientID := "mdb_sa_id_987654321" + account2.Name = &account2Name + account2.ClientId = &account2ClientID + + results := []admin.OrgServiceAccount{*account1, *account2} + totalCount := 2 + + m.EXPECT().ListOrgServiceAccounts(mock.Anything, mock.Anything). + Return(admin.ListOrgServiceAccountsApiRequest{ApiService: m}) + m.EXPECT().ListOrgServiceAccountsExecute(mock.Anything). + Return(&admin.PaginatedOrgServiceAccounts{ + Results: &results, + TotalCount: &totalCount, + }, &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.Success, + validate: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + require.NotNil(t, event.ResourceModels) + assert.GreaterOrEqual(t, len(event.ResourceModels), 1) + for _, rm := range event.ResourceModels { + model := rm.(*resource.Model) + if model.Secrets != nil { + for _, secret := range model.Secrets { + assert.Nil(t, secret.Secret, "Secret should be masked in list") + } + } + } + }, + }, + { + name: "List_Empty", + operation: resource.List, + setupModel: createTestModel, + mockSetup: func(m *mockadmin.ServiceAccountsApi) { + results := []admin.OrgServiceAccount{} + totalCount := 0 + + m.EXPECT().ListOrgServiceAccounts(mock.Anything, mock.Anything). + Return(admin.ListOrgServiceAccountsApiRequest{ApiService: m}) + m.EXPECT().ListOrgServiceAccountsExecute(mock.Anything). + Return(&admin.PaginatedOrgServiceAccounts{ + Results: &results, + TotalCount: &totalCount, + }, &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.Success, + validate: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + assert.Empty(t, event.ResourceModels) + }, + }, + { + name: "List_Error", + operation: resource.List, + setupModel: createTestModel, + mockSetup: func(m *mockadmin.ServiceAccountsApi) { + m.EXPECT().ListOrgServiceAccounts(mock.Anything, mock.Anything). + Return(admin.ListOrgServiceAccountsApiRequest{ApiService: m}) + m.EXPECT().ListOrgServiceAccountsExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 500}, fmt.Errorf("list failed")) + }, + expectedStatus: handler.Failed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockServiceAccountsAPI := mockadmin.NewServiceAccountsApi(t) + tt.mockSetup(mockServiceAccountsAPI) + + mockClient := &admin.APIClient{ServiceAccountsApi: mockServiceAccountsAPI} + mongoClient := &util.MongoDBClient{AtlasSDK: mockClient} + + resource.SetupRequest = func(req handler.Request, model *resource.Model, requiredFields []string) (*util.MongoDBClient, *handler.ProgressEvent) { + return mongoClient, nil + } + + event, err := tt.operation(handler.Request{}, nil, tt.setupModel()) + require.NoError(t, err) + assert.Equal(t, tt.expectedStatus, event.OperationStatus) + + if tt.validate != nil { + tt.validate(t, event) + } + }) + } +} + +func TestHandleError(t *testing.T) { + tests := []struct { + name string + response *http.Response + err error + expectedStatus handler.Status + }{ + {"NotFound", &http.Response{StatusCode: http.StatusNotFound}, fmt.Errorf("not found"), handler.Failed}, + {"InternalServerError", &http.Response{StatusCode: http.StatusInternalServerError}, fmt.Errorf("server error"), handler.Failed}, + {"BadRequest", &http.Response{StatusCode: http.StatusBadRequest}, fmt.Errorf("bad request"), handler.Failed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := resource.HandleError(tt.response, constants.CREATE, tt.err) + assert.Equal(t, tt.expectedStatus, event.OperationStatus) + }) + } +} diff --git a/cfn-resources/org-service-account/docs/README.md b/cfn-resources/org-service-account/docs/README.md new file mode 100644 index 000000000..1b5d72888 --- /dev/null +++ b/cfn-resources/org-service-account/docs/README.md @@ -0,0 +1,126 @@ +# MongoDB::Atlas::OrgServiceAccount + +Creates and manages a Service Account for an organization. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Type" : "MongoDB::Atlas::OrgServiceAccount",
+    "Properties" : {
+        "Profile" : String,
+        "OrgId" : String,
+        "Name" : String,
+        "Description" : String,
+        "Roles" : [ String, ... ],
+        "SecretExpiresAfterHours" : Integer,
+    }
+}
+
+ +### YAML + +
+Type: MongoDB::Atlas::OrgServiceAccount
+Properties:
+    Profile: String
+    OrgId: String
+    Name: String
+    Description: String
+    Roles: 
+      - String
+    SecretExpiresAfterHours: Integer
+
+ +## Properties + +#### Profile + +Profile used to provide credentials information. + +_Required_: No + +_Type_: String + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### OrgId + +Unique 24-hexadecimal digit string that identifies the organization. + +_Required_: Yes + +_Type_: String + +_Minimum Length_: 24 + +_Maximum Length_: 24 + +_Pattern_: ^([a-f0-9]{24})$ + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### Name + +Human-readable name for the Service Account. + +_Required_: Yes + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Description + +Human readable description for the Service Account. + +_Required_: Yes + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Roles + +List of organization-level roles for the Service Account. + +_Required_: Yes + +_Type_: List of String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### SecretExpiresAfterHours + +Expiration time of the new Service Account secret in hours. + +_Required_: Yes + +_Type_: Integer + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +## Return Values + +### Fn::GetAtt + +The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values. + +For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html). + +#### ClientId + +The Client ID of the Service Account. + +#### CreatedAt + +Date and time that the Service Account was created on. + +#### Secrets + +List of secrets associated with the Service Account. + diff --git a/cfn-resources/org-service-account/docs/secret.md b/cfn-resources/org-service-account/docs/secret.md new file mode 100644 index 000000000..f4284d89b --- /dev/null +++ b/cfn-resources/org-service-account/docs/secret.md @@ -0,0 +1,90 @@ +# MongoDB::Atlas::OrgServiceAccount Secret + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Id" : String,
+    "CreatedAt" : String,
+    "ExpiresAt" : String,
+    "LastUsedAt" : String,
+    "MaskedSecretValue" : String,
+    "Secret" : Secret
+}
+
+ +### YAML + +
+Id: String
+CreatedAt: String
+ExpiresAt: String
+LastUsedAt: String
+MaskedSecretValue: String
+Secret: Secret
+
+ +## Properties + +#### Id + +Unique identifier of the secret. + +_Required_: No + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### CreatedAt + +Date and time that the secret was created on. + +_Required_: No + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### ExpiresAt + +Date and time when the secret expires. + +_Required_: No + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### LastUsedAt + +Date and time when the secret was last used. + +_Required_: No + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### MaskedSecretValue + +Masked value of the secret. + +_Required_: No + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Secret + +_Required_: No + +_Type_: Secret + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json b/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json new file mode 100644 index 000000000..2e1aa0c7e --- /dev/null +++ b/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json @@ -0,0 +1,136 @@ +{ + "typeName": "MongoDB::Atlas::OrgServiceAccount", + "description": "Creates and manages a Service Account for an organization.", + "sourceUrl": "https://github.com/mongodb/mongodbatlas-cloudformation-resources", + "definitions": { + "Secret": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Unique identifier of the secret." + }, + "CreatedAt": { + "type": "string", + "description": "Date and time that the secret was created on." + }, + "ExpiresAt": { + "type": "string", + "description": "Date and time when the secret expires." + }, + "LastUsedAt": { + "type": "string", + "description": "Date and time when the secret was last used." + }, + "MaskedSecretValue": { + "type": "string", + "description": "Masked value of the secret." + }, + "Secret": { + "type": "string", + "format": "password", + "description": "The secret value. Only returned on create." + } + }, + "additionalProperties": false + } + }, + "properties": { + "Profile": { + "type": "string", + "description": "Profile used to provide credentials information.", + "default": "default" + }, + "OrgId": { + "type": "string", + "description": "Unique 24-hexadecimal digit string that identifies the organization.", + "maxLength": 24, + "minLength": 24, + "pattern": "^([a-f0-9]{24})$" + }, + "Name": { + "type": "string", + "description": "Human-readable name for the Service Account." + }, + "Description": { + "type": "string", + "description": "Human readable description for the Service Account." + }, + "Roles": { + "type": "array", + "description": "List of organization-level roles for the Service Account.", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "SecretExpiresAfterHours": { + "type": "integer", + "description": "Expiration time of the new Service Account secret in hours.", + "minimum": 1 + }, + "ClientId": { + "type": "string", + "description": "The Client ID of the Service Account." + }, + "CreatedAt": { + "type": "string", + "description": "Date and time that the Service Account was created on." + }, + "Secrets": { + "type": "array", + "description": "List of secrets associated with the Service Account.", + "items": { + "$ref": "#/definitions/Secret" + } + } + }, + "required": [ + "OrgId", + "Name", + "Description", + "Roles", + "SecretExpiresAfterHours" + ], + "readOnlyProperties": [ + "/properties/ClientId", + "/properties/CreatedAt", + "/properties/Secrets" + ], + "createOnlyProperties": [ + "/properties/OrgId", + "/properties/Profile", + "/properties/SecretExpiresAfterHours" + ], + "primaryIdentifier": [ + "/properties/OrgId", + "/properties/ClientId", + "/properties/Profile" + ], + "handlers": { + "create": { + "permissions": [ + "secretsmanager:GetSecretValue" + ] + }, + "read": { + "permissions": [ + "secretsmanager:GetSecretValue" + ] + }, + "update": { + "permissions": [ + "secretsmanager:GetSecretValue" + ] + }, + "delete": { + "permissions": [ + "secretsmanager:GetSecretValue" + ] + } + }, + "tagging": { + "taggable": false + }, + "additionalProperties": false +} diff --git a/cfn-resources/org-service-account/resource-role.yaml b/cfn-resources/org-service-account/resource-role.yaml new file mode 100644 index 000000000..e3301feee --- /dev/null +++ b/cfn-resources/org-service-account/resource-role.yaml @@ -0,0 +1,38 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: > + This CloudFormation template creates a role assumed by CloudFormation + during CRUDL operations to mutate resources on behalf of the customer. + +Resources: + ExecutionRole: + Type: AWS::IAM::Role + Properties: + MaxSessionDuration: 8400 + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: resources.cloudformation.amazonaws.com + Action: sts:AssumeRole + Condition: + StringEquals: + aws:SourceAccount: + Ref: AWS::AccountId + StringLike: + aws:SourceArn: + Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/MongoDB-Atlas-StreamWorkspace/* + Path: "/" + Policies: + - PolicyName: ResourceTypePolicy + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - "secretsmanager:GetSecretValue" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/cfn-resources/org-service-account/template.yml b/cfn-resources/org-service-account/template.yml new file mode 100644 index 000000000..9bb213290 --- /dev/null +++ b/cfn-resources/org-service-account/template.yml @@ -0,0 +1,27 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the MongoDB::Atlas::OrgServiceAccount resource type + +Globals: + Function: + Timeout: 180 # docker start-up times can be long for SAM CLI + MemorySize: 256 + +Resources: + TypeFunction: + Type: AWS::Serverless::Function + Properties: + Handler: bootstrap + Runtime: provided.al2 + CodeUri: bin/ + + TestEntrypoint: + Type: AWS::Serverless::Function + Properties: + Handler: bootstrap + Runtime: provided.al2 + CodeUri: bin/ + Environment: + Variables: + MODE: Test + diff --git a/cfn-resources/org-service-account/test/README.md b/cfn-resources/org-service-account/test/README.md new file mode 100644 index 000000000..c27e11fea --- /dev/null +++ b/cfn-resources/org-service-account/test/README.md @@ -0,0 +1,65 @@ +# MongoDB::Atlas::OrgServiceAccount + +## Impact +The following components use this resource and are potentially impacted by any changes. They should also be validated to ensure the changes do not cause a regression. +- Currently, no CDK constructors or other components depend on this resource. + +## Prerequisites +### Resources needed to run the manual QA +All resources are created as part of `cfn-testing-helper.sh`: + +- MongoDB Atlas Organization (OrgId from environment variable `MONGODB_ATLAS_ORG_ID`) + +**Note**: Service Account is an organization-level resource. No project or cluster resources are required. + +## Manual QA +Please follow the steps in [TESTING.md](../../../TESTING.md). + +### Success criteria when testing the resource +1. Ensure general [CFN resource success criteria](../../../TESTING.md#success-criteria-when-testing-the-resource) for this resource is met. +2. **Create Operation**: + - Service account is created successfully in Atlas + - Secret is returned in the response (writeOnly property) + - ClientId is returned (read-only property) + - All required fields are populated correctly +3. **Read Operation**: + - Service account details are retrieved correctly + - Secret is NOT returned (masked - writeOnly property) + - MaskedSecretValue is shown instead + - All read-only fields are populated +4. **Update Operation**: + - Name, Description, and Roles can be updated + - Secret is NOT returned in update response (masked) + - Changes are reflected in Atlas UI +5. **Delete Operation**: + - Service account is deleted successfully + - Resource is removed from Atlas +6. **List Operation**: + - All service accounts for the organization are listed + - Secrets are masked in list response + - Primary identifier fields are set correctly + +## Important Links +- [API Documentation](https://www.mongodb.com/docs/atlas/reference/api-resources-spec/v2/#tag/Service-Accounts) + +## Running requests locally + +To locally invoke requests, the AWS `sam local` and `cfn invoke` tools can be used: + +``` +sam local start-lambda --skip-pull-image +``` +then in another shell: +```bash +repo_root=$(git rev-parse --show-toplevel) +source <(${repo_root}/quickstart-mongodb-atlas/scripts/export-mongocli-config.py) +cd ${repo_root}/cfn-resources/org-service-account +export MONGODB_ATLAS_ORG_ID="your-org-id" +./test/cfn-test-create-inputs.sh cfn-test-service-account > test.request.json +echo "Sample request:" +cat test.request.json +cfn invoke --function-name TestEntrypoint resource CREATE test.request.json +cfn invoke --function-name TestEntrypoint resource DELETE test.request.json +cd - +``` + diff --git a/cfn-resources/org-service-account/test/cfn-test-create-inputs.sh b/cfn-resources/org-service-account/test/cfn-test-create-inputs.sh new file mode 100755 index 000000000..ccab179be --- /dev/null +++ b/cfn-resources/org-service-account/test/cfn-test-create-inputs.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# cfn-test-create-inputs.sh +# +# This tool generates json files in the inputs/ for `cfn test`. +# + +set -o errexit +set -o nounset +set -o pipefail + +function usage { + echo "usage:$0 " +} + +if [ "$#" -ne 1 ]; then usage; fi +if [[ "$*" == help ]]; then usage; fi + +rm -rf inputs +mkdir inputs + +serviceAccountName="${1}" + +#set profile +profile="default" +if [ ${MONGODB_ATLAS_PROFILE+x} ]; then + echo "profile set to ${MONGODB_ATLAS_PROFILE}" + profile=${MONGODB_ATLAS_PROFILE} +fi + +if [ -z "${MONGODB_ATLAS_ORG_ID+x}" ]; then + echo "MONGODB_ATLAS_ORG_ID is not set, exiting..." + exit 1 +fi + +orgId="${MONGODB_ATLAS_ORG_ID}" + +WORDTOREMOVE="template." + +cd "$(dirname "$0")" || exit +for inputFile in inputs_*.template.json; do + outputFile=${inputFile//$WORDTOREMOVE/} + jq --arg Name "$serviceAccountName" \ + --arg OrgId "$orgId" \ + --arg profile "$profile" \ + '.Profile?|=$profile | .Name?|=$Name + | .OrgId?|=$OrgId ' \ + "$inputFile" >"../inputs/$outputFile" +done + +cd .. + +ls -l inputs diff --git a/cfn-resources/org-service-account/test/inputs_1_create.template.json b/cfn-resources/org-service-account/test/inputs_1_create.template.json new file mode 100644 index 000000000..62d7532de --- /dev/null +++ b/cfn-resources/org-service-account/test/inputs_1_create.template.json @@ -0,0 +1,11 @@ +{ + "OrgId": "OrgId", + "Name": "cfn-test-service-account", + "Description": "Service account created for CFN testing", + "Roles": [ + "ORG_MEMBER" + ], + "SecretExpiresAfterHours": "720", + "Profile": "default" +} + diff --git a/cfn-resources/org-service-account/test/inputs_1_update.template.json b/cfn-resources/org-service-account/test/inputs_1_update.template.json new file mode 100644 index 000000000..2a314b591 --- /dev/null +++ b/cfn-resources/org-service-account/test/inputs_1_update.template.json @@ -0,0 +1,12 @@ +{ + "OrgId": "OrgId", + "ClientId": "", + "Name": "cfn-test-service-account-updated", + "Description": "Service account updated for CFN testing - all updatable fields changed", + "Roles": [ + "ORG_MEMBER", + "ORG_GROUP_CREATOR" + ], + "Profile": "default" +} + diff --git a/examples/org-service-account/README.md b/examples/org-service-account/README.md new file mode 100644 index 000000000..d4c2dc7a3 --- /dev/null +++ b/examples/org-service-account/README.md @@ -0,0 +1,24 @@ +# How to create a MongoDB::Atlas::OrgServiceAccount + +## Step 1: Activate the org service account resource in cloudformation + +Step a: Create Role using [execution-role.yaml](https://github.com/mongodb/mongodbatlas-cloudformation-resources/blob/master/examples/execution-role.yaml) in CFN resources folder. + +Step b: Search for Mongodb::Atlas::OrgServiceAccount resource. + + (CloudFormation > Public extensions > choose 'Third party' > Search with " Execution name prefix = MongoDB " ) + +Step c: Select and activate +Enter the RoleArn that is created in step 1. + +Your OrgServiceAccount Resource is ready to use. + +## Step 2: Create template using [org-service-account.json](org-service-account.json) + + Note: Make sure you are providing appropriate values for: + 1. OrgId + 2. Name + 3. Description + 4. Roles + 5. SecretExpiresAfterHours + 6. Profile (optional) diff --git a/examples/org-service-account/org-service-account.json b/examples/org-service-account/org-service-account.json new file mode 100644 index 000000000..af0c54867 --- /dev/null +++ b/examples/org-service-account/org-service-account.json @@ -0,0 +1,82 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "This template creates a Service Account for a MongoDB Atlas organization. Service accounts provide programmatic access to MongoDB Atlas resources.", + "Parameters": { + "OrgId": { + "Type": "String", + "Description": "Unique 24-hexadecimal digit string that identifies the organization.", + "MinLength": 24, + "MaxLength": 24 + }, + "Name": { + "Type": "String", + "Description": "Human-readable name for the Service Account. The name is modifiable and does not have to be unique." + }, + "Description": { + "Type": "String", + "Description": "Human readable description for the Service Account." + }, + "Roles": { + "Type": "CommaDelimitedList", + "Description": "List of organization-level roles for the Service Account (comma-separated). Example: ORG_MEMBER,ORG_GROUP_CREATOR", + "Default": "ORG_MEMBER" + }, + "SecretExpiresAfterHours": { + "Type": "Number", + "Description": "Expiration time of the new Service Account secret in hours. The minimum and maximum allowed expiration times are subject to change and are controlled by the organization's settings.", + "Default": 720, + "MinValue": 1 + }, + "Profile": { + "Type": "String", + "Description": "Secret Manager Profile that contains the Atlas Programmatic keys", + "Default": "default" + } + }, + "Resources": { + "OrgServiceAccount": { + "Type": "MongoDB::Atlas::OrgServiceAccount", + "Properties": { + "OrgId": { + "Ref": "OrgId" + }, + "Name": { + "Ref": "Name" + }, + "Description": { + "Ref": "Description" + }, + "Roles": { + "Ref": "Roles" + }, + "SecretExpiresAfterHours": { + "Ref": "SecretExpiresAfterHours" + }, + "Profile": { + "Ref": "Profile" + } + } + } + }, + "Outputs": { + "ClientId": { + "Description": "The Client ID of the Service Account", + "Value": { + "Fn::GetAtt": [ + "OrgServiceAccount", + "ClientId" + ] + } + }, + "CreatedAt": { + "Description": "Date and time that the Service Account was created on", + "Value": { + "Fn::GetAtt": [ + "OrgServiceAccount", + "CreatedAt" + ] + } + } + } +} + From b703b7bad713be9070d00b602b711a77639b9f6d Mon Sep 17 00:00:00 2001 From: sivaram-mongodb Date: Wed, 14 Jan 2026 15:56:11 +0530 Subject: [PATCH 2/7] feat: update atlas-sdk version and unit tests in org-service-account resource --- cfn-resources/cfn-testing-helper.sh | 9 +- .../cmd/resource/mappings.go | 2 +- .../cmd/resource/mappings_test.go | 2 +- .../cmd/resource/resource.go | 22 +- .../cmd/resource/resource_test.go | 54 --- .../org-service-account/cmd/resource/share.go | 26 +- .../cmd/resource/share_test.go | 379 ------------------ 7 files changed, 28 insertions(+), 466 deletions(-) delete mode 100644 cfn-resources/org-service-account/cmd/resource/resource_test.go delete mode 100644 cfn-resources/org-service-account/cmd/resource/share_test.go diff --git a/cfn-resources/cfn-testing-helper.sh b/cfn-resources/cfn-testing-helper.sh index 749ab8479..ae4966ecf 100755 --- a/cfn-resources/cfn-testing-helper.sh +++ b/cfn-resources/cfn-testing-helper.sh @@ -157,14 +157,9 @@ done echo "Step 4/4: cleaning up 'cfn test' inputs " SAM_LOG=$(mktemp) for resource in ${resources}; do - cd "${resource}" - if [ -f ./test/cfn-test-delete-inputs.sh ]; then + cd "${res}" chmod +x ./test/cfn-test-delete-inputs.sh - ./test/cfn-test-delete-inputs.sh "${PROJECT_NAME}-${resource}" && echo "resource:${resource} inputs delete OK" || echo "resource:${resource} input delete FAILED" - else - echo "resource:${resource} - delete script not found, skipping cleanup" - fi - cd - + ./test/cfn-test-delete-inputs.sh "${PROJECT_NAME}-${res}" && echo "resource:${res} inputs delete OK" || echo "resource:${res} input delete FAILED" done echo "Clean up project" diff --git a/cfn-resources/org-service-account/cmd/resource/mappings.go b/cfn-resources/org-service-account/cmd/resource/mappings.go index 01a8e780e..e67ea8000 100644 --- a/cfn-resources/org-service-account/cmd/resource/mappings.go +++ b/cfn-resources/org-service-account/cmd/resource/mappings.go @@ -17,7 +17,7 @@ package resource import ( "sort" - "go.mongodb.org/atlas-sdk/v20250312010/admin" + "go.mongodb.org/atlas-sdk/v20250312012/admin" "github.com/mongodb/mongodbatlas-cloudformation-resources/util" ) diff --git a/cfn-resources/org-service-account/cmd/resource/mappings_test.go b/cfn-resources/org-service-account/cmd/resource/mappings_test.go index 50fd8eeb0..2a8291e7a 100644 --- a/cfn-resources/org-service-account/cmd/resource/mappings_test.go +++ b/cfn-resources/org-service-account/cmd/resource/mappings_test.go @@ -18,7 +18,7 @@ import ( "testing" "time" - "go.mongodb.org/atlas-sdk/v20250312010/admin" + "go.mongodb.org/atlas-sdk/v20250312012/admin" "github.com/aws/smithy-go/ptr" "github.com/mongodb/mongodbatlas-cloudformation-resources/org-service-account/cmd/resource" diff --git a/cfn-resources/org-service-account/cmd/resource/resource.go b/cfn-resources/org-service-account/cmd/resource/resource.go index 29da7184d..e7666d41e 100644 --- a/cfn-resources/org-service-account/cmd/resource/resource.go +++ b/cfn-resources/org-service-account/cmd/resource/resource.go @@ -28,7 +28,7 @@ var ( ListRequiredFields = []string{"OrgId"} ) -var SetupRequest = func(req handler.Request, model *Model, requiredFields []string) (*util.MongoDBClient, *handler.ProgressEvent) { +func setupRequest(req handler.Request, model *Model, requiredFields []string) (*util.MongoDBClient, *handler.ProgressEvent) { util.SetupLogger("mongodb-atlas-org-service-account") if modelValidation := validator.ValidateModel(requiredFields, model); modelValidation != nil { return nil, modelValidation @@ -42,41 +42,41 @@ var SetupRequest = func(req handler.Request, model *Model, requiredFields []stri } func Create(req handler.Request, prevModel *Model, model *Model) (handler.ProgressEvent, error) { - client, setupErr := SetupRequest(req, model, CreateRequiredFields) + client, setupErr := setupRequest(req, model, CreateRequiredFields) if setupErr != nil { return *setupErr, nil } - return HandleCreate(&req, client, model), nil + return handleCreate(client, model), nil } func Read(req handler.Request, prevModel *Model, model *Model) (handler.ProgressEvent, error) { - client, setupErr := SetupRequest(req, model, ReadRequiredFields) + client, setupErr := setupRequest(req, model, ReadRequiredFields) if setupErr != nil { return *setupErr, nil } - return HandleRead(&req, client, model), nil + return handleRead(client, model), nil } func Update(req handler.Request, prevModel *Model, model *Model) (handler.ProgressEvent, error) { - client, setupErr := SetupRequest(req, model, UpdateRequiredFields) + client, setupErr := setupRequest(req, model, UpdateRequiredFields) if setupErr != nil { return *setupErr, nil } - return HandleUpdate(&req, client, model), nil + return handleUpdate(client, model), nil } func Delete(req handler.Request, prevModel *Model, model *Model) (handler.ProgressEvent, error) { - client, setupErr := SetupRequest(req, model, DeleteRequiredFields) + client, setupErr := setupRequest(req, model, DeleteRequiredFields) if setupErr != nil { return *setupErr, nil } - return HandleDelete(&req, client, model), nil + return handleDelete(client, model), nil } func List(req handler.Request, prevModel *Model, model *Model) (handler.ProgressEvent, error) { - client, setupErr := SetupRequest(req, model, ListRequiredFields) + client, setupErr := setupRequest(req, model, ListRequiredFields) if setupErr != nil { return *setupErr, nil } - return HandleList(&req, client, model), nil + return handleList(client, model), nil } diff --git a/cfn-resources/org-service-account/cmd/resource/resource_test.go b/cfn-resources/org-service-account/cmd/resource/resource_test.go deleted file mode 100644 index b3a8bfb09..000000000 --- a/cfn-resources/org-service-account/cmd/resource/resource_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2026 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 resource_test - -import ( - "testing" - - "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" - "github.com/mongodb/mongodbatlas-cloudformation-resources/org-service-account/cmd/resource" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestConstants(t *testing.T) { - assert.Equal(t, []string{"OrgId", "Name", "Description", "Roles"}, resource.CreateRequiredFields) - assert.Equal(t, []string{"OrgId", "ClientId"}, resource.ReadRequiredFields) - assert.Equal(t, []string{"OrgId", "ClientId"}, resource.UpdateRequiredFields) - assert.Equal(t, []string{"OrgId", "ClientId"}, resource.DeleteRequiredFields) - assert.Equal(t, []string{"OrgId"}, resource.ListRequiredFields) -} - -func TestValidationErrors(t *testing.T) { - tests := []struct { - operation func(handler.Request, *resource.Model, *resource.Model) (handler.ProgressEvent, error) - currentModel *resource.Model - name string - }{ - {resource.Create, &resource.Model{}, "Create_missingOrgId"}, - {resource.Read, &resource.Model{}, "Read_missingOrgId"}, - {resource.Update, &resource.Model{}, "Update_missingOrgId"}, - {resource.Delete, &resource.Model{}, "Delete_missingOrgId"}, - {resource.List, &resource.Model{}, "List_missingOrgId"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - event, err := tt.operation(handler.Request{}, nil, tt.currentModel) - require.NoError(t, err) - assert.Equal(t, handler.Failed, event.OperationStatus) - }) - } -} diff --git a/cfn-resources/org-service-account/cmd/resource/share.go b/cfn-resources/org-service-account/cmd/resource/share.go index fe71a0ca9..cf39d8d95 100644 --- a/cfn-resources/org-service-account/cmd/resource/share.go +++ b/cfn-resources/org-service-account/cmd/resource/share.go @@ -26,14 +26,14 @@ import ( progress_events "github.com/mongodb/mongodbatlas-cloudformation-resources/util/progressevent" ) -func HandleCreate(req *handler.Request, client *util.MongoDBClient, model *Model) handler.ProgressEvent { +func handleCreate(client *util.MongoDBClient, model *Model) handler.ProgressEvent { ctx := context.Background() orgID := model.OrgId serviceAccountReq := NewOrgServiceAccountCreateReq(model) serviceAccountResp, apiResp, err := client.AtlasSDK.ServiceAccountsApi.CreateOrgServiceAccount(ctx, *orgID, serviceAccountReq).Execute() if err != nil { - return HandleError(apiResp, constants.CREATE, err) + return handleError(apiResp, constants.CREATE, err) } resourceModel := GetOrgServiceAccountModel(serviceAccountResp, model) @@ -45,14 +45,14 @@ func HandleCreate(req *handler.Request, client *util.MongoDBClient, model *Model } } -func HandleRead(req *handler.Request, client *util.MongoDBClient, model *Model) handler.ProgressEvent { +func handleRead(client *util.MongoDBClient, model *Model) handler.ProgressEvent { ctx := context.Background() orgID := model.OrgId clientID := model.ClientId serviceAccount, apiResp, err := client.AtlasSDK.ServiceAccountsApi.GetOrgServiceAccount(ctx, *orgID, *clientID).Execute() if err != nil { - return HandleError(apiResp, constants.READ, err) + return handleError(apiResp, constants.READ, err) } resourceModel := GetOrgServiceAccountModel(serviceAccount, model) @@ -69,7 +69,7 @@ func HandleRead(req *handler.Request, client *util.MongoDBClient, model *Model) } } -func HandleUpdate(req *handler.Request, client *util.MongoDBClient, model *Model) handler.ProgressEvent { +func handleUpdate(client *util.MongoDBClient, model *Model) handler.ProgressEvent { ctx := context.Background() orgID := model.OrgId clientID := model.ClientId @@ -83,13 +83,13 @@ func HandleUpdate(req *handler.Request, client *util.MongoDBClient, model *Model HandlerErrorCode: string(types.HandlerErrorCodeNotFound), } } - return HandleError(apiResp, constants.UPDATE, err) + return handleError(apiResp, constants.UPDATE, err) } serviceAccountReq := NewOrgServiceAccountUpdateReq(model) serviceAccountResp, apiResp, err := client.AtlasSDK.ServiceAccountsApi.UpdateOrgServiceAccount(ctx, *clientID, *orgID, serviceAccountReq).Execute() if err != nil { - return HandleError(apiResp, constants.UPDATE, err) + return handleError(apiResp, constants.UPDATE, err) } resourceModel := GetOrgServiceAccountModel(serviceAccountResp, model) @@ -106,7 +106,7 @@ func HandleUpdate(req *handler.Request, client *util.MongoDBClient, model *Model } } -func HandleDelete(req *handler.Request, client *util.MongoDBClient, model *Model) handler.ProgressEvent { +func handleDelete(client *util.MongoDBClient, model *Model) handler.ProgressEvent { ctx := context.Background() orgID := model.OrgId clientID := model.ClientId @@ -120,12 +120,12 @@ func HandleDelete(req *handler.Request, client *util.MongoDBClient, model *Model HandlerErrorCode: string(types.HandlerErrorCodeNotFound), } } - return HandleError(resp, constants.DELETE, err) + return handleError(resp, constants.DELETE, err) } apiResp, err := client.AtlasSDK.ServiceAccountsApi.DeleteOrgServiceAccount(ctx, *clientID, *orgID).Execute() if err != nil { - return HandleError(apiResp, constants.DELETE, err) + return handleError(apiResp, constants.DELETE, err) } return handler.ProgressEvent{ @@ -134,13 +134,13 @@ func HandleDelete(req *handler.Request, client *util.MongoDBClient, model *Model } } -func HandleList(req *handler.Request, client *util.MongoDBClient, model *Model) handler.ProgressEvent { +func handleList(client *util.MongoDBClient, model *Model) handler.ProgressEvent { ctx := context.Background() orgID := model.OrgId serviceAccounts, apiResp, err := client.AtlasSDK.ServiceAccountsApi.ListOrgServiceAccounts(ctx, *orgID).Execute() if err != nil { - return HandleError(apiResp, constants.LIST, err) + return handleError(apiResp, constants.LIST, err) } response := make([]interface{}, 0) @@ -166,7 +166,7 @@ func HandleList(req *handler.Request, client *util.MongoDBClient, model *Model) } } -func HandleError(response *http.Response, method constants.CfnFunctions, err error) handler.ProgressEvent { +func handleError(response *http.Response, method constants.CfnFunctions, err error) handler.ProgressEvent { errMsg := fmt.Sprintf("%s error:%s", method, err.Error()) return progress_events.GetFailedEventByResponse(errMsg, response) } diff --git a/cfn-resources/org-service-account/cmd/resource/share_test.go b/cfn-resources/org-service-account/cmd/resource/share_test.go deleted file mode 100644 index f808abc0b..000000000 --- a/cfn-resources/org-service-account/cmd/resource/share_test.go +++ /dev/null @@ -1,379 +0,0 @@ -// Copyright 2026 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 resource_test - -import ( - "fmt" - "net/http" - "testing" - "time" - - "go.mongodb.org/atlas-sdk/v20250312010/admin" - "go.mongodb.org/atlas-sdk/v20250312010/mockadmin" - - "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" - "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" - "github.com/aws/smithy-go/ptr" - "github.com/mongodb/mongodbatlas-cloudformation-resources/org-service-account/cmd/resource" - "github.com/mongodb/mongodbatlas-cloudformation-resources/util" - "github.com/mongodb/mongodbatlas-cloudformation-resources/util/constants" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -func createTestModel() *resource.Model { - orgID := "63350255419cf25e3d511c95" - name := "test-service-account" - description := "Test description" - roles := []string{"ORG_MEMBER"} - secretExpiresAfterHours := 720 - profile := "default" - - return &resource.Model{ - Profile: &profile, - OrgId: &orgID, - Name: &name, - Description: &description, - Roles: roles, - SecretExpiresAfterHours: &secretExpiresAfterHours, - } -} - -func createTestResponse() *admin.OrgServiceAccount { - now := time.Now() - clientID := "mdb_sa_id_123456789" - name := "test-service-account" - description := "Test description" - roles := []string{"ORG_MEMBER"} - secretID := "secret-id-123" - - return &admin.OrgServiceAccount{ - ClientId: &clientID, - Name: &name, - Description: &description, - Roles: &roles, - CreatedAt: &now, - Secrets: &[]admin.ServiceAccountSecret{ - { - Id: secretID, - CreatedAt: now, - ExpiresAt: now.Add(720 * time.Hour), - MaskedSecretValue: ptr.String("****"), - Secret: ptr.String("secret-value"), - }, - }, - } -} - -func TestCRUDOperations(t *testing.T) { - originalSetupRequest := resource.SetupRequest - defer func() { resource.SetupRequest = originalSetupRequest }() - - tests := []struct { - name string - operation func(handler.Request, *resource.Model, *resource.Model) (handler.ProgressEvent, error) - setupModel func() *resource.Model - mockSetup func(*mockadmin.ServiceAccountsApi) - validate func(*testing.T, handler.ProgressEvent) - expectedStatus handler.Status - }{ - { - name: "Create_Success", - operation: resource.Create, - setupModel: createTestModel, - mockSetup: func(m *mockadmin.ServiceAccountsApi) { - resp := createTestResponse() - m.EXPECT().CreateOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). - Return(admin.CreateOrgServiceAccountApiRequest{ApiService: m}) - m.EXPECT().CreateOrgServiceAccountExecute(mock.Anything). - Return(resp, &http.Response{StatusCode: 200}, nil) - }, - expectedStatus: handler.Success, - validate: func(t *testing.T, event handler.ProgressEvent) { - t.Helper() - assert.Equal(t, constants.Complete, event.Message) - require.NotNil(t, event.ResourceModel) - model := event.ResourceModel.(*resource.Model) - assert.NotNil(t, model.ClientId) - if len(model.Secrets) > 0 { - assert.NotNil(t, model.Secrets[0].Secret, "Secret should be present on create") - } - }, - }, - { - name: "Create_Error", - operation: resource.Create, - setupModel: createTestModel, - mockSetup: func(m *mockadmin.ServiceAccountsApi) { - m.EXPECT().CreateOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). - Return(admin.CreateOrgServiceAccountApiRequest{ApiService: m}) - m.EXPECT().CreateOrgServiceAccountExecute(mock.Anything). - Return(nil, &http.Response{StatusCode: 500}, fmt.Errorf("API error")) - }, - expectedStatus: handler.Failed, - }, - { - name: "Read_Success", - operation: resource.Read, - setupModel: func() *resource.Model { - model := createTestModel() - clientID := "mdb_sa_id_123456789" - model.ClientId = &clientID - return model - }, - mockSetup: func(m *mockadmin.ServiceAccountsApi) { - resp := createTestResponse() - m.EXPECT().GetOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). - Return(admin.GetOrgServiceAccountApiRequest{ApiService: m}) - m.EXPECT().GetOrgServiceAccountExecute(mock.Anything). - Return(resp, &http.Response{StatusCode: 200}, nil) - }, - expectedStatus: handler.Success, - validate: func(t *testing.T, event handler.ProgressEvent) { - t.Helper() - assert.Equal(t, constants.ReadComplete, event.Message) - model := event.ResourceModel.(*resource.Model) - if model.Secrets != nil { - for _, secret := range model.Secrets { - assert.Nil(t, secret.Secret, "Secret should be masked on read") - } - } - }, - }, - { - name: "Read_NotFound", - operation: resource.Read, - setupModel: func() *resource.Model { - model := createTestModel() - clientID := "mdb_sa_id_123456789" - model.ClientId = &clientID - return model - }, - mockSetup: func(m *mockadmin.ServiceAccountsApi) { - m.EXPECT().GetOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). - Return(admin.GetOrgServiceAccountApiRequest{ApiService: m}) - m.EXPECT().GetOrgServiceAccountExecute(mock.Anything). - Return(nil, &http.Response{StatusCode: 404}, fmt.Errorf("not found")) - }, - expectedStatus: handler.Failed, - }, - { - name: "Update_Success", - operation: resource.Update, - setupModel: func() *resource.Model { - model := createTestModel() - clientID := "mdb_sa_id_123456789" - model.ClientId = &clientID - return model - }, - mockSetup: func(m *mockadmin.ServiceAccountsApi) { - resp := createTestResponse() - m.EXPECT().GetOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). - Return(admin.GetOrgServiceAccountApiRequest{ApiService: m}) - m.EXPECT().GetOrgServiceAccountExecute(mock.Anything). - Return(resp, &http.Response{StatusCode: 200}, nil) - m.EXPECT().UpdateOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(admin.UpdateOrgServiceAccountApiRequest{ApiService: m}) - m.EXPECT().UpdateOrgServiceAccountExecute(mock.Anything). - Return(resp, &http.Response{StatusCode: 200}, nil) - }, - expectedStatus: handler.Success, - validate: func(t *testing.T, event handler.ProgressEvent) { - t.Helper() - model := event.ResourceModel.(*resource.Model) - if model.Secrets != nil { - for _, secret := range model.Secrets { - assert.Nil(t, secret.Secret, "Secret should be masked on update") - } - } - }, - }, - { - name: "Update_NotFound", - operation: resource.Update, - setupModel: func() *resource.Model { - model := createTestModel() - clientID := "mdb_sa_id_123456789" - model.ClientId = &clientID - return model - }, - mockSetup: func(m *mockadmin.ServiceAccountsApi) { - m.EXPECT().GetOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). - Return(admin.GetOrgServiceAccountApiRequest{ApiService: m}) - m.EXPECT().GetOrgServiceAccountExecute(mock.Anything). - Return(nil, &http.Response{StatusCode: 404}, fmt.Errorf("not found")) - }, - expectedStatus: handler.Failed, - validate: func(t *testing.T, event handler.ProgressEvent) { - t.Helper() - assert.Equal(t, string(types.HandlerErrorCodeNotFound), event.HandlerErrorCode) - }, - }, - { - name: "Delete_Success", - operation: resource.Delete, - setupModel: func() *resource.Model { - model := createTestModel() - clientID := "mdb_sa_id_123456789" - model.ClientId = &clientID - return model - }, - mockSetup: func(m *mockadmin.ServiceAccountsApi) { - resp := createTestResponse() - m.EXPECT().GetOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). - Return(admin.GetOrgServiceAccountApiRequest{ApiService: m}) - m.EXPECT().GetOrgServiceAccountExecute(mock.Anything). - Return(resp, &http.Response{StatusCode: 200}, nil) - m.EXPECT().DeleteOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). - Return(admin.DeleteOrgServiceAccountApiRequest{ApiService: m}) - m.EXPECT().DeleteOrgServiceAccountExecute(mock.Anything). - Return(&http.Response{StatusCode: 204}, nil) - }, - expectedStatus: handler.Success, - }, - { - name: "Delete_NotFound", - operation: resource.Delete, - setupModel: func() *resource.Model { - model := createTestModel() - clientID := "mdb_sa_id_123456789" - model.ClientId = &clientID - return model - }, - mockSetup: func(m *mockadmin.ServiceAccountsApi) { - m.EXPECT().GetOrgServiceAccount(mock.Anything, mock.Anything, mock.Anything). - Return(admin.GetOrgServiceAccountApiRequest{ApiService: m}) - m.EXPECT().GetOrgServiceAccountExecute(mock.Anything). - Return(nil, &http.Response{StatusCode: 404}, fmt.Errorf("not found")) - }, - expectedStatus: handler.Failed, - }, - { - name: "List_Success", - operation: resource.List, - setupModel: createTestModel, - mockSetup: func(m *mockadmin.ServiceAccountsApi) { - account1 := createTestResponse() - account2 := createTestResponse() - account2Name := "test-service-account-2" - account2ClientID := "mdb_sa_id_987654321" - account2.Name = &account2Name - account2.ClientId = &account2ClientID - - results := []admin.OrgServiceAccount{*account1, *account2} - totalCount := 2 - - m.EXPECT().ListOrgServiceAccounts(mock.Anything, mock.Anything). - Return(admin.ListOrgServiceAccountsApiRequest{ApiService: m}) - m.EXPECT().ListOrgServiceAccountsExecute(mock.Anything). - Return(&admin.PaginatedOrgServiceAccounts{ - Results: &results, - TotalCount: &totalCount, - }, &http.Response{StatusCode: 200}, nil) - }, - expectedStatus: handler.Success, - validate: func(t *testing.T, event handler.ProgressEvent) { - t.Helper() - require.NotNil(t, event.ResourceModels) - assert.GreaterOrEqual(t, len(event.ResourceModels), 1) - for _, rm := range event.ResourceModels { - model := rm.(*resource.Model) - if model.Secrets != nil { - for _, secret := range model.Secrets { - assert.Nil(t, secret.Secret, "Secret should be masked in list") - } - } - } - }, - }, - { - name: "List_Empty", - operation: resource.List, - setupModel: createTestModel, - mockSetup: func(m *mockadmin.ServiceAccountsApi) { - results := []admin.OrgServiceAccount{} - totalCount := 0 - - m.EXPECT().ListOrgServiceAccounts(mock.Anything, mock.Anything). - Return(admin.ListOrgServiceAccountsApiRequest{ApiService: m}) - m.EXPECT().ListOrgServiceAccountsExecute(mock.Anything). - Return(&admin.PaginatedOrgServiceAccounts{ - Results: &results, - TotalCount: &totalCount, - }, &http.Response{StatusCode: 200}, nil) - }, - expectedStatus: handler.Success, - validate: func(t *testing.T, event handler.ProgressEvent) { - t.Helper() - assert.Empty(t, event.ResourceModels) - }, - }, - { - name: "List_Error", - operation: resource.List, - setupModel: createTestModel, - mockSetup: func(m *mockadmin.ServiceAccountsApi) { - m.EXPECT().ListOrgServiceAccounts(mock.Anything, mock.Anything). - Return(admin.ListOrgServiceAccountsApiRequest{ApiService: m}) - m.EXPECT().ListOrgServiceAccountsExecute(mock.Anything). - Return(nil, &http.Response{StatusCode: 500}, fmt.Errorf("list failed")) - }, - expectedStatus: handler.Failed, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockServiceAccountsAPI := mockadmin.NewServiceAccountsApi(t) - tt.mockSetup(mockServiceAccountsAPI) - - mockClient := &admin.APIClient{ServiceAccountsApi: mockServiceAccountsAPI} - mongoClient := &util.MongoDBClient{AtlasSDK: mockClient} - - resource.SetupRequest = func(req handler.Request, model *resource.Model, requiredFields []string) (*util.MongoDBClient, *handler.ProgressEvent) { - return mongoClient, nil - } - - event, err := tt.operation(handler.Request{}, nil, tt.setupModel()) - require.NoError(t, err) - assert.Equal(t, tt.expectedStatus, event.OperationStatus) - - if tt.validate != nil { - tt.validate(t, event) - } - }) - } -} - -func TestHandleError(t *testing.T) { - tests := []struct { - name string - response *http.Response - err error - expectedStatus handler.Status - }{ - {"NotFound", &http.Response{StatusCode: http.StatusNotFound}, fmt.Errorf("not found"), handler.Failed}, - {"InternalServerError", &http.Response{StatusCode: http.StatusInternalServerError}, fmt.Errorf("server error"), handler.Failed}, - {"BadRequest", &http.Response{StatusCode: http.StatusBadRequest}, fmt.Errorf("bad request"), handler.Failed}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - event := resource.HandleError(tt.response, constants.CREATE, tt.err) - assert.Equal(t, tt.expectedStatus, event.OperationStatus) - }) - } -} From 11f217613a1e4f8e2a69595fddc786e279aa4b50 Mon Sep 17 00:00:00 2001 From: Rakhul S Prakash Date: Tue, 20 Jan 2026 10:15:08 +0530 Subject: [PATCH 3/7] Add cfn test workflow, use http util functions --- .github/workflows/contract-testing.yaml | 46 ++++++++++++++++++- cfn-resources/org-service-account/Makefile | 16 ++++++- .../cmd/resource/{share.go => handlers.go} | 4 +- .../test/contract-testing/cfn-test-create.sh | 12 +++++ .../test/contract-testing/cfn-test-delete.sh | 11 +++++ 5 files changed, 85 insertions(+), 4 deletions(-) rename cfn-resources/org-service-account/cmd/resource/{share.go => handlers.go} (97%) create mode 100755 cfn-resources/org-service-account/test/contract-testing/cfn-test-create.sh create mode 100755 cfn-resources/org-service-account/test/contract-testing/cfn-test-delete.sh diff --git a/.github/workflows/contract-testing.yaml b/.github/workflows/contract-testing.yaml index 4820758d1..c4744fb7d 100644 --- a/.github/workflows/contract-testing.yaml +++ b/.github/workflows/contract-testing.yaml @@ -22,6 +22,7 @@ jobs: flex-cluster: ${{ steps.filter.outputs.flex-cluster }} online-archive: ${{ steps.filter.outputs.online-archive }} organization: ${{ steps.filter.outputs.organization }} + org-service-account: ${{ steps.filter.outputs.org-service-account }} private-endpoint-aws: ${{ steps.filter.outputs.private-endpoint-aws }} private-endpoint-service: ${{ steps.filter.outputs.private-endpoint-service }} privatelink-endpoint-service-data-federation-online-archive: ${{ steps.filter.outputs.privatelink-endpoint-service-data-federation-online-archive }} @@ -60,6 +61,8 @@ jobs: - 'cfn-resources/online-archive/**' organization: - 'cfn-resources/organization/**' + org-service-account: + - 'cfn-resources/org-service-account/**' private-endpoint-aws: - 'cfn-resources/private-endpoint-aws/**' private-endpoint-service: @@ -522,7 +525,48 @@ jobs: run: | pushd cfn-resources/organization make create-test-resources - + + cat inputs/inputs_1_create.json + cat inputs/inputs_1_update.json + + make run-contract-testing + make delete-test-resources + org-service-account: + needs: change-detection + if: ${{ needs.change-detection.outputs.org-service-account == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 + with: + go-version-file: 'cfn-resources/go.mod' + - name: setup Atlas CLI + uses: mongodb/atlas-github-action@e3c9e0204659bafbb3b65e1eb1ee745cca0e9f3b + - uses: aws-actions/setup-sam@c2a20b1822cc4a6bc594ff7f1dbb658758e383c3 + with: + use-installer: true + - uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_TEST_ENV }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_TEST_ENV }} + aws-region: eu-west-1 + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 + with: + python-version: '3.9' + cache: 'pip' # caching pip dependencies + - run: pip install cloudformation-cli cloudformation-cli-go-plugin + - name: Run the Contract test + shell: bash + env: + MONGODB_ATLAS_PUBLIC_API_KEY: ${{ secrets.CLOUD_DEV_PUBLIC_KEY }} + MONGODB_ATLAS_PRIVATE_API_KEY: ${{ secrets.CLOUD_DEV_PRIVATE_KEY }} + MONGODB_ATLAS_ORG_ID: ${{ secrets.CLOUD_DEV_ORG_ID }} + MONGODB_ATLAS_OPS_MANAGER_URL: ${{ vars.MONGODB_ATLAS_BASE_URL }} + MONGODB_ATLAS_PROFILE: cfn-cloud-dev-github-action + run: | + cd cfn-resources/org-service-account + make create-test-resources + cat inputs/inputs_1_create.json cat inputs/inputs_1_update.json diff --git a/cfn-resources/org-service-account/Makefile b/cfn-resources/org-service-account/Makefile index fd9da008a..562724f34 100644 --- a/cfn-resources/org-service-account/Makefile +++ b/cfn-resources/org-service-account/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test clean debug +.PHONY: build test clean debug create-test-resources delete-test-resources run-contract-testing tags=logging callback metrics scheduler cgo=0 goos=linux @@ -21,3 +21,17 @@ test: clean: rm -rf bin + +create-test-resources: + @echo "==> Creating test files and resources for contract testing" + ./test/contract-testing/cfn-test-create.sh + +delete-test-resources: + @echo "==> Delete test resources used for contract testing" + ./test/contract-testing/cfn-test-delete.sh + +run-contract-testing: + @echo "==> Run contract testing" + make build + sam local start-lambda & + cfn test --function-name TestEntrypoint --verbose diff --git a/cfn-resources/org-service-account/cmd/resource/share.go b/cfn-resources/org-service-account/cmd/resource/handlers.go similarity index 97% rename from cfn-resources/org-service-account/cmd/resource/share.go rename to cfn-resources/org-service-account/cmd/resource/handlers.go index cf39d8d95..2f9b3e922 100644 --- a/cfn-resources/org-service-account/cmd/resource/share.go +++ b/cfn-resources/org-service-account/cmd/resource/handlers.go @@ -76,7 +76,7 @@ func handleUpdate(client *util.MongoDBClient, model *Model) handler.ProgressEven _, apiResp, err := client.AtlasSDK.ServiceAccountsApi.GetOrgServiceAccount(ctx, *orgID, *clientID).Execute() if err != nil { - if apiResp != nil && apiResp.StatusCode == http.StatusNotFound { + if util.StatusNotFound(apiResp) { return handler.ProgressEvent{ OperationStatus: handler.Failed, Message: "Resource not found", @@ -113,7 +113,7 @@ func handleDelete(client *util.MongoDBClient, model *Model) handler.ProgressEven _, resp, err := client.AtlasSDK.ServiceAccountsApi.GetOrgServiceAccount(ctx, *orgID, *clientID).Execute() if err != nil { - if resp != nil && resp.StatusCode == http.StatusNotFound { + if util.StatusNotFound(resp) { return handler.ProgressEvent{ OperationStatus: handler.Failed, Message: "Resource not found", diff --git a/cfn-resources/org-service-account/test/contract-testing/cfn-test-create.sh b/cfn-resources/org-service-account/test/contract-testing/cfn-test-create.sh new file mode 100755 index 000000000..ebb3dbf2f --- /dev/null +++ b/cfn-resources/org-service-account/test/contract-testing/cfn-test-create.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# This tool generates json files in the inputs/ for `cfn test`. +set -o errexit +set -o nounset +set -o pipefail + +serviceAccountName="cfn-test-service-account-$(date +%s)-$RANDOM" + +echo "serviceAccountName: $serviceAccountName" + +./test/cfn-test-create-inputs.sh "$serviceAccountName" diff --git a/cfn-resources/org-service-account/test/contract-testing/cfn-test-delete.sh b/cfn-resources/org-service-account/test/contract-testing/cfn-test-delete.sh new file mode 100755 index 000000000..747e18346 --- /dev/null +++ b/cfn-resources/org-service-account/test/contract-testing/cfn-test-delete.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +# This tool deletes the mongodb resources used for `cfn test` as inputs. +# For org-service-account, no pre-created resources need cleanup. +# The CloudFormation test framework handles resource cleanup automatically. + +set -o errexit +set -o nounset +set -o pipefail + +echo "No test resources to delete" From eed920fb9154b0b3716ebde03a500b3eecab0a1d Mon Sep 17 00:00:00 2001 From: ParthasarathyV Date: Tue, 20 Jan 2026 23:14:34 -0500 Subject: [PATCH 4/7] CLOUDP-369799-org-service-account enhancements --- .../org-service-account/cmd/resource/handlers.go | 2 +- .../org-service-account/cmd/resource/mappings.go | 12 ++++++------ .../mongodb-atlas-orgserviceaccount.json | 8 ++++++++ cfn-resources/org-service-account/resource-role.yaml | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/cfn-resources/org-service-account/cmd/resource/handlers.go b/cfn-resources/org-service-account/cmd/resource/handlers.go index 2f9b3e922..a8e1abeb1 100644 --- a/cfn-resources/org-service-account/cmd/resource/handlers.go +++ b/cfn-resources/org-service-account/cmd/resource/handlers.go @@ -167,6 +167,6 @@ func handleList(client *util.MongoDBClient, model *Model) handler.ProgressEvent } func handleError(response *http.Response, method constants.CfnFunctions, err error) handler.ProgressEvent { - errMsg := fmt.Sprintf("%s error:%s", method, err.Error()) + errMsg := fmt.Sprintf("%s error: %s", method, err.Error()) return progress_events.GetFailedEventByResponse(errMsg, response) } diff --git a/cfn-resources/org-service-account/cmd/resource/mappings.go b/cfn-resources/org-service-account/cmd/resource/mappings.go index e67ea8000..5c1a9fed2 100644 --- a/cfn-resources/org-service-account/cmd/resource/mappings.go +++ b/cfn-resources/org-service-account/cmd/resource/mappings.go @@ -37,12 +37,9 @@ func GetOrgServiceAccountModel(account *admin.OrgServiceAccount, currentModel *M model.Description = account.Description if account.Roles != nil { roles := *account.Roles - if currentModel != nil && currentModel.Roles != nil && len(currentModel.Roles) > 0 { - model.Roles = currentModel.Roles - } else { - sort.Strings(roles) - model.Roles = roles - } + // Always sort roles for consistency (Terraform uses Set which is unordered) + sort.Strings(roles) + model.Roles = roles } model.ClientId = account.ClientId model.CreatedAt = util.TimePtrToStringPtr(account.CreatedAt) @@ -70,6 +67,9 @@ func NewOrgServiceAccountCreateReq(model *Model) *admin.OrgServiceAccountRequest if model == nil { return nil } + if model.SecretExpiresAfterHours == nil { + return nil + } secretExpiresAfterHours := *model.SecretExpiresAfterHours roles := make([]string, len(model.Roles)) copy(roles, model.Roles) diff --git a/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json b/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json index 2e1aa0c7e..d389d6d09 100644 --- a/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json +++ b/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json @@ -127,8 +127,16 @@ "permissions": [ "secretsmanager:GetSecretValue" ] + }, + "list": { + "permissions": [ + "secretsmanager:GetSecretValue" + ] } }, + "writeOnlyProperties": [ + "/properties/Secrets/*/Secret" + ], "tagging": { "taggable": false }, diff --git a/cfn-resources/org-service-account/resource-role.yaml b/cfn-resources/org-service-account/resource-role.yaml index e3301feee..8bcc172cf 100644 --- a/cfn-resources/org-service-account/resource-role.yaml +++ b/cfn-resources/org-service-account/resource-role.yaml @@ -21,7 +21,7 @@ Resources: Ref: AWS::AccountId StringLike: aws:SourceArn: - Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/MongoDB-Atlas-StreamWorkspace/* + Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/MongoDB-Atlas-OrgServiceAccount/* Path: "/" Policies: - PolicyName: ResourceTypePolicy From f293b0acf453eaec6c18bfcc0fcc5df0e940bec5 Mon Sep 17 00:00:00 2001 From: ParthasarathyV Date: Tue, 20 Jan 2026 23:48:12 -0500 Subject: [PATCH 5/7] CLOUDP-369799-org-service-account enhancements --- .../cmd/resource/handlers.go | 18 +++------- .../cmd/resource/mappings.go | 10 ++++-- .../mongodb-atlas-orgserviceaccount.json | 3 -- .../org-service-account/resource-role.yaml | 34 +++++++++++++------ 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/cfn-resources/org-service-account/cmd/resource/handlers.go b/cfn-resources/org-service-account/cmd/resource/handlers.go index a8e1abeb1..974e41c38 100644 --- a/cfn-resources/org-service-account/cmd/resource/handlers.go +++ b/cfn-resources/org-service-account/cmd/resource/handlers.go @@ -74,7 +74,8 @@ func handleUpdate(client *util.MongoDBClient, model *Model) handler.ProgressEven orgID := model.OrgId clientID := model.ClientId - _, apiResp, err := client.AtlasSDK.ServiceAccountsApi.GetOrgServiceAccount(ctx, *orgID, *clientID).Execute() + serviceAccountReq := NewOrgServiceAccountUpdateReq(model) + serviceAccountResp, apiResp, err := client.AtlasSDK.ServiceAccountsApi.UpdateOrgServiceAccount(ctx, *clientID, *orgID, serviceAccountReq).Execute() if err != nil { if util.StatusNotFound(apiResp) { return handler.ProgressEvent{ @@ -86,12 +87,6 @@ func handleUpdate(client *util.MongoDBClient, model *Model) handler.ProgressEven return handleError(apiResp, constants.UPDATE, err) } - serviceAccountReq := NewOrgServiceAccountUpdateReq(model) - serviceAccountResp, apiResp, err := client.AtlasSDK.ServiceAccountsApi.UpdateOrgServiceAccount(ctx, *clientID, *orgID, serviceAccountReq).Execute() - if err != nil { - return handleError(apiResp, constants.UPDATE, err) - } - resourceModel := GetOrgServiceAccountModel(serviceAccountResp, model) if resourceModel.Secrets != nil { for i := range resourceModel.Secrets { @@ -111,20 +106,15 @@ func handleDelete(client *util.MongoDBClient, model *Model) handler.ProgressEven orgID := model.OrgId clientID := model.ClientId - _, resp, err := client.AtlasSDK.ServiceAccountsApi.GetOrgServiceAccount(ctx, *orgID, *clientID).Execute() + apiResp, err := client.AtlasSDK.ServiceAccountsApi.DeleteOrgServiceAccount(ctx, *clientID, *orgID).Execute() if err != nil { - if util.StatusNotFound(resp) { + if util.StatusNotFound(apiResp) { return handler.ProgressEvent{ OperationStatus: handler.Failed, Message: "Resource not found", HandlerErrorCode: string(types.HandlerErrorCodeNotFound), } } - return handleError(resp, constants.DELETE, err) - } - - apiResp, err := client.AtlasSDK.ServiceAccountsApi.DeleteOrgServiceAccount(ctx, *clientID, *orgID).Execute() - if err != nil { return handleError(apiResp, constants.DELETE, err) } diff --git a/cfn-resources/org-service-account/cmd/resource/mappings.go b/cfn-resources/org-service-account/cmd/resource/mappings.go index 5c1a9fed2..cff96fbf8 100644 --- a/cfn-resources/org-service-account/cmd/resource/mappings.go +++ b/cfn-resources/org-service-account/cmd/resource/mappings.go @@ -37,9 +37,13 @@ func GetOrgServiceAccountModel(account *admin.OrgServiceAccount, currentModel *M model.Description = account.Description if account.Roles != nil { roles := *account.Roles - // Always sort roles for consistency (Terraform uses Set which is unordered) - sort.Strings(roles) - model.Roles = roles + // Preserve order from currentModel if it exists (required for CFN contract tests) + // Otherwise preserve API response order + if currentModel != nil && currentModel.Roles != nil && len(currentModel.Roles) > 0 { + model.Roles = currentModel.Roles + } else { + model.Roles = roles + } } model.ClientId = account.ClientId model.CreatedAt = util.TimePtrToStringPtr(account.CreatedAt) diff --git a/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json b/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json index d389d6d09..e3d8b7c3b 100644 --- a/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json +++ b/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json @@ -134,9 +134,6 @@ ] } }, - "writeOnlyProperties": [ - "/properties/Secrets/*/Secret" - ], "tagging": { "taggable": false }, diff --git a/cfn-resources/org-service-account/resource-role.yaml b/cfn-resources/org-service-account/resource-role.yaml index 8bcc172cf..c3d55cc70 100644 --- a/cfn-resources/org-service-account/resource-role.yaml +++ b/cfn-resources/org-service-account/resource-role.yaml @@ -9,28 +9,42 @@ Resources: Properties: MaxSessionDuration: 8400 AssumeRolePolicyDocument: - Version: "2012-10-17" + Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: resources.cloudformation.amazonaws.com Action: sts:AssumeRole - Condition: - StringEquals: - aws:SourceAccount: - Ref: AWS::AccountId - StringLike: - aws:SourceArn: - Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/MongoDB-Atlas-OrgServiceAccount/* Path: "/" Policies: - PolicyName: ResourceTypePolicy PolicyDocument: - Version: "2012-10-17" + Version: '2012-10-17' Statement: - Effect: Allow Action: - - "secretsmanager:GetSecretValue" + - "secretsmanager:CreateSecret" + - "secretsmanager:CreateSecretInput" + - "secretsmanager:DescribeSecret" + - "secretsmanager:GetSecretValue" + - "secretsmanager:PutSecretValue" + - "secretsmanager:UpdateSecretVersionStage" + - "ec2:CreateVpcEndpoint" + - "ec2:DeleteVpcEndpoints" + - "cloudformation:CreateResource" + - "cloudformation:DeleteResource" + - "cloudformation:GetResource" + - "cloudformation:GetResourceRequestStatus" + - "cloudformation:ListResources" + - "cloudformation:UpdateResource" + - "iam:AttachRolePolicy" + - "iam:CreateRole" + - "iam:DeleteRole" + - "iam:GetRole" + - "iam:GetRolePolicy" + - "iam:ListAttachedRolePolicies" + - "iam:ListRolePolicies" + - "iam:PutRolePolicy" Resource: "*" Outputs: ExecutionRoleArn: From d89bff4483c523807e7fe2e10b2d14328206a6e8 Mon Sep 17 00:00:00 2001 From: ParthasarathyV Date: Tue, 20 Jan 2026 23:55:01 -0500 Subject: [PATCH 6/7] CLOUDP-369799-org-service-account enhancements --- cfn-resources/org-service-account/resource-role.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cfn-resources/org-service-account/resource-role.yaml b/cfn-resources/org-service-account/resource-role.yaml index c3d55cc70..042e99402 100644 --- a/cfn-resources/org-service-account/resource-role.yaml +++ b/cfn-resources/org-service-account/resource-role.yaml @@ -15,6 +15,13 @@ Resources: Principal: Service: resources.cloudformation.amazonaws.com Action: sts:AssumeRole + Condition: + StringEquals: + aws:SourceAccount: + Ref: AWS::AccountId + StringLike: + aws:SourceArn: + Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/MongoDB-Atlas-OrgServiceAccount/* Path: "/" Policies: - PolicyName: ResourceTypePolicy @@ -24,7 +31,6 @@ Resources: - Effect: Allow Action: - "secretsmanager:CreateSecret" - - "secretsmanager:CreateSecretInput" - "secretsmanager:DescribeSecret" - "secretsmanager:GetSecretValue" - "secretsmanager:PutSecretValue" From d6f6b205b93c8a6cb725f8222f8ea2bfed2e79ec Mon Sep 17 00:00:00 2001 From: Rakhul S Prakash Date: Tue, 27 Jan 2026 10:25:39 +0530 Subject: [PATCH 7/7] Rename to service-account --- .github/workflows/contract-testing.yaml | 12 ++++++------ .../.rpdk-config | 4 ++-- .../Makefile | 0 .../README.md | 4 ++-- .../cmd/main.go | 2 +- .../cmd/resource/config.go | 0 .../cmd/resource/handlers.go | 0 .../cmd/resource/mappings.go | 0 .../cmd/resource/mappings_test.go | 2 +- .../cmd/resource/model.go | 0 .../cmd/resource/resource.go | 2 +- .../docs/README.md | 6 +++--- .../docs/secret.md | 2 +- .../mongodb-atlas-serviceaccount.json} | 2 +- .../resource-role.yaml | 2 +- .../template.yml | 2 +- .../test/README.md | 4 ++-- .../test/cfn-test-create-inputs.sh | 0 .../test/contract-testing/cfn-test-create.sh | 0 .../test/contract-testing/cfn-test-delete.sh | 2 +- .../test/inputs_1_create.template.json | 0 .../test/inputs_1_update.template.json | 0 .../README.md | 10 +++++----- .../service-account.json} | 2 +- 24 files changed, 29 insertions(+), 29 deletions(-) rename cfn-resources/{org-service-account => service-account}/.rpdk-config (87%) rename cfn-resources/{org-service-account => service-account}/Makefile (100%) rename cfn-resources/{org-service-account => service-account}/README.md (82%) rename cfn-resources/{org-service-account => service-account}/cmd/main.go (96%) rename cfn-resources/{org-service-account => service-account}/cmd/resource/config.go (100%) rename cfn-resources/{org-service-account => service-account}/cmd/resource/handlers.go (100%) rename cfn-resources/{org-service-account => service-account}/cmd/resource/mappings.go (100%) rename cfn-resources/{org-service-account => service-account}/cmd/resource/mappings_test.go (98%) rename cfn-resources/{org-service-account => service-account}/cmd/resource/model.go (100%) rename cfn-resources/{org-service-account => service-account}/cmd/resource/resource.go (98%) rename cfn-resources/{org-service-account => service-account}/docs/README.md (96%) rename cfn-resources/{org-service-account => service-account}/docs/secret.md (98%) rename cfn-resources/{org-service-account/mongodb-atlas-orgserviceaccount.json => service-account/mongodb-atlas-serviceaccount.json} (98%) rename cfn-resources/{org-service-account => service-account}/resource-role.yaml (96%) rename cfn-resources/{org-service-account => service-account}/template.yml (86%) rename cfn-resources/{org-service-account => service-account}/test/README.md (96%) rename cfn-resources/{org-service-account => service-account}/test/cfn-test-create-inputs.sh (100%) rename cfn-resources/{org-service-account => service-account}/test/contract-testing/cfn-test-create.sh (100%) rename cfn-resources/{org-service-account => service-account}/test/contract-testing/cfn-test-delete.sh (79%) rename cfn-resources/{org-service-account => service-account}/test/inputs_1_create.template.json (100%) rename cfn-resources/{org-service-account => service-account}/test/inputs_1_update.template.json (100%) rename examples/{org-service-account => service-account}/README.md (63%) rename examples/{org-service-account/org-service-account.json => service-account/service-account.json} (97%) diff --git a/.github/workflows/contract-testing.yaml b/.github/workflows/contract-testing.yaml index faa1eee19..21c4f122d 100644 --- a/.github/workflows/contract-testing.yaml +++ b/.github/workflows/contract-testing.yaml @@ -22,7 +22,7 @@ jobs: flex-cluster: ${{ steps.filter.outputs.flex-cluster }} online-archive: ${{ steps.filter.outputs.online-archive }} organization: ${{ steps.filter.outputs.organization }} - org-service-account: ${{ steps.filter.outputs.org-service-account }} + service-account: ${{ steps.filter.outputs.service-account }} private-endpoint-aws: ${{ steps.filter.outputs.private-endpoint-aws }} private-endpoint-service: ${{ steps.filter.outputs.private-endpoint-service }} privatelink-endpoint-service-data-federation-online-archive: ${{ steps.filter.outputs.privatelink-endpoint-service-data-federation-online-archive }} @@ -62,8 +62,8 @@ jobs: - 'cfn-resources/online-archive/**' organization: - 'cfn-resources/organization/**' - org-service-account: - - 'cfn-resources/org-service-account/**' + service-account: + - 'cfn-resources/service-account/**' private-endpoint-aws: - 'cfn-resources/private-endpoint-aws/**' private-endpoint-service: @@ -534,9 +534,9 @@ jobs: make run-contract-testing make delete-test-resources - org-service-account: + service-account: needs: change-detection - if: ${{ needs.change-detection.outputs.org-service-account == 'true' }} + if: ${{ needs.change-detection.outputs.service-account == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 @@ -567,7 +567,7 @@ jobs: MONGODB_ATLAS_OPS_MANAGER_URL: ${{ vars.MONGODB_ATLAS_BASE_URL }} MONGODB_ATLAS_PROFILE: cfn-cloud-dev-github-action run: | - cd cfn-resources/org-service-account + cd cfn-resources/service-account make create-test-resources cat inputs/inputs_1_create.json diff --git a/cfn-resources/org-service-account/.rpdk-config b/cfn-resources/service-account/.rpdk-config similarity index 87% rename from cfn-resources/org-service-account/.rpdk-config rename to cfn-resources/service-account/.rpdk-config index 799d5bd2f..a5c507fd4 100644 --- a/cfn-resources/org-service-account/.rpdk-config +++ b/cfn-resources/service-account/.rpdk-config @@ -1,6 +1,6 @@ { "artifact_type": "RESOURCE", - "typeName": "MongoDB::Atlas::OrgServiceAccount", + "typeName": "MongoDB::Atlas::ServiceAccount", "language": "go", "runtime": "provided.al2", "entrypoint": "bootstrap", @@ -16,7 +16,7 @@ "region": null, "target_schemas": [], "profile": null, - "import_path": "github.com/mongodb/mongodbatlas-cloudformation-resources/org-service-account", + "import_path": "github.com/mongodb/mongodbatlas-cloudformation-resources/service-account", "protocolVersion": "2.0.0" }, "canarySettings": { diff --git a/cfn-resources/org-service-account/Makefile b/cfn-resources/service-account/Makefile similarity index 100% rename from cfn-resources/org-service-account/Makefile rename to cfn-resources/service-account/Makefile diff --git a/cfn-resources/org-service-account/README.md b/cfn-resources/service-account/README.md similarity index 82% rename from cfn-resources/org-service-account/README.md rename to cfn-resources/service-account/README.md index 70049f799..6b8954fc6 100644 --- a/cfn-resources/org-service-account/README.md +++ b/cfn-resources/service-account/README.md @@ -1,4 +1,4 @@ -# MongoDB::Atlas::OrgServiceAccount +# MongoDB::Atlas::ServiceAccount ## Description @@ -15,5 +15,5 @@ See the [resource docs](docs/README.md). ## Cloudformation Examples -See the examples [CFN Template](/examples/org-service-account/README.md) for example resource. +See the examples [CFN Template](/examples/service-account/README.md) for example resource. diff --git a/cfn-resources/org-service-account/cmd/main.go b/cfn-resources/service-account/cmd/main.go similarity index 96% rename from cfn-resources/org-service-account/cmd/main.go rename to cfn-resources/service-account/cmd/main.go index cd90f2251..cb6fe5eaf 100644 --- a/cfn-resources/org-service-account/cmd/main.go +++ b/cfn-resources/service-account/cmd/main.go @@ -8,7 +8,7 @@ import ( "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn" "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" - "github.com/mongodb/mongodbatlas-cloudformation-resources/org-service-account/cmd/resource" + "github.com/mongodb/mongodbatlas-cloudformation-resources/service-account/cmd/resource" ) // Handler is a container for the CRUDL actions exported by resources diff --git a/cfn-resources/org-service-account/cmd/resource/config.go b/cfn-resources/service-account/cmd/resource/config.go similarity index 100% rename from cfn-resources/org-service-account/cmd/resource/config.go rename to cfn-resources/service-account/cmd/resource/config.go diff --git a/cfn-resources/org-service-account/cmd/resource/handlers.go b/cfn-resources/service-account/cmd/resource/handlers.go similarity index 100% rename from cfn-resources/org-service-account/cmd/resource/handlers.go rename to cfn-resources/service-account/cmd/resource/handlers.go diff --git a/cfn-resources/org-service-account/cmd/resource/mappings.go b/cfn-resources/service-account/cmd/resource/mappings.go similarity index 100% rename from cfn-resources/org-service-account/cmd/resource/mappings.go rename to cfn-resources/service-account/cmd/resource/mappings.go diff --git a/cfn-resources/org-service-account/cmd/resource/mappings_test.go b/cfn-resources/service-account/cmd/resource/mappings_test.go similarity index 98% rename from cfn-resources/org-service-account/cmd/resource/mappings_test.go rename to cfn-resources/service-account/cmd/resource/mappings_test.go index 2a8291e7a..58ff2d6e7 100644 --- a/cfn-resources/org-service-account/cmd/resource/mappings_test.go +++ b/cfn-resources/service-account/cmd/resource/mappings_test.go @@ -21,7 +21,7 @@ import ( "go.mongodb.org/atlas-sdk/v20250312012/admin" "github.com/aws/smithy-go/ptr" - "github.com/mongodb/mongodbatlas-cloudformation-resources/org-service-account/cmd/resource" + "github.com/mongodb/mongodbatlas-cloudformation-resources/service-account/cmd/resource" "github.com/stretchr/testify/assert" ) diff --git a/cfn-resources/org-service-account/cmd/resource/model.go b/cfn-resources/service-account/cmd/resource/model.go similarity index 100% rename from cfn-resources/org-service-account/cmd/resource/model.go rename to cfn-resources/service-account/cmd/resource/model.go diff --git a/cfn-resources/org-service-account/cmd/resource/resource.go b/cfn-resources/service-account/cmd/resource/resource.go similarity index 98% rename from cfn-resources/org-service-account/cmd/resource/resource.go rename to cfn-resources/service-account/cmd/resource/resource.go index e7666d41e..8cdc31016 100644 --- a/cfn-resources/org-service-account/cmd/resource/resource.go +++ b/cfn-resources/service-account/cmd/resource/resource.go @@ -29,7 +29,7 @@ var ( ) func setupRequest(req handler.Request, model *Model, requiredFields []string) (*util.MongoDBClient, *handler.ProgressEvent) { - util.SetupLogger("mongodb-atlas-org-service-account") + util.SetupLogger("mongodb-atlas-service-account") if modelValidation := validator.ValidateModel(requiredFields, model); modelValidation != nil { return nil, modelValidation } diff --git a/cfn-resources/org-service-account/docs/README.md b/cfn-resources/service-account/docs/README.md similarity index 96% rename from cfn-resources/org-service-account/docs/README.md rename to cfn-resources/service-account/docs/README.md index 1b5d72888..32dc3c1e0 100644 --- a/cfn-resources/org-service-account/docs/README.md +++ b/cfn-resources/service-account/docs/README.md @@ -1,4 +1,4 @@ -# MongoDB::Atlas::OrgServiceAccount +# MongoDB::Atlas::ServiceAccount Creates and manages a Service Account for an organization. @@ -10,7 +10,7 @@ To declare this entity in your AWS CloudFormation template, use the following sy
 {
-    "Type" : "MongoDB::Atlas::OrgServiceAccount",
+    "Type" : "MongoDB::Atlas::ServiceAccount",
     "Properties" : {
         "Profile" : String,
         "OrgId" : String,
@@ -25,7 +25,7 @@ To declare this entity in your AWS CloudFormation template, use the following sy
 ### YAML
 
 
-Type: MongoDB::Atlas::OrgServiceAccount
+Type: MongoDB::Atlas::ServiceAccount
 Properties:
     Profile: String
     OrgId: String
diff --git a/cfn-resources/org-service-account/docs/secret.md b/cfn-resources/service-account/docs/secret.md
similarity index 98%
rename from cfn-resources/org-service-account/docs/secret.md
rename to cfn-resources/service-account/docs/secret.md
index f4284d89b..4e99d3164 100644
--- a/cfn-resources/org-service-account/docs/secret.md
+++ b/cfn-resources/service-account/docs/secret.md
@@ -1,4 +1,4 @@
-# MongoDB::Atlas::OrgServiceAccount Secret
+# MongoDB::Atlas::ServiceAccount Secret
 
 ## Syntax
 
diff --git a/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json b/cfn-resources/service-account/mongodb-atlas-serviceaccount.json
similarity index 98%
rename from cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json
rename to cfn-resources/service-account/mongodb-atlas-serviceaccount.json
index e3d8b7c3b..2b3e05e15 100644
--- a/cfn-resources/org-service-account/mongodb-atlas-orgserviceaccount.json
+++ b/cfn-resources/service-account/mongodb-atlas-serviceaccount.json
@@ -1,5 +1,5 @@
 {
-  "typeName": "MongoDB::Atlas::OrgServiceAccount",
+  "typeName": "MongoDB::Atlas::ServiceAccount",
   "description": "Creates and manages a Service Account for an organization.",
   "sourceUrl": "https://github.com/mongodb/mongodbatlas-cloudformation-resources",
   "definitions": {
diff --git a/cfn-resources/org-service-account/resource-role.yaml b/cfn-resources/service-account/resource-role.yaml
similarity index 96%
rename from cfn-resources/org-service-account/resource-role.yaml
rename to cfn-resources/service-account/resource-role.yaml
index 042e99402..d0c0e6852 100644
--- a/cfn-resources/org-service-account/resource-role.yaml
+++ b/cfn-resources/service-account/resource-role.yaml
@@ -21,7 +21,7 @@ Resources:
                   Ref: AWS::AccountId
               StringLike:
                 aws:SourceArn:
-                  Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/MongoDB-Atlas-OrgServiceAccount/*
+                  Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/MongoDB-Atlas-ServiceAccount/*
       Path: "/"
       Policies:
         - PolicyName: ResourceTypePolicy
diff --git a/cfn-resources/org-service-account/template.yml b/cfn-resources/service-account/template.yml
similarity index 86%
rename from cfn-resources/org-service-account/template.yml
rename to cfn-resources/service-account/template.yml
index 9bb213290..7d10c69ac 100644
--- a/cfn-resources/org-service-account/template.yml
+++ b/cfn-resources/service-account/template.yml
@@ -1,6 +1,6 @@
 AWSTemplateFormatVersion: "2010-09-09"
 Transform: AWS::Serverless-2016-10-31
-Description: AWS SAM template for the MongoDB::Atlas::OrgServiceAccount resource type
+Description: AWS SAM template for the MongoDB::Atlas::ServiceAccount resource type
 
 Globals:
   Function:
diff --git a/cfn-resources/org-service-account/test/README.md b/cfn-resources/service-account/test/README.md
similarity index 96%
rename from cfn-resources/org-service-account/test/README.md
rename to cfn-resources/service-account/test/README.md
index c27e11fea..d4c90f31a 100644
--- a/cfn-resources/org-service-account/test/README.md
+++ b/cfn-resources/service-account/test/README.md
@@ -1,4 +1,4 @@
-# MongoDB::Atlas::OrgServiceAccount
+# MongoDB::Atlas::ServiceAccount
 
 ## Impact
 The following components use this resource and are potentially impacted by any changes. They should also be validated to ensure the changes do not cause a regression.
@@ -53,7 +53,7 @@ then in another shell:
 ```bash
 repo_root=$(git rev-parse --show-toplevel)
 source <(${repo_root}/quickstart-mongodb-atlas/scripts/export-mongocli-config.py)
-cd ${repo_root}/cfn-resources/org-service-account
+cd ${repo_root}/cfn-resources/service-account
 export MONGODB_ATLAS_ORG_ID="your-org-id"
 ./test/cfn-test-create-inputs.sh cfn-test-service-account > test.request.json 
 echo "Sample request:"
diff --git a/cfn-resources/org-service-account/test/cfn-test-create-inputs.sh b/cfn-resources/service-account/test/cfn-test-create-inputs.sh
similarity index 100%
rename from cfn-resources/org-service-account/test/cfn-test-create-inputs.sh
rename to cfn-resources/service-account/test/cfn-test-create-inputs.sh
diff --git a/cfn-resources/org-service-account/test/contract-testing/cfn-test-create.sh b/cfn-resources/service-account/test/contract-testing/cfn-test-create.sh
similarity index 100%
rename from cfn-resources/org-service-account/test/contract-testing/cfn-test-create.sh
rename to cfn-resources/service-account/test/contract-testing/cfn-test-create.sh
diff --git a/cfn-resources/org-service-account/test/contract-testing/cfn-test-delete.sh b/cfn-resources/service-account/test/contract-testing/cfn-test-delete.sh
similarity index 79%
rename from cfn-resources/org-service-account/test/contract-testing/cfn-test-delete.sh
rename to cfn-resources/service-account/test/contract-testing/cfn-test-delete.sh
index 747e18346..f49550a70 100755
--- a/cfn-resources/org-service-account/test/contract-testing/cfn-test-delete.sh
+++ b/cfn-resources/service-account/test/contract-testing/cfn-test-delete.sh
@@ -1,7 +1,7 @@
 #!/usr/bin/env bash
 
 # This tool deletes the mongodb resources used for `cfn test` as inputs.
-# For org-service-account, no pre-created resources need cleanup.
+# For service-account, no pre-created resources need cleanup.
 # The CloudFormation test framework handles resource cleanup automatically.
 
 set -o errexit
diff --git a/cfn-resources/org-service-account/test/inputs_1_create.template.json b/cfn-resources/service-account/test/inputs_1_create.template.json
similarity index 100%
rename from cfn-resources/org-service-account/test/inputs_1_create.template.json
rename to cfn-resources/service-account/test/inputs_1_create.template.json
diff --git a/cfn-resources/org-service-account/test/inputs_1_update.template.json b/cfn-resources/service-account/test/inputs_1_update.template.json
similarity index 100%
rename from cfn-resources/org-service-account/test/inputs_1_update.template.json
rename to cfn-resources/service-account/test/inputs_1_update.template.json
diff --git a/examples/org-service-account/README.md b/examples/service-account/README.md
similarity index 63%
rename from examples/org-service-account/README.md
rename to examples/service-account/README.md
index d4c2dc7a3..c52c99531 100644
--- a/examples/org-service-account/README.md
+++ b/examples/service-account/README.md
@@ -1,19 +1,19 @@
-# How to create a MongoDB::Atlas::OrgServiceAccount
+# How to create a MongoDB::Atlas::ServiceAccount
 
-## Step 1: Activate the org service account resource in cloudformation
+## Step 1: Activate the service account resource in cloudformation
 
 Step a: Create Role using [execution-role.yaml](https://github.com/mongodb/mongodbatlas-cloudformation-resources/blob/master/examples/execution-role.yaml) in CFN resources folder.
 
-Step b: Search for Mongodb::Atlas::OrgServiceAccount resource.
+Step b: Search for Mongodb::Atlas::ServiceAccount resource.
 
          (CloudFormation > Public extensions > choose 'Third party' > Search with " Execution name prefix = MongoDB " )
 
 Step c: Select and activate
 Enter the RoleArn that is created in step 1.
 
-Your OrgServiceAccount Resource is ready to use.
+Your ServiceAccount Resource is ready to use.
 
-## Step 2: Create template using [org-service-account.json](org-service-account.json)
+## Step 2: Create template using [service-account.json](service-account.json)
 
     Note: Make sure you are providing appropriate values for:
     1. OrgId
diff --git a/examples/org-service-account/org-service-account.json b/examples/service-account/service-account.json
similarity index 97%
rename from examples/org-service-account/org-service-account.json
rename to examples/service-account/service-account.json
index af0c54867..3fd282dec 100644
--- a/examples/org-service-account/org-service-account.json
+++ b/examples/service-account/service-account.json
@@ -35,7 +35,7 @@
   },
   "Resources": {
     "OrgServiceAccount": {
-      "Type": "MongoDB::Atlas::OrgServiceAccount",
+      "Type": "MongoDB::Atlas::ServiceAccount",
       "Properties": {
         "OrgId": {
           "Ref": "OrgId"