Module 7: Detective Vs Preventive Guardrails
3. Hands-on: a permission boundary for `AppServerRole`
Description
Lesson 2 left the concept ready: a permission boundary is an independent ceiling, evaluated as an intersection with a role's permissions policy. This lesson really builds that ceiling, in HCL, for AppServerRole — Andes Cargo's read-only role, the same one Module 2, lesson 7 already trimmed to photos/* only. The resource gets declared, validated, and planned with Terraform's real engine; really blocking a call the boundary should reject stays representative, for the exact reason lesson 2 already cited.
Connection to the module
This is this module's first built piece, after two purely conceptual lessons. It extends two files you already know — modules/iam-role/ (terraform-and-iac-guide Module 5, hardened in this guide's Module 2) and iam.tf (where AppServerRole lives) — with a genuinely new piece: permission-boundary.tf.
Step 1 — Extending modules/iam-role/variables.tf with an optional input
The iam-role module you already know — the mold terraform-and-iac-guide's Module 5, lesson 7 built, and this guide's Module 2 reused without touching a line — doesn't yet have any input for a permission boundary. Add one, with a deliberate detail: optional, with default = null, so existing calls to the module (LambdaManifestProcessorRole, which doesn't need a boundary today) keep working exactly the same, without anyone having to touch them:
variable "permissions_boundary_arn" {
description = "ARN of the managed policy used as this role's permissions boundary. Null means no boundary is attached."
type = string
default = null
}
This is the same module-design discipline you already saw in terraform-and-iac-guide: a new input, with a safe default value, never breaks whoever is already calling the module without knowing this input exists.
Step 2 — Extending modules/iam-role/main.tf: a single line
resource "aws_iam_role" "this" {
name = var.role_name
assume_role_policy = var.trust_policy_json
permissions_boundary = var.permissions_boundary_arn
tags = var.tags
}
resource "aws_iam_role_policy" "this" {
name = "${var.role_name}-policy"
role = aws_iam_role.this.id
policy = var.permissions_policy_json
}
The aws_iam_role resource's permissions_boundary argument directly accepts an ARN — when var.permissions_boundary_arn is null (LambdaManifestProcessorRole's case), Terraform simply doesn't attach any boundary, the same behavior the role had before this lesson. The whole module remains a single mold for Andes Cargo's two roles, with or without a boundary depending on what each call asks for — exactly the same generalization principle you already tested in terraform-and-iac-guide, Module 5, lesson 7.
Step 3 — permission-boundary.tf: AppServerRole's real ceiling
At andes-cargo-infra/'s root, a new file — the security piece this module adds, never a business resource:
data "aws_iam_policy_document" "app_server_boundary" {
statement {
sid = "BoundaryAllowShipmentDocsReadOnly"
effect = "Allow"
actions = ["s3:GetObject", "s3:ListBucket"]
resources = [
"arn:aws:s3:::${var.bucket_name}",
"arn:aws:s3:::${var.bucket_name}/*",
]
}
statement {
sid = "BoundaryDenyIdentityAndOrgManagement"
effect = "Deny"
actions = [
"iam:*",
"organizations:*",
"sts:AssumeRole",
]
resources = ["*"]
}
}
resource "aws_iam_policy" "app_server_boundary" {
name = "AppServerRole-permission-boundary"
description = "Maximum permissions AppServerRole can ever have, regardless of what its own inline policy grants."
policy = data.aws_iam_policy_document.app_server_boundary.json
tags = local.common_tags
}
Read it statement by statement, against the criterion lesson 2 already explained:
BoundaryAllowShipmentDocsReadOnly— the ceiling allows, at most, reading Andes Cargo's complete bucket. Notice it's wider thanAppServerRole's current permissions policy (scoped tophotos/*since Module 2, lesson 7) — a boundary doesn't have to be as narrow as the permissions policy; it only has to be narrow enough that nothing catastrophic fits inside it. If tomorrowAppServerRolealso needed to readmanifests/, that permissions policy change wouldn't clash with this boundary — it's still within the ceiling.BoundaryDenyIdentityAndOrgManagement— the part that makes this boundary worth having: an explicitDenyoveriam:*,organizations:*, andsts:AssumeRole, withResource: "*". No future permissions policy, no matter how broad or poorly written, can makeAppServerRolemanage identities, touch Organizations, or assume another role — the exact category of most serious privilege escalation for a read-only role.
Step 4 — Attaching the boundary to AppServerRole
In iam.tf, the module call that already exists since Module 2:
module "app_server_role" {
source = "./modules/iam-role"
role_name = "AppServerRole"
trust_policy_json = data.aws_iam_policy_document.ec2_trust.json
permissions_policy_json = data.aws_iam_policy_document.ec2_permissions.json
permissions_boundary_arn = aws_iam_policy.app_server_boundary.arn
tags = local.common_tags
}
A single new line: permissions_boundary_arn = aws_iam_policy.app_server_boundary.arn. module.lambda_manifest_processor_role, the other call to the same mold, stays exactly as it was in Module 2 — no boundary, because LambdaManifestProcessorRole doesn't need one today (nothing in THREAT-MODEL.md flags that role as the higher escalation risk; it's AppServerRole, with its historically broader scope, TM-07, that justifies this additional defense).
Step 5 — fmt, validate: the real engine, over the complete project
terraform fmt -recursive -check -diff
terraform validate
What to expect (literal, run to write this lesson):
Success! The configuration is valid.
terraform fmt -check produced no diff — the HCL in the three new/edited files (variables.tf, the module's main.tf, permission-boundary.tf) already follows Terraform's canonical style with no adjustment needed.
Step 6 — The plan: the ceiling, and the role that receives it
terraform plan
What to expect (literal, run to write this lesson — showing the part of this plan corresponding to the boundary and AppServerRole; the complete plan at this point in the guide also includes LambdaManifestProcessorRole unchanged, and the pieces from this same module's lessons 5 and 7, which add their own to the same plan):
# aws_iam_policy.app_server_boundary will be created
+ resource "aws_iam_policy" "app_server_boundary" {
+ arn = (known after apply)
+ attachment_count = (known after apply)
+ description = "Maximum permissions AppServerRole can ever have, regardless of what its own inline policy grants."
+ id = (known after apply)
+ name = "AppServerRole-permission-boundary"
+ name_prefix = (known after apply)
+ path = "/"
+ policy = jsonencode(
{
+ Statement = [
+ {
+ Action = [
+ "s3:ListBucket",
+ "s3:GetObject",
]
+ Effect = "Allow"
+ Resource = [
+ "arn:aws:s3:::andes-cargo-shipment-docs/*",
+ "arn:aws:s3:::andes-cargo-shipment-docs",
]
+ Sid = "BoundaryAllowShipmentDocsReadOnly"
},
+ {
+ Action = [
+ "sts:AssumeRole",
+ "organizations:*",
+ "iam:*",
]
+ Effect = "Deny"
+ Resource = "*"
+ Sid = "BoundaryDenyIdentityAndOrgManagement"
},
]
+ Version = "2012-10-17"
}
)
+ policy_id = (known after apply)
+ tags = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ tags_all = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
}
# module.app_server_role.aws_iam_role.this will be created
+ resource "aws_iam_role" "this" {
+ arn = (known after apply)
+ assume_role_policy = jsonencode(
{
+ Statement = [
+ {
+ Action = "sts:AssumeRole"
+ Effect = "Allow"
+ Principal = {
+ Service = "ec2.amazonaws.com"
}
},
]
+ Version = "2012-10-17"
}
)
+ create_date = (known after apply)
+ force_detach_policies = false
+ id = (known after apply)
+ managed_policy_arns = (known after apply)
+ max_session_duration = 3600
+ name = "AppServerRole"
+ name_prefix = (known after apply)
+ path = "/"
+ permissions_boundary = (known after apply)
+ tags = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ tags_all = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ unique_id = (known after apply)
+ inline_policy (known after apply)
}
The detail worth stopping to read: permissions_boundary = (known after apply), not the literal ARN. It's exactly the same mechanism Module 2, lesson 5 already explained for data.aws_iam_policy_document.trust: permissions_boundary_arn is calculated from aws_iam_policy.app_server_boundary.arn, a value that doesn't exist yet because the policy that produces it is created in the same apply. Terraform can't show the literal ARN in the plan — it can only confirm the reference is valid and will resolve correctly at apply time, in the correct order: the policy first, then the role that references it.
Step 7 — Applying and verifying (representative)
What to expect (representative) — this writing environment has no exported LOCALSTACK_AUTH_TOKEN, so LocalStack's container doesn't start here (Could not connect to the endpoint URL), the exact same limit from every earlier lesson in this guide:
tflocal apply -auto-approve
awslocal iam get-role --role-name AppServerRole
What to expect (representative — reconstructed field by field from the real plan above and from the officially documented format of iam get-role when the role has a boundary attached):
{
"Role": {
"Path": "/",
"RoleName": "AppServerRole",
"RoleId": "AROAQZ3EXAMPLEAPPSERVER",
"Arn": "arn:aws:iam::000000000000:role/AppServerRole",
"CreateDate": "2026-08-13T10:22:07+00:00",
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
},
"MaxSessionDuration": 3600,
"PermissionsBoundary": {
"PermissionsBoundaryType": "PermissionsBoundaryPolicy",
"PermissionsBoundaryArn": "arn:aws:iam::000000000000:policy/AppServerRole-permission-boundary"
}
}
}
There's the confirmation: PermissionsBoundary shows up as its own block, separate from AssumeRolePolicyDocument — the role now has two completely independent control mechanisms, exactly as lesson 2 described. What this command can't confirm, in this lab, is that a real call the boundary should block (for example, an AppServerRole attempt to run iam:AttachRolePolicy) really fails — that proof requires IAM Policy Enforcement, the paid feature already cited in lesson 2, unavailable on the free Hobby/Community plan.
The limit, documented at the exact point where it appears
It's worth stating it with the same precision as the rest of this guide: this lesson's terraform validate and terraform plan are real — the HCL is syntactically correct, internally consistent, and would apply without errors against a real AWS account or against LocalStack with IAM Policy Enforcement active. What this lesson can't demonstrate, in this specific $0 lab, is the behavior that justifies this control existing: that a real call, made from AppServerRole, to an action the boundary denies (for example, iam:CreateAccessKey), actually gets rejected with AccessDenied. That proof — the block itself, not just the resource that declares it — is exactly the kind of verification only a real AWS account, or a LocalStack instance with the Base or Ultimate plan, can offer.
Updating RISK-MAP.md
This control doesn't close any new RISK-MAP.md row by itself — TM-07 (AppServerRole privilege escalation) was already marked Resolved in Module 2, lesson 7, with the permissions policy trimmed to photos/*. This boundary is an additional defense over that same risk, not the resolution of a new one — it's worth noting it that way, without padding the map with a row that doesn't correspond to any original THREAT-MODEL.md finding:
- | 2 | TM-07 | Elevation of privilege | `AppServerRole` broader than its actual usage | Least-privilege role tightening | M2.7 | Resolved (M2.7): both roles' S3 permissions scoped to their exact prefix (`manifests/*`, `photos/*`), verified via `terraform plan` and `awslocal iam get-role-policy` |
+ | 2 | TM-07 | Elevation of privilege | `AppServerRole` broader than its actual usage | Least-privilege role tightening | M2.7 | Resolved (M2.7), hardened (M7.3): scoped S3 policy plus an independent `permissions_boundary` denying `iam:*`/`organizations:*`/`sts:AssumeRole` regardless of future policy changes |
Common mistakes
Declaring the boundary as narrow as the permissions policy, instead of wider (inverted-design mistake). What happens: someone writes AppServerRole's boundary with exactly photos/* as the only allowed resource, mirroring the current permissions policy. How to spot it: if your boundary and your permissions policy grant, literally, the same scope. How to fix it: it isn't a mistake that breaks validate or plan — but it wastes the boundary's purpose, which is to protect against future permissions-policy changes, not just confirm today's. If AppServerRole legitimately needed to read manifests/ too tomorrow, a boundary copied from photos/* would block that legitimate change just as easily as a malicious one — this lesson's boundary allows the entire bucket for reads precisely so it doesn't stay so tightly glued to the current permissions policy that it stops being useful when a reasonable change comes along.
Forgetting default = null in the module's new variable, and breaking the existing call for LambdaManifestProcessorRole (backward-compatibility mistake). What happens: someone adds permissions_boundary_arn as a required variable, with no default, and terraform validate fails with an error about a variable with no value in the module call that doesn't specify it. How to spot it: the error message mentions module.lambda_manifest_processor_role and a required variable left unassigned. How to fix it: exactly as Step 1 declared — default = null — so any existing module call that doesn't mention this input keeps working with no change at all, the same reusable-module discipline you already saw in terraform-and-iac-guide.
Confusing the boundary's Deny with a policy that "bans using IAM" in general, instead of scoping it to this specific role (scope mistake). What happens: someone, seeing "Action": "iam:*" with Effect: Deny, worries this affects other roles or the entire account. How to spot it: if your reading of the boundary assumes it impacts something outside AppServerRole. How to fix it: a permissions_boundary is an ARN attached to a specific role — in this case, only AppServerRole, via permissions_boundary_arn in its module call —; LambdaManifestProcessorRole has no boundary attached after this lesson, and keeps operating exactly as before. The boundary's Deny only applies when that specific role tries to use those actions.
Exercises
Exercise 1 — Predict the plan for LambdaManifestProcessorRole after this lesson. Without looking back at Step 6, what would change in module.lambda_manifest_processor_role.aws_iam_role.this's plan as a result of this lesson?
See solution
Nothing. LambdaManifestProcessorRole doesn't receive any boundary in this lesson — only AppServerRole does, via its own module call —, and the module's new variable (permissions_boundary_arn) has default = null, so any call that doesn't explicitly mention it keeps producing exactly the same aws_iam_role as always, with the permissions_boundary argument not affecting its real value (Terraform interprets it as "no boundary," the same behavior as before this lesson).
Exercise 2 — Explain why permissions_boundary = (known after apply) appears in the plan, instead of the literal ARN. Without looking back at Step 6, explain in your own words why Terraform can't show the boundary's complete ARN in the plan, even though the policy's name ("AppServerRole-permission-boundary") is literal and known ahead of time.
See solution
Because an IAM policy's ARN isn't a value you declare — AWS generates it at the moment the resource is created, incorporating the account ID and a unique identifier. aws_iam_policy.app_server_boundary doesn't exist yet at plan time (it's going to be created in the same apply), so its .arn attribute has no known value yet. It's exactly the same mechanism Module 2, lesson 5 already explained for data.aws_iam_policy_document.trust: a reference to a resource created in the same apply always produces a (known after apply) value in the plan, regardless of the rest of that same resource's fields (like its name, which you did write as a literal) showing up complete.
Exercise 3 — Design the boundary you'd give LambdaManifestProcessorRole, if this guide decided to add one. Based on Module 1's inventory (LambdaManifestProcessorRole reads manifests/* and writes dynamodb:PutItem to Shipments, nothing else), write, in prose or HCL, what two statements a reasonable boundary for that role would have.
See solution
A reasonable boundary would have the same two-statement shape as AppServerRole's: a broad Allow within the category the role legitimately needs — for example, s3:* and dynamodb:* over Andes Cargo's resources, wider than the current permissions policy but scoped to the two service categories that role actually uses — and an explicit Deny over iam:*, organizations:*, and sts:AssumeRole, identical in spirit to AppServerRole's — the category of privilege escalation no data-processing role should ever be able to reach, no matter how much its legitimate permissions policy grows over time. The point of the exercise isn't memorizing new HCL — it's confirming the design criterion ("broad ceiling within the right category, explicit denial of identity/organization") generalizes to any Andes Cargo role, not just AppServerRole.
Summary and next step
In this lesson you built Andes Cargo's first real permission boundary: you extended modules/iam-role/ with an optional, backward-compatible input, declared permission-boundary.tf with two statements (a broad read-only ceiling, an explicit identity/organization denial), and attached that boundary only to AppServerRole. You ran real fmt/validate/plan over the complete project, confirmed the (known after apply) mechanism over a reference between two new resources in the same apply, and precisely documented the exact limit of what this lab can prove: the resource is real, the block that justifies its existence stays representative, with the same source cited since lesson 2.
Before moving on you should be able to: explain why this lesson's boundary is wider than AppServerRole's current permissions policy, not narrower; write from memory the two-statement structure (broad Allow + explicit IAM/Organizations Deny); and explain, unaided, exactly what stops this lesson from demonstrating the real block.
Lesson 4 changes category completely: it leaves the preventive side behind and names AWS's three central detective guardrails — CloudTrail, Config, and GuardDuty — before lesson 5 builds the first of the three as far as this lab allows.
Resources
- AWS Docs — Permissions boundaries for IAM entities — the official source already cited in lesson 2, with the complete effective-permissions evaluation example.
- Terraform Registry —
aws_iam_role— complete reference for thepermissions_boundaryargument, used in Step 2. - Terraform Registry —
aws_iam_policy— complete reference for the resource declared in Step 3. - AWS CLI —
iam get-role— complete reference for Step 7's verification command, including thePermissionsBoundaryfield. - This course, Module 2, lesson 5 — the
(known after apply)mechanism over a dependency between two resources in the sameapply, already explained there fordata.aws_iam_policy_document.trust.