Skip to content

Configuring AWS Authentication

This guide covers how to securely configure AWS authentication for Controlinfra to scan your infrastructure.

Overview

Controlinfra needs AWS credentials to:

  1. Access your Terraform state (if using S3 backend)
  2. Run terraform plan to detect drift
  3. Query AWS APIs to compare actual vs. desired state

Authentication Methods

Controlinfra supports four authentication methods, each suited for different environments and security requirements:

MethodRunner TypeSecurity LevelBest For
Access KeysCloud or Self-HostedStandardQuick setup, development
Instance ProfileSelf-Hosted OnlyHighEC2-based runners
Assume Role (3a — control-plane)CloudHighCross-account scanning without operating your own runner. Configure via Cloud Accounts only — not available on the legacy per-repo creds form.
Assume Role (3b — self-hosted runner)Self-HostedHighCross-account, all credentials inside your AWS account
OIDC FederationCloudHighestEnterprise, zero secrets

Trust requirements at a glance

Each method involves a different principal on Controlinfra's side. Pick the one whose hosting model matches what you can run.

MethodWho Controlinfra authenticates asWhat you trust on Controlinfra's sideWhere the scanner must run
Access KeysThe IAM user you created — Controlinfra signs requests with the user's AccessKeyId + SecretAccessKey directly.Nothing on Controlinfra's side. The IAM user belongs to your AWS account; Controlinfra holds its keys in encrypted storage.Cloud runner or self-hosted.
Instance ProfileThe IAM role attached to the runner EC2 instance. The EC2 metadata service hands out short-lived credentials automatically.Nothing — credentials never leave your AWS environment.Self-hosted runner on EC2 only. Controlinfra's cloud runner is not on EC2.
Assume Role (3a — control-plane)A Controlinfra-managed master IAM user (controlinfra-master-scanner) inside Controlinfra's AWS account. The control plane calls sts:AssumeRole on your role using the master user's keys.Controlinfra's master IAM user ARN — listed in the Add Cloud Account form. Add it as a Principal.AWS in your role's trust policy.Cloud runner. Configure via Cloud Accounts only — the legacy per-repo creds form rejects assume_role on a cloud runner.
Assume Role (3b — self-hosted runner)The IAM role attached to your self-hosted runner (or its instance profile). The runner calls sts:AssumeRole on your target role using its own credentials.Nothing on Controlinfra's side — the AssumeRole call comes from your runner's identity.Self-hosted runner only.
OIDC FederationControlinfra signs a short-lived JWT (issuer = https://api.controlinfra.com, subject = org:<your-org-id>); AWS STS exchanges it via AssumeRoleWithWebIdentity for temporary credentials under your IAM role.Controlinfra's OIDC issuer URL — register https://api.controlinfra.com as an OIDC Identity Provider in your AWS account, then trust it in your role's Principal.Federated. The trust-policy condition keys must use the api.controlinfra.com: prefix to match the issuer host.Cloud runner (Enterprise plan).

Why this matters

"Self-Hosted Only" methods don't degrade gracefully — they fail outright if Controlinfra's cloud runner is the executor, because the cloud runner has no AWS-side identity to assume. Access Keys or Assume Role (3a) or OIDC for cloud-runner setups; Instance Profile or Assume Role (3b) when you operate the runner yourself.


Option 1: Access Keys

The simplest method using AWS Access Key ID and Secret Access Key.

Trust requirements

  • Who Controlinfra authenticates as: the IAM user you create below. Controlinfra holds its AccessKeyId + SecretAccessKey in encrypted credential storage and signs AWS API requests with them directly.
  • What you trust on Controlinfra's side: nothing — the IAM user belongs to your AWS account.
  • Where this works: cloud runner or self-hosted runner.

When to Use

  • Getting started quickly
  • Cloud runner (Controlinfra-managed)
  • Development/testing environments

Setup Steps

  1. Go to AWS IAM Console → Users → Create user
  2. Name the user (e.g., controlinfra-scanner)
  3. Attach the required policy (see IAM Permissions below)
  4. Create access keys → Security credentials → Create access key
  5. Enter in Controlinfra: Add Repository → AWS Authentication → Access Keys

Security Considerations

  • Credentials are encrypted at rest with AES-256-GCM
  • Rotate keys regularly (recommended: every 90 days)
  • Use least-privilege permissions

Option 2: Instance Profile Recommended

Use the IAM role attached to your EC2 instance. No credentials to manage!

Trust requirements & hosting constraint

  • Who Controlinfra authenticates as: the IAM role attached to your EC2 instance via its instance profile. The EC2 metadata service hands out short-lived credentials and they never leave the VM.
  • What you trust on Controlinfra's side: nothing — there is no Controlinfra-issued principal involved.
  • Where this works: only on a self-hosted runner running on EC2 with an instance profile attached. Controlinfra's cloud runner is not on EC2, so picking this option with a cloud runner will fail at scan time.

Self-Hosted Runner Required

This option is only available when using a self-hosted runner on EC2.

When to Use

  • Self-hosted runner on EC2
  • Enhanced security requirements
  • No credential rotation needed

How It Works

┌─────────────────────┐     ┌──────────────────┐     ┌─────────────┐
│  Controlinfra       │────▶│  Self-Hosted     │────▶│  AWS APIs   │
│  Cloud              │     │  Runner (EC2)    │     │             │
└─────────────────────┘     └──────────────────┘     └─────────────┘

                            IAM Instance Profile
                            (automatic credentials)

Setup Steps

  1. Create an IAM Role for your EC2 instance:

    • Go to IAM → Roles → Create role
    • Select "AWS service" → EC2
    • Attach the required policy (see IAM Permissions)
    • Name it (e.g., controlinfra-runner-role)
  2. Attach the role to your EC2 instance:

    • EC2 Console → Select instance → Actions → Security → Modify IAM role
    • Select your role and save
  3. Install the self-hosted runner on the EC2 instance (guide)

  4. Configure in Controlinfra:

    • Add Repository → AWS Authentication
    • Select "Self-Hosted" runner
    • Select "Instance Profile" authentication
    • Choose your AWS region

Benefits

  • No credentials stored - AWS SDK automatically uses EC2 metadata service
  • No rotation needed - AWS manages temporary credentials
  • Audit-friendly - All actions tracked under the IAM role
  • Secure - Credentials never leave your AWS account

Option 3: Assume Role Advanced

Use STS AssumeRole for cross-account access or enhanced security. Two flavours, depending on which Controlinfra component performs the AssumeRole call.

Where each flavour is supported in the UI

  • Cloud Accounts (Settings → Cloud Accounts → Add Cloud Account) — supports both 3a (control-plane) and 3b (self-hosted runner) flavours. This is the recommended path and the one this guide focuses on.
  • Per-repository AWS credentials (Add Repository → AWS Authentication) — the legacy per-repo creds form only supports 3b (self-hosted runner). The repo-creds validator rejects assume_role on a cloud runner with "Assume Role authentication requires a self-hosted runner". To use 3a control-plane AssumeRole on the cloud runner, configure it via Cloud Accounts and reference that account on the repo.

Pick a flavour

FlavourCaller of AssumeRoleWhen to useSetup steps
3a — Control-plane AssumeRoleThe Controlinfra control plane itself, using a master IAM user managed by ControlinfraCloud runner (Controlinfra-managed scanning); no infrastructure of your own to set up. Only available via the Cloud Accounts form, not the legacy per-repo creds form.Trust Controlinfra's master IAM user ARN in your role's trust policy. See Setup — Control-plane variant below.
3b — Runner-chained AssumeRoleA self-hosted runner you operate (e.g. EC2 with an Instance Profile)You want to keep all credentials inside your AWS account and cross to multiple target accounts from one runnerTrust your runner's role ARN in your target role's trust policy. See Setup — Self-hosted runner variant below.

In the Cloud Accounts form, both flavours sit under the same "Assume Role" option — you simply pick whichever runner type the surrounding workspace is configured for. The key onboarding choice is which principal to put in your trust policy, and that depends entirely on the flavour.

Where do I find Controlinfra's master IAM user ARN?

Open the Add Cloud Account form in the Controlinfra dashboard, choose AWS → Assume Role. The form surfaces the master IAM user ARN you need to add to your trust policy. The actual ARN differs between Controlinfra environments (stage / production), so always copy it from your live dashboard rather than hard-coding it.

When to Use

  • Cross-account infrastructure scanning
  • Centralized runner, multiple AWS accounts
  • Maximum security with external ID verification
  • Temporary credentials with defined duration

How It Works (Self-hosted runner variant)

┌─────────────────────┐     ┌──────────────────┐     ┌─────────────┐
│  Controlinfra       │────▶│  Runner Account  │     │  Target     │
│  Cloud              │     │  (EC2 + Role A)  │     │  Account    │
└─────────────────────┘     └──────────────────┘     │  (Role B)   │
                                    │                └─────────────┘
                                    │  STS AssumeRole      ▲
                                    └──────────────────────┘
Technical Flow
  1. Runner starts with its EC2 Instance Profile (Role A) as base credentials
  2. Runner calls aws sts assume-role using the Instance Profile credentials
  3. AWS STS returns temporary credentials for the target role (Role B)
  4. Terraform runs using the temporary credentials
  5. Credentials expire after the session duration (default: 1 hour)

This flow ensures the runner itself only needs sts:AssumeRole permission, while all infrastructure access is granted through the target role.

How It Works (Control-plane variant)

┌──────────────────────────┐                            ┌─────────────┐
│  Controlinfra control    │                            │  Target     │
│  plane                   │──── STS AssumeRole ───────▶│  Account    │
│  (master IAM user keys)  │                            │  (Role)     │
└──────────────────────────┘                            └─────────────┘
Technical Flow (Control-plane)
  1. Controlinfra control plane holds long-lived access keys for a single master IAM user (controlinfra-master-scanner) inside Controlinfra's own AWS account.
  2. You add Controlinfra's master IAM user ARN to the trust policy of your target role.
  3. When a scan or validation fires, Controlinfra calls sts:AssumeRole on your role, using the master user's credentials as the caller.
  4. AWS STS returns short-lived credentials for your target role (default: 1 hour).
  5. Controlinfra performs read-only AWS API calls with the temporary credentials.
  6. Credentials expire at the end of the session — Controlinfra re-assumes for the next scan.

The master IAM user is rotated by Controlinfra on a regular cadence (see AWS-MASTER-KEY-ROTATION.md in the repo for the current rotation policy). You don't need to update your trust policy when rotation happens — the user's ARN is stable; only its access keys change.

Setup — Control-plane variant (3a)

Use this when scans run from Controlinfra's managed cloud runner. Simplest setup; no infrastructure of your own to operate.

Step 1 (3a): Create the Target Role with Controlinfra's master IAM user as the principal

Open the Controlinfra dashboard → Add Cloud Account → AWS → Assume Role. Copy the master IAM user ARN the form shows you (it looks like arn:aws:iam::<controlinfra-account>:user/controlinfra-master-scanner — the exact account ID depends on your environment).

Create the trust policy file (trust-policy.json):

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::CONTROLINFRA_ACCOUNT_ID:user/controlinfra-master-scanner"
      },
      "Action": "sts:AssumeRole",
      "Condition": {}
    }
  ]
}

