Skip to content

Commit

Permalink
Add cdk and workflow for deploying Secrets Manager secrets (#688)
Browse files Browse the repository at this point in the history
  • Loading branch information
kyhau authored Oct 2, 2024
1 parent 0a3f44e commit c5841b4
Show file tree
Hide file tree
Showing 9 changed files with 373 additions and 0 deletions.
50 changes: 50 additions & 0 deletions .github/workflows/secretsmanager-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: SecretsManager - Build
run-name: Test IaC @ ${{ github.ref_name }}

on:
push:
paths:
- .github/workflows/secretsmanager-build.yml
- SecretsManager/cdk/**

concurrency:
cancel-in-progress: true
group: ${{ github.workflow }}

defaults:
run:
shell: bash

jobs:
redis:
name: Test Secrets Manager IaC
runs-on: ubuntu-latest
defaults:
run:
working-directory: SecretsManager/cdk/secrets
env:
ENV_STAGE: dev
steps:
- uses: actions/checkout@v4

- run: make lint-python

- uses: actions/setup-node@v4
with:
node-version: 22

- name: Set up aws-cdk
run: make install-cdk

- name: Print deployment environment
run: |
echo "INFO: cdk version: $(cdk --version)"
echo "INFO: node version: $(node --version)"
echo "INFO: npm version: $(npm --version)"
echo "INFO: python3 version: $(python3 --version)"
- name: Run cdk synth
env:
SECRET_1: Test1
SECRET_2: Test2
run: make synth-local
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
All notable changes to this project will be documented in this file.


## 2024-10-02

### Added
* Added [SecretsManager/cdk/secrets/](SecretsManager/cdk/secrets/) cdk and workflow for deploying Secrets Manager secrets.

## 2024-10-02

### Added
Expand Down
44 changes: 44 additions & 0 deletions SecretsManager/cdk/secrets/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export AWS_DEFAULT_REGION ?= ap-southeast-2
export CDK_DEFAULT_REGION ?= ap-southeast-2
export ENV_STAGE ?= dev

APP_NAME=$(shell grep -m 1 AppName environment/$(ENV_STAGE).yml | cut -c 10-)

install-cdk:
npm install -g aws-cdk
python3 -m pip install -U pip
pip3 install -r requirements.txt

synth:
cdk synth $(APP_NAME)-Secrets -c env=$(ENV_STAGE)

synth-local:
CDK_LOCAL_SYNC=true cdk synth $(APP_NAME)-Secrets -c env=$(ENV_STAGE)

diff:
cdk diff $(APP_NAME)-Secrets -c env=$(ENV_STAGE)

deploy:
cdk deploy $(APP_NAME)-Secrets -c env=$(ENV_STAGE) $(APP_NAME) --require-approval never

destroy:
cdk destroy $(APP_NAME)-Secrets -f -c env=$(ENV_STAGE)

test-cdk:
pip3 install -r requirements-dev.txt && \
python3 -m pytest .

pre-commit: format-python lint-python lint-yaml

format-python:
black --line-length=100 **.py */**.py

lint-python:
pip3 install flake8
flake8 --ignore E501,F541,W503,W605 **.py */**.py

lint-yaml:
yamllint -c .github/linters/.yaml-lint.yml -f parsable .

clean:
rm -rf cdk.out lib/__pycache__
69 changes: 69 additions & 0 deletions SecretsManager/cdk/secrets/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
from os import environ
from os.path import dirname, join, realpath

import yaml
from aws_cdk import App, CliCredentialsStackSynthesizer, Environment, Tags
from lib.secrets import SecretsStack

ENV_DIR = join(dirname(realpath(__file__)), "environment")


def retrieve_secrets_from_env(secrets: dict) -> dict:
for secret_name, item in secrets.items():
if environ.get(secret_name) is None:
raise ValueError(f"Missing secret {secret_name} in environment")
secrets[secret_name]["value"] = environ[secret_name]
return secrets


def main():
app = App()

ENV_NAME = app.node.try_get_context("env") or "dev"

with open(join(ENV_DIR, f"{ENV_NAME}.yml"), "r") as stream:
yaml_data = yaml.safe_load(stream)
config = yaml_data if yaml_data is not None else {}

app_name = config["AppName"]

secrets = retrieve_secrets_from_env(config["Secrets"]["env"])
is_single_object_secret = config["Secrets"].get("is-single-object-secret", False)

# TODO retrieve from other stack
key_user_arn_pattern_list = [
"arn:aws:iam::*:role/TODO*",
]

secrets_stack = None
if environ.get("SECRET_1"):
# Do not interact with this cdk stack if secret value is not provided (e.g., local env)
secrets_stack = SecretsStack(
scope=app,
id=f"{app_name}-Secrets",
app_name=app_name,
secrets=secrets,
is_single_object_secret=is_single_object_secret,
key_admin_arn_list=config["Kms"]["key-admin-arns"],
key_user_arn_pattern_list=key_user_arn_pattern_list,
env=Environment(account=config["Account"], region=config["Region"]),
description=f"{app_name} Secrets",
synthesizer=CliCredentialsStackSynthesizer(),
termination_protection=(ENV_NAME == "prd"),
)

# Add common tags
stacks = []
if secrets_stack:
stacks.append(secrets_stack)

for stack in stacks:
for key, value in config["Tags"].items():
Tags.of(stack).add(key, value)

app.synth()


if __name__ == "__main__":
main()
16 changes: 16 additions & 0 deletions SecretsManager/cdk/secrets/cdk.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"app": "python3 app.py",
"watch": {
"include": ["**"],
"exclude": [
"README.md",
"cdk*.json",
"requirements*.txt",
"**/__init__.py",
"**/__pycache__",
"tests"
]
},
"context": {
}
}
17 changes: 17 additions & 0 deletions SecretsManager/cdk/secrets/environment/dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
AppName: Test
Account: "123456789012"
Region: ap-southeast-2
Kms:
key-admin-arns:
- arn:aws:iam::123456789012:role/key-admin
- arn:aws:iam::123456789012:role/deploy-role
Secrets:
is-single-object-secret: false
env:
SECRET_1:
description: Test secret 1
SECRET_2:
description: Test secret 2
Tags:
CostCentre: TODO
Project: TODO
55 changes: 55 additions & 0 deletions SecretsManager/cdk/secrets/lib/kms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from aws_cdk import aws_iam as iam
from aws_cdk import aws_kms as kms
from constructs import Construct


