From cb73bc60672d2b7e1530bc1b8b163c60ab358089 Mon Sep 17 00:00:00 2001 From: Eduardo Aguilar Date: Thu, 2 Nov 2023 13:23:23 -0600 Subject: [PATCH] feat: add secret creation flag and flow --- README.md | 3 + action.yml | 8 +- index.js | 1 + src/action/Action.js | 37 ++++++- src/secrets-manager/SecretsManager.js | 38 +++++++ tests/action/Action.test.js | 44 ++++++++ tests/secrets-manager/SecretsManager.test.js | 103 ++++++++++++------- 7 files changed, 194 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index cb95e08..ae8d884 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Github action that sync an AWS Secrets Manager and it's values from a provided j - `secretsmanager:UpdateSecret` - `secretsmanager:GetSecretValue` +- `secretsmanager:ListSecrets` +- `secretsmanager:CreateSecret` ### Configuration @@ -28,6 +30,7 @@ jobs: aws_region: secret_name: json_file_path: path/to/json/secrets.json + create_secret: false # If true it will check if the secret exists or not to create it before execute sync (default false) dry_run: true # Default false show_values: false # If true secret values will be displayed on action logs (default false) exclude: '^_' # Regular expression that excludes the matching keys to be synced (default '^_') diff --git a/action.yml b/action.yml index f6e75bf..483e2c3 100644 --- a/action.yml +++ b/action.yml @@ -21,6 +21,10 @@ inputs: json_file_path: description: "Path to the JSON file containing secret data" required: true + create_secret: + description: "Flag to create the given secret if not exist before execute sync" + required: false + default: "false" dry_run: description: "Dry run mode (preview changes without modifying the secret)" required: false @@ -33,10 +37,6 @@ inputs: description: "List of regular expressions that determines if a secret key should be excluded from sync" required: false -outputs: - changes: - description: "List of changes made to the secret" - runs: using: "docker" image: "Dockerfile" diff --git a/index.js b/index.js index 348abf8..68a5043 100644 --- a/index.js +++ b/index.js @@ -10,6 +10,7 @@ const getAction = () => { core.getInput("json_file_path"), core.getInput("exclude"), core.getBooleanInput("show_values"), + core.getBooleanInput("create_secret"), ); }; diff --git a/src/action/Action.js b/src/action/Action.js index f349f5e..23c5052 100644 --- a/src/action/Action.js +++ b/src/action/Action.js @@ -34,6 +34,12 @@ export default class Action { */ #smClient; + /** + * Flag to create the secret before sync if not exists + * @type {boolean} + */ + #createSecretFlag; + /** * Creates a new Action instance. * @@ -44,8 +50,9 @@ export default class Action { * @param {string} jsonFile The path to the JSON file containing the new secret values. * @param {string} skipPattern A regular expression that eval keys of the json file and if match, * that key should be omitted - * @param {string} showValues If this flag is set to true all secret values will be displayed on logs, + * @param {boolean} showValues If this flag is set to true all secret values will be displayed on logs, * if false, a place holder will be displayed. + * @param {boolean} createSecretFlag Flag to create the secret before sync if not exists * * @throws {Error} Throws an error if any required parameter is missing or if the JSON file doesn't exist. */ @@ -57,12 +64,14 @@ export default class Action { jsonFile, skipPattern, showValues = false, + createSecret = false, ) { this.#validateData(keyId, secretKey, region, secretName, jsonFile); this.#jsonFile = jsonFile; this.#skipPattern = skipPattern || defaultSkipPattern; this.#showValues = showValues; + this.#createSecretFlag = createSecret; this.#smClient = new SecretsManager(keyId, secretKey, region, secretName); } @@ -76,12 +85,23 @@ export default class Action { this.#smClient = client; } + /** + * Set the flag value after constructor. + * + * @param {boolean} flag value of the flag + */ + setCreateSecretFlag(flag) { + this.#createSecretFlag = flag; + } + /** * Runs the action to synchronize secrets by fetching existing secrets and creating a change set. * * @returns {Promise} A promise that resolves to a ChangeSet instance representing the changes to be applied. */ async run() { + await this.#createSecret(); + const existingSecretData = await this.#smClient.getValues(); const newSecretData = JSON.parse(fs.readFileSync(this.#jsonFile, "utf8")); @@ -94,6 +114,21 @@ export default class Action { ); } + /** + * Execute secret creation if needed + */ + async #createSecret() { + if (!this.#createSecretFlag) { + return; + } + + if (await this.#smClient.exists()) { + return; + } + + await this.#smClient.create(); + } + /** * Validates input data, ensuring that required parameters are provided and the JSON file exists. * diff --git a/src/secrets-manager/SecretsManager.js b/src/secrets-manager/SecretsManager.js index f5010c3..3700ca3 100644 --- a/src/secrets-manager/SecretsManager.js +++ b/src/secrets-manager/SecretsManager.js @@ -2,6 +2,8 @@ import { SecretsManagerClient, GetSecretValueCommand, UpdateSecretCommand, + CreateSecretCommand, + ListSecretsCommand, } from "@aws-sdk/client-secrets-manager"; import lodash from "lodash"; @@ -54,4 +56,40 @@ export default class SecretsManager { const updateSecretCommand = new UpdateSecretCommand(updateSecretParams); await this.client.send(updateSecretCommand); } + + /** + * Assert if the given secrets name exists. + * + * @returns {boolean} + */ + async exists() { + const listCommand = new ListSecretsCommand({ + Filters: [ + { + Key: "name", + Values: [this.secretName], + }, + ], + }); + + const res = await this.client.send(listCommand); + + if (res.SecretList.length > 0) { + return true; + } + + return false; + } + + /** + * Create the given secret name with a default value + */ + async create() { + const createCommand = new CreateSecretCommand({ + Name: this.secretName, + SecretString: JSON.stringify({ generated: true }), + }); + + await this.client.send(createCommand); + } } diff --git a/tests/action/Action.test.js b/tests/action/Action.test.js index 5a8f40e..fa10aee 100644 --- a/tests/action/Action.test.js +++ b/tests/action/Action.test.js @@ -132,4 +132,48 @@ describe("Action", () => { // Clean up the fs.readFileSync stub fs.readFileSync.restore(); }); + + it("should create secret if not exists with flag create_secret", async () => { + const existingSecretData = { generated: true }; + const newSecretData = { key1: "new-value1", key2: "new-value2" }; + + action.setCreateSecretFlag(true); + + secretsManagerStub.exists.resolves(false); + secretsManagerStub.create.resolves(); + secretsManagerStub.getValues.resolves(existingSecretData); + + sinon.stub(fs, "readFileSync").returns(JSON.stringify(newSecretData)); + + const changeSet = await action.run(); + + expect(changeSet).to.be.an.instanceOf(ChangeSet); + expect(secretsManagerStub.exists.calledOnce).to.be.true; + expect(secretsManagerStub.create.calledOnce).to.be.true; + expect(secretsManagerStub.getValues.calledOnce).to.be.true; + expect(fs.readFileSync.calledOnce).to.be.true; + + fs.readFileSync.restore(); + }); + + it("should omit secret creation if exists with flag create_secret", async () => { + const existingSecretData = { key1: "value1", key2: "value2" }; + const newSecretData = { key1: "new-value1", key2: "new-value2" }; + + action.setCreateSecretFlag(true); + + secretsManagerStub.exists.resolves(true); + secretsManagerStub.getValues.resolves(existingSecretData); + + sinon.stub(fs, "readFileSync").returns(JSON.stringify(newSecretData)); + + const changeSet = await action.run(); + + expect(changeSet).to.be.an.instanceOf(ChangeSet); + expect(secretsManagerStub.exists.calledOnce).to.be.true; + expect(secretsManagerStub.getValues.calledOnce).to.be.true; + expect(fs.readFileSync.calledOnce).to.be.true; + + fs.readFileSync.restore(); + }); }); diff --git a/tests/secrets-manager/SecretsManager.test.js b/tests/secrets-manager/SecretsManager.test.js index 6879a7a..9573a72 100644 --- a/tests/secrets-manager/SecretsManager.test.js +++ b/tests/secrets-manager/SecretsManager.test.js @@ -1,14 +1,22 @@ -// test/SecretsManager.test.js -import chai, { expect } from "chai"; +import { expect } from "chai"; import sinon from "sinon"; -import { - SecretsManagerClient, - GetSecretValueCommand, - UpdateSecretCommand, -} from "@aws-sdk/client-secrets-manager"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import SecretsManager from "../../src/secrets-manager/SecretsManager.js"; describe("SecretsManager", () => { + let secretsManagerClientStub; + + beforeEach(() => { + secretsManagerClientStub = sinon.stub( + SecretsManagerClient.prototype, + "send", + ); + }); + + afterEach(() => { + secretsManagerClientStub.restore(); + }); + describe("Constructor", () => { it("should create a SecretsManager instance", () => { const secretsManager = new SecretsManager( @@ -22,6 +30,59 @@ describe("SecretsManager", () => { }); }); + describe("exists", () => { + it("should return true", async () => { + const secretName = "my-secret"; + + secretsManagerClientStub.resolves({ SecretList: [{ Name: secretName }] }); + + const secretsManager = new SecretsManager( + "keyId", + "secretKey", + "region", + secretName, + ); + + let exists = await secretsManager.exists(); + + expect(exists).to.be.true; + }); + + it("should return false", async () => { + const secretName = "my-secret"; + + secretsManagerClientStub.resolves({ SecretList: [] }); + + const secretsManager = new SecretsManager( + "keyId", + "secretKey", + "region", + secretName, + ); + + let exists = await secretsManager.exists(); + + expect(exists).to.be.false; + }); + }); + + describe("create", () => { + it("should create secret", async () => { + const secretName = "my-secret"; + + secretsManagerClientStub.resolves({ Name: secretName }); + + const secretsManager = new SecretsManager( + "keyId", + "secretKey", + "region", + secretName, + ); + + await secretsManager.create(); + }); + }); + describe("getValues", () => { it("should fetch and parse secret values", async () => { const secretName = "my-secret"; @@ -33,11 +94,6 @@ describe("SecretsManager", () => { const expectedSecretString = JSON.stringify(secrets); - const secretsManagerClientStub = sinon.stub( - SecretsManagerClient.prototype, - "send", - ); - secretsManagerClientStub.resolves({ SecretString: expectedSecretString }); const secretsManager = new SecretsManager( @@ -50,18 +106,11 @@ describe("SecretsManager", () => { const secretValues = await secretsManager.getValues(); expect(secretValues).to.deep.equal(secrets); - - secretsManagerClientStub.restore(); }); it("should handle errors when fetching secret values", async () => { const secretName = "my-secret"; - const secretsManagerClientStub = sinon.stub( - SecretsManagerClient.prototype, - "send", - ); - secretsManagerClientStub.rejects(new Error("Failed to fetch secret")); const secretsManager = new SecretsManager( @@ -76,8 +125,6 @@ describe("SecretsManager", () => { } catch (error) { expect(error.message).to.equal("Failed to fetch secret"); } - - secretsManagerClientStub.restore(); }); }); @@ -87,11 +134,6 @@ describe("SecretsManager", () => { const newValues = { key1: "new-value1", key2: "new-value2" }; - const secretsManagerClientStub = sinon.stub( - SecretsManagerClient.prototype, - "send", - ); - secretsManagerClientStub.resolves({}); const secretsManager = new SecretsManager( @@ -104,8 +146,6 @@ describe("SecretsManager", () => { await secretsManager.update(newValues); expect(secretsManagerClientStub.calledOnce).to.be.true; - - secretsManagerClientStub.restore(); }); it("should handle errors when updating secret values", async () => { @@ -113,11 +153,6 @@ describe("SecretsManager", () => { const newValues = { key1: "new-value1", key2: "new-value2" }; - const secretsManagerClientStub = sinon.stub( - SecretsManagerClient.prototype, - "send", - ); - secretsManagerClientStub.rejects(new Error("Failed to update secret")); const secretsManager = new SecretsManager( @@ -132,8 +167,6 @@ describe("SecretsManager", () => { } catch (error) { expect(error.message).to.equal("Failed to update secret"); } - - secretsManagerClientStub.restore(); }); it("should throw an error for empty new values", async () => {