This is a hands-on lab in the Security Specialty track. The reference posts argue the decisions; this stands one up. It pairs with the privilege-escalation post, which walks through why boundaries are the tool for delegating role creation safely. Here we build the role and write the boundary by hand, since writing one is the skill worth having. The full lab is in lab-scs-04-permission-boundary.zip.
Before your first lab, do the one-time, once-per-account setup: run the zip’s preflight.sh to confirm your account is ready, then deploy the lab reaper, a standing backstop that auto-deletes any lab you forget to tear down after 24 hours. Every lab tags its stack for the reaper on deploy.
The scenario
A developer role carries an identity policy that allows s3:* and iam:* on every resource. That is the access someone grants when they are moving fast and means to tidy it up later, and later has not come. The role can delete any object in any bucket, and it can create users, attach policies, and grant itself more. You want it reined in today.
The clean fix is to rewrite the identity policy, but you cannot always reach it: it might be generated by a pipeline, shared across roles, or owned by a team whose backlog will not clear this quarter. A permissions boundary caps the role without touching that identity policy at all.
A permissions boundary is a managed policy, AWS managed or customer managed, sitting in the role’s PermissionsBoundary slot. It grants nothing. The role’s effective permissions become the intersection of what the identity policy allows and what the boundary allows, so an action has to appear in both. Leave s3:DeleteObject out of the boundary and the role cannot delete, even though its identity policy still says s3:*.
Read a boundary as a second grant and you will expect the two policies to add up. They do the opposite: a boundary takes away everything it does not allow. So the skill is writing one tight enough to matter and still wide enough to leave the role useful, which means naming every action the role legitimately needs and nothing more. A size limit pushes the same way. A customer managed policy can hold 6,144 characters, whitespace excluded, and that limit is not adjustable, so a boundary spelled out action by action runs out of room where one written in service-level wildcards does not.
A boundary is not an SCP, and both being ceilings makes them easy to blur. An SCP is set in Organizations and caps every IAM user and role in a member account, that account’s root user included, but it has no effect in the management account and none on a service-linked role. A boundary is attached to one user or role by the account’s own administrators, and PutRolePermissionsBoundary rejects a service-linked role too. Both grant nothing; they cap at different scopes.
What you’re given
CloudFormation builds a demo S3 bucket, so there is a real ARN to scope to, and DemoDeveloperRole with its deliberately broad identity policy (s3:* and iam:* on *) and a trust policy allowing the account root to assume it. The role’s PermissionsBoundary already points at a managed policy called DeveloperBoundary. What is missing is the boundary’s own document.
As shipped, DeveloperBoundary allows * on *. A boundary that allows everything is no ceiling: the intersection of everything and the identity policy is the identity policy, and the role stays as dangerous as it was. Your job is to tighten the boundary so it caps the role to read-and-put on the demo bucket, metrics, and logs, and nothing else.
Your task
Replace the placeholder document in DeveloperBoundary with the safe set. Read and list the bucket, put objects into it, emit CloudWatch metrics, write logs. No iam:*, no s3:Delete*:
DeveloperBoundary:
Type: AWS::IAM::ManagedPolicy
Properties:
ManagedPolicyName: scs-lab-04-developer-boundary
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: ReadAndListTheDemoBucket
Effect: Allow
Action:
- s3:Get*
- s3:List*
Resource:
- !GetAtt DemoBucket.Arn
- !Sub '${DemoBucket.Arn}/*'
- Sid: PutObjectsIntoTheDemoBucket
Effect: Allow
Action:
- s3:PutObject
Resource:
- !Sub '${DemoBucket.Arn}/*'
- Sid: EmitMetricsAndLogs
Effect: Allow
Action:
- cloudwatch:PutMetricData
- logs:*
Resource: '*'
Nothing else in the template changes. The role keeps its broad identity policy, unchanged. s3:GetObject survives because both policies allow it. s3:DeleteObject is denied because the boundary omits it, and iam:CreateUser for the same reason. You never edited the dangerous policy; you set a ceiling it cannot exceed.
Note the resource ARNs. s3:GetObject and s3:PutObject act on objects, so they need the /* ARN; s3:ListBucket acts on the bucket itself, so it needs the bare bucket ARN. Omit the object ARN and a read that should work comes back denied, because an action the boundary does not cover is denied however broad the identity policy is.
Run it
./scripts/deploy.sh # deploys src/template.yaml
./scripts/test.sh # simulates the role's effective permissions
./scripts/teardown.sh # empties the bucket and deletes everything
test.sh never assumes the role and never touches an object, which keeps it safe to run as often as you like. It calls aws iam simulate-principal-policy against the role ARN. The simulation includes every policy associated with that principal, the boundary among them, so the decision it returns is the role’s effective decision with the cap applied. Pass --permissions-boundary-policy-input-list and the policy you pass is used instead of the attached one for that run, which is how you try a tighter ceiling before deploying it. The script asks three questions:
ok: s3:GetObject is allowed (allowed)
ok: s3:DeleteObject is denied (implicitDeny) - the boundary caps it
ok: iam:CreateUser is denied (implicitDeny) - the boundary caps it
PASS: the boundary lets the role read the demo bucket but caps away
s3:DeleteObject and iam:CreateUser, even though the identity policy grants
s3:* and iam:*. Effective permissions are the intersection of the two.
With the unedited template the boundary allows * on *, so DeleteObject and CreateUser come back allowed and the test fails with a hint to tighten the boundary.
One result comes back per action rather than per resource, and the top-level EvalDecision is the most restrictive decision across every resource ARN in the call. The script passes one ARN per question, so the two are the same here. Widen --resource-arns and read the per-resource answers out of ResourceSpecificResults instead.
If it fails
- DeleteObject or CreateUser comes back allowed. The boundary is still too wide. Every action it allows stays reachable, because the identity policy allows everything. Strip
iam:*ands3:Delete*out; list only the safe actions. - GetObject comes back denied. The boundary is too tight, or the ARN is wrong.
s3:GetObjectneeds the/*object ARN. An action the boundary leaves out is denied, so a read you meant to keep fails when the boundary is missing that resource. - You cannot tell which policy denied it.
implicitDenysays nothing allowed the action, not what stopped it. QueryEvaluationResults[0].PermissionsBoundaryDecisionDetailas well, andAllowedByPermissionsBoundarycoming backfalsenames the boundary as the reason. - The stack will not update. The boundary is a managed policy like any other. Edit its
PolicyDocumentand redeploy: that property updates with no interruption, so the policy ARN holds and the role’s boundary still points at it. The fixedManagedPolicyNameblocks any update that would need a replacement, so leave the name alone. Check you editedDeveloperBoundary, not the role.
Reveal the solution
SRC=solution ./scripts/deploy.sh && ./scripts/test.sh
What you just learned
- Effective permissions are the intersection of the identity policy and the boundary. Both must allow an action; leave it out of either and it is denied.
- A boundary is a ceiling, not a grant. Adding an action to a boundary gives the role nothing on its own. It only stops the boundary from removing what the identity policy already grants. The identity policy grants; the boundary caps.
- This is how you delegate safely. Let a team create roles, but use the
iam:PermissionsBoundarycondition key to require that every role they create carries your boundary, and they can never mint a role more powerful than that ceiling, even holdingiam:*. - Leaving an action out of the boundary is an implicit deny, and implicit denies in a boundary do not limit a resource-based policy. A bucket policy naming an assumed-role session ARN in the same account grants that session directly, past the ceiling. An explicit
Denyin the boundary applies in every case, which is the argument for writing the narrow denies in as well as leaving the actions out. - The boundary is the preventive half of this control. CloudTrail is the detection half: one
DeleteRolePermissionsBoundarycall takes the cap off the role, and that call is a management event recorded inus-east-1with the rest of IAM, so a single-Region trail anywhere else never holds it.
Next
The rest of the Security Specialty lab track is in the track’s README.