diff --git a/.github/workflows/grant-paper-executor-permissions.yml b/.github/workflows/grant-paper-executor-permissions.yml index dc79a06..d177ea1 100644 --- a/.github/workflows/grant-paper-executor-permissions.yml +++ b/.github/workflows/grant-paper-executor-permissions.yml @@ -52,10 +52,24 @@ 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 + # (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: + 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 +79,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-executor-grant-remediation.md b/docs/iam/paper-executor-grant-remediation.md new file mode 100644 index 0000000..0e76772 --- /dev/null +++ b/docs/iam/paper-executor-grant-remediation.md @@ -0,0 +1,291 @@ +# 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). 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 (generic): + +- Environment `longbridge-paper` variables table (settings → Environments → + `longbridge-paper`) +- Repository Actions variables table (settings → Secrets and variables → Actions) + +Owner confirmation (names/kinds/presence only; raw values and digests stay in +private operator notes): + +| 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 +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; 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. | + +### 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 bootstrap 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 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) + +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 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). + +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. 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 +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. 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. +- 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. +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 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 acceptance under a supported provenance +kind, 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) — held by bootstrap **before** +any Card C run so the grant job can authenticate as bootstrap: + +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. + +#### 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: + +- 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. + +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`. 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 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 (**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 mapping (no GCP GET + in this pass). +- 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). +- Codex `ai-review` / clearing-label state for PR #722 (merge policy; no labels + added by this pass). + +## Verification posture + +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, 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 7b08db9..26c742a 100644 --- a/docs/iam/paper-scheduler-get.md +++ b/docs/iam/paper-scheduler-get.md @@ -215,26 +215,50 @@ 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 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 (`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 + 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. 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` 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 retaining etag and every other field for concurrent-drift detection. diff --git a/scripts/paper_iam_discovery_evidence.py b/scripts/paper_iam_discovery_evidence.py new file mode 100644 index 0000000..7ad4eb6 --- /dev/null +++ b/scripts/paper_iam_discovery_evidence.py @@ -0,0 +1,203 @@ +"""Offline classifiers for PAPER IAM bootstrap discovery evidence. + +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 + +# 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. +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( + *, + 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, + 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 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: + 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") + + 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") + + 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") + + # 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") + + 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: + 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: + 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") + + 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, + 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 without trusted provenance. + + 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( + 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") + + 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. + + 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 + 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 + # 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 + + +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: + if reason not in seen: + seen.add(reason) + ordered.append(reason) + return ordered diff --git a/tests/test_paper_executor_grant.py b/tests/test_paper_executor_grant.py index 884e125..e2e75b7 100644 --- a/tests/test_paper_executor_grant.py +++ b/tests/test_paper_executor_grant.py @@ -153,6 +153,38 @@ 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() + workflow = (root / ".github/workflows/grant-paper-executor-permissions.yml").read_text() + for token in [ + "OWNER-PROVIDED", "PROPOSED_NOT_OBSERVED", "Card A", "Card B", + "Variable absence is **not** evidence", + "Rejected as bootstrap", + "paper-iam-bootstrap@", + "GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS", + "no grant", + "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 "technically detect or reject" in design + assert "NOT technically rejected" in workflow + # 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"]) def test_role_response_order_is_irrelevant(mode): cloud = Cloud() @@ -200,7 +232,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 +242,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 +264,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 +315,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 diff --git a/tests/test_paper_iam_discovery_evidence.py b/tests/test_paper_iam_discovery_evidence.py new file mode 100644 index 0000000..29c6862 --- /dev/null +++ b/tests/test_paper_iam_discovery_evidence.py @@ -0,0 +1,243 @@ +"""Fake-only adversarial contracts for bootstrap discovery evidence semantics. + +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, + 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" +# Explicitly synthetic fixture digests — not live mapping fingerprints. +SYNTHETIC_HASH_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +SYNTHETIC_HASH_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + +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 _self_attested_flags(**overrides): + """Checklist booleans only — not trusted provenance.""" + 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, + ) + payload.update(overrides) + return payload + + +def test_bootstrap_and_recipient_permission_strings_match_by_owner_design(): + assert BOOTSTRAP_PERMISSIONS == RECIPIENT_GRANT_PERMISSIONS + 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), + 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 evidence_verdict(**payload) == VERDICT_REJECTED + + +def test_sa_get_iam_policy_alone_is_trust_not_project_permission_proof(): + payload = dict( + candidate_principal=CANDIDATE, + evaluated_resource=PROJECT, + actual_caller=OPERATOR, + evaluated_permissions=BOOTSTRAP_PERMISSIONS, + 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(): + payload = _self_attested_flags( + actual_caller=CANDIDATE, + caller_test_iam_permissions=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(): + payload = _self_attested_flags(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 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 = _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 = _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(): + 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 "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_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 evidence_verdict(**payload) == VERDICT_UNKNOWN + assert not evidence_accepted(**payload) + + +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, + ) + assert "conditional_binding_not_authoritatively_satisfied" in unknown_reasons(**payload) + assert evidence_verdict(**payload) == VERDICT_UNKNOWN + + +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_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