Create the role and attach read-only permissions:

bash
aws iam create-role \
  --role-name ControlinfraTargetRole \
  --assume-role-policy-document file://trust-policy.json \
  --description "Role for Controlinfra control plane to scan infrastructure"

aws iam attach-role-policy \
  --role-name ControlinfraTargetRole \
  --policy-arn arn:aws:iam::aws:policy/ReadOnlyAccess

Step 2 (3a): Configure in Controlinfra

  1. Add Cloud Account → AWSAssume Role
  2. Enter the Role ARN of your ControlinfraTargetRole
  3. Optionally set an External ID (see Using External ID)
  4. Save — the form validates the trust relationship by performing a real AssumeRole call. A 200 response confirms the trust policy is correct.

Multiple principals (control-plane + self-hosted runner)

If you also operate a self-hosted runner that needs to scan the same target, list both principals in the trust policy:

json
"Principal": {
  "AWS": [
    "arn:aws:iam::CONTROLINFRA_ACCOUNT_ID:user/controlinfra-master-scanner",
    "arn:aws:iam::YOUR_RUNNER_ACCOUNT_ID:role/your-runner-role"
  ]
}

Setup — Self-hosted runner variant (3b)

Use this when you want all scanning to originate from your own AWS infrastructure (typical for regulated environments where leaving your VPC isn't acceptable).

Step 1 (3b): Create the Target Role (in each target AWS account)

Create the trust policy file (trust-policy.json):

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::RUNNER_ACCOUNT_ID:role/your-runner-role"
      },
      "Action": "sts:AssumeRole",
      "Condition": {}
    }
  ]
}

