Gating Terraform with Cedarling-OPA#
Not sure which flow to use?
See the Terraform authorization overview for a side-by-side comparison of the unsigned and JWT flows before diving into implementation details.
This guide walks through a concrete use case for the Cedarling-OPA plugin: enforcing role-based access control on Terraform operations before any infrastructure change is made.
The Problem#
Infrastructure teams often need to answer questions like:
- Can this engineer run
terraform applyagainst the production environment? - Should a CI job be allowed to destroy a staging workspace?
- How do we audit who approved an infrastructure change and why?
Traditional solutions rely on cloud-provider IAM policies, custom CI guards, or honor-system conventions. The Cedarling-OPA integration lets you express these rules as Cedar policies — auditable, version-controlled, and evaluated in milliseconds before terraform ever runs.
Choosing a Flow: Unsigned vs JWT#
Two Terraform authorization demos are available. Use this table to decide which fits your situation before diving into the implementation details.
| Unsigned (this guide) | JWT guide | |
|---|---|---|
| Identity source | Environment variables asserted by the caller | Signed GitHub Actions OIDC token |
| Signature validation | None — CEDARLING_JWT_SIG_VALIDATION: "disabled" |
Cryptographic — Cedarling fetches GitHub's JWKS and verifies every token |
| Trusted issuer config | Not required — no trusted-issuers/ directory needed |
Required — policy-store/trusted-issuers/github-actions.json must declare the issuer endpoint |
| Principal model | Infra::User with asserted role attribute |
CI::GitHubWorkflow with verified JWT claims (repository, ref, environment) |
| Cedarling built-in | authorize_unsigned |
authorize_multi_issuer |
| Secret management | Requires TF_USER_ID / TF_USER_ROLES set by the operator |
No secrets — GitHub issues the OIDC token automatically |
| Production approval gate | Role-based policy only | environment JWT claim proves GitHub Environment approval by a human reviewer |
| Best suited for | Local development, human operators, simple setups | CI/CD pipelines (GitHub Actions), automated deployments requiring cryptographic identity |
Choose the unsigned flow if you are running Terraform locally or in a simple script where the caller's identity is trusted by convention and you do not need cryptographic proof of origin.
Choose the JWT flow if you are deploying from a CI/CD pipeline and want Cedarling to verify the pipeline's identity cryptographically — eliminating the need for shared secrets and enabling enforceable production approval gates.
Authorization Model#
The demo uses three roles and three workspaces:
| Role | terraform plan |
terraform apply |
terraform destroy |
|---|---|---|---|
| Developer | ✓ | ✗ | ✗ |
| Ops | ✓ (non-prod only) | ✓ (non-prod only) | ✗ |
| Admin | ✓ | ✓ | ✓ |
Workspaces: dev, staging, production.
sequenceDiagram
participant Eng as Engineer / CI
participant Wrapper as tf_authz.sh
participant OPA
participant Rego
participant Cedarling
participant Cedar as Cedar Policies
Eng->>Wrapper: terraform apply
Wrapper->>OPA: POST /v1/data/infra/terraform
OPA->>Rego: evaluate infra.terraform
Rego->>Cedarling: cedarling.opa.authorize_unsigned(input)
Cedarling->>Cedar: check principal / action / resource
Cedar-->>Cedarling: decision + matching policy IDs
Cedarling-->>Rego: { decision, reasons, errors }
Rego-->>OPA: { allow, reasons }
OPA-->>Wrapper: HTTP 200 { result: { allow: true|false } }
alt ALLOWED
Wrapper->>Eng: terraform apply proceeds
else DENIED
Wrapper->>Eng: exit 1 + deny message
end
Cedar Schema#
The schema defines three entity types and three actions:
namespace Infra {
// Role entity kept for schema validity; role membership is enforced via
// principal.role.contains("RoleName") in Cedar policies.
entity Role;
entity User in [Role] {
sub: String,
role: Set<String>,
};
entity TerraformWorkspace;
action Plan, Apply, Destroy
appliesTo {
principal: [User],
resource: [TerraformWorkspace],
context: {
current_time: Long,
}
};
}
The role: Set<String> attribute is the key: when the wrapper asserts role: ["Ops"], Cedarling sets the role attribute on the User entity, and Cedar policies check principal.role.contains("Ops") to match.
Cedar Policies#
Three policy files cover all the rules.
Admin — unrestricted access:
@id("admin-permit-all")
permit (
principal,
action in [
Infra::Action::"Plan",
Infra::Action::"Apply",
Infra::Action::"Destroy"
],
resource
) when {
principal.role.contains("Admin")
};
Ops — plan and apply, but not production:
@id("ops-permit-plan-apply-non-prod")
permit (
principal,
action in [
Infra::Action::"Plan",
Infra::Action::"Apply"
],
resource
) when {
principal.role.contains("Ops") &&
resource != Infra::TerraformWorkspace::"production"
};
Developer — plan only, any workspace:
@id("developer-permit-plan")
permit (
principal,
action == Infra::Action::"Plan",
resource
) when {
principal.role.contains("Developer")
};
Cedar's default-deny posture means any combination not covered by a permit is automatically rejected — no forbid rules needed for the common case.
OPA Rego Policy#
The Rego policy is a thin adapter. It passes the entire input to Cedarling and exposes two rules for consumers:
package infra.terraform
default allow := false
result := cedarling.opa.authorize_unsigned(input)
allow if {
result.decision == true
}
# decision mirrors result.decision for per-rule access via
# /v1/data/infra/terraform/decision
decision := result.decision
# reasons contains the IDs of Cedar policies that granted the request.
reasons := result.reasons
The cedarling.opa.authorize_unsigned built-in is the right choice here because Terraform operations are service-initiated — the operator's identity is known by the calling system and asserted directly, rather than arriving via a JWT from an IDP.
OPA Configuration#
{
"plugins": {
"cedarling_opa": {
"stderr": false,
"bootstrap_config": {
"CEDARLING_APPLICATION_NAME": "TerraformAuthz",
"CEDARLING_USER_AUTHZ": "enabled",
"CEDARLING_WORKLOAD_AUTHZ": "disabled",
"CEDARLING_JWT_SIG_VALIDATION": "disabled",
"CEDARLING_JWT_STATUS_VALIDATION": "disabled",
"CEDARLING_ID_TOKEN_TRUST_MODE": "never",
"CEDARLING_LOG_TYPE": "std_out",
"CEDARLING_LOG_TTL": 60,
"CEDARLING_LOG_LEVEL": "INFO",
"CEDARLING_JWT_SIGNATURE_ALGORITHMS_SUPPORTED": ["HS256", "RS256"],
"CEDARLING_POLICY_STORE_LOCAL_FN": "./policy-store"
}
}
}
}
CEDARLING_POLICY_STORE_LOCAL_FN points to a directory containing metadata.json, schema.cedarschema, and a policies/ subdirectory — the directory-based policy store format.
No trusted issuer file needed#
The JWT-based demo (terraform-authz-jwt.md) requires a policy-store/trusted-issuers/ directory so Cedarling can fetch and validate the signing keys for the incoming OIDC token. This unsigned demo does not need that directory because:
CEDARLING_JWT_SIG_VALIDATION: "disabled"— no signature is checked.CEDARLING_ID_TOKEN_TRUST_MODE: "never"— Cedarling never tries to resolve an issuer from an ID token.- Identity is asserted directly in the
authorize_unsignedpayload (via theprincipalobject in the OPAinput) rather than carried inside a signed JWT.
The policy-store directory for this demo therefore contains only metadata.json, schema.cedarschema, and policies/. If you later switch to signed tokens, add a trusted-issuers/ subdirectory with one JSON file per issuer — see the trusted issuer file format in the JWT guide for the exact schema and field reference.
The Authorization Wrapper#
tf_authz.sh is a drop-in shim around the terraform binary. It:
- Maps the Terraform sub-command (
plan/apply/destroy) to the corresponding Cedar action. - Builds the
authorize_unsignedpayload from environment variables. - POSTs to OPA and inspects the
allowfield. - Either calls
terraformor exits with a denial message.
Environment variables#
| Variable | Required | Default | Description |
|---|---|---|---|
TF_WORKSPACE |
yes | dev |
Target workspace (dev, staging, production) |
TF_USER_ID |
yes | $USER |
Operator identity (username or service account) |
TF_USER_ROLES |
yes | Developer |
Comma-separated Cedar role names |
OPA_URL |
no | http://localhost:8181 |
OPA server base URL |
TERRAFORM_BIN |
no | terraform |
Path to the terraform binary |
Usage#
export TF_WORKSPACE=staging
export TF_USER_ID=alice
export TF_USER_ROLES=Developer
# Plan is allowed for Developers on any workspace.
# Terraform global flags (e.g. -chdir=) may appear before the sub-command:
./demo/terraform/tf_authz.sh -chdir=./environments/staging plan
# Or after it:
./demo/terraform/tf_authz.sh plan -chdir=./environments/staging
# Apply is denied for Developers:
./demo/terraform/tf_authz.sh apply -auto-approve
The wrapper scans all arguments for the first non-flag token to determine the sub-command, so terraform global flags (like -chdir=, -version, -help) placed before the sub-command are handled correctly and do not bypass the authorization check.
Commands other than plan, apply, and destroy (e.g. init, fmt, validate) bypass the authorization check and are passed directly to terraform.
Example OPA Query and Response#
The wrapper queries /v1/data/infra/terraform — the full package endpoint — rather than the narrower /v1/data/infra/terraform/allow. This returns the complete Rego package result in a single round-trip, making allow, decision, reasons, and the full Cedarling result object all available for inspection or logging. If you only need the boolean decision, you can query /v1/data/infra/terraform/allow instead.
The wrapper sends:
curl -X POST http://localhost:8181/v1/data/infra/terraform \
-H "Content-Type: application/json" \
-d '{
"input": {
"principal": {
"cedar_entity_mapping": {
"entity_type": "Infra::User",
"id": "alice"
},
"sub": "alice",
"role": ["Developer"]
},
"action": "Infra::Action::\"Apply\"",
"resource": {
"cedar_entity_mapping": {
"entity_type": "Infra::TerraformWorkspace",
"id": "production"
}
},
"context": {
"current_time": 1776826458
}
}
}'
Response (denied — no matching Cedar policy):
{
"result": {
"allow": false,
"reasons": [],
"result": {
"decision": false,
"errors": [],
"reasons": [],
"request_id": "019dadff-3a21-7e0d-a4fc-cce7e05ef2c1"
}
}
}
Now with an Ops user applying to staging:
-d '{
"input": {
"principal": {
"cedar_entity_mapping": { "entity_type": "Infra::User", "id": "bob" },
"sub": "bob",
"role": ["Ops"]
},
"action": "Infra::Action::\"Apply\"",
"resource": {
"cedar_entity_mapping": { "entity_type": "Infra::TerraformWorkspace", "id": "staging" }
},
"context": { "current_time": 1776826458 }
}
}'
Response (allowed — matched ops-permit-plan-apply-non-prod):
{
"result": {
"allow": true,
"reasons": ["ops-permit-plan-apply-non-prod"],
"result": {
"decision": true,
"errors": [],
"reasons": ["ops-permit-plan-apply-non-prod"],
"request_id": "019dae00-1c32-7a1d-b5fc-dde8f06ef3d2"
}
}
}
The reasons array tells you exactly which Cedar policy granted access — useful for audit logs.
Running the Demo#
See the demo README for complete step-by-step instructions including:
- Starting the Cedarling-OPA server
- Running all authorization scenarios
- Querying OPA directly with
curl - Adding custom policies
Adapting to Your Environment#
Integration with CI/CD#
In a CI pipeline (GitHub Actions, GitLab CI, Jenkins), set the environment variables from your pipeline secrets and replace the terraform call with ./tf_authz.sh:
- name: Terraform apply
env:
TF_WORKSPACE: ${{ vars.TF_WORKSPACE }}
TF_USER_ID: ${{ github.actor }}
TF_USER_ROLES: ${{ vars.CI_TERRAFORM_ROLES }}
OPA_URL: http://opa-service:8181
run: ./tf_authz.sh apply -auto-approve
For a more secure CI/CD integration that eliminates service-account secrets entirely, see the JWT-based Terraform authorization demo. That variant authenticates the pipeline with a signed GitHub Actions OIDC token, and Cedar policies check JWT claims such as repository, ref, and environment to gate access — including a production approval gate backed by GitHub Environments.
Using JWT tokens instead#
If your operators authenticate via an OIDC provider, switch from cedarling.opa.authorize_unsigned to cedarling.opa.authorize_multi_issuer and pass the access token in the payload. Update the Rego to match:
result := cedarling.opa.authorize_multi_issuer(input)
See the JWT-based Terraform authorization guide for a complete working example with GitHub Actions OIDC, Cedar schemas, and a wrapper script. For the full list of bootstrap properties see the Cedarling-OPA plugin reference.
Auditing decisions#
Cedarling emits structured logs for every authorization decision. Set CEDARLING_LOG_LEVEL to INFO or DEBUG to capture the principal, action, resource, decision, and matching policy IDs in your log pipeline.