Skip to main content

AWS IAM — Dynamic Credentials Engine

Generates temporary AWS IAM users with scoped policies. When a lease is requested, the engine creates an IAM user, attaches a policy based on the requested role, and returns temporary access keys. When the lease expires or is revoked, the engine deletes the access keys, detaches policies, and removes the user.

Capabilities

CapabilityDescription
engine:dynamic_credentialsCreates and revokes IAM users with access keys
host:store:readReads bootstrap config from Arcan's encrypted store
host:store:writePersists metadata (created usernames) for revocation
host:auditEmits audit events for credential lifecycle

Configuration

Bootstrap config provided during arcan plugin setup aws-iam:

FieldTypeRequiredDescription
access_key_idstringYesAWS access key ID of the admin IAM user
secret_access_keystringYesAWS secret access key of the admin IAM user
regionstringYesAWS region (e.g., us-east-1)
account_idstringYesAWS account ID (12 digits)
user_pathstringNoIAM path prefix for created users (default: /arcan/)
max_ttlstringNoMaximum lease duration (default: 24h)

The admin IAM user must have these permissions:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"iam:CreateUser",
"iam:DeleteUser",
"iam:CreateAccessKey",
"iam:DeleteAccessKey",
"iam:ListAccessKeys",
"iam:AttachUserPolicy",
"iam:DetachUserPolicy",
"iam:ListAttachedUserPolicies",
"sts:GetCallerIdentity"
],
"Resource": "*"
}
]
}

Roles

RolePolicy ARNDescription
readonlyarn:aws:iam::aws:policy/ReadOnlyAccessRead-only access to all AWS services
poweruserarn:aws:iam::aws:policy/PowerUserAccessFull access except IAM and Organizations
adminarn:aws:iam::aws:policy/AdministratorAccessFull admin access
customUser-provided ARNAny managed policy ARN the caller supplies

Operations

ping — Verify Credentials

Calls sts:GetCallerIdentity to confirm the bootstrap credentials are valid.

Request:

{"method": "ping", "params": null}

Response (success):

{"data": {"status": "healthy"}}

Response (failure):

{"error": "ping failed: operation error STS: GetCallerIdentity, https response error StatusCode: 403"}

generate — Create IAM User + Access Keys

Creates an IAM user with a unique name, attaches the role policy, and creates an access key pair.

Request:

{
"method": "generate",
"params": {
"role": "readonly",
"ttl": "1h",
"meta": {
"requester": "deploy-pipeline"
}
}
}

For custom roles, pass the policy ARN:

{
"method": "generate",
"params": {
"role": "custom",
"policy_arn": "arn:aws:iam::123456789012:policy/MyCustomPolicy",
"ttl": "4h"
}
}

Response (success):

{
"data": {
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"username": "arcan-a1b2c3d4",
"account_id": "123456789012",
"region": "us-east-1",
"policy_arn": "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
}

validate (Revoke) — Delete IAM User

Detaches all policies, deletes all access keys, and deletes the IAM user.

Request:

{
"method": "validate",
"params": {
"username": "arcan-a1b2c3d4"
}
}

Response (success):

{
"data": {
"valid": true,
"message": "user arcan-a1b2c3d4 revoked: policies detached, access keys deleted, user deleted"
}
}

Go Plugin Source

Complete, copy-paste ready. Compile with go build and register as an Arcan plugin.

package main

import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/iam"
"github.com/aws/aws-sdk-go-v2/service/sts"

"getarcan.dev/arcan/sdk"
)

// bootstrapConfig holds the admin credentials provided during setup.
type bootstrapConfig struct {
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
Region string `json:"region"`
AccountID string `json:"account_id"`
UserPath string `json:"user_path"`
}

// generateParams are the parameters for the generate operation.
type generateParams struct {
Role string `json:"role"`
TTL string `json:"ttl"`
PolicyARN string `json:"policy_arn"`
}

// revokeParams are the parameters for the validate (revoke) operation.
type revokeParams struct {
Username string `json:"username"`
}

// knownRoles maps role names to AWS managed policy ARNs.
var knownRoles = map[string]string{
"readonly": "arn:aws:iam::aws:policy/ReadOnlyAccess",
"poweruser": "arn:aws:iam::aws:policy/PowerUserAccess",
"admin": "arn:aws:iam::aws:policy/AdministratorAccess",
}

