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
              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, TransactWriteItems
FullAccessPolicy -full Ops teams managing config All of the above + PutItem, DeleteItem, UpdateItem, 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
TransactWriteItems -- Y Y
PutItem -- -- Y
UpdateItem -- -- Y
DeleteItem -- -- Y
BatchWriteItem -- -- Y

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.

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 Always Permission boundary ARN (empty if none)
RoleNameFormat Always Role name format template (empty if default)
CodeBucketName Audit archival S3 bucket for deployment artifacts
ProvisionerFunctionArn Provisioner deployed Limits provisioner Lambda function ARN
ProvisionerFunctionName Provisioner deployed Limits provisioner Lambda function name

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