Home/Templates/Audit logs nobody can delete
Audit logs nobody can delete
Send cloud audit logs to storage that refuses deletes for a set number of days.
Terraform
Why bother
An audit log only helps if it is still there after an incident. In a normal setup anyone with admin rights can delete it, and that is often the first thing someone does to cover their tracks. This sends the logs to storage with a retention lock, so deletes fail for everyone, including the account owner.
Try to delete a stored log file. The delete should fail. The exact commands differ by platform, and each tab below spells them out.
Set it up
Pick your platform, then open the level you want. Each level is complete on its own. Read the comments in the files as you go. Anything with a real consequence is explained on the line where it happens.
Terraform. CloudTrail writing to S3 with Object Lock turned on
What you need first
- An account where you can create S3 buckets, KMS keys, and CloudTrail trails.
- For the organization option, run it in the management account with CloudTrail trusted access already on.
What it creates
- A bucket with Object Lock, versioning, and public access blocked
- A trail covering every region, with log file validation on
- A KMS key with yearly rotation (Standard only)
- A CloudWatch log group so metric alarms have something to read (Standard, optional)
The code
Quick startDraft
# audit-log-immutable / aws / t0 "Paste"
#
# CloudTrail writing to an S3 bucket with Object Lock in GOVERNANCE mode.
# A locked log file cannot be deleted until its retention period ends.
#
# Run:
# terraform init && terraform apply
#
# Verify, once the first logs arrive (about fifteen minutes):
# terraform output verify
# Run the two commands it prints. The second must fail with AccessDenied.
#
# Why two commands: the bucket is versioned, so a plain delete-object only adds
# a "delete marker" and reports success while the log file is still there. The
# lock protects each stored version, so the real test deletes a version by ID.
#
# Retention here is one day, so you can try this in a scratch account and tear
# it down tomorrow. The Standard version defaults to 400 days.
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "aws" {}
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
resource "random_id" "suffix" {
byte_length = 4
}
locals {
bucket_name = "audit-log-immutable-${data.aws_caller_identity.current.account_id}-${random_id.suffix.hex}"
trail_name = "audit-log-immutable"
retention_days = 1
}
# ---------------------------------------------------------------------------
# Bucket. object_lock_enabled can only be set at creation time.
# ---------------------------------------------------------------------------
resource "aws_s3_bucket" "audit" {
bucket = local.bucket_name
object_lock_enabled = true
# True here so terraform destroy can empty the bucket once the one-day lock
# has passed. The Standard version sets this false.
force_destroy = true
tags = {
owner = "security-engineering"
environment = "prod"
data_classification = "restricted"
managed_by = "terraform"
}
}
# Object Lock requires versioning. Terraform will not enable it implicitly.
resource "aws_s3_bucket_versioning" "audit" {
bucket = aws_s3_bucket.audit.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_object_lock_configuration" "audit" {
bucket = aws_s3_bucket.audit.id
depends_on = [aws_s3_bucket_versioning.audit]
rule {
default_retention {
mode = "GOVERNANCE"
days = local.retention_days
}
}
}
resource "aws_s3_bucket_public_access_block" "audit" {
bucket = aws_s3_bucket.audit.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "audit" {
bucket = aws_s3_bucket.audit.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_lifecycle_configuration" "audit" {
bucket = aws_s3_bucket.audit.id
depends_on = [aws_s3_bucket_versioning.audit]
rule {
id = "abort-incomplete-uploads"
status = "Enabled"
filter {}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
}
# ---------------------------------------------------------------------------
# Bucket policy. Both statements are required or CreateTrail fails validation.
# aws:SourceArn scopes delivery to this trail only.
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "audit" {
statement {
sid = "AWSCloudTrailAclCheck"
effect = "Allow"
actions = ["s3:GetBucketAcl"]
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
resources = [aws_s3_bucket.audit.arn]
condition {
test = "StringEquals"
variable = "aws:SourceArn"
values = [local.trail_arn]
}
}
statement {
sid = "AWSCloudTrailWrite"
effect = "Allow"
actions = ["s3:PutObject"]
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
resources = ["${aws_s3_bucket.audit.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"]
condition {
test = "StringEquals"
variable = "s3:x-amz-acl"
values = ["bucket-owner-full-control"]
}
condition {
test = "StringEquals"
variable = "aws:SourceArn"
values = [local.trail_arn]
}
}
statement {
sid = "DenyUnencryptedTransport"
effect = "Deny"
actions = ["s3:*"]
principals {
type = "AWS"
identifiers = ["*"]
}
resources = [
aws_s3_bucket.audit.arn,
"${aws_s3_bucket.audit.arn}/*",
]
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}
resource "aws_s3_bucket_policy" "audit" {
bucket = aws_s3_bucket.audit.id
policy = data.aws_iam_policy_document.audit.json
# Applying a policy while the public access block is still being created can
# fail intermittently. Ordering them removes the race.
depends_on = [aws_s3_bucket_public_access_block.audit]
}
# ---------------------------------------------------------------------------
# Trail. The ARN is built from parts to avoid a cycle:
# the bucket policy needs the trail ARN, the trail needs the bucket policy.
# ---------------------------------------------------------------------------
locals {
trail_arn = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${local.trail_name}"
}
data "aws_region" "current" {}
resource "aws_cloudtrail" "audit" {
name = local.trail_name
s3_bucket_name = aws_s3_bucket.audit.id
include_global_service_events = true
is_multi_region_trail = true
enable_log_file_validation = true
depends_on = [aws_s3_bucket_policy.audit]
tags = {
owner = "security-engineering"
environment = "prod"
data_classification = "restricted"
managed_by = "terraform"
}
}
output "bucket" {
description = "Bucket holding the trail. Objects are WORM protected for the retention period."
value = aws_s3_bucket.audit.id
}
output "trail_arn" {
description = "Trail ARN."
value = aws_cloudtrail.audit.arn
}
output "verify" {
description = "Two commands. The second must fail with AccessDenied."
value = <<-EOT
aws s3api list-object-versions --bucket ${aws_s3_bucket.audit.id} --prefix AWSLogs/ --max-items 1 --query 'Versions[0].[Key,VersionId]' --output text
aws s3api delete-object --bucket ${aws_s3_bucket.audit.id} --key <Key from above> --version-id <VersionId from above>
EOT
}
StandardDraft
# Customer-managed key. Two reasons over SSE-S3: rotation you control, and a
# revocation lever that does not require deleting data.
resource "aws_kms_key" "audit" {
description = "${var.name} trail encryption"
deletion_window_in_days = 30
enable_key_rotation = true
policy = data.aws_iam_policy_document.key.json
}
resource "aws_kms_alias" "audit" {
name = "alias/${var.name}"
target_key_id = aws_kms_key.audit.key_id
}
data "aws_iam_policy_document" "key" {
statement {
sid = "AccountRootManagesKey"
effect = "Allow"
principals {
type = "AWS"
identifiers = ["arn:${data.aws_partition.current.partition}:iam::${local.account_id}:root"]
}
actions = ["kms:*"]
resources = ["*"]
}
statement {
sid = "CloudTrailEncrypt"
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
actions = ["kms:GenerateDataKey*", "kms:DescribeKey"]
resources = ["*"]
condition {
test = "StringLike"
variable = "kms:EncryptionContext:aws:cloudtrail:arn"
values = ["arn:${data.aws_partition.current.partition}:cloudtrail:*:${local.account_id}:trail/*"]
}
}
# CloudWatch Logs encrypts the log group with this key, and it can only do that
# if the key policy names it. Without this statement the log group fails to
# create with a message about the key not existing.
statement {
sid = "CloudWatchLogsEncrypt"
effect = "Allow"
principals {
type = "Service"
identifiers = ["logs.${data.aws_region.current.name}.amazonaws.com"]
}
actions = [
"kms:Encrypt*",
"kms:Decrypt*",
"kms:ReEncrypt*",
"kms:GenerateDataKey*",
"kms:Describe*",
]
resources = ["*"]
condition {
test = "ArnLike"
variable = "kms:EncryptionContext:aws:logs:arn"
values = ["arn:${data.aws_partition.current.partition}:logs:${data.aws_region.current.name}:${local.account_id}:log-group:/aws/cloudtrail/${var.name}"]
}
}
statement {
sid = "ReadersDecrypt"
effect = "Allow"
principals {
type = "AWS"
identifiers = ["arn:${data.aws_partition.current.partition}:iam::${local.account_id}:root"]
}
actions = ["kms:Decrypt", "kms:ReEncryptFrom"]
resources = ["*"]
condition {
test = "StringEquals"
variable = "kms:CallerAccount"
values = [local.account_id]
}
}
}
# audit-log-immutable / aws / t1 "Standard"
#
# CloudTrail to an Object Lock bucket, encrypted with a customer-managed key,
# optionally as an organization trail, optionally mirrored to CloudWatch Logs.
#
# Verify, once logs arrive:
# terraform output verify
# The second command it prints must fail with AccessDenied. A delete without a
# version ID only adds a delete marker and succeeds, which proves nothing.
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
data "aws_region" "current" {}
locals {
account_id = data.aws_caller_identity.current.account_id
partition = data.aws_partition.current.partition
tags = merge({
owner = var.owner
environment = var.environment
data_classification = "restricted"
managed_by = "terraform"
}, var.extra_tags)
# Built from parts to avoid a cycle. The bucket policy needs the trail ARN and
# the trail needs the bucket policy, so a direct reference is a cycle.
trail_arn = "arn:${local.partition}:cloudtrail:${data.aws_region.current.name}:${local.account_id}:trail/${var.name}"
# Organization trails write under AWSLogs/<org-id>/, single-account trails
# under AWSLogs/<account-id>/.
log_prefixes = var.organization_trail ? [
"${aws_s3_bucket.audit.arn}/AWSLogs/${local.account_id}/*",
"${aws_s3_bucket.audit.arn}/AWSLogs/${data.aws_organizations_organization.current[0].id}/*",
] : [
"${aws_s3_bucket.audit.arn}/AWSLogs/${local.account_id}/*",
]
}
data "aws_organizations_organization" "current" {
count = var.organization_trail ? 1 : 0
}
# ---------------------------------------------------------------------------
# Storage
# ---------------------------------------------------------------------------
resource "aws_s3_bucket" "audit" {
bucket = "${var.name}-${local.account_id}"
object_lock_enabled = true
force_destroy = false
}
resource "aws_s3_bucket_versioning" "audit" {
bucket = aws_s3_bucket.audit.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_object_lock_configuration" "audit" {
bucket = aws_s3_bucket.audit.id
depends_on = [aws_s3_bucket_versioning.audit]
rule {
default_retention {
mode = var.lock_mode
days = var.retention_days
}
}
}
resource "aws_s3_bucket_public_access_block" "audit" {
bucket = aws_s3_bucket.audit.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "audit" {
bucket = aws_s3_bucket.audit.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.audit.arn
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_lifecycle_configuration" "audit" {
bucket = aws_s3_bucket.audit.id
depends_on = [aws_s3_bucket_versioning.audit]
rule {
id = "cold-storage-after-90-days"
status = "Enabled"
filter {}
transition {
days = 90
storage_class = "GLACIER_IR"
}
}
rule {
id = "abort-incomplete-uploads"
status = "Enabled"
filter {}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
}
data "aws_iam_policy_document" "bucket" {
statement {
sid = "AWSCloudTrailAclCheck"
effect = "Allow"
actions = ["s3:GetBucketAcl"]
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
resources = [aws_s3_bucket.audit.arn]
condition {
test = "StringEquals"
variable = "aws:SourceArn"
values = [local.trail_arn]
}
}
statement {
sid = "AWSCloudTrailWrite"
effect = "Allow"
actions = ["s3:PutObject"]
resources = local.log_prefixes
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "s3:x-amz-acl"
values = ["bucket-owner-full-control"]
}
condition {
test = "StringEquals"
variable = "aws:SourceArn"
values = [local.trail_arn]
}
}
statement {
sid = "DenyUnencryptedTransport"
effect = "Deny"
actions = ["s3:*"]
principals {
type = "AWS"
identifiers = ["*"]
}
resources = [aws_s3_bucket.audit.arn, "${aws_s3_bucket.audit.arn}/*"]
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
# Skipped until terraform_role_name is set. Applying it on the first run
# would deny the very principal creating the bucket.
dynamic "statement" {
for_each = var.terraform_role_name == "" ? [] : [1]
content {
sid = "DenyLifecycleAndLockTampering"
effect = "Deny"
actions = [
"s3:PutBucketObjectLockConfiguration",
"s3:PutLifecycleConfiguration",
"s3:PutBucketVersioning",
]
principals {
type = "AWS"
identifiers = ["*"]
}
resources = [aws_s3_bucket.audit.arn]
condition {
test = "ArnNotLike"
variable = "aws:PrincipalArn"
values = ["arn:${local.partition}:iam::${local.account_id}:role/${var.terraform_role_name}"]
}
}
}
}
resource "aws_s3_bucket_policy" "audit" {
bucket = aws_s3_bucket.audit.id
policy = data.aws_iam_policy_document.bucket.json
depends_on = [aws_s3_bucket_public_access_block.audit]
}
# ---------------------------------------------------------------------------
# CloudWatch Logs delivery. Optional, and the reason alarms can exist at all.
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_log_group" "audit" {
count = var.cloudwatch_logs ? 1 : 0
name = "/aws/cloudtrail/${var.name}"
retention_in_days = var.cloudwatch_retention_days
kms_key_id = aws_kms_key.audit.arn
}
data "aws_iam_policy_document" "trail_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "aws:SourceArn"
values = [local.trail_arn]
}
}
}
resource "aws_iam_role" "trail" {
count = var.cloudwatch_logs ? 1 : 0
name = "${var.name}-cloudwatch-delivery"
assume_role_policy = data.aws_iam_policy_document.trail_assume.json
}
data "aws_iam_policy_document" "trail_delivery" {
count = var.cloudwatch_logs ? 1 : 0
statement {
effect = "Allow"
actions = ["logs:CreateLogStream", "logs:PutLogEvents"]
resources = ["${aws_cloudwatch_log_group.audit[0].arn}:*"]
}
}
resource "aws_iam_role_policy" "trail_delivery" {
count = var.cloudwatch_logs ? 1 : 0
name = "delivery"
role = aws_iam_role.trail[0].id
policy = data.aws_iam_policy_document.trail_delivery[0].json
}
# ---------------------------------------------------------------------------
# Trail
# ---------------------------------------------------------------------------
resource "aws_cloudtrail" "audit" {
name = var.name
s3_bucket_name = aws_s3_bucket.audit.id
kms_key_id = aws_kms_key.audit.arn
include_global_service_events = true
is_multi_region_trail = true
is_organization_trail = var.organization_trail
enable_log_file_validation = true
cloud_watch_logs_group_arn = var.cloudwatch_logs ? "${aws_cloudwatch_log_group.audit[0].arn}:*" : null
cloud_watch_logs_role_arn = var.cloudwatch_logs ? aws_iam_role.trail[0].arn : null
depends_on = [aws_s3_bucket_policy.audit]
}
output "bucket" {
description = "Bucket holding the trail."
value = aws_s3_bucket.audit.id
}
output "trail_arn" {
description = "Trail ARN."
value = aws_cloudtrail.audit.arn
}
output "kms_key_arn" {
description = "Key encrypting the trail. Disabling it is the revocation lever."
value = aws_kms_key.audit.arn
}
output "lock_mode" {
description = "GOVERNANCE or COMPLIANCE, as applied."
value = var.lock_mode
}
output "verify" {
description = "Two commands. The second must fail with AccessDenied."
value = <<-EOT
aws s3api list-object-versions --bucket ${aws_s3_bucket.audit.id} --prefix AWSLogs/ --max-items 1 --query 'Versions[0].[Key,VersionId]' --output text
aws s3api delete-object --bucket ${aws_s3_bucket.audit.id} --key <Key from above> --version-id <VersionId from above>
EOT
}
variable "name" {
description = "Base name for the trail and bucket."
type = string
default = "audit-log-immutable"
validation {
condition = can(regex("^[a-z0-9-]{3,40}$", var.name))
error_message = "name must be 3-40 characters of lowercase letters, digits, or hyphens."
}
}
variable "retention_days" {
description = "Object Lock default retention. Set this to at least your longest expected dwell time."
type = number
default = 400
validation {
condition = var.retention_days >= 90
error_message = "retention_days must be 90 or more. Shorter than that, the log expires before most investigations start."
}
}
variable "lock_mode" {
description = "GOVERNANCE allows bypass by a principal holding s3:BypassGovernanceRetention. COMPLIANCE allows no bypass, including by the root user, for the full retention period."
type = string
default = "GOVERNANCE"
validation {
condition = contains(["GOVERNANCE", "COMPLIANCE"], var.lock_mode)
error_message = "lock_mode must be GOVERNANCE or COMPLIANCE."
}
}
variable "organization_trail" {
description = "Create an organization trail covering every member account. Requires the management or delegated administrator account with CloudTrail trusted access enabled."
type = bool
default = false
}
variable "cloudwatch_logs" {
description = "Also deliver events to CloudWatch Logs so metric filters and alarms can run against them."
type = bool
default = true
}
variable "cloudwatch_retention_days" {
description = "CloudWatch Logs retention. The S3 copy is the system of record, so this is the alerting window."
type = number
default = 90
}
variable "owner" {
description = "Tag contract: accountable team."
type = string
default = "security-engineering"
}
variable "environment" {
description = "Tag contract: environment."
type = string
default = "prod"
}
variable "extra_tags" {
description = "Additional tags merged over the contract."
type = map(string)
default = {}
}
variable "terraform_role_name" {
description = "IAM role name that Terraform assumes. When set, the bucket policy denies lock, lifecycle, and versioning changes to every other principal. Leave empty to skip that statement, which you should do on the first apply and then set."
type = string
default = ""
}
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
# Remote state. Uncomment and point at your own backend.
# Native S3 locking (use_lockfile) needs Terraform 1.10 or later and removes
# the DynamoDB table requirement.
#
# backend "s3" {
# bucket = "tfstate-security-prod"
# key = "audit-log-immutable/aws/terraform.tfstate"
# region = "us-east-1"
# encrypt = true
# kms_key_id = "alias/tfstate"
# use_lockfile = true
# }
}
provider "aws" {
default_tags {
tags = local.tags
}
}
HardenedPlanned
How to check it worked
Wait about fifteen minutes for the first logs, then run terraform output verify. It prints two commands. The first finds a stored log file and its version ID. The second tries to delete that version, and must fail with AccessDenied.
Object Lock can only be switched on at the moment a bucket is created. You cannot add it to the log bucket you already have. Moving to this means a new bucket and a copy, which is worth knowing before you plan the work.
A plain delete-object on this bucket reports success. The bucket is versioned, so that call only adds a delete marker, and the locked file is still there underneath it. The lock protects each stored version, so the only honest test deletes a version by its ID.
How to undo it
The quick start locks for one day, so after that terraform destroy empties and removes the bucket. The Standard version locks for 400 days. In GOVERNANCE mode a principal holding s3:BypassGovernanceRetention can delete sooner. In COMPLIANCE mode nobody can, and you wait it out.
What it costs
Storage for the logs, plus about a dollar a month for the KMS key. The first management trail in an account carries no CloudTrail charge.
Terraform. Activity Log writing to a blob container with a retention policy
What you need first
- Owner or Contributor on the subscription.
- Permission to create a diagnostic setting at subscription scope, which is separate from resource-level rights.
What it creates
- A resource group, a storage account, and the insights-activity-logs container
- A time-based immutability policy on that container
- A diagnostic setting sending eight activity log categories
- A Log Analytics workspace so KQL and alert rules have something to read (Standard, optional)
- A Key Vault with purge protection, a rotating key, and an identity the storage account uses to reach it (Standard)
The code
Quick startDraft
# audit-log-immutable / azure / t0 "Paste"
#
# Subscription Activity Log written to a blob container under a time-based
# immutability policy. Blobs cannot be deleted or overwritten for the period,
# including by a subscription Owner.
#
# Retention here is one day and the policy is unlocked, so you can try this in a
# scratch subscription and remove it. The Standard version defaults to 400 days.
#
# Run:
# terraform init && terraform apply
#
# Verify:
# az storage blob delete --account-name <account> --container-name insights-activity-logs --name <blob>
# Expect: this operation is not permitted on an immutable blob.
#
# Gotcha this template exists to handle: the diagnostic setting writes to a
# container Azure creates on first delivery, named insights-activity-logs.
# Creating that container up front is what lets the policy attach to it.
terraform {
required_version = ">= 1.6"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "azurerm" {
features {}
# Key-based access is switched off on the storage account below, so the
# provider has to use Entra ID for any data-plane call it makes.
storage_use_azuread = true
}
data "azurerm_subscription" "current" {}
resource "random_string" "suffix" {
length = 6
special = false
upper = false
}
locals {
tags = {
owner = "security-engineering"
environment = "prod"
data_classification = "restricted"
managed_by = "terraform"
}
}
resource "azurerm_resource_group" "audit" {
name = "rg-audit-log-immutable"
location = "eastus"
tags = local.tags
}
resource "azurerm_storage_account" "audit" {
name = "auditlog${random_string.suffix.result}"
resource_group_name = azurerm_resource_group.audit.name
location = azurerm_resource_group.audit.location
account_tier = "Standard"
account_replication_type = "GRS"
account_kind = "StorageV2"
min_tls_version = "TLS1_2"
https_traffic_only_enabled = true
allow_nested_items_to_be_public = false
shared_access_key_enabled = false
tags = local.tags
blob_properties {
versioning_enabled = true
}
}
resource "azurerm_storage_container" "activity" {
name = "insights-activity-logs"
storage_account_id = azurerm_storage_account.audit.id
container_access_type = "private"
}
# Unlocked, so it can be shortened or removed while you are testing.
#
# protected_append_writes_all_enabled matters more than it looks. Azure Monitor
# appends to one blob per hour. Without this flag the policy blocks those appends
# and delivery fails quietly.
resource "azurerm_storage_container_immutability_policy" "activity" {
# With storage_account_id set on the container, its id is the Resource
# Manager id this resource expects.
storage_container_resource_manager_id = azurerm_storage_container.activity.id
immutability_period_in_days = 1
protected_append_writes_all_enabled = true
locked = false
}
resource "azurerm_monitor_diagnostic_setting" "activity" {
name = "activity-to-immutable-blob"
target_resource_id = data.azurerm_subscription.current.id
storage_account_id = azurerm_storage_account.audit.id
enabled_log { category = "Administrative" }
enabled_log { category = "Security" }
enabled_log { category = "ServiceHealth" }
enabled_log { category = "Alert" }
enabled_log { category = "Policy" }
enabled_log { category = "Autoscale" }
enabled_log { category = "ResourceHealth" }
enabled_log { category = "Recommendation" }
}
output "storage_account" {
description = "Account holding the activity log."
value = azurerm_storage_account.audit.name
}
output "container" {
description = "Container under the immutability policy."
value = azurerm_storage_container.activity.name
}
output "verify" {
description = "Command that must fail."
value = "az storage blob delete --account-name ${azurerm_storage_account.audit.name} --container-name ${azurerm_storage_container.activity.name} --name <blob> --auth-mode login"
}
StandardDraft
# Customer-managed key for the log storage account.
#
# Three pieces are needed and each one is easy to forget: a vault with purge
# protection, a key, and an identity the storage account uses to reach that key.
# The identity is what lets you revoke access by disabling the key, without
# touching the data.
resource "azurerm_key_vault" "audit" {
name = "kv${substr(local.storage_name, 0, 20)}"
location = azurerm_resource_group.audit.location
resource_group_name = azurerm_resource_group.audit.name
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
# Storage will not accept a key from a vault without purge protection.
purge_protection_enabled = true
soft_delete_retention_days = 7
enable_rbac_authorization = true
tags = local.tags
}
# Whoever runs Terraform needs to create the key. On a first apply this role
# can take a minute to take effect, and the key creation can fail with 403.
# Running apply again is the fix.
resource "azurerm_role_assignment" "runner_crypto_officer" {
scope = azurerm_key_vault.audit.id
role_definition_name = "Key Vault Crypto Officer"
principal_id = data.azurerm_client_config.current.object_id
}
resource "azurerm_key_vault_key" "audit" {
name = "${var.name}-storage"
key_vault_id = azurerm_key_vault.audit.id
key_type = "RSA"
key_size = 3072
key_opts = ["wrapKey", "unwrapKey"]
rotation_policy {
automatic {
time_before_expiry = "P30D"
}
expire_after = "P1Y"
notify_before_expiry = "P29D"
}
depends_on = [azurerm_role_assignment.runner_crypto_officer]
}
resource "azurerm_user_assigned_identity" "storage" {
name = "id-${var.name}-storage"
location = azurerm_resource_group.audit.location
resource_group_name = azurerm_resource_group.audit.name
tags = local.tags
}
# The narrowest role that lets the storage account wrap and unwrap with the key.
resource "azurerm_role_assignment" "storage_key_user" {
scope = azurerm_key_vault.audit.id
role_definition_name = "Key Vault Crypto Service Encryption User"
principal_id = azurerm_user_assigned_identity.storage.principal_id
}
# audit-log-immutable / azure / t1 "Standard"
#
# Subscription Activity Log to an immutable blob container, optionally locked,
# with the storage account closed to the public network by default and a Log
# Analytics workspace for alerting.
data "azurerm_subscription" "current" {}
data "azurerm_client_config" "current" {}
locals {
storage_name = substr(replace(lower(var.name), "/[^a-z0-9]/", ""), 0, 24)
tags = merge({
owner = var.owner
environment = var.environment
data_classification = "restricted"
managed_by = "terraform"
}, var.extra_tags)
}
resource "azurerm_resource_group" "audit" {
name = "rg-${var.name}"
location = var.location
tags = local.tags
}
resource "azurerm_storage_account" "audit" {
name = local.storage_name
resource_group_name = azurerm_resource_group.audit.name
location = azurerm_resource_group.audit.location
account_tier = "Standard"
account_replication_type = "GRS"
account_kind = "StorageV2"
min_tls_version = "TLS1_2"
https_traffic_only_enabled = true
allow_nested_items_to_be_public = false
# Key-based auth is the usual path to a log store an attacker can read.
shared_access_key_enabled = false
# Infrastructure encryption adds a second encryption pass at the platform
# layer. It can only be set at creation.
infrastructure_encryption_enabled = true
tags = local.tags
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.storage.id]
}
# versionless_id means the storage account follows the key as it rotates.
customer_managed_key {
key_vault_key_id = azurerm_key_vault_key.audit.versionless_id
user_assigned_identity_id = azurerm_user_assigned_identity.storage.id
}
depends_on = [azurerm_role_assignment.storage_key_user]
blob_properties {
versioning_enabled = true
change_feed_enabled = true
delete_retention_policy {
days = 30
}
}
network_rules {
default_action = "Deny"
bypass = ["AzureServices", "Logging", "Metrics"]
ip_rules = var.allowed_ip_ranges
}
}
# Azure creates this container on first delivery. Declaring it up front is what
# lets the immutability policy attach before the first blob lands.
resource "azurerm_storage_container" "activity" {
name = "insights-activity-logs"
storage_account_id = azurerm_storage_account.audit.id
container_access_type = "private"
}
# Azure Monitor appends to one blob per hour. protected_append_writes_all_enabled
# is what lets those appends through a policy that forbids every other change.
resource "azurerm_storage_container_immutability_policy" "activity" {
storage_container_resource_manager_id = azurerm_storage_container.activity.id
immutability_period_in_days = var.retention_days
protected_append_writes_all_enabled = true
locked = var.lock_policy
}
resource "azurerm_log_analytics_workspace" "audit" {
count = var.log_analytics ? 1 : 0
name = "law-${var.name}"
resource_group_name = azurerm_resource_group.audit.name
location = azurerm_resource_group.audit.location
sku = "PerGB2018"
retention_in_days = var.log_analytics_retention_days
tags = local.tags
}
resource "azurerm_monitor_diagnostic_setting" "activity" {
name = "activity-to-immutable-blob"
target_resource_id = data.azurerm_subscription.current.id
storage_account_id = azurerm_storage_account.audit.id
log_analytics_workspace_id = var.log_analytics ? azurerm_log_analytics_workspace.audit[0].id : null
dynamic "enabled_log" {
for_each = var.log_categories
content {
category = enabled_log.value
}
}
}
output "storage_account" {
description = "Account holding the activity log."
value = azurerm_storage_account.audit.name
}
output "container" {
description = "Container under the immutability policy."
value = azurerm_storage_container.activity.name
}
output "policy_locked" {
description = "True means the retention period can only be extended, by anyone, ever."
value = var.lock_policy
}
output "workspace_id" {
description = "Log Analytics workspace, when enabled."
value = var.log_analytics ? azurerm_log_analytics_workspace.audit[0].id : null
}
output "verify" {
description = "Command that must fail."
value = "az storage blob delete --account-name ${azurerm_storage_account.audit.name} --container-name ${azurerm_storage_container.activity.name} --name <blob> --auth-mode login"
}
output "key_id" {
description = "Key encrypting the storage account. Disabling it is the revocation lever."
value = azurerm_key_vault_key.audit.versionless_id
}
variable "name" {
description = "Base name. Storage account name is derived from it and stripped to 24 lowercase alphanumeric characters."
type = string
default = "auditlogimmutable"
}
variable "location" {
description = "Azure region."
type = string
default = "eastus"
}
variable "retention_days" {
description = "Immutability period. Applies to every blob from the moment it is written."
type = number
default = 400
validation {
condition = var.retention_days >= 90 && var.retention_days <= 146000
error_message = "retention_days must be between 90 and 146000."
}
}
variable "lock_policy" {
description = "Lock the immutability policy. A locked policy cannot be shortened or removed by anyone, including a subscription Owner, and the period can only be extended. Leave false until the retention is agreed, because this is not reversible."
type = bool
default = false
}
variable "log_analytics" {
description = "Also send activity logs to a Log Analytics workspace so KQL and alert rules can run against them."
type = bool
default = true
}
variable "log_analytics_retention_days" {
description = "Workspace retention. The blob copy is the system of record, so this is the alerting window."
type = number
default = 90
}
variable "allowed_ip_ranges" {
description = "CIDRs allowed to reach the storage account. Everything else is denied. An empty list denies all public network access."
type = list(string)
default = []
}
variable "log_categories" {
description = "Activity log categories to deliver."
type = list(string)
default = [
"Administrative",
"Security",
"ServiceHealth",
"Alert",
"Policy",
"Autoscale",
"ResourceHealth",
"Recommendation",
]
}
variable "owner" {
description = "Tag contract: accountable team."
type = string
default = "security-engineering"
}
variable "environment" {
description = "Tag contract: environment."
type = string
default = "prod"
}
variable "extra_tags" {
description = "Additional tags merged over the contract."
type = map(string)
default = {}
}
terraform {
required_version = ">= 1.6"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
# Remote state. Uncomment and point at your own backend.
# backend "azurerm" {
# resource_group_name = "rg-tfstate"
# storage_account_name = "tfstatesecprod"
# container_name = "tfstate"
# key = "audit-log-immutable/azure.tfstate"
# use_azuread_auth = true
# }
}
provider "azurerm" {
features {
key_vault {
purge_soft_delete_on_destroy = false
}
}
# Key-based access is off on the storage account, so the provider has to use
# Entra ID for any data-plane call it makes.
storage_use_azuread = true
}
HardenedPlanned
How to check it worked
Wait for the first hourly blob to land in insights-activity-logs, then try to delete it with az storage blob delete. It must be refused as an operation not permitted on an immutable blob.
Azure creates the container itself on first delivery, named insights-activity-logs. If you name yours anything else, the policy protects an empty container and the real logs land unprotected next to it. Both templates declare that exact name up front.
Azure Monitor appends to one blob per hour. A retention policy blocks every change to a blob, appends included, unless protected append writes are allowed. Leave that flag off and delivery stops with no error anyone is likely to see.
How to undo it
The quick start policy is unlocked and lasts one day, so terraform destroy removes everything. A locked policy cannot be shortened or removed by anyone until the period expires. The Standard version also leaves the Key Vault soft-deleted for seven days, and its name cannot be reused until then.
What it costs
Blob storage for the logs, and a small monthly charge per Key Vault key plus its operations. The Log Analytics workspace is charged per gigabyte ingested and is the part worth watching.
Terraform. Organization log sink to a bucket with Bucket Lock
Nothing written for this platform yet. It stays listed so the gap shows on the coverage table.
Terraform. Audit service to Object Storage with a retention rule
Nothing written for this platform yet. It stays listed so the gap shows on the coverage table.