Policy as Code – Enforcing Controls with OPA

Policy as Code – Enforcing Controls with OPA

Policy as Code: Enforcing GRC Controls Before They Hit Production — NAXS Labs

Policy as Code:
Enforcing GRC Controls
Before They Hit Production

Most compliance programs find misconfigurations after the fact. This is how you catch them before the resource ever gets created.

All posts

A team spins up infrastructure, an auditor reviews it weeks later, a finding lands in a spreadsheet, someone opens a ticket, and somewhere in that chain a public S3 bucket sat exposed long enough to matter. The problem isn’t that the control didn’t exist, but that enforcement happened after deployment instead of before it.

Policy as Code closes that gap by codifying controls as machine-readable rules wired directly into the deployment pipeline, so infrastructure that violates policy simply can’t be approved. The misconfiguration never reaches production.

This post walks through building exactly that using Open Policy Agent, Rego, Terraform, and GitLab CI. The scenario is an S3 bucket tagged for production that must not have public access enabled — a control that maps to CIS AWS Benchmark 2.1.5 and NIST SP 800-53 AC-3. By the end, that control is automated, version controlled, and enforced on every deployment.

Why This Matters from a GRC Perspective

GRC work has a documentation problem. Controls get written, baselines get approved, and then someone has to trust that engineers actually implement them correctly. Manual reviews and periodic audits create windows where non-compliant configuration can exist without anyone knowing, and that’s where the real risk lives.

When a control lives as code in a Git repository, every change to the policy has a commit, every deployment that violated it has a pipeline log, and every time the control fired and blocked a deployment you have evidence — not a screenshot, not a ticket, but an automated audit trail that exists as a byproduct of normal engineering workflow. That’s what makes Policy as Code relevant to GRC practitioners specifically. It’s not just a DevSecOps tool, it’s a control implementation strategy with built-in evidence collection.

The Stack

Open Policy Agent is a general-purpose policy engine used to enforce rules across infrastructure, Kubernetes, APIs, and CI/CD pipelines, and Rego is the language you use to write those rules. You feed OPA an input — in this case a Terraform plan — and it evaluates the input against your policy and returns any violations. Conftest is a wrapper around OPA built specifically for infrastructure configuration files that connects the policy check directly to the pipeline. The full flow looks like this:

Developer pushes Terraform changes
  ↓
GitLab CI triggers pipeline
  ↓
terraform validate → terraform plan → tfplan.json
  ↓
Conftest evaluates tfplan.json against policy.rego
  ↓
  PASS → manual approve → terraform apply → AWS
  FAIL → pipeline blocked, violation message returned

The key thing to understand about that diagram is where the blocking happens — Terraform hasn’t run yet when the policy check fires. The plan output describes what would be created, OPA evaluates that description, and if the policy fails nothing is created. AWS never sees the request.

Writing the Policy

The rule we’re enforcing: if an S3 bucket has public access controls disabled and is tagged as a production environment, deny the deployment.

package main

deny contains msg if {
  resource := input.resource_changes[_]
  resource.type == "aws_s3_bucket_public_access_block"
  resource.change.after.block_public_acls == false
  resource.change.after.restrict_public_buckets == false
  bucket := input.resource_changes[_]
  bucket.type == "aws_s3_bucket"
  bucket.change.after.tags.Environment == "production"
  msg := "Production bucket must not be public - public access block is disabled"
}

Reading through this top to bottom: the rule loops through every resource change in the Terraform plan, finds the public access block resource, and checks that both block_public_acls and restrict_public_buckets are false, then verifies the associated bucket is tagged as production. All of those conditions have to be true simultaneously — if any one of them is false, the deny set stays empty and the pipeline passes.

Policy decision

Why scope it to production? A public dev bucket might be intentional — a developer sharing test assets, a staging site, a temporary demo environment. Blocking all public buckets everywhere creates friction that gets bypassed. Scoping the control to production means it fires where the actual risk lives and leaves room for legitimate use in lower environments. This is a risk tolerance call baked into the rule, not a technical limitation.

What the Terraform Plan Actually Looks Like

The policy paths above map directly to the JSON structure Terraform produces when you run terraform show -json tfplan.binary. The relevant section for the public access block looks like this:

{
  "type": "aws_s3_bucket_public_access_block",
  "change": {
    "after": {
      "block_public_acls": false,
      "block_public_policy": false,
      "ignore_public_acls": false,
      "restrict_public_buckets": false
    }
  }
}

The change.after key is significant because it represents the intended future state — what the resource will look like after Terraform applies, not what exists now. That’s what makes pre-deployment enforcement possible; the policy evaluates a plan, not a live environment.

Unit Testing the Policy

Before wiring this into a pipeline, it should be tested in isolation. OPA has a built-in test runner, and the test file mocks the Terraform plan structure to verify the policy fires when it should and stays silent when it shouldn’t.

package main

test_public_prod_bucket_denied if {
  deny[_] with input as {
    "resource_changes": [
      {
        "type": "aws_s3_bucket_public_access_block",
        "change": { "after": { "block_public_acls": false, "restrict_public_buckets": false } }
      },
      {
        "type": "aws_s3_bucket",
        "change": { "after": { "tags": { "Environment": "production" } } }
      }
    ]
  }
}

test_public_prod_bucket_passes if {
  count(deny) == 0 with input as {
    "resource_changes": [
      {
        "type": "aws_s3_bucket_public_access_block",
        "change": { "after": { "block_public_acls": true, "restrict_public_buckets": true } }
      },
      {
        "type": "aws_s3_bucket",
        "change": { "after": { "tags": { "Environment": "production" } } }
      }
    ]
  }
}

Running opa test policy.rego policy_test.rego -v against both tests produces two passes before anything touches the pipeline.

The GitLab Pipeline

With the policy tested locally it gets wired into GitLab CI across four stages: validate checks Terraform syntax, plan generates the JSON output Conftest will evaluate, policy runs Conftest against that output, and deploy is gated behind a manual approval that only becomes available after policy passes.

stages:
  - validate
  - plan
  - policy
  - deploy

variables:
  TF_ROOT: s3

validate:
  stage: validate
  image:
    name: hashicorp/terraform:latest
    entrypoint: [""]
  before_script:
    - cd ${TF_ROOT} && terraform init
  script:
    - terraform validate

plan:
  stage: plan
  image:
    name: hashicorp/terraform:latest
    entrypoint: [""]
  before_script:
    - cd ${TF_ROOT} && terraform init
  script:
    - terraform plan -out=tfplan.binary
    - terraform show -json tfplan.binary > tfplan.json
  artifacts:
    paths:
      - s3/tfplan.json

policy:
  stage: policy
  image:
    name: openpolicyagent/conftest:latest
    entrypoint: [""]
  script:
    - conftest test s3/tfplan.json --policy s3/policies/policy.rego
  dependencies:
    - plan

deploy:
  stage: deploy
  image:
    name: hashicorp/terraform:latest
    entrypoint: [""]
  before_script:
    - cd ${TF_ROOT} && terraform init
  script:
    - terraform apply -auto-approve
  needs:
    - job: policy
    - job: plan
  when: manual

AWS credentials are stored as masked CI/CD variables in GitLab settings — AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_DEFAULT_REGION. The pipeline runner picks them up automatically. Nothing sensitive lives in the repository.

The when: manual on the deploy stage is the difference between continuous delivery and continuous deployment. The pipeline gets everything validated and ready, but a human has to click approve before Terraform touches AWS.

What the Output Looks Like

When Conftest catches a violation the pipeline fails at the policy stage and returns the violation message directly in the job log:

FAIL - s3/tfplan.json - main - Production bucket must not be public - public access block is disabled
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions
ERROR: Job failed: exit code 1

The deploy button never appears. The engineer sees the violation, updates the Terraform configuration, pushes again, and the pipeline re-evaluates. When the public access block is set correctly the policy stage passes, the deploy button becomes available, and a reviewer approves — only then does terraform apply run against AWS.

Mapping This to a Framework

This isn’t just a technical exercise. The policy enforces real controls:

CIS AWS 2.1.5 NIST AC-3 NIST SC-7 NIST CM-6

CIS AWS Benchmark 2.1.5 requires that S3 buckets block public access, NIST AC-3 covers access enforcement, SC-7 covers boundary protection, and CM-6 covers configuration settings. The policy file in the repository is the implementation evidence for all of them, and the pipeline logs are the continuous monitoring evidence.

An auditor asking for evidence of AC-3 implementation on S3 storage gets a Git repository with a policy file, a commit history showing when it was written and by whom, and pipeline logs showing it has fired on every deployment since. That’s a significantly stronger answer than a console screenshot taken the week before the audit.

Where This Goes Next

This lab is intentionally minimal — one bucket, one rule, one pipeline — but the same pattern scales to every resource type that needs to be governed. EC2 instances, security groups, IAM policies, RDS parameter groups, all of it can be encoded as policy and enforced at plan time.

As of now, if you deploy this bucket to AWS you’ll have to manually delete it through the CLI or the console, since the pipeline and your local environment don’t share state. Setting up the remote backend is what fixes that, and it’ll be covered in the another post.

The full repo is at git.naxslabs.com/darnell/opa-terraform-aws.


NAXS Labs
Logo