Building a NIS2-Compliant AWS Landing Zone - Identity Foundations
Last week I built the three audit trails — API, configuration, and network — all encrypted, all mapped to NIS2 measures, all streaming somewhere useful. Everything that happens in this account now gets recorded. This week answers the next question: who's actually allowed to make things happen, and what stops them from turning the recording off?
Logging without access control is half a system. You can prove what happened, but you haven't decided who's permitted to do it, and you've done nothing to stop someone from quietly disabling the logs you just spent a week setting up. NIS2 Article 21 names this directly — (i) access control policies and (j) the use of multi-factor authentication. So Week 3 is the identity layer: AWS Organizations to give the account a governance boundary, Service Control Policies as the guardrails inside it, and IAM Identity Center for how humans actually get access.
It's also the week the project hit its first real LocalStack wall that I couldn't just buy my way past — and the week a deceptively simple Terraform error taught me something I should have known already.
What I built this week
modules/organizations— an AWS Organization with the full feature set, the SCP policy type enabled, and three organizational units: Workloads, Security, Sandbox.modules/scp— three Service Control Policies (deny-root, region-lock, protect-logging), attached across all three OUs.modules/identity-center— two permission sets (Administrator and ReadOnly, both short-lived), a demo group, and an account assignment. This one ended up plan-only, for an honest reason I'll get to.
The shape of the identity layer