Multiple Runners

To allow multiple runner roles to assume this role, use an array:

json
"Principal": {
  "AWS": [
    "arn:aws:iam::RUNNER_ACCOUNT_ID:role/runner-role-1",
    "arn:aws:iam::RUNNER_ACCOUNT_ID:role/runner-role-2"
  ]
}

Create the role using AWS CLI:

bash
# Create the target role
aws iam create-role \
  --role-name ControlinfraTargetRole \
  --assume-role-policy-document file://trust-policy.json \
  --description "Role for Controlinfra to scan infrastructure"

# Attach read-only permissions (or your custom policy)
aws iam attach-role-policy \
  --role-name ControlinfraTargetRole \
  --policy-arn arn:aws:iam::aws:policy/ReadOnlyAccess

Step 2 (3b): Create the Runner Role with Instance Profile

First, create the EC2 trust policy (ec2-trust-policy.json):

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Create the runner role and instance profile:

bash
# Create the runner role
aws iam create-role \
  --role-name ControlinfraRunnerRole \
  --assume-role-policy-document file://ec2-trust-policy.json \
  --description "Runner role for Controlinfra self-hosted runner"

# Create instance profile
aws iam create-instance-profile \
  --instance-profile-name ControlinfraRunnerProfile

# Attach role to instance profile
aws iam add-role-to-instance-profile \
  --instance-profile-name ControlinfraRunnerProfile \
  --role-name ControlinfraRunnerRole

