Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🟧 AWS CloudTrail Terraform Module

Provisions a secure-by-default, account-wide CloudTrail — a multi-region, tamper-evident trail with optional CloudWatch Logs delivery, SNS notifications, event/insight selectors, and CloudTrail Lake event data stores — from a single module call. Built for the AWS provider v6.x.

Terraform aws module type resources


🧩 Overview

  • 🛡️ Tamper-evident by default. Multi-region coverage, log-file integrity validation, active logging, and global-service events are all ON out of the box — the (regulated data-privacy) audit baseline, not an opt-in.
  • 🔐 Encryption you control. Supply a customer-managed CMK via kms_key_arn for SSE-KMS so audit-log key access is independently auditable and revocable; omit it to fall back to CloudTrail-managed SSE-S3.
  • 📡 Delivery, wired your way. Optional CloudWatch Logs delivery (for metric filters / alarms) and SNS notifications are off until you supply the ARNs — and the module enforces that the CloudWatch pair is set together.
  • 🎯 Precise event capture. Basic event_selectors or finer-grained advanced_event_selectors (mutually exclusive, validated), plus CloudTrail Insights for unusual API-rate/error-rate activity.
  • 🗄️ CloudTrail Lake, optional. A for_each map of event data stores — multi-region, termination-protected, seven-year retention, CMK-encrypted by default.
  • 🏷️ Tags everywhere. var.tags flows to the trail and every event data store and merges with provider default_tags; the merged set is surfaced as tags_all.
  • 🌐 Global, not us-east-1-bound. A multi-region trail is created in one home Region and captures all Regions — no region variable, and not a us-east-1 global-service module.

💡 Why it matters: in a regulated FI the blast radius of a missing or tampered audit trail is enormous. This module makes the complete, tamper-evident, account-wide posture the default and forces an explicit opt-out to weaken it.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits in the family

terraform-aws-cloudtrail is an observability sink — it consumes a destination bucket, an encryption key, and (optionally) a log group + delivery role from foundation modules, and emits a trail ARN that governance and security modules reference.

flowchart LR
 s3["terraform-aws-s3-bucket<br/>destination + bucket policy"]
 kms["terraform-aws-kms<br/>CMK (SSE-KMS)"]
 cwlg["terraform-aws-cloudwatch-log-group<br/>log group (:*)"]
 role["terraform-aws-iam-role<br/>CloudWatch delivery role"]
 ct["terraform-aws-cloudtrail"]
 cfg["terraform-aws-config"]
 shub["terraform-aws-security-hub"]
 eb["terraform-aws-eventbridge"]

 s3 -->|"s3_bucket_name"| ct
 kms -->|"kms_key_arn"| ct
 cwlg -->|"cloud_watch_logs_group_arn"| ct
 role -->|"cloud_watch_logs_role_arn"| ct
 ct -->|"trail arn"| cfg
 ct -->|"trail arn"| shub
 ct -->|"log events"| eb

 style ct fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
Loading

🧬 What this module builds

flowchart TD
 subgraph mod["terraform-aws-cloudtrail"]
 trail["aws_cloudtrail.this<br/>(keystone)<br/>multi-region · log-file validation<br/>SSE-KMS · global events"]
 es["event_selector / advanced_event_selector<br/>(mutually exclusive, dynamic)"]
 ins["insight_selector<br/>(dynamic, optional)"]
 eds["aws_cloudtrail_event_data_store.this<br/>for_each event_data_stores<br/>(CloudTrail Lake, optional)"]
 end

 ext_s3[("S3 bucket + policy")]
 ext_kms[("KMS CMK")]
 ext_cw[("CloudWatch Logs + role")]
 ext_sns[("SNS topic")]

 trail --- es
 trail --- ins
 trail -->|delivers logs| ext_s3
 trail -.->|SSE-KMS| ext_kms
 trail -.->|optional| ext_cw
 trail -.->|optional| ext_sns
 eds -.->|SSE-KMS| ext_kms

 style trail fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
 style eds stroke-dasharray: 5 5
