# CloudFormation Template

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

## Template Overview

The template creates:

```mermaid
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

```bash
# 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

```yaml
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:

```yaml
GlobalSecondaryIndexes:
  - IndexName: GSI1
    KeySchema:
      - AttributeName: GSI1PK  # PARENT#{parent_id}
        KeyType: HASH
      - AttributeName: GSI1SK  # CHILD#{child_id}
        KeyType: RANGE
```

**GSI2** - Resource aggregation:

```yaml
  - IndexName: GSI2
    KeySchema:
      - AttributeName: GSI2PK  # RESOURCE#{resource}
        KeyType: HASH
      - AttributeName: GSI2SK  # BUCKET#{entity_id}
        KeyType: RANGE
```

**GSI3** - Entity config queries (sparse index):

```yaml
  - 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()`):

```yaml
  - IndexName: GSI4
    KeySchema:
      - AttributeName: GSI4PK  # namespace_id
        KeyType: HASH
      - AttributeName: GSI4SK  # PK (original partition key)
        KeyType: RANGE
    Projection:
      ProjectionType: KEYS_ONLY
```

### Stream Configuration

```yaml
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.

```yaml
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.

!!! tip "Performance Tuning"
    For guidance on memory tuning, concurrency management, and error handling configuration, see the [Performance Tuning Guide](../performance.md#2-lambda-concurrency-settings).

### Function Configuration

```yaml
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

```yaml
StreamEventMapping:
  Type: AWS::Lambda::EventSourceMapping
  Properties:
    EventSourceArn: !GetAtt Table.StreamArn
    FunctionName: !Ref AggregatorFunction
    StartingPosition: LATEST
    BatchSize: 100
    MaximumBatchingWindowInSeconds: 5
```

## IAM Permissions

### Lambda Execution Role

```yaml
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:

```yaml
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

```python
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](production.md#namespace-scoped-access-control).

## Customization

### Add Dead Letter Queue

```yaml
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

```yaml
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

```yaml
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

```bash
aws cloudformation deploy \
    --template-file template.yaml \
    --stack-name zae-limiter \
    --capabilities CAPABILITY_NAMED_IAM
```

### With Custom Parameters

```bash
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

```yaml
# samconfig.toml
[default.deploy.parameters]
stack_name = "limiter"
capabilities = "CAPABILITY_NAMED_IAM"
```

```bash
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 |

!!! tip "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:

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

## Next Steps

- [Deployment](deployment.md) - Deployment guide
- [LocalStack](../contributing/localstack.md) - Local development
