DEV Community

Cover image for My Policy-as-Code Check Passed. It Never Read the Policy.
Victor Okoroafor
Victor Okoroafor

Posted on Originally published at victorokoroafor.hashnode.dev

My Policy-as-Code Check Passed. It Never Read the Policy.

In my last post, I showed how OPA and Conftest block over-privileged IAM roles before anything gets deployed. I ended with a warning: there's a case where the check passes when it shouldn't.

This is that case. It's worse than a missed wildcard, because nothing tells you it happened.

The setup

My deny rule starts like this:

policy := json.unmarshal(resource.change.after.policy)
statement := policy.Statement[_]
has_wildcard(statement.Action)
Enter fullscreen mode Exit fullscreen mode

It reads the policy from the Terraform plan, parses it, and looks for a wildcard. It works perfectly when Terraform already knows the full policy at plan time.

Real policies are rarely that simple.

The policy Terraform can't see yet

Most IAM policies point at resources you're creating in the same apply. To test this, I wrote a small Terraform config with a DynamoDB table, a Lambda role, and this policy:

resource "aws_iam_role_policy" "lambda_dynamodb" {
  role = aws_iam_role.lambda.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "*"
      Resource = aws_dynamodb_table.transactions.arn
    }]
  })
}
Enter fullscreen mode Exit fullscreen mode

On a fresh deploy, the table doesn't exist, so its ARN doesn't exist either. Terraform can't build the final JSON string, so it marks the whole policy as unknown:

Terraform plan output for aws_iam_role_policy.lambda_dynamodb, with the policy, id, role, and name_prefix fields all shown as

In the plan JSON, that looks like this:

"after": { "name": "lambda_dynamodb" },
"after_unknown": { "policy": true }
Enter fullscreen mode Exit fullscreen mode

What OPA does with nothing

My rule reads resource.change.after.policy, and that field isn't there. In Rego, a missing field isn't an error. It's undefined, and an undefined line means the rule body doesn't match.

So the deny rule doesn't fire. No deny means no violation. No violation means Conftest exits 0.

Here's the real run. The wildcard is right there on line 55 of main.tf, Terraform confirms the policy is unknown, and my rules pass it anyway:

Terminal showing three commands. grep finds Action =

Honestly, my first reaction was confusion. The wildcard was right there in the code, and the check was green. It took me a while to realise the rule hadn't approved the policy. It had never seen it.

The check didn't say yes. It said nothing, and my pipeline would have treated silence as a yes. In my pipeline, the apply job only runs after a green Conftest step.

Why this is the dangerous one

A missed edge case like "s3:*" is a gap you can see in the rule. My own rule has that gap too. But this one is worse, because it hides in the input. The rule is correct. The data it needs just isn't there, and a deny-only policy fails open by design: it only blocks what it can prove is bad.

In security terms, that's backwards. If a gate can't evaluate something, it should stop it, not wave it through.

The fix: fail closed

I added a rule that denies any IAM policy Terraform can't resolve at plan time:

deny contains msg if {
    resource := input.resource_changes[_]
    iam_policy_resources[resource.type]
    not is_excluded_policy(resource.name)
    resource.change.after_unknown.policy == true

    msg := sprintf("IAM policy '%s' can't be evaluated at plan time (known after apply): failing closed", [resource.name])
}
Enter fullscreen mode Exit fullscreen mode

iam_policy_resources and is_excluded_policy are helpers already in my policy file. The full file is in the repo.

Same plan, new rule:

Conftest with the new policies fails:

Now an unreadable policy is a failed check, not a silent pass. In CI, that failure stops the pipeline before apply.

Then I turned it on in my own project

This is where it got real. I ran the new rule against the Terraform for my final-year project, and it failed on two of my own IAM policies. Both pointed at resources created in the same apply, a DynamoDB table and a Cognito user pool. My old rules had been passing them without reading them.

So the fail-closed rule did its job. It also meant my deploys would fail until I fixed the policies. There were two ways to deal with that:

  1. Split the apply. Create the table first, then the policy on the next run, when the ARN is known.

  2. Build the ARN from values Terraform already knows, like the account ID, region, and a fixed table name, so the policy resolves at plan time.

I went with the second. It keeps a single apply, and because the policy is readable at plan time, my wildcard rule can check it again. The fail-closed rule stays as a safety net for anything that still can't be resolved.

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  role = aws_iam_role.lambda_execution.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:Query"]
      Resource = "arn:aws:dynamodb:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:table/${var.transactions_table_name}"
    }]
  })
}
Enter fullscreen mode Exit fullscreen mode

The table and the policy both get their name from one shared local, so they can't drift apart.

Where this trick stops working

Building ARNs only works for names you control. Cognito was the exception: AWS generates the user pool ID when it creates the pool, so there's no way to write the exact ARN at plan time.

For that one, I scoped the policy to user pools in my own account and region, with read-only actions:

Resource = "arn:aws:cognito-idp:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:userpool/*"
Enter fullscreen mode Exit fullscreen mode

It's broader than one pool, but much narrower than "*". I chose that over adding an exception to the fail-closed rule, because exceptions are how silent passes come back.

After the fix, no IAM policy in the plan is unknown, and the pipeline goes green for the right reason this time:

GitHub Actions run where the Terraform Plan + OPA Validation job passed, the Conftest step shows 11 tests, 11 passed, 0 failures, and Terraform Apply is skipped.

The lesson

Every policy-as-code rule makes an assumption about its input. Mine assumed the policy would be there.

When you write a deny rule, ask one more question: what happens when the field I'm reading is missing? If the answer is "the rule doesn't fire," you've built a gate that fails open.

Test your gates with bad data. Then test them with no data.

Full code, including the fail-closed rule: github.com/vicGrey/aws-serverless-zero-trust-governance

Top comments (1)

Collapse
 
abhinavs1920 profile image
Abhinav Sharma •

"Known after apply" turning a deny rule vacuous is the scariest kind of pass, because the pipeline reports that the check ran, not that it had nothing to check against. A policy gate really needs three verdicts: pass, fail, and couldn't-see-it, with the third treated as fail-closed. Did you end up making unknowns deny by default, or flagging the plan for a second pass after apply?