Onboarding the AWS plugin (keyless, Web Identity Federation)
This guide lets you authorize Linro to scan your AWS account without handing
over any long-lived credential — no access keys, and no IAM user Linro holds.
Linro's managed sensor proves its identity with a short-lived Kubernetes token
and assumes a read-only role you control via
sts:AssumeRoleWithWebIdentity. Nothing long-lived ever exists.
This is the human operator's guide — console click-paths, AWS CLI
commands, 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 AWS trust; pick whichever fits how
you're onboarding.
Static credentials are out of scope for managed onboarding. The plugin
also supports a static role-ARN / shared-profile path via plugin config
(default.roleArn, accounts[<id>].profile, or the AWS SDK default chain) —
see docs/authentication.md. This guide covers the
keyless WIF path only.
How it works (30-second version)
managed sensor pod AWS (your account)
────────────────── ───────────────────
projected K8s token ──STS──▶ IAM OIDC identity provider
iss = Linro cluster (trusts Linro's cluster issuer,
aud = linro-managed-sensor client id = linro-managed-sensor)
sub = system:serviceaccount: │
linro-<your-install>:* ▼
AssumeRoleWithWebIdentity → linro-scanner role
(SecurityAudit + ViewOnlyAccess)
│
▼
read-only inventory scan
The trust anchor is your install's namespace (linro-<slug>), carried in
the token's sub claim. Any managed sensor Linro runs in that namespace can
assume the role; no other Linro install — on the same shared cluster, same
issuer, same audience — can, because the role's trust policy pins 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 / OIDC client id (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.
- API: call
SystemService.GetCloudFederationInfo (over MCP, the
GetCloudFederationInfo tool; or grpcurl against your install host). It
returns namespace, oidc_issuer_url, audience, token_path, and
oidc_discovery_url. To confirm the issuer is reachable from AWS before
configuring it, curl the oidc_discovery_url (the issuer's
/.well-known/openid-configuration) and the jwks_uri it points at.
Both are backed by the same API, so they always reflect the live install.
Step 1 — Create the AWS trust (Console or AWS CLI)
You create three things in your account: an IAM OIDC identity provider for
Linro's issuer, a read-only scanner role, and the trust policy that pins the
role to your namespace.
Console: IAM → Identity providers → Add provider → OpenID Connect; paste
the issuer URL and the audience from Step 0. Then IAM → Roles → Create role →
Web identity; pick the provider + audience, attach SecurityAudit and
ViewOnlyAccess, and after creation edit the trust policy to add the
namespace sub condition shown below.
AWS CLI:
# ── Values from Step 0 (yours — do not copy the examples) ──────────────
ISSUER="https://oidc.eks.eu-west-1.amazonaws.com/id/240C82FA..." # oidc_issuer_url
AUDIENCE="linro-managed-sensor" # audience
NAMESPACE="linro-acme" # namespace
ACCOUNT_ID="123456789012" # your AWS account
ISSUER_HOST="${ISSUER#https://}"
# 1) IAM OIDC identity provider — AWS allows ONE per issuer URL per account.
# If it already exists (EntityAlreadyExists), reuse its ARN below.
# No thumbprint needed: it is optional on current AWS APIs, and for
# issuers with publicly-trusted certificates (EKS issuers included) AWS
# validates against its own trust store anyway.
aws iam create-open-id-connect-provider \
--url "$ISSUER" \
--client-id-list "$AUDIENCE"
PROVIDER_ARN="arn:aws:iam::$ACCOUNT_ID:oidc-provider/$ISSUER_HOST"
# 2) Trust policy — the sub condition pins YOUR namespace; the per-sensor
# ServiceAccount name is wildcarded.
cat > linro-scanner-trust.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "$PROVIDER_ARN" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": { "$ISSUER_HOST:aud": "$AUDIENCE" },
"StringLike": { "$ISSUER_HOST:sub": "system:serviceaccount:$NAMESPACE:*" }
}
}]
}
EOF
# 3) Scanner role + read-only managed policies
aws iam create-role --role-name linro-scanner \
--assume-role-policy-document file://linro-scanner-trust.json
aws iam attach-role-policy --role-name linro-scanner \
--policy-arn arn:aws:iam::aws:policy/SecurityAudit
aws iam attach-role-policy --role-name linro-scanner \
--policy-arn arn:aws:iam::aws:policy/job-function/ViewOnlyAccess
Allow up to ~1 minute for IAM to propagate before the first federation attempt.
Then assemble the AWS_CREDENTIALS config to hand back to your Linro operator
for Step 2 — it is not secret (it carries no key material, only the role
ARN):
{
"type": "web_identity",
"role_arn": "arn:aws:iam::123456789012:role/linro-scanner",
"token_file": "/var/run/secrets/linro/federation/token",
"session_name": "linro-managed-sensor"
}
Only type and role_arn are required; keep token_file at the default
shown, verbatim, if you include it.
What to grant, and why
The plugin calls per-service Get*/List*/Describe* APIs directly (S3,
IAM, EC2 today; more services over time), so the scanner role needs broad read.
The recommended grant is two AWS managed policies:
SecurityAudit — read security-relevant configuration, including IAM
policies and settings the Get*Policy calls need, and
ViewOnlyAccess — broad List/Describe across services.
Prefer a single policy? Attach
arn:aws:iam::aws:policy/ReadOnlyAccess instead (broader, but simpler). Do
not attach write/admin policies — the plugin only ever reads.
Step 2 — Linro operator wires it up
The operator sets one sealed sensor-profile environment variable on the AWS
sensor profile:
AWS_CREDENTIALS = <the aws_credentials_config JSON from Step 1>
e.g. {"type":"web_identity","role_arn":"arn:aws:iam::123456789012:role/linro-scanner",...}
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
a web_identity config, and assumes the role 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 full sync for the AWS plugin (UI: the plugin's sync action; CLI:
linro reconciliation ...). Expect minutes, not seconds: first resources
(S3 buckets, IAM users/roles, EC2 VPCs/security groups, …) appear within
~1–2 minutes and the sync completes within ~5 on a small sensor. If nothing
appears after that, see Troubleshooting.
Optional — Event sourcing (near-real-time inventory)
The steps above give you poll-based inventory: the sensor periodically
reconciles by listing/describing resources. Event sourcing adds
near-real-time updates — the sensor consumes CloudTrail 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 (grant the scanner role read on the CloudTrail S3 bucket /
SQS queue / Kinesis stream, below).
Four strategies ship. EventBridge is the recommended real-time path; S3 is
the zero-extra-infra fallback.
| Strategy | Transport | Latency | Coverage | When to use |
|---|
eventbridge (recommended) | EventBridge rule → SQS (inline events) | seconds | Write events | Default. Real-time create/update/delete, low cost, no S3 fetch. |
sqs | CloudTrail → S3, SNS → SQS notify | 5–15 min | All (full log files) | Balanced; full event coverage incl. read events. |
s3 | CloudTrail → S3, sensor polls | 5–15 min | All | Zero extra infra; cost-sensitive. |
kinesis | CloudWatch Logs → Kinesis | seconds | All, ordered | Real-time + per-shard ordering (higher cost). |
A multi-region CloudTrail trail captures API calls from every region, and
global services (IAM, CloudFront, Route53, STS) always fire events in
us-east-1 — so start there. EventBridge rules and their Linro event
sources are per-region: you need one event source per region you want live
events for. us-east-1 is mandatory — without it IAM/global resources
never update in real time (they still reconcile) even if you scan no
resources there. Add one more per regional region you scan (EC2 etc.) by
repeating 4a's per-region block, and register a matching Linro event source
for each (Step 4c). A single-region source silently misses either global
events (if not us-east-1) or that region's regional events.
4a — Recommended: EventBridge (real-time)
You need, per region: an SQS queue the sensor consumes, an EventBridge rule
matching CloudTrail write events targeting that queue, and — once per account —
a multi-region CloudTrail trail. Start in us-east-1 (global-service
events), then repeat the per-region block for each region you scan.
ACCOUNT_ID="123456789012"
# The trail's log bucket. If you SKIP the trail block below because the
# account already runs a multi-region trail, set this to that trail's
# EXISTING bucket instead — the S3-strategy grants (4b) and the s3 event
# source config (4c) reference it.
TRAIL_BUCKET="linro-trail-logs-$ACCOUNT_ID"
# ── Once per account: a multi-region CloudTrail trail ──────────────────
# Skip this block if the account already runs one AND it is logging
# (EventBridge delivery needs an ACTIVE trail):
# aws cloudtrail describe-trails --query "trailList[?IsMultiRegionTrail]"
# aws cloudtrail get-trail-status --name <that-trail> # IsLogging: true
aws s3api create-bucket --bucket "$TRAIL_BUCKET" --region us-east-1
# The aws:SourceArn conditions pin the grant to THIS trail
# (confused-deputy protection, per AWS's recommended CloudTrail policy).
cat > trail-bucket-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "AWSCloudTrailAclCheck", "Effect": "Allow",
"Principal": {"Service": "cloudtrail.amazonaws.com"},
"Action": "s3:GetBucketAcl",
"Resource": "arn:aws:s3:::$TRAIL_BUCKET",
"Condition": {"ArnEquals": {"aws:SourceArn": "arn:aws:cloudtrail:us-east-1:$ACCOUNT_ID:trail/linro-trail"}} },
{ "Sid": "AWSCloudTrailWrite", "Effect": "Allow",
"Principal": {"Service": "cloudtrail.amazonaws.com"},
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::$TRAIL_BUCKET/AWSLogs/$ACCOUNT_ID/*",
"Condition": {"StringEquals": {"s3:x-amz-acl": "bucket-owner-full-control"},
"ArnEquals": {"aws:SourceArn": "arn:aws:cloudtrail:us-east-1:$ACCOUNT_ID:trail/linro-trail"}} }
]
}
EOF
aws s3api put-bucket-policy --bucket "$TRAIL_BUCKET" \
--policy file://trail-bucket-policy.json
aws cloudtrail create-trail --name linro-trail \
--s3-bucket-name "$TRAIL_BUCKET" --is-multi-region-trail --region us-east-1
aws cloudtrail start-logging --name linro-trail --region us-east-1
# ── Per region (us-east-1 first, then each scanned region) ─────────────
REGION="us-east-1"
QUEUE_URL=$(aws sqs create-queue --queue-name linro-trail-eventbridge \
--region "$REGION" --query QueueUrl --output text)
QUEUE_ARN=$(aws sqs get-queue-attributes --queue-url "$QUEUE_URL" \
--attribute-names QueueArn --region "$REGION" \
--query Attributes.QueueArn --output text)
# Write events only — the sensor refreshes on mutations.
aws events put-rule --name linro-trail-cloudtrail-events --region "$REGION" \
--event-pattern '{"detail-type":["AWS API Call via CloudTrail"],"detail":{"readOnly":[false]}}'
RULE_ARN=$(aws events describe-rule --name linro-trail-cloudtrail-events \
--region "$REGION" --query Arn --output text)
aws events put-targets --rule linro-trail-cloudtrail-events --region "$REGION" \
--targets "Id=linro-sqs,Arn=$QUEUE_ARN"
# Queue policy letting EventBridge deliver. Assemble the nested JSON with
# jq via files — inline shell quoting reliably mangles the ARNs.
cat > queue-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowEventBridgeDelivery", "Effect": "Allow",
"Principal": {"Service": "events.amazonaws.com"},
"Action": "sqs:SendMessage",
"Resource": "$QUEUE_ARN",
"Condition": {"ArnEquals": {"aws:SourceArn": "$RULE_ARN"}}
}]
}
EOF
jq -n --rawfile p queue-policy.json '{Policy: $p}' > queue-attrs.json
aws sqs set-queue-attributes --queue-url "$QUEUE_URL" --region "$REGION" \
--attributes file://queue-attrs.json
Grant the scanner role consume rights on each queue (events are delivered
inline — no S3 access needed):
cat > linro-scanner-sqs.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"],
"Resource": "arn:aws:sqs:*:$ACCOUNT_ID:linro-trail-eventbridge"
}]
}
EOF
aws iam put-role-policy --role-name linro-scanner \
--policy-name linro-eventbridge-sqs-read \
--policy-document file://linro-scanner-sqs.json
4b — Fallback: S3 polling (no extra infra)
Reuses the CloudTrail trail + S3 bucket from 4a — no queue, no rule. Just
grant the scanner role read on the log objects:
cat > linro-scanner-s3-logs.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::$TRAIL_BUCKET" },
{ "Effect": "Allow", "Action": "s3:GetObject",
"Resource": "arn:aws:s3:::$TRAIL_BUCKET/AWSLogs/*" }
]
}
EOF
aws iam put-role-policy --role-name linro-scanner \
--policy-name linro-trail-s3-read \
--policy-document file://linro-scanner-s3-logs.json
The sensor lists + reads CloudTrail log objects directly (5–15 min latency,
full coverage including read events). The sqs and kinesis strategies wire
the same trail through S3-notification→SQS or CloudWatch-Logs→Kinesis
respectively — standard AWS wiring; follow the AWS docs for those transports
and grant the scanner role the read actions from the strategy table above.
4c — Register the event source in Linro
Once the AWS side is in place, create a Linro EventSource for the AWS plugin
and attach it to the AWS sensor profile. The type field is the strategy
name (eventbridge / sqs / s3 / kinesis); config is the strategy
config.
Config payloads:
// type: "eventbridge" (also "sqs")
{ "queueURL": "<QUEUE_URL from 4a, this region>", "region": "us-east-1", "accountID": "123456789012" }
// type: "s3"
{ "bucketName": "<TRAIL_BUCKET from 4a>", "prefix": "AWSLogs/", "region": "us-east-1", "accountID": "123456789012" }
// type: "kinesis" (one per shard)
{ "streamARN": "<your Kinesis stream ARN>", "shardID": "shardId-000000000000" }
UI: System → Events → Sources → New. Pick the AWS plugin, choose the
strategy, fill the config form (rendered from the strategy's JSON Schema), enable
it, then add the event source to the AWS 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": "aws-eventbridge-us-east-1",
# "event_source": {
# "title": "AWS events (EventBridge us-east-1)",
# "plugin": "plugins/<aws-plugin-id>",
# "type": "eventbridge",
# "enabled": true,
# "config": { "queueURL": "...", "region": "us-east-1", "accountID": "123456789012" }
# }
# }
linro data-source create-event-source --address https://<your-install-host> \
--from-file request.json
# → returns eventSources/<id>
# 2. Attach it to the AWS 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 AWS then appear in inventory without waiting for the next reconcile.
Onboarding from an agent? The event-sourcing setup is restated as
structured steps (strategy decision rule, concrete calls, idempotency) in
onboarding-agent.md.
Troubleshooting
| Symptom | Cause | Fix |
|---|
AccessDenied / InvalidIdentityToken on assume | Namespace/audience/issuer mismatch | Re-read Step 0 from the install; the trust policy :sub condition must equal system:serviceaccount:<your-namespace>:* exactly; :aud and the OIDC provider client-id must include the audience |
Not authorized to perform sts:AssumeRoleWithWebIdentity | Trust policy principal wrong | The role's Federated principal must be the IAM OIDC provider ARN for Linro's issuer |
All resources of a service AccessDenied | Missing read permission | Confirm SecurityAudit + ViewOnlyAccess (or ReadOnlyAccess) are attached to the scanner role |
getIamPolicy-style calls fail but others pass | ViewOnlyAccess alone doesn't cover policy reads | Ensure SecurityAudit is attached (it grants the IAM policy reads) |
| OIDC provider creation fails / issuer unreachable | Private/non-public cluster issuer | WIF requires a publicly reachable issuer (AWS fetches JWKS itself); confirm by curling the oidc_discovery_url from GetCloudFederationInfo. Linro production uses a public EKS issuer |
| Inventory empty, sensor errors on credentials | AWS_CREDENTIALS not set on the sensor profile (or set on the wrong profile), or malformed JSON | Re-set the sealed env var from Step 2 on the profile the sensor runs on; allow ~30s for the rollout restart |
| S3 buckets + IAM appear, EC2 VPCs/security groups never do | The AWS connector has no region scopes — regional resource types only scan regions listed on the connector | Add a region scope per region to scan on the connector, then re-sync |
| REFERENCE resources (aws-region, aws-iam-managed-policy) never appear | The sensor profile carries only the user connector, not the plugin's aws-system connector | Attach both connectors to the AWS sensor profile |
| Event source enabled but no real-time updates | Event source not attached to the sensor profile | Add eventSources/<id> to the AWS sensor profile's event_sources — the sensor only streams attached sources |
| EventBridge source connects but stays empty | Rule filters readOnly=false (write events only), or no mutations occurred | Trigger a write (e.g. tag a resource); use s3/sqs for full coverage |
| S3 / SQS source lands nothing | Scanner role lacks s3:GetObject/s3:ListBucket or sqs:ReceiveMessage, wrong bucketName/queueURL/region | Grant the read actions from Step 4a/4b; verify the config values against what you created |
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 AWS_CREDENTIALS output, the
operator's 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.