This is the fifth lab in the SA Pro hands-on track. The reference posts argue
the decisions; this stands one up. It pairs with the cost-visibility post,
which builds the reporting pipeline from cost allocation tags to Cost
Categories to a CUR you can query. This lab builds the thing that makes any of
that trustworthy: the control that stops untagged resources existing at all.
The full lab is in lab-sa-pro-05-tag-cost.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 SA Pro lab tags its stack for the reaper on deploy.
The scenario
An estate’s bill is one number, and finance wants a number per team. The
mechanism is well worn: every resource carries a team tag, the tag is
activated for cost allocation, and Cost Explorer groups by it. The mechanism is
not the problem. The problem is the day one team’s pipeline ships resources
untagged, the “unallocated” bucket starts growing, and every report after that
carries an asterisk.
So the tag requirement gets enforced by machine. A guardrail refuses to create a resource unless the request carries the tag, and refuses to remove the tag afterwards. In this lab the guardrail is an IAM deny policy on a provisioner role, which works in any standalone account. In an organisation with all features enabled, the same policy document attached to an OU as a Service Control Policy caps every member account under it at once. Two exemptions survive that move, and they matter more at OU scale than they do here: an SCP never restricts the management account, and it never restricts a service-linked role. Anything a service does on your behalf through one of those roles stays outside the mandate and needs its own arrangement. Prove the rule on one role, widen its blast radius, then re-check what falls outside.
The resource being provisioned here is an SQS queue. An idle queue carries no
charge, the first million SQS requests a month are free for every account, and
sqs:CreateQueue accepts tags in the request, which is what makes
tag-on-create enforcement possible at all. That support is per-action rather
than universal: sqs:CreateQueue exposes aws:RequestTag/${TagKey} alongside
aws:TagKeys and aws:ResourceTag/${TagKey}, while sqs:UntagQueue exposes
aws:TagKeys and aws:ResourceTag/${TagKey} but no aws:RequestTag. The two
statements you are about to write are shaped by that difference.
What you’re given
CloudFormation builds the provisioner role: the identity a platform team’s
tooling would use to create queues. Its allow policy covers creating, tagging,
inspecting, and deleting queues within the lab’s name prefix. sqs:TagQueue is
in that allow list on purpose. Tagging a queue on create requires both
sqs:CreateQueue and sqs:TagQueue, so a guardrail that denies the second
locks out the happy path along with the loophole. What is missing is the
guardrail itself. The gap in src/template.yaml is a TODO block where a
managed policy goes: two Deny statements, one per loophole.
Your task
Write that managed policy and attach it to the role. The first statement denies
sqs:CreateQueue when the request carries no team tag. The IAM idiom for
“this key is absent at authorisation time” is the Null condition operator set
to true against aws:RequestTag/team (quote 'Null' in YAML so it stays a
string). The second denies sqs:UntagQueue when the keys being removed include
team, which is a set-membership test: ForAnyValue:StringEquals on
aws:TagKeys. The second closes the loophole the first leaves open. Without it
the guardrail is theatre: create tagged, untag a second later, and the spend is
orphaned again with nothing in the report to flag it.
An explicit Deny beats an Allow wherever the two live. The role’s allow policy says what the provisioner may do; the deny caps it. SCPs cap rather than grant in exactly the same way, which is why the document moves up a level unchanged.
Both statements together still leave two gaps. A tag key is not a tag value:
team=tbd passes the Null test, and pinning the acceptable values means a
StringNotEquals list on aws:RequestTag/team, or a tag policy from the
management account, which standardises keys and values across the organisation
but cannot require a tag that is absent. And sqs:TagQueue stays allowed, so a
principal who cannot remove the team tag can still overwrite it: in SQS a new
tag with an identical key replaces the existing one.
Run it
./scripts/deploy.sh # deploys src/template.yaml
./scripts/test.sh # assumes the role and pushes on the guardrail
./scripts/teardown.sh # deletes everything
test.sh assumes the provisioner role, so every check runs the way real
tooling would hit the control, then pushes three times: an untagged create
(must be refused), a tagged create (must succeed), and an attempt to strip the
tag (must be refused). With the unedited template the untagged create sails
through and the test tells you why. With the guardrail in place it prints:
1. Creating sa-pro-lab-05-t12345 with NO tags (this should be refused) ...
refused. No team tag, no queue.
2. Creating sa-pro-lab-05-t12345 tagged team=checkout (this should succeed) ...
created: https://sqs.../sa-pro-lab-05-t12345
3. Removing the team tag (this should be refused) ...
refused. The tag stays.
PASS: untagged create refused, tagged create allowed, tag removal refused.
The cost half happens on the bill, on billing’s timetable, and it is far slower
than the guardrail. Only a management account, or a standalone account that
belongs to no organisation, has the Cost allocation tags page, so that is where
team gets activated. Allow up to 24 hours for a newly used key to appear there
for activation, and up to another 24 for the activation to take. Activation
applies forward from there, though the history is not lost: the management
account can backfill activation status across the previous twelve months, one
request every 24 hours, and spend comes back tagged only where the resource
already carried the tag at the time. Don’t expect this lab on its own to put
team on that page, either. Its queue lives for seconds and meters nothing, so
run the activation step against a resource you actually keep. After that Cost
Explorer and the Cost and Usage Report group spend by team, and the guardrail
you proved is what makes the grouping mean something: nothing exists that isn’t
attributed.
If it fails
- The untagged create succeeds. The managed policy is missing, not
attached to the role, or its first condition is wrong. Check
'Null'is quoted; unquoted, YAML turns the operator into an actual null and the condition evaporates. - The tagged create is refused too. The deny is over-matching. The
condition must test
aws:RequestTag/team, notaws:TagKeys, for the create statement. - The tag can still be removed. The second statement is missing or its
operator isn’t
ForAnyValue:StringEqualsonaws:TagKeys. AccessDeniedonsts:AssumeRole. Your CLI identity isn’t allowed to assume roles in this account; run the test as a principal that can.- Both creates are refused after you changed Region. SQS’s tag condition
keys are not available everywhere. The ABAC documentation names Asia Pacific
(Hyderabad), Asia Pacific (Melbourne), Europe (Spain) and Europe (Zurich) as
exceptions, and where
aws:RequestTag/teamnever reaches the request context theNulltest matches every create, tagged or not. The lab’s defaultap-southeast-2is unaffected.
Reveal the solution
The two statements are short, and writing them from the operators named above is the exercise. When you want the reference answer, deploy it without editing anything:
SRC=solution ./scripts/deploy.sh && ./scripts/test.sh
Or unfold it here:
Show the answer
The managed policy’s two Deny statements:
- Sid: DenyCreateWithoutTeamTag
Effect: Deny
Action: sqs:CreateQueue
Resource: '*'
Condition:
'Null':
aws:RequestTag/team: 'true'
- Sid: DenyRemovingTeamTag
Effect: Deny
Action: sqs:UntagQueue
Resource: '*'
Condition:
ForAnyValue:StringEquals:
aws:TagKeys: team
What you just learned
- Cost allocation is a tagging problem, and tagging is an enforcement problem. A report is only as good as the worst-tagged resource in it.
aws:RequestTagconditions evaluate at create time, the only moment the resource can be refused before it exists. Everything after that is detective controls and cleanup toil.- A guardrail needs both halves: require the tag on create, and protect it from removal. One without the other does not survive contact with a deadline.
- An IAM deny on a role and an SCP on an OU are the same document with different blast radius. The deny caps what the allows grant, wherever it sits in the evaluation, and the SCP still reaches neither the management account nor a service-linked role.
- Enforcement lands in seconds; attribution takes up to 48 hours, and the tag can only be activated from a management account or a standalone one. Plan the reporting timeline around the slower half.
Next
All six SA Pro builds, with their domains and scaffolding levels, are listed in the track’s README. The one that hands you the least is the DMS change-data-capture migration.