From f256e2fd7cdbef98cb84df44b5dbec25a8a49f3f Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 7 Nov 2026 00:23:19 +0800 Subject: [PATCH 1/2] ci: Add protected PAPER executor grant workflow --- .../grant-paper-executor-permissions.yml | 106 +++++++++++++ docs/iam/paper-scheduler-get.md | 63 ++++++++ scripts/paper_executor_grant.py | 135 ++++++++++++++++ tests/test_paper_executor_grant.py | 149 ++++++++++++++++++ 4 files changed, 453 insertions(+) create mode 100644 .github/workflows/grant-paper-executor-permissions.yml create mode 100644 scripts/paper_executor_grant.py create mode 100644 tests/test_paper_executor_grant.py diff --git a/.github/workflows/grant-paper-executor-permissions.yml b/.github/workflows/grant-paper-executor-permissions.yml new file mode 100644 index 0000000..d12f401 --- /dev/null +++ b/.github/workflows/grant-paper-executor-permissions.yml @@ -0,0 +1,106 @@ +name: Grant fixed PAPER executor IAM permissions + +on: + workflow_dispatch: + inputs: + mode: + description: Dry-run first; apply needs a separate exact-revision run card + type: choice + options: [dry-run, apply] + default: dry-run + required: true + approved_revision: + description: Exact merged main SHA approved by the separate run card + type: string + required: true + owner_run_card: + description: Separate owner run-card reference; Environment reviewer verifies scope + type: string + required: true + confirm: + description: Must be GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS + type: string + required: true + +permissions: + contents: read + +# Serialize with the existing scheduler IAM route; never cancel an IAM writer. +concurrency: + group: paper-scheduler-jobs-get-iam + cancel-in-progress: false + +jobs: + fixed-grant: + if: >- + github.event_name == 'workflow_dispatch' + && github.repository == 'QuantStrategyLab/LongBridgePlatform' + && github.ref == 'refs/heads/main' + && github.run_attempt == 1 + && inputs.approved_revision == github.sha + && inputs.confirm == 'GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS' + && (inputs.mode == 'dry-run' || inputs.mode == 'apply') + environment: longbridge-paper + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + env: + IAM_MODE: ${{ inputs.mode }} + IAM_CONFIRM: ${{ inputs.confirm }} + IAM_APPROVED_REVISION: ${{ inputs.approved_revision }} + IAM_OWNER_RUN_CARD: ${{ inputs.owner_run_card }} + GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} + PAPER_SCHEDULER_GET_PROJECT_ID: ${{ vars.PAPER_SCHEDULER_GET_PROJECT_ID }} + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} + PAPER_IAM_GRANT_RECIPIENT: ${{ vars.PAPER_IAM_GRANT_RECIPIENT }} + PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT: ${{ vars.PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT }} + PAPER_IAM_BOOTSTRAP_WIF_PROVIDER: ${{ vars.PAPER_IAM_BOOTSTRAP_WIF_PROVIDER }} + steps: + - name: Verify Environment approval protection remains configured + uses: actions/github-script@v8 + with: + script: | + let data; + try { + ({data} = await github.rest.repos.getEnvironment({ + ...context.repo, environment_name: 'longbridge-paper' + })); + } catch { + throw new Error('Environment protection read failed'); + } + const approval = data.protection_rules?.find(r => r.type === 'required_reviewers'); + if (!approval || !approval.reviewers || approval.reviewers.length < 1) { + throw new Error('Required Environment approval protection is missing'); + } + - name: Checkout exact protected revision + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Validate fixed recipient and bootstrap mappings before authentication + run: | + python3 - <<'PY' + import os + from scripts.paper_executor_grant import identity + identity(os.environ) + if not os.environ.get('PAPER_IAM_BOOTSTRAP_WIF_PROVIDER'): + raise SystemExit('BLOCKED: bootstrap WIF mapping missing') + PY + - name: Authenticate existing authorized bootstrap identity + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ vars.PAPER_IAM_BOOTSTRAP_WIF_PROVIDER }} + service_account: ${{ vars.PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT }} + - name: Set up cloud client + uses: google-github-actions/setup-gcloud@v3 + with: + project_id: ${{ vars.GCP_PROJECT_ID }} + - name: Read plan and apply only the fixed delta when separately authorized + run: | + set -euo pipefail + IAM_MODE=dry-run python3 -m scripts.paper_executor_grant + if [ "$IAM_MODE" = apply ]; then + python3 -m scripts.paper_executor_grant + fi diff --git a/docs/iam/paper-scheduler-get.md b/docs/iam/paper-scheduler-get.md index 7e748e8..2e025b6 100644 --- a/docs/iam/paper-scheduler-get.md +++ b/docs/iam/paper-scheduler-get.md @@ -147,3 +147,66 @@ successful later operations. The original role GET, accepted 404, policy checks, CAS, dry-run default and apply gates remain required. A later executor grant, especially project `setIamPolicy`, needs a separate owner decision. Publishing this diagnostic does not authorize its execution or any IAM change. + + +## Protected executor bootstrap definition + +The [fixed executor grant workflow](../../.github/workflows/grant-paper-executor-permissions.yml) +addresses the executor-permission blocker in the scheduler IAM route. It creates +one project custom role containing exactly `iam.roles.get`, +`resourcemanager.projects.getIamPolicy`, `iam.roles.create`, and +`resourcemanager.projects.setIamPolicy`, and binds it only to the existing +`apply-paper-scheduler-jobs-get` executor. These are project-level IAM powers; +this grant does not restrict what a compromised executor could do with those +permissions. The fixed script restricts the intended operation, while the +Environment approval and WIF trust remain essential access controls. + +Publishing or merging this definition does not execute or authorize a grant. +The definition retains the four-permission scope of approval t1319s7706 and the +scheduler workflow lineage from PR #719; t1320s7720 authorizes this Draft only. +After merge, a separate owner run card must bind the merged main revision, +PAPER project, existing recipient, bootstrap identity, and grant scope. The +Environment reviewer must verify that card and a successful dry-run plan before +approving apply. The workflow also runs a read-only plan immediately before an +apply attempt. No scheduler apply, PAPER start, secret creation, deployment or +trading-switch change is part of this workflow. + +Use the existing `longbridge-paper` Environment. It must have at least one +required reviewer and protect main; the workflow rejects a missing or unreadable +reviewer rule before cloud authentication. Current Environment policy is an +operational prerequisite, not established by committing this file. Maintainers +must ensure WIF trust limits the bootstrap to this repository, protected main +and Environment, and prevent unreviewed callers from assuming it. + +Protected Environment mappings, never literal identifiers in Git: + +| Variable | Required meaning | +| --- | --- | +| `GCP_PROJECT_ID` | Approved PAPER project | +| `PAPER_SCHEDULER_GET_PROJECT_ID` | Same approved project | +| `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` | Existing scheduler IAM executor | +| `PAPER_IAM_GRANT_RECIPIENT` | Independent approved mapping to that same executor | +| `PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT` | Existing authorized bootstrap service account in the same project, distinct from recipient | +| `PAPER_IAM_BOOTSTRAP_WIF_PROVIDER` | Existing authorized WIF provider for that bootstrap | + +No new production principal is proposed or created. A compatible bootstrap +identity, its effective authority and these additional protected mappings have +not been proved by offline tests. Missing mappings block execution; configuration +and WIF changes need their own authorization. Never substitute a broad new +administrator grant or reuse the known deficient executor as bootstrap. + +Dispatch accepts only `dry-run` or `apply`, an exact merged-main revision, +`owner_run_card` reference and `GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS` +confirmation. Text input is an audit reference, not proof of owner authorization; +the required Environment reviewer validates it. Workflow reruns are rejected. +No grant dispatch is part of the Draft preparation job. + +The script checks bootstrap permissions, reads role and policy, rejects role or +binding collisions, and re-reads before writes. It shares the scheduler IAM +workflow concurrency group, uses policy etag compare-and-set, changes only +bindings, and verifies the exact additive delta after writing. Transport errors, +unknown state and concurrent changes exit nonzero without write retries. Role +creation and policy binding are separate operations: partial or ambiguous +outcomes require readback and a new scoped run card before another attempt. +An existing exact role and binding are idempotent across process restarts. +Only fixed status messages are emitted; raw cloud responses stay in memory. diff --git a/scripts/paper_executor_grant.py b/scripts/paper_executor_grant.py new file mode 100644 index 0000000..95ab608 --- /dev/null +++ b/scripts/paper_executor_grant.py @@ -0,0 +1,135 @@ +"""Fixed bootstrap grant for the existing PAPER scheduler IAM executor. + +No remote diagnostics or identifiers are emitted. Uses the scheduler IAM +transport and policy validator; never retries a write or changes runtime state. +""" +import copy +import os +import re + +from scripts.paper_scheduler_get_iam import ( + EXECUTOR_PERMISSIONS, + Blocked, + Cloud, + validate_policy, +) + +PERMISSIONS = EXECUTOR_PERMISSIONS +ROLE_ID = "paperSchedulerIamExecutor" +DEFINITION = { + "title": "PAPER scheduler IAM executor", + "description": "Fixed four-permission PAPER scheduler IAM executor grant", + "stage": "GA", "includedPermissions": list(PERMISSIONS), +} + + +def identity(env): + required = { + "GITHUB_EVENT_NAME": "workflow_dispatch", + "GITHUB_REPOSITORY": "QuantStrategyLab/LongBridgePlatform", + "GITHUB_REF": "refs/heads/main", "GITHUB_RUN_ATTEMPT": "1", + "IAM_CONFIRM": "GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS", + } + project = env.get("GCP_PROJECT_ID", "") + recipient = env.get("PAPER_IAM_GRANT_RECIPIENT", "") + bootstrap = env.get("PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT", "") + sa_pattern = r"[a-z][a-z0-9-]{4,28}[a-z0-9]@" + re.escape(project) + r"\.iam\.gserviceaccount\.com" + revision = env.get("IAM_APPROVED_REVISION", "") + if (any(env.get(k) != v for k, v in required.items()) + or env.get("IAM_MODE") not in {"dry-run", "apply"} + or not re.fullmatch(r"[a-z][a-z0-9-]{4,28}[a-z0-9]", project) + or project != env.get("PAPER_SCHEDULER_GET_PROJECT_ID") + or recipient != env.get("GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT") + or not re.fullmatch(sa_pattern, recipient) + or not re.fullmatch(sa_pattern, bootstrap) or bootstrap == recipient + or not re.fullmatch(r"[0-9a-f]{40}", revision) + or revision != env.get("GITHUB_SHA") + or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", env.get("IAM_OWNER_RUN_CARD", ""))): + raise Blocked("authority_mapping_rejected") + return project, f"serviceAccount:{recipient}" + + +def validate_role(role, name): + if (not isinstance(role, dict) or role.get("name") != name + or role.get("includedPermissions") != list(PERMISSIONS) + or role.get("deleted", False) is not False or role.get("stage") != "GA" + or not isinstance(role.get("etag"), str) or not role["etag"]): + raise Blocked("role_collision_or_integrity_failure") + + +def execute(env, request): + project, member = identity(env) + root = f"https://cloudresourcemanager.googleapis.com/v1/projects/{project}" + name = f"projects/{project}/roles/{ROLE_ID}" + role_url = f"https://iam.googleapis.com/v1/{name}" + held = request("POST", root + ":testIamPermissions", {"permissions": list(PERMISSIONS)}) + if (not isinstance(held, dict) or set(held) != {"permissions"} + or not isinstance(held["permissions"], list) + or len(held["permissions"]) != len(PERMISSIONS) + or any(type(p) is not str for p in held["permissions"]) + or set(held["permissions"]) != set(PERMISSIONS)): + raise Blocked("bootstrap_permissions_missing_or_unknown") + + def read_role(): + role = request("GET", role_url, missing=True) + if role is not None: + validate_role(role, name) + return role + + def read_policy(): + policy = request("POST", root + ":getIamPolicy", {"options": {"requestedPolicyVersion": 3}}) + validate_policy(policy, name, member) + return policy + + role = read_role() + before = read_policy() + bound = validate_policy(before, name, member) + if role is None and bound: + raise Blocked("orphan_binding_collision") + print("plan: fixed four-permission role; existing PAPER executor only") + if env["IAM_MODE"] == "dry-run": + print("dry-run: zero writes; grant NOT_EXECUTED") + return + # Fresh observations detect another process/writer before the first write. + if read_role() != role or read_policy() != before: + raise Blocked("concurrent_drift") + if role is None: + request("POST", f"https://iam.googleapis.com/v1/projects/{project}/roles", + {"roleId": ROLE_ID, "role": copy.deepcopy(DEFINITION)}) + role = read_role() + if role is None: + raise Blocked("created_role_missing") + # Creation and binding are not atomic. Stop on partial/unknown outcomes; + # never roll back or retry. A new run card must inspect any partial result. + fresh = read_policy() + if fresh != before or read_role() != role: + raise Blocked("concurrent_drift") + expected = copy.deepcopy(before) + if not bound: + expected["bindings"].append({"role": name, "members": [member]}) + request("POST", root + ":setIamPolicy", { + "policy": {k: expected[k] for k in ("version", "etag", "bindings")}, + "updateMask": "bindings,etag", + }) + after = read_policy() + if (read_role() != role + or {k: v for k, v in after.items() if k != "etag"} + != {k: v for k, v in expected.items() if k != "etag"} + or not validate_policy(after, name, member)): + raise Blocked("after_readback_drift") + print("verified: exact role and additive binding; no runtime action") + + +def main(): + try: + identity(os.environ) + execute(os.environ, Cloud()) + return 0 + except Exception: + # Shared transport suppresses remote data; never print exception values. + print("BLOCKED: executor_grant_failed_stop_no_retry") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_paper_executor_grant.py b/tests/test_paper_executor_grant.py new file mode 100644 index 0000000..bb6646b --- /dev/null +++ b/tests/test_paper_executor_grant.py @@ -0,0 +1,149 @@ +"""Fake-only contract and adverse tests for the bootstrap grant definition.""" +import copy +from pathlib import Path + +import pytest + +from scripts import paper_executor_grant as m +from scripts.paper_scheduler_get_iam import Blocked + +PROJECT = "fake-paper-project" +EXECUTOR = f"executor@{PROJECT}.iam.gserviceaccount.com" +ENV = { + "GITHUB_EVENT_NAME": "workflow_dispatch", + "GITHUB_REPOSITORY": "QuantStrategyLab/LongBridgePlatform", + "GITHUB_REF": "refs/heads/main", "GITHUB_RUN_ATTEMPT": "1", + "IAM_CONFIRM": "GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS", + "IAM_MODE": "apply", "GCP_PROJECT_ID": PROJECT, + "PAPER_SCHEDULER_GET_PROJECT_ID": PROJECT, + "GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT": EXECUTOR, + "PAPER_IAM_GRANT_RECIPIENT": EXECUTOR, + "PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT": f"bootstrap@{PROJECT}.iam.gserviceaccount.com", + "IAM_APPROVED_REVISION": "a" * 40, "GITHUB_SHA": "a" * 40, + "IAM_OWNER_RUN_CARD": "fake-owner-run-card", +} + + +class Cloud: + def __init__(self): + self.role = None + self.policy = {"version": 3, "etag": "fake-etag", "bindings": [], "auditConfigs": []} + self.calls = [] + self.reads = 0 + self.drift = False + self.ambiguous = False + + def __call__(self, method, url, body=None, missing=False): + self.calls.append((method, url, copy.deepcopy(body))) + if url.endswith(":testIamPermissions"): + return {"permissions": list(m.PERMISSIONS)} + if url.endswith(":getIamPolicy"): + self.reads += 1 + if self.drift and self.reads > 1: + self.policy["etag"] = "other-writer" + return copy.deepcopy(self.policy) + if url.endswith(":setIamPolicy"): + if self.ambiguous: + raise Blocked("unknown") + assert body["policy"]["etag"] == self.policy["etag"] + assert body["updateMask"] == "bindings,etag" + self.policy["bindings"] = body["policy"]["bindings"] + self.policy["etag"] = "next-etag" + return copy.deepcopy(self.policy) + if method == "GET": + return copy.deepcopy(self.role) + self.role = {**body["role"], "name": f"projects/{PROJECT}/roles/{m.ROLE_ID}", "etag": "role-etag"} + return copy.deepcopy(self.role) + + +def writes(cloud): + return [c for c in cloud.calls if c[0] == "POST" and not c[1].endswith((":getIamPolicy", ":testIamPermissions"))] + + +@pytest.mark.parametrize("key,value", [ + ("IAM_MODE", "unknown"), ("IAM_MODE", ""), ("IAM_OWNER_RUN_CARD", ""), + ("IAM_APPROVED_REVISION", "b" * 40), ("GITHUB_RUN_ATTEMPT", "2"), + ("PAPER_IAM_GRANT_RECIPIENT", "other@fake-project.iam.gserviceaccount.com"), + ("PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT", EXECUTOR), + ("PAPER_SCHEDULER_GET_PROJECT_ID", "wrong-project"), +]) +def test_scope_rejected_before_network(key, value): + cloud = Cloud() + with pytest.raises(Blocked): + m.execute({**ENV, key: value}, cloud) + assert not cloud.calls + + +def test_dry_run_zero_writes(): + cloud = Cloud() + m.execute({**ENV, "IAM_MODE": "dry-run"}, cloud) + assert not writes(cloud) + + +def test_exact_delta_and_restart_no_duplicate_write(): + cloud = Cloud() + before = copy.deepcopy(cloud.policy) + m.execute(ENV, cloud) + assert cloud.role["includedPermissions"] == list(m.PERMISSIONS) + assert cloud.policy["auditConfigs"] == before["auditConfigs"] + assert cloud.policy["bindings"] == [{"role": cloud.role["name"], "members": [f"serviceAccount:{EXECUTOR}"]}] + assert len(writes(cloud)) == 2 + m.execute(ENV, cloud) + assert len(writes(cloud)) == 2 + + +def test_cross_process_drift_blocks_before_write(): + cloud = Cloud() + cloud.drift = True + with pytest.raises(Blocked): + m.execute(ENV, cloud) + assert not writes(cloud) + + +def test_ambiguous_write_never_retried(): + cloud = Cloud() + cloud.ambiguous = True + with pytest.raises(Blocked): + m.execute(ENV, cloud) + assert len([c for c in writes(cloud) if c[1].endswith(":setIamPolicy")]) == 1 + + +def test_permission_expansion_collision_blocks(): + cloud = Cloud() + m.execute(ENV, cloud) + cloud.role["includedPermissions"].append("iam.roles.update") + cloud.calls.clear() + with pytest.raises(Blocked): + m.execute(ENV, cloud) + assert not writes(cloud) + + +def test_missing_bootstrap_permission_blocks(): + cloud = Cloud() + def request(method, url, body=None, missing=False): + if url.endswith(":testIamPermissions"): + return {"permissions": []} + return cloud(method, url, body, missing) + with pytest.raises(Blocked): + m.execute(ENV, request) + assert not writes(cloud) + + +def test_error_output_is_fixed(monkeypatch, capsys): + monkeypatch.setattr(m.os, "environ", ENV) + def fail(): + raise RuntimeError("fake-private-value@example.invalid") + monkeypatch.setattr(m, "Cloud", fail) + assert m.main() == 1 + assert capsys.readouterr().out == "BLOCKED: executor_grant_failed_stop_no_retry\n" + + +def test_workflow_protection_contract(): + text = (Path(__file__).parents[1] / ".github/workflows/grant-paper-executor-permissions.yml").read_text() + for gate in ["environment: longbridge-paper", "required_reviewers", "reviewers.length", + "default: dry-run", "cancel-in-progress: false", "paper-scheduler-jobs-get-iam", + "github.run_attempt == 1", "github.ref == 'refs/heads/main'", + "PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT", "PAPER_IAM_BOOTSTRAP_WIF_PROVIDER", + "persist-credentials: false"]: + assert gate in text + assert "secrets." not in text From 458bbf1f77f999a7356c311dbefb511228fdbea9 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 7 Nov 2026 01:12:22 +0800 Subject: [PATCH 2/2] fix: Mask grant mappings and accept unordered IAM permissions --- .../grant-paper-executor-permissions.yml | 47 ++++++-- docs/iam/paper-scheduler-get.md | 18 +++ scripts/paper_executor_grant.py | 7 +- tests/test_paper_executor_grant.py | 108 ++++++++++++++++++ 4 files changed, 170 insertions(+), 10 deletions(-) diff --git a/.github/workflows/grant-paper-executor-permissions.yml b/.github/workflows/grant-paper-executor-permissions.yml index d12f401..b733172 100644 --- a/.github/workflows/grant-paper-executor-permissions.yml +++ b/.github/workflows/grant-paper-executor-permissions.yml @@ -51,13 +51,42 @@ jobs: IAM_CONFIRM: ${{ inputs.confirm }} IAM_APPROVED_REVISION: ${{ inputs.approved_revision }} IAM_OWNER_RUN_CARD: ${{ inputs.owner_run_card }} - GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} - PAPER_SCHEDULER_GET_PROJECT_ID: ${{ vars.PAPER_SCHEDULER_GET_PROJECT_ID }} - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} - PAPER_IAM_GRANT_RECIPIENT: ${{ vars.PAPER_IAM_GRANT_RECIPIENT }} - PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT: ${{ vars.PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT }} - PAPER_IAM_BOOTSTRAP_WIF_PROVIDER: ${{ vars.PAPER_IAM_BOOTSTRAP_WIF_PROVIDER }} steps: + # Read mappings inside the action, never through logged inputs or env. + # Missing variable-read authority blocks before authentication or writes. + - name: Mask protected mappings before exposing step environment + uses: actions/github-script@v8 + with: + script: | + const names = [ + 'GCP_PROJECT_ID', 'PAPER_SCHEDULER_GET_PROJECT_ID', + 'GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT', 'PAPER_IAM_GRANT_RECIPIENT', + 'PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT', 'PAPER_IAM_BOOTSTRAP_WIF_PROVIDER' + ]; + const mappings = []; + try { + for (const name of names) { + let data; + try { + ({data} = await github.request( + 'GET /repos/{owner}/{repo}/environments/{environment_name}/variables/{name}', + {...context.repo, environment_name: 'longbridge-paper', name})); + } catch (error) { + if (error.status !== 404) throw error; + ({data} = await github.request( + 'GET /repos/{owner}/{repo}/actions/variables/{name}', + {...context.repo, name})); + } + if (typeof data.value !== 'string' || !data.value.trim()) { + throw new Error('Missing mapping'); + } + core.setSecret(data.value); + mappings.push([name, data.value]); + } + } catch { + throw new Error('Protected mapping read failed; stop before authentication'); + } + for (const [name, value] of mappings) core.exportVariable(name, value); - name: Verify Environment approval protection remains configured uses: actions/github-script@v8 with: @@ -91,12 +120,12 @@ jobs: - name: Authenticate existing authorized bootstrap identity uses: google-github-actions/auth@v3 with: - workload_identity_provider: ${{ vars.PAPER_IAM_BOOTSTRAP_WIF_PROVIDER }} - service_account: ${{ vars.PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT }} + workload_identity_provider: ${{ env.PAPER_IAM_BOOTSTRAP_WIF_PROVIDER }} + service_account: ${{ env.PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT }} - name: Set up cloud client uses: google-github-actions/setup-gcloud@v3 with: - project_id: ${{ vars.GCP_PROJECT_ID }} + project_id: ${{ env.GCP_PROJECT_ID }} - name: Read plan and apply only the fixed delta when separately authorized run: | set -euo pipefail diff --git a/docs/iam/paper-scheduler-get.md b/docs/iam/paper-scheduler-get.md index 2e025b6..045a870 100644 --- a/docs/iam/paper-scheduler-get.md +++ b/docs/iam/paper-scheduler-get.md @@ -210,3 +210,21 @@ creation and policy binding are separate operations: partial or ambiguous outcomes require readback and a new scoped run card before another attempt. An existing exact role and binding are idempotent across process restarts. Only fixed status messages are emitted; raw cloud responses stay in memory. + + +### Grant mapping redaction prerequisite + +The fixed executor grant masks mappings in its first action before exporting +any mapping to later step environments or authentication inputs. It reads the +six named variables from the protected Environment, falling back to repository +variables only for an absent Environment variable (HTTP 404). Organization-only +variables are not a fallback. All mappings must be readable and nonempty; +permission, transport or malformed-response failures stop before authentication. +No GitHub token permission or Environment configuration is changed by this +workflow definition. Variable API access by the workflow token is a separate +prerequisite and is not established by local tests; an inaccessible mapping +blocks the route and does not authorize provisioning a token or changing access. + +Role permission responses are checked for exact membership and cardinality, +independent of order. Fresh role snapshots normalize permission ordering while +retaining etag and every other field for concurrent-drift detection. diff --git a/scripts/paper_executor_grant.py b/scripts/paper_executor_grant.py index 95ab608..75cc995 100644 --- a/scripts/paper_executor_grant.py +++ b/scripts/paper_executor_grant.py @@ -51,7 +51,10 @@ def identity(env): def validate_role(role, name): if (not isinstance(role, dict) or role.get("name") != name - or role.get("includedPermissions") != list(PERMISSIONS) + or not isinstance(role.get("includedPermissions"), list) + or len(role["includedPermissions"]) != len(PERMISSIONS) + or any(type(p) is not str for p in role["includedPermissions"]) + or set(role["includedPermissions"]) != set(PERMISSIONS) or role.get("deleted", False) is not False or role.get("stage") != "GA" or not isinstance(role.get("etag"), str) or not role["etag"]): raise Blocked("role_collision_or_integrity_failure") @@ -74,6 +77,8 @@ def read_role(): role = request("GET", role_url, missing=True) if role is not None: validate_role(role, name) + # IAM does not promise ordering; compare fresh snapshots canonically. + role = {**role, "includedPermissions": sorted(role["includedPermissions"])} return role def read_policy(): diff --git a/tests/test_paper_executor_grant.py b/tests/test_paper_executor_grant.py index bb6646b..e9dd403 100644 --- a/tests/test_paper_executor_grant.py +++ b/tests/test_paper_executor_grant.py @@ -1,5 +1,9 @@ """Fake-only contract and adverse tests for the bootstrap grant definition.""" import copy +import json +import subprocess + +import textwrap from pathlib import Path import pytest @@ -147,3 +151,107 @@ def test_workflow_protection_contract(): "persist-credentials: false"]: assert gate in text assert "secrets." not in text + + +@pytest.mark.parametrize("mode", ["dry-run", "apply"]) +def test_role_response_order_is_irrelevant(mode): + cloud = Cloud() + m.execute(ENV, cloud) + cloud.calls.clear() + count = 0 + + def request(method, url, body=None, missing=False): + nonlocal count + result = cloud(method, url, body, missing) + if method == "GET" and result is not None: + count += 1 + result["includedPermissions"] = sorted(m.PERMISSIONS, reverse=count % 2 == 0) + return result + + m.execute({**ENV, "IAM_MODE": mode}, request) + assert not writes(cloud) + + +def test_role_creation_accepts_reordered_readback(): + cloud = Cloud() + + def request(method, url, body=None, missing=False): + result = cloud(method, url, body, missing) + if method == "GET" and result is not None: + result["includedPermissions"] = sorted(m.PERMISSIONS) + return result + + m.execute(ENV, request) + assert len(writes(cloud)) == 2 + + +@pytest.mark.parametrize("permissions", [ + None, "not-a-list", [], list(m.PERMISSIONS) + [m.PERMISSIONS[0]], + list(m.PERMISSIONS[:-1]) + [m.PERMISSIONS[0]], + list(m.PERMISSIONS[:-1]) + [None], + list(m.PERMISSIONS[:-1]) + [{}], +]) +def test_invalid_role_permissions_block_without_writes(permissions): + cloud = Cloud() + cloud.role = {**m.DEFINITION, "name": f"projects/{PROJECT}/roles/{m.ROLE_ID}", + "etag": "fake-role-etag", "includedPermissions": permissions} + with pytest.raises(Blocked): + m.execute(ENV, cloud) + assert not writes(cloud) + + +def test_mapping_masks_precede_all_step_details(): + workflow = (Path(__file__).parents[1] / + ".github/workflows/grant-paper-executor-permissions.yml").read_text() + mappings = ["GCP_PROJECT_ID", "PAPER_SCHEDULER_GET_PROJECT_ID", + "GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT", "PAPER_IAM_GRANT_RECIPIENT", + "PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT", "PAPER_IAM_BOOTSTRAP_WIF_PROVIDER"] + job_header, steps = workflow.split(" steps:\n", 1) + assert all(key + ":" not in job_header for key in mappings) + first = steps.split(" - name:")[1] + assert "uses: actions/github-script@v8" in first + assert "vars." not in first + assert "env:" not in first + script = textwrap.dedent(first.split(" script: |\n", 1)[1]) + harness = r""" +const assert = require('node:assert/strict'); +const script = SCRIPT; +const keys = KEYS; +async function check(failure) { + const masks = new Set(), exported = []; + const core = { + setSecret(value) { masks.add(value); }, + exportVariable(key, value) { + assert(masks.has(value), 'export before mask'); + assert.equal(masks.size, keys.length, 'export before all masks'); + exported.push(key); + } + }; + const github = { request: async (route, args) => { + assert(!exported.length, 'export before reads complete'); + if (failure === 'fallback' && route.includes('/environments/')) { + throw Object.assign(new Error('fake-private-diagnostic'), {status: 404}); + } + if (typeof failure === 'number') { + throw Object.assign(new Error('fake-private-diagnostic'), {status: failure}); + } + if (failure === 'empty') return {data: {value: ''}}; + if (failure === 'unknown') return {data: {value: null}}; + return {data: {value: 'fake-' + args.name}}; + }}; + const context = {repo: {owner: 'fake-owner', repo: 'fake-repo'}}; + const run = new Function('github', 'core', 'context', 'return (async () => {' + script + '})()'); + if (failure && failure !== 'fallback') { + await assert.rejects(run(github, core, context), error => + !error.message.includes('fake-private-diagnostic')); + assert.equal(exported.length, 0); + } else { + await run(github, core, context); + assert.deepEqual(exported, keys); + } +} +(async () => { await check(null); await check('fallback'); await check(403); await check(404); +await check('empty'); await check('unknown'); })() +.catch(() => process.exit(1)); +""".replace("SCRIPT", json.dumps(script)).replace("KEYS", json.dumps(mappings)) + subprocess.run(["node", "-e", harness], check=True, capture_output=True)