Skip to content

Service Accounts & Security: Least Privilege in Cloud Build

Tại sao lại quan trọng

Service accounts là security boundary của Cloud Build. Mỗi build step chạy với một service account, và service account đó có quyền gì thì step đó có quyền đó.

Điều này nghe đơn giản, nhưng reality phức tạp hơn:

  • Default Cloud Build service account ({PROJECT_ID}@cloudbuild.gserviceaccount.com) được tự động tạo, nhưng nó có quá nhiều permissions
  • Nếu không cẩn thận, bạn sẽ có một situation như: "Pull từ Cloud Source Repos" step chạy với account có quyền delete từ any GCS bucket ở project — mặc dù step đó chỉ cần read source code
  • Malicious code hoặc compromised build step có thể abuse những permissions không cần thiết

Trong production, least privilege là non-negotiable. Chương này dạy bạn cách thiết kế service account strategy sao cho mỗi step chỉ có exactly những permissions nó cần, không hơn không kém.


Default Cloud Build Service Account

Khi bạn enable Cloud Build ở một project, GCP tự động tạo:

{PROJECT_ID}@cloudbuild.gserviceaccount.com

Default IAM bindings:

Google automatically grants permissions như:

  • roles/logging.logWriter — write logs to Cloud Logging
  • roles/storage.admin — read/write all GCS buckets
  • roles/artifactregistry.createOnPushWriter — push images to Artifact Registry
  • Và một số roles khác khá broad

Tại sao broad?

Google thiết kế default SA này để "just work" cho most common use cases — build source, push image, deploy. Nhưng "just work" ngay từ đầu có cost: too many permissions.


Mental Model: Service Account Scope & Build Step Binding

Mỗi build step có thể specifies một service account:

yaml
steps:
  - name: 'gcr.io/cloud-builders/git'
    serviceAccount: 'projects/{PROJECT_ID}/serviceAccounts/source-puller@{PROJECT_ID}.iam.gserviceaccount.com'
    args: ['clone', 'https://...']
  
  - name: 'gcr.io/cloud-builders/docker'
    serviceAccount: 'projects/{PROJECT_ID}/serviceAccounts/image-builder@{PROJECT_ID}.iam.gserviceaccount.com'
    args: ['build', '-t', 'gcr.io/$PROJECT_ID/app:$SHORT_SHA', '.']
  
  - name: 'gcr.io/cloud-builders/docker'
    serviceAccount: 'projects/{PROJECT_ID}/serviceAccounts/image-pusher@{PROJECT_ID}.iam.gserviceaccount.com'
    args: ['push', 'gcr.io/$PROJECT_ID/app:$SHORT_SHA']

Binding model:

Mỗi step execute với exactly one service account. Nếu không specify, falls back to default Cloud Build SA. Nhưng nếu bạn specify custom SA, step runs với only those permissions.

Key insight: Bạn có thể use different service accounts cho different steps. Ví dụ:

  • Step 1 (clone source): source-puller SA — only cần read Cloud Source Repos
  • Step 2 (build Docker image): image-builder SA — cần read Dockerfile, write intermediate files
  • Step 3 (push image): image-pusher SA — cần write to Artifact Registry

Điều này implement least privilege at step level — không có single "God service account" với all permissions.


Designing Least Privilege Service Accounts

Step 1: Identify Required Permissions

Trước khi create custom SA, phải identify exactly what permissions each step needs.

Example: "Clone source code from Cloud Source Repos"

Minimal permissions:

  • source.repositories.get — retrieve repo metadata
  • source.repositories.getIamPolicy — check if current identity có access
  • source.repositories.list — list available repos

Không cần:

  • Storage permissions
  • KMS permissions
  • Any compute permissions

Example: "Build Docker image and push to Artifact Registry"

Minimal permissions:

  • artifactregistry.repositories.get — get repository metadata
  • artifactregistry.files.create — push new images (more specific than artifactregistry.writer)

Không cần:

  • storage.buckets.delete (too broad)
  • logging.buckets.update (not related)

Step 2: Create Custom Service Accounts

bash
gcloud iam service-accounts create source-puller \
  --display-name="Cloud Build: Source Code Puller"

