diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 3317e8d..81764c9 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -16,11 +16,18 @@ on: permissions: contents: read - # administration: read — the self-verifying mount canary (below) reads the - # default branch's protection to confirm THIS gate is a required, non-bypassable - # check. This is a CEILING; the effective grant comes from the caller's token, so - # the caller stub must also grant administration:read (templates/quality.caller.yml). - administration: read + # NOTE: `administration` is NOT a grantable GITHUB_TOKEN scope — a workflow + # `permissions:` block can only request the 15 documented automatic-token + # scopes (actions, attestations, checks, contents, deployments, discussions, + # id-token, issues, models, packages, pages, pull-requests, repository-projects, + # security-events, statuses), and declaring `administration:` here makes the + # workflow YAML invalid (the job dies at 0s and never emits a status check). + # So the in-band mount canary (below) reads the default branch's protection via + # an OPTIONAL `CI_KIT_TOKEN` secret — a fine-grained PAT / GitHub App + # installation token carrying repo Administration:read — forwarded by the + # caller's `secrets: inherit`. Absent that secret the canary is skipped with a + # warning and the required-check mount is enforced out-of-band (Constable sweep + # + templates/check-required-mount.sh). jobs: gate: @@ -204,15 +211,27 @@ jobs: # procedure (DESIGN §4.8, verdict PARTIAL) into mechanism — the missing # in-band leg of the out-of-band Constable canary. It reuses the audit # script verbatim (DRY) against the calling repo's default branch; the - # context is `gate / gate`. Needs administration:read (see permissions + - # the caller stub); a token that cannot read protection is treated as - # an unmounted gate, not waved through. + # context is `gate / gate`. + # + # Reading branch protection needs repo Administration:read, which the + # automatic GITHUB_TOKEN CANNOT be granted (it is not a grantable token + # scope — see the permissions block above). So the canary's TEETH are + # active ONLY when an admin-scoped CI_KIT_TOKEN (fine-grained PAT / + # GitHub App installation token) is provisioned and forwarded via the + # caller's `secrets: inherit`. Without it the leg DEGRADES to a warning + # (so the gate is not bricked for un-provisioned consumers) and the mount + # stays enforced out-of-band (Constable sweep + check-required-mount.sh). env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.CI_KIT_TOKEN || github.token }} + KIT_TOKEN: ${{ secrets.CI_KIT_TOKEN }} run: | + if [ -z "$KIT_TOKEN" ]; then + echo "::warning::quality-gate: in-band mount self-verification SKIPPED — no CI_KIT_TOKEN (admin:read) secret is provisioned, so the canary cannot read branch protection (the automatic GITHUB_TOKEN cannot be granted Administration:read). The out-of-band Constable sweep + templates/check-required-mount.sh remain the enforcement; provision a CI_KIT_TOKEN repo/org secret to give this leg teeth." + exit 0 + fi script="$GITHUB_WORKSPACE/.cf-quality/templates/check-required-mount.sh" if ! bash "$script" "$GITHUB_REPOSITORY"; then - echo "::error::quality-gate refused: this gate is not a required, non-bypassable status check on the default branch — OR the token lacks 'administration: read' to confirm it. A gate that cannot prove it is the law is treated as unmounted. Mount it (templates/mount-required.sh · docs/enforcement-runbook.md) and grant the caller administration:read." + echo "::error::quality-gate refused: this gate is not a required, non-bypassable status check on the default branch — OR the provisioned CI_KIT_TOKEN lacks Administration:read to confirm it. A gate that cannot prove it is the law is treated as unmounted. Mount it (templates/mount-required.sh · docs/enforcement-runbook.md)." exit 1 fi diff --git a/templates/quality.caller.yml b/templates/quality.caller.yml index cac5ebd..f6589d1 100644 --- a/templates/quality.caller.yml +++ b/templates/quality.caller.yml @@ -13,10 +13,14 @@ # anyway. The status-check context is `gate / gate` (caller job `gate` over # the reusable workflow's `gate` job). One-shot mount: templates/mount-required.sh; # procedure + audit: docs/enforcement-runbook.md (also check-required-mount.sh) -# - grant `administration: read` below — the gate's in-band canary reads this -# repo's branch protection to self-verify it is mounted as required, and -# REFUSES to pass when it cannot confirm it (a gate that can't prove it is -# the law is treated as unmounted) +# - the gate's in-band mount canary is OPTIONAL: it reads this repo's branch +# protection to self-verify the gate is mounted as required, which needs repo +# Administration:read — a scope the automatic GITHUB_TOKEN cannot be granted. +# Enable it by providing a `CI_KIT_TOKEN` repo/org secret (a fine-grained PAT +# or GitHub App installation token with repo Administration: read); the +# `secrets: inherit` below forwards it to the reusable workflow. Without that +# secret the gate still runs and the required-check mount is enforced +# out-of-band (Constable sweep + templates/check-required-mount.sh) name: quality-gate on: push: @@ -25,7 +29,7 @@ on: branches: [main] jobs: gate: - permissions: { contents: read, administration: read } + permissions: { contents: read } # Replace the placeholder with a real full commit SHA of the kit repo: uses: BonfireAI/candyfactory-quality/.github/workflows/quality-gate.yml@0000000000000000000000000000000000000000 secrets: inherit diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 963ca05..fcc4d69 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -29,6 +29,29 @@ SHA_PINNED = re.compile(r"@[0-9a-f]{40}$") +# The exact set of scopes a workflow `permissions:` block may request for the +# automatic GITHUB_TOKEN. `administration` is NOT among them (it is valid only +# for fine-grained PATs / GitHub Apps), so declaring it makes the YAML invalid. +VALID_GITHUB_TOKEN_PERMISSIONS = frozenset( + { + "actions", + "attestations", + "checks", + "contents", + "deployments", + "discussions", + "id-token", + "issues", + "models", + "packages", + "pages", + "pull-requests", + "repository-projects", + "security-events", + "statuses", + } +) + def _load(path: Path) -> dict[Any, Any]: # dict[Any, Any]: YAML 1.1 parses the bare trigger key ``on`` as the @@ -69,6 +92,23 @@ def _walk_keys(node: Any) -> list[str]: return keys +# recursion: bounded by the finite depth of a yaml.safe_load tree (acyclic by construction) +def _permissions_keys(node: Any) -> set[str]: + """Union of the keys of every ``permissions:`` mapping anywhere in the tree + (top-level and per-job). A ``permissions`` value that is a bare string + (e.g. ``read-all``) contributes no scope keys.""" + keys: set[str] = set() + if isinstance(node, dict): + for key, value in node.items(): + if str(key) == "permissions" and isinstance(value, dict): + keys.update(str(k) for k in value) + keys.update(_permissions_keys(value)) + elif isinstance(node, list): + for item in node: + keys.update(_permissions_keys(item)) + return keys + + def _uses_refs(data: dict[str, Any]) -> list[str]: refs: list[str] = [] for job in data["jobs"].values(): @@ -335,9 +375,42 @@ def test_gate_self_verifies_required_mount_in_band() -> None: assert canary[0]["env"]["GH_TOKEN"], "the canary needs a token to read protection" -def test_gate_declares_administration_read_permission() -> None: +def _mount_canary() -> dict[str, Any]: + canary = [ + s for s in _steps(_load(GATE_PATH), "gate") if "check-required-mount.sh" in s.get("run", "") + ] + assert len(canary) == 1, "exactly one in-band mount canary" + return canary[0] + + +def test_mount_canary_reads_optional_ci_kit_token() -> None: + # The canary reads branch protection (Administration:read) via an OPTIONAL + # CI_KIT_TOKEN secret, falling back to the automatic token for the API host; + # KIT_TOKEN carries the secret alone so the run can test its presence. + env = _mount_canary()["env"] + gh_token = env["GH_TOKEN"] + assert "CI_KIT_TOKEN" in gh_token and "github.token" in gh_token + assert "secrets.CI_KIT_TOKEN" in env["KIT_TOKEN"] + + +def test_mount_canary_degrades_without_admin_token() -> None: + # No admin token → skip-with-warning (degrade, never brick); token present → + # the strict proof reuses check-required-mount.sh and refuses on failure. + run = _mount_canary()["run"] + assert "::warning::" in run and '-z "$KIT_TOKEN"' in run + assert "check-required-mount.sh" in run and "exit 1" in run + + +def test_gate_permissions_only_grant_valid_github_token_scopes() -> None: + # `administration` is not a grantable GITHUB_TOKEN scope; declaring it makes + # the workflow YAML invalid and the gate job dies at 0s without a status + # check. The gate's top-level permissions must stay within the valid set. perms = _load(GATE_PATH).get("permissions") - assert isinstance(perms, dict) and perms.get("administration") == "read" + assert isinstance(perms, dict) + keys = {str(k) for k in perms} + invalid = keys - VALID_GITHUB_TOKEN_PERMISSIONS + assert not invalid, f"non-grantable scope(s): {invalid}" + assert "administration" not in keys def test_still_exactly_one_aggregating_cf_gate_step_after_additions() -> None: @@ -350,9 +423,21 @@ def test_still_exactly_one_aggregating_cf_gate_step_after_additions() -> None: # --- caller stub + the apply/audit mount scripts ----------------------------- -def test_caller_grants_administration_read() -> None: +def test_caller_permissions_only_grant_valid_github_token_scopes() -> None: gate = _load(TEMPLATE_PATH)["jobs"]["gate"] - assert gate["permissions"]["administration"] == "read" + keys = {str(k) for k in gate["permissions"]} + invalid = keys - VALID_GITHUB_TOKEN_PERMISSIONS + assert not invalid, f"non-grantable scope(s): {invalid}" + assert "administration" not in keys + + +def test_no_workflow_declares_administration_token_scope() -> None: + # Regression guard: `administration` is not a grantable GITHUB_TOKEN scope, so + # it must appear in NO permissions mapping (top-level or per-job) of any of + # our workflow surfaces. This FAILS on the pre-fix files and PASSES after. + for path in (GATE_PATH, SELF_CI_PATH, TEMPLATE_PATH): + keys = _permissions_keys(_load(path)) + assert "administration" not in keys, f"{path.name} declares non-grantable 'administration'" def test_mount_apply_script_present_and_proves_via_audit() -> None: