CloudFormation Template¶
This guide covers the CloudFormation template used by zae-limiter and how to customize it.
Template Overview¶
The template creates:
Export Template¶
# Export to file
zae-limiter cfn-template > template.yaml
# View template
zae-limiter cfn-template | less
Template Parameters¶
The DynamoDB table name is automatically derived from the CloudFormation stack name using the AWS::StackName pseudo-parameter. This ensures consistency between stack and resource names.
| Parameter | Type | Default | Description |
|---|---|---|---|
SnapshotWindows |
String | hourly,daily |
Comma-separated list of snapshot windows |
SnapshotRetentionDays |
Number | 90 |
Days to retain usage snapshots (1-3650) |
LambdaMemorySize |
Number | 256 |
Memory for aggregator Lambda (128-3008 MB) |
LambdaTimeout |
Number | 60 |
Timeout for aggregator Lambda (1-900 seconds) |
LambdaDurationThreshold |
Number | 54000 |
Duration alarm threshold in ms (90% of timeout) |
EnableAggregator |
String | true |
Whether to deploy the aggregator Lambda |
SchemaVersion |
String | 1.0.0 |
Schema version for infrastructure |
PITRRecoveryPeriodDays |
String | (empty) | PITR period (1-35 days, empty for AWS default) |
EnableAlarms |
String | true |
Whether to deploy CloudWatch alarms |
AlarmSNSTopicArn |
String | (empty) | SNS topic ARN for alarm notifications |
LogRetentionDays |
Number | 30 |
CloudWatch log retention (standard periods) |
PermissionBoundary |
String | (empty) | IAM permission boundary (ARN or policy name) |
RoleName |
String | (empty) | Custom IAM role name (use {} as placeholder for base name) |
EnableAuditArchival |
String | true |
Archive expired audit events to S3 |
AuditArchiveGlacierDays |
Number | 90 |
Days before Glacier IR transition (1-3650) |
EnableTracing |
String | false |
Enable AWS X-Ray tracing for Lambda |
EnableIAMRoles |
String | true |
Create App/Admin/ReadOnly IAM roles |
EnableIAM |
String | true |
Create all IAM resources (policies + roles) |
AggregatorRoleArn |
String | (empty) | External IAM role ARN for aggregator Lambda |
AggregatorRoleName |
String | (empty) | Custom name for aggregator Lambda role |
AppRoleName |
String | (empty) | Custom name for application IAM role |
AdminRoleName |
String | (empty) | Custom name for admin IAM role |
ReadOnlyRoleName |
String | (empty) | Custom name for read-only IAM role |
ProvisionerRoleName |
String | (empty) | Custom name for provisioner Lambda role |
AcquireOnlyPolicyName |
String | (empty) | Custom name for acquire-only policy |
FullAccessPolicyName |
String | (empty) | Custom name for full-access policy |
ReadOnlyPolicyName |
String | (empty) | Custom name for read-only policy |
EnableProvisioner |
String | true |
Deploy limits provisioner Lambda |
EnableDeletionProtection |
String | false |
Enable DynamoDB table deletion protection |
NamespaceAcquirePolicyName |
String | (empty) | Custom name for namespace acquire-only policy |
NamespaceFullAccessPolicyName |
String | (empty) | Custom name for namespace full-access policy |
NamespaceReadOnlyPolicyName |
String | (empty) | Custom name for namespace read-only policy |
DynamoDB Table¶
Schema¶
AttributeDefinitions:
- AttributeName: PK
AttributeType: S
- AttributeName: SK
AttributeType: S
- AttributeName: GSI1PK
AttributeType: S
- AttributeName: GSI1SK
AttributeType: S
- AttributeName: GSI2PK
AttributeType: S
- AttributeName: GSI2SK
AttributeType: S
- AttributeName: GSI3PK
AttributeType: S
- AttributeName: GSI3SK
AttributeType: S
- AttributeName: GSI4PK
AttributeType: S
- AttributeName: GSI4SK
AttributeType: S
KeySchema:
- AttributeName: PK
KeyType: HASH
- AttributeName: SK
KeyType: RANGE
Global Secondary Indexes¶
GSI1 - Parent to children lookups:
GlobalSecondaryIndexes:
- IndexName: GSI1
KeySchema:
- AttributeName: GSI1PK # PARENT#{parent_id}
KeyType: HASH
- AttributeName: GSI1SK # CHILD#{child_id}
KeyType: RANGE
GSI2 - Resource aggregation:
- IndexName: GSI2
KeySchema:
- AttributeName: GSI2PK # RESOURCE#{resource}
KeyType: HASH
- AttributeName: GSI2SK # BUCKET#{entity_id}
KeyType: RANGE
GSI3 - Entity config queries (sparse index):
- IndexName: GSI3
KeySchema:
- AttributeName: GSI3PK # ENTITY_CONFIG#{resource}
KeyType: HASH
- AttributeName: GSI3SK # entity_id
KeyType: RANGE
Projection:
ProjectionType: KEYS_ONLY
GSI4 - Namespace item discovery (for purge_namespace()):
- IndexName: GSI4
KeySchema:
- AttributeName: GSI4PK # namespace_id
KeyType: HASH
- AttributeName: GSI4SK # PK (original partition key)
KeyType: RANGE
Projection:
ProjectionType: KEYS_ONLY
Stream Configuration¶
S3 Audit Archive Bucket¶
When EnableAuditArchival is true, the template creates an S3 bucket to store expired audit events.
AuditArchiveBucket:
Type: AWS::S3::Bucket
Condition: DeployAuditArchive
Properties:
# BucketName omitted: CloudFormation auto-generates a globally unique name
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
LifecycleConfiguration:
Rules:
- Id: GlacierTransition
Status: Enabled
Prefix: audit/
Transitions:
- StorageClass: GLACIER_IR
TransitionInDays: !Ref AuditArchiveGlacierDays
Object Structure¶
Archived audit events are stored as gzip-compressed JSONL files:
s3://<auto-generated-bucket-name>/
audit/
year=2024/
month=01/
day=15/
audit-{request_id}-{timestamp}.jsonl.gz
Lifecycle Policy¶
Objects transition to Glacier Instant Retrieval after AuditArchiveGlacierDays (default: 90 days) for cost-effective long-term storage while maintaining millisecond retrieval times.
Lambda Aggregator¶
The aggregator Lambda processes DynamoDB Stream events to maintain usage snapshots and archive expired audit events.
Performance Tuning
For guidance on memory tuning, concurrency management, and error handling configuration, see the Performance Tuning Guide.
Function Configuration¶
AggregatorFunction:
Type: AWS::Lambda::Function
Properties:
Runtime: python3.12
Handler: zae_limiter_aggregator.handler.handler
MemorySize: 256
Timeout: 60
Environment:
Variables:
TABLE_NAME: !Ref AWS::StackName
SNAPSHOT_WINDOWS: !Ref SnapshotWindows
SNAPSHOT_TTL_DAYS: !Ref SnapshotRetentionDays
# When audit archival is enabled:
ENABLE_ARCHIVAL: "true"
ARCHIVE_BUCKET_NAME: !Ref AuditArchiveBucket
Event Source Mapping¶
StreamEventMapping:
Type: AWS::Lambda::EventSourceMapping
Properties:
EventSourceArn: !GetAtt Table.StreamArn
FunctionName: !Ref AggregatorFunction
StartingPosition: LATEST
BatchSize: 100
MaximumBatchingWindowInSeconds: 5
IAM Permissions¶
Lambda Execution Role¶
AggregatorRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: DynamoDBAccess
PolicyDocument:
Statement:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:Query
- dynamodb:ConditionCheckItem # a shard clone's META pin (ADR-150)
Resource: !GetAtt Table.Arn
- Effect: Allow
Action:
- dynamodb:GetRecords
- dynamodb:GetShardIterator
- dynamodb:DescribeStream
- dynamodb:ListStreams
Resource: !Sub "${Table.Arn}/stream/*"
# When audit archival is enabled:
- Effect: Allow
Action:
- s3:PutObject
Resource: !Sub "${AuditArchiveBucket.Arn}/*"
Application IAM Policies¶
The template creates three IAM managed policies by default for different access patterns. These policies can be attached to your own IAM roles, users, or federated identities.
Policy Summary¶
| Policy | Suffix | Use Case | DynamoDB Permissions |
|---|---|---|---|
AcquireOnlyPolicy |
-acq |
Applications calling acquire() |
GetItem, BatchGetItem, Query, ConditionCheckItem; PutItem, UpdateItem on bucket items only |
FullAccessPolicy |
-full |
Ops teams managing config | All of the above + DeleteItem, BatchWriteItem, Scan, DescribeTable |
ReadOnlyPolicy |
-read |
Monitoring and dashboards | GetItem, BatchGetItem, Query, Scan, DescribeTable |
Permission Matrix¶
| Action | ReadOnly | AcquireOnly | FullAccess |
|---|---|---|---|
GetItem |
Y | Y | Y |
BatchGetItem |
Y | Y | Y |
Query |
Y | Y | Y |
Scan |
Y | -- | Y |
DescribeTable |
Y | -- | Y |
ConditionCheckItem |
-- | Y | Y |
PutItem |
-- | buckets | Y |
UpdateItem |
-- | buckets | Y |
DeleteItem |
-- | -- | Y |
BatchWriteItem |
-- | -- | Y |
dynamodb:ConditionCheckItem is required from v0.17.0
A bucket create is one transaction that also checks the owner entity's record
(ADR-150), so every principal that acquires — and the aggregator's role, which
pre-creates shards — needs dynamodb:ConditionCheckItem on the table. The policies
and roles this template creates grant it once the stack runs the v0.17.0 template: a
new deploy, or zae-limiter upgrade on an existing stack, which applies the new
template (and refuses a change that would replace or remove a resource). Run
zae-limiter upgrade on each stack before rolling out v0.17.0 clients; custom IAM
must add it. Without it a bucket create is denied, and acquire() raises
PermissionDenied under BLOCK and ALLOW alike
(ALLOW never covers permission errors).
AcquireOnlyPolicy gains PutItem and UpdateItem in v0.17.0
AWS authorises each Put and Update inside a TransactWriteItems by
dynamodb:PutItem / dynamodb:UpdateItem, not by TransactWriteItems
(AWS docs).
Before v0.17.0 AcquireOnlyPolicy granted neither, so a principal holding only it
(including the {stack}-app role) could not write a bucket, and
NamespaceAcquirePolicy lacked PutItem, so it could never create one (ADR-152).
The policies no longer list dynamodb:TransactWriteItems: IAM defines no such action
(service reference),
so it never granted anything.
A stack deployed earlier keeps its old policies until its template is updated:
zae-limiter upgrade applies the new template through a CloudFormation change set
(and refuses a change that would replace or remove a resource), so run it on each
stack before rolling out v0.17.0 clients. Custom IAM modelled on the old policies
must add both actions.
The acquire policies write bucket items only, from v0.17.0
AcquireOnlyPolicy and NamespaceAcquirePolicy grant PutItem and UpdateItem
only where the partition key is a bucket's (dynamodb:LeadingKeys */BUCKET#*, and
{namespace id}/BUCKET#* for the namespace policy), because every write acquire()
makes is a bucket write (#756). A principal holding only an acquire policy can no
longer write config, entity records, audit events, the namespace registry or the
version record: create_entity(), set_limits(), set_resource_defaults(),
reset_bucket(), top_up() and a first-time Repository.open() (which registers the
namespace and writes the version record) are refused with AccessDeniedException.
Those are operator actions: give them FullAccessPolicy or
NamespaceFullAccessPolicy. It is least privilege, not a boundary: a principal that
may acquire can still raise its own buckets' balances with a direct write (ADR-149).
IAM Roles (Opt-In)¶
When EnableIAMRoles is true (or --create-iam-roles is passed), the template also creates IAM roles that attach these managed policies. All roles trust the same AWS account root principal:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
AWS: !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:root
Action: sts:AssumeRole
Policy and Role Naming¶
- Policies:
${StackName}-{acq,full,read}(e.g.,my-app-acq) - Roles (when enabled):
${StackName}-{acq,full,read}(same naming) - With custom
PolicyNameFormat/RoleName: format pattern applied to all
Policies and roles respect PermissionBoundary if configured.
Usage Example¶
import boto3
# Attach AcquireOnlyPolicy to your own role, or assume a created role
sts = boto3.client("sts")
credentials = sts.assume_role(
RoleArn="arn:aws:iam::123456789012:role/my-app-acq", RoleSessionName="my-app"
)["Credentials"]
# Use assumed credentials
session = boto3.Session(
aws_access_key_id=credentials["AccessKeyId"],
aws_secret_access_key=credentials["SecretAccessKey"],
aws_session_token=credentials["SessionToken"],
)
Namespace-Scoped IAM Policies¶
The template also creates three namespace-scoped policies that restrict DynamoDB access to items within a single namespace using tag-based access control (TBAC):
| Policy | Suffix | Use Case | Tag Required |
|---|---|---|---|
NamespaceAcquirePolicy |
-ns-acq |
Tenant apps calling acquire() |
zael_namespace_id |
NamespaceFullAccessPolicy |
-ns-full |
Tenant config management | zael_namespace_id |
NamespaceReadOnlyPolicy |
-ns-read |
Tenant monitoring | zael_namespace_id |
These policies use dynamodb:LeadingKeys with the caller's zael_namespace_id principal tag to scope DynamoDB operations to items prefixed with that namespace ID. Read access to the reserved namespace _/* (namespace registry, shared config) is always granted.
| Action | NamespaceReadOnly | NamespaceAcquire | NamespaceFullAccess |
|---|---|---|---|
GetItem |
Y | Y | Y |
BatchGetItem |
Y | Y | Y |
Query |
Y | Y | Y |
ConditionCheckItem |
-- | Y | Y |
PutItem |
-- | buckets | Y |
UpdateItem |
-- | buckets | Y |
DeleteItem |
-- | -- | Y |
BatchWriteItem |
-- | -- | Y |
NamespaceFullAccessPolicy gains BatchWriteItem in v0.17.0
delete_entity() deletes with BatchWriteItem, which the policy did not grant, so a
principal holding only NamespaceFullAccessPolicy got AccessDeniedException from it
(#742). dynamodb:LeadingKeys is checked on every item of a BatchWriteItem
(AWS docs),
so it can put or delete only the items the policy's PutItem and DeleteItem already
reach: the namespace's own ({id}/…), the bare namespace ID, and the shared _/ prefix
(the namespace registry). It adds no reach. Run zae-limiter upgrade on an existing
stack to apply it; custom IAM modelled on the policy must add the action.
For setup instructions, see Namespace-Scoped Access Control.
Customization¶
Add Dead Letter Queue¶
Parameters:
EnableDLQ:
Type: String
Default: "false"
AllowedValues: ["true", "false"]
Conditions:
CreateDLQ: !Equals [!Ref EnableDLQ, "true"]
Resources:
DeadLetterQueue:
Type: AWS::SQS::Queue
Condition: CreateDLQ
Properties:
QueueName: !Sub "${AWS::StackName}-aggregator-dlq"
MessageRetentionPeriod: 1209600 # 14 days
StreamEventMapping:
Properties:
DestinationConfig:
OnFailure:
Destination: !If
- CreateDLQ
- !GetAtt DeadLetterQueue.Arn
- !Ref AWS::NoValue
Add CloudWatch Alarms¶
ReadThrottleAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub "${AWS::StackName}-read-throttle"
AlarmDescription: Alert when DynamoDB read requests are throttled
MetricName: ReadThrottleEvents
Namespace: AWS/DynamoDB
Statistic: Sum
Period: 300 # 5 minutes
EvaluationPeriods: 2
Threshold: 1
ComparisonOperator: GreaterThanThreshold
Dimensions:
- Name: TableName
Value: !Ref RateLimitsTable
TreatMissingData: notBreaching
AlarmActions: !If
- HasSNSTopic
- [!Ref AlarmSNSTopicArn]
- !Ref AWS::NoValue
Enable Encryption with CMK¶
Parameters:
KmsKeyArn:
Type: String
Default: ""
Conditions:
UseCustomKey: !Not [!Equals [!Ref KmsKeyArn, ""]]
Resources:
Table:
Properties:
SSESpecification:
SSEEnabled: true
SSEType: !If [UseCustomKey, "KMS", "AWS_OWNED_KEY"]
KMSMasterKeyId: !If [UseCustomKey, !Ref KmsKeyArn, !Ref AWS::NoValue]
Deployment Examples¶
Basic Deployment¶
aws cloudformation deploy \
--template-file template.yaml \
--stack-name zae-limiter \
--capabilities CAPABILITY_NAMED_IAM
With Custom Parameters¶
aws cloudformation deploy \
--template-file template.yaml \
--stack-name prod \
--parameter-overrides \
PITRRecoveryPeriodDays=35 \
SnapshotRetentionDays=365 \
LogRetentionDays=90 \
EnableAlarms=true \
--capabilities CAPABILITY_NAMED_IAM
Note: The DynamoDB table name is automatically set to match the stack name (e.g., prod).
Using SAM¶
# samconfig.toml
[default.deploy.parameters]
stack_name = "limiter"
capabilities = "CAPABILITY_NAMED_IAM"
Outputs¶
The template exports:
| Output | Condition | Description |
|---|---|---|
TableName |
Always | DynamoDB table name |
TableArn |
Always | DynamoDB table ARN |
StreamArn |
Always | DynamoDB stream ARN |
AggregatorFunctionArn |
Aggregator deployed | Aggregator Lambda function ARN |
AggregatorFunctionName |
Aggregator deployed | Aggregator Lambda function name |
AggregatorDLQUrl |
Aggregator enabled | Dead Letter Queue URL |
AggregatorDLQArn |
Aggregator enabled | Dead Letter Queue ARN |
AggregatorDLQAlarmName |
Aggregator alarms | CloudWatch alarm for DLQ monitoring |
LambdaErrorRateAlarmName |
Aggregator alarms | CloudWatch alarm for Lambda error rate |
LambdaDurationAlarmName |
Aggregator alarms | CloudWatch alarm for Lambda duration |
DynamoDBReadThrottleAlarmName |
Alarms enabled | CloudWatch alarm for read throttles |
DynamoDBWriteThrottleAlarmName |
Alarms enabled | CloudWatch alarm for write throttles |
StreamIteratorAgeAlarmName |
Aggregator alarms | CloudWatch alarm for stream iterator age |
AuditArchiveBucketName |
Audit archival | S3 bucket for audit archives |
AuditArchiveBucketArn |
Audit archival | S3 bucket ARN |
AcquireOnlyPolicyArn |
IAM enabled | IAM policy ARN for acquire-only access |
FullAccessPolicyArn |
IAM enabled | IAM policy ARN for full access |
ReadOnlyPolicyArn |
IAM enabled | IAM policy ARN for read-only access |
NamespaceAcquirePolicyArn |
IAM enabled | Namespace-scoped acquire-only policy ARN |
NamespaceFullAccessPolicyArn |
IAM enabled | Namespace-scoped full-access policy ARN |
NamespaceReadOnlyPolicyArn |
IAM enabled | Namespace-scoped read-only policy ARN |
AppRoleArn |
IAM roles enabled | IAM role ARN for application access |
AppRoleName |
IAM roles enabled | IAM role name for application access |
AdminRoleArn |
IAM roles enabled | IAM role ARN for admin access |
AdminRoleName |
IAM roles enabled | IAM role name for admin access |
ReadOnlyRoleArn |
IAM roles enabled | IAM role ARN for read-only access |
ReadOnlyRoleName |
IAM roles enabled | IAM role name for read-only access |
PermissionBoundaryArn |
Permission boundary set | Permission boundary ARN used for IAM roles |
RoleNameFormat |
Role name format set | Role name format template |
CodeBucketName |
Audit archival | S3 bucket for deployment artifacts |
ProvisionerFunctionArn |
Provisioner deployed | Limits provisioner Lambda function ARN |
ProvisionerFunctionName |
Provisioner deployed | Limits provisioner Lambda function name |
Conditional outputs are omitted, not empty
PermissionBoundaryArn and RoleNameFormat are only emitted when the
corresponding option is set. CloudFormation rejects an export whose value is
empty or whitespace-only and rolls the entire stack back, so these outputs
carry a condition rather than exporting ''. Consumers should treat a
missing output as "not configured" — for example
outputs.get("RoleNameFormat", "{}").
Discovering namespace IDs
Namespace IDs are opaque strings stored in DynamoDB, not CloudFormation outputs.
Use zae-limiter namespace show default --name <stack> to retrieve the ID
for the default namespace, or any registered namespace.
Access outputs:
Next Steps¶
- Deployment - Deployment guide
- LocalStack - Local development