gcloud iam service-accounts create image-builder \
  --display-name="Cloud Build: Docker Image Builder"

gcloud iam service-accounts create image-pusher \
  --display-name="Cloud Build: Image Pusher to Artifact Registry"

Step 3: Grant Minimal IAM Roles

bash
# source-puller SA — read Cloud Source Repos
gcloud projects add-iam-policy-binding ${PROJECT_ID} \
  --member=serviceAccount:source-puller@${PROJECT_ID}.iam.gserviceaccount.com \
  --role=roles/source.reader

# image-builder SA — read workspace, write Dockerfile metadata
# (typically doesn't need GCP API permissions, since Docker build runs locally)

# image-pusher SA — push to Artifact Registry
gcloud projects add-iam-policy-binding ${PROJECT_ID} \
  --member=serviceAccount:image-pusher@${PROJECT_ID}.iam.gserviceaccount.com \
  --role=roles/artifactregistry.writer

# All SAs — write logs (mandatory)
for SA in source-puller image-builder image-pusher; do
  gcloud projects add-iam-policy-binding ${PROJECT_ID} \
    --member=serviceAccount:${SA}@${PROJECT_ID}.iam.gserviceaccount.com \
    --role=roles/logging.logWriter
done

Step 4: Update cloudbuild.yaml to Use Custom SAs

yaml
steps:
  - name: 'gcr.io/cloud-builders/git'
    serviceAccount: 'projects/${PROJECT_ID}/serviceAccounts/source-puller@${PROJECT_ID}.iam.gserviceaccount.com'
    args: ['clone', 'https://...']
  
  - name: 'gcr.io/cloud-builders/docker'
    serviceAccount: 'projects/${PROJECT_ID}/serviceAccounts/image-builder@${PROJECT_ID}.iam.gserviceaccount.com'
    args: ['build', '-t', 'gcr.io/$PROJECT_ID/app:$SHORT_SHA', '.']
  
  - name: 'gcr.io/cloud-builders/docker'
    serviceAccount: 'projects/${PROJECT_ID}/serviceAccounts/image-pusher@${PROJECT_ID}.iam.gserviceaccount.com'
    args: ['push', 'gcr.io/$PROJECT_ID/app:$SHORT_SHA']

images: ['gcr.io/$PROJECT_ID/app:$SHORT_SHA']

Common Least Privilege Patterns

Pattern 1: Separate Source & Build & Push

Tách 3 roles:

yaml
steps:
  - name: 'gcr.io/cloud-builders/git'
    serviceAccount: '...-source-reader'
    args: ['clone', '...']
  
  - name: 'gcr.io/cloud-builders/docker'
    serviceAccount: '...-builder'  # doesn't need Cloud APIs for docker build
    args: ['build', '...']
  
  - name: 'gcr.io/cloud-builders/docker'
    serviceAccount: '...-pusher'
    args: ['push', '...']

Rationale: Nếu docker build step bị compromise (malicious Dockerfile), attacker chỉ có thể write to workspace — không thể push malicious image (vì step này không có image-pusher SA). Pushing step là separate, chạy với different SA.

Pattern 2: Separate Per-Environment Deployment

Nếu bạn deploy tới multiple environments (dev, staging, prod), use different SAs per environment:

yaml
steps:
  # ... build steps ...
  
  - name: 'gcr.io/cloud-builders/kubectl'
    serviceAccount: 'projects/${PROJECT_ID}/serviceAccounts/deployer-dev@${PROJECT_ID}.iam.gserviceaccount.com'
    args:
      - 'set'
      - 'image'
      - 'deployment/app'
      - 'app=gcr.io/$PROJECT_ID/app:$SHORT_SHA'
      - '--namespace=dev'
    env:
      - 'CLOUDSDK_COMPUTE_ZONE=us-central1-a'
      - 'CLOUDSDK_CONTAINER_CLUSTER=dev-cluster'

Rationale: deployer-dev SA chỉ có quyền update resources trong dev namespace. Nếu build bị compromise, attacker không thể deploy tới production (different SA, different namespace permissions).

Pattern 3: Key Management with KMS

