Policy as Code

Policy as Code (PaC) manages access policies the way software is managed. Policies are written in a declarative policy language, kept in version control, tested automatically and released through a pipeline to the decision points that enforce them, instead of being edited in an administration console. What an organization gains is control over change: every change to who may do what has an author, a reviewer and a test run, the decision points run a known version, and a bad change is undone by releasing the previous one. The price is that the repository becomes part of the access control system, and the right to merge into it is a right to grant access.

In the model that Policy-Based Access Control (PBAC) describes, Policy as Code is the policy administration point. PBAC settles what a policy says, and Policy as Code settles how it is written, checked and published. The languages differ in style. Open Policy Agent (OPA) evaluates Rego, a general query language over JSON input. Cedar is a smaller language built to be validated against a schema and analysed. The eXtensible Access Control Markup Language (XACML) is the older OASIS standard, often written in ALFA and compiled to XML. Applications that call the decision point through OpenID AuthZEN do not depend on which of them a bundle is written in. The same discipline reaches beyond application authorization: to the identity provider's sign-in and MFA rules, where its configuration can be exported and managed as code, and to Kubernetes admission rules that decide which service account a workload may run as and which role bindings may be created, which is where workload identity is governed on a cluster.

One Rule in Three Languages

The rule "a user may view a document they own, or any document if they hold the viewer role" reads as follows in each language. Each version is a text file, so a change to it can be diffed, reviewed and tested like any other code.

Open Policy Agent (Rego):

package documents

default allow = false

allow if {
    input.action == "view"
    input.resource.type == "document"
    input.subject.id == input.resource.owner
}

allow if {
    input.action == "view"
    input.resource.type == "document"
    "viewer" in input.subject.roles
}

Cedar:

permit (
    principal,
    action == Action::"view",
    resource
)
when {
    resource.type == "Document" &&
    (resource.owner == principal ||
     principal.roles.contains("viewer"))
};

XACML/ALFA (Abbreviated Language for Authorization):

policy ViewDocument {
    apply firstApplicable
    rule AllowOwner {
        permit
        condition resourceType == "Document"
            && action == "view"
            && subjectId == resourceOwner
    }
    rule AllowViewer {
        permit
        condition resourceType == "Document"
            && action == "view"
            && "viewer" in subjectRoles
    }
}

The Change History Is the Audit Evidence

User or workloadApplicationPolicy decision pointPolicy information pointDirectory, HR, device posturePolicy administration pointPolicy repositoryDecision logPolicy enforcement pointProtected resource access request, decisionallowedattributesreadspublishesdistributedevery decision
Policy as Code is a claim about one point in this picture: the place a policy is written and published is a repository and a pipeline rather than a console, and nothing else about the enforcement path changes.
Platform engineerVersion controlChange historyPolicy testsVersioned bundleDecision points a changewho, when, whyevery changeonly if greendistributed
What that buys is the ordinary discipline of code: a change has an author and a diff, it is tested before anyone can merge it, and what reaches the decision points is a versioned bundle, so a rollback is a redeploy and the question of which policy decided a request has an answer.

An access change made in a console leaves, at best, an entry in that console's audit log: who clicked save, and when. A change made through a repository leaves a record an auditor can work with. The commit shows what changed, line by line. The merge request says why, links the ticket or requirement that asked for it, and names the reviewer who approved it, who can be required to be someone other than the author. The pipeline run shows which tests passed before release, and the release records which version went to which decision points and when. Together these answer what a change-management control such as ISO/IEC 27001:2022 Annex A 8.32 asks, for every access rule the policy contains, without anyone collecting screenshots at audit time.

The history proves something only if it cannot be edited afterwards and only if it is tied to the decisions it produced. The branch that releases policy has to refuse rewritten history and direct pushes, so that a reviewed merge is the only way in. Each decision point should also record in its decision log the bundle revision it evaluated, as OPA does, so that a single permit can be traced from the log to the release and from the release to the commit and its approval. That chain answers the audit question PBAC raises, why a particular request was allowed, down to the person who approved the rule that allowed it.

