Skip to content

feat: delegable board-seat and member-login permissions - #224

Merged
arkgast merged 15 commits into
mainfrom
feat/board-seat-delegation
Aug 28, 2026
Merged

feat: delegable board-seat and member-login permissions#224
arkgast merged 15 commits into
mainfrom
feat/board-seat-delegation

Conversation

@arkgast

@arkgast arkgast commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Two explicitly grantable permission codes an Admin can delegate temporarily and revoke,
    both surfaced as checkboxes in /permisos and as chips in the per-member overrides panel:
    • update:BoardSeat ("Asientos de directiva") — seat a member on any vacant cargo, CEL
      and power-granting alike.
    • create:MemberLogin ("Acceso de miembros") — provision a new member's Auth login. This
      is cargo-agnostic: it applies to every new member, board seat or not.
  • Why: with manage:Member alone the Cargo picker is empty in production. Every cargo in the
    catalog carries grants, so cargoAssignableByNonAdmin() filtered out all of them and the
    delegation lane shipped in feat(rules,backstage,beacon,auth): position-assignment lane + role-lifecycle follow-through #222 was unreachable on real data — the picker just said
    "Sin resultados" with no explanation. That empty state is now explained too.

Design and the full threat model: docs/specs/board-seat-delegation.md.

What the delegation deliberately does NOT reach

Stays Admin-only Why
Conferring the Admin role A minted Admin is itself a trust source, so a delegate who could mint one would make the delegation permanent. resolveTrustedGrants honors an Admin-granting cargo only for an assigner holding the Admin role.
Conferring anything on themselves A self-assignment of any granting cargo is honored only for an Admin. Without it the code is a self-service grant of every built-in role but Admin — one write, through the positions-only lane, onto your own member doc. A delegate self-seating still publishes the seat; it just confers nothing.
Unseating a power-cargo holder currentCargoGrantsEmpty() stays Admin-role-only. The roles claim derives exclusively from cargo grants, so displacing sitting Admins would strip every Admin claim in the chapter — unrecoverable outside the Firebase console.
The /positions catalog Otherwise: mint a grant-free CEL "Presidente", then seat yourself on it at public board rank 0. canAssignPowerGrants was split into canAssignBoardSeat / canEditCargoCatalog precisely because one flag drove both.
roleIds / permissionOverrides A delegate cannot re-grant the delegation.
Adopting / re-provisioning an existing login generatePasswordResetLink returns a bearer credential to the caller.

Security findings fixed during review

Five escalation paths were found by adversarial review and closed. None was part of the accepted
risk (which is only: a delegate may seat power-granting cargos).

  1. Adoption takeovermembers.email is unpinned by the rules and has no uniqueness check,
    so a delegate could file a member doc carrying an Admin's email and have the callable adopt
    that account, strip its claims, link its uid onto their own doc, and return its reset link.
  2. Resend takeover — naming an already-linked memberId returned a live reset link for that
    member's address.
  3. Power-seat takeover — "unprovisioned" does not mean "enrolled by this delegate". Any
    uid-less member was reachable, including one an Admin had already seated on an Admin cargo;
    linkUid then fired the trigger, which read the Admin's stored assignedBy and minted Admin
    onto the account the delegate just caused to exist.
  4. Direct-grant takeover — same shape via the other claims-mint source: roleIds /
    permissionOverrides mint perms with no cargo involved.
  5. Self-promotionupdate:BoardSeat let its holder seat a Secretario/ProjectManager cargo
    on their own member doc and be minted those roles. Found by /code-review; the four
    above were found by subagent passes.
  6. Create-lane term ride-along (pre-existing, in the function this branch rewrites) — a
    next-term power cargo with a real Admin's assignedBy rode along unvalidated at create and
    minted on the year rollover.