Step 3 (3b): Grant AssumeRole Permission to Runner

Create the assume role permission policy (assume-role-policy.json):

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Resource": [
        "arn:aws:iam::TARGET_ACCOUNT_1:role/ControlinfraTargetRole",
        "arn:aws:iam::TARGET_ACCOUNT_2:role/ControlinfraTargetRole"
      ]
    }
  ]
}

Attach the policy to the runner role:

bash
aws iam put-role-policy \
  --role-name ControlinfraRunnerRole \
  --policy-name AssumeRolePolicy \
  --policy-document file://assume-role-policy.json

Step 4 (3b): Launch EC2 Runner with Instance Profile

bash
# Launch EC2 instance with the runner instance profile
aws ec2 run-instances \
  --image-id ami-0123456789abcdef0 \
  --instance-type t3.small \
  --iam-instance-profile Name=ControlinfraRunnerProfile \
  --key-name your-key-pair \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=controlinfra-runner}]'

Step 5 (3b): Verify the Setup

SSH into your runner EC2 instance and verify:

bash
# Check the instance identity (should show the runner role)
aws sts get-caller-identity

# Expected output:
# {
#     "UserId": "AROA...:i-0123456789abcdef0",
#     "Account": "123456789012",
#     "Arn": "arn:aws:sts::123456789012:assumed-role/ControlinfraRunnerRole/i-0123456789abcdef0"
# }

# Test assuming the target role
aws sts assume-role \
  --role-arn arn:aws:iam::TARGET_ACCOUNT:role/ControlinfraTargetRole \
  --role-session-name test-session \
  --query 'Credentials.{AccessKeyId:AccessKeyId,Expiration:Expiration}' \
  --output table

# Expected output:
# -------------------------------------------------------
# |                     AssumeRole                      |
# +-----------------------+-----------------------------+
# |      AccessKeyId      |         Expiration          |
# +-----------------------+-----------------------------+
# |  ASIA...              |  2024-01-01T12:00:00+00:00  |
# +-----------------------+-----------------------------+

Step 6 (3b): Full End-to-End Test

Run this script on your runner to verify the complete flow:

bash
#!/bin/bash
echo "=== Current Identity (Instance Profile) ==="
aws sts get-caller-identity

