diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d6160f..0f7cb9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,8 +38,9 @@ jobs: run: | ruby -e 'require "yaml"; YAML.safe_load(File.read(".github/workflows/deploy.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)' ruby -e 'require "yaml"; YAML.safe_load(File.read(".github/workflows/verify-staging-authority.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)' + ruby -e 'require "yaml"; YAML.safe_load(File.read(".github/workflows/verify-staging-profile-authority.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)' ruby -e 'require "yaml"; YAML.safe_load(File.read(".github/workflows/ci.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)' - node --test tests/ci-workflow.test.js tests/release-workflow.test.js tests/firebase-release-verification.test.js tests/firebase-hosting-foundation.test.js tests/firebase-auth-staging-contract.test.js tests/firebase-profile-functions-staging-contract.test.js tests/staging-authority-workflow.test.js tests/test-artifact-safety.test.js tests/root-dependency-security.test.js + node --test tests/ci-workflow.test.js tests/release-workflow.test.js tests/firebase-release-verification.test.js tests/firebase-hosting-foundation.test.js tests/firebase-auth-staging-contract.test.js tests/firebase-profile-functions-staging-contract.test.js tests/staging-authority-workflow.test.js tests/staging-profile-authority.test.js tests/test-artifact-safety.test.js tests/root-dependency-security.test.js - name: Build env: CI: 'true' diff --git a/.github/workflows/verify-staging-profile-authority.yml b/.github/workflows/verify-staging-profile-authority.yml new file mode 100644 index 0000000..371aea4 --- /dev/null +++ b/.github/workflows/verify-staging-profile-authority.yml @@ -0,0 +1,53 @@ +name: Verify staging profile authority + +on: + workflow_dispatch: + +permissions: {} + +concurrency: + group: verify-staging-profile-authority + cancel-in-progress: false + +jobs: + verify-authority: + name: Verify scoped staging profile identity without deployment + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: + name: backend-staging + permissions: + contents: read + actions: read + id-token: write + env: + TARGET_ENVIRONMENT: backend-staging + TARGET_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} + DEPLOY_SERVICE_ACCOUNT: ${{ secrets.GCP_DEPLOY_SERVICE_ACCOUNT }} + WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + GH_TOKEN: ${{ github.token }} + steps: + - name: Check out exact workflow source without persisted credentials + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + - name: Set up reviewed Node runtime + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '22' + - name: Validate exact current-main context and all six CI jobs before authentication + run: node scripts/staging-profile-authority.js context + - id: auth + name: Obtain one five-minute staging identity with no credential file + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + token_format: access_token + access_token_lifetime: 300s + create_credentials_file: false + export_environment_variables: false + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GCP_DEPLOY_SERVICE_ACCOUNT }} + - name: Recheck exact source and verify project permissions without deploying + env: + GCP_ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} + run: node scripts/staging-profile-authority.js cloud diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 5a4ed10..ec55b61 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -28,6 +28,8 @@ Before creating or claiming anything from this catalog, search GitHub, confirm t ## Current runtime prerequisite +**Focused authority child, 2026-10-11:** CI-001C3 [#699](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/699) establishes a separate staging profile identity and protected no-op proof under #133, and applies the owner's restored administrator-bypass decision. Scope is staging IAM/approval metadata plus fixed read-only verification and tests, not runtime/data/payment or website deployment. Production/legacy #113, runtime/build identity, actual #136 deployment and hosted recovery proof remain open. GitHub records source, provider configuration, activation and no-op proof separately. + **Current staging cost prerequisite:** COST-002 [#693](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/693) records the October 11 club Free Trial readback, staging-only billing link, and monthly USD10 gross-usage warning budget with 50%/90%/100% actual-spend thresholds. Only the Budget API was enabled; production billing, runtime APIs, deployment and member/payment data remain unchanged. This supersedes historical staging billing-disabled checkpoints, not #113/#133/#136 release gates. GitHub holds the provider acceptance and documentation status; the [officer cost guide](./docs/officers/LOW_COST_COMMUNITY.md) is the no-terminal handoff. RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) prepares the initial profile backend for Node 22. Scope: Functions engine and lock metadata; exact staging guard/provider readback; a required sixth CI job; backend builder runtime; a fixed immutable redacted error stack; and officer handoff. Existing Node 20 checks, first-generation triggers, schema, auth, create-once semantics, two-Function scope, and #683 limits stay unchanged. No dependency versions or migrations change. #113/#133/#136, billing, hosted checks, and existing security advisories remain open; this is not a release or runner-matching activation. GitHub holds exact review/merge/CI evidence. diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 9a132c1..f471953 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -16,6 +16,8 @@ Attach the exact wording, public link, or approved photo when you have it. The A ## Choose the closest task +**Focused authority step, 2026-10-11:** [#699](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/699) keeps backend preparation limited to staging profile setup. Production still names Dave and main, but administrator bypass is allowed again by Dave's instruction. A separate `backend-staging` identity check reads only project/access metadata and deploys nothing. See [the short authority procedure](./docs/officers/ACCESS_CONTINUITY.md#scoped-staging-profile-authority--699). It is **NOT AVAILABLE YET** until #699 records its merged source and protected proof. Member/payment operation remains unavailable; no officer handles a key or member record. + **Backend ownership, 2026-10-11:** Dave approved continuing without a backup owner under [#697](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/697). Dave remains the sole backend release/recovery approver; backup coverage is absent, not complete. This removes only the backup-appointment gate. Protected deployment, staging proof, rollback and all data/payment approvals remain required. See [the sole-owner procedure](./docs/officers/ACCESS_CONTINUITY.md#sole-owner-backend-exception--approved-october-11-2026); no officer handles keys or runs commands. | What you need | Open this short guide | diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 05bb8c9..66f787c 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -42,7 +42,11 @@ Review access quarterly, when an officer changes, and after every incident. Remo ## 2. Emergency controls -**#697 provider readback, 2026-10-11:** GitHub `production` now has one required reviewer (`daliu`), a branch-only `main` policy, self-approval allowed, and administrator bypass disabled. The existing read-only `staging` settings are unchanged. No cloud IAM/API/billing, Firebase or website deployment occurred; a protected no-op/release run and deployment identity remain separate #133 proof. +**Superseding #699 owner decision, 2026-10-11:** Dave approved releases and restoring administrator bypass. GitHub `production` retains reviewer `daliu`, branch-only `main` and self-approval, but now allows administrator bypass. #697's earlier bypass-disabled readback is historical. A bypass is a recorded administrator override, not proof of reviewer approval; it does not bypass cloud/source/CI/target checks or appoint another owner. The existing read-only `staging` settings and Jeff's authority are unchanged. + +**Scoped staging authority, #699:** a separate `backend-staging` environment and dedicated keyless principal/provider have been configured for `run-mprc-staging`. The custom project role contains the 26 permissions in [the reviewed contract](./config/staging-profile-authority.json), not Owner/Editor, general Firebase administration, secret access, direct member-data permissions or runtime/build impersonation. Existing runtime APIs remain disabled; no Function, Rule, website or payment is deployed by this step. The protected workflow is manual and read-only, including `testIamPermissions`, which checks access without changing IAM. Consult #699 for exact source, provider activation, protected run and readback evidence; until that acceptance is complete, the authority procedure is **NOT AVAILABLE YET**. + +IAM creation/update permissions are project-scoped: they cannot by themselves restrict Function names to the two profile names. The reviewed workflow performs no deployment at all. A later release must separately add a fixed two-Function source path and reviewed runtime/build identities; do not advertise this role as per-Function IAM isolation or a working backend. Permission reasons: project/Firebase and Service Usage reads identify the target; `serviceusage.services.use` permits quota attribution, not API enabling; first-generation Function lifecycle/IAM/source-upload plus build-status reads support the targeted future release; Rules create/test/read/release-update supports reviewed Rules and roll-forward. Delete, secret, account administration and direct data access are omitted. Pinned CLI 15.24.0 inspection confirmed explicit runtime/`actAs` work remains necessary; do not give project-wide impersonation as a shortcut. [Google's deployment IAM guidance](https://docs.cloud.google.com/functions/docs/reference/iam/roles) and [GitHub claim reference](https://docs.github.com/en/actions/reference/security/oidc) informed this review. ### Commerce pause — B1 source only, operator control NOT AVAILABLE YET diff --git a/SECURITY.md b/SECURITY.md index f807c36..9e12b32 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,6 +18,8 @@ RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) ta The dual-runtime run exposed a Node 22 error-stack accessor that remained writable despite `Object.freeze`. The unused Checkout observation projector now replaces that accessor with a fixed non-enumerable, non-writable data property before freezing; the regression test also rejects a stack-formatting hook. No raw provider value or stack frame is retained in that diagnostic. Existing malformed-input, proxy/getter, no-output, and redaction tests remain intact. This is runtime compatibility, not payment activation or a dependency-audit fix. #113/#133/#136, existing dependency findings, and hosted verification remain separate release gates. +**Scoped staging authority, 2026-10-11 — #699:** Dave approved restoring GitHub administrator bypass while keeping named approval and main-only policy. A bypass must be recorded as an administrator override, not a normal reviewer approval. Separate `backend-staging` federation is bound to immutable repository/owner IDs, exact main workflow/manual event/environment and matching workflow/source SHA. The existing #667 read-only authority is unchanged. The current workflow only verifies project metadata and permissions; no deployment or direct member/account/secret access is part of it. A 26-permission custom staging role avoids Owner/Editor and broad Firebase administration; project-level Function creation is not a per-name IAM boundary. Runtime/build impersonation, an actual fixed-target deploy path, synthetic hosted profile tests and recovery remain later reviewed work. No production data, payments or legacy disposition are authorized by this staging slice. + ## 2. Reporting a vulnerability **Profile callable source boundary, 2026-10-11:** ABUSE-001A2A [#695](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/695) builds only `ensureMemberProfile` with native `enforceAppCheck: true`, independent of `ENFORCE_APP_CHECK`. Real installed-SDK request-wrapper tests use synthetic token verifiers and mocked profile stores; missing/invalid app tokens are rejected before business work with the flag absent, false or true. Valid app requests still require valid Auth and an empty payload. No shared helper, Auth-created trigger, resource limit, dependency, callback-dependent callable, provider setting or deployed service changes. This is source evidence, not cryptographic token verification, an emulator browser flow, or hosted enforcement. Other sensitive callables and #113/#133/#136 deployment/authorization/provider proof remain open. diff --git a/config/staging-profile-authority.json b/config/staging-profile-authority.json new file mode 100644 index 0000000..0f6e1d5 --- /dev/null +++ b/config/staging-profile-authority.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 1, + "projectId": "run-mprc-staging", + "environment": "backend-staging", + "workflow": "verify-staging-profile-authority.yml", + "authoritySha256": "ed13e7498b1517316611aa5efb5ae2be965e28adc03d3b1edf8d1e9bcaab0a6e", + "futureFunctionTargets": ["createMemberOnSignUp", "ensureMemberProfile"], + "permissions": [ + "firebase.projects.get", + "resourcemanager.projects.get", + "serviceusage.services.get", + "serviceusage.services.list", + "serviceusage.services.use", + "cloudfunctions.functions.create", + "cloudfunctions.functions.get", + "cloudfunctions.functions.list", + "cloudfunctions.functions.update", + "cloudfunctions.functions.sourceCodeSet", + "cloudfunctions.functions.getIamPolicy", + "cloudfunctions.functions.setIamPolicy", + "cloudfunctions.locations.list", + "cloudfunctions.operations.get", + "cloudfunctions.operations.list", + "cloudbuild.builds.get", + "cloudbuild.builds.list", + "firebaserules.rulesets.create", + "firebaserules.rulesets.get", + "firebaserules.rulesets.list", + "firebaserules.rulesets.test", + "firebaserules.releases.create", + "firebaserules.releases.get", + "firebaserules.releases.getExecutable", + "firebaserules.releases.list", + "firebaserules.releases.update" + ], + "deniedPermissions": [ + "resourcemanager.projects.setIamPolicy", + "serviceusage.services.enable", + "iam.serviceAccountKeys.create", + "iam.serviceAccounts.actAs", + "firebaseauth.users.get", + "firebaseauth.users.update", + "datastore.entities.get", + "datastore.entities.create", + "secretmanager.versions.access", + "cloudfunctions.functions.delete" + ] +} diff --git a/docs/officers/ACCESS_CONTINUITY.md b/docs/officers/ACCESS_CONTINUITY.md index aea7c7f..5d6ae5c 100644 --- a/docs/officers/ACCESS_CONTINUITY.md +++ b/docs/officers/ACCESS_CONTINUITY.md @@ -14,18 +14,18 @@ This dated exception supersedes this guide's two-owner appointment and approval **Prerequisites:** a maintainer supplies a reviewed exact commit, successful required checks, one fixed project/resource set, private redacted evidence and a recovery plan. Protected release authority must be configured and verified first. -**GitHub configuration readback, October 11:** the `production` environment now requires `daliu`, admits only branch `main`, and disables administrator bypass. Self-approval remains allowed so the sole owner can approve their own request. The existing `staging` read-only environment is unchanged. This verifies approval settings only, not a release run, cloud deploy authority or account recovery. +**Superseding GitHub readback, October 11 — #699:** the `production` environment retains `daliu`, branch `main` only and self-approval, but administrator bypass is allowed again at Dave's request. The earlier #697 bypass-disabled state is historical. Record a bypass as an administrator override, not a reviewer approval; it does not change the cloud/source/target checks or appoint a substitute owner. Existing `staging` read-only settings and Jeff's authority are unchanged. This is approval configuration, not a deployed backend or account-recovery proof. ```mermaid flowchart LR Request["Reviewed exact backend request"] --> Dave{"Dave approves fixed release?"} Dave -- "No or unavailable" --> Stop["Stop; no substitute owner"] - Dave -- "Yes" --> Gate["Protected keyless identity and target checks"] + Dave -- "Yes or recorded administrator override" --> Gate["Protected keyless identity and target checks"] Gate --> Proof["Synthetic staging and rollback proof"] Proof --> Release["Separate approved target release"] ``` -Text alternative: Dave alone may approve the fixed backend request; a protected keyless identity and staging/recovery proof must still pass, and Dave's absence stops rather than appoints a replacement. +Text alternative: Dave remains the named owner; an administrator override is possible and must be recorded separately, while keyless identity, exact target and staging/recovery proof still apply and no replacement owner is implied. 1. Open the maintainer's reviewed release request. 2. Confirm its exact commit, fixed project and named resources. @@ -131,6 +131,38 @@ Text alternative: the platform maintainer requests the exact verifier from `main **Escalation:** platform owner first; security owner second. If a token, private locator, or unexpected cloud response appears, stop and use the private incident path. +### Scoped staging profile authority — #699 + +**Purpose:** check the separate profile-release identity without deploying a backend or reading member data. +**Approver:** Dave Liu. Administrator bypass is allowed; record any use as an override rather than claiming Dave reviewed the run. +**Availability:** check [#699](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/699) first. Until exact merged source, provider activation and protected run proof are recorded there, this procedure is **NOT AVAILABLE YET**. Member/profile deployment and payments remain **NOT AVAILABLE YET** regardless of this check. +**Prerequisites:** the maintainer confirms the dedicated staging federation is enabled, the exact current main commit passed all six checks, and no runtime/build impersonation or direct member/secret permission has been added. Use only workflow **Verify staging profile authority** and environment **backend-staging**. The old **staging** environment remains read-only and separate. + +```mermaid +flowchart LR + Main["Exact main; all six checks passed"] --> Approval{"Dave approval or recorded admin override"} + Approval --> Context["Fixed staging identity and workflow checks"] + Context --> Token["Five-minute keyless token; no credential file"] + Token --> Read["Project metadata and access check only"] + Read --> Proof["Fixed result; no backend deployed"] +``` + +Text alternative: exact green main and approval or a recorded override permit one fixed staging identity check; a five-minute token can read project/access metadata, and no backend or website is deployed. + +1. Open the maintainer's run link and confirm the workflow name above. +2. Confirm branch main and environment backend-staging. +3. Confirm the request says identity check only, not backend or website deployment. +4. Confirm the exact source and all six successful checks. +5. Approve the matching request, or record the administrator override and its reason. +6. Wait for the fixed `staging_profile_authority_cloud_verified` result. +7. Ask the maintainer to record dated permission/key/trust readback on #699 privately, without provider locators or policy dumps. + +**Expected result:** the check succeeds without creating a credential file, deploying anything or reading an account/document. +**Stop conditions:** stop on a wrong/stale source, changed workflow/environment, new write step, missing/failed check, unexpected permission or request for a key/member record. A failed check does not authorize a broader role. +**Success proof:** run link, exact source, named approval or recorded override, fixed result and private readback of zero user-managed keys. The role permits staging Function/Rules configuration, not direct records or project administration. IAM creation is project-scoped; the workflow, not IAM Function-name filtering, limits this step to reads. +**Undo:** ask the platform maintainer to disable only the dedicated profile federation pool or remove only its workload-identity binding, using the native service record. Existing five-minute tokens can survive until expiry. Leave the old read-only identity, project, records and unrelated roles alone. Re-enabling requires source/permission review and this check again. +**Escalation:** Dave as platform owner; security specialist for an unexpected permission, exposure or identity mismatch. Actual identity revocation and later backend deployment still require a maintainer, not an officer terminal. + ## Private service record For each system, record only: diff --git a/docs/officers/PUBLISH_AND_CHECK.md b/docs/officers/PUBLISH_AND_CHECK.md index 7b8c063..9c6abe6 100644 --- a/docs/officers/PUBLISH_AND_CHECK.md +++ b/docs/officers/PUBLISH_AND_CHECK.md @@ -76,6 +76,8 @@ Remote readback on 2026-09-14 found the old #473/#623/#659 hot source refs still ## The release gate +**#699 approval correction, October 11:** administrator bypass is allowed again by Dave's instruction; Dave and main remain configured. Record any override separately from reviewer approval. The new **Verify staging profile authority** workflow uses **backend-staging** and performs only project/access reads, not deployment. Follow [its narrow procedure](./ACCESS_CONTINUITY.md#scoped-staging-profile-authority--699) only once #699 records merged source/provider/protected proof; until then **NOT AVAILABLE YET**. No new officer is needed. General backend/website release and member/payment operation remain unavailable until their own proof. + **Sole-owner backend decision, 2026-10-11:** under [#697](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/697), Dave Liu (`daliu`) is the sole backend release/recovery approver. Do not wait for backup appointment or imply backup coverage. The [scoped procedure](./ACCESS_CONTINUITY.md#sole-owner-backend-exception--approved-october-11-2026) keeps protected approval, exact source/project/resource checks, keyless least-privilege identity, synthetic staging and reviewed recovery. It grants no broader authority to the existing staging read-only reviewers. The general release remains **NOT AVAILABLE YET** until its other gates are verified. ```mermaid diff --git a/docs/officers/README.md b/docs/officers/README.md index c42f887..1e7c237 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -33,6 +33,8 @@ In words: approve the merge, request one exact release, and approve its protecte ## Short guides +**Focused backend authority, #699:** Dave approved releases and restoring administrator bypass. Production retains Dave/main; a separate `backend-staging` identity check cannot deploy the backend. Follow [the scoped procedure](./ACCESS_CONTINUITY.md#scoped-staging-profile-authority--699) only after #699 has recorded exact merged source and protected proof; until then it is **NOT AVAILABLE YET**. This creates no member/payment service or broader authority for the existing read-only staging reviewers. + 1. [Request a change](./REQUEST_A_CHANGE.md) 2. [Update public content](./UPDATE_PUBLIC_CONTENT.md) 3. [Events, shop, members, and money](./EVENTS_SHOP_MEMBERS.md) diff --git a/scripts/staging-profile-authority.js b/scripts/staging-profile-authority.js new file mode 100644 index 0000000..5b52950 --- /dev/null +++ b/scripts/staging-profile-authority.js @@ -0,0 +1,130 @@ +'use strict'; + +const { createHash } = require('node:crypto'); +const contract = require('../config/staging-profile-authority.json'); + +const REPOSITORY = 'Run-MPRC/Run-MPRC.github.io'; +const REQUIRED_JOBS = Object.freeze([ + 'Frontend lint + build', + 'Test artifact scrubber', + 'Cloud Functions lint + test', + 'Firestore security-rules tests', + 'Commerce command journal emulator', + 'Node 22 backend compatibility', +]); + +function requireValid(condition) { + if (!condition) throw new Error('staging_profile_authority_failed'); +} + +function validateContext(env, fingerprint = contract.authoritySha256) { + requireValid(env.GITHUB_REPOSITORY === REPOSITORY); + requireValid(env.GITHUB_REPOSITORY_ID === '718285092'); + requireValid(env.GITHUB_REPOSITORY_OWNER_ID === '150727922'); + requireValid(env.GITHUB_REF === 'refs/heads/main'); + requireValid(env.GITHUB_EVENT_NAME === 'workflow_dispatch'); + requireValid(env.GITHUB_WORKFLOW_REF === `${REPOSITORY}/.github/workflows/${contract.workflow}@refs/heads/main`); + requireValid(/^[0-9a-f]{40}$/.test(env.GITHUB_SHA || '')); + requireValid(env.GITHUB_WORKFLOW_SHA === env.GITHUB_SHA); + requireValid(env.TARGET_ENVIRONMENT === contract.environment); + requireValid(env.TARGET_PROJECT_ID === contract.projectId); + requireValid(typeof env.DEPLOY_SERVICE_ACCOUNT === 'string'); + requireValid(typeof env.WORKLOAD_IDENTITY_PROVIDER === 'string'); + requireValid(createHash('sha256').update( + `${env.WORKLOAD_IDENTITY_PROVIDER}\n${env.DEPLOY_SERVICE_ACCOUNT}`, + ).digest('hex') === fingerprint); +} + +async function readJson(fetchImpl, url, token, body) { + requireValid(typeof token === 'string' && token.length > 0); + const response = await fetchImpl(url, { + method: body ? 'POST' : 'GET', + redirect: 'error', + signal: AbortSignal.timeout(10000), + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + requireValid(response.ok); + // Never print a response, locator, token or provider error. + return response.json(); +} + +async function verifyCurrentSource(env, fetchImpl = fetch) { + const base = `https://api.github.com/repos/${REPOSITORY}`; + const main = await readJson(fetchImpl, `${base}/git/ref/heads/main`, env.GH_TOKEN); + requireValid(main.object?.sha === env.GITHUB_SHA); + const runs = await readJson(fetchImpl, + `${base}/actions/workflows/ci.yml/runs?branch=main&event=push&head_sha=${env.GITHUB_SHA}&per_page=20`, env.GH_TOKEN); + requireValid(Array.isArray(runs.workflow_runs) && runs.workflow_runs.length <= 20); + const candidates = runs.workflow_runs.filter((run) => run.head_sha === env.GITHUB_SHA + && run.head_branch === 'main' && run.event === 'push'); + requireValid(candidates.every((run) => Number.isSafeInteger(run.run_number) && run.run_number > 0 + && (run.run_attempt === undefined || (Number.isSafeInteger(run.run_attempt) && run.run_attempt > 0)))); + candidates.sort((a, b) => b.run_number - a.run_number || (b.run_attempt || 0) - (a.run_attempt || 0)); + const latest = candidates[0]; + requireValid(latest?.status === 'completed' && latest.conclusion === 'success'); + requireValid(Number.isSafeInteger(latest.id) && latest.id > 0); + const results = await readJson(fetchImpl, + `${base}/actions/runs/${latest.id}/jobs?filter=latest&per_page=100`, env.GH_TOKEN); + requireValid(Array.isArray(results.jobs) && results.total_count === results.jobs.length && results.total_count <= 100); + requireValid(REQUIRED_JOBS.every((name) => { + const matches = results.jobs.filter((job) => job.name === name); + return matches.length === 1 && matches[0].status === 'completed' + && matches[0].conclusion === 'success' + && Array.isArray(matches[0].steps) && matches[0].steps.length > 0 + && matches[0].steps.every((step) => step.status === 'completed' && step.conclusion === 'success'); + })); +} + +function validatePermissions(result) { + requireValid(Array.isArray(result.permissions)); + requireValid(result.permissions.every((permission) => typeof permission === 'string')); + const allowed = new Set(result.permissions); + requireValid(result.permissions.length === contract.permissions.length); + requireValid(contract.permissions.every((permission) => allowed.has(permission))); + requireValid(contract.deniedPermissions.every((permission) => !allowed.has(permission))); +} + +async function verifyCloudAuthority(env, fetchImpl = fetch) { + const project = await readJson(fetchImpl, + `https://cloudresourcemanager.googleapis.com/v3/projects/${contract.projectId}`, env.GCP_ACCESS_TOKEN); + requireValid(project.projectId === contract.projectId && project.state === 'ACTIVE'); + const number = env.WORKLOAD_IDENTITY_PROVIDER?.match(/^projects\/([0-9]+)\//)?.[1]; + requireValid(number && project.name === `projects/${number}`); + const permissions = await readJson(fetchImpl, + `https://cloudresourcemanager.googleapis.com/v1/projects/${contract.projectId}:testIamPermissions`, + env.GCP_ACCESS_TOKEN, { permissions: [...contract.permissions, ...contract.deniedPermissions] }); + validatePermissions(permissions); +} + +async function main() { + try { + requireValid(process.argv.length === 3); + const phase = process.argv[2]; + requireValid(phase === 'context' || phase === 'cloud'); + validateContext(process.env); + if (phase === 'context') { + await verifyCurrentSource(process.env); + // Secret locators are already masked by GitHub. Also mask the number + // fragment that a third-party authentication error could echo alone. + const number = process.env.WORKLOAD_IDENTITY_PROVIDER.match(/^projects\/([0-9]+)\//)?.[1]; + requireValid(number); + process.stdout.write(`::add-mask::${number}\n`); + } + else { + await verifyCurrentSource(process.env); + await verifyCloudAuthority(process.env); + } + process.stdout.write(`staging_profile_authority_${phase}_verified\n`); + } catch { + process.stderr.write('staging_profile_authority_failed\n'); + process.exitCode = 1; + } +} + +if (require.main === module) void main(); +module.exports = { contract, REQUIRED_JOBS, validateContext, verifyCurrentSource, validatePermissions, verifyCloudAuthority }; diff --git a/tests/staging-profile-authority.test.js b/tests/staging-profile-authority.test.js new file mode 100644 index 0000000..7f3dace --- /dev/null +++ b/tests/staging-profile-authority.test.js @@ -0,0 +1,175 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { createHash } = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const { + contract, REQUIRED_JOBS, validateContext, verifyCurrentSource, + validatePermissions, verifyCloudAuthority, +} = require('../scripts/staging-profile-authority'); + +const ROOT = path.resolve(__dirname, '..'); +const workflow = fs.readFileSync(path.join(ROOT, '.github/workflows/verify-staging-profile-authority.yml'), 'utf8'); +const script = fs.readFileSync(path.join(ROOT, 'scripts/staging-profile-authority.js'), 'utf8'); +const sha = 'a'.repeat(40); +const context = Object.freeze({ + GITHUB_REPOSITORY: 'Run-MPRC/Run-MPRC.github.io', + GITHUB_REPOSITORY_ID: '718285092', + GITHUB_REPOSITORY_OWNER_ID: '150727922', + GITHUB_REF: 'refs/heads/main', + GITHUB_EVENT_NAME: 'workflow_dispatch', + GITHUB_WORKFLOW_REF: 'Run-MPRC/Run-MPRC.github.io/.github/workflows/verify-staging-profile-authority.yml@refs/heads/main', + GITHUB_SHA: sha, + GITHUB_WORKFLOW_SHA: sha, + TARGET_ENVIRONMENT: 'backend-staging', + TARGET_PROJECT_ID: 'run-mprc-staging', + DEPLOY_SERVICE_ACCOUNT: 'synthetic@run-mprc-staging.iam.gserviceaccount.com', + WORKLOAD_IDENTITY_PROVIDER: 'projects/123456/locations/global/workloadIdentityPools/synthetic/providers/github', + GH_TOKEN: 'synthetic-github-token', + GCP_ACCESS_TOKEN: 'synthetic-cloud-token', +}); +const fingerprint = createHash('sha256').update( + `${context.WORKLOAD_IDENTITY_PROVIDER}\n${context.DEPLOY_SERVICE_ACCOUNT}`, +).digest('hex'); + +function mockFetch(responses, calls = []) { + return async (url, options) => { + calls.push({ url, options }); + assert.ok(responses.length > 0, 'unexpected request'); + const value = responses.shift(); + return { ok: true, json: async () => value }; + }; +} +function sourceResponses() { + return [ + { object: { sha } }, + { workflow_runs: [{ id: 7, run_number: 10, run_attempt: 1, head_sha: sha, head_branch: 'main', event: 'push', status: 'completed', conclusion: 'success' }] }, + { total_count: 6, jobs: REQUIRED_JOBS.map((name) => ({ name, status: 'completed', conclusion: 'success', steps: [{ name: 'synthetic check', status: 'completed', conclusion: 'success' }] })) }, + ]; +} + +test('context accepts only the closed immutable manual staging context', () => { + assert.doesNotThrow(() => validateContext(context, fingerprint)); + for (const [name, value] of Object.entries({ + GITHUB_REPOSITORY: 'different/repository', GITHUB_REPOSITORY_ID: '1', + GITHUB_REPOSITORY_OWNER_ID: '1', GITHUB_REF: 'refs/heads/other', + GITHUB_EVENT_NAME: 'push', GITHUB_WORKFLOW_REF: 'other.yml', + GITHUB_SHA: 'invalid', GITHUB_WORKFLOW_SHA: 'b'.repeat(40), + TARGET_ENVIRONMENT: 'production', TARGET_PROJECT_ID: 'mid-peninsula-running-club', + DEPLOY_SERVICE_ACCOUNT: 'different.test', WORKLOAD_IDENTITY_PROVIDER: 'different.test', + })) { + assert.throws(() => validateContext({ ...context, [name]: value }, fingerprint)); + } + for (const name of Object.keys(context).filter((name) => !name.endsWith('TOKEN'))) { + const missing = { ...context }; + delete missing[name]; + assert.throws(() => validateContext(missing, fingerprint)); + } + assert.throws(() => validateContext(context), 'synthetic authority cannot match real contract'); +}); + +test('current-main and all six successful jobs are required, using fixed read-only GitHub URLs', async () => { + const calls = []; + await verifyCurrentSource(context, mockFetch(sourceResponses(), calls)); + assert.equal(calls.length, 3); + assert.ok(calls.every(({ url, options }) => url.startsWith('https://api.github.com/repos/Run-MPRC/Run-MPRC.github.io/') && options.method === 'GET' && options.redirect === 'error')); + const stale = sourceResponses(); + stale[0].object.sha = 'b'.repeat(40); + const staleCalls = []; + await assert.rejects(verifyCurrentSource(context, mockFetch(stale, staleCalls))); + assert.equal(staleCalls.length, 1); +}); + +test('missing, duplicate, skipped or failed job/step cannot borrow another successful CI result', async () => { + for (const mutate of [ + (r) => { r[1].workflow_runs[0].conclusion = 'failure'; }, + (r) => { r[1].workflow_runs[0].status = 'in_progress'; }, + (r) => { r[1].workflow_runs[0].head_branch = 'other'; }, + (r) => { r[1].workflow_runs[0].event = 'pull_request'; }, + (r) => { r[1].workflow_runs[0].run_number = 'invalid'; }, + (r) => { r[2].jobs.pop(); }, + (r) => { r[2].jobs.push(r[2].jobs[0]); }, + (r) => { r[2].jobs[0].conclusion = 'skipped'; }, + (r) => { r[2].jobs[0].steps[0].conclusion = 'skipped'; }, + (r) => { r[2].jobs[0].steps = []; }, + (r) => { r[2].total_count = 101; }, + ]) { + const responses = sourceResponses(); + mutate(responses); + await assert.rejects(verifyCurrentSource(context, mockFetch(responses))); + } +}); + +test('latest failed or pending rerun beats older success; wrong-head runs are ignored', async () => { + for (const status of ['completed', 'in_progress']) { + const responses = sourceResponses(); + responses[1].workflow_runs.push({ ...responses[1].workflow_runs[0], id: 8, run_attempt: 2, status, conclusion: status === 'completed' ? 'failure' : null }); + await assert.rejects(verifyCurrentSource(context, mockFetch(responses))); + } + const responses = sourceResponses(); + responses[1].workflow_runs.push({ ...responses[1].workflow_runs[0], head_sha: 'b'.repeat(40), run_number: 100, conclusion: 'failure' }); + await verifyCurrentSource(context, mockFetch(responses)); +}); + +test('authority check uses only exact staging metadata and read-only testIamPermissions', async () => { + const calls = []; + await verifyCloudAuthority(context, mockFetch([ + { name: 'projects/123456', projectId: 'run-mprc-staging', state: 'ACTIVE' }, + { permissions: contract.permissions }, + ], calls)); + assert.deepEqual(calls.map(({ url, options }) => [url, options.method]), [ + ['https://cloudresourcemanager.googleapis.com/v3/projects/run-mprc-staging', 'GET'], + ['https://cloudresourcemanager.googleapis.com/v1/projects/run-mprc-staging:testIamPermissions', 'POST'], + ]); + assert.deepEqual(JSON.parse(calls[1].options.body).permissions, [...contract.permissions, ...contract.deniedPermissions]); + for (const invalid of [ + { name: 'projects/123456', projectId: 'production', state: 'ACTIVE' }, + { name: 'invalid', projectId: 'run-mprc-staging', state: 'ACTIVE' }, + { name: 'projects/999999', projectId: 'run-mprc-staging', state: 'ACTIVE' }, + { name: 'projects/123456', projectId: 'run-mprc-staging', state: 'DELETE_REQUESTED' }, + ]) await assert.rejects(verifyCloudAuthority(context, mockFetch([invalid]))); +}); + +test('required deployment permissions present and every prohibited data/admin permission absent', () => { + validatePermissions({ permissions: [...contract.permissions] }); + for (const missing of contract.permissions) { + assert.throws(() => validatePermissions({ permissions: contract.permissions.filter((p) => p !== missing) })); + } + for (const prohibited of contract.deniedPermissions) { + assert.throws(() => validatePermissions({ permissions: [...contract.permissions, prohibited] })); + } + for (const invalid of [{}, { permissions: null }, { permissions: [null] }, { permissions: [...contract.permissions, 'unexpected'] }]) { + assert.throws(() => validatePermissions(invalid)); + } + assert.equal(new Set(contract.permissions).size, contract.permissions.length); + assert.deepEqual(contract.futureFunctionTargets, ['createMemberOnSignUp', 'ensureMemberProfile']); +}); + +test('workflow is manual, pinned, separately protected and never deploys or creates a credential file', () => { + assert.match(workflow, /\bon:\n workflow_dispatch:\n/); + assert.doesNotMatch(workflow, /\n (push|pull_request|schedule):|inputs:/); + assert.match(workflow, /environment:\n name: backend-staging/); + assert.match(workflow, /^permissions: \{\}$/m); + assert.match(workflow, /access_token_lifetime: 300s/); + assert.match(workflow, /create_credentials_file: false/); + assert.match(workflow, /export_environment_variables: false/); + assert.match(workflow, /persist-credentials: false/); + for (const action of workflow.matchAll(/^\s*uses:\s*([^\s#]+)/gm)) assert.match(action[1], /@[0-9a-f]{40}$/); + assert.ok(workflow.indexOf('staging-profile-authority.js context') < workflow.indexOf('google-github-actions/auth@')); + assert.doesNotMatch(`${workflow}\n${script}`, /firebase\s+deploy|gcloud\s|credentials_json|FIREBASE_TOKEN|setIamPolicy\(/); + assert.doesNotMatch(script, /console\.|JSON.stringify\((?:project|permissions|env)\)/); +}); + +test('CLI rejects invalid context and extra arguments without printing any synthetic credential or locator', () => { + for (const args of [['context'], ['cloud'], ['context', 'override']]) { + const result = spawnSync(process.execPath, ['scripts/staging-profile-authority.js', ...args], { + cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...context }, + }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, 'staging_profile_authority_failed\n'); + } +}); diff --git a/tests/test-artifact-safety.test.js b/tests/test-artifact-safety.test.js index abdf6db..abdbde2 100644 --- a/tests/test-artifact-safety.test.js +++ b/tests/test-artifact-safety.test.js @@ -26,7 +26,7 @@ const temporaryDirectories = []; const JOB_ID = 'test-artifact-scrubber'; const JOB_NAME = 'Test artifact scrubber'; const TEST_COMMAND = 'node --test tests/test-artifact-safety.test.js'; -const FRONTEND_TEST_COMMAND = 'node --test tests/ci-workflow.test.js tests/release-workflow.test.js tests/firebase-release-verification.test.js tests/firebase-hosting-foundation.test.js tests/firebase-auth-staging-contract.test.js tests/firebase-profile-functions-staging-contract.test.js tests/staging-authority-workflow.test.js tests/test-artifact-safety.test.js tests/root-dependency-security.test.js'; +const FRONTEND_TEST_COMMAND = 'node --test tests/ci-workflow.test.js tests/release-workflow.test.js tests/firebase-release-verification.test.js tests/firebase-hosting-foundation.test.js tests/firebase-auth-staging-contract.test.js tests/firebase-profile-functions-staging-contract.test.js tests/staging-authority-workflow.test.js tests/staging-profile-authority.test.js tests/test-artifact-safety.test.js tests/root-dependency-security.test.js'; const NEVER_RUN = ['$', '{{ false }}'].join(''); const ALWAYS_TRUE = ['$', '{{ true }}'].join(''); const BRACKET_SECRET = ['$', "{{ secrets['SYNTHETIC_CANARY'] }}"].join(''); @@ -64,6 +64,7 @@ const EXPECTED_FRONTEND_VALIDATION_STEP = [ ' run: |', ' ruby -e \'require "yaml"; YAML.safe_load(File.read(".github/workflows/deploy.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)\'', ' ruby -e \'require "yaml"; YAML.safe_load(File.read(".github/workflows/verify-staging-authority.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)\'', + ' ruby -e \'require "yaml"; YAML.safe_load(File.read(".github/workflows/verify-staging-profile-authority.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)\'', ' ruby -e \'require "yaml"; YAML.safe_load(File.read(".github/workflows/ci.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)\'', ` ${FRONTEND_TEST_COMMAND}`, ].join('\n'); @@ -1332,6 +1333,20 @@ test('frontend independently runs the exact workflow safety suite without a skip assert.notEqual(missingProfileFunctionsTest, ciWorkflow); assert.notDeepEqual(frontendValidationErrors(missingProfileFunctionsTest), []); + const missingScopedAuthorityTest = ciWorkflow.replace( + ' tests/staging-profile-authority.test.js', + '', + ); + assert.notEqual(missingScopedAuthorityTest, ciWorkflow); + assert.notDeepEqual(frontendValidationErrors(missingScopedAuthorityTest), []); + + const missingScopedWorkflowParse = ciWorkflow.replace( + EXPECTED_FRONTEND_VALIDATION_STEP.split('\n').find((line) => line.includes('verify-staging-profile-authority.yml')), + '', + ); + assert.notEqual(missingScopedWorkflowParse, ciWorkflow); + assert.notDeepEqual(frontendValidationErrors(missingScopedWorkflowParse), []); + const swallowedValidation = ciWorkflow.replace( FRONTEND_TEST_COMMAND, `${FRONTEND_TEST_COMMAND} || true`,