Skip to content

IAM

Identity and access management across GCP and AWS. Service accounts are managed in Terraform — human IAM should be audited periodically.

GCP service accounts

All defined in infrastructure/gcp/.

cnpg-backup-sa

Used by CNPG to write WAL + base backups to GCS.

GCP SA: cnpg-backup-sa@development-485000.iam.gserviceaccount.com
Workload Identity bound to:
  - data/postgres-dev   (K8s SA)
  - data/postgres-prod  (K8s SA)
Roles:
  - roles/storage.objectAdmin on gs://tomoda-db-backups-development-485000

The Cluster CR's serviceAccountTemplate annotates the per-cluster K8s SA with iam.gke.io/gcp-service-account: cnpg-backup-sa@..., which is what completes the Workload Identity link.

observability

Used by Grafana Tempo (trace blocks) and Grafana Loki (log chunks) to read/write the shared observability bucket. One SA, two Workload Identity bindings — Tempo and Loki share the same blast radius (the bucket) so there's no IAM win from splitting them.

GCP SA: observability@development-485000.iam.gserviceaccount.com
Workload Identity bound to:
  - monitoring/tempo  (K8s SA, created by the Tempo chart)
  - monitoring/loki   (K8s SA, created by the Loki chart)
Roles:
  - roles/storage.objectAdmin        on gs://tomoda-observability-development-485000
  - roles/storage.legacyBucketReader on gs://tomoda-observability-development-485000

legacyBucketReader adds storage.buckets.get (bucket-metadata read), which objectAdmin lacks. Tempo reads bucket attrs on startup and 403'd without it. Both charts annotate their KSA with iam.gke.io/gcp-service-account: observability@…. The bucket prefix split (tempo/ vs loki/) is enforced via the chart values — the IAM grant is at the bucket level.

Replaces the older Tempo-only tempo SA + tomoda-traces bucket pair. Terraform moved blocks rename the resources in place; no re-creation.

photon-indexer

Used by the photon-indexer image when it (eventually, once un-suspended) runs as a CronJob inside the cluster. Today the same SA is also used directly from the build VM during ad-hoc index builds.

Workload Identity bound to: photon-indexer K8s SA in the photon-indexer namespace
Roles:
  - roles/storage.objectAdmin on gs://<project-id>-photon-index-usc1

The Photon index bucket has a special allUsers reader binding — the OSM data is public, and the in-cluster Photon pod downloads it anonymously over HTTPS. This requires an organisation policy override (storage.publicAccessPrevention exception) on the project. Document this in any IAM audit so future operators don't try to "fix" it.

cloudbuild-worker-sa

Used by Cloud Build triggers to build images, push to Artifact Registry, and (optionally) interact with GKE.

GCP SA: cloudbuild-worker-sa@development-485000.iam.gserviceaccount.com
Roles:
  - roles/logging.logWriter        (write build logs)
  - roles/artifactregistry.writer  (push images)
  - roles/container.developer      (deploy to GKE, if used)
The default Cloud Build SA (287267207777@cloudbuild.gserviceaccount.com) has
roles/iam.serviceAccountUser on this SA so it can act-as during builds.

The SA does not have roles/secretmanager.secretAccessor today. If a build step ever needs a secret, grant the role narrowly to the specific secret resource — not project-wide.

argocd-image-updater-sa

Used by the Argo CD Image Updater to discover new image SHAs.

GCP SA: argocd-image-updater-sa@development-485000.iam.gserviceaccount.com
Workload Identity bound to: argocd/argocd-image-updater (K8s SA)
Roles:
  - roles/artifactregistry.reader (metadata + tags only)

Read-only on Artifact Registry. Image Updater never pushes images.

cost-exporter

Used by the cost-exporter to read the BigQuery billing export for GCP month-to-date spend.

GCP SA: cost-exporter@development-485000.iam.gserviceaccount.com
Workload Identity bound to: monitoring/cost-exporter (K8s SA)
Roles:
  - roles/bigquery.jobUser                       (run queries)
  - roles/bigquery.dataViewer (on billing_export dataset)  (read the export)

Defined in infrastructure/gcp/cost_exporter_iam.tf. No key — Workload Identity only.

gke-node-sa

The identity both GKE node pools run as (google_service_account.gke_nodes, wired via node_config.service_account in infrastructure/gcp/gke.tf).