Access reviews can use the same record. A reviewer certifying a policy reads the diff since the last review and the tests that describe what the policy is meant to allow and refuse. That is easier to judge than a list of thousands of entitlements, provided the tests are named so that someone outside the engineering team can read them.

Testing a Policy Before It Decides

A policy is tested like code, with cases that state an input and the expected decision. In OPA the tests are Rego rules themselves, run by opa test:

package documents_test

import data.documents

test_owner_can_view if {
    documents.allow with input as {
        "action": "view",
        "resource": {"type": "document", "owner": "alice"},
        "subject": {"id": "alice", "roles": []}
    }
}

test_other_user_cannot_view if {
    not documents.allow with input as {
        "action": "view",
        "resource": {"type": "document", "owner": "alice"},
        "subject": {"id": "bob", "roles": ["editor"]}
    }
}

The deny cases carry more weight than the permit cases. A rule that grants too much passes every test showing that a legitimate user gets in, and only a test showing that a stranger, a leaver or a user from the wrong tenant is refused will catch it. Every rule needs both kinds, and every incident in which a policy permitted something it should not have should end as a new deny case, so that the same mistake cannot be released twice.

Coverage shows where the tests stop. OPA reports which lines of a policy a test run evaluated (opa test --coverage), and a rule no test reaches is a rule nobody has checked. Coverage does not prove a policy right, because a line can be evaluated without its outcome being asserted, but a figure that drops with a merge request is a reason to hold it. Languages built for analysis catch another class of error before any test runs: Cedar validates policies against a schema of entity types and actions, which rejects a rule that refers to an attribute that does not exist, a mistake that would otherwise show up only as a rule that never applies.

Tests check the cases someone thought of. Before release, a new bundle can also be evaluated against a sample of recent requests from the decision log, once with the old version and once with the new, and every decision that changes listed. That list states whose access the merge request adds or removes, and it is what the reviewer should approve.

Drift Between the Repository and the Decision Points

The repository describes the policy an organization means to enforce. The decision points enforce whatever they have loaded, and the two diverge more often than the pipeline suggests. A decision point that cannot reach the bundle server keeps evaluating the last bundle it received, which is right for availability and means a withdrawn rule keeps deciding in that location for as long as the outage lasts. An engine that also accepts policies through its own management API can be changed by anyone who reaches that API, without review; OPA's REST API accepts policy uploads unless it is locked down. A product that imports policy from the repository and also lets administrators edit it in a console holds two sources of truth, and only one of them is reviewed.

Drift is found by comparison and prevented by removing the other ways in. Each decision point reports the revision it is running, through OPA's status API or the engine's equivalent, and monitoring compares that with the revision the pipeline last released; a mismatch that outlasts the rollout is an alert. Decision points load only signed bundles, so a policy that did not come through the pipeline is refused, and the direct-write API and console editing are switched off in production. A change that cannot wait for review goes through an emergency path that still ends in the repository, with the review done afterwards, so that it is neither lost nor silently reverted by the next release. For identity provider configuration managed as code, the same comparison against the live tenant is what Identity Security Posture Management (ISPM) runs continuously.

Merge Rights Are Privileged Access

Once policies are released from a repository, whoever can merge into its release branch can grant access in every application that asks the decision points, without an access request, an approval workflow or an entry in the governance system. Merge rights on the policy repository are therefore privileged access and belong under the same governance: a small named group, reviewed on the same cycle as administrative roles, with a second person's approval required on every change and the owning team's approval required on the policies of its application.

Merge rights are not the only way in. Repository administrators can change the branch protection that enforces review, so their accounts need the controls of any administrator, ideally granted just in time as Zero Standing Privileges describes. The pipeline holds the key that signs bundles and the credentials that publish them, which belong in secret management rather than in a variable every pipeline author can read, and whoever can edit the pipeline definition can make it skip the tests. A merge without the required approval, a change to branch protection, or a bundle published outside the pipeline is a signal for Identity Threat Detection and Response.

Separation of duties applies to the authors as well. An engineer who can write the rule that decides access to the finance application should not be able to approve it alone, and the owner of that application, who is accountable for its access, should approve changes to its policies even without reading Rego. Tests whose names state what the policy allows and refuses are what make that approval more than a signature.