Skip to Content
WorkshopsPolicy authoring (hosted + local dev)

Policy authoring workshop

Write your first overlay rule: a piece of your team’s knowledge, turned into signed Rego that every TruStacks proposal is judged against. The Coordinator drafts the Rego in chat. You review it, test it, and land it with the trustacks rule CLI. The agent does the syntax. You do the judgment.

⚠️

This workshop needs a local development environment. Landing a rule uses the trustacks rule CLI, which signs with a local key and publishes to the local registry, and works against local and self-hosted control planes. Set up with Local development first (make up, then make seed).

On the hosted product? Parts A, B and B2 work there: draft the rule in the Workspace chat, review it, and check what it would have blocked against your real recent proposals. Instead of Part C, click Propose as PR under the draft (runner 0.2.18) and merge the pull request it opens on your platform repo; the next run enforces the rule. See Add your own rule.

What you will build

A rule that requires every GitHub Actions workflow TruStacks proposes to set top-level permissions: explicitly, so jobs never run with the default token scope. The rule id is acme.requires_explicit_workflow_permissions. Substitute your own namespace for acme.

Rule ids are <namespace>.<name>: lowercase letters, digits and underscores, at most 64 characters. The namespaces argocd, constitution, posture, practice, proposal and trustacks are reserved for TruStacks.

Time required

About 30 minutes.

How overlay rules work

The overlay is a set of Rego rules that lives in your overlay repo, next to environment-profile.yaml. It is built into a bundle, signed with cosign, and loaded by the runner alongside the constitution. Your rules’ denies are added to the constitution’s, so a proposal must satisfy both. That is why an overlay can only make the gate stricter: it can add denies, never remove one. From runner 0.2.14 the runner enforces this: every overlay .rego file must be package overlay.<name>, and an overlay that reaches outside data.overlay gets no verdict at all. The full rules are in the constitution reference.

Each rule is two files:

rules/<rule_id>/<rule_id>.rego # package, rule_metadata, deny rules/<rule_id>/<rule_id>_test.rego # opa tests

Part A. Draft the rule with the Coordinator

  1. In the app, open Workspace in the sidebar (or Rules → + Draft a rule with the Coordinator).

  2. Type:

    /rule new acme.requires_explicit_workflow_permissions
  3. The Coordinator asks what the rule should deny. Describe it plainly:

    Deny any proposed file under .github/workflows/ that does not declare a top-level permissions: key. The message should name the file.

  4. The Coordinator narrates as it drafts, then shows the Rego in a block headed rules/acme.requires_explicit_workflow_permissions/acme.requires_explicit_workflow_permissions.rego with Copy and Download.

You can also start from prose (/rule new deny workflows without explicit permissions); the Coordinator proposes a rule id for you.

A draft is not live. It becomes a rule only when it is merged into your overlay: in this workshop, by landing it in Part C.

Part B. Review the draft

Read what the Coordinator produced. A good draft has:

  • a header comment, package overlay.acme_requires_explicit_workflow_permissions and import rego.v1;
  • a rule_metadata map keyed on the rule id that declares every required field: a description, kind: "structural" (it checks the shape of a workflow), required_tooling_categories: [] and practice_dimensions: [] (it only reads the proposal), and tier_scope and target_kinds of ["any"]. It may add "subjects": ["proposal", "repo"], so the repo audit also judges your service repo’s own workflows with it. Then rule_index := rule_metadata;
  • a deny contains msg if { … } block with a real condition, emitting {"rule_id": …, "message": …}.

The judgment questions are yours:

  • Does it check what you actually mean? (Top-level permissions:, not a permissions: nested inside a job.)
  • What about edge cases? A workflow that sets permissions: {} is explicit and should pass.
  • Is the message something a reviewer can act on?

The deny body for this rule should look roughly like this:

deny contains msg if { some f in input.proposal.files startswith(f.path, ".github/workflows/") not regex.match(`(?m)^permissions:`, f.content) msg := { "rule_id": "acme.requires_explicit_workflow_permissions", "message": sprintf("workflow %s must declare top-level permissions: explicitly", [f.path]), } }

Part B2. Check what it would have blocked

Under the finished draft, click Would it have blocked my recent proposals?

TruStacks replays your Application’s 10 most recent proposals through the policy engine, with the rules you have today and with your draft added, and lists each one as would be blocked (with your rule’s message) or passes this rule. The policy engine decides each line; the assistant isn’t involved.

The impact check under a rule draft: “This rule would have blocked N of your 10 most recent proposals”, each proposal listed by service and time as “would be blocked” or “passes this rule”.

Read the result as a review of your rule:

  • It blocks proposals you expected it to block. Good: that’s the gap the rule closes.
  • It blocks nothing, though you expected it to. The rule may not be looking where your changes are. When this workshop was written, a draft that defined a “production deploy job” as one particular deploy action blocked 0 of 10 proposals, because the service deploys a different way. Tighten the description and draft again.
  • It blocks everything. The rule may be too broad, or it may ask for something no proposal can contain. A rule must check what the gate can see: the proposed files, the target (cluster, tier, kind) and your EnvironmentProfile. A ticket number in another system isn’t something a proposal carries. Check for a change-approval step in the pipeline instead.