Nếu build cần decrypt secrets:

yaml
steps:
  - name: 'gcr.io/cloud-builders/gke-deploy'
    serviceAccount: 'projects/${PROJECT_ID}/serviceAccounts/deployer@${PROJECT_ID}.iam.gserviceaccount.com'
    # This SA cần:
    # - cloudkms.cryptoKeyVersions.useToDecrypt (chỉ for specific key)
    # - container.deployments.update
    # Nhưng KHÔNG cần storage.buckets.delete, logging.buckets.update, etc.
    args: ['run', '--filename=k8s/', ...]

Service Account Best Practices

1. Never Use Default Cloud Build SA for Production

Default SA quá broad. Luôn create custom SAs cho production builds.

Anti-pattern:

yaml
steps:
  - name: 'gcr.io/cloud-builders/docker'
    # No explicit serviceAccount specified — falls back to default
    args: ['push', '...']

Better:

yaml
steps:
  - name: 'gcr.io/cloud-builders/docker'
    serviceAccount: 'projects/${PROJECT_ID}/serviceAccounts/image-pusher@${PROJECT_ID}.iam.gserviceaccount.com'
    args: ['push', '...']

2. Use Descriptive SA Names

Tên như builder-1, sa-temp không giúp. Use names rõ ràng:

  • source-code-puller (pulling từ CSR)
  • artifact-registry-pusher (pushing images)
  • gke-deployer-prod (deploy to production GKE)
  • cloud-run-deployer-staging (deploy to staging Cloud Run)

3. Bind Permissions at Lowest Possible Scope

Không bind ở project level nếu có thể bind ở resource level:

bash
# Good: specific repo permissions
gcloud source repos add-iam-policy-binding myrepo \
  --member=serviceAccount:source-puller@${PROJECT_ID}.iam.gserviceaccount.com \
  --role=roles/source.reader

# Better: specific artifact registry repository
gcloud artifacts repositories add-iam-policy-binding my-repo \
  --location=us-central1 \
  --member=serviceAccount:image-pusher@${PROJECT_ID}.iam.gserviceaccount.com \
  --role=roles/artifactregistry.writer

4. Regularly Audit Service Account Usage

Monitor tại SAs actually được sử dụng:

bash
# View logs của specific SA
gcloud logging read "protoPayload.authenticationInfo.principalEmail='image-pusher@${PROJECT_ID}.iam.gserviceaccount.com'" \
  --limit=100 --format=json | jq '.[] | {timestamp, methodName, resourceName}'

Xoá SAs không dùng:

bash
gcloud iam service-accounts delete unused-sa@${PROJECT_ID}.iam.gserviceaccount.com

5. Use Workload Identity For GKE Deployments

Nếu Cloud Build step deploy tới GKE, dùng Workload Identity (từ Chương 13) để map Cloud Build SA → Kubernetes SA:

bash
# Bind Cloud Build SA to k8s SA
gcloud iam service-accounts add-iam-policy-binding deployer@${PROJECT_ID}.iam.gserviceaccount.com \
  --role roles/iam.workloadIdentityUser \
  --member "serviceAccount:${PROJECT_ID}.svc.id.goog[default/deployer]"

Ưu điểm: Build không cần long-lived credentials hoặc kubeconfig files.


Constraints & Failure Modes

Permission Denied Errors

Error message:

ERROR: (gcloud.builds.submit) User [user@example.com] does not have permission [...] on resource [...]

Diagnosis: Service account (image-pusher) không có required permission.

Solution:

bash
gcloud projects add-iam-policy-binding ${PROJECT_ID} \
  --member=serviceAccount:image-pusher@${PROJECT_ID}.iam.gserviceaccount.com \
  --role=roles/artifactregistry.writer

Service Account Not Found

Error:

Service account projects/123456/serviceAccounts/missing@123456.iam.gserviceaccount.com does not exist.

Diagnosis: Tên SA sai hoặc SA chưa được create.

Solution:

bash
gcloud iam service-accounts create missing \
  --display-name="Cloud Build Service Account"

# Update cloudbuild.yaml với correct SA name

Cross-Project Service Account References