Loading
Resource Count Created when
aws_cloudtrail.this 1 always (keystone)
aws_cloudtrail_event_data_store.this 0..N one per event_data_stores entry

event_selector, advanced_event_selector, insight_selector, and the CloudWatch/SNS wiring are blocks/arguments on the keystone, rendered by dynamic blocks and absent unless configured.


✅ Provider / Versions

Requirement Version
Terraform >= 1.12.0
hashicorp/aws >= 6.0, < 7.0

The module declares only a required_providers block (providers.tf) and inherits the configured provider. There is no provider {} block and no credential variable — credentials resolve through the standard AWS chain at the root/pipeline level (env vars → SSO/shared credentials → assume_role → instance profile / IRSA → OIDC web identity).


🔑 Required IAM Permissions

Least-privilege actions the Terraform execution identity needs to manage this module.

Action Required for Notes
cloudtrail:CreateTrail, cloudtrail:DeleteTrail, cloudtrail:UpdateTrail Trail lifecycle Core CRUD
cloudtrail:StartLogging, cloudtrail:StopLogging Enable/disable logging Driven by enable_logging
cloudtrail:PutEventSelectors, cloudtrail:PutInsightSelectors Data / insight selectors Applied when event_selectors / advanced_event_selectors / insight_selectors are set
cloudtrail:GetTrail, cloudtrail:GetTrailStatus, cloudtrail:GetEventSelectors, cloudtrail:GetInsightSelectors, cloudtrail:ListTags Read / refresh Plan & state refresh
cloudtrail:AddTags, cloudtrail:RemoveTags Tagging Trail + event data stores
cloudtrail:CreateEventDataStore, cloudtrail:UpdateEventDataStore, cloudtrail:DeleteEventDataStore, cloudtrail:GetEventDataStore CloudTrail Lake Only when event_data_stores is non-empty
iam:PassRole (on cloud_watch_logs_role_arn) Pass the CloudWatch delivery role Required only when CloudWatch Logs delivery is configured
iam:GetRole, iam:CreateServiceLinkedRole Org trail / Lake SLR Auto-creates AWSServiceRoleForCloudTrail
kms:DescribeKey (on kms_key_arn) Validate the CMK at create Only when a CMK is supplied
s3:PutObject, s3:GetBucketAcl AWS-side create-time validation Exercised by the CloudTrail service principal via the bucket policy — not by the Terraform identity

⚠️ The Terraform identity does NOT need s3:PutObject / kms:GenerateDataKey* itself. Those are exercised at runtime by the cloudtrail.amazonaws.com service principal through the destination bucket policy and the CMK key policy. iam:PassRole is a module permission — but only because the module wires cloud_watch_logs_role_arn into the trail.

