Encrypted storage¶
Four resources show up in nearly every data-handling stack, each with a handful of settings that should be the same every time: an encryption key, a private bucket, a table, and a container repository. These patterns set those defaults once.
import re
from collections.abc import Mapping, Sequence
from cfn_typed_sumo.types import dynamodb, ecr, kms, s3, ssm
from cfn_typed import Expr, Resources, Tag, allow, deny, get_att, join, ref, sub
from cfn_typed.dynamodb import Index, Key, keys
from cfn_typed.permissions import Ecr
from cfn_typed.policy import ANY, GlobalKeys, aws, bool_, string_not_like
An encryption key¶
def encryption_key(name: str, *, alias: str, description: str = "", tags: list[Expr[Tag]] | None = None) -> Resources:
"""A KMS key with rotation and an alias: logical IDs `{name}Key` and `{name}KeyAlias`.
The key policy only enables IAM: access is then granted with ordinary identity policies. Both resources
are retained when the stack is deleted, because deleting a key makes everything encrypted with it unreadable.
"""
key = f"{name}Key"
props: kms.KeyProps = {
"Description": description or f"{name} encryption key",
"EnableKeyRotation": True,
"KeyPolicy": {
"Version": "2012-10-17",
"Statement": [
# in a key policy Resource "*" means the key itself; naming its ARN would be a circular reference
allow("kms:*", "*", sid="EnableIamPolicies", principal=aws(sub("arn:${AWS::Partition}:iam::${AWS::AccountId}:root"))),
],
},
}
if tags:
props["Tags"] = tags
return {
key: kms.key(deletion_policy="Retain", update_replace_policy="Retain", **props),
f"{name}KeyAlias": kms.alias(
AliasName=f"alias/{alias}", TargetKeyId=ref(key), deletion_policy="Retain", update_replace_policy="Retain",
),
}
- The key policy only enables IAM. Access is then granted with ordinary identity policies, for example with grants, instead of listing principals inside the key.
Resourceis"*"in the key policy. In a key policy that means the key itself. Using the key's own ARN there is a circular reference, which is why the policy cannot name it.- Both resources are retained. Deleting a key makes everything encrypted with it unreadable, so a stack delete should never take it along.
A private bucket and its policy¶
def secure_bucket(
name: str,
*,
key_arn: Expr[str],
allowed_principals: Sequence[Expr[str]] | None = None,
archive_after_days: int | None = None,
tags: list[Expr[Tag]] | None = None,
) -> Resources:
"""A private, encrypted bucket and its policy: logical IDs `{name}Bucket` and `{name}BucketPolicy`.
Public access is blocked, objects are encrypted with `key_arn`, and the bucket is retained on delete. The
policy refuses plain HTTP. With `allowed_principals` it also refuses everyone else, so include the role that
deploys the stack, or you will lock yourself out of the bucket.
"""
bucket = f"{name}Bucket"
bucket_arn = get_att(bucket, "Arn")
both: list[Expr[str]] = [bucket_arn, join("", [bucket_arn, "/*"])]
statements = [
deny("s3:*", both, sid="DenyInsecureTransport", principal=ANY, condition=bool_(GlobalKeys.SecureTransport, False)),
]
if allowed_principals:
statements.append(
deny("s3:*", both, sid="DenyEveryoneElse", principal=ANY,
condition=string_not_like(GlobalKeys.PrincipalArn, list(allowed_principals))),
)
props: s3.BucketProps = {
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": True, "BlockPublicPolicy": True, "IgnorePublicAcls": True, "RestrictPublicBuckets": True,
},
"BucketEncryption": {"ServerSideEncryptionConfiguration": [{
"ServerSideEncryptionByDefault": {"SSEAlgorithm": "aws:kms", "KMSMasterKeyID": key_arn},
"BucketKeyEnabled": True,
}]},
}
if archive_after_days is not None:
props["LifecycleConfiguration"] = {"Rules": [{
"Id": "archive", "Status": "Enabled",
"Transitions": [{"StorageClass": "GLACIER", "TransitionInDays": archive_after_days}],
}]}
if tags:
props["Tags"] = tags
return {
bucket: s3.bucket(deletion_policy="Retain", update_replace_policy="Retain", **props),
f"{name}BucketPolicy": s3.bucket_policy(
Bucket=ref(bucket), PolicyDocument={"Version": "2012-10-17", "Statement": statements},
),
}
- No bucket name. CloudFormation picks a unique one, so a replacement never collides with the old bucket. The next page shows how other stacks find it.
- Two policy statements, both
Deny. A deny for plain HTTP, and withallowed_principalsa deny for everyone not listed. Deny statements win over any allow, so they are a backstop, not a grant. - The lockout risk is real. With
allowed_principals, a principal that is not on the list cannot use the bucket, including the role that deploys the stack. The docstring says so, and the example stack takes the deploy role as a parameter for exactly this reason. Wildcards work:role/team-*matches a family of roles.
A table¶
def dynamodb_table(
pk: Key,
sk: Key | None = None,
*,
gsis: Sequence[Index] = (),
lsis: Sequence[Index] = (),
deletion_protection: bool = True,
tags: list[Expr[Tag]] | None = None,
) -> dynamodb.Table:
"""An on-demand table with point-in-time recovery. Keys are `"name"` (a string) or `("name", "N")`."""
return dynamodb.table(
**keys(pk, sk, gsis=gsis, lsis=lsis),
BillingMode="PAY_PER_REQUEST",
PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": True},
DeletionProtectionEnabled=deletion_protection,
**({"Tags": tags} if tags else {}),
)
On-demand billing and point-in-time recovery are the defaults you almost always want, and deletion protection is on unless you turn it off. The pattern returns the table itself, so you choose its logical ID.
The key properties come from cfn_typed.dynamodb.keys. It is entirely optional. dynamodb.table(...) takes
AttributeDefinitions, KeySchema and the indexes as ordinary typed properties, and writing them by hand is
fine, and often clearer for a simple table. keys only saves the bookkeeping, mostly once a table has indexes:
- A key is
"name"or("name", "N"). A bare name is a string attribute. AttributeDefinitionsis derived. CloudFormation wants every key of the table and of each index declared exactly once.keyscollects them, and refuses one name with two types.- The result is a plain
dict. Spread it into the factory,dynamodb.table(**keys(...), BillingMode=...), andtystill checks everything you pass next to it. The keys themselves are correct by construction, so they are not typed against the generated shapes: that keeps this module free of generated types, with nothing to drift. The arguments are typed, though: an attribute type is"S","N"or"B", and a projection is"ALL","KEYS_ONLY"or a list of names. lsi("name", sk)is a local index: it takes the table's hash key.
A container repository¶
def ecr_repository(
name: str,
*,
pull_principals: Sequence[Expr[str]] = (),
tags: list[Expr[Tag]] | None = None,
) -> ecr.Repository:
"""A repository with immutable tags and scan-on-push, that the given principals may pull from."""
props: ecr.RepositoryProps = {
"RepositoryName": name,
"ImageTagMutability": "IMMUTABLE",
"ImageScanningConfiguration": {"ScanOnPush": True},
}
if pull_principals:
props["RepositoryPolicyText"] = {
"Version": "2012-10-17",
"Statement": [
# no Resource: a repository policy applies to the repository it is attached to
allow([Ecr.GetDownloadUrlForLayer, Ecr.BatchGetImage, Ecr.BatchCheckLayerAvailability],
sid="AllowPull", principal=aws(*pull_principals)),
],
}
if tags:
props["Tags"] = tags
return ecr.repository(**props)
Immutable tags and scan-on-push, and a pull policy only when you name principals. The pull actions are written with
the permission names (Ecr.BatchGetImage and so on), so a typo in one is an error.
Resource policies¶
The bucket, key and repository policies above are three different shapes of resource policy. They are built with the policy helpers, which is why their shapes differ in the table:
| Policy | Principal |
Resource |
|---|---|---|
| bucket policy (its own resource) | ANY, narrowed by conditions |
the bucket and its objects |
| key policy (inside the key) | an account root | "*", meaning the key itself |
| repository policy | the principals that may pull | none: it applies to its repository |
The two bucket statements are deny guardrails with conditions, and the key and repository policies are allow
statements. Grants can produce the allow side too, with a principal.