Policy as Code Without a Cluster: Adding OPA to FinChat

FinChat is my reference banking Data and AI platform on GCP. Before adding a policy engine to it I checked what was already enforcing rules mechanically, and found three things. The agent registry gate fails the build when an agent's tool list drifts from what the code passes to the constructor. The refusal playbook compiles out of YAML frontmatter into agent system instructions, with a drift test behind it. The AI gateway rejects an unregistered workload class at runtime rather than defaulting it to a permissive tier. All three enforce policy, and all three are hand-written Python that happens to enforce policy rather than policy expressed as such.

Terraform had nothing. The infrastructure gate was terraform fmt -check and terraform validate, which answer whether the configuration is syntactically valid HCL. Neither answers whether the change is acceptable, so a pull request adding a project-level roles/editor binding, publishing a backend Cloud Run service to allUsers, or creating an agent service account absent from the registry would have passed every check in CI.

What OPA is

Open Policy Agent is a general-purpose policy engine with a single operation: you supply a JSON document as input, the engine evaluates rules written in a declarative language called Rego, and it returns a decision. The engine has no opinion about what the JSON represents, which is the property that makes it general-purpose.

Rules live in a package and are usually written as partial set rules, which accumulate zero or more results rather than returning one value. The convention Conftest follows is a set named deny holding one message per violation, so an empty set means the input is acceptable:

package finchat.terraform

deny contains msg if {
    # conditions, all of which must hold
    msg := "what is wrong"
}

Why separate the decision from the thing being governed

The rules below could have been written as an if-statement inside the tool that does the work, and in FinChat several of them already were. Pulling them out buys three specific things.

First, the policy becomes a reviewable artifact in its own right. A rule stating that every consequential agent must declare a human-in-the-loop gate can be read by the person who owns that requirement without them reading the program that enforces it, which matters when the reader is a risk or audit function rather than an engineer.

Second, the same rule set can be evaluated at more than one point. A rule over an agent registry can run in CI against the committed catalogue and later against the registry table in the warehouse, without being written twice.

Third, the decision itself becomes something you can version and record. If a request is refused, the audit record can carry which policy version refused it, rather than only the outcome. That is the difference between "this call was blocked" and "this call was blocked by version 14 of the tool-scope rule".

The three uses you find first, and why none of them applied

Look OPA up and you land on the same three deployments. They are worth explaining plainly, because none of them fitted FinChat and working out why is what pointed at the use that did.

The first is Kubernetes admission control. Kubernetes has a hook that hands every proposed resource to an external service for approval before the cluster stores it, and Gatekeeper is the OPA project that plugs into that hook, packaging rules as ConstraintTemplate objects and auditing what is already in the cluster on a schedule. FinChat runs on Cloud Run, chosen because scale-to-zero is the near-zero-cost premise of the build, so there is no cluster and no hook to plug into.

The second is OPA as a sidecar. A sidecar is a second container running beside the application in the same unit, and the application asks it over localhost whether a caller may do a thing rather than implementing that check itself. This is the right shape for runtime authorization and it is the one I would eventually want for FinChat's AI gateway, which I have not done, for a reason I will come back to.

The third is supply chain checking in CI, over things like image provenance or a software bill of materials, which is an inventory of what went into a built artifact. FinChat builds container images in CI but produces no SBOM, so there is nothing there to check yet.

What did apply is the plainest use of all. A Terraform plan is a JSON document describing changes that have not happened yet, and a policy engine can read it and refuse.

How this fits the policy layers FinChat already had

The reason this took a while to see is that FinChat was not short of policy. It had five layers of it in place, each expressed in a different substrate:

Layer Governs Enforced by At what moment
IAM and custom roles which identity may do what GCP control plane every API call
Policy tags and BigQuery data policy which columns a caller reads, masked or not BigQuery every query
Dataplex aspects, scans, access groups discovery, quality, how access is requested the catalog when data is found or requested
OKF refusal and perimeter rules what an agent may say or attempt compiled into system instructions at inference
AI gateway which model tier, what token budget, PII screening the gateway service every model call

A sixth belongs on that list and is not there. FinChat has no Organization Policy constraints, which are the GCP mechanism for saying what may exist in a project at all, independent of who is asking. That absence matters for everything below, so it is better stated up front than discovered in the conclusion.

Two of the five are already policy as code in substance. The OKF bundle keeps the refusal rules as machine-readable YAML and compiles them into agent system instructions, with a drift test asserting the compiled output still matches the source. The agent registry does the same for agent ownership and tool scope. Neither is written in a policy language, and both are enforced by Python that has to be read to be understood.

What none of them can do is look at a change that does not exist yet. IAM evaluates an API call, so by the time it sees anything the request is already being made. Policy tags evaluate a query. The OKF rules evaluate a model turn. Every one of them answers "is this action allowed", and none answers "is this proposed configuration acceptable", because a proposal is not an action and never reaches them.

That is the specific hole, and it is why the plan file turned out to be the right input. Between the moment someone writes a change and the moment it exists, the change is a document, and a document is exactly what a policy engine reads.

