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 situation
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, worse, it can create users, attach policies, and hand 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. The role stays dangerous while you wait. A permission boundary caps the role without touching that identity policy at all.
What actually matters
A permission boundary is a managed policy you attach to a role as its PermissionsBoundary. It does not grant anything. It sets a ceiling, and the role’s effective permissions become the intersection of two things: what the identity policy allows, and what the boundary allows. An action is permitted only when both allow it. Leave s3:DeleteObject out of the boundary and the role cannot delete, even though its identity policy still says s3:*. The identity policy stays broad; the boundary makes the broad part unreachable.
That intersection is the mental model to hold. It is easy to read a boundary as a second grant and expect the two policies to add up. They do the opposite. The boundary can only take away, and it takes away everything it does not explicitly allow. So the skill is writing a boundary that is tight enough to matter and still wide enough to leave the role useful: name every action the role legitimately needs, and nothing more.
It also matters that a boundary is not an SCP, because both are ceilings and it is easy to blur them. An SCP is an Organizations control that caps every principal in an account, root included, and lives above IAM. A boundary is attached to one role or user and caps only that principal. Both grant nothing; they cap at different scopes.
What we’ll build
CloudFormation builds a demo S3 bucket, so there is a real ARN to scope to, and the DemoDeveloperRole with its deliberately broad identity policy (s3:* and iam:* on *) and its trust policy allowing the account root to assume it. The role’s PermissionsBoundary is already set to a managed policy called DeveloperBoundary. What is missing is the boundary’s own document.
As shipped, DeveloperBoundary allows * on *. That is the trap the lab starts from: a boundary that allows everything is no ceiling at all, so the intersection of “everything” and the identity policy is just 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 the identity policy and the boundary allow it. s3:DeleteObject dies because the boundary omits it, and the intersection of “the identity policy allows it” and “the boundary does not” is a deny. iam:CreateUser dies for the same reason. You never edited the dangerous policy; you built a ceiling it cannot poke through.
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. Miss the object ARN and a read that should work fails, because the intersection removes anything the boundary leaves out.
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 cheap and safe. It calls aws iam simulate-principal-policy against the role ARN, and the simulator is the reason this works: it evaluates a principal’s identity policies and its permission boundary together, so the decision it returns is the role’s effective decision, cap included. It 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.
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. The intersection deletes anything the boundary omits, so a read you meant to keep fails when the boundary is missing that resource. - The stack will not update. The boundary is just a managed policy. Edit its
PolicyDocumentand redeploy and CloudFormation replaces it in place. 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. 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 require every role they create to carry your boundary, and they can never mint a role more powerful than the ceiling you set, even holding
iam:*. - A boundary is not an SCP. An SCP caps every principal in an account from above IAM; a boundary caps one role or user. Both grant nothing and cap at different scopes.
Next
The rest of the Security Specialty lab track is in the track’s README.