Policy as Code for Self-Service Platforms Developers Won’t Bypass

Policy as code for self-service platforms is where the two tracks meet, and it’s mostly a placement problem: which decisions belong in the form, and which belong against the plan.

Written by

The Cycloid Team

Published

November 13, 2026

Read time

16 min

In short

  • Constrain predictable inputs in the form, and enforce the rules against the Terraform plan so CLI and API requests get the same checks.
  • Ship every new rule at Advisory and promote it to Critical only on evidence.
  • A deny message should name the resource, the rejected value and the accepted alternatives.
  • Test Rego against plans your catalog generates, with one passing and one failing fixture per rule.
  • Measure overrides and unmanaged resources, not blocked violations.

TL;DR

  • When a self-service form accepts unrestricted values, developers discover invalid requests eleven minutes later in the pipeline. Constrain predictable inputs at the form, but enforce rules against Terraform plans so CLI and API requests face the same checks.
  • A policy that blocks every violation eventually sends developers back to tickets. Start new rules at Advisory, measure their impact on existing catalog plans, and promote them only after the team has evidence that blocking won’t break legitimate deployments.
  • When RBAC decides which engineers can deploy to production, senior engineers become approval gates rather than policy owners. Keep access permissions separate from infrastructure constraints, and let plan-level rules enforce what each deployment can provision.
  • A Terraform policy that returns only a failure status leaves developers with the same problem they had before self-service. Include the resource address, rejected value, and accepted alternatives so developers can correct requests without waiting for platform engineering.
  • When Rego rules are tested only against hand-written JSON, provider-generated plans can expose failures that never appeared in development. Keep fixtures from every catalog stack, test both passing and failing cases with opa test, and regenerate fixtures after provider upgrades.
  • When developers repeatedly override policies or provision resources outside the platform, blocked-violation counts can hide a growing bypass problem. Track overrides against request volume and compare cloud inventory with IaC-managed resources to find where developers have stopped using self-service.

 

 

 

Where Self-Service Guardrails Start Sending Developers Back to Tickets

A developer requests a production Postgres instance through the platform’s form, types db.r6g.2xlarge into a free-text field, and waits eleven minutes for the pipeline to reject an instance class that was never allowed. The failure reads policy violation and nothing else, so she opens a Slack thread, and the self-service request turns into a ticket with extra steps.

 

Internal developer platforms were built to kill that ticket; Gartner projected that 80% of large software engineering organizations would run platform engineering teams by 2026, and most of those platforms converge on one shape: a template, a parameter form, and a pipeline that runs Terraform. Policy as code grew up on a separate track, in CI scanners and Kubernetes admission control, where written standards became rules a machine checks against a plan. Policy as code for self-service platforms is where the two tracks meet, and it’s mostly a placement problem: which decisions belong in the form, and which belong against the plan.

 

The breakage comes from blanket hard denies written for audit intent and never tested against the plans the catalog generates. Once blocking hurts enough, someone finds the override path: in one Atlantis bug report, allowing repo-level overrides of apply requirements silently dropped the hidden policies_passed check, so plans that failed conftest policies could still be applied.

 

GitHub issue #3908 in Atlantis: the hidden policies_passed requirement is removed when apply requirements are overridden at repo level, so failed policy checks can be bypassed

 

What follows traces a Postgres request through each enforcement layer. You’ll see where form constraints stop bad input and where they quietly don’t, how graded severity keeps false positives out of the ticket queue, what a deny message has to contain, and how to test Rego before one broken rule blocks every deployment in the organization.

 

 

 

Where Policy Guardrails Belong in a Self-Service Request Path

A self-service request crosses two enforcement points before anything reaches a cloud API: the form that collects parameters and the Terraform plan the pipeline builds from them. Each point sees a different slice of the request, and a rule placed at the wrong one either fires too late to help or never fires at all.

 

 

Form Constraints Stop Bad Input Before a Plan Exists

The eleven-minute wait in the Postgres example came from a field that accepted any string. Moving the constraint into the form turns a pipeline failure into a value the developer can’t select in the first place, and the check costs nothing because no plan runs. Four controls cover most catalog inputs: fixed option lists for instance classes and regions, bounded integer ranges for sizes and counts, regex validation on names, and read-only fields for values like a VPC ID that developers shouldn’t touch.

 

Swimlane diagram of a self-service request: invalid values are blocked at the form, valid values become a Terraform plan, and policy evaluation blocks violations before apply

 

A self-service request passes through two validation layers: form validation and Terraform plan evaluation. The form catches invalid inputs early, while plan-level policies enforce infrastructure rules regardless of whether requests come through the UI, CLI, or API.

 

Cycloid’s StackForms declares those controls in a .forms.yml file per stack, and a values_ref attribute can point any option list at a remote URL returning JSON. With values_ref, the approved-regions list comes from the service that owns the region decision instead of a stale copy in YAML. The field below comes from the StackForms variables reference, and it stops a developer from requesting a 40 GB volume on a template sized for 10.

 

Here’s how the field is built. key maps it to the variable disk_size, and widget: slider_range renders a slider. type: integer rejects non-numeric input, and default: 5 preselects a value. values: [1,10] sets the bounds; the file-format reference accepts exactly two values for this widget, minimum first. unit only labels the field. The nesting from use_cases down to groups organizes the form layout, and technologies: [pipeline] says which kind of variable file receives the value.

 

use_cases:
- name: myusecase
  sections:
  - name: mysection
    groups:
    - name: mygroup
      technologies: [pipeline]
      vars:
        - name: "Disk size"
          key: disk_size
          widget: slider_range
          type: integer
          default: 5
          values: [1,10]
          unit: Go

 

Widget:

 

Self-service form field rendered as a disk size slider bounded from 1 to 10, with 5 preselected

 

Two weaknesses keep the form from being the only layer. Not every widget restricts: that same file-format reference states auto_complete values are hints rather than restrictions, and regex validation applies only to simple_text fields. The second weakness is structural, since StackForms writes saved configuration directly into the project’s config repository, and anyone with push access to that repository can commit a value the form would never have offered.

 

 

Plan-Level Evaluation Catches the Requests That Skip the Form

Plenty of changes never render a form: a developer driving the platform from the CLI, or an AI agent calling the same API. Each still produces a Terraform plan if it runs through the pipeline, so plan JSON is the one artifact worth evaluating on every change. An OPA engine reads that JSON, runs Rego rules against resource_changes and planned_values, and returns a decision or a list of reasons.

 

Cycloid’s InfraPolicies evaluate an input document with three fields. tfplan holds the plan in JSON, while project_canonical and environment_canonical identify where the change is headed, and that context lets one rule behave differently per environment later on. In the pipeline, a Concourse resource sits right after the terraform plan step and before notification, so a failing plan never reaches apply.

 

Coverage stops at the pipeline boundary; A developer holding raw cloud credentials can run terraform apply from a laptop without touching the check, which is why the permission layer matters as much as the rules.

 

 

RBAC Decides Who Can Request, Not What They Can Request

Role design is where policy quietly turns back into a queue. Encode “only senior engineers deploy to production” as a role, and two things happen: seniors become human approval gates, and everyone else files access requests for work the catalog supposedly offers. Keep roles about who can submit and move what they can submit in plan-level rules, and let more people ship to production while the Rego carries the safety weight.

 

Cycloid roles are lists of rules, each pairing an action code (globs allowed) with an allow effect and the resources it covers. The Terraform provider’s role resource shows a read-only role scoped to a single project’s environments.

 

provider "cycloid" {
  # The Cycloid API URL to use, you can also use the CY_API_URL environment variable.
  api_url = var.cycloid_api_url
  # The Cycloid API key to use, you can also use the CY_API_KEY environment variable.
  api_key = var.cycloid_api_key
  # Organization canonical points to the organization that is governing all the entities in Cycloid (except users).
  # It's used as a default 'organization' parameter for all the resources that are created in the Cycloid.
  # You can also fill this with the CY_ORG environment variable.
  default_organization = var.cycloid_organization

  # Use this parameter only if you have self signed TLS certificates for the API
  # This is not a good practice
  insecure = true
}

terraform {
  required_providers {
    cycloid = {
      source = "registry.terraform.io/cycloidio/cycloid"
    }
  }
}

 

Child organizations isolate business units, and a mid-2026 release restricted child-organization access to organization admins by default. Scoping who can read cloud credentials closes the laptop-apply bypass.

 

Cycloid release note: component-scoped permissions for create, update and delete operations, no longer tied to project:update

 

 

 

Severity Design Decides Whether Self-Service Survives Policy Enforcement

