Skip to content

feat(members): per-member permission overrides - #262

Merged
kipavy merged 21 commits into
devfrom
feat/per-member-permission-overrides-client
Sep 12, 2026
Merged

kipavy merged 21 commits into
devfrom
feat/per-member-permission-overrides-client

Conversation

@kipavy

@kipavy kipavy commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Client half of per-member permission overrides. Each member gains two bitmasks, allow and deny, layered over their role union, resolving as effective = (roleUnion | allow) & ~deny with deny applied last — identical to the server.

Server side shipped as VoltiusApp/server#19, merged and deployed (migration 042 live). This client half is inert against an older server, and now hides the section entirely in that case rather than offering controls that 404.

What's here

  • effectivePermissions applies the overlay — the single implementation; resolveCan's team fallback and teamDataManager pass the Team itself so a team-level deny is not silently dropped on the cold-start path.
  • The keychain role mirror reuses effectivePermissions, so the Rust vault-write gate agrees with the server instead of carrying a second copy of the formula.
  • A tri-state row per permission (deny / inherit / allow) in the member detail panel, showing what each bit is inherited from.
  • All four server guardrails mirrored client-side with the reason surfaced, so a locked row explains itself.
  • A confirmation when a change revokes vault-key access, which then kicks the rotation pass.
  • Roster marker for members carrying overrides, and an audit-log entry for member.permissions_changed.

Deliberate deviations from the spec

  • One confirmation, not two buttons. The spec described "Deny only / Deny and rotate". The shipped server queues the rotation itself inside the PUT transaction whenever the vault-key gate is crossed, so "Deny only" would have promised something that cannot happen. Consequences worth knowing: denying COPY_SECRETS alone never rotates, and clearing an allow grant can. The dialog fires on gate-crossed, not on "the bit was effective".
  • The read-only reason renders as a visible line, not a hover title. A disabled control fires no reliable hover, and this app ships on mobile.

Verified live

Against an isolated stack running the exact merged server image:

  • 16 permission rows render with their inherited sources ("From editor · allowed" / "No role grants this · denied").
  • Denying View secrets while the role still grants Connect writes cleanly with no dialog — the gate is not crossed.
  • Also denying Connect raises the dialog; confirming writes allow=0 deny=5, and the server queues exactly one rotation request. The client kicks rotation-status immediately after the write commits.

Not verified live: the roster marker, and deny-persistence across an app reload — the test fixture's second member was inserted directly into the database rather than joined through the real invite flow, so the client lost its vault record on reload. Server-side persistence is confirmed in the DB. Both are covered by unit tests.

Behaviour changes inherited from the server half

  • Re-anchoring the resolvers closes a hole where a removed member's orphaned role rows still produced a full permission mask. Anyone exercising permissions that way loses them.
  • A removed-and-re-invited member now starts from the role their invitation grants.

Known gap, not addressed here

assign_member_role has none of the four guardrails and can assign the builtin owner role, including to self — so the escalation guardrail remains bypassable by that route. Pre-existing and out of scope; flagged so no reader assumes the threat model holds end to end.

Follow-ups

  1. at() falls back to empty masks when the member is absent from the store — safe today because the panel has one caller that always sources the member from it, but it should bail out rather than write 0/0.
  2. Extract the guardrail predicate out of MemberDetailPanel.tsx into services/permissions.ts, returning a reason enum.

Tests: src/services 1230/1232 (2 pre-existing deepLink failures inherited from the branch point, already fixed on dev by #260), src/stores 464, src/components/members 124, src/hooks 158, src/components/logs 6, src/i18n 9. tsc --noEmit clean.

The unchanged-list comparison ignored permission_allow/permission_deny, so an
override change with no other team change was discarded and cacheVaultRoles
mirrored stale bits into the keychain the Rust vault-write gate reads.
…red bit clearable

Hide the section entirely when the server serves neither mask, so an older
server does not render 16 live rows that 404 on click. Recompute undo and redo
from current store state instead of replaying a whole-mask snapshot, so the
full-replace PUT no longer erases a concurrent admin's change. Render
CREATE_CUSTOM_ROLES when either mask carries it, so an offending bit always has
a row that can clear it.
… member

at() defaulted to 0/0 when the member was missing from membersByTeam,
silently discarding real permission bits on undo/redo.
…ermissions.ts

minRolePosition + the guardrail chain move beside crossesVaultKeyGate, the
file that already carries the "stay in sync with server/src/permissions.rs"
contract. Returns the reason enum, not a translated string; READONLY_REASON_KEYS
moves to module scope so it isn't rebuilt every render. Behaviour-preserving.
@kipavy
kipavy merged commit 0947eb9 into dev Sep 12, 2026
4 checks passed
@kipavy
kipavy deleted the feat/per-member-permission-overrides-client branch September 12, 2026 10:15
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