echo ""
echo "=== Assuming Target Role ==="
CREDS=$(aws sts assume-role \
  --role-arn arn:aws:iam::TARGET_ACCOUNT:role/ControlinfraTargetRole \
  --role-session-name controlinfra-scan \
  --output json)

export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r '.Credentials.AccessKeyId')
export AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r '.Credentials.SecretAccessKey')
export AWS_SESSION_TOKEN=$(echo $CREDS | jq -r '.Credentials.SessionToken')

echo ""
echo "=== New Identity (Assumed Role) ==="
aws sts get-caller-identity

echo ""
echo "=== Testing AWS Access ==="
aws s3 ls --region us-east-1 | head -5

Step 7 (3b): Configure in Controlinfra

  1. Go to Add RepositoryAWS Authentication
  2. Select "Self-Hosted" runner type
  3. Select your registered runner
  4. Choose "Assume Role" authentication
  5. Enter the Role ARN: arn:aws:iam::TARGET_ACCOUNT:role/ControlinfraTargetRole
  6. Enter the External ID (optional, see below)
  7. Select your AWS region

AWS China and GovCloud Support

Controlinfra supports IAM role ARNs for all AWS partitions:

  • Standard AWS: arn:aws:iam::123456789012:role/RoleName
  • AWS China: arn:aws-cn:iam::123456789012:role/RoleName
  • AWS GovCloud: arn:aws-us-gov:iam::123456789012:role/RoleName

The External ID prevents the "confused deputy" problem and adds an extra layer of security.

Update Trust Policy with External ID

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::RUNNER_ACCOUNT_ID:role/ControlinfraRunnerRole"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "your-unique-external-id-here"
        }
      }
    }
  ]
}

Test with External ID

bash
# Test assuming role with external ID
aws sts assume-role \
  --role-arn arn:aws:iam::TARGET_ACCOUNT:role/ControlinfraTargetRole \
  --role-session-name test-session \
  --external-id "your-unique-external-id-here" \
  --query 'Credentials.{AccessKeyId:AccessKeyId,Expiration:Expiration}' \
  --output table

Best Practices

  • Use a unique, random string (e.g., UUID: 550e8400-e29b-41d4-a716-446655440000)
  • Store it securely - you'll need it for configuration
  • Use different external IDs for different repositories/environments
  • Generate with: uuidgen or python -c "import uuid; print(uuid.uuid4())"

WARNING

Always use an External ID when the role can be assumed by third parties or in production environments.

Troubleshooting Assume Role

"Access Denied" when assuming role

  1. Check trust policy - Ensure the runner role ARN is in the Principal:

    bash
    aws iam get-role --role-name ControlinfraTargetRole \
      --query 'Role.AssumeRolePolicyDocument'
  2. Check runner permissions - Ensure the runner can call sts:AssumeRole:

    bash
    aws iam list-role-policies --role-name ControlinfraRunnerRole
    aws iam get-role-policy --role-name ControlinfraRunnerRole \
      --policy-name AssumeRolePolicy
  3. Check External ID - If configured, ensure it matches exactly

"InvalidIdentityToken" error

  • The runner's instance profile may not be properly attached
  • Verify with: aws sts get-caller-identity

Role assumed but actions fail

  • The target role may lack required permissions
  • Check attached policies: aws iam list-attached-role-policies --role-name ControlinfraTargetRole

Option 4: OIDC Federation Enterprise

The most secure method — Controlinfra authenticates with AWS using OpenID Connect (OIDC) federation. No secrets are stored or exchanged. Controlinfra signs a short-lived JWT token and exchanges it for temporary AWS credentials via AssumeRoleWithWebIdentity.

Trust requirements

  • Who Controlinfra authenticates as: Controlinfra's control plane signs a short-lived JWT (issuer = https://api.controlinfra.com, subject = org:<your-org-id>); AWS STS exchanges it via AssumeRoleWithWebIdentity for temporary credentials under the IAM role you create below.
  • What you trust on Controlinfra's side: the Controlinfra OIDC issuer URL https://api.controlinfra.com. Register it as an OIDC Identity Provider in your AWS account, then put arn:aws:iam::<your-account>:oidc-provider/api.controlinfra.com in your role's Principal.Federated and gate it with an api.controlinfra.com:sub condition that matches org:<your-org-id>. The condition key prefix MUST match the issuer host exactly — controlinfra.com:sub will not match a JWT whose iss claim is https://api.controlinfra.com and AWS will reject the AssumeRoleWithWebIdentity call with InvalidIdentityToken.
  • Where this works: cloud runner only (Enterprise plan). The token is signed by the control plane.

