carlos / platform Public

IAM permissions boundary for minted store/mail users — PutUserPolicy on a creatable path is an escalation route Issue open

Migrated from github.com/carlosframework/platform issue #264, opened 2026-08-23. GitHub issues are closed for this repo; see AGENTS.md, "Work is tracked on amadan, not GitHub".

Still outstanding: Neither mail nor objstore EnsureUser passes an IAM permissions boundary, so a compromised console could self-escalate.

---

Found while writing the IAM grant for tenant email (#240). It is not new with that feature — the object-storage primitive has the same shape today — so this is a gap to close deliberately, not a regression.

The shape

Both primitives give the console's host role, on a path it can also create users in:

iam:CreateUser, iam:PutUserPolicy, iam:CreateAccessKey   on   user/carlos-store/*
iam:CreateUser, iam:PutUserPolicy, iam:CreateAccessKey   on   user/carlos-mail/*

Path scoping stops the role touching other users. It does not bound what policy those users get, because the caller supplies the policy document. So a console compromise escalates:

  1. CreateUser at /carlos-mail/anything
  2. PutUserPolicy with {"Action":"*","Resource":"*"}
  3. CreateAccessKey
  4. long-lived AWS admin credentials for the account

The inline policies both primitives actually write are tightly scoped (mail.SendPolicyJSON pins the identity ARN and a ses:FromAddress condition, and refuses to render without both). That is a property of the code, not a constraint IAM enforces — which is exactly the gap.

The fix

An IAM permissions boundary:

  1. Create a boundary policy granting the maximum any minted user should ever have (ses:SendRawEmail/SendEmail for mail; the bucket verbs for store).
  2. Pass it at creation — CreateUserInput.PermissionsBoundary. mail.EnsureUser does not today; objstore's equivalent does not either.
  3. Require it from the role side with a condition:
Condition = {
  StringEquals = { "iam:PermissionsBoundary" = aws_iam_policy.carlos_mail_boundary.arn }
}

Order matters: the condition must land after the code passes the boundary. A boundary required by the policy but not set by the caller refuses every CreateUser and takes the feature down. That is why the grant merged for #240 says so in a comment rather than adding the condition speculatively.

Scope

  • platform-infrastructure/flagship/mail.tf — aws_iam_role_policy.host_ie_tenant_mail
  • teamtito/carlos-infrastructure/object-store.tf — aws_iam_role_policy.host_ie_object_store
  • internal/mail/aws.go — EnsureUser
  • internal/objstore — the equivalent user-creation path

Worth doing both together: they are the same bug and the same fix, and a boundary added to one primitive invites the assumption the other has it too.