Skip to content

Runbook: grant log-read access to the deployer service account

Who runs this: a human with resourcemanager.projects.setIamPolicy on ordermatic-prod. Agents cannot grant themselves IAM.

github-deployer@ordermatic-prod.iam.gserviceaccount.com is the account to impersonate for gcloud work here β€” user credentials expire constantly and the deployer is stable. It already covers Compute and IAP, which is why SSH-to-VM and deploy flows work.

It does not cover Cloud Logging. Every attempt to read logs as the deployer fails with a logging.logEntries.list permission error. Since Coolify was retired and the platform moved to Cloud Run, Cloud Logging is where all service logs live β€” so this gap blocks routine diagnosis, and it gets rediscovered from scratch each time as β€œthe deployer is broken.”

roles/logging.viewer is the right role: it grants logging.logEntries.list (plus logging.logs.list and log-metric reads) β€” enough to read normal service logs from the _Default and _Required buckets. Do not use roles/logging.admin (adds sink/bucket mutation) or roles/viewer (project-wide read of everything).

It does not grant access to private or Data Access audit logs; those need roles/logging.privateLogViewer. Don’t add that unless something specifically needs it β€” Cloud Run service logs don’t.

You need resourcemanager.projects.getIamPolicy and setIamPolicy to run the grant (e.g. roles/resourcemanager.projectIamAdmin). If you don’t have them, this is an ask for whoever owns the project’s IAM.

Two more prerequisites, both of which fail in ways that look like the grant didn’t work:

  • You need iam.serviceAccounts.getAccessToken on the deployer SA (roles/iam.serviceAccountTokenCreator) to run the impersonated verification below. Without it the verify step fails on impersonation, not on logging.
  • The deployer SA needs serviceusage.services.use (roles/serviceusage.serviceUsageConsumer) on the project for gcloud logging read to resolve a quota project. Without it the read fails even with roles/logging.viewer correctly bound.

Read the error text rather than assuming the logging grant is at fault: each of these names a different permission.

Terminal window
gcloud projects add-iam-policy-binding ordermatic-prod \
--member="serviceAccount:github-deployer@ordermatic-prod.iam.gserviceaccount.com" \
--role="roles/logging.viewer" \
--condition=None

--condition=None explicitly requests an unconditioned binding. The flag is optional and omitting it also creates an unconditioned binding; passing it states the intent, and keeps the grant and the rollback below referring to the same binding.

If custom log views are in use, roles/logging.viewAccessor on the specific view is the narrower alternative β€” prefer it if it covers the logs you need. (roles/logging.privateLogViewer is a different thing again: private logs such as Data Access audit logs.)

Read a log entry as the deployer, not as yourself. Impersonation is the whole point of the check:

Terminal window
gcloud logging read \
'resource.type="cloud_run_revision"' \
--project=ordermatic-prod --limit=1 --freshness=7d \
--impersonate-service-account=github-deployer@ordermatic-prod.iam.gserviceaccount.com

--freshness matters: it defaults to 1d, so a quiet service returns zero entries on a working grant and reads as failure. Distinguish the two by the error, not the row count β€” PERMISSION_DENIED naming logging.logEntries.list is the failure; empty output with no error is success against a quiet service.

If it does deny, confirm the binding actually landed:

Terminal window
gcloud projects get-iam-policy ordermatic-prod \
--flatten="bindings[].members" \
--filter="bindings.members=serviceAccount:github-deployer@ordermatic-prod.iam.gserviceaccount.com AND bindings.role=roles/logging.viewer" \
--format="value(bindings.role)"

Use = (exact match), not : (pattern match) β€” a : filter can match a similarly-named principal or role and report a binding you don’t have.

Empty output means no binding, regardless of what add-iam-policy-binding printed. IAM changes usually propagate within ~2 minutes but can take 7 minutes or more; if the policy shows the role and reads still fail, wait before re-granting.

Only if the grant created the binding. Removal is unconditional β€” it strips the member/role pair regardless of who added it or when, so running it against a binding that predated you revokes access someone else depends on. Record the pre-grant state before step 1 by running the policy check from Verify: empty output then means the binding was absent and this rollback is safe.

Terminal window
gcloud projects remove-iam-policy-binding ordermatic-prod \
--member="serviceAccount:github-deployer@ordermatic-prod.iam.gserviceaccount.com" \
--role="roles/logging.viewer" \
--condition=None

Two things are true at once here, and the second is the one to weigh.

This is a read-only grant to a service account that already holds broader deploy and compute rights on the same project, so it barely moves the blast radius of a compromised deployer key. Rotation of that key is covered by the secret rotation playbook, separately.

But roles/logging.viewer at project scope grants read access to all project logs, and logs can carry sensitive field values. The narrower grant is roles/logging.viewAccessor on a specific custom log view β€” use it when it covers the logs you actually need. Project scope is used here because the diagnostic need is unscoped: the whole point is reading logs from whichever Cloud Run service is misbehaving, which is not known in advance. If a fixed set of services ever covers it, replace this with a per-view grant.