Nếu Cloud Build project khác với deployment target project, bạn phải:

  1. Create SA ở Cloud Build project
  2. Grant permissions ở target project (cross-project binding)
bash
# Create SA ở build project
gcloud iam service-accounts create deployer \
  --project=build-project

# Grant permission ở production project to use that SA
gcloud projects add-iam-policy-binding prod-project \
  --member=serviceAccount:deployer@build-project.iam.gserviceaccount.com \
  --role=roles/container.developer

Real-world Scenario

Scenario: Secure Multi-Environment CI/CD

Setup:

  • Build Project: my-builds
  • Development cluster: dev-project
  • Production cluster: prod-project

Service Accounts:

bash
# In my-builds project
gcloud iam service-accounts create source-reader --project=my-builds
gcloud iam service-accounts create image-builder --project=my-builds
gcloud iam service-accounts create image-pusher --project=my-builds
gcloud iam service-accounts create dev-deployer --project=my-builds
gcloud iam service-accounts create prod-deployer --project=my-builds

# Grant permissions in my-builds
gcloud source repos add-iam-policy-binding myrepo \
  --member=serviceAccount:source-reader@my-builds.iam.gserviceaccount.com \
  --role=roles/source.reader

gcloud artifacts repositories add-iam-policy-binding images \
  --location=us-central1 \
  --member=serviceAccount:image-pusher@my-builds.iam.gserviceaccount.com \
  --role=roles/artifactregistry.writer

# Cross-project: grant dev-deployer permission in dev-project
gcloud projects add-iam-policy-binding dev-project \
  --member=serviceAccount:dev-deployer@my-builds.iam.gserviceaccount.com \
  --role=roles/container.developer

# Cross-project: grant prod-deployer permission in prod-project (but ONLY to prod resources)
gcloud projects add-iam-policy-binding prod-project \
  --member=serviceAccount:prod-deployer@my-builds.iam.gserviceaccount.com \
  --role=roles/container.developer

cloudbuild.yaml:

yaml
steps:
  - name: 'gcr.io/cloud-builders/git'
    serviceAccount: 'projects/my-builds/serviceAccounts/source-reader@my-builds.iam.gserviceaccount.com'
    args: ['clone', 'https://...']
  
  - name: 'gcr.io/cloud-builders/docker'
    serviceAccount: 'projects/my-builds/serviceAccounts/image-builder@my-builds.iam.gserviceaccount.com'
    args: ['build', '-t', 'us-central1-docker.pkg.dev/my-builds/images/app:$SHORT_SHA', '.']
  
  - name: 'gcr.io/cloud-builders/docker'
    serviceAccount: 'projects/my-builds/serviceAccounts/image-pusher@my-builds.iam.gserviceaccount.com'
    args: ['push', 'us-central1-docker.pkg.dev/my-builds/images/app:$SHORT_SHA']
  
  - name: 'gcr.io/cloud-builders/kubectl'
    serviceAccount: 'projects/my-builds/serviceAccounts/dev-deployer@my-builds.iam.gserviceaccount.com'
    args: ['set', 'image', 'deployment/app', 'app=us-central1-docker.pkg.dev/my-builds/images/app:$SHORT_SHA', '--namespace=default']
    env:
      - 'CLOUDSDK_COMPUTE_ZONE=us-central1-a'
      - 'CLOUDSDK_CONTAINER_CLUSTER=dev-cluster'
  
  - name: 'gcr.io/cloud-builders/kubectl'
    serviceAccount: 'projects/my-builds/serviceAccounts/prod-deployer@my-builds.iam.gserviceaccount.com'
    args: ['set', 'image', 'deployment/app', 'app=us-central1-docker.pkg.dev/my-builds/images/app:$SHORT_SHA', '--namespace=default']
    env:
      - 'CLOUDSDK_COMPUTE_ZONE=us-central1-a'
      - 'CLOUDSDK_CONTAINER_CLUSTER=prod-cluster'

Benefits:

  • If image-pusher SA bị compromise, attacker chỉ push images — không thể deploy
  • If dev-deployer bị compromise, attacker deploy to dev only — not prod
  • Prod deployment cần explicitly use prod-deployer SA — extra safety gate

References