Enterprise Plan Required

OIDC Federation is available exclusively on the Enterprise plan.

When to Use

  • Maximum security — zero stored secrets
  • Cloud runner (Controlinfra-managed) — no self-hosted runner needed
  • Compliance requirements that prohibit long-lived credentials
  • Enterprise environments with strict credential policies

How It Works

┌─────────────────────┐     ┌──────────────────┐     ┌─────────────┐
│  Controlinfra       │     │  AWS STS          │     │  Your AWS   │
│  Cloud              │     │                   │     │  Account    │
│                     │──1──▶  AssumeRoleWith   │──2──▶             │
│  Signs JWT token    │     │  WebIdentity      │     │  IAM Role   │
│                     │◀─3──│  (temp creds)     │     │             │
└─────────────────────┘     └──────────────────┘     └─────────────┘
Technical Flow
  1. Controlinfra signs a JWT with your organization's subject claim (sub: org:<orgId>)
  2. AWS STS verifies the token against Controlinfra's OIDC Identity Provider
  3. AWS returns temporary credentials (valid for 1 hour) for the target IAM role
  4. Controlinfra uses the temp credentials to scan your infrastructure
  5. Credentials expire automatically — nothing to rotate

Setup Steps

Step 1: Create an OIDC Identity Provider in Your AWS Account

First, retrieve the TLS certificate thumbprint for the OIDC issuer:

bash
# Get the thumbprint for api.controlinfra.com
echo | openssl s_client -servername api.controlinfra.com \
  -showcerts -connect api.controlinfra.com:443 2>/dev/null \
  | openssl x509 -fingerprint -noout -sha1 \
  | sed 's/://g' | cut -d= -f2

Then create the provider with the thumbprint:

bash
aws iam create-open-id-connect-provider \
  --url https://api.controlinfra.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list "<THUMBPRINT_FROM_ABOVE>"

TIP

AWS automatically fetches and validates the TLS certificate thumbprint for HTTPS issuers.

Step 2: Create the IAM Trust Policy

Create a file called oidc-trust-policy.json:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/api.controlinfra.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "api.controlinfra.com:aud": "sts.amazonaws.com",
          "api.controlinfra.com:sub": "org:YOUR_ORG_ID"
        }
      }
    }
  ]
}

Replace Placeholders

  • Replace YOUR_ACCOUNT_ID with your 12-digit AWS account ID.
  • Replace YOUR_ORG_ID with your Controlinfra organization ID (found in Settings > General). This ensures only your organization can assume the role.

Step 3: Create the IAM Role

bash
aws iam create-role \
  --role-name ControlinfraOIDCRole \
  --assume-role-policy-document file://oidc-trust-policy.json \
  --description "Controlinfra OIDC federation role for drift scanning"

# Attach read-only permissions
aws iam attach-role-policy \
  --role-name ControlinfraOIDCRole \
  --policy-arn arn:aws:iam::aws:policy/ReadOnlyAccess

Step 4: Configure in Controlinfra

  1. Go to SettingsIntegrationsCloud Credentials
  2. Click Add Configuration
  3. Select OIDC Federation as the authentication method
  4. Enter a name (e.g., Production OIDC)
  5. Enter the Role ARN: arn:aws:iam::YOUR_ACCOUNT_ID:role/ControlinfraOIDCRole
  6. Select your AWS region
  7. Click Add Configuration

Benefits

  • Zero secrets stored — No access keys, no secret keys, nothing to leak
  • No rotation needed — Temporary credentials are issued on-demand and expire automatically
  • Cloud runner compatible — Works with Controlinfra's managed infrastructure, no self-hosted runner required
  • Audit-friendly — All actions are tracked under the IAM role with the session name controlinfra-oidc
  • Scoped access — The trust policy condition ensures only your Controlinfra organization can assume the role

Troubleshooting OIDC