A policy engine that only says yes or no pushes every rule toward blocking, because blocking looks safest in a design review. Blocking is also the setting that turns every false positive into a direct message to the platform team.

 

Flow diagram of a policy rule lifecycle: new rule to Advisory, Review and Critical on evidence, disabled when too noisy, revisited when overridden often

 

New policies shouldn’t block deployments before their behavior is tested against real infrastructure plans. Advisory enforcement collects violations without interrupting deployments, allowing teams to refine rules before promoting them to Critical. Follow the progression from Advisory to Critical, paying attention to the conditions that trigger review or disablement.

 

 

Promoting a Rule From Advisory to Critical on Evidence

A tagging rule enabled straight at Critical fails templates that passed yesterday, because nobody ran it against plans the catalog already generates. Ship every new rule at Advisory first. In Cycloid’s pipelines, an Advisory result leaves the job green and records the violation as job metadata, so the rule gathers evidence without blocking anyone.

 

Example of output:

 

Validation with advisories: the job is green, and advisories are displayed as metadata:

 

Green terraform-plan pipeline job with an advisory policy result recorded as job metadata

 

Validation with criticals and/or warnings: the job fails and displays the result as metadata:

 

Failed terraform-plan pipeline job showing a critical policy violation and an advisory message as job metadata

 

The green job status is the point of this screenshot: the violation is logged, and the developer still ships.

 

After two weeks of Advisory hits, the count tells you which templates to fix before promotion. Each InfraPolicy has its own enabled or disabled status, so promotion changes one rule rather than releasing the whole set. Give every new rule a named owner and a promotion date when it ships, or its Advisory output turns into noise.

 

 

One Rule Set, Loose in Dev and Strict in Production

Separate policy sets per environment drift apart, and developers meet production’s rules for the first time at promotion. A single rule scoped by environment keeps dev permissive without a second copy to maintain. The following excerpt from Cycloid’s InfraPolicies reference enforces backups only in production, so a throwaway RDS instance in dev doesn’t need a retention setting.

 

Here’s how the rule reads. is_production is a helper that’s true only when environment_canonical equals prod. The deny rule walks every resource under input.tfplan.planned_values.root_module.resources, matches aws_db_instance, and fails when backup_retention_period isn’t above zero. resource.address puts the exact Terraform address into the failure message.

 

package example

is_production {
  input.environment_canonical == "prod"
}

deny[reason] {
  is_production

  resource := input.tfplan.planned_values.root_module.resources[_]
  resource.type == "aws_db_instance"
  not resource.values.backup_retention_period > 0

  reason = sprintf("Backup is required on production for %q", [resource.address])
}

 

The weak spot is the string comparison i.e., once a rule keys off an environment name, that name becomes a security boundary, and an environment someone calls production-eu sails past a check on prod. Lock environment names down in the template, or match on a prefix and treat any rename as a policy change.

 

 

 

Terraform Policy as Code That Tells Developers What to Fix

The deny message is the part of a policy developers read most, and it usually gets written last by whoever wrote the rule. A rule that blocks correctly and explains nothing still produces a ticket; it just produces it eleven minutes later.

 

 

A Deny Message Is the Only Documentation Most Developers Read

Give the developer the resource address and the value that would pass, and they can fix the request without asking anyone. Cycloid’s deny rules return an array of reason strings instead of a boolean, which leaves room for that detail. The instance-type example from the same reference builds its message that way, and it’s the rule that would have saved the Postgres request.

 

Here’s how the rule works. allowed_instance_types is a set of approved types. The deny rule reads instance_type from change.after on each resource change and fails when the value isn’t in the set. sprintf inserts both the rejected value and the full allowed set into the message.

 

package example

allowed_instance_types = {
  "t2.medium",
  "t2.large",
  "t2.xlarge"
}

deny[reason] {
  itype := input.tfplan.resource_changes[_].change.after["instance_type"]
  not allowed_instance_types[itype]

  reason = sprintf("instance_type %q is not accepted, use one of the allowed: %v", [itype, allowed_instance_types])
}

 

One edit makes it better: bind the resource change to a variable and add its address to the message. A plan with six instances then tells the developer which one broke the rule. Printing allowed values does expose internal standards in pipeline logs. That’s harmless for instance types and worth rethinking for CIDR ranges or approved AMI IDs.

 

 

