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 three 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 RoleSelf-Hosted OnlyHighestCross-account, production

Option 1: Access Keys

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

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!

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.

Self-Hosted Runner Required

This option requires a self-hosted runner with an Instance Profile that can assume the target role.

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

┌─────────────────────┐     ┌──────────────────┐     ┌─────────────┐
│  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.

Setup Steps

Step 1: 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: 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: 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: 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: 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: 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: 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

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

AI-powered infrastructure drift detection