Skip to main content

Provision the Upload Archive with Terraform (AWS)

This page is the infrastructure-as-code alternative to Part 2 of the

Configure File Upload Capture

guide. It produces the same AWS resources as the console walkthrough there — a customer-managed KMS key, an S3 bucket configured for SSE-KMS, and a cross-account IAM role Anzenna assumes — in a single terraform apply.

Before starting, complete Part 1 in the main guide (generate an age recipient keypair). When you finish the apply below, return to the main guide and continue at Part 3 (Register everything in Anzenna).

The Terraform below provisions everything in one shot: a customer-managed KMS key, an S3 bucket (versioning + SSE-KMS + Bucket Keys + public access blocked + TLS-only + SSE-KMS-only PutObject), and the cross-account IAM role Anzenna assumes. Anzenna's federation role only assumes roles whose name starts with anzenna-upload-, so the role's name is built from a customer_label you provide.

terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
random = { source = "hashicorp/random", version = "~> 3.0" }
}
}

variable "region" {
type = string
default = "us-west-2"
}

variable "customer_label" {
type = string
description = "Short identifier for this deployment. Becomes part of the IAM role, KMS alias, and bucket names. Lowercase alphanumeric or hyphens, 2-32 chars."

validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$", var.customer_label))
error_message = "customer_label must be lowercase alphanumeric or hyphens, 2-32 chars, not starting/ending with a hyphen."
}
}

variable "federation_role_arn" {
type = string
description = "ARN of the Anzenna federation role that will assume this upload role."
default = "arn:aws:iam::590183739574:role/anzenna-upload-archival"
}

variable "object_key_prefix" {
type = string
default = "uploads/"
}

provider "aws" {
region = var.region
}

data "aws_caller_identity" "current" {}

resource "random_id" "bucket_suffix" {
byte_length = 4
}

resource "random_uuid" "external_id" {}

locals {
name = "anzenna-upload-${var.customer_label}"
bucket_name = "${local.name}-${random_id.bucket_suffix.hex}"
}

# IAM role Anzenna assumes. Trust policy pins the federation role + External ID.
data "aws_iam_policy_document" "upload_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]

principals {
type = "AWS"
identifiers = [var.federation_role_arn]
}

condition {
test = "StringEquals"
variable = "sts:ExternalId"
values = [random_uuid.external_id.result]
}
}
}

resource "aws_iam_role" "upload" {
name = local.name
assume_role_policy = data.aws_iam_policy_document.upload_trust.json
max_session_duration = 3600
}

# Role can only PutObject under the configured prefix, and only with SSE-KMS
# using this bucket's specific CMK. Both conditions are required.
data "aws_iam_policy_document" "upload_permissions" {
statement {
effect = "Allow"
actions = ["s3:PutObject"]
resources = ["${aws_s3_bucket.upload.arn}/${var.object_key_prefix}*"]

condition {
test = "StringEquals"
variable = "s3:x-amz-server-side-encryption"
values = ["aws:kms"]
}

condition {
test = "StringEquals"
variable = "s3:x-amz-server-side-encryption-aws-kms-key-id"
values = [aws_kms_key.upload.arn]
}
}
}

resource "aws_iam_role_policy" "upload" {
name = "put-objects"
role = aws_iam_role.upload.id
policy = data.aws_iam_policy_document.upload_permissions.json
}

# KMS CMK. The key policy must grant the upload role explicit access, in
# addition to the IAM policy above — KMS requires both.
data "aws_iam_policy_document" "kms_policy" {
statement {
sid = "EnableRootPermissions"
effect = "Allow"
actions = ["kms:*"]
resources = ["*"]

principals {
type = "AWS"
identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"]
}
}

statement {
sid = "AllowUploadRoleEncrypt"
effect = "Allow"
actions = ["kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"]
resources = ["*"]

principals {
type = "AWS"
identifiers = [aws_iam_role.upload.arn]
}
}
}

resource "aws_kms_key" "upload" {
description = "Anzenna upload archive (${var.customer_label})"
deletion_window_in_days = 7
enable_key_rotation = true
policy = data.aws_iam_policy_document.kms_policy.json
}

resource "aws_kms_alias" "upload" {
name = "alias/${local.name}"
target_key_id = aws_kms_key.upload.key_id
}

# S3 bucket: versioned, public access blocked, default SSE-KMS + Bucket Keys.
resource "aws_s3_bucket" "upload" {
bucket = local.bucket_name
}

resource "aws_s3_bucket_versioning" "upload" {
bucket = aws_s3_bucket.upload.id
versioning_configuration {
status = "Enabled"
}
}

resource "aws_s3_bucket_public_access_block" "upload" {
bucket = aws_s3_bucket.upload.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" "upload" {
bucket = aws_s3_bucket.upload.id

rule {
bucket_key_enabled = true
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.upload.arn
}
}
}

# Bucket policy: require TLS and reject any PutObject that isn't SSE-KMS.
data "aws_iam_policy_document" "bucket_policy" {
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
actions = ["s3:*"]
resources = [aws_s3_bucket.upload.arn, "${aws_s3_bucket.upload.arn}/*"]

principals {
type = "*"
identifiers = ["*"]
}

condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}

statement {
sid = "DenyWrongSSE"
effect = "Deny"
actions = ["s3:PutObject"]
resources = ["${aws_s3_bucket.upload.arn}/*"]

principals {
type = "*"
identifiers = ["*"]
}

condition {
test = "StringNotEqualsIfExists"
variable = "s3:x-amz-server-side-encryption"
values = ["aws:kms"]
}
}
}

resource "aws_s3_bucket_policy" "upload" {
bucket = aws_s3_bucket.upload.id
policy = data.aws_iam_policy_document.bucket_policy.json
}

output "upload_role_arn" {
value = aws_iam_role.upload.arn
}

output "external_id" {
value = random_uuid.external_id.result
sensitive = true
}

output "bucket_name" {
value = aws_s3_bucket.upload.id
}

output "region" {
value = var.region
}

output "kms_key_arn" {
value = aws_kms_key.upload.arn
}

output "object_key_prefix" {
value = var.object_key_prefix
}
  1. Save the file (e.g. main.tf) and create a terraform.tfvars with your customer_label:

    customer_label = "yourorg"
  2. Run terraform init && terraform apply.

  3. Read the outputs you'll paste into Anzenna in Part 3 — the External ID is marked sensitive so it requires an explicit fetch:

    terraform output terraform output -raw external_id

You're done with AWS. Return to the

main setup guide

and continue at Part 3 (Register everything in Anzenna).


Need help? Contact Anzenna Support at support@anzenna.ai for assistance.