Running the Organization’s Policy Locally Before the Pipeline Does

The pipeline is the slowest place to learn about a violation. Cycloid’s CLI runs the same check on a laptop. Save the plan with terraform plan -out=./plan, export it with terraform show -json ./plan > plan.json, then pass the file to cy infrapolicy validate –plan-path ./plan.json, which prints a count per severity level. Wired into a pre-commit hook, it moves the eleven-minute failure to the few seconds before git push.

 

Local runs only help if they evaluate the same rules the pipeline enforces. A Rego file copied onto a laptop in March is wrong by May, and a developer who passes locally and fails in CI stops trusting both checks.

 

 

Cost as Feedback Before Cost as a Deny Rule

A cost rule written as a hard deny blocks right-sized requests while the developer never saw a price before submitting. StackForms shows a live cost estimate from TerraCost that updates as parameters change, backed by cost estimation for any Terraform-defined infrastructure. Most over-provisioning corrects itself once the number is on screen. Save deny rules for hard ceilings, like the instance cap on an Auto Scaling group.

 

When using stackforms you are able to access an estimation of the current stack you are configuring. To do so, fill all the required variables and click “Estimate cost” on the right side.

 

Environment configuration form with the Estimate cost button highlighted in the right-hand panel

 

If any estimation was possible, it will then give you its output:

 

Up-to-date cost estimate panel showing the monthly and annual estimates in USD

 

Here you will also be able to dig into the details of that estimation, to see which parts represent each component or which got ignored:

 

Cost estimation details window listing the cost of each Terraform resource, with monthly and annual totals

 

If you happened to change anything related to the project, you’ll have to refresh the estimation just like you did the initial one:

 

Cost estimate panel marked as outdated after a form value changed, with an Update cost estimate button

 

At the end of the configuration, you will also have access to a summary of the overall estimation/change.

 

Review and run step with a cost overview per environment, dev and staging, and the monthly and annual totals

 

An estimate built from a plan can only price what the plan declares. Data egress and per-request charges depend on traffic, not configuration, so a database with a modest monthly estimate can still run up a bill through cross-region replication reads.

 

 

 

Testing OPA Terraform Policies Before They Block Every Deployment

A policy repository has a wider blast radius than any template in the catalog, because each enabled rule evaluates against every plan in the organization. A bug in a Terraform module breaks one stack, while a bug in a Rego rule breaks all of them at once.

 

 

The Default-Deny Trap Inside Allow Rules

Cycloid’s reference warns that allow and deny fail in opposite directions: allow fails when its result is false, and deny fails when its result is true. The max_size example from that page shows why the difference matters. default allow = false means the rule counts as false whenever its body doesn’t succeed. The body passes only if some resource change carries a max_size below 5.

 

package example

default allow = false

allow {
  input.tfplan.resource_changes[_].change.after["max_size"] < 5
}

 

Now run the Postgres request through it. That plan holds an aws_db_instance and no Auto Scaling group, so no resource change carries max_size. The body never succeeds, allow falls back to false, and a database request fails a rule written for autoscaling. The same rule also passes a plan where one group caps at 3 and another at 50, because [_] succeeds when any single element matches. This is our reading of a documentation example, not a reported bug, but hand-written Rego repeats the shape constantly.

 

Decision flowchart of an OPA allow rule: a resource change with no max_size, or a max_size of 5 or more, ends in allow = false

 

An allow rule with default allow = false can block unrelated resources when they don’t contain the attributes the rule expects. This happens when a policy checks an Auto Scaling attribute against a database resource. Follow the decision branches to see how missing attributes lead to an unintended denial, even when the resource doesn’t violate the original requirement.

 

The fix is to filter by resource type and write the rule as a deny that fires on the group exceeding the cap. deny rules fail open on unexpected input, though. If a provider upgrade renames the attribute, the rule silently stops firing. Pick the failure direction per rule, and keep fail-closed logic for the controls you’ve marked Critical.

 

 

Unit Tests Against Plans the Catalog Generates

Rules tested against hand-written JSON pass, then fail on the first plan a template produces, because generated plans carry computed values and nested module paths nobody typed by hand. Record terraform show -json output from every stack in the catalog as fixtures. Run opa test in the policy repository’s CI, and give each rule one fixture that should pass and one that should fail. Cycloid references opa test and the OPA Playground for quick assertions.

 

