Skip to content

CloudFormation Template

This guide covers the CloudFormation template used by zae-limiter and how to customize it.

Template Overview

The template creates:

flowchart TB subgraph stack[CloudFormation Stack] subgraph data[Data Layer] table[(DynamoDB Table)] stream([DynamoDB Stream]) s3[(S3 Bucket<br/>audit archive)] end subgraph compute[Compute Layer] role[IAM Role] lambda[[Lambda Aggregator]] end subgraph iam[Application IAM Policies] acqPolicy[AcquireOnlyPolicy] fullPolicy[FullAccessPolicy] readPolicy[ReadOnlyPolicy] end subgraph observe[Observability] logs[(CloudWatch Logs)] alarms([CloudWatch Alarms]) dlq([Dead Letter Queue]) end end table --> stream stream --> lambda lambda --> table lambda --> s3 role --> lambda lambda --> logs lambda -.->|on failure| dlq alarms -.-> lambda alarms -.-> dlq acqPolicy -.-> table fullPolicy -.-> table readPolicy -.-> table click table "#dynamodb-table" click stream "#stream-configuration" click s3 "#s3-audit-archive-bucket" click lambda "#lambda-aggregator" click role "#iam-permissions" click acqPolicy "#application-iam-policies" click fullPolicy "#application-iam-policies" click readPolicy "#application-iam-policies" click dlq "#add-dead-letter-queue" click alarms "#add-cloudwatch-alarms"

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

StreamSpecification:
  StreamViewType: NEW_AND_OLD_IMAGES

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"
sam deploy --guided

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:

aws cloudformation describe-stacks \
    --stack-name zae-limiter \
    --query "Stacks[0].Outputs"

Next Steps