diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index c59d615b..23d55447 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -55,8 +55,8 @@ Every issue inherits `AGENTS.md` and the definition of done in `IMPLEMENTATION_P | 3 | SUPPLY-001 | Remove vulnerable dependency chains and stage SDK/build upgrades | P0 | L | partial: SUPPLY-001A tracked by #127; remaining upgrade/scanning slices open | CI-001 baseline | | 4 | SEC-001 | Replace the Firestore admin catch-all with resource-specific rules | P0 | M | source merged #123; Firebase live unproven | — | | 5 | CONFIG-001 | Fail closed on server environment and commerce configuration | P0 | M | CONFIG-001A merged in #149/PR #150; CONFIG-001B1 tracked in [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151); B2 blocked; not deployed | CI-001A recommended | -| 6 | AUTH-001 | Require verified email for member and privileged claims | P0 | M | partial: #98 merged; backend live unproven; parent open | SEC-001 recommended | -| 7 | AUTH-002 | Replace the legacy static-key membership synchronization endpoint | P0 | M | ready | AUTH-001 | +| 6 | AUTH-001 | Require verified email for member and privileged claims | P0 | M | partial: #98 merged; Rules slice tracked in #196; Firebase live unproven; parent open | SEC-001 recommended | +| 7 | AUTH-002 | Replace the legacy static-key membership synchronization endpoint | P0 | M | waiting on remaining AUTH-001 | AUTH-001 | | 8 | OAUTH-001 | Make Strava token lifecycle server-only, transactional, and auditable | P0 | M | represented by live [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88); proposed/unclaimed | SAFETY-001/#99; SEC-001 and AUTH-003 for A; staged deferral/cutover with ABUSE-001A for C | | 9 | AUTH-003 | Introduce scoped admin capabilities, MFA, and recent authentication | P1 | L | proposed | AUTH-001, AUTH-002, SEC-001 | | 10 | ABUSE-001 | Enforce native App Check and privacy-preserving abuse limits | P0 | L | partial: Enterprise browser source/tests tracked in [#159](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/159); provider config, native enforcement, rate limits, and live proof open | CI-001 baseline | @@ -320,7 +320,7 @@ Implement only the live CONFIG child claimed in `GITHUB_ISSUE_SLICES.md`. Never ## AUTH-001 — Require verified email for member and privileged claims **Labels:** `priority:P0`, `type:security`, `area:auth`, `area:firebase`, `size:M` -**Status:** Partial. AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) merged through [PR #107](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/107) as `ce22c110f2132b157bd8a0d43b065585e0b43cb5`; focused source/tests are proven, but Firebase deployment skipped and the parent scope remains open. +**Status:** Partial. AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) merged through [PR #107](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/107) as `ce22c110f2132b157bd8a0d43b065585e0b43cb5`; its Firebase deployment skipped. AUTH-001B [#196](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/196) tracks the role-based Firestore Rules source/test slice; it has not been deployed. Neither slice is proven live. Remaining Functions/mirror/revocation work is open, and the parent is not complete. **Depends on:** SEC-001 recommended ### Problem @@ -329,6 +329,8 @@ Both the legacy member synchronization function and admin role callable grant cl AUTH-001A now rejects unverified targets at the two existing role-grant endpoints. Do not duplicate that merged slice. It is not proof that Firebase production runs the new code, and it does not complete the remaining parent requirements below. +AUTH-001B [#196](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/196) is the distinct Rules outcome. A `member` or `admin` role authorizes a role-based Firestore operation only when Firebase's token also carries exact boolean `email_verified == true`. Missing, false, string, numeric, or profile-mirror values deny. UID-bound profile read/update and connection-metadata read remain identity self-service so an unverified person can recover; verification never creates membership or admin authority. This source/test boundary is not Firebase deployment or live proof. + Adjacent account-email recovery is represented by canonical live parent [#120](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/120), not by a new AUTH-001 role-grant child. AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) merged the account-creation/request split as `46557c7` but is not published. AUTH-MAIL-002B [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) merged the truthful My Account resend result and browser cooldown as `23bca8c8`, but protected release run `29252492614` stopped before build and published nothing. AUTH-MAIL-002C1 [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155) tracks one neutral password-reset request result and repeat-safe browser cooldown. AUTH-MAIL-002C2 is represented by live child [#194](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/194): a verification-only `/auth/action` source route that scrubs capability state, requires an explicit click and provider `VERIFY_EMAIL`, and exposes only fixed non-identifying results. It must not be configured as Firebase's project-wide custom handler while reset-password and recover-email modes remain unsupported. None grants membership, proves provider delivery, reconciles the Firestore verification mirror, or proves Firebase API enumeration protection. ### Scope diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index b6bdf383..be522ef4 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -34,6 +34,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | ID | Status/dependency | Bounded deliverable | Close evidence | | --- | --- | --- | --- | +| AUTH-001B | source/tests tracked in live [#196](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/196); #98/#100; not deployed | Require exact boolean token `email_verified == true` for every current role-based Firestore member/admin decision. Preserve public reads and UID-bound safe profile recovery; verification grants no role. | Old-Rules proof fails the explicit missing/false/string/numeric matrices; focused/full Rules emulator suites pass with every privileged test context stating verification. Firebase deployment/live behavior remain separate. | | AUTH-003A | proposed; AUTH-001/002, SEC-001 | Define capability matrix, additive claims, role-change audit, and idempotent legacy-admin migration. | Table-driven claim/action tests and emulator dry-run/rollback. | | AUTH-003B | proposed; AUTH-003A | Apply capabilities to server functions and Firestore projections/rules. | Every capability/action pair has allow/deny tests. | | AUTH-003C | owner_action; AUTH-003B | Enforce privileged MFA, recent-auth thresholds, revocation, and break-glass/last-admin policy. | Staging failure/success demonstrations and private owner inventory. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 309078f4..aee7cda5 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -212,7 +212,7 @@ The first tranche deliberately combines one urgent safety repair with the most c 2. **PAY-003 — webhook event inbox and safe reducer:** PAY-003A [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) source merged through [PR #140](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/140) as `87bd1210575882cdd06214bcb98ed38ce00f71c2`, adding event replay protection, async-aware payment confirmation, expected total/currency validation, direct metadata lookup, terminal-state guards, and lifecycle tests. Firebase and Stripe live behavior remain unproven; PAY-003B/C remain open. 3. **SEC-001 — Firestore trust boundaries:** #100 merged through [PR #123](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/123) at `a7fc301e85b0aeabe396e771faea21d3fc8e7b2b`. Source and 295 Rules tests remove recursive browser-admin authority. Firebase deployment explicitly skipped, so deployed/live Rules remain unproven under #105. 4. **PROMO-001 — fail-safe monetary policy:** [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) disables promotion entry and automatic tax in both current creators, adds exact payload tests, and quarantines unknown or nonzero discount/tax/shipping adjustments on success, failure, and expiry. The private pre-change Session/provider inventory, protected deployment, provider readback, and live verification remain owner actions; no discount, tax, or shipping feature is authorized. -5. **Identity/config next:** AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) merged at `ce22c110`, proving source/tests for unverified-target rejection at the existing role-grant endpoints. Its Firebase deployment skipped, so backend-live status is unproven and the remaining AUTH-001 parent work stays open. CONFIG-001A merged through [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) / PR #150 as `2c62b8dd`, but remains undeployed. CONFIG-001B1 is tracked in [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) for fail-closed read-only admission; B2 retains the protected audited operator control, provider-object recovery, and drill. Follow the live issues and do not duplicate these slices. +5. **Identity/config next:** AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) merged at `ce22c110`, proving source/tests for unverified-target rejection at the existing role-grant endpoints. AUTH-001B [#196](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/196) tracks the next bounded source/test outcome: role-based Firestore access requires an exact verified-email token while UID-bound safe profile recovery remains available. Firebase deployment is unproven, and Functions guards, verification-mirror refresh, revocation/audit, authoritative membership, and legacy-sync retirement keep the AUTH-001 parent open. CONFIG-001A merged through [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) / PR #150 as `2c62b8dd`, but remains undeployed. CONFIG-001B1 is tracked in [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) for fail-closed read-only admission; B2 retains the protected audited operator control, provider-object recovery, and drill. Follow the live issues and do not duplicate these slices. 6. **PAY-001/PAY-002:** PAY-001A source/tests are tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-002A1 is tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for pure separate state reducers and synthetic legacy classification. PAY-002B1 is tracked in [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) for pure command/fingerprint/Stripe-key derivation. PAY-002B2A is tracked in [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) for the immutable registered command/audit pair only. PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) owns the separate unused lease/fence/terminal-commitment source and tests without send permission. PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) owns only the unused lease-bound immutable initial plan and audit. PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) owns only the unused separate pre-send marker/audit, exact-attempt retry classification, and persisted 23-hour cutoff. PAY-002B2C3A [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) owns only the unused pure closed reconciliation decision. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; C3B/C3C must persist verified reconciliation evidence and separately authorize/version safe attempt advancement; PAY-002C/D must then add persistence-first sagas; PAY-003B must adopt the shared reducer. No current endpoint imports these PAY-002 foundations, and none of #169/#173/#182/#184 deploys or configures Firebase/Stripe. 7. **Then:** RACE-001 and MERCH-001 transactional reservations. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 6ff81a55..feba83e8 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -277,6 +277,10 @@ npm run test:rules This requires Java. Rules tests must prove both allowed behavior and explicit denial of secret/financial/arbitrary paths for browser admins. +AUTH-001B [#196](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/196) adds one required role matrix to this check. Every test context using `member` or `admin` must state email verification explicitly. Exact boolean `true` permits only the operation already allowed by that role; missing, false, string, numeric, or profile-document values must not substitute. Unverified users must still be able to read and make the existing name-only update to their own profile, and must remain unable to use role-based access or another UID. Stop a release if any privileged fixture relies on an inferred verification default. + +Passing this local/hosted emulator suite proves the reviewed Rules source only. Under #105/#133/#136, deploy and read back the exact Rules revision before publishing a dependent website. Then use made-up accounts to verify one allowed and one denied role case. Never test with a real member/admin account, manually toggle a production role, or treat email verification as membership approval. + ### Production build without changing generated sitemap during a diagnostic check ```bash diff --git a/SECURITY.md b/SECURITY.md index c50481f6..e6283461 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,7 +44,7 @@ The findings below describe the repository at the start of the 2026-07-12 assess | RISK-004 | Race capacity uses a count-then-create sequence outside a transaction; `registeredCount` is displayed but not maintained. | Concurrent users can exceed capacity; stale pending Sessions can block capacity for up to seven days. | Introduce transactional capacity counters/reservations, matching expiry, backfill, and concurrency tests. | | RISK-005 | Merchandise has product status but no SKU/variant inventory reservation. | The club can sell unavailable size/color combinations or oversell stock. | Add canonical variants and atomic inventory holds before live merchandise. | | RISK-006 | `main` source now removes the recursive browser-admin rule, but deployment repeatedly skipped Firebase and the live Rules revision is unproven. | A stale deployed ruleset may still expose secrets or financial/audit writes to a compromised admin session. | Use #105 to stage and deploy the exact tested #100 Rules revision, verify deny behavior with synthetic accounts, and record rollback. | -| RISK-007 | Member promotion by email does not require the Firebase account's email to be verified. The legacy static-key bulk endpoint can grant `member` to a pre-registered known address. | An attacker who registers another member's email first can obtain membership access/discount after sync. | Require verified email and authoritative membership match; retire or strongly redesign the static-key endpoint. | +| RISK-007 | Repository source rejects unverified targets at the two existing role-grant endpoints, and AUTH-001B [#196](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/196) adds exact verified-email checks to role-based Firestore Rules. Firebase deployment, the complete server-guard inventory, verification-mirror refresh, revocation, and replacement of the legacy static-key endpoint remain unproven. | Older deployed code or a remaining role-only server guard can let an unverified known-email account use member/admin authority. | Deploy and verify the exact Rules/Functions through #105, complete the remaining AUTH-001 guard/mirror/revocation work, require authoritative membership, and retire the static-key endpoint. | | RISK-008 | `requireAppCheck` is controlled by optional `ENFORCE_APP_CHECK`; repository/deployment config does not prove it is true. Missing site key also disables client App Check. | Public callable functions may be scripted directly, enabling abuse and cloud/Stripe cost amplification. | Use Firebase runtime `enforceAppCheck: true` for sensitive callables, reCAPTCHA Enterprise, staged metrics, and no environment fail-open. | | RISK-009 | #99 source preserves and validates the complete same-origin Pages callback route, applies `strict-origin` before subresources, and suppresses initial callback App Check/telemetry startup; #126 adds that standalone suite as a blocking hosted CI step. No production Stripe/Strava callback has been rehearsed and the live custom domain uses a different host. | Hosting drift or an untested deployment can still break return links even when source tests pass. Enforcing App Check too early on the three callback callables would also break confirmation or Strava exchange. | Keep the hosted callback gate blocking; keep `lookupRegistration`/`lookupOrder` deferred until DATA-001A and `stravaExchangeCode` deferred until OAUTH-001C within canonical tracker [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88); verify test-mode callbacks only after provider gates; consolidate hosting under WEB-001. | | RISK-010 | Development/test now use synthetic loopback Auth, Firestore, and Functions, but optimized previews still target production Firebase and emulated Functions can call outside providers. | A maintainer can mistake a preview or Firebase emulator for complete staging and affect production/provider systems. | Prohibit private actions in previews; add dedicated staging/config under #105/CONFIG; prove test keys/sinks before provider flows. | @@ -107,7 +107,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Test-only foundation for RISK-010 and RISK-035 | TEST-001A1 is tracked in live [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175). The Functions Jest setup installs a fixed-error, loopback-only guard around supported Node outbound transports and rejects any supplied production-like project/origin/emulator host or live Stripe mode/key. Shared deterministic deeply frozen fixtures use reserved values, and the existing webhook suite adopts one synthetic signed-payload helper. | This is a test-process boundary, not a runtime sandbox. It does not intercept every possible native/add-on transport, isolate an emulated Function process, configure a test provider, scan emitted artifacts, deploy Firebase, or prove live behavior. TEST-001A2 [#177](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/177) separately adds the bounded artifact scrubber and named CI/release gate; later TEST-001 children must add emulator sagas and isolated staging evidence. | | Emitted-test-output part of RISK-035 | TEST-001A2 is tracked in live [#177](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/177). Its source adds a dependency-free read-only scanner for explicit narrow artifact roots, bounded deterministic traversal, fixed redacted failures, deliberate synthetic allow/deny tests, the exact `Test artifact scrubber` CI job, and that job in both protected-release CI rechecks. Current CI still uploads no test artifact. | This scanner covers a documented bounded set of recognizable patterns; green is not a general data-loss-prevention guarantee or proof that every unknown/obfuscated value is absent. It does not inspect source, runtime logs, website builds, provider responses, production data, or an operating-system sandbox, and it does not sanitize a rejected file. Every later report producer must invoke the scanner before upload, and exact hosted evidence remains separate from source and local-test evidence. | | Configuration part of RISK-001/RISK-010 | CONFIG-001A [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) merged through PR #150 as `2c62b8dd`, requiring an exact environment/origin/expected Stripe mode, key-mode compatibility on the four key-bound Functions, no production-origin fallback, and no Stripe API key on webhook/mail. CONFIG-001B1 [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) adds exact `COMMERCE_ENABLED` parsing. A false ceiling denies new commerce before Firestore; otherwise new-commerce commands and every refund perform a fresh strict server-only global/domain/resource admission read. Missing controls/resource flags mean disabled; fixed errors expose no supplied value; Rules tests deny browser control; webhook/mail remain independent. | Neither source result configures or deploys Firebase/Stripe. B1 cannot stop already-admitted commands or existing Sessions/Payment Links and has no safe operator writer. CONFIG-001B2, PAY-002/PAY-004/PAY-005, and #105/#133/#136 retain protected control, expiry/reconciliation, staging, deployment, provider readback, and drill. No live payment or email is authorized. | -| Part of RISK-007 | AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) was merged through [PR #107](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/107) as `ce22c110f2132b157bd8a0d43b065585e0b43cb5`. Source and focused tests reject unverified targets at the two existing role-grant endpoints. | The workflow skipped Firebase because `FIREBASE_SERVICE_ACCOUNT` was absent, so backend-live behavior is unproven. Remaining AUTH-001 guards, Rules, verification mirror/refresh, demotion propagation, audit, and authoritative membership work stay open. | +| Part of RISK-007 | AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) was merged through [PR #107](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/107) as `ce22c110f2132b157bd8a0d43b065585e0b43cb5`; it rejects unverified targets at the two existing role-grant endpoints. AUTH-001B [#196](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/196) tracks the companion Rules boundary: every `member`/`admin` role authorization additionally requires exact boolean token claim `email_verified == true`, while UID-bound profile recovery remains available. Its test harness requires explicit verification state for every privileged context and covers missing, false, string, numeric, and profile-mirror substitution attempts. | Neither slice proves Firebase deployment or live access. Remaining Functions guards, verification mirror/refresh, demotion propagation, audit, authoritative membership, and legacy-sync retirement stay open. Verification alone never grants a role. | | RISK-013 | PROMO-001 [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) sets both current Checkout creators to promotion entry off and automatic tax off. Exact payload and signed event tests require an explicit all-zero breakdown; malformed or nonzero discount, tax, or shipping data creates durable review evidence while definitive failed/expired Sessions still cancel. | Privately inventory pre-change Sessions/provider promotion settings, keep the guard through protected staging/deploy, verify provider and live state separately, and implement an approved authoritative discount/tax/shipping contract before ever enabling it. | | Part of RISK-014 | SUPPLY-001A [#127](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/127) removes the unused direct `gpxparser` dependency and 50 lockfile entries without changing any retained package version, source URL, or integrity hash. A fresh Node 20 install lowers the root production audit from 33 findings (3 critical, 9 high) to 22 (1 critical, 8 high), and the optimized production asset hashes remain identical. | #192 completes the separately tested router patch. Remediate or time-bound the remaining root and Functions findings through the Firebase, Stripe, build-system, and scanning slices; do not use a forced audit upgrade. | | Part of RISK-014 | SUPPLY-001B [#192](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/192) pins `react-router-dom` and `react-router` 6.30.4 with `@remix-run/router` 1.23.3. The only lockfile identity changes are those three nodes. A fresh Node 20 production audit removes all three router findings and lowers the root total from 22 to 19 and high findings from 8 to 5. The declarative router opts into `v7_startTransition` and `v7_relativeSplatPath`; focused tests cover warning removal, double-slash normalization, wildcard navigation, login/account harnesses, and the standalone callback boundary. | The remaining 19 root and 9 Functions production findings are not accepted as safe or fixed. Patch Firebase in separate compatibility slices, replace the unmaintained build chain, and add continuous scanning. Source/tests/merge do not prove website publication or live route behavior. | diff --git a/docs/officers/ACCESS_CONTINUITY.md b/docs/officers/ACCESS_CONTINUITY.md index 609c0a78..86b717f6 100644 --- a/docs/officers/ACCESS_CONTINUITY.md +++ b/docs/officers/ACCESS_CONTINUITY.md @@ -86,6 +86,7 @@ For each system, record only: 12. Confirm Firebase verification must finish before the GitHub Pages publication job can start. 13. Confirm Netlify Git-triggered production builds remain paused until a protected live-host path exists. 14. Confirm reviewers reject release requests older than 24 hours and request the current `main` commit again. +15. **NOT AVAILABLE YET:** after the exact #196 Rules revision is deployed to protected staging, use a made-up account to confirm that private role access requires both an already-approved member/admin role and a verified email. Verification alone never grants access. Never change a real member's role for this drill. ## Expected result and success proof @@ -98,6 +99,8 @@ For each system, record only: Stop if a service has only one owner, uses Dave's personal recovery channel, has no tested rollback, or asks for a password/code in a shared document. Also stop if a merge can publish unexpectedly, a server credential reaches a website job, a project/scope can be typed freely, a green backend skip is possible, or a website can publish before backend verification. Do not remove an account, token, integration, fork, or billing method until two owners complete a dependency check. +Also stop if a role-bearing Firebase account can open private member/admin data before its email is verified. Do not work around that result by editing a profile, changing a role, or using Firebase Console. Record only the fixed test result and escalate to the platform/security owner. + ## Undo and recovery If an access change blocks a valid officer, stop further removals. Use the service's approved recovery procedure with two owners. Restore only the minimum former access, record why, and schedule a new least-access review. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 66fdfcf5..1586ebbc 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -83,6 +83,24 @@ flowchart TD In words: people use their own accounts, at least two officers cover every service, and recovery information stays in the approved password manager rather than the repository or AI. +## Role-based Firebase access — SOURCE ONLY, NOT LIVE + +```mermaid +flowchart TD + SignIn["Person signs in"] --> Own{"Safe action on their own profile?"} + Own -- "Yes" --> Self["Use the existing own-account permission"] + Own -- "No; private role access" --> Verified{"Account email verified?"} + Verified -- "No" --> Deny["Deny private member/admin access"] + Verified -- "Yes" --> Role{"Allowed member/admin role?"} + Role -- "No" --> Deny + Role -- "Yes" --> Allow["Allow only that role's specific operation"] + Verified -. "does not grant" .-> Role +``` + +In words: safe self-service is limited to the person's own signed-in account, while every private member/admin operation requires both a verified account email and the already-approved role; verification does not grant the role. + +Issue [#196](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/196) owns this Rules source/test boundary. It is **NOT LIVE** until the exact Rules revision is deployed and checked with made-up accounts. An officer must not edit a profile, toggle a role, or use a real account as a workaround or test. + ## Member profile setup and recovery — NOT LIVE YET ```mermaid diff --git a/firestore.rules b/firestore.rules index d035cd37..5e3e6f15 100644 --- a/firestore.rules +++ b/firestore.rules @@ -3,8 +3,24 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { + function hasVerifiedEmail() { + // Firebase Auth owns this token claim. A profile field or request value + // is not authorization, and missing/malformed values fail this exact + // boolean comparison closed. + return request.auth != null + && request.auth.token.email_verified == true; + } + + function hasVerifiedRole(role) { + return hasVerifiedEmail() && request.auth.token.role == role; + } + function isAdmin() { - return request.auth != null && request.auth.token.role == 'admin'; + return hasVerifiedRole('admin'); + } + + function isMember() { + return hasVerifiedRole('member') || isAdmin(); } function isNonEmptyString(value, maxLength) { @@ -251,8 +267,7 @@ service cloud.firestore { // Members-only legacy collection match /members_only/{document} { - allow read: if request.auth != null - && (request.auth.token.role == 'member' || isAdmin()); + allow read: if isMember(); } // A signed-in user can read and update their own member doc. @@ -351,8 +366,7 @@ service cloud.firestore { && hasLegacyPublishedStatus(resource.data); // Authenticated member/admin read: members-only events - allow read: if request.auth != null - && (request.auth.token.role == 'member' || request.auth.token.role == 'admin') + allow read: if isMember() && ((resource.data.visibility == 'members_only' && isPublishedEventStatus(resource.data)) || (!('visibility' in resource.data) diff --git a/tests/firestore-rules/events.test.js b/tests/firestore-rules/events.test.js index 95ae2301..9e311389 100644 --- a/tests/firestore-rules/events.test.js +++ b/tests/firestore-rules/events.test.js @@ -167,19 +167,39 @@ describe('events collection', () => { test('member CAN read members_only event', async () => { await seed('events/e1', MEMBERS_ONLY); - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertSucceeds(member.doc('events/e1').get()); }); + test.each([ + ['a missing verification claim', { + uid: 'm-missing', role: 'member', omitEmailVerified: true, + }], + ['a false verification claim', { + uid: 'm-false', role: 'member', emailVerified: false, + }], + ['a string verification claim', { + uid: 'm-string', role: 'member', emailVerified: 'true', + }], + ['a numeric verification claim', { + uid: 'm-number', role: 'member', emailVerified: 1, + }], + ])('member with %s CANNOT read a members-only event', async (_label, auth) => { + await seed('events/e1', MEMBERS_ONLY); + const member = await db(auth); + + await assertFails(member.doc('events/e1').get()); + }); + test('member CANNOT read a new-schema members_only draft', async () => { await seed('events/e1', MEMBERS_ONLY_DRAFT); - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(member.doc('events/e1').get()); }); test('member CANNOT read a legacy members-only draft', async () => { await seed('events/e1', LEGACY_MEMBERS_ONLY_DRAFT); - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(member.doc('events/e1').get()); }); @@ -191,7 +211,7 @@ describe('events collection', () => { status: 'unexpected', }); const anon = await db(); - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(anon.doc('events/public-unknown').get()); await assertFails(member.doc('events/member-unknown').get()); @@ -201,8 +221,8 @@ describe('events collection', () => { test('visibility=draft remains admin-only even with an open status', async () => { await seed('events/e1', { ...PUBLIC_OPEN, visibility: 'draft' }); const anon = await db(); - const member = await db({ uid: 'u1', role: 'member' }); - const admin = await db({ uid: 'a1', role: 'admin' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(anon.doc('events/e1').get()); await assertFails(member.doc('events/e1').get()); @@ -211,10 +231,31 @@ describe('events collection', () => { test('admin CAN read draft event through the explicit events rule', async () => { await seed('events/e1', PUBLIC_DRAFT); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('events/e1').get()); }); + test.each([ + ['a missing verification claim', { + uid: 'a-missing', role: 'admin', omitEmailVerified: true, + }], + ['a false verification claim', { + uid: 'a-false', role: 'admin', emailVerified: false, + }], + ['a string verification claim', { + uid: 'a-string', role: 'admin', emailVerified: 'true', + }], + ['a numeric verification claim', { + uid: 'a-number', role: 'admin', emailVerified: 1, + }], + ])('admin with %s CANNOT read draft event data', async (_label, auth) => { + await seed('events/e1', PUBLIC_DRAFT); + const admin = await db(auth); + + await assertFails(admin.doc('events/e1').get()); + await assertFails(admin.collection('events').get()); + }); + test('anonymous CAN read legacy event (no visibility, member_only=false)', async () => { await seed('events/e1', LEGACY_OPEN); const anon = await db(); @@ -235,13 +276,13 @@ describe('events collection', () => { test('admin CAN read a legacy draft event', async () => { await seed('events/e1', LEGACY_DRAFT); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('events/e1').get()); }); test('member CAN read legacy member_only=true event', async () => { await seed('events/e1', LEGACY_MEMBERS_ONLY); - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertSucceeds(member.doc('events/e1').get()); }); }); @@ -273,14 +314,14 @@ describe('events collection', () => { // but is unreadable, so Firestore denies the whole list. This is why // listMemberEvents must instead run two visibility-scoped queries (the // two assertSucceeds cases here) and merge them client-side. - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails( member.collection('events').where('status', 'in', ['open', 'closed']).get(), ); }); test('member CAN list members_only open/closed events with a visibility filter', async () => { - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertSucceeds( member.collection('events') .where('visibility', '==', 'members_only') @@ -290,7 +331,7 @@ describe('events collection', () => { }); test('admin CAN list all events for the current admin screen', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.collection('events').get()); }); }); @@ -302,7 +343,7 @@ describe('events collection', () => { }); test('member CANNOT create an event', async () => { - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(member.doc('events/new').set(PUBLIC_OPEN)); }); @@ -312,12 +353,12 @@ describe('events collection', () => { }); test('admin CAN create an event', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('events/new').set(adminEvent('new'))); }); test('admin CAN create the current client payload only as a hidden inert draft', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('events/free').set(adminEvent('free', { capacity: null, pricing: { memberCents: 0, nonMemberCents: 0 }, @@ -325,13 +366,13 @@ describe('events collection', () => { }); test.each(INVALID_SLUGS)('admin CANNOT create an event with invalid slug %s', async (slug) => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc(`events/${slug}`).set(adminEvent(slug))); }); test('admin CAN update an event with the current editor payload', async () => { await seed('events/e1', adminEvent('e1')); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('events/e1').update(editorUpdate())); }); @@ -359,7 +400,7 @@ describe('events collection', () => { registrationClosesAt, }; await seed('events/e1', adminEvent('e1', operational)); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('events/e1').update(editorUpdate({ ...operational, }))); @@ -367,14 +408,14 @@ describe('events collection', () => { test('admin CANNOT delete an event directly', async () => { await seed('events/e1', adminEvent('e1')); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('events/e1').delete()); }); test.each([ ['a user without a role claim', { uid: 'u1' }], ['an unverified user', { uid: 'u2', role: 'unverified' }], - ['a member', { uid: 'u3', role: 'member' }], + ['a member', { uid: 'u3', role: 'member', emailVerified: true }], ])('%s CANNOT update or delete an event', async (_label, auth) => { await seed('events/e1', adminEvent('e1')); const user = await db(auth); @@ -430,7 +471,7 @@ describe('events collection', () => { })], ['a missing creation timestamp', (event) => withoutField(event, 'createdAt')], ])('admin CANNOT create an event with %s', async (_label, buildInvalid) => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); const event = buildInvalid(adminEvent('invalid')); await assertFails(admin.doc('events/invalid').set(event)); }); @@ -453,7 +494,7 @@ describe('events collection', () => { ['an insecure hero-image URL', { heroImageUrl: 'http://example.com/hero.jpg' }], ])('admin CANNOT update an event with %s', async (_label, patch) => { await seed('events/e1', adminEvent('e1')); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('events/e1').update(patch)); }); @@ -490,14 +531,14 @@ describe('events collection', () => { ['inventory state', { inventory: { onHand: 10 } }], ['audit data', { auditLog: [{ action: 'created' }] }], ])('admin CANNOT create an event containing protected %s', async (_label, patch) => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails( admin.doc('events/protected').set(adminEvent('protected', patch)), ); }); test('admin CANNOT inject Stripe fields into event pricing', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('events/protected').set(adminEvent('protected', { pricing: { memberCents: 2500, @@ -509,7 +550,7 @@ describe('events collection', () => { test('admin CANNOT inject Stripe fields while updating event pricing', async () => { await seed('events/e1', adminEvent('e1')); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('events/e1').update({ pricing: { memberCents: 2500, @@ -552,7 +593,7 @@ describe('events collection', () => { ['audit data', { auditLog: [{ action: 'tampered' }] }], ])('admin CANNOT update protected event %s', async (_label, patch) => { await seed('events/e1', adminEvent('e1')); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('events/e1').update(patch)); }); }); @@ -575,7 +616,7 @@ describe('registrations subcollection', () => { }); test('member CANNOT read a registration', async () => { - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(member.doc('events/e1/registrations/r1').get()); }); @@ -585,10 +626,27 @@ describe('registrations subcollection', () => { }); test('admin CAN read a registration through the explicit registrations rule', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('events/e1/registrations/r1').get()); }); + test.each([ + ['a missing verification claim', { + uid: 'a-missing', role: 'admin', omitEmailVerified: true, + }], + ['a false verification claim', { + uid: 'a-false', role: 'admin', emailVerified: false, + }], + ['a malformed verification claim', { + uid: 'a-string', role: 'admin', emailVerified: 'true', + }], + ])('admin with %s CANNOT read or list registrations', async (_label, auth) => { + const admin = await db(auth); + + await assertFails(admin.doc('events/e1/registrations/r1').get()); + await assertFails(admin.collection('events/e1/registrations').get()); + }); + test('anonymous CANNOT write a registration', async () => { const anon = await db(); await assertFails( @@ -597,21 +655,21 @@ describe('registrations subcollection', () => { }); test('member CANNOT write a registration', async () => { - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails( member.doc('events/e1/registrations/r2').set({ status: 'paid' }), ); }); test('admin CANNOT create a registration directly', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails( admin.doc('events/e1/registrations/r2').set({ status: 'paid' }), ); }); test('admin CANNOT update registration financial state directly', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails( admin.doc('events/e1/registrations/r1').update({ status: 'refunded', @@ -621,17 +679,17 @@ describe('registrations subcollection', () => { }); test('admin CANNOT delete a registration directly', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('events/e1/registrations/r1').delete()); }); test('admin CAN list registrations for a known event', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.collection('events/e1/registrations').get()); }); test('member CANNOT list registrations across events', async () => { - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(member.collectionGroup('registrations').get()); }); @@ -641,7 +699,7 @@ describe('registrations subcollection', () => { status: 'paid', amountCents: 5000, }); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.collectionGroup('registrations').get()); }); }); diff --git a/tests/firestore-rules/members-only.test.js b/tests/firestore-rules/members-only.test.js index 1f7dc5bb..4d525033 100644 --- a/tests/firestore-rules/members-only.test.js +++ b/tests/firestore-rules/members-only.test.js @@ -28,23 +28,55 @@ describe('members_only collection', () => { test('members CAN read private content', async () => { await seed('members_only/benefits', PRIVATE_CONTENT); - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertSucceeds(member.doc('members_only/benefits').get()); }); test('admins CAN read private content through the explicit collection rule', async () => { await seed('members_only/benefits', PRIVATE_CONTENT); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('members_only/benefits').get()); }); + test.each([ + ['member with no verification claim', { + uid: 'm-missing', role: 'member', omitEmailVerified: true, + }], + ['member with a false verification claim', { + uid: 'm-false', role: 'member', emailVerified: false, + }], + ['member with a string verification claim', { + uid: 'm-string', role: 'member', emailVerified: 'true', + }], + ['member with a numeric verification claim', { + uid: 'm-number', role: 'member', emailVerified: 1, + }], + ['admin with no verification claim', { + uid: 'a-missing', role: 'admin', omitEmailVerified: true, + }], + ['admin with a false verification claim', { + uid: 'a-false', role: 'admin', emailVerified: false, + }], + ['admin with a string verification claim', { + uid: 'a-string', role: 'admin', emailVerified: 'true', + }], + ['admin with a numeric verification claim', { + uid: 'a-number', role: 'admin', emailVerified: 1, + }], + ])('%s CANNOT read private content', async (_label, auth) => { + await seed('members_only/benefits', PRIVATE_CONTENT); + const user = await db(auth); + + await assertFails(user.doc('members_only/benefits').get()); + }); + test.each([ ['anonymous users', undefined], ['users without a role claim', { uid: 'u1' }], ['unverified users', { uid: 'u2', role: 'unverified' }], - ['members', { uid: 'u3', role: 'member' }], + ['members', { uid: 'u3', role: 'member', emailVerified: true }], ])('%s CANNOT create, update, or delete private content', async (_label, auth) => { await seed('members_only/benefits', PRIVATE_CONTENT); const user = await db(auth); @@ -58,7 +90,7 @@ describe('members_only collection', () => { test('admins CANNOT write private content from the browser', async () => { await seed('members_only/benefits', PRIVATE_CONTENT); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('members_only/new-benefit').set(PRIVATE_CONTENT)); await assertFails(admin.doc('members_only/benefits').update({ diff --git a/tests/firestore-rules/members.test.js b/tests/firestore-rules/members.test.js index 8fa56641..f7470bd6 100644 --- a/tests/firestore-rules/members.test.js +++ b/tests/firestore-rules/members.test.js @@ -33,27 +33,62 @@ describe('members collection', () => { test('user CANNOT read another user\'s member doc', async () => { await seed('members/u2', SAMPLE_MEMBER); - const me = await db({ uid: 'u1', role: 'member' }); + const me = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(me.doc('members/u2').get()); }); test('admin CAN read any member doc through the explicit members rule', async () => { await seed('members/u1', SAMPLE_MEMBER); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('members/u1').get()); }); test('admin CAN list member docs for the current admin screen', async () => { await seed('members/u1', SAMPLE_MEMBER); await seed('members/u2', SAMPLE_MEMBER); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.collection('members').get()); }); + test.each([ + ['a missing verification claim', { + uid: 'a-missing', role: 'admin', omitEmailVerified: true, + }], + ['a false verification claim', { + uid: 'a-false', role: 'admin', emailVerified: false, + }], + ['a string verification claim', { + uid: 'a-string', role: 'admin', emailVerified: 'true', + }], + ['a numeric verification claim', { + uid: 'a-number', role: 'admin', emailVerified: 1, + }], + ])('admin with %s CANNOT read or list another member profile', async (_label, auth) => { + // A profile mirror is data, not authorization. Seed it true to prove it + // cannot replace the signed Firebase token claim. + await seed('members/u1', { ...SAMPLE_MEMBER, emailVerified: true }); + const admin = await db(auth); + + await assertFails(admin.doc('members/u1').get()); + await assertFails(admin.collection('members').get()); + }); + + test('an unverified user retains UID-bound profile recovery access', async () => { + await seed('members/u1', SAMPLE_MEMBER); + const me = await db({ uid: 'u1', role: 'member', emailVerified: false }); + + await assertSucceeds(me.doc('members/u1').get()); + await assertSucceeds(me.doc('members/u1').update({ + fullName: 'Recovery Name', + updatedAt: new Date(), + })); + await assertFails(me.doc('members/u2').get()); + }); + test('user CANNOT list the members collection', async () => { await seed('members/u1', SAMPLE_MEMBER); await seed('members/u2', SAMPLE_MEMBER); - const me = await db({ uid: 'u1', role: 'member' }); + const me = await db({ uid: 'u1', role: 'member', emailVerified: true }); // No query constraint can satisfy `request.auth.uid == uid` for every // doc, so a collection list is denied — users can't enumerate members. await assertFails(me.collection('members').get()); @@ -72,8 +107,8 @@ describe('members collection', () => { test.each([ ['a user without a role claim', { uid: 'u1' }], ['an unverified user', { uid: 'u1', role: 'unverified' }], - ['a member', { uid: 'u1', role: 'member' }], - ['an admin editing their own profile', { uid: 'u1', role: 'admin' }], + ['a member', { uid: 'u1', role: 'member', emailVerified: true }], + ['an admin editing their own profile', { uid: 'u1', role: 'admin', emailVerified: true }], ])('%s CAN make the exact existing-profile edit', async (_label, auth) => { // Mirrors accountService.updateMyProfile. The role grants no extra write // authority; ownership and the exact field/value contract are decisive. @@ -263,26 +298,26 @@ describe('members collection', () => { test('user CANNOT update someone else\'s member doc', async () => { await seed('members/u2', SAMPLE_MEMBER); - const me = await db({ uid: 'u1', role: 'member' }); + const me = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(me.doc('members/u2').update({ fullName: 'Hax' })); }); test('admin CANNOT promote another user\'s role directly', async () => { await seed('members/u1', SAMPLE_MEMBER); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('members/u1').update({ role: 'admin' })); }); test('admin CANNOT update another member profile directly', async () => { await seed('members/u1', SAMPLE_MEMBER); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('members/u1').update({ fullName: 'Changed by admin' })); }); test('user and admin CANNOT delete a member profile directly', async () => { await seed('members/u1', SAMPLE_MEMBER); - const me = await db({ uid: 'u1', role: 'member' }); - const admin = await db({ uid: 'a1', role: 'admin' }); + const me = await db({ uid: 'u1', role: 'member', emailVerified: true }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(me.doc('members/u1').delete()); await assertFails(admin.doc('members/u1').delete()); }); @@ -295,7 +330,7 @@ describe('members collection', () => { }); test('admin CANNOT create a member doc directly', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('members/u9').set(SAMPLE_MEMBER)); }); }); @@ -328,7 +363,7 @@ describe('members/{uid}/connections subcollection', () => { test('admin CANNOT read or write connection metadata without an explicit need', async () => { await seed('members/u1/connections/strava', conn); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('members/u1/connections/strava').get()); await assertFails(admin.doc('members/u1/connections/strava').update({ athleteId: 99999 })); }); @@ -339,14 +374,14 @@ describe('members/{uid}/connections subcollection', () => { // cannot harvest everyone's Strava connection metadata. await seed('members/u1/connections/strava', conn); await seed('members/u2/connections/strava', conn); - const me = await db({ uid: 'u1', role: 'member' }); + const me = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(me.collectionGroup('connections').get()); }); test('admin CANNOT collectionGroup-query all connections', async () => { await seed('members/u1/connections/strava', conn); await seed('members/u2/connections/strava', conn); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.collectionGroup('connections').get()); }); }); @@ -371,19 +406,19 @@ describe('members/{uid}/secrets subcollection', () => { test('member CANNOT read another user\'s secrets', async () => { await seed('members/u2/secrets/strava', secret); - const m = await db({ uid: 'u1', role: 'member' }); + const m = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(m.doc('members/u2/secrets/strava').get()); }); test('admin CANNOT read OAuth secrets', async () => { await seed('members/u1/secrets/strava', secret); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('members/u1/secrets/strava').get()); }); test('admin CANNOT create, update, or delete OAuth secrets', async () => { await seed('members/u1/secrets/strava', secret); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('members/u2/secrets/strava').set(secret)); await assertFails(admin.doc('members/u1/secrets/strava').update({ expires_at: 1 })); await assertFails(admin.doc('members/u1/secrets/strava').delete()); @@ -392,14 +427,14 @@ describe('members/{uid}/secrets subcollection', () => { test('member CANNOT collectionGroup-query all secrets', async () => { await seed('members/u1/secrets/strava', secret); await seed('members/u2/secrets/strava', secret); - const m = await db({ uid: 'u1', role: 'member' }); + const m = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(m.collectionGroup('secrets').get()); }); test('admin CANNOT collectionGroup-query all secrets', async () => { await seed('members/u1/secrets/strava', secret); await seed('members/u2/secrets/strava', secret); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.collectionGroup('secrets').get()); }); }); diff --git a/tests/firestore-rules/setup.js b/tests/firestore-rules/setup.js index 4b84b8ca..ba13dd62 100644 --- a/tests/firestore-rules/setup.js +++ b/tests/firestore-rules/setup.js @@ -47,10 +47,25 @@ function ctx(role, uid) { return { role, uid }; } -async function db({ uid, role } = {}) { +async function db(auth = {}) { + const { + uid, role, emailVerified, omitEmailVerified = false, + } = auth; const env = await getEnv(); if (!uid) return env.unauthenticatedContext().firestore(); - return env.authenticatedContext(uid, role ? { role } : {}).firestore(); + + const token = {}; + if (role) token.role = role; + + const hasEmailVerified = Object.prototype.hasOwnProperty.call(auth, 'emailVerified'); + if ((role === 'member' || role === 'admin') + && !hasEmailVerified + && !omitEmailVerified) { + throw new Error('Privileged test contexts must state emailVerified explicitly'); + } + if (hasEmailVerified) token.email_verified = emailVerified; + + return env.authenticatedContext(uid, token).firestore(); } /** diff --git a/tests/firestore-rules/shop-and-locked.test.js b/tests/firestore-rules/shop-and-locked.test.js index 90cd5e0b..c6d1a262 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -75,40 +75,61 @@ describe('products collection', () => { test('admin CAN read draft product through the explicit products rule', async () => { await seed('products/secret', { status: 'draft', title: 'Secret', priceCents: 0 }); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('products/secret').get()); }); + test.each([ + ['a missing verification claim', { + uid: 'a-missing', role: 'admin', omitEmailVerified: true, + }], + ['a false verification claim', { + uid: 'a-false', role: 'admin', emailVerified: false, + }], + ['a string verification claim', { + uid: 'a-string', role: 'admin', emailVerified: 'true', + }], + ['a numeric verification claim', { + uid: 'a-number', role: 'admin', emailVerified: 1, + }], + ])('admin with %s CANNOT read draft product data', async (_label, auth) => { + await seed('products/secret', { status: 'draft', title: 'Secret', priceCents: 0 }); + const admin = await db(auth); + + await assertFails(admin.doc('products/secret').get()); + await assertFails(admin.collection('products').get()); + }); + test('anonymous CANNOT create a product', async () => { const anon = await db(); await assertFails(anon.doc('products/new').set({ status: 'active' })); }); test('member CANNOT create a product', async () => { - const member = await db({ uid: 'u1', role: 'member' }); + const member = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(member.doc('products/new').set({ status: 'active' })); }); test('admin CAN create a product', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('products/new').set(adminProduct('new'))); }); test('admin CAN deliberately create a zero-price draft product', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('products/free').set(adminProduct('free', { priceCents: 0, }))); }); test.each(INVALID_SLUGS)('admin CANNOT create a product with invalid slug %s', async (slug) => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc(`products/${slug}`).set(adminProduct(slug))); }); test('admin CAN update a product with the current editor payload', async () => { await seed('products/hat', adminProduct('hat')); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('products/hat').update(productEditorUpdate())); }); @@ -121,7 +142,7 @@ describe('products collection', () => { sizes, colors, })); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('products/hat').update({ ...productEditorUpdate({ status: 'active' }), priceCents: 1000, @@ -132,14 +153,14 @@ describe('products collection', () => { test('admin CANNOT delete a product directly', async () => { await seed('products/hat', adminProduct('hat')); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('products/hat').delete()); }); test.each([ ['a user without a role claim', { uid: 'u1' }], ['an unverified user', { uid: 'u2', role: 'unverified' }], - ['a member', { uid: 'u3', role: 'member' }], + ['a member', { uid: 'u3', role: 'member', emailVerified: true }], ])('%s CANNOT update or delete a product', async (_label, auth) => { await seed('products/hat', adminProduct('hat')); const user = await db(auth); @@ -172,7 +193,7 @@ describe('products collection', () => { createdBy: product.createdBy, })], ])('admin CANNOT create a product with %s', async (_label, buildInvalid) => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); const product = buildInvalid(adminProduct('invalid')); await assertFails(admin.doc('products/invalid').set(product)); }); @@ -191,7 +212,7 @@ describe('products collection', () => { ['an invalid updatedAt', { updatedAt: 'not-a-timestamp' }], ])('admin CANNOT update a product with %s', async (_label, patch) => { await seed('products/hat', adminProduct('hat')); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('products/hat').update(patch)); }); @@ -207,7 +228,7 @@ describe('products collection', () => { ['payment state', { paymentStatus: 'paid' }], ['audit data', { auditLog: [{ action: 'created' }] }], ])('admin CANNOT create a product containing protected %s', async (_label, patch) => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails( admin.doc('products/protected').set(adminProduct('protected', patch)), ); @@ -227,7 +248,7 @@ describe('products collection', () => { ['audit data', { auditLog: [{ action: 'tampered' }] }], ])('admin CANNOT update protected product %s', async (_label, patch) => { await seed('products/hat', adminProduct('hat')); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('products/hat').update(patch)); }); }); @@ -259,7 +280,7 @@ describe('products list queries (rules-are-not-filters)', () => { }); test('admin CAN list all products for the current admin screen', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.collection('products').get()); }); }); @@ -280,37 +301,54 @@ describe('orders collection', () => { }); test('member CANNOT read an order', async () => { - const m = await db({ uid: 'u1', role: 'member' }); + const m = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(m.doc('orders/o1').get()); }); test('admin CAN read an order through the explicit orders rule', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.doc('orders/o1').get()); }); test('admin CAN list orders for the current admin screen', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertSucceeds(admin.collection('orders').get()); }); + test.each([ + ['a missing verification claim', { + uid: 'a-missing', role: 'admin', omitEmailVerified: true, + }], + ['a false verification claim', { + uid: 'a-false', role: 'admin', emailVerified: false, + }], + ['a malformed verification claim', { + uid: 'a-string', role: 'admin', emailVerified: 'true', + }], + ])('admin with %s CANNOT read or list orders', async (_label, auth) => { + const admin = await db(auth); + + await assertFails(admin.doc('orders/o1').get()); + await assertFails(admin.collection('orders').get()); + }); + test('anonymous CANNOT write an order', async () => { const anon = await db(); await assertFails(anon.doc('orders/o2').set({ status: 'paid' })); }); test('member CANNOT write an order', async () => { - const m = await db({ uid: 'u1', role: 'member' }); + const m = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(m.doc('orders/o2').set({ status: 'paid' })); }); test('admin CANNOT create an order directly', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('orders/o2').set({ status: 'paid' })); }); test('admin CANNOT update order financial state directly', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('orders/o1').update({ status: 'refunded', amountCents: 0, @@ -318,7 +356,7 @@ describe('orders collection', () => { }); test('admin CANNOT delete an order directly', async () => { - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('orders/o1').delete()); }); }); @@ -338,8 +376,8 @@ describe('server-only operational collections', () => { test.each([ ['anonymous', undefined], - ['member', { uid: 'member-1', role: 'member' }], - ['browser admin', { uid: 'admin-1', role: 'admin' }], + ['member', { uid: 'member-1', role: 'member', emailVerified: true }], + ['browser admin', { uid: 'admin-1', role: 'admin', emailVerified: true }], ])('%s CANNOT read or write the commerce control', async (_label, auth) => { await seed(existingPath, control); const client = await db(auth); @@ -353,8 +391,8 @@ describe('server-only operational collections', () => { describe.each([ ['anonymous', undefined], - ['member', { uid: 'member-1', role: 'member' }], - ['browser admin', { uid: 'admin-1', role: 'admin' }], + ['member', { uid: 'member-1', role: 'member', emailVerified: true }], + ['browser admin', { uid: 'admin-1', role: 'admin', emailVerified: true }], ])('checkoutRequests lifecycle — %s', (_label, auth) => { const existingPath = 'checkoutRequests/synthetic-existing/lifecycle/current'; const newPath = 'checkoutRequests/synthetic-new/lifecycle/current'; @@ -378,8 +416,8 @@ describe('server-only operational collections', () => { describe.each([ ['anonymous', undefined], - ['member', { uid: 'member-1', role: 'member' }], - ['browser admin', { uid: 'admin-1', role: 'admin' }], + ['member', { uid: 'member-1', role: 'member', emailVerified: true }], + ['browser admin', { uid: 'admin-1', role: 'admin', emailVerified: true }], ])('checkoutRequests provider attempt — %s', (_label, auth) => { const existingPath = 'checkoutRequests/synthetic-existing/providerAttempts/0000000001'; const newPath = 'checkoutRequests/synthetic-new/providerAttempts/0000000001'; @@ -403,8 +441,8 @@ describe('server-only operational collections', () => { describe.each([ ['anonymous', undefined], - ['member', { uid: 'member-1', role: 'member' }], - ['browser admin', { uid: 'admin-1', role: 'admin' }], + ['member', { uid: 'member-1', role: 'member', emailVerified: true }], + ['browser admin', { uid: 'admin-1', role: 'admin', emailVerified: true }], ])('checkoutRequests provider send evidence — %s', (_label, auth) => { const existingPath = 'checkoutRequests/synthetic-existing/providerAttempts/0000000001/sendEvidence/first'; const newPath = 'checkoutRequests/synthetic-new/providerAttempts/0000000001/sendEvidence/first'; @@ -451,7 +489,7 @@ describe('server-only operational collections', () => { test('member CANNOT read', async () => { await seed(path_, sample); - const m = await db({ uid: 'u1', role: 'member' }); + const m = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(m.doc(path_).get()); }); @@ -462,13 +500,13 @@ describe('server-only operational collections', () => { test('admin CANNOT read', async () => { await seed(path_, sample); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc(path_).get()); }); test('admin CANNOT create, update, or delete', async () => { await seed(path_, sample); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc(newPath).set(sample)); await assertFails(admin.doc(path_).update({ probe: true })); await assertFails(admin.doc(path_).delete()); @@ -480,7 +518,7 @@ describe('server-only operational collections', () => { ['auditEvents', 'events/e1/auditEvents/audit1', { action: 'edit' }], ])('admin CANNOT collectionGroup-list protected %s', async (group, path_, sample) => { await seed(path_, sample); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.collectionGroup(group).get()); }); }); @@ -488,13 +526,13 @@ describe('server-only operational collections', () => { describe('default-deny boundary', () => { test('admin CANNOT read an arbitrary future collection', async () => { await seed('totallyMadeUp/x', { foo: 'bar' }); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('totallyMadeUp/x').get()); }); test('admin CANNOT create, update, or delete in an arbitrary future collection', async () => { await seed('totallyMadeUp/x', { foo: 'bar' }); - const admin = await db({ uid: 'a1', role: 'admin' }); + const admin = await db({ uid: 'a1', role: 'admin', emailVerified: true }); await assertFails(admin.doc('totallyMadeUp/new').set({ foo: 'bar' })); await assertFails(admin.doc('totallyMadeUp/x').update({ foo: 'changed' })); await assertFails(admin.doc('totallyMadeUp/x').delete()); @@ -502,13 +540,13 @@ describe('default-deny boundary', () => { test('non-admin CANNOT read an arbitrary collection', async () => { await seed('totallyMadeUp/x', { foo: 'bar' }); - const m = await db({ uid: 'u1', role: 'member' }); + const m = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(m.doc('totallyMadeUp/x').get()); }); test('user with role=member is NOT treated as admin', async () => { await seed('orders/o1', { status: 'paid' }); - const m = await db({ uid: 'u1', role: 'member' }); + const m = await db({ uid: 'u1', role: 'member', emailVerified: true }); await assertFails(m.doc('orders/o1').get()); });