From ab6a87f959c5a5422d7b5467e8f8ceb9f1287b86 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Nov 2026 08:28:42 +0000 Subject: [PATCH 1/5] fix: Resolve PAPER IAM mappings via vars context Replace the Variables REST lookup that GITHUB_TOKEN cannot call with the same Environment vars channel used by the scheduler IAM workflow. Co-authored-by: Pigbibi --- .../grant-paper-executor-permissions.yml | 63 +++----- docs/iam/paper-scheduler-get.md | 47 ++++-- tests/test_paper_executor_grant.py | 150 ++++++++---------- 3 files changed, 124 insertions(+), 136 deletions(-) diff --git a/.github/workflows/grant-paper-executor-permissions.yml b/.github/workflows/grant-paper-executor-permissions.yml index dc79a06..3b4ff4d 100644 --- a/.github/workflows/grant-paper-executor-permissions.yml +++ b/.github/workflows/grant-paper-executor-permissions.yml @@ -52,10 +52,22 @@ jobs: IAM_APPROVED_REVISION: ${{ inputs.approved_revision }} IAM_OWNER_RUN_CARD: ${{ inputs.owner_run_card }} steps: - # Read mappings inside the action, never through logged inputs or env. - # Missing variable-read authority blocks before authentication or writes. + # Resolve mappings through the job Environment vars context (same channel as + # apply-paper-scheduler-jobs-get). Do not call the Variables REST API: the + # workflow GITHUB_TOKEN only receives contents/metadata and cannot read it + # (run 42266306297 failed on the first existing name before authentication). + # Mask every value before exporting it to later steps. Missing or blank + # mappings fail closed. Organization-only names remain unsupported: do not + # define these six names only at org scope. - name: Mask protected mappings before exposing step environment uses: actions/github-script@v8 + env: + MAPPING_GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} + MAPPING_PAPER_SCHEDULER_GET_PROJECT_ID: ${{ vars.PAPER_SCHEDULER_GET_PROJECT_ID }} + MAPPING_GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} + MAPPING_PAPER_IAM_GRANT_RECIPIENT: ${{ vars.PAPER_IAM_GRANT_RECIPIENT }} + MAPPING_PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT: ${{ vars.PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT }} + MAPPING_PAPER_IAM_BOOTSTRAP_WIF_PROVIDER: ${{ vars.PAPER_IAM_BOOTSTRAP_WIF_PROVIDER }} with: script: | const names = [ @@ -65,51 +77,20 @@ jobs: ]; const mappings = []; for (const name of names) { - let scope = 'environment'; - let category = 'transport_error'; - const read = async (route, args) => { - let response; - try { - response = await github.request(route, args); - } catch (error) { - const status = error?.status; - if (status === 401 || status === 403 || status === 429) { - category = `http_${status}`; - } else if (status === 404) { - category = scope === 'repository' ? 'http_404_both' : 'other_http'; - } else if (Number.isInteger(status) && status >= 500 && status <= 599) { - category = 'http_5xx'; - } else if (Number.isInteger(status)) { - category = 'other_http'; - } else { - category = 'transport_error'; - } - // Only an Environment HTTP 404 permits the same-name fallback. - if (scope === 'environment' && status === 404) return null; - throw new Error('Mapping lookup failed'); - } - const data = response?.data; - if (!data || typeof data.value !== 'string') { + const scope = 'vars'; + let category = 'blank_value'; + try { + const value = process.env['MAPPING_' + name]; + if (value === undefined || value === null || typeof value !== 'string') { category = 'malformed_response'; throw new Error('Invalid mapping response'); } - if (!data.value.trim()) { + if (!value.trim()) { category = 'blank_value'; throw new Error('Empty mapping'); } - return data; - }; - try { - let data = await read( - 'GET /repos/{owner}/{repo}/environments/{environment_name}/variables/{name}', - {...context.repo, environment_name: 'longbridge-paper', name}); - if (data === null) { - scope = 'repository'; - data = await read('GET /repos/{owner}/{repo}/actions/variables/{name}', - {...context.repo, name}); - } - core.setSecret(data.value); - mappings.push([name, data.value]); + core.setSecret(value); + mappings.push([name, value]); } catch { throw new Error(`Protected mapping read failed: name=${name} scope=${scope} category=${category}; stop before authentication`); } diff --git a/docs/iam/paper-scheduler-get.md b/docs/iam/paper-scheduler-get.md index 7b08db9..5a465cb 100644 --- a/docs/iam/paper-scheduler-get.md +++ b/docs/iam/paper-scheduler-get.md @@ -215,26 +215,45 @@ 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. +any mapping to later step environments or authentication inputs. It resolves +the six named variables through the job Environment `vars` context (Environment +then same-name repository), matching +[apply-paper-scheduler-jobs-get](../../.github/workflows/apply-paper-scheduler-jobs-get.yml). +It does not call the GitHub Variables REST API: workflow `GITHUB_TOKEN` with +`contents: read` lacks that authority, and historical run `42266306297` failed +on the first already-configured name before authentication. Organization-only +variables remain unsupported; do not define these six names only at org scope. +All mappings must be nonempty strings; blank or malformed values stop before +authentication. No GitHub token permission or Environment configuration is +changed by this workflow definition. A mapping-read failure reports only the failing fixed variable name, lookup -scope (`environment` or `repository`) and an allowlisted category: `http_401`, -`http_403`, `http_404_both`, `http_429`, `http_5xx`, `other_http`, `blank_value`, -`malformed_response` or `transport_error`. `http_404_both` means that both -same-name lookups returned HTTP 404. A non-string or absent value is a malformed -response; an empty or whitespace-only string is a blank value. No raw exception, +scope (`vars`) and an allowlisted category: `blank_value` or +`malformed_response`. An empty or whitespace-only string is a blank value; a +non-string or absent process environment entry is malformed. No raw exception, URL, response, headers or mapping value is included. If any read fails, no mapping is exported, including previously read values. This diagnostic does not prove a permission repair or authorize a retry, grant, or PAPER start. +### Mapping provenance (reviewed Git scope) + +Authoritative in-repo sources for the three grant-specific mappings. This table +does not assert live Environment presence and does not invent identities. + +| Variable | Role | Reviewed state | Source path + commit + scope | +| --- | --- | --- | --- | +| `PAPER_IAM_GRANT_RECIPIENT` | Independent approved mapping that must equal the existing scheduler IAM executor (`GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT`) | `EXISTS_SOURCE_RESOLVED` for the intended value source; the separate Environment name still needs an authorized same-value fill when absent | Contract and dual-check: `docs/iam/paper-scheduler-get.md` (this section; introduced with `#720` / `1fef697`), `scripts/paper_executor_grant.py` `identity()`; executor auth source: `.github/workflows/apply-paper-scheduler-jobs-get.yml` (`vars.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT`, Environment `longbridge-paper`) | +| `PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT` | Existing authorized bootstrap SA in the same PAPER project, distinct from recipient; never the deficient executor | `REQUIRES_DESIGN` in reviewed Git/IaC/history — `#720` states no compatible authorized bootstrap is proved; no Terraform/provisioning module or committed email exists | Required by `.github/workflows/grant-paper-executor-permissions.yml` and `scripts/paper_executor_grant.py`; no reusable identity source in reviewed scope | +| `PAPER_IAM_BOOTSTRAP_WIF_PROVIDER` | Existing authorized WIF provider that actually trusts that bootstrap | `REQUIRES_DESIGN` in reviewed Git/IaC/history — same gap as bootstrap SA; do not reuse runtime/execution provider as a substitute without separate proof | Required by `.github/workflows/grant-paper-executor-permissions.yml` auth step; no reusable provider source in reviewed scope | + +Sibling scheduler mappings (`GCP_PROJECT_ID`, `PAPER_SCHEDULER_GET_PROJECT_ID`, +`GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT`, `GCP_WORKLOAD_IDENTITY_PROVIDER`) are +consumed by the existing scheduler IAM route and are out of scope for inventing +bootstrap. Owner browser review on 2026-11-07 (Asia/Shanghai) reported the three +`PAPER_IAM_*` names absent from the visible Environment/repo variable lists in +that reviewed scope only; that is not a claim of global absence. Live GET of +those names remains a bounded parent-Mac operation when separately authorized. + 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/tests/test_paper_executor_grant.py b/tests/test_paper_executor_grant.py index 884e125..f156710 100644 --- a/tests/test_paper_executor_grant.py +++ b/tests/test_paper_executor_grant.py @@ -200,7 +200,7 @@ def test_invalid_role_permissions_block_without_writes(permissions): assert not writes(cloud) -def test_mapping_masks_precede_all_step_details(): +def test_mapping_channel_uses_vars_context_not_rest_api(): workflow = (Path(__file__).parents[1] / ".github/workflows/grant-paper-executor-permissions.yml").read_text() mappings = ["GCP_PROJECT_ID", "PAPER_SCHEDULER_GET_PROJECT_ID", @@ -210,8 +210,21 @@ def test_mapping_masks_precede_all_step_details(): 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 + assert "environments/{environment_name}/variables/{name}" not in first + assert "actions/variables/{name}" not in first + assert "github.request" not in first + for name in mappings: + assert f"MAPPING_{name}: ${{{{ vars.{name} }}}}" in first + assert "process.env['MAPPING_' + name]" in first + + +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"] + first = workflow.split(" steps:\n", 1)[1].split(" - name:")[1] script = textwrap.dedent(first.split(" script: |\n", 1)[1]) harness = r""" const assert = require('node:assert/strict'); @@ -219,58 +232,48 @@ def test_mapping_masks_precede_all_step_details(): 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}); + const prior = {...process.env}; + for (const name of keys) process.env['MAPPING_' + name] = 'fake-' + name; + if (failure === 'empty') process.env['MAPPING_' + keys[0]] = ''; + if (failure === 'blank') process.env['MAPPING_' + keys[0]] = ' '; + try { + 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 run = new Function('github', 'core', 'context', 'return (async () => {' + script + '})()'); + if (failure) { + await assert.rejects(run({}, core, {}), error => + error.message.includes('Protected mapping read failed: name=' + keys[0] + + ' scope=vars category=blank_value')); + assert.equal(exported.length, 0); + } else { + await run({}, core, {}); + assert.deepEqual(exported, keys); } - if (typeof failure === 'number') { - throw Object.assign(new Error('fake-private-diagnostic'), {status: failure}); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in prior)) delete process.env[key]; } - 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); + Object.assign(process.env, prior); } } -(async () => { await check(null); await check('fallback'); await check(403); await check(404); -await check('empty'); await check('unknown'); })() -.catch(() => process.exit(1)); +(async () => { await check(null); await check('empty'); await check('blank'); })() +.catch((error) => { console.error(error); process.exit(1); }); """.replace("SCRIPT", json.dumps(script)).replace("KEYS", json.dumps(mappings)) subprocess.run(["node", "-e", harness], check=True, capture_output=True) @pytest.mark.parametrize("index", range(6)) -@pytest.mark.parametrize("scope", ["environment", "repository"]) @pytest.mark.parametrize("failure,category", [ - (401, "http_401"), (403, "http_403"), (429, "http_429"), - (500, "http_5xx"), (599, "http_5xx"), (400, "other_http"), - (302, "other_http"), (600, "other_http"), ("403", "transport_error"), - ("transport", "transport_error"), ("null_error", "transport_error"), - ("blank", "blank_value"), ("null", "malformed_response"), - ("missing", "malformed_response"), ("wrong_type", "malformed_response"), - ("null_response", "malformed_response"), - (404, "http_404_both"), + ("blank", "blank_value"), ("empty", "blank_value"), ]) -def test_mapping_failure_diagnostic_is_allowlisted_and_exports_nothing(index, scope, failure, category): - # Execute the actual inline action offline, including failures after partial success. +def test_mapping_failure_diagnostic_is_allowlisted_and_exports_nothing(index, failure, category): + # Actions vars context yields strings; unset names arrive as "". Exercise that path. workflow = (Path(__file__).parents[1] / ".github/workflows/grant-paper-executor-permissions.yml").read_text() first = workflow.split(" - name:")[1] @@ -280,47 +283,32 @@ def test_mapping_failure_diagnostic_is_allowlisted_and_exports_nothing(index, sc const keys = ['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 [index, scope, failure, category] = CASE; -const calls = [], masks = [], exports = []; +const [index, failure, category] = CASE; +const masks = [], exports = []; +const prior = {...process.env}; +for (const name of keys) process.env['MAPPING_' + name] = 'fake-' + name; +const target = 'MAPPING_' + keys[index]; +process.env[target] = failure === 'blank' ? ' ' : ''; const core = {setSecret: value => masks.push(value), exportVariable: (...args) => exports.push(args)}; -const github = {request: async (route, args) => { - assert.equal(exports.length, 0); - const actualScope = route.includes('/environments/') ? 'environment' : 'repository'; - calls.push([args.name, actualScope]); - if (args.name !== keys[index]) return {data: {value: 'fake-' + args.name}}; - if (actualScope === 'environment' && (scope === 'repository' || failure === 404)) { - throw {status: 404, message: 'fake-private-payload'}; - } - if (failure === 'blank') return {data: {value: ' '}}; - if (failure === 'null') return {data: null}; - if (failure === 'missing') return {}; - if (failure === 'wrong_type') return {data: {value: 17}}; - if (failure === 'null_response') return null; - if (failure === 'null_error') throw null; - const error = new Error('fake-private-payload'); - error.response = {data: 'fake-private-payload', headers: {'fake': 'fake-private-payload'}}; - error.request = {url: 'fake-private-payload'}; - if (failure !== 'transport') error.status = failure; - throw error; -}}; -const context = {repo: {owner: 'fake-owner', repo: 'fake-repo'}}; const run = new Function('github', 'core', 'context', 'return (async () => {' + SCRIPT + '})()'); (async () => { - await assert.rejects(run(github, core, context), error => { - const expectedScope = failure === 404 ? 'repository' : scope; - assert.equal(error.message, 'Protected mapping read failed: name=' + keys[index] + - ' scope=' + expectedScope + ' category=' + category + '; stop before authentication'); - assert.equal(error.cause, undefined); - return true; - }); - assert.deepEqual(exports, []); - assert.equal(masks.length, index); - const expected = keys.slice(0, index).map(name => [name, 'environment']); - expected.push([keys[index], 'environment']); - if (scope === 'repository' || failure === 404) expected.push([keys[index], 'repository']); - assert.deepEqual(calls, expected); + try { + await assert.rejects(run({}, core, {}), error => { + assert.equal(error.message, 'Protected mapping read failed: name=' + keys[index] + + ' scope=vars category=' + category + '; stop before authentication'); + assert.equal(error.cause, undefined); + return true; + }); + assert.deepEqual(exports, []); + assert.equal(masks.length, index); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in prior)) delete process.env[key]; + } + Object.assign(process.env, prior); + } })().catch(error => { console.error(error); process.exit(1); }); -""".replace("SCRIPT", json.dumps(script)).replace("CASE", json.dumps([index, scope, failure, category])) +""".replace("SCRIPT", json.dumps(script)).replace("CASE", json.dumps([index, failure, category])) result = subprocess.run(["node", "-e", harness], capture_output=True, text=True) assert result.returncode == 0, result.stderr From a030e5a7a059e027b18ebde14667336de754d49f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Nov 2026 09:06:12 +0000 Subject: [PATCH 2/5] docs: Add offline PAPER IAM grant remediation cards Record owner t1393 installed hashes versus contract intent, and a PROPOSED_NOT_OBSERVED bootstrap design with discovery preference. Co-authored-by: Pigbibi --- docs/iam/paper-executor-grant-remediation.md | 202 +++++++++++++++++++ docs/iam/paper-scheduler-get.md | 38 ++-- tests/test_paper_executor_grant.py | 22 ++ 3 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 docs/iam/paper-executor-grant-remediation.md diff --git a/docs/iam/paper-executor-grant-remediation.md b/docs/iam/paper-executor-grant-remediation.md new file mode 100644 index 0000000..103a101 --- /dev/null +++ b/docs/iam/paper-executor-grant-remediation.md @@ -0,0 +1,202 @@ +# PAPER executor grant remediation (offline) + +Status: Proposal only. Publishing or merging this note authorizes neither +Environment variable writes, IAM changes, WIF provisioning, grant dispatch, +Environment approval, deployment, scheduler mutation nor PAPER start. + +Related definition: [paper-scheduler-get.md](paper-scheduler-get.md) and +[grant-paper-executor-permissions.yml](../../.github/workflows/grant-paper-executor-permissions.yml). +Lookup-channel fix lineage: PR #722 (Draft). + +## Owner-provided installed configuration (t1393) + +Source class: **OWNER-PROVIDED browser metadata**, not an independent cloud or +API verification by this engineering pass. Observation surfaces: + +- Environment `longbridge-paper` variables table (40 items): + `https://github.com/QuantStrategyLab/LongBridgePlatform/settings/environments/15143916325/edit` +- Repository Actions variables (18 items): + `https://github.com/QuantStrategyLab/LongBridgePlatform/settings/variables/actions` + +Hashes are SHA-256 over the full GitHub variable value encoded as UTF-8. Raw +values are not recorded here. + +| Name | Installed? | Scope observed | Updated (UTC) | Value sha256 (UTF-8) | +| --- | --- | --- | --- | --- | +| `GCP_PROJECT_ID` | yes, nonempty | Environment | 2026-11-06T05:04:28Z | `5fd9761bc0f5e7a1b75fc007b946b554af81773faee45d940bbf52c04c52152e` | +| `PAPER_SCHEDULER_GET_PROJECT_ID` | yes, nonempty | Environment | 2026-11-06T09:11:15Z | **same hash as** `GCP_PROJECT_ID` | +| `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` | yes, nonempty | Environment | 2026-11-03T06:51:52Z | `382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22` | +| `PAPER_IAM_GRANT_RECIPIENT` | **absent** exact name | Environment then repository | — | — | +| `PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT` | **absent** exact name | Environment then repository | — | — | +| `PAPER_IAM_BOOTSTRAP_WIF_PROVIDER` | **absent** exact name | Environment then repository | — | — | + +Absence means the GitHub variable name is missing in the reviewed Environment +table and, for those three names, also in the repository Actions variables +table. Variable absence is **not** evidence that a GCP service account, WIF +pool or provider is absent in the cloud project. + +## Intent versus installed config + +| Mapping | Intent (contract) | Installed GitHub config (t1393) | Decision input | +| --- | --- | --- | --- | +| Recipient | `PAPER_IAM_GRANT_RECIPIENT` must equal the existing scheduler IAM executor `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` in the same PAPER project (`scripts/paper_executor_grant.py` `identity()`) | Executor mapping **exists** in Environment; recipient name **absent** | Fill Environment variable from the already-installed executor mapping; expected post-fill value sha256 must equal `382a7cbe…697cc22`. Do not invent a new email. | +| Project pair | `PAPER_SCHEDULER_GET_PROJECT_ID` must equal `GCP_PROJECT_ID` | Both exist; **hashes already match** | No project remapping required for this gate. | +| Bootstrap SA | Distinct authorized SA in the same project, not the recipient/executor | Name absent; no in-repo proved reusable IAM bootstrap | Requires design or bounded discovery (below). **PROPOSED_NOT_OBSERVED** if newly named. | +| Bootstrap WIF | Provider that actually trusts that bootstrap under repo/main/`longbridge-paper` limits | Name absent; no in-repo proved bootstrap provider | Same as bootstrap SA. | + +### In-repo identities reviewed (not adopted as bootstrap) + +These appear in workflows or docs and must **not** be treated as grant bootstrap +without separate proof that they hold exactly the four executor-grant +permissions and are distinct from the recipient: + +| Identity channel | Role in repo | Bootstrap suitability | +| --- | --- | --- | +| `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` + `GCP_WORKLOAD_IDENTITY_PROVIDER` | Scheduler IAM / deploy executor for Environment `longbridge-paper` | **Rejected as bootstrap** — this is the deficient recipient; contract forbids equality with bootstrap. | +| `GCP_RUNTIME_SERVICE_ACCOUNT` | Cloud Run runtime service identity | Not an IAM role/policy writer in reviewed contracts. | +| `GCP_SCHEDULER_SERVICE_ACCOUNT` | Scheduler OIDC invoker | Invoker only; not project IAM admin. | +| `PAPER_READBACK_SERVICE_ACCOUNT` + `PAPER_READBACK_IDENTITY_PROVIDER` | Dedicated read-only PAPER readback Secrets | Documented read-only; wrong privilege class. | + +No Terraform or provisioning module in this repository names a compatible +bootstrap. Prefer reuse only after an owner-authorized proof of an **existing** +independent SA that already holds the four permissions below and a WIF binding +that trusts only this repository, protected `main`, and Environment +`longbridge-paper`. + +## Card A — recipient Environment fill (config only) + +Preconditions: + +1. PR #722 lookup-channel fix is merged to `main` (engineering merge only; still + no grant). Until then, grant runs remain blocked on blank mappings even after + fills. +2. Owner confirms Environment `longbridge-paper` still shows executor sha256 + `382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22`. +3. Exact authorization for GitHub Environment **variable create** on + `longbridge-paper` (not repository-only, not org-only). + +Action (human / approved operator; not this agent): + +1. Create Environment variable `PAPER_IAM_GRANT_RECIPIENT`. +2. Set its value to the **same bytes** as Environment + `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` (copy from the existing UI field). +3. Verify sha256(UTF-8(value)) == + `382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22`. +4. Leave repository/org copies unset for this name. + +Rollback: delete the Environment variable `PAPER_IAM_GRANT_RECIPIENT` if the +fill was wrong. No GCP IAM change is involved. + +Out of scope for Card A: grant workflow_dispatch, IAM apply, PAPER start. + +## Card B options — bootstrap identity + +### B0 — bounded discovery of an existing independent identity (preferred if found) + +Class: **discovery proposal only**. Variable absence does not imply cloud +absence. This engineering pass did **not** perform GCP GET (owner one-shot +budget already consumed elsewhere). Exact later read-only targets, once the +project id is taken from the privately known `GCP_PROJECT_ID` mapping: + +1. Resolve `` from Environment `GCP_PROJECT_ID` (hash + `5fd9761b…52152e`); do not publish the raw id in chat. +2. `serviceAccounts.get` / `getIamPolicy` only for candidate SA emails already + known from protected config or prior approved inventory — **no org-wide or + project-wide account listing**. +3. On each candidate, `projects.testIamPermissions` for exactly: + `iam.roles.get`, `resourcemanager.projects.getIamPolicy`, `iam.roles.create`, + `resourcemanager.projects.setIamPolicy`. +4. For a candidate that holds all four and whose email sha256 is **not** + `382a7cbe…697cc22`, read only that SA's WIF principal bindings and the + referenced `workloadIdentityPools.providers.get` for providers already named + in protected config. +5. Accept only if trust is limited to this repository, protected `main`, and + Environment `longbridge-paper` (or can be narrowed under a separate trust + card). + +If no candidate passes, do not widen search; proceed to B1. + +### B1 — minimal new bootstrap design (**PROPOSED_NOT_OBSERVED**) + +Proposed logical names (placeholders only; not created): + +| Resource | Proposed id / pattern | Notes | +| --- | --- | --- | +| Service account | `paper-iam-bootstrap@.iam.gserviceaccount.com` (display name e.g. `paper-iam-bootstrap`) | Same PAPER project as `GCP_PROJECT_ID`. Must remain distinct from recipient. | +| WIF pool | `github-paper-iam` (or reuse an existing pool **only** if a separate trust card proves isolation) | Prefer a dedicated pool so trading/deploy providers are untouched. | +| WIF provider | `github-paper-iam-bootstrap` | Attribute conditions: repository `QuantStrategyLab/LongBridgePlatform`, ref `refs/heads/main`, Environment `longbridge-paper`. | +| Custom role on project (bootstrap itself) | `paperIamBootstrap` | Exact permissions listed below — not `roles/owner` / `roles/resourcemanager.projectIamAdmin` unbounded. | +| GitHub Environment variables | `PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT`, `PAPER_IAM_BOOTSTRAP_WIF_PROVIDER` | Set only after resources exist and trust is verified. | + +Bootstrap custom role permissions (complete set): + +1. `iam.roles.get` +2. `resourcemanager.projects.getIamPolicy` +3. `iam.roles.create` +4. `resourcemanager.projects.setIamPolicy` + +Affected resources: + +- Project `` IAM policy (etag CAS via the grant script only). +- Project custom role `paperSchedulerIamExecutor` created/bound by the grant + workflow (recipient-facing), not by this provisioning card alone. +- WIF pool/provider and SA `roles/iam.workloadIdentityUser` binding for the + GitHub principal matching the provider condition. + +Trust principals: + +- GitHub OIDC subject for this repository + `main` + Environment + `longbridge-paper` only. +- Required Environment reviewers remain mandatory before any grant job auth + step. + +Explicitly forbidden: + +- Granting the four permissions (or broader project IAM admin) to + `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT`, `GCP_RUNTIME_SERVICE_ACCOUNT`, + `GCP_SCHEDULER_SERVICE_ACCOUNT`, or PAPER readback identities as a shortcut. +- Reusing the known deficient executor as bootstrap. +- Org-wide admin, key creation, or unbound `setIamPolicy` without Environment + gates. + +Preconditions for B1 apply: + +1. Separate owner card naming exact ``, SA id, pool/provider ids. +2. Single-writer window for project IAM and WIF admin. +3. Card A complete (recipient mapping installed) before any grant dry-run that + needs all six names. +4. PR #722 on `main` so the grant workflow can read mappings via `vars`. + +Rollback for B1: + +1. Remove Environment variables `PAPER_IAM_BOOTSTRAP_*` first (fail-closed). +2. Remove SA WIF user binding; disable/delete provider; delete SA only if unused. +3. Remove bootstrap custom role binding/role if created. +4. Never roll back by widening another identity's permissions. + +## Card C — grant dry-run / apply (future; not authorized here) + +After Cards A and B, a **separate** exact-revision owner run card may authorize +`workflow_dispatch` of `grant-paper-executor-permissions.yml` with +`mode=dry-run` first, then a new card for `apply`. Confirm string remains +`GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS`. This remediation note is not that card. + +Grant script effect if later authorized: create project role +`paperSchedulerIamExecutor` with the same four permissions and bind it only to +`serviceAccount:`. No PAPER runtime enablement. + +## Remaining unknowns (do not fabricate) + +- Live GCP presence/permissions of any SA behind the executor hash (no GCP GET + in this pass). +- Whether any non-executor SA already holds the four permissions. +- Effective WIF attribute conditions on existing providers. +- Whether Environment protection reviewers remain configured at grant time + (workflow re-checks; not re-verified here). +- Codex `ai-review` / clearing-label state for PR #722 (merge policy; no labels + added by this pass). + +## Verification posture + +Offline: contract tests in `tests/test_paper_executor_grant.py` and documentation +checks. Online grant, IAM, or PAPER verification requires future exact cards. diff --git a/docs/iam/paper-scheduler-get.md b/docs/iam/paper-scheduler-get.md index 5a465cb..47608da 100644 --- a/docs/iam/paper-scheduler-get.md +++ b/docs/iam/paper-scheduler-get.md @@ -235,24 +235,26 @@ URL, response, headers or mapping value is included. If any read fails, no mapping is exported, including previously read values. This diagnostic does not prove a permission repair or authorize a retry, grant, or PAPER start. -### Mapping provenance (reviewed Git scope) - -Authoritative in-repo sources for the three grant-specific mappings. This table -does not assert live Environment presence and does not invent identities. - -| Variable | Role | Reviewed state | Source path + commit + scope | -| --- | --- | --- | --- | -| `PAPER_IAM_GRANT_RECIPIENT` | Independent approved mapping that must equal the existing scheduler IAM executor (`GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT`) | `EXISTS_SOURCE_RESOLVED` for the intended value source; the separate Environment name still needs an authorized same-value fill when absent | Contract and dual-check: `docs/iam/paper-scheduler-get.md` (this section; introduced with `#720` / `1fef697`), `scripts/paper_executor_grant.py` `identity()`; executor auth source: `.github/workflows/apply-paper-scheduler-jobs-get.yml` (`vars.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT`, Environment `longbridge-paper`) | -| `PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT` | Existing authorized bootstrap SA in the same PAPER project, distinct from recipient; never the deficient executor | `REQUIRES_DESIGN` in reviewed Git/IaC/history — `#720` states no compatible authorized bootstrap is proved; no Terraform/provisioning module or committed email exists | Required by `.github/workflows/grant-paper-executor-permissions.yml` and `scripts/paper_executor_grant.py`; no reusable identity source in reviewed scope | -| `PAPER_IAM_BOOTSTRAP_WIF_PROVIDER` | Existing authorized WIF provider that actually trusts that bootstrap | `REQUIRES_DESIGN` in reviewed Git/IaC/history — same gap as bootstrap SA; do not reuse runtime/execution provider as a substitute without separate proof | Required by `.github/workflows/grant-paper-executor-permissions.yml` auth step; no reusable provider source in reviewed scope | - -Sibling scheduler mappings (`GCP_PROJECT_ID`, `PAPER_SCHEDULER_GET_PROJECT_ID`, -`GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT`, `GCP_WORKLOAD_IDENTITY_PROVIDER`) are -consumed by the existing scheduler IAM route and are out of scope for inventing -bootstrap. Owner browser review on 2026-11-07 (Asia/Shanghai) reported the three -`PAPER_IAM_*` names absent from the visible Environment/repo variable lists in -that reviewed scope only; that is not a claim of global absence. Live GET of -those names remains a bounded parent-Mac operation when separately authorized. +### Mapping provenance (reviewed Git scope + owner t1393) + +Authoritative in-repo sources for the three grant-specific mappings, plus +owner-provided GitHub variable presence. Offline remediation and approval-ready +cards live in +[paper-executor-grant-remediation.md](paper-executor-grant-remediation.md). +This table does not invent cloud identities. Variable absence is not cloud +identity absence. + +| Variable | Role | Intent state | Installed GitHub config (owner t1393) | Source path + commit + scope | +| --- | --- | --- | --- | --- | +| `PAPER_IAM_GRANT_RECIPIENT` | Independent approved mapping that must equal the existing scheduler IAM executor (`GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT`) | `EXISTS_SOURCE_RESOLVED` — intended value is the Environment executor mapping | **Absent** exact name in Environment then repository. Expected post-fill value sha256 (UTF-8) equals executor hash `382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22` (executor updated 2026-11-03T06:51:52Z). | Contract: this section / `#720` `1fef697`; dual-check `scripts/paper_executor_grant.py` `identity()`; executor installed source: Environment `longbridge-paper` / `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` via `.github/workflows/apply-paper-scheduler-jobs-get.yml` | +| `PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT` | Existing authorized bootstrap SA in the same PAPER project, distinct from recipient; never the deficient executor | `REQUIRES_DESIGN` — no proved reusable bootstrap in Git/IaC | **Absent** exact name in Environment then repository. Any concrete SA id remains `PROPOSED_NOT_OBSERVED` until a discovery or provisioning card proves it. | Required by `.github/workflows/grant-paper-executor-permissions.yml` and `scripts/paper_executor_grant.py`; see remediation Card B | +| `PAPER_IAM_BOOTSTRAP_WIF_PROVIDER` | Existing authorized WIF provider that actually trusts that bootstrap | `REQUIRES_DESIGN` — same gap as bootstrap SA | **Absent** exact name in Environment then repository. Provider resource `PROPOSED_NOT_OBSERVED` until proved. | Required by grant workflow auth step; see remediation Card B | + +Sibling scheduler mappings observed nonempty in Environment `longbridge-paper` +(owner t1393): `GCP_PROJECT_ID` and `PAPER_SCHEDULER_GET_PROJECT_ID` share sha256 +`5fd9761bc0f5e7a1b75fc007b946b554af81773faee45d940bbf52c04c52152e` (project pair +already consistent). `GCP_WORKLOAD_IDENTITY_PROVIDER` remains the scheduler +executor auth provider and is not a bootstrap substitute without separate proof. Role permission responses are checked for exact membership and cardinality, independent of order. Fresh role snapshots normalize permission ordering while diff --git a/tests/test_paper_executor_grant.py b/tests/test_paper_executor_grant.py index f156710..7fe09b6 100644 --- a/tests/test_paper_executor_grant.py +++ b/tests/test_paper_executor_grant.py @@ -153,6 +153,28 @@ def test_workflow_protection_contract(): assert "secrets." not in text +def test_remediation_artifact_separates_intent_install_and_proposals(): + root = Path(__file__).parents[1] + remediation = (root / "docs/iam/paper-executor-grant-remediation.md").read_text() + design = (root / "docs/iam/paper-scheduler-get.md").read_text() + for token in [ + "OWNER-PROVIDED", "PROPOSED_NOT_OBSERVED", "Card A", "Card B", + "382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22", + "5fd9761bc0f5e7a1b75fc007b946b554af81773faee45d940bbf52c04c52152e", + "Variable absence is **not** evidence", + "Rejected as bootstrap", + "paper-iam-bootstrap@", + "GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS", + "no grant", + ]: + assert token in remediation + assert "paper-executor-grant-remediation.md" in design + assert "382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22" in design + # Recipient expected hash must track the installed executor mapping, not a new identity. + assert remediation.index("GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT") < remediation.index( + "382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22") + + @pytest.mark.parametrize("mode", ["dry-run", "apply"]) def test_role_response_order_is_irrelevant(mode): cloud = Cloud() From e205ab4cb2f08f0c5c23f7d46d4c6f110ac468b8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Nov 2026 10:03:04 +0000 Subject: [PATCH 3/5] fix: Reject caller-scoped IAM discovery false passes Correct B0 evidence rules for projects.testIamPermissions caller semantics, clarify B1 versus Card C stages, and document org-vars as operational-only. Co-authored-by: Pigbibi --- .../grant-paper-executor-permissions.yml | 5 +- docs/iam/paper-executor-grant-remediation.md | 134 +++++++++++++----- docs/iam/paper-scheduler-get.md | 5 +- scripts/paper_iam_discovery_evidence.py | 92 ++++++++++++ tests/test_paper_executor_grant.py | 6 + tests/test_paper_iam_discovery_evidence.py | 125 ++++++++++++++++ 6 files changed, 332 insertions(+), 35 deletions(-) create mode 100644 scripts/paper_iam_discovery_evidence.py create mode 100644 tests/test_paper_iam_discovery_evidence.py diff --git a/.github/workflows/grant-paper-executor-permissions.yml b/.github/workflows/grant-paper-executor-permissions.yml index 3b4ff4d..df80a33 100644 --- a/.github/workflows/grant-paper-executor-permissions.yml +++ b/.github/workflows/grant-paper-executor-permissions.yml @@ -57,8 +57,9 @@ jobs: # workflow GITHUB_TOKEN only receives contents/metadata and cannot read it # (run 42266306297 failed on the first existing name before authentication). # Mask every value before exporting it to later steps. Missing or blank - # mappings fail closed. Organization-only names remain unsupported: do not - # define these six names only at org scope. + # mappings fail closed. Organization-only names are an operational + # prerequisite (unsupported by policy) but are NOT technically rejected: + # vars has no scope tag and GITHUB_TOKEN cannot query Variables REST. - name: Mask protected mappings before exposing step environment uses: actions/github-script@v8 env: diff --git a/docs/iam/paper-executor-grant-remediation.md b/docs/iam/paper-executor-grant-remediation.md index 103a101..6d2de65 100644 --- a/docs/iam/paper-executor-grant-remediation.md +++ b/docs/iam/paper-executor-grant-remediation.md @@ -6,7 +6,8 @@ Environment approval, deployment, scheduler mutation nor PAPER start. Related definition: [paper-scheduler-get.md](paper-scheduler-get.md) and [grant-paper-executor-permissions.yml](../../.github/workflows/grant-paper-executor-permissions.yml). -Lookup-channel fix lineage: PR #722 (Draft). +Lookup-channel fix lineage: PR #722 (Draft). Offline discovery classifier: +[paper_iam_discovery_evidence.py](../../scripts/paper_iam_discovery_evidence.py). ## Owner-provided installed configuration (t1393) @@ -47,8 +48,8 @@ pool or provider is absent in the cloud project. ### In-repo identities reviewed (not adopted as bootstrap) These appear in workflows or docs and must **not** be treated as grant bootstrap -without separate proof that they hold exactly the four executor-grant -permissions and are distinct from the recipient: +without separate proof that they hold exactly the four bootstrap permissions and +are distinct from the recipient: | Identity channel | Role in repo | Bootstrap suitability | | --- | --- | --- | @@ -59,8 +60,8 @@ permissions and are distinct from the recipient: No Terraform or provisioning module in this repository names a compatible bootstrap. Prefer reuse only after an owner-authorized proof of an **existing** -independent SA that already holds the four permissions below and a WIF binding -that trusts only this repository, protected `main`, and Environment +independent SA that already holds the four bootstrap permissions below and a +WIF binding that trusts only this repository, protected `main`, and Environment `longbridge-paper`. ## Card A — recipient Environment fill (config only) @@ -82,7 +83,7 @@ Action (human / approved operator; not this agent): `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` (copy from the existing UI field). 3. Verify sha256(UTF-8(value)) == `382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22`. -4. Leave repository/org copies unset for this name. +4. Leave repository/org copies unset for this name (operational; see org-vars note). Rollback: delete the Environment variable `PAPER_IAM_GRANT_RECIPIENT` if the fill was wrong. No GCP IAM change is involved. @@ -94,25 +95,58 @@ Out of scope for Card A: grant workflow_dispatch, IAM apply, PAPER start. ### B0 — bounded discovery of an existing independent identity (preferred if found) Class: **discovery proposal only**. Variable absence does not imply cloud -absence. This engineering pass did **not** perform GCP GET (owner one-shot -budget already consumed elsewhere). Exact later read-only targets, once the -project id is taken from the privately known `GCP_PROJECT_ID` mapping: +absence. This engineering pass did **not** perform GCP GET. Exact later +read-only targets, once the project id is taken from the privately known +`GCP_PROJECT_ID` mapping: + +Always keep these axes separate in any evidence pack: + +| Axis | Meaning | +| --- | --- | +| Candidate principal | `serviceAccount:` under review for bootstrap | +| Evaluated resource | `projects/` (project IAM powers) | +| Evaluated permissions | The four bootstrap permissions listed under B1 | +| Actual caller | Authenticated principal that invoked each API | +| Trust binding | Who may act as the SA (SA `getIamPolicy` / WIF user binding) | + +Official contract: +[`projects.testIamPermissions`](https://cloud.google.com/resource-manager/reference/rest/v1/projects/testIamPermissions) +**returns permissions that a caller has on the specified Project**. The request +body accepts only `permissions[]` — there is **no** candidate-principal field. +Therefore an operator (or any non-candidate caller) invoking +`testIamPermissions` and observing all four permissions **must not** be treated +as proof that the candidate holds them. That pattern is a **false pass** +(`caller_test_iam_not_candidate_effective`). Offline classifier: +`scripts/paper_iam_discovery_evidence.py`. + +Forbidden proof techniques for B0: + +- Impersonating the candidate or minting a token as the candidate, then calling + `testIamPermissions` (bypass). +- Treating SA `getIamPolicy` / WIF bindings as project-effective permission proof + (`sa_get_iam_policy_is_trust_not_project_perm`). +- Equating the candidate with the recipient/executor principal. + +Accepted proof shape (owner-authorized read-only later): 1. Resolve `` from Environment `GCP_PROJECT_ID` (hash `5fd9761b…52152e`); do not publish the raw id in chat. -2. `serviceAccounts.get` / `getIamPolicy` only for candidate SA emails already - known from protected config or prior approved inventory — **no org-wide or - project-wide account listing**. -3. On each candidate, `projects.testIamPermissions` for exactly: - `iam.roles.get`, `resourcemanager.projects.getIamPolicy`, `iam.roles.create`, - `resourcemanager.projects.setIamPolicy`. -4. For a candidate that holds all four and whose email sha256 is **not** - `382a7cbe…697cc22`, read only that SA's WIF principal bindings and the - referenced `workloadIdentityPools.providers.get` for providers already named - in protected config. -5. Accept only if trust is limited to this repository, protected `main`, and - Environment `longbridge-paper` (or can be narrowed under a separate trust - card). +2. `serviceAccounts.get` only for candidate emails already known from protected + config or prior approved inventory — **no org-wide or project-wide account + listing**. +3. Read **project** `getIamPolicy` and collect role bindings whose `members` + include the candidate principal. Expand each bound role's permission set via + `iam.roles.get` for custom roles (or an owner-approved predefined-role + permission baseline). Candidate is acceptable for permissions only if the + union of those expanded sets covers exactly-needed bootstrap permissions + (at least the four; extra permissions require a separate risk note). +4. Separately, read the candidate SA's `getIamPolicy` **only** to inspect WIF / + `roles/iam.workloadIdentityUser` trust — never as a substitute for step 3. +5. For providers already named in protected config, `workloadIdentityPools.providers.get` + and accept only if trust is limited to this repository, protected `main`, and + Environment `longbridge-paper` (or can be narrowed under a separate trust card). +6. Reject any pack where `actual_caller == operator` and the only permission + evidence is that caller's `testIamPermissions` result. If no candidate passes, do not widen search; proceed to B1. @@ -128,7 +162,8 @@ Proposed logical names (placeholders only; not created): | Custom role on project (bootstrap itself) | `paperIamBootstrap` | Exact permissions listed below — not `roles/owner` / `roles/resourcemanager.projectIamAdmin` unbounded. | | GitHub Environment variables | `PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT`, `PAPER_IAM_BOOTSTRAP_WIF_PROVIDER` | Set only after resources exist and trust is verified. | -Bootstrap custom role permissions (complete set): +Bootstrap custom role permissions (complete set) — held by bootstrap **before** +any Card C run so the grant job can authenticate as bootstrap: 1. `iam.roles.get` 2. `resourcemanager.projects.getIamPolicy` @@ -150,11 +185,26 @@ Trust principals: - Required Environment reviewers remain mandatory before any grant job auth step. +#### B1 versus Card C — same four permission strings, different stage and principal + +Owner decision preserved: the grant ultimately binds the four permissions to the +**recipient** (scheduler IAM executor) so that executor can run the scheduler +jobs.get IAM route. That is Card C's intentional outcome. + +| Stage | Principal | Four permissions meaning | Allowed? | +| --- | --- | --- | --- | +| B1 provision / B0 accept | Bootstrap SA (≠ recipient) | Bootstrap must **already hold** them to execute the grant workflow | Required for bootstrap | +| B1 forbidden shortcut | Execution / runtime / scheduler / readback identities | Do **not** grant them to these as a way to skip a distinct bootstrap | Forbidden | +| Card C apply | Recipient = `PAPER_IAM_GRANT_RECIPIENT` (= executor) | Grant workflow **binds** role `paperSchedulerIamExecutor` containing the same four strings to the recipient | Intended outcome after dry-run | + +There is no contradiction: forbidding “execution identity as bootstrap” blocks +self-elevation without a separate trusted caller; Card C is that separate, +Environment-gated write performed **by** bootstrap **to** recipient. + Explicitly forbidden: -- Granting the four permissions (or broader project IAM admin) to - `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT`, `GCP_RUNTIME_SERVICE_ACCOUNT`, - `GCP_SCHEDULER_SERVICE_ACCOUNT`, or PAPER readback identities as a shortcut. +- Using `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT`, `GCP_RUNTIME_SERVICE_ACCOUNT`, + `GCP_SCHEDULER_SERVICE_ACCOUNT`, or PAPER readback identities **as bootstrap**. - Reusing the known deficient executor as bootstrap. - Org-wide admin, key creation, or unbound `setIamPolicy` without Environment gates. @@ -179,17 +229,35 @@ Rollback for B1: After Cards A and B, a **separate** exact-revision owner run card may authorize `workflow_dispatch` of `grant-paper-executor-permissions.yml` with `mode=dry-run` first, then a new card for `apply`. Confirm string remains -`GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS`. This remediation note is not that card. +`GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS`. First attempt only; Environment +approval; masking before auth; fail-closed. This remediation note is not that +card. Grant script effect if later authorized: create project role -`paperSchedulerIamExecutor` with the same four permissions and bind it only to -`serviceAccount:`. No PAPER runtime enablement. +`paperSchedulerIamExecutor` with the four permissions and bind it only to +`serviceAccount:`. Bootstrap remains the authenticated +caller; recipient is the binding member. No PAPER runtime enablement. + +## Org-inherited GitHub variables (claim versus enforcement) + +The grant workflow resolves mappings through the job Environment `vars` context. +GitHub's documented resolution order is Environment → repository → organization. +This repository's **policy** is that the six grant mapping names must not be +defined only at organization scope. + +**Enforcement status:** operational prerequisite only. The workflow does **not** +technically detect or reject an org-only value: `vars.` returns a string +without a scope tag, and the Variables REST API is unavailable to +`GITHUB_TOKEN` with `contents: read` (historical run `42266306297`). Comments and +docs must not claim a technical org-only block. Operators must keep these names +unset at org scope; Card A step 4 restates that control. ## Remaining unknowns (do not fabricate) - Live GCP presence/permissions of any SA behind the executor hash (no GCP GET in this pass). -- Whether any non-executor SA already holds the four permissions. +- Whether any non-executor SA already holds the four permissions under project + policy expansion. - Effective WIF attribute conditions on existing providers. - Whether Environment protection reviewers remain configured at grant time (workflow re-checks; not re-verified here). @@ -198,5 +266,7 @@ Grant script effect if later authorized: create project role ## Verification posture -Offline: contract tests in `tests/test_paper_executor_grant.py` and documentation -checks. Online grant, IAM, or PAPER verification requires future exact cards. +Offline: contract and negative tests in `tests/test_paper_executor_grant.py` and +`tests/test_paper_iam_discovery_evidence.py`. Passing offline tests is **not** +merge acceptance or grant readiness. Online grant, IAM, or PAPER verification +requires future exact cards and independent review markers on the exact head. diff --git a/docs/iam/paper-scheduler-get.md b/docs/iam/paper-scheduler-get.md index 47608da..49fd2fb 100644 --- a/docs/iam/paper-scheduler-get.md +++ b/docs/iam/paper-scheduler-get.md @@ -222,7 +222,10 @@ then same-name repository), matching It does not call the GitHub Variables REST API: workflow `GITHUB_TOKEN` with `contents: read` lacks that authority, and historical run `42266306297` failed on the first already-configured name before authentication. Organization-only -variables remain unsupported; do not define these six names only at org scope. +definitions of these six names are unsupported by **policy** (operational +prerequisite). The `vars` channel does **not** technically detect or reject +org-only values: resolution may still return an org string, and this workflow +cannot scope-tag the source. Do not define these names only at org scope. All mappings must be nonempty strings; blank or malformed values stop before authentication. No GitHub token permission or Environment configuration is changed by this workflow definition. diff --git a/scripts/paper_iam_discovery_evidence.py b/scripts/paper_iam_discovery_evidence.py new file mode 100644 index 0000000..3a376af --- /dev/null +++ b/scripts/paper_iam_discovery_evidence.py @@ -0,0 +1,92 @@ +"""Offline classifiers for PAPER IAM bootstrap discovery evidence. + +No network I/O. Encodes the B0 contract so operator-held permissions cannot be +mistaken for a candidate service account's project permissions. +""" + +from __future__ import annotations + +# Same four permissions the grant bootstrap must hold to run Card C. +BOOTSTRAP_PERMISSIONS = ( + "iam.roles.get", + "resourcemanager.projects.getIamPolicy", + "iam.roles.create", + "resourcemanager.projects.setIamPolicy", +) + +# Recipient receives these via Card C as role paperSchedulerIamExecutor. +# That outcome is intentional and distinct from using the recipient as bootstrap. +RECIPIENT_GRANT_PERMISSIONS = BOOTSTRAP_PERMISSIONS + + +def reject_reasons( + *, + candidate_principal: str, + evaluated_resource: str, + actual_caller: str, + evaluated_permissions: tuple[str, ...] | list[str], + caller_test_iam_permissions: set[str] | None = None, + candidate_project_role_bindings: dict[str, set[str]] | None = None, + candidate_role_permission_sets: dict[str, set[str]] | None = None, + candidate_sa_iam_policy_members: set[str] | None = None, + impersonation_or_token_mint_used: bool = False, + recipient_executor_principal: str | None = None, +) -> list[str]: + """Return fixed reject categories; empty list means evidence is acceptable. + + Distinguishes: + - candidate_principal: identity whose project powers are under review + - evaluated_resource: usually projects/ + - evaluated_permissions: permission strings being checked + - actual_caller: authenticated principal that invoked any API + - trust binding on the SA (getIamPolicy) vs project-effective permissions + """ + reasons: list[str] = [] + wanted = set(evaluated_permissions) + if not candidate_principal or not evaluated_resource or not actual_caller: + reasons.append("missing_principal_resource_or_caller") + if not wanted: + reasons.append("missing_evaluated_permissions") + if recipient_executor_principal and candidate_principal == recipient_executor_principal: + reasons.append("candidate_equals_recipient_executor") + if impersonation_or_token_mint_used: + reasons.append("impersonation_or_token_mint_forbidden") + + # projects.testIamPermissions returns only the caller's permissions on the + # resource; the request has no candidate principal field. + # https://cloud.google.com/resource-manager/reference/rest/v1/projects/testIamPermissions + if caller_test_iam_permissions is not None: + if actual_caller != candidate_principal: + if wanted <= set(caller_test_iam_permissions): + reasons.append("caller_test_iam_not_candidate_effective") + reasons.append("test_iam_permissions_is_caller_scoped") + + # SA getIamPolicy describes who may act as the SA (trust), not which project + # permissions the SA itself holds. + if candidate_sa_iam_policy_members is not None and candidate_project_role_bindings is None: + reasons.append("sa_get_iam_policy_is_trust_not_project_perm") + + held: set[str] = set() + if candidate_project_role_bindings and candidate_role_permission_sets: + for role, members in candidate_project_role_bindings.items(): + if candidate_principal in members: + held |= set(candidate_role_permission_sets.get(role, ())) + if not (wanted <= held): + reasons.append("candidate_project_bindings_missing_permissions") + elif candidate_project_role_bindings is None and caller_test_iam_permissions is None: + reasons.append("no_candidate_effective_permission_evidence") + elif candidate_project_role_bindings is not None and candidate_role_permission_sets is None: + reasons.append("role_permission_expansion_missing") + + # De-duplicate while preserving order. + seen: set[str] = set() + ordered: list[str] = [] + for reason in reasons: + if reason not in seen: + seen.add(reason) + ordered.append(reason) + return ordered + + +def evidence_accepted(**kwargs) -> bool: + return not reject_reasons(**kwargs) diff --git a/tests/test_paper_executor_grant.py b/tests/test_paper_executor_grant.py index 7fe09b6..fe8c038 100644 --- a/tests/test_paper_executor_grant.py +++ b/tests/test_paper_executor_grant.py @@ -157,6 +157,7 @@ def test_remediation_artifact_separates_intent_install_and_proposals(): root = Path(__file__).parents[1] remediation = (root / "docs/iam/paper-executor-grant-remediation.md").read_text() design = (root / "docs/iam/paper-scheduler-get.md").read_text() + workflow = (root / ".github/workflows/grant-paper-executor-permissions.yml").read_text() for token in [ "OWNER-PROVIDED", "PROPOSED_NOT_OBSERVED", "Card A", "Card B", "382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22", @@ -166,10 +167,15 @@ def test_remediation_artifact_separates_intent_install_and_proposals(): "paper-iam-bootstrap@", "GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS", "no grant", + "caller_test_iam_not_candidate_effective", + "B1 versus Card C", + "operational prerequisite", ]: assert token in remediation assert "paper-executor-grant-remediation.md" in design assert "382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22" in design + assert "does **not** technically detect or reject" in design + assert "NOT technically rejected" in workflow # Recipient expected hash must track the installed executor mapping, not a new identity. assert remediation.index("GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT") < remediation.index( "382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22") diff --git a/tests/test_paper_iam_discovery_evidence.py b/tests/test_paper_iam_discovery_evidence.py new file mode 100644 index 0000000..c72b8f4 --- /dev/null +++ b/tests/test_paper_iam_discovery_evidence.py @@ -0,0 +1,125 @@ +"""Fake-only negative contracts for bootstrap discovery evidence semantics.""" +from scripts.paper_iam_discovery_evidence import ( + BOOTSTRAP_PERMISSIONS, + RECIPIENT_GRANT_PERMISSIONS, + evidence_accepted, + reject_reasons, +) + +PROJECT = "projects/fake-paper-project" +OPERATOR = "user:operator@example.invalid" +CANDIDATE = "serviceAccount:candidate@fake-paper-project.iam.gserviceaccount.com" +EXECUTOR = "serviceAccount:executor@fake-paper-project.iam.gserviceaccount.com" +ROLE = "projects/fake-paper-project/roles/paperIamBootstrap" + + +def test_bootstrap_and_recipient_permission_strings_match_by_owner_design(): + # Same strings; different stage/principal — see remediation B1 versus Card C. + assert BOOTSTRAP_PERMISSIONS == RECIPIENT_GRANT_PERMISSIONS + assert len(BOOTSTRAP_PERMISSIONS) == 4 + + +def test_operator_test_iam_holding_all_four_does_not_prove_candidate(): + reasons = reject_reasons( + candidate_principal=CANDIDATE, + evaluated_resource=PROJECT, + actual_caller=OPERATOR, + evaluated_permissions=BOOTSTRAP_PERMISSIONS, + caller_test_iam_permissions=set(BOOTSTRAP_PERMISSIONS), + candidate_project_role_bindings={ROLE: set()}, # candidate unbound + candidate_role_permission_sets={ROLE: set(BOOTSTRAP_PERMISSIONS)}, + candidate_sa_iam_policy_members={OPERATOR}, + ) + assert "caller_test_iam_not_candidate_effective" in reasons + assert "test_iam_permissions_is_caller_scoped" in reasons + assert "candidate_project_bindings_missing_permissions" in reasons + assert not evidence_accepted( + candidate_principal=CANDIDATE, + evaluated_resource=PROJECT, + actual_caller=OPERATOR, + evaluated_permissions=BOOTSTRAP_PERMISSIONS, + caller_test_iam_permissions=set(BOOTSTRAP_PERMISSIONS), + ) + + +def test_sa_get_iam_policy_alone_is_trust_not_project_permission_proof(): + reasons = reject_reasons( + candidate_principal=CANDIDATE, + evaluated_resource=PROJECT, + actual_caller=OPERATOR, + evaluated_permissions=BOOTSTRAP_PERMISSIONS, + candidate_sa_iam_policy_members={ + "principalSet://iam.googleapis.com/projects/1/locations/global/" + "workloadIdentityPools/fake/attribute.repository/" + "QuantStrategyLab/LongBridgePlatform" + }, + ) + assert "sa_get_iam_policy_is_trust_not_project_perm" in reasons + assert not evidence_accepted( + candidate_principal=CANDIDATE, + evaluated_resource=PROJECT, + actual_caller=OPERATOR, + evaluated_permissions=BOOTSTRAP_PERMISSIONS, + candidate_sa_iam_policy_members={"user:anyone@example.invalid"}, + ) + + +def test_impersonation_or_token_mint_is_rejected_even_if_bindings_look_complete(): + reasons = reject_reasons( + candidate_principal=CANDIDATE, + evaluated_resource=PROJECT, + actual_caller=CANDIDATE, + evaluated_permissions=BOOTSTRAP_PERMISSIONS, + caller_test_iam_permissions=set(BOOTSTRAP_PERMISSIONS), + candidate_project_role_bindings={ROLE: {CANDIDATE}}, + candidate_role_permission_sets={ROLE: set(BOOTSTRAP_PERMISSIONS)}, + impersonation_or_token_mint_used=True, + ) + assert "impersonation_or_token_mint_forbidden" in reasons + assert not evidence_accepted( + candidate_principal=CANDIDATE, + evaluated_resource=PROJECT, + actual_caller=CANDIDATE, + evaluated_permissions=BOOTSTRAP_PERMISSIONS, + candidate_project_role_bindings={ROLE: {CANDIDATE}}, + candidate_role_permission_sets={ROLE: set(BOOTSTRAP_PERMISSIONS)}, + impersonation_or_token_mint_used=True, + ) + + +def test_candidate_equals_recipient_executor_is_rejected(): + reasons = reject_reasons( + candidate_principal=EXECUTOR, + evaluated_resource=PROJECT, + actual_caller=OPERATOR, + evaluated_permissions=BOOTSTRAP_PERMISSIONS, + candidate_project_role_bindings={ROLE: {EXECUTOR}}, + candidate_role_permission_sets={ROLE: set(BOOTSTRAP_PERMISSIONS)}, + recipient_executor_principal=EXECUTOR, + ) + assert "candidate_equals_recipient_executor" in reasons + + +def test_project_bindings_with_role_expansion_accept_distinct_candidate(): + assert evidence_accepted( + candidate_principal=CANDIDATE, + evaluated_resource=PROJECT, + actual_caller=OPERATOR, + evaluated_permissions=BOOTSTRAP_PERMISSIONS, + candidate_project_role_bindings={ROLE: {CANDIDATE}}, + candidate_role_permission_sets={ROLE: set(BOOTSTRAP_PERMISSIONS)}, + recipient_executor_principal=EXECUTOR, + impersonation_or_token_mint_used=False, + ) + + +def test_remediation_doc_forbids_caller_scoped_false_pass(): + from pathlib import Path + text = (Path(__file__).parents[1] / "docs/iam/paper-executor-grant-remediation.md").read_text() + assert "returns permissions that a caller has on the specified Project" in text + assert "caller_test_iam_not_candidate_effective" in text + assert "sa_get_iam_policy_is_trust_not_project_perm" in text + assert "B1 versus Card C" in text + assert "operational prerequisite" in text + assert "technically detect or reject an org-only value" in text + assert "must not claim a technical org-only block" in text From 17024456cffe05fbebc99526fdc75b06b1d90170 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Nov 2026 10:06:10 +0000 Subject: [PATCH 4/5] fix: Fail closed on incomplete IAM discovery context Treat allow-binding plus role expansion as UNKNOWN unless conditions, deny, PAB, inheritance, identity and time axes are authoritatively covered; keep rejected and unknown fail-closed. Co-authored-by: Pigbibi --- docs/iam/paper-executor-grant-remediation.md | 26 ++- scripts/paper_iam_discovery_evidence.py | 115 ++++++++-- tests/test_paper_iam_discovery_evidence.py | 210 +++++++++++++------ 3 files changed, 265 insertions(+), 86 deletions(-) diff --git a/docs/iam/paper-executor-grant-remediation.md b/docs/iam/paper-executor-grant-remediation.md index 6d2de65..26c3c76 100644 --- a/docs/iam/paper-executor-grant-remediation.md +++ b/docs/iam/paper-executor-grant-remediation.md @@ -127,6 +127,16 @@ Forbidden proof techniques for B0: (`sa_get_iam_policy_is_trust_not_project_perm`). - Equating the candidate with the recipient/executor principal. +Offline classifier verdicts (`scripts/paper_iam_discovery_evidence.py`): +`rejected` | `unknown` | `accepted`. **Fail-closed:** both `rejected` and +`unknown` deny bootstrap acceptance. A project **allow-binding plus role +permission expansion is necessary but not sufficient** for unconditional +effective permission. Conditional bindings (resource/time/principal CEL), +inherited allow/deny, Principal Access Boundary (PAB), and any unknown +evaluation context must remain `unknown` unless authoritatively covered. +Fake fixtures never prove live IAM; `accepted` is only an offline pack-completeness +signal. + Accepted proof shape (owner-authorized read-only later): 1. Resolve `` from Environment `GCP_PROJECT_ID` (hash @@ -135,20 +145,22 @@ Accepted proof shape (owner-authorized read-only later): config or prior approved inventory — **no org-wide or project-wide account listing**. 3. Read **project** `getIamPolicy` and collect role bindings whose `members` - include the candidate principal. Expand each bound role's permission set via - `iam.roles.get` for custom roles (or an owner-approved predefined-role - permission baseline). Candidate is acceptable for permissions only if the - union of those expanded sets covers exactly-needed bootstrap permissions - (at least the four; extra permissions require a separate risk note). + include the **exact** candidate principal. Expand each bound role's permission + set via `iam.roles.get` for custom roles (or an owner-approved predefined-role + permission baseline). Missing expansion is a hard reject. Binding membership + alone without authoritative condition/deny/PAB/inheritance/time/identity + coverage is `unknown`, not pass. 4. Separately, read the candidate SA's `getIamPolicy` **only** to inspect WIF / `roles/iam.workloadIdentityUser` trust — never as a substitute for step 3. 5. For providers already named in protected config, `workloadIdentityPools.providers.get` and accept only if trust is limited to this repository, protected `main`, and Environment `longbridge-paper` (or can be narrowed under a separate trust card). 6. Reject any pack where `actual_caller == operator` and the only permission - evidence is that caller's `testIamPermissions` result. + evidence is that caller's `testIamPermissions` result; reject resource + mismatch and stale value hashes when those axes are supplied. -If no candidate passes, do not widen search; proceed to B1. +If no candidate reaches authoritative `accepted`, do not widen search; proceed +to B1. ### B1 — minimal new bootstrap design (**PROPOSED_NOT_OBSERVED**) diff --git a/scripts/paper_iam_discovery_evidence.py b/scripts/paper_iam_discovery_evidence.py index 3a376af..a2ee873 100644 --- a/scripts/paper_iam_discovery_evidence.py +++ b/scripts/paper_iam_discovery_evidence.py @@ -1,7 +1,10 @@ """Offline classifiers for PAPER IAM bootstrap discovery evidence. No network I/O. Encodes the B0 contract so operator-held permissions cannot be -mistaken for a candidate service account's project permissions. +mistaken for a candidate service account's project permissions, and so a bare +project allow-binding plus role expansion is never treated as unconditional +effective permission when conditions, deny, PAB or inheritance context are +unknown. """ from __future__ import annotations @@ -18,6 +21,10 @@ # That outcome is intentional and distinct from using the recipient as bootstrap. RECIPIENT_GRANT_PERMISSIONS = BOOTSTRAP_PERMISSIONS +VERDICT_REJECTED = "rejected" +VERDICT_UNKNOWN = "unknown" +VERDICT_ACCEPTED = "accepted" + def reject_reasons( *, @@ -31,16 +38,13 @@ def reject_reasons( candidate_sa_iam_policy_members: set[str] | None = None, impersonation_or_token_mint_used: bool = False, recipient_executor_principal: str | None = None, + expected_resource: str | None = None, + evidence_resource: str | None = None, + expected_value_sha256: str | None = None, + observed_value_sha256: str | None = None, + **_ignored, ) -> list[str]: - """Return fixed reject categories; empty list means evidence is acceptable. - - Distinguishes: - - candidate_principal: identity whose project powers are under review - - evaluated_resource: usually projects/ - - evaluated_permissions: permission strings being checked - - actual_caller: authenticated principal that invoked any API - - trust binding on the SA (getIamPolicy) vs project-effective permissions - """ + """Return hard-reject categories (definite failure of the evidence pack).""" reasons: list[str] = [] wanted = set(evaluated_permissions) if not candidate_principal or not evaluated_resource or not actual_caller: @@ -52,6 +56,16 @@ def reject_reasons( if impersonation_or_token_mint_used: reasons.append("impersonation_or_token_mint_forbidden") + if expected_resource is not None and evidence_resource is not None: + if expected_resource != evidence_resource or evaluated_resource != evidence_resource: + reasons.append("resource_mismatch") + elif expected_resource is not None and expected_resource != evaluated_resource: + reasons.append("resource_mismatch") + + if expected_value_sha256 is not None and observed_value_sha256 is not None: + if expected_value_sha256 != observed_value_sha256: + reasons.append("stale_or_mismatched_value_hash") + # projects.testIamPermissions returns only the caller's permissions on the # resource; the request has no candidate principal field. # https://cloud.google.com/resource-manager/reference/rest/v1/projects/testIamPermissions @@ -68,9 +82,13 @@ def reject_reasons( held: set[str] = set() if candidate_project_role_bindings and candidate_role_permission_sets: + members_union: set[str] = set() for role, members in candidate_project_role_bindings.items(): + members_union |= set(members) if candidate_principal in members: held |= set(candidate_role_permission_sets.get(role, ())) + if candidate_principal not in members_union: + reasons.append("candidate_principal_not_in_bindings") if not (wanted <= held): reasons.append("candidate_project_bindings_missing_permissions") elif candidate_project_role_bindings is None and caller_test_iam_permissions is None: @@ -78,7 +96,78 @@ def reject_reasons( elif candidate_project_role_bindings is not None and candidate_role_permission_sets is None: reasons.append("role_permission_expansion_missing") - # De-duplicate while preserving order. + return _dedupe(reasons) + + +def unknown_reasons( + *, + candidate_project_role_bindings: dict[str, set[str]] | None = None, + candidate_role_permission_sets: dict[str, set[str]] | None = None, + bindings_have_conditions: bool | None = None, + conditions_authoritatively_satisfied: bool | None = None, + deny_policies_authoritatively_evaluated: bool | None = None, + principal_access_boundary_authoritatively_evaluated: bool | None = None, + inherited_allow_deny_context_complete: bool | None = None, + principal_identity_context_complete: bool | None = None, + time_context_complete: bool | None = None, + **_ignored, +) -> list[str]: + """Return UNKNOWN categories: fail-closed unless context is authoritatively covered. + + A project allow-binding plus role permission expansion is necessary but not + sufficient for unconditional effective permission. Conditional bindings, + unsatisfied resource/time/principal CEL, inherited allow/deny, and Principal + Access Boundary (PAB) must not produce ACCEPTED when uncovered. + """ + reasons: list[str] = [] + has_binding_expansion = bool( + candidate_project_role_bindings and candidate_role_permission_sets + ) + if not has_binding_expansion: + return reasons + + if bindings_have_conditions is None: + reasons.append("binding_condition_presence_unknown") + elif bindings_have_conditions and conditions_authoritatively_satisfied is not True: + reasons.append("conditional_binding_not_authoritatively_satisfied") + + if deny_policies_authoritatively_evaluated is not True: + reasons.append("deny_policy_context_unknown") + if principal_access_boundary_authoritatively_evaluated is not True: + reasons.append("principal_access_boundary_context_unknown") + if inherited_allow_deny_context_complete is not True: + reasons.append("inherited_allow_deny_context_unknown") + if principal_identity_context_complete is not True: + reasons.append("principal_identity_context_unknown") + if time_context_complete is not True: + reasons.append("time_context_unknown") + + return _dedupe(reasons) + + +def evidence_verdict(**kwargs) -> str: + """Three-state verdict: rejected | unknown | accepted. + + Fake fixtures never prove live IAM. ACCEPTED only means the offline pack + supplied authoritative coverage for every required axis; it is not a cloud + permission proof. + """ + if reject_reasons(**kwargs): + return VERDICT_REJECTED + if unknown_reasons(**kwargs): + return VERDICT_UNKNOWN + if not kwargs.get("candidate_project_role_bindings") or not kwargs.get( + "candidate_role_permission_sets"): + return VERDICT_UNKNOWN + return VERDICT_ACCEPTED + + +def evidence_accepted(**kwargs) -> bool: + """True only for ACCEPTED. UNKNOWN and REJECTED are both fail-closed.""" + return evidence_verdict(**kwargs) == VERDICT_ACCEPTED + + +def _dedupe(reasons: list[str]) -> list[str]: seen: set[str] = set() ordered: list[str] = [] for reason in reasons: @@ -86,7 +175,3 @@ def reject_reasons( seen.add(reason) ordered.append(reason) return ordered - - -def evidence_accepted(**kwargs) -> bool: - return not reject_reasons(**kwargs) diff --git a/tests/test_paper_iam_discovery_evidence.py b/tests/test_paper_iam_discovery_evidence.py index c72b8f4..0748c20 100644 --- a/tests/test_paper_iam_discovery_evidence.py +++ b/tests/test_paper_iam_discovery_evidence.py @@ -1,49 +1,87 @@ -"""Fake-only negative contracts for bootstrap discovery evidence semantics.""" +"""Fake-only adversarial contracts for bootstrap discovery evidence semantics. + +These fixtures exercise classifier fail-closed behavior only. They do not prove +live GCP permissions. +""" +from pathlib import Path + from scripts.paper_iam_discovery_evidence import ( BOOTSTRAP_PERMISSIONS, RECIPIENT_GRANT_PERMISSIONS, + VERDICT_ACCEPTED, + VERDICT_REJECTED, + VERDICT_UNKNOWN, evidence_accepted, + evidence_verdict, reject_reasons, + unknown_reasons, ) PROJECT = "projects/fake-paper-project" +OTHER_PROJECT = "projects/other-fake-project" OPERATOR = "user:operator@example.invalid" CANDIDATE = "serviceAccount:candidate@fake-paper-project.iam.gserviceaccount.com" +OTHER_CANDIDATE = "serviceAccount:other@fake-paper-project.iam.gserviceaccount.com" EXECUTOR = "serviceAccount:executor@fake-paper-project.iam.gserviceaccount.com" ROLE = "projects/fake-paper-project/roles/paperIamBootstrap" +EXECUTOR_HASH = "382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22" +STALE_HASH = "0000000000000000000000000000000000000000000000000000000000000000" + + +def _base(**overrides): + payload = dict( + candidate_principal=CANDIDATE, + evaluated_resource=PROJECT, + actual_caller=OPERATOR, + evaluated_permissions=BOOTSTRAP_PERMISSIONS, + candidate_project_role_bindings={ROLE: {CANDIDATE}}, + candidate_role_permission_sets={ROLE: set(BOOTSTRAP_PERMISSIONS)}, + recipient_executor_principal=EXECUTOR, + impersonation_or_token_mint_used=False, + ) + payload.update(overrides) + return payload + + +def _authoritative_context(**overrides): + """Only for offline ACCEPTED shape tests — not a live IAM attestation.""" + payload = _base( + bindings_have_conditions=False, + conditions_authoritatively_satisfied=True, + deny_policies_authoritatively_evaluated=True, + principal_access_boundary_authoritatively_evaluated=True, + inherited_allow_deny_context_complete=True, + principal_identity_context_complete=True, + time_context_complete=True, + expected_resource=PROJECT, + evidence_resource=PROJECT, + expected_value_sha256=EXECUTOR_HASH, + observed_value_sha256=EXECUTOR_HASH, + ) + payload.update(overrides) + return payload def test_bootstrap_and_recipient_permission_strings_match_by_owner_design(): - # Same strings; different stage/principal — see remediation B1 versus Card C. assert BOOTSTRAP_PERMISSIONS == RECIPIENT_GRANT_PERMISSIONS assert len(BOOTSTRAP_PERMISSIONS) == 4 def test_operator_test_iam_holding_all_four_does_not_prove_candidate(): - reasons = reject_reasons( - candidate_principal=CANDIDATE, - evaluated_resource=PROJECT, - actual_caller=OPERATOR, - evaluated_permissions=BOOTSTRAP_PERMISSIONS, + payload = _base( caller_test_iam_permissions=set(BOOTSTRAP_PERMISSIONS), - candidate_project_role_bindings={ROLE: set()}, # candidate unbound - candidate_role_permission_sets={ROLE: set(BOOTSTRAP_PERMISSIONS)}, - candidate_sa_iam_policy_members={OPERATOR}, + candidate_project_role_bindings={ROLE: set()}, ) + reasons = reject_reasons(**payload) assert "caller_test_iam_not_candidate_effective" in reasons assert "test_iam_permissions_is_caller_scoped" in reasons - assert "candidate_project_bindings_missing_permissions" in reasons - assert not evidence_accepted( - candidate_principal=CANDIDATE, - evaluated_resource=PROJECT, - actual_caller=OPERATOR, - evaluated_permissions=BOOTSTRAP_PERMISSIONS, - caller_test_iam_permissions=set(BOOTSTRAP_PERMISSIONS), - ) + assert "candidate_principal_not_in_bindings" in reasons + assert evidence_verdict(**payload) == VERDICT_REJECTED + assert not evidence_accepted(**payload) def test_sa_get_iam_policy_alone_is_trust_not_project_permission_proof(): - reasons = reject_reasons( + payload = dict( candidate_principal=CANDIDATE, evaluated_resource=PROJECT, actual_caller=OPERATOR, @@ -54,67 +92,109 @@ def test_sa_get_iam_policy_alone_is_trust_not_project_permission_proof(): "QuantStrategyLab/LongBridgePlatform" }, ) - assert "sa_get_iam_policy_is_trust_not_project_perm" in reasons - assert not evidence_accepted( - candidate_principal=CANDIDATE, - evaluated_resource=PROJECT, - actual_caller=OPERATOR, - evaluated_permissions=BOOTSTRAP_PERMISSIONS, - candidate_sa_iam_policy_members={"user:anyone@example.invalid"}, - ) + assert "sa_get_iam_policy_is_trust_not_project_perm" in reject_reasons(**payload) + assert evidence_verdict(**payload) == VERDICT_REJECTED def test_impersonation_or_token_mint_is_rejected_even_if_bindings_look_complete(): - reasons = reject_reasons( - candidate_principal=CANDIDATE, - evaluated_resource=PROJECT, + payload = _authoritative_context( actual_caller=CANDIDATE, - evaluated_permissions=BOOTSTRAP_PERMISSIONS, caller_test_iam_permissions=set(BOOTSTRAP_PERMISSIONS), - candidate_project_role_bindings={ROLE: {CANDIDATE}}, - candidate_role_permission_sets={ROLE: set(BOOTSTRAP_PERMISSIONS)}, - impersonation_or_token_mint_used=True, - ) - assert "impersonation_or_token_mint_forbidden" in reasons - assert not evidence_accepted( - candidate_principal=CANDIDATE, - evaluated_resource=PROJECT, - actual_caller=CANDIDATE, - evaluated_permissions=BOOTSTRAP_PERMISSIONS, - candidate_project_role_bindings={ROLE: {CANDIDATE}}, - candidate_role_permission_sets={ROLE: set(BOOTSTRAP_PERMISSIONS)}, impersonation_or_token_mint_used=True, ) + assert "impersonation_or_token_mint_forbidden" in reject_reasons(**payload) + assert evidence_verdict(**payload) == VERDICT_REJECTED def test_candidate_equals_recipient_executor_is_rejected(): - reasons = reject_reasons( - candidate_principal=EXECUTOR, - evaluated_resource=PROJECT, - actual_caller=OPERATOR, - evaluated_permissions=BOOTSTRAP_PERMISSIONS, - candidate_project_role_bindings={ROLE: {EXECUTOR}}, - candidate_role_permission_sets={ROLE: set(BOOTSTRAP_PERMISSIONS)}, - recipient_executor_principal=EXECUTOR, + payload = _authoritative_context(candidate_principal=EXECUTOR) + assert "candidate_equals_recipient_executor" in reject_reasons(**payload) + assert evidence_verdict(**payload) == VERDICT_REJECTED + + +def test_candidate_principal_mismatch_in_bindings_is_rejected(): + payload = _base(candidate_project_role_bindings={ROLE: {OTHER_CANDIDATE}}) + reasons = reject_reasons(**payload) + assert "candidate_principal_not_in_bindings" in reasons + assert "candidate_project_bindings_missing_permissions" in reasons + assert evidence_verdict(**payload) == VERDICT_REJECTED + + +def test_role_expansion_missing_is_rejected(): + payload = _base(candidate_role_permission_sets=None) + assert "role_permission_expansion_missing" in reject_reasons(**payload) + assert evidence_verdict(**payload) == VERDICT_REJECTED + + +def test_resource_mismatch_is_rejected(): + payload = _authoritative_context( + expected_resource=PROJECT, + evidence_resource=OTHER_PROJECT, + evaluated_resource=OTHER_PROJECT, ) - assert "candidate_equals_recipient_executor" in reasons + assert "resource_mismatch" in reject_reasons(**payload) + assert evidence_verdict(**payload) == VERDICT_REJECTED -def test_project_bindings_with_role_expansion_accept_distinct_candidate(): - assert evidence_accepted( - candidate_principal=CANDIDATE, - evaluated_resource=PROJECT, - actual_caller=OPERATOR, - evaluated_permissions=BOOTSTRAP_PERMISSIONS, - candidate_project_role_bindings={ROLE: {CANDIDATE}}, - candidate_role_permission_sets={ROLE: set(BOOTSTRAP_PERMISSIONS)}, - recipient_executor_principal=EXECUTOR, - impersonation_or_token_mint_used=False, +def test_stale_value_hash_is_rejected(): + payload = _authoritative_context(observed_value_sha256=STALE_HASH) + assert "stale_or_mismatched_value_hash" in reject_reasons(**payload) + assert evidence_verdict(**payload) == VERDICT_REJECTED + + +def test_binding_plus_expansion_alone_is_unknown_not_accepted(): + # Necessary but not sufficient: must not false-PASS as effective permission. + payload = _base() + assert reject_reasons(**payload) == [] + unknowns = unknown_reasons(**payload) + assert "binding_condition_presence_unknown" in unknowns + assert "deny_policy_context_unknown" in unknowns + assert "principal_access_boundary_context_unknown" in unknowns + assert "inherited_allow_deny_context_unknown" in unknowns + assert "principal_identity_context_unknown" in unknowns + assert "time_context_unknown" in unknowns + assert evidence_verdict(**payload) == VERDICT_UNKNOWN + assert not evidence_accepted(**payload) + + +def test_conditional_binding_unsatisfied_is_unknown_fail_closed(): + payload = _base( + bindings_have_conditions=True, + conditions_authoritatively_satisfied=False, + deny_policies_authoritatively_evaluated=True, + principal_access_boundary_authoritatively_evaluated=True, + inherited_allow_deny_context_complete=True, + principal_identity_context_complete=True, + time_context_complete=True, ) + assert "conditional_binding_not_authoritatively_satisfied" in unknown_reasons(**payload) + assert evidence_verdict(**payload) == VERDICT_UNKNOWN + assert not evidence_accepted(**payload) + + +def test_unknown_deny_or_pab_context_fail_closed_even_with_unconditional_allow(): + payload = _base( + bindings_have_conditions=False, + conditions_authoritatively_satisfied=True, + deny_policies_authoritatively_evaluated=None, + principal_access_boundary_authoritatively_evaluated=None, + inherited_allow_deny_context_complete=True, + principal_identity_context_complete=True, + time_context_complete=True, + ) + unknowns = unknown_reasons(**payload) + assert "deny_policy_context_unknown" in unknowns + assert "principal_access_boundary_context_unknown" in unknowns + assert evidence_verdict(**payload) == VERDICT_UNKNOWN + + +def test_authoritative_complete_pack_is_accepted_offline_only(): + payload = _authoritative_context() + assert evidence_verdict(**payload) == VERDICT_ACCEPTED + assert evidence_accepted(**payload) -def test_remediation_doc_forbids_caller_scoped_false_pass(): - from pathlib import Path +def test_remediation_doc_forbids_caller_scoped_and_binding_false_pass(): text = (Path(__file__).parents[1] / "docs/iam/paper-executor-grant-remediation.md").read_text() assert "returns permissions that a caller has on the specified Project" in text assert "caller_test_iam_not_candidate_effective" in text @@ -123,3 +203,5 @@ def test_remediation_doc_forbids_caller_scoped_false_pass(): assert "operational prerequisite" in text assert "technically detect or reject an org-only value" in text assert "must not claim a technical org-only block" in text + assert "allow-binding plus role" in text or "allow binding plus role" in text.lower() + assert "UNKNOWN" in text or "unknown" in text From b0130200aee11d7d3679dbf844dd65b0b46bda72 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Nov 2026 10:14:41 +0000 Subject: [PATCH 5/5] fix: Scrub public fingerprints and block self-attested IAM accept Remove live mapping digests and ids from the tip tree, and keep discovery ACCEPTED unreachable without a supported trusted provenance kind. Co-authored-by: Pigbibi --- .../grant-paper-executor-permissions.yml | 11 +- docs/iam/paper-executor-grant-remediation.md | 113 ++++++------ docs/iam/paper-scheduler-get.md | 28 +-- scripts/paper_iam_discovery_evidence.py | 66 ++++--- tests/test_paper_executor_grant.py | 18 +- tests/test_paper_iam_discovery_evidence.py | 170 +++++++++++------- 6 files changed, 240 insertions(+), 166 deletions(-) diff --git a/.github/workflows/grant-paper-executor-permissions.yml b/.github/workflows/grant-paper-executor-permissions.yml index df80a33..d177ea1 100644 --- a/.github/workflows/grant-paper-executor-permissions.yml +++ b/.github/workflows/grant-paper-executor-permissions.yml @@ -55,11 +55,12 @@ jobs: # Resolve mappings through the job Environment vars context (same channel as # apply-paper-scheduler-jobs-get). Do not call the Variables REST API: the # workflow GITHUB_TOKEN only receives contents/metadata and cannot read it - # (run 42266306297 failed on the first existing name before authentication). - # Mask every value before exporting it to later steps. Missing or blank - # mappings fail closed. Organization-only names are an operational - # prerequisite (unsupported by policy) but are NOT technically rejected: - # vars has no scope tag and GITHUB_TOKEN cannot query Variables REST. + # (a historical mapping-read failure under that token shape stopped before + # authentication on an already-configured name). Mask every value before + # exporting it to later steps. Missing or blank mappings fail closed. + # Organization-only names are an operational prerequisite (unsupported by + # policy) but are NOT technically rejected: vars has no scope tag and + # GITHUB_TOKEN cannot query Variables REST. - name: Mask protected mappings before exposing step environment uses: actions/github-script@v8 env: diff --git a/docs/iam/paper-executor-grant-remediation.md b/docs/iam/paper-executor-grant-remediation.md index 26c3c76..0e76772 100644 --- a/docs/iam/paper-executor-grant-remediation.md +++ b/docs/iam/paper-executor-grant-remediation.md @@ -9,27 +9,33 @@ Related definition: [paper-scheduler-get.md](paper-scheduler-get.md) and Lookup-channel fix lineage: PR #722 (Draft). Offline discovery classifier: [paper_iam_discovery_evidence.py](../../scripts/paper_iam_discovery_evidence.py). +Public material in this tree uses only synthetic placeholders. Digests, +numeric Environment ids and Actions run ids that fingerprint live mappings are +not recorded here. Earlier tip revisions of this PR introduced such fingerprints +into tracked files; those commits remain in git history. This tip removes them +from the working tree and does **not** claim history scrubbing or force-push +erasure. + ## Owner-provided installed configuration (t1393) Source class: **OWNER-PROVIDED browser metadata**, not an independent cloud or -API verification by this engineering pass. Observation surfaces: +API verification by this engineering pass. Observation surfaces (generic): -- Environment `longbridge-paper` variables table (40 items): - `https://github.com/QuantStrategyLab/LongBridgePlatform/settings/environments/15143916325/edit` -- Repository Actions variables (18 items): - `https://github.com/QuantStrategyLab/LongBridgePlatform/settings/variables/actions` +- Environment `longbridge-paper` variables table (settings → Environments → + `longbridge-paper`) +- Repository Actions variables table (settings → Secrets and variables → Actions) -Hashes are SHA-256 over the full GitHub variable value encoded as UTF-8. Raw -values are not recorded here. +Owner confirmation (names/kinds/presence only; raw values and digests stay in +private operator notes): -| Name | Installed? | Scope observed | Updated (UTC) | Value sha256 (UTF-8) | -| --- | --- | --- | --- | --- | -| `GCP_PROJECT_ID` | yes, nonempty | Environment | 2026-11-06T05:04:28Z | `5fd9761bc0f5e7a1b75fc007b946b554af81773faee45d940bbf52c04c52152e` | -| `PAPER_SCHEDULER_GET_PROJECT_ID` | yes, nonempty | Environment | 2026-11-06T09:11:15Z | **same hash as** `GCP_PROJECT_ID` | -| `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` | yes, nonempty | Environment | 2026-11-03T06:51:52Z | `382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22` | -| `PAPER_IAM_GRANT_RECIPIENT` | **absent** exact name | Environment then repository | — | — | -| `PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT` | **absent** exact name | Environment then repository | — | — | -| `PAPER_IAM_BOOTSTRAP_WIF_PROVIDER` | **absent** exact name | Environment then repository | — | — | +| Name | Installed? | Scope observed | +| --- | --- | --- | +| `GCP_PROJECT_ID` | yes, nonempty | Environment | +| `PAPER_SCHEDULER_GET_PROJECT_ID` | yes, nonempty; privately verified same UTF-8 value as `GCP_PROJECT_ID` | Environment | +| `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` | yes, nonempty | Environment | +| `PAPER_IAM_GRANT_RECIPIENT` | **absent** exact name | Environment then repository | +| `PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT` | **absent** exact name | Environment then repository | +| `PAPER_IAM_BOOTSTRAP_WIF_PROVIDER` | **absent** exact name | Environment then repository | Absence means the GitHub variable name is missing in the reviewed Environment table and, for those three names, also in the repository Actions variables @@ -40,8 +46,8 @@ pool or provider is absent in the cloud project. | Mapping | Intent (contract) | Installed GitHub config (t1393) | Decision input | | --- | --- | --- | --- | -| Recipient | `PAPER_IAM_GRANT_RECIPIENT` must equal the existing scheduler IAM executor `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` in the same PAPER project (`scripts/paper_executor_grant.py` `identity()`) | Executor mapping **exists** in Environment; recipient name **absent** | Fill Environment variable from the already-installed executor mapping; expected post-fill value sha256 must equal `382a7cbe…697cc22`. Do not invent a new email. | -| Project pair | `PAPER_SCHEDULER_GET_PROJECT_ID` must equal `GCP_PROJECT_ID` | Both exist; **hashes already match** | No project remapping required for this gate. | +| Recipient | `PAPER_IAM_GRANT_RECIPIENT` must equal the existing scheduler IAM executor `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` in the same PAPER project (`scripts/paper_executor_grant.py` `identity()`) | Executor mapping **exists** in Environment; recipient name **absent** | Fill Environment variable from the already-installed executor mapping; privately verify UTF-8 digests match. Do not invent a new email. | +| Project pair | `PAPER_SCHEDULER_GET_PROJECT_ID` must equal `GCP_PROJECT_ID` | Both exist; privately verified equal | No project remapping required for this gate. | | Bootstrap SA | Distinct authorized SA in the same project, not the recipient/executor | Name absent; no in-repo proved reusable IAM bootstrap | Requires design or bounded discovery (below). **PROPOSED_NOT_OBSERVED** if newly named. | | Bootstrap WIF | Provider that actually trusts that bootstrap under repo/main/`longbridge-paper` limits | Name absent; no in-repo proved bootstrap provider | Same as bootstrap SA. | @@ -71,8 +77,8 @@ Preconditions: 1. PR #722 lookup-channel fix is merged to `main` (engineering merge only; still no grant). Until then, grant runs remain blocked on blank mappings even after fills. -2. Owner confirms Environment `longbridge-paper` still shows executor sha256 - `382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22`. +2. Owner confirms Environment `longbridge-paper` still holds the nonempty + executor mapping (private digest check against operator notes). 3. Exact authorization for GitHub Environment **variable create** on `longbridge-paper` (not repository-only, not org-only). @@ -81,8 +87,7 @@ Action (human / approved operator; not this agent): 1. Create Environment variable `PAPER_IAM_GRANT_RECIPIENT`. 2. Set its value to the **same bytes** as Environment `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` (copy from the existing UI field). -3. Verify sha256(UTF-8(value)) == - `382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22`. +3. Privately verify UTF-8 digests match; do not publish digests in Git. 4. Leave repository/org copies unset for this name (operational; see org-vars note). Rollback: delete the Environment variable `PAPER_IAM_GRANT_RECIPIENT` if the @@ -126,41 +131,40 @@ Forbidden proof techniques for B0: - Treating SA `getIamPolicy` / WIF bindings as project-effective permission proof (`sa_get_iam_policy_is_trust_not_project_perm`). - Equating the candidate with the recipient/executor principal. - -Offline classifier verdicts (`scripts/paper_iam_discovery_evidence.py`): -`rejected` | `unknown` | `accepted`. **Fail-closed:** both `rejected` and -`unknown` deny bootstrap acceptance. A project **allow-binding plus role -permission expansion is necessary but not sufficient** for unconditional -effective permission. Conditional bindings (resource/time/principal CEL), -inherited allow/deny, Principal Access Boundary (PAB), and any unknown -evaluation context must remain `unknown` unless authoritatively covered. -Fake fixtures never prove live IAM; `accepted` is only an offline pack-completeness -signal. - -Accepted proof shape (owner-authorized read-only later): - -1. Resolve `` from Environment `GCP_PROJECT_ID` (hash - `5fd9761b…52152e`); do not publish the raw id in chat. +- Treating self-attested completeness booleans or unsigned fixture packs as + trusted provenance for operational ACCEPTED. + +Offline classifier verdicts: `rejected` | `unknown` | `accepted`. +**Fail-closed:** both `rejected` and `unknown` deny bootstrap acceptance. +A project **allow-binding plus role permission expansion is necessary but not +sufficient**. Conditional bindings (resource/time/principal CEL), inherited +allow/deny, Principal Access Boundary (PAB), incomplete resource/hash pairs, +and any missing trusted provenance must remain `unknown`. +`SUPPORTED_TRUSTED_PROVENANCE_KINDS` is empty in-tree: **ACCEPTED is currently +unreachable** from local assertions. Fake fixtures never prove live IAM. + +Accepted proof shape (owner-authorized read-only later, once a supported +provenance kind exists): + +1. Resolve `` from Environment `GCP_PROJECT_ID` privately; do not + publish the raw id in chat or Git. 2. `serviceAccounts.get` only for candidate emails already known from protected config or prior approved inventory — **no org-wide or project-wide account listing**. 3. Read **project** `getIamPolicy` and collect role bindings whose `members` include the **exact** candidate principal. Expand each bound role's permission set via `iam.roles.get` for custom roles (or an owner-approved predefined-role - permission baseline). Missing expansion is a hard reject. Binding membership - alone without authoritative condition/deny/PAB/inheritance/time/identity - coverage is `unknown`, not pass. + permission baseline). Missing expansion is a hard reject. 4. Separately, read the candidate SA's `getIamPolicy` **only** to inspect WIF / `roles/iam.workloadIdentityUser` trust — never as a substitute for step 3. 5. For providers already named in protected config, `workloadIdentityPools.providers.get` and accept only if trust is limited to this repository, protected `main`, and Environment `longbridge-paper` (or can be narrowed under a separate trust card). -6. Reject any pack where `actual_caller == operator` and the only permission - evidence is that caller's `testIamPermissions` result; reject resource - mismatch and stale value hashes when those axes are supplied. +6. Reject caller-scoped `testIamPermissions` as candidate proof; reject resource + mismatch and one-sided or stale value hashes when those axes are supplied. -If no candidate reaches authoritative `accepted`, do not widen search; proceed -to B1. +If no candidate reaches authoritative acceptance under a supported provenance +kind, do not widen search; proceed to B1. ### B1 — minimal new bootstrap design (**PROPOSED_NOT_OBSERVED**) @@ -257,16 +261,18 @@ GitHub's documented resolution order is Environment → repository → organizat This repository's **policy** is that the six grant mapping names must not be defined only at organization scope. -**Enforcement status:** operational prerequisite only. The workflow does **not** -technically detect or reject an org-only value: `vars.` returns a string -without a scope tag, and the Variables REST API is unavailable to -`GITHUB_TOKEN` with `contents: read` (historical run `42266306297`). Comments and -docs must not claim a technical org-only block. Operators must keep these names -unset at org scope; Card A step 4 restates that control. +**Enforcement status:** operational prerequisite only (**D3 residual**). The +workflow does **not** technically detect or reject an org-only value: +`vars.` returns a string without a scope tag, and the Variables REST API +is unavailable to `GITHUB_TOKEN` with `contents: read` (historical mapping-read +failure under contents-only token). Comments and docs must not claim a technical +org-only block. Operators must keep these names unset at org scope; Card A +step 4 restates that control. This residual is lower priority than mapping +lookup and discovery semantics; do not pretend enforcement was restored. ## Remaining unknowns (do not fabricate) -- Live GCP presence/permissions of any SA behind the executor hash (no GCP GET +- Live GCP presence/permissions of any SA behind the executor mapping (no GCP GET in this pass). - Whether any non-executor SA already holds the four permissions under project policy expansion. @@ -280,5 +286,6 @@ unset at org scope; Card A step 4 restates that control. Offline: contract and negative tests in `tests/test_paper_executor_grant.py` and `tests/test_paper_iam_discovery_evidence.py`. Passing offline tests is **not** -merge acceptance or grant readiness. Online grant, IAM, or PAPER verification -requires future exact cards and independent review markers on the exact head. +merge acceptance, grant readiness, or live IAM proof. Online grant, IAM, or PAPER +verification requires future exact cards and independent review markers on the +exact head. diff --git a/docs/iam/paper-scheduler-get.md b/docs/iam/paper-scheduler-get.md index 49fd2fb..26c742a 100644 --- a/docs/iam/paper-scheduler-get.md +++ b/docs/iam/paper-scheduler-get.md @@ -220,15 +220,15 @@ the six named variables through the job Environment `vars` context (Environment then same-name repository), matching [apply-paper-scheduler-jobs-get](../../.github/workflows/apply-paper-scheduler-jobs-get.yml). It does not call the GitHub Variables REST API: workflow `GITHUB_TOKEN` with -`contents: read` lacks that authority, and historical run `42266306297` failed -on the first already-configured name before authentication. Organization-only -definitions of these six names are unsupported by **policy** (operational -prerequisite). The `vars` channel does **not** technically detect or reject -org-only values: resolution may still return an org string, and this workflow -cannot scope-tag the source. Do not define these names only at org scope. -All mappings must be nonempty strings; blank or malformed values stop before -authentication. No GitHub token permission or Environment configuration is -changed by this workflow definition. +`contents: read` lacks that authority, and a historical mapping-read failure +under that token shape stopped on an already-configured name before +authentication. Organization-only definitions of these six names are +unsupported by **policy** (operational prerequisite). The `vars` channel does +**not** technically detect or reject org-only values: resolution may still +return an org string, and this workflow cannot scope-tag the source. Do not +define these names only at org scope. All mappings must be nonempty strings; +blank or malformed values stop before authentication. No GitHub token +permission or Environment configuration is changed by this workflow definition. A mapping-read failure reports only the failing fixed variable name, lookup scope (`vars`) and an allowlisted category: `blank_value` or @@ -249,15 +249,15 @@ identity absence. | Variable | Role | Intent state | Installed GitHub config (owner t1393) | Source path + commit + scope | | --- | --- | --- | --- | --- | -| `PAPER_IAM_GRANT_RECIPIENT` | Independent approved mapping that must equal the existing scheduler IAM executor (`GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT`) | `EXISTS_SOURCE_RESOLVED` — intended value is the Environment executor mapping | **Absent** exact name in Environment then repository. Expected post-fill value sha256 (UTF-8) equals executor hash `382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22` (executor updated 2026-11-03T06:51:52Z). | Contract: this section / `#720` `1fef697`; dual-check `scripts/paper_executor_grant.py` `identity()`; executor installed source: Environment `longbridge-paper` / `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` via `.github/workflows/apply-paper-scheduler-jobs-get.yml` | +| `PAPER_IAM_GRANT_RECIPIENT` | Independent approved mapping that must equal the existing scheduler IAM executor (`GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT`) | `EXISTS_SOURCE_RESOLVED` — intended value is the Environment executor mapping | **Absent** exact name in Environment then repository. Post-fill UTF-8 digests must privately match the installed executor mapping; digests are not published here. | Contract: this section / `#720`; dual-check `scripts/paper_executor_grant.py` `identity()`; executor installed source: Environment `longbridge-paper` / `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` via `.github/workflows/apply-paper-scheduler-jobs-get.yml` | | `PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT` | Existing authorized bootstrap SA in the same PAPER project, distinct from recipient; never the deficient executor | `REQUIRES_DESIGN` — no proved reusable bootstrap in Git/IaC | **Absent** exact name in Environment then repository. Any concrete SA id remains `PROPOSED_NOT_OBSERVED` until a discovery or provisioning card proves it. | Required by `.github/workflows/grant-paper-executor-permissions.yml` and `scripts/paper_executor_grant.py`; see remediation Card B | | `PAPER_IAM_BOOTSTRAP_WIF_PROVIDER` | Existing authorized WIF provider that actually trusts that bootstrap | `REQUIRES_DESIGN` — same gap as bootstrap SA | **Absent** exact name in Environment then repository. Provider resource `PROPOSED_NOT_OBSERVED` until proved. | Required by grant workflow auth step; see remediation Card B | Sibling scheduler mappings observed nonempty in Environment `longbridge-paper` -(owner t1393): `GCP_PROJECT_ID` and `PAPER_SCHEDULER_GET_PROJECT_ID` share sha256 -`5fd9761bc0f5e7a1b75fc007b946b554af81773faee45d940bbf52c04c52152e` (project pair -already consistent). `GCP_WORKLOAD_IDENTITY_PROVIDER` remains the scheduler -executor auth provider and is not a bootstrap substitute without separate proof. +(owner t1393): `GCP_PROJECT_ID` and `PAPER_SCHEDULER_GET_PROJECT_ID` privately +verified equal; digests not published in Git. `GCP_WORKLOAD_IDENTITY_PROVIDER` +remains the scheduler executor auth provider and is not a bootstrap substitute +without separate proof. Role permission responses are checked for exact membership and cardinality, independent of order. Fresh role snapshots normalize permission ordering while diff --git a/scripts/paper_iam_discovery_evidence.py b/scripts/paper_iam_discovery_evidence.py index a2ee873..7ad4eb6 100644 --- a/scripts/paper_iam_discovery_evidence.py +++ b/scripts/paper_iam_discovery_evidence.py @@ -1,10 +1,12 @@ """Offline classifiers for PAPER IAM bootstrap discovery evidence. -No network I/O. Encodes the B0 contract so operator-held permissions cannot be -mistaken for a candidate service account's project permissions, and so a bare -project allow-binding plus role expansion is never treated as unconditional -effective permission when conditions, deny, PAB or inheritance context are -unknown. +No network I/O. Distinguishes hard rejects, UNKNOWN (fail-closed), and a +reserved ACCEPTED path that requires a supported trusted provenance kind. + +No supported trusted provenance kind is registered in this repository. Local +booleans, fixture packs, and self-attested completeness flags therefore cannot +produce operational ACCEPTED. Structural tests exercise reject/unknown only. +This module never calls GCP and never mints credentials. """ from __future__ import annotations @@ -18,13 +20,16 @@ ) # Recipient receives these via Card C as role paperSchedulerIamExecutor. -# That outcome is intentional and distinct from using the recipient as bootstrap. RECIPIENT_GRANT_PERMISSIONS = BOOTSTRAP_PERMISSIONS VERDICT_REJECTED = "rejected" VERDICT_UNKNOWN = "unknown" VERDICT_ACCEPTED = "accepted" +# Empty until an owner-approved, non-self-attested provenance scheme is wired. +# Do not invent authority here (no local "self_attested" / fixture kinds). +SUPPORTED_TRUSTED_PROVENANCE_KINDS: frozenset[str] = frozenset() + def reject_reasons( *, @@ -56,13 +61,15 @@ def reject_reasons( if impersonation_or_token_mint_used: reasons.append("impersonation_or_token_mint_forbidden") - if expected_resource is not None and evidence_resource is not None: + if (expected_resource is None) ^ (evidence_resource is None): + reasons.append("one_sided_resource_provenance") + elif expected_resource is not None and evidence_resource is not None: if expected_resource != evidence_resource or evaluated_resource != evidence_resource: reasons.append("resource_mismatch") - elif expected_resource is not None and expected_resource != evaluated_resource: - reasons.append("resource_mismatch") - if expected_value_sha256 is not None and observed_value_sha256 is not None: + if (expected_value_sha256 is None) ^ (observed_value_sha256 is None): + reasons.append("one_sided_value_hash") + elif expected_value_sha256 is not None and observed_value_sha256 is not None: if expected_value_sha256 != observed_value_sha256: reasons.append("stale_or_mismatched_value_hash") @@ -75,8 +82,6 @@ def reject_reasons( reasons.append("caller_test_iam_not_candidate_effective") reasons.append("test_iam_permissions_is_caller_scoped") - # SA getIamPolicy describes who may act as the SA (trust), not which project - # permissions the SA itself holds. if candidate_sa_iam_policy_members is not None and candidate_project_role_bindings is None: reasons.append("sa_get_iam_policy_is_trust_not_project_perm") @@ -110,14 +115,19 @@ def unknown_reasons( inherited_allow_deny_context_complete: bool | None = None, principal_identity_context_complete: bool | None = None, time_context_complete: bool | None = None, + expected_resource: str | None = None, + evidence_resource: str | None = None, + expected_value_sha256: str | None = None, + observed_value_sha256: str | None = None, + trusted_provenance: dict | None = None, **_ignored, ) -> list[str]: - """Return UNKNOWN categories: fail-closed unless context is authoritatively covered. + """Return UNKNOWN categories: fail-closed without trusted provenance. - A project allow-binding plus role permission expansion is necessary but not - sufficient for unconditional effective permission. Conditional bindings, - unsatisfied resource/time/principal CEL, inherited allow/deny, and Principal - Access Boundary (PAB) must not produce ACCEPTED when uncovered. + Self-attested booleans (conditions/deny/PAB/etc.) are structural checklist + fields only. They never authorize ACCEPTED by themselves. Resource and hash + pairs must both be present for a complete pack; missing pairs stay UNKNOWN + when not already hard-rejected as one-sided. """ reasons: list[str] = [] has_binding_expansion = bool( @@ -142,15 +152,26 @@ def unknown_reasons( if time_context_complete is not True: reasons.append("time_context_unknown") + if expected_resource is None or evidence_resource is None: + reasons.append("resource_provenance_incomplete") + if expected_value_sha256 is None or observed_value_sha256 is None: + reasons.append("value_hash_provenance_incomplete") + + kind = None + if isinstance(trusted_provenance, dict): + kind = trusted_provenance.get("kind") + if kind not in SUPPORTED_TRUSTED_PROVENANCE_KINDS: + reasons.append("trusted_provenance_missing_or_unsupported") + return _dedupe(reasons) def evidence_verdict(**kwargs) -> str: """Three-state verdict: rejected | unknown | accepted. - Fake fixtures never prove live IAM. ACCEPTED only means the offline pack - supplied authoritative coverage for every required axis; it is not a cloud - permission proof. + ACCEPTED is reserved for a future supported trusted provenance kind. + With ``SUPPORTED_TRUSTED_PROVENANCE_KINDS`` empty, ACCEPTED is unreachable + from fixtures or local assertions. Fake packs never prove live IAM. """ if reject_reasons(**kwargs): return VERDICT_REJECTED @@ -159,6 +180,11 @@ def evidence_verdict(**kwargs) -> str: if not kwargs.get("candidate_project_role_bindings") or not kwargs.get( "candidate_role_permission_sets"): return VERDICT_UNKNOWN + # Provenance allowlist empty ⇒ unreachable; keep explicit guard. + provenance = kwargs.get("trusted_provenance") + kind = provenance.get("kind") if isinstance(provenance, dict) else None + if kind not in SUPPORTED_TRUSTED_PROVENANCE_KINDS: + return VERDICT_UNKNOWN return VERDICT_ACCEPTED diff --git a/tests/test_paper_executor_grant.py b/tests/test_paper_executor_grant.py index fe8c038..e2e75b7 100644 --- a/tests/test_paper_executor_grant.py +++ b/tests/test_paper_executor_grant.py @@ -160,8 +160,6 @@ def test_remediation_artifact_separates_intent_install_and_proposals(): workflow = (root / ".github/workflows/grant-paper-executor-permissions.yml").read_text() for token in [ "OWNER-PROVIDED", "PROPOSED_NOT_OBSERVED", "Card A", "Card B", - "382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22", - "5fd9761bc0f5e7a1b75fc007b946b554af81773faee45d940bbf52c04c52152e", "Variable absence is **not** evidence", "Rejected as bootstrap", "paper-iam-bootstrap@", @@ -170,15 +168,21 @@ def test_remediation_artifact_separates_intent_install_and_proposals(): "caller_test_iam_not_candidate_effective", "B1 versus Card C", "operational prerequisite", + "SUPPORTED_TRUSTED_PROVENANCE_KINDS", + "D3 residual", ]: assert token in remediation assert "paper-executor-grant-remediation.md" in design - assert "382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22" in design - assert "does **not** technically detect or reject" in design + assert "technically detect or reject" in design assert "NOT technically rejected" in workflow - # Recipient expected hash must track the installed executor mapping, not a new identity. - assert remediation.index("GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT") < remediation.index( - "382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22") + # Public tip: no full sha256 digests, numeric Environment settings paths, or numeric run ids. + import re + for text in (remediation, design, workflow): + assert not re.search(r"\b[0-9a-f]{64}\b", text) + assert not re.search(r"environments/\d+/edit", text) + assert not re.search(r"\brun\s+`?\d{8,}", text, flags=re.I) + assert "GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT" in remediation + assert "same bytes" in remediation @pytest.mark.parametrize("mode", ["dry-run", "apply"]) diff --git a/tests/test_paper_iam_discovery_evidence.py b/tests/test_paper_iam_discovery_evidence.py index 0748c20..29c6862 100644 --- a/tests/test_paper_iam_discovery_evidence.py +++ b/tests/test_paper_iam_discovery_evidence.py @@ -1,13 +1,15 @@ """Fake-only adversarial contracts for bootstrap discovery evidence semantics. -These fixtures exercise classifier fail-closed behavior only. They do not prove -live GCP permissions. +Structural fixture tests only. They do not prove live GCP permissions and must +not unlock operational ACCEPTED without a supported trusted provenance kind. +Synthetic digests below are labeled fixtures, not live Environment fingerprints. """ from pathlib import Path from scripts.paper_iam_discovery_evidence import ( BOOTSTRAP_PERMISSIONS, RECIPIENT_GRANT_PERMISSIONS, + SUPPORTED_TRUSTED_PROVENANCE_KINDS, VERDICT_ACCEPTED, VERDICT_REJECTED, VERDICT_UNKNOWN, @@ -24,8 +26,9 @@ OTHER_CANDIDATE = "serviceAccount:other@fake-paper-project.iam.gserviceaccount.com" EXECUTOR = "serviceAccount:executor@fake-paper-project.iam.gserviceaccount.com" ROLE = "projects/fake-paper-project/roles/paperIamBootstrap" -EXECUTOR_HASH = "382a7cbea2ea0de0f455ed00cf29a3181766399a5cd28a794d00a79d8697cc22" -STALE_HASH = "0000000000000000000000000000000000000000000000000000000000000000" +# Explicitly synthetic fixture digests — not live mapping fingerprints. +SYNTHETIC_HASH_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +SYNTHETIC_HASH_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" def _base(**overrides): @@ -43,8 +46,8 @@ def _base(**overrides): return payload -def _authoritative_context(**overrides): - """Only for offline ACCEPTED shape tests — not a live IAM attestation.""" +def _self_attested_flags(**overrides): + """Checklist booleans only — not trusted provenance.""" payload = _base( bindings_have_conditions=False, conditions_authoritatively_satisfied=True, @@ -53,10 +56,6 @@ def _authoritative_context(**overrides): inherited_allow_deny_context_complete=True, principal_identity_context_complete=True, time_context_complete=True, - expected_resource=PROJECT, - evidence_resource=PROJECT, - expected_value_sha256=EXECUTOR_HASH, - observed_value_sha256=EXECUTOR_HASH, ) payload.update(overrides) return payload @@ -67,6 +66,10 @@ def test_bootstrap_and_recipient_permission_strings_match_by_owner_design(): assert len(BOOTSTRAP_PERMISSIONS) == 4 +def test_no_supported_trusted_provenance_kinds_are_registered(): + assert SUPPORTED_TRUSTED_PROVENANCE_KINDS == frozenset() + + def test_operator_test_iam_holding_all_four_does_not_prove_candidate(): payload = _base( caller_test_iam_permissions=set(BOOTSTRAP_PERMISSIONS), @@ -75,9 +78,7 @@ def test_operator_test_iam_holding_all_four_does_not_prove_candidate(): reasons = reject_reasons(**payload) assert "caller_test_iam_not_candidate_effective" in reasons assert "test_iam_permissions_is_caller_scoped" in reasons - assert "candidate_principal_not_in_bindings" in reasons assert evidence_verdict(**payload) == VERDICT_REJECTED - assert not evidence_accepted(**payload) def test_sa_get_iam_policy_alone_is_trust_not_project_permission_proof(): @@ -86,18 +87,14 @@ def test_sa_get_iam_policy_alone_is_trust_not_project_permission_proof(): evaluated_resource=PROJECT, actual_caller=OPERATOR, evaluated_permissions=BOOTSTRAP_PERMISSIONS, - candidate_sa_iam_policy_members={ - "principalSet://iam.googleapis.com/projects/1/locations/global/" - "workloadIdentityPools/fake/attribute.repository/" - "QuantStrategyLab/LongBridgePlatform" - }, + candidate_sa_iam_policy_members={"principalSet://iam.googleapis.com/fake"}, ) assert "sa_get_iam_policy_is_trust_not_project_perm" in reject_reasons(**payload) assert evidence_verdict(**payload) == VERDICT_REJECTED -def test_impersonation_or_token_mint_is_rejected_even_if_bindings_look_complete(): - payload = _authoritative_context( +def test_impersonation_or_token_mint_is_rejected(): + payload = _self_attested_flags( actual_caller=CANDIDATE, caller_test_iam_permissions=set(BOOTSTRAP_PERMISSIONS), impersonation_or_token_mint_used=True, @@ -107,7 +104,7 @@ def test_impersonation_or_token_mint_is_rejected_even_if_bindings_look_complete( def test_candidate_equals_recipient_executor_is_rejected(): - payload = _authoritative_context(candidate_principal=EXECUTOR) + payload = _self_attested_flags(candidate_principal=EXECUTOR) assert "candidate_equals_recipient_executor" in reject_reasons(**payload) assert evidence_verdict(**payload) == VERDICT_REJECTED @@ -116,7 +113,6 @@ def test_candidate_principal_mismatch_in_bindings_is_rejected(): payload = _base(candidate_project_role_bindings={ROLE: {OTHER_CANDIDATE}}) reasons = reject_reasons(**payload) assert "candidate_principal_not_in_bindings" in reasons - assert "candidate_project_bindings_missing_permissions" in reasons assert evidence_verdict(**payload) == VERDICT_REJECTED @@ -127,81 +123,121 @@ def test_role_expansion_missing_is_rejected(): def test_resource_mismatch_is_rejected(): - payload = _authoritative_context( + payload = _self_attested_flags( expected_resource=PROJECT, evidence_resource=OTHER_PROJECT, evaluated_resource=OTHER_PROJECT, + expected_value_sha256=SYNTHETIC_HASH_A, + observed_value_sha256=SYNTHETIC_HASH_A, ) assert "resource_mismatch" in reject_reasons(**payload) assert evidence_verdict(**payload) == VERDICT_REJECTED +def test_one_sided_value_hash_is_rejected(): + payload = _self_attested_flags(expected_value_sha256=SYNTHETIC_HASH_A) + assert "one_sided_value_hash" in reject_reasons(**payload) + assert evidence_verdict(**payload) == VERDICT_REJECTED + + +def test_one_sided_resource_provenance_is_rejected(): + payload = _self_attested_flags(expected_resource=PROJECT) + assert "one_sided_resource_provenance" in reject_reasons(**payload) + assert evidence_verdict(**payload) == VERDICT_REJECTED + + def test_stale_value_hash_is_rejected(): - payload = _authoritative_context(observed_value_sha256=STALE_HASH) + payload = _self_attested_flags( + expected_resource=PROJECT, + evidence_resource=PROJECT, + expected_value_sha256=SYNTHETIC_HASH_A, + observed_value_sha256=SYNTHETIC_HASH_B, + ) assert "stale_or_mismatched_value_hash" in reject_reasons(**payload) assert evidence_verdict(**payload) == VERDICT_REJECTED def test_binding_plus_expansion_alone_is_unknown_not_accepted(): - # Necessary but not sufficient: must not false-PASS as effective permission. payload = _base() assert reject_reasons(**payload) == [] + assert evidence_verdict(**payload) == VERDICT_UNKNOWN + assert not evidence_accepted(**payload) + + +def test_self_attested_six_booleans_without_provenance_stay_unknown(): + # Former false-PASS path: bindings + six True flags and no resource/hash. + payload = _self_attested_flags() + assert reject_reasons(**payload) == [] unknowns = unknown_reasons(**payload) - assert "binding_condition_presence_unknown" in unknowns - assert "deny_policy_context_unknown" in unknowns - assert "principal_access_boundary_context_unknown" in unknowns - assert "inherited_allow_deny_context_unknown" in unknowns - assert "principal_identity_context_unknown" in unknowns - assert "time_context_unknown" in unknowns + assert "resource_provenance_incomplete" in unknowns + assert "value_hash_provenance_incomplete" in unknowns + assert "trusted_provenance_missing_or_unsupported" in unknowns assert evidence_verdict(**payload) == VERDICT_UNKNOWN assert not evidence_accepted(**payload) -def test_conditional_binding_unsatisfied_is_unknown_fail_closed(): - payload = _base( - bindings_have_conditions=True, - conditions_authoritatively_satisfied=False, - deny_policies_authoritatively_evaluated=True, - principal_access_boundary_authoritatively_evaluated=True, - inherited_allow_deny_context_complete=True, - principal_identity_context_complete=True, - time_context_complete=True, +def test_self_attested_flags_plus_matching_hashes_still_unknown_without_trusted_kind(): + payload = _self_attested_flags( + expected_resource=PROJECT, + evidence_resource=PROJECT, + expected_value_sha256=SYNTHETIC_HASH_A, + observed_value_sha256=SYNTHETIC_HASH_A, + trusted_provenance={"kind": "self_attested_fixture"}, + ) + assert "trusted_provenance_missing_or_unsupported" in unknown_reasons(**payload) + assert evidence_verdict(**payload) == VERDICT_UNKNOWN + assert evidence_verdict(**payload) != VERDICT_ACCEPTED + + +def test_forged_provenance_flags_cannot_unlock_accepted(): + payload = _self_attested_flags( + expected_resource=PROJECT, + evidence_resource=PROJECT, + expected_value_sha256=SYNTHETIC_HASH_A, + observed_value_sha256=SYNTHETIC_HASH_A, + trusted_provenance={"kind": "forged", "signed": True, "authority": "local"}, ) - assert "conditional_binding_not_authoritatively_satisfied" in unknown_reasons(**payload) assert evidence_verdict(**payload) == VERDICT_UNKNOWN assert not evidence_accepted(**payload) -def test_unknown_deny_or_pab_context_fail_closed_even_with_unconditional_allow(): - payload = _base( - bindings_have_conditions=False, - conditions_authoritatively_satisfied=True, - deny_policies_authoritatively_evaluated=None, - principal_access_boundary_authoritatively_evaluated=None, - inherited_allow_deny_context_complete=True, - principal_identity_context_complete=True, - time_context_complete=True, +def test_conditional_binding_unsatisfied_is_unknown_fail_closed(): + payload = _self_attested_flags( + bindings_have_conditions=True, + conditions_authoritatively_satisfied=False, + expected_resource=PROJECT, + evidence_resource=PROJECT, + expected_value_sha256=SYNTHETIC_HASH_A, + observed_value_sha256=SYNTHETIC_HASH_A, ) - unknowns = unknown_reasons(**payload) - assert "deny_policy_context_unknown" in unknowns - assert "principal_access_boundary_context_unknown" in unknowns + assert "conditional_binding_not_authoritatively_satisfied" in unknown_reasons(**payload) assert evidence_verdict(**payload) == VERDICT_UNKNOWN -def test_authoritative_complete_pack_is_accepted_offline_only(): - payload = _authoritative_context() - assert evidence_verdict(**payload) == VERDICT_ACCEPTED - assert evidence_accepted(**payload) +def test_caller_candidate_mismatch_with_complete_looking_pack_still_rejects(): + payload = _self_attested_flags( + caller_test_iam_permissions=set(BOOTSTRAP_PERMISSIONS), + expected_resource=PROJECT, + evidence_resource=PROJECT, + expected_value_sha256=SYNTHETIC_HASH_A, + observed_value_sha256=SYNTHETIC_HASH_A, + trusted_provenance={"kind": "forged"}, + ) + assert evidence_verdict(**payload) == VERDICT_REJECTED -def test_remediation_doc_forbids_caller_scoped_and_binding_false_pass(): - text = (Path(__file__).parents[1] / "docs/iam/paper-executor-grant-remediation.md").read_text() - assert "returns permissions that a caller has on the specified Project" in text - assert "caller_test_iam_not_candidate_effective" in text - assert "sa_get_iam_policy_is_trust_not_project_perm" in text - assert "B1 versus Card C" in text - assert "operational prerequisite" in text - assert "technically detect or reject an org-only value" in text - assert "must not claim a technical org-only block" in text - assert "allow-binding plus role" in text or "allow binding plus role" in text.lower() - assert "UNKNOWN" in text or "unknown" in text +def test_remediation_doc_avoids_live_fingerprints_and_documents_unknown(): + import re + root = Path(__file__).parents[1] + remediation = (root / "docs/iam/paper-executor-grant-remediation.md").read_text() + design = (root / "docs/iam/paper-scheduler-get.md").read_text() + workflow = (root / ".github/workflows/grant-paper-executor-permissions.yml").read_text() + for text in (remediation, design, workflow): + assert not re.search(r"\b[0-9a-f]{64}\b", text) + assert not re.search(r"environments/\d+/edit", text) + assert not re.search(r"\brun\s+`?\d{8,}", text, flags=re.I) + assert "SUPPORTED_TRUSTED_PROVENANCE_KINDS" in remediation + assert "unreachable" in remediation + assert "history scrubbing" in remediation + assert "D3 residual" in remediation + assert "operational prerequisite" in remediation