"InvalidIdentityToken" error

  • Verify the OIDC Identity Provider URL is exactly https://api.controlinfra.com (the JWT's iss claim is signed with this exact value — even a host mismatch like controlinfra.com vs api.controlinfra.com will fail validation)
  • Verify the trust-policy condition keys use the api.controlinfra.com: prefix (must match the issuer host)
  • Check the audience is set to sts.amazonaws.com
  • Ensure the OIDC provider's thumbprint is up to date

"AccessDenied" when assuming role

  • Verify the trust policy Principal.Federated matches your OIDC provider ARN
  • Check that the Condition block matches exactly
  • Ensure the role has the required permissions attached

"ExpiredTokenException"

  • This is normal if a scan takes longer than 1 hour — Controlinfra automatically refreshes credentials

Required IAM Permissions

Controlinfra needs two types of permissions:

  1. Read-Only Access to AWS resources for drift detection
  2. Terraform State Access to read state from S3 and manage DynamoDB locks

The simplest and most maintainable approach:

bash
# Attach AWS managed ReadOnlyAccess policy
aws iam attach-role-policy \
  --role-name YourRoleName \
  --policy-arn arn:aws:iam::aws:policy/ReadOnlyAccess

# Then add a custom inline policy for Terraform state access
aws iam put-role-policy \
  --role-name YourRoleName \
  --policy-name TerraformStateAccess \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [
      {
        "Sid": "TerraformStateS3",
        "Effect": "Allow",
        "Action": [
          "s3:GetObject",
          "s3:PutObject",
          "s3:ListBucket",
          "s3:GetBucketLocation"
        ],
        "Resource": [
          "arn:aws:s3:::YOUR-STATE-BUCKET",
          "arn:aws:s3:::YOUR-STATE-BUCKET/*"
        ]
      },
      {
        "Sid": "TerraformDynamoDBLock",
        "Effect": "Allow",
        "Action": [
          "dynamodb:GetItem",
          "dynamodb:PutItem",
          "dynamodb:DeleteItem"
        ],
        "Resource": "arn:aws:dynamodb:*:*:table/terraform-*"
      }
    ]
  }'

Replace Placeholder Values

Replace YOUR-STATE-BUCKET with your actual Terraform state S3 bucket name.

Why ReadOnlyAccess?

The AWS managed ReadOnlyAccess policy covers all AWS services and is automatically updated by AWS when new services are added. This ensures Controlinfra can detect drift in any resource Terraform manages without requiring policy updates.

Alternative: Minimal Custom Policy

If you prefer a more restrictive policy, use this custom policy:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "TerraformStateAccess",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::your-terraform-state-bucket",
        "arn:aws:s3:::your-terraform-state-bucket/*"
      ]
    },
    {
      "Sid": "DynamoDBLocking",
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:DeleteItem"
      ],
      "Resource": "arn:aws:dynamodb:*:*:table/terraform-locks"
    },
    {
      "Sid": "ReadOnlyInfrastructure",
      "Effect": "Allow",
      "Action": [
        "ec2:Describe*",
        "s3:GetBucket*",
        "s3:GetObject*",
        "s3:ListBucket*",
        "rds:Describe*",
        "elasticloadbalancing:Describe*",
        "autoscaling:Describe*",
        "cloudwatch:Describe*",
        "cloudwatch:GetMetricStatistics",
        "iam:GetRole",
        "iam:GetPolicy",
        "iam:GetUser",
        "iam:ListAttachedRolePolicies",
        "iam:ListRolePolicies",
        "lambda:GetFunction",
        "lambda:ListFunctions",
        "sns:GetTopicAttributes",
        "sqs:GetQueueAttributes",
        "route53:GetHostedZone",
        "route53:ListResourceRecordSets",
        "cloudfront:GetDistribution",
        "acm:DescribeCertificate"
      ],
      "Resource": "*"
    }
  ]
}

Full Infrastructure Policy

