Skip to main content

Provision the Upload Archive with Terraform (Google Cloud Storage)

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

Configure File Upload Capture (Google Cloud Storage)

guide. It produces the same resources as the console walkthrough there (a hardened Cloud Storage bucket, an optional Cloud KMS customer-managed key, and the bucket-scoped IAM grant the Anzenna writer service account uses) in a single terraform apply.

The module also takes an enable_read_access variable, off by default. It grants the writer service account roles/storage.objectViewer so an Anzenna admin can open a captured item and decrypt it in the product UI. Leave it false to keep this destination write-only: capture and the offline decrypt tool in Part 5 work either way.

Before starting:

When the apply finishes, return to the Google Cloud Storage guide and continue at Part 3 (Register everything in Anzenna).

terraform {
required_version = ">= 1.5"

required_providers {
google = { source = "hashicorp/google", version = "~> 6.0" }
random = { source = "hashicorp/random", version = "~> 3.6" }
}
}

variable "project_id" {
type = string
description = "The GCP project that will own the archive bucket."
}

variable "bucket_location" {
type = string
default = "us-west1"
description = "Bucket location. Must also be a valid Cloud KMS location, since the CMEK key ring is created in the same location. For a multi-region bucket (e.g. US) use the matching KMS multi-region (e.g. us)."
}

variable "customer_label" {
type = string
description = "Short identifier. Becomes part of the bucket and key 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 "anzenna_writer_sa_email" {
type = string
default = "user-content@anzenna-live.iam.gserviceaccount.com"
description = "The Anzenna writer service account you grant write access to. Default is Anzenna's production (live) signer. Override per env with the value Anzenna support gives you."
}

variable "writer_role" {
type = string
default = "roles/storage.objectCreator"
description = "Role granted to the Anzenna writer SA, scoped to the bucket. objectCreator is create-only (cannot list, read, overwrite, or delete), which fits archival's write-once-with-unique-keys model. Switch to roles/storage.objectUser only if Anzenna must overwrite a stable path."
}

variable "object_expiration_days" {
type = number
default = 90
description = "Lifecycle expiry for live object versions."
}

variable "enable_cmek" {
type = bool
default = true
description = "Encrypt the bucket with a customer-managed Cloud KMS key. Set false for Google-managed encryption (still encrypted at rest, just not with your own key)."
}

variable "enable_read_access" {
type = bool
default = false
description = "Also grant the Anzenna writer SA roles/storage.objectViewer, scoped to the bucket, so Anzenna admins can open a captured item and decrypt it in the product UI. Optional: leave false to keep this destination write-only; the offline decrypt tool works either way."
}

provider "google" {
project = var.project_id
region = var.bucket_location
}

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

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

# Customer-managed key for encryption at rest. Key ring and key live in your
# project, in the same location as the bucket. 90-day rotation; never exported.
resource "google_kms_key_ring" "upload" {
count = var.enable_cmek ? 1 : 0
name = "${local.name}-keyring"
location = var.bucket_location
}

resource "google_kms_crypto_key" "upload" {
count = var.enable_cmek ? 1 : 0
name = "${local.name}-key"
key_ring = google_kms_key_ring.upload[0].id
rotation_period = "7776000s" # 90 days

# Destroying a CMEK key makes every object it encrypted permanently
# unrecoverable, so guard it. Flip to false only for a disposable test deploy.
lifecycle {
prevent_destroy = true
}
}

# The GCS service agent (not the writer SA) encrypts/decrypts objects at rest,
# so it, and only it, needs cryptoKeyEncrypterDecrypter on the key. The bucket
# depends on this grant existing first, or create fails.
data "google_storage_project_service_account" "gcs" {
count = var.enable_cmek ? 1 : 0
}

resource "google_kms_crypto_key_iam_member" "gcs_agent" {
count = var.enable_cmek ? 1 : 0
crypto_key_id = google_kms_crypto_key.upload[0].id
role = "roles/cloudkms.cryptoKeyEncrypterDecrypter"
member = "serviceAccount:${data.google_storage_project_service_account.gcs[0].email_address}"
}

# Upload bucket: uniform bucket-level access + public-access-prevention enforced
# (no ACLs, no public exposure possible), versioning on, lifecycle expiry.
resource "google_storage_bucket" "upload" {
name = local.bucket_name
location = var.bucket_location
storage_class = "STANDARD"
force_destroy = false
uniform_bucket_level_access = true
public_access_prevention = "enforced"

versioning {
enabled = true
}

dynamic "encryption" {
for_each = var.enable_cmek ? [1] : []
content {
default_kms_key_name = google_kms_crypto_key.upload[0].id
}
}

# Expire live objects after your retention window, and reap noncurrent
# versions 7 days after they're superseded.
lifecycle_rule {
action {
type = "Delete"
}
condition {
age = var.object_expiration_days
with_state = "LIVE"
}
}

lifecycle_rule {
action {
type = "Delete"
}
condition {
days_since_noncurrent_time = 7
with_state = "ARCHIVED"
}
}

depends_on = [google_kms_crypto_key_iam_member.gcs_agent]
}

# The single grant the whole onboarding hinges on: the Anzenna writer SA gets a
# write-only role, scoped to this bucket only, not the project. No read, list,
# or delete.
resource "google_storage_bucket_iam_member" "writer" {
bucket = google_storage_bucket.upload.name
role = var.writer_role
member = "serviceAccount:${var.anzenna_writer_sa_email}"
}

# Optional read grant, gated by enable_read_access. Lets the writer SA fetch
# an object back for in-app viewing; still bucket-scoped, not project-scoped.
resource "google_storage_bucket_iam_member" "reader" {
count = var.enable_read_access ? 1 : 0
bucket = google_storage_bucket.upload.name
role = "roles/storage.objectViewer"
member = "serviceAccount:${var.anzenna_writer_sa_email}"
}

output "bucket_name" {
value = google_storage_bucket.upload.name
}
  1. Save the file (e.g. main.tf) and create a terraform.tfvars with your project and label:

    project_id = "<your-project-id>" customer_label = "yourorg"

    If your tenant is not on production, add anzenna_writer_sa_email with the value Anzenna support gave you. Add enable_read_access = true only if you want Anzenna admins to be able to view captured items in the product UI.

  2. Authenticate:

    gcloud auth application-default login
  3. Run terraform init && terraform apply.

  4. Read the output you'll paste into Anzenna in Part 3:

    terraform output bucket_name

    The destination form maps as: Bucket name = bucket_name. Leave Object key prefix blank.

Domain-restricted sharing

If your organization enforces the iam.allowedPolicyMemberDomains org policy, the writer grant will fail until you add Anzenna's Google Workspace customer ID (C03htzkal) to the allowed list. See

2b in the Google Cloud Storage guide

. Do that first, then re-run terraform apply. :::

You're done with Google Cloud. Return to the

Google Cloud Storage setup guide

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


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