Onboarding Google Cloud
Grant Ringleader least-privilege, keyless access to run workstation VMs in one of your GCP projects, with Terraform or the gcloud CLI.
Your developers’ workstations can run as VMs in your own Google Cloud project, on your bill and inside your network. This page sets that up.
You run one Terraform module, or one shell script, in a project you own. It creates a service account for Ringleader to use, and tells Google to let Ringleader use it without a password or a key. You send a few values back to Ringleader, and from then on your developers create and delete workstations themselves. Ringleader never holds a key to your project, and never gets project admin.
What this creates in your project
Onboarding creates four things in your project, and you can see all of them in the Google Cloud console afterwards:
- A service account called
ringleader-workstations. This is the account Ringleader uses when it creates or deletes a VM. - Three roles and one custom role granted to that account. Together they let it create, start, stop and delete VMs, connect them to your network, and create service accounts for the machines it runs, and nothing else. The full list is below.
- A workload identity pool with one provider. This is how Google confirms that a request really comes from Ringleader and is meant for your organization, without Ringleader ever holding a password or a key.
- A network for the workstations: a VPC, a subnet, Cloud NAT and firewall rules.
Terraform creates this unless you set
create_network = false. On the gcloud path it is a separate script you run only if you want it.
What Ringleader can and cannot do in your project
There is no key to leak. Ringleader never receives a password or a key for your project, so there is nothing to rotate and nothing to steal. Each time it acts, it presents a short-lived signed token and Google checks it. Delete the pool and Ringleader loses access immediately.
Other Ringleader customers cannot reach your project. Every token names the organization it is for, and your project accepts only tokens that name yours. Google turns the rest away; that decision is made in your project, not by Ringleader.
Ringleader can only manage VMs. The service account holds three predefined roles and one custom role: no Owner, no Editor, nothing in billing.
roles/compute.instanceAdmin.v1: create, start, stop and delete VMs and their disks.roles/compute.networkUser: attach a VM to your subnet.roles/iam.serviceAccountUser: attach a service account to a VM. Every VM on Google Cloud runs as a service account, and attaching one is a separate permission that the two roles above do not include. Without it, creating the first workstation fails with a403error.- A custom role,
Ringleader Managed Identities: create and delete service accounts, and nothing else. A VM on Google Cloud cannot start without a service account, and some machines Ringleader runs for you are not workstations, so it creates an account with no roles for each of those. This role cannot grant any role, so those accounts can do nothing in your project.
Three features are on by default and add to this: runtime identities, egress control and artifact storage. Each section says what it adds and how to turn it off.
What the workstations themselves can do
Ringleader’s own access stops at the roles above. The workstations are a separate question: every VM on Google Cloud runs as a service account, and whatever that account can do, the software on the VM can do too, including any AI coding agent your developers run there.
Unless you say otherwise, Google gives each workstation the project’s default Compute
Engine service account. In many projects, especially older ones, that account has
the Editor role, which can change almost anything in the project. You can check in the
console under IAM: look for the account ending in
compute@developer.gserviceaccount.com.
If the project exists only for Ringleader workstations, there is little for a workstation to reach and this is fine. If it holds anything else, give the workstations a service account with fewer roles; runtime identities shows two ways.
Before you start
You will have received two values from Ringleader when your organization was set up. If you do not have them, ask us.
| Value | What it is | Example |
|---|---|---|
| issuer URL | The address Ringleader’s tokens come from. No trailing slash. | https://oidc-app.ringleader.dev |
| organization id | Your organization’s id: a UUID, never its name. | 0192f5bf-af83-7178-8d0a-f1c7aea06bde |
You also need a Google Cloud project to put the workstations in. We recommend a new, empty project used for nothing else. Workstations are billed to it and everything Ringleader may do is limited to it, so a dedicated project keeps the bill and the risk in one place. It is also the case where every default on this page is safe to leave alone.
In that project you need permission to manage service accounts and IAM: Owner, or Project IAM Admin plus Service Account Admin. You need it only for this setup, which you do once.
Set it up
Two paths that do the same thing, so use whichever your team already runs. Both live in github.com/ringleader-dev/cloud-onboarding, and both finish by printing the values you send back to Ringleader (see what you send back).
gcloud
The scripts are safe to run again if something goes wrong partway:
git clone https://github.com/ringleader-dev/cloud-onboarding
cd cloud-onboarding/gcp/gcloud
export PROJECT=my-company-dev-workstations
export ISSUER_URL='https://oidc-app.ringleader.dev'
export ORG_UID='0192f5bf-af83-7178-8d0a-f1c7aea06bde'
./onboard.sh # prints the values to send back to Ringleader
./verify.sh
# optional, if you don't already have a network for the workstations.
# SSH_RANGES is where your engineers connect from, for an SSH rule of your own.
REGION=us-central1 SSH_RANGES=203.0.113.0/24 ./network-landing-pad.shTerraform
cd cloud-onboarding/gcp/terraform/examples/standalone
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars: your project id, the two values above, and
# ssh_source_ranges, the addresses your engineers connect from.
terraform init && terraform apply
terraform output handoff # the values to send backThe module is reusable. If you prefer, reference it as a module source from your own configuration rather than copying it.
What your project checks
When Ringleader asks to act in your project, Google checks three things about the token it presents. All three are built from the two values Ringleader gave you, and the module and the script configure them, so you never type them yourself.
| What Google checks | Value | Where it is configured |
|---|---|---|
| Who signed the token | <issuer-url>/org/<org-id> | the provider’s issuer_uri |
| Which organization it speaks for | org:<org-id> | the provider’s attribute_condition |
| Which cloud it may be used against | <issuer-url>/org/<org-id>/gcp | the provider’s allowed_audiences |
If Google ever turns Ringleader away, compare these three against what was created. A mismatch in one of them is the usual cause, and a trailing slash on the issuer is the usual mismatch.
If you used the module or the script, that is all you need to know. If you built this
into your own Terraform instead, check one more thing, because it is the common
copy-paste mistake: who may use the ringleader-workstations service account
(in the console, or with the third command under Verifying). It should be
your organization alone, written principal://…/subject/org:<org-id>. If you see
principalSet://…/workloadIdentityPools/<pool>/* instead, that is a wildcard that would
let anyone the pool accepts use your service account.
Reaching your workstations
rl shell, rl tmux, port-forwards and VS Code Web all connect to the workstation
over SSH on TCP 22, straight to the machine. A workstation behind an
Edge is reached
through a port on the edge instance instead.
Ringleader writes the firewall rule that lets you in. It puts a network tag of its own on
every workstation it creates, and writes one VPC firewall rule admitting TCP 22 and 2222 to
VMs with that tag, from any address unless the
CloudAccount
lists sshSourceRanges. A VM Ringleader did not create does not carry the tag, so the rule
does not reach it. Writing the rule needs the egress control
grant. An ingress deny rule of your own with a priority number of 900 or lower takes
precedence over it, and so does a deny in a hierarchical firewall policy.
The module and the script can also write a rule of your own on TCP 22, for the addresses your engineers connect from:
ssh_source_ranges = ["203.0.113.0/24"] # Terraform, in terraform.tfvarsSSH_RANGES=203.0.113.0/24 ./network-landing-pad.sh # gcloudThat rule matches the ringleader-workstation network tag, which Ringleader puts on every
workstation that declares no networkTags of its own. A workstation that declares its own
list needs that tag in it. The rule applies alongside Ringleader’s, and it keeps workstations
reachable from those addresses when Ringleader cannot write its own rule, for example with
egress control turned off.
A workstation cannot fully confirm any of this for you. It sets itself up over its own
outbound connection, so it reports Ready whether or not anything can reach it. When
Ringleader could not write its rule, the workstation reports the SSHAdmissionMissing
condition. Otherwise, open a shell to check.
External IP addresses
A workstation gets an external address by default, and with Ringleader’s rule in place that
address accepts SSH connections from the internet, unless sshSourceRanges narrows it.
If you would rather none of your workstations had an external address, set
allowPublicAddresses: false on the CloudAccount. To make it a default that developers can
change per workstation, or to force it for the workstations one CloudIdentity builds, set it
on the CloudIdentity instead:
spec:
overrideProviderConfig:
gcp:
assignPublicIp: falseUse overrideProviderConfig when developers must not be able to turn it back on, and
defaultProviderConfig when they may. Either way a workstation with no external
address still reaches Ringleader through Cloud NAT, and you reach it over your VPN,
Interconnect or peering.
Runtime identities (on by default)
Ringleader can give your developers their own Google Cloud identity to work as: it creates a service account per user, shared across that user’s workstations, and binds roles to it, so one person’s workstations can read one bucket and nobody else’s can (see runtime identity).
This is the one default on this page worth a deliberate decision, because creating and binding those accounts takes two more roles than the three above:
| Capability | Role it needs |
|---|---|
| Create and delete the per-user service account | roles/iam.serviceAccountAdmin |
| Bind roles to it on the project | roles/resourcemanager.projectIamAdmin |
| Attach it to a VM | roles/iam.serviceAccountUser, already granted above |
The second one is the reason to think about it. roles/resourcemanager.projectIamAdmin
can grant any role in the project to anyone, including roles/owner. There is no
narrower permission to give instead: the ability to set roles on a project is the
ability to set any role.
So: in a project dedicated to Ringleader workstations, leave it on. In a project that holds anything else, turn it off when you apply:
enable_workstation_identities = false # TerraformWORKSTATION_IDENTITIES=0 ./onboard.sh # gcloudWith it off, a workstation that asks for its own identity fails with a 403 error
rather than silently getting nothing, and workstations run as the project’s default
Compute Engine service account, as described
above. Two ways to give them less:
- Turn the feature back on, in a project that holds nothing but Ringleader workstations, and let Ringleader create a narrow account per developer.
- Create one service account with no roles yourself and tell Ringleader to use it.
This is how to get safe workstations in a shared project with the feature off:
attaching an account Ringleader did not create needs only
roles/iam.serviceAccountUser, which it already has. In the CloudIdentity, name the account in theserviceAccountblock withroles: []andautoCreate: false. The workstation starts exactly as before, and the software on it can do nothing in your project. See runtime identity for the fields.
Egress control (on by default)
By default a workstation can connect to anything your network can reach. Ringleader can narrow that to a list you choose per workstation, so a workstation can reach GitHub and your package registry and nothing else (see restricting outbound connections), enforced by VPC firewall rules Ringleader creates and keeps in step with the workstation’s configuration.
It is on by default. It also lets Ringleader write the firewall rule that admits SSH to
your workstations, so with it turned off they are reachable only through a rule of your own.
A workstation with no egress policy still has no limit on where it connects. Turn it off
with enable_egress_control = false (Terraform) or EGRESS_CONTROL=0 (gcloud).
What it grants is a custom project role with fourteen permissions and nothing else:
| Permissions | For |
|---|---|
compute.firewalls.create / delete / get / list / update | create and maintain the firewall rules that carry each policy, and the rule that admits SSH to your workstations |
compute.routes.create / delete / get / list | the static route that sends traffic to the DNS/HTTPS proxy, for a policy that names hostnames rather than address ranges |
compute.networks.updatePolicy | creating that route additionally requires it |
compute.addresses.create / delete / get / list | reserving a fixed address for that proxy. Ringleader does not use them today: the proxy’s public address is not reserved, and it changes when Ringleader replaces the machine |
Each distinct policy becomes one set of firewall rules, matched by network tag, so a hundred workstations sharing a policy cost one set of rules rather than a hundred.
What can defeat a policy here
The rules Ringleader writes are enough on their own to narrow a workstation. The one thing that can defeat them is a firewall rule of your own, because Google evaluates rules by priority and the lowest number wins:
| Priority | Rule |
|---|---|
0–899 | Yours, deliberately left free so you can always override Ringleader in your own VPC |
900 | the policy’s allowances, written by Ringleader |
1000 | the policy’s default-deny, written by Ringleader |
65535 | Google Cloud’s own implied allow-all egress, which the deny above exists to beat |
An outbound allow rule of yours below 900 therefore wins over the deny, and the
workstation reaches whatever that rule permits, while Ringleader still reports the
policy as enforced, because it checks the rules it wrote and not yours. That is
deliberate, so you can always override Ringleader in your own VPC. Know it before you
write the first policy.
The network this module creates has no egress rule at all, so a VPC it built is clear. In a VPC you already had, check first:
gcloud compute firewall-rules list --project <project> --filter='direction=EGRESS' \
--format='table(name, network.basename(), priority, disabled,
targetTags.list():label=TARGET_TAGS,
allowed[].map().firewall_rule().list():label=ALLOW)'A policy also stops workstations reaching each other
If your developers run work that spans several machines, a policy stops those machines talking to each other unless you allow it. Name the workstation subnet range among the policy’s destinations and they keep talking.
The module’s allow_internal_traffic rule (on by default) lets workstations accept
connections from each other, but a policy governs what a workstation may start, so that
rule alone is not enough once a policy is in place.
Artifact storage (on by default)
This grant lets Ringleader store files for your organization in a Cloud Storage bucket in your project, instead of in Ringleader’s own storage, so the data stays in a project you control. Granting it writes nothing: Ringleader stores files there only once your organization is set up to use it.
It cannot widen its own access, it creates no long-lived keys, and it creates no bucket at apply time.
Two ways to take it, chosen by whether you name a bucket:
- Leave
artifact_storage_bucketempty and Ringleader creates and manages its own buckets, namedringleader-*. The grant is confined to that name pattern by an IAM condition, so it reaches no bucket you already have. - Name a bucket you created and Ringleader gets object access to that one bucket and nothing else, with no ability to create, change or delete a bucket. Its location, its lifecycle rules and its encryption key stay yours. Take this one if you have a data-residency or key-custody position to defend.
Turn it off with enable_artifact_storage = false (Terraform) or ARTIFACT_STORAGE=0
(gcloud). With it off, those files stay in Ringleader’s own storage, and nothing else
changes.
What you hand back to Ringleader
| Value | Where it lands |
|---|---|
| target service account email | CloudAccount spec.gcp.targetServiceAccount |
| workload identity provider (the resource name) | spec.gcp.workloadIdentityProvider |
| project id | CloudIdentity providerConfig.gcp.project |
| subnetwork self-link (only if you created a network) | providerConfig.gcp.subnetwork |
terraform output handoff prints all of these together, and the gcloud path prints
them when ./onboard.sh finishes.
Send them to whoever administers Ringleader for your organization, which may be you. They go into two Ringleader objects: a CloudAccount, which records which service account to use in your project, and a CloudIdentity, which says how workstations in your project are built (project, zone, machine type, network). See how Ringleader signs in to your cloud for a worked example of both.
Verifying
./verify.sh checks all of this for you. By hand:
# The service account has exactly the roles listed on this page:
gcloud projects get-iam-policy <project> \
--flatten='bindings[].members' \
--filter="bindings.members:ringleader-workstations@<project>.iam.gserviceaccount.com" \
--format='table(bindings.role)'
# The provider accepts only Ringleader's tokens, for your organization, for GCP:
gcloud iam workload-identity-pools providers describe oidc \
--project <project> --location global --workload-identity-pool ringleader \
--format='yaml(oidc.issuerUri, oidc.allowedAudiences, attributeCondition)'
# Your organization alone may use the service account (principal://, not principalSet://):
gcloud iam service-accounts get-iam-policy \
ringleader-workstations@<project>.iam.gserviceaccount.com --project <project>Once an administrator has created the
CloudAccount and
CloudIdentity from the values you
sent back, confirm Ringleader can use them before anyone creates a workstation.
Replace gcp and dev with the CloudIdentity’s name and namespace:
rl wait cloudidentity gcp -n dev --for Ready --timeout 2m
rl cloudidentity get gcp -n dev -o yaml # status.valid: truestatus.valid: true means Ringleader can use the identity. false means it cannot,
and rl cloudidentity describe gcp -n dev says why. The usual causes are a wrong
workload identity provider name or a wrong service-account email.
Revoking
Ringleader loses access the moment the pool is deleted. There are no keys to hunt down:
cd cloud-onboarding/gcp/gcloud
./revoke.sh # deletes the pool, keeps the service account
FULL=1 ./revoke.sh # also deletes the service accountTerraform: terraform destroy.
A deleted pool is soft-deleted for 30 days
gcloud iam workload-identity-pools undelete ringleader --location global. The
scripts already handle this.