GitHub Copilot for Google Cloud
This lesson produced the most instructive result in Cluster 4, and it is a negative one.
The Terraform configuration below — a Cloud Run service, two service accounts and an IAM binding — was run through Checkov, the same policy scanner that found eighteen problems in a draft AWS configuration and twenty-one in a draft Kubernetes manifest. The output was:
(nothing)No findings. No summary. No exit code worth reading. The obvious interpretation is that the configuration is clean.
The correct interpretation is that Checkov has no policies for
google_cloud_run_v2_service, google_service_account or
google_cloud_run_v2_service_iam_member, so zero checks ran. Confirmed by
running the identical command against a bare google_storage_bucket, which
produced four findings — the Google framework works, the coverage for those
resource types does not exist.
A clean scan and an empty scan are indistinguishable in a CI log. Both are a green tick.
Key takeaways
- Read your scanner’s check count, not its exit code. “51 passed, 0 failed” ran fifty-one checks; an empty report ran none.
- The single IAM binding that makes a Cloud Run service public is
roles/run.invokergranted toallUsers. Never generate it unless asked for in those words. - Cloud Run’s default runtime identity is the Compute Engine default service account, which holds Editor on the entire project. Always create a dedicated one.
- Downloaded service-account key files are long-lived credentials with no expiry. Workload identity federation exists to replace them.
gcloudruns against the active project, which is ambient state. Use--projectexplicitly.
The empty scan, in detail
This is worth spending a section on because the habit it undermines — trusting a green scanner — is nearly universal.
$ checkov -d . --framework terraform
_ _
___| |__ ___ ___| | _______ __
/ __| '_ \ / _ \/ __| |/ / _ \ \ / /
| (__| | | | __/ (__| < (_) \ V /
\___|_| |_|\___|\___|_|\_\___/ \_/
By Prisma Cloud | version: 3.3.13
$ echo $?
0Banner, no findings, exit 0. In a pipeline that is a passing step.
The control experiment:
$ cat t.tf
resource "google_storage_bucket" "b" {
name = "test-bucket"
location = "EU"
}
$ checkov -d . --framework terraform
passed=0 failed=4
CKV_GCP_29, CKV_GCP_62, CKV_GCP_114, CKV_GCP_78So the tool, the framework and the invocation are all correct. The difference is coverage.
The general form of this problem appears throughout Cluster 4 — hadolint missing the root user, ansible-lint missing the password, kubeconform passing a privileged manifest. This is the sharpest version, because the others at least reported something.
Where Copilot helps with Google Cloud
Terraform for GCP resources. The provider is large and regular, and this is the bulk of the useful output.
gcloud invocations — with the strong preference for asking for the command and its explanation, then running it yourself.
IAM bindings, with the caveat that the default is over-broad in a specific way covered below.
Explaining an existing project’s configuration. “What can this service account do?” against a list of bindings is a reading task.
Reading errors. Google Cloud’s permission errors name the missing permission
precisely — Permission 'run.services.create' denied on resource … — which makes
the fix mechanical once you decide the permission is appropriate.
The project is ambient
gcloud commands run against the active project, set by gcloud config set project at some point in the past and persisted.
gcloud config get-value projectThat is the check, and the better habit is to not depend on it:
gcloud run services list --project my-project-idTwo Google-specific wrinkles. Project ID and project number are different identifiers, both appear in documentation, and a generated command using the wrong one fails confusingly. And project IDs are globally unique and immutable — you cannot rename one, so a typo in a generated project ID either fails or, in the worst case, addresses somebody else’s project.
I need to update the ingress setting on a Cloud Run service.
Do not give me the command yet. Tell me what it changes, which IAM permissions it requires, whether it is destructive, whether it causes downtime, a read-only command to record the current setting first, and how to revert.
Then give me the command with —project stated explicitly.
Identity, and the two defaults that matter
Cloud Run’s default runtime service account is the Compute Engine default,
which is granted the Editor role on the project. Editor can create, modify and
delete almost everything. A Cloud Run service deployed without specifying an
identity therefore runs with project-wide write access — and generated Terraform
omits service_account unless asked.
# A dedicated service account with nothing attached. Cloud Run's default
# service account is the Compute Engine default, which holds the Editor role
# on the whole project.
resource "google_service_account" "run" {
account_id = "${var.service_name}-run"
display_name = "Runtime identity for ${var.service_name}"
}Creating it is two lines. Attaching it is one more. Not doing so is a project-wide privilege grant that nothing in the review will flag.
Service-account key files are the other default to avoid. A downloaded JSON key is a credential with no expiry, no rotation, and full authority of that service account. It is the Google Cloud equivalent of a long-lived AWS access key, and it is what generated CI configuration reaches for.
The alternatives, in order of preference: workload identity federation for anything outside Google Cloud, including GitHub Actions; attached service accounts for anything running inside it; and short-lived credentials via impersonation for humans. A key file is the option of last resort, and if one exists it should be in a secret manager and rotated on a schedule somebody owns.
IAM
Google Cloud IAM has a specific hazard that AWS does not: the basic roles — Owner, Editor, Viewer — which predate the granular ones and are still what suggestions reach for.
Editor is not “can edit the thing I am working on”; it is “can modify nearly everything in the project”. Viewer includes read access to data in many services. Google’s own documentation recommends against basic roles in production, and they appear in generated bindings constantly because they are short and they always work.
The other thing to check is binding scope. An IAM binding at project level
applies to every resource in the project. Most services support resource-level
bindings — the Cloud Run example below binds roles/run.invoker on the service
rather than on the project — and generated bindings default to project level
because it is the simplest form.
Two Terraform-specific traps worth knowing, because they are authoritative resources that silently remove other people’s access:
google_project_iam_policy replaces the entire project policy. If your
configuration does not list a binding, that binding is deleted. This is almost
never what anyone wants and it is capable of locking everyone out of a project.
google_project_iam_binding is authoritative for one role: it replaces the
full member list for that role. google_project_iam_member is the additive form
that adds a single member and leaves everything else alone.
Practical project: a Cloud Run service that is not public
Practical example
Cloud Run with a dedicated identity, internal ingress and a digest-pinned image
A configuration that validates locally, and a demonstration that a passing policy scan can mean nothing was checked.
- Status
- Tested implementation
- Runtime
- Terraform v1.15.9 with hashicorp/google, Checkov 3.3.13
- Command
terraform fmt -check -recursive && terraform init -backend=false && terraform validate && checkov -d . --framework terraform- Result
- fmt exit 0; init succeeded; validate: Success! The configuration is valid. Checkov produced an EMPTY report — zero checks ran, confirmed by comparison against a google_storage_bucket which produced four findings.
- Run on
- August 21, 2026
Files
copilot-gcp-demo/ ├── terraform/main.tf ├── README.md └── .github/ └── instructions/ └── gcp.instructions.md
The prompt
Create Terraform for a Cloud Run v2 service.
Requirements:
- A dedicated service account for the runtime identity. Do not use the default.
- Ingress restricted to internal and load balancer traffic, not all.
- The image pinned by digest, with a variable validation that rejects a tag.
- CPU and memory limits, cpu_idle true, min instances 0.
- Startup and liveness probes on /healthz.
- deletion_protection enabled.
- Grant roles/run.invoker to a named service account, on the service — not to allUsers, and not at project scope.
Ingress and the public binding
resource "google_cloud_run_v2_service" "api" {
name = var.service_name
location = var.region
# INGRESS_TRAFFIC_ALL puts the service on the public internet. Internal
# means only VPC and Cloud Load Balancing traffic reaches it.
ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
deletion_protection = true
…
}
# Explicitly *not* granting roles/run.invoker to allUsers. That single binding
# is what makes a Cloud Run service publicly callable, and it is the most
# common way a private service becomes public.
resource "google_cloud_run_v2_service_iam_member" "invoker" {
project = google_cloud_run_v2_service.api.project
location = google_cloud_run_v2_service.api.location
name = google_cloud_run_v2_service.api.name
role = "roles/run.invoker"
member = "serviceAccount:${google_service_account.invoker.email}"
}Two controls, and they are independent. ingress decides what network traffic
reaches the service; the run.invoker binding decides who is authorised. A
service with internal ingress and an allUsers invoker binding is not reachable
from the internet — until someone puts a load balancer in front of it, at which
point it is public and nobody changed the IAM.
Set both, deliberately.
Enforcing the digest in a variable
variable "image" {
description = "Fully qualified container image, pinned by digest."
type = string
validation {
condition = can(regex("@sha256:[0-9a-f]{64}$", var.image))
error_message = "image must be pinned by digest, not by tag."
}
}Four lines that make it impossible to deploy a mutable tag. This is the pattern worth generalising: where a policy scanner has no rule, a variable validation is the local substitute — and given what this lesson found about coverage, that is not a hypothetical situation.
Observed output
$ terraform fmt -check -recursive
(exit 0)
$ terraform validate
Success! The configuration is valid.
$ checkov -d . --framework terraform
(banner only — no findings, no summary, exit 0)Validation
Validate before deploying
Google Cloud: the local validation sequence
| Step | Command | Executed here |
|---|---|---|
| Terraform format and validate | terraform fmt -check && terraform init -backend=false && terraform validate | PASSSuccess! The configuration is valid. |
| Security scan | checkov -d . --framework terraform | PASSCheckov produced an EMPTY report — it has no policies for google_cloud_run_v2_service. The same command on a google_storage_bucket produced 4 findings, confirming this is missing coverage rather than a clean result. |
| gcloud calls | gcloud … | NOT RUNThe Google Cloud CLI is deliberately not installed here and no credentials exist. |
The services, and their defaults
Compute Engine. Two defaults worth changing in every generated instance. The
default service account is the one described above, and the default access scope
is cloud-platform on some paths — a combination that gives the VM broad project
authority. And can_ip_forward, OS Login and Shielded VM settings are all
omitted by suggestions; OS Login in particular replaces per-instance SSH keys
with IAM-managed access, which is the difference between revoking someone’s
access centrally and hunting for their key on every host.
Cloud Storage. Uniform bucket-level access should be on. With it off — the default for older buckets — object ACLs work alongside IAM, so a bucket that looks private in the IAM policy can have individually public objects. Also check versioning, a lifecycle rule, and whether the bucket has public access prevention enforced rather than merely unset.
GKE. Everything in the Kubernetes lesson applies to the workloads. At the cluster level, the things generated Terraform omits are Workload Identity — which is how a Pod gets a Google Cloud identity without a key file, and is the single most important GKE security setting — a private cluster endpoint, Shielded GKE nodes, and release channel selection. A cluster without Workload Identity means the alternative is a key file mounted into a Pod.
Cloud Functions. Same identity point as Cloud Run: a dedicated service
account rather than the default. And the same ingress consideration, expressed
differently — a function with an HTTP trigger and an allUsers invoker binding
is a public endpoint.
Networking. Google’s default VPC comes with firewall rules named
default-allow-ssh and default-allow-rdp, sourced from 0.0.0.0/0. They
exist so a new project works immediately. Generated configuration adds rules
alongside them rather than questioning them, so a project that inherited the
default network is permitting SSH from the internet unless somebody removed it.
Creating a custom VPC rather than using the default is the clean answer and it is
not what a suggestion produces.
Logging. Cloud Audit Logs are partly enabled by default — Admin Activity is always on and cannot be disabled, Data Access is off for most services. Turning on Data Access logs is a project-level IAM policy change that no resource declaration includes, and it is the difference between knowing who read a bucket and not.
CI/CD to Google Cloud
The CI story is where the key-file question becomes concrete, and it is worth being specific because generated pipeline configuration gets it wrong in a predictable way.
What a suggestion will produce: a GCP_SA_KEY repository secret containing
the JSON of a downloaded service-account key, decoded in a step and written to
disk, with GOOGLE_APPLICATION_CREDENTIALS pointing at it. It works. It also
means a permanent, non-expiring credential with the full authority of that
service account lives in a repository secret, is written to a runner’s
filesystem, and is one cat away from any step in the workflow.
What to ask for instead: workload identity federation. GitHub Actions requests an OIDC token, exchanges it through a workload identity pool for a short-lived Google Cloud access token, and there is no stored secret at all. The pieces are a workload identity pool, a provider configured to trust GitHub’s issuer, and an IAM binding letting the pool impersonate a service account.
The control — exactly as in the AWS lesson — is
the attribute condition on the provider. A pool that trusts
token.actions.githubusercontent.com without constraining the repository can be
used by any GitHub Actions workflow in the world. The condition needs to name
your repository, and ideally the ref:
assertion.repository == 'example-org/copilot-demo' &&
assertion.ref == 'refs/heads/main'Set up GitHub Actions to deploy to Cloud Run using workload identity federation. Do not use a service-account key file.
Include the attribute condition on the provider, constrained to the repository example-org/copilot-demo and the main branch.
Explain what would be possible if the attribute condition were omitted, and what the least-privilege role for a Cloud Run deployment is — not roles/editor.
The two “explain” clauses are the ones that produce a reviewable answer rather than a working one, and given how many generated pipelines reach for a key file, having read the explanation once is what stops you accepting the next one.
Google Cloud-specific risks
The default Compute Engine service account. Editor on the project, attached to anything that does not specify otherwise.
allUsers on roles/run.invoker or on a Cloud Storage bucket’s
roles/storage.objectViewer. Both are one line and both make something public.
Basic roles. Owner, Editor and Viewer where a predefined role fits.
Authoritative IAM resources. _policy and _binding in Terraform, which
remove access they do not list.
Downloaded service-account keys. Long-lived, non-expiring, and frequently committed.
Wrong active project. Ambient, persistent, and not visible in the command.
Firewall rules with a source of 0.0.0.0/0. Google’s default VPC ships with
rules permitting SSH and RDP from anywhere, and generated configuration adds to
them rather than replacing them.
Public Cloud Storage buckets. Uniform bucket-level access should be on, which disables ACLs and makes IAM the only mechanism — otherwise object-level ACLs provide a second path nobody is reviewing.
Organisation policies not considered. A generated resource may violate a policy constraint and fail at apply with a message about the policy rather than about the resource.
Organisation policies
Google Cloud’s guardrail layer is worth knowing about for the same reason Service Control Policies matter in AWS: it is the control that works when the review does not.
Organisation policy constraints are set above the project and cannot be overridden inside it. Four are directly relevant to the failure modes in this lesson:
iam.disableServiceAccountKeyCreationmakes it impossible to download the key file that generated CI configuration keeps reaching for.iam.allowedPolicyMemberDomainsrestricts IAM bindings to identities in your own domain, which blocksallUsersandallAuthenticatedUsersoutright.storage.publicAccessPreventionenforces the bucket setting rather than leaving it to each bucket.compute.vmExternalIpAccessstops instances getting public addresses.
None of these appears in generated Terraform, because none is part of the resource anyone asked for. They are also the difference between “we review carefully” and “the mistake is not possible”.
There is a practical consequence for AI-assisted work specifically: a
constraint produces an apply-time failure with a message about policy rather
than about your configuration, which is confusing the first time. A generated
resource that violates one will validate, plan, and then fail with something like
Constraint constraints/compute.vmExternalIpAccess violated. Knowing the layer
exists is what turns that from a mystery into an answer.
Destructive commands
Review workflow
- Check the active projectHuman judgementgcloud config get-value project, and prefer --project on the command itself.
- terraform fmt, init -backend=false, validateLocal, no credentials, type-checked against the provider schema.
- Run a policy scan — and read the check countAn empty report is not a pass. This lesson is the reason that sentence is here.
- Grep for allUsers and allAuthenticatedUsersHuman judgementThe two members that make something public.
- Check every service account and roleHuman judgementDedicated identity? Predefined role rather than a basic one? Scoped to the resource?
- Check for _binding and _policy resourcesHuman judgementAuthoritative forms remove access they do not list.
- Read the plan for destroys and replacementsHuman judgement
Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.
Best practices
- Create a dedicated service account for every runtime; never inherit the default.
- Prefer the additive
_memberIAM resources. - Never generate an
allUsersbinding without being asked for one in those words. - Pin images by digest, and enforce it with a variable validation where a scanner has no rule.
- Use workload identity federation instead of key files.
- Read the scanner’s check count, and fail the build when it is zero.
- Put
--projectin every command.
Common mistakes
- Reading an empty scan as a clean one.
- Deploying Cloud Run without a service account and inheriting Editor.
- Using
google_project_iam_bindingand removing everyone else’s access. - Granting a basic role because the predefined one is harder to find.
- Committing a service-account key because it is the fastest way to make CI work.
Where to go next
GitHub Copilot for Terraform covers the tool this configuration is written in. GitHub Copilot for Kubernetes applies to GKE workloads, and GitHub Copilot for Go is the language most of Google Cloud’s own tooling is written in — including the scanners whose coverage gaps this lesson found.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.