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. There is no supported way yet to deliver your own overlay bundle
to a hosted runner. Set up with Local development
first (make up, then make seed).
On the hosted product? You can still do Part A (drafting in the Activity chat) and Part B’s review and tests on your own machine. The rule will not run on your hosted runner yet.
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 proposal, argocd,
constitution 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.
Each rule is two files:
rules/<rule_id>/<rule_id>.rego # package, rule_metadata, deny
rules/<rule_id>/<rule_id>_test.rego # opa testsPart A. Draft the rule with the Coordinator
-
In the app, open the Activity chat: Rules → + Draft a rule with the Coordinator (or go to
#/activity). -
Type:
/rule new acme.requires_explicit_workflow_permissions -
The Coordinator asks what the rule should deny. Describe it plainly:
Deny any proposed file under
.github/workflows/that does not declare a top-levelpermissions:key. The message should name the file. -
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.regowith Copy and Download.
You can also start from prose (/rule new deny workflows without explicit permissions); the Coordinator proposes a rule id for you.
Drafts are ephemeral. The draft is not saved anywhere and does not go live. It becomes a rule only when you land 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_permissionsandimport rego.v1; - a
rule_metadatamap keyed on the rule id, with adescriptionandrequired_tooling_categories(empty here, because the rule only reads the proposal), andrule_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 apermissions: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 C. Land it with the CLI
-
Clone your overlay repo. In local development it is the seeded
overlayrepo in Gitea:git clone http://gitea.localtest.me:8080/trustacks/overlay.git ~/overlay export OVERLAY=~/overlayThe CLI checks that the directory has
environment-profile.yamlat its root, which is how it recognizes an overlay repo. Run thetrustackscommands below from the root of yourtrustacks-mvpcheckout, passing--overlay "$OVERLAY". (rule signlooks for the local signing key in.policy-keys/relative to where you run it, which is the repo root.) -
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.regofile with the Coordinator’s draft (keep the package nameoverlay.acme_requires_explicit_workflow_permissions). -
Write the tests. The scaffold includes one passing baseline test. Add a negative and a positive case to the
_test.regofile: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 } -
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 testrunsopa teston the rule.rule lintchecks the rule’s structure: the directory name is a valid rule id, both files exist, thepackageandrule_metadataare present and name the rule id, andopa checkpasses. Lint does not prove anything about how the rule relates to the constitution; the “stricter, never looser” property comes from how denies are combined, not from a linter. -
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:8080by default) and signs it with the local development cosign key under.policy-keys/. It does not run lint, so run lint first. -
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.
-
Restart the runner so its init container pulls and verifies the new bundle. In local development:
kubectl -n trustacks-system rollout restart deployment runner-runner
Part D. Watch it decide
- Open Rules in the app. Filter by Overlay: your rule is listed with a Your overlay badge and its Rego source.
- 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 citesacme.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.) - 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
- Rule CLI reference: every
trustacks ruleoption. - Rules and Workspace and Activity chat: the screens used here.
- Constitution reference: the rules your overlay adds to.
- CI/CD generation workshop