If the check refuses the draft, for example because its package isn’t package overlay.<name> and the gate would never consult it, the message says so. Fix the draft before landing it.

Part C. Land it with the CLI

  1. Clone your overlay repo. In local development it is the seeded overlay repo in Gitea:

    git clone http://gitea.localtest.me:8080/trustacks/overlay.git ~/overlay export OVERLAY=~/overlay

    The CLI checks that the directory has environment-profile.yaml at its root, which is how it recognizes an overlay repo. Run the trustacks commands below from the root of your trustacks-mvp checkout, passing --overlay "$OVERLAY". (rule sign looks for the local signing key in .policy-keys/ relative to where you run it, which is the repo root.)

  2. Scaffold the rule. This creates both files, with a test file wired to the right package:

    uv run --project runner trustacks rule new acme.requires_explicit_workflow_permissions \ --overlay "$OVERLAY" \ --description "Workflows must declare top-level permissions explicitly"

    The scaffolded rule’s condition is false, so it never fires. Replace the whole .rego file with the Coordinator’s draft (keep the package name overlay.acme_requires_explicit_workflow_permissions).

    ⚠️

    Keep every package under overlay.. A file whose package is anything else (package proposal, say) is refused by rule lint and rule sign, and the runner rejects the whole overlay. rule new --package refuses a package that does not start with overlay..

  3. Write the tests. The scaffold includes one passing baseline test. Add a negative and a positive case to the _test.rego file:

    test_denies_workflow_without_permissions if { bad := {"proposal": {"files": [{ "path": ".github/workflows/ci-api.yaml", "content": "name: ci\non:\n workflow_call:\njobs: {}\n", }]}} violations := acme_requires_explicit_workflow_permissions.deny with input as bad some v in violations v.rule_id == "acme.requires_explicit_workflow_permissions" } test_allows_workflow_with_permissions if { good := {"proposal": {"files": [{ "path": ".github/workflows/ci-api.yaml", "content": "name: ci\non:\n workflow_call:\npermissions:\n contents: read\njobs: {}\n", }]}} count(acme_requires_explicit_workflow_permissions.deny) == 0 with input as good }
  4. Test and lint:

    uv run --project runner trustacks rule test acme.requires_explicit_workflow_permissions --overlay "$OVERLAY" -v uv run --project runner trustacks rule lint acme.requires_explicit_workflow_permissions --overlay "$OVERLAY"

    rule test runs opa test on the rule. rule lint checks the rule’s structure: the directory name is a valid rule id, both files exist, the package and rule_metadata are present and name the rule id, opa check passes, the overlay is confined to data.overlay (the same check the runner makes before it evaluates an overlay), and rule_metadata declares every required field with values in the schema. Without OPA on your PATH, lint skips opa check, the package part of the confinement check and the metadata check.

  5. Sign and publish the overlay bundle:

    uv run --project runner trustacks rule sign --overlay "$OVERLAY"

    This builds the overlay bundle, pushes it to the local registry (zot.localtest.me:8080 by default) and signs it with the local development cosign key under .policy-keys/. Before building, it runs the confinement check and refuses to sign an overlay that reaches outside data.overlay. It does not run the rest of lint or your tests, so run those first. Note the bundle ref it prints, for example zot.localtest.me:8080/trustacks/policy/overlay/trustacks-mvp-local:dev (the name comes from your environment-profile.yaml).

  6. Commit and open a PR on the overlay repo, so the rule has a reviewed history like any other code. Explain why the rule exists, what it denies, and the edge cases it handles.

  7. Point the runner at the overlay so its init container pulls and verifies it. The local runner is installed with no overlay (policyBundle.overlayRef is empty), so the first time, set it. Inside the cluster the registry is zot.trustacks-system.svc.cluster.local:5000, so use that host with the repository and tag rule sign printed:

    helm upgrade runner charts/runner -n trustacks-system \ --reset-then-reuse-values \ --set policyBundle.overlayRef=zot.trustacks-system.svc.cluster.local:5000/trustacks/policy/overlay/trustacks-mvp-local:dev

    The upgrade restarts the runner. After later re-signs to the same tag, a restart is enough:

    kubectl -n trustacks-system rollout restart deployment runner-runner

Part D. Watch it decide

  1. Open Rules in the app. Filter by Overlay: your rule is listed with a Your overlay badge and its Rego source.
  2. Promote an analysis to a proposal (dry run is fine), as in the CI/CD generation workshop. If the emitted workflow has no top-level permissions:, the Constitution reports denied and cites acme.requires_explicit_workflow_permissions. No PR opens. (If the emitted workflow already declares top-level permissions, the rule passes, which is the rule working too.)
  3. On Overview, Policy in force counts the denial against your rule.

What just happened

You turned a piece of team knowledge into a rule that is:

  • reviewed, in a PR on your overlay repo;
  • tested, with positive and negative cases;
  • signed, and verified by the runner before it loads;
  • enforced on every proposal, with denials that cite it by id;
  • visible to everyone on the Rules screen.

The person who knew this rule no longer has to be the one who remembers it.

Where to go next

Last updated on