Enterprise Policies, Allowed Actions, Runner Governance, and Auditability: Guided Hands-On Workflow
Use a disposable local policy fixture to inspect, evaluate and explain allowed-action, token, fork, runner-group and exception controls without mutating an enterprise.
Learning objectives
- Create and hash a local Actions governance policy fixture.
- Evaluate safe and unsafe sample workflows without an Enterprise account.
- Preserve denial evidence and repair the workflow instead of weakening policy.
- Perform optional read-only organization inspection safely.
- Build a reproducible evidence packet with a time-bounded exception.
1. Lab goal and safety boundary
This lab models an organization called AtlasWorks with
three fictional repositories: web-api,
payments and docs. You will not create an
organization, edit enterprise policy, register a runner or expose a
real token. Instead, a local evaluator will read a policy document
plus sample workflow manifests and produce the same kind of
allow/deny reasoning an administrator should perform before touching
live settings.
The lab is intentionally split into two modes. Mandatory mode is completely local and free. Optional inspection mode uses read-only GitHub settings/API calls only if you already administer an authorized organization. Mutation commands are deliberately omitted because this chapter is about policy reasoning, not practicing dangerous account-wide writes.
2. Preflight and assumptions
- Python 3.11+ and Git are sufficient for the mandatory path.
-
Use a new temporary directory such as
gha-policy-lab; never point the evaluator at proprietary workflows unless authorized. - All identities, runner names and exceptions are synthetic.
-
If you use optional GitHub API inspection, examples assume API
version
2026-03-10and an identity already authorized to read organization settings. -
Executable workflow snippets pin
actions/checkoutv7.0.1 to3d3c42e5aac5ba805825da76410c181273ba90b1.
mkdir -p gha-policy-lab/{policy,workflows,evidence}
cd gha-policy-lab
python --version
git --version
3. Create the policy snapshot before testing workflows
Save the policy as evidence first. This makes the control revision explicit and hashable. The evaluator will never silently fetch “whatever the organization currently says,” because that would make later evidence impossible to reproduce.
{
"owner": "AtlasWorks Security Engineering",
"scope": "organization",
"actions": {
"mode": "selected",
"require_full_sha": true,
"allow": [
"actions/checkout",
"actions/upload-artifact",
"atlasworks/platform-ci"
]
},
"token": {
"default": "restricted",
"forbid_write_all": true
},
"forks": {
"send_write_token": false,
"send_secrets": false,
"require_approval_for_untrusted": true
},
"runner_groups": {
"standard-hosted": {"repos": ["*"]},
"prod-deploy": {
"repos": ["payments"],
"workflows": ["deploy.yml"],
"public_repositories": false
}
},
"exceptions": {
"require_owner": true,
"require_expiry": true,
"max_days": 14
}
}
cat > policy/atlasworks-policy.json <<'JSON'
{
"owner": "AtlasWorks Security Engineering",
"scope": "organization",
"actions": {
"mode": "selected",
"require_full_sha": true,
"allow": [
"actions/checkout",
"actions/upload-artifact",
"atlasworks/platform-ci"
]
},
"token": {
"default": "restricted",
"forbid_write_all": true
},
"forks": {
"send_write_token": false,
"send_secrets": false,
"require_approval_for_untrusted": true
},
"runner_groups": {
"standard-hosted": {"repos": ["*"]},
"prod-deploy": {
"repos": ["payments"],
"workflows": ["deploy.yml"],
"public_repositories": false
}
},
"exceptions": {
"require_owner": true,
"require_expiry": true,
"max_days": 14
}
}
JSON
sha256sum policy/atlasworks-policy.json | tee evidence/policy.sha256
The digest is the policy revision identifier for the lab. If you later change the standard, create a new digest rather than overwriting the evidence and pretending the old workflow was evaluated under the new rule.
4. Create three sample workflows with different policy outcomes
The first workflow should pass. The second uses an unapproved mutable external action. The third tries to route an ordinary pull-request job to the restricted production deployment group. These are policy tests, not live workflows.
name: safe-ci
on: [push]
permissions: {}
jobs:
test:
permissions:
contents: read
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: echo "safe"
name: mutable-action
on: [push]
permissions: {}
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: vendor/scan-action@v4
- run: echo "scan"
name: wrong-runner-boundary
on: [pull_request]
permissions: {}
jobs:
test:
runs-on:
group: prod-deploy
steps:
- run: echo "PR code should not reach prod-deploy"
cat > workflows/safe-ci.yml <<'YAML'
name: safe-ci
on: [push]
permissions: {}
jobs:
test:
permissions:
contents: read
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: echo "safe"
YAML
cat > workflows/mutable-action.yml <<'YAML'
name: mutable-action
on: [push]
permissions: {}
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: vendor/scan-action@v4
- run: echo "scan"
YAML
cat > workflows/wrong-runner.yml <<'YAML'
name: wrong-runner-boundary
on: [pull_request]
permissions: {}
jobs:
test:
runs-on:
group: prod-deploy
steps:
- run: echo "PR code should not reach prod-deploy"
YAML
5. Evaluate policy locally and preserve the denial evidence
The evaluator is intentionally small and incomplete; it is a teaching fixture, not a replacement for GitHub’s parser or enterprise control plane. Its value is that each denial names the governed state that failed: dependency allowlist, immutable reference, token authority or runner boundary.
from pathlib import Path
import json, re, sys, datetime
policy=json.loads(Path("policy/atlasworks-policy.json").read_text())
sha_re=re.compile(r"^[0-9a-fA-F]{40}$")
def scan(path: Path):
text=path.read_text()
findings=[]
if "write-all" in text:
findings.append(("DENY","token.write-all","write-all is forbidden"))
for line in text.splitlines():
s=line.strip()
if not s.startswith("- uses:"):
continue
ref=s.split("uses:",1)[1].strip().split()[0]
if ref.startswith("./") or ref.startswith("$/"):
continue
if "@" not in ref:
findings.append(("DENY","action.ref","external action has no ref")); continue
repo, rev=ref.rsplit("@",1)
if repo not in policy["actions"]["allow"]:
findings.append(("DENY","action.allowlist",f"{repo} is not allowed"))
if policy["actions"]["require_full_sha"] and not sha_re.match(rev):
findings.append(("DENY","action.sha",f"{repo} is not pinned to a full SHA"))
if "group: prod-deploy" in text:
if path.name != "deploy.yml":
findings.append(("DENY","runner.workflow","prod-deploy requires deploy.yml"))
if "pull_request" in text:
findings.append(("DENY","runner.trust","pull_request job cannot use prod-deploy"))
return findings
rows=[]
for p in sorted(Path("workflows").glob("*.yml")):
fs=scan(p)
rows.append({"workflow":p.name,"decision":"DENY" if fs else "ALLOW","findings":fs})
print(json.dumps(rows,indent=2))
if any(r["decision"]=="DENY" for r in rows):
sys.exit(2)
cat > evaluate_policy.py <<'PY'
from pathlib import Path
import json, re, sys, datetime
policy=json.loads(Path("policy/atlasworks-policy.json").read_text())
sha_re=re.compile(r"^[0-9a-fA-F]{40}$")
def scan(path: Path):
text=path.read_text()
findings=[]
if "write-all" in text:
findings.append(("DENY","token.write-all","write-all is forbidden"))
for line in text.splitlines():
s=line.strip()
if not s.startswith("- uses:"):
continue
ref=s.split("uses:",1)[1].strip().split()[0]
if ref.startswith("./") or ref.startswith("$/"):
continue
if "@" not in ref:
findings.append(("DENY","action.ref","external action has no ref")); continue
repo, rev=ref.rsplit("@",1)
if repo not in policy["actions"]["allow"]:
findings.append(("DENY","action.allowlist",f"{repo} is not allowed"))
if policy["actions"]["require_full_sha"] and not sha_re.match(rev):
findings.append(("DENY","action.sha",f"{repo} is not pinned to a full SHA"))
if "group: prod-deploy" in text:
if path.name != "deploy.yml":
findings.append(("DENY","runner.workflow","prod-deploy requires deploy.yml"))
if "pull_request" in text:
findings.append(("DENY","runner.trust","pull_request job cannot use prod-deploy"))
return findings
rows=[]
for p in sorted(Path("workflows").glob("*.yml")):
fs=scan(p)
rows.append({"workflow":p.name,"decision":"DENY" if fs else "ALLOW","findings":fs})
print(json.dumps(rows,indent=2))
if any(r["decision"]=="DENY" for r in rows):
sys.exit(2)
PY
set +e
python evaluate_policy.py > evidence/first-evaluation.json
rc=$?
set -e
printf 'evaluator_exit=%s
' "$rc" | tee evidence/first-evaluation.exit
cat evidence/first-evaluation.json
An exit code of 2 is expected because two sample
workflows violate policy. Preserve this first evaluation. Do not
“fix the test” by deleting the denied samples; they are evidence
that the standard detects the intended failure modes.
6. Apply the least destructive corrections
For the mutable external action, the safest correction is not to
broaden the allowlist automatically. Either remove the dependency,
replace it with an already-approved capability, or run the action
through the organization’s review process and then pin the exact
approved commit. For the runner violation, keep ordinary PR
validation on a GitHub-hosted runner and reserve
prod-deploy for the selected deployment workflow.
# Corrected CI dependency boundary
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: ./scripts/local-scan.sh
# Corrected privileged runner boundary belongs in deploy.yml
jobs:
deploy:
if: github.ref == 'refs/heads/main'
runs-on:
group: prod-deploy
permissions: {}
steps:
- run: echo "fictional protected deployment"
Notice that neither correction weakens the global standard. The workflow is adapted to the policy, or the policy review process evaluates a narrowly justified new dependency.
7. Optional read-only inspection on an authorized organization
If you administer a disposable or non-production organization,
compare the local policy to GitHub’s real settings using read-only
calls. Never add a broad PAT merely because an endpoint returns
403. Record that denial and identify the required
administrator role or token scope.
ORG=your-authorized-org
gh auth status
gh api -H "X-GitHub-Api-Version: 2026-03-10" \
"/orgs/$ORG/actions/permissions/selected-actions" \
| tee evidence/github-selected-actions.json
# Inspect in the web UI as needed:
# Settings -> Actions -> General
# Settings -> Actions -> Runner groups
# Organization audit log (if your plan/role exposes the required view/API)
Treat these captures as point-in-time evidence. If policy changes after the capture, the old JSON remains useful because it explains the state under which a historical workflow was evaluated.
8. Add one justified time-bounded exception
Assume payments needs a reviewed external action for a
seven-day migration. The exception must not become a permanent
wildcard. Record exact scope and expiry, then have the evaluator or
human review treat it as a separate approval input rather than
editing the baseline silently.
{
"exception_id": "EX-2026-0910-01",
"repository": "payments",
"workflow": "migration.yml",
"control": "actions.allowlist",
"requested_dependency": "vendor/migration-action@0123456789abcdef0123456789abcdef01234567",
"risk_owner": "payments-platform-owner",
"reason": "seven-day synthetic migration exercise",
"approved_at": "2026-09-10T00:00:00Z",
"expires_at": "2026-09-17T00:00:00Z",
"compensating_controls": ["full SHA", "contents:read only", "GitHub-hosted runner"],
"removal_check": "dependency removed after migration"
}
A reviewer can now distinguish baseline policy from temporary authorization. If the expiry passes, the correct state is deny until a new approval exists—not silent extension.
9. Build the evidence packet
A minimal governance evidence packet should contain the policy digest, sample workflow hashes, first denial output, repaired output, exception record, platform assumptions and—if available—read-only GitHub setting/audit snapshots. Store fake lab evidence locally or upload it only from a disposable repository.
sha256sum workflows/*.yml > evidence/workflows.sha256
printf '%s
' 'api_version=2026-03-10' 'mandatory_path=local_simulation' 'live_enterprise_mutations=none' > evidence/assumptions.txt
find evidence -maxdepth 1 -type f -print -exec sha256sum {} \;
10. Challenge: choose the correct control layer
A team says its deployment job needs access to a privileged runner but the workflow is not on the runner group’s selected-workflow list. Choose the correct layer to change. The answer is not to rename labels or add a broader token. Either the authorized runner-group owner adds the exact reviewed workflow to the group, or the job stays on a non-privileged runner. Record the decision and evidence as governance state.
11. Lesson summary
You practiced governance without requiring an enterprise subscription: snapshot a policy, hash it, evaluate exact workflows, preserve denial evidence, repair the workflow instead of weakening the policy, model one narrow exception and separate optional live inspection from mandatory learning. Lesson 3 turns those mechanics into design decisions for a production platform.
Knowledge check
Why did the lab hash the policy file before evaluating workflows?
So the decision can be tied to an exact policy revision instead of an unnamed moving setting.
The evaluator denies vendor/scan-action@v4. What
are the two distinct reasons?
The repository is not on the allowlist, and the reference is mutable instead of a full 40-character commit SHA.
Should a 403 from an organization settings API be
“fixed” by immediately using a broader PAT?
No. Preserve the denial, verify the required role/scope, and obtain least-privilege authorization only if the inspection is actually necessary.
What is the safer response when a PR workflow targets a production runner group?
Keep untrusted validation on an isolated hosted/low-trust runner; reserve the production group for explicitly reviewed workflows and repositories.
Why is an exception stored separately from the baseline policy?
It preserves the stable control standard and makes temporary scope, owner, justification and expiry independently auditable.
Official references and version notes
- Enforcing policies for GitHub Actions in your enterprise — Current enterprise policy options, selected-action rules and full-SHA enforcement.
- Managing GitHub Actions settings for a repository — Repository-level action policy, token defaults and fork settings, including inheritance limits.
- Managing access to self-hosted runners using groups — Runner-group organization/repository/workflow access and public-repository warnings.
- Reviewing the audit log for your organization — Audit-log search/export/API boundaries and retention guidance.
- Secure use reference — Least privilege, untrusted input, third-party action and runner security guidance.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.