callsy-cdk-secure-parameter 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- callsy_cdk_secure_parameter-1.0.0/.gitignore +31 -0
- callsy_cdk_secure_parameter-1.0.0/CHANGELOG.md +18 -0
- callsy_cdk_secure_parameter-1.0.0/LICENSE +15 -0
- callsy_cdk_secure_parameter-1.0.0/PKG-INFO +196 -0
- callsy_cdk_secure_parameter-1.0.0/README.md +167 -0
- callsy_cdk_secure_parameter-1.0.0/pyproject.toml +85 -0
- callsy_cdk_secure_parameter-1.0.0/src/callsy_cdk/secure_parameter/__init__.py +17 -0
- callsy_cdk_secure_parameter-1.0.0/src/callsy_cdk/secure_parameter/py.typed +0 -0
- callsy_cdk_secure_parameter-1.0.0/src/callsy_cdk/secure_parameter/role.py +66 -0
- callsy_cdk_secure_parameter-1.0.0/src/callsy_cdk/secure_parameter/secure_parameter.py +133 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
### Python ###
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
|
|
14
|
+
### CDK ###
|
|
15
|
+
cdk.out/
|
|
16
|
+
.cdk.staging/
|
|
17
|
+
|
|
18
|
+
### Node ###
|
|
19
|
+
node_modules/
|
|
20
|
+
|
|
21
|
+
### JetBrains ###
|
|
22
|
+
.idea/
|
|
23
|
+
*.iml
|
|
24
|
+
|
|
25
|
+
### macOS ###
|
|
26
|
+
.DS_Store
|
|
27
|
+
._*
|
|
28
|
+
|
|
29
|
+
### Environment ###
|
|
30
|
+
.env
|
|
31
|
+
.env.*
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
This project adheres to [semantic versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## 1.0.0
|
|
7
|
+
|
|
8
|
+
Initial release. Extracted from the Callsy infrastructure repositories, where the construct
|
|
9
|
+
had already been running in production.
|
|
10
|
+
|
|
11
|
+
- `SecureParameter` — one encrypted SecureString parameter, written once with a placeholder
|
|
12
|
+
and never rewritten by a later deploy.
|
|
13
|
+
- One IAM role per stack, granted over the whole parameter prefix rather than per parameter.
|
|
14
|
+
- `SecureParameter.build_name` — the full name a parameter carries under a prefix.
|
|
15
|
+
- `SecureParameter.chain` — serialises a group of parameters so Parameter Store does not throttle.
|
|
16
|
+
- `SecureParameter.as_string_parameter` / `as_ecs_secret` — read the deployed value at runtime.
|
|
17
|
+
- New over the in-repo version: an explicit `prefix` instead of a repository local config
|
|
18
|
+
object, plus `tags`, `placeholder`, `key_id`, `tier` and `ignore_existing` options.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Laimonas Sutkus
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: callsy-cdk-secure-parameter
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: AWS CDK construct that creates an encrypted SSM parameter a deploy never overwrites.
|
|
5
|
+
Project-URL: Homepage, https://github.com/CallsyAI/callsy-cdk-secure-parameter
|
|
6
|
+
Project-URL: Repository, https://github.com/CallsyAI/callsy-cdk-secure-parameter
|
|
7
|
+
Project-URL: Changelog, https://github.com/CallsyAI/callsy-cdk-secure-parameter/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/CallsyAI/callsy-cdk-secure-parameter/issues
|
|
9
|
+
Author: Laimonas Sutkus
|
|
10
|
+
License-Expression: ISC
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: aws,cdk,cloudformation,custom-resource,parameter-store,secrets,securestring,ssm
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
23
|
+
Classifier: Topic :: System :: Systems Administration
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: aws-cdk-lib<3.0.0,>=2.180.0
|
|
27
|
+
Requires-Dist: constructs<11.0.0,>=10.0.0
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# callsy-cdk-secure-parameter
|
|
31
|
+
|
|
32
|
+
An AWS CDK construct that creates an encrypted SSM SecureString parameter, writes a placeholder into it once, and then never touches the value again. Populate it by hand, redeploy as often as you like, and the secret stays where you put it.
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- **Write-once semantics** — the parameter is created with a placeholder and has no update call. A later deploy can never rewrite a value you filled in by hand.
|
|
37
|
+
- **Secrets never touch your repository** — no value passes through the codebase, through git, or through a CloudFormation template.
|
|
38
|
+
- **Encrypted at rest** — every parameter is a `SecureString`, encrypted with the account default KMS key or one of your own.
|
|
39
|
+
- **One IAM role per stack** — a single role is shared by every parameter and granted over the whole prefix. A grant per parameter races IAM propagation; this one does not.
|
|
40
|
+
- **Safe deletes** — removing the stack removes the parameter, and a parameter already deleted by hand does not wedge the delete.
|
|
41
|
+
- **Throttle-aware** — `SecureParameter.chain()` serialises a group of parameters, because Parameter Store throttles a burst of concurrent writes.
|
|
42
|
+
- **Reads its own value back** — `as_string_parameter()` and `as_ecs_secret()` hand the deployed parameter to whatever consumes it, resolved at runtime rather than at synthesis.
|
|
43
|
+
- **No Lambda, no Docker, no bundling** — built on the CDK's own `AwsCustomResource` singleton.
|
|
44
|
+
- **Typed** — ships a `py.typed` marker, so mypy and your IDE see the full signature.
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install callsy-cdk-secure-parameter
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Requirements:** Python >= 3.10, `aws-cdk-lib` >= 2.180.0.
|
|
53
|
+
|
|
54
|
+
## Quick start
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from aws_cdk import Stack
|
|
58
|
+
from callsy_cdk.secure_parameter import SecureParameter
|
|
59
|
+
|
|
60
|
+
class SecretsStack(Stack):
|
|
61
|
+
def __init__(self, scope):
|
|
62
|
+
super().__init__(scope, "SecretsStack")
|
|
63
|
+
|
|
64
|
+
SecureParameter(
|
|
65
|
+
scope=self,
|
|
66
|
+
name="STRIPE_SECRET_KEY",
|
|
67
|
+
description="Stripe secret key used by the billing service.",
|
|
68
|
+
prefix="MyProject"
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Deploy, and you have `/MyProject/STRIPE_SECRET_KEY` in Parameter Store, encrypted, holding the placeholder `-`. Open the console (or run `aws ssm put-parameter --overwrite`) and set the real value once. Every deploy after that leaves it alone.
|
|
73
|
+
|
|
74
|
+
## How the value is managed
|
|
75
|
+
|
|
76
|
+
This is the whole point of the construct, so it is worth being explicit about what happens on each CloudFormation event.
|
|
77
|
+
|
|
78
|
+
| Event | What the construct does |
|
|
79
|
+
|---|---|
|
|
80
|
+
| **Create** | `ssm:PutParameter` with `Overwrite: false`, `Type: SecureString`, and the placeholder as the value. |
|
|
81
|
+
| **Update** | **Nothing.** No update call is registered, so a deploy can never overwrite the value you set by hand. |
|
|
82
|
+
| **Delete** | `ssm:DeleteParameter`. A `ParameterNotFound` error is tolerated, so a parameter you already removed does not block the delete. |
|
|
83
|
+
|
|
84
|
+
Two consequences worth knowing:
|
|
85
|
+
|
|
86
|
+
- **Changing `description` in code does nothing.** Because there is no update call, the description in Parameter Store keeps whatever it was given at create time. This is the deliberate cost of never rewriting the value.
|
|
87
|
+
- **A parameter that already exists fails the create.** `Overwrite: false` means `PutParameter` raises `ParameterAlreadyExists`, which is usually what you want — it stops a deploy from silently adopting a parameter it does not own. If a rolled-back stack has left one behind, pass `ignore_existing=True` to adopt it instead.
|
|
88
|
+
|
|
89
|
+
## Naming and the prefix
|
|
90
|
+
|
|
91
|
+
Every parameter is named `/<prefix>/<name>`:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
SecureParameter(scope=stack, name="STRIPE_SECRET_KEY", description="...", prefix="MyProject")
|
|
95
|
+
# -> /MyProject/STRIPE_SECRET_KEY
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The prefix is what the shared IAM role is granted over (`arn:aws:ssm:<region>:<account>:parameter/MyProject/*`), and it is the default value of the `Project` tag. Use one prefix per project and environment, e.g. `MyProjectProd` and `MyProjectDev`.
|
|
99
|
+
|
|
100
|
+
If you already carry a project prefix in your own config, a thin wrapper keeps every call site short:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
def project_parameter(scope: Stack, name: str, description: str) -> SecureParameter:
|
|
104
|
+
"""
|
|
105
|
+
Returns one secure parameter under this project's prefix.
|
|
106
|
+
"""
|
|
107
|
+
return SecureParameter(scope=scope, name=name, description=description, prefix=config.prefix)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Grouping parameters
|
|
111
|
+
|
|
112
|
+
Parameter Store throttles a burst of concurrent writes, and a stack with dozens of parameters will hit it. `chain()` makes each parameter depend on the one before it, so CloudFormation creates them one at a time.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from callsy_cdk.secure_parameter import SecureParameter
|
|
116
|
+
|
|
117
|
+
parameters = [
|
|
118
|
+
SecureParameter(scope=self, name="STRIPE_SECRET_KEY", description="...", prefix=prefix),
|
|
119
|
+
SecureParameter(scope=self, name="STRIPE_WEBHOOK_SECRET", description="...", prefix=prefix),
|
|
120
|
+
SecureParameter(scope=self, name="TWILIO_AUTH_TOKEN", description="...", prefix=prefix)
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
SecureParameter.chain(parameters)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Reading the value back
|
|
127
|
+
|
|
128
|
+
The deployed value is never resolved at synthesis. These helpers hand the parameter to a consumer that reads it at runtime.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
# As a CDK parameter, for anything that takes an IStringParameter.
|
|
132
|
+
parameter = secure_parameter.as_string_parameter(scope=self)
|
|
133
|
+
|
|
134
|
+
# As a container secret, injected as an environment variable when the task starts.
|
|
135
|
+
container.add_container(
|
|
136
|
+
"App",
|
|
137
|
+
secrets={"STRIPE_SECRET_KEY": secure_parameter.as_ecs_secret(scope=self)}
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Both helpers set `simple_name=False`, because the full name holds a slash and is therefore a path. Left undetected, the rendered ARN carries a doubled slash and matches nothing.
|
|
142
|
+
|
|
143
|
+
## API
|
|
144
|
+
|
|
145
|
+
### `SecureParameter(scope, name, description, *, prefix, ...)`
|
|
146
|
+
|
|
147
|
+
| Argument | Type | Default | Description |
|
|
148
|
+
|---|---|---|---|
|
|
149
|
+
| `scope` | `Stack` | — | The stack the parameter belongs to. The shared IAM role is built here. |
|
|
150
|
+
| `name` | `str` | — | The parameter name, appended to the prefix. A slash is allowed and is stripped from the construct id. |
|
|
151
|
+
| `description` | `str` | — | What the parameter holds. Set at create and never updated. |
|
|
152
|
+
| `prefix` | `str` | — | The project prefix. Keyword-only. |
|
|
153
|
+
| `tags` | `Mapping[str, str] \| None` | `{"Project": prefix}` | Tags applied to the parameter. |
|
|
154
|
+
| `placeholder` | `str` | `"-"` | The value written at create. |
|
|
155
|
+
| `key_id` | `str \| None` | `None` | KMS key id or alias. The account default key is used when omitted. |
|
|
156
|
+
| `tier` | `str \| None` | `None` | One of `Standard`, `Advanced` or `Intelligent-Tiering`. |
|
|
157
|
+
| `ignore_existing` | `bool` | `False` | Tolerate `ParameterAlreadyExists` on create, adopting a parameter left behind by a rolled-back stack. |
|
|
158
|
+
|
|
159
|
+
**Attributes**
|
|
160
|
+
|
|
161
|
+
| Attribute | Type | Description |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `parameter_name` | `str` | The full name, `/<prefix>/<name>`. |
|
|
164
|
+
| `prefix` | `str` | The prefix this parameter was built with. |
|
|
165
|
+
|
|
166
|
+
**Methods**
|
|
167
|
+
|
|
168
|
+
| Method | Signature | Description |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `build_name` | `build_name(prefix: str, name: str) -> str` | Static. The full name a parameter carries under a prefix, without building anything. |
|
|
171
|
+
| `chain` | `chain(parameters: Sequence[SecureParameter]) -> None` | Static. Makes each parameter wait for the one before it. |
|
|
172
|
+
| `as_string_parameter` | `as_string_parameter(scope, id=None) -> IStringParameter` | The deployed parameter, for anything taking an `IStringParameter`. |
|
|
173
|
+
| `as_ecs_secret` | `as_ecs_secret(scope, id=None) -> EcsSecret` | The deployed parameter, as a container secret. |
|
|
174
|
+
|
|
175
|
+
`SecureParameter` extends `AwsCustomResource`, so the whole construct API (`node`, `add_dependency`, and the rest) is available as usual.
|
|
176
|
+
|
|
177
|
+
### `get_role(scope, prefix) -> Role`
|
|
178
|
+
|
|
179
|
+
The IAM role every parameter of a stack shares, built on first use under the construct id `SecureParameterRole` and reused after that. Each new prefix widens its policy exactly once. Call it directly only if you need to grant the role something extra.
|
|
180
|
+
|
|
181
|
+
## IAM
|
|
182
|
+
|
|
183
|
+
The shared role is a Lambda execution role with `AWSLambdaBasicExecutionRole` and one statement per prefix:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
ssm:PutParameter
|
|
187
|
+
ssm:AddTagsToResource
|
|
188
|
+
ssm:DeleteParameter
|
|
189
|
+
on arn:aws:ssm:<region>:<account>:parameter/<prefix>/*
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
It holds no `ssm:GetParameter`, so the custom resource can create and delete a parameter but can never read one back.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
ISC
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# callsy-cdk-secure-parameter
|
|
2
|
+
|
|
3
|
+
An AWS CDK construct that creates an encrypted SSM SecureString parameter, writes a placeholder into it once, and then never touches the value again. Populate it by hand, redeploy as often as you like, and the secret stays where you put it.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Write-once semantics** — the parameter is created with a placeholder and has no update call. A later deploy can never rewrite a value you filled in by hand.
|
|
8
|
+
- **Secrets never touch your repository** — no value passes through the codebase, through git, or through a CloudFormation template.
|
|
9
|
+
- **Encrypted at rest** — every parameter is a `SecureString`, encrypted with the account default KMS key or one of your own.
|
|
10
|
+
- **One IAM role per stack** — a single role is shared by every parameter and granted over the whole prefix. A grant per parameter races IAM propagation; this one does not.
|
|
11
|
+
- **Safe deletes** — removing the stack removes the parameter, and a parameter already deleted by hand does not wedge the delete.
|
|
12
|
+
- **Throttle-aware** — `SecureParameter.chain()` serialises a group of parameters, because Parameter Store throttles a burst of concurrent writes.
|
|
13
|
+
- **Reads its own value back** — `as_string_parameter()` and `as_ecs_secret()` hand the deployed parameter to whatever consumes it, resolved at runtime rather than at synthesis.
|
|
14
|
+
- **No Lambda, no Docker, no bundling** — built on the CDK's own `AwsCustomResource` singleton.
|
|
15
|
+
- **Typed** — ships a `py.typed` marker, so mypy and your IDE see the full signature.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install callsy-cdk-secure-parameter
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Requirements:** Python >= 3.10, `aws-cdk-lib` >= 2.180.0.
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from aws_cdk import Stack
|
|
29
|
+
from callsy_cdk.secure_parameter import SecureParameter
|
|
30
|
+
|
|
31
|
+
class SecretsStack(Stack):
|
|
32
|
+
def __init__(self, scope):
|
|
33
|
+
super().__init__(scope, "SecretsStack")
|
|
34
|
+
|
|
35
|
+
SecureParameter(
|
|
36
|
+
scope=self,
|
|
37
|
+
name="STRIPE_SECRET_KEY",
|
|
38
|
+
description="Stripe secret key used by the billing service.",
|
|
39
|
+
prefix="MyProject"
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Deploy, and you have `/MyProject/STRIPE_SECRET_KEY` in Parameter Store, encrypted, holding the placeholder `-`. Open the console (or run `aws ssm put-parameter --overwrite`) and set the real value once. Every deploy after that leaves it alone.
|
|
44
|
+
|
|
45
|
+
## How the value is managed
|
|
46
|
+
|
|
47
|
+
This is the whole point of the construct, so it is worth being explicit about what happens on each CloudFormation event.
|
|
48
|
+
|
|
49
|
+
| Event | What the construct does |
|
|
50
|
+
|---|---|
|
|
51
|
+
| **Create** | `ssm:PutParameter` with `Overwrite: false`, `Type: SecureString`, and the placeholder as the value. |
|
|
52
|
+
| **Update** | **Nothing.** No update call is registered, so a deploy can never overwrite the value you set by hand. |
|
|
53
|
+
| **Delete** | `ssm:DeleteParameter`. A `ParameterNotFound` error is tolerated, so a parameter you already removed does not block the delete. |
|
|
54
|
+
|
|
55
|
+
Two consequences worth knowing:
|
|
56
|
+
|
|
57
|
+
- **Changing `description` in code does nothing.** Because there is no update call, the description in Parameter Store keeps whatever it was given at create time. This is the deliberate cost of never rewriting the value.
|
|
58
|
+
- **A parameter that already exists fails the create.** `Overwrite: false` means `PutParameter` raises `ParameterAlreadyExists`, which is usually what you want — it stops a deploy from silently adopting a parameter it does not own. If a rolled-back stack has left one behind, pass `ignore_existing=True` to adopt it instead.
|
|
59
|
+
|
|
60
|
+
## Naming and the prefix
|
|
61
|
+
|
|
62
|
+
Every parameter is named `/<prefix>/<name>`:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
SecureParameter(scope=stack, name="STRIPE_SECRET_KEY", description="...", prefix="MyProject")
|
|
66
|
+
# -> /MyProject/STRIPE_SECRET_KEY
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The prefix is what the shared IAM role is granted over (`arn:aws:ssm:<region>:<account>:parameter/MyProject/*`), and it is the default value of the `Project` tag. Use one prefix per project and environment, e.g. `MyProjectProd` and `MyProjectDev`.
|
|
70
|
+
|
|
71
|
+
If you already carry a project prefix in your own config, a thin wrapper keeps every call site short:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
def project_parameter(scope: Stack, name: str, description: str) -> SecureParameter:
|
|
75
|
+
"""
|
|
76
|
+
Returns one secure parameter under this project's prefix.
|
|
77
|
+
"""
|
|
78
|
+
return SecureParameter(scope=scope, name=name, description=description, prefix=config.prefix)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Grouping parameters
|
|
82
|
+
|
|
83
|
+
Parameter Store throttles a burst of concurrent writes, and a stack with dozens of parameters will hit it. `chain()` makes each parameter depend on the one before it, so CloudFormation creates them one at a time.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from callsy_cdk.secure_parameter import SecureParameter
|
|
87
|
+
|
|
88
|
+
parameters = [
|
|
89
|
+
SecureParameter(scope=self, name="STRIPE_SECRET_KEY", description="...", prefix=prefix),
|
|
90
|
+
SecureParameter(scope=self, name="STRIPE_WEBHOOK_SECRET", description="...", prefix=prefix),
|
|
91
|
+
SecureParameter(scope=self, name="TWILIO_AUTH_TOKEN", description="...", prefix=prefix)
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
SecureParameter.chain(parameters)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Reading the value back
|
|
98
|
+
|
|
99
|
+
The deployed value is never resolved at synthesis. These helpers hand the parameter to a consumer that reads it at runtime.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# As a CDK parameter, for anything that takes an IStringParameter.
|
|
103
|
+
parameter = secure_parameter.as_string_parameter(scope=self)
|
|
104
|
+
|
|
105
|
+
# As a container secret, injected as an environment variable when the task starts.
|
|
106
|
+
container.add_container(
|
|
107
|
+
"App",
|
|
108
|
+
secrets={"STRIPE_SECRET_KEY": secure_parameter.as_ecs_secret(scope=self)}
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Both helpers set `simple_name=False`, because the full name holds a slash and is therefore a path. Left undetected, the rendered ARN carries a doubled slash and matches nothing.
|
|
113
|
+
|
|
114
|
+
## API
|
|
115
|
+
|
|
116
|
+
### `SecureParameter(scope, name, description, *, prefix, ...)`
|
|
117
|
+
|
|
118
|
+
| Argument | Type | Default | Description |
|
|
119
|
+
|---|---|---|---|
|
|
120
|
+
| `scope` | `Stack` | — | The stack the parameter belongs to. The shared IAM role is built here. |
|
|
121
|
+
| `name` | `str` | — | The parameter name, appended to the prefix. A slash is allowed and is stripped from the construct id. |
|
|
122
|
+
| `description` | `str` | — | What the parameter holds. Set at create and never updated. |
|
|
123
|
+
| `prefix` | `str` | — | The project prefix. Keyword-only. |
|
|
124
|
+
| `tags` | `Mapping[str, str] \| None` | `{"Project": prefix}` | Tags applied to the parameter. |
|
|
125
|
+
| `placeholder` | `str` | `"-"` | The value written at create. |
|
|
126
|
+
| `key_id` | `str \| None` | `None` | KMS key id or alias. The account default key is used when omitted. |
|
|
127
|
+
| `tier` | `str \| None` | `None` | One of `Standard`, `Advanced` or `Intelligent-Tiering`. |
|
|
128
|
+
| `ignore_existing` | `bool` | `False` | Tolerate `ParameterAlreadyExists` on create, adopting a parameter left behind by a rolled-back stack. |
|
|
129
|
+
|
|
130
|
+
**Attributes**
|
|
131
|
+
|
|
132
|
+
| Attribute | Type | Description |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| `parameter_name` | `str` | The full name, `/<prefix>/<name>`. |
|
|
135
|
+
| `prefix` | `str` | The prefix this parameter was built with. |
|
|
136
|
+
|
|
137
|
+
**Methods**
|
|
138
|
+
|
|
139
|
+
| Method | Signature | Description |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `build_name` | `build_name(prefix: str, name: str) -> str` | Static. The full name a parameter carries under a prefix, without building anything. |
|
|
142
|
+
| `chain` | `chain(parameters: Sequence[SecureParameter]) -> None` | Static. Makes each parameter wait for the one before it. |
|
|
143
|
+
| `as_string_parameter` | `as_string_parameter(scope, id=None) -> IStringParameter` | The deployed parameter, for anything taking an `IStringParameter`. |
|
|
144
|
+
| `as_ecs_secret` | `as_ecs_secret(scope, id=None) -> EcsSecret` | The deployed parameter, as a container secret. |
|
|
145
|
+
|
|
146
|
+
`SecureParameter` extends `AwsCustomResource`, so the whole construct API (`node`, `add_dependency`, and the rest) is available as usual.
|
|
147
|
+
|
|
148
|
+
### `get_role(scope, prefix) -> Role`
|
|
149
|
+
|
|
150
|
+
The IAM role every parameter of a stack shares, built on first use under the construct id `SecureParameterRole` and reused after that. Each new prefix widens its policy exactly once. Call it directly only if you need to grant the role something extra.
|
|
151
|
+
|
|
152
|
+
## IAM
|
|
153
|
+
|
|
154
|
+
The shared role is a Lambda execution role with `AWSLambdaBasicExecutionRole` and one statement per prefix:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
ssm:PutParameter
|
|
158
|
+
ssm:AddTagsToResource
|
|
159
|
+
ssm:DeleteParameter
|
|
160
|
+
on arn:aws:ssm:<region>:<account>:parameter/<prefix>/*
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
It holds no `ssm:GetParameter`, so the custom resource can create and delete a parameter but can never read one back.
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
ISC
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "callsy-cdk-secure-parameter"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "AWS CDK construct that creates an encrypted SSM parameter a deploy never overwrites."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "ISC"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Laimonas Sutkus" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"aws",
|
|
16
|
+
"cdk",
|
|
17
|
+
"cloudformation",
|
|
18
|
+
"ssm",
|
|
19
|
+
"parameter-store",
|
|
20
|
+
"securestring",
|
|
21
|
+
"custom-resource",
|
|
22
|
+
"secrets",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 5 - Production/Stable",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: 3.14",
|
|
34
|
+
"Topic :: Software Development :: Build Tools",
|
|
35
|
+
"Topic :: System :: Systems Administration",
|
|
36
|
+
"Typing :: Typed",
|
|
37
|
+
]
|
|
38
|
+
dependencies = [
|
|
39
|
+
"aws-cdk-lib>=2.180.0,<3.0.0",
|
|
40
|
+
"constructs>=10.0.0,<11.0.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/CallsyAI/callsy-cdk-secure-parameter"
|
|
45
|
+
Repository = "https://github.com/CallsyAI/callsy-cdk-secure-parameter"
|
|
46
|
+
Changelog = "https://github.com/CallsyAI/callsy-cdk-secure-parameter/blob/main/CHANGELOG.md"
|
|
47
|
+
Issues = "https://github.com/CallsyAI/callsy-cdk-secure-parameter/issues"
|
|
48
|
+
|
|
49
|
+
[dependency-groups]
|
|
50
|
+
dev = [
|
|
51
|
+
"mypy>=1.18",
|
|
52
|
+
"ruff>=0.14",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/callsy_cdk"]
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.sdist]
|
|
59
|
+
include = [
|
|
60
|
+
"src",
|
|
61
|
+
"README.md",
|
|
62
|
+
"CHANGELOG.md",
|
|
63
|
+
"LICENSE",
|
|
64
|
+
"pyproject.toml",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.ruff]
|
|
68
|
+
line-length = 120
|
|
69
|
+
src = ["src"]
|
|
70
|
+
target-version = "py310"
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint]
|
|
73
|
+
select = ["E", "F", "I", "N", "UP", "B", "C4", "SIM", "RUF"]
|
|
74
|
+
ignore = [
|
|
75
|
+
# A single line `if ...: return` is the established style of this codebase.
|
|
76
|
+
"E701",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[tool.mypy]
|
|
80
|
+
python_version = "3.10"
|
|
81
|
+
strict = true
|
|
82
|
+
files = ["src"]
|
|
83
|
+
mypy_path = "src"
|
|
84
|
+
namespace_packages = true
|
|
85
|
+
explicit_package_bases = true
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from callsy_cdk.secure_parameter.role import ROLE_ACTIONS, ROLE_ID, get_role
|
|
2
|
+
from callsy_cdk.secure_parameter.secure_parameter import (
|
|
3
|
+
PARAMETER_ALREADY_EXISTS,
|
|
4
|
+
PARAMETER_NOT_FOUND,
|
|
5
|
+
PLACEHOLDER,
|
|
6
|
+
SecureParameter,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"PARAMETER_ALREADY_EXISTS",
|
|
11
|
+
"PARAMETER_NOT_FOUND",
|
|
12
|
+
"PLACEHOLDER",
|
|
13
|
+
"ROLE_ACTIONS",
|
|
14
|
+
"ROLE_ID",
|
|
15
|
+
"SecureParameter",
|
|
16
|
+
"get_role",
|
|
17
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import cast
|
|
3
|
+
|
|
4
|
+
from aws_cdk import Stack
|
|
5
|
+
from aws_cdk.aws_iam import ManagedPolicy, PolicyStatement, Role, ServicePrincipal
|
|
6
|
+
from constructs import Construct
|
|
7
|
+
|
|
8
|
+
# Construct id of the role that every secure parameter of a stack shares.
|
|
9
|
+
ROLE_ID = "SecureParameterRole"
|
|
10
|
+
|
|
11
|
+
# Actions the role needs to create, tag and remove one parameter.
|
|
12
|
+
ROLE_ACTIONS = ["ssm:PutParameter", "ssm:AddTagsToResource", "ssm:DeleteParameter"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_role(scope: Stack, prefix: str) -> Role:
|
|
16
|
+
"""
|
|
17
|
+
Returns the role that writes every secure parameter of this stack.
|
|
18
|
+
The role is built once and reused, and each new prefix widens its policy once.
|
|
19
|
+
"""
|
|
20
|
+
existing = scope.node.try_find_child(ROLE_ID)
|
|
21
|
+
role = cast(Role, existing) if existing is not None else _create_role(scope)
|
|
22
|
+
|
|
23
|
+
_grant_prefix(scope=scope, role=role, prefix=prefix)
|
|
24
|
+
|
|
25
|
+
return role
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _create_role(scope: Stack) -> Role:
|
|
29
|
+
"""
|
|
30
|
+
Builds the role the custom resource assumes while it writes a parameter.
|
|
31
|
+
"""
|
|
32
|
+
return Role(
|
|
33
|
+
scope=scope,
|
|
34
|
+
id=ROLE_ID,
|
|
35
|
+
assumed_by=ServicePrincipal("lambda.amazonaws.com"),
|
|
36
|
+
managed_policies=[
|
|
37
|
+
ManagedPolicy.from_aws_managed_policy_name("service-role/AWSLambdaBasicExecutionRole")
|
|
38
|
+
]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _grant_prefix(scope: Stack, role: Role, prefix: str) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Grants the role every parameter under one prefix, and does so once per prefix.
|
|
45
|
+
One grant covers the whole prefix because a grant per parameter races IAM propagation.
|
|
46
|
+
"""
|
|
47
|
+
# An empty construct marks the prefix as granted. The stack tree is the one place that
|
|
48
|
+
# outlives two parameters built far apart in the same synthesis.
|
|
49
|
+
marker_id = f"{ROLE_ID}Grant{re.sub(r'[^A-Za-z0-9]', '', prefix)}"
|
|
50
|
+
|
|
51
|
+
if scope.node.try_find_child(marker_id) is not None: return
|
|
52
|
+
|
|
53
|
+
Construct(scope=scope, id=marker_id)
|
|
54
|
+
|
|
55
|
+
role.add_to_policy(
|
|
56
|
+
PolicyStatement(
|
|
57
|
+
actions=ROLE_ACTIONS,
|
|
58
|
+
resources=[
|
|
59
|
+
scope.format_arn(
|
|
60
|
+
service="ssm",
|
|
61
|
+
resource="parameter",
|
|
62
|
+
resource_name=f"{prefix}/*"
|
|
63
|
+
)
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from aws_cdk import Stack
|
|
5
|
+
from aws_cdk.aws_ecs import Secret as EcsSecret
|
|
6
|
+
from aws_cdk.aws_ssm import IStringParameter, StringParameter
|
|
7
|
+
from aws_cdk.custom_resources import AwsCustomResource, AwsSdkCall, PhysicalResourceId
|
|
8
|
+
from constructs import Construct
|
|
9
|
+
|
|
10
|
+
from callsy_cdk.secure_parameter.role import get_role
|
|
11
|
+
|
|
12
|
+
# Value written at create. The real value is filled in by hand afterwards.
|
|
13
|
+
PLACEHOLDER = "-"
|
|
14
|
+
|
|
15
|
+
# Error the delete call tolerates, so a parameter removed by hand does not wedge a stack.
|
|
16
|
+
PARAMETER_NOT_FOUND = "ParameterNotFound"
|
|
17
|
+
|
|
18
|
+
# Error the create call tolerates when `ignore_existing` is set, so a parameter left behind
|
|
19
|
+
# by a rolled back stack does not block the next deploy.
|
|
20
|
+
PARAMETER_ALREADY_EXISTS = "ParameterAlreadyExists"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SecureParameter(AwsCustomResource):
|
|
24
|
+
"""
|
|
25
|
+
Creates one encrypted parameter in Parameter Store.
|
|
26
|
+
The value is written once and later deploys leave it alone.
|
|
27
|
+
The parameter is removed when the stack is deleted.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
scope: Stack,
|
|
33
|
+
name: str,
|
|
34
|
+
description: str,
|
|
35
|
+
*,
|
|
36
|
+
prefix: str,
|
|
37
|
+
tags: Mapping[str, str] | None = None,
|
|
38
|
+
placeholder: str = PLACEHOLDER,
|
|
39
|
+
key_id: str | None = None,
|
|
40
|
+
tier: str | None = None,
|
|
41
|
+
ignore_existing: bool = False
|
|
42
|
+
) -> None:
|
|
43
|
+
parameter_name = self.build_name(prefix=prefix, name=name)
|
|
44
|
+
|
|
45
|
+
# Tag this custom resource with a project prefix (as the rest of the resources).
|
|
46
|
+
parameter_tags = dict(tags) if tags is not None else {"Project": prefix}
|
|
47
|
+
|
|
48
|
+
parameters: dict[str, Any] = {
|
|
49
|
+
"Name": parameter_name,
|
|
50
|
+
"Description": description,
|
|
51
|
+
"Value": placeholder,
|
|
52
|
+
"Type": "SecureString",
|
|
53
|
+
# Never override the value as they are populated manually.
|
|
54
|
+
"Overwrite": False,
|
|
55
|
+
"Tags": [{"Key": key, "Value": value} for key, value in parameter_tags.items()]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# The account default key encrypts the parameter when no key is named.
|
|
59
|
+
if key_id is not None: parameters["KeyId"] = key_id
|
|
60
|
+
|
|
61
|
+
# One of `Standard`, `Advanced` or `Intelligent-Tiering`.
|
|
62
|
+
if tier is not None: parameters["Tier"] = tier
|
|
63
|
+
|
|
64
|
+
# SecureString is written through the SSM API.
|
|
65
|
+
create_call = AwsSdkCall(
|
|
66
|
+
service="SSM",
|
|
67
|
+
action="putParameter",
|
|
68
|
+
parameters=parameters,
|
|
69
|
+
physical_resource_id=PhysicalResourceId.of(parameter_name),
|
|
70
|
+
ignore_error_codes_matching=PARAMETER_ALREADY_EXISTS if ignore_existing else None
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
delete_call = AwsSdkCall(
|
|
74
|
+
service="SSM",
|
|
75
|
+
action="deleteParameter",
|
|
76
|
+
parameters={"Name": parameter_name},
|
|
77
|
+
# A parameter removed by hand must not wedge the stack.
|
|
78
|
+
ignore_error_codes_matching=PARAMETER_NOT_FOUND
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
super().__init__(
|
|
82
|
+
scope=scope,
|
|
83
|
+
# A construct id can not hold the slash that separates the provider from the key.
|
|
84
|
+
id=name.replace("/", ""),
|
|
85
|
+
# No `update` call is given. A deploy must never rewrite a populated value.
|
|
86
|
+
on_create=create_call,
|
|
87
|
+
on_delete=delete_call,
|
|
88
|
+
role=get_role(scope=scope, prefix=prefix),
|
|
89
|
+
install_latest_aws_sdk=False
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
self.prefix = prefix
|
|
93
|
+
self.parameter_name = parameter_name
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def build_name(prefix: str, name: str) -> str:
|
|
97
|
+
"""
|
|
98
|
+
Returns the full name a parameter carries under one prefix.
|
|
99
|
+
"""
|
|
100
|
+
return f"/{prefix}/{name}"
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def chain(parameters: Sequence["SecureParameter"]) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Makes each parameter wait for the one before it.
|
|
106
|
+
Parameter Store throttles a burst of writes, so they are created one after another.
|
|
107
|
+
"""
|
|
108
|
+
previous: SecureParameter | None = None
|
|
109
|
+
|
|
110
|
+
for parameter in parameters:
|
|
111
|
+
if previous is not None: parameter.node.add_dependency(previous)
|
|
112
|
+
previous = parameter
|
|
113
|
+
|
|
114
|
+
def as_string_parameter(self, scope: Construct, id: str | None = None) -> IStringParameter:
|
|
115
|
+
"""
|
|
116
|
+
Returns the deployed parameter, read by whatever consumes it.
|
|
117
|
+
The value is never resolved at synthesis.
|
|
118
|
+
"""
|
|
119
|
+
return StringParameter.from_secure_string_parameter_attributes(
|
|
120
|
+
scope=scope,
|
|
121
|
+
id=id or f"{self.node.id}Parameter",
|
|
122
|
+
parameter_name=self.parameter_name,
|
|
123
|
+
# The full name holds a slash, so it is a path and not a simple name. Left
|
|
124
|
+
# undetected, the rendered arn carries a doubled slash and matches nothing.
|
|
125
|
+
simple_name=False
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def as_ecs_secret(self, scope: Construct, id: str | None = None) -> EcsSecret:
|
|
129
|
+
"""
|
|
130
|
+
Returns the container secret that reads this parameter.
|
|
131
|
+
The container reads the value when it starts, never at synthesis.
|
|
132
|
+
"""
|
|
133
|
+
return EcsSecret.from_ssm_parameter(self.as_string_parameter(scope=scope, id=id))
|