From 31c778187b8c7c0dbcfa32c9c8412bdac354cb86 Mon Sep 17 00:00:00 2001
From: Arnold Gandarillas Castillo
Date: Thu, 27 Aug 2026 18:30:34 -0400
Subject: [PATCH 01/15] feat(types): add BoardSeat and MemberLogin permission
subjects
Two delegable capabilities an Admin can grant temporarily and revoke:
update:BoardSeat (seat a member on any vacant cargo, CEL and power-granting
alike) and create:MemberLogin (provision a member's Auth login).
Both gates are exact hasPerm code tests, so manage:all does not satisfy them
and the sibling codes the cross-product generates stay inert.
Spec and plan record the three guards adversarial review required: the trust
gate must be non-reflexive, currentCargoGrantsEmpty stays Admin-only, and a
non-Admin provision caller may only mint a new Auth account.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../permissions/lib/permission-matrix.ts | 2 +
docs/plans/board-seat-delegation.md | 527 ++++++++++++++++++
docs/specs/board-seat-delegation.md | 99 ++++
packages/types/src/permission.test.ts | 26 +
packages/types/src/permission.ts | 15 +
5 files changed, 669 insertions(+)
create mode 100644 docs/plans/board-seat-delegation.md
create mode 100644 docs/specs/board-seat-delegation.md
diff --git a/apps/backstage/src/features/permissions/lib/permission-matrix.ts b/apps/backstage/src/features/permissions/lib/permission-matrix.ts
index 1a7356a2..7c8e7cff 100644
--- a/apps/backstage/src/features/permissions/lib/permission-matrix.ts
+++ b/apps/backstage/src/features/permissions/lib/permission-matrix.ts
@@ -38,6 +38,8 @@ export const SUBJECT_LABELS: Record, string> =
Lead: "Prospectos",
Notification: "Notificaciones",
Showcase: "Destacados públicos",
+ BoardSeat: "Asientos de directiva",
+ MemberLogin: "Acceso de miembros",
};
/** Human label for a single code, e.g. "Editar Miembros". */
diff --git a/docs/plans/board-seat-delegation.md b/docs/plans/board-seat-delegation.md
new file mode 100644
index 00000000..61207936
--- /dev/null
+++ b/docs/plans/board-seat-delegation.md
@@ -0,0 +1,527 @@
+# Implementation Plan — Board-seat + member-login delegation
+
+Branch: `feat/board-seat-delegation`
+
+## 0. Accepted decision
+
+The chapter owner has accepted that `update:BoardSeat` is a **claims-minting delegation**: a
+delegate may seat a member on a cargo whose `grants` include `Admin`, and beacon's trust gate
+will mint that claim. Deliberate; no guard is added against *that*. The delegation is meant to
+be granted temporarily and revoked.
+
+The acceptance is explicitly premised on **revocability**. Three guards below exist to make that
+premise true; without them it is false. They are not a narrowing of the accepted decision — a
+delegate can still seat any vacant cargo, including `Presidente`.
+
+## 0b. Security guards (added after adversarial review — G1/G2/G3)
+
+Two independent reviews found three defects, all confirmed by reading code. Each guard below
+removes a capability the owner did **not** ask for, and none removes one they did.
+
+### G1 — `provisionMemberLogin`: a non-Admin caller may only mint a NEW Auth account
+
+`provision-member-login.ts:82-114`. The relink refusal at `:85` is guarded by
+`linkedUid !== null`, so it does not run for a member doc with no `uid`. Rules never constrain
+`members.email` (`firestore.rules:428-435` pins `totalPoints`/`uid`/`publicProfile`/`name`/
+`roleIds`/`positions`, not `email`), and no uniqueness check exists. So a
+`create:Member` + `create:MemberLogin` holder could create `members/evil` carrying a sitting
+Admin's email, call the callable, and have it (a) adopt the Admin's Auth account, (b) strip its
+claims via `adoptedClaims` at `:109`, (c) `linkUid` the Admin's uid onto their own doc through
+the admin SDK at `:111`, and (d) return a **password-reset link for the Admin's email** at
+`:112-114`. Full account takeover, unrelated to board seating.
+
+**Guard:** thread the caller's privilege into `provisionMember`. For a non-Admin caller require
+`user === null || user.uid === linkedUid` **before** `:97` — a delegate may create a brand-new
+Auth account or re-provision an already-linked one, never adopt a pre-existing account. Costs
+the delegate nothing: a genuinely new member has no existing account.
+
+Note also: the invite **email** is not the privileged part. `requestPasswordReset`
+(`apps/backstage/src/lib/auth/request-password-reset.ts`) is a plain client-side
+`sendPasswordResetEmail` any signed-in user can already call. The callable's privilege is
+account creation/adoption + uid linking + claim writing.
+
+### G2 — the trust gate must be non-reflexive on self-assignment
+
+`sync.ts:69` + `compute-roles.ts:8-12`. A delegate self-seats `Presidente` in one write on the
+positions-only lane; beacon mints `roles: ["Admin","Member"]`. Revoking `update:BoardSeat` then
+re-fires `onMemberWritten`, the gate reads their **live** claims, finds the `Admin` role the
+cargo just minted, and re-honors the grants. The claim satisfies the gate that minted it, so the
+delegation is permanent. `recomputeAllClaims` runs the same code and does not break the loop.
+
+**Guard:** when `assignedBy === member.uid`, trust **only** `assigner.perms.includes("update:BoardSeat")`,
+never `assigner.roles.includes("Admin")`. Cargo-derived Admin can no longer bootstrap its own
+trust. An Admin seating *someone else* is unaffected; an Admin self-seating still works via the
+perm if they hold it, and via a second Admin otherwise.
+
+Test: assigner === target, `roles:["Admin"]`, `perms:[]` → grants dropped.
+
+### G3 — `currentCargoGrantsEmpty()` stays Admin-only
+
+`computeMemberRoles` derives the `roles` claim **exclusively** from cargo grants; directly
+assigned `roleIds` feed `perms`, never `roles`. So bypassing `currentCargoGrantsEmpty()` lets a
+delegate clear every Admin's cargo and strip them all. After the last one there is no Admin:
+`setUserRoles` is `requireAdmin`, `roles/*` and `permissionOverrides` writes are Admin-only.
+Unrecoverable outside the Firebase console.
+
+**Guard:** substitute `boardSeatDelegate()` only on the NEW-side conjunct. `positionsAssignmentSafe()`
+becomes:
+
+```
+(boardSeatDelegate() || cargoAssignableByNonAdmin())
+ && (hasAnyRole(['Admin']) || currentCargoGrantsEmpty())
+```
+
+A delegate seats any **vacant** cargo but cannot displace a sitting power-cargo holder. Hand-over
+stays an Admin action. `createPositionsSafe()` has no old side, so it takes the plain
+substitution.
+
+If the owner later wants de-elevation delegated too, that is a separate code with its own
+anti-lockout guard — not this one.
+
+## The two codes
+
+| Subject | Spanish label | Live code | Grants |
+|---|---|---|---|
+| `BoardSeat` | "Asientos de directiva" | `update:BoardSeat` | assign/clear ANY cargo — CEL category and power-granting alike — on the member CREATE and UPDATE lanes |
+| `MemberLogin` | "Acceso de miembros" | `create:MemberLogin` | run `provisionMemberLogin`: link a Firebase Auth user and return the password-reset action link (the "Enviar acceso" invite email). Cargo-agnostic — applies to every new member, board seat or not. |
+
+Independent by construction: an Admin can grant emailing without board seating and vice versa.
+
+## Corrections to the original brief
+
+### C1 (critical). `canAssignPowerGrants` also governs the `/positions` CATALOG
+
+Five consumers, one of which is the catalog:
+
+- `member-invite-drawer.tsx:180` -> `MemberForm allowPowerGrants` (create lane) — in scope
+- `member-drawer.tsx:153` -> `MemberForm allowPowerGrants` (update lane) — in scope
+- `member-profile-page.tsx:148` -> `MemberForm allowPowerGrants` — in scope
+- `member-profile-page.tsx:176` -> `MemberPositionsForm allowPowerGrants` — in scope
+- `positions-page.tsx:178` -> `PositionForm canEditGrants` — **OUT of scope**
+
+`PositionForm.canEditGrants` unlocks the `grants` editor, the `category` select and the
+board-cargo `title`/`titleFemale` fields (`position-form.tsx:88,134,157,179`) — exactly the
+`/positions` arms that stay Admin-only. Widening the shared flag would render that editor for a
+delegate whose write `firestore.rules` then rejects: the render-then-403 shape `use-can.ts`'s own
+`canFeatureInitiatives` comment exists to prevent.
+
+**Resolution:** split the flag.
+
+- `canAssignBoardSeat` = `hasAnyRole(["Admin"]) || hasPerm("update:BoardSeat")` — the four member-lane call sites.
+- `canEditCargoCatalog` = `hasAnyRole(["Admin"])` — `positions-page.tsx` only.
+- `canAssignPowerGrants` is deleted. Both replacements are `Can` members, so every call site is compiler-guided.
+
+### C2. `PERMISSION_CAP` and `ALL_PERMISSION_CODES` — no test breaks
+
+- `SUBJECTS` 14 -> 16, `ALL_PERMISSION_CODES` 84 -> 96. `permission.test.ts:34` asserts
+ `ACTIONS.length * SUBJECTS.length`, self-adjusting.
+- The 1000-byte claim test (`permission.test.ts:38`) keys on the longest code, still
+ `checkIn:MemberPoints` / `checkIn:Notification` (20 chars). `checkIn:MemberLogin` is 19,
+ `checkIn:BoardSeat` is 17. Worst case stays ~855 B. `PERMISSION_CAP` does not move.
+- Product consequence worth one spec line: two more codes compete for the same 30-slot effective-perm
+ budget, and a member breaching the cap gets `perms: []` fail-closed (`sync.ts:106-118`) — silently
+ removing their `update:BoardSeat`.
+- `sync.test.ts:580` `distinctCodes(n)` walks `ACTIONS x SUBJECTS` in order; adding subjects changes
+ which codes it picks, not their validity. No change.
+
+### C3. Two permission surfaces; neither needs a component change
+
+- `MATRIX_SUBJECTS` is a runtime `SUBJECTS.filter(...)` (`permission-matrix.ts:6`). Both new subjects
+ appear as checkbox rows in the `/permisos` role editor grid automatically. The only compiler-guided
+ edit is `SUBJECT_LABELS`.
+- The per-member surface — `member-roles-panel.tsx`, Admin-only, on `/members/$memberId`, a MultiSelect
+ of chips — derives options from `ALL_PERMISSION_CODES` + `permissionLabel()` (`:17-21`), so it picks
+ the codes up for free. Without the `SUBJECT_LABELS` entries the chip would read "Crear MemberLogin"
+ (raw-subject fallback at `permission-matrix.ts:46`).
+
+### C4. `firestore.rules`: exactly TWO substitution sites
+
+`cargoAssignableByNonAdmin()` and `currentCargoGrantsEmpty()` contain no role check of their own —
+they are pure cargo predicates.
+
+| Function | Current | After |
+|---|---|---|
+| `positionsAssignmentSafe()` | `hasAnyRole(['Admin']) \|\| (cargoAssignableByNonAdmin() && currentCargoGrantsEmpty())` | `boardSeatDelegate() \|\| (...unchanged...)` |
+| `createPositionsSafe()` | `assignedBySelf() && (hasAnyRole(['Admin']) \|\| cargoAssignableByNonAdmin())` | `assignedBySelf() && (boardSeatDelegate() \|\| ...)` |
+
+Two further facts:
+
+- **`update:BoardSeat` on its own opens nothing.** Both functions are conjuncts inside an arm whose
+ entry condition is `canDo('update','Member')`, `canDo('update','Position') && hasOnly(['positions'])`,
+ or `canDo('create','Member')`. Rules tests must use a principal holding one of those, or they pass
+ for the wrong reason.
+- **The delegate also gains de-elevation.** Bypassing `currentCargoGrantsEmpty()` means a delegate can
+ clear or replace an existing power cargo, not just assign one. Say it in the rules comment.
+
+Explicitly UNCHANGED (Admin-role-only): the `/positions` create arm's
+`hasAnyRole(['Admin']) || (grants == [] && !boardSurfacingCategory())`; the `/positions` update arm's
+`hasAnyRole(['Admin']) || (unchanged('grants') && unchanged('category') && ...)`;
+`createPermissionAssignmentSafe()` / `updatePermissionAssignmentSafe()`; the members Admin takedown arm.
+
+### C5. `permsFromClaims` exists but is not reusable from `callable-auth.ts`
+
+`firestore-deps.ts:16` defines it module-local, not exported, and its contract differs: it validates
+through `isValidPermissionCode` and returns `PermissionCode[] | undefined`, where `undefined` means "no
+`perms` key" — which `getExistingClaims` uses for its claim diff. Importing it would pull
+`firebase-admin/firestore`, `chunk`, `role-doc` and `resolve-member-perms` into the callable trust
+boundary for a boolean membership test.
+
+Instead: extract the genuinely shared three lines — `stringArrayClaim(request, key)` — so `callerRoles`
+and the new perms reader share one implementation. Satisfies guardrail #1 for the logic actually
+duplicated, without coupling the trust boundary to the claims-sync Firestore port.
+
+### C6. `member-drawer.tsx` has NO provision affordance
+
+Complete list of `isAdmin`-gated provision affordances:
+
+| File | Line | Gate today | Move to |
+|---|---|---|---|
+| `member-invite-drawer.tsx` | 41, 43, 51, 56, 184 | `isAdmin` | `canProvisionLogin` |
+| `member-row-menu.tsx` | 50 | `` | `` |
+| `member-profile-page.tsx` | 132 | `` | `` |
+
+`members-page.tsx` owns the mutation and passes `onProvision` down unconditionally — no change.
+`ActionGate` needs no change: `role` and `when` are ANDed and `role` is optional (`action-gate.tsx:21`).
+
+### C7. Only ONE test file implements `ClaimsSyncDeps`
+
+`apps/beacon/src/claims-sync/sync.test.ts` — the `fakeDeps` factory (`:43-85`), the `spied`
+spread-override (`:154-160`), and the standalone rejecting deps (`:536-547`).
+
+### C8. `assignable-cargo.ts` — no change needed
+
+Fully parameterized on `allowPowerGrants` (`:41-115`). Widening the source of that boolean is the
+entire change. Its doc comment needs one sentence ("Admin, or an `update:BoardSeat` delegate") so the
+file stops claiming the branch is Admin-only.
+
+### C9. `BUILT_IN_ROLE_PERMS` — no change needed
+
+Neither code is seeded onto any built-in role. `role-seed.mjs` mirrors only `BUILT_IN_ROLE_PERMS` /
+`ROLE_LABELS` / `ROLE_DESCRIPTIONS`, cross-checked by `role-definition.mirror.test.ts`. That table is
+untouched. `seed-contract.test.ts` never enumerates `SUBJECTS`.
+
+---
+
+## Slice 1 — Vocabulary, labels, spec
+
+1. `docs/specs/board-seat-delegation.md` (new) — the two codes; the accepted escalation decision; the
+ "`update:BoardSeat` alone opens nothing" dependency; the "revoking de-elevates on next write" note;
+ the out-of-scope list from C4; the cap note from C2.
+2. `packages/types/src/permission.ts` — add `"BoardSeat"` and `"MemberLogin"` to `SUBJECTS`, before
+ `"all"`. Each gets a comment in the shape of the existing `Showcase` block: name the ONE live code,
+ state the gate is an exact `hasPerm`, state the sibling codes are inert *because* the gate is exact.
+3. `packages/types/src/permission.test.ts` — subject in `SUBJECTS`; live code validates; inert siblings
+ validate too (the matrix renders the full grid and the role editor's write validation would reject an
+ assignable-but-unvalidatable code).
+4. `apps/backstage/src/features/permissions/lib/permission-matrix.ts` — `SUBJECT_LABELS` gains
+ `BoardSeat: "Asientos de directiva"` and `MemberLogin: "Acceso de miembros"`. No `MATRIX_SUBJECTS`
+ edit (C3).
+
+Verify: `pnpm --filter @luminova/types run build && pnpm --filter @luminova/auth run build`,
+`pnpm --filter @luminova/types run ci`, `pnpm --filter backstage run typecheck`.
+
+Commit: `feat(types): add BoardSeat and MemberLogin permission subjects`
+
+## Slice 2 — `firestore.rules` + rules tests
+
+1. `firestore.rules` — add above `cargoAssignableByNonAdmin()` (define-before-use is this file's
+ convention at that point):
+
+ ```
+ function boardSeatDelegate() {
+ return hasAnyRole(['Admin']) || hasPerm('update:BoardSeat');
+ }
+ ```
+
+ Comment in the shape of `canCurateFeatured()`'s: Admin by ROLE (locked, undeactivatable); everyone
+ else by exact PERM so revoking the code revokes the authority, which a surviving role NAME would not;
+ `hasPerm` not `canDo` so `manage:all` cannot satisfy it and the sibling codes stay inert; and the
+ accepted decision that a delegate may seat a power-granting cargo, with beacon's trust gate widened
+ to match.
+
+ Substitute at the two C4 sites. Extend the existing comments rather than replacing: the non-Admin
+ branch is unchanged, so a non-delegate is still held to
+ `cargoAssignableByNonAdmin() && currentCargoGrantsEmpty()` and the asymmetric grant-free-CEL takedown
+ survives; a delegate bypasses both conjuncts and therefore also gains replace/clear of a power cargo.
+
+ Leave the `/positions` arms, the permission-assignment arms and the takedown arm alone.
+
+2. `tests/firestore-rules/rules.test.ts` — module-scoped principals beside `ORG_CHART`/`orgChart`:
+
+ ```ts
+ const seatDelegate = () => as(SEAT_DELEGATE, [], ["update:Position", "update:BoardSeat"]);
+ const plainDelegate = () => as("seatonly-uid", [], ["update:BoardSeat"]);
+ ```
+
+ `update:Position` is load-bearing — without it the delegate never reaches an arm and every ALLOW
+ below would pass for the wrong reason.
+
+ New fixtures in the once-only `beforeAll` seed: `members/m_delegate`, `members/m_delegate_power`
+ (seeded holding `pos1` in the current term), `members/m_delegate_cel`.
+
+ Cases in `describe("firestore.rules — member positions assignment")`, before the terminal
+ "allows Admin to assign a power-conferring cargo" test:
+ - delegate assigns a grant-free CEL cargo (`pos_cel_free`) to `m_delegate_cel`, self-stamped — the
+ case `orgChart()` is denied twelve lines above. Pair them in the comment.
+ - delegate assigns a power-granting cargo (`pos1`) to `m_delegate`, self-stamped.
+ - delegate replaces the power cargo on `m_delegate_power` with `pos_soft` — the
+ `currentCargoGrantsEmpty()` bypass, i.e. de-elevation authority.
+ - `plainDelegate()` (has `update:BoardSeat`, lacks `update:Position`/`update:Member`) is denied any
+ positions write. Non-vacuity pin for the whole feature.
+ - delegate denied a forged `assignedBy` — `assignedBySelf()` is outside the substituted disjunction.
+ - delegate denied a non-current-term write — `positionsDelta().hasOnly([currentTermKey()])`.
+ - delegate denied any ride-along non-positions field on the `hasOnly(['positions'])` lane.
+ - regression pin: `orgChart()` still denied `pos1` and `pos_cel_free`, still allowed the grant-free
+ JDL `pos_soft`. Extend the existing comments to name the new disjunct.
+ - takedown pin: a non-delegate `update:Position` holder may still CLEAR a member off a grant-free CEL
+ seat. **No test today** — `pos_cel_free` appears only in assign-side assertions. Most important
+ regression guard in the slice, because the delegate bypass touches the same expression.
+
+ Create lane in `describe("firestore.rules — members")`: a delegate holding
+ `create:Member` + `update:BoardSeat` may create a member born on `pos_cel_free` and on `pos1`,
+ self-stamped; the same principal without `update:BoardSeat` is still denied both. Mirrors the
+ existing `new_cel_free` / `new_cel_admin` pair.
+
+ Catalog-UNCHANGED in `describe("firestore.rules — positions")`: a catalog delegate holding
+ `["create:Position","update:Position","update:BoardSeat"]` is still denied minting a CEL cargo and a
+ JDL dirección, still denied setting `grants`, still denied changing `category`, still denied
+ retitling a board cargo. Without the catalog codes these pass vacuously.
+
+Verify: `pnpm --filter @luminova/firestore-rules-tests run ci`
+
+Commit: `feat(rules): delegate board-seat assignment via update:BoardSeat`
+
+## Slice 3 — beacon claims-sync trust gate
+
+**Port shape.** Rename `getUserRoles(uid): Promise` to
+`getAssignerClaims(uid): Promise<{ roles: Role[]; perms: PermissionCode[] }>`.
+
+Rejected alternatives:
+
+- *A second method `getUserPerms(uid)`* — two calls to answer one question is two chances for a future
+ edit to consult one and not the other, precisely where that must not happen (guardrail #1). Free in
+ production (`loadUser` memo) but not in the fakes, which would need two hand-consistent maps.
+- *Reuse `getExistingClaims(uid)` for the assigner* — structurally smallest, but it would blunt the
+ sharpest test in `sync.test.ts`: the `spied` case at `:154` asserts `assignerLookups).toEqual([])` to
+ prove `resolveTrustedGrants` short-circuits on `grants.length === 0` **before** consulting the
+ assigner. Merged, the same spy would also record the target's own claim read, degrading the assertion
+ to a uid-filtering heuristic. Also `getExistingClaims` returns `perms?:` optional by design (absence
+ vs. empty is meaningful for its diff) — the wrong contract for a membership test.
+
+1. `apps/beacon/src/claims-sync/sync.ts` — port change; `resolveTrustedGrants` keeps the `isSafeDocId`
+ screen and the `grants.length === 0` early return exactly as they are, then:
+
+ ```ts
+ const assigner = assignedBy ? await deps.getAssignerClaims(assignedBy) : null;
+ const trusted =
+ assigner !== null &&
+ (assigner.roles.includes("Admin") || assigner.perms.includes("update:BoardSeat"));
+ return trusted ? [...new Set(position.grants)] : [];
+ ```
+
+ Extend the doc comment: name `update:BoardSeat` as the second trust source; state why (a delegate
+ stamps their own uid into `assignedBy` via `assignedBySelf()`, so without this the seat publishes on
+ the public Directiva and mints nothing — half-working, not safe); state the live-claims
+ re-evaluation now also covers perm revocation; cross-reference `boardSeatDelegate()` in
+ `firestore.rules`. One line on the cap interaction (C2).
+
+2. `apps/beacon/src/claims-sync/firestore-deps.ts` — rename the impl at `:287`, returning both, reusing
+ the two module-local readers already there. No new read: `loadUser` is the same per-instance memo.
+
+3. `apps/beacon/src/claims-sync/sync.test.ts` — update the three port literals (C7). `fakeDeps` gains a
+ `userPerms` option map. New cases:
+ - honors power grants when the assigner holds `update:BoardSeat` and NOT the Admin role — mirror of
+ the existing "honors power grants when assignedBy is Admin" at `:109`.
+ - a `manage:all` perm holder who is neither Admin-by-role nor an `update:BoardSeat` holder does NOT
+ satisfy the gate — the exact-code property, asserted server-side too.
+ - revocation — same fixture with the assigner's perms emptied; the target recomputes down to a plain
+ `Member` claim.
+ - the existing `:129` positive-and-inert spy case: update the override, keep both assertions verbatim.
+
+Verify: `pnpm --filter beacon exec vitest run src/claims-sync/sync.test.ts`, then
+`pnpm --filter beacon run ci`.
+
+Commit: `feat(beacon): honor cargo grants from an update:BoardSeat assigner`
+
+## Slice 4 — beacon callable auth
+
+1. `apps/beacon/src/callable-auth.ts` — extract the shared reader (C5), then:
+
+ ```ts
+ export function requireAdminOrPerm(request: CallableRequest, code: PermissionCode): void {
+ if (!request.auth) throw new HttpsError("unauthenticated", "sign-in required");
+ if (callerRoles(request).includes("Admin")) return;
+ if (stringArrayClaim(request, "perms").includes(code)) return;
+ throw new HttpsError("permission-denied", `Admin role or ${code} required`);
+ }
+ ```
+
+ Comment: exact-code match mirroring the rules' `hasPerm()` and `use-can`'s `hasPerm` — deliberately
+ NOT a `canDo`-style expansion, so `manage:all` does not satisfy it. `requireAdmin` keeps its exact
+ current behaviour and message.
+
+2. `apps/beacon/src/callable-auth.test.ts` (new) — this trust boundary is unasserted today.
+ `requireAdmin`: unauthenticated / non-Admin / Admin. `requireAdminOrPerm`: unauthenticated; Admin with
+ no `perms` claim at all; `{roles:["Member"], perms:["create:MemberLogin"]}` passes;
+ `{roles:["Member"], perms:["manage:all"]}` **throws** (wildcard-must-not-satisfy);
+ `{roles:["Member"], perms:["update:BoardSeat"]}` asked for `create:MemberLogin` throws (independence);
+ a non-array `perms` claim throws (fail-closed on a malformed token).
+
+3. `apps/beacon/src/provision-member-login.ts` — `requireAdmin(request)` ->
+ `requireAdminOrPerm(request, "create:MemberLogin")` at `:118`. Comment: the callable links an Auth
+ account and returns a password-reset action link, delegable per the owner decision; the `adoptedClaims`
+ de-elevation and the different-uid refusal are unchanged and still bind on every caller.
+
+ Do not touch `seed-roles.ts`, `recompute-claims.ts:22,65,267`, `set-user-roles.ts:86`.
+
+Verify: `pnpm --filter beacon run ci`
+
+Commit: `feat(beacon): allow create:MemberLogin to call provisionMemberLogin`
+
+## Slice 5 — backstage gate flags (the C1 split)
+
+1. `apps/backstage/src/lib/authz/use-can.ts` — delete `canAssignPowerGrants`; add
+ `canAssignBoardSeat`, `canEditCargoCatalog`, `canProvisionLogin` with doc comments. Both perm-based
+ flags use `hasPerm` from `@luminova/auth/roles`, never `abilityAllows` — one line referencing the
+ existing `canFeatureInitiatives` comment. `canEditCargoCatalog`'s comment must state that this is what
+ the seat delegation deliberately does NOT widen, or the next person will "unify" the two flags and
+ reopen C1.
+2. `apps/backstage/src/lib/authz/use-can.test.ts` — mirror the six existing `canFeatureInitiatives` cases
+ per flag, plus `canEditCargoCatalog === false` for an `update:BoardSeat` holder (the C1 pin) and
+ `manage:all -> false` for all three.
+3. `positions-page.tsx` — `canAssignPowerGrants` -> `canEditCargoCatalog` (`:37`, `:178`).
+4. `member-invite-drawer.tsx` — -> `canAssignBoardSeat` (`:41`, `:180`).
+5. `member-drawer.tsx` — same (`:139`, `:153`).
+6. `member-profile-page.tsx` — `gate.canAssignPowerGrants` -> `gate.canAssignBoardSeat` (`:148`, `:176`).
+7. `assignable-cargo.ts` — no logic change (C8); one sentence added to the `allowPowerGrants` doc
+ comments and the two form prop JSDocs: the branch is "Admin, or an `update:BoardSeat` delegate".
+
+Verify: `pnpm --filter backstage run ci`
+
+Commit: `feat(backstage): split the seat, catalog and login-provision gates`
+
+## Slice 6 — provision affordances move to `canProvisionLogin`
+
+1. `member-invite-drawer.tsx` — `canProvisionLogin` drives `useState` (`:43`), the open-resync
+ `useEffect` (`:50-52`), `reset()` (`:56`) and the checkbox render guard (`:184`). **Keep the
+ `useEffect`** — its reason (the drawer mounts before the token's claims decode; the store emits empty
+ claims first and re-emits) applies harder to a perms-derived flag, since `perms` is minted by
+ claims-sync and arrives in the same late token. Rewrite the comment to say "Admin-role or
+ `create:MemberLogin`".
+2. `member-invite-drawer.test.tsx` — parameterize the claims wrapper (`:10-18`); add: a delegate
+ `{roles:["Member"], perms:["create:Member","create:MemberLogin"]}` sees the checkbox, defaults it ON,
+ reaches `onProvision`; a `create:Member`-only creator does not see it and `onProvision` is never
+ called; a `manage:all` holder does not see it.
+3. `member-row-menu.tsx` — `` at `:50` -> `when={canProvisionLogin}`; hoist
+ the `useCan()` call to the component body (hook rules).
+4. `member-row-menu.test.tsx` — add a delegate-sees-it case and a `manage:all`-does-not case.
+5. `member-profile-page.tsx` — `` at `:131-133` -> `when={gate.canProvisionLogin}`.
+ **Do not touch** the `` at `:203` around `` — `roleIds` /
+ `permissionOverrides` writes stay Admin-only, and that is the panel where the delegation is granted.
+
+Verify: `pnpm --filter backstage run ci`
+
+Commit: `feat(backstage): gate the invite affordances on create:MemberLogin`
+
+## Slice 7 — honest empty state for the Cargo combobox
+
+One shared place: both forms render the same `` + `Combobox options={cargoOptions}`
+shape (`member-form.tsx:230-264`, `member-positions-form.tsx:76-104`). Their `locked` / `takedownOnly`
+notes legitimately differ in wording, so extract only the new note.
+
+1. `apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx` (new) — props-free,
+ in the established `role="note" className="text-ui-xs text-ink-3"` pattern. The quoted permission
+ name must equal `permissionLabel("update:BoardSeat")` = "Editar Asientos de directiva"; one comment
+ line says so, since the strings live in different features and nothing enforces the match.
+2. `member-form.tsx` — render when `!positionsLocked && !allowPowerGrants && cargoOptions.length === 0`,
+ in the note stack after `cargoTakedown` (`:306`).
+3. `member-positions-form.tsx` — same condition, after the `takedownOnly` note (`:130`).
+4. `member-form.test.tsx` — with `allowPowerGrants={false}` and a CEL/power-only positions list, the note
+ renders and the combobox has no selectable option; with `allowPowerGrants` the note is absent and the
+ CEL option is present.
+5. `member-positions-form.test.tsx` — the same pair, plus: when `locked` is true the locked note renders
+ and the empty note does not (mutually exclusive today because `cargoOptionsForEditor` appends the held
+ cargo disabled — pin it so a future change cannot produce two notes).
+6. `assignable-cargo.ts` — no code change; one sentence noting an empty return for a non-delegate is a
+ real, expected state the forms explain.
+
+Residual, stated in the PR body and deliberately not fixed: an Admin/delegate facing a genuinely empty
+catalog still sees the bare "Sin resultados" from `packages/ui/src/components/combobox.tsx:28`. That is
+an empty-catalog problem, not a permissions problem; giving it the delegation copy would be a lie.
+
+Verify: `pnpm --filter backstage run ci`
+
+Commit: `feat(backstage): explain an empty cargo list to a non-delegate`
+
+## Slice 8 — docs, route, review, PR
+
+`docs/specs/position-assignment-lane.md` gains a cross-reference (its "who may assign" narrative is now
+one disjunct out of date). `packages/auth/CLAUDE.md`'s Gotchas gains one line naming `BoardSeat` /
+`MemberLogin` as hand-granted codes deliberately absent from `BUILT_IN_ROLE_PERMS`.
+
+Then, per the binding review-routing contract:
+
+```bash
+.claude/hooks/route.sh
+pnpm pr-tests
+```
+
+Run whatever the router prints, stamp with the exact command it emits (trailer in the final paragraph),
+mirror the token list under `## Reviews` in the PR body.
+
+## Fact-check corrections (applied — cite these, not the originals)
+
+| Plan said | Actually |
+|---|---|
+| `sync.ts:106-118` cap block | `sync.ts:111-122` |
+| "six existing `canFeatureInitiatives` cases" | five, at `use-can.test.ts:44,54,58,64,72` |
+| `member-profile-page.tsx:203` roles-panel gate | `:202-206` |
+| "define-before-use is this file's convention" | false — `cargoAssignableByNonAdmin()` calls `nonAdminAssignable()` defined *after* it (`firestore.rules:169-175`). Place `boardSeatDelegate()` next to `canCurateFeatured()` (`:323`) instead |
+| "worst case ~855 B" | 830 B; longest code unchanged at 20 chars, so the byte count does not move at all |
+| insert note after `member-form.tsx:306` | after `:312` (`:306-312` is the whole `cargoTakedown` block). `member-positions-form.tsx:130` is correct as written |
+| "`callable-auth` is unasserted today" | no *direct* unit test; indirect coverage at `reseed-role-perms.emulator.test.ts:54` |
+| C5: importing `permsFromClaims` pulls in `firebase-admin/firestore` | those imports are **type-only**; the real runtime pull-in is `../chunk.js`, `../firestore-util.js`, `./role-doc.js` (`firestore-deps.ts:3-8`). Argument stands, evidence did not |
+| "`members-page.tsx` owns the mutation" | two owners — also a local `InviteAccess` in `member-profile-page.tsx:224-225` |
+| "hoist the `useCan()` call" in `member-row-menu.tsx` | nothing to hoist; the component calls no hook. Add the import and the call |
+| C7 rejecting-deps literal `:536-547` | `:537-548`. Also unstated: `fakeDeps`' **opts parameter type at `:34-41`** needs the `userPerms` map |
+| C8 "fully parameterized (`:41-115`)" | file is 116 lines; `positionsLockedForNonAdmin` (`:41-45`) takes only the cargo — the flag is applied externally at `member-form.tsx:121` / `member-positions-form.tsx:52`. "No change needed" still holds |
+| C4 quotes `createPositionsSafe()` | omits the leading `!('positions' in request.resource.data) ||` disjunct (`firestore.rules:224-227`) |
+| `member-form.tsx:230-264` Cargo Field | `:230-265` |
+| "denied twelve lines above" | ~70 — denial at `rules.test.ts:2941`, insertion point `:3012`, describe runs `:2741-3019` |
+
+**Resolved open question (was Risk 5): token refresh does NOT happen.** `claims.ts` is a pure
+14-line decoder; the token comes from `auth-store.ts:54` calling `getIdTokenResult()` with no
+`forceRefresh`. A newly granted `update:BoardSeat` / `create:MemberLogin` is invisible to
+`firestore.rules`, `requireAdminOrPerm` and `useCan` until the hourly refresh or a re-login.
+**Operator note for the spec: after granting or revoking either code, the delegate must sign out
+and back in.** Not fixed here — a force-refresh on every load costs a network round trip on the
+critical path.
+
+## Risks and open questions
+
+1. **C1 is the review's centre of gravity.** A single widened `canAssignPowerGrants` silently delegates
+ the `/positions` catalog editor. Verify `positions-page.tsx` reads `canEditCargoCatalog` and that
+ `use-can.test.ts` pins `canEditCargoCatalog === false` for an `update:BoardSeat` holder.
+2. **`update:BoardSeat` grants nothing alone, and the UI does not say so.** An Admin ticking only that
+ box produces a delegate who can still do nothing — they also need `update:Position` or
+ `update:Member`. Neither surface hints at the dependency. Documented in the spec; not otherwise fixed.
+3. **Revocation is retroactive and silent.** Removing the perm de-elevates the people they seated on the
+ *next write* to each member doc — possibly never. Inherited from the existing Admin behaviour, but
+ "revoke the delegation" is not "undo what they did". No operator sweep short of `recomputeAllClaims`.
+4. **The cap can silently revoke the delegation.** A delegate exceeding `PERMISSION_CAP = 30` is written
+ `perms: []` fail-closed, taking `update:BoardSeat` with it.
+5. **Token freshness on grant.** Rules, `requireAdminOrPerm` and `useCan` all read `perms` off the ID
+ token. A newly granted code does not take effect until the token refreshes. Verify whether
+ `apps/backstage/src/lib/authz/claims.ts` force-refreshes; if not, the delegate sees "no access" for up
+ to an hour with no explanation. Not addressed here.
+6. **`stringArrayClaim` vs. reusing `permsFromClaims` (C5).** A reviewer applying guardrail #1
+ mechanically will call the new reader a copy. Counter-argument is in C5; cheap to switch to a shared
+ `apps/beacon/src/claims-read.ts` if a reviewer insists.
+7. **Rules-test non-vacuity.** Three new rules tests pass for the wrong reason if their principal is
+ under-permissioned. Check each `as(uid, [], [...])` literal by hand.
+8. **The grant-free-CEL takedown has no test today.** Slice 2 adds it at the same time as the change to
+ the expression it depends on, so it proves post-change behaviour, not preservation. Stronger evidence
+ would land that test on `main` first.
+9. **No end-to-end cross-product test.** `use-can.test.ts` and `callable-auth.test.ts` each pin
+ independence at their own layer; nothing tests a `create:MemberLogin`-only holder seeing the invite
+ button and an empty cargo list simultaneously. Deliberate omission.
diff --git a/docs/specs/board-seat-delegation.md b/docs/specs/board-seat-delegation.md
new file mode 100644
index 00000000..d9825243
--- /dev/null
+++ b/docs/specs/board-seat-delegation.md
@@ -0,0 +1,99 @@
+# Board-seat and member-login delegation
+
+Two explicitly grantable permission codes that let an Admin **temporarily** delegate work that
+was previously hardcoded to the `Admin` role, then revoke it. Both are granted per member in the
+`/members/$memberId` overrides panel (Admin-only) or per role in the `/permisos` matrix.
+
+## The two codes
+
+| Subject | Label in the matrix | Live code | Confers |
+|---|---|---|---|
+| `BoardSeat` | Asientos de directiva | `update:BoardSeat` | seat a member on **any vacant cargo** — CEL category and power-granting alike |
+| `MemberLogin` | Acceso de miembros | `create:MemberLogin` | call `provisionMemberLogin`: create the member's Auth account, link their uid, return the password-reset link |
+
+They are independent by construction. An Admin can grant emailing without board seating and vice
+versa. The other five codes each subject generates (`manage:BoardSeat`, `read:MemberLogin`, …)
+are inert: every gate is an exact `hasPerm` code test, never a `canDo` expansion, so `manage:all`
+does not satisfy either one.
+
+## What `update:BoardSeat` does NOT do
+
+- **It confers nothing on its own.** It only widens the cargo set for an editor who *already*
+ holds `update:Member`, `create:Member`, or `update:Position`. A member holding only
+ `update:BoardSeat` still cannot write anything. Granting it alone is a no-op; the UI does not
+ say so.
+- **It does not unseat anyone.** `currentCargoGrantsEmpty()` stays Admin-only, so a delegate
+ cannot displace a member sitting on a power-granting cargo. Hand-over is an Admin action.
+ Rationale: the `roles` claim is derived *exclusively* from cargo grants
+ (`compute-roles.ts`), so clearing every Admin's cargo would strip every Admin claim in the
+ chapter — and `setUserRoles`, `roles/*` writes and `permissionOverrides` writes are all
+ Admin-only, making that state unrecoverable outside the Firebase console.
+- **It does not reach the `/positions` catalog.** Creating a CEL or JDL cargo, and editing any
+ cargo's `grants`, `category` or board `title`/`titleFemale`, stay Admin-only
+ (`boardSurfacingCategory()` and the update-arm pins). The delegate seats members on cargos
+ that already exist.
+- **It does not touch `roleIds` or `permissionOverrides`.** `createPermissionAssignmentSafe()` /
+ `updatePermissionAssignmentSafe()` are unchanged, so a delegate cannot re-grant the delegation
+ to themselves or anyone else.
+
+## Accepted decision — claims-minting delegation
+
+The chapter owner has explicitly accepted that a delegate may seat a member on a cargo whose
+`grants` include `Admin`, and that beacon's trust gate will mint that claim. That is the feature,
+not a defect. The acceptance is premised on the delegation being **revocable**, which required
+one guard:
+
+**The trust gate is non-reflexive.** When `assignedBy === member.uid` (a self-assignment),
+`resolveTrustedGrants` trusts only `update:BoardSeat` in the assigner's `perms` — never an
+`Admin` role in their `roles`. Without this, a delegate who self-seats `Presidente` is minted
+`Admin`, and that minted `Admin` then satisfies the very gate that minted it: revoking
+`update:BoardSeat` re-fires the trigger, the Admin disjunct passes, and the grants are re-honored
+forever. `recomputeAllClaims` runs the same code and would not break the loop either. An Admin
+seating *someone else* is unaffected.
+
+## `create:MemberLogin` — what is actually privileged
+
+The invite **email** is not. `apps/backstage/src/lib/auth/request-password-reset.ts` is a plain
+client-side `sendPasswordResetEmail` that any signed-in user can already call. What the callable
+owns is Auth account creation, uid linking (the only path that can write `members.uid` at all,
+since the rules forbid it on every client lane) and the initial claim write.
+
+**A non-Admin caller may only mint a NEW Auth account, or re-provision one already linked to that
+same member.** It may never adopt a pre-existing unlinked account. Without that restriction the
+code would be an account-takeover primitive rather than an invite helper: `members.email` is
+unconstrained by the rules and has no uniqueness check, so a delegate could create a member doc
+carrying a sitting Admin's email and have the callable adopt that Admin's account, strip its
+claims, link its uid onto the attacker's member doc, and return a password-reset link for it.
+An Admin caller keeps the full adoption path — it is the documented recovery op.
+
+This restriction costs the delegation nothing: a genuinely new member has no Auth account.
+
+## Operator notes
+
+1. **Grant `update:BoardSeat` together with a member-editing capability.** On its own it does
+ nothing. The usual pairing is the `Membresía` role (which carries `manage:Member`) plus the
+ `update:BoardSeat` override; an org-chart-only delegate wants `update:Position` instead.
+2. **The delegate must sign out and back in after being granted or revoked.** All three gates —
+ `firestore.rules`, `requireAdminOrPerm` and `useCan` — read the `perms` claim off the ID
+ token, and `auth-store.ts` calls `getIdTokenResult()` without `forceRefresh`. A freshly
+ granted code is invisible for up to an hour otherwise, and a freshly revoked one keeps
+ working for up to an hour.
+3. **Revocation is not an undo.** Removing the code stops future seating. Members already seated
+ keep their cargo, and their cargo-derived claims are recomputed on the *next write* to their
+ member doc — which may be much later. To force it, re-write the member docs or run
+ `recomputeAllClaims`.
+4. **The `PERMISSION_CAP` interaction.** A member whose resolved perms exceed 30 is written
+ `perms: []` fail-closed, which silently takes `update:BoardSeat` with it. Two more subjects
+ in the vocabulary make the 30-slot budget marginally tighter.
+5. **There is a consistency window.** `firestore.rules` reads the token while beacon reads stored
+ claims. If a delegate's own perms are dropped (cap breach, or revocation) their cached token
+ still passes the rules for up to an hour, so a seat write can succeed while
+ `resolveTrustedGrants` declines to mint the grants. The member is then published on the public
+ Directiva with no claim — visible, powerless. Re-running the write after the token refreshes
+ resolves it.
+
+## Out of scope
+
+Creating or editing cargo docs; `roleIds` / `permissionOverrides` assignment; unseating a sitting
+power-cargo holder; delegating `setUserRoles`, `seedRoles`, `recomputeAllClaims` or
+`reseedBuiltInRolePerms`. All stay Admin-role-only.
diff --git a/packages/types/src/permission.test.ts b/packages/types/src/permission.test.ts
index e6ac2346..b6183c6a 100644
--- a/packages/types/src/permission.test.ts
+++ b/packages/types/src/permission.test.ts
@@ -62,6 +62,32 @@ describe("Showcase subject", () => {
});
});
+describe("BoardSeat subject", () => {
+ it("is a known subject", () => {
+ expect(SUBJECTS).toContain("BoardSeat");
+ });
+ // Same shape as Showcase: only update:BoardSeat is read (firestore.rules' boardSeatDelegate,
+ // beacon's resolveTrustedGrants, backstage's canAssignBoardSeat). The siblings gate nothing
+ // but must stay VALID — the /permisos matrix renders the full grid and the per-member
+ // override panel offers every code, so an unvalidatable one would fail the write.
+ it("accepts update:BoardSeat and the inert siblings the matrix will render", () => {
+ expect(isValidPermissionCode("update:BoardSeat")).toBe(true);
+ expect(isValidPermissionCode("manage:BoardSeat")).toBe(true);
+ expect(isValidPermissionCode("read:BoardSeat")).toBe(true);
+ });
+});
+
+describe("MemberLogin subject", () => {
+ it("is a known subject", () => {
+ expect(SUBJECTS).toContain("MemberLogin");
+ });
+ it("accepts create:MemberLogin and the inert siblings the matrix will render", () => {
+ expect(isValidPermissionCode("create:MemberLogin")).toBe(true);
+ expect(isValidPermissionCode("manage:MemberLogin")).toBe(true);
+ expect(isValidPermissionCode("read:MemberLogin")).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 b691741d..71f2ba40 100644
--- a/packages/types/src/permission.ts
+++ b/packages/types/src/permission.ts
@@ -27,6 +27,21 @@ export const SUBJECTS = [
// the /permisos matrix renders the full actions × subjects grid, so `checkIn:Member` and
// dozens like it are already assignable and equally inert.
"Showcase",
+ // Delegable board seating. Only `update:BoardSeat` is live: firestore.rules'
+ // boardSeatDelegate() is `hasAnyRole(['Admin']) || hasPerm('update:BoardSeat')` — an EXACT
+ // code match, not canDo(), so `manage:all` cannot satisfy it and the other five codes are
+ // inert. It confers no authority ALONE: it only widens which cargo an editor who already
+ // holds update:Member / create:Member / update:Position may assign, from "grant-free and
+ // non-CEL" to "any vacant cargo". Displacing a sitting power-cargo holder stays Admin-only
+ // (currentCargoGrantsEmpty), and the /positions CATALOG stays Admin-only.
+ "BoardSeat",
+ // Delegable login provisioning. Only `create:MemberLogin` is live, read by beacon's
+ // requireAdminOrPerm on provisionMemberLogin. NOT the invite email itself — that is a
+ // client-side sendPasswordResetEmail any signed-in user can already call. What this gates is
+ // Auth account creation + uid linking + the initial claim write. A non-Admin holder may only
+ // mint a NEW account, never adopt a pre-existing one (see provisionMember's callerIsAdmin
+ // branch), so it cannot be turned on an existing privileged account.
+ "MemberLogin",
"all",
] as const;
export type Subject = (typeof SUBJECTS)[number];
From 8470f78c1c08efe6619e08d6cc8b53f3f02b803a Mon Sep 17 00:00:00 2001
From: Arnold Gandarillas Castillo
Date: Thu, 27 Aug 2026 18:34:33 -0400
Subject: [PATCH 02/15] feat(rules): delegate board-seat assignment via
update:BoardSeat
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
boardSeatDelegate() lifts the NEW-side cargo conjunct so an update:BoardSeat
holder may seat a member on a CEL or power-granting cargo. It confers nothing
alone — the principal still needs update:Member, create:Member or
update:Position to reach an arm at all.
The OLD side (currentCargoGrantsEmpty) stays Admin-role-only. computeMemberRoles
derives the roles claim exclusively from cargo grants, so a principal able to
overwrite a sitting Admin's cargo could strip every Admin claim in the chapter,
and setUserRoles / roles/* / permissionOverrides are all Admin-only — the state
would be unrecoverable outside the Firebase console.
Falsified: neutralizing boardSeatDelegate() to Admin-only turns exactly the
three new ALLOW tests red, 481 others green.
Co-Authored-By: Claude Opus 5 (1M context)
---
firestore.rules | 46 +++++-
tests/firestore-rules/rules.test.ts | 222 ++++++++++++++++++++++++++++
2 files changed, 265 insertions(+), 3 deletions(-)
diff --git a/firestore.rules b/firestore.rules
index af78c354..297f8170 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -203,11 +203,29 @@ service cloud.firestore {
// also closes a ride-along: a non-Admin can no longer sneak a power cargo + forged
// assignedBy under a different term key in the same write. Comisión power grants are
// not loop-checkable here — the beacon claims-sync trust gate is their backstop.
+ // The two cargo conjuncts are gated SEPARATELY, and the asymmetry is the whole point.
+ //
+ // NEW side (cargoAssignableByNonAdmin, the cargo being written IN) — delegable.
+ // boardSeatDelegate() lifts it, so an update:BoardSeat holder may seat a CEL or
+ // power-granting cargo. That is the feature.
+ //
+ // OLD side (currentCargoGrantsEmpty, the cargo being REPLACED) — Admin role ONLY.
+ // NOT delegated, deliberately. computeMemberRoles derives the `roles` claim
+ // EXCLUSIVELY from cargo grants — directly-assigned roleIds feed `perms`, never
+ // `roles` — so a principal who can overwrite a sitting Admin's cargo can strip
+ // every Admin claim in the chapter one write at a time. After the last one there is
+ // no Admin: setUserRoles is requireAdmin, and roles/* plus permissionOverrides
+ // writes are all hasAnyRole(['Admin']). The chapter would be unrecoverable outside
+ // the Firebase console. A delegate therefore seats VACANT and grant-free cargos and
+ // cannot displace a power-cargo holder; hand-over stays an Admin action.
+ //
+ // A non-delegate is unchanged: both conjuncts still bind, so the deliberately
+ // asymmetric grant-free-CEL takedown below still works for an update:Position holder.
function positionsAssignmentSafe() {
return positionsDelta().hasOnly([currentTermKey()])
&& assignedBySelf()
- && (hasAnyRole(['Admin'])
- || (cargoAssignableByNonAdmin() && currentCargoGrantsEmpty()));
+ && (boardSeatDelegate() || cargoAssignableByNonAdmin())
+ && (hasAnyRole(['Admin']) || currentCargoGrantsEmpty());
}
// 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
@@ -221,9 +239,12 @@ service cloud.firestore {
// 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.
+ // Plain substitution, unlike positionsAssignmentSafe(): a create has no prior resource,
+ // so there is no old side to keep Admin-only — currentCargoGrantsEmpty() has never
+ // applied here, and a member born on a cargo displaces nobody.
function createPositionsSafe() {
return !('positions' in request.resource.data)
- || (assignedBySelf() && (hasAnyRole(['Admin']) || cargoAssignableByNonAdmin()));
+ || (assignedBySelf() && (boardSeatDelegate() || cargoAssignableByNonAdmin()));
}
// roleIds + permissionOverrides feed Auth custom claims via the beacon trigger.
@@ -323,6 +344,25 @@ service cloud.firestore {
function canCurateFeatured() {
return hasAnyRole(['Admin']) || hasPerm('update:Showcase');
}
+ // Who may seat a member on a cargo the non-Admin lane otherwise refuses — a
+ // power-granting one, or a CEL one. Split the same way as canCurateFeatured(), for the
+ // same two reasons: Admin by ROLE (locked and undeactivatable, so its name carries none
+ // of the staleness this fixes), everyone else by the exact PERM, so revoking the code
+ // revokes the authority where a surviving role NAME in the claim would not.
+ //
+ // hasPerm, deliberately NOT canDo: manage:all must not answer this, and the other five
+ // *:BoardSeat codes the cross-product generates stay inert BECAUSE the gate is exact.
+ //
+ // ACCEPTED, per docs/specs/board-seat-delegation.md: a delegate may seat a cargo whose
+ // grants confer Admin, and beacon's claims-sync mints it. What that acceptance is
+ // premised on — revocability — needs beacon's trust gate to be non-reflexive on a
+ // SELF-assignment, or the minted Admin satisfies the gate that minted it and the
+ // delegation can never be revoked. See resolveTrustedGrants in apps/beacon.
+ //
+ // Deliberately NOT widened to currentCargoGrantsEmpty(): see positionsAssignmentSafe().
+ function boardSeatDelegate() {
+ return hasAnyRole(['Admin']) || hasPerm('update:BoardSeat');
+ }
function initiativeCreateAllowed(subject) {
return canDo('create', subject)
&& request.resource.data.get('directionUids', []) == []
diff --git a/tests/firestore-rules/rules.test.ts b/tests/firestore-rules/rules.test.ts
index 035f981d..c747c580 100644
--- a/tests/firestore-rules/rules.test.ts
+++ b/tests/firestore-rules/rules.test.ts
@@ -47,6 +47,21 @@ function anon() {
const ORG_CHART = "orgchart-uid";
const orgChart = () => as(ORG_CHART, [], ["update:Position"]);
+/** The board-seat delegate: an org-chart editor who ALSO holds update:BoardSeat, so
+ * boardSeatDelegate() lifts the new-side cargo conjunct for them. `update:Position` is
+ * load-bearing, not decoration — without an entry capability the delegate never reaches the
+ * arm at all and every ALLOW below would pass for the wrong reason. */
+const SEAT_DELEGATE = "seatdelegate-uid";
+const seatDelegate = () => as(SEAT_DELEGATE, [], ["update:Position", "update:BoardSeat"]);
+
+/** update:BoardSeat and NOTHING else. The non-vacuity pin for the whole feature: the code
+ * widens which cargo an editor may assign, it does not make anyone an editor. */
+const plainDelegate = () => as("seatonly-uid", [], ["update:BoardSeat"]);
+
+/** A delegate on the CREATE lane. create:Member is the entry capability there. */
+const createDelegate = () => as("createdelegate-uid", [], ["create:Member", "update:BoardSeat"]);
+const createOnly = () => as("createonly-uid", [], ["create:Member"]);
+
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
@@ -587,6 +602,43 @@ beforeAll(async () => {
active: true,
deletedAt: null,
});
+ // Board-seat delegation targets. Each assertion that SUCCEEDS needs its own doc: the
+ // suite seeds once and never resets, so a shared target would carry the previous test's
+ // cargo into the next one's currentCargoGrantsEmpty() check.
+ await setDoc(doc(db, "members/m_delegate"), {
+ name: "Delegado",
+ totalPoints: 0,
+ uid: "delegate-target-uid",
+ active: true,
+ deletedAt: null,
+ });
+ await setDoc(doc(db, "members/m_delegate_cel"), {
+ name: "Delegado CEL",
+ totalPoints: 0,
+ uid: "delegate-cel-uid",
+ active: true,
+ deletedAt: null,
+ });
+ // Seated on a POWER cargo, for the G3 pin: a delegate must NOT be able to displace this.
+ await setDoc(doc(db, "members/m_delegate_power"), {
+ name: "Delegado Poder",
+ totalPoints: 0,
+ uid: "delegate-power-uid",
+ active: true,
+ deletedAt: null,
+ positions: { [TERM]: { cargoId: "pos1", comisionIds: [], assignedBy: "admin-uid" } },
+ });
+ // Seated on a GRANT-FREE CEL cargo, for the takedown pin. Seeded directly rather than
+ // produced by an earlier test, so currentCargoGrantsEmpty() actually evaluates its get()
+ // branch instead of short-circuiting on `prior == null` and passing vacuously.
+ await setDoc(doc(db, "members/m_cel_takedown"), {
+ name: "Takedown",
+ totalPoints: 0,
+ uid: "cel-takedown-uid",
+ active: true,
+ deletedAt: null,
+ positions: { [TERM]: { cargoId: "pos_cel_free", comisionIds: [], assignedBy: "admin-uid" } },
+ });
// 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
@@ -1039,6 +1091,56 @@ describe("firestore.rules — members", () => {
}),
);
});
+ it("allows an update:BoardSeat delegate to CREATE a member on a CEL or power cargo", async () => {
+ // createPositionsSafe() takes the plain substitution — a create has no prior resource, so
+ // there is no old side to keep Admin-only and a member born on a cargo displaces nobody.
+ await assertSucceeds(
+ setDoc(doc(createDelegate(), "members/new_delegate_cel"), {
+ name: "Ximena Paz",
+ totalPoints: 0,
+ ...BORN_LIVE,
+ positions: {
+ [TERM]: { cargoId: "pos_cel_free", comisionIds: [], assignedBy: "createdelegate-uid" },
+ },
+ }),
+ );
+ await assertSucceeds(
+ setDoc(doc(createDelegate(), "members/new_delegate_pow"), {
+ name: "Ximena Paz",
+ totalPoints: 0,
+ ...BORN_LIVE,
+ positions: {
+ [TERM]: { cargoId: "pos1", comisionIds: [], assignedBy: "createdelegate-uid" },
+ },
+ }),
+ );
+ });
+
+ it("BLOCKING: the same create principal WITHOUT update:BoardSeat is still denied both", async () => {
+ // The paired denial — otherwise the two ALLOWs above would pass for any create:Member
+ // holder and prove nothing about the new disjunct.
+ await assertFails(
+ setDoc(doc(createOnly(), "members/new_createonly_cel"), {
+ name: "Ximena Paz",
+ totalPoints: 0,
+ ...BORN_LIVE,
+ positions: {
+ [TERM]: { cargoId: "pos_cel_free", comisionIds: [], assignedBy: "createonly-uid" },
+ },
+ }),
+ );
+ await assertFails(
+ setDoc(doc(createOnly(), "members/new_createonly_pow"), {
+ name: "Ximena Paz",
+ totalPoints: 0,
+ ...BORN_LIVE,
+ positions: {
+ [TERM]: { cargoId: "pos1", comisionIds: [], assignedBy: "createonly-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,
@@ -2372,6 +2474,30 @@ describe("firestore.rules — positions", () => {
setDoc(doc(as("admin-uid", ["Admin"]), "positions/mint_cel_admin"), boardCargo("CEL")),
);
});
+ it("BLOCKING: update:BoardSeat does NOT reach the positions CATALOG", async () => {
+ // The delegation is a SEATING authority, never an authoring one. It lifts the cargo
+ // conjunct on members/{id}.positions and nothing else — boardSurfacingCategory() on
+ // create, and the grants/category/title pins on update, all stay hasAnyRole(['Admin']).
+ // Without that boundary the delegation would be self-serving: mint a grant-free CEL
+ // 'Presidente', then seat yourself on it, landing at public board rank 0.
+ // The principal deliberately holds the CATALOG capabilities too, so each denial is the
+ // Admin-only pin firing and not a missing create:Position / update:Position.
+ const catalogDelegate = () =>
+ as("catalogdelegate-uid", [], ["create:Position", "update:Position", "update:BoardSeat"]);
+ await assertFails(
+ setDoc(doc(catalogDelegate(), "positions/mint_cel_delegate"), boardCargo("CEL")),
+ );
+ await assertFails(
+ setDoc(doc(catalogDelegate(), "positions/mint_jdl_delegate"), boardCargo("JDL")),
+ );
+ await assertFails(
+ updateDoc(doc(catalogDelegate(), "positions/pos_payload"), { grants: ["Admin"] }),
+ );
+ await assertFails(updateDoc(doc(catalogDelegate(), "positions/pos_cat"), { category: "CEL" }));
+ await assertFails(
+ updateDoc(doc(catalogDelegate(), "positions/pos_payload"), { title: "Presidente" }),
+ );
+ });
// 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.
@@ -3006,6 +3132,102 @@ describe("firestore.rules — member positions assignment", () => {
);
});
+ // --- update:BoardSeat delegation (docs/specs/board-seat-delegation.md) ---
+
+ it("allows an update:BoardSeat delegate to assign a GRANT-FREE CEL cargo", async () => {
+ // The exact write orgChart() is denied above, same cargo, same lane, differing only by
+ // the delegate's update:BoardSeat. That pairing is what proves the new disjunct is what
+ // opened it, rather than the CEL conjunct having quietly disappeared.
+ await assertSucceeds(
+ updateDoc(doc(seatDelegate(), "members/m_delegate_cel"), {
+ [`positions.${TERM}`]: {
+ cargoId: "pos_cel_free",
+ comisionIds: [],
+ assignedBy: SEAT_DELEGATE,
+ },
+ }),
+ );
+ });
+
+ it("allows an update:BoardSeat delegate to assign a POWER-conferring cargo", async () => {
+ // The accepted claims-minting delegation: pos1 confers grants, and beacon's claims-sync
+ // will mint them because the delegate holds update:BoardSeat. Owner-accepted; the
+ // revocability that acceptance rests on is enforced in beacon (non-reflexive trust gate),
+ // not here.
+ await assertSucceeds(
+ updateDoc(doc(seatDelegate(), "members/m_delegate"), {
+ [`positions.${TERM}`]: { cargoId: "pos1", comisionIds: [], assignedBy: SEAT_DELEGATE },
+ }),
+ );
+ });
+
+ it("G3 BLOCKING: denies a delegate DISPLACING a member who already holds a power cargo", async () => {
+ // currentCargoGrantsEmpty() is deliberately NOT delegated. computeMemberRoles derives the
+ // `roles` claim exclusively from cargo grants, so a principal who can overwrite a sitting
+ // Admin's cargo can strip every Admin claim in the chapter one write at a time — and
+ // setUserRoles, roles/* and permissionOverrides are all Admin-role-only, so the result is
+ // unrecoverable outside the Firebase console. If this goes green, the chapter is one
+ // delegate away from having no Admin.
+ await assertFails(
+ updateDoc(doc(seatDelegate(), "members/m_delegate_power"), {
+ [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: SEAT_DELEGATE },
+ }),
+ );
+ // Clearing it is the same authority and equally denied — the guard is about the cargo
+ // being REPLACED, not about what replaces it.
+ await assertFails(
+ updateDoc(doc(seatDelegate(), "members/m_delegate_power"), {
+ [`positions.${TERM}`]: { cargoId: null, comisionIds: [], assignedBy: SEAT_DELEGATE },
+ }),
+ );
+ });
+
+ it("BLOCKING: denies update:BoardSeat ALONE any positions write", async () => {
+ // Non-vacuity pin for the whole feature. The code widens WHICH cargo an editor may
+ // assign; it does not make anyone an editor. Without update:Position / update:Member the
+ // principal never reaches an arm — which is also why every ALLOW above pairs it with one.
+ await assertFails(
+ updateDoc(doc(plainDelegate(), "members/m_positions"), {
+ [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: "seatonly-uid" },
+ }),
+ );
+ });
+
+ it("denies a delegate a forged assignedBy, a past term, and a ride-along field", async () => {
+ // assignedBySelf(), the current-term restriction and hasOnly(['positions']) all sit
+ // OUTSIDE the substituted disjunction. Pin that the delegation did not loosen them —
+ // a forged assignedBy is what the beacon trust gate reads to decide whether to mint.
+ await assertFails(
+ updateDoc(doc(seatDelegate(), "members/m_delegate"), {
+ [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: "admin-uid" },
+ }),
+ );
+ await assertFails(
+ updateDoc(doc(seatDelegate(), "members/m_delegate"), {
+ "positions.2099": { cargoId: "pos1", comisionIds: [], assignedBy: SEAT_DELEGATE },
+ }),
+ );
+ await assertFails(
+ updateDoc(doc(seatDelegate(), "members/m_delegate"), {
+ [`positions.${TERM}`]: { cargoId: "pos_soft", comisionIds: [], assignedBy: SEAT_DELEGATE },
+ name: "Renombrado",
+ }),
+ );
+ });
+
+ it("REGRESSION: a non-delegate may still CLEAR a member off a grant-free CEL seat", async () => {
+ // The deliberate asymmetry (firestore.rules' currentCargoGrantsEmpty comment): keeping a
+ // grant-free CEL seat is denied to a non-Admin, but clearing one is allowed, or a takedown
+ // would be stranded behind an Admin. This is the guard on the expression the delegation
+ // touches, and m_cel_takedown is SEEDED holding pos_cel_free so currentCargoGrantsEmpty()
+ // reaches its get() instead of short-circuiting on a null prior.
+ await assertSucceeds(
+ updateDoc(doc(orgChart(), "members/m_cel_takedown"), {
+ [`positions.${TERM}`]: { cargoId: null, 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 19546b32f8cab7c9a4fdac98891d411fc42f4c85 Mon Sep 17 00:00:00 2001
From: Arnold Gandarillas Castillo
Date: Thu, 27 Aug 2026 18:39:29 -0400
Subject: [PATCH 03/15] feat(beacon): honor update:BoardSeat assigners and
delegate provisionMemberLogin
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Trust gate: resolveTrustedGrants now honors a cargo's grants when the assigner
holds the Admin role OR the exact update:BoardSeat perm. Without the second
disjunct a delegate's seat would publish on the public Directiva and mint
nothing, since the rules make them stamp their own uid into assignedBy.
The gate is NON-REFLEXIVE on a self-assignment: when the assigner is the member
being seated, only the perm is trusted. Otherwise a delegate self-seats
Presidente, gets minted Admin, and that minted Admin satisfies the gate that
minted it — revoking the perm would re-fire the trigger and re-honor the grants
forever. recomputeAllClaims runs the same code and would not break the loop.
provisionMemberLogin moves to requireAdminOrPerm(create:MemberLogin), with a
hard adoption guard: a non-Admin caller may only mint a brand-new Auth account
or re-provision one already linked to that member. members.email is not
constrained by firestore.rules and has no uniqueness check, so without the
guard a create:Member + create:MemberLogin holder could file a member doc
carrying an Admin's email and have the callable strip that Admin's claims, bind
their uid via the admin SDK, and return a password-reset link for their mailbox.
callerIsAdmin defaults to false so a new call site opts into adoption.
callable-auth gains its first direct unit tests, incl. the manage:all and
manage: wildcards failing the exact-code gate.
Co-Authored-By: Claude Opus 5 (1M context)
---
apps/beacon/src/callable-auth.test.ts | 121 ++++++++++++++++++
apps/beacon/src/callable-auth.ts | 41 +++++-
apps/beacon/src/claims-sync/firestore-deps.ts | 7 +-
apps/beacon/src/claims-sync/sync.test.ts | 107 +++++++++++++++-
apps/beacon/src/claims-sync/sync.ts | 50 ++++++--
.../beacon/src/provision-member-login.test.ts | 75 ++++++++++-
apps/beacon/src/provision-member-login.ts | 42 +++++-
7 files changed, 414 insertions(+), 29 deletions(-)
create mode 100644 apps/beacon/src/callable-auth.test.ts
diff --git a/apps/beacon/src/callable-auth.test.ts b/apps/beacon/src/callable-auth.test.ts
new file mode 100644
index 00000000..9a9357e0
--- /dev/null
+++ b/apps/beacon/src/callable-auth.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from "vitest";
+import { HttpsError, type CallableRequest } from "firebase-functions/v2/https";
+import { callerIsAdmin, requireAdmin, requireAdminOrPerm } from "./callable-auth.js";
+
+/** A callable request carrying just the claim shape the gates read. The cast is test-only
+ * and justified: CallableRequest carries rawRequest/acceptsStreaming/etc. that no gate
+ * touches, and building them would assert nothing. */
+function req(token?: Record): CallableRequest {
+ return (token === undefined ? {} : { auth: { uid: "u", token } }) as unknown as CallableRequest;
+}
+
+function codeOf(fn: () => void): string {
+ try {
+ fn();
+ } catch (err) {
+ return err instanceof HttpsError ? err.code : "not-an-https-error";
+ }
+ return "no-throw";
+}
+
+describe("requireAdmin", () => {
+ it("rejects an unauthenticated caller", () => {
+ expect(codeOf(() => requireAdmin(req()))).toBe("unauthenticated");
+ });
+ it("rejects a signed-in non-Admin", () => {
+ expect(codeOf(() => requireAdmin(req({ roles: ["Member"] })))).toBe("permission-denied");
+ });
+ it("accepts an Admin", () => {
+ expect(codeOf(() => requireAdmin(req({ roles: ["Admin", "Member"] })))).toBe("no-throw");
+ });
+ it("rejects a manage:all perm holder who is not Admin by role", () => {
+ // requireAdmin is a ROLE gate; the wildcard perm has never satisfied it and must not
+ // start now that a sibling gate reads perms from the same token.
+ expect(codeOf(() => requireAdmin(req({ roles: ["Member"], perms: ["manage:all"] })))).toBe(
+ "permission-denied",
+ );
+ });
+});
+
+describe("requireAdminOrPerm", () => {
+ it("rejects an unauthenticated caller", () => {
+ expect(codeOf(() => requireAdminOrPerm(req(), "create:MemberLogin"))).toBe("unauthenticated");
+ });
+
+ it("accepts an Admin carrying no perms claim at all", () => {
+ // The role disjunct must stand alone — an Admin whose perms claim has not been minted
+ // yet (or was fail-closed to empty by the cap) still passes.
+ expect(codeOf(() => requireAdminOrPerm(req({ roles: ["Admin"] }), "create:MemberLogin"))).toBe(
+ "no-throw",
+ );
+ });
+
+ it("accepts a non-Admin holding the exact code", () => {
+ expect(
+ codeOf(() =>
+ requireAdminOrPerm(
+ req({ roles: ["Member"], perms: ["create:MemberLogin"] }),
+ "create:MemberLogin",
+ ),
+ ),
+ ).toBe("no-throw");
+ });
+
+ it("BLOCKING: manage:all does NOT satisfy it", () => {
+ // Exact-code, mirroring firestore.rules' hasPerm(). A canDo-style expansion here would
+ // hand the delegation to every wildcard holder silently.
+ expect(
+ codeOf(() =>
+ requireAdminOrPerm(req({ roles: ["Member"], perms: ["manage:all"] }), "create:MemberLogin"),
+ ),
+ ).toBe("permission-denied");
+ });
+
+ it("BLOCKING: manage:MemberLogin does NOT satisfy create:MemberLogin", () => {
+ // The subject wildcard is equally inert — the gate is the literal code, not canDo().
+ expect(
+ codeOf(() =>
+ requireAdminOrPerm(
+ req({ roles: ["Member"], perms: ["manage:MemberLogin"] }),
+ "create:MemberLogin",
+ ),
+ ),
+ ).toBe("permission-denied");
+ });
+
+ it("keeps the two delegations independent", () => {
+ // A board-seat delegate is not a login provisioner and vice versa. Pinned because both
+ // codes ship together and the obvious future mistake is to conflate them.
+ expect(
+ codeOf(() =>
+ requireAdminOrPerm(
+ req({ roles: ["Member"], perms: ["update:BoardSeat"] }),
+ "create:MemberLogin",
+ ),
+ ),
+ ).toBe("permission-denied");
+ });
+
+ it("fails closed on a malformed perms claim", () => {
+ // A string (or anything non-array) reads as empty rather than throwing — a malformed
+ // token must deny, not 500.
+ expect(
+ codeOf(() =>
+ requireAdminOrPerm(
+ req({ roles: ["Member"], perms: "create:MemberLogin" }),
+ "create:MemberLogin",
+ ),
+ ),
+ ).toBe("permission-denied");
+ });
+});
+
+describe("callerIsAdmin", () => {
+ it("is false for an unauthenticated caller and for a wildcard perm holder", () => {
+ expect(callerIsAdmin(req())).toBe(false);
+ expect(callerIsAdmin(req({ roles: ["Member"], perms: ["manage:all"] }))).toBe(false);
+ });
+ it("is true only for the Admin role", () => {
+ expect(callerIsAdmin(req({ roles: ["Admin"] }))).toBe(true);
+ });
+});
diff --git a/apps/beacon/src/callable-auth.ts b/apps/beacon/src/callable-auth.ts
index d84932f4..1c87adf1 100644
--- a/apps/beacon/src/callable-auth.ts
+++ b/apps/beacon/src/callable-auth.ts
@@ -1,10 +1,23 @@
import { HttpsError, type CallableRequest } from "firebase-functions/v2/https";
+import type { PermissionCode } from "@luminova/types";
-function callerRoles(request: CallableRequest): string[] {
- const token = request.auth?.token as { roles?: unknown } | undefined;
- return Array.isArray(token?.roles)
- ? (token.roles as unknown[]).filter((role): role is string => typeof role === "string")
- : [];
+/** One reader for both string-array claims. `roles` and `perms` are read identically and
+ * had drifted into two copies of the same three lines the moment a second gate needed one.
+ *
+ * The `as` narrows `DecodedIdToken`'s `[key: string]: any` index signature to `unknown`,
+ * which is a tightening — every value is still filtered before use. Deliberately NOT
+ * `permsFromClaims` from claims-sync: that returns `PermissionCode[] | undefined` because
+ * `getExistingClaims` needs absence and empty to differ for its claim diff, and importing
+ * it would pull the Firestore port's runtime graph (chunk, role-doc, resolve-member-perms)
+ * into the callable trust boundary for a membership test. */
+function stringArrayClaim(request: CallableRequest, key: "roles" | "perms"): string[] {
+ const token = request.auth?.token as Record | undefined;
+ const raw = token?.[key];
+ return Array.isArray(raw) ? raw.filter((v): v is string => typeof v === "string") : [];
+}
+
+export function callerIsAdmin(request: CallableRequest): boolean {
+ return stringArrayClaim(request, "roles").includes("Admin");
}
/** Reject anyone who isn't a signed-in Admin. Shared by every admin-only callable. */
@@ -12,7 +25,23 @@ export function requireAdmin(request: CallableRequest): void {
if (!request.auth) {
throw new HttpsError("unauthenticated", "sign-in required");
}
- if (!callerRoles(request).includes("Admin")) {
+ if (!callerIsAdmin(request)) {
throw new HttpsError("permission-denied", "Admin role required");
}
}
+
+/** Admin by ROLE, or the exact permission code — the callable-side mirror of
+ * firestore.rules' `hasAnyRole(['Admin']) || hasPerm(code)`.
+ *
+ * Exact-code, deliberately not a `canDo`-style expansion: `manage:all` must not satisfy a
+ * delegation gate, or every wildcard holder silently becomes a delegate. Same discipline as
+ * the rules' `hasPerm()` and backstage's `hasPerm`. A malformed `perms` claim (non-array,
+ * string, absent) reads as empty and therefore denies. */
+export function requireAdminOrPerm(request: CallableRequest, code: PermissionCode): void {
+ if (!request.auth) {
+ throw new HttpsError("unauthenticated", "sign-in required");
+ }
+ if (callerIsAdmin(request)) return;
+ if (stringArrayClaim(request, "perms").includes(code)) return;
+ throw new HttpsError("permission-denied", `Admin role or ${code} required`);
+}
diff --git a/apps/beacon/src/claims-sync/firestore-deps.ts b/apps/beacon/src/claims-sync/firestore-deps.ts
index 2fa90a1f..0883df40 100644
--- a/apps/beacon/src/claims-sync/firestore-deps.ts
+++ b/apps/beacon/src/claims-sync/firestore-deps.ts
@@ -284,9 +284,12 @@ export function firestoreClaimsDeps(db: Firestore, auth: Auth): FirestoreClaimsD
const grants = (snap.data()?.grants ?? []) as unknown[];
return { grants: grants.filter((g): g is Role => isValidRole(g)) };
},
- getUserRoles: async (uid) => {
+ getAssignerClaims: async (uid) => {
+ // Same per-instance loadUser memo getExistingClaims uses — reading perms alongside
+ // roles costs no extra Auth read.
const user = await loadUser(uid);
- return user ? rolesFromClaims(user.customClaims as Record | undefined) : [];
+ const claims = user?.customClaims as Record | undefined;
+ return { roles: rolesFromClaims(claims), perms: permsFromClaims(claims) ?? [] };
},
getExistingClaims: async (uid) => {
const user = await loadUser(uid);
diff --git a/apps/beacon/src/claims-sync/sync.test.ts b/apps/beacon/src/claims-sync/sync.test.ts
index 48fd7220..c30cdbf2 100644
--- a/apps/beacon/src/claims-sync/sync.test.ts
+++ b/apps/beacon/src/claims-sync/sync.test.ts
@@ -34,6 +34,9 @@ const customRole = (id: string, permissions: PermissionCode[]): RoleDefinition =
function fakeDeps(opts: {
positions: Record;
userRoles: Record;
+ /** The assigner's `perms` claim — the second trust source alongside the Admin role, and
+ * the ONLY one honored on a self-assignment (see resolveTrustedGrants). */
+ userPerms?: Record;
existing: Record;
builtInDocs?: RoleDefinition[];
customRoles?: Record;
@@ -42,7 +45,10 @@ function fakeDeps(opts: {
const writes: Record = {};
const deps: ClaimsSyncDeps = {
getPosition: async (id) => opts.positions[id] ?? null,
- getUserRoles: async (uid) => opts.userRoles[uid] ?? [],
+ getAssignerClaims: async (uid) => ({
+ roles: opts.userRoles[uid] ?? [],
+ perms: opts.userPerms?.[uid] ?? [],
+ }),
getExistingClaims: async (uid) => opts.existing[uid] ?? { roles: [] },
// COVERAGE is preserved (no liveness filter — a deactivated built-in must still reach
// resolveMemberPerms so it COVERS its key), but `live` is COMPUTED with the production
@@ -126,6 +132,99 @@ describe("syncMemberClaims", () => {
});
});
+ it("honors power grants when the assigner holds update:BoardSeat and NOT the Admin role", async () => {
+ // The mirror of the Admin case above, and the reason Slice 3 exists: a delegate stamps
+ // their own uid into assignedBy, so without this disjunct the seat would land and mint
+ // nothing.
+ const { deps, writes } = fakeDeps({
+ positions: { "pos-pres": { grants: ["Admin"] } },
+ userRoles: { "delegate-uid": ["Member"] },
+ userPerms: { "delegate-uid": ["update:BoardSeat"] },
+ existing: { "target-uid": { roles: ["Member"] } },
+ });
+ await syncMemberClaims(
+ deps,
+ {
+ uid: "target-uid",
+ positions: { "2026": { cargoId: "pos-pres", comisionIds: [], assignedBy: "delegate-uid" } },
+ },
+ "2026",
+ );
+ expect(writes["target-uid"]).toEqual({
+ roles: ["Admin", "Member"],
+ perms: permsFor(["Admin", "Member"]),
+ });
+ });
+
+ it("BLOCKING: manage:all does NOT satisfy the trust gate", async () => {
+ // The gate is an exact code test, matching firestore.rules' hasPerm(). The CASL wildcard
+ // must not answer it on the server any more than it does in the client gate — otherwise
+ // any manage:all holder silently becomes a seat delegate.
+ const { deps, writes } = fakeDeps({
+ positions: { "pos-pres": { grants: ["Admin"] } },
+ userRoles: { "wildcard-uid": ["Member"] },
+ userPerms: { "wildcard-uid": ["manage:all"] },
+ existing: { "target-uid": { roles: ["Member"], perms: permsFor(["Member"]) } },
+ });
+ await syncMemberClaims(
+ deps,
+ {
+ uid: "target-uid",
+ positions: { "2026": { cargoId: "pos-pres", comisionIds: [], assignedBy: "wildcard-uid" } },
+ },
+ "2026",
+ );
+ expect(writes["target-uid"]).toBeUndefined();
+ });
+
+ it("BLOCKING: a SELF-assignment is non-reflexive — a cargo-derived Admin cannot re-trust itself", async () => {
+ // The guard the whole delegation rests on. Without it: a delegate self-seats Presidente,
+ // this function mints them Admin, and the minted Admin then satisfies the gate that
+ // minted it — so revoking update:BoardSeat re-fires the trigger, finds the Admin role,
+ // and re-honors the grants forever. The delegation would be permanent.
+ // Fixture is the post-revocation state exactly: assigner IS the target, holds Admin by
+ // role (from the cargo), and no longer holds the perm.
+ const { deps, writes } = fakeDeps({
+ positions: { "pos-pres": { grants: ["Admin"] } },
+ userRoles: { "self-uid": ["Admin", "Member"] },
+ userPerms: { "self-uid": [] },
+ existing: { "self-uid": { roles: ["Admin", "Member"] } },
+ });
+ await syncMemberClaims(
+ deps,
+ {
+ uid: "self-uid",
+ positions: { "2026": { cargoId: "pos-pres", comisionIds: [], assignedBy: "self-uid" } },
+ },
+ "2026",
+ );
+ // De-elevated to a plain Member: revocation actually revokes.
+ expect(writes["self-uid"]).toEqual({ roles: ["Member"], perms: permsFor(["Member"]) });
+ });
+
+ it("still honors a SELF-assignment backed by the perm itself", async () => {
+ // The paired ALLOW — otherwise the test above would pass for a rule that simply refused
+ // every self-assignment, which would break an Admin seating their own cargo.
+ const { deps, writes } = fakeDeps({
+ positions: { "pos-pres": { grants: ["Admin"] } },
+ userRoles: { "self-uid": ["Member"] },
+ userPerms: { "self-uid": ["update:BoardSeat"] },
+ existing: { "self-uid": { roles: ["Member"] } },
+ });
+ await syncMemberClaims(
+ deps,
+ {
+ uid: "self-uid",
+ positions: { "2026": { cargoId: "pos-pres", comisionIds: [], assignedBy: "self-uid" } },
+ },
+ "2026",
+ );
+ expect(writes["self-uid"]).toEqual({
+ roles: ["Admin", "Member"],
+ perms: permsFor(["Admin", "Member"]),
+ });
+ });
+
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.
@@ -153,9 +252,9 @@ describe("syncMemberClaims", () => {
});
const spied: ClaimsSyncDeps = {
...deps,
- getUserRoles: async (uid) => {
+ getAssignerClaims: async (uid) => {
assignerLookups.push(uid);
- return deps.getUserRoles(uid);
+ return deps.getAssignerClaims(uid);
},
};
await syncMemberClaims(
@@ -536,7 +635,7 @@ describe("syncMemberClaims", () => {
const writes: Record = {};
const deps: ClaimsSyncDeps = {
getPosition: async () => ({ grants: ["Admin"] }),
- getUserRoles: async () => {
+ getAssignerClaims: async () => {
throw new Error("auth lookup failed");
},
getExistingClaims: async () => ({ roles: ["Member"] }),
diff --git a/apps/beacon/src/claims-sync/sync.ts b/apps/beacon/src/claims-sync/sync.ts
index c1177ac4..41b623d6 100644
--- a/apps/beacon/src/claims-sync/sync.ts
+++ b/apps/beacon/src/claims-sync/sync.ts
@@ -13,8 +13,13 @@ export interface MemberClaims {
export interface ClaimsSyncDeps extends RolePermsDeps {
/** Catalog position by id, or null if missing/deleted. */
getPosition(id: string): Promise<{ grants: Role[] } | null>;
- /** The assigner's current claim roles (for the power-grant trust gate). */
- getUserRoles(uid: string): Promise;
+ /** The assigner's current claim roles AND perms (for the power-grant trust gate).
+ * Both, from one call: the gate asks a single question and two accessors would be two
+ * chances for a later edit to consult one and not the other. Deliberately not folded into
+ * `getExistingClaims` — the spy in sync.test.ts proves the gate is never REACHED on a
+ * grant-free cargo by asserting no assigner lookup happened, and a shared accessor would
+ * degrade that assertion to a uid-filtering heuristic. */
+ getAssignerClaims(uid: string): Promise<{ roles: Role[]; perms: PermissionCode[] }>;
/** The target member's existing custom claims. */
getExistingClaims(uid: string): Promise<{ roles: Role[]; perms?: PermissionCode[] }>;
setClaims(uid: string, claims: MemberClaims): Promise;
@@ -43,14 +48,30 @@ type MemberLike = {
* claim true; do not re-loosen it without re-reading this comment.
*
* The assigner lookup runs only when the cargo actually confers power.
- * `getUserRoles` reads the assigner's LIVE claims, so a later Firestore write
+ * `getAssignerClaims` reads the assigner's LIVE claims, so a later Firestore write
* that re-invokes this function re-evaluates trust: if the assigner has since
* lost Admin, their previously granted power cargo is revoked and claims
- * reflect current org state (by design). */
+ * reflect current org state (by design).
+ *
+ * TWO trust sources, mirroring firestore.rules' boardSeatDelegate(): the Admin ROLE, or
+ * the exact `update:BoardSeat` PERM. The perm is not optional politeness — a delegate
+ * stamps their OWN uid into `assignedBy` (the rules' assignedBySelf()), so without it the
+ * seat lands, the member is published on the world-readable Directiva, and no claim is
+ * minted. Visible, powerless, and silent: half-working rather than safe.
+ *
+ * NON-REFLEXIVE on a self-assignment, and this is the guard the whole delegation rests on.
+ * When the assigner IS the member being seated, only the PERM is trusted — never the Admin
+ * role. Otherwise: a delegate self-seats Presidente, this function mints them Admin, and
+ * from then on the minted Admin satisfies the gate that minted it. Revoking
+ * `update:BoardSeat` would re-fire this very trigger, find the Admin role, and re-honor the
+ * grants forever; `recomputeAllClaims` runs the same code and would not break the loop
+ * either. The delegation would be permanent, which is exactly what its acceptance was
+ * premised on NOT being. An Admin seating someone ELSE is untouched. */
async function resolveTrustedGrants(
deps: ClaimsSyncDeps,
cargoId: string | null,
assignedBy: string | undefined,
+ memberUid: string,
): Promise {
// FULL screening, not just the empty-string half this used to check. `cargoId` comes
// straight off the member doc and every implementation of `getPosition` interpolates it
@@ -65,10 +86,13 @@ async function resolveTrustedGrants(
if (!isSafeDocId(cargoId)) return [];
const position = await deps.getPosition(cargoId);
if (!position || position.grants.length === 0) return [];
- const assignerIsAdmin = assignedBy
- ? (await deps.getUserRoles(assignedBy)).includes("Admin")
- : false;
- return assignerIsAdmin ? [...new Set(position.grants)] : [];
+ if (!assignedBy) return [];
+ const assigner = await deps.getAssignerClaims(assignedBy);
+ const selfAssigned = assignedBy === memberUid;
+ const trusted = selfAssigned
+ ? assigner.perms.includes("update:BoardSeat")
+ : assigner.roles.includes("Admin") || assigner.perms.includes("update:BoardSeat");
+ return trusted ? [...new Set(position.grants)] : [];
}
function sameList(a: readonly string[], b: readonly string[]): boolean {
@@ -96,7 +120,12 @@ export async function syncMemberClaims(
): Promise {
if (!member.uid) return;
const term = member.positions?.[termKey];
- const trustedGrants = await resolveTrustedGrants(deps, term?.cargoId ?? null, term?.assignedBy);
+ const trustedGrants = await resolveTrustedGrants(
+ deps,
+ term?.cargoId ?? null,
+ term?.assignedBy,
+ member.uid,
+ );
const existing = await deps.getExistingClaims(member.uid);
const hadScanner = existing.roles.includes("Scanner");
@@ -113,6 +142,9 @@ export async function syncMemberClaims(
// keep a stale grant while dropping a revoke). We still write the recomputed
// `roles` + empty `perms` so a concurrent role revocation always lands —
// never leave the member on stale, possibly-elevated claims.
+ // Note for update:BoardSeat holders: this takes their delegation with it, silently. A
+ // delegate over the cap keeps seating (their cached token still passes the rules) while
+ // this function stops honoring the grants — the seat publishes, no claim is minted.
deps.logError?.("effective perms exceed cap; writing empty perms (fail-closed)", {
uid: member.uid,
count: perms.length,
diff --git a/apps/beacon/src/provision-member-login.test.ts b/apps/beacon/src/provision-member-login.test.ts
index 05dc2ef2..ef6c82ca 100644
--- a/apps/beacon/src/provision-member-login.test.ts
+++ b/apps/beacon/src/provision-member-login.test.ts
@@ -89,13 +89,18 @@ describe("provisionMember", () => {
});
it("self-heals a stale link when the linked account was deleted — adopts by email, de-elevated", async () => {
+ // ADMIN caller. The self-heal is an adoption too — it binds an account this member was
+ // never linked to — so it sits behind the same guard, and deliberately: `email` is not
+ // pinned on the members update arm either, so an update:Member delegate could retarget an
+ // already-linked member at an Admin's mailbox and reach this branch whenever the stale
+ // link happens to be dead. Recovery from a deleted account stays an Admin op.
const { deps, calls } = fakeDeps({
member: { ...active, uid: "dead-uid" },
usersByEmail: {
"a@b.co": { uid: "u2", email: "a@b.co", customClaims: { roles: ["Admin"] } },
},
});
- const result = await provisionMember(deps, "m1");
+ const result = await provisionMember(deps, "m1", true);
expect(result.email).toBe("a@b.co");
expect(calls.createUser).toEqual([]);
expect(calls.linkUid).toEqual(["u2"]);
@@ -130,17 +135,74 @@ describe("provisionMember", () => {
});
it("reuses an existing auth user for an unlinked member (pre-created account)", async () => {
+ // ADMIN caller: adoption is the documented recovery op and stays open for the Admin role.
+ // The `true` is load-bearing — the same call with `false` is the takedown case below.
const { deps, calls } = fakeDeps({
member: active,
usersByEmail: {
"a@b.co": { uid: "u9", email: "a@b.co", customClaims: { roles: ["Scanner"] } },
},
});
- await provisionMember(deps, "m1");
+ await provisionMember(deps, "m1", true);
expect(calls.createUser).toEqual([]);
expect(calls.linkUid).toEqual(["u9"]);
});
+ it("BLOCKING: a non-Admin caller may NOT adopt a pre-existing unlinked account", async () => {
+ // The takeover this guard closes: firestore.rules never constrains members.email and
+ // there is no uniqueness check, so a create:Member + create:MemberLogin delegate could
+ // file a member doc carrying a sitting Admin's email. Reaching the writes below would
+ // strip that Admin's claims (adoptedClaims), bind their uid to the attacker's member doc
+ // through the admin SDK, and hand back a password-reset link for their mailbox.
+ // Same fixture as "reuses an existing auth user for an unlinked member" one test above —
+ // the ONLY difference is the caller's privilege.
+ const { deps, calls } = fakeDeps({
+ member: active,
+ usersByEmail: {
+ "a@b.co": { uid: "u9", email: "a@b.co", customClaims: { roles: ["Admin"] } },
+ },
+ });
+ await expect(provisionMember(deps, "m1", false)).rejects.toMatchObject({
+ code: "permission-denied",
+ });
+ // Nothing partial: no claim write, no uid link, no reset link generated.
+ expect(calls.setClaims).toEqual([]);
+ expect(calls.linkUid).toEqual([]);
+ expect(calls.createUser).toEqual([]);
+ });
+
+ it("still lets a non-Admin caller mint a BRAND-NEW account, and resend to its own link", async () => {
+ // The delegation costs nothing on the path it is actually for: a genuinely new member
+ // has no Auth account.
+ const fresh = fakeDeps({ member: active });
+ await expect(provisionMember(fresh.deps, "m1", false)).resolves.toEqual({
+ email: "a@b.co",
+ actionLink: "link:a@b.co",
+ });
+ expect(fresh.calls.createUser).toEqual(["a@b.co"]);
+ // And re-provisioning a member ALREADY linked to that account stays open (resend invite):
+ // user.uid === linkedUid, so it is not an adoption.
+ const resend = fakeDeps({
+ member: { ...active, uid: "u1" },
+ usersByEmail: { "a@b.co": { uid: "u1", email: "a@b.co" } },
+ });
+ await expect(provisionMember(resend.deps, "m1", false)).resolves.toEqual({
+ email: "a@b.co",
+ actionLink: "link:a@b.co",
+ });
+ expect(resend.calls.createUser).toEqual([]);
+ expect(resend.calls.linkUid).toEqual(["u1"]);
+ });
+
+ it("defaults callerIsAdmin to false — a new call site must opt into adoption", async () => {
+ // The parameter defaults closed so an added caller that forgets it gets the SAFE path.
+ const { deps } = fakeDeps({
+ member: active,
+ usersByEmail: { "a@b.co": { uid: "u9", email: "a@b.co" } },
+ });
+ await expect(provisionMember(deps, "m1")).rejects.toMatchObject({ code: "permission-denied" });
+ });
+
it("rejects a missing / inactive / email-less member", async () => {
await expect(provisionMember(fakeDeps({ member: null }).deps, "m1")).rejects.toMatchObject({
code: "not-found",
@@ -154,6 +216,9 @@ describe("provisionMember", () => {
});
});
+// Every case here exercises the ADOPTION path, which is Admin-only — hence the explicit
+// `true` third argument throughout. A non-Admin caller is refused before any of this runs
+// (see "a non-Admin caller may NOT adopt a pre-existing unlinked account").
describe("provisionMember — stale-claims bootstrap (fresh adopt)", () => {
it("strips stale org roles when adopting a pre-existing auth account, keeping Scanner", async () => {
const claimsWrites: Record[] = [];
@@ -173,7 +238,7 @@ describe("provisionMember — stale-claims bootstrap (fresh adopt)", () => {
claimsWrites.push(claims);
},
};
- await provisionMember(spied, "m1");
+ await provisionMember(spied, "m1", true);
expect(claimsWrites).toEqual([{ roles: ["Scanner", "Member"] }]);
});
@@ -191,7 +256,7 @@ describe("provisionMember — stale-claims bootstrap (fresh adopt)", () => {
claimsWrites.push(claims);
},
};
- await provisionMember(spied, "m1");
+ await provisionMember(spied, "m1", true);
expect(claimsWrites).toEqual([{ roles: ["Member"] }]);
});
@@ -209,7 +274,7 @@ describe("provisionMember — stale-claims bootstrap (fresh adopt)", () => {
claimsWrites.push(claims);
},
};
- await provisionMember(spied, "m1");
+ await provisionMember(spied, "m1", true);
expect(claimsWrites).toEqual([{ roles: ["Admin", "Member"] }]);
});
});
diff --git a/apps/beacon/src/provision-member-login.ts b/apps/beacon/src/provision-member-login.ts
index 7a602884..959e1023 100644
--- a/apps/beacon/src/provision-member-login.ts
+++ b/apps/beacon/src/provision-member-login.ts
@@ -2,7 +2,7 @@ import { getAuth } from "firebase-admin/auth";
import { getFirestore } from "firebase-admin/firestore";
import { HttpsError, onCall } from "firebase-functions/v2/https";
import { isValidRole, type Role } from "@luminova/auth/roles";
-import { requireAdmin } from "./callable-auth.js";
+import { callerIsAdmin, requireAdminOrPerm } from "./callable-auth.js";
import { firestoreProvisionDeps } from "./provision-deps.js";
import { ensureApp } from "./runtime.js";
@@ -71,6 +71,10 @@ function adoptedClaims(existing: RawClaims | undefined): RawClaims {
export async function provisionMember(
deps: ProvisionDeps,
memberId: string,
+ /** Whether the CALLER holds the Admin role. A `create:MemberLogin` delegate does not, and
+ * is confined to the new-account path below — see the adoption guard. Defaults to false:
+ * a new call site must opt INTO the privileged path, never inherit it by omission. */
+ callerIsAdmin = false,
): Promise<{ email: string; actionLink: string }> {
const member = await deps.getMember(memberId);
if (member === null) throw new HttpsError("not-found", "member not found");
@@ -94,6 +98,30 @@ export async function provisionMember(
);
}
}
+ // ADOPTION GUARD — the boundary that makes create:MemberLogin delegable at all.
+ //
+ // Adoption is the branch where an Auth account already exists for this email and is not
+ // the one this member is linked to. For an Admin it is the documented recovery op. For a
+ // delegate it would be an account-takeover primitive, because NOTHING upstream ties
+ // members.email to the person: firestore.rules constrains totalPoints, uid, publicProfile,
+ // name, roleIds and positions on the create arm, never `email`, and no uniqueness check
+ // exists anywhere. So a create:Member + create:MemberLogin holder could file a member doc
+ // carrying a sitting Admin's email and reach the three writes below — adoptedClaims()
+ // stripping that Admin's claims, linkUid() binding the Admin's uid to the attacker's
+ // member doc through the admin SDK (the only path that can write members.uid at all), and
+ // passwordResetLink() handing back a reset link for the Admin's mailbox.
+ //
+ // A non-Admin therefore gets exactly two shapes: mint a brand-new account, or re-provision
+ // one already linked to THIS member (resend invite). That costs the delegation nothing —
+ // a genuinely new member has no Auth account — and is why the guard is a hard refusal
+ // rather than a claims-only restriction.
+ if (!callerIsAdmin && user !== null && user.uid !== linkedUid) {
+ throw new HttpsError(
+ "permission-denied",
+ "this email already has a login; only an Admin can link an existing account",
+ { reason: "adoption-requires-admin" },
+ );
+ }
if (!user) user = await deps.createUser(email);
const targetEmail = user.email ?? email;
@@ -114,9 +142,17 @@ export async function provisionMember(
return { email: targetEmail, actionLink } as const;
}
+// Delegable per docs/specs/board-seat-delegation.md: an Admin may hand `create:MemberLogin`
+// to whoever is enrolling members, then revoke it. What the code gates is Auth account
+// creation, uid linking and the initial claim write — NOT the invite email, which is a plain
+// client-side sendPasswordResetEmail any signed-in user can already call.
export const provisionMemberLogin = onCall(async (request) => {
- requireAdmin(request);
+ requireAdminOrPerm(request, "create:MemberLogin");
const { memberId } = validateProvisionInput(request.data);
ensureApp();
- return provisionMember(firestoreProvisionDeps(getFirestore(), getAuth()), memberId);
+ return provisionMember(
+ firestoreProvisionDeps(getFirestore(), getAuth()),
+ memberId,
+ callerIsAdmin(request),
+ );
});
From 450219a045fbc814c26cc482545f9fdd66d26c30 Mon Sep 17 00:00:00 2001
From: Arnold Gandarillas Castillo
Date: Thu, 27 Aug 2026 18:44:31 -0400
Subject: [PATCH 04/15] feat(backstage): split the seat, catalog and login
gates; explain the empty cargo list
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
canAssignPowerGrants is replaced by three flags. It had five consumers and one
of them — positions-page's PositionForm canEditGrants — governs the /positions
CATALOG, not member seating. Widening it in place would have handed a seat
delegate the ability to mint a grant-free CEL 'Presidente' and then seat itself
on it at public board rank 0.
canAssignBoardSeat = Admin role || update:BoardSeat (member create+update lanes)
canEditCargoCatalog = Admin role (/positions, unchanged)
canProvisionLogin = Admin role || create:MemberLogin (invite affordances)
The invite affordances (drawer checkbox, row menu, profile header) move off the
Admin role onto canProvisionLogin. All three flags use the exact-code hasPerm,
never CASL, so manage:all cannot satisfy them and produce a render-then-403.
NoAssignableCargosNote explains a Cargo picker the permission ceiling emptied —
previously a bare "Sin resultados" indistinguishable from an empty catalog, which
is the permanent state of a chapter whose every cargo carries grants.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../members/components/member-drawer.tsx | 4 +-
.../members/components/member-form.test.tsx | 46 +++++++++++++++
.../members/components/member-form.tsx | 4 ++
.../components/member-invite-drawer.test.tsx | 57 +++++++++++++++++--
.../components/member-invite-drawer.tsx | 26 +++++----
.../components/member-positions-form.test.tsx | 49 ++++++++++++++++
.../components/member-positions-form.tsx | 2 +
.../components/member-profile-page.tsx | 9 +--
.../components/member-row-menu.test.tsx | 19 +++++++
.../members/components/member-row-menu.tsx | 7 ++-
.../components/no-assignable-cargos-note.tsx | 20 +++++++
.../positions/components/positions-page.tsx | 4 +-
apps/backstage/src/lib/authz/use-can.test.ts | 50 ++++++++++++++++
apps/backstage/src/lib/authz/use-can.ts | 35 +++++++++---
14 files changed, 298 insertions(+), 34 deletions(-)
create mode 100644 apps/backstage/src/features/members/components/no-assignable-cargos-note.tsx
diff --git a/apps/backstage/src/features/members/components/member-drawer.tsx b/apps/backstage/src/features/members/components/member-drawer.tsx
index 5cd39678..4dbbf5bd 100644
--- a/apps/backstage/src/features/members/components/member-drawer.tsx
+++ b/apps/backstage/src/features/members/components/member-drawer.tsx
@@ -136,7 +136,7 @@ function EditBody({
onSubmit: (data: MemberInput) => Promise;
}) {
const { onUpload, onRemove } = useMemberPhoto(member.id);
- const { canAssignPowerGrants } = useCan();
+ const { canAssignBoardSeat } = useCan();
return (
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 0fa5bdde..c2252593 100644
--- a/apps/backstage/src/features/members/components/member-form.test.tsx
+++ b/apps/backstage/src/features/members/components/member-form.test.tsx
@@ -105,6 +105,52 @@ describe("MemberForm", () => {
expect(screen.queryByText("Comisión de Eventos")).not.toBeInTheDocument();
});
+ // The empty-state the delegation exists to explain. A chapter whose every cargo carries
+ // grants (which is the real production shape) leaves a non-delegate with zero options, and
+ // the bare Combobox "Sin resultados" cannot be told apart from an empty catalog.
+ it("explains an empty cargo list to a non-delegate, and stays silent for a delegate", async () => {
+ const gatedCargo = (
+ id: string,
+ category: Position["category"],
+ grants: Position["grants"],
+ ): Position => ({
+ id,
+ title: id,
+ titleFemale: id,
+ category,
+ grants,
+ term: null,
+ sigla: null,
+ description: "",
+ active: true,
+ deletedAt: null,
+ });
+ const celOnly: Position[] = [
+ gatedCargo("pos-cel", "CEL", []),
+ gatedCargo("pos-power", "JDL", ["Membership"]),
+ ];
+ const { unmount } = render(
+ ,
+ );
+ expect(screen.getByRole("note")).toHaveTextContent(/Asientos de directiva/);
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ expect(await screen.findByText("Sin resultados")).toBeInTheDocument();
+ unmount();
+
+ // The delegate sees the very same catalog as assignable, and no note.
+ render(
+ ,
+ );
+ expect(screen.queryByRole("note")).not.toBeInTheDocument();
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ expect(screen.queryByText("Sin resultados")).not.toBeInTheDocument();
+ });
+
// The admin half of memberSchemaFor: a member enrolled before memberNameValid() existed
// must stay editable. Without the per-member schema the form blocks on a name the admin
// never touched, making the rules' touched('name') affordance unreachable.
diff --git a/apps/backstage/src/features/members/components/member-form.tsx b/apps/backstage/src/features/members/components/member-form.tsx
index e6ee820a..a3ad59a6 100644
--- a/apps/backstage/src/features/members/components/member-form.tsx
+++ b/apps/backstage/src/features/members/components/member-form.tsx
@@ -28,6 +28,7 @@ import {
cargoTakedownOnly,
positionsLockedForNonAdmin,
} from "../lib/assignable-cargo";
+import { NoAssignableCargosNote } from "./no-assignable-cargos-note";
interface MemberFormProps {
positions: Position[];
@@ -310,6 +311,9 @@ export function MemberForm({
igual.
)}
+ {!positionsLocked && !allowPowerGrants && cargoOptions.length === 0 && (
+
+ )}
(
-
+
{children}
),
@@ -142,4 +146,49 @@ describe("MemberInviteDrawer", () => {
);
expect(screen.getByRole("button", { name: "Copiar enlace de acceso" })).toBeInTheDocument();
});
+
+ // --- create:MemberLogin delegation ---
+
+ const drawer = (
+ onProvision = vi.fn().mockResolvedValue({ email: "a@b.co", actionLink: "l" }),
+ ) => ({
+ node: (
+ {}}
+ onCreate={async () => "idD"}
+ onProvision={onProvision}
+ />
+ ),
+ onProvision,
+ });
+
+ it("shows 'Enviar acceso' to a create:MemberLogin delegate, defaulted ON, and provisions", async () => {
+ const { node, onProvision } = drawer();
+ renderWithAbility(node, { roles: ["Member"], perms: ["create:Member", "create:MemberLogin"] });
+ const checkbox = screen.getByLabelText("Enviar acceso a la app");
+ expect(checkbox).toBeChecked();
+ await fill();
+ fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
+ await waitFor(() => expect(onProvision).toHaveBeenCalledWith("idD"));
+ });
+
+ it("hides it from a member creator without the code, and never calls onProvision", async () => {
+ const { node, onProvision } = drawer();
+ renderWithAbility(node, { roles: ["Member"], perms: ["create:Member"] });
+ expect(screen.queryByLabelText("Enviar acceso a la app")).not.toBeInTheDocument();
+ await fill();
+ fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
+ await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
+ expect(onProvision).not.toHaveBeenCalled();
+ });
+
+ it("BLOCKING: hides it from a manage:all perm holder without the Admin role", async () => {
+ // The render-then-403 this gate exists to stop: beacon's requireAdminOrPerm is an exact
+ // code test, so the wildcard would fail server-side after the member was already created.
+ const { node } = drawer();
+ renderWithAbility(node, { roles: ["Member"], perms: ["manage:all"] });
+ expect(screen.queryByLabelText("Enviar acceso a la app")).not.toBeInTheDocument();
+ });
});
diff --git a/apps/backstage/src/features/members/components/member-invite-drawer.tsx b/apps/backstage/src/features/members/components/member-invite-drawer.tsx
index e36ac1ba..1bce8c2c 100644
--- a/apps/backstage/src/features/members/components/member-invite-drawer.tsx
+++ b/apps/backstage/src/features/members/components/member-invite-drawer.tsx
@@ -34,26 +34,28 @@ export function MemberInviteDrawer({
onCreate,
onProvision,
}: MemberInviteDrawerProps) {
- // Provisioning login is Admin-role-only (provisionMemberLogin → requireAdmin). A
- // non-Admin may still create the member; they just can't send access here, so hide
- // the option and default it off — otherwise the provision step fails silently after
- // the member is already created.
- const { isAdmin, canAssignPowerGrants } = useCan();
+ // Provisioning login is the Admin role OR the create:MemberLogin perm
+ // (provisionMemberLogin → requireAdminOrPerm). A member creator without either may still
+ // create the member; they just can't send access here, so hide the option and default it
+ // off — otherwise the provision step fails silently after the member is already created.
+ const { canProvisionLogin, canAssignBoardSeat } = useCan();
const [done, setDone] = useState(null);
- const [sendAccess, setSendAccess] = useState(isAdmin);
+ const [sendAccess, setSendAccess] = useState(canProvisionLogin);
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
// The drawer mounts with the page, before the auth token's claims decode (the store
// emits with empty claims first, then re-emits). Re-sync the default each time it
- // OPENS — by then isAdmin is resolved — so an Admin's first invite doesn't silently
+ // OPENS — by then the flag is resolved — so a provisioner's first invite doesn't silently
// default "Enviar acceso" off. Won't clobber a manual toggle (deps stable while open).
+ // This matters MORE now than it did for a role gate: `perms` is minted by claims-sync and
+ // lands in the same late token, so a perm-derived flag is false for exactly as long.
useEffect(() => {
- if (open) setSendAccess(isAdmin);
- }, [open, isAdmin]);
+ if (open) setSendAccess(canProvisionLogin);
+ }, [open, canProvisionLogin]);
const reset = () => {
setDone(null);
- setSendAccess(isAdmin);
+ setSendAccess(canProvisionLogin);
setCopyState("idle");
};
@@ -177,11 +179,11 @@ export function MemberInviteDrawer({
submitLabel="Enviar invitación"
pendingLabel="Enviando…"
showPreview
- allowPowerGrants={canAssignPowerGrants}
+ allowPowerGrants={canAssignBoardSeat}
defaultValues={{ joinDate: today(), status: "Activo", cargoId: null, comisionIds: [] }}
onSubmit={handleSubmit}
>
- {isAdmin && (
+ {canProvisionLogin && (
{
expect(onSubmit).toHaveBeenCalledWith({ cargoId: null, comisionIds: [] });
});
+ it("explains an empty cargo list to a non-delegate, and never doubles up with the locked note", async () => {
+ // A catalog of only CEL / power-granting cargos — the real production shape, and the
+ // state that made the picker silently empty.
+ const gated: Position[] = [
+ { ...pos("presi", "CEL"), grants: [] },
+ { ...pos("power", "JDL"), grants: ["Membership"] },
+ ];
+ const { unmount } = render(
+ ,
+ );
+ expect(screen.getByRole("note")).toHaveTextContent(/Asientos de directiva/);
+ unmount();
+
+ // A delegate assigns the same catalog: no note.
+ const asDelegate = render(
+ ,
+ );
+ expect(screen.queryByRole("note")).not.toBeInTheDocument();
+ asDelegate.unmount();
+
+ // Locked (seated on a power cargo) renders the LOCKED note and not this one. They are
+ // mutually exclusive today only because cargoOptionsForEditor appends the held cargo,
+ // making the list non-empty — pin it so a change there cannot produce two notes.
+ render(
+ ,
+ );
+ const notes = screen.getAllByRole("note");
+ expect(notes).toHaveLength(1);
+ expect(notes[0]).toHaveTextContent(/Solo un Admin/);
+ });
+
it("submits selected cargo and comisiones", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
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 bc02f796..f322d95d 100644
--- a/apps/backstage/src/features/members/components/member-positions-form.tsx
+++ b/apps/backstage/src/features/members/components/member-positions-form.tsx
@@ -9,6 +9,7 @@ import {
cargoTakedownOnly,
positionsLockedForNonAdmin,
} from "../lib/assignable-cargo";
+import { NoAssignableCargosNote } from "./no-assignable-cargos-note";
const positionsSchema = z.object({
cargoId: z.string().min(1).nullable(),
@@ -128,6 +129,7 @@ export function MemberPositionsForm({
con «Quitar cargo» y guardar, o elegir otro cargo.
)}
+ {!locked && !allowPowerGrants && cargoOptions.length === 0 && }
{formError && (
{member.status && {member.status}}
- {/* provisionMemberLogin is requireAdmin (role), not the manage:all perm. */}
-
+ {/* provisionMemberLogin is requireAdminOrPerm(create:MemberLogin) — the Admin
+ role or that exact code, never the manage:all perm. */}
+
@@ -145,7 +146,7 @@ export function MemberProfilePage() {
defaultValues={memberFormDefaults(member)}
submitLabel="Guardar cambios"
pendingLabel="Guardando…"
- allowPowerGrants={gate.canAssignPowerGrants}
+ allowPowerGrants={gate.canAssignBoardSeat}
onSubmit={handleEdit}
avatarSeed={member.name}
/>
@@ -173,7 +174,7 @@ export function MemberProfilePage() {
{
expect(screen.queryByText("Desafiliar")).not.toBeInTheDocument();
expect(screen.queryByText("Invitar a la app")).not.toBeInTheDocument();
});
+
+ it("shows the invite item to a create:MemberLogin delegate with no privileged role", async () => {
+ // The affordance moved off the Admin ROLE onto canProvisionLogin, mirroring beacon's
+ // requireAdminOrPerm. update:Member is what keeps the menu itself reachable.
+ renderMenu(member({ status: "Activo" }), {
+ roles: ["Member"],
+ perms: ["update:Member", "create:MemberLogin"],
+ });
+ await userEvent.click(screen.getByLabelText(/Acciones para Ana/));
+ expect(screen.getByText("Invitar a la app")).toBeInTheDocument();
+ });
+
+ it("BLOCKING: hides the invite item from a manage:all perm holder without the Admin role", async () => {
+ // Exact-code gate, matching the callable. A wildcard holder clicking this would get a
+ // permission-denied from beacon after the fact.
+ renderMenu(member({ status: "Activo" }), { roles: ["Member"], perms: ["manage:all"] });
+ await userEvent.click(screen.getByLabelText(/Acciones para Ana/));
+ expect(screen.queryByText("Invitar a la app")).not.toBeInTheDocument();
+ });
});
diff --git a/apps/backstage/src/features/members/components/member-row-menu.tsx b/apps/backstage/src/features/members/components/member-row-menu.tsx
index 89e0cece..bc2811b2 100644
--- a/apps/backstage/src/features/members/components/member-row-menu.tsx
+++ b/apps/backstage/src/features/members/components/member-row-menu.tsx
@@ -2,6 +2,7 @@ import { Menu, MenuItem, MenuSeparator } from "@luminova/ui";
import type { Member, MemberStatus } from "@luminova/types";
import { Can } from "../../../lib/authz/ability-context";
import { ActionGate } from "../../../lib/authz/action-gate";
+import { useCan } from "../../../lib/authz/use-can";
interface MemberRowMenuProps {
member: Member;
@@ -20,6 +21,7 @@ export function MemberRowMenu({
onSetStatus,
onUnpublish,
}: MemberRowMenuProps) {
+ const { canProvisionLogin } = useCan();
return (