A production outage was also caught before it shipped. The first form of the trust guard
keyed on self-assignment, which would have stripped the sitting president's Admin: the seeded
president self-stamps assignedBy and an Admin's perms are manage:all, never the exact code.
Verified against the live member doc (Presidente, grants: ["Admin"], assignedBy = own uid).
Keying on the grant instead fixes it with no owner-op and no bootstrap change.

Every guard is mutation-tested: neutralizing each turns exactly its own tests red and nothing
else.

Test plan

  • @luminova/types 329
  • backstage 766 (eslint + tsc + build + vitest + knip + size-limit)
  • beacon 327 unit + 37 emulator
  • tests/firestore-rules 489
  • Bundle: −84 B eager JS gz vs main (160/162 kB budget), CSS unchanged, knip clean
  • pnpm pr-tests — fails only on the Firestore emulator port: 4010 is held by a running
    dev emulator, which I did not kill. 11/11 non-emulator packages pass; both emulator suites
    pass on a free port. Stop the dev emulator and re-run to get a clean single command.

Reviews

  • security-review
  • firestore-security-reviewer
  • firebase-functions-reviewer
  • code-review — run by the user after the PR opened. It found a HIGH the subagent passes had
    missed (self-promotion, feat(backstage): auth foundation + protected shell #5 above) plus four stale-claim / dead-affordance issues; all six
    are fixed in fix(beacon,rules,backstage): a delegate may confer power on others….
  • simplify
  • react-best-practices
  • bundle-budget-watcher

Operator notes

  1. update:BoardSeat confers nothing alone — pair it with Membresía (manage:Member) or
    update:Position. Neither surface hints at the dependency.
  2. The delegate must sign out and back in after being granted or revoked: all three gates
    read perms off the ID token and auth-store.ts does not force-refresh.
  3. Revocation stops future seating; already-seated members are recomputed on the next write to
    their doc.

🤖 Generated with Claude Code

arkgast and others added 15 commits August 27, 2026 18:30
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…MemberLogin

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:<subject> wildcards failing the exact-code gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… empty cargo list

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) <noreply@anthropic.com>
- extract noAssignableCargos() into assignable-cargo.ts. The three-clause render
  guard was typed out at both member forms, immediately next to the two sibling
  predicates that file exists to hold so the forms cannot disagree.
- extract adminOrPerm() in use-can.ts at its third occurrence.
- rename provisionMember's callerIsAdmin parameter to callerHoldsAdminRole: it
  shadowed the imported callerIsAdmin function in the same module.

Skipped: hoisting canProvisionLogin out of member-row-menu into member-table as
a prop. It saves one buildCan() per row, bounded by the page size (max 32) and
each a trivial object construction, at the cost of prop plumbing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e-lane ride-along

Adversarial review found the first form of the trust guard was both unsafe and
a production outage. Replaced, not patched.

Trust gate: a cargo whose grants include Admin is honored only for an assigner
holding the Admin ROLE; every other cargo is honored for an update:BoardSeat
delegate too. The previous self-assignment form was wrong twice over:

- it missed the two-write puppet loop (delegate creates a second member on a
  mailbox they control, seats IT on Presidente — not a self-assignment, so the
  perm was trusted — and that puppet is Admin forever), so revocation, the
  premise the whole delegation was accepted on, did not work; and
- it stripped the SITTING PRESIDENT. seed-president.mjs stamps assignedBy with
  the president's own uid and an Admin's perms are manage:all, never the exact
  code — verified against the live production member doc, which is self-assigned
  on an Admin-granting cargo. Pinned by a regression test.

provisionMemberLogin: a non-Admin may provision only a member with NO login yet.
The resend path was the hole — passwordResetLink is generatePasswordResetLink,
which returns the oobCode URL to the CALLER, so a delegate could pass the
president's memberId and receive a live reset link for their address.

createPositionsSafe: restrict to the current term key, mirroring the update arm.
assignedBySelf() and cargoAssignableByNonAdmin() read only the current term, so
a second term key rode along unvalidated — a next-term power cargo attributed to
a real Admin, minted on the year rollover. Pre-existing, in the function this
branch rewrites.

