From b2b10f68a5bd914f4b6e9eeea3cda20de928ca95 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 08:55:42 -0400 Subject: [PATCH 01/16] docs(specs): position-assignment lane + role-lifecycle follow-through Design for PR 4 (members-positions write lane keyed on update:Position) plus the three residuals PRs 1-3 deferred: well-formedness on the remaining soft-delete lanes, the three-way resolution extracted to packages/auth, and canCurateFeatured migrated off role names. Revised after two adversarial passes; the Review corrections section records the seven claims they falsified. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/position-assignment-lane.md | 565 +++++++++++++++++++++++++ 1 file changed, 565 insertions(+) create mode 100644 docs/specs/position-assignment-lane.md diff --git a/docs/specs/position-assignment-lane.md b/docs/specs/position-assignment-lane.md new file mode 100644 index 00000000..59d9f61f --- /dev/null +++ b/docs/specs/position-assignment-lane.md @@ -0,0 +1,565 @@ +# Position-assignment lane + role-lifecycle follow-through + +**Status:** design (revised after two adversarial passes — see Review corrections) +**Branch:** `feat/position-assignment-lane` +**Predecessors:** PR 1 (#216 role display), PR 2 (#219 built-in role set), PR 3 (#221 role lifecycle) + +The fourth and last PR of the role-management overhaul, plus three residuals its +predecessors documented and deferred. Four changes, one branch, because three of the four +edit `firestore.rules` and share one emulator suite. + +| # | Change | Surface | +|---|---|---| +| A | Members-positions write lane keyed on `update:Position` | rules, backstage | +| B | Well-formedness on the four remaining soft-delete lanes | rules, beacon showcase | +| C | The three-way built-in resolution extracted to `packages/auth` | packages/auth, beacon, backstage | +| D | `canCurateFeatured` migrated from a role name to a permission | rules, types, beacon seed, backstage | + +--- + +## A. The members-positions write lane + +### Problem + +PR 2 withdrew `manage:Position` from `ExecutiveCommittee` and deleted the dedicated +positions-only rule that went with it. Cargo assignment on `members/{id}.positions` became +Admin-only. `memberEditMode` (`apps/backstage/src/features/members/lib/member-edit-gate.ts:5`) +still declares a `"positions"` arm in its return union but its body +(`member-edit-gate.ts:15-17`) never returns it, so `member-profile-page.tsx:168-184` is +unreachable code, and its doc comment names this PR as the thing that brings it back. + +### What blocks a non-Admin today + +Not `positionsAssignmentSafe()`. The institutional update arm (`firestore.rules:302-312`) +leads with `canDo('update', 'Member')`, so a principal holding only `update:Position` fails +at the **first** conjunct and never reaches the positions gate at all. + +### Design + +A **new, fourth `allow update` arm** on `match /members/{memberId}`, rather than relaxing +the leading `canDo('update','Member')` on the existing one. Rules arms OR together, so a new +arm is purely additive and its scope is auditable in isolation. + +``` +// Positions-only lane: an org-chart editor who is NOT a member editor. Keyed on +// update:Position — the same capability that governs the positions CATALOG — and +// confined to the positions map. The power-cargo restriction is NOT relaxed: +// positionsAssignmentSafe()'s non-Admin branch still demands cargoGrantsEmpty() && +// currentCargoGrantsEmpty(), so this principal assigns and clears grant-free cargos +// only, on BOTH sides of a swap. +// +// The four conjuncts after positionsAssignmentSafe() are implied by hasOnly(['positions']) +// TODAY and are stated anyway: they are the claims-mint boundary (roleIds, +// permissionOverrides, uid) and the points ledger. If hasOnly is ever widened — the +// obvious future edit is adding a second key — three invariants must not vanish with it. +allow update: if canDo('update', 'Position') + && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['positions']) + && positionsAssignmentSafe() + && softDeleteSafe() + && updatePermissionAssignmentSafe() + && !touched('uid') + && !touched('publicProfile') + && unchanged('totalPoints'); +``` + +`positionsAssignmentSafe()` is reused verbatim — **not edited**. Its `hasAnyRole(['Admin'])` +disjunct keeps Admin unrestricted; its `cargoGrantsEmpty() && currentCargoGrantsEmpty()` +disjunct is exactly the grant-empty-only semantic this lane wants. `currentCargoGrantsEmpty()` +must stay inside the non-Admin branch: without it an `update:Position` holder could overwrite +a president's power cargo with a grant-free one and silently strip Admin. + +`hasOnly(['positions'])` is the correct constraint for the production dot-path payload +`{"positions.2026": {...}}` — `affectedKeys()` reports **top-level** keys, pinned by +`rules.test.ts:2262-2270` and `:1310`. + +**What `softDeleteSafe()` in this arm does and does not do.** It does not stop a write onto a +soft-deleted member: it blocks *resurrection*, and `unchanged('active')` is satisfied +whenever the write does not touch `active`, which `hasOnly` guarantees. So this lane can +assign a cargo to a soft-deleted member — the same as the existing institutional arm, so not +a regression. What it does do, once change B lands, is refuse a write onto a **malformed** +member doc. + +### Create is deliberately untouched + +`createPositionsSafe()` cannot call `currentCargoGrantsEmpty()` — a create has no prior +`resource`. There is no old side to protect on a create, and no reason to let an org-chart +editor mint members: creation stays `canDo('create','Member')`. + +### This lane is a PUBLIC publication authority — stated, and deliberately accepted + +`positions..cargoId` is the sole input to `boardShowcase`, the world-readable public +Directiva (`firestore.rules:706-709`, `allow read: if true`; projection at +`apps/beacon/src/showcase/project-board.ts:63-97`). `boardGroupFromCategory` +(`packages/types/src/engine/board-public.ts:39-41`) publishes both `CEL` and `JDL`. Every +**seeded CEL** cargo carries non-empty `grants`, so the non-Admin branch blocks those. **JDL +direcciones are hand-created in `/positions` and normally carry `grants: []`** — so an +`update:Position` holder can assign a grant-free board cargo to any member, **including +themselves**, and put that person on the public site under that title. Combined with the +self-service arm (which they own for their own `publicProfile` and `profilePicture`), that is +self-publication with no Admin action. + +This is **accepted as the feature**, not an oversight: assigning a JDL dirección *is* the +darkened surface PR 2 created, and appearing on the Directiva is the correct product outcome +of holding that cargo. It is not privilege escalation — no claim is minted, because +`resolveTrustedGrants` returns at `sync.ts:67` on `grants.length === 0`, before the +`assignedBy`-holds-Admin gate is even consulted. + +Two things follow, and both are in scope: + +1. **The catalog's `category` must stop being freely writable by a non-Admin.** Today the + positions update arm (`firestore.rules:358-361`) pins only `grants` for a non-Admin, so an + `update:Position` holder could retitle a grant-free Comisión to `title: "Presidente", + category: "CEL"` (→ `boardRank` 0) and assign it to themselves. `unchanged('category')` + joins `unchanged('grants')` in the non-Admin branch. Defensible on its own: `category` + decides board group, board rank, and whether `comisionGrantsEmpty()` applies. +2. **The exposure is asserted, not left incidental** — a rules test pins that an + `update:Position` holder can put a member on the board, so a future reader sees it was a + decision. + +Owner-op 1 states this in the same words, so nobody grants the capability without knowing +it publishes. + +### No built-in role gains the capability, and the hand-off must be a CUSTOM role + +`update:Position` is held by **no** entry in `BUILT_IN_ROLE_PERMS` — only Admin satisfies it, +via `manage:all`. This PR does not change that: every existing principal's authority stays +byte-identical, so no test asserting a current denial has to be weakened. + +**The hand-off must be a custom role doc, not an edit to the `ExecutiveCommittee` built-in.** +`planRolePermReseed` (`apps/beacon/src/recompute-claims.ts:205-238`) walks **every** built-in +role doc and rewrites `permissions` to the `BUILT_IN_ROLE_PERMS` snapshot whenever they +differ — so an `update:Position` added to the CEL built-in from `/permisos` is **silently +stripped by the next reseed**, which owner-op 2 of this very spec mandates. A custom role +(`builtIn: false`) is skipped by the reseed as `"not-built-in"`. Owner-op 1 says so +explicitly. + +### Client mirror + +```ts +export function memberEditMode(gate: Pick): MemberEditMode { + if (gate.can("update", "Member")) return "full"; + if (gate.can("update", "Position")) return "positions"; + return "none"; +} +``` + +Order matters and is asserted: a principal holding both gets `"full"`, so the two editors +never both render. + +The rest of the UI mirror is **already correct** — `member-positions-form.tsx:42-50` filters +options on `p.grants.length === 0` and locks the form when the *current* cargo grants power, +both keyed on grants-emptiness, not on Admin. `canAssignPowerGrants` (`use-can.ts:55`) stays +a role check, mirroring `hasAnyRole(['Admin'])` in the rule. + +Two stale comments must move with the code, or they read as guarantees: +`MemberRepository.setPositions` (`member-repository.ts:81-85`) says the write "goes through +the ordinary `update:Member` lane… not about a separate allow-rule" — false once this arm +exists; and `effectiveRoles` (`member-permissions.ts:3-4`) claims it "mirrors exactly what +the claims-sync trigger will mint", which omits the `assignedBy`-holds-Admin gate. The second +is pre-existing and only needs correcting, not re-implementing. + +### Nav parity fix + +`nav-config.ts:130` re-admits a perms-only principal to `/positions` via +`orCan: { action: "manage", subject: "Position" }`, but the catalog's own rules accept +`canDo('update','Position')`. `orCan` moves to `update` — `canDo` treats `manage:Position` as +satisfying `update:Position`, so this widens nothing the rules did not already allow +(guardrail #6). + +### Tests that assert today's denial — rewritten, never deleted + +| Test | Today | After | +|---|---|---| +| `member-edit-gate.test.ts:39` | `manage:Position` → `"none"` | → `"positions"`. CASL `manage` satisfies `can("update", …)` (`ability.ts:34-39`), exactly as `canDo` does in the rules | +| `member-edit-gate.test.ts:40` | `read:Position` → `"none"` | unchanged | +| `member-edit-gate.test.ts:43` | `{roles:["Member"], perms:["manage:Position"]}` → `"none"` | → `"positions"` | +| `member-edit-gate.test.ts:12` | CEL → `"none"` | unchanged — CEL holds no `update:Position` in the seed | +| `rules.test.ts:2164` | CEL denied any cargo | unchanged, plus a new `update:Position` principal that is allowed | +| `nav-config.test.ts:170` | admits `manage:Position` | passes, plus an `update:Position` case | + +New rules tests. The `describe` at `rules.test.ts:2163` ends with `:2281` "allows Admin to +assign a power-conferring cargo", which is last **on purpose** — the suite seeds once and +never resets. Insert before it, and mind the fixtures: at that point `members/m1` holds +`pos_soft` (grant-free, set at `:2173`, re-set at `:2262`), **not** a power cargo, so the +old-side test must target `members/m_powercargo` (`:505-512`), which the C1 describe at +`:2290` also uses as `assertFails` — safe to share. + +- allows an `update:Position`-only principal to assign a grant-free cargo, self-stamped +- **positive-and-inert**: after that assignment, the computed claims are unchanged — asserts + "cannot mint" rather than inferring it +- BLOCKING: denies that principal assigning a power-conferring cargo (new side) +- BLOCKING: denies that principal replacing `m_powercargo`'s cargo with a grant-free one (old side) +- BLOCKING: denies that principal touching any non-`positions` field in the same write +- denies a forged `assignedBy`; denies a non-current-term write; denies creating a member +- denies a whole-map replacement that drops the current term key, and a `deleteField()` on + `positions` — both currently deny via `assignedBySelf()`, an incidental mechanism that a + future refactor could remove unnoticed +- pins the accepted exposure: an `update:Position` holder can assign a grant-free JDL board cargo +- denies a non-Admin changing a position's `category` (the new catalog conjunct) + +Every new create-shaped test carries `active`/`deletedAt`, or change B makes it vacuous. + +### Residual: the term-rollover window + +`currentCargoGrantsEmpty()` reads `resource.data.positions[currentTermKey()].cargoId`. At the +UTC-year rollover a victim whose Admin comes from `positions["2026"]` has an empty +`positions["2027"]` slot, so the guard hits its `prior == null` short-circuit and an +`update:Position` holder can write a grant-free cargo into the new term — and +`syncMemberClaims`, which resolves from the same current-year key, recomputes +`roles: ['Member']`. **Pre-existing** — it falls to any `manage:Member` holder through the +institutional arm today, and A does not create it — but the guard is not unconditional, and +this spec says so rather than repeating "closes the strip-Admin hole" without qualification. +A test pins the shape. Fixing it properly means resolving liveness across terms and is its +own pass. + +--- + +## B. Well-formedness on the remaining soft-delete lanes + +### Problem + +`roleLifecycleSafe()` enforces that `active` is present and a bool and `deletedAt` is +present. The four `softDeleteSafe()` lanes — `members` ×2, `positions`, `allies` — enforce +neither. The hazard is concrete: a member doc carrying the **string** `"false"` in `active` +passes `softDeleteSafe()`, is dropped by `memberDocSchema` so it is invisible throughout +backstage, and is **published to the public Directiva**, because +`apps/beacon/src/showcase/project-board.ts:75` tests `member.active === false` — the +fail-open direction. `project-ally.ts:22` uses the fail-closed `active !== true`. + +### Design — three parts + +**B1. The check goes inside `softDeleteSafe()`, not onto the arms.** + +``` +function softDeleteSafe() { + let d = request.resource.data; + return ('active' in d) && (d.active is bool) && ('deletedAt' in d) + && (resource.data.deletedAt == null || unchanged('deletedAt')) + && (resource.data.active == true || unchanged('active')); +} +``` + +Inside the helper, because the members Admin-takedown arm (`firestore.rules:320-322`) +deliberately does **not** call it. That arm is the only rules-level path that can unpublish +exactly the malformed member this change is about; putting the requirement at arm level would +remove the remedy along with the disease. + +The one-way semantics below the new lines are untouched. Two in-file comments assert +"softDeleteSafe itself must not change" (`firestore.rules:371-375`, `rules.test.ts:2529-2533`); +both are updated to say what changed and what did not. + +**This is a real new denial, not a reclassification — corrected from the first draft.** The +first draft argued the missing-field case already denies, because `softDeleteSafe` reads +`resource.data.active` bare and an absent-key read errors. That is **false**: rules are CEL, +whose `||` absorbs errors (`error || true == true`), and the bare read sits on the left of a +`||` whose right side is `unchanged(field)` — which uses `.get(…, null)` and returns +`null == null → true` on an absent key. Measured against the emulator: a doc missing +`active`/`deletedAt` is **editable today** and **denied** after B1. The same wrong +generalization is written into `firestore.rules:396` and is corrected in this PR. + +So B1 moves legacy `members`/`positions`/`allies` docs missing either field, or holding a +non-bool `active`, from *client-editable* to *admin-SDK-only*. Owner-op 4 is therefore a +**blocking pre-deploy audit**, not a nice-to-know: the count must be known, and ideally zero, +before the rules ship. There is no rules-layer repair — the only fix is the console or the +admin SDK. + +**What is genuinely safe** is the merge half: on an update `request.resource.data` is the +**merged** document, so a repository writing neither field still satisfies the check when the +stored doc is well-formed. `RoleRepository.update` is the live proof under the identical rule +(`rules.test.ts:2482-2495`). + +**B2. The create arms are constrained to match.** `members` (`:287-293`), `positions` +(`:355-357`) and `allies` (`:547`) require nothing of the two fields, so they mint the exact +docs B1 then refuses to update. Each gains, mirroring the roles create arm (`:527-529`): + +``` +&& request.resource.data.get('active', false) == true +&& ('deletedAt' in request.resource.data) +&& request.resource.data.deletedAt == null +``` + +Every client creator already writes both. The test cost is larger than the first draft said, +and it is the interesting part of B2: + +- **Three `assertSucceeds` go red** and must carry the fields: `rules.test.ts:608`, `:708`, + `:717`. +- **Twelve `assertFails` become vacuous** — they would pass for the newly-added reason rather + than the one they are named for: members `:613, :625, :634, :671, :676, :681, :690, :699, + :740, :748, :756` and positions `:1799`. `:681` and `:699` are escalation guards, and + `:613-624` states in a comment that it *isolates* the `publicProfile` create guard — a claim + B2 falsifies unless the payload is repaired. All twelve carry `active`/`deletedAt` so they + keep failing for their own reason. This repo has already cleaned up 14 tautological tests + once; this is that class. +- One new test pins that the bare shape is now denied. + +**B3. `project-board.ts` flips to the fail-closed direction.** + +```ts +if (member.deletedAt != null || member.active !== true) return null; +``` + +B1 and B2 stop new malformed docs; **only B3 removes the existing public exposure**, so it +ships with them, not after. It also ends the split with `project-ally.ts`. + +### Not in scope + +The **coupling** half of `roleLifecycleSafe()` (`active == true ⟹ deletedAt == null`, plus +the `deletedAt == request.time` stamp) is not generalized. It would deny every subsequent +edit to an existing ghost doc (`active: true` + non-null `deletedAt`) — a shape that passes +all four zod schemas and therefore *is* listed and editable today — and it would turn three +`assertSucceeds` soft-delete tests red for using a client `new Date()`. Owed its own pass. + +--- + +## C. One three-way resolution, in `packages/auth` + +### Problem + +The absent / live / inactive resolution is implemented twice — `resolveMemberPerms` +(`apps/beacon/src/claims-sync/resolve-member-perms.ts:46-64`) and `previewEffectivePerms` +(`apps/backstage/src/features/permissions/lib/effective-preview.ts:44-66`). Both already +import `resolveEffectivePerms` from `@luminova/auth/perms`, so the shared half has a home and +creates no new dependency edge (`auth → types` is the only edge; `types` references +`@luminova/auth` nowhere). + +### Design + +New file `packages/auth/src/built-in-perms.ts`, new export subpath +`@luminova/auth/built-in-perms` — the package has no root `"."` export, so an unlisted file +is unresolvable to esbuild and Vite alike. Relative imports carry explicit `.js`; beacon +typechecks this package through `NodeNext`. + +```ts +export interface BuiltInRoleDoc { + readonly permissions: readonly PermissionCode[]; + readonly builtInKey: Role; + /** Precomputed liveness. NEVER the raw `active` field: a doc with `active: true` and a + * non-null `deletedAt` is a ghost — covered, contributing nothing. */ + readonly live: boolean; +} + +export function resolveBuiltInPerms(input: { + builtInRoleNames: readonly Role[]; + builtInDocs: readonly BuiltInRoleDoc[]; + customDocs: readonly Pick[]; + overrides?: { grant: PermissionCode[]; revoke: PermissionCode[] }; +}): PermissionCode[]; +``` + +Synchronous and pure over already-fetched docs. It must not sort or mutate its inputs — +beacon's graph is deep-frozen. The seed fallback moves **inside** it (that is what +`builtInRoleNames` is for), so `packages/auth` gains an import of `BUILT_IN_ROLE_PERMS` from +`@luminova/types/role-definition`. + +**What does NOT move.** `isActiveRoleDoc` stays in beacon: it imports `DocumentData` from +`firebase-admin/firestore`, and `packages/auth` is consumed by the browser bundle and the +rules test suite. Liveness *derivation* stays per-side; only *consumption* is shared. +`PERMISSION_CAP` stays out — beacon fail-closes to `perms: []`, backstage blocks Save. + +**One divergence is settled, in the tighter direction.** Beacon unions every live doc it is +handed without checking its `builtInKey` is one the member holds; backstage only collects +docs for a requested name. In production the beacon query is `where("builtInKey","in",keys)` +so they cannot differ — but the *functions* do, and the shared one picks backstage's: docs +whose key is not in `builtInRoleNames` are ignored. Tested directly, since no production path +produces it. No existing beacon test changes — all five docs in +`resolve-member-perms.test.ts:24-72` carry a key that is in `builtInRoleNames`. + +**One divergence survives, documented not fixed.** A doc the zod schema rejects reads ABSENT +to backstage (`parseDocs` dropped it) and COVERED to beacon (which sanitizes per-element). +That is a difference between the two **ports**, not the two resolutions, so extracting the +resolution cannot close it. + +### Callers and the adapters + +`resolveMemberPerms` keeps its `deps` fetch orchestration and delegates; its +`LiveBuiltInRoleDoc` satisfies `BuiltInRoleDoc` with no cast. `previewEffectivePerms` is the +harder side — its `builtInDocs` is today a union of `{permissions}` (the snapshot fallback, +with no `builtInKey` and no `live`) and `RoleDefinition` (whose `builtInKey` is `Role | null`). +Its adapter maps to `{permissions: r.permissions, builtInKey: name, live: isLiveRole(r)}`, +castless because `name` is already `Role`, and keeps its `r.builtIn && r.builtInKey === name` +filter. + +Both existing suites stay green unchanged — that is the refactor's acceptance criterion — +plus a new unit suite in `packages/auth`, which has none for this today. + +**Cold-worktree caveat.** `turbo.json`'s `test` task has no `dependsOn: ["^build"]`, and +`packages/auth`'s `exports` map the `import` condition to `./dist/*.js`, so a bare +`pnpm test` on a fresh worktree cannot resolve the new subpath. Build the packages first, or +run `pnpm --filter run ci` / `pnpm pr-tests`, which go through `turbo run ci`. + +--- + +## D. `canCurateFeatured` — role name to permission + +### Problem + +`canCurateFeatured()` (`firestore.rules:176-178`) is `hasAnyRole(['Admin','ProjectManager'])`. +`computeMemberRoles` is pure over `{trustedGrants, hadScanner}` and reads no role doc, so a +**deactivated** `ProjectManager` keeps the name in its claim and keeps the authority. It gates +the only client-writable input to public-site content that is not a cargo assignment: +`featured` on `projects` and `programs`, which beacon projects to `showcase/{id}` and +spotlight renders on `/impacto` and the home band. + +### The gate + +``` +function canCurateFeatured() { + return hasAnyRole(['Admin']) || hasPerm('update:Showcase'); +} +``` + +**`hasPerm`, not `canDo` — corrected from the first draft.** `canDo` would let `manage:all` +satisfy the gate, and `manage:all` is reachable as a *perm* without the Admin role: an +Admin-written custom role doc or a `permissionOverrides.grant` can carry it, since +`roleShapeValid()` only requires `permissions is list`. Such a principal already satisfies +`canDo('update','Project')` at `firestore.rules:208` — the **only** thing stopping them from +setting `featured` today is this role gate, which the file documents as deliberate at +`:200-202` ("Intentionally role-based, NOT perm-based"). Migrating to `canDo` would silently +delete that boundary; `nav-equivalence.test.ts:154-163` exists to assert a perm never unlocks +a role gate, and does not cover this route (`/initiatives` is `kind: "curationOnly"` and the +implication loop skips it — so a green suite is not coverage here). + +`Admin` stays role-keyed. That is consistent with the rest of the file: `Admin` is `locked` +and undeactivatable, so name-keyed authority for it carries none of the staleness this change +exists to fix, and the alternative — adding `update:Showcase` to a role whose entire seeded +permission set is `["manage:all"]` — would misrepresent how Admin works. Only +`ProjectManager` moves, which is the whole point: deactivating that role now revokes +curation. + +### Vocabulary — a new subject + +Codes are a generated cross-product: 6 actions × 13 subjects = 78. No existing code fits. +`update:Project` / `manage:Project` already satisfy `canDo('update', subject)` at +`firestore.rules:208`, precisely the disjunct `featuredUpdateSafe()` layers on top of to +exclude — reusing one makes the gate a tautology and deletes the boundary `rules.test.ts:1968` +pins. + +Add the subject **`"Showcase"`**: 78 → 84. A new *action* would cost 78 → 91 and put a column +across all subjects into the `/permisos` matrix. The subject is a slight misnomer — what is +gated is `featured` on `projects`/`programs`, not the beacon-owned `showcase` collection — and +the doc comment says so. + +The other five `*:Showcase` codes gate nothing. That is the pre-existing condition of this +vocabulary, not a new defect: the matrix renders the full actions × subjects grid, so +`checkIn:Member` and dozens like it are already assignable and inert. Notably `manage:Showcase` +is inert **because** the gate uses exact `hasPerm` — there is no second, undocumented path to +curation. A test pins that. + +Blast radius: `SUBJECT_LABELS` (`permission-matrix.ts:25-40`) is +`Record, string>` and fails to compile until the label is added. +`ACTION_LABELS` is untouched. `MATRIX_SUBJECTS` and `ASSIGNABLE_CODES` are derived, so the +code appears in `/permisos` automatically. Cap headroom is ample: largest built-in holds 9, +the union of all nine holds 20, cap is 30. + +### Seed + +`ProjectManager` gains `update:Showcase` in `BUILT_IN_ROLE_PERMS` +(`packages/types/src/role-definition.ts:45-51`) **and** in the hand-mirror +`tools/scripts/lib/role-seed.mjs:28-34` — `role-definition.mirror.test.ts:16-18` fails +otherwise. `Admin` needs no edit. No other role can curate today, so no other role gains it. + +### The deploy landmine + +`BUILT_IN_ROLE_PERMS` is a seed **snapshot**, not the live source: once a role doc exists its +stored `permissions` win, and `seedBuiltInRoles` is create-only. Editing the constant mints +nothing in production. If the rules ship first, every current `ProjectManager` loses curation. + +**Order: deploy beacon → run `reseedBuiltInRolePerms` → verify the CLAIM → deploy rules → +deploy hosting.** The new perm is inert until the rules read it, so granting it early is safe +and the reverse is not. + +`reseedBuiltInRolePerms` is update-only and skips `missing`, `locked`, `not-built-in` and +**`inactive`** docs (`recompute-claims.ts:212-228`). A *missing* `roles/ProjectManager` is +harmless — the `BUILT_IN_ROLE_PERMS` fallback already carries the code. A *locked* or +*inactive* one silently drops PM curation, which is why the verification step is the claim and +not the doc. + +### Residual introduced by D + +Curation now sits behind `PERMISSION_CAP`'s fail-closed path: a member over 30 effective perms +gets `perms: []` written while keeping `roles: ['ProjectManager']` (`sync.ts:111-122`). Today +they still curate via the role gate; after D they cannot. One more surface behind the cap. + +### Client mirror + +`canFeatureInitiatives` (`use-can.ts:54`) moves from `hasAnyRole(claims, ["Admin","ProjectManager"])` +to `isAdmin || `, mirroring the rule's two disjuncts +exactly. Its two consumers (`initiatives-page.tsx:32`, `initiative-detail-page.tsx:60`) are +unchanged. `isAdmin` and `canAssignPowerGrants` in the same file stay role checks — they guard +the claims-mint trust anchor. + +### Tests + +**Sixteen** `rules.test.ts` featured tests, not eight: `:1320, 1323, 1328, 1333, 1338, 1343, +1348, 1353` and `:1959, 1968, 1977, 1986, 1995, 2003, 2015, 2024`. They keep their names and +intent; the `as(...)` principals gain the code where they are meant to pass. New: + +- BLOCKING: a `ProjectManager` whose role doc is DEACTIVATED cannot set `featured` — the claim + keeps the name, the perms do not carry the code. The whole point of D. +- BLOCKING: `manage:all` as a **perm**, with no Admin role, cannot set `featured` — pins the + `hasPerm`-not-`canDo` decision and preserves the `:200-202` boundary +- BLOCKING: `manage:Showcase` alone cannot set `featured` — pins that the inert codes are inert +- a custom role holding `update:Showcase` and nothing else can curate + +`use-can.test.ts:41-45` needs a **fixture rewrite**, not a relabel: its cases pass +`{ roles: ["ProjectManager"] }` with no `perms`, and `buildAbility` reads `claims.perms ?? []`, +so all three assertions go false until the fixtures carry `perms: ["update:Showcase"]`. +`initiative-form.tsx`'s `canFeature &&` render branch has no test today — one is added. + +--- + +## Owner-ops + +1. **Hand cargo assignment to an org-chart editor (optional, data-only).** In `/permisos`, + create a **custom** role carrying `update:Position` and assign it. **Do not add the perm to + the `ExecutiveCommittee` built-in** — `reseedBuiltInRolePerms` rewrites built-in docs back + to the seed snapshot and would strip it without warning. Understand what it confers: the + holder may assign and clear **grant-free** cargos and comisiones (never a power cargo, on + either side of a swap), may edit the positions catalog except `grants` and `category`, and + — because grant-free JDL direcciones are board cargos — **may publish a member, including + themselves, to the public Directiva**. +2. **Before deploying D's rules:** run `reseedBuiltInRolePerms`, then run `recomputeAllClaims`, + then confirm a live `ProjectManager`'s **ID-token claim** carries `update:Showcase`. The + reseed's `onRoleWritten` fan-out is unbounded and `retry: false`, so it can strand members; + checking the role doc is not sufficient. +3. **Outstanding from PR 2, still open:** in `/positions`, ADD `Secretary` to the Secretario + cargo's grants, THEN remove `Admin`. In that order, or ally management goes dark. +4. **BLOCKING pre-deploy audit for B.** Count `members`, `positions` and `allies` docs whose + `active` is missing or not a bool, or which lack `deletedAt`. Each becomes admin-SDK-only + to edit the moment B1 ships, with no UI affordance to repair it — and unlike the first + draft's claim, they are editable **today**. Repair them from the console before the rules + deploy, or accept a known, counted set. + +## Deploy order + +**beacon → reseed + recomputeAllClaims → verify claim → rules → hosting.** B3 and D's seed +live in beacon; D's rules depend on the reseed; A and B are rules-only; the mirrors are +hosting. + +## Review corrections + +Recorded because each falsified something this document previously asserted: + +1. **CEL `||` absorbs errors**, so B1's "denies nothing legitimate" was wrong — the `in` checks + are a genuine new denial on legacy docs. Owner-op 4 upgraded to blocking. + `firestore.rules:396` carries the same wrong generalization and is corrected here. +2. **`planRolePermReseed` strips a `/permisos` grant on a built-in role**, so owner-op 1 must + use a custom role — the two owner-ops previously contradicted each other. +3. **`canDo('update','Showcase')` would hand curation to a `manage:all`-perm principal**, the + exact boundary `firestore.rules:200-202` declares deliberate. Changed to `hasPerm`. +4. **The new lane is a public publication authority** via grant-free JDL board cargos. + Accepted deliberately, stated in owner-op 1, pinned by a test, and narrowed by + `unchanged('category')` on the catalog arm. +5. **B2's test cost is 3 reds + 12 vacuous**, not one red. +6. **D's test cost is 16 rules tests**, not eight, plus a `use-can` fixture rewrite. +7. **`manage:Position` opens the positions editor too** (CASL `manage` satisfies `update`), so + two `member-edit-gate` assertions invert rather than one. + +## Residuals + +- The coupling half of `roleLifecycleSafe()` on the four lanes (B, Not in scope). +- The term-rollover window in `currentCargoGrantsEmpty()` (A, Residual). +- The port-level divergence in C: a zod-rejected doc reads ABSENT to backstage, COVERED to beacon. +- `siteConfig` write is still `hasAnyRole(['Admin'])` — same class as D, smaller blast radius. +- Refresh-token revocation: a revoked perm survives in a long-lived tab until reload. +- `onRoleWritten`'s unbounded, no-retry fan-out. From 1718b71b7dfd947fa54658ebe50d3e1add9bfda0 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 09:08:02 -0400 Subject: [PATCH 02/16] refactor(auth): one three-way built-in perms resolution, shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The absent/live/inactive resolution was implemented twice — once in beacon's claims-sync and once in backstage's preview mirror — so the subtle half was duplicated across the trust boundary. Extracted to resolveBuiltInPerms in @luminova/auth/built-in-perms; both sides now delegate. Synchronous and pure over already-fetched docs: it never sorts or mutates its inputs, because beacon's graph is deep-frozen. Liveness DERIVATION stays per-side (isActiveRoleDoc imports firebase-admin and packages/auth is also a browser and rules-test dependency); only its consumption is shared. PERMISSION_CAP stays out — beacon fail-closes to perms:[], backstage blocks Save. Settles the one divergence in the tighter direction: a doc whose builtInKey is not among the requested names is now ignored on both sides. Unreachable in production (the beacon query filters by key), so it gets a direct unit test. Both existing suites pass unchanged — the acceptance criterion for the refactor. Co-Authored-By: Claude Opus 5 (1M context) --- .../permissions/lib/effective-preview.ts | 46 ++-- .../src/claims-sync/resolve-member-perms.ts | 29 ++- packages/auth/package.json | 5 + packages/auth/src/built-in-perms.test.ts | 200 ++++++++++++++++++ packages/auth/src/built-in-perms.ts | 67 ++++++ 5 files changed, 305 insertions(+), 42 deletions(-) create mode 100644 packages/auth/src/built-in-perms.test.ts create mode 100644 packages/auth/src/built-in-perms.ts diff --git a/apps/backstage/src/features/permissions/lib/effective-preview.ts b/apps/backstage/src/features/permissions/lib/effective-preview.ts index 4237d49b..e61d6e2d 100644 --- a/apps/backstage/src/features/permissions/lib/effective-preview.ts +++ b/apps/backstage/src/features/permissions/lib/effective-preview.ts @@ -1,26 +1,19 @@ -import { resolveEffectivePerms } from "@luminova/auth/perms"; -import { - BUILT_IN_ROLE_PERMS, - type PermissionCode, - type Role, - type RoleDefinition, -} from "@luminova/types"; +import { resolveBuiltInPerms } from "@luminova/auth/built-in-perms"; +import type { PermissionCode, Role, RoleDefinition } from "@luminova/types"; import { assignableRoles, isLiveRole } from "../../../lib/role-lifecycle"; /** Client-side mirror of the beacon resolution for the member-assignment preview: * effective perms = built-in roles (held via positions) ∪ selected custom roles ∪ * override grants − revokes. * - * Three-way per built-in key, mirroring resolveMemberPerms: - * - NO doc claims the key → the BUILT_IN_ROLE_PERMS snapshot (pre-seed window) - * - doc(s) claim it, live → the UNION of their permissions - * - doc(s) claim it, none live → nothing, and the key stays COVERED (so the - * snapshot must NOT come back) + * The three-way itself is NOT reimplemented here — it is `resolveBuiltInPerms` + * (`@luminova/auth/built-in-perms`), the same function beacon's `resolveMemberPerms` + * delegates to, so preview and mint cannot drift. This file owns only the PORT: which + * docs claim a key, and whether each is live. `PERMISSION_CAP` is likewise not applied + * here — the role editor blocks Save on it; beacon fail-closes instead. * - * Grouped per key, not mapped: two docs may claim one builtInKey, and beacon computes - * coverage over every doc it read and unions the live ones. A Map would keep only the - * last, making this preview disagree with the perms that get minted — and disagree - * differently depending on the sort order it happened to receive. + * Every doc claiming the key is passed through, live or not: coverage is what suppresses + * the snapshot, liveness is what contributes perms. * * NOT parity, and it cannot be: this reads `RoleDefinition[]` already through `parseDocs` + * `roleDefinitionDocSchema`, so a doc the zod schema rejects is dropped before it arrives @@ -48,19 +41,20 @@ export function previewEffectivePerms(input: { overrides: { grant: PermissionCode[]; revoke: PermissionCode[] }; }): PermissionCode[] { const byId = new Map(assignableRoles(input.allRoles).map((r) => [r.id, r])); - const builtInDocs = input.builtInRoleNames.flatMap((name) => { - // Every doc claiming the key, live or not: coverage is what suppresses the snapshot, - // liveness is what contributes perms. One flatMap so `builtInKey === name` narrows - // the Role type without a cast. - const claiming = input.allRoles.filter((r) => r.builtIn && r.builtInKey === name); - if (claiming.length === 0) return [{ permissions: BUILT_IN_ROLE_PERMS[name] }]; - return claiming.filter(isLiveRole); - }); + // One flatMap over the requested names so `builtInKey` comes from `name`, already a + // `Role`, and the adapter stays castless — `r.builtInKey` is `Role | null`. + const builtInDocs = input.builtInRoleNames.flatMap((name) => + input.allRoles + .filter((r) => r.builtIn && r.builtInKey === name) + .map((r) => ({ permissions: r.permissions, builtInKey: name, live: isLiveRole(r) })), + ); const customDocs = input.selectedCustomRoleIds .map((id) => byId.get(id)) .filter((r): r is RoleDefinition => r !== undefined); - return resolveEffectivePerms({ - roleDocs: [...builtInDocs, ...customDocs], + return resolveBuiltInPerms({ + builtInRoleNames: input.builtInRoleNames, + builtInDocs, + customDocs, overrides: input.overrides, }); } diff --git a/apps/beacon/src/claims-sync/resolve-member-perms.ts b/apps/beacon/src/claims-sync/resolve-member-perms.ts index 1efdd106..28d2de2e 100644 --- a/apps/beacon/src/claims-sync/resolve-member-perms.ts +++ b/apps/beacon/src/claims-sync/resolve-member-perms.ts @@ -1,9 +1,11 @@ import type { Role } from "@luminova/auth/roles"; -import { resolveEffectivePerms } from "@luminova/auth/perms"; -import { BUILT_IN_ROLE_PERMS } from "@luminova/types/role-definition"; +import { resolveBuiltInPerms } from "@luminova/auth/built-in-perms"; import type { PermissionCode, RoleDefinition } from "@luminova/types"; -/** A built-in role doc as this resolver consumes it. +/** A built-in role doc as this resolver's PORT produces it. Structurally the shared + * `BuiltInRoleDoc` from `@luminova/auth/built-in-perms` — restated here rather than + * aliased so `firestore-deps.ts` keeps a beacon-local name to build against, and checked + * by the compiler where `resolveBuiltInPerms` consumes it below, with no cast. * * `live` is deliberately NOT `RoleDefinition["active"]`, and this type is deliberately * not a `Pick` of the doc shape: liveness is the TWO-field predicate `isActiveRoleDoc` @@ -35,11 +37,13 @@ export interface RolePermsDeps { * the built-in roles they hold (via positions), their directly-assigned custom * roles, and their per-member overrides. * - * Three-way per built-in key: - * - doc ABSENT → BUILT_IN_ROLE_PERMS[key] (the pre-seed window must - * still mint perms on a fresh project) - * - doc present, live → the doc's stored `permissions` - * - doc present, not live → nothing, and the key stays COVERED + * This function owns the FETCH orchestration only; the absent/live/inactive three-way + * — including the `BUILT_IN_ROLE_PERMS` fallback — lives in `resolveBuiltInPerms` + * (`@luminova/auth/built-in-perms`), shared with the backstage assignment preview so the + * admin authorizes from the same resolution beacon then mints. `PERMISSION_CAP` stays out + * of the shared half: this side fail-closes to `perms: []` in `sync.ts`, backstage blocks + * Save. Liveness DERIVATION also stays here (`isActiveRoleDoc` reads `DocumentData`), + * only its consumption is shared. * * Two production callers inherit this: claims-sync/sync.ts (the onMemberWritten / * onRoleWritten trigger) and set-user-roles.ts (the setUserRoles admin callable). */ @@ -52,13 +56,6 @@ export async function resolveMemberPerms( const builtInDocs = builtInRoleNames.length ? await deps.getRoleDocsByBuiltInKeys(builtInRoleNames) : []; - const covered = new Set(builtInDocs.map((doc) => doc.builtInKey)); - const fallback = builtInRoleNames - .filter((role) => !covered.has(role)) - .map((role) => ({ permissions: BUILT_IN_ROLE_PERMS[role] })); const customDocs = roleIds.length ? await deps.getRolesByIds(roleIds) : []; - return resolveEffectivePerms({ - roleDocs: [...builtInDocs.filter((doc) => doc.live), ...fallback, ...customDocs], - overrides, - }); + return resolveBuiltInPerms({ builtInRoleNames, builtInDocs, customDocs, overrides }); } diff --git a/packages/auth/package.json b/packages/auth/package.json index 47471041..f58ae89e 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -19,6 +19,11 @@ "import": "./dist/perms.js", "default": "./dist/perms.js" }, + "./built-in-perms": { + "types": "./src/built-in-perms.ts", + "import": "./dist/built-in-perms.js", + "default": "./dist/built-in-perms.js" + }, "./test-helpers": { "types": "./src/test-helpers.ts", "import": "./dist/test-helpers.js", diff --git a/packages/auth/src/built-in-perms.test.ts b/packages/auth/src/built-in-perms.test.ts new file mode 100644 index 00000000..e0821527 --- /dev/null +++ b/packages/auth/src/built-in-perms.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; +import { BUILT_IN_ROLE_PERMS } from "@luminova/types/role-definition"; +import type { PermissionCode } from "@luminova/types"; +import { resolveBuiltInPerms, type BuiltInRoleDoc } from "./built-in-perms.js"; + +const NO_OVERRIDES = { grant: [], revoke: [] } as const; + +/** Same shape beacon's `firestore-deps` hands in: array, docs and each `permissions` + * array all frozen, because one memoized graph is reused for the whole fan-out. */ +function frozenDocs(docs: BuiltInRoleDoc[]): readonly BuiltInRoleDoc[] { + return Object.freeze( + docs.map((doc) => Object.freeze({ ...doc, permissions: Object.freeze(doc.permissions) })), + ); +} + +describe("resolveBuiltInPerms", () => { + it("falls back to the seed snapshot when NO doc claims the key", () => { + const out = resolveBuiltInPerms({ + builtInRoleNames: ["Treasury"], + builtInDocs: [], + customDocs: [], + overrides: NO_OVERRIDES, + }); + expect(out).toEqual([...BUILT_IN_ROLE_PERMS.Treasury].sort()); + }); + + it("prefers a live doc's stored permissions over the seed snapshot", () => { + const out = resolveBuiltInPerms({ + builtInRoleNames: ["Treasury"], + builtInDocs: [{ permissions: ["read:Member"], builtInKey: "Treasury", live: true }], + customDocs: [], + overrides: NO_OVERRIDES, + }); + expect(out).toEqual(["read:Member"]); + }); + + it("BLOCKING: a not-live doc contributes nothing AND suppresses the seed fallback", () => { + // The distinction the whole three-way exists for: "deactivated" must not be + // indistinguishable from "never seeded". Perms deliberately non-empty, so an + // implementation that ignores `live` fails loudly instead of returning [] by accident. + const out = resolveBuiltInPerms({ + builtInRoleNames: ["Treasury"], + builtInDocs: [{ permissions: ["manage:all"], builtInKey: "Treasury", live: false }], + customDocs: [], + overrides: NO_OVERRIDES, + }); + expect(out).toEqual([]); + }); + + it("BLOCKING: a ghost doc is COVERED and contributes nothing", () => { + // `live` is precomputed by the caller over BOTH `active` and `deletedAt`, so the ghost + // shape (`active: true` with a non-null `deletedAt`) arrives here as `live: false`. It + // must behave exactly like a deactivated doc — covered, minting nothing — and not like + // an absent one. Second key present so the assertion cannot pass by returning [] wholesale. + const out = resolveBuiltInPerms({ + builtInRoleNames: ["Treasury", "Secretary"], + builtInDocs: [{ permissions: ["manage:all"], builtInKey: "Treasury", live: false }], + customDocs: [], + overrides: NO_OVERRIDES, + }); + expect(out).toEqual([...BUILT_IN_ROLE_PERMS.Secretary].sort()); + expect(out).not.toContain("manage:all"); + }); + + it.each([ + ["live doc first", false], + ["not-live doc first", true], + ])("unions the LIVE docs when two docs claim one key (%s)", (_name, deadFirst) => { + const live: BuiltInRoleDoc = { + permissions: ["read:Member"], + builtInKey: "Treasury", + live: true, + }; + const alsoLive: BuiltInRoleDoc = { + permissions: ["read:Position"], + builtInKey: "Treasury", + live: true, + }; + const dead: BuiltInRoleDoc = { + permissions: ["manage:all"], + builtInKey: "Treasury", + live: false, + }; + const docs = deadFirst ? [dead, live, alsoLive] : [live, alsoLive, dead]; + expect( + resolveBuiltInPerms({ + builtInRoleNames: ["Treasury"], + builtInDocs: docs, + customDocs: [], + overrides: NO_OVERRIDES, + }), + ).toEqual(["read:Member", "read:Position"]); + }); + + it("BLOCKING: two docs claiming one key, both not live, still suppress the fallback", () => { + // A per-key `find`/Map that kept only one doc would get this right by luck; a + // `some(live)` coverage test would get it wrong and re-mint the snapshot. + const out = resolveBuiltInPerms({ + builtInRoleNames: ["Treasury"], + builtInDocs: [ + { permissions: ["manage:all"], builtInKey: "Treasury", live: false }, + { permissions: ["read:Member"], builtInKey: "Treasury", live: false }, + ], + customDocs: [], + overrides: NO_OVERRIDES, + }); + expect(out).toEqual([]); + }); + + it("BLOCKING: a doc for an unrequested key is IGNORED — no perms, no coverage", () => { + // Settles the one divergence between the two former implementations, in the tighter + // direction: beacon unioned every doc it was handed without checking the key. No + // production path produces such a doc (the query is where('builtInKey','in',keys)), + // so this direct test is the only thing pinning the choice. + const out = resolveBuiltInPerms({ + builtInRoleNames: ["Treasury"], + builtInDocs: [ + { permissions: ["manage:all"], builtInKey: "Membership", live: true }, + { permissions: ["manage:Ally"], builtInKey: "Secretary", live: false }, + ], + customDocs: [], + overrides: NO_OVERRIDES, + }); + expect(out).toEqual([...BUILT_IN_ROLE_PERMS.Treasury].sort()); + }); + + it("unions custom role docs with the built-in resolution", () => { + const out = resolveBuiltInPerms({ + builtInRoleNames: ["Treasury"], + builtInDocs: [{ permissions: ["read:Member"], builtInKey: "Treasury", live: true }], + customDocs: [{ permissions: ["manage:Ally"] }], + overrides: NO_OVERRIDES, + }); + expect(out).toEqual(["manage:Ally", "read:Member"]); + }); + + it("applies overrides on top: grant adds, revoke wins", () => { + const out = resolveBuiltInPerms({ + builtInRoleNames: ["Treasury"], + builtInDocs: [], + customDocs: [], + overrides: { grant: ["manage:Position", "read:Member"], revoke: ["read:Member"] }, + }); + expect(out).toEqual(["manage:Position", "read:MemberPoints"]); + }); + + it("resolves with no overrides argument at all", () => { + expect( + resolveBuiltInPerms({ + builtInRoleNames: [], + builtInDocs: [], + customDocs: [{ permissions: ["manage:Ally"] }], + }), + ).toEqual(["manage:Ally"]); + }); + + it("BLOCKING: does not mutate a deep-frozen input graph", () => { + // Beacon memoizes ONE frozen graph and hands it to every member of an unbounded + // fan-out. An in-place `.sort()` here would corrupt every remaining member's claims — + // frozen, it throws in strict mode instead, which is what this test observes. + const names = Object.freeze<["Membership", "Treasury", "Secretary"]>([ + "Membership", + "Treasury", + "Secretary", + ]); + const docs = frozenDocs([ + { + permissions: ["read:Position", "checkIn:Attendance"], + builtInKey: "Membership", + live: true, + }, + { permissions: ["manage:all"], builtInKey: "Treasury", live: false }, + ]); + const customPerms: PermissionCode[] = ["manage:Ally"]; + Object.freeze(customPerms); + const customDocs = Object.freeze([Object.freeze({ permissions: customPerms })]); + + const out = resolveBuiltInPerms({ + builtInRoleNames: names, + builtInDocs: docs, + customDocs, + overrides: NO_OVERRIDES, + }); + + expect(out).toEqual( + [ + ...new Set([ + "read:Position", + "checkIn:Attendance", + "manage:Ally", + ...BUILT_IN_ROLE_PERMS.Secretary, + ]), + ].sort(), + ); + expect(names).toEqual(["Membership", "Treasury", "Secretary"]); + expect(docs[0]?.permissions).toEqual(["read:Position", "checkIn:Attendance"]); + expect(docs[1]?.permissions).toEqual(["manage:all"]); + expect(customDocs[0]?.permissions).toEqual(["manage:Ally"]); + }); +}); diff --git a/packages/auth/src/built-in-perms.ts b/packages/auth/src/built-in-perms.ts new file mode 100644 index 00000000..3350affa --- /dev/null +++ b/packages/auth/src/built-in-perms.ts @@ -0,0 +1,67 @@ +import { BUILT_IN_ROLE_PERMS } from "@luminova/types/role-definition"; +import type { PermissionCode, Role, RoleDefinition } from "@luminova/types"; +import { resolveEffectivePerms } from "./perms.js"; + +/** A built-in role doc as the shared resolution consumes it. + * + * Deliberately NOT a `Pick` of the stored doc shape. Liveness is the TWO-field predicate + * over `active` AND `deletedAt`, so a port field named `active` would read as "the doc's + * `active` field" and an implementer returning `d.get("active")` would satisfy the type + * while readmitting the ghost shape (`active: true` with a non-null `deletedAt`) that + * mints the doc's real perms. Naming the semantic keeps the contract unspoofable by a + * plain field read. + * + * Liveness *derivation* stays per-side (beacon's `isActiveRoleDoc` reads `DocumentData` + * from `firebase-admin/firestore`, which this package — consumed by the browser bundle + * and the rules test suite — must not import). Only *consumption* is shared. */ +export interface BuiltInRoleDoc { + readonly permissions: readonly PermissionCode[]; + readonly builtInKey: Role; + /** Precomputed liveness. NEVER the raw `active` field: a doc with `active: true` and a + * non-null `deletedAt` is a ghost — covered, contributing nothing. */ + readonly live: boolean; +} + +/** The one three-way built-in resolution, shared by beacon's claims-sync + * (`resolveMemberPerms`) and the backstage member-assignment preview + * (`previewEffectivePerms`). Synchronous and pure over already-fetched docs — it does not + * sort or mutate its inputs, because beacon hands it a deep-frozen graph. + * + * Three-way per built-in key: + * - NO doc claims the key → BUILT_IN_ROLE_PERMS[key] (the pre-seed window must still + * mint perms on a fresh project) + * - doc(s) claim it, live → the UNION of their stored `permissions` + * - doc(s) claim it, none live → nothing, and the key stays COVERED (so the snapshot + * must NOT come back). That distinction is the entire + * reason not-live docs have to be passed in: drop them at + * the port and a deactivation silently restores the seed. + * + * Grouped per key, not mapped: two docs may claim one `builtInKey`. A Map would keep only + * the last, making the answer depend on the order the docs happened to arrive in. + * + * A doc whose `builtInKey` is not in `builtInRoleNames` is IGNORED — it neither + * contributes perms nor covers a key. In production beacon queries + * `where("builtInKey","in",keys)` so it cannot be handed one, but the function is total + * and picks the tighter reading rather than leaving it to the caller's query. + * + * `PERMISSION_CAP` is deliberately NOT applied: the callers disagree on the response + * (beacon fail-closes to `perms: []`, backstage blocks Save), so the cap stays theirs. */ +export function resolveBuiltInPerms(input: { + builtInRoleNames: readonly Role[]; + builtInDocs: readonly BuiltInRoleDoc[]; + customDocs: readonly Pick[]; + overrides?: { grant: PermissionCode[]; revoke: PermissionCode[] }; +}): PermissionCode[] { + const requested = new Set(input.builtInRoleNames); + const claiming = input.builtInDocs.filter((doc) => requested.has(doc.builtInKey)); + const covered = new Set(claiming.map((doc) => doc.builtInKey)); + const roleDocs: Pick[] = []; + for (const doc of claiming) { + if (doc.live) roleDocs.push({ permissions: [...doc.permissions] }); + } + for (const name of requested) { + if (!covered.has(name)) roleDocs.push({ permissions: [...BUILT_IN_ROLE_PERMS[name]] }); + } + for (const doc of input.customDocs) roleDocs.push({ permissions: [...doc.permissions] }); + return resolveEffectivePerms({ roleDocs, overrides: input.overrides }); +} From 84fc366c3c49cce1be7e6413815715e29c0edc67 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 09:08:17 -0400 Subject: [PATCH 03/16] feat(rules,backstage): members-positions write lane keyed on update:Position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 2 withdrew manage:Position from ExecutiveCommittee and deleted the positions-only rule with it, leaving cargo assignment Admin-only and memberEditMode's "positions" arm unreachable. This restores the lane. A new fourth allow-update arm on members, rather than relaxing the institutional arm's leading canDo('update','Member') — arms OR together, so a new one is additive and auditable in isolation. positionsAssignmentSafe() is reused verbatim: its non-Admin branch still demands cargoGrantsEmpty() AND currentCargoGrantsEmpty(), so this principal assigns grant-free cargos only, on both sides of a swap. The four conjuncts after it are implied by hasOnly(['positions']) today and stated anyway — they are the claims-mint boundary, and a future widening of hasOnly must not drop them silently. The lane IS a public publication authority: grant-free JDL direcciones are board cargos, so its holder can put a member — including themselves — on the world-readable Directiva. Accepted deliberately and pinned by a test. Narrowed by adding unchanged('category') beside unchanged('grants') on the catalog arm, without which the holder could retitle a grant-free Comisión to CEL/Presidente (board rank 0) and self-assign it. No built-in role gains update:Position, so no existing principal's authority changes. The hand-off is a custom role in /permisos — NOT an edit to the CEL built-in, which reseedBuiltInRolePerms would silently strip. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/nav-config.test.ts | 11 ++ apps/backstage/src/components/nav-config.ts | 9 +- .../members/lib/member-edit-gate.test.ts | 32 ++-- .../features/members/lib/member-edit-gate.ts | 22 ++- .../members/lib/member-permissions.ts | 7 +- .../members/repositories/member-repository.ts | 12 +- firestore.rules | 33 +++- tests/firestore-rules/rules.test.ts | 148 ++++++++++++++++++ 8 files changed, 246 insertions(+), 28 deletions(-) diff --git a/apps/backstage/src/components/nav-config.test.ts b/apps/backstage/src/components/nav-config.test.ts index 82c8ec13..549793f9 100644 --- a/apps/backstage/src/components/nav-config.test.ts +++ b/apps/backstage/src/components/nav-config.test.ts @@ -178,6 +178,17 @@ describe("isNavItemVisible — conditional grants must not leak", () => { expect(canSee("/positions", claimsFor("Treasury"))).toBe(false); }); + it("admits an update:Position custom role to /positions, matching the catalog's own rule", () => { + // The catalog arms are canDo('update','Position') / canDo('create','Position'), and + // canDo treats manage:Position as satisfying update:Position — so keying `orCan` on + // `update` widens nothing the rules did not already allow, and stops the nav from + // hiding a page whose writes this principal can actually make (guardrail #6). + const orgChartEditor: AuthClaims = { roles: [], perms: ["update:Position"] }; + expect(canSee("/positions", orgChartEditor)).toBe(true); + // read:Position is what a plain Member carries; it must still not open the page. + expect(canSee("/positions", { roles: [], perms: ["read:Position"] })).toBe(false); + }); + it("shows /notificaciones to a compose-only principal (create:Notification, no read)", () => { // The page's history list gates on read:Notification, but a compose-only principal // holds only create:Notification. The item's `subject: Notification` read would hide diff --git a/apps/backstage/src/components/nav-config.ts b/apps/backstage/src/components/nav-config.ts index 5ad740df..8f6a2ae9 100644 --- a/apps/backstage/src/components/nav-config.ts +++ b/apps/backstage/src/components/nav-config.ts @@ -124,10 +124,13 @@ export const NAV_GROUPS: NavGroup[] = [ // Members can read Position (chip resolution on /me), and Membership shares // ONLY that same read grant — so no perm cleanly separates catalog viewers // from Members; hence the built-in allowlist. `orCan` re-admits a dynamic - // custom role that manages the org chart (manage:Position) but carries no - // built-in role name, so the route guard doesn't lock the perms system out. + // custom role that edits the org chart but carries no built-in role name, so + // the route guard doesn't lock the perms system out. Keyed on `update` to match + // the catalog's own rules (canDo('update','Position')), which canDo already + // treats manage:Position as satisfying — so this admits no principal the rules + // did not already let write. roles: ["Admin", "Membership", "ExecutiveCommittee"], - orCan: { action: "manage", subject: "Position" }, + orCan: { action: "update", subject: "Position" }, }, { to: "/permisos", label: "Permisos", icon: "lock", roles: ["Admin"] }, ], diff --git a/apps/backstage/src/features/members/lib/member-edit-gate.test.ts b/apps/backstage/src/features/members/lib/member-edit-gate.test.ts index ff0e54f7..7c7e2c7a 100644 --- a/apps/backstage/src/features/members/lib/member-edit-gate.test.ts +++ b/apps/backstage/src/features/members/lib/member-edit-gate.test.ts @@ -30,16 +30,30 @@ describe("memberEditMode", () => { expect(modeFor(roleClaims("Membership"))).toBe("full"); }); - it("BLOCKING: a Position capability alone opens no member editor — wrong collection", () => { - // Both editors submit to members/{id}, gated by canDo('update','Member'). manage:Position - // governs the separate `positions` cargo catalog, and it is Admin-assignable per member - // from the profile's permissions panel — so returning "positions" for it rendered the - // Cargos form to someone whose every submit is PERMISSION_DENIED, permanently. The - // "positions" arm returns in PR 4 with the members-positions rules lane it maps to. - expect(modeFor({ roles: [], perms: ["manage:Position"] })).toBe("none"); - expect(modeFor({ roles: [], perms: ["read:Position"] })).toBe("none"); + it("BLOCKING: update:Member wins over update:Position, so the two editors never both render", () => { + // Order is the whole contract of this function: a principal holding both capabilities + // must get the full form, never the positions-only one, or the profile page would + // mount two competing editors over the same doc. + expect(modeFor({ roles: [], perms: ["update:Member", "update:Position"] })).toBe("full"); + }); + + it("opens the positions editor for an update:Position holder — the members-positions lane", () => { + // firestore.rules' fourth members update arm is keyed on canDo('update','Position') and + // confined to hasOnly(['positions']), so this principal's cargo submit really lands. + expect(modeFor({ roles: [], perms: ["update:Position"] })).toBe("positions"); + // CASL `manage` satisfies can("update", …) exactly as canDo() treats manage:Position as + // satisfying update:Position in the rules — so the manage holder gets the same editor. + expect(modeFor({ roles: [], perms: ["manage:Position"] })).toBe("positions"); // Even riding on the Member role every provisioned user carries, which is what makes // the profile page load in the first place. - expect(modeFor({ roles: ["Member"], perms: ["manage:Position"] })).toBe("none"); + expect(modeFor({ roles: ["Member"], perms: ["manage:Position"] })).toBe("positions"); + }); + + it("BLOCKING: read:Position alone opens no member editor — reading the catalog is not assigning", () => { + // The real guard: `read:Position` is what a plain Member and Membership both carry for + // chip resolution on /me. It confers no write on members/{id}, so rendering the Cargos + // form for it would be the render-then-PERMISSION_DENIED shape this gate exists to remove. + expect(modeFor({ roles: [], perms: ["read:Position"] })).toBe("none"); + expect(modeFor({ roles: ["Member"], perms: ["read:Position"] })).toBe("none"); }); }); diff --git a/apps/backstage/src/features/members/lib/member-edit-gate.ts b/apps/backstage/src/features/members/lib/member-edit-gate.ts index 4aa0f27b..c7bf3e37 100644 --- a/apps/backstage/src/features/members/lib/member-edit-gate.ts +++ b/apps/backstage/src/features/members/lib/member-edit-gate.ts @@ -5,13 +5,19 @@ import type { Can } from "../../../lib/authz/use-can"; export type MemberEditMode = "full" | "positions" | "none"; /** Gates on the capability that governs the WRITE, not on a role and not on the subject the - * form is named after. Both editors submit to `members/{id}`, whose update rule is - * `canDo('update','Member')` (+ `positionsAssignmentSafe()`); `update:Position` governs the - * separate `positions` cargo catalog, so reading it here rendered the Cargos form to a - * principal whose every submit is denied — the render-then-die shape this gate exists to - * remove, just relocated. Cargo assignment is Admin-only until PR 4 adds the - * members-positions write lane keyed on `update:Position`; the `"positions"` arm comes back - * then, together with the `firestore.rules` lane that makes it true. */ + * form is named after. Both editors submit to `members/{id}`, and each arm maps to a real + * `firestore.rules` lane on that doc: + * - `"full"` → the institutional arm, `canDo('update','Member')`; + * - `"positions"` → the members-positions arm, `canDo('update','Position')` confined to + * `hasOnly(['positions'])` + `positionsAssignmentSafe()` — so an org-chart editor who is + * not a member editor may assign and clear GRANT-FREE cargos only, on both sides of a + * swap. `read:Position` (which a plain Member carries for chip resolution on /me) is + * deliberately not enough: it writes nothing, and rendering the form for it would be the + * render-then-PERMISSION_DENIED shape this gate exists to remove. + * Order is load-bearing: `update:Member` is checked first so a principal holding both never + * mounts two competing editors over the same doc. */ export function memberEditMode(gate: Pick): MemberEditMode { - return gate.can("update", "Member") ? "full" : "none"; + if (gate.can("update", "Member")) return "full"; + if (gate.can("update", "Position")) return "positions"; + return "none"; } diff --git a/apps/backstage/src/features/members/lib/member-permissions.ts b/apps/backstage/src/features/members/lib/member-permissions.ts index 07b6a589..bacecb60 100644 --- a/apps/backstage/src/features/members/lib/member-permissions.ts +++ b/apps/backstage/src/features/members/lib/member-permissions.ts @@ -1,7 +1,10 @@ import { ROLES, type Member, type Position, type Role } from "@luminova/types"; -// Cargo grants only — comisiones are chips-only, so the panel mirrors exactly -// what the claims-sync trigger will mint (rules⇄client parity). +// Cargo grants only — comisiones are chips-only, matching the claims-sync trigger, which +// ignores comisión grants. This is the UPPER BOUND of what the trigger will mint, not an +// exact mirror: resolveTrustedGrants (apps/beacon/src/claims-sync/sync.ts) additionally +// requires the cargo's `assignedBy` to hold Admin, and drops every grant when it does not. +// So a cargo assigned by a non-Admin shows here and mints nothing. export function effectiveRoles( member: Pick, positionsById: Map, diff --git a/apps/backstage/src/features/members/repositories/member-repository.ts b/apps/backstage/src/features/members/repositories/member-repository.ts index 5da4f881..2bce84ed 100644 --- a/apps/backstage/src/features/members/repositories/member-repository.ts +++ b/apps/backstage/src/features/members/repositories/member-repository.ts @@ -78,11 +78,13 @@ export class MemberRepository { await updateDoc(doc(this.collection, id), toSelfProfileDoc(data)); } - /** Org-chart edit: writes ONLY the current term's assignment (dot-path). The dedicated - * ExecutiveCommittee positions-only rule this used to target is gone — the write now - * goes through the ordinary `update:Member` lane plus `positionsAssignmentSafe()`, so - * the narrow payload is about not tripping those constraints, not about a separate - * allow-rule. */ + /** Org-chart edit: writes ONLY the current term's assignment (dot-path). Two + * `firestore.rules` arms accept it: the institutional `update:Member` lane, and the + * members-positions lane keyed on `update:Position`, which additionally requires + * `affectedKeys().hasOnly(['positions'])`. So the narrow payload IS load-bearing — a + * ride-along field on this write would drop an `update:Position`-only caller off its + * only arm. Both arms also apply `positionsAssignmentSafe()`, hence the self-stamped + * `assignedBy` and the current-term-only dot path. */ async setPositions( id: string, assignment: { cargoId: string | null; comisionIds: string[] }, diff --git a/firestore.rules b/firestore.rules index 14b45094..da465209 100644 --- a/firestore.rules +++ b/firestore.rules @@ -334,6 +334,31 @@ service cloud.firestore { && resource.data.get('active', true) == true && selfProfileValid(request.resource.data.diff(resource.data).affectedKeys()) && softDeleteSafe(); + // Positions-only lane: an org-chart editor who is NOT a member editor. Keyed on + // update:Position — the same capability that governs the positions CATALOG — and + // confined to the positions map. The power-cargo restriction is NOT relaxed: + // positionsAssignmentSafe()'s non-Admin branch still demands cargoGrantsEmpty() && + // currentCargoGrantsEmpty(), so this principal assigns and clears grant-free cargos + // only, on BOTH sides of a swap. + // + // This lane IS a public publication authority, deliberately: grant-free JDL + // direcciones are board cargos, so its holder can put a member — including + // themselves — on the world-readable Directiva. No claim is minted (claims-sync + // returns early on grants.length == 0). Accepted, and pinned by a rules test. + // + // The four conjuncts after positionsAssignmentSafe() are implied by + // hasOnly(['positions']) TODAY and are stated anyway: they are the claims-mint + // boundary (roleIds, permissionOverrides, uid), publication consent, and the points + // ledger. If hasOnly is ever widened — the obvious future edit is adding a second + // key — those invariants must not vanish with it. + allow update: if canDo('update', 'Position') + && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['positions']) + && positionsAssignmentSafe() + && softDeleteSafe() + && updatePermissionAssignmentSafe() + && !touched('uid') + && !touched('publicProfile') + && unchanged('totalPoints'); allow delete: if false; } @@ -355,8 +380,14 @@ service cloud.firestore { allow create: if canDo('create', 'Position') && (hasAnyRole(['Admin']) || request.resource.data.grants == []) && comisionGrantsEmpty(); + // `category` joins `grants` in the non-Admin pin because it is an authority field, + // not a label: it decides the public board GROUP and board RANK, and whether + // comisionGrantsEmpty() applies at all. Without it an update:Position holder could + // retitle a grant-free Comisión to { category: 'CEL', title: 'Presidente' } — rank 0 — + // and then assign it to themselves through the members-positions lane, landing on the + // world-readable Directiva with no Admin action anywhere in the chain. allow update: if canDo('update', 'Position') - && (hasAnyRole(['Admin']) || unchanged('grants')) + && (hasAnyRole(['Admin']) || (unchanged('grants') && unchanged('category'))) && softDeleteSafe() && comisionGrantsEmpty(); allow delete: if false; diff --git a/tests/firestore-rules/rules.test.ts b/tests/firestore-rules/rules.test.ts index 0e81a851..7bebb49c 100644 --- a/tests/firestore-rules/rules.test.ts +++ b/tests/firestore-rules/rules.test.ts @@ -522,6 +522,28 @@ beforeAll(async () => { active: true, deletedAt: null, }); + // The members-positions lane's own principal (perms-only `update:Position`, no built-in + // role) has a member record of its own — the accepted-exposure test assigns a grant-free + // JDL board cargo to THIS doc, i.e. to itself. + await setDoc(doc(db, "members/m_orgchart"), { + name: "Gabriela", + totalPoints: 0, + uid: "orgchart-uid", + active: true, + deletedAt: null, + }); + // Category-mutation target for the positions catalog arm. Its own doc so the Admin + // success case cannot disturb pos1/pos_soft, which half this suite reads. + await setDoc(doc(db, "positions/pos_cat"), { + title: "Director de Membresía", + titleFemale: "Directora de Membresía", + category: "JDL", + grants: [], + term: 2026, + description: "Dirección de membresía.", + active: true, + deletedAt: null, + }); await setDoc(doc(db, "positions/pos_soft"), { title: "Vocal", titleFemale: "Vocal", @@ -1867,6 +1889,29 @@ describe("firestore.rules — positions", () => { updateDoc(doc(as("u", ["Admin"]), "positions/pos1"), { category: "Comision" }), ); }); + it("BLOCKING: denies a non-Admin update:Position holder changing a position's category", async () => { + // The escalation this closes, now that the members-positions lane exists: `grants` was + // the only field pinned for a non-Admin, so an update:Position holder could retitle a + // grant-free Comisión to { category: 'CEL', title: 'Presidente' } — boardRank 0 — and + // then assign it to themselves on the public Directiva, with no Admin action anywhere. + // category also decides board GROUP and whether comisionGrantsEmpty() applies at all. + await assertFails( + updateDoc(doc(as("orgchart-uid", [], ["update:Position"]), "positions/pos_cat"), { + category: "CEL", + }), + ); + // The rest of the catalog stays writable for them — this pins one field, not the arm. + await assertSucceeds( + updateDoc(doc(as("orgchart-uid", [], ["update:Position"]), "positions/pos_cat"), { + description: "Actualizada.", + }), + ); + }); + it("allows an Admin to change a position's category (the surviving authority)", async () => { + await assertSucceeds( + updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_cat"), { category: "CEL" }), + ); + }); // Deliberate fail-closed: a legacy power comisión (possible before the // invariant) is client-unwritable — even soft-delete — until an admin-SDK/ // console repair empties its grants. Documented in the design spec. @@ -2275,6 +2320,109 @@ describe("firestore.rules — member positions assignment", () => { }), ); }); + // ── The members-positions lane: canDo('update','Position') + hasOnly(['positions']) ── + // A perms-only principal with NO built-in role name — the custom role owner-op 1 + // describes. `as(uid, [], [...])` is what makes it perms-only: the third argument + // replaces the seeded role perms entirely. + const ORG_CHART = "orgchart-uid"; + const orgChart = () => as(ORG_CHART, [], ["update:Position"]); + + it("allows an update:Position-only principal to assign a grant-free cargo, self-stamped", async () => { + await assertSucceeds( + updateDoc(doc(orgChart(), "members/m_positions"), { + [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART }, + }), + ); + }); + + it("BLOCKING: denies the update:Position lane assigning a power-conferring cargo (new side)", async () => { + // positionsAssignmentSafe() is reused verbatim: its non-Admin branch demands + // cargoGrantsEmpty(), and pos1 grants Treasury. Without it this lane would mint claims. + await assertFails( + updateDoc(doc(orgChart(), "members/m_positions"), { + [`positions.${TERM}`]: { cargoId: "pos1", comisionIds: [], assignedBy: ORG_CHART }, + }), + ); + }); + + it("BLOCKING: denies the update:Position lane replacing a power cargo with a grant-free one (old side)", async () => { + // The de-elevation attack from the OTHER direction: m_powercargo holds pos1, so only + // currentCargoGrantsEmpty() — the old-side half — stops this lane from stripping a + // claim. assertFails, so m_powercargo stays intact for the C1 describe below. + await assertFails( + updateDoc(doc(orgChart(), "members/m_powercargo"), { + [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART }, + }), + ); + }); + + it("BLOCKING: denies the update:Position lane touching any non-positions field in the same write", async () => { + // hasOnly(['positions']) is what confines this lane to the org chart. A ride-along + // `name` (or roleIds, or totalPoints) must drop the whole write off the arm. + await assertFails( + updateDoc(doc(orgChart(), "members/m_positions"), { + name: "Renombrado", + [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART }, + }), + ); + }); + + it("denies the update:Position lane a forged assignedBy", async () => { + await assertFails( + updateDoc(doc(orgChart(), "members/m_positions"), { + [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: "admin-uid" }, + }), + ); + }); + + it("denies the update:Position lane a non-current-term write", async () => { + await assertFails( + updateDoc(doc(orgChart(), "members/m_positions"), { + "positions.2099": { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART }, + }), + ); + }); + + it("denies the update:Position lane creating a member (create stays canDo('create','Member'))", async () => { + // createPositionsSafe() cannot call currentCargoGrantsEmpty() — a create has no prior + // resource — and there is no old side to protect, so creation was deliberately not + // widened. An org-chart editor may not mint member records. + await assertFails( + setDoc(doc(orgChart(), "members/m_orgchart_new"), { + name: "Nuevo", + totalPoints: 0, + active: true, + deletedAt: null, + positions: { [TERM]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART } }, + }), + ); + }); + + it("denies the update:Position lane wiping the current term — whole-map replacement and deleteField", async () => { + // Both currently deny via assignedBySelf(): a positions map with no current-term key + // reads assignedBy '' != uid. That is INCIDENTAL — nothing else in the arm asks whether + // an assignment was removed — so pin it, or a future refactor of assignedBySelf() drops + // the clear-a-cargo guard without a red test. + await assertFails(updateDoc(doc(orgChart(), "members/m_positions"), { positions: {} })); + await assertFails( + updateDoc(doc(orgChart(), "members/m_positions"), { positions: deleteField() }), + ); + }); + + it("ACCEPTED EXPOSURE: an update:Position holder may put a member — including itself — on the public Directiva", async () => { + // Deliberate, not an oversight (see docs/specs/position-assignment-lane.md, section A). + // pos_soft is a JDL dirección with grants: [], and boardGroupFromCategory publishes both + // CEL and JDL, so this write lands on the world-readable boardShowcase projection. It is + // not privilege escalation — resolveTrustedGrants returns early on grants.length === 0, + // so no claim is minted — but it IS a publication authority, and owner-op 1 says so in + // the same words. Seeded CEL cargos all carry non-empty grants, so those stay Admin-only. + await assertSucceeds( + updateDoc(doc(orgChart(), "members/m_orgchart"), { + [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART }, + }), + ); + }); + // LAST in this block on purpose: the suite seeds once and never resets, and this write // leaves members/m1 holding a power cargo. A Membership success case running after it // would be denied by currentCargoGrantsEmpty() — the C1 guard — not by its own subject. From 15b8c1597e4ad3506f2201e5b23482c4d0f500da Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 09:23:46 -0400 Subject: [PATCH 04/16] fix(rules,beacon): well-formedness on the four soft-delete lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member doc carrying the STRING "false" in active passed softDeleteSafe(), was dropped by memberDocSchema so it was invisible throughout backstage, and was still published to the public Directiva — project-board.ts tested `active === false`, the fail-open direction, while project-ally.ts used the fail-closed `active !== true`. softDeleteSafe() gains the well-formedness prefix roleLifecycleSafe() already had. Inside the helper, not on the arms: the members Admin-takedown arm deliberately does not call it, and it is the only rules-level path that can unpublish exactly the malformed member this fixes. The three create arms gain the matching born-live requirement — a guard that only holds the update door is half a guard, and the create arms were minting the docs the update arm would then refuse. project-board.ts flips to fail-closed. That is the only part that removes the EXISTING exposure; the rules halves only stop new malformed docs. This is a real new denial, not a reclassification. The first draft argued the missing-field case already denied via an erroring bare read — false for the deletedAt half, because CEL's || absorbs errors and unchanged() returns true on an absent key. Measured on the emulator: such docs were freely editable before. The wrong generalization was also written into a rules comment and is corrected here. Repairs 15 existing test payloads that B2 would otherwise have made vacuous — including two escalation guards and one that states in a comment that it isolates the publicProfile create guard. Two further tests were ALREADY vacuous (a 2-char name failed memberNameValid, not the guard they name); fixed while here. Co-Authored-By: Claude Opus 5 (1M context) --- .../beacon/src/showcase/project-board.test.ts | 11 + apps/beacon/src/showcase/project-board.ts | 8 +- firestore.rules | 95 +++++- tests/firestore-rules/rules.test.ts | 312 +++++++++++++++++- 4 files changed, 404 insertions(+), 22 deletions(-) diff --git a/apps/beacon/src/showcase/project-board.test.ts b/apps/beacon/src/showcase/project-board.test.ts index 8815a69b..bba88ea5 100644 --- a/apps/beacon/src/showcase/project-board.test.ts +++ b/apps/beacon/src/showcase/project-board.test.ts @@ -141,6 +141,17 @@ describe("projectBoard", () => { expect(project("m1", { ...member, active: false, deletedAt: {} }, celCargo)).toBeNull(); }); + it("drops a member whose active flag is not the boolean true (fail-closed)", () => { + // The direction that matters. This gate used to read `active === false`, so the ONE + // shape the rules could not reject — a string "false", or a legacy doc with no `active` + // at all — was invisible in backstage (the zod doc schema drops it) and PUBLISHED on + // the world-readable Directiva. `!== true` matches projectAlly, which had it right. + expect(project("m1", { ...member, active: "false" }, celCargo)).toBeNull(); + expect(project("m1", { ...member, active: "true" }, celCargo)).toBeNull(); + expect(project("m1", { ...member, active: 1 }, celCargo)).toBeNull(); + expect(project("m1", { ...member, active: undefined }, celCargo)).toBeNull(); + }); + it("drops a cargo with an empty title", () => { expect(project("m1", member, { category: "CEL", title: "" })).toBeNull(); }); diff --git a/apps/beacon/src/showcase/project-board.ts b/apps/beacon/src/showcase/project-board.ts index 575cf798..ffb1bb5a 100644 --- a/apps/beacon/src/showcase/project-board.ts +++ b/apps/beacon/src/showcase/project-board.ts @@ -72,7 +72,13 @@ export function projectBoard( // would be publication with an unreachable opt-out — the self lane keys on // `resource.data.uid == request.auth.uid`. Publish only members who can revoke it. if (typeof member.uid !== "string" || member.uid.length === 0) return null; - if (member.deletedAt != null || member.active === false) return null; + // `!== true`, not `=== false` — the fail-CLOSED direction, matching projectAlly. A member + // doc holding a non-bool `active` (the string "false"), or no `active` at all, is dropped + // by memberDocSchema and so is invisible everywhere in backstage; under the old test it + // was nonetheless published here, on the world-readable Directiva. firestore.rules now + // refuses to create or update such a doc, but only this line un-publishes the ones that + // already exist. + if (member.deletedAt != null || member.active !== true) return null; // Shared with the internal birthday lists — one allowlist, so a status added later // can't surface itself on either. if (!isSurfaceableStatus(member.status)) return null; diff --git a/firestore.rules b/firestore.rules index da465209..61c609eb 100644 --- a/firestore.rules +++ b/firestore.rules @@ -35,8 +35,46 @@ service cloud.firestore { } // Block client-side resurrection of soft-deleted docs: once deletedAt is set // it is immutable, and active can only transition true -> false, never back. + // + // The three leading conjuncts are a WELL-FORMEDNESS prefix (B), the same one + // roleLifecycleSafe() already carried, and they are a genuine new denial rather than a + // tidier spelling of an existing one. The tempting argument — "a doc missing `active` + // already dies, because the bare resource.data.active read errors and an erroring rule + // denies" — is FALSE for this function: rules are CEL, whose || ABSORBS errors + // (error || true == true), and that bare read sits on the left of a || whose right side + // is unchanged(field), which compares .get(…, null) on both sides and yields + // null == null -> true on an absent key. Measured against the emulator: before this + // prefix, a members/positions/allies doc missing either field, or holding a non-bool + // `active`, was freely client-editable. See the same correction applied to the wrong + // generalization under roleShapeValid() below. + // + // What the prefix costs: a legacy doc in any of those shapes becomes admin-SDK-only to + // edit, with no rules-layer repair for `active` (see the tests). The three create arms + // are constrained to match, so no NEW doc can enter those shapes. The Admin takedown arm + // on members deliberately does NOT call this function — it is the only rules-level path + // that can unpublish such a member, so the requirement lives here in the helper and not + // on the arms, which would have removed the remedy along with the disease. + // + // The one-way semantics below the prefix are UNCHANGED: deletedAt stays immutable once + // set and active still only goes true -> false. Four collections depend on that + // (members ×3 lanes, positions, allies); roles deliberately does not, which is why it + // has roleLifecycleSafe() instead. + // + // Which of the three prefix conjuncts a mutation sweep can turn red, measured (each + // neutralized to `true` in turn, full rules suite re-run): + // (d.active is bool) — RED alone: the three "stored active is a non-bool" tests. + // ('deletedAt' in d) — RED alone: "denies updating a member whose stored doc has no + // deletedAt key". This is the conjunct the || could not reach. + // ('active' in d) — NEVER red (448/448 still green). With it gone, `d.active is + // bool` reads an absent key, that read ERRORS, and unlike the + // deletedAt half there is no || to absorb it — so the rule still + // denies. It buys a clean denial, not a guard. Recorded so + // nobody counts it as coverage (guardrail #6), and kept for the + // same reason roleShapeValid() keeps its four. function softDeleteSafe() { - return (resource.data.deletedAt == null || unchanged('deletedAt')) + let d = request.resource.data; + return ('active' in d) && (d.active is bool) && ('deletedAt' in d) + && (resource.data.deletedAt == null || unchanged('deletedAt')) && (resource.data.active == true || unchanged('active')); } @@ -284,11 +322,21 @@ service cloud.firestore { // supplies the third gate. projectBoard additionally requires a uid, so the member // always has a reachable /me opt-out — but they are published before using it. // Making publication require a member signal again would mean reverting to opt-in. + // + // Born live, with an explicit null deletedAt — the same three conjuncts the roles + // create arm carries, for the same reason. Without them this arm mints exactly the + // docs softDeleteSafe()'s well-formedness prefix then refuses to update, i.e. a doc + // that is admin-SDK-only from birth. `.get('active', false) == true` and an explicit + // `'deletedAt' in` check, not bare reads: an omitted field must deny CLEANLY, and + // `.get('deletedAt', null) == null` would let an omission through. allow create: if canDo('create', 'Member') && request.resource.data.totalPoints == 0 && !('uid' in request.resource.data) && !('publicProfile' in request.resource.data) && memberNameValid(request.resource.data.get('name', '')) + && request.resource.data.get('active', false) == true + && ('deletedAt' in request.resource.data) + && request.resource.data.deletedAt == null && createPermissionAssignmentSafe() && createPositionsSafe(); // publicProfile is member-consent-only: it publishes their face/name to the @@ -377,8 +425,12 @@ service cloud.firestore { allow read: if signedIn(); // Authorization is perm-based; the power-grant authority (who may confer // non-empty grants → claims) stays Admin-role-only, as before. + // Born live with a null deletedAt — see the members create arm. allow create: if canDo('create', 'Position') && (hasAnyRole(['Admin']) || request.resource.data.grants == []) + && request.resource.data.get('active', false) == true + && ('deletedAt' in request.resource.data) + && request.resource.data.deletedAt == null && comisionGrantsEmpty(); // `category` joins `grants` in the non-Admin pin because it is an authority field, // not a label: it decides the public board GROUP and board RANK, and whether @@ -401,9 +453,18 @@ service cloud.firestore { } // Roles are the ONE collection whose soft-delete is REVERSIBLE: a built-in role // must be able to go out of service and come back. softDeleteSafe() is one-way and - // shared by four other collections (members, positions, allies) — it must NOT - // change; member resurrection is pinned denied in tests/firestore-rules/rules.test.ts - // ("denies resurrecting a soft-deleted member"). + // shared by four other lanes (members ×3, positions, allies), so the roles lane + // cannot borrow it. + // + // softDeleteSafe() has since gained this function's well-formedness prefix + // (`active` present and a bool, `deletedAt` present), for the reason spelled out + // below. What must NOT change there is its ONE-WAY semantics: + // member resurrection is pinned denied in tests/firestore-rules/rules.test.ts + // ("denies resurrecting a soft-deleted member"). What did NOT come with the prefix is + // this function's COUPLING half below (active == true => deletedAt == null, plus the + // request.time stamp): generalizing that would deny every later edit to an existing + // ghost doc, a shape all four zod schemas accept and which is therefore listed and + // editable today. Owed its own pass — see docs/specs/position-assignment-lane.md, B. // // What this closes: this repo's two definitions of "inactive" disagree. // roleDefinitionDocSchema requires `active: z.boolean()`, so a malformed doc is @@ -421,12 +482,20 @@ service cloud.firestore { // (see below), and on a doc whose deletedAt is already null that alone would let // active:false through — inactive with no stamp, which the UI reads as still // restorable. This conjunct is what rejects it. - // The two `in` checks add no authorization outcome on their own: reading an absent - // key ERRORS, and an erroring rule already denies. They are here so a missing field - // denies CLEANLY rather than by evaluation error — the same reason this file prefers - // .get() on the resource side (softDeleteSafe's bare resource.data.active errors on a - // doc missing the field). Don't remove them on a "no test covers it" argument; no - // test CAN, because nothing becomes allowed. + // The two `in` checks add no authorization outcome IN THIS FUNCTION: reading an absent + // key errors, and here the erroring read sits in a pure CONJUNCTION, which cannot + // recover from it — so the rule denies either way and they only buy a clean denial. + // Don't remove them on a "no test covers it" argument; no test CAN, because nothing + // becomes allowed. + // + // That is a claim about this function's SHAPE, not a general property of rules, and it + // was previously written here as though it were general. It is not. Rules are CEL and + // CEL's `||` ABSORBS errors (error || true == true), so the same bare read under a + // disjunction denies nothing: softDeleteSafe()'s `resource.data.active` sits opposite + // `unchanged('active')`, which reads .get(…, null) on both sides and returns + // null == null -> true on an absent key — so a doc missing the field passed. That is + // why softDeleteSafe() needed a real `in` prefix (B) and this one did not, and why the + // legacy-doc cost landed there and not here. // deletedAt value forgery has no authorization effect (every consumer tests // null-ness only, never ordering); `== request.time` is audit hygiene on the // DEACTIVATING write. It cannot be required of every write that merely leaves the @@ -575,7 +644,11 @@ service cloud.firestore { match /allies/{allyId} { allow read: if canDo('read', 'Ally'); - allow create: if canDo('create', 'Ally'); + // Born live with a null deletedAt — see the members create arm. + allow create: if canDo('create', 'Ally') + && request.resource.data.get('active', false) == true + && ('deletedAt' in request.resource.data) + && request.resource.data.deletedAt == null; allow update: if canDo('update', 'Ally') && softDeleteSafe(); allow delete: if false; } diff --git a/tests/firestore-rules/rules.test.ts b/tests/firestore-rules/rules.test.ts index 7bebb49c..2fe63504 100644 --- a/tests/firestore-rules/rules.test.ts +++ b/tests/firestore-rules/rules.test.ts @@ -41,6 +41,13 @@ function anon() { const MEMBER_DOC = { name: "Ana", totalPoints: 0, uid: "owner-uid", active: true, deletedAt: null }; +/** The birth state every members/positions/allies create arm now requires (B2): born + * live, with an explicit null deletedAt. Spread into a create payload whose test is + * about something ELSE, so it keeps failing (or succeeding) for its own reason rather + * than for the missing lifecycle pair — the tautological-test class this repo has + * already had to clean up once. */ +const BORN_LIVE = { active: true, deletedAt: null }; + // Rules derive the term from request.time.year() (UTC); compute it from the client // clock so this suite can't rot when the calendar year rolls over. const TERM = String(new Date().getUTCFullYear()); @@ -544,6 +551,55 @@ beforeAll(async () => { active: true, deletedAt: null, }); + // Malformed soft-delete state, seeded through the admin SDK because no client arm can + // produce it any more (B2). These are the legacy shapes B1's well-formedness prefix + // moves from client-editable to admin-SDK-only — the whole reason owner-op 4 is a + // blocking pre-deploy audit. m_badactive additionally carries publicProfile: true so + // the Admin TAKEDOWN arm — the one arm that does not call softDeleteSafe() — can be + // proven still open on it. That arm is the only rules-level remedy for these docs. + await setDoc(doc(db, "members/m_badactive"), { + name: "Hilda", + totalPoints: 0, + uid: "badactive-uid", + publicProfile: true, + active: "false", + deletedAt: null, + }); + await setDoc(doc(db, "members/m_noactive"), { + name: "Irene", + totalPoints: 0, + deletedAt: null, + }); + // active is a well-formed bool, so the self lane's own `active == true` check passes + // and ('deletedAt' in d) is the only thing left to deny — isolation, not a pile-up. + await setDoc(doc(db, "members/m_nodeletedat"), { + name: "Julio", + totalPoints: 0, + uid: "nodeletedat-uid", + active: true, + }); + // Same shape as m_nodeletedat, its own doc: the repair-asymmetry test WRITES the missing + // key, and sharing a fixture with the denial tests above would couple them to order. + await setDoc(doc(db, "members/m_nodeletedat_fix"), { + name: "Karina", + totalPoints: 0, + active: true, + }); + await setDoc(doc(db, "positions/pos_badactive"), { + title: "Vocal Suplente", + titleFemale: "Vocal Suplente", + category: "JDL", + grants: [], + term: 2026, + description: "Malformado.", + active: "false", + deletedAt: null, + }); + await setDoc(doc(db, "allies/a_badactive"), { + companyName: "Malformada", + active: "false", + deletedAt: null, + }); await setDoc(doc(db, "positions/pos_soft"), { title: "Vocal", titleFemale: "Vocal", @@ -629,7 +685,11 @@ describe("firestore.rules — members", () => { }); it("allows Membership to create with totalPoints 0", async () => { await assertSucceeds( - setDoc(doc(as("u", ["Membership"]), "members/new1"), { name: "Bruno Paz", totalPoints: 0 }), + setDoc(doc(as("u", ["Membership"]), "members/new1"), { + name: "Bruno Paz", + totalPoints: 0, + ...BORN_LIVE, + }), ); }); it("denies create with publicProfile pre-set (consent is not institutionally stamped)", async () => { @@ -640,6 +700,7 @@ describe("firestore.rules — members", () => { setDoc(doc(as("u", ["Membership"]), "members/new_consent"), { name: "Bruno Paz", totalPoints: 0, + ...BORN_LIVE, publicProfile: true, }), ); @@ -647,8 +708,12 @@ describe("firestore.rules — members", () => { it("denies create with publicProfile explicitly false (no client owns this key)", async () => { await assertFails( setDoc(doc(as("u", ["Membership"]), "members/new_consent_false"), { - name: "B", + // "Bruno Paz", not "B": a one-character name is below memberNameValid()'s floor, + // so the old payload was already denied for the NAME — the publicProfile guard + // this test is named for never got to speak. + name: "Bruno Paz", totalPoints: 0, + ...BORN_LIVE, publicProfile: false, }), ); @@ -656,8 +721,9 @@ describe("firestore.rules — members", () => { it("denies create with publicProfile null (an explicit null still counts as present)", async () => { await assertFails( setDoc(doc(as("u", ["Membership"]), "members/new_consent_null"), { - name: "B", + name: "Bruno Paz", totalPoints: 0, + ...BORN_LIVE, publicProfile: null, }), ); @@ -692,12 +758,20 @@ describe("firestore.rules — members", () => { }); it("denies create when totalPoints != 0", async () => { await assertFails( - setDoc(doc(as("u", ["Membership"]), "members/new2"), { name: "Bruno Paz", totalPoints: 5 }), + setDoc(doc(as("u", ["Membership"]), "members/new2"), { + name: "Bruno Paz", + totalPoints: 5, + ...BORN_LIVE, + }), ); }); it("denies a non-admin/non-membership role from creating", async () => { await assertFails( - setDoc(doc(as("u", ["Treasury"]), "members/new3"), { name: "Bruno Paz", totalPoints: 0 }), + setDoc(doc(as("u", ["Treasury"]), "members/new3"), { + name: "Bruno Paz", + totalPoints: 0, + ...BORN_LIVE, + }), ); }); it("BLOCKING: denies Membership creating with a forged assignedBy + power cargo (escalation on create)", async () => { @@ -705,6 +779,7 @@ describe("firestore.rules — members", () => { setDoc(doc(as("mem-uid", ["Membership"]), "members/new_evil"), { name: "Evil", totalPoints: 0, + ...BORN_LIVE, positions: { [TERM]: { cargoId: "pos1", comisionIds: [], assignedBy: "admin-victim-uid" } }, }), ); @@ -714,6 +789,7 @@ describe("firestore.rules — members", () => { setDoc(doc(as("mem-uid", ["Membership"]), "members/new_uid"), { name: "Ximena Paz", totalPoints: 0, + ...BORN_LIVE, uid: "mem-uid", }), ); @@ -723,6 +799,7 @@ describe("firestore.rules — members", () => { setDoc(doc(as("mem-uid", ["Membership"]), "members/new_pow"), { name: "Ximena Paz", totalPoints: 0, + ...BORN_LIVE, positions: { [TERM]: { cargoId: "pos1", comisionIds: [], assignedBy: "mem-uid" } }, }), ); @@ -732,6 +809,7 @@ describe("firestore.rules — members", () => { setDoc(doc(as("mem-uid", ["Membership"]), "members/new_ok"), { name: "Ximena Paz", totalPoints: 0, + ...BORN_LIVE, positions: { [TERM]: { cargoId: "pos_soft", comisionIds: [], assignedBy: "mem-uid" } }, }), ); @@ -741,6 +819,7 @@ describe("firestore.rules — members", () => { setDoc(doc(as("admin-uid", ["Admin"]), "members/new_admin"), { name: "Ximena Paz", totalPoints: 0, + ...BORN_LIVE, positions: { [TERM]: { cargoId: "pos1", comisionIds: [], assignedBy: "admin-uid" } }, }), ); @@ -764,6 +843,7 @@ describe("firestore.rules — members", () => { setDoc(doc(as("u", ["Membership"]), "members/new_formula"), { name: '=HYPERLINK("http://evil")', totalPoints: 0, + ...BORN_LIVE, }), ); }); @@ -772,12 +852,17 @@ describe("firestore.rules — members", () => { setDoc(doc(as("u", ["Membership"]), "members/new_digits"), { name: "Ana Rivas 2", totalPoints: 0, + ...BORN_LIVE, }), ); }); it("denies Membership creating a member with a name below the length floor", async () => { await assertFails( - setDoc(doc(as("u", ["Membership"]), "members/new_short"), { name: "Al", totalPoints: 0 }), + setDoc(doc(as("u", ["Membership"]), "members/new_short"), { + name: "Al", + totalPoints: 0, + ...BORN_LIVE, + }), ); }); it("denies Membership renaming a member to a formula-shaped name", async () => { @@ -1820,7 +1905,7 @@ describe("firestore.rules — positions", () => { }); it("denies Membership creating positions", async () => { await assertFails( - setDoc(doc(as("u", ["Membership"]), "positions/new2"), { title: "X", active: true }), + setDoc(doc(as("u", ["Membership"]), "positions/new2"), { title: "X", ...BORN_LIVE }), ); }); it("denies resurrecting a soft-deleted position", async () => { @@ -2466,6 +2551,210 @@ describe("firestore.rules — replacing an already-assigned power cargo (C1)", ( }); }); +// B: the four softDeleteSafe() lanes (members ×2 + the positions lane, positions, allies) +// gained the same well-formedness prefix roleLifecycleSafe() already had, and their three +// create arms gained the born-live requirement the roles create arm already had. +// +// The two halves are inseparable on purpose: the create arms stop new malformed docs, the +// prefix stops client writes onto the ones already stored. Neither repairs anything — the +// remedy for an existing malformed doc is the console, the admin SDK, or (for publication +// specifically) the Admin takedown arm, which deliberately does NOT call softDeleteSafe() +// and is pinned open at the bottom of this block. +describe("firestore.rules — soft-delete well-formedness (B)", () => { + const secretary = () => as("sec-uid", ["Secretary"]); + + it("denies creating a member with no active/deletedAt (the bare legacy shape)", async () => { + // Same payload as "allows Membership to create with totalPoints 0" minus BORN_LIVE — + // so this isolates the new create conjuncts and nothing else. + await assertFails( + setDoc(doc(as("u", ["Membership"]), "members/new_bare"), { + name: "Bruno Paz", + totalPoints: 0, + }), + ); + }); + it("denies creating a position with no active/deletedAt", async () => { + await assertFails( + setDoc(doc(as("admin-uid", ["Admin"]), "positions/new_bare"), { + title: "Director de Finanzas", + titleFemale: "Directora de Finanzas", + category: "JDL", + grants: [], + term: 2026, + description: "Finanzas.", + }), + ); + }); + it("denies creating an ally with no active/deletedAt", async () => { + await assertFails( + setDoc(doc(secretary(), "allies/a_bare"), { companyName: "Sin ciclo de vida" }), + ); + }); + + it("denies a member born soft-deleted (active false / deletedAt already stamped)", async () => { + // A doc born dead is invisible to every list yet occupies its id; a doc born with a + // deletedAt is the ghost shape (live to a `where`, dead to the pipeline). Neither is a + // state any client flow produces, so neither is client-creatable. + await assertFails( + setDoc(doc(as("u", ["Membership"]), "members/new_born_dead"), { + name: "Bruno Paz", + totalPoints: 0, + active: false, + deletedAt: null, + }), + ); + await assertFails( + setDoc(doc(as("u", ["Membership"]), "members/new_born_ghost"), { + name: "Bruno Paz", + totalPoints: 0, + active: true, + deletedAt: DELETED_AT, + }), + ); + }); + it("denies a position born soft-deleted or born with a deletedAt", async () => { + const born = (extra: Record) => ({ + title: "Director de Finanzas", + titleFemale: "Directora de Finanzas", + category: "JDL", + grants: [], + term: 2026, + description: "Finanzas.", + ...extra, + }); + await assertFails( + setDoc( + doc(as("admin-uid", ["Admin"]), "positions/new_born_dead"), + born({ active: false, deletedAt: null }), + ), + ); + await assertFails( + setDoc( + doc(as("admin-uid", ["Admin"]), "positions/new_born_ghost"), + born({ active: true, deletedAt: DELETED_AT }), + ), + ); + }); + it("denies an ally born soft-deleted or born with a deletedAt", async () => { + await assertFails( + setDoc(doc(secretary(), "allies/a_born_dead"), { + companyName: "Nacida muerta", + active: false, + deletedAt: null, + }), + ); + await assertFails( + setDoc(doc(secretary(), "allies/a_born_ghost"), { + companyName: "Fantasma", + active: true, + deletedAt: DELETED_AT, + }), + ); + }); + + // The stored-side half. A non-bool `active` is the exact ghost this change exists for: it + // is dropped by the zod doc schemas (invisible in backstage) while every `!== false` / + // `!= null` reader treats it as live — which is how it reached the public Directiva. + it("denies every client update to a member whose stored active is a non-bool", async () => { + await assertFails( + updateDoc(doc(as("u", ["Membership"]), "members/m_badactive"), { profession: "Arquitecta" }), + ); + await assertFails( + updateDoc(doc(as("admin-uid", ["Admin"]), "members/m_badactive"), { name: "Hilda Paz" }), + ); + // The members-positions lane (A) calls softDeleteSafe() too — all four lanes close + // together, which is the point of putting the check in the helper. + await assertFails( + updateDoc(doc(as("orgchart-uid", [], ["update:Position"]), "members/m_badactive"), { + [`positions.${TERM}`]: { + cargoId: "pos_soft", + comisionIds: [], + assignedBy: "orgchart-uid", + }, + }), + ); + }); + it("denies every client update to a position whose stored active is a non-bool", async () => { + await assertFails( + updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_badactive"), { title: "Vocal" }), + ); + await assertFails( + updateDoc(doc(as("orgchart-uid", [], ["update:Position"]), "positions/pos_badactive"), { + title: "Vocal", + }), + ); + }); + it("denies every client update to an ally whose stored active is a non-bool", async () => { + await assertFails( + updateDoc(doc(secretary(), "allies/a_badactive"), { companyName: "Reparada" }), + ); + await assertFails( + updateDoc(doc(as("admin-uid", ["Admin"]), "allies/a_badactive"), { + companyName: "Reparada", + }), + ); + }); + + it("denies updating a member whose stored doc has no active key", async () => { + await assertFails( + updateDoc(doc(as("u", ["Membership"]), "members/m_noactive"), { profession: "Bióloga" }), + ); + }); + it("denies updating a member whose stored doc has no deletedAt key", async () => { + await assertFails( + updateDoc(doc(as("u", ["Membership"]), "members/m_nodeletedat"), { profession: "Biólogo" }), + ); + // active: true is well-formed here, so the self lane's own `active == true` check passes + // and the missing deletedAt is the only thing denying — the /me lane is closed too. + await assertFails( + updateDoc(doc(as("nodeletedat-uid", ["Member"]), "members/m_nodeletedat"), { + profession: "Biólogo", + }), + ); + }); + it("pins which malformed field a client can repair and which it cannot", async () => { + // Stated so nobody reads the denials above as "just write the right value". The two + // halves differ, and the difference is exactly why owner-op 4 is a console/admin-SDK + // audit rather than a UI affordance: + // active — NOT client-repairable. The one-way half demands + // resource.data.active == true || unchanged('active'), and a stored + // non-bool satisfies neither once the write changes it. + // deletedAt — client-repairable by stamping the null the doc should have had: + // unchanged('deletedAt') reads .get(…, null) on both sides, so + // absent -> null is a no-op to the one-way half and the merged doc then + // carries the key. A missing deletedAt is therefore self-healing; a + // malformed active is not. + await assertFails( + updateDoc(doc(as("admin-uid", ["Admin"]), "members/m_badactive"), { active: true }), + ); + await assertSucceeds( + updateDoc(doc(as("u", ["Membership"]), "members/m_nodeletedat_fix"), { deletedAt: null }), + ); + }); + + it("keeps the Admin takedown arm open on a malformed member (the remedy path)", async () => { + // The single most important assertion in this block. The takedown arm deliberately does + // not call softDeleteSafe(), so it is the ONE rules-level path that can unpublish + // exactly the malformed member this change is about. Moving the well-formedness check + // onto the arms instead of into the helper would have removed the remedy with the + // disease. + await assertSucceeds( + updateDoc(doc(as("admin-uid", ["Admin"]), "members/m_badactive"), { publicProfile: false }), + ); + }); + + it("leaves the /me self lane working on an ordinary well-formed member", async () => { + // The merge half: on an update request.resource.data is the MERGED doc, so a write that + // touches neither field still satisfies the prefix when the stored doc is well-formed. + // Without this the prefix would have locked every member out of their own profile. + await assertSucceeds( + updateDoc(doc(as("carlos-uid", ["Member"]), "members/m_positions"), { + profession: "Ingeniero", + }), + ); + }); +}); + describe("firestore.rules — roles collection", () => { const ROLE_DOC = { name: "Coordinador", @@ -2676,9 +2965,12 @@ describe("firestore.rules — roles collection", () => { }); it("allows Admin to reactivate a deactivated built-in role", async () => { // softDeleteSafe() hard-blocks active:false -> true, which is why the roles lane - // uses roleLifecycleSafe() instead. softDeleteSafe itself must not change: four - // other collections depend on its one-way semantics and member resurrection is - // pinned denied by "denies resurrecting a soft-deleted member" above. + // uses roleLifecycleSafe() instead. softDeleteSafe() has since gained this function's + // WELL-FORMEDNESS prefix (active present and a bool, deletedAt present) — see the + // "soft-delete well-formedness (B)" block above. What must not change is its ONE-WAY + // semantics: four other lanes depend on them and member resurrection is pinned denied + // by "denies resurrecting a soft-deleted member" above. The coupling half below + // (active == true => deletedAt == null) is still roles-only. await assertSucceeds( updateDoc(doc(as("admin-uid", ["Admin"]), "roles/inactive_builtin"), { active: true, From df2cb6bf71533d01d54ea029c823296439b087d3 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 09:36:44 -0400 Subject: [PATCH 05/16] feat(rules,types,backstage): key featured curation on a perm, not a role name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canCurateFeatured() gated public-site curation on hasAnyRole(['Admin', 'ProjectManager']). computeMemberRoles is pure over {trustedGrants, hadScanner} and reads no role doc, so a DEACTIVATED ProjectManager kept the name in its claim and kept the authority — over the only client-writable input to public content that is not a cargo assignment. The gate becomes hasAnyRole(['Admin']) || hasPerm('update:Showcase'), behind a new Showcase subject (78 -> 84 codes). hasPerm, deliberately not canDo: canDo would let manage:all satisfy the gate, and manage:all is reachable as a PERM without the Admin role. That principal already satisfies canDo('update','Project') on the initiative arm, so this role gate is the only thing that stops them setting featured today — the file documents that boundary as intentional, and canDo would have deleted it silently. Admin stays role-keyed: it is locked and undeactivatable, so it carries none of the staleness this fixes. The client mirror is the exact two-disjunct check, NOT an ability query: CASL maps manage:all to can('manage','all'), so an ability check returns true for a manage:all perm holder — the exact principal the rule excludes. It would have rendered the Destacar checkbox to someone whose write the rules reject, sinking the whole initiative save. Deploy order matters: BUILT_IN_ROLE_PERMS is a seed snapshot, so the constant alone mints nothing on an existing project. Run reseedBuiltInRolePerms and verify a live ProjectManager's CLAIM before the rules ship, or every current curator loses the ability. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/initiative-form.test.tsx | 40 +++++++ .../permissions/lib/permission-matrix.ts | 1 + apps/backstage/src/lib/authz/use-can.test.ts | 35 +++++- apps/backstage/src/lib/authz/use-can.ts | 19 ++- firestore.rules | 41 +++++-- packages/types/src/permission.test.ts | 15 +++ packages/types/src/permission.ts | 13 +++ packages/types/src/role-definition.ts | 5 + tests/firestore-rules/rules.test.ts | 109 ++++++++++++++++++ tools/scripts/lib/role-seed.mjs | 1 + 10 files changed, 260 insertions(+), 19 deletions(-) diff --git a/apps/backstage/src/components/initiative-form.test.tsx b/apps/backstage/src/components/initiative-form.test.tsx index 12a389b3..6a568627 100644 --- a/apps/backstage/src/components/initiative-form.test.tsx +++ b/apps/backstage/src/components/initiative-form.test.tsx @@ -83,4 +83,44 @@ describe("InitiativeForm", () => { expect(screen.queryByLabelText("Estado")).not.toBeInTheDocument(); expect(screen.getByText(/no se puede reabrir/i)).toBeInTheDocument(); }); + + // `featured` curation is gated (rules' canCurateFeatured): Admin by role, everyone else by + // the update:Showcase perm. A non-curator must not even see the control — the write would + // be denied by firestore.rules, taking the whole save down with it. + it("renders the destacar checkbox only when the caller may curate", () => { + const { unmount } = render( + , + ); + expect(screen.getByLabelText(/destacar en \/programas/i)).toBeInTheDocument(); + unmount(); + + render( + , + ); + expect(screen.queryByLabelText(/destacar en \/programas/i)).not.toBeInTheDocument(); + }); + + it("hides the destacar checkbox when canFeature is not passed at all", () => { + render( + , + ); + expect(screen.queryByLabelText(/destacar en \/programas/i)).not.toBeInTheDocument(); + }); }); diff --git a/apps/backstage/src/features/permissions/lib/permission-matrix.ts b/apps/backstage/src/features/permissions/lib/permission-matrix.ts index c416c87d..1a7356a2 100644 --- a/apps/backstage/src/features/permissions/lib/permission-matrix.ts +++ b/apps/backstage/src/features/permissions/lib/permission-matrix.ts @@ -37,6 +37,7 @@ export const SUBJECT_LABELS: Record, string> = Position: "Cargos", Lead: "Prospectos", Notification: "Notificaciones", + Showcase: "Destacados públicos", }; /** Human label for a single code, e.g. "Editar Miembros". */ diff --git a/apps/backstage/src/lib/authz/use-can.test.ts b/apps/backstage/src/lib/authz/use-can.test.ts index c3441e54..52557627 100644 --- a/apps/backstage/src/lib/authz/use-can.test.ts +++ b/apps/backstage/src/lib/authz/use-can.test.ts @@ -38,10 +38,39 @@ describe("buildCan", () => { expect(gate.hasRole(["Admin"])).toBe(true); }); - it("canFeatureInitiatives holds for Admin or ProjectManager only", () => { + // Mirrors the rules' canCurateFeatured(): Admin by ROLE, everyone else by the + // update:Showcase PERM. ProjectManager curates because its seeded perms carry the code — + // deactivate that role and the name survives in the claim while the perm does not. + it("canFeatureInitiatives holds for Admin by role or an update:Showcase perm", () => { expect(can({ roles: ["Admin"] }).canFeatureInitiatives).toBe(true); - expect(can({ roles: ["ProjectManager"] }).canFeatureInitiatives).toBe(true); - expect(can({ roles: ["Membership"] }).canFeatureInitiatives).toBe(false); + expect( + can({ roles: ["ProjectManager"], perms: ["update:Showcase"] }).canFeatureInitiatives, + ).toBe(true); + expect(can({ roles: ["Membership"], perms: ["manage:Member"] }).canFeatureInitiatives).toBe( + false, + ); + }); + + it("canFeatureInitiatives holds for Admin with no perms claim at all (role disjunct)", () => { + expect(can({ roles: ["Admin"], perms: [] }).canFeatureInitiatives).toBe(true); + }); + + it("canFeatureInitiatives holds for a perms-only holder with no curation role", () => { + expect(can({ roles: ["Member"], perms: ["update:Showcase"] }).canFeatureInitiatives).toBe(true); + }); + + // The stale-claim case D exists to fix: the deactivated role doc contributes no perms, so + // the surviving ProjectManager name must not re-open curation. + it("canFeatureInitiatives is false for a ProjectManager whose role doc was deactivated", () => { + expect( + can({ roles: ["ProjectManager"], perms: ["manage:Project"] }).canFeatureInitiatives, + ).toBe(false); + }); + + // hasPerm, not canDo: manage:all is reachable as a perm without the Admin role, and the + // rules' gate is an exact code match. The client gate must not be looser than the rule. + it("canFeatureInitiatives is false for a manage:all perm holder without the Admin role", () => { + expect(can({ roles: ["Member"], perms: ["manage:all"] }).canFeatureInitiatives).toBe(false); }); // Same invariant the `` gate carries: a conditional own-doc grant answers only the diff --git a/apps/backstage/src/lib/authz/use-can.ts b/apps/backstage/src/lib/authz/use-can.ts index 1083917b..84a0708f 100644 --- a/apps/backstage/src/lib/authz/use-can.ts +++ b/apps/backstage/src/lib/authz/use-can.ts @@ -27,9 +27,8 @@ export interface Can { canRemoveCheckIn(entry: { role: ParticipationRole }): boolean; /** Shorthand for the Admin role (not the `manage:all` perm). */ readonly isAdmin: boolean; - /** May curate the public /programas page (rules' `featuredUpdateSafe`). Named - * here so the Admin/ProjectManager policy lives in one place, not scattered - * role-array literals at each call site. */ + /** May curate the public /programas page (rules' `canCurateFeatured`). Named here so the + * policy lives in one place, not scattered role-array literals at each call site. */ readonly canFeatureInitiatives: boolean; /** May assign power-granting cargos (rules' `cargoGrantsEmpty` / `createPositionsSafe` * — Admin only). Named so the policy isn't a bare `isAdmin` at each grant site. */ @@ -51,7 +50,19 @@ export function buildCan(ability: AppAbility, claims: AuthClaims): Can { isNavItemVisible(item, ability, claims) && !(item.to === "/" && isMemberOnly(claims)), canRemoveCheckIn: (entry) => canRemoveEntry(ability, claims, entry), isAdmin: hasAnyRole(claims, ["Admin"]), - canFeatureInitiatives: hasAnyRole(claims, ["Admin", "ProjectManager"]), + // Mirrors canCurateFeatured() in firestore.rules disjunct for disjunct: Admin by ROLE + // (locked + undeactivatable, so its name carries none of the staleness this gate fixes), + // everyone else by the update:Showcase PERM — so deactivating a role revokes curation, + // which the surviving role NAME in the claim would not. + // + // Deliberately an exact code test on the claim, NOT `abilityAllows(..., "update", + // "Showcase")`: the rule is `hasPerm`, and CASL's `manage:all` wildcard would answer + // yes to the ability question. That would show the Destacar checkbox to a manage:all + // perm holder whose write firestore.rules then rejects — taking the whole save down + // with it. `probe.ts` does not help here: it narrows CONDITIONAL grants, and the + // divergence is the unconditional wildcard. + canFeatureInitiatives: + hasAnyRole(claims, ["Admin"]) || (claims.perms ?? []).includes("update:Showcase"), canAssignPowerGrants: hasAnyRole(claims, ["Admin"]), }; } diff --git a/firestore.rules b/firestore.rules index 61c609eb..117e3c84 100644 --- a/firestore.rules +++ b/firestore.rules @@ -212,7 +212,7 @@ service cloud.firestore { // Single source of truth for who may curate `featured` — shared by the // create and update arms so the authority can never drift apart again. function canCurateFeatured() { - return hasAnyRole(['Admin', 'ProjectManager']); + return hasAnyRole(['Admin']) || hasPerm('update:Showcase'); } function initiativeCreateAllowed(subject) { return canDo('create', subject) @@ -224,20 +224,37 @@ service cloud.firestore { || request.resource.data.get('featured', false) == false); } // `featured` promotes an already-public finalized initiative onto the curated - // public /programas page — a curation decision, not an editing one. Only Admin/ - // ProjectManager may flip it; a direction-only editor (who may otherwise update - // their own initiative) must leave it unchanged. A pre-feature doc has no - // `featured` field, which is semantically `false`, so default-to-false on BOTH - // sides — else a direction editor echoing `featured: false` (the form always - // sends it) on a legacy doc reads as null!=false and the whole update is denied. + // public /programas page — a curation decision, not an editing one. Only a curator + // may flip it; a direction-only editor (who may otherwise update their own + // initiative) must leave it unchanged. A pre-feature doc has no `featured` field, + // which is semantically `false`, so default-to-false on BOTH sides — else a + // direction editor echoing `featured: false` (the form always sends it) on a legacy + // doc reads as null!=false and the whole update is denied. // Create mirrors the same authority: initiativeCreateAllowed is PERM-based // (canDo), so a custom role holding create:Project/Program could otherwise // create a doc born `featured: true` and reach the public page once finalized — - // hence its Admin/PM-or-featured-false arm. An Admin/PM creating it featured is - // their prerogative and only reaches the public page once finalized (isProjectable). - // Intentionally role-based, NOT perm-based: `featured` is a curation decision - // bound to Admin/ProjectManager. A custom role with manage:Project may edit an - // initiative but must leave `featured` unchanged (falls through to the second arm). + // hence its curator-or-featured-false arm. A curator creating it featured is their + // prerogative and only reaches the public page once finalized (isProjectable). + // + // The authority is SPLIT, and each half is the way it is on purpose: + // - Admin by ROLE. It is `locked` and undeactivatable, so its name carries none of + // the staleness the perm half exists to fix, and adding update:Showcase to a role + // whose entire seeded permission set is ["manage:all"] would misrepresent Admin. + // - everyone else by exact PERM (update:Showcase, seeded onto ProjectManager). Role + // NAMES are the wrong key here: computeMemberRoles is pure over the trusted grants + // and reads no role doc, so a DEACTIVATED ProjectManager keeps the name in its + // claim — and, under the old `hasAnyRole(['Admin','ProjectManager'])`, kept the + // authority. Keying on the perm means deactivating the role revokes curation. + // `hasPerm`, deliberately NOT `canDo`: canDo would let `manage:all` satisfy this, and + // manage:all is reachable as a PERM without the Admin role (an Admin-written custom + // role doc or a permissionOverrides.grant can carry it — roleShapeValid() only + // requires `permissions is list`). Such a principal already satisfies + // canDo('update','Project') on the arm below, so this gate is the ONLY thing stopping + // them from setting `featured`; canDo would silently delete that boundary. It also + // keeps the other five *:Showcase codes — manage:Showcase included — inert, so there + // is no second, undocumented path to curation. A custom role with manage:Project may + // edit an initiative but must leave `featured` unchanged (falls through to the second + // arm). function featuredUpdateSafe() { return canCurateFeatured() || request.resource.data.get('featured', false) == resource.data.get('featured', false); diff --git a/packages/types/src/permission.test.ts b/packages/types/src/permission.test.ts index ecf7ff3f..e6ac2346 100644 --- a/packages/types/src/permission.test.ts +++ b/packages/types/src/permission.test.ts @@ -47,6 +47,21 @@ describe("permission vocabulary", () => { }); }); +describe("Showcase subject", () => { + it("is a known subject", () => { + expect(SUBJECTS).toContain("Showcase"); + }); + // Only update:Showcase is read by firestore.rules' canCurateFeatured(); the other five + // are generated by the cross-product and gate nothing. They must still be VALID codes — + // the /permisos matrix renders the full grid, and an assignable-but-unvalidatable code + // would fail the role editor's write validation. + it("accepts update:Showcase and the inert siblings the matrix will render", () => { + expect(isValidPermissionCode("update:Showcase")).toBe(true); + expect(isValidPermissionCode("manage:Showcase")).toBe(true); + expect(isValidPermissionCode("read:Showcase")).toBe(true); + }); +}); + describe("Notification subject", () => { it("is a known subject", () => { expect(SUBJECTS).toContain("Notification"); diff --git a/packages/types/src/permission.ts b/packages/types/src/permission.ts index 3edb685e..b691741d 100644 --- a/packages/types/src/permission.ts +++ b/packages/types/src/permission.ts @@ -14,6 +14,19 @@ export const SUBJECTS = [ "Role", "Lead", "Notification", + // Public-site curation. What this gates is the `featured` flag on `projects`/`programs` + // — NOT the beacon-owned `showcase` collection, which is `allow read: if true` with no + // client write at all. The name is a slight misnomer kept because `featured` is the input + // to that public projection. + // + // Only `update:Showcase` is live: firestore.rules' canCurateFeatured() is + // `hasAnyRole(['Admin']) || hasPerm('update:Showcase')` — an EXACT code match, not + // canDo(). So the other five codes gate nothing, and `manage:Showcase` in particular is + // inert BECAUSE the gate is exact: there is no second, undocumented path to curation. + // That inertness is the pre-existing condition of this vocabulary, not a new defect — + // the /permisos matrix renders the full actions × subjects grid, so `checkIn:Member` and + // dozens like it are already assignable and equally inert. + "Showcase", "all", ] as const; export type Subject = (typeof SUBJECTS)[number]; diff --git a/packages/types/src/role-definition.ts b/packages/types/src/role-definition.ts index 621675fa..0204e41c 100644 --- a/packages/types/src/role-definition.ts +++ b/packages/types/src/role-definition.ts @@ -48,6 +48,11 @@ export const BUILT_IN_ROLE_PERMS: Record = { "manage:Activity", "checkIn:Attendance", "read:Ally", + // Public-site curation of `featured` (rules' canCurateFeatured). Held as a perm, not + // as the ProjectManager role NAME, so deactivating the role doc revokes it — the name + // survives in the claim, the perm does not. manage:Project does NOT imply it: the gate + // is an exact hasPerm. + "update:Showcase", ], // Meant for a JDL dirección — prod data created in /positions, never seeded onto a cargo. ActivityManager: ["manage:Activity", "checkIn:Attendance"], diff --git a/tests/firestore-rules/rules.test.ts b/tests/firestore-rules/rules.test.ts index 2fe63504..7a999a38 100644 --- a/tests/firestore-rules/rules.test.ts +++ b/tests/firestore-rules/rules.test.ts @@ -420,6 +420,43 @@ beforeAll(async () => { impact: null, status: "EnEjecucion", }); + // Targets for the perm-keyed curation describe. Each absorbs a featured:true + // success, so they are separate docs (the suite seeds once, no per-test reset) + // and separate from each other so neither success can mask the other. + await setDoc(doc(db, "projects/p_feat_showcase"), { + termId: "2026", + title: "Destacable (update:Showcase)", + roster: { directorId: "m1", coDirectorIds: [], teamIds: [] }, + directionUids: ["owner-uid"], + finalReport: null, + impact: null, + status: "EnEjecucion", + }); + await setDoc(doc(db, "projects/p_feat_admin_role"), { + termId: "2026", + title: "Destacable (Admin sin perms)", + roster: { directorId: "m1", coDirectorIds: [], teamIds: [] }, + directionUids: ["owner-uid"], + finalReport: null, + impact: null, + status: "EnEjecucion", + }); + // ONE pristine (featured absent) target per curation DENY test. They must not share a + // doc: if any deny regresses, its write persists featured:true and every later deny on + // the same doc turns vacuous — it would then pass as an unchanged echo rather than as a + // real denial. Measured, not theorised: with a shared doc the manage:all and + // manage:Showcase denials both passed against the OLD role-keyed gate. + for (const id of ["p_feat_deny_stale", "p_feat_deny_mgrall", "p_feat_deny_inert"]) { + await setDoc(doc(db, `projects/${id}`), { + termId: "2026", + title: "No destacable", + roster: { directorId: "m1", coDirectorIds: [], teamIds: [] }, + directionUids: [], + finalReport: null, + impact: null, + status: "EnEjecucion", + }); + } // Finalized targets for the featured quick-toggle (curation happens after // completion). finalReport + impact are non-null so a featured-only write // survives finalizedRequiresReport() / initiativeWriteSafe(). Two docs, like @@ -2162,6 +2199,78 @@ describe("firestore.rules — initiative featured create-gate", () => { }); }); +// canCurateFeatured() is `hasAnyRole(['Admin']) || hasPerm('update:Showcase')`. Admin stays +// role-keyed (locked + undeactivatable, so its name carries no staleness); everyone else is +// perm-keyed, so DEACTIVATING a role now revokes curation — computeMemberRoles is pure over +// the trusted grants and reads no role doc, so the NAME survives a deactivation in the claim +// while the perms do not. +// +// Every test here targets a doc the principal can otherwise update (a direction uid, or an +// initiative update perm) and is paired with a non-featured write that SUCCEEDS, so a denial +// is the curation gate and never a missing update perm. +describe("firestore.rules — featured curation is perm-keyed (canCurateFeatured)", () => { + it("denies a DEACTIVATED ProjectManager setting featured (stale role name in the claim)", async () => { + // The whole point of D: roles claim still says ProjectManager, perms carry no + // update:Showcase because the role doc is inactive. manage:Project comes from some + // other live role, so the write clears every conjunct except the curation gate. + const stale = as("pm-stale-uid", ["ProjectManager"], ["manage:Project"]); + await assertFails(updateDoc(doc(stale, "projects/p_feat_deny_stale"), { featured: true })); + await assertSucceeds( + updateDoc(doc(stale, "projects/p_feat_deny_stale"), { title: "No destacable (PM inactivo)" }), + ); + }); + + it("denies a manage:all PERM holder with no Admin role setting featured", async () => { + // manage:all is reachable as a perm without the Admin role (a custom role doc or a + // permissionOverrides.grant can carry it — roleShapeValid() only requires a list). The + // gate uses exact hasPerm, NOT canDo, so manage:all does not satisfy update:Showcase. + const superPerm = as("mgr-all-uid", ["Member"], ["manage:all"]); + await assertFails(updateDoc(doc(superPerm, "projects/p_feat_deny_mgrall"), { featured: true })); + await assertSucceeds( + updateDoc(doc(superPerm, "projects/p_feat_deny_mgrall"), { + title: "No destacable (manage:all)", + }), + ); + }); + + it("denies a manage:Showcase holder setting featured (the inert codes stay inert)", async () => { + // The other five *:Showcase codes gate nothing. manage:Showcase is inert BECAUSE the + // gate is exact hasPerm — there is no second, undocumented path to curation. + const inert = as("mgr-showcase-uid", ["Member"], ["manage:Project", "manage:Showcase"]); + await assertFails(updateDoc(doc(inert, "projects/p_feat_deny_inert"), { featured: true })); + await assertSucceeds( + updateDoc(doc(inert, "projects/p_feat_deny_inert"), { + title: "No destacable (manage:Showcase)", + }), + ); + }); + + it("allows a custom role holding update:Showcase to curate, with no Admin/PM role", async () => { + await assertSucceeds( + updateDoc( + doc( + as("cust-showcase-uid", ["Member"], ["update:Project", "update:Showcase"]), + "projects/p_feat_showcase", + ), + { + featured: true, + }, + ), + ); + }); + + it("allows Admin with an EMPTY perms claim to curate (the role disjunct)", async () => { + // Real shape: a member over PERMISSION_CAP gets perms:[] written while keeping + // roles:['Admin']. The uid is a direction uid, so the update itself is allowed and the + // only question left is curation authority. + await assertSucceeds( + updateDoc(doc(as("owner-uid", ["Admin"], []), "projects/p_feat_admin_role"), { + featured: true, + }), + ); + }); +}); + describe("firestore.rules — activity lock (hasCheckIns)", () => { it("has a drift value for every field activityLockSafe() locks (no probe lags the rules)", () => { expect(RULES_LOCKED_FIELDS.length).toBeGreaterThan(0); diff --git a/tools/scripts/lib/role-seed.mjs b/tools/scripts/lib/role-seed.mjs index 69ee69ce..8a01fbb0 100644 --- a/tools/scripts/lib/role-seed.mjs +++ b/tools/scripts/lib/role-seed.mjs @@ -31,6 +31,7 @@ export const BUILT_IN_ROLE_PERMS = { "manage:Activity", "checkIn:Attendance", "read:Ally", + "update:Showcase", ], ActivityManager: ["manage:Activity", "checkIn:Attendance"], Secretary: ["manage:Notification", "manage:Lead", "manage:Ally"], From b04d7e54f70b727be08e67e5b1cc1ab52d6d55f3 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 09:58:10 -0400 Subject: [PATCH 06/16] refactor: /simplify pass over the branch diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review angles converged on the same duplication. Extracted: - bornLive() — the born-live triple was copy-pasted onto four create arms, three of them added by this branch. Rule of three, exceeded on the first pass. - memberWriteInvariants() — the claims-mint boundary, publication consent and the points ledger were written verbatim on both the institutional and the new positions-only member update arms. A security invariant stated twice is the mechanism that drifts, not the one that prevents drift; the file's own canCurateFeatured() extraction is the precedent. Reordering the positions-only arm around it also puts the only get()-issuing conjunct last, so denials now cost zero billed reads. - hasPerm() in @luminova/auth — the client mirror of the rules primitive of the same name. canFeatureInitiatives had hand-rolled it, including its own null default. - LiveBuiltInRoleDoc is now an alias of the shared BuiltInRoleDoc rather than a byte-equivalent restatement with a duplicated docblock. resolveEffectivePerms takes readonly arrays, which deletes four defensive permission-array copies per resolution in an unbounded fan-out and retires a cast. The two independent Firestore fetches in resolveMemberPerms now run in Promise.all instead of in series. The backstage preview adapter is one pass instead of O(names x allRoles). Corrects a comment that gave the wrong reason for keeping the two liveness predicates separate: the blocker is not the firebase-admin type-only import, it is that isActiveRoleDoc is fail-OPEN and isLiveRole is fail-CLOSED, so collapsing them is an unscoped behaviour change. Declined deliberately: table-driving the new rules tests, and deleting the three-way cases from the beacon and backstage suites. Those suites go red THROUGH the shared function, which is what proves the delegation is real. No behaviour change. auth 44->46 (two hasPerm tests); types 319, beacon 283, backstage 731, rules 453 all unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../permissions/lib/effective-preview.ts | 15 +- apps/backstage/src/lib/authz/use-can.ts | 17 +- apps/backstage/src/lib/role-lifecycle.ts | 8 +- apps/beacon/src/claims-sync/firestore-deps.ts | 7 +- .../src/claims-sync/resolve-member-perms.ts | 42 ++-- apps/beacon/src/claims-sync/role-doc.ts | 9 + docs/specs/position-assignment-lane.md | 31 +-- firestore.rules | 185 ++++++++++-------- packages/auth/src/built-in-perms.ts | 48 ++--- packages/auth/src/perms.ts | 8 +- packages/auth/src/roles.test.ts | 15 +- packages/auth/src/roles.ts | 10 + tests/firestore-rules/rules.test.ts | 27 +-- 13 files changed, 247 insertions(+), 175 deletions(-) diff --git a/apps/backstage/src/features/permissions/lib/effective-preview.ts b/apps/backstage/src/features/permissions/lib/effective-preview.ts index e61d6e2d..a625806b 100644 --- a/apps/backstage/src/features/permissions/lib/effective-preview.ts +++ b/apps/backstage/src/features/permissions/lib/effective-preview.ts @@ -41,12 +41,15 @@ export function previewEffectivePerms(input: { overrides: { grant: PermissionCode[]; revoke: PermissionCode[] }; }): PermissionCode[] { const byId = new Map(assignableRoles(input.allRoles).map((r) => [r.id, r])); - // One flatMap over the requested names so `builtInKey` comes from `name`, already a - // `Role`, and the adapter stays castless — `r.builtInKey` is `Role | null`. - const builtInDocs = input.builtInRoleNames.flatMap((name) => - input.allRoles - .filter((r) => r.builtIn && r.builtInKey === name) - .map((r) => ({ permissions: r.permissions, builtInKey: name, live: isLiveRole(r) })), + // ONE pass over allRoles, emitting every built-in doc once — not a rescan per requested + // name. Passing docs for keys the member does not hold is safe: `resolveBuiltInPerms` + // iterates the requested NAMES, so an unrequested key is never visited (it neither + // contributes perms nor covers a key). The `builtInKey !== null` guard is what narrows + // `Role | null` to `Role`, keeping the adapter castless. + const builtInDocs = input.allRoles.flatMap((r) => + r.builtIn && r.builtInKey !== null + ? [{ permissions: r.permissions, builtInKey: r.builtInKey, live: isLiveRole(r) }] + : [], ); const customDocs = input.selectedCustomRoleIds .map((id) => byId.get(id)) diff --git a/apps/backstage/src/lib/authz/use-can.ts b/apps/backstage/src/lib/authz/use-can.ts index 84a0708f..b3b79f66 100644 --- a/apps/backstage/src/lib/authz/use-can.ts +++ b/apps/backstage/src/lib/authz/use-can.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { hasAnyRole, type AuthClaims, type Role } from "@luminova/auth/roles"; +import { hasAnyRole, hasPerm, type AuthClaims, type Role } from "@luminova/auth/roles"; import type { Action, AppAbility, Subject } from "@luminova/auth/ability"; import type { ParticipationRole } from "@luminova/types/engine"; import { isNavItemVisible, type NavItem } from "../../components/nav-config"; @@ -55,14 +55,13 @@ export function buildCan(ability: AppAbility, claims: AuthClaims): Can { // everyone else by the update:Showcase PERM — so deactivating a role revokes curation, // which the surviving role NAME in the claim would not. // - // Deliberately an exact code test on the claim, NOT `abilityAllows(..., "update", - // "Showcase")`: the rule is `hasPerm`, and CASL's `manage:all` wildcard would answer - // yes to the ability question. That would show the Destacar checkbox to a manage:all - // perm holder whose write firestore.rules then rejects — taking the whole save down - // with it. `probe.ts` does not help here: it narrows CONDITIONAL grants, and the - // divergence is the unconditional wildcard. - canFeatureInitiatives: - hasAnyRole(claims, ["Admin"]) || (claims.perms ?? []).includes("update:Showcase"), + // `hasPerm` is the client mirror of the rules' own `hasPerm()` — an exact code test on + // the claim, deliberately NOT `abilityAllows(..., "update", "Showcase")`: CASL's + // `manage:all` wildcard would answer yes to the ability question. That would show the + // Destacar checkbox to a manage:all perm holder whose write firestore.rules then + // rejects — taking the whole save down with it. `probe.ts` does not help here: it + // narrows CONDITIONAL grants, and the divergence is the unconditional wildcard. + canFeatureInitiatives: hasAnyRole(claims, ["Admin"]) || hasPerm(claims, "update:Showcase"), canAssignPowerGrants: hasAnyRole(claims, ["Admin"]), }; } diff --git a/apps/backstage/src/lib/role-lifecycle.ts b/apps/backstage/src/lib/role-lifecycle.ts index 6cdeb601..54f389df 100644 --- a/apps/backstage/src/lib/role-lifecycle.ts +++ b/apps/backstage/src/lib/role-lifecycle.ts @@ -22,7 +22,13 @@ export function isUndeactivatableRole(role: RoleDefinition): boolean { * (apps/beacon/src/claims-sync/role-doc.ts). BOTH fields matter: `active: true` with * `deletedAt` set is live to `where("active","==",true)` and dead to the perms * pipeline, so a surface that trusted `active` alone would offer a role that mints - * nothing. Keep the two in lockstep. */ + * nothing. Keep the two in lockstep. + * + * Not the SAME function, though, and it cannot be shared as one: this side is + * fail-CLOSED (`active === true`) where beacon's is fail-OPEN (`active !== false`), so a + * doc with a missing `active` reads dead here and live there. That gap is documented, not + * fixed — see `previewEffectivePerms` and docs/specs/role-lifecycle.md — and closing it + * would be a behaviour change, so it is not the kind of duplication to collapse. */ export function isLiveRole(role: RoleDefinition): boolean { return role.active && role.deletedAt === null; } diff --git a/apps/beacon/src/claims-sync/firestore-deps.ts b/apps/beacon/src/claims-sync/firestore-deps.ts index a790ae3d..fe7c1f67 100644 --- a/apps/beacon/src/claims-sync/firestore-deps.ts +++ b/apps/beacon/src/claims-sync/firestore-deps.ts @@ -135,11 +135,14 @@ async function queryBuiltInRoleDocs( // object graph to every member of the fan-out, so an in-place mutation anywhere // downstream (a future `doc.permissions.sort()`) would corrupt every remaining member's // claims — one write, N wrong results, no log. Freezing makes that throw in strict mode - // instead. resolveEffectivePerms only reads, so nothing needs the mutability today. + // instead. The type agrees: `BuiltInRoleDoc.permissions` is `readonly`, and + // `resolveBuiltInPerms` only iterates it, so the frozen array needs no cast to satisfy + // the port. (Claims-sync no longer calls `resolveEffectivePerms` directly at all — it + // goes through `resolveMemberPerms` → `resolveBuiltInPerms`.) return Object.freeze( covering.map((d) => Object.freeze({ - permissions: Object.freeze(permsFromRoleDoc(d.data())) as PermissionCode[], + permissions: Object.freeze(permsFromRoleDoc(d.data())), builtInKey: d.get("builtInKey") as Role, live: isActiveRoleDoc(d.data()), }), diff --git a/apps/beacon/src/claims-sync/resolve-member-perms.ts b/apps/beacon/src/claims-sync/resolve-member-perms.ts index 28d2de2e..bfe6121c 100644 --- a/apps/beacon/src/claims-sync/resolve-member-perms.ts +++ b/apps/beacon/src/claims-sync/resolve-member-perms.ts @@ -1,25 +1,11 @@ import type { Role } from "@luminova/auth/roles"; -import { resolveBuiltInPerms } from "@luminova/auth/built-in-perms"; +import { resolveBuiltInPerms, type BuiltInRoleDoc } from "@luminova/auth/built-in-perms"; import type { PermissionCode, RoleDefinition } from "@luminova/types"; -/** A built-in role doc as this resolver's PORT produces it. Structurally the shared - * `BuiltInRoleDoc` from `@luminova/auth/built-in-perms` — restated here rather than - * aliased so `firestore-deps.ts` keeps a beacon-local name to build against, and checked - * by the compiler where `resolveBuiltInPerms` consumes it below, with no cast. - * - * `live` is deliberately NOT `RoleDefinition["active"]`, and this type is deliberately - * not a `Pick` of the doc shape: liveness is the TWO-field predicate `isActiveRoleDoc` - * computes over `active` AND `deletedAt`. A port field named `active` reads as "the doc's - * `active` field", so an implementer returning `d.get("active")` would satisfy the type - * while readmitting the ghost shape (`active: true` with a non-null `deletedAt`) that - * mints the doc's real perms — the failure the three `resolveMemberPerms` liveness tests - * exist to catch. Naming the semantic keeps the contract unspoofable by a plain field read. */ -export interface LiveBuiltInRoleDoc { - permissions: PermissionCode[]; - builtInKey: Role; - /** `isActiveRoleDoc(doc)` — active AND not soft-deleted. Never the raw `active` field. */ - live: boolean; -} +/** The beacon-local name for the shared `BuiltInRoleDoc` port — an ALIAS, not a restatement, + * so the compiler couples them and the `live`-not-`active` rationale lives in one docblock + * (`@luminova/auth/built-in-perms`). */ +export type LiveBuiltInRoleDoc = BuiltInRoleDoc; export interface RolePermsDeps { /** EVERY built-in role doc matching these keys by builtInKey — live AND not-live. @@ -42,8 +28,14 @@ export interface RolePermsDeps { * (`@luminova/auth/built-in-perms`), shared with the backstage assignment preview so the * admin authorizes from the same resolution beacon then mints. `PERMISSION_CAP` stays out * of the shared half: this side fail-closes to `perms: []` in `sync.ts`, backstage blocks - * Save. Liveness DERIVATION also stays here (`isActiveRoleDoc` reads `DocumentData`), - * only its consumption is shared. + * Save. Liveness DERIVATION also stays here — `isActiveRoleDoc` is fail-open where the + * backstage mirror is fail-closed, so the two are not one function — and only its + * consumption is shared. + * + * The two fetches are INDEPENDENT and run concurrently: this resolver runs once per member + * in `onRoleWritten`'s unbounded fan-out, which has a 540 s budget and `retry: false`, so a + * serial await here compounds into the timeout that strands members. The `.length ? … : []` + * short-circuits are preserved — an empty input must still skip its query outright. * * Two production callers inherit this: claims-sync/sync.ts (the onMemberWritten / * onRoleWritten trigger) and set-user-roles.ts (the setUserRoles admin callable). */ @@ -53,9 +45,9 @@ export async function resolveMemberPerms( roleIds: string[], overrides: { grant: PermissionCode[]; revoke: PermissionCode[] }, ): Promise { - const builtInDocs = builtInRoleNames.length - ? await deps.getRoleDocsByBuiltInKeys(builtInRoleNames) - : []; - const customDocs = roleIds.length ? await deps.getRolesByIds(roleIds) : []; + const [builtInDocs, customDocs] = await Promise.all([ + builtInRoleNames.length ? deps.getRoleDocsByBuiltInKeys(builtInRoleNames) : [], + roleIds.length ? deps.getRolesByIds(roleIds) : [], + ]); return resolveBuiltInPerms({ builtInRoleNames, builtInDocs, customDocs, overrides }); } diff --git a/apps/beacon/src/claims-sync/role-doc.ts b/apps/beacon/src/claims-sync/role-doc.ts index ae5476d1..8a1c8153 100644 --- a/apps/beacon/src/claims-sync/role-doc.ts +++ b/apps/beacon/src/claims-sync/role-doc.ts @@ -22,6 +22,15 @@ export function roleDocPermsMalformed(data: DocumentData | undefined): boolean { return rawPermsFromRoleDoc(data).some((p) => !isValidPermissionCode(p)); } +/** Beacon's liveness predicate, and deliberately NOT unifiable with backstage's `isLiveRole` + * (apps/backstage/src/lib/role-lifecycle.ts) today. The blocker is not the `DocumentData` + * import — that is type-only and erases. It is that the two do not compute the same + * function: this one is fail-OPEN (`active !== false`, so a missing or non-bool `active` + * reads LIVE and keeps minting the doc's perms) while `isLiveRole` is fail-CLOSED + * (`active === true`). Collapsing them either revokes perms from docs that hold them today + * or offers assignment of docs that mint nothing — a behaviour change nobody has scoped. + * The divergence and its two directions are written out on `previewEffectivePerms` and in + * docs/specs/role-lifecycle.md; firestore.rules now bars authoring the shapes that reach it. */ export function isActiveRoleDoc(data: DocumentData | undefined): boolean { return data?.active !== false && (data?.deletedAt === null || data?.deletedAt === undefined); } diff --git a/docs/specs/position-assignment-lane.md b/docs/specs/position-assignment-lane.md index 59d9f61f..9c5614c6 100644 --- a/docs/specs/position-assignment-lane.md +++ b/docs/specs/position-assignment-lane.md @@ -48,20 +48,23 @@ arm is purely additive and its scope is auditable in isolation. // currentCargoGrantsEmpty(), so this principal assigns and clears grant-free cargos // only, on BOTH sides of a swap. // -// The four conjuncts after positionsAssignmentSafe() are implied by hasOnly(['positions']) -// TODAY and are stated anyway: they are the claims-mint boundary (roleIds, -// permissionOverrides, uid) and the points ledger. If hasOnly is ever widened — the -// obvious future edit is adding a second key — three invariants must not vanish with it. +// memberWriteInvariants() is implied by hasOnly(['positions']) TODAY and is stated +// anyway: if hasOnly is ever widened — the obvious future edit is adding a second key — +// the claims-mint boundary, publication consent and the points ledger must not vanish +// with it. Ordered cheapest-first: positionsAssignmentSafe() is the only conjunct that +// can issue cross-document get()s, so every earlier denial costs zero billed reads. allow update: if canDo('update', 'Position') && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['positions']) - && positionsAssignmentSafe() - && softDeleteSafe() - && updatePermissionAssignmentSafe() - && !touched('uid') - && !touched('publicProfile') - && unchanged('totalPoints'); + && memberWriteInvariants() + && positionsAssignmentSafe(); ``` +`memberWriteInvariants()` is `unchanged('totalPoints') && !touched('uid') && +!touched('publicProfile') && updatePermissionAssignmentSafe() && softDeleteSafe()`, shared +with the institutional arm — the five conjuncts were duplicated across both until +`/simplify` extracted them. The self-service arm must touch `publicProfile` and the Admin +takedown arm deliberately skips `softDeleteSafe()`, so neither calls it. + `positionsAssignmentSafe()` is reused verbatim — **not edited**. Its `hasAnyRole(['Admin'])` disjunct keeps Admin unrestricted; its `cargoGrantsEmpty() && currentCargoGrantsEmpty()` disjunct is exactly the grant-empty-only semantic this lane wants. `currentCargoGrantsEmpty()` @@ -560,6 +563,12 @@ Recorded because each falsified something this document previously asserted: - The coupling half of `roleLifecycleSafe()` on the four lanes (B, Not in scope). - The term-rollover window in `currentCargoGrantsEmpty()` (A, Residual). - The port-level divergence in C: a zod-rejected doc reads ABSENT to backstage, COVERED to beacon. -- `siteConfig` write is still `hasAnyRole(['Admin'])` — same class as D, smaller blast radius. +- (Not a residual, recorded so it is not re-opened as one: `siteConfig` write staying + `hasAnyRole(['Admin'])` is **correct**, and is not "the D case with a smaller blast + radius". D's argument is that Admin-by-role is the right key precisely because `Admin` is + `locked` and undeactivatable, so its name in a claim can never go stale — which is why D + kept the Admin disjunct and only moved the *other* half onto a perm. A single-authority + Admin-only gate has no second half to move. Migrating it to a perm would be a change with + no defect behind it.) - Refresh-token revocation: a revoked perm survives in a long-lived tab until reload. - `onRoleWritten`'s unbounded, no-retry fan-out. diff --git a/firestore.rules b/firestore.rules index 117e3c84..9b65d1a6 100644 --- a/firestore.rules +++ b/firestore.rules @@ -45,8 +45,11 @@ service cloud.firestore { // is unchanged(field), which compares .get(…, null) on both sides and yields // null == null -> true on an absent key. Measured against the emulator: before this // prefix, a members/positions/allies doc missing either field, or holding a non-bool - // `active`, was freely client-editable. See the same correction applied to the wrong - // generalization under roleShapeValid() below. + // `active`, was freely client-editable. Note the absorption is specific to the ||: the + // ('active' in d) conjunct below CANNOT be mutation-proven red, because removing it + // leaves `d.active is bool` reading an absent key inside a pure && chain, where the + // error does deny. It buys a clean denial, not a guard — recorded so nobody counts it + // as coverage. // // What the prefix costs: a legacy doc in any of those shapes becomes admin-SDK-only to // edit, with no rules-layer repair for `active` (see the tests). The three create arms @@ -77,6 +80,24 @@ service cloud.firestore { && (resource.data.deletedAt == null || unchanged('deletedAt')) && (resource.data.active == true || unchanged('active')); } + // The birth state every soft-deletable collection's create arm requires: born live, + // with an explicit null deletedAt. FOUR arms share it — members, positions, allies + // (softDeleteSafe lanes) and roles (roleLifecycleSafe) — so it lives here rather than + // being retyped per arm. + // + // Without it a create mints exactly the docs softDeleteSafe()'s well-formedness prefix + // then refuses to update, i.e. a doc that is admin-SDK-only from birth. It also closes + // the INACTIVE-vs-MISSING ambiguity on the way in: with the three update-side lanes + // constrained to match, no NEW doc can enter the legacy shapes those lanes now reject. + // + // `.get('active', false) == true` and an explicit `'deletedAt' in` check, not bare + // reads: a create OMITTING either field must deny CLEANLY, and + // `.get('deletedAt', null) == null` would let an omission through. + function bornLive() { + return request.resource.data.get('active', false) == true + && ('deletedAt' in request.resource.data) + && request.resource.data.deletedAt == null; + } // Missing-key-safe comparison (docs created before a field existed). function unchangedOrAbsent(field) { @@ -186,6 +207,35 @@ service cloud.firestore { || (unchanged('roleIds') && unchanged('permissionOverrides')); } + // The invariants EVERY institutional write to a member doc must hold, as one named + // thing: the claims-mint boundary (roleIds + permissionOverrides via + // updatePermissionAssignmentSafe, plus uid), publication consent (publicProfile), the + // points ledger (totalPoints) and the soft-delete/well-formedness contract. Shared by + // the members update arm and the positions-only lane so the authority can never drift + // apart again — the same reason canCurateFeatured() exists. + // + // publicProfile is member-consent-only: it publishes their face/name to the public + // Directiva, so an institutional writer may never set or change it — only the + // owner-only self-lane, or the Admin takedown arm, can. Those two arms are therefore + // the ones that do NOT call this, each deliberately: the self lane MUST touch + // publicProfile (it IS the consent path), and the takedown arm must skip + // softDeleteSafe() (it is the only rules-level way to unpublish a malformed member). + // + // touched(), not unchanged(), on both pinned keys: writing an explicit null onto a doc + // that lacks the key passes a null == null comparison. For uid that null then fails + // memberDocSchema, dropping the member out of every backstage list with only a + // console.error. + // + // Cheap by construction — token reads and request/resource data only, no cross-document + // get() — so an arm may put it ahead of anything that does. + function memberWriteInvariants() { + return unchanged('totalPoints') + && !touched('uid') + && !touched('publicProfile') + && updatePermissionAssignmentSafe() + && softDeleteSafe(); + } + // The completion trio (status + finalReport + impact) is written atomically by the // wizard. A status flip to Finalizado missing either the report or the impact would // orphan child-activity point confirmation and permanently strand the doc (the trio @@ -211,6 +261,31 @@ service cloud.firestore { // Single source of truth for who may curate `featured` — shared by the // create and update arms so the authority can never drift apart again. + // + // The authority is SPLIT, and each half is the way it is on purpose: + // - Admin by ROLE. It is `locked` and undeactivatable, so its name carries none of + // the staleness the perm half exists to fix, and adding update:Showcase to a role + // whose entire seeded permission set is ["manage:all"] would misrepresent Admin. + // - everyone else by exact PERM (update:Showcase, seeded onto ProjectManager). Role + // NAMES are the wrong key here: computeMemberRoles is pure over the trusted grants + // and reads no role doc, so a DEACTIVATED ProjectManager keeps the name in its + // claim — and, under the old `hasAnyRole(['Admin','ProjectManager'])`, kept the + // authority. Keying on the perm means deactivating the role revokes curation. + // `hasPerm`, deliberately NOT `canDo`: canDo would let `manage:all` satisfy this, and + // manage:all is reachable as a PERM without the Admin role (an Admin-written custom + // role doc or a permissionOverrides.grant can carry it — roleShapeValid() only + // requires `permissions is list`). Such a principal already satisfies + // canDo('update','Project') on the update arm below, so this gate is the ONLY thing + // stopping them from setting `featured`; canDo would silently delete that boundary. It + // also keeps the other five *:Showcase codes — manage:Showcase included — inert, so + // there is no second, undocumented path to curation. A custom role with manage:Project + // may edit an initiative but must leave `featured` unchanged. + // + // Both arms need it: initiativeCreateAllowed is PERM-based (canDo), so a custom role + // holding create:Project/Program could otherwise create a doc born `featured: true` + // and reach the public page once finalized — hence its curator-or-featured-false arm. + // A curator creating it featured is their prerogative and only reaches the public page + // once finalized (isProjectable). function canCurateFeatured() { return hasAnyRole(['Admin']) || hasPerm('update:Showcase'); } @@ -230,31 +305,8 @@ service cloud.firestore { // which is semantically `false`, so default-to-false on BOTH sides — else a // direction editor echoing `featured: false` (the form always sends it) on a legacy // doc reads as null!=false and the whole update is denied. - // Create mirrors the same authority: initiativeCreateAllowed is PERM-based - // (canDo), so a custom role holding create:Project/Program could otherwise - // create a doc born `featured: true` and reach the public page once finalized — - // hence its curator-or-featured-false arm. A curator creating it featured is their - // prerogative and only reaches the public page once finalized (isProjectable). - // - // The authority is SPLIT, and each half is the way it is on purpose: - // - Admin by ROLE. It is `locked` and undeactivatable, so its name carries none of - // the staleness the perm half exists to fix, and adding update:Showcase to a role - // whose entire seeded permission set is ["manage:all"] would misrepresent Admin. - // - everyone else by exact PERM (update:Showcase, seeded onto ProjectManager). Role - // NAMES are the wrong key here: computeMemberRoles is pure over the trusted grants - // and reads no role doc, so a DEACTIVATED ProjectManager keeps the name in its - // claim — and, under the old `hasAnyRole(['Admin','ProjectManager'])`, kept the - // authority. Keying on the perm means deactivating the role revokes curation. - // `hasPerm`, deliberately NOT `canDo`: canDo would let `manage:all` satisfy this, and - // manage:all is reachable as a PERM without the Admin role (an Admin-written custom - // role doc or a permissionOverrides.grant can carry it — roleShapeValid() only - // requires `permissions is list`). Such a principal already satisfies - // canDo('update','Project') on the arm below, so this gate is the ONLY thing stopping - // them from setting `featured`; canDo would silently delete that boundary. It also - // keeps the other five *:Showcase codes — manage:Showcase included — inert, so there - // is no second, undocumented path to curation. A custom role with manage:Project may - // edit an initiative but must leave `featured` unchanged (falls through to the second - // arm). + // Who counts as a curator, and why that authority is split the way it is, lives on + // canCurateFeatured() above. function featuredUpdateSafe() { return canCurateFeatured() || request.resource.data.get('featured', false) == resource.data.get('featured', false); @@ -339,41 +391,22 @@ service cloud.firestore { // supplies the third gate. projectBoard additionally requires a uid, so the member // always has a reachable /me opt-out — but they are published before using it. // Making publication require a member signal again would mean reverting to opt-in. - // - // Born live, with an explicit null deletedAt — the same three conjuncts the roles - // create arm carries, for the same reason. Without them this arm mints exactly the - // docs softDeleteSafe()'s well-formedness prefix then refuses to update, i.e. a doc - // that is admin-SDK-only from birth. `.get('active', false) == true` and an explicit - // `'deletedAt' in` check, not bare reads: an omitted field must deny CLEANLY, and - // `.get('deletedAt', null) == null` would let an omission through. allow create: if canDo('create', 'Member') && request.resource.data.totalPoints == 0 && !('uid' in request.resource.data) && !('publicProfile' in request.resource.data) && memberNameValid(request.resource.data.get('name', '')) - && request.resource.data.get('active', false) == true - && ('deletedAt' in request.resource.data) - && request.resource.data.deletedAt == null + && bornLive() && createPermissionAssignmentSafe() && createPositionsSafe(); - // publicProfile is member-consent-only: it publishes their face/name to the - // public Directiva, so the institutional (membership-tier) arm may never set or - // change it — only the owner-only self-lane below, or the Admin takedown arm after - // it, can. touched() rather than unchanged(): writing an explicit null onto a doc - // that lacks the key would pass a null == null comparison. + // The institutional (membership-tier) lane. memberWriteInvariants() carries the + // claims-mint boundary, publication consent, the points ledger and softDeleteSafe(). // touched('name'): an admin editing only a phone must not be denied by a legacy name // stored before this gate existed — an unchanged value is absent from affectedKeys(). // Setting a name, on any lane, must satisfy it. allow update: if canDo('update', 'Member') - && unchanged('totalPoints') - // touched(), not unchanged(): writing uid:null onto a member that has none passes - // a null == null comparison, and a null then fails memberDocSchema — dropping - // that member out of every backstage list with only a console.error. - && !touched('uid') - && !touched('publicProfile') + && memberWriteInvariants() && (!touched('name') || memberNameValid(request.resource.data.get('name', ''))) - && updatePermissionAssignmentSafe() - && softDeleteSafe() && (!touched('positions') || positionsAssignmentSafe()); // Takedown-only arm: an Admin may turn publication OFF and nothing else. Needed // because publication now defaults on, so a member who has lost account access — @@ -411,19 +444,19 @@ service cloud.firestore { // themselves — on the world-readable Directiva. No claim is minted (claims-sync // returns early on grants.length == 0). Accepted, and pinned by a rules test. // - // The four conjuncts after positionsAssignmentSafe() are implied by - // hasOnly(['positions']) TODAY and are stated anyway: they are the claims-mint - // boundary (roleIds, permissionOverrides, uid), publication consent, and the points - // ledger. If hasOnly is ever widened — the obvious future edit is adding a second - // key — those invariants must not vanish with it. + // memberWriteInvariants() is implied by hasOnly(['positions']) TODAY and is stated + // anyway: if hasOnly is ever widened — the obvious future edit is adding a second + // key — the claims-mint boundary, publication consent and the points ledger must not + // vanish with it. + // + // Ordered cheapest-first on purpose: positionsAssignmentSafe() is the only conjunct + // here that can issue cross-document get()s (cargoGrantsEmpty / + // currentCargoGrantsEmpty), so every denial this arm reaches through the token, + // affectedKeys or the invariants costs zero billed reads. allow update: if canDo('update', 'Position') && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['positions']) - && positionsAssignmentSafe() - && softDeleteSafe() - && updatePermissionAssignmentSafe() - && !touched('uid') - && !touched('publicProfile') - && unchanged('totalPoints'); + && memberWriteInvariants() + && positionsAssignmentSafe(); allow delete: if false; } @@ -442,12 +475,9 @@ service cloud.firestore { allow read: if signedIn(); // Authorization is perm-based; the power-grant authority (who may confer // non-empty grants → claims) stays Admin-role-only, as before. - // Born live with a null deletedAt — see the members create arm. allow create: if canDo('create', 'Position') && (hasAnyRole(['Admin']) || request.resource.data.grants == []) - && request.resource.data.get('active', false) == true - && ('deletedAt' in request.resource.data) - && request.resource.data.deletedAt == null + && bornLive() && comisionGrantsEmpty(); // `category` joins `grants` in the non-Admin pin because it is an authority field, // not a label: it decides the public board GROUP and board RANK, and whether @@ -505,14 +535,10 @@ service cloud.firestore { // Don't remove them on a "no test covers it" argument; no test CAN, because nothing // becomes allowed. // - // That is a claim about this function's SHAPE, not a general property of rules, and it - // was previously written here as though it were general. It is not. Rules are CEL and - // CEL's `||` ABSORBS errors (error || true == true), so the same bare read under a - // disjunction denies nothing: softDeleteSafe()'s `resource.data.active` sits opposite - // `unchanged('active')`, which reads .get(…, null) on both sides and returns - // null == null -> true on an absent key — so a doc missing the field passed. That is - // why softDeleteSafe() needed a real `in` prefix (B) and this one did not, and why the - // legacy-doc cost landed there and not here. + // That is a claim about this function's SHAPE, not a general property of rules — under + // a DISJUNCTION the same bare read is absorbed and denies nothing. Written out in full + // on softDeleteSafe() above, which is why the `in` prefix (B) and its legacy-doc cost + // landed there and not here. // deletedAt value forgery has no authorization effect (every consumer tests // null-ness only, never ordering); `== request.time` is audit hygiene on the // DEACTIVATING write. It cannot be required of every write that merely leaves the @@ -638,12 +664,7 @@ service cloud.firestore { && request.resource.data.builtIn == false && request.resource.data.builtInKey == null && request.resource.data.get('locked', false) == false - // Close the same INACTIVE-vs-MISSING ambiguity on create. `.get(…, false)` and an - // explicit key check, not a bare read: a create OMITTING the field must deny - // cleanly, and `.get('deletedAt', null) == null` would let an omission through. - && request.resource.data.get('active', false) == true - && ('deletedAt' in request.resource.data) - && request.resource.data.deletedAt == null; + && bornLive(); // A `locked` role is immutable. Identity fields can't change. Every write must leave // a doc that BOTH readers agree on (roleShapeValid + roleLifecycleSafe). A built-in // role MAY otherwise be deactivated and reactivated — the perms consequence is @@ -661,11 +682,7 @@ service cloud.firestore { match /allies/{allyId} { allow read: if canDo('read', 'Ally'); - // Born live with a null deletedAt — see the members create arm. - allow create: if canDo('create', 'Ally') - && request.resource.data.get('active', false) == true - && ('deletedAt' in request.resource.data) - && request.resource.data.deletedAt == null; + allow create: if canDo('create', 'Ally') && bornLive(); allow update: if canDo('update', 'Ally') && softDeleteSafe(); allow delete: if false; } diff --git a/packages/auth/src/built-in-perms.ts b/packages/auth/src/built-in-perms.ts index 3350affa..a1df01cf 100644 --- a/packages/auth/src/built-in-perms.ts +++ b/packages/auth/src/built-in-perms.ts @@ -2,6 +2,10 @@ import { BUILT_IN_ROLE_PERMS } from "@luminova/types/role-definition"; import type { PermissionCode, Role, RoleDefinition } from "@luminova/types"; import { resolveEffectivePerms } from "./perms.js"; +/** Anything `resolveEffectivePerms` can union perms out of — a live built-in doc, a custom + * doc, or the seed snapshot. Structural, so no branch has to be widened by hand. */ +type PermsSource = { readonly permissions: readonly PermissionCode[] }; + /** A built-in role doc as the shared resolution consumes it. * * Deliberately NOT a `Pick` of the stored doc shape. Liveness is the TWO-field predicate @@ -11,9 +15,11 @@ import { resolveEffectivePerms } from "./perms.js"; * mints the doc's real perms. Naming the semantic keeps the contract unspoofable by a * plain field read. * - * Liveness *derivation* stays per-side (beacon's `isActiveRoleDoc` reads `DocumentData` - * from `firebase-admin/firestore`, which this package — consumed by the browser bundle - * and the rules test suite — must not import). Only *consumption* is shared. */ + * Liveness *derivation* stays per-side, and NOT because of an import boundary: beacon's + * `isActiveRoleDoc` is fail-OPEN (`active !== false`, so a missing or non-bool `active` + * reads live) while backstage's `isLiveRole` is fail-CLOSED (`active === true`). They do + * not compute the same function, so unifying them would be a behaviour change, not a + * refactor. Only *consumption* is shared. */ export interface BuiltInRoleDoc { readonly permissions: readonly PermissionCode[]; readonly builtInKey: Role; @@ -36,13 +42,11 @@ export interface BuiltInRoleDoc { * reason not-live docs have to be passed in: drop them at * the port and a deactivation silently restores the seed. * - * Grouped per key, not mapped: two docs may claim one `builtInKey`. A Map would keep only - * the last, making the answer depend on the order the docs happened to arrive in. - * - * A doc whose `builtInKey` is not in `builtInRoleNames` is IGNORED — it neither - * contributes perms nor covers a key. In production beacon queries - * `where("builtInKey","in",keys)` so it cannot be handed one, but the function is total - * and picks the tighter reading rather than leaving it to the caller's query. + * Iterating the deduped NAMES (not the docs) is what makes all three cases one expression: + * coverage is "this name has at least one claiming doc", so a doc whose `builtInKey` is + * not requested is ignored STRUCTURALLY — it is never visited. Grouping per name also + * handles two docs claiming one `builtInKey`: both live ones are unioned, where a + * `Map` keyed on the doc would have kept whichever arrived last. * * `PERMISSION_CAP` is deliberately NOT applied: the callers disagree on the response * (beacon fail-closes to `perms: []`, backstage blocks Save), so the cap stays theirs. */ @@ -52,16 +56,16 @@ export function resolveBuiltInPerms(input: { customDocs: readonly Pick[]; overrides?: { grant: PermissionCode[]; revoke: PermissionCode[] }; }): PermissionCode[] { - const requested = new Set(input.builtInRoleNames); - const claiming = input.builtInDocs.filter((doc) => requested.has(doc.builtInKey)); - const covered = new Set(claiming.map((doc) => doc.builtInKey)); - const roleDocs: Pick[] = []; - for (const doc of claiming) { - if (doc.live) roleDocs.push({ permissions: [...doc.permissions] }); - } - for (const name of requested) { - if (!covered.has(name)) roleDocs.push({ permissions: [...BUILT_IN_ROLE_PERMS[name]] }); - } - for (const doc of input.customDocs) roleDocs.push({ permissions: [...doc.permissions] }); - return resolveEffectivePerms({ roleDocs, overrides: input.overrides }); + // The return annotation is load-bearing: without it the two branches infer as a union of + // two ARRAY types, which flatMap cannot flatten. + const roleDocs = [...new Set(input.builtInRoleNames)].flatMap((name): PermsSource[] => { + const claiming = input.builtInDocs.filter((doc) => doc.builtInKey === name); + return claiming.length + ? claiming.filter((doc) => doc.live) + : [{ permissions: BUILT_IN_ROLE_PERMS[name] }]; + }); + return resolveEffectivePerms({ + roleDocs: [...roleDocs, ...input.customDocs], + overrides: input.overrides, + }); } diff --git a/packages/auth/src/perms.ts b/packages/auth/src/perms.ts index 70bfdaa3..3d73ac58 100644 --- a/packages/auth/src/perms.ts +++ b/packages/auth/src/perms.ts @@ -1,13 +1,17 @@ -import type { PermissionCode, RoleDefinition } from "@luminova/types"; +import type { PermissionCode } from "@luminova/types"; /** Resolve a member's effective coarse permission set: union of all role perms, * plus override grants, minus override revokes. Deduped and sorted so equality * checks (idempotent claim writes) are stable. Revoke wins over grant. * + * `roleDocs` is readonly all the way down because this function only ITERATES it — + * beacon hands it a deep-frozen graph, so requiring mutability would force callers to + * copy arrays purely to launder the type. + * * Returns the full set uncapped — the caller enforces `PERMISSION_CAP` * (fail-closed in the beacon trigger; a save-blocking preview in the admin UI). */ export function resolveEffectivePerms(input: { - roleDocs: Pick[]; + roleDocs: readonly { readonly permissions: readonly PermissionCode[] }[]; overrides?: { grant: PermissionCode[]; revoke: PermissionCode[] }; }): PermissionCode[] { const set = new Set(); diff --git a/packages/auth/src/roles.test.ts b/packages/auth/src/roles.test.ts index c9b17c18..62e8a65a 100644 --- a/packages/auth/src/roles.test.ts +++ b/packages/auth/src/roles.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isValidRole, hasRole, hasAnyRole, type AuthClaims } from "./roles"; +import { isValidRole, hasRole, hasAnyRole, hasPerm, type AuthClaims } from "./roles"; // The canonical ROLES catalog is derivation-guarded in packages/types // (role-definition.test.ts: every role has a BUILT_IN_ROLE_PERMS entry). A retyped @@ -25,4 +25,17 @@ describe("roles", () => { expect(hasAnyRole(claims, ["Admin", "ExecutiveCommittee"])).toBe(true); expect(hasAnyRole(claims, ["Admin", "Treasury"])).toBe(false); }); + + it("hasPerm tests the EXACT code, with no manage:* expansion", () => { + const claims: AuthClaims = { roles: [], perms: ["update:Showcase", "manage:all"] }; + expect(hasPerm(claims, "update:Showcase")).toBe(true); + // manage:all is present, yet an unheld exact code is still false — mirrors the rules' + // hasPerm(), not canDo(). Expanding here would re-open the boundary canCurateFeatured() + // relies on. + expect(hasPerm(claims, "update:Member")).toBe(false); + }); + + it("hasPerm reads an absent perms claim as zero coarse abilities", () => { + expect(hasPerm({ roles: ["Admin"] }, "update:Showcase")).toBe(false); + }); }); diff --git a/packages/auth/src/roles.ts b/packages/auth/src/roles.ts index 0aea2224..8952d141 100644 --- a/packages/auth/src/roles.ts +++ b/packages/auth/src/roles.ts @@ -18,3 +18,13 @@ export function hasRole(claims: AuthClaims, role: Role): boolean { export function hasAnyRole(claims: AuthClaims, roles: readonly Role[]): boolean { return claims.roles.some((role) => roles.includes(role)); } + +/** Client mirror of `hasPerm()` in firestore.rules: does the claim carry this EXACT code? + * No `manage:all` / `manage:` expansion — that is the rules' `canDo()`, and on + * this side the CASL ability (`buildAbility` / `abilityAllows`) is what answers it. + * + * Use this only where the rule being mirrored is itself `hasPerm` — a gate that reads the + * ability instead would show an affordance whose write the rules then reject. */ +export function hasPerm(claims: AuthClaims, code: PermissionCode): boolean { + return (claims.perms ?? []).includes(code); +} diff --git a/tests/firestore-rules/rules.test.ts b/tests/firestore-rules/rules.test.ts index 7a999a38..cff3b88d 100644 --- a/tests/firestore-rules/rules.test.ts +++ b/tests/firestore-rules/rules.test.ts @@ -39,6 +39,14 @@ function anon() { return env.unauthenticatedContext().firestore(); } +/** The members-positions lane's principal: a perms-only org-chart editor with NO built-in + * role name — the custom role owner-op 1 describes. `as(uid, [], [...])` is what makes it + * perms-only: the third argument replaces the seeded role perms entirely. Module-scoped + * because the positions catalog arm, the members-positions lane and the well-formedness + * sweep all need the same principal. */ +const ORG_CHART = "orgchart-uid"; +const orgChart = () => as(ORG_CHART, [], ["update:Position"]); + const MEMBER_DOC = { name: "Ana", totalPoints: 0, uid: "owner-uid", active: true, deletedAt: null }; /** The birth state every members/positions/allies create arm now requires (B2): born @@ -572,7 +580,7 @@ beforeAll(async () => { await setDoc(doc(db, "members/m_orgchart"), { name: "Gabriela", totalPoints: 0, - uid: "orgchart-uid", + uid: ORG_CHART, active: true, deletedAt: null, }); @@ -2018,13 +2026,13 @@ describe("firestore.rules — positions", () => { // then assign it to themselves on the public Directiva, with no Admin action anywhere. // category also decides board GROUP and whether comisionGrantsEmpty() applies at all. await assertFails( - updateDoc(doc(as("orgchart-uid", [], ["update:Position"]), "positions/pos_cat"), { + updateDoc(doc(orgChart(), "positions/pos_cat"), { category: "CEL", }), ); // The rest of the catalog stays writable for them — this pins one field, not the arm. await assertSucceeds( - updateDoc(doc(as("orgchart-uid", [], ["update:Position"]), "positions/pos_cat"), { + updateDoc(doc(orgChart(), "positions/pos_cat"), { description: "Actualizada.", }), ); @@ -2515,12 +2523,7 @@ describe("firestore.rules — member positions assignment", () => { ); }); // ── The members-positions lane: canDo('update','Position') + hasOnly(['positions']) ── - // A perms-only principal with NO built-in role name — the custom role owner-op 1 - // describes. `as(uid, [], [...])` is what makes it perms-only: the third argument - // replaces the seeded role perms entirely. - const ORG_CHART = "orgchart-uid"; - const orgChart = () => as(ORG_CHART, [], ["update:Position"]); - + // Principal: the module-scoped orgChart() / ORG_CHART above. it("allows an update:Position-only principal to assign a grant-free cargo, self-stamped", async () => { await assertSucceeds( updateDoc(doc(orgChart(), "members/m_positions"), { @@ -2774,11 +2777,11 @@ describe("firestore.rules — soft-delete well-formedness (B)", () => { // The members-positions lane (A) calls softDeleteSafe() too — all four lanes close // together, which is the point of putting the check in the helper. await assertFails( - updateDoc(doc(as("orgchart-uid", [], ["update:Position"]), "members/m_badactive"), { + updateDoc(doc(orgChart(), "members/m_badactive"), { [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], - assignedBy: "orgchart-uid", + assignedBy: ORG_CHART, }, }), ); @@ -2788,7 +2791,7 @@ describe("firestore.rules — soft-delete well-formedness (B)", () => { updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_badactive"), { title: "Vocal" }), ); await assertFails( - updateDoc(doc(as("orgchart-uid", [], ["update:Position"]), "positions/pos_badactive"), { + updateDoc(doc(orgChart(), "positions/pos_badactive"), { title: "Vocal", }), ); From 0cd471771b6b3e12841c8411b791de5d5b396927 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 12:01:41 -0400 Subject: [PATCH 07/16] fix(rules,backstage,beacon): close the review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mandated review round found the branch's own category pin was defeated one door over, plus two false claims the branch made about its own tests. - boardSurfacingCategory() on the positions CREATE arm. Pinning only the update arm left the same outcome reachable through create: a non-Admin create:Position holder could mint a fresh {category:'CEL', title: 'Presidente', grants:[]} cargo — grant-free, so the power-grant check passes — and self-assign it onto the public Directiva at board rank 0. "Seeded CEL cargos all carry grants" is what made that look blocked; nothing stopped an unseeded one. - title/titleFemale join the non-Admin pin on board cargos, because boardRank reads the TITLE, not the category — so pinning category alone still allowed retitling Vicepresidente to Presidente. Comisión renames stay open. - PositionForm mirrors both pins. Without it the rules reject a submission the form invited, with a generic save error. - An order-coupled test pair: an Admin test wrote category:'CEL' onto the doc a BLOCKING denial read, so afterwards the identical non-Admin write was an unchanged echo and was ALLOWED. The denial passed only because vitest runs tests in declaration order. Measured by reordering; fixed with its own fixture. - The full toPositionUpdateDoc payload now has tests. A rules pin with no test of the real client payload is how a 403 reaches production. - Two tests the spec claimed existed and did not: the term-rollover residual (pinned as an ACCEPTED hole, not silently) and the grant-free-cargo-mints- nothing property the whole accepted-exposure argument rests on. - Missing-field coverage for positions and allies; the prefix is one helper shared by four lanes but only members exercised it. - tools/scripts/audit-soft-delete-shapes.mjs makes the blocking owner-op executable. It is a repair tool, not a count: B3 only stops NEW malformed members being published, and onBoardMemberWritten fires only on a member write — which B1 makes impossible from the client. Repairing through the admin SDK re-fires the projection and removes the row. --repair refuses to guess at a non-bool active. Spec corrected where it overstated: B3's reach, the rollover guarantee in owner-op 1, and the two absent tests. Owner-op 1 now also states the custom role needs read:Member, without which the capability is unreachable. rules 457->468, backstage 731->732. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/initiative-form.tsx | 14 +- .../src/components/nav-config.test.ts | 10 + .../members/lib/member-edit-gate.test.ts | 10 +- .../features/members/lib/member-edit-gate.ts | 12 +- .../positions/components/position-form.tsx | 28 +- apps/beacon/src/claims-sync/firestore-deps.ts | 19 +- .../claims-sync/resolve-member-perms.test.ts | 27 ++ .../src/claims-sync/resolve-member-perms.ts | 15 +- apps/beacon/src/claims-sync/sync.test.ts | 64 ++++ docs/firebase-setup.md | 23 ++ docs/specs/position-assignment-lane.md | 79 ++++- firestore.rules | 48 ++- package.json | 1 + packages/auth/CLAUDE.md | 29 +- packages/auth/src/built-in-perms.test.ts | 14 +- packages/types/src/role-definition.ts | 3 + tests/firestore-rules/rules.test.ts | 312 +++++++++++++++++- tools/scripts/audit-soft-delete-shapes.mjs | 145 ++++++++ 18 files changed, 793 insertions(+), 60 deletions(-) create mode 100644 tools/scripts/audit-soft-delete-shapes.mjs diff --git a/apps/backstage/src/components/initiative-form.tsx b/apps/backstage/src/components/initiative-form.tsx index 4d85cd27..ae180aa2 100644 --- a/apps/backstage/src/components/initiative-form.tsx +++ b/apps/backstage/src/components/initiative-form.tsx @@ -39,8 +39,11 @@ interface InitiativeFormProps { isSaving: boolean; onSubmit: (data: InitiativeInput) => void; lockStatus?: boolean; - /** Whether the caller may set `featured` — Admin/ProjectManager only, mirroring - * the rules' `featuredUpdateSafe`. A direction/perm editor sees it disabled. */ + /** Whether the caller may set `featured` — the Admin ROLE, or the `update:Showcase` + * PERM, mirroring the rules' `canCurateFeatured()`. Not a ProjectManager role check: + * the seed grants that role the perm, so deactivating the role now revokes curation, + * and a custom role carrying `update:Showcase` gains it. A direction/perm editor + * without either sees it disabled. */ canFeature?: boolean; } @@ -207,9 +210,10 @@ export function InitiativeForm({ )} - {/* `featured` curation is Admin/ProjectManager-only (rules' featuredUpdateSafe); - a non-curator can never set it here, so hide the control entirely. The form - still submits the initiative's current value (unchanged), which the rule allows. */} + {/* `featured` curation needs the Admin role or the `update:Showcase` perm (rules' + canCurateFeatured); a non-curator can never set it here, so hide the control + entirely. The form still submits the initiative's current value (unchanged), + which the rule allows. */} {canFeature && (
{ expect(canSee("/positions", { roles: [], perms: ["read:Position"] })).toBe(false); }); + it("offers /members to the org-chart principal — read:Member + update:Position, never update:Position alone", () => { + // Cargo assignment happens ON /members (the member roster), and the nav probes + // read:Member there. A custom role carrying only update:Position therefore cannot + // reach the page — the whole members-positions lane is inert for exactly the role the + // owner-op tells the operator to create. The operable principal carries both perms; + // this pins that pair open, and that the write perm alone still opens nothing. + expect(canSee("/members", { roles: [], perms: ["read:Member", "update:Position"] })).toBe(true); + expect(canSee("/members", { roles: [], perms: ["update:Position"] })).toBe(false); + }); + it("shows /notificaciones to a compose-only principal (create:Notification, no read)", () => { // The page's history list gates on read:Notification, but a compose-only principal // holds only create:Notification. The item's `subject: Notification` read would hide diff --git a/apps/backstage/src/features/members/lib/member-edit-gate.test.ts b/apps/backstage/src/features/members/lib/member-edit-gate.test.ts index 7c7e2c7a..e34662f8 100644 --- a/apps/backstage/src/features/members/lib/member-edit-gate.test.ts +++ b/apps/backstage/src/features/members/lib/member-edit-gate.test.ts @@ -50,9 +50,13 @@ describe("memberEditMode", () => { }); it("BLOCKING: read:Position alone opens no member editor — reading the catalog is not assigning", () => { - // The real guard: `read:Position` is what a plain Member and Membership both carry for - // chip resolution on /me. It confers no write on members/{id}, so rendering the Cargos - // form for it would be the render-then-PERMISSION_DENIED shape this gate exists to remove. + // The real guard. `read:Position` confers no write on members/{id}, so rendering the + // Cargos form for it would be the render-then-PERMISSION_DENIED shape this gate exists to + // remove — and the ability to read the catalog is close to universal. Not because the + // `perms` claim carries it (`BUILT_IN_ROLE_PERMS.Member` does not), but because + // `applyConditional` in packages/auth/src/ability.ts gives the `Member` ROLE an + // unconditioned `read` on `Position` for chip resolution on /me, and every provisioned + // user holds that role. The second case below is the production shape. expect(modeFor({ roles: [], perms: ["read:Position"] })).toBe("none"); expect(modeFor({ roles: ["Member"], perms: ["read:Position"] })).toBe("none"); }); diff --git a/apps/backstage/src/features/members/lib/member-edit-gate.ts b/apps/backstage/src/features/members/lib/member-edit-gate.ts index c7bf3e37..4a98511d 100644 --- a/apps/backstage/src/features/members/lib/member-edit-gate.ts +++ b/apps/backstage/src/features/members/lib/member-edit-gate.ts @@ -11,9 +11,15 @@ export type MemberEditMode = "full" | "positions" | "none"; * - `"positions"` → the members-positions arm, `canDo('update','Position')` confined to * `hasOnly(['positions'])` + `positionsAssignmentSafe()` — so an org-chart editor who is * not a member editor may assign and clear GRANT-FREE cargos only, on both sides of a - * swap. `read:Position` (which a plain Member carries for chip resolution on /me) is - * deliberately not enough: it writes nothing, and rendering the form for it would be the - * render-then-PERMISSION_DENIED shape this gate exists to remove. + * swap. `read:Position` is deliberately not enough: it writes nothing, and rendering the + * form for it would be the render-then-PERMISSION_DENIED shape this gate exists to + * remove. That case is not hypothetical — every provisioned user holds the `Member` + * role, and `applyConditional` (`packages/auth/src/ability.ts`) hands the `Member` role + * an unconditioned `read` on `Position` so cargo chips resolve on /me. It is a + * CONDITIONAL grant, not a coarse perm: `BUILT_IN_ROLE_PERMS.Member` carries no + * `read:Position`, so it never appears in the `perms` claim and cannot be revoked from + * `/permisos`. `can("read","Position")` is therefore true for essentially everyone, + * which is exactly why this gate keys on `update`. * Order is load-bearing: `update:Member` is checked first so a principal holding both never * mounts two competing editors over the same doc. */ export function memberEditMode(gate: Pick): MemberEditMode { diff --git a/apps/backstage/src/features/positions/components/position-form.tsx b/apps/backstage/src/features/positions/components/position-form.tsx index 09cccb72..6a2dcc5c 100644 --- a/apps/backstage/src/features/positions/components/position-form.tsx +++ b/apps/backstage/src/features/positions/components/position-form.tsx @@ -37,6 +37,9 @@ function SectionLabel({ children }: { children: ReactNode }) { interface PositionFormProps { defaultValues?: Partial; submitLabel: string; + /** Admin-only authority over the fields the catalog rules pin for everyone else: + * `grants`, `category`, and — on a board cargo (CEL/JDL) — `title`/`titleFemale`. + * One prop, because firestore.rules keys all four on the same `hasAnyRole(['Admin'])`. */ canEditGrants: boolean; onSubmit: (data: PositionInput) => Promise; } @@ -62,7 +65,13 @@ export function PositionForm({ formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(positionSchema), - defaultValues: { ...EMPTY, ...defaultValues }, + // A non-Admin may only ever create a comisión (firestore.rules `boardSurfacingCategory()`), + // so a NEW cargo must not start on the CEL default and die on save. On an EDIT the stored + // category wins — it is pinned, not chosen. + defaultValues: + canEditGrants || defaultValues + ? { ...EMPTY, ...defaultValues } + : { ...EMPTY, category: "Comision" }, }); const categoryField = register("category"); @@ -70,6 +79,12 @@ export function PositionForm({ const title = watch("title"); const isTermVisible = category === "JDL"; const isComision = category === "Comision"; + // Mirror of the non-Admin pin on the positions update arm in firestore.rules. `category` + // is an authority field (it decides the public board group), and on a board cargo so is + // the TITLE — boardRank() orders the public Directiva by it, so 'Vicepresidente' renamed + // to 'Presidente' sorts first. Rendering them editable to a non-Admin buys a generic + // "No se pudo guardar" on save: the render-then-die shape this repo guards against. + const areLabelsLocked = !canEditGrants && !isComision; const submit = handleSubmit(async (data) => { setFormError(null); @@ -90,7 +105,7 @@ export function PositionForm({ required error={errors.title?.message} > - + {!isComision && ( @@ -114,6 +130,7 @@ export function PositionForm({ + {!canEditGrants && ( +

+ {areLabelsLocked + ? "Solo un Admin puede cambiar la categoría o el nombre de un cargo del CEL o de una dirección: el nombre define el orden en la Directiva pública." + : "Solo un Admin puede cambiar la categoría de un cargo."} +

+ )} {isTermVisible && ( diff --git a/apps/beacon/src/claims-sync/firestore-deps.ts b/apps/beacon/src/claims-sync/firestore-deps.ts index fe7c1f67..2fa90a1f 100644 --- a/apps/beacon/src/claims-sync/firestore-deps.ts +++ b/apps/beacon/src/claims-sync/firestore-deps.ts @@ -53,7 +53,7 @@ async function queryBuiltInRoleDocs( db: Firestore, keys: Role[], { log = true }: { log?: boolean } = {}, -): Promise { +): Promise { // `in` supports ≤30 values; ROLES has 9. `builtIn === true` is defense in // depth against an impostor custom role spoofing a builtInKey (rules also // forbid clients setting builtInKey, but the trust boundary is the trigger). @@ -135,10 +135,13 @@ async function queryBuiltInRoleDocs( // object graph to every member of the fan-out, so an in-place mutation anywhere // downstream (a future `doc.permissions.sort()`) would corrupt every remaining member's // claims — one write, N wrong results, no log. Freezing makes that throw in strict mode - // instead. The type agrees: `BuiltInRoleDoc.permissions` is `readonly`, and - // `resolveBuiltInPerms` only iterates it, so the frozen array needs no cast to satisfy - // the port. (Claims-sync no longer calls `resolveEffectivePerms` directly at all — it - // goes through `resolveMemberPerms` → `resolveBuiltInPerms`.) + // instead. The type agrees, and that is the point: the PORT itself is + // `Promise` (and `BuiltInRoleDoc.permissions` is + // `readonly`), so the frozen value is honestly typed and needs no cast. A mutable array + // type here would assert a mutability the value does not have — a later `docs.sort()` + // would compile clean and throw at runtime inside a `retry: false` fan-out. + // (Claims-sync no longer calls `resolveEffectivePerms` directly at all — it goes through + // `resolveMemberPerms` → `resolveBuiltInPerms`.) return Object.freeze( covering.map((d) => Object.freeze({ @@ -147,7 +150,7 @@ async function queryBuiltInRoleDocs( live: isActiveRoleDoc(d.data()), }), ), - ) as LiveBuiltInRoleDoc[]; + ); } /** Order-insensitive canonical form of a built-in query result, for the staleness compare. @@ -222,8 +225,8 @@ export function firestoreClaimsDeps(db: Firestore, auth: Auth): FirestoreClaimsD // and the caller logs the operator's cue to run recomputeAllClaims. Deliberately NOT a TTL: // a TTL would narrow the window while keeping the failure silent, which is strictly worse // than a wide window an operator is told about. - const builtInDocsCache = new Map>(); - function loadBuiltInRoleDocs(keys: Role[]): Promise { + const builtInDocsCache = new Map>(); + function loadBuiltInRoleDocs(keys: Role[]): Promise { const cacheKey = [...new Set(keys)].sort().join(","); let pending = builtInDocsCache.get(cacheKey); if (!pending) { diff --git a/apps/beacon/src/claims-sync/resolve-member-perms.test.ts b/apps/beacon/src/claims-sync/resolve-member-perms.test.ts index b88ae950..d15c347c 100644 --- a/apps/beacon/src/claims-sync/resolve-member-perms.test.ts +++ b/apps/beacon/src/claims-sync/resolve-member-perms.test.ts @@ -105,6 +105,33 @@ describe("resolveMemberPerms", () => { expect(out).toEqual(["manage:Position", "read:MemberPoints"]); }); + it("skips each query outright when its input is empty", async () => { + // The `.length ? … : []` short-circuits are an asserted invariant of the docblock, and + // nothing else pins them: dropping one still returns the right perms (both ports answer + // [] for an empty input), it just spends a Firestore round trip per member of an + // unbounded fan-out. Counting fake, so the regression is observable at all. + let builtInCalls = 0; + let customCalls = 0; + const counting = deps({ + getRoleDocsByBuiltInKeys: async () => { + builtInCalls += 1; + return []; + }, + getRolesByIds: async () => { + customCalls += 1; + return []; + }, + }); + + await resolveMemberPerms(counting, [], ["role-x"], NO_OVERRIDES); + expect(builtInCalls).toBe(0); + expect(customCalls).toBe(1); + + await resolveMemberPerms(counting, ["Treasury"], [], NO_OVERRIDES); + expect(builtInCalls).toBe(1); + expect(customCalls).toBe(1); + }); + it("resolves the coarse reads for a plain Member/Scanner with no extras", async () => { // Scanner is no longer conditional-only: event scoping was abandoned, so it carries // coarse read:Activity + checkIn:Attendance alongside Member's member-facing reads. diff --git a/apps/beacon/src/claims-sync/resolve-member-perms.ts b/apps/beacon/src/claims-sync/resolve-member-perms.ts index bfe6121c..64200718 100644 --- a/apps/beacon/src/claims-sync/resolve-member-perms.ts +++ b/apps/beacon/src/claims-sync/resolve-member-perms.ts @@ -13,7 +13,7 @@ export interface RolePermsDeps { * COVER their key, which is the only thing that tells "deactivated" apart from * "never seeded". Filter them out here and a deactivation silently restores the * seed snapshot through the fallback below. */ - getRoleDocsByBuiltInKeys(keys: Role[]): Promise; + getRoleDocsByBuiltInKeys(keys: Role[]): Promise; /** Custom role docs by id — ACTIVE only. There is no fallback on this path, so * dropping an inactive doc already yields zero perms. */ getRolesByIds(ids: string[]): Promise[]>; @@ -32,10 +32,15 @@ export interface RolePermsDeps { * backstage mirror is fail-closed, so the two are not one function — and only its * consumption is shared. * - * The two fetches are INDEPENDENT and run concurrently: this resolver runs once per member - * in `onRoleWritten`'s unbounded fan-out, which has a 540 s budget and `retry: false`, so a - * serial await here compounds into the timeout that strands members. The `.length ? … : []` - * short-circuits are preserved — an empty input must still skip its query outright. + * The two fetches are INDEPENDENT and run concurrently, and the saving is ONE round-trip + * overlap per fresh deps instance — not one per member. `firestoreClaimsDeps` memoizes the + * built-in query per instance and `onRoleWritten` holds ONE instance for its whole fan-out, + * so from the second member onward `getRoleDocsByBuiltInKeys` returns an already-settled + * promise: a microtask, not a round trip. What `Promise.all` actually buys is the first + * member of a fan-out and every `onMemberWritten` invocation (one instance, one member). + * Cheap and correct, but it is not what keeps the 540 s / `retry: false` fan-out inside its + * budget — the memo is. The `.length ? … : []` short-circuits are preserved: an empty input + * must still skip its query outright (pinned by a test in this file's suite). * * Two production callers inherit this: claims-sync/sync.ts (the onMemberWritten / * onRoleWritten trigger) and set-user-roles.ts (the setUserRoles admin callable). */ diff --git a/apps/beacon/src/claims-sync/sync.test.ts b/apps/beacon/src/claims-sync/sync.test.ts index dd6e74a0..48fd7220 100644 --- a/apps/beacon/src/claims-sync/sync.test.ts +++ b/apps/beacon/src/claims-sync/sync.test.ts @@ -126,6 +126,70 @@ describe("syncMemberClaims", () => { }); }); + it("BLOCKING: positive-and-inert — a grant-free cargo from a NON-Admin assigner mints nothing", async () => { + // The members-positions lane (firestore.rules' fourth members update arm, keyed on + // update:Position) lets an org-chart editor who is NOT an Admin assign GRANT-FREE cargos. + // docs/specs/position-assignment-lane.md accepts that lane as a PUBLIC publication + // authority — a grant-free JDL dirección puts the member on the world-readable Directiva — + // precisely BECAUSE it confers no claim. That was inferred from reading sync.ts; this test + // asserts it, both halves: + // + // 1. INERT — the computed claims equal what the member already had, so setClaims is + // never even called. Nothing leaks out of the cargo and nothing is carried in. + // 2. The assigner is NEVER LOOKED UP. This is the falsifiable half and the load-bearing + // one: `resolveTrustedGrants` returns at the `grants.length === 0` early return + // BEFORE the assignedBy-holds-Admin gate is consulted. So the inertness does not + // depend on the assigner being a non-Admin — it holds for ANY assigner, which is what + // makes "this lane cannot mint" a property of the lane rather than of the fixture. + // Delete that early return and this assertion goes red while the claims stay equal. + const grantFree = { grants: [] as Role[] }; + const assignerLookups: string[] = []; + const { deps, writes } = fakeDeps({ + positions: { "pos-jdl-dir": grantFree, "pos-pres": { grants: ["Admin"] } }, + // The org-chart editor holds update:Position via a CUSTOM role — no built-in carries it + // — and is emphatically not an Admin. + userRoles: { "orgchart-uid": ["Member"] }, + existing: { "target-uid": { roles: ["Member"], perms: permsFor(["Member"]) } }, + }); + const spied: ClaimsSyncDeps = { + ...deps, + getUserRoles: async (uid) => { + assignerLookups.push(uid); + return deps.getUserRoles(uid); + }, + }; + await syncMemberClaims( + spied, + { + uid: "target-uid", + positions: { + "2026": { cargoId: "pos-jdl-dir", comisionIds: [], assignedBy: "orgchart-uid" }, + }, + }, + "2026", + ); + // 1. Byte-identical to the claims the member already held → the idempotent no-op path. + expect(writes).toEqual({}); + // 2. The trust gate was never reached. + expect(assignerLookups).toEqual([]); + + // CONTRAST with the power-cargo half, same non-Admin assigner, same member. Here the + // early return does NOT fire, so the assigner IS looked up — and only then dropped for + // not holding Admin. Seen side by side: grant-free short-circuits, power-conferring + // is adjudicated. + await syncMemberClaims( + spied, + { + uid: "target-uid", + positions: { "2026": { cargoId: "pos-pres", comisionIds: [], assignedBy: "orgchart-uid" } }, + }, + "2026", + ); + expect(assignerLookups).toEqual(["orgchart-uid"]); + // Same end state — but reached by the gate, not by the early return. + expect(writes).toEqual({}); + }); + it("drops power grants when assignedBy is missing (legacy doc)", async () => { const { deps, writes } = fakeDeps({ positions: { "pos-pres": { grants: ["Admin"] } }, diff --git a/docs/firebase-setup.md b/docs/firebase-setup.md index 771af9be..dec5cb6d 100644 --- a/docs/firebase-setup.md +++ b/docs/firebase-setup.md @@ -348,6 +348,29 @@ is re-written each run. For wiping production data, see the runbook at `tools/scripts/wipe-prod.md`. +## Soft-Delete Shape Audit (pre-deploy gate) + +`pnpm audit:soft-delete-shapes` scans `members`, `positions` and `allies` for docs +missing `active`/`deletedAt` or holding a non-bool `active` — the shapes the +well-formedness rules make admin-SDK-only to edit (owner-op 4 of +`docs/specs/position-assignment-lane.md`, BLOCKING before those rules deploy). It +exits non-zero when anything is found, so it can gate a deploy. Read-only by +default; `--repair` fixes only the unambiguous shapes (a missing `deletedAt` +becomes `null`; a missing `active` on a never-deleted doc becomes `true`) and +refuses to guess at a non-bool `active` — that one is a human decision. For a +malformed **member** it also reports whether a `boardShowcase` row is currently +published: the admin-SDK repair is what re-fires `onBoardMemberWritten` and takes +the row down, since the rules deny the client write that would have. + +```bash +gcloud auth application-default login +pnpm audit:soft-delete-shapes # count + gate +pnpm audit:soft-delete-shapes --repair # fix the unambiguous, report the rest +``` + +Same credential model as `seed:production`; point it at the emulator by setting +`FIRESTORE_EMULATOR_HOST` first. + ## Correo de invitación When an admin provisions login access for a member, the app calls Firebase Auth's diff --git a/docs/specs/position-assignment-lane.md b/docs/specs/position-assignment-lane.md index 9c5614c6..3e4e9f54 100644 --- a/docs/specs/position-assignment-lane.md +++ b/docs/specs/position-assignment-lane.md @@ -212,7 +212,12 @@ UTC-year rollover a victim whose Admin comes from `positions["2026"]` has an emp `roles: ['Member']`. **Pre-existing** — it falls to any `manage:Member` holder through the institutional arm today, and A does not create it — but the guard is not unconditional, and this spec says so rather than repeating "closes the strip-Admin hole" without qualification. -A test pins the shape. Fixing it properly means resolving liveness across terms and is its +A test pins the shape as ALLOWED, named as an accepted hole so a future reader sees it was +measured rather than missed — an earlier draft claimed that test existed when it did not. +A does not create the hole, but it does widen the principal set: before A this needed +`manage:Member`; after A a role whose entire authority is `update:Position` reaches it. +Owner-op 1 states that, so nobody grants the capability on the strength of an unqualified +"never a power cargo". Fixing it properly means resolving liveness across terms and is its own pass. --- @@ -301,8 +306,17 @@ and it is the interesting part of B2: if (member.deletedAt != null || member.active !== true) return null; ``` -B1 and B2 stop new malformed docs; **only B3 removes the existing public exposure**, so it -ships with them, not after. It also ends the split with `project-ally.ts`. +B1 and B2 stop new malformed docs; B3 is what stops an existing one being published. It also +ends the split with `project-ally.ts`. + +**B3 alone does not reach the rows already published — corrected after review.** An earlier +draft said "only B3 removes the existing public exposure", which overstates its reach: +`onBoardMemberWritten` fires only on a `members/{id}` write, so a member already on +`boardShowcase` under the fail-open predicate stays there until their doc is next written — +and B1 is precisely what makes that write impossible from the client. The remedy is +therefore operational, not automatic: `pnpm audit:soft-delete-shapes --repair` writes through +the admin SDK, which re-fires the trigger and deletes the row. That is why owner-op 4 is +blocking, and why the script is a repair tool rather than a count. ### Not in scope @@ -514,13 +528,27 @@ so all three assertions go false until the fixtures carry `perms: ["update:Showc ## Owner-ops 1. **Hand cargo assignment to an org-chart editor (optional, data-only).** In `/permisos`, - create a **custom** role carrying `update:Position` and assign it. **Do not add the perm to - the `ExecutiveCommittee` built-in** — `reseedBuiltInRolePerms` rewrites built-in docs back - to the seed snapshot and would strip it without warning. Understand what it confers: the - holder may assign and clear **grant-free** cargos and comisiones (never a power cargo, on - either side of a swap), may edit the positions catalog except `grants` and `category`, and - — because grant-free JDL direcciones are board cargos — **may publish a member, including - themselves, to the public Directiva**. + create a **custom** role carrying **both `update:Position` and `read:Member`** and assign + it. Both: the nav gates `/members` on `read:Member` and the profile page must read the + member doc, so `update:Position` alone delivers a capability the holder cannot reach — + pinned by a `nav-config` test. **Do not add the perm to the `ExecutiveCommittee` + built-in** — `reseedBuiltInRolePerms` rewrites built-in docs back to the seed snapshot and + would strip it without warning. + + Understand what it confers. The holder may assign and clear **grant-free** cargos and + comisiones, and may edit the positions catalog except `grants`, `category`, and — on a + CEL/JDL cargo — `title`/`titleFemale`. Two consequences to accept before granting it: + + - **They may publish a member, including themselves, to the public Directiva**, because + grant-free JDL direcciones are board cargos. + - **"Never a power cargo" holds for the CURRENT term only.** `currentCargoGrantsEmpty()` + reads `positions[currentTermKey()]`, so between the UTC-year rollover and the victim's + next write, a member whose Admin comes from last year's cargo has an empty current-term + slot, the guard short-circuits, and this holder can write a grant-free cargo into the new + term — which claims-sync resolves to `roles: ['Member']`, stripping the Admin claim. + Pre-existing (it falls to any `manage:Member` holder through the institutional arm) but + newly reachable by a role whose entire authority is `update:Position`. See Residuals; the + shape is now pinned by a rules test named as an accepted hole. 2. **Before deploying D's rules:** run `reseedBuiltInRolePerms`, then run `recomputeAllClaims`, then confirm a live `ProjectManager`'s **ID-token claim** carries `update:Showcase`. The reseed's `onRoleWritten` fan-out is unbounded and `retry: false`, so it can strand members; @@ -530,8 +558,13 @@ so all three assertions go false until the fixtures carry `perms: ["update:Showc 4. **BLOCKING pre-deploy audit for B.** Count `members`, `positions` and `allies` docs whose `active` is missing or not a bool, or which lack `deletedAt`. Each becomes admin-SDK-only to edit the moment B1 ships, with no UI affordance to repair it — and unlike the first - draft's claim, they are editable **today**. Repair them from the console before the rules - deploy, or accept a known, counted set. + draft's claim, they are editable **today**. Run `pnpm audit:soft-delete-shapes` + (`tools/scripts/audit-soft-delete-shapes.mjs`, documented in `docs/firebase-setup.md`): + it exits non-zero on any finding so it gates the deploy, `--repair` fixes the unambiguous + shapes through the admin SDK (which also re-fires `onBoardMemberWritten` — the only + remaining path that unpublishes a malformed member from the public Directiva), and it + refuses to guess at a non-bool `active`. Repair the reported remainder from the console + before the rules deploy, or accept a known, counted set. ## Deploy order @@ -558,6 +591,28 @@ Recorded because each falsified something this document previously asserted: 7. **`manage:Position` opens the positions editor too** (CASL `manage` satisfies `update`), so two `member-edit-gate` assertions invert rather than one. +Found by the mandated review round, after the code was written: + +8. **`unchanged('category')` was defeated one door over.** Pinning the update arm left the same + outcome reachable through CREATE: a non-Admin `create:Position` holder could mint a fresh + `{category: 'CEL', title: 'Presidente', grants: []}` cargo — grant-free, so the power-grant + check passes — and self-assign it at board rank 0. "Seeded CEL cargos all carry grants" is + what made it look blocked; nothing stopped an UNSEEDED one. Closed by + `boardSurfacingCategory()` on the create arm. +9. **`boardRank` reads the TITLE, not the category**, so pinning `category` alone still let a + non-Admin retitle Vicepresidente to "Presidente". `title`/`titleFemale` are now pinned on + board cargos and left open on comisiones. +10. **The rules pin had no client mirror**, so `PositionForm` offered a Categoría dropdown whose + submission the rules reject with a generic save error — render-then-die. Mirrored in the UI. +11. **Two tests were order-coupled through one fixture.** An Admin test wrote `category: 'CEL'` + onto the doc a BLOCKING denial read, so after it ran the identical non-Admin write became an + unchanged echo and was ALLOWED. The denial passed only because vitest runs `it`s in + declaration order. Measured by reordering; fixed with a dedicated fixture. +12. **Claims the branch made about its own tests were false**: the rollover test and the + "positive-and-inert" claims-mint test were both described as existing and did not. Both + written. +13. **B3's reach was overstated** — see B3 above. + ## Residuals - The coupling half of `roleLifecycleSafe()` on the four lanes (B, Not in scope). diff --git a/firestore.rules b/firestore.rules index 9b65d1a6..8b69f5fa 100644 --- a/firestore.rules +++ b/firestore.rules @@ -471,22 +471,56 @@ service cloud.firestore { return request.resource.data.get('category', '') != 'Comision' || request.resource.data.get('grants', []) == []; } + // A category that boardGroupFromCategory() publishes — packages/types/src/engine/ + // board-public.ts maps exactly CEL and JDL onto the world-readable Directiva, and + // boardRank() then orders them (an unknown CEL title sorts last; 'Presidente' is 0). + // Creating one is a PUBLICATION decision, so it is Admin-only, matching the pin on + // the update arm below. Non-Admins create comisiones. + function boardSurfacingCategory() { + return request.resource.data.get('category', '') in ['CEL', 'JDL']; + } match /positions/{positionId} { allow read: if signedIn(); // Authorization is perm-based; the power-grant authority (who may confer // non-empty grants → claims) stays Admin-role-only, as before. + // + // boardSurfacingCategory() closes the create-side twin of the update-side + // `category` pin. Pinning only the update arm left the same outcome reachable one + // door over: a non-Admin create:Position holder could MINT a fresh + // { category: 'CEL', title: 'Presidente', grants: [] } cargo — grants-free, so it + // passes the power-grant check — and self-assign it through the members-positions + // lane, landing on the public Directiva at rank 0. Seeded CEL cargos all carry + // grants, which is what made that look blocked; nothing stopped an UNSEEDED one. allow create: if canDo('create', 'Position') - && (hasAnyRole(['Admin']) || request.resource.data.grants == []) + && (hasAnyRole(['Admin']) || (request.resource.data.grants == [] + && !boardSurfacingCategory())) && bornLive() && comisionGrantsEmpty(); // `category` joins `grants` in the non-Admin pin because it is an authority field, - // not a label: it decides the public board GROUP and board RANK, and whether - // comisionGrantsEmpty() applies at all. Without it an update:Position holder could - // retitle a grant-free Comisión to { category: 'CEL', title: 'Presidente' } — rank 0 — - // and then assign it to themselves through the members-positions lane, landing on the - // world-readable Directiva with no Admin action anywhere in the chain. + // not a label: it decides the public board GROUP and whether comisionGrantsEmpty() + // applies at all. Without it an update:Position holder could retitle a grant-free + // Comisión to { category: 'CEL', title: 'Presidente' } and then assign it to + // themselves through the members-positions lane, landing on the world-readable + // Directiva with no Admin action anywhere in the chain. + // + // The TITLE is pinned with it, on board cargos only — and that is not symmetry for its + // own sake. boardRank() (packages/types/src/engine/board-public.ts:49-52) is computed + // from the base TITLE, not the category: CEL_ORDER maps 'Presidente' onto rank 0, and + // project-board.ts copies `title` verbatim into the world-readable Directiva. So + // pinning `category` alone left the same outcome one door over: a non-Admin cannot + // ASSIGN a seeded CEL cargo (they all carry grants) but could RENAME one — retitle the + // grant-free 'Vicepresidente' to 'Presidente' and the public board shows them first. + // On a board cargo a title is an authority field; on a comisión it is a label — + // comisiones never reach boardGroupFromCategory() — so comisión renames stay open, + // which is the legitimate org-chart-editor use case owner-op 1 describes. + // `unchanged('category')` is evaluated first, so reading the POST-write category to + // decide which branch applies cannot be steered by the writer. allow update: if canDo('update', 'Position') - && (hasAnyRole(['Admin']) || (unchanged('grants') && unchanged('category'))) + && (hasAnyRole(['Admin']) + || (unchanged('grants') + && unchanged('category') + && (request.resource.data.get('category', '') == 'Comision' + || (unchanged('title') && unchanged('titleFemale'))))) && softDeleteSafe() && comisionGrantsEmpty(); allow delete: if false; diff --git a/package.json b/package.json index 21d0a308..6ffa2411 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "pr-tests": "pnpm format && turbo run ci && pnpm knip && pnpm audit --audit-level=high && pnpm test:seed && pnpm test:harness", "seed:emulator": "FIRESTORE_EMULATOR_HOST=127.0.0.1:4010 FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:4030 node tools/scripts/seed-emulator.mjs", "seed:production": "node tools/scripts/seed-production.mjs", + "audit:soft-delete-shapes": "node tools/scripts/audit-soft-delete-shapes.mjs", "deploy:rules": "firebase deploy --only firestore,storage", "deploy:indexes": "firebase deploy --only firestore:indexes", "deploy:functions": "firebase deploy --only functions", diff --git a/packages/auth/CLAUDE.md b/packages/auth/CLAUDE.md index fc51b4dc..8717dd55 100644 --- a/packages/auth/CLAUDE.md +++ b/packages/auth/CLAUDE.md @@ -19,6 +19,8 @@ sentence: `.claude/hooks/route.sh` prints the mandated set for your diff. | `@luminova/auth/roles` | `AuthClaims`, `Role`, `ROLES`, `isValidRole`, `hasRole`, `hasAnyRole` | | `@luminova/auth/ability` | `buildAbility`, `subject`, `AppAbility`, plus `Action`/`Subject` **re-exported** from `@luminova/types` | | `@luminova/auth/perms` | `resolveEffectivePerms` | +| `@luminova/auth/built-in-perms` | `resolveBuiltInPerms`, `BuiltInRoleDoc` — the ONE absent/live/inactive three-way over already-fetched role docs, shared by beacon's claims-sync and the backstage assignment preview | +| `@luminova/auth/test-helpers` | `roleClaims` — mints `{roles, perms}` the production way; tests must not use a bare `{ roles: [...] }` fixture (see the `claims.perms` invariant below) | `exports` maps types to `src/*.ts` but runtime to `dist/*.js`, so a **fresh worktree must build this package before an app's vitest run** — an unbuilt `dist` @@ -56,13 +58,26 @@ a data change. ## Invariants -- **`resolveEffectivePerms` returns the set UNCAPPED.** Enforcing `PERMISSION_CAP` - (`@luminova/types`) is the caller's job, and the three callers do not agree: - beacon's claims-sync is **fail-closed** (`sync.ts`), the backstage role editor - blocks Save, and `apps/beacon/scripts/seed-roles.ts` enforces **nothing** — it - writes `perms` straight to `setCustomUserClaims`. That last one is emulator-only - (`assertEmulator()`), which is the only reason it is not a hole. Any new caller - must enforce the cap. +- **`resolveEffectivePerms` returns the set UNCAPPED, and so does `resolveBuiltInPerms` + on top of it.** Enforcing `PERMISSION_CAP` (`@luminova/types`) is the caller's job. + Neither production consumer calls `resolveEffectivePerms` directly any more — both + reach it through `resolveBuiltInPerms`, so that is where the cap discipline now + attaches. The current call graph: + - `resolveBuiltInPerms` (`built-in-perms.ts`) — in-package, uncapped by design + because its two callers disagree on the *response*, not on the limit: + - beacon claims-sync → `resolveMemberPerms` → `sync.ts`, **fail-closed** to + `perms: []`; and `set-user-roles.ts`, which throws `internal` over the cap; + - backstage `previewEffectivePerms` + (`features/permissions/lib/effective-preview.ts`) → `member-roles-panel.tsx`, + which disables Save while `effective.length > PERMISSION_CAP`. + - `roleClaims` (`test-helpers.ts`) — test-only fixtures, no cap. + - `apps/beacon/scripts/seed-roles.ts` — enforces **nothing**, writing `perms` + straight to `setCustomUserClaims`. Emulator-only (`assertEmulator()`), which is + the only reason it is not a hole. + + (The backstage *role editor* caps a different thing: `roleDefinitionSchema` bounds + one role doc's own `permissions` array. That is not this resolution.) Any new + caller of either function must enforce the cap. - Output is **deduped**, which is what makes the write-skip check work: beacon's `sameList` compares length then Set membership, so a duplicate would flip lengths and force a redundant claim write. It is **not** order-sensitive — that diff --git a/packages/auth/src/built-in-perms.test.ts b/packages/auth/src/built-in-perms.test.ts index e0821527..b6e995b5 100644 --- a/packages/auth/src/built-in-perms.test.ts +++ b/packages/auth/src/built-in-perms.test.ts @@ -47,11 +47,15 @@ describe("resolveBuiltInPerms", () => { expect(out).toEqual([]); }); - it("BLOCKING: a ghost doc is COVERED and contributes nothing", () => { - // `live` is precomputed by the caller over BOTH `active` and `deletedAt`, so the ghost - // shape (`active: true` with a non-null `deletedAt`) arrives here as `live: false`. It - // must behave exactly like a deactivated doc — covered, minting nothing — and not like - // an absent one. Second key present so the assertion cannot pass by returning [] wholesale. + it("BLOCKING: a not-live doc covers its key while an absent key still falls back", () => { + // This does NOT construct a ghost — resolveBuiltInPerms never sees active/deletedAt; + // it takes the caller's precomputed `live`. The ghost shape (`active: true` with a + // non-null `deletedAt`) being derived INTO live: false is beacon's job, and the test + // that feeds real doc fields through that derivation lives in + // apps/beacon/src/claims-sync/sync.test.ts. What this case adds over the + // suppresses-the-fallback test above is the split verdict: the covered key mints + // nothing while the absent key still falls back to its seed — so the assertion cannot + // pass by returning [] wholesale. const out = resolveBuiltInPerms({ builtInRoleNames: ["Treasury", "Secretary"], builtInDocs: [{ permissions: ["manage:all"], builtInKey: "Treasury", live: false }], diff --git a/packages/types/src/role-definition.ts b/packages/types/src/role-definition.ts index 0204e41c..dd0fa089 100644 --- a/packages/types/src/role-definition.ts +++ b/packages/types/src/role-definition.ts @@ -1,3 +1,6 @@ +// MUST stay `import type`. `firebase` is only a devDependency here, and `@luminova/auth/built-in-perms` +// pulls this file into the esbuild Cloud Functions bundle — a value import would resolve and bundle +// the client SDK into a beacon artifact that is admin-SDK-only, silently. No lint rule enforces this. import type { Timestamp } from "firebase/firestore"; import type { Role } from "./permission-role.js"; import type { PermissionCode } from "./permission.js"; diff --git a/tests/firestore-rules/rules.test.ts b/tests/firestore-rules/rules.test.ts index cff3b88d..39c0dbbb 100644 --- a/tests/firestore-rules/rules.test.ts +++ b/tests/firestore-rules/rules.test.ts @@ -59,6 +59,9 @@ const BORN_LIVE = { active: true, deletedAt: null }; // Rules derive the term from request.time.year() (UTC); compute it from the client // clock so this suite can't rot when the calendar year rolls over. const TERM = String(new Date().getUTCFullYear()); +/** The term BEFORE the current one — the term-rollover residual's whole point: a power cargo + * parked here is invisible to currentCargoGrantsEmpty(), which only reads currentTermKey(). */ +const PRIOR_TERM = String(Number(TERM) - 1); const DELETED_AT = new Date("2026-01-01T00:00:00Z"); // Fixed instant for the activity-lock fixtures so echo-update tests can resend // the exact same startAt value. @@ -584,6 +587,48 @@ beforeAll(async () => { active: true, deletedAt: null, }); + // A member whose POWER cargo sits under a PRIOR term key, leaving the CURRENT term slot + // empty. Pins the term-rollover residual (docs/specs/position-assignment-lane.md, + // "Residual: the term-rollover window"): currentCargoGrantsEmpty() reads only + // positions[currentTermKey()], so it short-circuits on `prior == null` and never sees + // this cargo. Its own doc — m_powercargo holds its power cargo in the CURRENT term and + // is what proves the guard DOES fire, so the two must not share a fixture. + await setDoc(doc(db, "members/m_priorterm_power"), { + name: "Lucía", + totalPoints: 0, + uid: "priorterm-uid", + active: true, + deletedAt: null, + positions: { [PRIOR_TERM]: { cargoId: "pos1", comisionIds: [], assignedBy: "admin-uid" } }, + }); + // Full-payload targets for the positions catalog arm. toPositionUpdateDoc + // (apps/backstage/src/features/positions/repositories/position-mapper.ts:14) spreads the + // WHOLE PositionInput on every save, so the non-Admin arm has to pass that seven-field + // shape — not just the `{ description }` partial the category-pin test writes. One board + // cargo (labels pinned) and one comisión (labels open), each its own doc so the success + // cases cannot disturb pos1/pos_cat/pos_soft. + await setDoc(doc(db, "positions/pos_payload"), { + title: "Director de Prensa", + titleFemale: "Directora de Prensa", + sigla: null, + category: "JDL", + grants: [], + term: 2026, + description: "Prensa.", + active: true, + deletedAt: null, + }); + await setDoc(doc(db, "positions/com_payload"), { + title: "Comisión de Prensa", + titleFemale: null, + sigla: "CP", + category: "Comision", + grants: [], + term: null, + description: "Prensa.", + active: true, + deletedAt: null, + }); // Category-mutation target for the positions catalog arm. Its own doc so the Admin // success case cannot disturb pos1/pos_soft, which half this suite reads. await setDoc(doc(db, "positions/pos_cat"), { @@ -596,6 +641,34 @@ beforeAll(async () => { active: true, deletedAt: null, }); + // The ADMIN category-change success case gets its own doc, away from pos_cat: the suite + // seeds once with no reset, so an Admin writing category: 'CEL' onto pos_cat would turn + // the BLOCKING non-Admin denial (which writes that same value) into an unchanged echo — + // unchanged('category') true, arm ALLOWS — leaving that denial green only by + // declaration order. Same shape as pos_cat so the two tests differ in principal only. + await setDoc(doc(db, "positions/pos_cat_admin"), { + title: "Director de Capacitación", + titleFemale: "Directora de Capacitación", + category: "JDL", + grants: [], + term: 2026, + description: "Dirección de capacitación.", + active: true, + deletedAt: null, + }); + // The ADMIN title-change success case likewise: retitling pos_payload would silently + // turn the non-Admin full-payload echo into a title CHANGE and flip that success red. + await setDoc(doc(db, "positions/pos_payload_admin"), { + title: "Director de Finanzas", + titleFemale: "Directora de Finanzas", + sigla: null, + category: "JDL", + grants: [], + term: 2026, + description: "Finanzas.", + active: true, + deletedAt: null, + }); // Malformed soft-delete state, seeded through the admin SDK because no client arm can // produce it any more (B2). These are the legacy shapes B1's well-formedness prefix // moves from client-editable to admin-SDK-only — the whole reason owner-op 4 is a @@ -640,11 +713,56 @@ beforeAll(async () => { active: "false", deletedAt: null, }); + // The missing-field twins for positions and allies. softDeleteSafe() is ONE helper + // shared by four lanes, but "shared helper" is a claim until each lane's denial is + // measured — members alone had the missing-active / missing-deletedAt tests. The + // *_nodeletedat_fix docs are separate because the repair-asymmetry test WRITES the + // missing key (a mutating success), and the denial tests must keep reading a doc + // that still lacks it. + await setDoc(doc(db, "positions/pos_noactive"), { + title: "Vocal Segundo", + titleFemale: "Vocal Segunda", + category: "JDL", + grants: [], + term: 2026, + description: "Sin active.", + deletedAt: null, + }); + await setDoc(doc(db, "positions/pos_nodeletedat"), { + title: "Vocal Tercero", + titleFemale: "Vocal Tercera", + category: "JDL", + grants: [], + term: 2026, + description: "Sin deletedAt.", + active: true, + }); + await setDoc(doc(db, "positions/pos_nodeletedat_fix"), { + title: "Vocal Cuarto", + titleFemale: "Vocal Cuarta", + category: "JDL", + grants: [], + term: 2026, + description: "Sin deletedAt, reparable.", + active: true, + }); await setDoc(doc(db, "allies/a_badactive"), { companyName: "Malformada", active: "false", deletedAt: null, }); + await setDoc(doc(db, "allies/a_noactive"), { + companyName: "Sin active", + deletedAt: null, + }); + await setDoc(doc(db, "allies/a_nodeletedat"), { + companyName: "Sin deletedAt", + active: true, + }); + await setDoc(doc(db, "allies/a_nodeletedat_fix"), { + companyName: "Sin deletedAt, reparable", + active: true, + }); await setDoc(doc(db, "positions/pos_soft"), { title: "Vocal", titleFemale: "Vocal", @@ -2038,8 +2156,128 @@ describe("firestore.rules — positions", () => { ); }); it("allows an Admin to change a position's category (the surviving authority)", async () => { + // pos_cat_admin, NOT pos_cat: writing CEL onto pos_cat would make the BLOCKING denial + // above an unchanged echo — allowed — for every run after this one (seed-once suite). + // Measured: with the shared fixture, running this test FIRST fails that denial. await assertSucceeds( - updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_cat"), { category: "CEL" }), + updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_cat_admin"), { category: "CEL" }), + ); + }); + // ── The production payload against the title/category pins ──────────────────────────── + // toPositionUpdateDoc (apps/backstage/src/features/positions/repositories/ + // position-mapper.ts:14) spreads the WHOLE PositionInput on every save: seven fields, + // no active/deletedAt. The `{ description }` partial the category-pin test writes never + // proves that shape passes the arm — the exact way a rules pin ships and 403s the first + // real save in production. These payload literals mirror the pos_payload/com_payload + // seeds field-for-field so an unchanged spread is a true echo. + const boardPayload = () => ({ + title: "Director de Prensa", + titleFemale: "Directora de Prensa", + sigla: null, + category: "JDL", + grants: [], + term: 2026, + description: "Prensa.", + }); + const comisionPayload = () => ({ + title: "Comisión de Prensa", + titleFemale: null, + sigla: "CP", + category: "Comision", + grants: [], + term: null, + description: "Prensa.", + }); + it("allows a non-Admin update:Position holder the full production payload (pins unchanged)", async () => { + await assertSucceeds( + updateDoc(doc(orgChart(), "positions/pos_payload"), { + ...boardPayload(), + description: "Prensa y comunicación.", + }), + ); + }); + it("BLOCKING: denies the full payload when it changes the category", async () => { + await assertFails( + updateDoc(doc(orgChart(), "positions/pos_payload"), { + ...boardPayload(), + category: "CEL", + term: null, + }), + ); + }); + it("BLOCKING: denies the full payload retitling a board cargo (either label)", async () => { + await assertFails( + updateDoc(doc(orgChart(), "positions/pos_payload"), { + ...boardPayload(), + title: "Presidente", + }), + ); + await assertFails( + updateDoc(doc(orgChart(), "positions/pos_payload"), { + ...boardPayload(), + titleFemale: "Presidenta", + }), + ); + }); + it("allows the full payload retitling a comisión (the branch the pin leaves open)", async () => { + // On a comisión a title is a label, not an authority field — comisiones never reach + // boardGroupFromCategory() — so the org-chart editor's rename use case stays open. + await assertSucceeds( + updateDoc(doc(orgChart(), "positions/com_payload"), { + ...comisionPayload(), + title: "Comisión de Comunicación", + }), + ); + }); + it("allows an Admin to retitle a board cargo via the full payload (the surviving authority)", async () => { + // pos_payload_admin, not pos_payload: retitling pos_payload would flip the non-Admin + // echo success above into a title change on later runs (seed-once suite). + await assertSucceeds( + updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_payload_admin"), { + title: "Director de Tesorería", + titleFemale: "Directora de Tesorería", + sigla: null, + category: "JDL", + grants: [], + term: 2026, + description: "Finanzas.", + }), + ); + }); + // The CREATE-side twin of the category pin above. Pinning only the update arm left the + // same outcome one door over: MINT a grant-free CEL cargo instead of retitling one. + // grants:[] passes the power-grant check, so `grants` alone never blocked this — and + // "seeded CEL cargos all carry grants" is true only of SEEDED ones. + const posCreator = () => as("poscreate-uid", [], ["create:Position"]); + const boardCargo = (category: string) => ({ + title: "Presidente", + titleFemale: null, + sigla: null, + category, + term: category === "JDL" ? 2026 : null, + grants: [], + description: "", + ...BORN_LIVE, + }); + it("BLOCKING: denies a non-Admin create:Position holder minting a CEL cargo", async () => { + await assertFails(setDoc(doc(posCreator(), "positions/mint_cel"), boardCargo("CEL"))); + }); + it("BLOCKING: denies a non-Admin create:Position holder minting a JDL dirección", async () => { + await assertFails(setDoc(doc(posCreator(), "positions/mint_jdl"), boardCargo("JDL"))); + }); + it("still allows a non-Admin create:Position holder to create a comisión", async () => { + // Pins that the fix narrows the board categories only — it does not close the arm. + await assertSucceeds( + setDoc(doc(posCreator(), "positions/mint_com"), { + ...boardCargo("Comision"), + title: "Comisión de Prensa", + sigla: "CP", + }), + ); + }); + it("allows an Admin to create a CEL cargo (the surviving authority)", async () => { + await assertSucceeds( + setDoc(doc(as("admin-uid", ["Admin"]), "positions/mint_cel_admin"), boardCargo("CEL")), ); }); // Deliberate fail-closed: a legacy power comisión (possible before the @@ -2125,8 +2363,9 @@ describe("firestore.rules — activity parent-initiative direction", () => { }); describe("firestore.rules — initiative featured create-gate", () => { - // Curation authority is the Admin/ProjectManager ROLE; a custom role holding only - // the create perm may create initiatives but never born-featured ones. + // Curation authority is canCurateFeatured(): the Admin ROLE or the update:Showcase PERM + // (seeded onto ProjectManager). A custom role holding only the create perm may create + // initiatives but never born-featured ones. function asCustom(uid: string, perms: string[]) { return env.authenticatedContext(uid, { roles: ["Member"], perms }).firestore(); } @@ -2661,6 +2900,25 @@ describe("firestore.rules — replacing an already-assigned power cargo (C1)", ( }), ); }); + + it("ACCEPTED HOLE (term rollover): a prior-term power cargo does NOT guard the empty current term", async () => { + // Pins the shape docs/specs/position-assignment-lane.md documents under "Residual: the + // term-rollover window" — this assertSucceeds is the test that section promises, NOT a + // regression. currentCargoGrantsEmpty() reads only positions[currentTermKey()]: + // m_priorterm_power's Admin-granting pos1 sits under the PRIOR term key, the current + // slot is empty, the guard short-circuits on `prior == null`, and an update:Position + // holder writes a grant-free cargo into the current term. claims-sync resolves from the + // same current-year key, so it recomputes roles: ['Member'] — the Admin claim is gone. + // Pre-existing (any manage:Member holder had it through the institutional arm) and + // deliberately NOT fixed here: closing it means resolving liveness across terms, its + // own pass. If this test goes RED, the guard grew cross-term eyes — delete this test + // and the Residual section together. + await assertSucceeds( + updateDoc(doc(orgChart(), "members/m_priorterm_power"), { + [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART }, + }), + ); + }); }); // B: the four softDeleteSafe() lanes (members ×2 + the positions lane, positions, allies) @@ -2824,6 +3082,36 @@ describe("firestore.rules — soft-delete well-formedness (B)", () => { }), ); }); + // The prefix is ONE helper across four lanes, but that sharing is a claim until each + // lane's denial is measured — only members had the missing-field tests. Same principals + // as the non-bool-active tests above, so the missing key is the only variable. + it("denies updating a position whose stored doc has no active key", async () => { + await assertFails( + updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_noactive"), { title: "Vocal II" }), + ); + await assertFails( + updateDoc(doc(orgChart(), "positions/pos_noactive"), { description: "Editada." }), + ); + }); + it("denies updating a position whose stored doc has no deletedAt key", async () => { + await assertFails( + updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_nodeletedat"), { + title: "Vocal III", + }), + ); + }); + it("denies updating an ally whose stored doc has no active key", async () => { + await assertFails(updateDoc(doc(secretary(), "allies/a_noactive"), { companyName: "Editada" })); + await assertFails( + updateDoc(doc(as("admin-uid", ["Admin"]), "allies/a_noactive"), { companyName: "Editada" }), + ); + }); + it("denies updating an ally whose stored doc has no deletedAt key", async () => { + await assertFails( + updateDoc(doc(secretary(), "allies/a_nodeletedat"), { companyName: "Editada" }), + ); + }); + it("pins which malformed field a client can repair and which it cannot", async () => { // Stated so nobody reads the denials above as "just write the right value". The two // halves differ, and the difference is exactly why owner-op 4 is a console/admin-SDK @@ -2843,6 +3131,24 @@ describe("firestore.rules — soft-delete well-formedness (B)", () => { updateDoc(doc(as("u", ["Membership"]), "members/m_nodeletedat_fix"), { deletedAt: null }), ); }); + it("pins the same repair asymmetry on positions and allies (one helper, four lanes)", async () => { + // active — not repairable even by Admin, on any lane: + await assertFails( + updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_badactive"), { active: true }), + ); + await assertFails( + updateDoc(doc(as("admin-uid", ["Admin"]), "allies/a_badactive"), { active: true }), + ); + // deletedAt — self-healing by stamping the null the doc should have had: + await assertSucceeds( + updateDoc(doc(as("admin-uid", ["Admin"]), "positions/pos_nodeletedat_fix"), { + deletedAt: null, + }), + ); + await assertSucceeds( + updateDoc(doc(secretary(), "allies/a_nodeletedat_fix"), { deletedAt: null }), + ); + }); it("keeps the Admin takedown arm open on a malformed member (the remedy path)", async () => { // The single most important assertion in this block. The takedown arm deliberately does diff --git a/tools/scripts/audit-soft-delete-shapes.mjs b/tools/scripts/audit-soft-delete-shapes.mjs new file mode 100644 index 00000000..9f534709 --- /dev/null +++ b/tools/scripts/audit-soft-delete-shapes.mjs @@ -0,0 +1,145 @@ +// The BLOCKING pre-deploy audit for the soft-delete well-formedness rules (owner-op 4 of +// docs/specs/position-assignment-lane.md). Those rules make a members/positions/allies doc +// missing `active` or `deletedAt`, or holding a non-bool `active`, admin-SDK-only to edit — +// so the malformed count must be known (ideally zero) BEFORE the rules deploy, and this +// script is also the remedy, not just the count: for members specifically, a fail-open +// `active` may sit PUBLISHED on the world-readable boardShowcase. project-board.ts is now +// fail-closed, but onBoardMemberWritten fires only on a members/{id} write — the very write +// the new rules deny to clients — so repairing the doc through the admin SDK here is what +// re-fires the trigger and removes (or refreshes) the public row. +// +// Read-only by default; exits non-zero when anything is found so it can gate a deploy. +// `--repair` fixes ONLY the unambiguous shapes and refuses to guess: +// deletedAt missing → deletedAt: null (the value the create arm would have +// stamped; missing can only mean "never soft-deleted" — +// a deletion always writes the timestamp) +// active missing, deletedAt → active: true (never deleted ⇒ live; every fail-open +// null/missing reader has treated the doc as live all along) +// active present but non-bool → NEVER coerced, even the string "false" — whether that +// doc was meant to be inactive is a human call +// active missing, deletedAt → reported for a human (the two fields disagree about +// non-null what state the doc is in) +// +// Targets PRODUCTION via Application Default Credentials, like seed-production.mjs: +// gcloud auth application-default login && pnpm audit:soft-delete-shapes +// Or the emulator, by setting the env first: +// FIRESTORE_EMULATOR_HOST=127.0.0.1:4010 pnpm audit:soft-delete-shapes +import { initializeApp, applicationDefault } from "firebase-admin/app"; +import { getFirestore, FieldPath } from "firebase-admin/firestore"; + +const COLLECTIONS = ["members", "positions", "allies"]; +// Page size mirrors the beacon chunk() bound; MAX_PRINT keeps the report readable on a +// large malformed set — the counts are always complete, the listing is capped. +const PAGE = 300; +const MAX_PRINT = 20; + +const REPAIR = process.argv.includes("--repair"); +const emulator = process.env.FIRESTORE_EMULATOR_HOST; +const projectId = process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCLOUD_PROJECT ?? "jci-oriente"; + +initializeApp(emulator ? { projectId } : { credential: applicationDefault(), projectId }); +const db = getFirestore(); + +console.log( + `Auditing soft-delete shapes in ${emulator ? `EMULATOR (${emulator})` : "PRODUCTION"} ` + + `project ${projectId}${REPAIR ? " — repair mode" : " — read-only"}\n`, +); + +/** Classify one doc. Returns null when well-formed, else { problems, repair | null }. */ +function classify(data) { + const problems = []; + const repair = {}; + let ambiguous = false; + + const hasActive = "active" in data; + const hasDeletedAt = "deletedAt" in data; + const deletedAtNullish = !hasDeletedAt || data.deletedAt === null; + + if (!hasDeletedAt) { + problems.push("missing deletedAt"); + repair.deletedAt = null; + } + if (!hasActive) { + problems.push("missing active"); + if (deletedAtNullish) repair.active = true; + else ambiguous = true; // deletedAt set but active missing: the fields disagree + } else if (typeof data.active !== "boolean") { + problems.push(`non-bool active (${JSON.stringify(data.active)})`); + ambiguous = true; // never coerced — a human decides what "false"-the-string meant + } + + if (problems.length === 0) return null; + return { problems, repair: ambiguous ? null : repair }; +} + +async function* scan(coll) { + let cursor = null; + for (;;) { + let q = db.collection(coll).orderBy(FieldPath.documentId()).limit(PAGE); + if (cursor) q = q.startAfter(cursor); + const snap = await q.get(); + if (snap.empty) return; + yield* snap.docs; + if (snap.size < PAGE) return; + cursor = snap.docs[snap.docs.length - 1]; + } +} + +let found = 0; +let repaired = 0; +let refused = 0; + +for (const coll of COLLECTIONS) { + const rows = []; + for await (const doc of scan(coll)) { + const verdict = classify(doc.data()); + if (verdict) rows.push({ doc, ...verdict }); + } + found += rows.length; + console.log(`${coll}: ${rows.length} malformed doc(s)`); + + for (const [i, { doc, problems, repair }] of rows.entries()) { + if (i < MAX_PRINT) console.log(` - ${coll}/${doc.id}: ${problems.join(", ")}`); + else if (i === MAX_PRINT) console.log(` … and ${rows.length - MAX_PRINT} more`); + + if (coll === "members") { + // The board-showcase consequence: under the old fail-open reader this doc may be + // published RIGHT NOW on the world-readable Directiva, and no client write can fix + // it any more. Only an admin-SDK write (this script's --repair, or the console) + // re-fires onBoardMemberWritten, whose now-fail-closed projection removes the row. + const showcase = await db.doc(`boardShowcase/${doc.id}`).get(); + if (showcase.exists && i < MAX_PRINT) { + console.log( + ` PUBLISHED: boardShowcase/${doc.id} exists — this malformed member is on ` + + "the public Directiva; repairing the doc via the admin SDK re-fires " + + "onBoardMemberWritten and takes the row down", + ); + } + } + + if (!REPAIR) continue; + if (repair) { + await doc.ref.update(repair); + repaired += 1; + if (i < MAX_PRINT) console.log(` repaired: ${JSON.stringify(repair)}`); + } else { + refused += 1; + if (i < MAX_PRINT) console.log(" REFUSED to repair (ambiguous) — fix by hand"); + } + } +} + +const remaining = REPAIR ? found - repaired : found; +console.log( + `\n${found} malformed doc(s) found` + + (REPAIR ? `; ${repaired} repaired, ${refused} refused (ambiguous)` : "") + + `; ${remaining} remaining`, +); +if (remaining > 0) { + console.log( + REPAIR ? "Ambiguous docs need a human — see above." : "Run with --repair, or fix by hand.", + ); + process.exit(1); +} +console.log("Audit clean — the soft-delete rules can ship."); +process.exit(0); From 4cbf35e794ada2ef75115ee4432dbc83d97a3d9e Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 12:04:23 -0400 Subject: [PATCH 08/16] perf(backstage): hoist the non-Admin position default to module scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-best-practices pass over the branch's .tsx changes. The only finding: the non-Admin blank-form default was an object literal built inline in the useForm call (rerender-memo-with-default-value). RHF captures defaultValues once, so the cost was an allocation per render rather than a re-render — small, but the fix is free and the constant now carries the WHY. Otherwise clean: areLabelsLocked is derived during render rather than in an effect, no waterfalls or barrel imports were added, no new dependency, no component defined inside a component, and the && conditionals guard booleans rather than possibly-zero values. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/positions/components/position-form.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/backstage/src/features/positions/components/position-form.tsx b/apps/backstage/src/features/positions/components/position-form.tsx index 6a2dcc5c..5bfc3ef3 100644 --- a/apps/backstage/src/features/positions/components/position-form.tsx +++ b/apps/backstage/src/features/positions/components/position-form.tsx @@ -28,6 +28,10 @@ const EMPTY: PositionInput = { description: "", }; +/** A non-Admin may only ever CREATE a comisión (firestore.rules `boardSurfacingCategory()`), + * so their blank form must not start on the CEL default and die on save. */ +const EMPTY_NON_ADMIN: PositionInput = { ...EMPTY, category: "Comision" }; + function SectionLabel({ children }: { children: ReactNode }) { return (

{children}

@@ -65,13 +69,10 @@ export function PositionForm({ formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(positionSchema), - // A non-Admin may only ever create a comisión (firestore.rules `boardSurfacingCategory()`), - // so a NEW cargo must not start on the CEL default and die on save. On an EDIT the stored - // category wins — it is pinned, not chosen. + // On an EDIT the stored category wins — it is pinned, not chosen. Both branches are + // module constants, so no fresh object is allocated per render. defaultValues: - canEditGrants || defaultValues - ? { ...EMPTY, ...defaultValues } - : { ...EMPTY, category: "Comision" }, + canEditGrants || defaultValues ? { ...EMPTY, ...defaultValues } : EMPTY_NON_ADMIN, }); const categoryField = register("category"); From 6fd661064750a0fe8ede33647cb7e9810b420c3c Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 12:57:59 -0400 Subject: [PATCH 09/16] fix(rules,backstage): enforce the CEL publication boundary, close the audit-script gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round. The findings that mattered: - The grant-free-CEL boundary was a claim about DATA, enforced by nothing. "Seeded CEL cargos all carry grants" is true of the seed and false the moment an Admin mints a grant-free one — which this branch's own test does. positionsAssignmentSafe()'s non-Admin branch now reads category off the same get() cargoGrantsEmpty() already pays for and refuses CEL outright, so 'Presidente' at public rank 0 stays an Admin decision. JDL stays open: that is the accepted exposure this lane exists to deliver. The replaced cargo is deliberately NOT category-gated — clearing someone off a CEL seat is exposure-reducing and must not need an Admin. - A drift guard binds boardSurfacingCategory()'s literal to BOARD_GROUPS. The rules hardcoded ['CEL','JDL'] with nothing tying it to its authority, so a third publishable group would have silently reopened the hole just closed. - --repair could PUBLISH someone. Repairing a member missing "active" writes active:true, and the re-fired trigger ADDS them to the public Directiva if they are otherwise projectable. The script announced only the takedown direction. It now forecasts publication, prints WILL PUBLISH, and withholds those repairs behind --allow-publish. - A sentinel bug: "published.get(id) ?? false" collapsed the null "unreadable" state into "not published", making the UNKNOWN branch dead code and letting unknown-exposure docs be truncated out of the operator's worklist. - The UI mirror had ZERO tests and one existing test had gone vacuous — it selected a value on a now-disabled select that already held that value, so it measured the default. A regression to register(name, {disabled}) would make RHF submit undefined and 403 every non-Admin save, and would have shipped green. - The two rules arms disagreed on a doc with no "category" key; both now use boardSurfacingCategory(). rules 468->473, backstage 732->737, types 319->323. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/nav-config.test.ts | 16 +- .../components/position-form.test.tsx | 114 +++- apps/backstage/src/lib/authz/use-can.ts | 8 +- apps/beacon/CLAUDE.md | 22 + docs/firebase-setup.md | 73 ++- docs/specs/position-assignment-lane.md | 131 ++++- firestore.rules | 55 +- .../board-surfacing-category.rules.test.ts | 71 +++ tests/firestore-rules/rules.test.ts | 112 +++- tools/scripts/audit-soft-delete-shapes.mjs | 507 ++++++++++++++++-- 10 files changed, 1019 insertions(+), 90 deletions(-) create mode 100644 packages/types/src/board-surfacing-category.rules.test.ts diff --git a/apps/backstage/src/components/nav-config.test.ts b/apps/backstage/src/components/nav-config.test.ts index a15f3b65..7f0383a8 100644 --- a/apps/backstage/src/components/nav-config.test.ts +++ b/apps/backstage/src/components/nav-config.test.ts @@ -189,13 +189,17 @@ describe("isNavItemVisible — conditional grants must not leak", () => { expect(canSee("/positions", { roles: [], perms: ["read:Position"] })).toBe(false); }); - it("offers /members to the org-chart principal — read:Member + update:Position, never update:Position alone", () => { + it("gates /members on read:Member alone — update:Position opens nothing here", () => { // Cargo assignment happens ON /members (the member roster), and the nav probes - // read:Member there. A custom role carrying only update:Position therefore cannot - // reach the page — the whole members-positions lane is inert for exactly the role the - // owner-op tells the operator to create. The operable principal carries both perms; - // this pins that pair open, and that the write perm alone still opens nothing. - expect(canSee("/members", { roles: [], perms: ["read:Member", "update:Position"] })).toBe(true); + // read:Member there. So the perm that carries the members-positions LANE is inert for + // reaching the page: a custom role holding only update:Position cannot get to the + // capability the owner-op hands it, which is why owner-op 1 mandates BOTH perms. + // Both halves are load-bearing and falsifiable: read:Member alone is what opens the + // page (drop it from the nav gate and the first line goes red), update:Position alone + // is what does not (add it as an `orCan` and the second goes red). The pair assertion + // this replaced was neither — read:Member already satisfied it, so deleting + // update:Position from it could not turn anything red. + expect(canSee("/members", { roles: [], perms: ["read:Member"] })).toBe(true); expect(canSee("/members", { roles: [], perms: ["update:Position"] })).toBe(false); }); diff --git a/apps/backstage/src/features/positions/components/position-form.test.tsx b/apps/backstage/src/features/positions/components/position-form.test.tsx index 94ef0d3f..411c507d 100644 --- a/apps/backstage/src/features/positions/components/position-form.test.tsx +++ b/apps/backstage/src/features/positions/components/position-form.test.tsx @@ -3,7 +3,7 @@ import { render as rtlRender, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { RoleDefinition } from "@luminova/types"; +import type { PositionInput, RoleDefinition } from "@luminova/types"; import { roleKeys } from "../../permissions/hooks/role-keys"; import { PositionForm } from "./position-form"; @@ -86,7 +86,13 @@ describe("PositionForm", () => { it("submits a valid comisión with term null and grants untouched", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); - render(); + // canEditGrants, NOT false. With canEditGrants={false} this form starts on + // EMPTY_NON_ADMIN (category already "Comision") AND renders the select `disabled`, so + // the selectOptions below was a silent no-op and the assertion measured the default + // — it passed without the interaction it names. An Admin form starts on CEL with the + // select enabled, so selecting "Comision" is a real state change and the payload + // assertion is about the onChange handler (term -> null, grants -> [], sigla kept). + render(); await userEvent.selectOptions(screen.getByLabelText("Categoría *"), "Comision"); await userEvent.type(screen.getByLabelText("Nombre *"), "Director de Ética"); await userEvent.type(screen.getByLabelText("Sigla *"), "CCE"); @@ -106,6 +112,110 @@ describe("PositionForm", () => { }); }); +// The UI mirror of the non-Admin pin on the positions update/create arms in firestore.rules +// (`unchanged('grants') && unchanged('category') && (!boardSurfacingCategory() || unchanged +// title/titleFemale)`, and `!boardSurfacingCategory()` on create). It had NO tests: every +// case below is the render-then-die shape — a control the rules reject, offered as editable — +// or its inverse, a control the rules allow, locked for no reason. +describe("PositionForm mirrors the non-Admin catalog pins", () => { + const boardCargo: Partial = { + category: "JDL", + term: 2026, + title: "Director de Prensa", + titleFemale: "Directora de Prensa", + description: "Prensa.", + grants: [], + }; + + it("locks title and titleFemale for a non-Admin editing a board cargo", () => { + // boardRank orders the public Directiva by the BASE title, so on a CEL/JDL cargo the + // label is an authority field and the rules pin it. Editable here = a generic + // "No se pudo guardar" on save. + render( + , + ); + expect(screen.getByLabelText("Cargo *")).toBeDisabled(); + expect(screen.getByLabelText("Variante femenina (opcional)")).toBeDisabled(); + expect(screen.getByRole("note")).toHaveTextContent(/categoría o el nombre/i); + }); + + it("BLOCKING: still submits the pinned labels on a non-Admin board-cargo save", async () => { + // The regression this exists for: `register("title", { disabled: true })` instead of a + // `disabled` prop on the Input. RHF's own `disabled` option submits the field as + // undefined, so every non-Admin save would post a doc with no title — zod rejects it + // (min 3) and onSubmit is never reached, or, past zod, firestore.rules 403s it because + // `unchanged('title')` compares a real title against null. Both halves asserted: the + // form validates AND the pinned values ride along unchanged. + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + await userEvent.type(screen.getByLabelText("Descripción *"), " y comunicación."); + await userEvent.click(screen.getByRole("button", { name: /guardar/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Director de Prensa", + titleFemale: "Directora de Prensa", + category: "JDL", + }), + ); + }); + + it("leaves a comisión's name editable for a non-Admin (a label there is not authority)", () => { + // The inverse case, and the org-chart editor's legitimate use case per owner-op 1: + // comisiones never reach boardGroupFromCategory, so the rules leave their labels open. + // Locking them would be a UI-only denial with no rule behind it. + render( + , + ); + expect(screen.getByLabelText("Nombre *")).toBeEnabled(); + expect(screen.getByLabelText("Sigla *")).toBeEnabled(); + expect(screen.getByRole("note")).toHaveTextContent(/solo un admin puede cambiar la categoría/i); + }); + + it("defaults a non-Admin CREATE to Comisión and locks the category select", () => { + // `boardSurfacingCategory()` makes CEL/JDL creation Admin-only, so the blank non-Admin + // form must not start on the CEL default and die on save. The select is disabled for + // them on every form, create and edit alike — `category` is pinned either way. + render(); + const category = screen.getByLabelText("Categoría *"); + expect(category).toBeDisabled(); + expect(category).toHaveValue("Comision"); + // A comisión is not a board cargo, so on this same form the labels stay open. + expect(screen.getByLabelText("Nombre *")).toBeEnabled(); + }); + + it("leaves the category select open for an Admin", () => { + render(); + expect(screen.getByLabelText("Categoría *")).toBeEnabled(); + expect(screen.getByLabelText("Cargo *")).toBeEnabled(); + expect(screen.queryByRole("note")).not.toBeInTheDocument(); + }); +}); + describe("PositionForm grant options are total", () => { it("still renders a stored grant whose role doc is not in the cache", () => { // The real-world consequence of deriving the option list from the doc list: MultiSelect diff --git a/apps/backstage/src/lib/authz/use-can.ts b/apps/backstage/src/lib/authz/use-can.ts index b3b79f66..ec17e427 100644 --- a/apps/backstage/src/lib/authz/use-can.ts +++ b/apps/backstage/src/lib/authz/use-can.ts @@ -30,8 +30,12 @@ export interface Can { /** May curate the public /programas page (rules' `canCurateFeatured`). Named here so the * policy lives in one place, not scattered role-array literals at each call site. */ readonly canFeatureInitiatives: boolean; - /** May assign power-granting cargos (rules' `cargoGrantsEmpty` / `createPositionsSafe` - * — Admin only). Named so the policy isn't a bare `isAdmin` at each grant site. */ + /** The Admin-only half of the positions authority — one flag because firestore.rules keys + * every part of it on the same `hasAnyRole(['Admin'])`: assigning a power-granting cargo + * (`cargoGrantsEmpty` / `currentCargoGrantsEmpty` / `createPositionsSafe`) or a CEL cargo + * at all, creating a board-surfacing cargo (`boardSurfacingCategory()`), and editing a + * stored cargo's `grants`, `category` or — on a board cargo — `title`/`titleFemale`. + * Named so the policy isn't a bare `isAdmin` at each grant site. */ readonly canAssignPowerGrants: boolean; } diff --git a/apps/beacon/CLAUDE.md b/apps/beacon/CLAUDE.md index 35f1e626..61c15672 100644 --- a/apps/beacon/CLAUDE.md +++ b/apps/beacon/CLAUDE.md @@ -205,6 +205,28 @@ undone by re-running anything: reverting means editing `roles/Member` back down. then query programs+projects on `roster.directorId` / `roster.coDirectorIds` / `roster.teamIds` (nested paths — the bare names match nothing), bounded with `.limit()` and re-projected through `chunk()`. +- **Deferred (boardShowcase stale publication under the fail-closed `active` guard):** + `projectBoard` now drops a member on `deletedAt != null || active !== true` — fail-closed, + matching `projectAlly`. That stops the NEXT publication of a member whose `active` is a + non-bool (the string `"false"`) or absent, but it cannot reach a row already published + under the old `active === false` test: `onBoardMemberWritten` fires only on a + `members/{id}` write, and `firestore.rules` now makes such a doc admin-SDK-only on every + lane but one. There is no automatic remedy — `pnpm audit:soft-delete-shapes --repair` + deliberately refuses to coerce a non-bool `active`, so it writes nothing and fires no + trigger for exactly the exposed shape. The only remedies are (a) that script's report, + which lists every exposed doc untruncated, then a Firebase console edit of `active`, or + (b) an Admin `publicProfile: false` write on the member — the takedown arm in + `firestore.rules` skips `softDeleteSafe()` on purpose so it stays open on these docs (a + rules test pins it), though backstage will not list the member because `memberDocSchema` + drops it. The script's repair moves the projection the OTHER way for a different shape: a + member missing `active` is repaired to `active: true`, which un-blocks this same fail-closed + gate and, if the rest of `projectBoard` passes, **adds** a public row. That is announced per + doc (`WILL PUBLISH:`) and withheld behind `--allow-publish`, so publication is never a + silent side effect of a shape fix. **`pnpm audit:soft-delete-shapes` is a deploy + precondition** for the + well-formedness rules (owner-op 4 of `docs/specs/position-assignment-lane.md`); it exits 1 + on findings and 2 when the run itself did not complete. Fix later with a scheduled + re-projection, which would also close the term-rollover gap above. - **boardShowcase ordering (CLOSED):** `onBoardMemberWritten` used to project `after.data()`, so a late-delivered invocation could re-publish a member from a stale payload — silently undoing an opt-out or an Admin takedown until the next member write. diff --git a/docs/firebase-setup.md b/docs/firebase-setup.md index dec5cb6d..c077df7b 100644 --- a/docs/firebase-setup.md +++ b/docs/firebase-setup.md @@ -351,25 +351,72 @@ For wiping production data, see the runbook at `tools/scripts/wipe-prod.md`. ## Soft-Delete Shape Audit (pre-deploy gate) `pnpm audit:soft-delete-shapes` scans `members`, `positions` and `allies` for docs -missing `active`/`deletedAt` or holding a non-bool `active` — the shapes the -well-formedness rules make admin-SDK-only to edit (owner-op 4 of -`docs/specs/position-assignment-lane.md`, BLOCKING before those rules deploy). It -exits non-zero when anything is found, so it can gate a deploy. Read-only by -default; `--repair` fixes only the unambiguous shapes (a missing `deletedAt` -becomes `null`; a missing `active` on a never-deleted doc becomes `true`) and -refuses to guess at a non-bool `active` — that one is a human decision. For a -malformed **member** it also reports whether a `boardShowcase` row is currently -published: the admin-SDK repair is what re-fires `onBoardMemberWritten` and takes -the row down, since the rules deny the client write that would have. +whose soft-delete pair is malformed — the shapes the well-formedness rules make +admin-SDK-only to edit (owner-op 4 of `docs/specs/position-assignment-lane.md`, +BLOCKING before those rules deploy). It exits non-zero when anything is found, so +it can gate a deploy. Read-only by default; `--repair` fixes only the unambiguous +shapes (a missing `deletedAt` becomes `null`; a missing `active` on a never-deleted +doc becomes `true`) and refuses to guess at the rest: + +| Shape | `--repair` | +|---|---| +| `deletedAt` missing | → `null` | +| `active` missing, `deletedAt` null/missing | → `true` | +| `active` present but not a bool (the string `"false"`, or `null`) | refused — a human decides | +| `active` missing, `deletedAt` set | refused — the two fields disagree | +| `active: true` **with** a non-null `deletedAt` (the **ghost**) | refused — same disagreement. This is the one malformed shape that is client-reachable and that `memberDocSchema` accepts, so backstage lists the doc as an ordinary live member while every `deletedAt`-aware reader treats it as gone | +| `deletedAt` present, non-null, not a Timestamp (an ISO string, a number) | refused — the zod schemas reject it and the rules pin it immutable, so the doc is invisible and unwritable at once | + +Repair is all-or-nothing per doc: when one field is ambiguous the unambiguous fix +is withheld too, so whoever resolves it sees the shape the audit reported. + +Exit codes are distinct on purpose: **1** = the run completed and found malformed +docs (the gate); **2** = the run did *not* complete (a per-doc read/write failed, +or a production `--repair` was not confirmed). A crash must not read as a clean +gate failure. + +For a malformed **member** it also reports whether a `boardShowcase` row is +currently published — but `--repair` is **not** a takedown, and removes no public +row: + +- A **repaired** doc declares the member live (`active: true` / `deletedAt: null`), + so the re-fired `onBoardMemberWritten` re-publishes it. The row correctly stays up. +- A **refused** doc (the non-bool `active`, the shape that was fail-open published) + is not written at all, so no trigger fires and the row **stays published**. Two + hand remedies, both printed per doc: a Firebase console edit of `active`, or an + Admin `publicProfile: false` write — the members takedown arm in `firestore.rules` + deliberately skips `softDeleteSafe()` and stays open on exactly these docs (pinned + by a rules test). Backstage will not list such a member (`memberDocSchema` drops + it), so make that write from the console or directly. + +`--repair` **can add one**, though, and that direction is opt-in. Writing +`active: true` un-blocks the fail-closed `projectBoard` gate, so a member who also +carries `publicProfile: true` (the stamped org-wide default), a `uid`, a pinned +portrait and a current-term CEL/JDL cargo is **newly published** by the re-fired +trigger — publication as a side effect of a shape fix. Each such member gets a +`WILL PUBLISH:` line and their repair is **withheld** (counted separately from the +ambiguous refusals) unless `--allow-publish` is passed. The alternative to passing +it is to set `publicProfile: false` on the member first — the opt-out they never +exercised — and re-run. The forecast fails safe: a gate it cannot settle (an +unreadable `positions` or `boardShowcase` doc) is named in the output and the +member is announced anyway, never quietly repaired. + +Ids that need a hand fix, and every PUBLISHED / WILL PUBLISH / UNKNOWN-publication +line, are never truncated; only the benign repairable listing is capped. ```bash gcloud auth application-default login -pnpm audit:soft-delete-shapes # count + gate -pnpm audit:soft-delete-shapes --repair # fix the unambiguous, report the rest +pnpm audit:soft-delete-shapes # count + gate +pnpm audit:soft-delete-shapes --repair # fix the unambiguous, report the rest +pnpm audit:soft-delete-shapes --repair --allow-publish # …incl. the repairs that publish ``` Same credential model as `seed:production`; point it at the emulator by setting -`FIRESTORE_EMULATOR_HOST` first. +`FIRESTORE_EMULATOR_HOST` first. A **production** `--repair` writes to members and, +through the trigger, re-projects the world-readable Directiva, so it demands an +explicit confirmation — type `repair-production-shapes` at the prompt, or pass +`--confirm=repair-production-shapes` in a non-interactive shell. The emulator needs +no confirmation. ## Correo de invitación diff --git a/docs/specs/position-assignment-lane.md b/docs/specs/position-assignment-lane.md index 3e4e9f54..3954b0e5 100644 --- a/docs/specs/position-assignment-lane.md +++ b/docs/specs/position-assignment-lane.md @@ -93,15 +93,30 @@ editor mint members: creation stays `canDo('create','Member')`. `positions..cargoId` is the sole input to `boardShowcase`, the world-readable public Directiva (`firestore.rules:706-709`, `allow read: if true`; projection at `apps/beacon/src/showcase/project-board.ts:63-97`). `boardGroupFromCategory` -(`packages/types/src/engine/board-public.ts:39-41`) publishes both `CEL` and `JDL`. Every -**seeded CEL** cargo carries non-empty `grants`, so the non-Admin branch blocks those. **JDL +(`packages/types/src/engine/board-public.ts:39-41`) publishes both `CEL` and `JDL`. **JDL direcciones are hand-created in `/positions` and normally carry `grants: []`** — so an `update:Position` holder can assign a grant-free board cargo to any member, **including themselves**, and put that person on the public site under that title. Combined with the self-service arm (which they own for their own `publicProfile` and `profilePicture`), that is self-publication with no Admin action. -This is **accepted as the feature**, not an oversight: assigning a JDL dirección *is* the +**The exposure is scoped to JDL, and that scoping is now ENFORCED rather than assumed.** An +earlier draft argued CEL was blocked because "every seeded CEL cargo carries non-empty +`grants`" — a claim about DATA, with nothing holding it true. The create arm deliberately +still lets an **Admin** mint a grant-free CEL cargo (this branch's own rules suite creates +exactly one, `mint_cel_admin`), and from that moment the claim is false: any `update:Position` +holder could self-assign it, at CEL rank, `Presidente` included — `boardRank` maps that title +to 0, so they would head the public Directiva. `positionsAssignmentSafe()`'s non-Admin branch +therefore reads `category` off the cargo doc it was **already** fetching for +`cargoGrantsEmpty()` (`cargoAssignableByNonAdmin()` — one `get()`, two questions) and refuses +`CEL` whatever its grants. Cost: an Admin who mints a ceremonial grant-free CEL cargo must +also assign it — consistent with the create arm, which already made minting one Admin-only. +Three rules tests pin it (non-Admin CEL denied, grant-free JDL still allowed, Admin +unaffected) plus one for the institutional lane, and the conjunct is mutation-proven. +The REPLACED cargo (`currentCargoGrantsEmpty()`) is deliberately not category-gated: clearing +a member off a grant-free CEL cargo only reduces exposure. + +The JDL half is **accepted as the feature**, not an oversight: assigning a JDL dirección *is* the darkened surface PR 2 created, and appearing on the Directiva is the correct product outcome of holding that cargo. It is not privilege escalation — no claim is minted, because `resolveTrustedGrants` returns at `sync.ts:67` on `grants.length === 0`, before the @@ -198,7 +213,12 @@ old-side test must target `members/m_powercargo` (`:505-512`), which the C1 desc `positions` — both currently deny via `assignedBySelf()`, an incidental mechanism that a future refactor could remove unnoticed - pins the accepted exposure: an `update:Position` holder can assign a grant-free JDL board cargo +- BLOCKING: denies that same principal a grant-free **CEL** cargo, with the JDL allow adjacent + so the denial cannot pass for the wrong reason; plus the Admin allow, and the institutional + (`manage:Member`) lane held to the same boundary - denies a non-Admin changing a position's `category` (the new catalog conjunct) +- allows a non-Admin retitling a legacy cargo with **no `category` key** — the create and + update arms now ask the same `!boardSurfacingCategory()` question Every new create-shaped test carries `active`/`deletedAt`, or change B makes it vacuous. @@ -309,14 +329,27 @@ if (member.deletedAt != null || member.active !== true) return null; B1 and B2 stop new malformed docs; B3 is what stops an existing one being published. It also ends the split with `project-ally.ts`. -**B3 alone does not reach the rows already published — corrected after review.** An earlier -draft said "only B3 removes the existing public exposure", which overstates its reach: -`onBoardMemberWritten` fires only on a `members/{id}` write, so a member already on -`boardShowcase` under the fail-open predicate stays there until their doc is next written — -and B1 is precisely what makes that write impossible from the client. The remedy is -therefore operational, not automatic: `pnpm audit:soft-delete-shapes --repair` writes through -the admin SDK, which re-fires the trigger and deletes the row. That is why owner-op 4 is -blocking, and why the script is a repair tool rather than a count. +**B3 alone does not reach the rows already published, and neither does the audit script — +corrected twice after review.** An earlier draft said "only B3 removes the existing public +exposure", which overstates its reach: `onBoardMemberWritten` fires only on a `members/{id}` +write, so a member already on `boardShowcase` under the fail-open predicate stays there until +their doc is next written. The correction to *that* correction — "`--repair` re-fires the +trigger and deletes the row" — is also false, in both directions: + +- A doc `--repair` can fix declares the member **live** (`deletedAt: null`, `active: true`). + The re-fired trigger re-publishes it. The row correctly stays up; nothing is taken down. +- The doc that is actually exposed — a non-bool `active`, the string `"false"` — is + **refused**, because coercing it is a human call. Nothing is written, so no trigger fires, + and the row survives. + +So the true division of labour is: **B3 stops the next publication, the script detects and +gates, and the existing row comes down by hand.** The script prints the two remedies per +exposed doc: a Firebase console edit of `active`, or an Admin `publicProfile: false` write — +which the members takedown arm permits precisely because it does not call `softDeleteSafe()` +(B1's whole reason for living inside the helper; a rules test pins the arm open on a +malformed member). Neither is reachable from backstage, because `memberDocSchema` drops the +doc from every list. That is why owner-op 4 is blocking: the script's value is a complete, +untruncated worklist plus a non-zero exit, not an automatic fix. ### Not in scope @@ -540,7 +573,10 @@ so all three assertions go false until the fixtures carry `perms: ["update:Showc CEL/JDL cargo — `title`/`titleFemale`. Two consequences to accept before granting it: - **They may publish a member, including themselves, to the public Directiva**, because - grant-free JDL direcciones are board cargos. + grant-free JDL direcciones are board cargos. The ceiling is JDL and it is a RULE, not a + data assumption: `cargoAssignableByNonAdmin()` refuses a `CEL` cargo to a non-Admin + whatever its `grants`, so `Presidente` at public rank 0 stays an Admin decision on both + ends (minting the cargo and assigning it). - **"Never a power cargo" holds for the CURRENT term only.** `currentCargoGrantsEmpty()` reads `positions[currentTermKey()]`, so between the UTC-year rollover and the victim's next write, a member whose Admin comes from last year's cargo has an empty current-term @@ -560,11 +596,28 @@ so all three assertions go false until the fixtures carry `perms: ["update:Showc to edit the moment B1 ships, with no UI affordance to repair it — and unlike the first draft's claim, they are editable **today**. Run `pnpm audit:soft-delete-shapes` (`tools/scripts/audit-soft-delete-shapes.mjs`, documented in `docs/firebase-setup.md`): - it exits non-zero on any finding so it gates the deploy, `--repair` fixes the unambiguous - shapes through the admin SDK (which also re-fires `onBoardMemberWritten` — the only - remaining path that unpublishes a malformed member from the public Directiva), and it - refuses to guess at a non-bool `active`. Repair the reported remainder from the console - before the rules deploy, or accept a known, counted set. + it exits non-zero on any finding so it gates the deploy (exit 1 = findings, exit 2 = the + run did not complete), `--repair` fixes the unambiguous shapes through the admin SDK, and + it refuses to guess at a non-bool `active`, a **ghost** (`active: true` with a non-null + `deletedAt` — client-reachable, and the one malformed shape `memberDocSchema` accepts, so + it renders as an ordinary live member) or a **non-Timestamp `deletedAt`**. **`--repair` + takes no `boardShowcase` row down** — see B3: a repaired doc re-publishes, and the exposed + shape is the refused one. For each malformed member the script reports whether a public row + is live and prints the two hand remedies (console `active` edit, or an Admin + `publicProfile: false` write through the takedown arm). + + **`--repair` can ADD a public row, and that is opt-in.** Writing `active: true` un-blocks + the fail-closed `projectBoard` gate, so a member who also carries `publicProfile: true` + (the stamped org-wide default), a `uid`, a pinned portrait and a current-term CEL/JDL cargo + is **newly published** by the re-fired trigger — publication as a side effect of a shape + fix. Every such member gets a `WILL PUBLISH:` line and their repair is **withheld** (a + count of its own, apart from the ambiguous refusals) unless `--allow-publish` is passed. + The forecast mirrors `projectBoard` by hand and fails safe: any gate it cannot settle (an + unreadable `positions` doc, an unreadable `boardShowcase` doc) is named in the output and + the member is still announced, never quietly repaired. + + Clear the reported remainder that way before the rules deploy, or accept a known, counted + set. A production `--repair` requires an explicit confirmation. ## Deploy order @@ -612,9 +665,53 @@ Found by the mandated review round, after the code was written: "positive-and-inert" claims-mint test were both described as existing and did not. Both written. 13. **B3's reach was overstated** — see B3 above. +14. **And so was the audit script's.** The correction in 13 replaced one false claim with + another: `--repair` does not take a `boardShowcase` row down in any branch — a repaired + doc re-publishes, and the exposed non-bool `active` is refused, so nothing is written at + all. The script is a gate plus complete detection; the public row comes down by hand + (console `active` edit, or the Admin `publicProfile: false` takedown arm). B3 and + owner-op 4 rewritten; `docs/firebase-setup.md` too. + +Found by the final review round: + +15. **The CEL half of the exposure was ASSUMED closed, not enforced** — "seeded CEL cargos all + carry grants" is a data claim, and this branch's own suite mints the counterexample + (`mint_cel_admin`). `cargoAssignableByNonAdmin()` now refuses a `CEL` cargo to a non-Admin + off the get() `cargoGrantsEmpty()` already paid for. Section A and owner-op 1 rewritten + from "assumed" to "enforced". +16. **Nothing tied `boardSurfacingCategory()` to `BOARD_GROUPS`.** A third publishable group + would have reopened correction 8 silently. `packages/types/src/board-surfacing-category. + rules.test.ts` parses the rules literal and asserts the set — the fourth instance of this + repo's parse-the-rules-file pattern. +17. **The two catalog arms disagreed on an absent/unrecognized `category`.** Create asked + `!boardSurfacingCategory()`, the update escape hand-rolled `== 'Comision'`, so a legacy + doc with no `category` was non-board on create and board on update — fail-closed, but + over-restrictive and a second definition of the same question. One predicate now. +18. **A sentinel bug made the audit script's UNKNOWN-publication branch dead code.** + `published.get(id) ?? false` falls back on exactly the `null` that meant "unreadable", so + one rejected `getAll` chunk silently reclassified those members as unpublished — and + therefore truncatable. Proven against the emulator by injecting a `getAll` rejection: with + the bug, zero UNKNOWN lines; fixed, one per member. +19. **`--repair` could PUBLISH a member and announced only takedowns.** See owner-op 4: + `WILL PUBLISH:` per doc, withheld behind `--allow-publish`. +20. **`classify()` under-reported two shapes** it is the blocking gate for: the ghost + (`active: true` + non-null `deletedAt`) and a non-Timestamp `deletedAt`. +21. **The UI mirror had no tests and one existing test had gone vacuous.** `position-form. + test.tsx`'s comisión submit selected a category on a select that was both `disabled` and + already defaulted to that value — the interaction was a no-op and the assertion measured + the default. Fixed, plus coverage for the label/category locks, including a BLOCKING test + that would go red on `register("title", { disabled: true })` (RHF's own `disabled` submits + the field as `undefined`, which 403s every non-Admin save). ## Residuals +- **A stale `boardShowcase` row for a member with a non-bool `active`** (B3): fail-closed + `projectBoard` stops the next publication, but an existing row is removed only by a + `members/{id}` write — which `--repair` refuses to fabricate for that shape. Detected and + gated by `pnpm audit:soft-delete-shapes`, remediated by hand. Recorded in + `apps/beacon/CLAUDE.md` next to the trigger that owns it. +- **Publication of a grant-free JDL dirección by an `update:Position` holder** — the accepted + exposure, narrowed to JDL by rule (correction 15) rather than by an assumption about data. - The coupling half of `roleLifecycleSafe()` on the four lanes (B, Not in scope). - The term-rollover window in `currentCargoGrantsEmpty()` (A, Residual). - The port-level divergence in C: a zod-rejected doc reads ABSENT to backstage, COVERED to beacon. diff --git a/firestore.rules b/firestore.rules index 8b69f5fa..1ea9c2d6 100644 --- a/firestore.rules +++ b/firestore.rules @@ -147,6 +147,33 @@ service cloud.firestore { return assignedCargoId() == null || get(/databases/$(database)/documents/positions/$(assignedCargoId())).data.grants.size() == 0; } + // The two questions a non-Admin assignment must answer, read off ONE cargo doc — this + // is the same get() cargoGrantsEmpty() already pays for, with `category` taken from it + // rather than a second read. + // grants.size() == 0 the claims-mint boundary (as cargoGrantsEmpty). + // category != 'CEL' the PUBLICATION boundary. boardGroupFromCategory publishes + // CEL and JDL alike, and boardRank orders CEL by statutory + // title with 'Presidente' at 0 — so a grant-free CEL cargo, + // self-assigned through the members-positions lane, puts its + // holder at the head of the world-readable Directiva as the + // chapter president, with no Admin action in the chain. + // That was previously argued blocked because "seeded CEL cargos all carry grants" — a + // claim about DATA, enforced by nothing. The create arm below deliberately still lets an + // ADMIN mint a grant-free CEL cargo (rules.test.ts creates exactly one, `mint_cel_admin`), + // and from that moment the data claim is false. This makes the boundary a RULE. + // JDL stays open: grant-free direcciones are the accepted publication exposure this lane + // exists to deliver (spec A, owner-op 1). CEL is the executive committee, whose seats are + // an Admin decision on both ends — the create arm already says so. + // Assignment only. currentCargoGrantsEmpty() (the cargo being REPLACED) is deliberately + // NOT category-gated: clearing a member off a grant-free CEL cargo is exposure-reducing, + // and denying it would strand a takedown behind an Admin. + function cargoAssignableByNonAdmin() { + return assignedCargoId() == null + || nonAdminAssignable(get(/databases/$(database)/documents/positions/$(assignedCargoId())).data); + } + function nonAdminAssignable(cargo) { + return cargo.grants.size() == 0 && cargo.get('category', '') != 'CEL'; + } // The OTHER side of cargoGrantsEmpty(): the cargo being REPLACED. assignedCargoId() // reads request.resource.data — the post-write cargo — so without this a manage:Member // holder could overwrite a president's positions. with a grant-free cargo, @@ -180,7 +207,8 @@ service cloud.firestore { function positionsAssignmentSafe() { return positionsDelta().hasOnly([currentTermKey()]) && assignedBySelf() - && (hasAnyRole(['Admin']) || (cargoGrantsEmpty() && currentCargoGrantsEmpty())); + && (hasAnyRole(['Admin']) + || (cargoAssignableByNonAdmin() && currentCargoGrantsEmpty())); } // Create has no prior resource to diff, so it can't use positionsDelta(); it // applies the same self-stamp + power-cargo-Admin-only gate to any positions it @@ -435,14 +463,16 @@ service cloud.firestore { // Positions-only lane: an org-chart editor who is NOT a member editor. Keyed on // update:Position — the same capability that governs the positions CATALOG — and // confined to the positions map. The power-cargo restriction is NOT relaxed: - // positionsAssignmentSafe()'s non-Admin branch still demands cargoGrantsEmpty() && - // currentCargoGrantsEmpty(), so this principal assigns and clears grant-free cargos - // only, on BOTH sides of a swap. + // positionsAssignmentSafe()'s non-Admin branch still demands + // cargoAssignableByNonAdmin() && currentCargoGrantsEmpty(), so this principal assigns + // and clears grant-free cargos only, on BOTH sides of a swap. // - // This lane IS a public publication authority, deliberately: grant-free JDL - // direcciones are board cargos, so its holder can put a member — including - // themselves — on the world-readable Directiva. No claim is minted (claims-sync - // returns early on grants.length == 0). Accepted, and pinned by a rules test. + // This lane IS a public publication authority, deliberately, and its ceiling is JDL: + // grant-free JDL direcciones are board cargos, so its holder can put a member — + // including themselves — on the world-readable Directiva under a dirección. No claim + // is minted (claims-sync returns early on grants.length == 0). Accepted, and pinned by + // a rules test. CEL is NOT in that grant — cargoAssignableByNonAdmin() refuses it + // whatever its grants, so 'Presidente' at public rank 0 stays an Admin decision. // // memberWriteInvariants() is implied by hasOnly(['positions']) TODAY and is stated // anyway: if hasOnly is ever widened — the obvious future edit is adding a second @@ -515,11 +545,18 @@ service cloud.firestore { // which is the legitimate org-chart-editor use case owner-op 1 describes. // `unchanged('category')` is evaluated first, so reading the POST-write category to // decide which branch applies cannot be steered by the writer. + // + // The escape is `!boardSurfacingCategory()`, NOT a hand-rolled `== 'Comision'`: the two + // disagree on a doc whose `category` is absent or unrecognized (a legacy doc predating + // the field). The hand-rolled form treated that as a board cargo and pinned its labels — + // fail-closed, so never a hole, but over-restrictive on exactly the docs an org-chart + // editor has the most reason to fix, and a second definition of "is this published" + // drifting beside the create arm's (guardrail #1). One predicate, both arms. allow update: if canDo('update', 'Position') && (hasAnyRole(['Admin']) || (unchanged('grants') && unchanged('category') - && (request.resource.data.get('category', '') == 'Comision' + && (!boardSurfacingCategory() || (unchanged('title') && unchanged('titleFemale'))))) && softDeleteSafe() && comisionGrantsEmpty(); diff --git a/packages/types/src/board-surfacing-category.rules.test.ts b/packages/types/src/board-surfacing-category.rules.test.ts new file mode 100644 index 00000000..309e69bf --- /dev/null +++ b/packages/types/src/board-surfacing-category.rules.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { BOARD_GROUPS, boardGroupFromCategory } from "./engine/board-public.js"; + +// firestore.rules cannot import this package, so boardSurfacingCategory() hand-writes the +// set of Position categories that reach the world-readable Directiva. This package owns the +// authority — BOARD_GROUPS / boardGroupFromCategory, which beacon's projectBoard actually +// consults — so it owns the proof the two agree ("rules mirror code", guardrail #2), the +// same job as role-name-bound.rules.test and member-self-lane.rules.test. +// +// What goes wrong without it: the two literals are the ONLY thing standing between a +// non-Admin create:Position holder and a self-minted board cargo. Add a third publishable +// group to BOARD_GROUPS — beacon starts publishing it — and the rules create arm still reads +// `in ['CEL','JDL']`, so minting one is a non-Admin write again. That is precisely the hole +// `boardSurfacingCategory()` was added to close, silently reopened, with nothing red. +// Runs in the fast `checks` job (no emulator). +// +// Parsed out of boardSurfacingCategory() specifically, not matched loose: 'CEL' and 'JDL' +// appear throughout the rules (the CEL conjunct in nonAdminAssignable(), comment prose), so +// a repo-wide search for the strings would pass on the wrong occurrence. + +const RULES = readFileSync( + fileURLToPath(new URL("../../../firestore.rules", import.meta.url)), + "utf8", +); + +function parseBoardSurfacingCategories(rules: string): string[] { + const fn = rules.match(/function boardSurfacingCategory\(\)\s*\{[\s\S]*?\n {4}\}/)?.[0]; + if (fn === undefined) throw new Error("boardSurfacingCategory() not found in firestore.rules"); + const list = fn.match(/\bin\s*\[([^\]]*)\]/)?.[1]; + if (list === undefined) { + throw new Error("boardSurfacingCategory() no longer tests membership of a [...] literal"); + } + return list + .split(",") + .map((entry) => entry.trim().replace(/^'(.*)'$/, "$1")) + .filter((entry) => entry.length > 0); +} + +describe("firestore.rules boardSurfacingCategory is in sync with BOARD_GROUPS", () => { + const categories = parseBoardSurfacingCategories(RULES); + + it("gates exactly the categories BOARD_GROUPS publishes", () => { + expect(new Set(categories)).toEqual(new Set(BOARD_GROUPS)); + }); + + // BOARD_GROUPS is the constant; boardGroupFromCategory is what projectBoard calls. Bind + // the rules literal to the FUNCTION too, so a hand-written mapping that drifts from the + // constant it is derived from cannot slip between them. + it("names only categories boardGroupFromCategory actually publishes", () => { + for (const category of categories) { + expect(boardGroupFromCategory(category), category).not.toBeNull(); + } + }); + + it("omits no category boardGroupFromCategory publishes", () => { + for (const group of BOARD_GROUPS) { + expect(boardGroupFromCategory(group)).not.toBeNull(); + expect(categories, group).toContain(group); + } + }); + + // The guard is only worth having if the non-board side stays non-board: `Comision` must + // be absent from both, or the create arm goes Admin-only for the one category a non-Admin + // is supposed to be able to mint. + it("leaves Comision out of both", () => { + expect(boardGroupFromCategory("Comision")).toBeNull(); + expect(categories).not.toContain("Comision"); + }); +}); diff --git a/tests/firestore-rules/rules.test.ts b/tests/firestore-rules/rules.test.ts index 39c0dbbb..7a7280f2 100644 --- a/tests/firestore-rules/rules.test.ts +++ b/tests/firestore-rules/rules.test.ts @@ -656,6 +656,20 @@ beforeAll(async () => { active: true, deletedAt: null, }); + // A LEGACY cargo with no `category` key at all — the doc shape the create and update + // arms used to disagree about. `!boardSurfacingCategory()` reads it as non-board (labels + // open), matching what the create arm has always said; the hand-rolled `== 'Comision'` + // it replaced read it as a board cargo and pinned its labels shut. + await setDoc(doc(db, "positions/pos_nocategory"), { + title: "Comisión Heredada", + titleFemale: null, + sigla: "CH", + grants: [], + term: null, + description: "Sin categoría.", + active: true, + deletedAt: null, + }); // The ADMIN title-change success case likewise: retitling pos_payload would silently // turn the non-Admin full-payload echo into a title CHANGE and flip that success red. await setDoc(doc(db, "positions/pos_payload_admin"), { @@ -773,6 +787,32 @@ beforeAll(async () => { active: true, deletedAt: null, }); + // The artifact that made "seeded CEL cargos all carry grants" the wrong kind of defense: + // a grant-free CEL cargo at statutory rank 0. An Admin can mint one (the create arm + // permits it, and "allows an Admin to create a CEL cargo" does exactly that as + // mint_cel_admin). Seeded here rather than reusing that test's output so the + // cargoAssignableByNonAdmin() probes below cannot pass or fail on declaration order. + await setDoc(doc(db, "positions/pos_cel_free"), { + title: "Presidente", + titleFemale: "Presidenta", + sigla: null, + category: "CEL", + grants: [], + term: null, + description: "Preside el capítulo.", + active: true, + deletedAt: null, + }); + // Assignment target for the Admin half of the CEL probe. Its own doc: the Admin case + // SUCCEEDS, so sharing m_positions would leave a CEL cargo on a doc four other lane + // tests write, and the next currentCargoGrantsEmpty() read would answer about it. + await setDoc(doc(db, "members/m_celadmin"), { + name: "Renata", + totalPoints: 0, + uid: "celadmin-uid", + active: true, + deletedAt: null, + }); await setDoc(doc(db, "showcase/s1"), { id: "s1", kind: "Project", title: "Eco" }); await setDoc(doc(db, "boardShowcase/b1"), { id: "b1", @@ -2219,6 +2259,21 @@ describe("firestore.rules — positions", () => { }), ); }); + it("lets a non-Admin retitle a legacy cargo that has no category key (both arms agree)", async () => { + // The create arm asks `!boardSurfacingCategory()` and the update arm now asks the same + // question, so a doc whose `category` is absent (or an unrecognized value) is non-board + // on BOTH — creatable by a non-Admin, therefore editable by one. Under the + // `== 'Comision'` escape this write was DENIED: fail-closed, so never a hole, but it + // locked the labels of exactly the legacy docs an org-chart editor is meant to clean up. + // Falsifiable both ways: restore `== 'Comision'` and this goes red; drop the escape's + // guard entirely and "denies the full payload retitling a board cargo" goes red. + await assertSucceeds( + updateDoc(doc(orgChart(), "positions/pos_nocategory"), { + title: "Comisión de Actas", + titleFemale: "Comisión de Actas", + }), + ); + }); it("allows the full payload retitling a comisión (the branch the pin leaves open)", async () => { // On a comisión a title is a label, not an authority field — comisiones never reach // boardGroupFromCategory() — so the org-chart editor's rename use case stays open. @@ -2845,6 +2900,57 @@ describe("firestore.rules — member positions assignment", () => { ); }); + it("BLOCKING: denies the update:Position lane assigning a GRANT-FREE CEL cargo", async () => { + // The publication half of cargoAssignableByNonAdmin(), and the conjunct this test + // exists for. pos_cel_free carries grants: [], so cargoGrantsEmpty() — the whole + // non-Admin check before this branch — says yes; only `category != 'CEL'` denies it. + // What it stops: boardRank maps 'Presidente' to 0, so this write would put the + // org-chart editor at the head of the world-readable Directiva as chapter president. + // If this goes green, neutralize nothing and re-read the rule: the CEL conjunct is gone. + await assertFails( + updateDoc(doc(orgChart(), "members/m_positions"), { + [`positions.${TERM}`]: { cargoId: "pos_cel_free", comisionIds: [], assignedBy: ORG_CHART }, + }), + ); + }); + + it("still allows the update:Position lane a grant-free JDL dirección (the accepted exposure survives)", async () => { + // The paired ALLOW, adjacent on purpose: it is what proves the denial above is the CEL + // conjunct and not the lane closing on grant-free board cargos generally. pos_soft is + // JDL with grants: [] — byte-identical to pos_cel_free but for `category`. + await assertSucceeds( + updateDoc(doc(orgChart(), "members/m_positions"), { + [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART }, + }), + ); + }); + + it("leaves Admin unaffected — an Admin may still assign a grant-free CEL cargo", async () => { + // The conjunct lives inside the non-Admin branch only. Seating the CEL is an Admin + // decision on BOTH ends (the create arm already made minting one Admin-only); this pins + // that the fix narrowed the delegate, not the authority. + await assertSucceeds( + updateDoc(doc(as("admin-uid", ["Admin"]), "members/m_celadmin"), { + [`positions.${TERM}`]: { + cargoId: "pos_cel_free", + comisionIds: [], + assignedBy: "admin-uid", + }, + }), + ); + }); + + it("BLOCKING: denies Membership assigning a grant-free CEL cargo too (the conjunct is not lane-local)", async () => { + // positionsAssignmentSafe() is shared by the institutional members arm, so a manage:Member + // holder is held to the same publication boundary — otherwise the fix would just move the + // door. m_positions, not m1: m1 ends this block holding a power cargo. + await assertFails( + updateDoc(doc(as("mem-uid", ["Membership"]), "members/m_positions"), { + [`positions.${TERM}`]: { cargoId: "pos_cel_free", comisionIds: [], assignedBy: "mem-uid" }, + }), + ); + }); + it("ACCEPTED EXPOSURE: an update:Position holder may put a member — including itself — on the public Directiva", async () => { // Deliberate, not an oversight (see docs/specs/position-assignment-lane.md, section A). // pos_soft is a JDL dirección with grants: [], and boardGroupFromCategory publishes both @@ -2905,10 +3011,12 @@ describe("firestore.rules — replacing an already-assigned power cargo (C1)", ( // Pins the shape docs/specs/position-assignment-lane.md documents under "Residual: the // term-rollover window" — this assertSucceeds is the test that section promises, NOT a // regression. currentCargoGrantsEmpty() reads only positions[currentTermKey()]: - // m_priorterm_power's Admin-granting pos1 sits under the PRIOR term key, the current + // m_priorterm_power's Treasury-granting pos1 sits under the PRIOR term key, the current // slot is empty, the guard short-circuits on `prior == null`, and an update:Position // holder writes a grant-free cargo into the current term. claims-sync resolves from the - // same current-year key, so it recomputes roles: ['Member'] — the Admin claim is gone. + // same current-year key, so it recomputes roles: ['Member'] — the Treasury claim is gone. + // The grant is Treasury rather than Admin only because that is what pos1 seeds; the + // shape is "any cargo-conferred claim", and Admin sits in it the same way. // Pre-existing (any manage:Member holder had it through the institutional arm) and // deliberately NOT fixed here: closing it means resolving liveness across terms, its // own pass. If this test goes RED, the guard grew cross-term eyes — delete this test diff --git a/tools/scripts/audit-soft-delete-shapes.mjs b/tools/scripts/audit-soft-delete-shapes.mjs index 9f534709..faee8092 100644 --- a/tools/scripts/audit-soft-delete-shapes.mjs +++ b/tools/scripts/audit-soft-delete-shapes.mjs @@ -1,14 +1,32 @@ // The BLOCKING pre-deploy audit for the soft-delete well-formedness rules (owner-op 4 of // docs/specs/position-assignment-lane.md). Those rules make a members/positions/allies doc // missing `active` or `deletedAt`, or holding a non-bool `active`, admin-SDK-only to edit — -// so the malformed count must be known (ideally zero) BEFORE the rules deploy, and this -// script is also the remedy, not just the count: for members specifically, a fail-open -// `active` may sit PUBLISHED on the world-readable boardShowcase. project-board.ts is now -// fail-closed, but onBoardMemberWritten fires only on a members/{id} write — the very write -// the new rules deny to clients — so repairing the doc through the admin SDK here is what -// re-fires the trigger and removes (or refreshes) the public row. +// so the malformed count must be known (ideally zero) BEFORE the rules deploy. +// +// What this script provides is a blocking gate plus COMPLETE detection. It is NOT a public +// takedown tool: `--repair` removes no boardShowcase row, and the one direction it CAN move +// the public surface — adding a row — is announced per doc and withheld behind +// `--allow-publish` (see the `--repair` contract below): +// - A repaired doc declares the member LIVE (`active: true` / `deletedAt: null`), so the +// re-fired onBoardMemberWritten RE-PUBLISHES it. The row correctly stays up. +// - The shape that actually matters — a non-bool `active`, the string "false" — is REFUSED, +// so nothing is written, no trigger fires, and a row already published under the old +// fail-open projection SURVIVES. That one is remediated by hand; the script's job is to +// name it. The two remedies are printed per doc: a Firebase console edit of `active`, or +// an Admin `publicProfile: false` write — the members takedown arm in firestore.rules +// deliberately does not call `softDeleteSafe()` and stays open on exactly these docs +// (pinned by rules.test.ts, "keeps the Admin takedown arm open on a malformed member"). +// +// Note the new rules do NOT deny every client write to such a doc: that takedown arm is left +// open on purpose. What makes the remedy awkward is the client side — `memberDocSchema` drops +// a malformed member, so backstage never lists it and offers no affordance. The write has to +// come from the console, this script, or a direct admin/Admin-authenticated write. // // Read-only by default; exits non-zero when anything is found so it can gate a deploy. +// exit 1 → the run completed and found malformed docs (the gate) +// exit 2 → the run did NOT complete (a per-doc read/write failed, or repair was refused +// confirmation). Distinct on purpose: a crash must not look like a clean gate. +// // `--repair` fixes ONLY the unambiguous shapes and refuses to guess: // deletedAt missing → deletedAt: null (the value the create arm would have // stamped; missing can only mean "never soft-deleted" — @@ -16,35 +34,149 @@ // active missing, deletedAt → active: true (never deleted ⇒ live; every fail-open // null/missing reader has treated the doc as live all along) // active present but non-bool → NEVER coerced, even the string "false" — whether that -// doc was meant to be inactive is a human call +// doc was meant to be inactive is a human call. `active: +// null` counts as present and is refused too, while a +// MISSING `active` on the same nullish `deletedAt` is +// repaired: an explicit null is a value somebody wrote and +// may have meant, absence is just absence. // active missing, deletedAt → reported for a human (the two fields disagree about // non-null what state the doc is in) +// active: true AND deletedAt → the GHOST: reported for a human, same disagreement as +// non-null the row above, mirrored. Unlike the other shapes this one +// is client-REACHABLE (softDeleteSafe permits it) and +// memberDocSchema accepts it, so the doc renders as an +// ordinary live member while deletedAt-aware readers treat +// it as gone. +// deletedAt present, non-null, → reported, never repaired: junk (an ISO string, a number) +// not a Timestamp that the zod doc-schemas reject and the rules pin +// immutable — invisible and unwritable at once. +// Repair is all-or-nothing per doc, by design: when `active` is ambiguous the unambiguous +// `deletedAt: null` is withheld too, so the human who resolves the doc sees the shape the +// audit reported rather than one this script half-changed underneath them. // -// Targets PRODUCTION via Application Default Credentials, like seed-production.mjs: +// `--repair` can also PUBLISH, and that is opt-in. Writing `active: true` un-blocks the +// fail-CLOSED projectBoard gate, so a member who also has publicProfile: true (the stamped +// org-wide default), a uid, a pinned portrait and a current-term CEL/JDL cargo is ADDED to +// the world-readable Directiva by the re-fired trigger — a new publication nobody asked for, +// as a side effect of a shape fix. Every such member gets a `WILL PUBLISH:` line, and their +// repair is WITHHELD (counted apart from the ambiguous refusals) unless `--allow-publish` is +// passed. The forecast fails safe: any gate it cannot settle is reported as unknown and the +// member is still announced, never quietly repaired. +// +// Targets PRODUCTION via Application Default Credentials, like seed-production.mjs. A +// production `--repair` writes to members and, through the trigger, to the world-readable +// Directiva, so it demands an explicit confirmation (typed, or `--confirm=` for a +// non-interactive shell) — the same posture as seed-production.mjs and the +// `confirm: "overwrite-builtin-roles"` token on reseedBuiltInRolePerms. // gcloud auth application-default login && pnpm audit:soft-delete-shapes -// Or the emulator, by setting the env first: +// Or the emulator, by setting the env first (no confirmation there): // FIRESTORE_EMULATOR_HOST=127.0.0.1:4010 pnpm audit:soft-delete-shapes import { initializeApp, applicationDefault } from "firebase-admin/app"; import { getFirestore, FieldPath } from "firebase-admin/firestore"; +import { createInterface } from "node:readline/promises"; const COLLECTIONS = ["members", "positions", "allies"]; -// Page size mirrors the beacon chunk() bound; MAX_PRINT keeps the report readable on a -// large malformed set — the counts are always complete, the listing is capped. +// Page size and the getAll fan-out both mirror the beacon chunk() bound (guardrail #5). +// MAX_PRINT caps ONLY the benign listing — a repairable doc with no public exposure. Ids +// that need a hand fix (refused) and every PUBLISHED / unknown-publication line always +// print: this output is the operator's worklist, and truncating it would hide exactly the +// docs that must be acted on. The counts are always complete. const PAGE = 300; +const GETALL_CHUNK = 300; const MAX_PRINT = 20; +const CONFIRM_TOKEN = "repair-production-shapes"; +const CONFIRM_FLAG = "--confirm="; +/** Opt-in for the one repair that ADDS public exposure — see the publication forecast. */ +const ALLOW_PUBLISH_FLAG = "--allow-publish"; + +const EXIT_MALFORMED = 1; +const EXIT_INCOMPLETE = 2; const REPAIR = process.argv.includes("--repair"); +const ALLOW_PUBLISH = process.argv.includes(ALLOW_PUBLISH_FLAG); +const confirmArg = process.argv + .find((arg) => arg.startsWith(CONFIRM_FLAG)) + ?.slice(CONFIRM_FLAG.length); const emulator = process.env.FIRESTORE_EMULATOR_HOST; const projectId = process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCLOUD_PROJECT ?? "jci-oriente"; -initializeApp(emulator ? { projectId } : { credential: applicationDefault(), projectId }); -const db = getFirestore(); - console.log( `Auditing soft-delete shapes in ${emulator ? `EMULATOR (${emulator})` : "PRODUCTION"} ` + `project ${projectId}${REPAIR ? " — repair mode" : " — read-only"}\n`, ); +async function confirmProductionRepair() { + if (confirmArg === CONFIRM_TOKEN) return; + if (confirmArg !== undefined) { + console.error(`Refusing to repair: --confirm must be exactly "${CONFIRM_TOKEN}".`); + process.exit(EXIT_INCOMPLETE); + } + if (!process.stdin.isTTY) { + console.error( + `Refusing to repair PRODUCTION project ${projectId} without confirmation. ` + + `No TTY to prompt on — re-run with ${CONFIRM_FLAG}${CONFIRM_TOKEN}.`, + ); + process.exit(EXIT_INCOMPLETE); + } + const rl = createInterface({ input: process.stdin, output: process.stdout }); + let answer; + try { + answer = ( + await rl.question( + `About to WRITE to members/positions/allies in PRODUCTION project ${projectId}.\n` + + "A members write re-fires onBoardMemberWritten and re-projects the world-readable " + + "Directiva.\n" + + `Type ${CONFIRM_TOKEN} to proceed: `, + ) + ).trim(); + } catch { + // Ctrl+D / a closed stdin rejects the question. Exiting through the normal abort path + // keeps that out of EXIT_MALFORMED, which an unhandled rejection would collide with. + answer = ""; + } + rl.close(); + if (answer !== CONFIRM_TOKEN) { + console.error("Aborted — nothing was written."); + process.exit(EXIT_INCOMPLETE); + } + console.log(""); +} + +if (REPAIR && !emulator) await confirmProductionRepair(); + +initializeApp(emulator ? { projectId } : { credential: applicationDefault(), projectId }); +const db = getFirestore(); + +/** Duck-typed Timestamp test, mirroring apps/beacon/src/firestore-util.ts hasToMillis(). Not + * `instanceof Timestamp`: the admin SDK's Timestamp class identity is not guaranteed across + * a duplicated firebase-admin install, and an identity miss here would report every healthy + * doc in the collection as junk. Shape, not class. */ +function isTimestamp(value) { + return ( + typeof value === "object" && + value !== null && + typeof value.toMillis === "function" && + typeof value.toDate === "function" + ); +} + +/** Short, non-throwing rendering of a field value for the operator's worklist. */ +function preview(value) { + if (isTimestamp(value)) { + try { + return value.toDate().toISOString(); + } catch { + // A Timestamp outside the JS Date range. Not worth a failure — name it and move on. + return "Timestamp (unrenderable)"; + } + } + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + /** Classify one doc. Returns null when well-formed, else { problems, repair | null }. */ function classify(data) { const problems = []; @@ -58,6 +190,15 @@ function classify(data) { if (!hasDeletedAt) { problems.push("missing deletedAt"); repair.deletedAt = null; + } else if (data.deletedAt !== null && !isTimestamp(data.deletedAt)) { + // Reported, never repaired: the four zod doc-schemas require a Timestamp here, so this + // doc is already dropped from every backstage list, and the rules' one-way + // `resource.data.deletedAt == null || unchanged('deletedAt')` reads a non-null value and + // pins it — the doc is client-unwritable and invisible at once. Whether the junk encodes + // a real deletion (an ISO string somebody wrote by hand) or is a stray key is a human + // call, and coercing it would either resurrect a deleted member or bury a live one. + problems.push(`non-Timestamp deletedAt (${preview(data.deletedAt)})`); + ambiguous = true; } if (!hasActive) { problems.push("missing active"); @@ -66,12 +207,136 @@ function classify(data) { } else if (typeof data.active !== "boolean") { problems.push(`non-bool active (${JSON.stringify(data.active)})`); ambiguous = true; // never coerced — a human decides what "false"-the-string meant + } else if (data.active === true && hasDeletedAt && data.deletedAt !== null) { + // The GHOST: live and deleted at once. Client-REACHABLE, unlike the shapes above — + // softDeleteSafe() permits it (its one-way half only fires once the STORED deletedAt is + // non-null, and its well-formedness prefix is satisfied here), and memberDocSchema + // accepts it, so backstage lists this doc as an ordinary live member while every + // deletedAt-aware reader treats it as gone. The two fields disagree about what state the + // doc is in and neither is more authoritative, so this is reported for a human exactly + // like "active missing, deletedAt set" — the same disagreement, mirrored. + problems.push(`active true with deletedAt set (ghost, deletedAt ${preview(data.deletedAt)})`); + ambiguous = true; } if (problems.length === 0) return null; return { problems, repair: ambiguous ? null : repair }; } +/** + * Does the doc read as a LIVE member once `repair` is applied? That, not the repair itself, + * decides which direction the re-fired projection moves the public row: projectBoard is now + * fail-closed on `deletedAt != null || active !== true`. + */ +function liveAfterRepair(data, repair) { + const active = "active" in repair ? repair.active : data.active; + const deletedAt = "deletedAt" in repair ? repair.deletedAt : data.deletedAt; + return active === true && (deletedAt === null || deletedAt === undefined); +} + +// ── Publication forecast ────────────────────────────────────────────────────────────── +// Repairing a member missing `active` writes `active: true`, and projectBoard fail-CLOSES on +// `active !== true` — so before the repair that member is NOT published, and after it they +// may be. Publication defaults ON (`publicProfile` is stamped true server-side at create), so +// this is not hypothetical: an unpublished board member with a portrait and a current-term +// CEL/JDL cargo goes onto the world-readable Directiva as a SIDE EFFECT of a shape repair. +// The script announces the takedown direction, so it must announce this one. +// +// These predicates MIRROR apps/beacon/src/showcase/project-board.ts (projectBoard + +// currentCargoId + isMemberPhotoUrl) and packages/types/src/engine/board-public.ts +// (isSurfaceableStatus, boardGroupFromCategory). A .mjs operator script cannot import +// either — neither package resolves from the repo root — so this is a hand mirror, and it is +// built to FAIL SAFE in the one direction that matters: every gate answers true / false / +// UNKNOWN, and only a confident `false` suppresses the warning. Drift therefore over-warns +// (a member announced who would not have published) rather than under-warns (a silent +// publication). The unknown gates are named in the output instead of being guessed. +const SURFACEABLE_STATUSES = new Set(["Activo", "Inactivo"]); +const BOARD_CATEGORIES = new Set(["CEL", "JDL"]); +const MEMBER_PHOTO_HOST = "firebasestorage.googleapis.com"; + +/** Mirrors isSafeDocId (apps/beacon/src/firestore-util.ts) — an id this script is willing to + * interpolate into a `positions/${id}` path. */ +function isSafeDocId(id) { + if (typeof id !== "string" || id.length === 0 || id.includes("/")) return false; + if (id === "." || id === "..") return false; + if (id.startsWith("__") && id.endsWith("__")) return false; + return new TextEncoder().encode(id).length <= 1500; +} + +/** Mirrors currentTermKey() — getUTCFullYear(), the same key the rules derive from + * request.time.year(). The trigger reads the term at PROJECTION time, so "now" is right. */ +function currentTermKey() { + return String(new Date().getUTCFullYear()); +} + +function currentCargoId(data) { + const positions = data.positions; + if (!positions || typeof positions !== "object" || Array.isArray(positions)) return null; + const term = positions[currentTermKey()]; + if (!term || typeof term !== "object") return null; + return isSafeDocId(term.cargoId) ? term.cargoId : null; +} + +/** Mirrors isMemberPhotoUrl: this project's own bucket AND this member's own object. */ +function isMemberPhotoUrl(value, memberId) { + if (typeof value !== "string" || !URL.canParse(value) || projectId.length === 0) return false; + const url = new URL(value); + if (url.protocol !== "https:" || url.hostname !== MEMBER_PHOTO_HOST) return false; + const object = encodeURIComponent(`members/${memberId}/profile.jpg`); + return [`${projectId}.appspot.com`, `${projectId}.firebasestorage.app`].some( + (bucket) => url.pathname === `/v0/b/${bucket}/o/${object}`, + ); +} + +/** + * The gates projectBoard applies that this script can settle from the member doc alone. + * Returns the names of the ones that definitively FAIL — empty means "nothing local stops + * this publication". The cargo gate is not here; it needs a positions read. + */ +function localPublicationBlockers(id, data, repair) { + const blockers = []; + if (!liveAfterRepair(data, repair)) blockers.push("not live after repair"); + if (data.publicProfile !== true) blockers.push("publicProfile is not true"); + if (typeof data.uid !== "string" || data.uid.length === 0) blockers.push("no uid"); + if (!(data.status === undefined || SURFACEABLE_STATUSES.has(data.status))) { + blockers.push(`status ${preview(data.status)} is not surfaceable`); + } + if (typeof data.name !== "string" || data.name.length === 0) blockers.push("no name"); + if (!isMemberPhotoUrl(data.profilePicture, id)) blockers.push("no pinned portrait URL"); + return blockers; +} + +/** + * Resolve the current-term cargo doc for every candidate, batched through getAll at the + * chunk() bound. Map value: the cargo data, or null when the doc is missing, or the string + * "unreadable" when the read itself failed — which is the UNKNOWN this must not flatten. + */ +async function cargoState(cargoIds) { + const state = new Map(); + const ids = [...cargoIds]; + for (let i = 0; i < ids.length; i += GETALL_CHUNK) { + const slice = ids.slice(i, i + GETALL_CHUNK); + try { + const snaps = await db.getAll(...slice.map((id) => db.doc(`positions/${id}`))); + snaps.forEach((snap, j) => state.set(slice[j], snap.exists ? snap.data() : null)); + } catch (error) { + for (const id of slice) { + state.set(id, "unreadable"); + failed.push({ ref: `positions/${id}`, op: "read", message: String(error) }); + } + } + } + return state; +} + +/** true / false / null(unknown) — does this cargo put the member on the Directiva? */ +function cargoPublishes(cargo) { + if (cargo === "unreadable") return null; + if (cargo === null) return false; + if (!BOARD_CATEGORIES.has(cargo.category)) return false; + return typeof cargo.title === "string" && cargo.title.trim().length > 0; +} + async function* scan(coll) { let cursor = null; for (;;) { @@ -88,58 +353,222 @@ async function* scan(coll) { let found = 0; let repaired = 0; let refused = 0; +/** Repairable, but the repair would newly PUBLISH the member, and --allow-publish was not + * passed. Counted apart from `refused`: those are shapes the script cannot resolve, these + * are shapes it can — it is the CONSEQUENCE that needs a decision. */ +let withheld = 0; +/** Per-doc read/write failures. Non-empty ⇒ the run did not complete ⇒ EXIT_INCOMPLETE. */ +const failed = []; + +/** + * Publication state for every malformed member, batched through getAll at the chunk() bound + * rather than a get() per doc. Map value: true published, false absent, null unreadable. + */ +async function showcaseState(rows) { + const state = new Map(); + for (let i = 0; i < rows.length; i += GETALL_CHUNK) { + const slice = rows.slice(i, i + GETALL_CHUNK); + const refs = slice.map(({ doc }) => db.doc(`boardShowcase/${doc.id}`)); + try { + const snaps = await db.getAll(...refs); + snaps.forEach((snap, j) => state.set(slice[j].doc.id, snap.exists)); + } catch (error) { + for (const { doc } of slice) { + state.set(doc.id, null); + failed.push({ ref: `boardShowcase/${doc.id}`, op: "read", message: String(error) }); + } + } + } + return state; +} for (const coll of COLLECTIONS) { const rows = []; - for await (const doc of scan(coll)) { - const verdict = classify(doc.data()); - if (verdict) rows.push({ doc, ...verdict }); + try { + for await (const doc of scan(coll)) { + const data = doc.data(); + const verdict = classify(data); + if (verdict) rows.push({ doc, data, ...verdict }); + } + } catch (error) { + // A paging failure means this collection was only partially seen — never report its + // (partial) count as a clean one; the run is incomplete. + failed.push({ ref: coll, op: "scan", message: String(error) }); + console.log(`${coll}: FAILED to scan — ${error}`); + continue; } found += rows.length; console.log(`${coll}: ${rows.length} malformed doc(s)`); - for (const [i, { doc, problems, repair }] of rows.entries()) { - if (i < MAX_PRINT) console.log(` - ${coll}/${doc.id}: ${problems.join(", ")}`); - else if (i === MAX_PRINT) console.log(` … and ${rows.length - MAX_PRINT} more`); - - if (coll === "members") { - // The board-showcase consequence: under the old fail-open reader this doc may be - // published RIGHT NOW on the world-readable Directiva, and no client write can fix - // it any more. Only an admin-SDK write (this script's --repair, or the console) - // re-fires onBoardMemberWritten, whose now-fail-closed projection removes the row. - const showcase = await db.doc(`boardShowcase/${doc.id}`).get(); - if (showcase.exists && i < MAX_PRINT) { + const published = coll === "members" ? await showcaseState(rows) : new Map(); + + // Which repairable members the repair could NEWLY publish. Only members have a + // projection, and only a doc that is not already published can be newly published — but + // "already published" must be KNOWN, so an unreadable boardShowcase counts as a candidate + // (the same fail-safe direction as every gate below). + const publishCandidates = + coll === "members" + ? rows.filter( + ({ doc, data, repair }) => + repair !== null && + published.get(doc.id) !== true && + localPublicationBlockers(doc.id, data, repair).length === 0, + ) + : []; + const cargos = await cargoState( + new Set(publishCandidates.map(({ data }) => currentCargoId(data)).filter((id) => id !== null)), + ); + /** id -> { unknownPublication, unknownCargo } for every member the repair may publish. */ + const willPublish = new Map(); + for (const { doc, data } of publishCandidates) { + const cargoId = currentCargoId(data); + // No current-term cargo is a settled NO — projectBoard returns null on a null cargo. + if (cargoId === null) continue; + const verdict = cargoPublishes(cargos.get(cargoId)); + if (verdict === false) continue; + willPublish.set(doc.id, { + unknownPublication: published.get(doc.id) === null, + unknownCargo: verdict === null ? cargoId : null, + }); + } + + let shownBenign = 0; + let hiddenBenign = 0; + for (const { doc, data, problems, repair } of rows) { + // `has ? get : false`, NOT `get(...) ?? false`: showcaseState uses null as the + // "publication unreadable" sentinel, and ?? falls back on exactly null — collapsing an + // UNKNOWN into "not published". That killed the branch below, dropped the promised + // UNKNOWN line, and reclassified the doc as benign, i.e. truncatable — losing from the + // worklist precisely the docs whose public exposure nobody knows. One rejected getAll + // chunk (a transient 503) is enough to trigger it. + const showcase = published.has(doc.id) ? published.get(doc.id) : false; + const publishing = willPublish.get(doc.id); + // Always print a doc a human must act on: ambiguous, publicly exposed, unverifiable, or + // about to be published by its own repair. + const mustShow = repair === null || showcase !== false || publishing !== undefined; + let show = mustShow; + if (!mustShow) { + if (shownBenign < MAX_PRINT) { + show = true; + shownBenign += 1; + } else { + hiddenBenign += 1; + } + } + if (show) console.log(` - ${coll}/${doc.id}: ${problems.join(", ")}`); + + if (showcase === null) { + console.log( + ` UNKNOWN publication: boardShowcase/${doc.id} could not be read — treat this ` + + "member as possibly on the public Directiva and check by hand", + ); + } else if (showcase === true) { + if (repair === null) { + console.log( + ` PUBLISHED: boardShowcase/${doc.id} exists and STAYS PUBLISHED. This doc is ` + + "ambiguous, so this script writes nothing for it — no members write, no " + + "onBoardMemberWritten, and the row survives under the old fail-open projection.\n" + + " Two remedies, both by hand:\n" + + ` 1. Firebase console → members/${doc.id}: set 'active' to a real boolean. ` + + "That admin write re-fires the now fail-closed projection, which drops the row " + + "when the member is not live.\n" + + ` 2. An Admin write of publicProfile: false on members/${doc.id}. The members ` + + "takedown arm in firestore.rules deliberately skips softDeleteSafe(), so it stays " + + "open on exactly these docs (a rules test pins it). Backstage will not list this " + + "member — memberDocSchema drops it — so make that write from the console or directly.", + ); + } else if (liveAfterRepair(data, repair)) { + console.log( + ` PUBLISHED: boardShowcase/${doc.id} exists — this member is on the public ` + + "Directiva. The repair declares the doc LIVE (active true / deletedAt null), so " + + "the re-fired onBoardMemberWritten RE-PUBLISHES it from the repaired doc. That is " + + "correct, and it is NOT a takedown: to unpublish, write publicProfile: false as " + + "an Admin.", + ); + } else { + console.log( + ` PUBLISHED: boardShowcase/${doc.id} exists — this member is on the public ` + + "Directiva. The repair leaves the doc NOT live, so the re-fired " + + "onBoardMemberWritten removes the row.", + ); + } + } + + if (publishing) { + console.log( + ` WILL PUBLISH: repairing members/${doc.id} writes active: true, which un-blocks ` + + "the fail-closed projectBoard gate. This member holds a current-term CEL/JDL " + + "cargo, a pinned portrait and publicProfile: true, so the re-fired " + + `onBoardMemberWritten ADDS boardShowcase/${doc.id} to the world-readable ` + + "Directiva. That is a NEW publication, not a repair side effect anyone asked " + + `for.\n ${ALLOW_PUBLISH ? `${ALLOW_PUBLISH_FLAG} was passed — repairing it.` : `Withheld: re-run with ${ALLOW_PUBLISH_FLAG} to repair these too, or set publicProfile: false on the member first (the opt-out this member never exercised).`}`, + ); + if (publishing.unknownCargo !== null) { console.log( - ` PUBLISHED: boardShowcase/${doc.id} exists — this malformed member is on ` + - "the public Directiva; repairing the doc via the admin SDK re-fires " + - "onBoardMemberWritten and takes the row down", + ` (positions/${publishing.unknownCargo} could not be read — the cargo half of ` + + "the forecast is a GUESS-FREE unknown, reported rather than assumed benign)", + ); + } + if (publishing.unknownPublication) { + console.log( + ` (boardShowcase/${doc.id} could not be read either, so this may be a ` + + "re-publication rather than a new one)", ); } } if (!REPAIR) continue; - if (repair) { - await doc.ref.update(repair); - repaired += 1; - if (i < MAX_PRINT) console.log(` repaired: ${JSON.stringify(repair)}`); + if (repair && (!publishing || ALLOW_PUBLISH)) { + try { + await doc.ref.update(repair); + repaired += 1; + if (show) console.log(` repaired: ${JSON.stringify(repair)}`); + } catch (error) { + failed.push({ ref: `${coll}/${doc.id}`, op: "repair", message: String(error) }); + console.log(` FAILED to repair ${coll}/${doc.id}: ${error}`); + } + } else if (repair) { + withheld += 1; + console.log(` WITHHELD (would publish) — re-run with ${ALLOW_PUBLISH_FLAG} to apply`); } else { refused += 1; - if (i < MAX_PRINT) console.log(" REFUSED to repair (ambiguous) — fix by hand"); + console.log(" REFUSED to repair (ambiguous) — fix by hand"); } } + if (hiddenBenign > 0) { + console.log( + ` … and ${hiddenBenign} more repairable, unpublished doc(s) not listed ` + + "(counts are complete; ambiguous, PUBLISHED and WILL PUBLISH docs are never truncated)", + ); + } } const remaining = REPAIR ? found - repaired : found; console.log( `\n${found} malformed doc(s) found` + - (REPAIR ? `; ${repaired} repaired, ${refused} refused (ambiguous)` : "") + + (REPAIR + ? `; ${repaired} repaired, ${refused} refused (ambiguous), ${withheld} withheld (would publish)` + : "") + `; ${remaining} remaining`, ); + +if (failed.length > 0) { + console.log(`\n${failed.length} failure(s) — THE RUN DID NOT COMPLETE:`); + for (const { ref, op, message } of failed) console.log(` - ${ref} (${op}): ${message}`); + console.log( + "Counts above are partial and the scan may have stopped early. Fix the cause and re-run; " + + "repairs already written have already fired their triggers.", + ); + process.exit(EXIT_INCOMPLETE); +} + if (remaining > 0) { console.log( - REPAIR ? "Ambiguous docs need a human — see above." : "Run with --repair, or fix by hand.", + REPAIR + ? `Docs still needing a human — see above${withheld > 0 ? ` (${withheld} of them only need the ${ALLOW_PUBLISH_FLAG} decision)` : ""}.` + : "Run with --repair, or fix by hand.", ); - process.exit(1); + process.exit(EXIT_MALFORMED); } console.log("Audit clean — the soft-delete rules can ship."); process.exit(0); From c204ff4f7f9bc3b0ffe24f0afdafe28dfbb9b1e9 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 13:23:40 -0400 Subject: [PATCH 10/16] fix(rules,backstage): make the CEL ceiling true on the create arm too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made "CEL stays an Admin decision" a rule on the members UPDATE lane and wrote that guarantee into firestore.rules, the spec, and owner-op 1 — the text an owner reads before granting the capability. On the CREATE lane it was still a data assumption: createPositionsSafe() kept the old grants-only predicate, so a Membership holder (manage:Member satisfies create:Member) could mint a member BORN holding an Admin-minted grant-free CEL cargo. The create arm forbids uid, so the doc is unpublished at birth — but onMemberCreated stamps publicProfile true, the creator controls the portrait and the email, and a routine Admin provisionMemberLogin then writes uid, after which projectBoard publishes an attacker-controlled account at board rank 0 as Presidente. One Admin action in the chain, taken with no reason to inspect the cargo. Create now uses the same cargoAssignableByNonAdmin() — it reads only request.resource.data, so it is create-safe, unlike currentCargoGrantsEmpty(), which stays excluded because a create has no old side. cargoGrantsEmpty() has no callers left and is deleted. The CEL literal was also an ALLOW-by-default denylist with no drift guard: add a third publishable group to BOARD_GROUPS and the existing guard would force it into boardSurfacingCategory() (minting goes Admin-only) while nonAdminAssignable() silently kept ASSIGNING it open to everyone — a new board group auto-enrolled into the accepted-exposure class with nobody deciding. The guard now pins the assignable set to exactly ['JDL']. Client mirror carried across, which the rules tightening had outrun: a grant-free CEL cargo still appeared in the non-Admin picker, and a member already seated on one had no lock while every save re-stamped cargoId — so even a comisiones-only edit was denied with a generic error. One shared assignable-cargo helper now drives the option list and the lock in both member forms. rules 473->475, backstage 737->745, types 323->325. Co-Authored-By: Claude Opus 5 (1M context) --- .../members/components/member-form.test.tsx | 58 +++++++- .../members/components/member-form.tsx | 28 ++-- .../components/member-positions-form.test.tsx | 66 +++++++++ .../components/member-positions-form.tsx | 28 ++-- .../features/members/lib/assignable-cargo.ts | 20 +++ apps/backstage/src/lib/authz/use-can.ts | 2 +- docs/data-models.md | 3 +- docs/firebase-setup.md | 7 +- docs/specs/position-assignment-lane.md | 125 ++++++++++++++---- firestore.rules | 60 +++++---- .../board-surfacing-category.rules.test.ts | 51 ++++++- tests/firestore-rules/rules.test.ts | 56 +++++++- tools/scripts/audit-soft-delete-shapes.mjs | 51 +++++-- 13 files changed, 454 insertions(+), 101 deletions(-) create mode 100644 apps/backstage/src/features/members/lib/assignable-cargo.ts diff --git a/apps/backstage/src/features/members/components/member-form.test.tsx b/apps/backstage/src/features/members/components/member-form.test.tsx index 24a9f55b..393949b0 100644 --- a/apps/backstage/src/features/members/components/member-form.test.tsx +++ b/apps/backstage/src/features/members/components/member-form.test.tsx @@ -91,8 +91,13 @@ describe("MemberForm", () => { expect(onSubmit).not.toHaveBeenCalled(); }); + // allowPowerGrants: pos-pres is a grant-free CEL cargo, which only an Admin may assign + // (rules' cargoAssignableByNonAdmin). This case is about the LABELS, so give it the + // authority that renders them all. it("shows gendered cargo labels and excludes comisiones from the cargo options", async () => { - render(); + render( + , + ); await userEvent.click(screen.getByRole("button", { name: "Femenino" })); await userEvent.click(screen.getByLabelText("Cargo")); expect(await screen.findByText("Presidenta")).toBeInTheDocument(); @@ -183,9 +188,12 @@ describe("MemberForm", () => { ); }); + // allowPowerGrants for the same reason: picking a CEL cargo at all is an Admin flow. it("locks comisiones as Comité Ejecutivo Local and clears them for a CEL cargo", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); - render(); + render( + , + ); await userEvent.type(screen.getByLabelText(/nombre/i), "Ana Pérez"); await userEvent.type(screen.getByLabelText(/correo/i), "ana@jci.bo"); await userEvent.click(screen.getByRole("button", { name: "Femenino" })); @@ -229,6 +237,52 @@ describe("MemberForm", () => { expect(await screen.findByText("Tesorero (inactivo)")).toBeInTheDocument(); }); + // Mirror of firestore.rules cargoAssignableByNonAdmin() on the CREATE lane + // (createPositionsSafe applies the same predicate). Without it a non-Admin sees a + // grant-free CEL cargo, picks 'Presidente', and the create 403s into a generic error. + it("hides a grant-free CEL cargo from a non-Admin and keeps the JDL dirección", async () => { + render(); + await userEvent.click(screen.getByLabelText("Cargo")); + expect(await screen.findByText("Director de Área")).toBeInTheDocument(); + expect(screen.queryByText("Presidente")).not.toBeInTheDocument(); + }); + + it("shows a grant-free CEL cargo to an Admin", async () => { + render( + , + ); + await userEvent.click(screen.getByLabelText("Cargo")); + expect(await screen.findByText("Presidente")).toBeInTheDocument(); + }); + + // The lock, not just the option list: every save re-stamps the assigned cargoId, so a + // non-Admin editing a member already seated on a grant-free CEL cargo is denied on the + // positions slot. Locking it keeps the bio fields savable (the mapper omits the + // unchanged slot) instead of failing the whole form with no explanation. + it("locks the cargo for a non-Admin editing a member on a grant-free CEL cargo", () => { + render( + , + ); + expect(screen.getByText(/Solo un Admin puede cambiar el cargo/i)).toBeInTheDocument(); + }); + + it("does NOT lock a non-Admin editing a member on a grant-free JDL dirección", () => { + render( + , + ); + expect(screen.queryByText(/Solo un Admin puede cambiar el cargo/i)).not.toBeInTheDocument(); + }); + it("renders comisión option as 'sigla — title' when sigla is present", async () => { render( Promise; showPreview?: boolean; avatarSeed?: string; - /** Whether the editor may assign power-granting cargos — Admin only (rules' - * `cargoGrantsEmpty` / `createPositionsSafe`). Non-Admin sees only grant-free + /** Whether the editor may assign the cargos the rules reserve to an Admin — power-granting + * ones and CEL seats alike (rules' `cargoAssignableByNonAdmin`, applied by both + * `createPositionsSafe` and `positionsAssignmentSafe`). Non-Admin sees only assignable * cargos plus the current selection. */ allowPowerGrants?: boolean; children?: ReactNode; @@ -102,20 +104,22 @@ export function MemberForm({ const isExecutiveCommitteeCargo = positions.find((p) => p.id === currentCargoId)?.category === "CEL"; const term = currentTermKey(); - // Keep the member's ORIGINALLY-assigned cargo selectable for a non-Admin even if it - // grants power — but off the static default, not the reactive selection, so switching - // away and back still works (matches MemberPositionsForm). + // Keep the member's ORIGINALLY-assigned cargo selectable for a non-Admin even when the + // rules reserve it to an Admin — but off the static default, not the reactive selection, + // so switching away and back still works (matches MemberPositionsForm). const assignedCargoId = defaultValues?.cargoId ?? null; - // If that assigned cargo grants power and the editor isn't Admin, any positions write - // is rule-denied (cargoGrantsEmpty) — lock the cargo/comisiones so bio edits still save - // (the mapper omits the unchanged slot) but a futile positions change can't be attempted. + // If that assigned cargo is Admin-only — power-granting OR a CEL seat — any positions + // write by a non-Admin is rule-denied (cargoAssignableByNonAdmin), because the write + // re-stamps the same cargoId. Lock the cargo/comisiones so bio edits still save (the + // mapper omits the unchanged slot) but a futile positions change can't be attempted. + const assignedCargo = positions.find((p) => p.id === assignedCargoId); const positionsLocked = - !allowPowerGrants && (positions.find((p) => p.id === assignedCargoId)?.grants.length ?? 0) > 0; + !allowPowerGrants && assignedCargo !== undefined && !cargoAssignableByNonAdmin(assignedCargo); const activeCargoOptions = positions .filter( (p) => p.active && p.category !== "Comision" && (p.term === null || String(p.term) === term), ) - .filter((p) => allowPowerGrants || p.grants.length === 0 || p.id === assignedCargoId) + .filter((p) => allowPowerGrants || cargoAssignableByNonAdmin(p) || p.id === assignedCargoId) .map((p) => ({ value: p.id, label: positionTitle(p, gender) })); const assignedInactiveCargo = currentCargoId && !activeCargoOptions.some((o) => o.value === currentCargoId) @@ -284,8 +288,8 @@ export function MemberForm({ )} {positionsLocked && (

- Solo un Admin puede cambiar el cargo de un miembro con permisos. Puedes editar el resto - de sus datos. + Solo un Admin puede cambiar el cargo de un miembro del Comité Ejecutivo Local o con + permisos. Puedes editar el resto de sus datos.

)} { expect(screen.getByRole("button", { name: /guardar/i })).not.toBeDisabled(); }); + // The publication half of the mirror. pos_cel_free is grant-free, so the grants filter + // alone still offered it: a non-Admin picked 'Presidente' and the save 403'd on the rules' + // `category != 'CEL'` conjunct with a generic error. + const celFree = pos("presidente_libre", "CEL"); + + it("hides a grant-free CEL cargo from a non-Admin", async () => { + render( + , + ); + await userEvent.click(screen.getByLabelText("Cargo")); + // The paired JDL dirección proves the filter is the CEL conjunct, not the list closing + // on grant-free board cargos generally — that exposure is accepted and must survive. + expect(await screen.findByText("dir")).toBeInTheDocument(); + expect(screen.queryByText("presidente_libre")).not.toBeInTheDocument(); + }); + + it("shows a grant-free CEL cargo to an Admin", async () => { + render( + , + ); + await userEvent.click(screen.getByLabelText("Cargo")); + expect(await screen.findByText("presidente_libre")).toBeInTheDocument(); + }); + + // Not just the option list: `locked` has to cover it too. Every save re-stamps the + // assigned cargoId into the merged doc, so with the form unlocked a comisiones-only edit + // on a CEL-seated member is denied — no lock, no note, one generic error. + it("locks the form for a non-Admin editing a member seated on a grant-free CEL cargo", () => { + render( + , + ); + expect(screen.getByRole("button", { name: /guardar/i })).toBeDisabled(); + expect(screen.getByText(/Solo un Admin puede cambiar los cargos/i)).toBeInTheDocument(); + }); + + it("does NOT lock a non-Admin editing a member seated on a grant-free JDL dirección", () => { + render( + , + ); + expect(screen.getByRole("button", { name: /guardar/i })).not.toBeDisabled(); + }); + it("shows error alert when onSubmit throws", async () => { const onSubmit = vi.fn().mockRejectedValue(new Error("fail")); render( diff --git a/apps/backstage/src/features/members/components/member-positions-form.tsx b/apps/backstage/src/features/members/components/member-positions-form.tsx index 708784db..406311da 100644 --- a/apps/backstage/src/features/members/components/member-positions-form.tsx +++ b/apps/backstage/src/features/members/components/member-positions-form.tsx @@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, Combobox, Field, MultiSelect } from "@luminova/ui"; import { positionTitle, currentTermKey, type MemberGender, type Position } from "@luminova/types"; +import { cargoAssignableByNonAdmin } from "../lib/assignable-cargo"; const positionsSchema = z.object({ cargoId: z.string().min(1).nullable(), @@ -22,9 +23,10 @@ export function MemberPositionsForm({ positions: Position[]; gender: MemberGender | undefined; defaultValues: PositionsInput; - /** Whether the caller may assign power-granting cargos. Only Admin may (rules' - * `cargoGrantsEmpty`); a non-Admin sees only grant-free cargos (plus the current - * assignment, so an existing selection still renders). */ + /** Whether the caller may assign the cargos the rules reserve to an Admin — power-granting + * ones and CEL seats alike (rules' `cargoAssignableByNonAdmin`). A non-Admin sees only + * assignable cargos (plus the current assignment, so an existing selection still + * renders). */ allowPowerGrants: boolean; onSubmit: (data: PositionsInput) => Promise; }) { @@ -36,17 +38,20 @@ export function MemberPositionsForm({ } = useForm({ resolver: zodResolver(positionsSchema), defaultValues }); const term = currentTermKey(); - // A non-Admin can't write positions at all for a member whose current cargo grants - // power: the write re-stamps that cargoId and the rules' `cargoGrantsEmpty` denies it - // (comisiones can't be changed either — the whole slot is rejected). Lock the form. - const assignedCargoHasGrants = - (positions.find((p) => p.id === defaultValues.cargoId)?.grants.length ?? 0) > 0; - const locked = !allowPowerGrants && assignedCargoHasGrants; + // A non-Admin can't write positions at all for a member whose current cargo is Admin-only + // — power-granting OR a CEL seat: every save re-stamps that cargoId into the merged doc, + // so `cargoAssignableByNonAdmin()` rejects the whole slot, comisiones included, even on a + // comisiones-only edit. Lock the form rather than 403 a save that looks legitimate. + const assignedCargo = positions.find((p) => p.id === defaultValues.cargoId); + const locked = + !allowPowerGrants && assignedCargo !== undefined && !cargoAssignableByNonAdmin(assignedCargo); const cargoOptions = positions .filter( (p) => p.active && p.category !== "Comision" && (p.term === null || String(p.term) === term), ) - .filter((p) => allowPowerGrants || p.grants.length === 0 || p.id === defaultValues.cargoId) + .filter( + (p) => allowPowerGrants || cargoAssignableByNonAdmin(p) || p.id === defaultValues.cargoId, + ) .map((p) => ({ value: p.id, label: positionTitle(p, gender) })); const comisionOptions = positions .filter((p) => p.active && p.category === "Comision") @@ -96,7 +101,8 @@ export function MemberPositionsForm({ {locked && (

- Solo un Admin puede cambiar los cargos de un miembro con permisos. + Solo un Admin puede cambiar los cargos de un miembro del Comité Ejecutivo Local o con + permisos.

)} {formError && ( diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.ts b/apps/backstage/src/features/members/lib/assignable-cargo.ts new file mode 100644 index 00000000..a3e3f2bf --- /dev/null +++ b/apps/backstage/src/features/members/lib/assignable-cargo.ts @@ -0,0 +1,20 @@ +import type { Position } from "@luminova/types"; + +/** + * Client mirror of firestore.rules `cargoAssignableByNonAdmin()` — the two questions a + * non-Admin assignment must answer about the cargo being written in: + * grants.length === 0 the claims-mint boundary (assigning it would mint custom claims). + * category !== "CEL" the publication boundary. boardGroupFromCategory publishes CEL and + * JDL alike and boardRank puts 'Presidente' at rank 0, so a + * grant-free CEL cargo seats its holder at the head of the + * world-readable Directiva. JDL direcciones stay assignable — that + * is the accepted exposure this lane exists to deliver. + * + * The rules apply it on BOTH member lanes (`createPositionsSafe` and + * `positionsAssignmentSafe`), so both member forms apply it here. One function, not a + * `grants.length === 0` re-typed per form: rendering an option the save will 403 on is the + * render-then-die shape this repo guards against, and two copies drift. + */ +export function cargoAssignableByNonAdmin(cargo: Pick): boolean { + return cargo.grants.length === 0 && cargo.category !== "CEL"; +} diff --git a/apps/backstage/src/lib/authz/use-can.ts b/apps/backstage/src/lib/authz/use-can.ts index ec17e427..94dfa074 100644 --- a/apps/backstage/src/lib/authz/use-can.ts +++ b/apps/backstage/src/lib/authz/use-can.ts @@ -32,7 +32,7 @@ export interface Can { readonly canFeatureInitiatives: boolean; /** The Admin-only half of the positions authority — one flag because firestore.rules keys * every part of it on the same `hasAnyRole(['Admin'])`: assigning a power-granting cargo - * (`cargoGrantsEmpty` / `currentCargoGrantsEmpty` / `createPositionsSafe`) or a CEL cargo + * (`cargoAssignableByNonAdmin` / `currentCargoGrantsEmpty` / `createPositionsSafe`) or a CEL cargo * at all, creating a board-surfacing cargo (`boardSurfacingCategory()`), and editing a * stored cargo's `grants`, `category` or — on a board cargo — `title`/`titleFemale`. * Named so the policy isn't a bare `isAdmin` at each grant site. */ diff --git a/docs/data-models.md b/docs/data-models.md index ae45d189..0735b890 100644 --- a/docs/data-models.md +++ b/docs/data-models.md @@ -185,7 +185,8 @@ hard-coded role checks. `firestore.rules` is the source of truth; summary: > **Positions-update constraints (all tiers):** any write touching `positions` must satisfy `positionsAssignmentSafe()`: > - Only the **current term key** (`string(request.time.year())`) may change — past terms are read-only for all client writes (admin-SDK/console for historical corrections). > - `positions..assignedBy` must equal `request.auth.uid` (writer stamps themselves). -> - Non-Admin writers may only assign a cargo whose `grants` array is empty (no power conferral) **and** may only displace a cargo whose `grants` array is empty (`currentCargoGrantsEmpty()`) — otherwise a `manage:Member` holder could overwrite a president's cargo with a grant-free one and strip the Admin claim. Admin is unrestricted. +> - Non-Admin writers may only assign a cargo that is both **grant-free and not `CEL`** (`cargoAssignableByNonAdmin()`) **and** may only displace a cargo whose `grants` array is empty (`currentCargoGrantsEmpty()`) — otherwise a `manage:Member` holder could overwrite a president's cargo with a grant-free one and strip the Admin claim. The `CEL` half is the PUBLICATION boundary: `boardGroupFromCategory` publishes CEL and JDL alike and `boardRank` puts `Presidente` at 0, so a grant-free CEL cargo would seat its holder at the head of the world-readable Directiva. Grant-free JDL direcciones stay assignable — that exposure is the feature. Admin is unrestricted. +> - The **create** arm (`createPositionsSafe()`) applies the same `cargoAssignableByNonAdmin()` to any `positions` it writes; it cannot apply `currentCargoGrantsEmpty()` (no prior `resource`) and has no old side to protect. > - These constraints close the "ride-along" attack where a non-Admin sneaks a power cargo under a different term key in the same write. > - Comisión `grants` are not loop-checkable in rules — instead the invariant is structural: comisiones can never hold grants (`comisionGrantsEmpty()` on positions writes) and claims-sync ignores `comisionIds` for grants entirely. > diff --git a/docs/firebase-setup.md b/docs/firebase-setup.md index c077df7b..eb35bf2d 100644 --- a/docs/firebase-setup.md +++ b/docs/firebase-setup.md @@ -415,8 +415,11 @@ Same credential model as `seed:production`; point it at the emulator by setting `FIRESTORE_EMULATOR_HOST` first. A **production** `--repair` writes to members and, through the trigger, re-projects the world-readable Directiva, so it demands an explicit confirmation — type `repair-production-shapes` at the prompt, or pass -`--confirm=repair-production-shapes` in a non-interactive shell. The emulator needs -no confirmation. +`--confirm=repair-production-shapes` in a non-interactive shell. Adding +`--allow-publish` widens what the run may do, so it widens the token: the string +becomes `repair-production-shapes-and-publish`, and the plain one is then rejected. +The typed string names the consequence, rather than the one flag that can ADD public +exposure being the one the prompt is silent about. The emulator needs no confirmation. ## Correo de invitación diff --git a/docs/specs/position-assignment-lane.md b/docs/specs/position-assignment-lane.md index 3954b0e5..db7ffdb8 100644 --- a/docs/specs/position-assignment-lane.md +++ b/docs/specs/position-assignment-lane.md @@ -44,8 +44,8 @@ arm is purely additive and its scope is auditable in isolation. // Positions-only lane: an org-chart editor who is NOT a member editor. Keyed on // update:Position — the same capability that governs the positions CATALOG — and // confined to the positions map. The power-cargo restriction is NOT relaxed: -// positionsAssignmentSafe()'s non-Admin branch still demands cargoGrantsEmpty() && -// currentCargoGrantsEmpty(), so this principal assigns and clears grant-free cargos +// positionsAssignmentSafe()'s non-Admin branch still demands cargoAssignableByNonAdmin() +// && currentCargoGrantsEmpty(), so this principal assigns and clears grant-free cargos // only, on BOTH sides of a swap. // // memberWriteInvariants() is implied by hasOnly(['positions']) TODAY and is stated @@ -65,11 +65,12 @@ with the institutional arm — the five conjuncts were duplicated across both un `/simplify` extracted them. The self-service arm must touch `publicProfile` and the Admin takedown arm deliberately skips `softDeleteSafe()`, so neither calls it. -`positionsAssignmentSafe()` is reused verbatim — **not edited**. Its `hasAnyRole(['Admin'])` -disjunct keeps Admin unrestricted; its `cargoGrantsEmpty() && currentCargoGrantsEmpty()` -disjunct is exactly the grant-empty-only semantic this lane wants. `currentCargoGrantsEmpty()` -must stay inside the non-Admin branch: without it an `update:Position` holder could overwrite -a president's power cargo with a grant-free one and silently strip Admin. +`positionsAssignmentSafe()` is reused as-is by this lane — the arm adds no cargo condition of +its own. Its `hasAnyRole(['Admin'])` disjunct keeps Admin unrestricted; its +`cargoAssignableByNonAdmin() && currentCargoGrantsEmpty()` disjunct is exactly the +assignable-only semantic this lane wants. `currentCargoGrantsEmpty()` must stay inside the +non-Admin branch: without it an `update:Position` holder could overwrite a president's power +cargo with a grant-free one and silently strip Admin. `hasOnly(['positions'])` is the correct constraint for the production dot-path payload `{"positions.2026": {...}}` — `affectedKeys()` reports **top-level** keys, pinned by @@ -82,11 +83,23 @@ assign a cargo to a soft-deleted member — the same as the existing institution a regression. What it does do, once change B lands, is refuse a write onto a **malformed** member doc. -### Create is deliberately untouched +### Create keeps the same new-side predicate, and only that `createPositionsSafe()` cannot call `currentCargoGrantsEmpty()` — a create has no prior -`resource`. There is no old side to protect on a create, and no reason to let an org-chart -editor mint members: creation stays `canDo('create','Member')`. +`resource` — and there is no old side to protect on a create. It **does** call +`cargoAssignableByNonAdmin()`, the same new-side predicate as the update arm, because that +one reads `request.resource.data` only (through `assignedTerm()`) and is therefore +create-safe. Keeping the arms on one predicate is not symmetry for its own sake: a create arm +that asked only about `grants` let a `manage:Member` holder — `manage:Member` satisfies +`canDo('create','Member')` — mint a member **born** holding an Admin-minted grant-free CEL +cargo, self-stamped. `uid` is forbidden at create, so that doc is unpublished at birth; but +`onMemberCreated` stamps the `publicProfile` default, the creator picks `name` and +`profilePicture`, and one routine Admin `provisionMemberLogin` later supplies the `uid` — +after which `projectBoard` publishes an attacker-composed account at board rank 0 as +Presidente, with a single Admin action in the chain taken for an unrelated reason. + +The org-chart lane itself still may not create members: creation stays +`canDo('create','Member')`, which an `update:Position`-only principal does not hold. ### This lane is a PUBLIC publication authority — stated, and deliberately accepted @@ -106,13 +119,16 @@ earlier draft argued CEL was blocked because "every seeded CEL cargo carries non still lets an **Admin** mint a grant-free CEL cargo (this branch's own rules suite creates exactly one, `mint_cel_admin`), and from that moment the claim is false: any `update:Position` holder could self-assign it, at CEL rank, `Presidente` included — `boardRank` maps that title -to 0, so they would head the public Directiva. `positionsAssignmentSafe()`'s non-Admin branch -therefore reads `category` off the cargo doc it was **already** fetching for -`cargoGrantsEmpty()` (`cargoAssignableByNonAdmin()` — one `get()`, two questions) and refuses -`CEL` whatever its grants. Cost: an Admin who mints a ceremonial grant-free CEL cargo must -also assign it — consistent with the create arm, which already made minting one Admin-only. -Three rules tests pin it (non-Admin CEL denied, grant-free JDL still allowed, Admin -unaffected) plus one for the institutional lane, and the conjunct is mutation-proven. +to 0, so they would head the public Directiva. The non-Admin branch therefore reads +`category` off the cargo doc it was **already** fetching for the grants check +(`cargoAssignableByNonAdmin()` — one `get()`, two questions) and refuses `CEL` whatever its +grants. **Both member lanes ask it**: `positionsAssignmentSafe()` on update and +`createPositionsSafe()` on create — see "Create keeps the same new-side predicate" above for +what splitting them cost. Cost: an Admin who mints a ceremonial grant-free CEL cargo must +also assign it — consistent with the positions create arm, which already made minting one +Admin-only. Five rules tests pin it (non-Admin CEL denied on the org-chart lane, grant-free +JDL still allowed, Admin unaffected, the institutional update lane, and the create lane) and +the conjunct is mutation-proven. The REPLACED cargo (`currentCargoGrantsEmpty()`) is deliberately not category-gated: clearing a member off a grant-free CEL cargo only reduces exposure. @@ -164,10 +180,15 @@ export function memberEditMode(gate: Pick): MemberEditMode { Order matters and is asserted: a principal holding both gets `"full"`, so the two editors never both render. -The rest of the UI mirror is **already correct** — `member-positions-form.tsx:42-50` filters -options on `p.grants.length === 0` and locks the form when the *current* cargo grants power, -both keyed on grants-emptiness, not on Admin. `canAssignPowerGrants` (`use-can.ts:55`) stays -a role check, mirroring `hasAnyRole(['Admin'])` in the rule. +The cargo mirror in `member-positions-form.tsx` and `member-form.tsx` was keyed on +`p.grants.length === 0` alone, which was correct only while grants were the whole boundary. +Both now call one shared `cargoAssignableByNonAdmin()` +(`features/members/lib/assignable-cargo.ts`), the client twin of the rules predicate, for the +option list **and** the lock — a grant-free CEL cargo is otherwise offered to a non-Admin who +then eats a generic 403, and a member already seated on one has no lock at all even though +every save re-stamps that `cargoId` and is denied. One function, not the same condition typed +into two forms. `canAssignPowerGrants` (`use-can.ts:55`) stays a role check, mirroring +`hasAnyRole(['Admin'])` in the rule. Two stale comments must move with the code, or they read as guarantees: `MemberRepository.setPositions` (`member-repository.ts:81-85`) says the write "goes through @@ -240,6 +261,17 @@ Owner-op 1 states that, so nobody grants the capability on the strength of an un "never a power cargo". Fixing it properly means resolving liveness across terms and is its own pass. +**The claim strip is only half of it: the same write re-fires the projection.** +`onBoardMemberWritten` resolves the cargo through `currentCargoId(member, termKey)` at +projection time, so the new-term slot is what the public Directiva reads the moment it is +written. Post-rollover, that same short-circuit therefore lets an `update:Position` holder +move a sitting president — whose CEL cargo carries `grants`, and who is therefore untouchable +in-term on BOTH sides (`cargoAssignableByNonAdmin()` refuses CEL; `currentCargoGrantsEmpty()` +refuses displacing a granted cargo) — onto a grant-free JDL dirección, demoting them from +public rank 0 to a dirección on the world-readable board. That is a **public-board mutation +they cannot make in-term at all**, not just a claims change, and it is the half a reader +tracking only the Admin claim would miss. Same window, same fix, same pass. + --- ## B. Well-formedness on the remaining soft-delete lanes @@ -582,9 +614,13 @@ so all three assertions go false until the fixtures carry `perms: ["update:Showc next write, a member whose Admin comes from last year's cargo has an empty current-term slot, the guard short-circuits, and this holder can write a grant-free cargo into the new term — which claims-sync resolves to `roles: ['Member']`, stripping the Admin claim. - Pre-existing (it falls to any `manage:Member` holder through the institutional arm) but - newly reachable by a role whose entire authority is `update:Position`. See Residuals; the - shape is now pinned by a rules test named as an accepted hole. + The same write also re-fires the board projection, which reads the current-term cargo: + so in that window this holder can also move a sitting **president** off CEL rank 0 onto a + JDL dirección on the public Directiva — a public-board change the in-term rules deny them + outright on both sides. Pre-existing (it falls to any `manage:Member` holder through the + institutional arm) but newly reachable by a role whose entire authority is + `update:Position`. See Residuals; the shape is now pinned by a rules test named as an + accepted hole. 2. **Before deploying D's rules:** run `reseedBuiltInRolePerms`, then run `recomputeAllClaims`, then confirm a live `ProjectManager`'s **ID-token claim** carries `update:Showcase`. The reseed's `onRoleWritten` fan-out is unbounded and `retry: false`, so it can strand members; @@ -677,7 +713,7 @@ Found by the final review round: 15. **The CEL half of the exposure was ASSUMED closed, not enforced** — "seeded CEL cargos all carry grants" is a data claim, and this branch's own suite mints the counterexample (`mint_cel_admin`). `cargoAssignableByNonAdmin()` now refuses a `CEL` cargo to a non-Admin - off the get() `cargoGrantsEmpty()` already paid for. Section A and owner-op 1 rewritten + off the get() the grants check already paid for. Section A and owner-op 1 rewritten from "assumed" to "enforced". 16. **Nothing tied `boardSurfacingCategory()` to `BOARD_GROUPS`.** A third publishable group would have reopened correction 8 silently. `packages/types/src/board-surfacing-category. @@ -685,8 +721,12 @@ Found by the final review round: repo's parse-the-rules-file pattern. 17. **The two catalog arms disagreed on an absent/unrecognized `category`.** Create asked `!boardSurfacingCategory()`, the update escape hand-rolled `== 'Comision'`, so a legacy - doc with no `category` was non-board on create and board on update — fail-closed, but - over-restrictive and a second definition of the same question. One predicate now. + doc with no `category` was non-board on create and board on update — fail-closed, so + never a hole. The payoff is **not** that legacy docs become editable: `positionDocSchema` + requires `category`, so `parseDocs` drops a category-less cargo and `/positions` never + lists it — unreachable from the UI either way. It is that the second, longhand definition + of "is this published" cannot drift from the create arm's (guardrail #1), and that one is + load-bearing on live docs. One predicate now. 18. **A sentinel bug made the audit script's UNKNOWN-publication branch dead code.** `published.get(id) ?? false` falls back on exactly the `null` that meant "unreadable", so one rejected `getAll` chunk silently reclassified those members as unpublished — and @@ -702,6 +742,33 @@ Found by the final review round: the default. Fixed, plus coverage for the label/category locks, including a BLOCKING test that would go red on `register("title", { disabled: true })` (RHF's own `disabled` submits the field as `undefined`, which 403s every non-Admin save). +22. **Correction 15 fixed the update arm only, and this spec claimed otherwise.** + `createPositionsSafe()` still asked `cargoGrantsEmpty()`, so a `manage:Member` holder + could mint a member BORN on an Admin-minted grant-free CEL cargo — unpublished only until + a routine Admin `provisionMemberLogin` wrote the `uid`. Both arms now share + `cargoAssignableByNonAdmin()`; `cargoGrantsEmpty()` had no remaining caller and is + deleted. The three claims this falsified (the rules comment's "an Admin decision on both + ends", "an Admin who mints must also assign", owner-op 1's "rank 0 stays an Admin + decision on both ends") are now true rather than aspirational, and a mirror rules test + pins the create lane. +23. **The CEL literal had no drift guard and defaults to ALLOW.** Correction 16 bound + `boardSurfacingCategory()` to `BOARD_GROUPS` but parsed around `nonAdminAssignable()`, a + denylist of one. A third publishable group would have gone Admin-only to MINT and stayed + open to ASSIGN for every non-Admin — auto-enrolled into the accepted exposure with nobody + deciding. The same test now parses both and asserts the board groups a non-Admin may + assign are exactly `['JDL']`. Mutation-proven in both directions. +24. **No client mirror for the new denial.** Both member forms filtered cargo options on + `grants.length === 0`, so a grant-free CEL cargo was still offered to a non-Admin + (render-then-die), and a member already seated on one showed no lock while every save + re-stamped the `cargoId` — a comisiones-only edit was denied with no explanation. One + shared `cargoAssignableByNonAdmin()` now backs the option list and the lock in both forms. +25. **Three audit-script defects.** The `WILL PUBLISH` line printed "`--allow-publish` was + passed — repairing it" in READ-ONLY mode, where nothing is written; `cargoPublishes()` + would have TypeError'd on an `undefined` cargo (reachable when `currentTermKey()` + straddles a UTC-year boundary between its two evaluations) and now reports it as the + unknown it is; and `--allow-publish` — the one flag that can ADD public exposure — now + changes the typed confirmation token to `repair-production-shapes-and-publish`, so the + string the operator types names the consequence. ## Residuals @@ -713,7 +780,9 @@ Found by the final review round: - **Publication of a grant-free JDL dirección by an `update:Position` holder** — the accepted exposure, narrowed to JDL by rule (correction 15) rather than by an assumption about data. - The coupling half of `roleLifecycleSafe()` on the four lanes (B, Not in scope). -- The term-rollover window in `currentCargoGrantsEmpty()` (A, Residual). +- The term-rollover window in `currentCargoGrantsEmpty()` (A, Residual) — **both** halves: the + Admin-claim strip, and the public-board move of a sitting president off CEL rank 0 that the + same write re-projects. - The port-level divergence in C: a zod-rejected doc reads ABSENT to backstage, COVERED to beacon. - (Not a residual, recorded so it is not re-opened as one: `siteConfig` write staying `hasAnyRole(['Admin'])` is **correct**, and is not "the D case with a smaller blast diff --git a/firestore.rules b/firestore.rules index 1ea9c2d6..af78c354 100644 --- a/firestore.rules +++ b/firestore.rules @@ -140,17 +140,9 @@ service cloud.firestore { function assignedCargoId() { return assignedTerm().get('cargoId', null); } - // A null cargoId short-circuits (no get). For a non-null cargoId, get() on a - // missing position errors → rule denies (fail-closed). Comisión grants are not - // checked here (rules can't iterate the array) — the beacon trust gate is their backstop. - function cargoGrantsEmpty() { - return assignedCargoId() == null - || get(/databases/$(database)/documents/positions/$(assignedCargoId())).data.grants.size() == 0; - } - // The two questions a non-Admin assignment must answer, read off ONE cargo doc — this - // is the same get() cargoGrantsEmpty() already pays for, with `category` taken from it - // rather than a second read. - // grants.size() == 0 the claims-mint boundary (as cargoGrantsEmpty). + // The two questions a non-Admin assignment must answer, read off ONE cargo doc — one + // get(), two questions, rather than a read per boundary. + // grants.size() == 0 the claims-mint boundary. // category != 'CEL' the PUBLICATION boundary. boardGroupFromCategory publishes // CEL and JDL alike, and boardRank orders CEL by statutory // title with 'Presidente' at 0 — so a grant-free CEL cargo, @@ -158,12 +150,19 @@ service cloud.firestore { // holder at the head of the world-readable Directiva as the // chapter president, with no Admin action in the chain. // That was previously argued blocked because "seeded CEL cargos all carry grants" — a - // claim about DATA, enforced by nothing. The create arm below deliberately still lets an - // ADMIN mint a grant-free CEL cargo (rules.test.ts creates exactly one, `mint_cel_admin`), - // and from that moment the data claim is false. This makes the boundary a RULE. + // claim about DATA, enforced by nothing. The positions create arm below deliberately + // still lets an ADMIN mint a grant-free CEL cargo (rules.test.ts creates exactly one, + // `mint_cel_admin`), and from that moment the data claim is false. This makes the + // boundary a RULE. // JDL stays open: grant-free direcciones are the accepted publication exposure this lane // exists to deliver (spec A, owner-op 1). CEL is the executive committee, whose seats are - // an Admin decision on both ends — the create arm already says so. + // an Admin decision on both ends — the positions create arm already says so. + // Reads request.resource.data only (via assignedTerm()), so BOTH member arms use it: + // positionsAssignmentSafe() on update and createPositionsSafe() on create. Splitting them + // is what let a manage:Member holder mint a member BORN on a grant-free CEL cargo. + // A null cargoId short-circuits (no get). For a non-null cargoId, get() on a missing + // position errors → rule denies (fail-closed). Comisión grants are not checked here + // (rules can't iterate the array) — the beacon trust gate is their backstop. // Assignment only. currentCargoGrantsEmpty() (the cargo being REPLACED) is deliberately // NOT category-gated: clearing a member off a grant-free CEL cargo is exposure-reducing, // and denying it would strand a takedown behind an Admin. @@ -174,7 +173,7 @@ service cloud.firestore { function nonAdminAssignable(cargo) { return cargo.grants.size() == 0 && cargo.get('category', '') != 'CEL'; } - // The OTHER side of cargoGrantsEmpty(): the cargo being REPLACED. assignedCargoId() + // The OTHER side of cargoAssignableByNonAdmin(): the cargo being REPLACED. assignedCargoId() // reads request.resource.data — the post-write cargo — so without this a manage:Member // holder could overwrite a president's positions. with a grant-free cargo, // claims-sync would resolve grants.length == 0, and the Admin claim would be gone with @@ -210,14 +209,21 @@ service cloud.firestore { && (hasAnyRole(['Admin']) || (cargoAssignableByNonAdmin() && currentCargoGrantsEmpty())); } - // Create has no prior resource to diff, so it can't use positionsDelta(); it - // applies the same self-stamp + power-cargo-Admin-only gate to any positions it - // writes. Without this a Membership user could create a member with a forged - // assignedBy = a known Admin's uid + a power cargo; the claims-sync trigger would - // then honor the grant and mint Admin onto that member's (or the attacker's) uid. + // Create has no prior resource to diff, so it can't use positionsDelta(); it applies the + // same self-stamp + Admin-only-cargo gate to any positions it writes. Without this a + // Membership user could create a member with a forged assignedBy = a known Admin's uid + + // a power cargo; the claims-sync trigger would then honor the grant and mint Admin onto + // that member's (or the attacker's) uid. + // Same predicate as the update arm, deliberately: a create arm that only asked about + // `grants` let a manage:Member holder mint a member BORN holding a grant-free CEL cargo. + // The create arm forbids `uid`, so that doc is unpublished at birth — but onMemberCreated + // stamps the publicProfile default, the creator picks profilePicture, and one routine + // Admin provisionMemberLogin then writes the uid, after which projectBoard publishes an + // attacker-composed account at board rank 0 as Presidente. currentCargoGrantsEmpty() is + // the only half a create cannot ask (no prior resource), and it has no old side to guard. function createPositionsSafe() { return !('positions' in request.resource.data) - || (assignedBySelf() && (hasAnyRole(['Admin']) || cargoGrantsEmpty())); + || (assignedBySelf() && (hasAnyRole(['Admin']) || cargoAssignableByNonAdmin())); } // roleIds + permissionOverrides feed Auth custom claims via the beacon trigger. @@ -480,7 +486,7 @@ service cloud.firestore { // vanish with it. // // Ordered cheapest-first on purpose: positionsAssignmentSafe() is the only conjunct - // here that can issue cross-document get()s (cargoGrantsEmpty / + // here that can issue cross-document get()s (cargoAssignableByNonAdmin / // currentCargoGrantsEmpty), so every denial this arm reaches through the token, // affectedKeys or the invariants costs zero billed reads. allow update: if canDo('update', 'Position') @@ -549,9 +555,11 @@ service cloud.firestore { // The escape is `!boardSurfacingCategory()`, NOT a hand-rolled `== 'Comision'`: the two // disagree on a doc whose `category` is absent or unrecognized (a legacy doc predating // the field). The hand-rolled form treated that as a board cargo and pinned its labels — - // fail-closed, so never a hole, but over-restrictive on exactly the docs an org-chart - // editor has the most reason to fix, and a second definition of "is this published" - // drifting beside the create arm's (guardrail #1). One predicate, both arms. + // fail-closed, so never a hole. The reason to unify is NOT that it frees such docs to be + // edited: positionDocSchema requires `category`, so parseDocs drops a category-less cargo + // and /positions never lists it — no UI reaches it either way. It is that a second + // definition of "is this published", written out longhand, drifts from the create arm's + // (guardrail #1) — and that one IS load-bearing on live docs. One predicate, both arms. allow update: if canDo('update', 'Position') && (hasAnyRole(['Admin']) || (unchanged('grants') diff --git a/packages/types/src/board-surfacing-category.rules.test.ts b/packages/types/src/board-surfacing-category.rules.test.ts index 309e69bf..f93c7b82 100644 --- a/packages/types/src/board-surfacing-category.rules.test.ts +++ b/packages/types/src/board-surfacing-category.rules.test.ts @@ -16,9 +16,18 @@ import { BOARD_GROUPS, boardGroupFromCategory } from "./engine/board-public.js"; // `boardSurfacingCategory()` was added to close, silently reopened, with nothing red. // Runs in the fast `checks` job (no emulator). // -// Parsed out of boardSurfacingCategory() specifically, not matched loose: 'CEL' and 'JDL' -// appear throughout the rules (the CEL conjunct in nonAdminAssignable(), comment prose), so -// a repo-wide search for the strings would pass on the wrong occurrence. +// The same job is done a second time for nonAdminAssignable(), the OTHER hand-written +// literal over these categories — and it is the one that defaults to ALLOW. It encodes the +// assignment boundary as a denylist of one (`category != 'CEL'`), so a third publishable +// group added to BOARD_GROUPS would be forced into boardSurfacingCategory() by the +// assertions above — minting it goes Admin-only, good — while nonAdminAssignable() silently +// kept ASSIGNING it open to every non-Admin. A new board group would auto-enroll into the +// accepted-exposure class (spec A: "the ceiling is JDL") with nobody deciding. Same +// silent-reopen shape, one function over. +// +// Parsed out of each function specifically, not matched loose: 'CEL' and 'JDL' appear +// throughout the rules (comment prose, the positions arms), so a repo-wide search for the +// strings would pass on the wrong occurrence. const RULES = readFileSync( fileURLToPath(new URL("../../../firestore.rules", import.meta.url)), @@ -38,6 +47,19 @@ function parseBoardSurfacingCategories(rules: string): string[] { .filter((entry) => entry.length > 0); } +/** The categories nonAdminAssignable() names in a `category ... != ''` conjunct — the + * denylist that decides which board cargos a non-Admin may assign through the members + * lanes. */ +function parseNonAdminAssignableDenials(rules: string): string[] { + const fn = rules.match(/function nonAdminAssignable\(cargo\)\s*\{[\s\S]*?\n {4}\}/)?.[0]; + if (fn === undefined) throw new Error("nonAdminAssignable() not found in firestore.rules"); + const denials = [...fn.matchAll(/!=\s*'([^']*)'/g)].map((match) => match[1]); + if (denials.length === 0) { + throw new Error("nonAdminAssignable() no longer excludes any category by name"); + } + return denials; +} + describe("firestore.rules boardSurfacingCategory is in sync with BOARD_GROUPS", () => { const categories = parseBoardSurfacingCategories(RULES); @@ -68,4 +90,27 @@ describe("firestore.rules boardSurfacingCategory is in sync with BOARD_GROUPS", expect(boardGroupFromCategory("Comision")).toBeNull(); expect(categories).not.toContain("Comision"); }); + + // The ALLOW-by-default twin. Everything above binds the Admin-only MINT boundary; this + // binds the non-Admin ASSIGN boundary to the same constant, so a new board group cannot + // enroll itself into the accepted public exposure. + describe("nonAdminAssignable's exclusions cover every board group but JDL", () => { + const denied = new Set(parseNonAdminAssignableDenials(RULES)); + + it("leaves exactly JDL assignable by a non-Admin", () => { + // Not `expect(denied).toEqual(new Set(['CEL']))`: the assertion that must go red on a + // new BOARD_GROUPS entry is about the categories left OPEN, and it is derived from the + // constant. Adding 'XYZ' to BOARD_GROUPS makes this ['JDL','XYZ'] until somebody + // either excludes it here or decides, in writing, that it is publishable by delegates. + expect(BOARD_GROUPS.filter((group) => !denied.has(group))).toEqual(["JDL"]); + }); + + it("excludes only categories that actually reach the public Directiva", () => { + // The other direction: an exclusion naming a non-board category would be dead weight + // gating nothing (and would wrongly narrow the comisión lane if it named 'Comision'). + for (const category of denied) { + expect(boardGroupFromCategory(category), category).not.toBeNull(); + } + }); + }); }); diff --git a/tests/firestore-rules/rules.test.ts b/tests/firestore-rules/rules.test.ts index 7a7280f2..035f981d 100644 --- a/tests/firestore-rules/rules.test.ts +++ b/tests/firestore-rules/rules.test.ts @@ -1017,6 +1017,43 @@ describe("firestore.rules — members", () => { }), ); }); + it("BLOCKING: denies Membership creating a member holding a grant-free CEL cargo", async () => { + // The create-arm mirror of "denies the update:Position lane assigning a GRANT-FREE CEL + // cargo". pos_cel_free has grants: [], so the grants half says yes; only the `category + // != 'CEL'` conjunct of cargoAssignableByNonAdmin() denies it — which the create arm + // only asks because it shares that predicate with positionsAssignmentSafe(). + // What it stops: manage:Member satisfies canDo('create','Member'), so a Membership + // holder could mint a member BORN at board rank 0 as Presidente, self-stamped. `uid` is + // forbidden at create, so the doc is unpublished at birth — but onMemberCreated stamps + // publicProfile, the creator owns name/profilePicture, and one routine Admin + // provisionMemberLogin later supplies the uid, at which point projectBoard publishes it. + // If this goes green, the create arm has drifted back off the shared predicate. + await assertFails( + setDoc(doc(as("mem-uid", ["Membership"]), "members/new_cel_free"), { + name: "Ximena Paz", + totalPoints: 0, + ...BORN_LIVE, + positions: { + [TERM]: { cargoId: "pos_cel_free", comisionIds: [], assignedBy: "mem-uid" }, + }, + }), + ); + }); + it("allows Admin creating a member on a grant-free CEL cargo (the authority, not the delegate)", async () => { + // The paired ALLOW: the CEL conjunct lives inside the non-Admin branch of the create arm + // too, so seating the CEL at create stays possible — for an Admin only. Without this, + // a create arm that denied everyone would pass the test above for the wrong reason. + await assertSucceeds( + setDoc(doc(as("admin-uid", ["Admin"]), "members/new_cel_admin"), { + name: "Ximena Paz", + totalPoints: 0, + ...BORN_LIVE, + positions: { + [TERM]: { cargoId: "pos_cel_free", comisionIds: [], assignedBy: "admin-uid" }, + }, + }), + ); + }); it("allows Admin creating with a power cargo + self assignedBy", async () => { await assertSucceeds( setDoc(doc(as("admin-uid", ["Admin"]), "members/new_admin"), { @@ -2828,7 +2865,8 @@ describe("firestore.rules — member positions assignment", () => { it("BLOCKING: denies the update:Position lane assigning a power-conferring cargo (new side)", async () => { // positionsAssignmentSafe() is reused verbatim: its non-Admin branch demands - // cargoGrantsEmpty(), and pos1 grants Treasury. Without it this lane would mint claims. + // cargoAssignableByNonAdmin(), and pos1 grants Treasury. Without it this lane would + // mint claims. await assertFails( updateDoc(doc(orgChart(), "members/m_positions"), { [`positions.${TERM}`]: { cargoId: "pos1", comisionIds: [], assignedBy: ORG_CHART }, @@ -2902,8 +2940,9 @@ describe("firestore.rules — member positions assignment", () => { it("BLOCKING: denies the update:Position lane assigning a GRANT-FREE CEL cargo", async () => { // The publication half of cargoAssignableByNonAdmin(), and the conjunct this test - // exists for. pos_cel_free carries grants: [], so cargoGrantsEmpty() — the whole - // non-Admin check before this branch — says yes; only `category != 'CEL'` denies it. + // exists for. pos_cel_free carries grants: [], so the grants half of that predicate — + // the whole non-Admin check before this branch — says yes; only `category != 'CEL'` + // denies it. // What it stops: boardRank maps 'Presidente' to 0, so this write would put the // org-chart editor at the head of the world-readable Directiva as chapter president. // If this goes green, neutralize nothing and re-read the rule: the CEL conjunct is gone. @@ -2957,7 +2996,9 @@ describe("firestore.rules — member positions assignment", () => { // CEL and JDL, so this write lands on the world-readable boardShowcase projection. It is // not privilege escalation — resolveTrustedGrants returns early on grants.length === 0, // so no claim is minted — but it IS a publication authority, and owner-op 1 says so in - // the same words. Seeded CEL cargos all carry non-empty grants, so those stay Admin-only. + // the same words. Its ceiling is JDL: CEL is Admin-only BY RULE whatever its grants + // (cargoAssignableByNonAdmin's `category != 'CEL'`), not because of what the seeded CEL + // cargos happen to hold — the test two above pins exactly that. await assertSucceeds( updateDoc(doc(orgChart(), "members/m_orgchart"), { [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: ORG_CHART }, @@ -2979,8 +3020,9 @@ describe("firestore.rules — member positions assignment", () => { describe("firestore.rules — replacing an already-assigned power cargo (C1)", () => { it("BLOCKING: denies Membership replacing an Admin-granting cargo with a grant-free one", async () => { - // The de-elevation attack: the NEW cargo is grant-free, so the old cargoGrantsEmpty() - // check passed. currentCargoGrantsEmpty() is what looks at resource.data — the cargo + // The de-elevation attack: the NEW cargo is grant-free and not CEL, so the new-side + // cargoAssignableByNonAdmin() passed. currentCargoGrantsEmpty() looks at resource.data — + // the cargo // being displaced — and denies the write. await assertFails( updateDoc(doc(as("mem-uid", ["Membership"]), "members/m_powercargo"), { @@ -2990,7 +3032,7 @@ describe("firestore.rules — replacing an already-assigned power cargo (C1)", ( }); it("BLOCKING: denies Membership clearing an Admin-granting cargo to null", async () => { - // cargoId: null makes cargoGrantsEmpty() short-circuit true; only the old-side guard + // cargoId: null makes cargoAssignableByNonAdmin() short-circuit true; only the old-side guard // catches it. await assertFails( updateDoc(doc(as("mem-uid", ["Membership"]), "members/m_powercargo"), { diff --git a/tools/scripts/audit-soft-delete-shapes.mjs b/tools/scripts/audit-soft-delete-shapes.mjs index faee8092..4c05e358 100644 --- a/tools/scripts/audit-soft-delete-shapes.mjs +++ b/tools/scripts/audit-soft-delete-shapes.mjs @@ -67,7 +67,10 @@ // production `--repair` writes to members and, through the trigger, to the world-readable // Directiva, so it demands an explicit confirmation (typed, or `--confirm=` for a // non-interactive shell) — the same posture as seed-production.mjs and the -// `confirm: "overwrite-builtin-roles"` token on reseedBuiltInRolePerms. +// `confirm: "overwrite-builtin-roles"` token on reseedBuiltInRolePerms. Adding +// `--allow-publish` changes the token to `repair-production-shapes-and-publish`: the string +// the operator types names the consequence, so the one flag that can ADD public exposure is +// not the one the confirmation is silent about. // gcloud auth application-default login && pnpm audit:soft-delete-shapes // Or the emulator, by setting the env first (no confirmation there): // FIRESTORE_EMULATOR_HOST=127.0.0.1:4010 pnpm audit:soft-delete-shapes @@ -85,6 +88,10 @@ const PAGE = 300; const GETALL_CHUNK = 300; const MAX_PRINT = 20; const CONFIRM_TOKEN = "repair-production-shapes"; +/** `--allow-publish` is the one flag that can ADD world-readable exposure, so it gets its + * own token: the string the operator types has to NAME the consequence they are accepting, + * or the confirmation prompt is silent about the only irreversible half of the run. */ +const PUBLISH_CONFIRM_TOKEN = "repair-production-shapes-and-publish"; const CONFIRM_FLAG = "--confirm="; /** Opt-in for the one repair that ADDS public exposure — see the publication forecast. */ const ALLOW_PUBLISH_FLAG = "--allow-publish"; @@ -97,6 +104,18 @@ const ALLOW_PUBLISH = process.argv.includes(ALLOW_PUBLISH_FLAG); const confirmArg = process.argv .find((arg) => arg.startsWith(CONFIRM_FLAG)) ?.slice(CONFIRM_FLAG.length); +/** What a `WILL PUBLISH:` line may truthfully claim the run is doing about that doc. The + * forecast prints in READ-ONLY mode too — where nothing is written no matter which flags + * were passed — so the outcome text has to branch on REPAIR before ALLOW_PUBLISH. */ +const publishOutcome = !REPAIR + ? `Read-only — nothing was written. A --repair run would ${ + ALLOW_PUBLISH + ? "apply this repair and publish the member" + : `WITHHOLD it (no ${ALLOW_PUBLISH_FLAG})` + }.` + : ALLOW_PUBLISH + ? `${ALLOW_PUBLISH_FLAG} was passed — repairing it.` + : `Withheld: re-run with ${ALLOW_PUBLISH_FLAG} to repair these too, or set publicProfile: false on the member first (the opt-out this member never exercised).`; const emulator = process.env.FIRESTORE_EMULATOR_HOST; const projectId = process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCLOUD_PROJECT ?? "jci-oriente"; @@ -106,15 +125,19 @@ console.log( ); async function confirmProductionRepair() { - if (confirmArg === CONFIRM_TOKEN) return; + // --allow-publish widens what this run may do, so it widens the token that authorizes it. + // The plain token never authorizes a publishing run: pass the flag and the plain token is + // rejected, exactly like any other wrong string. + const requiredToken = ALLOW_PUBLISH ? PUBLISH_CONFIRM_TOKEN : CONFIRM_TOKEN; + if (confirmArg === requiredToken) return; if (confirmArg !== undefined) { - console.error(`Refusing to repair: --confirm must be exactly "${CONFIRM_TOKEN}".`); + console.error(`Refusing to repair: --confirm must be exactly "${requiredToken}".`); process.exit(EXIT_INCOMPLETE); } if (!process.stdin.isTTY) { console.error( `Refusing to repair PRODUCTION project ${projectId} without confirmation. ` + - `No TTY to prompt on — re-run with ${CONFIRM_FLAG}${CONFIRM_TOKEN}.`, + `No TTY to prompt on — re-run with ${CONFIRM_FLAG}${requiredToken}.`, ); process.exit(EXIT_INCOMPLETE); } @@ -126,7 +149,12 @@ async function confirmProductionRepair() { `About to WRITE to members/positions/allies in PRODUCTION project ${projectId}.\n` + "A members write re-fires onBoardMemberWritten and re-projects the world-readable " + "Directiva.\n" + - `Type ${CONFIRM_TOKEN} to proceed: `, + (ALLOW_PUBLISH + ? `${ALLOW_PUBLISH_FLAG} was passed: repairs that would ADD a member to the ` + + "world-readable Directiva will be applied, not withheld. Every such member is " + + "announced with a WILL PUBLISH line before it is written.\n" + : "") + + `Type ${requiredToken} to proceed: `, ) ).trim(); } catch { @@ -135,7 +163,7 @@ async function confirmProductionRepair() { answer = ""; } rl.close(); - if (answer !== CONFIRM_TOKEN) { + if (answer !== requiredToken) { console.error("Aborted — nothing was written."); process.exit(EXIT_INCOMPLETE); } @@ -331,7 +359,11 @@ async function cargoState(cargoIds) { /** true / false / null(unknown) — does this cargo put the member on the Directiva? */ function cargoPublishes(cargo) { - if (cargo === "unreadable") return null; + // `undefined` = this id was never resolved, which currentTermKey() straddling a UTC-year + // boundary between the two calls below can produce. Unknown, not benign: reported like an + // unreadable read rather than TypeError-ing on `cargo.category` — same fail-safe direction + // as every other gate here. + if (cargo === undefined || cargo === "unreadable") return null; if (cargo === null) return false; if (!BOARD_CATEGORIES.has(cargo.category)) return false; return typeof cargo.title === "string" && cargo.title.trim().length > 0; @@ -501,7 +533,10 @@ for (const coll of COLLECTIONS) { "cargo, a pinned portrait and publicProfile: true, so the re-fired " + `onBoardMemberWritten ADDS boardShowcase/${doc.id} to the world-readable ` + "Directiva. That is a NEW publication, not a repair side effect anyone asked " + - `for.\n ${ALLOW_PUBLISH ? `${ALLOW_PUBLISH_FLAG} was passed — repairing it.` : `Withheld: re-run with ${ALLOW_PUBLISH_FLAG} to repair these too, or set publicProfile: false on the member first (the opt-out this member never exercised).`}`, + // REPAIR first: this forecast prints in read-only mode too, where NOTHING is + // written and "--allow-publish was passed — repairing it" would be a false + // statement about what the run just did. + `for.\n ${publishOutcome}`, ); if (publishing.unknownCargo !== null) { console.log( From 82870df4a8c22f95eb48aaf6804453892c5f38af Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 13:32:01 -0400 Subject: [PATCH 11/16] fix(backstage): don't lock the takedown the rules deliberately allow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client mirror added in the previous commit collapsed two asymmetric rules conjuncts into one. cargoAssignableByNonAdmin() denies KEEPING a grant-free CEL seat, but currentCargoGrantsEmpty() is deliberately not category-gated — firestore.rules says denying that would strand a takedown behind an Admin — so CLEARING the seat is allowed. Mirroring the wrong conjunct stranded exactly that takedown in the UI: the form locked, the submit button disabled, and a non-Admin could no longer clear a member off a CEL seat even though the rules permit it. positionsLockedForNonAdmin() now locks only on a power-granting cargo, where nothing the non-Admin submits can succeed. A grant-free CEL seat leaves the form open and is simply not offered, so the only submittable states are cleared or some other assignable cargo — which is the rules' answer. The two tests that asserted the old behaviour are rewritten to the corrected semantics rather than deleted, and a power-cargo case is added beside each so the lock still has a test. Both are mutation-proven: restoring the negation turns exactly them red. backstage 745->746. Co-Authored-By: Claude Opus 5 (1M context) --- .../members/components/member-form.test.tsx | 8 +++++-- .../members/components/member-form.tsx | 23 +++++++++++------- .../components/member-positions-form.test.tsx | 24 ++++++++++++++++++- .../components/member-positions-form.tsx | 21 +++++++++------- .../features/members/lib/assignable-cargo.ts | 21 ++++++++++++++++ 5 files changed, 78 insertions(+), 19 deletions(-) diff --git a/apps/backstage/src/features/members/components/member-form.test.tsx b/apps/backstage/src/features/members/components/member-form.test.tsx index 393949b0..18fa0d03 100644 --- a/apps/backstage/src/features/members/components/member-form.test.tsx +++ b/apps/backstage/src/features/members/components/member-form.test.tsx @@ -259,7 +259,11 @@ describe("MemberForm", () => { // non-Admin editing a member already seated on a grant-free CEL cargo is denied on the // positions slot. Locking it keeps the bio fields savable (the mapper omits the // unchanged slot) instead of failing the whole form with no explanation. - it("locks the cargo for a non-Admin editing a member on a grant-free CEL cargo", () => { + // BLOCKING: the rules conjuncts are asymmetric. Keeping a grant-free CEL seat is denied + // (`cargoAssignableByNonAdmin`), but CLEARING it is allowed on purpose — + // `currentCargoGrantsEmpty()` is not category-gated, because denying it "would strand a + // takedown behind an Admin". So the seat is dropped from the options rather than locked. + it("BLOCKING: does NOT lock a grant-free CEL seat — clearing it is the allowed takedown", () => { render( { onSubmit={vi.fn()} />, ); - expect(screen.getByText(/Solo un Admin puede cambiar el cargo/i)).toBeInTheDocument(); + expect(screen.queryByText(/Solo un Admin puede cambiar el cargo/i)).not.toBeInTheDocument(); }); it("does NOT lock a non-Admin editing a member on a grant-free JDL dirección", () => { diff --git a/apps/backstage/src/features/members/components/member-form.tsx b/apps/backstage/src/features/members/components/member-form.tsx index 315375b8..fd4a75c2 100644 --- a/apps/backstage/src/features/members/components/member-form.tsx +++ b/apps/backstage/src/features/members/components/member-form.tsx @@ -25,7 +25,7 @@ import { MEMBER_NAME_MAX_LENGTH, } from "@luminova/types"; import { avatarColor } from "../lib/member-display"; -import { cargoAssignableByNonAdmin } from "../lib/assignable-cargo"; +import { cargoAssignableByNonAdmin, positionsLockedForNonAdmin } from "../lib/assignable-cargo"; interface MemberFormProps { positions: Position[]; @@ -108,18 +108,25 @@ export function MemberForm({ // rules reserve it to an Admin — but off the static default, not the reactive selection, // so switching away and back still works (matches MemberPositionsForm). const assignedCargoId = defaultValues?.cargoId ?? null; - // If that assigned cargo is Admin-only — power-granting OR a CEL seat — any positions - // write by a non-Admin is rule-denied (cargoAssignableByNonAdmin), because the write - // re-stamps the same cargoId. Lock the cargo/comisiones so bio edits still save (the - // mapper omits the unchanged slot) but a futile positions change can't be attempted. + // A power-granting assigned cargo locks cargo/comisiones for a non-Admin — the write + // re-stamps the same cargoId and `currentCargoGrantsEmpty()` blocks clearing it, so no + // positions change succeeds. Bio edits still save, because the mapper omits an unchanged + // slot. A grant-free CEL seat is NOT locked: clearing it is deliberately allowed, so the + // form stays open and the seat is just not offered. See positionsLockedForNonAdmin(). const assignedCargo = positions.find((p) => p.id === assignedCargoId); - const positionsLocked = - !allowPowerGrants && assignedCargo !== undefined && !cargoAssignableByNonAdmin(assignedCargo); + const positionsLocked = !allowPowerGrants && positionsLockedForNonAdmin(assignedCargo); + const keepsAssignedCargo = + allowPowerGrants || (assignedCargo !== undefined && cargoAssignableByNonAdmin(assignedCargo)); const activeCargoOptions = positions .filter( (p) => p.active && p.category !== "Comision" && (p.term === null || String(p.term) === term), ) - .filter((p) => allowPowerGrants || cargoAssignableByNonAdmin(p) || p.id === assignedCargoId) + .filter( + (p) => + allowPowerGrants || + cargoAssignableByNonAdmin(p) || + (keepsAssignedCargo && p.id === assignedCargoId), + ) .map((p) => ({ value: p.id, label: positionTitle(p, gender) })); const assignedInactiveCargo = currentCargoId && !activeCargoOptions.some((o) => o.value === currentCargoId) diff --git a/apps/backstage/src/features/members/components/member-positions-form.test.tsx b/apps/backstage/src/features/members/components/member-positions-form.test.tsx index a10f937d..67cf4306 100644 --- a/apps/backstage/src/features/members/components/member-positions-form.test.tsx +++ b/apps/backstage/src/features/members/components/member-positions-form.test.tsx @@ -175,7 +175,12 @@ describe("MemberPositionsForm", () => { // Not just the option list: `locked` has to cover it too. Every save re-stamps the // assigned cargoId into the merged doc, so with the form unlocked a comisiones-only edit // on a CEL-seated member is denied — no lock, no note, one generic error. - it("locks the form for a non-Admin editing a member seated on a grant-free CEL cargo", () => { + // BLOCKING: the two rules conjuncts are asymmetric, so the client must not mirror the + // wrong one. `cargoAssignableByNonAdmin()` denies KEEPING a grant-free CEL seat, but + // `currentCargoGrantsEmpty()` is deliberately not category-gated, so CLEARING it is + // allowed — firestore.rules says denying that "would strand a takedown behind an Admin". + // Locking the form here would strand exactly that takedown in the UI instead. + it("BLOCKING: does NOT lock a grant-free CEL seat — clearing it is the allowed takedown", () => { render( { onSubmit={vi.fn().mockResolvedValue(undefined)} />, ); + expect(screen.getByRole("button", { name: /guardar/i })).toBeEnabled(); + // Not offered either — keeping it is what the rules deny, so the only submittable + // states are "cleared" or "some other assignable cargo". + expect(screen.queryByText(/Presidente/i)).not.toBeInTheDocument(); + }); + + it("locks the form for a non-Admin when the current cargo GRANTS power (nothing succeeds)", () => { + const granting: Position = { ...pos("tesorero", "CEL"), grants: ["Treasury"] }; + render( + , + ); expect(screen.getByRole("button", { name: /guardar/i })).toBeDisabled(); expect(screen.getByText(/Solo un Admin puede cambiar los cargos/i)).toBeInTheDocument(); }); diff --git a/apps/backstage/src/features/members/components/member-positions-form.tsx b/apps/backstage/src/features/members/components/member-positions-form.tsx index 406311da..8d40d3ce 100644 --- a/apps/backstage/src/features/members/components/member-positions-form.tsx +++ b/apps/backstage/src/features/members/components/member-positions-form.tsx @@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, Combobox, Field, MultiSelect } from "@luminova/ui"; import { positionTitle, currentTermKey, type MemberGender, type Position } from "@luminova/types"; -import { cargoAssignableByNonAdmin } from "../lib/assignable-cargo"; +import { cargoAssignableByNonAdmin, positionsLockedForNonAdmin } from "../lib/assignable-cargo"; const positionsSchema = z.object({ cargoId: z.string().min(1).nullable(), @@ -38,19 +38,24 @@ export function MemberPositionsForm({ } = useForm({ resolver: zodResolver(positionsSchema), defaultValues }); const term = currentTermKey(); - // A non-Admin can't write positions at all for a member whose current cargo is Admin-only - // — power-granting OR a CEL seat: every save re-stamps that cargoId into the merged doc, - // so `cargoAssignableByNonAdmin()` rejects the whole slot, comisiones included, even on a - // comisiones-only edit. Lock the form rather than 403 a save that looks legitimate. + // A power-granting current cargo locks the whole slot for a non-Admin: every save re-stamps + // that cargoId, and `currentCargoGrantsEmpty()` blocks clearing it too, so nothing they can + // submit succeeds. A grant-free CEL seat is the asymmetric case — keeping it is denied, + // CLEARING it is allowed on purpose — so the form stays open and the cargo is simply not + // offered. See positionsLockedForNonAdmin(). const assignedCargo = positions.find((p) => p.id === defaultValues.cargoId); - const locked = - !allowPowerGrants && assignedCargo !== undefined && !cargoAssignableByNonAdmin(assignedCargo); + const locked = !allowPowerGrants && positionsLockedForNonAdmin(assignedCargo); + const keepsCurrentCargo = + allowPowerGrants || (assignedCargo !== undefined && cargoAssignableByNonAdmin(assignedCargo)); const cargoOptions = positions .filter( (p) => p.active && p.category !== "Comision" && (p.term === null || String(p.term) === term), ) .filter( - (p) => allowPowerGrants || cargoAssignableByNonAdmin(p) || p.id === defaultValues.cargoId, + (p) => + allowPowerGrants || + cargoAssignableByNonAdmin(p) || + (keepsCurrentCargo && p.id === defaultValues.cargoId), ) .map((p) => ({ value: p.id, label: positionTitle(p, gender) })); const comisionOptions = positions diff --git a/apps/backstage/src/features/members/lib/assignable-cargo.ts b/apps/backstage/src/features/members/lib/assignable-cargo.ts index a3e3f2bf..8d079890 100644 --- a/apps/backstage/src/features/members/lib/assignable-cargo.ts +++ b/apps/backstage/src/features/members/lib/assignable-cargo.ts @@ -18,3 +18,24 @@ import type { Position } from "@luminova/types"; export function cargoAssignableByNonAdmin(cargo: Pick): boolean { return cargo.grants.length === 0 && cargo.category !== "CEL"; } + +/** + * Whether a non-Admin is barred from touching the positions slot AT ALL, given the cargo the + * member currently holds. NOT the negation of `cargoAssignableByNonAdmin` — the two rules + * conjuncts are asymmetric, and mirroring the wrong one strands a takedown: + * + * grants.length > 0 → locked. `currentCargoGrantsEmpty()` gates the cargo being REPLACED, + * so a non-Admin can neither keep it (the save re-stamps it) nor clear + * it. Nothing they can do here succeeds. + * grant-free CEL → NOT locked. `currentCargoGrantsEmpty()` is deliberately not + * category-gated — firestore.rules says denying this "would strand a + * takedown behind an Admin" — so clearing the seat is allowed even + * though keeping it is not. The cargo is dropped from the options + * instead, which makes the only submittable states "clear" or "some + * other assignable cargo" — exactly the rules' answer. + */ +export function positionsLockedForNonAdmin( + cargo: Pick | undefined, +): boolean { + return cargo !== undefined && cargo.grants.length > 0; +} From 07d11276313e8dd9842537d7a20135e42f5b5af6 Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 13:33:18 -0400 Subject: [PATCH 12/16] chore: reviews Reviews: 82870df4a8c22f95eb48aaf6804453892c5f38af security-review,firestore-security-reviewer,firebase-functions-reviewer,code-review,simplify,react-best-practices,secure-dep-vetting,bundle-budget-watcher From be6831ecd1b9be922545a1f3d50623f21c7f1fbb Mon Sep 17 00:00:00 2001 From: Arnold Gandarillas Castillo Date: Fri, 14 Aug 2026 14:43:17 -0400 Subject: [PATCH 13/16] fix(backstage): make the CEL takedown actually reachable, and the exit-code fix testable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /code-review found that the client mirror added two commits ago was half a mirror, and that the "clearing is the allowed takedown" it promised was not reachable at all. Dropping the grant-free CEL seat from the options left it as the RHF value with nothing to render it, so the trigger showed the "Sin cargo" PLACEHOLDER for a member who holds a cargo; saving as-is re-submitted the cargoId into a 403; and Combobox clears only by re-selecting the currently-selected option, which was no longer in the list — with the test's own fixture the option list was empty, so there was no path to null whatsoever. The sibling form did the opposite of its comment: the pre-existing inactive-cargo fallback re-added the seat labelled "(inactivo)" — an ACTIVE cargo mislabelled, and re-offered to exactly the principal the rules reject. The third disjunct both forms used to "keep the current selection visible" was dead code, which is what let both happen. Both forms now share cargoOptionsForEditor()/cargoTakedownOnly(): the held seat renders DISABLED so the trigger names the real cargo, an explicit "Quitar cargo" clears it, and the save is blocked only while the denied seat is still selected. The lock note no longer names CEL as a cause it can no longer be. The audit script's exit sites move from process.exit() to process.exitCode. On POSIX a piped stdout is an async write, so process.exit() dropped whatever was still buffered — losing exactly the per-doc worklist a blocking pre-deploy gate exists to produce, while still returning the right code. Two defects in the new tests themselves, both found by running them rather than reading them: - the confirmation-gate test called its own helper positionally where it takes an options object, so --repair, --confirm and the env override were all silently dropped; it asserted a production refusal while actually running read-only against the emulator. - the truncation test attached its reader immediately, so the pipe drained as fast as the child filled it and nothing ever queued. Measured: it passed with process.exit() restored — vacuous against the one bug it exists for. It now leaves the stream paused, and the mutation yields "got 0 bytes". backstage 746->752, test:seed 28->30. Co-Authored-By: Claude Opus 5 (1M context) --- .../members/components/member-form.test.tsx | 97 ++++- .../members/components/member-form.tsx | 105 ++--- .../components/member-positions-form.test.tsx | 60 ++- .../components/member-positions-form.tsx | 84 ++-- .../features/members/lib/assignable-cargo.ts | 73 +++- tools/scripts/audit-soft-delete-shapes.mjs | 391 ++++++++++-------- .../lib/audit-soft-delete-shapes.test.mjs | 163 ++++++++ 7 files changed, 701 insertions(+), 272 deletions(-) create mode 100644 tools/scripts/lib/audit-soft-delete-shapes.test.mjs diff --git a/apps/backstage/src/features/members/components/member-form.test.tsx b/apps/backstage/src/features/members/components/member-form.test.tsx index 18fa0d03..0fa5bdde 100644 --- a/apps/backstage/src/features/members/components/member-form.test.tsx +++ b/apps/backstage/src/features/members/components/member-form.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { Position } from "@luminova/types"; +import type { MemberInput, Position } from "@luminova/types"; import { MemberForm } from "./member-form"; +import { toMemberUpdateDoc } from "../repositories/member-mapper"; import { pickDate } from "../../../test/pick-date"; const positions: Position[] = [ @@ -262,7 +263,18 @@ describe("MemberForm", () => { // BLOCKING: the rules conjuncts are asymmetric. Keeping a grant-free CEL seat is denied // (`cargoAssignableByNonAdmin`), but CLEARING it is allowed on purpose — // `currentCargoGrantsEmpty()` is not category-gated, because denying it "would strand a - // takedown behind an Admin". So the seat is dropped from the options rather than locked. + // takedown behind an Admin". So the seat renders disabled rather than locked or dropped. + const celSeated = { + name: "Ana Pérez", + email: "ana@jci.bo", + gender: "Femenino" as const, + joinDate: "2020-03-15", + birthdate: "1992-07-15", + status: "Activo" as const, + cargoId: "pos-pres", + comisionIds: [], + }; + it("BLOCKING: does NOT lock a grant-free CEL seat — clearing it is the allowed takedown", () => { render( { expect(screen.queryByText(/Solo un Admin puede cambiar el cargo/i)).not.toBeInTheDocument(); }); + // Dropping the seat from the options handed it to the `(inactivo)` fallback, which re-added + // an ACTIVE cargo under an inactive label — and re-offered it to the very non-Admin whose + // write the rules reject. The two member forms must answer the same rules predicate. + it("BLOCKING: never labels the active grant-free CEL seat '(inactivo)' to a non-Admin", () => { + render( + , + ); + const trigger = screen.getByLabelText("Cargo"); + expect(trigger).toHaveTextContent("Presidente"); + expect(trigger).not.toHaveTextContent(/inactivo/i); + }); + + it("BLOCKING: reaches the takedown of a grant-free CEL seat and submits cargoId null", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i })); + expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo"); + await userEvent.click(screen.getByRole("button", { name: /guardar/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ cargoId: null })); + }); + + it("BLOCKING: a non-Admin cannot re-assign the grant-free CEL seat once cleared", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i })); + await userEvent.click(screen.getByLabelText("Cargo")); + const seat = await screen.findByRole("option", { name: "Presidenta" }); + expect(seat).toHaveAttribute("aria-disabled", "true"); + await userEvent.click(seat); + await userEvent.keyboard("{Escape}"); + expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo"); + await userEvent.click(screen.getByRole("button", { name: /guardar/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ cargoId: null })); + }); + + // The other half of "never submittable": leaving the seat untouched is the one state that + // still carries the CEL cargoId out of the form, and it must never become a positions + // WRITE. Asserted through the mapper the edit lane actually uses, not by inspection — the + // form's safety here is entirely toMemberUpdateDoc omitting an unchanged slot. + it("BLOCKING: an untouched CEL seat never reaches the positions write", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /guardar/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + const [submitted] = onSubmit.mock.calls[0]! as [MemberInput]; + expect(submitted.cargoId).toBe("pos-pres"); + const doc = toMemberUpdateDoc(submitted, "uid-editor", { + cargoId: "pos-pres", + comisionIds: [], + }); + expect(Object.keys(doc).some((key) => key.startsWith("positions."))).toBe(false); + }); + it("does NOT lock a non-Admin editing a member on a grant-free JDL dirección", () => { render( p.id === currentCargoId)?.category === "CEL"; - const term = currentTermKey(); - // Keep the member's ORIGINALLY-assigned cargo selectable for a non-Admin even when the - // rules reserve it to an Admin — but off the static default, not the reactive selection, - // so switching away and back still works (matches MemberPositionsForm). + const selectedCargo = positions.find((p) => p.id === currentCargoId); + const isExecutiveCommitteeCargo = selectedCargo?.category === "CEL"; + // Keep the member's ORIGINALLY-assigned cargo visible for a non-Admin even when the rules + // reserve it to an Admin — off the static default, not the reactive selection, so switching + // away and back still works (shared with MemberPositionsForm via cargoOptionsForEditor: a + // per-form copy is what let this one re-add the held seat labelled "(inactivo)" while the + // other dropped it). const assignedCargoId = defaultValues?.cargoId ?? null; // A power-granting assigned cargo locks cargo/comisiones for a non-Admin — the write // re-stamps the same cargoId and `currentCargoGrantsEmpty()` blocks clearing it, so no // positions change succeeds. Bio edits still save, because the mapper omits an unchanged // slot. A grant-free CEL seat is NOT locked: clearing it is deliberately allowed, so the - // form stays open and the seat is just not offered. See positionsLockedForNonAdmin(). + // form stays open, the seat renders disabled (visible, not assignable) and "Quitar cargo" + // makes the takedown reachable. See positionsLockedForNonAdmin() / cargoTakedownOnly(). const assignedCargo = positions.find((p) => p.id === assignedCargoId); const positionsLocked = !allowPowerGrants && positionsLockedForNonAdmin(assignedCargo); - const keepsAssignedCargo = - allowPowerGrants || (assignedCargo !== undefined && cargoAssignableByNonAdmin(assignedCargo)); - const activeCargoOptions = positions - .filter( - (p) => p.active && p.category !== "Comision" && (p.term === null || String(p.term) === term), - ) - .filter( - (p) => - allowPowerGrants || - cargoAssignableByNonAdmin(p) || - (keepsAssignedCargo && p.id === assignedCargoId), - ) - .map((p) => ({ value: p.id, label: positionTitle(p, gender) })); - const assignedInactiveCargo = - currentCargoId && !activeCargoOptions.some((o) => o.value === currentCargoId) - ? positions - .filter((p) => p.id === currentCargoId) - .map((p) => ({ value: p.id, label: `${positionTitle(p, gender)} (inactivo)` })) - : []; - const cargoOptions = [...activeCargoOptions, ...assignedInactiveCargo]; + const cargoTakedown = cargoTakedownOnly(selectedCargo, allowPowerGrants); + const cargoOptions = cargoOptionsForEditor({ + positions, + gender, + allowPowerGrants, + assignedCargoId, + }); const comisionLabel = (p: Position) => (p.sigla ? `${p.sigla} — ${p.title}` : p.title); const activeComisionOptions = positions @@ -241,21 +232,34 @@ export function MemberForm({ control={control} name="cargoId" render={({ field }) => ( - { - field.onChange(v); - // Switching to a CEL cargo drops any picked comisiones (CEL members - // belong to the Comité Ejecutivo Local, not a comisión). - if (positions.find((p) => p.id === v)?.category === "CEL") { - setValue("comisionIds", []); - } - }} - placeholder="Sin cargo" - disabled={positionsLocked} - /> +
+ { + field.onChange(v); + // Switching to a CEL cargo drops any picked comisiones (CEL members + // belong to the Comité Ejecutivo Local, not a comisión). + if (positions.find((p) => p.id === v)?.category === "CEL") { + setValue("comisionIds", []); + } + }} + placeholder="Sin cargo" + disabled={positionsLocked} + /> + {cargoTakedown && ( + + )} +
)} />
@@ -295,8 +299,15 @@ export function MemberForm({ )} {positionsLocked && (

- Solo un Admin puede cambiar el cargo de un miembro del Comité Ejecutivo Local o con - permisos. Puedes editar el resto de sus datos. + Solo un Admin puede cambiar el cargo de un miembro cuyo cargo otorga permisos. Puedes + editar el resto de sus datos. +

+ )} + {cargoTakedown && ( +

+ Este cargo es del Comité Ejecutivo Local: solo un Admin puede asignarlo. Puedes + quitárselo con «Quitar cargo» o dejarlo como está; el resto de sus datos se guarda + igual.

)} { // `currentCargoGrantsEmpty()` is deliberately not category-gated, so CLEARING it is // allowed — firestore.rules says denying that "would strand a takedown behind an Admin". // Locking the form here would strand exactly that takedown in the UI instead. - it("BLOCKING: does NOT lock a grant-free CEL seat — clearing it is the allowed takedown", () => { - render( - , + const celSeatedProps = { + positions: [celFree, pos("etica", "Comision")], + gender: "Masculino" as const, + allowPowerGrants: false, + defaultValues: { cargoId: "presidente_libre", comisionIds: [] }, + }; + + // Dropping the seat from the options was half a mirror: it left the CEL cargoId as the RHF + // value with no option to render it, so the trigger showed the "Sin cargo" PLACEHOLDER for a + // seated member, saving as-is re-submitted the cargoId into a 403, and Combobox's clear + // gesture (re-select the selected option) was unreachable because that option did not exist. + it("BLOCKING: names the grant-free CEL seat a non-Admin holds instead of 'Sin cargo'", () => { + render(); + expect(screen.getByLabelText("Cargo")).toHaveTextContent("presidente_libre"); + // Not locked — the takedown stays open — but not savable while the seat is kept either. + expect(screen.queryByText(/Solo un Admin puede cambiar los cargos/i)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /guardar/i })).toBeDisabled(); + }); + + it("BLOCKING: reaches the takedown a grant-free CEL seat allows and submits cargoId null", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i })); + expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo"); + const save = screen.getByRole("button", { name: /guardar/i }); + expect(save).toBeEnabled(); + await userEvent.click(save); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ cargoId: null, comisionIds: [] }), + ); + }); + + it("BLOCKING: a non-Admin cannot re-assign the grant-free CEL seat once cleared", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i })); + await userEvent.click(screen.getByLabelText("Cargo")); + const seat = await screen.findByRole("option", { name: "presidente_libre" }); + expect(seat).toHaveAttribute("aria-disabled", "true"); + await userEvent.click(seat); + await userEvent.keyboard("{Escape}"); + expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo"); + await userEvent.click(screen.getByRole("button", { name: /guardar/i })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ cargoId: null, comisionIds: [] }), ); - expect(screen.getByRole("button", { name: /guardar/i })).toBeEnabled(); - // Not offered either — keeping it is what the rules deny, so the only submittable - // states are "cleared" or "some other assignable cargo". - expect(screen.queryByText(/Presidente/i)).not.toBeInTheDocument(); }); it("locks the form for a non-Admin when the current cargo GRANTS power (nothing succeeds)", () => { diff --git a/apps/backstage/src/features/members/components/member-positions-form.tsx b/apps/backstage/src/features/members/components/member-positions-form.tsx index 8d40d3ce..bc02f796 100644 --- a/apps/backstage/src/features/members/components/member-positions-form.tsx +++ b/apps/backstage/src/features/members/components/member-positions-form.tsx @@ -3,8 +3,12 @@ import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, Combobox, Field, MultiSelect } from "@luminova/ui"; -import { positionTitle, currentTermKey, type MemberGender, type Position } from "@luminova/types"; -import { cargoAssignableByNonAdmin, positionsLockedForNonAdmin } from "../lib/assignable-cargo"; +import { type MemberGender, type Position } from "@luminova/types"; +import { + cargoOptionsForEditor, + cargoTakedownOnly, + positionsLockedForNonAdmin, +} from "../lib/assignable-cargo"; const positionsSchema = z.object({ cargoId: z.string().min(1).nullable(), @@ -25,8 +29,8 @@ export function MemberPositionsForm({ defaultValues: PositionsInput; /** Whether the caller may assign the cargos the rules reserve to an Admin — power-granting * ones and CEL seats alike (rules' `cargoAssignableByNonAdmin`). A non-Admin sees only - * assignable cargos (plus the current assignment, so an existing selection still - * renders). */ + * assignable cargos, plus the seat the member already holds rendered DISABLED, so the + * trigger names the real cargo without putting a denied write one click away. */ allowPowerGrants: boolean; onSubmit: (data: PositionsInput) => Promise; }) { @@ -34,30 +38,26 @@ export function MemberPositionsForm({ const { control, handleSubmit, + watch, formState: { isSubmitting }, } = useForm({ resolver: zodResolver(positionsSchema), defaultValues }); - const term = currentTermKey(); // A power-granting current cargo locks the whole slot for a non-Admin: every save re-stamps // that cargoId, and `currentCargoGrantsEmpty()` blocks clearing it too, so nothing they can // submit succeeds. A grant-free CEL seat is the asymmetric case — keeping it is denied, - // CLEARING it is allowed on purpose — so the form stays open and the cargo is simply not - // offered. See positionsLockedForNonAdmin(). + // CLEARING it is allowed on purpose — so the form stays open, the seat renders as a disabled + // option (the trigger must not claim "Sin cargo" for a seated member) and only the takedown + // can be saved. See positionsLockedForNonAdmin() / cargoTakedownOnly(). const assignedCargo = positions.find((p) => p.id === defaultValues.cargoId); const locked = !allowPowerGrants && positionsLockedForNonAdmin(assignedCargo); - const keepsCurrentCargo = - allowPowerGrants || (assignedCargo !== undefined && cargoAssignableByNonAdmin(assignedCargo)); - const cargoOptions = positions - .filter( - (p) => p.active && p.category !== "Comision" && (p.term === null || String(p.term) === term), - ) - .filter( - (p) => - allowPowerGrants || - cargoAssignableByNonAdmin(p) || - (keepsCurrentCargo && p.id === defaultValues.cargoId), - ) - .map((p) => ({ value: p.id, label: positionTitle(p, gender) })); + const cargoOptions = cargoOptionsForEditor({ + positions, + gender, + allowPowerGrants, + assignedCargoId: defaultValues.cargoId, + }); + const selectedCargo = positions.find((p) => p.id === watch("cargoId")); + const takedownOnly = cargoTakedownOnly(selectedCargo, allowPowerGrants); const comisionOptions = positions .filter((p) => p.active && p.category === "Comision") .map((p) => ({ value: p.id, label: p.sigla ? `${p.sigla} — ${p.title}` : p.title })); @@ -78,14 +78,27 @@ export function MemberPositionsForm({ control={control} name="cargoId" render={({ field }) => ( - +
+ + {takedownOnly && ( + + )} +
)} />
@@ -106,8 +119,13 @@ export function MemberPositionsForm({ {locked && (

- Solo un Admin puede cambiar los cargos de un miembro del Comité Ejecutivo Local o con - permisos. + Solo un Admin puede cambiar los cargos de un miembro cuyo cargo otorga permisos. +

+ )} + {takedownOnly && ( +

+ Este cargo es del Comité Ejecutivo Local: solo un Admin puede asignarlo. Puedes quitárselo + con «Quitar cargo» y guardar, o elegir otro cargo.

)} {formError && ( @@ -115,10 +133,14 @@ export function MemberPositionsForm({ {formError}
)} + {/* takedownOnly disables the save, not the form: every positions write this page makes + re-stamps the whole slot (MemberRepository.setPositions), so saving while the CEL seat + is still selected is the 403 the rules promise. Clearing it (or picking another cargo) + re-enables the save — that takedown is exactly what the rules keep open. */}