def create_kms_key_and_alias(
scope: Construct,
id: str,
key_alias: str,
key_admin_arns: list,
key_user_arns: list,
enable_key_rotation: bool = False,
) -> kms.Key:
policy_document = iam.PolicyDocument(
statements=[
iam.PolicyStatement(
actions=["kms:*"],
principals=[iam.AccountRootPrincipal()],
resources=["*"],
sid="EnableIAMUserPermissions",
),
iam.PolicyStatement(
actions=[
"kms:Create*",
"kms:Describe*",
"kms:Enable*",
"kms:List*",
"kms:Put*",
"kms:Update*",
"kms:Revoke*",
"kms:Disable*",
"kms:Get*",
"kms:Delete*",
"kms:TagResource",
"kms:UntagResource",
"kms:ScheduleKeyDeletion",
"kms:CancelKeyDeletion",
],
principals=key_admin_arns,
resources=["*"],
sid="AllowAccessForKeyAdministrators",
),
iam.PolicyStatement(
actions=["kms:Decrypt"],
conditions={"ArnLike": {"aws:PrincipalArn": key_user_arns}},
principals=[iam.AnyPrincipal()],
resources=["*"],
sid="AllowDecrypt",
),
]
)

key = kms.Key(scope, id, enable_key_rotation=enable_key_rotation, policy=policy_document)

