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.comDefault IAM bindings:
Google automatically grants permissions như:
roles/logging.logWriter— write logs to Cloud Loggingroles/storage.admin— read/write all GCS bucketsroles/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:
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-pullerSA — only cần read Cloud Source Repos - Step 2 (build Docker image):
image-builderSA — cần read Dockerfile, write intermediate files - Step 3 (push image):
image-pusherSA — 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 metadatasource.repositories.getIamPolicy— check if current identity có accesssource.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 metadataartifactregistry.files.create— push new images (more specific thanartifactregistry.writer)
Không cần:
storage.buckets.delete(too broad)logging.buckets.update(not related)
Step 2: Create Custom Service Accounts
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
# 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
doneStep 4: Update cloudbuild.yaml to Use Custom SAs
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:
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:
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:
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:
steps:
- name: 'gcr.io/cloud-builders/docker'
# No explicit serviceAccount specified — falls back to default
args: ['push', '...']Better:
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:
# 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.writer4. Regularly Audit Service Account Usage
Monitor tại SAs actually được sử dụng:
# 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:
gcloud iam service-accounts delete unused-sa@${PROJECT_ID}.iam.gserviceaccount.com5. 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:
# 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:
gcloud projects add-iam-policy-binding ${PROJECT_ID} \
--member=serviceAccount:image-pusher@${PROJECT_ID}.iam.gserviceaccount.com \
--role=roles/artifactregistry.writerService 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:
gcloud iam service-accounts create missing \
--display-name="Cloud Build Service Account"
# Update cloudbuild.yaml với correct SA nameCross-Project Service Account References
Nếu Cloud Build project khác với deployment target project, bạn phải:
- Create SA ở Cloud Build project
- Grant permissions ở target project (cross-project binding)
# 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.developerReal-world Scenario
Scenario: Secure Multi-Environment CI/CD
Setup:
- Build Project:
my-builds - Development cluster:
dev-project - Production cluster:
prod-project
Service Accounts:
# 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.developercloudbuild.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-pusherSA bị compromise, attacker chỉ push images — không thể deploy - If
dev-deployerbị compromise, attacker deploy to dev only — not prod - Prod deployment cần explicitly use
prod-deployerSA — extra safety gate