Fixtures go stale when providers change plan structure. Regenerate them on every provider version bump, and treat a fixture diff as a signal to re-read each rule touching those resource types.

 

 

Who Edits the Rego When Security Owns the Intent

A rule misfires at 4 p.m. on a Friday, security wrote it, platform engineering can’t merge a fix, and every deployment waits until Monday. The split that avoids this puts security in charge of policy intent, severity, and exception criteria while platform engineering implements the enforcement hooks. Cycloid manages InfraPolicies from a dedicated Security section of the console, which keeps ownership visible in the product itself.

 

Two-team review doubles the time to fix a false positive. Give the platform on-call the right to flip a misfiring rule’s status to disabled, which excludes it from validation, and have security review the change within one working day.

 

 

 

Measuring Whether Guardrails Are Pushing Developer Self-Service Off-Platform

Blocked-violation counts look like proof that the guardrails work, and they climb fastest when developers are most frustrated. The number that matters is how many requests stopped arriving through the platform at all.

 

 

Override Counts and Ticket Fallback as Early Signals

Watch two leading indicators. A rising Warning override count on a single rule says the rule is wrong for the work people do. Tickets asking the platform team for something the catalog already offers say a form or a rule is too tight to use. Cycloid’s Events feature tracks organization activity and is the natural place to count overrides.

 

A falling override count isn’t automatically good news. If request volume dropped over the same weeks, developers may have stopped using the platform, so read overrides against total requests, never alone.

 

 

Unmanaged Resources Show Where Developers Left the Platform

A resource that exists in the cloud account but belongs to no project marks a spot where someone routed around the guardrails. Cycloid’s Asset Inventory shows assets across projects and environments, and comparing it against the provider’s own resource list surfaces the strays.

 

Asset inventory filtered by resource type, listing a load balancer, an RDS database and an S3 bucket with their project and environment

 

Infra Import then generates infrastructure as code from those manually deployed resources. Importing codifies whatever misconfiguration the resource shipped with. Run the generated plan through your InfraPolicies at Advisory before adopting it, and expect the first run to be loud.

 

 

 

Policy as Code for Self-Service Platforms Is Judged by the Bypass Rate

Every rule in a self-service platform needs a home and a severity. Constraints expressible as allowed values belong in the form, where they cost nothing. Anything that has to hold for CLI and API paths belongs in the Terraform plan, and access control decides who submits rather than what. New rules start at Advisory, earn promotion with evidence, and ship with a deny message that names the fix. Test them against plans your catalog generates, because one bad Rego rule stalls every stack at once. Measure overrides and unmanaged resources, not violations blocked. A guardrail developers route around protects nothing.

 

This article followed one production Postgres request from a constrained parameter form to an OPA check on its plan JSON. Along the way, it covered where auto_complete and direct repo commits leak past the form, Critical, Warning, and Advisory grading with evidence-based promotion, and environment scoping on environment_canonical. It also covered sprintf deny messages, local cy infrapolicy validate runs, the default-deny trap in allow rules, fixture-based opa test suites, and the two bypass signals worth tracking.

 

 

 

FAQs

 

 

1. Do policy guardrails slow down developer self-service?

Evaluating a policy generally takes seconds; the delay comes from blanket blocking rules, violations discovered deep in a pipeline run, and exceptions negotiated by hand. Form constraints add no wait at all, and plan-level failures that show the rejected value and the allowed set let developers resubmit within minutes.

 

 

2. How do you handle exceptions when a policy blocks a legitimate deployment?

Put rules with known legitimate exceptions at a level that allows an audited override, like Cycloid’s Warning severity, and keep Critical for controls nobody should waive. Review overrides per rule on a fixed cadence. A rule that gets overridden more often than it holds needs rewriting.

 

 

3. How do you prove policy guardrails are working before apply?

Run every rule against recorded plan fixtures with opa test, including a plan that should fail. Confirm enforcement sits between plan and apply in every pipeline. Then compare your asset inventory against IaC-managed state, because resources outside any project never met the policy.

 

 

4. Should policy checks run in the self-service form or against the Terraform plan?

Run them in both places; form constraints catch bad input before a plan exists, but CLI and API requests never render a form. Rules that must always hold belong against the plan, with the form mirroring them so developers get feedback earlier.

 

Check our Comparisons: Cycloid vs Backstage / Cycloid vs Port / Cycloid vs Humanitec