Tests: retargeted three delegate pins that were silently denied by G3 rather
than by the conjunct they named; added rules coverage for hasPerm-vs-canDo, the
create-lane self-stamp and the create-lane non-vacuity pin.

Every new guard mutation-tested: neutralizing each turns exactly its own test
red and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…member

/security-review found a third takeover path, and the strongest: the adoption
guard asked whether this was a NEW login, never WHOSE member doc it was. Every
uid-less member is reachable by memberId — including one an Admin already seated
on an Admin-granting cargo, which is the normal state between being seated and
being invited.

The delegate forges nothing. linkUid fires onMemberWritten, resolveTrustedGrants
reads the STORED assignedBy (a genuine Admin), honors the grants, and mints Admin
onto the account the call just created; the attacker then reaches it through the
invite. So the mint is refused at the source: a non-Admin provision is denied when
the member's current-term cargo carries grants, and fails closed when that cargo
cannot be read.

Suppressing the returned link would NOT have been sufficient on its own — with
manage:Member the attacker rewrites members.email first, which the rules never
pin, and the ordinary reset mail lands in their inbox. The link is withheld from
non-Admin callers anyway as defence in depth; the drawer's copy-link fallback is
hidden when there is no link rather than offering to copy "".

Both guards mutation-tested: neutralizing each turns exactly its own tests red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rgo read

Verification pass found readCurrentCargoId treated a PRESENT but unreadable
shape as "no cargo" and therefore ALLOWED a non-Admin provision — a non-object
term entry, a non-string cargoId, or an empty one. That is the power-seat
guard's own bypass, and it contradicted the docstring's fail-closed promise.
Three outcomes now, explicitly: null for a genuinely absent cargo (allow), the
id for a usable one, "" for present-but-unreadable (refuse, since "" fails
isSafeDocId at the port too).

readPositionGrants extracted: getPositionGrants and claims-sync's getPosition
were the same isSafeDocId + read + isValidRole filter with different wrappers,
and they must not drift — both decide whether a cargo confers power, one before
minting claims and one before creating a login.

Mutation-tested: restoring the pre-fix null returns turns the malformed-shape
test red, and the paired "genuinely absent cargo is unseated" test keeps the
guard from passing for a rule that just refuses every delegate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… comment

Delta re-review found the retargeted "past term" pin was still over-determined:
on an unseated fixture a lone 2099 write is denied by assignedBySelf() (the
post-merge current term is {}), so the term conjunct was not the denier and the
comment claiming otherwise was a guardrail #6 claim with nothing behind it. Now
paired with a valid current-term entry so only positionsDelta().hasOnly() denies.

Added the missing Admin pin on the create arm. The term restriction binds every
principal, and the tempting future edit is `hasAnyRole(['Admin']) ||` in front of
it the first time a migration looks blocked — which silently reopens the ride-along
for anyone who can get an Admin to run a create. Nothing went red on that mutation
before; now exactly this test does.

Also de-escaped HTML entities that leaked into a firestore.rules comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…grants

Final security pass found the power-seat guard closed only one of the two
claims-mint sources. syncMemberClaims mints `roles` from trusted cargo grants
AND `perms` from roleIds + permissionOverrides via resolveMemberPerms — the
second path needs no cargo at all, and it is precisely what the Admin-only roles
panel writes, so "granted but not yet invited" is as ordinary a state as "seated
but not yet invited".

Exploit it closes: a manage:Member + create:MemberLogin holder finds an
un-invited member an Admin granted a custom role or an override to, rewrites
their email (the rules never pin it), provisions them, and the account they now
control is minted that member's entire granted perm set — which may itself
include update:BoardSeat, chaining into the seating lane.

hasDirectGrants fails closed on any shape that is not a clean empty, while
absent / null / [] stay ungranted: the rules' unchanged()/touched() gap admits an
explicit null and parseMember resolves it to [], so treating null as malformed
would make ordinary members un-invitable by a delegate.

