IAM deploy/maintenance user — least-privilege runbook

Root credentials were used exactly once, as the bootstrap for the 2026-07-10 launch sweep. This runbook retires them: it creates a dedicated deploy/maintenance user whose policy is derived from observation, not guesswork — every AWS service-action pair exercised during the launch deploys was recorded as it ran (operator-side command log) and cross-checked against CloudTrail's management-event history for the same window (110 unique service:action pairs observed), then validated with the IAM policy simulator.

The policy lives at infra/iam-deploy-policy.json.

What the policy covers

ConcernStatementsScope
Stack lifecycle (sam deploy, both stacks)StackLifecycle, StackDiscoveryleague-of-agents-platform-*, the SAM-managed artifact-bucket stack, the Serverless transform
Build artifacts + archive bucketSamArtifactsAndArchiveBucketsaws-sam-cli-managed-default*, league-of-agents-* buckets
Function roles (CAPABILITY_IAM)FunctionRoles, ServiceLinkedRoleFirstUserole/league-of-agents-platform-*, role/aws-service-role/*
Lambda code/config (incl. the cold-start probe)Functions, SamPreflightAndFirstUseServiceRolesfunction:league-of-agents-*
HTTP API + custom domainsHttpApiAndCustomDomainsAPI Gateway v2 REST-verb actions on /apis*, /domainnames*
DynamoDB tables + GSIsTablestable/league-of-agents-* (+ indexes)
Log groups + debugging (aws logs tail)FunctionLogGroups, LogGroupDiscovery/aws/lambda/league-of-agents-*
ACM certs (domain stack)DomainCertificates* (RequestCertificate cannot be resource-scoped pre-creation)
Cleanup scheduleCleanupSchedulerule/league-of-agents-platform-*
$20 budget alarmBudgetCeiling, BudgetResourceApiaccount budgets (API requires *)
OAuth secrets (next iteration, platform#6)OauthSecretsNextIterationparameter/league/*
Identity + auditIdentityAndAuditsts:GetCallerIdentity, cloudtrail:LookupEvents

Deliberately excluded despite appearing in the observation window, with reasons: logs:CreateLogStream (the function role's own runtime writes, not the operator's), sts:AssumeRole + signin:* (session plumbing), ec2:DescribeVpcs / glue:GetDatabases / iam:GetAccountSummary / iam:ListRoles (console browsing noise from the same session), kms:* (DynamoDB/SSM call KMS with service grants, not operator credentials).

Validation

aws iam simulate-custom-policy over the 20 representative deploy-path action/resource pairs (changeset lifecycle, artifact upload, role create/pass, function code+config update, API+domain writes, table create/GSI update, log retention/tail, cert request, schedule, budget, SSM parameter, CloudTrail audit): all allowed. Two out-of-scope probes (dynamodb:CreateTable on an unrelated table, iam:CreateRole on an unrelated role): implicitly denied.

Re-run the validation any time the policy changes — the simulation script pattern is one aws iam simulate-custom-policy --policy-input-list "$(cat infra/iam-deploy-policy.json)" --action-names <action> --resource-arns <arn> call per pair.

Create the user (run once, as root or an IAM admin)

aws iam create-user --user-name league-deploy
aws iam put-user-policy \
  --user-name league-deploy \
  --policy-name league-deploy-least-privilege \
  --policy-document file://infra/iam-deploy-policy.json
aws iam create-access-key --user-name league-deploy   # store in your credential manager

Configure locally (aws configure --profile league-deploy), then deploy with:

AWS_PROFILE=league-deploy bash infra/deploy.sh prod <budget-email>

Rotation and root retirement

Observation appendix

The raw observation set this policy was derived from (110 unique service:action pairs, CloudTrail management events over the launch window) is reproducible with aws cloudtrail lookup-events --start-time <window-start> — extract eventSource + eventName per event and de-duplicate.