From 5ae10dca6e91443c0b01c5f973b45ac0aca5169d Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Sun, 11 Oct 2026 14:11:05 -0700 Subject: [PATCH] feat: protect two-function staging release with explicit identities --- .github/workflows/ci.yml | 1 + .github/workflows/deploy-staging-profiles.yml | 53 +++++ GITHUB_ISSUES.md | 2 + OFFICER_START_HERE.md | 2 +- OPERATIONS_RUNBOOK.md | 2 + docs/officers/ACCESS_CONTINUITY.md | 14 +- docs/officers/LOW_COST_COMMUNITY.md | 24 ++- docs/officers/PUBLISH_AND_CHECK.md | 2 +- docs/officers/README.md | 2 +- docs/officers/SYSTEM_MAPS.md | 15 ++ functions/profileOnly.js | 7 + scripts/staging-profile-release.js | 193 ++++++++++++++++++ tests/staging-profile-authority.test.js | 99 +++++++++ tests/test-artifact-safety.test.js | 1 + 14 files changed, 406 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/deploy-staging-profiles.yml create mode 100644 functions/profileOnly.js create mode 100644 scripts/staging-profile-release.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f7cb9e..cbe95cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: 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/deploy-staging-profiles.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/staging-profile-authority.test.js tests/test-artifact-safety.test.js tests/root-dependency-security.test.js - name: Build diff --git a/.github/workflows/deploy-staging-profiles.yml b/.github/workflows/deploy-staging-profiles.yml new file mode 100644 index 0000000..b8fe458 --- /dev/null +++ b/.github/workflows/deploy-staging-profiles.yml @@ -0,0 +1,53 @@ +name: Deploy two staging profile Functions + +on: + workflow_dispatch: + +permissions: {} + +concurrency: + group: staging-profile-release + cancel-in-progress: false + +jobs: + deploy-profiles: + name: Deploy and verify only two staging profile Functions + runs-on: ubuntu-latest + timeout-minutes: 35 + 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 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: Verify exact green main before cloud authentication + run: node scripts/staging-profile-release.js context + - id: auth + name: Obtain bounded keyless staging release identity + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + token_format: access_token + access_token_lifetime: 1800s + create_credentials_file: false + export_environment_variables: false + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GCP_DEPLOY_SERVICE_ACCOUNT }} + - name: Deploy only reviewed profile bundle and verify resource settings + env: + GCP_ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} + run: node scripts/staging-profile-release.js release diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index ec55b61..5889c08 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -1,5 +1,7 @@ # GitHub Issue Backlog: Secure Commerce Program +**CI-001C4 / #701 (2026-10-11):** scoped staging runtime/build permissions and protected deployment/hosted proof of `createMemberOnSignUp` and `ensureMemberProfile`. Production data, payments, website publication and parent #113/#133/#136 remain outside this slice. Initial baseline has no Functions. No schema/claims migration; create-once profiles remain unchanged. Source preparation is not hosted completion. Officer impact: staged profile readiness with normal approval and administrator bypass disabled. Officer documentation: ACCESS_CONTINUITY, LOW_COST_COMMUNITY, SYSTEM_MAPS and entrypoints. Deployment evidence: pending exact source, provider readback and made-up hosted checks on the native issue. + **Prepared:** 2026-07-12 **Status:** Dated design/assessment catalog; GitHub is canonical for execution **Execution model:** One focused issue per branch/pull request diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index f471953..4f602d1 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -16,7 +16,7 @@ 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. +**Focused staging release, 2026-10-11:** [#699](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/699) completed its protected identity proof. [#701](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/701) separately prepares two staging profile Functions and made-up hosted tests. Dave chose disabled administrator bypass for production/backend-staging; normal self-approval remains. Original read-only staging is unchanged. Follow [the staging procedure](./docs/officers/LOW_COST_COMMUNITY.md#engineering-staging-profiles--701), **NOT AVAILABLE YET** until #701 records hosted proof. Production/payments remain unavailable; no officer handles a key or 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. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 66f787c..583aa06 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -730,6 +730,8 @@ CI/staging must eventually include: For expand-and-contract changes: +**Isolated staging profiles (#701):** a separate manual `deploy-staging-profiles.yml` requires exact green main, normal backend-staging approval and thirty-minute keyless credentials. The v1 Functions API selects explicit runtime/build accounts, avoiding Firebase CLI default-account checks and unavailable custom v1 build-account selection. A deterministic ten-file bundle changes only the package entrypoint to `profileOnly.js`, keeping the reviewed dependencies/lockfile and profile logic. No prototype index or payment/mail endpoint is shipped. Runtime permissions: Auth user get, Firestore document get/create and begin/rollback transaction. These are staging project IAM, not collection rules. Build access: source-object read, one image repository write and logs. ActAs is granted only on the two exact staging accounts. Google auto-created a default App Engine Editor grant during API activation; #701 removes that new grant and never selects the account. Initial baseline is no Functions; reviewed roll-forward is preferred, and exact-owned two-Function removal requires Dave/specialist recovery authority, not the deploy principal. No schema migration or production changes. Source/CI/provider/hosted proof remain separate; #113/#133/#136 stay open. See the [officer procedure](./docs/officers/LOW_COST_COMMUNITY.md#engineering-staging-profiles--701). + 1. Backup/export or snapshot the affected operational data according to the approved method. 2. Deploy additive indexes/rules/backend functions compatible with the current client. 3. Run migrations/backfills in dry-run mode; review counts and anomalies. diff --git a/docs/officers/ACCESS_CONTINUITY.md b/docs/officers/ACCESS_CONTINUITY.md index 5d6ae5c..bd7026a 100644 --- a/docs/officers/ACCESS_CONTINUITY.md +++ b/docs/officers/ACCESS_CONTINUITY.md @@ -134,33 +134,33 @@ Text alternative: the platform maintainer requests the exact verifier from `main ### 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. +**Approver:** Dave Liu. #701 disabled administrator bypass for production and backend-staging. Dave can approve his own release through the normal gate. Original read-only staging is unchanged. **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. +**Prerequisites:** the dedicated staging federation is enabled and exact current main passed all six checks. #701 adds use of only the named staging runtime/build accounts; the deployment identity still cannot read member records or secrets directly. Use only **Verify staging profile authority** and **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"} + Main["Exact main; all six checks passed"] --> Approval{"Dave normal approval; no bypass"} 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. +Text alternative: exact green main and Dave's normal approval permit one fixed staging identity check; bypass is disabled, a five-minute token reads 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. +5. Approve the matching request through the normal gate; administrator bypass is disabled. 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. +**Success proof:** run link, exact source, normal named approval, fixed result and private readback of zero user-managed keys. The identity permits staging Function/Rules configuration and use of two exact staging accounts, not direct records or project administration. IAM creation is project-scoped; the workflow, not IAM Function-name filtering, limits this verifier to reads. #701 separately limits deployment to two profiles. +**Undo:** ask the maintainer to disable only the dedicated profile federation pool or remove only its workload-identity binding. Existing tokens can survive until expiry: five minutes for this verifier, up to thirty minutes for #701 deployment. 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 diff --git a/docs/officers/LOW_COST_COMMUNITY.md b/docs/officers/LOW_COST_COMMUNITY.md index 6b23ba8..45d442d 100644 --- a/docs/officers/LOW_COST_COMMUNITY.md +++ b/docs/officers/LOW_COST_COMMUNITY.md @@ -26,7 +26,7 @@ Dave clarified the core scope on 2026-10-11: club membership, events, member sig The warning budget is named **MPRC staging gross usage alerts**. It covers only staging, all services, and each calendar month. Its amount is **$10 USD**, with actual-usage alerts at **$5, $9, and $10**. Credits are excluded from the calculation so trial credits cannot hide underlying usage. Default billing-account notification recipients remain enabled; no new recipient, access grant, Pub/Sub automation, or shutdown rule was added. Configuration was read back; email delivery was not tested. -This is an **alerts-only budget**, not a bill ceiling or approval to spend club money. The only API enabled by this step was the Cloud Billing Budget API. Functions, Cloud Build, and Artifact Registry APIs remain disabled. Production billing remains disabled/unlinked. No Function, website, DNS, Stripe configuration, member record, or payment was changed. See the [staging billing map](./SYSTEM_MAPS.md#engineering-staging-billing--checked-2026-10-11). +This is an **alerts-only budget**, not a bill ceiling or approval to spend club money. #693 enabled only the Cloud Billing Budget API. #701 subsequently enabled Functions, Cloud Build and Artifact Registry in staging for two profiles. Production billing remains disabled/unlinked. #693 changed no Function, website, DNS, Stripe configuration, member record or payment. See the [staging billing map](./SYSTEM_MAPS.md#engineering-staging-billing--checked-2026-10-11). ### Check the trial and warnings without a terminal @@ -95,6 +95,28 @@ The #683 source change configured no budget, spending cap, artifact-cleanup poli **Escalation:** platform/security maintainer. A specialist remains necessary for provider setup, tests and deployment. +## Engineering staging profiles — #701 + +**Purpose:** prove only profile creation and recovery using made-up staging accounts. This is not membership approval, event management, a payment release or a public website launch. +**Approver:** Dave Liu, sole release/recovery owner. Administrator bypass is disabled; normal self-approval remains permitted. +**Availability:** **NOT AVAILABLE YET** until #701 records exact merged source, protected deployment, actual settings and hosted checks. #113/#133/#136 remain unresolved for production; this isolated engineering proof does not waive them. +**Prerequisites:** #693 trial/warnings, #699 keyless authority, #683/#691/#695 reviewed profile source, #676 staging isolation, and all six exact-main checks. Use only **Deploy two staging profile Functions**, environment **backend-staging**. No real member, email or payment tests. + +1. Open the maintainer's run link. +2. Confirm main, backend-staging, the two-profile workflow and six passing source checks. +3. Approve the normal release request. +4. Wait for a separate verified result for each Function. +5. Ask for private readback of explicit runtime/build identities and all four resource limits above. +6. Observe made-up signup, recovery, repeated/concurrent recovery and denied invalid attestation, sign-in and injected fields. +7. Confirm existing profiles/roles stayed unchanged and made-up records were removed. +8. Record source, merge, staging deployment, hosted tests, website and production separately. + +**Expected result:** two Node 22 staging Functions; no idle instances, maximum two instances each, 256 MB memory, 30-second timeout. Runtime can read Auth users and read/create database documents, not update/delete or change claims. Server IAM does not restrict individual collections; staging isolation and UID-bound code remain essential. Build access covers source objects, the profile image repository and build logs only. No Owner/Editor, secret or payment grant. +**Success proof:** fixed deployment markers, exact source/bundle digest, provider readback and hosted synthetic results on #701. Metadata/source tests alone do not prove working requests. Images keep the newest two versions per package; older images are eligible for deletion after seven days. Cleanup is asynchronous, not a cost ceiling. Native Function source recovery copies are separate. +**Stop conditions:** third handler, broad/default identity, paid upgrade, missing proof, unexpected allow, real data or unexplained bill. A failed build does not authorize wider access or disabling App Check. +**Undo:** ask Dave to hold further release work and use a reviewed same-source retry/roll-forward. Initial baseline is no staging Functions, not an older deployed runtime. If removal is needed, a specialist must verify #701 ownership and remove only these two Functions. Never delete the project, database or unrelated artifacts. The deploy identity has no Function-delete permission. No schema migration or production rollback is involved. +**Escalation:** Dave/platform specialist for release, access and recovery; club finance approver/cardholder for recurring spending. Officers do not use terminals, edit records or handle tokens. No backup owner is appointed. + ## Review before a low-cost release **Prerequisites:** issues #683 and #691 and their reviewed pull requests; the exact release source and test results; a named release approver; a private billing/payment-owner decision; and a platform maintainer. The existing release dependencies still apply. Never put payment details, passwords, or member records in an issue. diff --git a/docs/officers/PUBLISH_AND_CHECK.md b/docs/officers/PUBLISH_AND_CHECK.md index 9c6abe6..cec4306 100644 --- a/docs/officers/PUBLISH_AND_CHECK.md +++ b/docs/officers/PUBLISH_AND_CHECK.md @@ -76,7 +76,7 @@ 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. +**#701 approval decision, October 11:** administrator bypass is disabled for production/backend-staging; Dave/main and normal self-approval remain configured. #699 completed its read-only identity proof. The separate **Deploy two staging profile Functions** workflow changes only two engineering staging profiles, not the website or production. Follow [the staging procedure](./LOW_COST_COMMUNITY.md#engineering-staging-profiles--701), **NOT AVAILABLE YET** until #701 records protected deployment and hosted proof. General backend/website and member/payment releases remain unavailable until their own proof. No backup appointment is required. **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. diff --git a/docs/officers/README.md b/docs/officers/README.md index 1e7c237..bf230e8 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -33,7 +33,7 @@ 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. +**Focused backend authority, #699/#701:** #699 completed its protected identity proof. #701 separately prepares two staging profile Functions and synthetic hosted tests. Administrator bypass is disabled for production/backend-staging; Dave/main and normal self-approval remain. [The staging procedure](./LOW_COST_COMMUNITY.md#engineering-staging-profiles--701) is **NOT AVAILABLE YET** until hosted proof is recorded. Existing read-only reviewers gain no deployment access; production/payments remain untouched. 1. [Request a change](./REQUEST_A_CHANGE.md) 2. [Update public content](./UPDATE_PUBLIC_CONTENT.md) diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 451a748..05ce1e5 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -221,6 +221,21 @@ In words: the source page removes the private code, waits for the member to choo Issue [#194](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/194) owns only the verification path. Firebase's single custom handler also receives password-reset and email-recovery links, so [#119](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/119) must keep the default handler or wait for safe coverage of every mode before enabling this route. Website, provider, and live behavior remain separate proof. +## Scoped engineering profile release — #701 + +```mermaid +flowchart LR + Main["Exact main; six green checks"] --> Gate["Dave normal approval; no bypass"] + Gate --> Deploy["Thirty-minute keyless staging release"] + Deploy --> Build["Build identity: source, images, logs only"] + Build --> Two["Signup profile and profile recovery only"] + Two --> Runtime["Runtime: Auth read; database get/create"] + Runtime --> Stage["Isolated staging; made-up profiles"] + Two -. "no release or access" .-> Excluded["Production, payments, email, website publication"] +``` + +Text alternative: exact green main and Dave's normal approval permit one bounded staging release; separate build/runtime identities support only two profiles and synthetic staging records, excluding production, payments, email and website publication. Hosted availability needs separate #701 proof. + ## Emergency decision ```mermaid diff --git a/functions/profileOnly.js b/functions/profileOnly.js new file mode 100644 index 0000000..6a68b67 --- /dev/null +++ b/functions/profileOnly.js @@ -0,0 +1,7 @@ +// Staging-only deployment entry point. Never load the prototype endpoint index. +const admin = require('firebase-admin'); + +admin.initializeApp(); + +exports.createMemberOnSignUp = require('./signup').onSignUp; +exports.ensureMemberProfile = require('./ensureMemberProfile').ensureMemberProfile; diff --git a/scripts/staging-profile-release.js b/scripts/staging-profile-release.js new file mode 100644 index 0000000..4508c9f --- /dev/null +++ b/scripts/staging-profile-release.js @@ -0,0 +1,193 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { createHash } = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +const { validateContext, verifyCurrentSource, verifyCloudAuthority } = require('./staging-profile-authority'); + +const PROJECT = 'run-mprc-staging'; +const REGION = 'us-central1'; +const WORKFLOW = 'deploy-staging-profiles.yml'; +const TARGETS = Object.freeze(['createMemberOnSignUp', 'ensureMemberProfile']); +const FILES = Object.freeze([ + 'profileOnly.js', 'signup.js', 'ensureMemberProfile.js', 'memberProfile.js', + 'profileRuntime.js', 'stripeHelpers.js', 'serverConfig.js', 'verifiedRolePolicy.js', + 'package.json', 'package-lock.json', +]); +const BASE = `https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/${REGION}`; +const runtimeAccount = `mprc-profile-runtime@${PROJECT}.iam.gserviceaccount.com`; +const buildAccount = `projects/${PROJECT}/serviceAccounts/mprc-profile-build@${PROJECT}.iam.gserviceaccount.com`; +const repository = `projects/${PROJECT}/locations/${REGION}/repositories/mprc-profile-artifacts`; +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +const check = (value) => { if (!value) throw new Error('staging_profile_release_failed'); }; + +function validateReleaseContext(env, fingerprint) { + check(env.GITHUB_WORKFLOW_REF === `Run-MPRC/Run-MPRC.github.io/.github/workflows/${WORKFLOW}@refs/heads/main`); + // Reuse the immutable repo/main/environment/authority and exact-source checks; + // only the independently checked workflow is different from the read-only job. + validateContext({ ...env, GITHUB_WORKFLOW_REF: 'Run-MPRC/Run-MPRC.github.io/.github/workflows/verify-staging-profile-authority.yml@refs/heads/main' }, fingerprint); +} + +function bundleFiles(root) { + const entries = FILES.map((name) => { + const filename = path.join(root, 'functions', name); + check(fs.lstatSync(filename).isFile() && !fs.lstatSync(filename).isSymbolicLink()); + const content = fs.readFileSync(filename); + check(content.length < 1000000); + return [name, content]; + }); + const manifest = JSON.parse(entries.find(([name]) => name === 'package.json')[1]); + check(manifest.main === 'index.js' && manifest.engines.node === '22'); + manifest.main = 'profileOnly.js'; + // Preserve dependencies and their reviewed lockfile. No new dependency or + // prototype handler is shipped; Stripe remains an unused helper dependency. + entries[FILES.indexOf('package.json')][1] = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); + return entries; +} + +function createBundle(root) { + const entries = bundleFiles(root); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mprc-profile-stage-')); + try { + for (const [name, content] of entries) { + fs.writeFileSync(path.join(directory, name), content, { flag: 'wx', mode: 0o600 }); + fs.utimesSync(path.join(directory, name), new Date('1980-01-01T00:00:00Z'), new Date('1980-01-01T00:00:00Z')); + } + const zip = execFileSync('zip', ['-X', '-q', '-', ...FILES], { cwd: directory, maxBuffer: 2000000 }); + check(zip.length > 0 && zip.length < 1000000); + return { zip, digest: sha256(zip) }; + } finally { + // Exact task-owned generated source only; no checkout or user data. + fs.rmSync(directory, { recursive: true }); + } +} + +function functionSpec(target, sourceSha, bundleDigest, uploadUrl) { + check(TARGETS.includes(target)); + check(/^[0-9a-f]{40}$/.test(sourceSha) && /^[0-9a-f]{64}$/.test(bundleDigest)); + check(typeof uploadUrl === 'string'); + const parsedUpload = new URL(uploadUrl); + check(parsedUpload.protocol === 'https:' && parsedUpload.hostname === 'storage.googleapis.com' + && !parsedUpload.username && !parsedUpload.password && !parsedUpload.port); + return { + name: `projects/${PROJECT}/locations/${REGION}/functions/${target}`, + entryPoint: target, runtime: 'nodejs22', serviceAccountEmail: runtimeAccount, + buildServiceAccount: buildAccount, dockerRegistry: 'ARTIFACT_REGISTRY', dockerRepository: repository, + sourceUploadUrl: uploadUrl, availableMemoryMb: 256, timeout: '30s', minInstances: 0, maxInstances: 2, + ingressSettings: 'ALLOW_ALL', + labels: { 'managed-by': 'mprc-profile-stage', 'source-sha': sourceSha, 'bundle-a': bundleDigest.slice(0, 32), 'bundle-b': bundleDigest.slice(32) }, + environmentVariables: { FIREBASE_CONFIG: JSON.stringify({ projectId: PROJECT }), ENFORCE_APP_CHECK: 'true', COMMERCE_ENABLED: 'false' }, + ...(target === 'ensureMemberProfile' + ? { httpsTrigger: { securityLevel: 'SECURE_ALWAYS' } } + : { eventTrigger: { eventType: 'providers/firebase.auth/eventTypes/user.create', resource: `projects/${PROJECT}`, service: 'firebaseauth.googleapis.com' } }), + }; +} + +function validateHostedFunction(actual, expected, { ready = true } = {}) { + check(actual && actual.name === expected.name); + for (const field of ['entryPoint', 'runtime', 'serviceAccountEmail', 'buildServiceAccount', 'dockerRepository', 'availableMemoryMb', 'timeout', 'maxInstances', 'ingressSettings']) check(actual[field] === expected[field]); + // Protobuf omits a configured numeric zero, but not other required settings. + check((actual.minInstances === undefined ? 0 : actual.minInstances) === 0); + check(actual.labels?.['managed-by'] === 'mprc-profile-stage'); + if (ready) { + check(actual.status === 'ACTIVE'); + for (const [key, value] of Object.entries(expected.labels)) check(actual.labels[key] === value); + } + check(JSON.stringify(actual.environmentVariables) === JSON.stringify(expected.environmentVariables) + || Object.keys(actual.environmentVariables || {}).length === Object.keys(expected.environmentVariables).length + && Object.entries(expected.environmentVariables).every(([key, value]) => actual.environmentVariables[key] === value)); + check(!actual.secretEnvironmentVariables?.length && !actual.secretVolumes?.length && !actual.vpcConnector); + if (expected.httpsTrigger) { + check(actual.httpsTrigger?.securityLevel === 'SECURE_ALWAYS' && !actual.eventTrigger); + } else { + check(actual.eventTrigger?.eventType === expected.eventTrigger.eventType && actual.eventTrigger?.resource === expected.eventTrigger.resource && !actual.httpsTrigger); + check(!actual.eventTrigger.failurePolicy); // Same non-retrying background source behavior. + } +} + +async function jsonRequest(url, token, method = 'GET', body, fetchImpl = fetch) { + check(url.startsWith('https://cloudfunctions.googleapis.com/v1/') || url.startsWith('https://iam.googleapis.com/v1/')); + const response = await fetchImpl(url, { method, redirect: 'error', signal: AbortSignal.timeout(15000), headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, ...(body ? { body: JSON.stringify(body) } : {}) }); + check(response.ok); + return response.json(); +} + +async function validateActAs(token, fetchImpl = fetch) { + for (const account of [runtimeAccount, buildAccount.split('/serviceAccounts/')[1]]) { + const result = await jsonRequest(`https://iam.googleapis.com/v1/projects/${PROJECT}/serviceAccounts/${account}:testIamPermissions`, token, 'POST', { permissions: ['iam.serviceAccounts.actAs', 'iam.serviceAccountKeys.create'] }, fetchImpl); + check(result.permissions?.length === 1 && result.permissions[0] === 'iam.serviceAccounts.actAs'); + } +} + +async function waitForOperation(operation, token, request = jsonRequest, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) { + check(/^operations\/[A-Za-z0-9_\-/]+$/.test(operation.name || '')); + for (let count = 0; count < 90; count += 1) { + const result = await request(`https://cloudfunctions.googleapis.com/v1/${operation.name}`, token); + if (result.done) { check(!result.error); return; } + await sleep(10000); + } + throw new Error('staging_profile_release_failed'); +} + +async function release(env) { + validateReleaseContext(env); + await verifyCurrentSource(env); + await verifyCloudAuthority(env); + await validateActAs(env.GCP_ACCESS_TOKEN); + const bundle = createBundle(path.resolve(__dirname, '..')); + // Inspect all regions and refuse foreign handlers, pagination or unreachable regions. + const inventory = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, env.GCP_ACCESS_TOKEN); + check(!inventory.nextPageToken && !inventory.unreachable?.length); + const existing = inventory.functions || []; + check(Array.isArray(existing) && existing.length <= 2); + for (const fn of existing) { + const target = fn.name?.split('/').at(-1); + validateHostedFunction(fn, functionSpec(target, env.GITHUB_SHA, bundle.digest, 'https://storage.googleapis.com/synthetic'), { ready: false }); + } + for (const target of TARGETS) { + await verifyCurrentSource(env); // Recheck immediately before each write. + const uploaded = await jsonRequest(`${BASE}/functions:generateUploadUrl`, env.GCP_ACCESS_TOKEN, 'POST', {}); + const spec = functionSpec(target, env.GITHUB_SHA, bundle.digest, uploaded.uploadUrl); + const response = await fetch(uploaded.uploadUrl, { method: 'PUT', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'Content-Type': 'application/zip', 'x-goog-content-length-range': '0,104857600' }, body: bundle.zip }); + check(response.ok); // Signed upload URL never receives the cloud access token. + const prior = existing.find((fn) => fn.name === spec.name); + const operation = await jsonRequest(prior ? `${BASE}/functions/${target}?updateMask=${Object.keys(spec).filter((key) => key !== 'name').join(',')}` : `${BASE}/functions`, env.GCP_ACCESS_TOKEN, prior ? 'PATCH' : 'POST', spec); + await waitForOperation(operation, env.GCP_ACCESS_TOKEN); + validateHostedFunction(await jsonRequest(`${BASE}/functions/${target}`, env.GCP_ACCESS_TOKEN), spec); + if (target === 'ensureMemberProfile') { + // Callable transport is public, but SDK Auth/App Check verification is + // mandatory. Never grant public access to the background trigger. + const iam = await jsonRequest(`${BASE}/functions/${target}:getIamPolicy`, env.GCP_ACCESS_TOKEN); + check((iam.bindings || []).every((binding) => binding.role === 'roles/cloudfunctions.invoker' && binding.members?.length === 1 && binding.members[0] === 'allUsers' && !binding.condition)); + const policy = { version: 1, ...(iam.etag ? { etag: iam.etag } : {}), bindings: [{ role: 'roles/cloudfunctions.invoker', members: ['allUsers'] }] }; + await jsonRequest(`${BASE}/functions/${target}:setIamPolicy`, env.GCP_ACCESS_TOKEN, 'POST', { policy }); + } + process.stdout.write(`staging_profile_${target}_deployed_verified\n`); + } + const final = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, env.GCP_ACCESS_TOKEN); + check(final.functions?.length === 2 && !final.nextPageToken && !final.unreachable?.length); + for (const target of TARGETS) validateHostedFunction(final.functions.find((fn) => fn.name.endsWith(`/${target}`)), functionSpec(target, env.GITHUB_SHA, bundle.digest, 'https://storage.googleapis.com/synthetic')); + process.stdout.write(`staging_profile_bundle_sha256=${bundle.digest}\n`); +} + +async function main() { + try { + check(process.argv.length === 3 && ['context', 'release'].includes(process.argv[2])); + validateReleaseContext(process.env); + if (process.argv[2] === 'context') { + await verifyCurrentSource(process.env); + const number = process.env.WORKLOAD_IDENTITY_PROVIDER.match(/^projects\/([0-9]+)\//)?.[1]; + check(number); + process.stdout.write(`::add-mask::${number}\nstaging_profile_release_context_verified\n`); + } else await release(process.env); + } catch { + // Do not expose URLs, identities, tokens, provider errors or build logs. + process.stderr.write('staging_profile_release_failed\n'); + process.exitCode = 1; + } +} + +if (require.main === module) void main(); +module.exports = { TARGETS, FILES, validateReleaseContext, bundleFiles, createBundle, functionSpec, validateHostedFunction, jsonRequest, validateActAs, waitForOperation }; diff --git a/tests/staging-profile-authority.test.js b/tests/staging-profile-authority.test.js index 7f3dace..43e1d54 100644 --- a/tests/staging-profile-authority.test.js +++ b/tests/staging-profile-authority.test.js @@ -173,3 +173,102 @@ test('CLI rejects invalid context and extra arguments without printing any synth assert.equal(result.stderr, 'staging_profile_authority_failed\n'); } }); + +const release = require('../scripts/staging-profile-release'); +const releaseWorkflow = fs.readFileSync(path.join(ROOT, '.github/workflows/deploy-staging-profiles.yml'), 'utf8'); +const releaseContext = { ...context, GITHUB_WORKFLOW_REF: 'Run-MPRC/Run-MPRC.github.io/.github/workflows/deploy-staging-profiles.yml@refs/heads/main' }; +const expectedFunction = (target = 'ensureMemberProfile') => release.functionSpec(target, sha, 'b'.repeat(64), 'https://storage.googleapis.com/synthetic'); + +test('release context retains all immutable checks and cannot use the verifier workflow', () => { + release.validateReleaseContext(releaseContext, fingerprint); + assert.throws(() => release.validateReleaseContext(context, fingerprint)); + for (const [name, value] of Object.entries({ GITHUB_SHA: 'invalid', GITHUB_REF: 'refs/heads/other', GITHUB_WORKFLOW_SHA: 'b'.repeat(40), GITHUB_EVENT_NAME: 'push', TARGET_PROJECT_ID: 'production', TARGET_ENVIRONMENT: 'staging', DEPLOY_SERVICE_ACCOUNT: 'changed', GITHUB_REPOSITORY_ID: '1' })) { + assert.throws(() => release.validateReleaseContext({ ...releaseContext, [name]: value }, fingerprint)); + } +}); + +test('deterministic deployment bundle ships no prototype handler, dependencies or private input', () => { + const entries = release.bundleFiles(ROOT); + assert.deepEqual(entries.map(([name]) => name), release.FILES); + assert.ok(!release.FILES.includes('index.js')); + const before = JSON.parse(fs.readFileSync(path.join(ROOT, 'functions/package.json'))); + const after = JSON.parse(entries.find(([name]) => name === 'package.json')[1]); + assert.deepEqual(after, { ...before, main: 'profileOnly.js' }); + const source = entries.find(([name]) => name === 'profileOnly.js')[1].toString(); + assert.deepEqual([...source.matchAll(/^exports\.([A-Za-z]+)\s*=/gm)].map((m) => m[1]), release.TARGETS); + assert.doesNotMatch(source, /require\('\.\/index'\)|stripeWebhook|Checkout|setMemberRole|sendConfirmation/); + const first = release.createBundle(ROOT); + const second = release.createBundle(ROOT); + assert.equal(first.digest, second.digest); + assert.deepEqual(first.zip, second.zip); +}); + +test('two first-generation specs use explicit identities, limits and no secrets/commerce', () => { + assert.throws(() => expectedFunction('stripeWebhook')); + for (const target of release.TARGETS) { + const spec = expectedFunction(target); + assert.equal(spec.runtime, 'nodejs22'); + assert.equal(spec.minInstances, 0); + assert.equal(spec.maxInstances, 2); + assert.equal(spec.availableMemoryMb, 256); + assert.equal(spec.timeout, '30s'); + assert.match(spec.serviceAccountEmail, /^mprc-profile-runtime@run-mprc-staging\./); + assert.match(spec.buildServiceAccount, /^projects\/run-mprc-staging\/serviceAccounts\/mprc-profile-build@/); + assert.equal(spec.environmentVariables.COMMERCE_ENABLED, 'false'); + assert.ok(!spec.secretEnvironmentVariables && !spec.secretVolumes); + if (target === 'ensureMemberProfile') assert.equal(spec.httpsTrigger.securityLevel, 'SECURE_ALWAYS'); + else assert.equal(spec.eventTrigger.resource, 'projects/run-mprc-staging'); + } + for (const url of ['http://storage.googleapis.com/synthetic', 'https://other.test/synthetic', 'https://user:password@storage.googleapis.com/synthetic', 'https://storage.googleapis.com:444/synthetic']) { + assert.throws(() => release.functionSpec('ensureMemberProfile', sha, 'b'.repeat(64), url)); + } +}); + +test('hosted resource readback rejects broad identities, foreign config, missing limits and stale provenance', () => { + for (const target of release.TARGETS) { + const expected = expectedFunction(target); + const actual = { ...expected, status: 'ACTIVE' }; + release.validateHostedFunction(actual, expected); + const omittedZero = { ...actual }; delete omittedZero.minInstances; + release.validateHostedFunction(omittedZero, expected); + for (const [field, value] of Object.entries({ runtime: 'nodejs20', serviceAccountEmail: 'default', buildServiceAccount: 'default', minInstances: 1, maxInstances: undefined, availableMemoryMb: 512, timeout: '540s', status: 'DEPLOY_IN_PROGRESS', secretEnvironmentVariables: [{}], vpcConnector: 'foreign', labels: {}, environmentVariables: { ...expected.environmentVariables, COMMERCE_ENABLED: 'true' } })) { + assert.throws(() => release.validateHostedFunction({ ...actual, [field]: value }, expected)); + } + if (target === 'createMemberOnSignUp') assert.throws(() => release.validateHostedFunction({ ...actual, eventTrigger: { ...expected.eventTrigger, resource: 'projects/production' } }, expected)); + } +}); + +test('runtime/build actAs is checked on exact accounts without accepting key creation', async () => { + const calls = []; + await release.validateActAs('synthetic-token', mockFetch([{ permissions: ['iam.serviceAccounts.actAs'] }, { permissions: ['iam.serviceAccounts.actAs'] }], calls)); + assert.equal(calls.length, 2); + assert.ok(calls.every(({ url }) => url.startsWith('https://iam.googleapis.com/v1/projects/run-mprc-staging/serviceAccounts/mprc-profile-'))); + await assert.rejects(release.validateActAs('synthetic-token', mockFetch([{ permissions: [] }]))); + await assert.rejects(release.validateActAs('synthetic-token', mockFetch([{ permissions: ['iam.serviceAccounts.actAs', 'iam.serviceAccountKeys.create'] }]))); +}); + +test('operation polling is bounded, and failed or malformed operations never count as success', async () => { + let requests = 0; + await release.waitForOperation({ name: 'operations/synthetic' }, 'synthetic-token', async () => ({ done: ++requests === 2 }), async () => {}); + assert.equal(requests, 2); + await assert.rejects(release.waitForOperation({ name: 'operations/synthetic' }, 'synthetic-token', async () => ({ done: true, error: { message: 'private provider error' } }), async () => {})); + await assert.rejects(release.waitForOperation({ name: 'https://other.test' }, 'synthetic-token', async () => ({ done: true }), async () => {})); + requests = 0; + await assert.rejects(release.waitForOperation({ name: 'operations/synthetic' }, 'synthetic-token', async () => { requests += 1; return {}; }, async () => {})); + assert.equal(requests, 90); +}); + +test('protected release is fixed manual staging-only, pinned and has no credential file or data/payment access', () => { + assert.match(releaseWorkflow, /\bon:\n workflow_dispatch:\n/); + assert.doesNotMatch(releaseWorkflow, /\n (push|pull_request|schedule):|inputs:|upload-artifact|firebase deploy|gcloud|credentials_json|FIREBASE_TOKEN/); + assert.match(releaseWorkflow, /environment:\n name: backend-staging/); + assert.match(releaseWorkflow, /access_token_lifetime: 1800s/); + assert.match(releaseWorkflow, /create_credentials_file: false/); + assert.match(releaseWorkflow, /export_environment_variables: false/); + assert.ok(releaseWorkflow.indexOf('staging-profile-release.js context') < releaseWorkflow.indexOf('google-github-actions/auth@')); + for (const action of releaseWorkflow.matchAll(/^\s*uses:\s*([^\s#]+)/gm)) assert.match(action[1], /@[0-9a-f]{40}$/); + const invalid = spawnSync(process.execPath, ['scripts/staging-profile-release.js', 'release'], { cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...context } }); + assert.equal(invalid.status, 1); + assert.equal(invalid.stdout, ''); + assert.equal(invalid.stderr, 'staging_profile_release_failed\n'); +}); diff --git a/tests/test-artifact-safety.test.js b/tests/test-artifact-safety.test.js index abdbde2..72faea8 100644 --- a/tests/test-artifact-safety.test.js +++ b/tests/test-artifact-safety.test.js @@ -65,6 +65,7 @@ const EXPECTED_FRONTEND_VALIDATION_STEP = [ ' 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/deploy-staging-profiles.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');