Mutation-tested both ways: dropping the guard and softening its fail-closed
branches each turn exactly their own tests red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… one

Final verification found the last gap: syncMemberClaims reads
positions[currentTermKey()] at TRIGGER time, so a future-term power cargo was
invisible to the guard and would mint on the UTC-year rollover — a genuine Admin
in assignedBy, the cargo's grants honored, onto an account a delegate caused to
exist and whose mailbox they control.

Every client write lane is term-pinned (positionsDelta().hasOnly() on update,
keys().hasOnly() on create, both binding Admins), so the shape needs a console
edit, an admin-SDK write or a legacy migration — the same reachability this file
already fail-closes on for a malformed cargoId, and a console-authored next-term
board slate is the more plausible of the two.

readCargoIds now yields every cargo in the map, deduped, with "" for any
present-but-unreadable entry so the port's isSafeDocId still refuses it.

Mutation-tested: restricting the loop back to the current term turns exactly the
future-term test red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e delegate invite

Functions re-review, both Mediums:

M1 — readPositionGrants called .filter on snap.data().grants, which is a
TypeError when that field is a string or a map. firestore.rules short-circuits
every grants check on hasAnyRole(['Admin']) and never type-checks the field, so
a console edit or a migration can store one. onMemberWritten is retry:false and
the bad value persists, so the throw would kill claims sync for every member
seated on that cargo, permanently and silently. Returns null now — fail-closed
for both callers. The module had no test file; it has one.

M2 — member-profile-page's InviteAccess still assumed an actionLink. For a
delegate (who is withheld one) it opened a dialog over an empty code block with
a copy button that copied nothing, having already created the account. It now
sends the reset mail itself, exactly as the invite drawer does.

Also: validateProvisionInput uses isSafeDocId rather than a weaker hand-rolled
subset; createUser's catch is narrowed to auth/email-already-exists instead of
swallowing quota and invalid-email errors; the post-createUser recovery comment
now says a delegate's retry is refused; a duplicated comment fragment removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviews: 2c328f5 security-review,firestore-security-reviewer,firebase-functions-reviewer,code-review,simplify,react-best-practices,bundle-budget-watcher
…ever on themselves

/code-review (the real skill, run by the user) found a HIGH the subagent passes
missed, plus four stale-claim/dead-affordance issues.

HIGH — update:BoardSeat was a self-service grant of every built-in role but
Admin. The spec's own recommended pairing (update:Position + update:BoardSeat)
could write a Secretario/ProjectManager cargo onto its OWN member doc through the
positions-only lane — one write, no puppet — and claims-sync minted those roles
onto the author. resolveTrustedGrants now requires the Admin ROLE for a
self-assignment of any granting cargo, alongside the existing Admin-grant rule.
The seeded president is unaffected (both halves defer to the Admin role, which he
holds), and seating someone ELSE still works, which is the feature.

Also:
- firestore.rules' positions-only lane still claimed "CEL is NOT in that grant"
  and "the power-cargo restriction is NOT relaxed" — false since this branch
  lifted the new-side conjunct. boardSeatDelegate()'s comment still described the
  abandoned non-reflexive design, which would have led a reader to re-introduce
  the check that broke the president.
- The invite drawer offered "Enviar acceso" on a cargo whose provisioning beacon
  always refuses: it created the member, 403'd, and pointed at a row action that
  failed identically forever. It now decides before writing anything and says so.
- "Reenviar invitación/acceso" was shown to delegates, whom the adoption guard
  rejects on every click. Gated on isAdmin || !member.uid.
- hasDirectGrants failed OPEN on an array permissionOverrides (typeof [] is
  "object"), contradicting its own fail-closed docblock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviews: 58266d3 security-review,firestore-security-reviewer,firebase-functions-reviewer,code-review,simplify,react-best-practices,bundle-budget-watcher
