diff --git a/.github/workflows/contract-testing.yaml b/.github/workflows/contract-testing.yaml index 81eedd449..21c4f122d 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 }} + 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 }} @@ -61,6 +62,8 @@ jobs: - 'cfn-resources/online-archive/**' organization: - 'cfn-resources/organization/**' + service-account: + - 'cfn-resources/service-account/**' private-endpoint-aws: - 'cfn-resources/private-endpoint-aws/**' private-endpoint-service: @@ -525,7 +528,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 + service-account: + needs: change-detection + if: ${{ needs.change-detection.outputs.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/service-account + make create-test-resources + cat inputs/inputs_1_create.json cat inputs/inputs_1_update.json diff --git a/cfn-resources/service-account/.rpdk-config b/cfn-resources/service-account/.rpdk-config new file mode 100644 index 000000000..a5c507fd4 --- /dev/null +++ b/cfn-resources/service-account/.rpdk-config @@ -0,0 +1,27 @@ +{ + "artifact_type": "RESOURCE", + "typeName": "MongoDB::Atlas::ServiceAccount", + "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/service-account", + "protocolVersion": "2.0.0" + }, + "canarySettings": { + "contract_test_file_names": [ + "inputs_1.json" + ] + } +} diff --git a/cfn-resources/service-account/Makefile b/cfn-resources/service-account/Makefile new file mode 100644 index 000000000..562724f34 --- /dev/null +++ b/cfn-resources/service-account/Makefile @@ -0,0 +1,37 @@ +.PHONY: build test clean debug create-test-resources delete-test-resources run-contract-testing +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 + +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/service-account/README.md b/cfn-resources/service-account/README.md new file mode 100644 index 000000000..6b8954fc6 --- /dev/null +++ b/cfn-resources/service-account/README.md @@ -0,0 +1,19 @@ +# MongoDB::Atlas::ServiceAccount + +## 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/service-account/README.md) for example resource. + diff --git a/cfn-resources/service-account/cmd/main.go b/cfn-resources/service-account/cmd/main.go new file mode 100644 index 000000000..cb6fe5eaf --- /dev/null +++ b/cfn-resources/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/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/service-account/cmd/resource/config.go b/cfn-resources/service-account/cmd/resource/config.go new file mode 100644 index 000000000..4d9eb7831 --- /dev/null +++ b/cfn-resources/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/service-account/cmd/resource/handlers.go b/cfn-resources/service-account/cmd/resource/handlers.go new file mode 100644 index 000000000..974e41c38 --- /dev/null +++ b/cfn-resources/service-account/cmd/resource/handlers.go @@ -0,0 +1,162 @@ +// 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(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(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(client *util.MongoDBClient, model *Model) handler.ProgressEvent { + ctx := context.Background() + orgID := model.OrgId + clientID := model.ClientId + + 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{ + OperationStatus: handler.Failed, + Message: "Resource not found", + HandlerErrorCode: string(types.HandlerErrorCodeNotFound), + } + } + 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(client *util.MongoDBClient, model *Model) handler.ProgressEvent { + ctx := context.Background() + orgID := model.OrgId + clientID := model.ClientId + + apiResp, err := client.AtlasSDK.ServiceAccountsApi.DeleteOrgServiceAccount(ctx, *clientID, *orgID).Execute() + if err != nil { + if util.StatusNotFound(apiResp) { + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + Message: "Resource not found", + HandlerErrorCode: string(types.HandlerErrorCodeNotFound), + } + } + return handleError(apiResp, constants.DELETE, err) + } + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: constants.Complete, + } +} + +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) + } + + 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/service-account/cmd/resource/mappings.go b/cfn-resources/service-account/cmd/resource/mappings.go new file mode 100644 index 000000000..cff96fbf8 --- /dev/null +++ b/cfn-resources/service-account/cmd/resource/mappings.go @@ -0,0 +1,105 @@ +// 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/v20250312012/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 + // 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) + + 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 + } + if model.SecretExpiresAfterHours == 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/service-account/cmd/resource/mappings_test.go b/cfn-resources/service-account/cmd/resource/mappings_test.go new file mode 100644 index 000000000..58ff2d6e7 --- /dev/null +++ b/cfn-resources/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/v20250312012/admin" + + "github.com/aws/smithy-go/ptr" + "github.com/mongodb/mongodbatlas-cloudformation-resources/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/service-account/cmd/resource/model.go b/cfn-resources/service-account/cmd/resource/model.go new file mode 100644 index 000000000..483c8c7c5 --- /dev/null +++ b/cfn-resources/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/service-account/cmd/resource/resource.go b/cfn-resources/service-account/cmd/resource/resource.go new file mode 100644 index 000000000..8cdc31016 --- /dev/null +++ b/cfn-resources/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"} +) + +func setupRequest(req handler.Request, model *Model, requiredFields []string) (*util.MongoDBClient, *handler.ProgressEvent) { + util.SetupLogger("mongodb-atlas-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(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(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(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(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(client, model), nil +} diff --git a/cfn-resources/service-account/docs/README.md b/cfn-resources/service-account/docs/README.md new file mode 100644 index 000000000..32dc3c1e0 --- /dev/null +++ b/cfn-resources/service-account/docs/README.md @@ -0,0 +1,126 @@ +# MongoDB::Atlas::ServiceAccount + +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::ServiceAccount",
+    "Properties" : {
+        "Profile" : String,
+        "OrgId" : String,
+        "Name" : String,
+        "Description" : String,
+        "Roles" : [ String, ... ],
+        "SecretExpiresAfterHours" : Integer,
+    }
+}
+
+ +### YAML + +
+Type: MongoDB::Atlas::ServiceAccount
+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/service-account/docs/secret.md b/cfn-resources/service-account/docs/secret.md new file mode 100644 index 000000000..4e99d3164 --- /dev/null +++ b/cfn-resources/service-account/docs/secret.md @@ -0,0 +1,90 @@ +# MongoDB::Atlas::ServiceAccount 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/service-account/mongodb-atlas-serviceaccount.json b/cfn-resources/service-account/mongodb-atlas-serviceaccount.json new file mode 100644 index 000000000..2b3e05e15 --- /dev/null +++ b/cfn-resources/service-account/mongodb-atlas-serviceaccount.json @@ -0,0 +1,141 @@ +{ + "typeName": "MongoDB::Atlas::ServiceAccount", + "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" + ] + }, + "list": { + "permissions": [ + "secretsmanager:GetSecretValue" + ] + } + }, + "tagging": { + "taggable": false + }, + "additionalProperties": false +} diff --git a/cfn-resources/service-account/resource-role.yaml b/cfn-resources/service-account/resource-role.yaml new file mode 100644 index 000000000..d0c0e6852 --- /dev/null +++ b/cfn-resources/service-account/resource-role.yaml @@ -0,0 +1,58 @@ +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-ServiceAccount/* + Path: "/" + Policies: + - PolicyName: ResourceTypePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - "secretsmanager:CreateSecret" + - "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: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/cfn-resources/service-account/template.yml b/cfn-resources/service-account/template.yml new file mode 100644 index 000000000..7d10c69ac --- /dev/null +++ b/cfn-resources/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::ServiceAccount 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/service-account/test/README.md b/cfn-resources/service-account/test/README.md new file mode 100644 index 000000000..d4c90f31a --- /dev/null +++ b/cfn-resources/service-account/test/README.md @@ -0,0 +1,65 @@ +# 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. +- 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/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/service-account/test/cfn-test-create-inputs.sh b/cfn-resources/service-account/test/cfn-test-create-inputs.sh new file mode 100755 index 000000000..ccab179be --- /dev/null +++ b/cfn-resources/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/service-account/test/contract-testing/cfn-test-create.sh b/cfn-resources/service-account/test/contract-testing/cfn-test-create.sh new file mode 100755 index 000000000..ebb3dbf2f --- /dev/null +++ b/cfn-resources/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/service-account/test/contract-testing/cfn-test-delete.sh b/cfn-resources/service-account/test/contract-testing/cfn-test-delete.sh new file mode 100755 index 000000000..f49550a70 --- /dev/null +++ b/cfn-resources/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 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" diff --git a/cfn-resources/service-account/test/inputs_1_create.template.json b/cfn-resources/service-account/test/inputs_1_create.template.json new file mode 100644 index 000000000..62d7532de --- /dev/null +++ b/cfn-resources/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/service-account/test/inputs_1_update.template.json b/cfn-resources/service-account/test/inputs_1_update.template.json new file mode 100644 index 000000000..2a314b591 --- /dev/null +++ b/cfn-resources/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/service-account/README.md b/examples/service-account/README.md new file mode 100644 index 000000000..c52c99531 --- /dev/null +++ b/examples/service-account/README.md @@ -0,0 +1,24 @@ +# How to create a MongoDB::Atlas::ServiceAccount + +## 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::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 ServiceAccount Resource is ready to use. + +## Step 2: Create template using [service-account.json](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/service-account/service-account.json b/examples/service-account/service-account.json new file mode 100644 index 000000000..3fd282dec --- /dev/null +++ b/examples/service-account/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::ServiceAccount", + "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" + ] + } + } + } +} +