IAM Roles & Security

The IAM module (createIamRoles) creates 3 IAM roles following the principle of least privilege. Each role is scoped to the minimum permissions needed for its specific workload — no wildcard ** resources on sensitive actions, no over-provisioned policies.

Module overview

AspectDetail
Filelib/iam.ts
FunctioncreateIamRoles(scope: Construct, input: IamInput): IamOutput
Resources3 IAM roles with inline/managed policies
Return type{ ecsTaskExecutionRole, ecsTaskRole, lambdaExecutionRole }

IAM Roles & Security Diagram


1. Role Summary

RoleAssumed ByUsed ByKey Permissions
ECS Task Executionecs-tasks.amazonaws.comECS agent (container startup)ECR pull, CloudWatch Logs write, Secrets Manager read
ECS Taskecs-tasks.amazonaws.comGo application + RaftDB sidecarDynamoDB CRUD (4 tables), S3 R/W (2 buckets), EFS mount
Lambda Executionlambda.amazonaws.comLambda functionDynamoDB CRUD (4 tables), S3 PutObject (1 bucket), CloudWatch Logs
export function createIamRoles(scope: Construct, input: IamInput): IamOutput {
  const { db, storage } = input;

  const ecsTaskExecutionRole = new iam.Role(scope, 'EcsTaskExecutionRole', {
    assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
  });

  const ecsTaskRole = new iam.Role(scope, 'EcsTaskRole', {
    assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
  });

  const lambdaExecutionRole = new iam.Role(scope, 'LambdaExecutionRole', {
    assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
  });

  // ... policy attachments ...

  return { ecsTaskExecutionRole, ecsTaskRole, lambdaExecutionRole };
}

2. Role 1: ECS Task Execution Role

This role is used by the ECS agent (not the application) to pull container images, write logs, and retrieve secrets at container startup.

Permissions

ServiceActionsResource ScopePurpose
ECRGetAuthorizationToken** (ECS-managed policy)Authenticate to ECR
ECRBatchCheckLayerAvailability, GetDownloadUrlForLayer, BatchGetImage** (ECS-managed policy)Pull container image layers
CloudWatch LogsCreateLogStream, PutLogEvents** (ECS-managed policy)Write container stdout/stderr to CloudWatch
Secrets ManagerGetSecretValueawsplace/app-secrets ARN onlyRetrieve SESSION_SECRET for injection into App container

Why three separate ECR permissions?

The ECS agent needs three distinct ECR permissions to pull an image:

  1. GetAuthorizationToken — obtain temporary Docker login credentials
  2. BatchCheckLayerAvailability — check which image layers already exist locally (avoids re-download)
  3. GetDownloadUrlForLayer + BatchGetImage — download missing layers and the image manifest

These are provided by the AWS-managed AmazonECSTaskExecutionRolePolicy. The Secrets Manager permission is the only custom addition — scoped to the single awsplace/app-secrets secret.


3. Role 2: ECS Task Role

This role is assumed by the Go application container (and the RaftDB sidecar) at runtime. It grants access to application data services.

Permissions

ServiceActionsResource ScopePurpose
DynamoDBGetItem, PutItem, UpdateItem, DeleteItem, Query, Scan, BatchGetItem, BatchWriteItemAll 4 table ARNs + index ARNsConfig, Bans, Milestones, History CRUD
S3GetObject, PutObject, DeleteObjectawsplace-canvas-{account}/, awsplace-exports-{account}/Canvas binary load/save, PNG export upload
S3ListBucketawsplace-canvas-{account}, awsplace-exports-{account}Bucket listing for export enumeration
EFSClientMount, ClientWriteRaftDB filesystem ARN, conditioned on access point ARNMount EFS for RaftDB WAL

a) DynamoDB resource scoping

function allTableArns(db: DatabaseOutput): string[] {
  const tables = [db.configTable, db.bansTable, db.milestonesTable, db.historyTable];
  return tables.flatMap((t) => [t.tableArn, `${t.tableArn}/index/*`]);
}

// Each table gets its own grant — CDK generates minimal, scoped policies
db.configTable.grantReadWriteData(ecsTaskRole);
db.bansTable.grantReadWriteData(ecsTaskRole);
db.milestonesTable.grantReadWriteData(ecsTaskRole);
db.historyTable.grantReadWriteData(ecsTaskRole);

Using grantReadWriteData() (rather than hand-writing policies) is important: CDK resolves the exact table ARN and index ARNs at synthesis time. If a table name changes, the policy updates automatically.

This prevents the application from accessing any DynamoDB table other than the four application tables — a misconfigured BACKEND env var pointing to a wrong table name would fail with an IAM error, not silently read/write the wrong data.

b) S3 resource scoping

storage.canvasBucket.grantReadWrite(ecsTaskRole);
storage.exportsBucket.grantReadWrite(ecsTaskRole);

Again using CDK grant methods — permissions are scoped to the exact bucket ARNs and their object paths. The task role cannot access any other S3 bucket in the account.

c) EFS condition key

