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.
@@ -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
+ ]
@@ -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))