return kms.Alias(scope, f"{id}Alias", alias_name=key_alias, target_key=key)
115 changes: 115 additions & 0 deletions SecretsManager/cdk/secrets/lib/secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from aws_cdk import CfnOutput, SecretValue, Stack
from aws_cdk import aws_iam as iam
from aws_cdk import aws_secretsmanager as sm
from constructs import Construct
from lib.kms import create_kms_key_and_alias


class SecretsStack(Stack):
def __init__(
self,
scope: Construct,
id: str,
app_name: str,
secrets: dict,
is_single_object_secret: bool,
key_admin_arn_list: list,
key_user_arn_pattern_list: list,
**kwargs,
) -> None:
super().__init__(scope, id, **kwargs)

key_admin_arns = [
iam.Role.from_role_arn(self, arn_str.split("/")[-1], arn_str)
for arn_str in key_admin_arn_list
]

key_alias_name = f"alias/{app_name.lower()}"

key_alias = create_kms_key_and_alias(
self,
id="KmsKey",
key_alias=key_alias_name,
key_admin_arns=key_admin_arns,
key_user_arns=key_user_arn_pattern_list,
)

app_secrets = {}
if is_single_object_secret is True:
sm_secret_name = f"/apps/{app_name}/secrets".lower()
sm_secret = self.create_secret_with_object(
sm_secret_name=sm_secret_name,
description=f"Secrets of {app_name}",
secrets=secrets,
key_alias=key_alias,
)
app_secrets[app_name.replace("_", "-").lower()] = sm_secret
else:
for secret_name, item in secrets.items():
sm_secret_name = f"/apps/{app_name}/{secret_name}".lower()
sm_secret = self.create_secret_with_single_value(
sm_secret_name=sm_secret_name,
description=item.get("description", ""),
secret_value=item["value"],
key_alias=key_alias,
)
app_secrets[f"{app_name}-{secret_name}".replace("_", "-").lower()] = sm_secret

policy_statement = self.create_secrets_policy_statement(
key_user_arns_patterns=key_user_arn_pattern_list
)

for secret_name, sm_secret in app_secrets.items():
sm_secret.add_to_resource_policy(policy_statement)
sm_secret.node.add_dependency(key_alias)

CfnOutput(
scope=self,
id=f"{secret_name}-secret-arn",
value=sm_secret.secret_arn,
description=f"ARN of Secrets Manager secret {secret_name}",
export_name=f"{secret_name}-secret-arn",
)

CfnOutput(
scope=self,
id=f"{app_name}-secret-kms-alias-arn",
value=key_alias.alias_arn,
description="ARN of KMS key alias used to encrypt app secrets in Secrets Manager",
export_name=f"{app_name}-secret-kms-alias-arn",
)

def create_secret_with_object(
self, sm_secret_name: str, description: str, secrets: dict, key_alias: str
) -> sm.Secret:
return sm.Secret(
self,
id=sm_secret_name.replace("/", "-").lower(),
description=description,
encryption_key=key_alias,
secret_name=sm_secret_name,
secret_object_value={
secret["Name"]: SecretValue.unsafe_plain_text(secret["Value"]) for secret in secrets
},
)

def create_secret_with_single_value(
self, sm_secret_name: str, description: str, secret_value: str, key_alias: str
) -> sm.Secret:
return sm.Secret(
self,
id=sm_secret_name.replace("/", "-").lower(),
description=description,
encryption_key=key_alias,
secret_name=sm_secret_name,
secret_string_value=SecretValue.unsafe_plain_text(secret_value),
)

def create_secrets_policy_statement(self, key_user_arns_patterns: list) -> iam.PolicyStatement:
return iam.PolicyStatement(
actions=["secretsmanager:GetSecretValue"],
conditions={"ArnLike": {"aws:PrincipalArn": key_user_arns_patterns}},
effect=iam.Effect.ALLOW,
principals=[iam.AnyPrincipal()],
resources=["*"],
)
2 changes: 2 additions & 0 deletions SecretsManager/cdk/secrets/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
aws-cdk-lib==2.158.0
constructs==10.3.0

0 comments on commit c5841b4

Please sign in to comment.