GCP SA: gke-node-sa@development-485000.iam.gserviceaccount.com
Roles:
  - roles/logging.logWriter          (ship node + pod logs)
  - roles/monitoring.metricWriter    (ship node + pod metrics)
  - roles/artifactregistry.reader    (pull images on behalf of pods)

Dedicated, minimally privileged. Replaces the over-privileged default Compute Engine SA (287267207777-compute@developer.gserviceaccount.com), which nodes no longer run as. Keeping this role list short is important since every pod without an explicit Workload Identity binding inherits the node SA's access.

terraform-ci

The identity the terraform CI/CD pipeline runs as (infrastructure/gcp/cicd_wif.tf). GitHub Actions authenticates via Workload Identity Federation and impersonates this SA — no service-account key is ever stored in GitHub.

GCP SA: terraform-ci@development-485000.iam.gserviceaccount.com
Roles:
  - roles/editor                            (provision GKE, buckets, Cloud Build, AR)
  - roles/secretmanager.admin               (manage GCP SM secrets)
  - roles/resourcemanager.projectIamAdmin   (manage project IAM + create SAs)
Impersonated by:
  principalSet://…/attribute.repository/tomoda-labs/devops  (WIF, this repo only)

Broad by design (editor) for a single-project setup that provisions the whole GCP stack; tighten to a custom role if a least-privilege review calls for it. The WIF trust is scoped to assertion.repository == tomoda-labs/devops, so no other repo or fork can assume it. See Terraform CI/CD.

AWS IAM

The uploader + eso-reader users are defined in infrastructure/aws/iam_uploader.tf; the cost-explorer reader in infrastructure/aws/cost_explorer_iam.tf.

tomoda-uploader-{env}

Backend asset uploader. PutObject-only.

Path: /system/
Access key stored in: aws_secretsmanager_secret.s3_uploader_creds
Inline policy:
  Effect: Allow
  Action: [ s3:PutObject, s3:PutObjectAcl ]
  Resource: arn:aws:s3:::tomoda-assets-{env}/*

Note what is not in the policy:

  • No s3:GetObject (commented out in Terraform — backend reads via CloudFront)
  • No s3:DeleteObject
  • No s3:ListBucket

Compromised access keys can only write new objects into the assets bucket; they cannot enumerate, read back, or destroy.

tomoda-eso-reader-{env}

The IAM user ESO uses to read the uploader's credentials from AWS Secrets Manager.

Inline policy:
  Effect: Allow
  Action: secretsmanager:GetSecretValue
  Resource: <ARN of the uploader's secret only>

The reader can fetch exactly one secret. It can't list other secrets, can't write, can't decrypt anything else.

Its access keys live in the aws-eso-credentials K8s Secret in the external-secrets namespace (created once during setup — see Secrets Management).

tomoda-cost-explorer-reader-{env}

The IAM user the cost-exporter uses to read AWS month-to-date spend.

Path: /system/
Access key stored in: aws_secretsmanager_secret.cost_explorer_creds
Inline policy:
  Effect: Allow
  Action: ce:GetCostAndUsage
  Resource: "*"   (Cost Explorer actions do not support resource scoping)

Scoped to the single read action. The operator mirrors its key into GCP SM tomoda-aws-cost-explorer-creds so ESO projects it into monitoring — see Secrets Management.

Workload Identity bindings

Every workload that touches a GCP resource follows the same shape: a K8s SA in some namespace is bound to a GCP SA via Workload Identity, and the GCP SA holds the IAM role for the bucket/registry/etc. The full inventory of bindings (which KSA → which GSA → which resource) lives in Infrastructure → GCP → IAM.

Auditing

# Every binding on the GCP project
gcloud projects get-iam-policy development-485000 \
  --format=json > iam-audit.json

# Just owners + editors (these should be empty for humans)
gcloud projects get-iam-policy development-485000 \
  --flatten="bindings[].members" \
  --filter="bindings.role:roles/owner OR bindings.role:roles/editor" \
  --format="table(bindings.members,bindings.role)"

For AWS:

aws iam list-users --query 'Users[?starts_with(Path, `/system/`)].UserName'
aws iam list-attached-user-policies --user-name tomoda-uploader-prod
aws iam list-user-policies --user-name tomoda-uploader-prod

Any IAM change should land via Terraform — manual changes drift and will be reverted on the next terraform apply.