// AWSIAMEngine implements the Arcan SecretEngine interface.
type AWSIAMEngine struct {
cfg bootstrapConfig
awsCfg aws.Config
}

func (e *AWSIAMEngine) Describe() sdk.Descriptor {
return sdk.Descriptor{
Name: "aws-iam",
Version: "0.1.0",
DisplayName: "AWS IAM",
Description: "Dynamic IAM user credentials with scoped policies",
Capabilities: []string{
"engine:dynamic_credentials",
"host:store:read",
"host:store:write",
"host:audit",
},
}
}

func (e *AWSIAMEngine) Ping() error {
client := sts.NewFromConfig(e.awsCfg)
_, err := client.GetCallerIdentity(context.Background(), &sts.GetCallerIdentityInput{})
return err
}

func (e *AWSIAMEngine) Generate(params json.RawMessage) (*sdk.SecretResult, error) {
var p generateParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid generate params: %w", err)
}

// Resolve the policy ARN from role name.
policyARN, err := e.resolvePolicy(p.Role, p.PolicyARN)
if err != nil {
return nil, err
}

ctx := context.Background()
client := iam.NewFromConfig(e.awsCfg)

// Generate a unique username.
username := fmt.Sprintf("arcan-%s", randomSuffix(8))
path := e.cfg.UserPath
if path == "" {
path = "/arcan/"
}

// 1. Create the IAM user.
_, err = client.CreateUser(ctx, &iam.CreateUserInput{
UserName: aws.String(username),
Path: aws.String(path),
Tags: []iamTypes.Tag{
{Key: aws.String("managed-by"), Value: aws.String("arcan")},
},
})
if err != nil {
return nil, fmt.Errorf("CreateUser failed: %w", err)
}

// 2. Attach the policy.
_, err = client.AttachUserPolicy(ctx, &iam.AttachUserPolicyInput{
UserName: aws.String(username),
PolicyArn: aws.String(policyARN),
})
if err != nil {
// Cleanup: delete the user we just created.
_, _ = client.DeleteUser(ctx, &iam.DeleteUserInput{UserName: aws.String(username)})
return nil, fmt.Errorf("AttachUserPolicy failed: %w", err)
}

// 3. Create access keys.
keyOut, err := client.CreateAccessKey(ctx, &iam.CreateAccessKeyInput{
UserName: aws.String(username),
})
if err != nil {
_, _ = client.DetachUserPolicy(ctx, &iam.DetachUserPolicyInput{
UserName: aws.String(username), PolicyArn: aws.String(policyARN),
})
_, _ = client.DeleteUser(ctx, &iam.DeleteUserInput{UserName: aws.String(username)})
return nil, fmt.Errorf("CreateAccessKey failed: %w", err)
}

return &sdk.SecretResult{
Data: map[string]any{
"access_key_id": aws.ToString(keyOut.AccessKey.AccessKeyId),
"secret_access_key": aws.ToString(keyOut.AccessKey.SecretAccessKey),
"username": username,
"account_id": e.cfg.AccountID,
"region": e.cfg.Region,
"policy_arn": policyARN,
},
}, nil
}

func (e *AWSIAMEngine) Validate(params json.RawMessage) (*sdk.ValidationResult, error) {
var p revokeParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid revoke params: %w", err)
}
if p.Username == "" {
return nil, fmt.Errorf("username is required for revocation")
}
// Safety check: only revoke arcan-managed users.
if !strings.HasPrefix(p.Username, "arcan-") {
return nil, fmt.Errorf("refusing to revoke non-arcan user %q", p.Username)
}

ctx := context.Background()
client := iam.NewFromConfig(e.awsCfg)

// 1. List and delete all access keys.
keys, err := client.ListAccessKeys(ctx, &iam.ListAccessKeysInput{
UserName: aws.String(p.Username),
})
if err != nil {
return nil, fmt.Errorf("ListAccessKeys failed: %w", err)
}
for _, key := range keys.AccessKeyMetadata {
_, _ = client.DeleteAccessKey(ctx, &iam.DeleteAccessKeyInput{
UserName: aws.String(p.Username),
AccessKeyId: key.AccessKeyId,
})
}

// 2. Detach all policies.
policies, err := client.ListAttachedUserPolicies(ctx, &iam.ListAttachedUserPoliciesInput{
UserName: aws.String(p.Username),
})
if err != nil {
return nil, fmt.Errorf("ListAttachedUserPolicies failed: %w", err)
}
for _, pol := range policies.AttachedPolicies {
_, _ = client.DetachUserPolicy(ctx, &iam.DetachUserPolicyInput{
UserName: aws.String(p.Username),
PolicyArn: pol.PolicyArn,
})
}

// 3. Delete the user.
_, err = client.DeleteUser(ctx, &iam.DeleteUserInput{
UserName: aws.String(p.Username),
})
if err != nil {
return nil, fmt.Errorf("DeleteUser failed: %w", err)
}

return &sdk.ValidationResult{
Valid: true,
Message: fmt.Sprintf("user %s revoked: policies detached, access keys deleted, user deleted", p.Username),
}, nil
}

func (e *AWSIAMEngine) resolvePolicy(role, customARN string) (string, error) {
if role == "custom" {
if customARN == "" {
return "", fmt.Errorf("custom role requires policy_arn parameter")
}
return customARN, nil
}
arn, ok := knownRoles[role]
if !ok {
return "", fmt.Errorf("unknown role %q — valid roles: readonly, poweruser, admin, custom", role)
}
return arn, nil
}

func main() {
// Load bootstrap config from environment or stdin init message.
// In production, Arcan core passes config via the bootstrap flow.
cfg := loadBootstrapConfig()

awsCfg, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion(cfg.Region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cfg.AccessKeyID, cfg.SecretAccessKey, "",
)),
)
if err != nil {
panic(fmt.Sprintf("failed to load AWS config: %v", err))
}

sdk.Serve(&AWSIAMEngine{cfg: cfg, awsCfg: awsCfg})
}

// loadBootstrapConfig reads config from ARCAN_ENGINE_CONFIG env var (JSON).
func loadBootstrapConfig() bootstrapConfig {
raw := os.Getenv("ARCAN_ENGINE_CONFIG")
if raw == "" {
fmt.Fprintln(os.Stderr, "ARCAN_ENGINE_CONFIG not set")
os.Exit(1)
}
var cfg bootstrapConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
fmt.Fprintf(os.Stderr, "invalid config: %v\n", err)
os.Exit(1)
}
return cfg
}

// randomSuffix generates a hex suffix for unique usernames.
func randomSuffix(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)[:n]
}

Required imports not shown in snippet (add to the import block):

import (
"crypto/rand"
"encoding/hex"
"os"

iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
)

Build & Install

# Initialize module
mkdir arcan-engine-aws-iam && cd arcan-engine-aws-iam
go mod init getarcan.dev/engines/aws-iam

# Get dependencies
go get getarcan.dev/arcan/sdk
go get github.com/aws/aws-sdk-go-v2
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/credentials
go get github.com/aws/aws-sdk-go-v2/service/iam
go get github.com/aws/aws-sdk-go-v2/service/sts

# Build
go build -o arcan-engine-aws-iam .

# Install into Arcan plugin directory
cp arcan-engine-aws-iam ~/.arcan/plugins/

# Register with Arcan
arcan plugin register aws-iam --path ~/.arcan/plugins/arcan-engine-aws-iam

Usage Example

# Bootstrap the engine with admin credentials
arcan plugin setup aws-iam \
--config '{"access_key_id":"AKIA...","secret_access_key":"...","region":"us-east-1","account_id":"123456789012"}'

# Ping to verify connectivity
echo '{"method":"ping","params":null}' | arcan-engine-aws-iam
# → {"data":{"status":"healthy"}}

# Generate temporary readonly credentials
echo '{"method":"generate","params":{"role":"readonly","ttl":"1h"}}' | arcan-engine-aws-iam
# → {"data":{"access_key_id":"AKIA...","secret_access_key":"...","username":"arcan-a1b2c3d4",...}}

# Revoke credentials when done
echo '{"method":"validate","params":{"username":"arcan-a1b2c3d4"}}' | arcan-engine-aws-iam
# → {"data":{"valid":true,"message":"user arcan-a1b2c3d4 revoked: ..."}}

Security Notes

  • The admin IAM user should be scoped to only the permissions listed above. Do not use root credentials.
  • Created users are prefixed with arcan- and placed under the /arcan/ path for easy identification and policy scoping.
  • The engine refuses to revoke any user not prefixed with arcan- to prevent accidental deletion.
  • On generate failure, the engine rolls back partially created resources (user, policy attachment).