Chapter 34Lesson 02~285 minutes

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.

Policy labLeast privilegeRead-only inspectionExceptionsEvidence

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-10 and an identity already authorized to read organization settings.
  • Executable workflow snippets pin actions/checkout v7.0.1 to 3d3c42e5aac5ba805825da76410c181273ba90b1.
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.

Next lesson

Enterprise Policies, Allowed Actions, Runner Governance, and Auditability: Configuration, Design Patterns, and Trade-Offs

Continue with the next lesson to build on the current concepts, evidence, security boundaries, and operational practices.

Knowledge check

Why did the lab hash the policy file before evaluating workflows?

The evaluator denies vendor/scan-action@v4. What are the two distinct reasons?

Should a 403 from an organization settings API be “fixed” by immediately using a broader PAT?

What is the safer response when a PR workflow targets a production runner group?

Why is an exception stored separately from the baseline policy?

Official references and version notes

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.