@arkgast
arkgast merged commit 3d82645 into main Aug 28, 2026
2 checks passed
@arkgast
arkgast deleted the feat/board-seat-delegation branch August 28, 2026 10:10
arkgast added a commit that referenced this pull request Aug 29, 2026
positionsLockedForEditor took `Position | undefined` and asked only whether it
confers power, so a held cargo whose id resolves to NOTHING read as "no cargo" and
unlocked the picker. The rules have no such gap: currentCargoGrantsEmpty() get()s the
real doc and a missing one errors the rule, which denies. So the client offered a full
picker and an enabled Guardar for a write firestore.rules always rejects — the
render-then-die shape this module exists to prevent, one input short.

Reachable without a console edit. The catalog is parseDocs(positionDocSchema, …),
which DROPS any doc failing the schema, so the very corruption that makes a cargo's
power unknowable is what removes it from the array the client searches.

`heldCargo(positions, cargoId)` is now the one way to build that input — it keeps the
id alongside the lookup, so "seated on something unreadable" and "not seated" stop
being the same value. It also replaces the `positions.find(...)` each form had typed
for itself. `cargoSlotsForEditor` and `draftProvisionBlocked` lose their truthiness
tests on the id for the same reason: "" is an id that resolves to nothing, not "no
cargo", and beacon's readCargoIds manufactures exactly that value so its own guard
refuses it. The provision-gate test enshrined that divergence as deliberate; the
reasoning it gave ("the draft schema cannot produce one") makes the case unreachable,
not the fail-open answer correct.

MemberForm's four authority props are REQUIRED now. They were optional with `= false`
defaults, which are not safe in the same direction: `isSelfAssignment = false`
suppresses the mint-pending warning, `allowReplacePowerCargo = false` locks an Admin's
picker, and a call site that forgot either compiled clean.

The BLOCKING test named for the #224 flag conflation could not fail on flag
conflation — one assertion duplicated an earlier test verbatim and the other was
decided by the cargo alone. It now pins that the two flags DISAGREE for the same
principal, which is the property collapsing them destroys. cargoTakedownOnly gets the
truth table it never had.

ui: MultiSelect takes aria-describedby, like Combobox. Both forms disable the
comisiones picker on the same `locked` flag, and only the cargo picker was explaining
itself — a screen-reader user met a dead control with no way to tell a permission
ceiling from a broken widget.

Mutation-tested: dropping the unresolvable clause turns exactly the two new BLOCKING
rows red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
arkgast added a commit that referenced this pull request Aug 29, 2026
…irroring buildCan

Two defects in this branch's headline deliverable.

It asserted `client offers => rules allow` and nothing else, which catches a rules
TIGHTENING and structurally cannot catch a LOOSENING. Delete `&& category != 'CEL'`
from cargoAssignableByNonAdmin(), or widen boardSeatDelegate() from
hasPerm('update:BoardSeat') to canDo('update','BoardSeat') so manage:all satisfies it,
and all 429 lines stayed green — including the row named "the delegation is live",
which reads as a rules property and asserts only about the client. The converse cannot
be asserted wholesale (the client is deliberately stricter about comisiones, retired
and inactive cargos, and flagging that curation would make the test an obstacle to
it), but it can be for the three cargos the delegation is ABOUT, where the gap is not
curation but the boundary. Both loosenings now turn those rows red — verified by
making each mutation against firestore.rules and re-running the suite.

And `gatesFor` hand-re-implemented buildCan's claims -> flags mapping: the exact
mirror class this file exists to delete, of the exact flag whose widening caused the
#224 regression. It would have kept agreeing with itself while use-can.ts drifted. The
derivation is now `capabilityFlags()`, split out of use-can.ts (React, so this package
cannot load it) and spread back into buildCan — one function, both sides.

Also corrects a comment that justified the local category union by claiming CargoLike
widens it to `string`. It does not; assignable-cargo-core declares PositionCategory
and documents at length why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant