linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Lessons
  • How it works
  • Knowledge base
  • Cheat sheet
  • Capstone
  • Interview prep
home/terraform/lessons/tf-production-06-opa-rego

lesson ── terraform-production ── ~16 мин ── 5 шагов

OPA + Rego, gating plan.json

OPA + Rego, for the rules Checkov does not have. A business invariant (every resource carries a CostCenter tag), cross-resource (Lambda and RDS in the same VPC), conditional (a prod resource, only from the main branch). In this lesson you write Rego rules and run conftest against plan.json.

▶ интерактивный sandbox

Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.

запустить sandbox →

stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя

Шаги

  1. 01

    Drop in HCL that breaks the policy

    Business rule: every S3 bucket must carry CostCenter and Environment tags. One of the buckets breaks it.

    bash
    cd /home/student/tf-opa
    cat > main.tf <<'EOF'
    resource "aws_s3_bucket" "good" {
      bucket = "linuxlab-opa-good"
      tags = {
        CostCenter  = "ml-team"
        Environment = "dev"
      }
    }
    resource "aws_s3_bucket" "missing_tags" {
      bucket = "linuxlab-opa-missing"
      tags = {
        Owner = "student"
      }
    }
    EOF
    terraform init -no-color > /dev/null
    terraform plan -no-color -out=plan.tfplan
    terraform show -json plan.tfplan > plan.json

    plan.json is ready. Now we write the rule.

    ✓ Plan generated. The missing_tags bucket breaks the policy.

  2. 02

    Write a Rego rule

    bash
    cat > policies/tags.rego <<'EOF'
    package terraform.tags
    import future.keywords.contains
    import future.keywords.if
    import future.keywords.in
    mandatory_tags := {"CostCenter", "Environment"}
    deny contains msg if {
        some resource in input.resource_changes
        resource.type == "aws_s3_bucket"
        resource.change.actions[_] == "create"
        tags := object.get(resource.change.after, "tags", {})
        some tag in mandatory_tags
        not tags[tag]
        msg := sprintf("%s missing mandatory tag %q", [resource.address, tag])
    }
    EOF

    What it does: for every aws_s3_bucket in the plan that will be created, it checks that each mandatory tag is present. If one is missing it adds a deny message.

    Run it right away:

    bash
    opa eval -d policies/ -i plan.json 'data.terraform.tags.deny'

    It should return an array with two messages (CostCenter and Environment are both missing on missing_tags).

    ✓ Rego sees the violation. OPA spotted the bucket without tags.

  3. 03

    Tests on the Rego itself

    Tests on the rules are critical: a buggy rule lets violations through.

    bash
    cat > policies/tags_test.rego <<'EOF'
    package terraform.tags
    import future.keywords.if
    test_deny_when_missing_tags if {
        mock := {"resource_changes": [{
            "address": "aws_s3_bucket.bad",
            "type": "aws_s3_bucket",
            "change": {
                "actions": ["create"],
                "after": {"tags": {"Owner": "x"}},
            },
        }]}
        count(deny) == 2 with input as mock
    }
    test_no_deny_with_all_tags if {
        mock := {"resource_changes": [{
            "address": "aws_s3_bucket.good",
            "type": "aws_s3_bucket",
            "change": {
                "actions": ["create"],
                "after": {"tags": {
                    "CostCenter": "team",
                    "Environment": "dev",
                }},
            },
        }]}
        count(deny) == 0 with input as mock
    }
    test_ignores_delete_actions if {
        mock := {"resource_changes": [{
            "address": "aws_s3_bucket.gone",
            "type": "aws_s3_bucket",
            "change": {"actions": ["delete"], "after": null},
        }]}
        count(deny) == 0 with input as mock
    }
    EOF
    opa test policies/

    It should show "PASS: 3/3". If the rule ignored missing tags, test_deny_when_missing_tags would fail. That is the safety net.

    ✓ The Rego rules are tested. Safe to use in CI.

  4. 04

    conftest for CI-friendly output

    opa eval is flexible, but in CI you want a human-friendly format with pass/fail. conftest, a wrapper with output, an exit code, and severity.

    First, the policies/ directory layout for conftest:

    bash
    # conftest reads the "main" namespace by default, rename the package
    sed -i 's/^package terraform.tags$/package main/' policies/tags.rego
    sed -i 's/^package terraform.tags$/package main/' policies/tags_test.rego
    conftest test plan.json --policy policies/
    echo "exit: $?"

    It should show FAIL with two messages and exit 1.

    An alternative, keep the namespace and use --all-namespaces or --namespace terraform.tags. That is a matter of taste; CI decides it once.

    ✓ conftest shows the same thing opa eval does, but in a CI-friendly format.

    The same thing on OpenTofu

    OpenTofu keeps the CLI and state compatible with Terraform for the commands in this step: migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. On a first switch, though, back up the state and do a run on a feature branch, the differences cluster in the newer features (variables in backend, state encryption, OCI registry-backed modules). See tf-opentofu-parity for the full matrix.

    • → OpenTofu parity
  5. 05

    Fix the HCL, the gate passes

    Add the tags:

    bash
    sed -i '/"missing_tags"/,/^}$/ s|tags = {|tags = {\n    CostCenter  = "data-team"\n    Environment = "dev"|' main.tf
    cat main.tf
    terraform plan -no-color -out=plan.tfplan
    terraform show -json plan.tfplan > plan.json
    conftest test plan.json --policy policies/
    echo "exit: $?"

    Now exit 0, the gate is clean. The PR is mergeable.

    ✓ The policy gate passes. In CI this is the last stage before the apply job.

    Cross-resource policy

    That was a simple example, all S3 with tags. Here is what OPA does better than the rest:

    rego
    # "If the plan creates a Lambda, it must be in the same VPC
    # as the RDS it references through the DB_HOST env var"
    deny contains msg if {
        some lambda in input.resource_changes
        lambda.type == "aws_lambda_function"
        lambda.change.actions[_] == "create"
        db_host := lambda.change.after.environment[0].variables.DB_HOST
        db_address := substr(db_host, 0, indexof(db_host, "."))
        some db in input.resource_changes
        db.type == "aws_db_instance"
        db.change.after.address == db_address
        # subnets
        lambda_subnets := lambda.change.after.vpc_config[0].subnet_ids
        db_subnet_group := db.change.after.db_subnet_group_name
        count(lambda_subnets) > 0
        db_subnet_group != ""
        msg := sprintf(
            "Lambda %q is in VPC subnet %v but reads RDS %q in subnet group %q, check VPC alignment",
            [lambda.address, lambda_subnets, db.address, db_subnet_group],
        )
    }

    Checkov will not write that. Neither will terraform-compliance. This is OPA territory.

    More in tf-policy-as-code.

    • → OPA + Rego in full
    • → The BDD variant, terraform-compliance

Что ты узнал

terraform plan -out=plan.tfplan && terraform show -json plan.tfplan > plan.json. You run the resulting JSON through opa eval or conftest test. The rules in Rego use input.resource_changes. deny, a list of messages; a non-empty one means fail.

команды

  • opa version && opa eval --help | head -5OPA is already in the image.
  • opa eval --data policies/ --input plan.json 'data.terraform.deny'what the rule returns.
  • terraform show -json plan.tfplan > plan.jsonthe format OPA understands.

концепции

  • · input is the plan.json structure
  • · deny, a convention; the rules agree on this name
  • · Rego tests, next to the rules, a *_test.rego file, run with opa test

← предыдущий

Remote state in S3 (on LocalStack)

следующий →

OpenTofu, matrix CI alongside Terraform

Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies