diff --git a/.github/workflows/contract-testing.yaml b/.github/workflows/contract-testing.yaml index 42518cc96..76d4088e5 100644 --- a/.github/workflows/contract-testing.yaml +++ b/.github/workflows/contract-testing.yaml @@ -31,6 +31,7 @@ jobs: 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 }} project: ${{ steps.filter.outputs.project }} + project-service-account-secret: ${{ steps.filter.outputs.project-service-account-secret }} resource-policy: ${{ steps.filter.outputs.resource-policy }} search-deployment: ${{ steps.filter.outputs.search-deployment }} search-index: ${{ steps.filter.outputs.search-index }} @@ -87,6 +88,8 @@ jobs: - 'cfn-resources/privatelink-endpoint-service-data-federation-online-archive/**' project: - 'cfn-resources/project/**' + project-service-account-secret: + - 'cfn-resources/project-service-account-secret/**' resource-policy: - 'cfn-resources/resource-policy/**' search-deployment: @@ -927,6 +930,46 @@ jobs: make run-contract-testing make delete-test-resources + project-service-account-secret: + needs: change-detection + if: ${{ needs.change-detection.outputs.project-service-account-secret == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - 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@d78e1a4a9656d3b223e59b80676a797f20093133 + with: + use-installer: true + - uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 + 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 + 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/project-service-account-secret + make create-test-resources + + cat inputs/* + + make run-contract-testing + make delete-test-resources resource-policy: needs: change-detection if: ${{ needs.change-detection.outputs.resource-policy == 'true' }} diff --git a/cfn-resources/project-service-account-secret/.rpdk-config b/cfn-resources/project-service-account-secret/.rpdk-config new file mode 100644 index 000000000..e75dafe7c --- /dev/null +++ b/cfn-resources/project-service-account-secret/.rpdk-config @@ -0,0 +1,12 @@ +{ + "typeName": "MongoDB::Atlas::ProjectServiceAccountSecret", + "language": "go", + "runtime": "provided.al2", + "entrypoint": "bootstrap", + "testEntrypoint": "bootstrap", + "settings": { + "import_path": "github.com/mongodb/mongodbatlas-cloudformation-resources/project-service-account-secret", + "protocolVersion": "2.0.0", + "pluginVersion": "2.0.4" + } +} diff --git a/cfn-resources/project-service-account-secret/Makefile b/cfn-resources/project-service-account-secret/Makefile new file mode 100644 index 000000000..4f3d93ebd --- /dev/null +++ b/cfn-resources/project-service-account-secret/Makefile @@ -0,0 +1,37 @@ +.PHONY: build test clean +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/debug cmd/main.go + +clean: + rm -rf bin + +submit: clean build # submit to private registry must use release build not debug build + @echo "==> Submitting to private registry for testing" + cfn submit --set-default --region us-east-1 + +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/project-service-account-secret/README.md b/cfn-resources/project-service-account-secret/README.md new file mode 100644 index 000000000..d157e447b --- /dev/null +++ b/cfn-resources/project-service-account-secret/README.md @@ -0,0 +1,29 @@ +# MongoDB::Atlas::ProjectServiceAccountSecret + +## Description + +The Project Service Account Secret resource provides a secret for a Service Account at the project level. This resource lets you create and delete secrets for Project Service Accounts. For more information, see [Create One Project Service Account Secret](https://www.mongodb.com/docs/atlas/reference/api-resources-spec/v2/#tag/Service-Accounts/operation/createGroupServiceAccountSecret) in the MongoDB Atlas API documentation. + +~> **IMPORTANT WARNING:** Managing Service Account Secrets with CloudFormation **exposes sensitive organizational secrets** in CloudFormation's outputs and logs. We suggest following [AWS Secrets Manager best practices](https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html) for handling sensitive data. + +-> **NOTE:** This resource does not support updates. Any property change will trigger a replacement (delete + create). To rotate secrets, simply replace the resource. + +## 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/project-service-account-secret/README.md) for example resource. + +## Important Notes + +- The `Secret` property contains the actual secret value and is only returned once during creation +- This resource does not support updates - any change will trigger a replacement +- Secrets have an expiration time controlled by `SecretExpiresAfterHours` +- The minimum and maximum expiration times are controlled by your organization's settings diff --git a/cfn-resources/project-service-account-secret/cmd/main.go b/cfn-resources/project-service-account-secret/cmd/main.go new file mode 100644 index 000000000..1e77b2e7b --- /dev/null +++ b/cfn-resources/project-service-account-secret/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/project-service-account-secret/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/project-service-account-secret/cmd/resource/config.go b/cfn-resources/project-service-account-secret/cmd/resource/config.go new file mode 100644 index 000000000..4d9eb7831 --- /dev/null +++ b/cfn-resources/project-service-account-secret/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/project-service-account-secret/cmd/resource/handlers.go b/cfn-resources/project-service-account-secret/cmd/resource/handlers.go new file mode 100644 index 000000000..8b95a885e --- /dev/null +++ b/cfn-resources/project-service-account-secret/cmd/resource/handlers.go @@ -0,0 +1,151 @@ +// 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" + "go.mongodb.org/atlas-sdk/v20250312013/admin" +) + +func handleCreate(client *util.MongoDBClient, model *Model) handler.ProgressEvent { + ctx := context.Background() + projectID := *model.ProjectId + clientID := *model.ClientId + + createReq := &admin.ServiceAccountSecretRequest{} + if model.SecretExpiresAfterHours != nil { + createReq.SecretExpiresAfterHours = *model.SecretExpiresAfterHours + } + + secretResp, apiResp, err := client.AtlasSDK.ServiceAccountsApi.CreateGroupSecret(ctx, projectID, clientID, createReq).Execute() + if err != nil { + return handleError(apiResp, constants.CREATE, err) + } + + UpdateModelFromSecret(model, secretResp) + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: constants.Complete, + ResourceModel: model, + } +} + +func handleRead(client *util.MongoDBClient, model *Model) handler.ProgressEvent { + ctx := context.Background() + projectID := *model.ProjectId + clientID := *model.ClientId + secretID := *model.SecretId + + serviceAccountResp, apiResp, err := client.AtlasSDK.ServiceAccountsApi.GetGroupServiceAccount(ctx, projectID, clientID).Execute() + if err != nil { + return handleError(apiResp, constants.READ, err) + } + + var foundSecret *admin.ServiceAccountSecret + if serviceAccountResp.Secrets != nil { + for i := range *serviceAccountResp.Secrets { + secret := &(*serviceAccountResp.Secrets)[i] + if secret.Id == secretID { + foundSecret = secret + break + } + } + } + + if foundSecret == nil { + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + Message: fmt.Sprintf("Secret with ID %s not found in service account %s", secretID, clientID), + HandlerErrorCode: string(types.HandlerErrorCodeNotFound), + } + } + + UpdateModelFromSecret(model, foundSecret) + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: constants.ReadComplete, + ResourceModel: model, + } +} + +func handleDelete(client *util.MongoDBClient, model *Model) handler.ProgressEvent { + ctx := context.Background() + projectID := *model.ProjectId + clientID := *model.ClientId + secretID := *model.SecretId + + apiResp, err := client.AtlasSDK.ServiceAccountsApi.DeleteGroupSecret(ctx, clientID, secretID, projectID).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() + projectID := *model.ProjectId + clientID := *model.ClientId + + serviceAccountResp, apiResp, err := client.AtlasSDK.ServiceAccountsApi.GetGroupServiceAccount(ctx, projectID, clientID).Execute() + if err != nil { + return handleError(apiResp, constants.LIST, err) + } + + var secretModels []any + if serviceAccountResp.Secrets != nil { + for i := range *serviceAccountResp.Secrets { + secret := &(*serviceAccountResp.Secrets)[i] + secretModel := &Model{ + ProjectId: model.ProjectId, + ClientId: model.ClientId, + Profile: model.Profile, + } + UpdateModelFromSecret(secretModel, secret) + secretModels = append(secretModels, secretModel) + } + } + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: constants.Complete, + ResourceModels: secretModels, + } +} + +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/project-service-account-secret/cmd/resource/mappings.go b/cfn-resources/project-service-account-secret/cmd/resource/mappings.go new file mode 100644 index 000000000..353f85d15 --- /dev/null +++ b/cfn-resources/project-service-account-secret/cmd/resource/mappings.go @@ -0,0 +1,32 @@ +// 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/mongodb/mongodbatlas-cloudformation-resources/util" + "go.mongodb.org/atlas-sdk/v20250312013/admin" +) + +func UpdateModelFromSecret(model *Model, secret *admin.ServiceAccountSecret) { + if secret == nil { + return + } + model.SecretId = &secret.Id + model.Secret = secret.Secret + model.MaskedSecretValue = secret.MaskedSecretValue + model.CreatedAt = util.TimePtrToStringPtr(&secret.CreatedAt) + model.ExpiresAt = util.TimePtrToStringPtr(&secret.ExpiresAt) + model.LastUsedAt = util.TimePtrToStringPtr(secret.LastUsedAt) +} diff --git a/cfn-resources/project-service-account-secret/cmd/resource/mappings_test.go b/cfn-resources/project-service-account-secret/cmd/resource/mappings_test.go new file mode 100644 index 000000000..713d0dfa4 --- /dev/null +++ b/cfn-resources/project-service-account-secret/cmd/resource/mappings_test.go @@ -0,0 +1,59 @@ +// 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" + + "github.com/aws/smithy-go/ptr" + "github.com/mongodb/mongodbatlas-cloudformation-resources/project-service-account-secret/cmd/resource" + "github.com/stretchr/testify/assert" + "go.mongodb.org/atlas-sdk/v20250312013/admin" +) + +func TestUpdateModelFromSecret(t *testing.T) { + now := time.Now() + secretID := "698eac1419a6b89540c8e7b5" + secretValue := "mdb_sa_sk_test123" //nolint:gosec // test data + maskedValue := "****7b5" + projectID := "695feecd6de62c462a0f09cb" + clientID := "mdb_sa_id_698eac1419a6b89540c8e7b6" + + model := &resource.Model{ + ProjectId: ptr.String(projectID), + ClientId: ptr.String(clientID), + } + + secret := &admin.ServiceAccountSecret{ + Id: secretID, + Secret: ptr.String(secretValue), + MaskedSecretValue: ptr.String(maskedValue), + CreatedAt: now, + ExpiresAt: now.Add(720 * time.Hour), + LastUsedAt: ptr.Time(now.Add(-1 * time.Hour)), + } + + resource.UpdateModelFromSecret(model, secret) + + assert.Equal(t, secretID, *model.SecretId) + assert.Equal(t, secretValue, *model.Secret) + assert.Equal(t, maskedValue, *model.MaskedSecretValue) + assert.NotNil(t, model.CreatedAt) + assert.NotNil(t, model.ExpiresAt) + assert.NotNil(t, model.LastUsedAt) + assert.Equal(t, projectID, *model.ProjectId) + assert.Equal(t, clientID, *model.ClientId) +} diff --git a/cfn-resources/project-service-account-secret/cmd/resource/model.go b/cfn-resources/project-service-account-secret/cmd/resource/model.go new file mode 100644 index 000000000..f42fe8919 --- /dev/null +++ b/cfn-resources/project-service-account-secret/cmd/resource/model.go @@ -0,0 +1,17 @@ +// 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 { + ProjectId *string `json:",omitempty"` + ClientId *string `json:",omitempty"` + SecretExpiresAfterHours *int `json:",omitempty"` + SecretId *string `json:",omitempty"` + Secret *string `json:",omitempty"` + MaskedSecretValue *string `json:",omitempty"` + CreatedAt *string `json:",omitempty"` + ExpiresAt *string `json:",omitempty"` + LastUsedAt *string `json:",omitempty"` + Profile *string `json:",omitempty"` +} diff --git a/cfn-resources/project-service-account-secret/cmd/resource/resource.go b/cfn-resources/project-service-account-secret/cmd/resource/resource.go new file mode 100644 index 000000000..91a6c8d33 --- /dev/null +++ b/cfn-resources/project-service-account-secret/cmd/resource/resource.go @@ -0,0 +1,84 @@ +// 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 ( + "errors" + + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util/constants" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util/validator" +) + +var ( + CreateRequiredFields = []string{constants.ProjectID, constants.ClientID} + ReadRequiredFields = []string{constants.ProjectID, constants.ClientID, constants.SecretID} + DeleteRequiredFields = []string{constants.ProjectID, constants.ClientID, constants.SecretID} + ListRequiredFields = []string{constants.ProjectID, constants.ClientID} +) + +func setupRequest(req handler.Request, model *Model, requiredFields []string) (*util.MongoDBClient, *handler.ProgressEvent) { + util.SetupLogger("mongodb-atlas-project-service-account-secret") + 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 +} + +// Create handles the Create event from the Cloudformation service. +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 +} + +// Read handles the Read event from the Cloudformation service. +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) { + return handler.ProgressEvent{}, errors.New("not implemented: Update") +} + +// Delete handles the Delete event from the Cloudformation service. +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 +} + +// List handles the List event from the Cloudformation service. +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/project-service-account-secret/docs/README.md b/cfn-resources/project-service-account-secret/docs/README.md new file mode 100644 index 000000000..6443b88db --- /dev/null +++ b/cfn-resources/project-service-account-secret/docs/README.md @@ -0,0 +1,109 @@ +# MongoDB::Atlas::ProjectServiceAccountSecret + +Creates a secret for the specified Service Account at the project level. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Type" : "MongoDB::Atlas::ProjectServiceAccountSecret",
+    "Properties" : {
+        "ProjectId" : String,
+        "ClientId" : String,
+        "SecretExpiresAfterHours" : Integer,
+        "Profile" : String
+    }
+}
+
+ +### YAML + +
+Type: MongoDB::Atlas::ProjectServiceAccountSecret
+Properties:
+    ProjectId: String
+    ClientId: String
+    SecretExpiresAfterHours: Integer
+    Profile: String
+
+ +## Properties + +#### ProjectId + +Unique 24-hexadecimal digit string that identifies your project. + +_Required_: Yes + +_Type_: String + +_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) + +#### ClientId + +The Client ID of the Service Account. + +_Required_: Yes + +_Type_: String + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### SecretExpiresAfterHours + +The expiration time of the new Service Account secret, provided in hours. The minimum and maximum allowed expiration times are subject to change and are controlled by the organization's settings. + +_Required_: No + +_Type_: Integer + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### Profile + +Profile used to provide credentials information, (a secret with the cfn/atlas/profile/{Profile}, is required), if not provided default is used. + +_Required_: No + +_Type_: String + +_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). + +#### SecretId + +Unique 24-hexadecimal digit string that identifies the secret. + +#### Secret + +The secret value for the Service Account. It will be returned only the first time after creation. + +#### MaskedSecretValue + +The masked Service Account secret. + +#### CreatedAt + +The date that the secret was created on. This parameter expresses its value in the ISO 8601 timestamp format in UTC. + +#### ExpiresAt + +The date for the expiration of the secret. This parameter expresses its value in the ISO 8601 timestamp format in UTC. + +#### LastUsedAt + +The last time the secret was used. This parameter expresses its value in the ISO 8601 timestamp format in UTC. + diff --git a/cfn-resources/project-service-account-secret/mongodb-atlas-projectserviceaccountsecret.json b/cfn-resources/project-service-account-secret/mongodb-atlas-projectserviceaccountsecret.json new file mode 100644 index 000000000..bb1f78cce --- /dev/null +++ b/cfn-resources/project-service-account-secret/mongodb-atlas-projectserviceaccountsecret.json @@ -0,0 +1,91 @@ +{ + "typeName": "MongoDB::Atlas::ProjectServiceAccountSecret", + "description": "Creates a secret for the specified Service Account at the project level.", + "sourceUrl": "https://github.com/mongodb/mongodbatlas-cloudformation-resources/tree/master/cfn-resources/project-service-account-secret", + "definitions": {}, + "properties": { + "ProjectId": { + "description": "Unique 24-hexadecimal digit string that identifies your project.", + "type": "string", + "pattern": "^([a-f0-9]{24})$" + }, + "ClientId": { + "description": "The Client ID of the Service Account.", + "type": "string" + }, + "SecretExpiresAfterHours": { + "description": "The expiration time of the new Service Account secret, provided in hours. The minimum and maximum allowed expiration times are subject to change and are controlled by the organization's settings.", + "type": "integer", + "minimum": 1 + }, + "SecretId": { + "description": "Unique 24-hexadecimal digit string that identifies the secret.", + "type": "string", + "pattern": "^([a-f0-9]{24})$" + }, + "Secret": { + "description": "The secret value for the Service Account. It will be returned only the first time after creation.", + "type": "string" + }, + "MaskedSecretValue": { + "description": "The masked Service Account secret.", + "type": "string" + }, + "CreatedAt": { + "description": "The date that the secret was created on. This parameter expresses its value in the ISO 8601 timestamp format in UTC.", + "type": "string" + }, + "ExpiresAt": { + "description": "The date for the expiration of the secret. This parameter expresses its value in the ISO 8601 timestamp format in UTC.", + "type": "string" + }, + "LastUsedAt": { + "description": "The last time the secret was used. This parameter expresses its value in the ISO 8601 timestamp format in UTC.", + "type": "string" + }, + "Profile": { + "description": "Profile used to provide credentials information, (a secret with the cfn/atlas/profile/{Profile}, is required), if not provided default is used.", + "type": "string", + "default": "default" + } + }, + "additionalProperties": false, + "required": ["ProjectId", "ClientId"], + "createOnlyProperties": [ + "/properties/ProjectId", + "/properties/ClientId", + "/properties/SecretExpiresAfterHours", + "/properties/Profile" + ], + "readOnlyProperties": [ + "/properties/SecretId", + "/properties/Secret", + "/properties/MaskedSecretValue", + "/properties/CreatedAt", + "/properties/ExpiresAt", + "/properties/LastUsedAt" + ], + "primaryIdentifier": [ + "/properties/ProjectId", + "/properties/ClientId", + "/properties/SecretId" + ], + "handlers": { + "create": { + "permissions": ["secretsmanager:GetSecretValue"] + }, + "read": { + "permissions": ["secretsmanager:GetSecretValue"] + }, + "delete": { + "permissions": ["secretsmanager:GetSecretValue"] + }, + "list": { + "permissions": ["secretsmanager:GetSecretValue"] + } + }, + "documentationUrl": "https://github.com/mongodb/mongodbatlas-cloudformation-resources/blob/master/cfn-resources/project-service-account-secret/README.md", + "tagging": { + "taggable": false + } +} diff --git a/cfn-resources/project-service-account-secret/resource-role.yaml b/cfn-resources/project-service-account-secret/resource-role.yaml new file mode 100644 index 000000000..ec8ef0b1f --- /dev/null +++ b/cfn-resources/project-service-account-secret/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-ProjectServiceAccountSecret/* + 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/project-service-account-secret/template.yml b/cfn-resources/project-service-account-secret/template.yml new file mode 100644 index 000000000..b141baffd --- /dev/null +++ b/cfn-resources/project-service-account-secret/template.yml @@ -0,0 +1,26 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the MongoDB::Atlas::ProjectServiceAccountSecret resource type + +Globals: + Function: + Timeout: 60 + +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 + LOG_LEVEL: debug diff --git a/cfn-resources/project-service-account-secret/test/cfn-test-create-inputs.sh b/cfn-resources/project-service-account-secret/test/cfn-test-create-inputs.sh new file mode 100755 index 000000000..ed6794577 --- /dev/null +++ b/cfn-resources/project-service-account-secret/test/cfn-test-create-inputs.sh @@ -0,0 +1,77 @@ +#!/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 " + echo "Generates test input files for project service account secret" +} + +if [ "$#" -ne 1 ]; then usage; fi +if [[ "$*" == help ]]; then usage; fi + +rm -rf inputs +mkdir inputs + +#set profile - relevant for contract tests which define a custom profile +profile="default" +if [ ${MONGODB_ATLAS_PROFILE+x} ]; then + echo "profile set to ${MONGODB_ATLAS_PROFILE}" + profile=${MONGODB_ATLAS_PROFILE} +fi + +projectName="${1}" +projectId=$(atlas projects list --output json | jq --arg NAME "${projectName}" -r '.results[] | select(.name==$NAME) | .id') +if [ -z "$projectId" ]; then + projectId=$(atlas projects create "${projectName}" --output=json | jq -r '.id') + echo -e "Created project \"${projectName}\" with id: ${projectId}\n" +else + echo -e "FOUND project \"${projectName}\" with id: ${projectId}\n" +fi + +echo "ProjectId: $projectId" + +serviceAccountName="cfn-test-project-sa-$(date +%s)-$RANDOM" +echo "Creating project service account: $serviceAccountName" + +serviceAccountJson=$(cat <"../inputs/$outputFile" +done +cd .. +ls -l inputs diff --git a/cfn-resources/project-service-account-secret/test/cfn-test-delete-inputs.sh b/cfn-resources/project-service-account-secret/test/cfn-test-delete-inputs.sh new file mode 100755 index 000000000..ebe6a497b --- /dev/null +++ b/cfn-resources/project-service-account-secret/test/cfn-test-delete-inputs.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# cfn-test-delete-inputs.sh +# +# Cleans up the project service account created for testing +# + +set -o errexit +set -o nounset +set -o pipefail + +if [ ! -f ./inputs/inputs_1_create.json ]; then + echo "No inputs file found, nothing to clean up" + exit 0 +fi + +projectId=$(jq -r '.ProjectId' ./inputs/inputs_1_create.json) +clientId=$(jq -r '.ClientId' ./inputs/inputs_1_create.json) + +if atlas api serviceAccounts deleteGroupServiceAccount --groupId "$projectId" --clientId "$clientId" --version "2024-08-05" --output json 2>/dev/null; then + echo "Project service account $clientId deletion OK" +else + exitCode=$? + if [ $exitCode -eq 0 ]; then + echo "Project service account $clientId deletion OK" + else + echo "Warning: Failed cleaning project service account: $clientId (may already be deleted)" + fi +fi diff --git a/cfn-resources/project-service-account-secret/test/contract-testing/cfn-test-create.sh b/cfn-resources/project-service-account-secret/test/contract-testing/cfn-test-create.sh new file mode 100755 index 000000000..10151d0d3 --- /dev/null +++ b/cfn-resources/project-service-account-secret/test/contract-testing/cfn-test-create.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# This tool generates the resources and json files in the inputs/ for `cfn test`. +set -o errexit +set -o nounset +set -o pipefail + +projectName="cfn-test-bot-$(date +%s)-$RANDOM" + +echo "projectName: $projectName" + +./test/cfn-test-create-inputs.sh "$projectName" diff --git a/cfn-resources/project-service-account-secret/test/contract-testing/cfn-test-delete.sh b/cfn-resources/project-service-account-secret/test/contract-testing/cfn-test-delete.sh new file mode 100755 index 000000000..2fabd96d1 --- /dev/null +++ b/cfn-resources/project-service-account-secret/test/contract-testing/cfn-test-delete.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +# This tool cleans up resources created for `cfn test`. +set -o errexit +set -o nounset +set -o pipefail + +./test/cfn-test-delete-inputs.sh diff --git a/cfn-resources/project-service-account-secret/test/inputs_1_create.template.json b/cfn-resources/project-service-account-secret/test/inputs_1_create.template.json new file mode 100644 index 000000000..e7e6ae5ff --- /dev/null +++ b/cfn-resources/project-service-account-secret/test/inputs_1_create.template.json @@ -0,0 +1,6 @@ +{ + "ProjectId": "", + "ClientId": "", + "Profile": "default", + "SecretExpiresAfterHours": "2160" +} diff --git a/cfn-resources/project-service-account-secret/test/project-service-account-secret.sample-cfn-request.json b/cfn-resources/project-service-account-secret/test/project-service-account-secret.sample-cfn-request.json new file mode 100644 index 000000000..de50708f0 --- /dev/null +++ b/cfn-resources/project-service-account-secret/test/project-service-account-secret.sample-cfn-request.json @@ -0,0 +1,10 @@ +{ + "desiredResourceState": { + "ProjectId": "", + "ClientId": "", + "Profile": "default", + "SecretExpiresAfterHours": 2160 + }, + "providerLogGroupName": "mongodb-atlas-projectserviceaccountsecret-logs", + "previousResourceState": {} +} diff --git a/cfn-resources/util/constants/constants.go b/cfn-resources/util/constants/constants.go index 0e5aca05b..b46929186 100644 --- a/cfn-resources/util/constants/constants.go +++ b/cfn-resources/util/constants/constants.go @@ -170,4 +170,7 @@ const ( AuthorizedEmail = "AuthorizedEmail" AuthorizedUserFirstName = "AuthorizedUserFirstName" AuthorizedUserLastName = "AuthorizedUserLastName" + + ClientID = "ClientId" + SecretID = "SecretId" ) diff --git a/examples/project-service-account-secret/README.md b/examples/project-service-account-secret/README.md new file mode 100644 index 000000000..3aa2b2048 --- /dev/null +++ b/examples/project-service-account-secret/README.md @@ -0,0 +1,28 @@ +# How to create a MongoDB::Atlas::ProjectServiceAccountSecret + +## Step 1: Activate the 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::ProjectServiceAccountSecret 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 ProjectServiceAccountSecret Resource is ready to use. + +## Step 2: Create template using [project-service-account-secret.json](project-service-account-secret.json) + + Note: Make sure you are providing appropriate values for: + 1. ProjectId + 2. ClientId (of an existing Project Service Account) + 3. SecretExpiresAfterHours (optional) + 4. Profile (optional) + +## Important Notes + +- **Existing Service Account Required**: You must have an existing Project Service Account. Create one using the MongoDB Atlas UI or API. +- **Secret Value**: The actual secret value is only returned during creation and is not available in subsequent reads. +- **No Updates**: This resource does not support updates. Any property change will trigger a replacement (delete + create). diff --git a/examples/project-service-account-secret/project-service-account-secret.json b/examples/project-service-account-secret/project-service-account-secret.json new file mode 100644 index 000000000..40cad71ed --- /dev/null +++ b/examples/project-service-account-secret/project-service-account-secret.json @@ -0,0 +1,72 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "This template creates a Project Service Account Secret for an existing MongoDB Atlas Project Service Account. Secrets provide credentials for programmatic access.", + "Parameters": { + "ProjectId": { + "Type": "String", + "Description": "Unique 24-hexadecimal digit string that identifies the project.", + "MinLength": 24, + "MaxLength": 24 + }, + "ClientId": { + "Type": "String", + "Description": "The Client ID of an existing Project Service Account. Get this from the MongoDB Atlas UI or from a previously created Project Service Account." + }, + "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": 2160, + "MinValue": 1 + }, + "Profile": { + "Type": "String", + "Description": "Secret Manager Profile that contains the Atlas Programmatic keys", + "Default": "default" + } + }, + "Resources": { + "ProjectServiceAccountSecret": { + "Type": "MongoDB::Atlas::ProjectServiceAccountSecret", + "Properties": { + "ProjectId": { + "Ref": "ProjectId" + }, + "ClientId": { + "Ref": "ClientId" + }, + "SecretExpiresAfterHours": { + "Ref": "SecretExpiresAfterHours" + }, + "Profile": { + "Ref": "Profile" + } + } + } + }, + "Outputs": { + "SecretId": { + "Description": "The ID of the created secret", + "Value": { + "Fn::GetAtt": ["ProjectServiceAccountSecret", "SecretId"] + } + }, + "MaskedSecretValue": { + "Description": "The masked secret value", + "Value": { + "Fn::GetAtt": ["ProjectServiceAccountSecret", "MaskedSecretValue"] + } + }, + "SecretCreatedAt": { + "Description": "Date and time that the secret was created", + "Value": { + "Fn::GetAtt": ["ProjectServiceAccountSecret", "CreatedAt"] + } + }, + "SecretExpiresAt": { + "Description": "Date and time that the secret expires", + "Value": { + "Fn::GetAtt": ["ProjectServiceAccountSecret", "ExpiresAt"] + } + } + } +}