Skip to content

IAM

The AWS account holds exactly two IAM users per environment, both defined in iam_uploader.tf. Neither is a human; both are service identities consumed by workloads running on GKE.

flowchart LR
    A[Backend pod<br/>tomoda API] -- PutObject --> B[(S3 bucket<br/>tomoda-assets-env)]
    A -. reads creds .-> C[K8s Secret]
    D[External Secrets Operator] -- GetSecretValue --> E[AWS Secrets Manager]
    D -- projects --> C
    E -. populated by .-> F[uploader IAM access key]
    G[tomoda-eso-reader-env<br/>IAM user] --> D
    F --> H[tomoda-uploader-env<br/>IAM user]

tomoda-uploader-{env}

This is the identity the tomoda backend assumes when it writes user-generated content (avatars, chat images, anything the API persists to S3). Its policy is PutObject-only:

data "aws_iam_policy_document" "uploader_policy" {
  statement {
    sid    = "AllowS3Upload"
    effect = "Allow"
    actions = [
      "s3:PutObject",
      "s3:PutObjectAcl",
    ]
    resources = [
      "${aws_s3_bucket.static_assets.arn}/*",
    ]
  }
}

Notably absent:

  • No s3:ListBucket — a leaked credential cannot enumerate existing objects.
  • No s3:DeleteObject — the backend cannot remove or overwrite previously uploaded content (it can only PutObject to the same key, which AWS treats as an overwrite, but with versioning off there is no soft-delete recovery either way).
  • No s3:GetObject — the backend never reads back from S3 directly; it links clients to the CloudFront URL. (There is a commented-out GetObject in iam_uploader.tf documenting the intentional exclusion.)

Credentials are persisted into AWS Secrets Manager as a JSON blob, scoped to that environment:

resource "aws_secretsmanager_secret" "s3_uploader_creds" {
  name = "${var.project_name}-s3-uploader-${var.environment}"
}

resource "aws_secretsmanager_secret_version" "s3_uploader_creds" {
  secret_string = jsonencode({
    AWS_ACCESS_KEY_ID     = aws_iam_access_key.uploader_key.id
    AWS_SECRET_ACCESS_KEY = aws_iam_access_key.uploader_key.secret
    S3_BUCKET             = aws_s3_bucket.static_assets.id
    S3_REGION             = var.aws_region
    S3_BASE_URL           = "https://${local.full_domain_name}"
  })
}

The backend only ever sees the projected Kubernetes Secret — see Secrets Management for the projection mechanism.

tomoda-eso-reader-{env}

This is the identity External Secrets Operator (ESO) uses to pull the uploader credentials out of AWS Secrets Manager and into a Kubernetes Secret. Its policy is the absolute minimum needed for that one job:

resource "aws_iam_user_policy" "eso_reader_policy" {
  name = "secrets-manager-read-only"
  user = aws_iam_user.eso_reader.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["secretsmanager:GetSecretValue"]
        Resource = [aws_secretsmanager_secret.s3_uploader_creds.arn]
      }
    ]
  })
}

Scoped to a single Resource ARN — the uploader secret for that environment. The ESO reader for dev cannot read the prod uploader's creds and vice versa.

The reader's own access key is fed into the cluster via a bootstrap secret created out-of-band (you cannot bootstrap ESO with ESO). The eso_reader_access_key_id and eso_reader_secret_access_key outputs in iam_uploader.tf exist for exactly that bootstrap moment.

Why two users, not one

The uploader needs S3 write but no Secrets Manager read; ESO needs Secrets Manager read but no S3 anything. Splitting the identities means each blast radius is bounded by the smallest verb set that gets the job done. A compromised uploader credential cannot exfiltrate other secrets; a compromised ESO reader credential cannot write to or delete from S3.

Rotation

There is no automated rotation today. Both access keys are created once by Terraform and live until someone runs terraform apply after tainting the aws_iam_access_key resource. If a credential needs rotating manually:

  1. terraform taint aws_iam_access_key.uploader_key (or .eso_reader_key).
  2. terraform apply — generates a new key.
  3. For the uploader, ESO syncs the new value into the cluster within its refresh interval.
  4. For the ESO reader, the cluster-side bootstrap secret must be updated manually.

This is acceptable given the small surface but should move to scheduled rotation (or IRSA-style federated identities) if AWS usage ever expands.