The second thing the engine adds is that the rule stops being a property of its substrate. An IAM binding is data. A policy tag is data. A refusal rule is YAML that becomes prompt text. A budget limit is a semicolon-delimited environment variable. Each is enforced somewhere different and read differently, and none of them can be unit tested as a rule. In Rego a rule is a named, testable artifact that says what it refuses and can be run against a document by anyone, which is what made moving the agent registry checks worth doing even though the Python worked.

What this does not replace

The layers the platform enforces natively should stay native. IAM and column-level masking hold no matter how a request arrives, including from someone working in the console who never opened a pull request. A rule in CI holds only for changes that come through CI.

That distinction cuts against some of what I just built. Two of the seven Terraform rules, the one refusing basic roles and the one refusing public access, are things an Organization Policy constraint would enforce properly, at the API rather than at the merge. Writing them in Rego was the fastest way to get a check in place, not the best available control, and the right end state is that those two become constraints and the Rego version becomes an early warning rather than the only thing standing there.

The rules that earn their place permanently are the ones no GCP constraint expresses: that a Cloud Run service carries the label the alerting depends on, that an agent service account traces back to a registered agent with a named owner. Those are FinChat's own invariants, and there is nowhere else to put them.

The same reasoning is why the AI gateway is still Python rather than the sidecar I described earlier. It deliberately degrades to a direct model call when it is unreachable, so the product does not go down with the governance layer, but a policy refusal must never fall back that way. A decision point reached over a network makes those two cases indistinguishable, so the version worth building there embeds the policy in the process rather than calling out to it.

Where the two policy gates sit in the pipeline

Part one: the Terraform plan as an input document

terraform show -json tfplan emits a document whose relevant section is resource_changes, an array in which each entry carries a type, an address, and a change object holding actions, before, after and after_unknown. Two properties of that format shape every rule.

First, a plan describes creates, updates, deletes and no-ops in the same array, and only creates and updates carry a posture to police. A resource being destroyed has no configuration to judge, and denying one would block teardown. So the rules iterate a helper rather than the raw array:

changed contains rc if {
    some rc in input.resource_changes
    actions := {a | some a in rc.change.actions}
    actions & {"create", "update"} != set()
}

Second, an attribute Terraform cannot resolve until apply is absent from change.after and listed in change.after_unknown. An attribute the author simply omitted is null in after and absent from after_unknown. The two look alike from inside a rule and mean opposite things: the first is a value that will exist and is not knowable yet, the second is a real omission. Rules that care about the difference test after_unknown explicitly instead of inferring it from a missing key.

The seven rules

Rule Refuses
IAM-1 roles/owner or roles/editor on any binding
IAM-2 allUsers or allAuthenticatedUsers, except the demo UI service
IAM-3 Authoritative *_iam_binding and *_iam_policy resources
RUN-1 A Cloud Run service without a usable env label
BQ-1 A production evidence table without deletion protection
GCS-1 A bucket with uniform bucket-level access disabled
SA-1 An agent service account without an owner and recertification date

Each came out of something specific in this codebase rather than a generic benchmark. RUN-1 is the clearest example:

deny contains msg if {
    some rc in changed
    rc.type == "google_cloud_run_v2_service"
    not unknown(rc, "labels")
    not labelled_with_env(rc.change.after)
    msg := sprintf("RUN-1: %s has no usable `env` label ...", [rc.address])
}

labelled_with_env(after) if { after.labels.env in {"dev", "test", "prod"} }

FinChat's technical-control alerting routes on resource.labels.env. That label is stamped by Cloud Run from Terraform and cannot be set by the workload, unlike the environment field inside the control-event payload, which is written by the emitting process and is only as trustworthy as that process. A service deployed without the label still starts and still serves traffic; what changes is that its control events reach the wrong ServiceNow assignment group or none at all. The failure is silent, which is why I wanted it caught at plan time.

IAM-2 shows how an exception is modelled:

deny contains msg if {
    some rc in changed
    rc.type in iam_member_types
    rc.change.after.member in {"allUsers", "allAuthenticatedUsers"}
    not sanctioned_public(rc)
    msg := sprintf("IAM-2: %s grants %q to %s ...", [...])
}

sanctioned_public(rc) if {
    rc.type == "google_cloud_run_v2_service_iam_member"
    rc.change.after.role == "roles/run.invoker"
    regex.match(`^finchat-(dev|test|prod)-ui$`, rc.change.after.name)
}

The UI serves the sign-in page and therefore cannot sit behind run.invoker. The exception matches on the service name rather than the module address, so copying or renaming the module does not carry the exception with it.

IAM-1 covers roles/owner and roles/editor only. I left roles/viewer out deliberately: the CI/CD deploy service account holds it so that terraform plan can refresh state across every module, the grant is read-only, and it is justified inline in the foundation module. Including it would have meant shipping the rule with a standing exception attached, and I would rather the rule cover the two roles that have no such argument.

Conftest refusing a Terraform plan, with all seven rules firing