ecsTaskRole.addToPrincipalPolicy(new iam.PolicyStatement({
  actions: ['elasticfilesystem:ClientMount', 'elasticfilesystem:ClientWrite'],
  resources: [raftDbFileSystem.fileSystemArn],
  conditions: {
    StringEquals: {
      'elasticfilesystem:AccessPointArn': raftDbAccessPoint.accessPointArn,
    },
  },
}));

The EFS permission is doubly scoped:

  1. Resource-level: Only the specific filesystem ARN
  2. Condition-level: Only when accessed through the specific access point ARN

This means even if the task role were somehow granted EFS access to another filesystem, the mount would fail unless the access point condition also matched.


4. Role 3: Lambda Execution Role

This role is assumed by the Lambda function for authentication and admin proxy operations.

Permissions

ServiceActionsResource ScopePurpose
DynamoDBSame CRUD as ECS task roleAll 4 table ARNs + index ARNsDirect DynamoDB access for auth/admin queries
S3PutObjectawsplace-exports-{account}/* onlyWrite PNG exports (read is not needed by Lambda)
CloudWatch LogsCreateLogGroup, CreateLogStream, PutLogEvents** (managed policy)Lambda logging via AWSLambdaBasicExecutionRole

a) Lambda is NOT in a VPC

// Lambda does NOT receive vpcConfig — it runs outside the VPC
const apiFunction = new lambda.Function(scope, 'ApiFunction', {
  // ... no vpc or vpcSubnets property ...
});

The Lambda has **no ec2:DescribeNetworkInterfaces permission and no VPC attachment. It reaches DynamoDB and S3 over public AWS API endpoints. This is a deliberate design choice:

In-VPC LambdaOut-of-VPC Lambda
Cold start + ENI provisioning (~5-10s)Cold start only (~500ms-2s)
Needs ec2:DescribeNetworkInterfaces, ec2:CreateNetworkInterfaceNo EC2 permissions
Can reach VPC-private resources (RDS, ElastiCache)Can only reach public AWS APIs
Requires NAT Gateway for internet accessDirect internet access

Since the Lambda only needs DynamoDB and S3 (both public AWS APIs), and it makes outbound calls to Discord’s OAuth API, the VPC would add cost and latency with no security benefit.

b) S3 write-only for exports

The Lambda only has PutObject on the exports bucket — no GetObject, no DeleteObject, no ListBucket. It writes PNG exports but never reads them. The admin dashboard reads exports directly from the S3 bucket (via the ECS task role or a pre-signed URL), not through the Lambda.


5. Security Architecture Diagram

┌────────────────────────────────────────────────────────────────────┐
│                             AWS Cloud                              │
│                                                                    │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────────────┐  │
│  │    Lambda    │    │     ALB      │    │     ECS Fargate      │  │
│  │   (no VPC)   │    │   (public)   │    │   (public subnet)    │  │
│  │              │    │              │    │                      │  │
│  │ Role:        │    │ SG:          │    │ Execution Role:      │  │
│  │ Lambda Exec  │    │ 0.0.0.0/0    │    │ - ECR pull           │  │
│  │              │    │ :80, :443    │    │ - CW Logs            │  │
│  │ Perms:       │    │              │    │ - Secrets Manager    │  │
│  │ - DDB CRUD   │    │              │    │                      │  │
│  │ - S3 Put     │    │              │    │ Task Role:           │  │
│  │ - CW Logs    │    │              │    │ - DDB CRUD (4 tbl)   │  │
│  │              │    │              │    │ - S3 R/W (2 bkt)     │  │
│  │              │    │              │    │ - EFS mount          │  │
│  └──────┬───────┘    └──────┬───────┘    └──────────┬───────────┘  │
│         │                   │                       │              │
│         ▼                   ▼                       ▼              │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │                   AWS Services (public APIs)                 │  │
│  │                                                              │  │
│  │    DynamoDB (4 tables)      S3 (2 buckets)     Secrets Mgr   │  │
│  │    ECR (1 repo)             EFS (1 fs)         CloudWatch    │  │
│  └──────────────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────────────┘

Key security properties

PropertyImplementationVerified By
ECS → DynamoDBScoped to 4 table ARNs + index ARNsgrantReadWriteData() per table
ECS → S3Scoped to 2 bucket ARNsgrantReadWrite() per bucket
ECS → EFSScoped to filesystem ARN + access point ARN conditionCondition key in policy
ECS → Secrets ManagerScoped to single secret ARN (awsplace/app-secrets)Explicit resource ARN
Lambda → DynamoDBScoped to 4 table ARNs + index ARNsSame as ECS task role pattern
Lambda → S3Write-only (PutObject) to exports bucket onlyNo GetObject/ListBucket granted
No ** resources on write actionsAll Put*, Delete*, Update* actions have explicit resource ARNsCode review
JWT secretNever in code; pulled from Secrets Manager at ECS startupsecrets field in container definition
No VPC for LambdaNo ENI cold start, no ec2:* permissionsAbsence of vpc property