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:
CreateUserat/carlos-mail/anythingPutUserPolicywith{"Action":"*","Resource":"*"}CreateAccessKey- 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:
- Create a boundary policy granting the maximum any minted user should ever have (
ses:SendRawEmail/SendEmailfor mail; the bucket verbs for store). - Pass it at creation —
CreateUserInput.PermissionsBoundary.mail.EnsureUserdoes not today;objstore's equivalent does not either. - 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_mailteamtito/carlos-infrastructure/object-store.tf—aws_iam_role_policy.host_ie_object_storeinternal/mail/aws.go—EnsureUserinternal/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.