Wiring it into the pipeline

- name: Plan
  run: terraform plan -input=false -no-color -out=tfplan

- name: Policy gate — Terraform posture
  run: |
    terraform show -json tfplan > tfplan.json
    conftest test --policy policy/terraform \
      --namespace finchat.terraform tfplan.json

- name: Apply
  if: ${{ github.event.inputs.action == 'apply' }}
  run: terraform apply -input=false tfplan

Plan writes to a file and apply applies that file. The previous workflow re-planned at apply time, which meant the plan that was inspected and the plan that ran were two separate operations against a project that could have changed in between. Conftest is version-pinned in both workflows, since the engine version determines how the rules are parsed and evaluated.

Part two: moving registry rules out of Python

The agent registry gate had eight checks. Four moved to Rego and four stayed in Python, and the criterion was what each check has to read.

DRIFT-1 through 4 parse agent source files with ast and compare constructor names, tool lists and model arguments against the catalogue. Rego has no facility for that and would be a poor fit, so they stayed. REG-1 through 3 and LIFE-1 are assertions over a JSON document, which is what Rego evaluates natively, so they moved. The input comes from a new emitter on the catalogue:

python scripts/agents_catalog.py --env prod --emit-policy-input -

It produces {env, today, agents: [...]}, with recert_due and the truncated IAM service account id computed, and today carried in the document rather than read from the clock inside the policy, so a run is reproducible and a test can pin the date.

The rules that moved are the ones the risk function relies on, and they are now readable without reading Python:

deny contains msg if {
    some a in input.agents
    a.consequential == true
    object.get(a, "hitl", false) != true
    msg := sprintf("REG-3: %s takes consequential action but declares no human-in-the-loop gate.", [a.id])
}

The object.get with a default is load-bearing. Testing a.hitl != true would leave the expression undefined when the field is absent, and an undefined expression makes the rule body fail, so an agent could pass by having the field deleted rather than set correctly.

Writing the rules declaratively also produced one I had not had in Python. LIFE-2 re-derives each recertification due date from the last recertification plus the cadence its risk tier earns, and denies when the catalogue disagrees:

recert_days := {"HIGH": 90, "MEDIUM": 180, "LOW": 365}

deny contains msg if {
    some a in input.agents
    expected := date_string(to_ns(a.last_recertified) + (recert_days[a.risk_tier] * day_ns))
    a.recert_due != expected
    msg := sprintf("LIFE-2: %s declares recert_due %s, but a %s-tier agent last recertified on %s is due %s.", [...])
}

The cadence is duplicated between the Python catalogue and the policy on purpose. It is the same construction as the reconciliation control elsewhere in FinChat: two values computed independently from the same source, with divergence treated as its own finding. Here it means a recert_due edited by hand without a corresponding recertification fails the build.

Testing the rules

Each rule has a test asserting it fires on the violation it names and a test asserting it returns nothing for the compliant shape, run by conftest verify:

test_iam2_allows_the_public_ui_service if {
    count(deny) == 0 with input as iam(
        "google_cloud_run_v2_service_iam_member",
        {"role": "roles/run.invoker", "member": "allUsers",
         "name": "finchat-prod-ui"})
}

test_iam2_rejects_a_public_backend_service if {
    count(deny) == 1 with input as iam(
        "google_cloud_run_v2_service_iam_member",
        {"role": "roles/run.invoker", "member": "allUsers",
         "name": "finchat-prod-txn-api"})
}

That is 42 tests across the two policy sets. I also validated both against real terraform show -json output rather than hand-written fixtures alone, by building two throwaway Terraform configurations mirroring the modules FinChat actually uses. The compliant plan passes seven of seven and the deliberately non-compliant plan fails seven of seven, with the messages naming the resource address in each case.

Policy unit tests passing, and the registry rules against the live registry

Where I think it goes next here

The surfaces I would take next are the ones where FinChat already makes a decision in Python that no platform control covers:

  • Agent tool allow-lists, which tools an agent may invoke, asserted against the code that constructs it
  • AI gateway authorization: workload class, model tier clamp, daily token budget
  • Human-in-the-loop gates, by action class, deciding whether an agent may act or must hand off
  • RAG retrieval scope, which corpora or which rows a caller's question may be grounded in
  • MCP server authorization, whether this caller may make this tool call

Each of those is a structured request meeting a rule, which is the same shape as a plan file meeting a rule.

The takeaway

CI already runs tests, and a test answers whether the code does what it is supposed to do. Policy is the other question: whether the change should be made at all. In FinChat that question was going unasked in the one place where a change is still only a document and nothing has happened yet.

Adding an engine did not add governance to a platform that already had six layers of it. What it did was fill the gap between writing a change and the change existing, and turn a class of rule that had been buried inside Python into something a person outside the team can read and a test can pin.

The limit is worth repeating rather than leaving implied, which is why it is in the ADR: these rules stop a merge, not an API call. I did not want a green check in this pipeline to be read later as evidence that the control is enforced.

0 Comments

Leave a Comment