The organization is the boundary; the OUs group accounts; the SCPs hang off the OUs as ceilings on what anyone inside can do. Identity Center sits alongside, handling how people actually sign in. Three policies across three OUs is nine attachments, which matters later.
The thing to understand about a Service Control Policy is that it can only take away. It never grants permission — it sets the maximum any account in the OU is allowed to have, and IAM operates underneath that ceiling. That makes them the right tool for a small number of absolute rules.
I encoded three:
- deny-root blocks the root user from doing anything. The root account shouldn't be used for daily work, and this makes "shouldn't" into "can't."
- region-lock denies any operation outside eu-central-1, with carve-outs for the genuinely global services (IAM, Organizations, STS, and a few others) that don't belong to a region. That keeps data in the EU — useful for the residency story a NIS2 reviewer cares about — and it closes the trick where an attacker spins resources up in an unused region precisely because nobody's watching it.
- protect-logging is the one I'm most pleased with. It denies the API calls that would turn off CloudTrail or AWS Config —
StopLogging,DeleteTrail, the Config recorder deletions. This is the policy that ties the whole project together: last week's audit layer is now something even an administrator can't quietly switch off. The logs aren't just on; they're defended.
All three map to NIS2 (i) access control, and protect-logging reaches back to (b) and (f) by keeping the evidence layer intact.
What broke (1): for_each and values that don't exist yet
The SCP module's own tests passed cleanly. Then I wired it into the full composition — SCPs attaching to the OUs that the Organizations module creates — and the integration test threw this:
Error: Invalid for_each argument
the "for_each" map includes keys derived from resource attributes
that cannot be determined until apply
Here's what I'd done. The attachment resource looped with for_each over a map keyed by OU ID. But the OU IDs come from the Organizations module — they don't exist until that module applies. Terraform builds its plan before anything is created, so it needs to know the full set of for_each keys up front. Keys derived from not-yet-created IDs are unknowable at plan time, and Terraform refuses rather than guess.
The reason the module's own tests passed is that they fed it static fake IDs. Only the integration test, with real apply-time IDs flowing in from another module, exposed the problem — which is exactly what an integration test is for.
The fix is a genuine Terraform pattern, not a hack. Keys have to be plan-time-known; values are allowed to be unknown. So I changed the module's input from a list of IDs to a map of name-to-ID:
# before: targets = ["ou-1111", "ou-2222"] # IDs unknown at plan time
# after: targets = { Workloads = "ou-1111", Security = "ou-2222", ... }
attachments = {
for pair in setproduct(keys(local.policies), keys(var.targets)) :
"${pair[0]}:${pair[1]}" => {
policy_id = local.policies[pair[0]]
target_id = var.targets[pair[1]] # the unknown part lives in the value
}
}
Now the keys are built from the OU names — static strings I know at plan time — and the apply-time IDs sit safely in the values. The rule I wrote down: if for_each complains that something is known only after apply, move the dynamic part out of the keys and into the values.
What broke (2): Identity Center, and an honest plan-only call
Identity Center was the module I expected trouble from, so I probed it before building the whole thing — ran the raw API calls against LocalStack to see what actually works. Good news at first: it created the SSO instance, the permission sets, and the identity-store group without complaint. So I built the full module, apply-mode like everything else, and ran it.
It failed — but not where I expected:
StatusCode: 501
DescribePermissionSetProvisioningStatus action has not been implemented
The permission sets create fine. But when you attach a managed policy to one, the AWS provider then polls a provisioning-status endpoint to confirm the change went through — and LocalStack hasn't implemented that endpoint. So the attachment hangs and fails. The create calls I probed all worked; the failure was in a status check that only the full apply reaches. Lesson noted: when you probe a risky service, probe the whole graph, including the attachments and whatever happens after them, not just the create.
Then the actual decision. I had three options: code a workaround, drop the managed-policy attachments, or keep the module exactly as it should be and accept it can't fully apply on the emulator. I went with the third — the module is correct Terraform that applies on real AWS, so I kept it whole, marked it plan-only, and left it out of the local composition. It still gets validated and plan-tested in CI; it gets its real apply in Week 6 on actual AWS.
I wrote that up as a decision record, because the distinction matters: this isn't "my code is broken," it's "the emulator doesn't implement this one endpoint." Being able to tell those two apart, and being honest about which one you're looking at, is most of the job. Pretending everything applies cleanly would be the weaker story.
The refactor I didn't plan on: dev and prod over one composition
While closing out the week I noticed the structure was about to become a liability. Everything lived in a single demo environment pointed at LocalStack. Week 6 needs a real-AWS target too, and the obvious move — copy the whole module wiring into a second environment — is exactly how environments drift apart. The day they diverge, the project's central claim ("the same code runs on LocalStack and on real AWS") quietly stops being true.
So I split it: the module wiring now lives once, in a shared composition with no provider block of its own. Two thin wrappers call it — dev supplies the LocalStack provider and endpoints, prod supplies the real-AWS provider and a remote backend. The wiring exists in one place, so the two environments can't drift. The parity is structural now, not something I have to remember to maintain.
That refactor surfaced one more thing worth keeping: the integration test is a separate consumer of the composition, so it needs the full set of LocalStack endpoints too. The first run after the split failed with UnrecognizedClientException on half the services — they were falling through to real AWS because the test's provider only declared four endpoints. A small fix, but a clean illustration of why pulling provider config apart from module wiring is the right separation: the gap was suddenly obvious instead of hidden.
A note on the scanner findings
One new finding this week, and it's a fair one: Checkov flagged the Administrator permission set for using the AWS AdministratorAccess policy. I didn't remove it and I didn't silently ignore it. Every organization needs a break-glass admin path; the control isn't "never have admin," it's how tightly that path is bounded. Mine is bounded four ways — one-hour sessions, MFA-enforced SSO sign-in, assignment to a single group on a single account, and the SCP guardrails sitting above it. I suppressed the check with that reasoning written inline and a matching entry in the decision log. A reviewer sees exactly what was granted and what fences it in.
While reconciling the open findings against the log, I also caught one that had slipped through un-triaged — an EIP-attachment warning on the NAT gateway, which is a false positive (the IP attaches to a NAT gateway by design, not an EC2 instance). Now recorded. The point of the decision log is that every open finding has a written answer; an un-triaged one is a small hole in that promise.
Where this leaves things
The account now has a governance boundary, three guardrails that hold even against an admin, and a workforce-access model built on short-lived SSO sessions instead of long-lived keys. One module is plan-only and honest about why. The environments are split so the real-AWS run in Week 6 is a thin wrapper away, not a copy-paste job.
NIS2 Article 21 coverage after Week 3: measures (a), (b), (c), (f), (h), (i), (j) — seven of ten.
Next week: detection. GuardDuty, Security Hub, and the EventBridge-to-SNS alerting pipeline — including the SNS notification I deferred back in Week 2. The point where all these logs finally turn into something that pages you when it matters. I'm expecting GuardDuty and Security Hub to have their own emulation quirks, so the probe-first habit comes with me.
Repo Link: github.com/olanak/aws-nis2-baseline