Onboarding the Google Cloud plugin (keyless, Workload Identity Federation)
This guide lets you authorize Linro to scan your Google Cloud without
handing over a service-account key. Linro's managed sensor proves its
identity with a short-lived Kubernetes token, exchanges it for a Google STS
token, and impersonates a read-only service account you control. Nothing
long-lived ever leaves your project.
It works even when your org enforces
iam.disableServiceAccountKeyCreation (which makes SA keys impossible).
This document is written to be followed by a human operator or executed
by an automation agent. Section For automation / agents
restates every step as structured parameters.
How it works (30-second version)
managed sensor pod Google Cloud (your project)
────────────────── ───────────────────────────
projected K8s token ──STS──▶ Workload Identity Pool + OIDC provider
iss = Linro cluster (trusts Linro's cluster issuer,
aud = linro-managed-sensor restricted to YOUR install namespace)
ns = linro-<your-install> │
▼
impersonate linro-scanner@<project>
(roles/viewer + iam.securityReviewer)
│
▼
read-only inventory scan
The trust anchor is your install's namespace (linro-<slug>). Any managed
sensor Linro runs in that namespace can scan; no other Linro install — on the
same shared cluster, same issuer, same audience — can, because the OIDC
provider is pinned to your namespace. There is no per-sensor configuration and
no secret to rotate.
Step 0 — Get your install's exact values
Three values describe your install. Do not guess or copy them from this
doc — read them from your own install, which is the source of truth:
| Value | What it is | Example |
|---|
| namespace | The Kubernetes namespace your install runs in (the trust anchor) | linro-acme |
| OIDC issuer | Linro's cluster issuer URL (same for every install on a cluster) | https://oidc.eks.eu-west-1.amazonaws.com/id/240C82FA... |
| audience | The token audience (fixed) | linro-managed-sensor |
Two equivalent ways to read them:
-
UI: in your Linro install, open System → Security. The "Cloud
federation" panel shows all three and a pre-filled snippet.
-
CLI: run
linro oidc info --address https://<your-install-host>
It prints the namespace, issuer, and audience, and (with --fetch) the
issuer's live OpenID discovery + JWKS documents so you can confirm the
issuer is reachable before configuring GCP.
Both are backed by the same API (SystemService.GetCloudFederationInfo), so
they always reflect the live install.
Step 1 — Create the GCP trust (Terraform, recommended)
Use the example module at
deploy/terraform/examples/onboarding-wif/.
It creates the read-only service account, the workload-identity pool + OIDC
provider (pinned to your namespace), and the impersonation binding — then
outputs the exact external_account config to hand back to Linro.
cd deploy/terraform/examples/onboarding-wif
terraform init
terraform apply \
-var project_id=YOUR_PROJECT_ID \
-var org_id=YOUR_ORG_ID \ # omit for project-level read only
-var linro_namespace=linro-acme # from Step 0
# linro_oidc_issuer / linro_audience default to Linro prod; override only
# if Step 0 reported different values.
When it finishes:
terraform output -raw external_account_config
Copy that JSON — it is not secret (it carries no key material) — and give
it to your Linro operator for Step 2.
What to grant, and why
The plugin calls per-service GET/LIST APIs directly (Compute, Cloud
Storage, IAM, BigQuery, KMS, Cloud SQL, GKE, …), so the scanner SA needs:
roles/viewer — broad per-service read, and
roles/iam.securityReviewer — read IAM policies/bindings (Viewer does
not fully cover getIamPolicy).
Grant at the organization (or folder) level for full multi-project
coverage; project level works but must be repeated per project and misses new
projects.
Do not substitute roles/cloudasset.viewer. The plugin does not call
the Cloud Asset Inventory API; the per-service calls would 403.
Enable the service APIs
A disabled API returns 403 regardless of role. Enable the services you want
scanned on each target project, e.g.:
gcloud services enable \
compute.googleapis.com container.googleapis.com sqladmin.googleapis.com \
cloudkms.googleapis.com storage.googleapis.com iam.googleapis.com \
--project YOUR_PROJECT_ID
Disabled-API 403s are isolated — they skip that one service and do not abort
the scan — but you only get inventory for enabled services.
Step 2 — Linro operator wires it up
The operator sets one sealed sensor-profile environment variable on the GCP
sensor profile:
GOOGLE_CREDENTIALS = <the external_account JSON from Step 1>
The value is AES-GCM sealed, materialized into a per-profile Kubernetes Secret,
and mounted into the sensor pod (envFrom). The plugin reads it, validates it
is an external_account config, and authenticates via the projected token the
pod already mounts at /var/run/secrets/linro/federation/token. No plugin
config and no per-sensor setup are required.
Step 3 — Verify
Trigger a reconcile for the GCP plugin (UI: the plugin's sync action; CLI:
linro reconciliation ...). Within a minute, GCP resources (buckets, compute
instances, IAM service accounts, …) appear in your inventory. If nothing
appears, see Troubleshooting.
Optional — Event sourcing (near-real-time inventory)
The steps above give you poll-based inventory: the sensor periodically
reconciles by listing/getting resources. Event sourcing adds near-real-time
updates — the sensor consumes Cloud Audit Log events and refreshes (or evicts)
exactly the resource that changed, within seconds of the change, instead of
waiting for the next reconcile.
Event sourcing is additive and optional. Reconciliation always runs as the
eventual-consistency backstop; events just make the inventory fresher between
reconciles. It reuses the same WIF scanner identity from Steps 0–2 — there
is no second credential.
Two strategies ship. Pub/Sub is the recommended path; GCS is the fallback.
| Strategy | Transport | Latency | Log coverage | When to use |
|---|
pubsub (recommended) | Logging sink → Pub/Sub topic → subscription | seconds | Admin Activity (mutations) | Default. Real-time create/update/delete reactions. |
gcs (fallback) | Logging sink → GCS bucket, sensor polls | minutes (poll interval, default 5m) | Admin Activity + Data Access + system_event + policy | When Pub/Sub is unavailable/blocked by org policy, when you need a durable compliance trail, or when you need Data Access events. Can also run alongside Pub/Sub. |
You can enable both: Pub/Sub for low-latency mutation reactions and GCS
for the full durable trail. They are independent event sources; the consumer
deduplicates downstream by provider event ID.
One event source covers the whole org. Both setups create an
org-level sink (--include-children), so a single topic/bucket
receives audit logs from every child project. The consumer derives each
event's project from its logName — so you create one EventSource (not
one per project), and it fans out across all projects automatically.
The GCP plumbing is four objects: a logging sink that routes audit logs to
a destination (a Pub/Sub topic or a GCS bucket), and two IAM grants —
one letting the sink write to the destination, one letting the linro-scanner
SA read it. Set them up by hand (Console) or have an agent run the gcloud
commands below. Use the same linro-scanner SA and PROJECT_ID from Steps 0–2,
and your org id (ORG_ID).
4a — Recommended: Pub/Sub
What a human does (Console):
- Pub/Sub → Topics → Create topic — e.g.
linro-audit-logs (in your
project). It auto-creates nothing else; leave defaults.
- On that topic, Create subscription — e.g.
linro-audit-logs-sub, type
Pull, ack deadline 600s, message retention 7 days.
- Logging → Log Router → Create sink at the organization level. Set
Include logs from child resources, destination Cloud Pub/Sub topic →
your topic, and inclusion filter
log_id("cloudaudit.googleapis.com/activity").
Saving the sink prints a writer identity (serviceAccount:…).
- Grant the sink writer
Pub/Sub Publisher on the topic (so the sink can
deliver).
- Grant
linro-scanner@PROJECT_ID.iam.gserviceaccount.com Pub/Sub Subscriber on the subscription (so the sensor can read).
How an agent does it (gcloud):
# enable the APIs these commands depend on (no-op if already enabled)
gcloud services enable pubsub.googleapis.com logging.googleapis.com \
--project="$PROJECT_ID"
gcloud pubsub topics create linro-audit-logs --project="$PROJECT_ID"
gcloud pubsub subscriptions create linro-audit-logs-sub \
--topic=linro-audit-logs --project="$PROJECT_ID" \
--ack-deadline=600 --message-retention-duration=7d
gcloud logging sinks create linro-audit-sink \
"pubsub.googleapis.com/projects/$PROJECT_ID/topics/linro-audit-logs" \
--organization="$ORG_ID" --include-children \
--log-filter='log_id("cloudaudit.googleapis.com/activity")'
# capture the sink's writer identity, then let it publish
WRITER=$(gcloud logging sinks describe linro-audit-sink \
--organization="$ORG_ID" --format='value(writerIdentity)')
gcloud pubsub topics add-iam-policy-binding linro-audit-logs \
--project="$PROJECT_ID" --member="$WRITER" --role=roles/pubsub.publisher
# let the scanner SA consume
gcloud pubsub subscriptions add-iam-policy-binding linro-audit-logs-sub \
--project="$PROJECT_ID" \
--member="serviceAccount:linro-scanner@$PROJECT_ID.iam.gserviceaccount.com" \
--role=roles/pubsub.subscriber
Coverage note: the activity filter means the Pub/Sub stream carries
Admin Activity events (resource mutations) — which is what drives
refresh/evict. Data Access events are excluded by design (high volume,
opt-in); use the GCS strategy if you need them.
4b — Fallback: GCS
What a human does (Console):
- Cloud Storage → Create bucket — a globally-unique name, uniform
bucket-level access, public access prevention enforced. Optionally a
lifecycle rule to delete objects after N days.
- Logging → Log Router → Create sink at the organization level,
Include logs from child resources, destination Cloud Storage bucket →
your bucket, inclusion filter covering all four audit log types (activity,
data_access, system_event, policy). Note the writer identity.
- Grant the sink writer
Storage Object Creator on the bucket.
- Grant
linro-scanner@PROJECT_ID.iam.gserviceaccount.com Storage Object Viewer on the bucket.
How an agent does it (gcloud):
# enable the APIs these commands depend on (no-op if already enabled)
gcloud services enable storage.googleapis.com logging.googleapis.com \
--project="$PROJECT_ID"
gcloud storage buckets create "gs://$BUCKET" \
--project="$PROJECT_ID" --location=US \
--uniform-bucket-level-access --public-access-prevention
gcloud logging sinks create linro-audit-trail-sink \
"storage.googleapis.com/$BUCKET" \
--organization="$ORG_ID" --include-children \
--log-filter='log_id("cloudaudit.googleapis.com/activity") OR log_id("cloudaudit.googleapis.com/data_access") OR log_id("cloudaudit.googleapis.com/system_event") OR log_id("cloudaudit.googleapis.com/policy")'
WRITER=$(gcloud logging sinks describe linro-audit-trail-sink \
--organization="$ORG_ID" --format='value(writerIdentity)')
gcloud storage buckets add-iam-policy-binding "gs://$BUCKET" \
--member="$WRITER" --role=roles/storage.objectCreator
gcloud storage buckets add-iam-policy-binding "gs://$BUCKET" \
--member="serviceAccount:linro-scanner@$PROJECT_ID.iam.gserviceaccount.com" \
--role=roles/storage.objectViewer
Data Access logs are opt-in. The sink routes Data Access logs, but they
are only generated for services where you have enabled Data Access audit
logging in the IAM audit config (Console: IAM & Admin → Audit Logs).
Without that, the bucket holds Admin Activity only.
4c — Register the event source in Linro
Once the GCP side is in place, create a Linro EventSource for the GCP
plugin and attach it to the GCP sensor profile. The type field is the
strategy name (pubsub or gcs); config is the strategy config.
Config payloads:
// type: "pubsub"
{ "projectId": "YOUR_PROJECT_ID", "subscriptionId": "linro-audit-logs-sub" }
// type: "gcs"
{
"projectId": "YOUR_PROJECT_ID",
"bucketName": "YOUR_UNIQUE_BUCKET",
"prefix": "", // optional — narrow to a key prefix
"pollInterval": "5m", // optional — Go duration, default 5m
"initialBackfillDays": 1 // optional — 0 = start at now, max 90
}
UI: System → Events → Sources → New. Pick the GCP plugin, choose the
strategy, fill the config form (rendered from the strategy's JSON Schema),
enable it, then add the event source to the GCP sensor profile.
CLI (config is a struct, so pass the whole request via --from-file):
# 1. Create the event source. request.json:
# {
# "event_source_id": "gcp-audit-pubsub",
# "event_source": {
# "title": "GCP audit logs (Pub/Sub)",
# "plugin": "plugins/<gcp-plugin-id>",
# "type": "pubsub",
# "enabled": true,
# "config": { "projectId": "YOUR_PROJECT_ID", "subscriptionId": "linro-audit-logs-sub" }
# }
# }
linro data-source create-event-source --address https://<your-install-host> \
--from-file request.json
# → returns eventSources/<id>
# 2. Attach it to the GCP sensor profile. NOTE: --sensor-profile.event-sources
# REPLACES the whole set — first read the profile's current event sources
# (get-sensor-profile), then pass ALL of them plus the new id, or you will
# detach the existing ones:
linro sensor-profile update-sensor-profile --address https://<your-install-host> \
--sensor-profile.event-sources eventSources/<existing-1> \
--sensor-profile.event-sources eventSources/<id>
Attaching is required: the sensor only runs Produce for event sources bound
to its profile. After attach, the sensor opens the stream within ~one
heartbeat; mutations in GCP then appear in inventory without waiting for the
next reconcile.
For automation / agents (event sourcing)
Restated as structured steps for MCP-driven onboarding. Decision rule: try
pubsub first; fall back to gcs only if Pub/Sub creation or subscription is
blocked (e.g. org policy), or if Data Access events are explicitly required.
Preconditions (from Steps 0–2; do not duplicate the identity):
- Scanner SA exists:
linro-scanner@<project>.iam.gserviceaccount.com.
- WIF +
GOOGLE_CREDENTIALS already wired on the GCP sensor profile.
gcp_plugin_id = the plugin resource id (plugins/{plugin}), read from
ListPlugins / the install — never hardcode.
Strategy = pubsub (preferred):
| # | Action | Concrete call |
|---|
| 1 | Create topic + subscription + org sink, grant sink writer pubsub.publisher | the gcloud block in 4a |
| 2 | Grant consumer read | roles/pubsub.subscriber to linro-scanner@<project> on the subscription |
| 3 | Create EventSource | CreateEventSource{ event_source_id, event_source:{ plugin:<gcp_plugin_id>, type:"pubsub", enabled:true, config:{ projectId, subscriptionId } } } |
| 4 | Attach to profile | UpdateSensorProfile adding eventSources/<id> to event_sources (read-modify-write; keep existing) |
| 5 | Verify | mutate a resource (or wait for organic change); confirm it refreshes in inventory within ~1m |
Strategy = gcs (fallback / Data Access trail):
| # | Action | Concrete call |
|---|
| 1 | Create bucket + org sink (all 4 log types), grant sink writer storage.objectCreator | the gcloud block in 4b |
| 2 | Grant consumer read | roles/storage.objectViewer to linro-scanner@<project> on the bucket |
| 3 | (If Data Access needed) enable Data Access audit logging in the IAM audit config for the target services | org/project setIamPolicy auditConfigs |
| 4 | Create EventSource | type:"gcs", config:{ projectId, bucketName, prefix?, pollInterval?, initialBackfillDays? } |
| 5 | Attach to profile | UpdateSensorProfile adding eventSources/<id> |
| 6 | Verify | as above; first poll lands within pollInterval (default 5m) |
Idempotency / safety for agents:
event_source_id is caller-supplied and becomes the resource name — use a
stable id (e.g. gcp-audit-pubsub) so re-runs are detectable; on
ALREADY_EXISTS, switch to UpdateEventSource.
- The IAM grants (steps 2/3) are idempotent — re-applying the same binding is a
no-op.
UpdateSensorProfile.event_sources replaces the set — read the current
list and append, never send the new id alone.
- Setting
enabled:false (or detaching from the profile) stops the stream
without deleting offsets; reconciliation keeps inventory correct meanwhile.
Troubleshooting
| Symptom | Cause | Fix |
|---|
PERMISSION_DENIED ... has not been used in project ... or it is disabled | The service API is off | gcloud services enable <api> --project ... (Step 1) |
| All resources of a service 403 | Missing role | Confirm both roles/viewer and roles/iam.securityReviewer are granted to the scanner SA |
Token exchange fails / unauthorized_client | Namespace/audience/issuer mismatch | Re-read Step 0 from the install; the attribute_condition namespace must equal your install namespace exactly; allowed_audiences must include the audience |
iam.allowedPolicyMemberDomains blocks the binding | Org policy restricts principal domains | Add an exception for principalSet/principal members of the workload-identity pool, or apply the binding from a project exempted by the policy |
| Issuer not reachable from GCP | Private/non-public cluster issuer | WIF requires a publicly reachable issuer (GCP fetches JWKS itself); confirm with linro oidc info --fetch. Linro production uses a public EKS issuer |
| Event source enabled but no real-time updates | Event source not attached to the sensor profile | Add eventSources/<id> to the GCP sensor profile's event_sources — the sensor only streams attached sources |
| Pub/Sub stream connects but stays empty | linro-scanner lacks roles/pubsub.subscriber, or no mutations occurred | Grant subscriber on the subscription; trigger a resource change to produce an Admin Activity event |
| Pub/Sub works but Data Access changes never appear | base sink is Admin-Activity-only by design | Use the gcs strategy (routes all log types) and enable Data Access audit logging for the service |
| GCS strategy lands nothing | linro-scanner lacks roles/storage.objectViewer, wrong bucketName/prefix, or initialBackfillDays:0 and no new logs yet | Grant objectViewer on the bucket; verify config; set initialBackfillDays to look back |
For automation / agents
Goal: produce a GCP external_account JSON and have the Linro operator set it
as the GOOGLE_CREDENTIALS sealed sensor-profile env var.
Inputs to fetch from the live install (never hardcode):
| Param | Source of truth |
|---|
namespace | GetCloudFederationInfo().namespace (UI System→Security, or linro oidc info) |
issuer | GetCloudFederationInfo().oidc_issuer_url |
audience | GetCloudFederationInfo().audience |
linro oidc info --output json returns these fields machine-readably; add
--fetch to also include the issuer's openid_configuration and jwks.
GCP objects to create (project = customer project; PROJECT_NUMBER from
gcloud projects describe):
- Service account
linro-scanner@<project>.iam.gserviceaccount.com, granted
roles/viewer + roles/iam.securityReviewer (org or project level).
- Workload-identity pool, e.g.
linro-pool.
- OIDC provider in the pool:
issuer-uri = issuer
allowed-audiences = [audience]
attribute-mapping = google.subject=assertion['kubernetes.io']['namespace']
attribute-condition = assertion['kubernetes.io']['namespace'] == "<namespace>"
- Binding:
roles/iam.workloadIdentityUser on the SA for member
principal://iam.googleapis.com/projects/<PROJECT_NUMBER>/locations/global/workloadIdentityPools/<pool>/subject/<namespace>
Output to hand to the operator (external_account, no secret):
{
"type": "external_account",
"audience": "//iam.googleapis.com/projects/<PROJECT_NUMBER>/locations/global/workloadIdentityPools/<pool>/providers/<provider>",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/linro-scanner@<project>.iam.gserviceaccount.com:generateAccessToken",
"credential_source": {
"file": "/var/run/secrets/linro/federation/token",
"format": { "type": "text" }
}
}
The Terraform module emits this exact JSON as the external_account_config
output — preferred over hand-assembly. The plugin validates that token_url
and service_account_impersonation_url are *.googleapis.com and that
credential_source is file-based, so keep these endpoints verbatim.
See docs/authentication.md for the credential
resolution + validation details.