For comprehensive coverage of all AWS services:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadOnlyAccess",
      "Effect": "Allow",
      "Action": [
        "acm:Describe*",
        "acm:Get*",
        "acm:List*",
        "apigateway:GET",
        "autoscaling:Describe*",
        "cloudfront:Get*",
        "cloudfront:List*",
        "cloudwatch:Describe*",
        "cloudwatch:Get*",
        "cloudwatch:List*",
        "cognito-idp:Describe*",
        "cognito-idp:List*",
        "dynamodb:Describe*",
        "dynamodb:List*",
        "ec2:Describe*",
        "ecr:Describe*",
        "ecr:Get*",
        "ecr:List*",
        "ecs:Describe*",
        "ecs:List*",
        "eks:Describe*",
        "eks:List*",
        "elasticache:Describe*",
        "elasticloadbalancing:Describe*",
        "es:Describe*",
        "es:List*",
        "events:Describe*",
        "events:List*",
        "iam:Get*",
        "iam:List*",
        "kms:Describe*",
        "kms:Get*",
        "kms:List*",
        "lambda:Get*",
        "lambda:List*",
        "logs:Describe*",
        "logs:Get*",
        "rds:Describe*",
        "rds:List*",
        "route53:Get*",
        "route53:List*",
        "s3:GetBucket*",
        "s3:GetObject*",
        "s3:ListBucket*",
        "secretsmanager:Describe*",
        "secretsmanager:List*",
        "sns:Get*",
        "sns:List*",
        "sqs:Get*",
        "sqs:List*",
        "ssm:Describe*",
        "ssm:Get*",
        "ssm:List*"
      ],
      "Resource": "*"
    },
    {
      "Sid": "TerraformState",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::your-terraform-state-bucket",
        "arn:aws:s3:::your-terraform-state-bucket/*"
      ]
    },
    {
      "Sid": "TerraformLocking",
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:DeleteItem"
      ],
      "Resource": "arn:aws:dynamodb:*:*:table/terraform-locks"
    }
  ]
}

Security Best Practices

1. Use Least Privilege

Only grant permissions for resources Terraform manages:

json
{
  "Effect": "Allow",
  "Action": ["ec2:Describe*"],
  "Resource": "*",
  "Condition": {
    "StringEquals": {
      "ec2:ResourceTag/ManagedBy": "terraform"
    }
  }
}

2. Rotate Credentials Regularly

  • Set up a rotation schedule (e.g., every 90 days)
  • Use AWS Secrets Manager for automated rotation
  • Update Controlinfra settings after rotation

3. Monitor Credential Usage

Enable CloudTrail to track API calls:

json
{
  "eventSource": "sts.amazonaws.com",
  "eventName": "AssumeRole",
  "userIdentity": {
    "userName": "Controlinfra-scanner"
  }
}

4. Use Self-Hosted Runners

For production environments:

  • Credentials stay in your AWS account
  • Use IAM roles instead of access keys
  • Better audit trail and compliance

Multiple AWS Accounts

If you manage infrastructure across multiple AWS accounts:

Option 1: Separate Credentials per Repository

Configure different AWS credentials for each repository in Controlinfra.

Option 2: Cross-Account Assume Role

  1. Create a role in each target account:
json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::SCANNER_ACCOUNT:user/Controlinfra-scanner"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
  1. Grant assume role permission to the scanner user:
json
{
  "Effect": "Allow",
  "Action": "sts:AssumeRole",
  "Resource": [
    "arn:aws:iam::ACCOUNT_1:role/Controlinfra-scanner",
    "arn:aws:iam::ACCOUNT_2:role/Controlinfra-scanner"
  ]
}

Credential Storage

Controlinfra secures your credentials:

  • Encryption: All credentials are encrypted at rest using AES-256
  • Access Control: Credentials are only decrypted during scan execution
  • No Logging: Credentials are never written to logs
  • Isolation: Each user's credentials are isolated

Troubleshooting

"Access Denied" Errors

  1. Check IAM policy is attached to the user
  2. Verify the resource ARNs in the policy
  3. Check for Service Control Policies (SCPs) in AWS Organizations

"InvalidClientTokenId" Error

  • Access Key ID may be incorrect
  • User may have been deleted
  • Credentials may have been deactivated

"ExpiredToken" Error

  • If using temporary credentials, they may have expired
  • Generate new access keys

State File Access Issues

Error: Failed to load state: AccessDenied
  • Verify S3 bucket permissions
  • Check bucket policy allows the IAM user
  • Ensure the state file key path is correct

Next Steps