🔒 Scope cloudtrail:* to the trail ARN pattern (arn:aws:cloudtrail:*:<account>:trail/*) and iam:PassRole to the specific delivery role ARN with an iam:PassedToService = cloudtrail.amazonaws.com condition.


📋 AWS Prerequisites

  • Destination S3 bucket policy (mandatory). CreateTrail fails AWS-side validation unless the destination bucket grants cloudtrail.amazonaws.com s3:GetBucketAcl on the bucket and s3:PutObject on AWSLogs/<account-id>/* with the s3:x-amz-acl = bucket-owner-full-control and aws:SourceArn / aws:SourceAccount conditions. terraform-aws-s3-bucket can emit this policy. The bucket policy is intentionally out of scope here — it lives with the bucket.
  • KMS key policy (when kms_key_arn set). The CMK policy must allow cloudtrail.amazonaws.com kms:GenerateDataKey* (encrypt, scoped with aws:SourceArn to the trail ARN) and grant the log consumers kms:Decrypt.
  • CloudWatch Logs integration (optional). Requires a log group (terraform-aws-cloudwatch-log-group, ARN with the trailing :*) and an IAM role assumable by cloudtrail.amazonaws.com with logs:CreateLogStream / logs:PutLogEvents on the group. Both must be supplied together.
  • Service-linked role. Organization trails and CloudTrail Lake auto-create AWSServiceRoleForCloudTrail (iam:CreateServiceLinkedRole).
  • Organization trail. is_organization_trail = true can only be created from the management account or a registered delegated administrator.
  • Region. A multi-region trail is created in one home Region and captures all enabled Regions — no region variable. CloudTrail is not a us-east-1 global-service module (unlike CloudFront / WAFv2-CLOUDFRONT / ACM-for-CloudFront).
  • Quotas (per Quotas in AWS CloudTrailnone are adjustable):
  • 5 trails per Region. Capture management events with one multi-region trail per account; additional trails re-log the same events and are billed.
  • 10 event data stores per Region (counts single-region, multi-region, and org stores in any lifecycle stage).
  • 5 event selectors per trail; 250 data resources across all event selectors; 500 condition values across all advanced event selectors.

📁 Module Structure

terraform-aws-cloudtrail/
├── providers.tf # required_providers (aws >= 6.0, < 7.0); no provider block
├── variables.tf # name → required → delivery → secure posture → selectors → event_data_stores → tags
├── main.tf # aws_cloudtrail.this + aws_cloudtrail_event_data_store.this (for_each)
├── outputs.tf # id + arn + name + home_region + sns_topic_arn + EDS maps + tags_all
├── README.md # this file
└── SCOPE.md # in/out-of-scope, IAM permissions, prerequisites, gotchas

⚙️ Quick Start

Smallest working call — a secure multi-region trail wired to a bucket and a CMK from sibling modules:

module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-org-audit"
  s3_bucket_name = module.audit_bucket.id # bucket policy owned by terraform-aws-s3-bucket
  kms_key_arn    = module.audit_kms.arn   # SSE-KMS; -preferred for PII/privacy-regulation logs

  # Secure defaults are already ON: multi-region, log-file validation,
  # logging, global-service events. No opt-out needed for the baseline.

  tags = {
    Environment = "prod"
    DataClass   = "restricted"
    Compliance  = "glba"
  }
}

Sequence the bucket policy / CMK policy before the trail (sibling modules that own those policies handle this; otherwise use depends_on at the root) — see Architecture Notes.


🔌 Cross-Module Contract

Consumes

Input Type Source module
s3_bucket_name string (bucket id) terraform-aws-s3-bucket (also owns the CloudTrail bucket policy)
kms_key_arn string (KMS key ARN / alias / id) terraform-aws-kms
cloud_watch_logs_group_arn string (log group ARN, :* suffix) terraform-aws-cloudwatch-log-group
cloud_watch_logs_role_arn string (IAM role ARN) terraform-aws-iam-role
sns_topic_name string (topic name, or ARN if cross-Region) app-integration modules

Emits

Output Description Consumed by
id Trail id (its ARN — CloudTrail uses the ARN as the resource id) references / CLI
arn Trail ARN arn:aws:cloudtrail:<region>:<account>:trail/<name> — the cross-resource reference type Config, Security Hub, CloudWatch metric filters, KMS/SNS aws:SourceArn conditions
name Trail name metric filters / CLI
home_region Home Region the multi-region trail was created in governance / audit
sns_topic_arn SNS topic ARN for delivery notifications; null when sns_topic_name unset event routing
event_data_store_arns Map of CloudTrail Lake event data store ARNs keyed by map key Lake queries
event_data_store_ids Map of event data store ids keyed by map key references
tags_all All trail tags incl. provider default_tags (resource tags win) governance / audit
event_data_store_tags_all Map of computed tags_all per event data store governance / audit

📚 Example Library

1 · Minimal secure trail (SSE-S3 fallback)
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-audit"
  s3_bucket_name = module.audit_bucket.id
  # kms_key_arn omitted → CloudTrail-managed SSE-S3 encryption
}
2 · Customer-managed KMS (SSE-KMS) wired from terraform-aws-kms
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-audit"
  s3_bucket_name = module.audit_bucket.id
  kms_key_arn    = module.audit_kms.arn # CMK policy must allow cloudtrail.amazonaws.com kms:GenerateDataKey*
}
# -preferred for PII/privacy-regulation audit logs — key access is independently auditable and revocable.
3 · CloudWatch Logs delivery (group + role set together)
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-audit"
  s3_bucket_name = module.audit_bucket.id
  kms_key_arn    = module.audit_kms.arn

  # MUST be set together — the module validates the pair.
  cloud_watch_logs_group_arn = "${module.ct_log_group.arn}:*" # CloudTrail requires the:* suffix
  cloud_watch_logs_role_arn  = module.ct_delivery_role.arn    # trusts cloudtrail.amazonaws.com
}
4 · SNS delivery notifications
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-audit"
  s3_bucket_name = module.audit_bucket.id
  sns_topic_name = "casey-cloudtrail-delivery" # topic policy must allow cloudtrail.amazonaws.com sns:Publish
}
5 · Tags (merge with provider default_tags)
# Caller's provider block owns default_tags; the module never sets it.
provider "aws" {
  default_tags { tags = { Owner = "secops", ManagedBy = "terraform" } }
}

module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-audit"
  s3_bucket_name = module.audit_bucket.id

  tags = {
    Environment = "prod" # resource tag — wins over default_tags on key conflict
    DataClass   = "restricted"
  }
}
# module.trail.tags_all == { Owner, ManagedBy, Environment, DataClass }
6 · S3 data-event logging via basic event selectors
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-data-events"
  s3_bucket_name = module.audit_bucket.id
  kms_key_arn    = module.audit_kms.arn

  event_selectors = [{
    read_write_type           = "All"
    include_management_events = true
    data_resource = [{
      type   = "AWS::S3::Object"
      values = ["${module.sensitive_bucket.arn}/"] # scope tightly — data events are billed per event
    }]
  }]
}
7 · Exclude noisy management event sources
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-audit"
  s3_bucket_name = module.audit_bucket.id

  event_selectors = [{
    read_write_type                  = "All"
    include_management_events        = true
    exclude_management_event_sources = ["kms.amazonaws.com"] # drop high-volume KMS events
  }]
}
8 · Advanced event selectors (Lambda data events)
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-advanced"
  s3_bucket_name = module.audit_bucket.id
  kms_key_arn    = module.audit_kms.arn

  # Mutually exclusive with event_selectors — set at most one.
  advanced_event_selectors = [{
    name = "log-lambda-invokes"
    field_selector = [
      { field = "eventCategory", equals = ["Data"] },
      { field = "resources.type", equals = ["AWS::Lambda::Function"] },
    ]
  }]
}
9 · CloudTrail Insights (API-rate + error-rate)
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-audit"
  s3_bucket_name = module.audit_bucket.id

  insight_selectors = [
    { insight_type = "ApiCallRateInsight" },
    { insight_type = "ApiErrorRateInsight" },
  ]
}
# Insights require management-event logging and incur additional cost.
10 · CloudTrail Lake event data store (secure defaults)
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-audit"
  s3_bucket_name = module.audit_bucket.id
  kms_key_arn    = module.audit_kms.arn # inherited by the store unless it sets its own kms_key_id

  event_data_stores = {
    security-lake = {
      # multi-region, termination protection ON, 7-year (2555d) retention — all default
      advanced_event_selector = [{
        field_selector = [{ field = "eventCategory", equals = ["Management"] }]
      }]
    }
  }
}
11 · Event data store with per-store CMK + extended retention
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-audit"
  s3_bucket_name = module.audit_bucket.id

  event_data_stores = {
    long-retention = {
      retention_period = 3653 # ~10y (EXTENDABLE_RETENTION_PRICING)
      kms_key_id       = module.lake_kms.arn
      tags             = { Retention = "10y" } # merges over (and wins against) module tags
    }
  }
}
# ⚠️ Reducing retention_period on an existing store deletes events older than the new window.
12 · Single-Region trail (deliberate exception)
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name                          = "casey-regional-audit"
  s3_bucket_name                = module.audit_bucket.id
  is_multi_region_trail         = false # opt-out of the secure baseline — document the exception
  include_global_service_events = false # avoid duplicating global events captured by the org trail
}
13 · Provision in a stopped state
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-staged-audit"
  s3_bucket_name = module.audit_bucket.id
  enable_logging = false # provision the trail but do not StartLogging yet
}
14 · Organization trail (management / delegated-admin account)
module "org_trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name                  = "casey-org-audit"
  s3_bucket_name        = module.org_audit_bucket.id
  kms_key_arn           = module.org_audit_kms.arn
  is_organization_trail = true # only from the org management account or a delegated administrator
}
# Auto-creates the AWSServiceRoleForCloudTrail service-linked role.
15 · End-to-end composition — full audit pipeline (bucket + CMK + log group + role + trail)
# Customer-managed CMK for audit-log encryption
module "audit_kms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-kms?ref=v1.0.0"
  alias  = "casey/cloudtrail"
  # key policy allows cloudtrail.amazonaws.com kms:GenerateDataKey* (aws:SourceArn → trail ARN)
}

# Destination bucket — owns the CloudTrail bucket policy
module "audit_bucket" {
  source                = "git::https://github.com/microsoftexpert/terraform-aws-s3-bucket?ref=v1.0.0"
  bucket                = "casey-cloudtrail-logs-prod"
  cloudtrail_log_writer = true # emit the cloudtrail.amazonaws.com bucket policy
  kms_key_arn           = module.audit_kms.arn
}

# CloudWatch Logs target for near-real-time monitoring
module "ct_log_group" {
  source            = "git::https://github.com/microsoftexpert/terraform-aws-cloudwatch-log-group?ref=v1.0.0"
  name              = "/aws/cloudtrail/casey-org-audit"
  kms_key_arn       = module.audit_kms.arn
  retention_in_days = 365
}

# Role CloudTrail assumes to write to CloudWatch Logs
module "ct_delivery_role" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-iam-role?ref=v1.0.0"
  name   = "casey-cloudtrail-cw-delivery"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "cloudtrail.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  inline_policies = {
    deliver = {
      policy = jsonencode({
        Version = "2012-10-17"
        Statement = [{
          Effect   = "Allow"
          Action   = ["logs:CreateLogStream", "logs:PutLogEvents"]
          Resource = "${module.ct_log_group.arn}:*"
        }]
      })
    }
  }
}

# This module — the trail wired to everything above
module "trail" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"

  name           = "casey-org-audit"
  s3_bucket_name = module.audit_bucket.id
  kms_key_arn    = module.audit_kms.arn

  cloud_watch_logs_group_arn = "${module.ct_log_group.arn}:*"
  cloud_watch_logs_role_arn  = module.ct_delivery_role.arn

  insight_selectors = [{ insight_type = "ApiCallRateInsight" }]

  event_data_stores = {
    security-lake = {
      advanced_event_selector = [{
        field_selector = [{ field = "eventCategory", equals = ["Management"] }]
      }]
    }
  }

  tags = { Environment = "prod", DataClass = "restricted", Compliance = "glba" }

  # Bucket/KMS policies are owned by their modules, so ordering is implicit via the
  # references above. If you author those policies separately, add:
  # depends_on = [module.audit_bucket, module.audit_kms]
}

📥 Inputs

Name Type Default Description
name string required Trail name. FORCE-NEW. 3–128 chars, unique per account/Region.
s3_bucket_name string required Destination bucket id (its policy must pre-grant CloudTrail).
s3_key_prefix string null Key prefix for delivered log files.
kms_key_arn string null CMK for SSE-KMS; null → CloudTrail-managed SSE-S3. Also default key for event data stores.
sns_topic_name string null SNS topic for delivery notifications.
cloud_watch_logs_group_arn string null Log group ARN (must end :*). Set with the role ARN.
cloud_watch_logs_role_arn string null Delivery role ARN. Set with the group ARN.
is_multi_region_trail bool true Capture all Regions (secure baseline).
enable_log_file_validation bool true Tamper-evident log signing (secure baseline).
enable_logging bool true Start logging on create.
include_global_service_events bool true Record IAM/STS/CloudFront global events.
is_organization_trail bool false Org-wide trail (management/delegated-admin only).
event_selectors list(object(...)) [] Basic data-event selectors. Conflicts with advanced_event_selectors.
advanced_event_selectors list(object(...)) [] Advanced field-match selectors. Conflicts with event_selectors.
insight_selectors list(object({ insight_type })) [] CloudTrail Insights (ApiCallRateInsight / ApiErrorRateInsight).
event_data_stores map(object(...)) {} CloudTrail Lake event data stores, keyed by stable name.
tags map(string) {} Tags for the trail and every event data store (merge with default_tags).

See variables.tf for full heredoc schemas and validation rules.


🧾 Outputs

Name Description
id Trail id (its ARN).
arn Trail ARN (cross-resource reference type).
name Trail name.
home_region Home Region of the multi-region trail.
sns_topic_arn SNS topic ARN; null when unset.
event_data_store_arns Map of Lake event data store ARNs keyed by map key.
event_data_store_ids Map of Lake event data store ids keyed by map key.
tags_all All trail tags incl. provider default_tags.
event_data_store_tags_all Map of computed tags_all per event data store.

🧠 Architecture Notes

  • ARN format: arn:aws:cloudtrail:<region>:<account-id>:trail/<name>. CloudTrail uses the ARN as the resource idoutput.id and output.arn are the same value. Event data store ARN: arn:aws:cloudtrail:<region>:<account-id>:eventdatastore/<uuid>.
  • Multi-region but home-Region-anchored. A multi-region trail is created in (and named for) one home Region and captures all enabled Regions; home_region reports that Region. This is not a us-east-1 global-service module — create it in your normal operating Region.
  • Force-new fields: name forces replacement (the ARN embeds the name). Renaming destroys and recreates the trail and anything referencing it by name.
  • tagstags_alldefault_tags: var.tags is applied to the trail and each event data store; tags_all is the provider-computed merge of resource tags over provider default_tags, with resource tags winning on key conflict. Per-store tags merge over (and win against) module tags. default_tags is configured in the caller's provider block — never inside this module.
  • Bucket / KMS policy ordering (the #1 create-time failure). CreateTrail performs AWS-side validation: the destination bucket policy (and the CMK key policy, if kms_key_arn is set) must already grant the cloudtrail.amazonaws.com principal. Wire the bucket/key from sibling modules that own those policies (implicit ordering via references) or add explicit depends_on at the root.
  • CloudWatch Logs :* suffix. cloud_watch_logs_group_arn must carry the trailing :* — CloudTrail writes to log streams under the group. The module validates this and that the group/role pair is set together.
  • Eventual consistency. StartLogging lags: GetTrailStatus.IsLogging may briefly read false immediately after apply even though logging is enabled. This is propagation latency, not a module defect.
  • Event selectors are mutually exclusive. Set at most one of event_selectors / advanced_event_selectors; the trail cannot use both (validated in variables.tf).
  • Event data store retention is destructive on shrink. Reducing retention_period on an existing store deletes events older than the new window. Termination protection (termination_protection_enabled = true by default) must be disabled before a store can be deleted.
  • Destroy ordering. No ENI/NAT-style dependencies here. Event data stores with termination protection on cannot be destroyed until it is turned off; the trail itself deletes cleanly once logging stops.

🧱 Design Principles

Secure-by-default posture and every opt-out, explicitly:

Posture Default Opt-out
Multi-region coverage is_multi_region_trail = true is_multi_region_trail = false (document the exception)
Log-file integrity enable_log_file_validation = true enable_log_file_validation = false (strongly discouraged)
Encryption at rest SSE-KMS when kms_key_arn set; SSE-S3 otherwise supply / omit kms_key_arn
Logging active enable_logging = true enable_logging = false
Global-service events include_global_service_events = true set false
Event data store integrity multi_region_enabled = true + termination_protection_enabled = true + 7y retention per-store override

Other principles:

  • One composite, one keystone. The trail owns only what is meaningless without it (its selectors, insights, and optional Lake event data stores). The destination bucket, CMK, log group, delivery role, and SNS topic are referenced by arn/name so blast radius stays on the trail itself.
  • The bucket policy is deliberately out of scope. It lives with the bucket (terraform-aws-s3-bucket) so the bucket module owns its own access policy.
  • for_each, never count, for event data stores — keyed by stable caller strings so reorders don't churn the plan.
  • Optional everything stays absent. CloudWatch delivery, SNS, selectors, insights, and Lake stores default empty and render via dynamic blocks — nothing is created unless configured.
  • Primary outputs id + arn, plus name, home_region, the event-data-store maps, and tags_all.

🚀 Runbook

# Validate without backend or credentials
terraform init -backend=false
terraform validate
terraform fmt -check

plan / apply require valid AWS credentials (profile / SSO / OIDC) resolved through the standard provider chain, plus a configured Region and the IAM actions listed above. The destination bucket policy (and CMK policy, if used) must already grant the CloudTrail service principal or CreateTrail fails AWS-side validation.


🧪 Testing

  • terraform init -backend=false && terraform validate — schema + reference integrity.
  • terraform fmt -check — canonical formatting.
  • terraform plan against a sandbox account with a pre-policied bucket to confirm the trail, selectors, and any event data stores materialize.
  • Assert module.<name>.arn, home_region, event_data_store_arns, and tags_all in your root-module test harness.
  • Validate the conflict guard: setting both event_selectors and advanced_event_selectors must fail at plan.

💬 Example Output

module.trail.aws_cloudtrail.this: Creation complete after 4s [id=arn:aws:cloudtrail:us-east-1:123456789012:trail/casey-org-audit]
module.trail.aws_cloudtrail_event_data_store.this["security-lake"]: Creation complete after 9s

Outputs:
arn = "arn:aws:cloudtrail:us-east-1:123456789012:trail/casey-org-audit"
id = "arn:aws:cloudtrail:us-east-1:123456789012:trail/casey-org-audit"
home_region = "us-east-1"
name = "casey-org-audit"
event_data_store_arns = { "security-lake" = "arn:aws:cloudtrail:us-east-1:123456789012:eventdatastore/EXAMPLE-uuid" }
tags_all = { "Compliance" = "glba", "DataClass" = "restricted", "Environment" = "prod" }

🔍 Troubleshooting

Symptom Likely cause Fix
InsufficientS3BucketPolicyException on apply Destination bucket policy doesn't grant cloudtrail.amazonaws.com Add the CloudTrail bucket policy (terraform-aws-s3-bucket can emit it); ensure it exists before the trail
InsufficientEncryptionPolicyException CMK key policy missing kms:GenerateDataKey* for CloudTrail Grant cloudtrail.amazonaws.com kms:GenerateDataKey* scoped with aws:SourceArn to the trail ARN
CloudWatchLogsDeliveryUnavailableException Delivery role doesn't trust CloudTrail or lacks logs:* Role must trust cloudtrail.amazonaws.com and allow logs:CreateLogStream / logs:PutLogEvents
InvalidCloudWatchLogsLogGroupArnException Group ARN missing the :* suffix Append :* to cloud_watch_logs_group_arn
Plan error: set at most one of event_selectors or advanced_event_selectors Both selector variables set Use one; advanced selectors are the finer-grained superset
AccessDenied: iam:PassRole TF identity can't pass the delivery role Grant iam:PassRole on the role ARN with iam:PassedToService = cloudtrail.amazonaws.com
IsLogging = false right after apply StartLogging eventual consistency Expected propagation lag; re-check status shortly after
MaximumNumberOfTrailsExceededException More than 5 trails in the Region Consolidate to one multi-region management-event trail (quota is non-adjustable)
Can't destroy an event data store Termination protection on Set termination_protection_enabled = false, apply, then destroy
Lost old Lake events after a change retention_period reduced on an existing store Shrinking retention deletes events older than the new window — raise, don't lower, in place
Tag drift on every plan A tag also set by provider default_tags with a different value Let resource tags win, or remove the overlap from default_tags
AccessDenied creating an org trail Not the management / delegated-admin account Create from the org management account or register a delegated administrator

🔗 Related Docs


🧡 "Infrastructure as Code should be standardized, consistent, and secure."