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 is the human operator's guide — exact gcloud commands, console
notes, and troubleshooting. Onboarding from an AI agent instead? Follow
onboarding-agent.md, which restates every step as
structured, agent-executable parameters (and is what Linro's MCP serves to a
driving agent). Both produce the identical GCP trust; pick whichever fits how
you're onboarding.
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
Run these gcloud commands from Cloud Shell or any shell authenticated as an
admin (Owner or equivalent) on the project/org. They create a read-only scanner
service account, a workload-identity pool + OIDC provider pinned to your
install's namespace, and the impersonation binding — the keyless trust Linro
uses to scan. Fill the values from Step 0 first:
PROJECT_ID=your-project-id
NAMESPACE=linro-acme # from Step 0 (the trust anchor)
ISSUER=https://oidc.eks.eu-west-1.amazonaws.com/id/XXXX # from Step 0
AUDIENCE=linro-managed-sensor # from Step 0
ORG_ID= # set for an org-level read grant; leave empty for project-only
PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')
SCANNER="linro-scanner@$PROJECT_ID.iam.gserviceaccount.com"
# 1. Read-only scanner service account.
gcloud iam service-accounts create linro-scanner \
--project="$PROJECT_ID" \
--display-name="Linro read-only inventory scanner (keyless)"
# 2. Grant the read-only roles. Uncomment EXACTLY ONE pair (see "Scope
# decision" below) — the default block grants nothing until you choose.
# Organization-level — one grant covers all current + future projects (needs ORG_ID):
# gcloud organizations add-iam-policy-binding "$ORG_ID" \
# --member="serviceAccount:$SCANNER" --role="roles/viewer"
# gcloud organizations add-iam-policy-binding "$ORG_ID" \
# --member="serviceAccount:$SCANNER" --role="roles/iam.securityReviewer"
# Project-level — single project (repeat per project you want scanned):
# gcloud projects add-iam-policy-binding "$PROJECT_ID" \
# --member="serviceAccount:$SCANNER" --role="roles/viewer"
# gcloud projects add-iam-policy-binding "$PROJECT_ID" \
# --member="serviceAccount:$SCANNER" --role="roles/iam.securityReviewer"
# 3. Workload-identity pool + OIDC provider (global is the only WIF location).
gcloud iam workload-identity-pools create linro-pool \
--project="$PROJECT_ID" --location="global" --display-name="Linro"
gcloud iam workload-identity-pools providers create-oidc linro-provider \
--project="$PROJECT_ID" --location="global" \
--workload-identity-pool="linro-pool" \
--issuer-uri="$ISSUER" \
--allowed-audiences="$AUDIENCE" \
--attribute-mapping="google.subject=assertion['kubernetes.io']['namespace']" \
--attribute-condition="assertion['kubernetes.io']['namespace'] == \"$NAMESPACE\""
# 4. Let any managed sensor in your install namespace impersonate the scanner.
gcloud iam service-accounts add-iam-policy-binding "$SCANNER" \
--project="$PROJECT_ID" \
--role="roles/iam.workloadIdentityUser" \
--member="principal://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/linro-pool/subject/$NAMESPACE"
Scope decision. Grant the read roles (step 2) at the organization
level when you want one binding to cover every current and future project —
the common choice. Grant at the project level (the commented pair,
repeated per project) for a single project, or when you don't have org-level
admin. Only that read grant's scope changes; the pool, provider, and
impersonation binding always live in the one project.
Allow ~2 minutes for the new IAM bindings to propagate before the first
scan — early token exchanges can 403 transiently while they take effect.
Assemble the external_account config
Hand this JSON to your Linro operator for Step 2. It is not secret (it
carries no key material) — substitute PROJECT_NUMBER and PROJECT_ID:
{
"type": "external_account",
"audience": "//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/linro-pool/providers/linro-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_ID.iam.gserviceaccount.com:generateAccessToken",
"credential_source": {
"file": "/var/run/secrets/linro/federation/token",
"format": { "type": "text" }
}
}
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).
(Org- vs project-level scope for these is the Scope decision above.)
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.
Sink scope — match it to your read grant from Step 1. The logging sink
can be org-level (--organization=$ORG_ID --include-children — a single
topic/bucket receives audit logs from every child project) or project-level
(--project=$PROJECT_ID — just this project). Use org-level if you granted
org-level read in Step 1 and want all projects' events through one source;
project-level for a single project. Either way the consumer derives each
event's project from its logName, so you create one EventSource (not one
per project). The gcloud blocks below read a SINK_SCOPE variable so the
same commands work for both.
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.
Set the sink scope once, up front:
# Org-level sink (all child projects):
SINK_SCOPE="--organization=$ORG_ID"
SINK_CHILDREN="--include-children"
# …or project-level sink (this project only) — use instead of the two lines above:
# SINK_SCOPE="--project=$PROJECT_ID"
# SINK_CHILDREN=""
SINK_SCOPE selects where the sink lives (used by both create and
describe); SINK_CHILDREN adds child-project coverage on create (org only).
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) or the project level — match
your Step 1 read grant. Destination Cloud Pub/Sub topic → your topic,
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" \
$SINK_SCOPE $SINK_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 \
$SINK_SCOPE --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 (with
Include logs from child resources) or the project level — match
your Step 1 read grant. 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" \
$SINK_SCOPE $SINK_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 \
$SINK_SCOPE --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.
Onboarding from an agent? The event-sourcing setup is restated as
structured steps (pubsub/gcs decision rule, concrete calls, idempotency) in
onboarding-agent.md.
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 |
Onboarding from an AI agent
If an automation agent (Claude or any MCP-aware agent) is driving the
onboarding, follow onboarding-agent.md instead — it
restates this entire flow (trust creation, the external_account output, the
operator's GOOGLE_CREDENTIALS step, verification, and event sourcing) as
ordered, structured steps with concrete API calls and the questions the agent
should ask you up-front. Linro's MCP serves that guide to a driving agent via
get_plugin_onboarding.
See docs/authentication.md for the credential
resolution + validation details.