# Per-role permission audit — 2026-05-06

Focused review of UI/server permission divergence on the new endpoints
shipped during the smart-archive / hard-delete / bulk-wizard /
external-EOI / webhook-replay work bundle. Skips items already covered
in `docs/audit-comprehensive-2026-05-06.md` (audit-log gating H6,
residential_partner sidebar nav).

The pattern hunted for: `<PermissionGate>` (or `usePermissions().can`)
on the UI side hides a control under permission **X**, while the
matching API route gates on permission **Y** (or doesn't gate at all,
or gates strictly — producing 403 toast spam for users who can see the
button but can't use it).

Scope: 8 routes + 5 components + the seed permission matrix. Hard cap
of 10 findings, ranked by impact. Critical/High/Medium/Low.

---

## CRITICAL

_None._ The four new hard-delete endpoints all gate on
`admin.permanently_delete_clients` on both layers (UI hides the button
via `<PermissionGate resource="admin" action="permanently_delete_clients">`
in `client-detail-header.tsx:162` and via `canHardDelete = can('admin',
'permanently_delete_clients')` in `client-list.tsx:53`; the four routes
all wrap with `withPermission('admin', 'permanently_delete_clients', …)`).
The webhook-replay route gates on `admin.manage_webhooks` — see H1 below
for the matching UI gap.

---

## HIGH

### H1. Webhook replay button has no UI permission gate (403 toast for non-admins)

- **UI:** `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
  — the Replay `<Button>` renders for any user who can load the page,
  with no `<PermissionGate>` wrapper and no `usePermissions().can('admin',
'manage_webhooks')` check.
- **Server:** `src/app/api/v1/admin/webhooks/[webhookId]/deliveries/[deliveryId]/redeliver/route.ts:15`
  — `withPermission('admin', 'manage_webhooks', …)`.

**Divergence:** A `sales_manager` / `sales_agent` / `viewer` who
somehow lands on `/admin/webhooks/{id}` (e.g. via a deep link from a
shared message) sees enabled Replay buttons. Clicking surfaces a
generic 403 toast — the user has no signal that the action is
restricted, just that "Replay failed".

**Fix:** wrap the Replay `<Button>` in
`<PermissionGate resource="admin" action="manage_webhooks">…</PermissionGate>`,
or skip rendering the entire "Replay" column when
`!can('admin', 'manage_webhooks')`. The page-level guard on
`/admin/webhooks` should prevent non-admins from reaching the route in
the first place, but defense-in-depth is cheap and the toast UX is
poor.

---

### H2. Bulk-archive bulk action exposed to roles without `clients.delete`

- **UI:** `src/components/clients/client-list.tsx:182-190` — the
  "Archive" entry in `bulkActions` is unconditionally rendered (only
  the "Permanently delete" entry checks `canHardDelete`).
- **Server:** `src/app/api/v1/clients/bulk/route.ts:40-57` — gates
  `archive` action on `clients.delete`. Also
  `src/app/api/v1/clients/bulk-archive-preflight/route.ts:30` —
  `withPermission('clients', 'delete', …)`.

**Divergence:** `sales_agent` (`clients.delete:false`,
seed-permissions.ts:246) and `viewer` (`clients.delete:false`,
seed-permissions.ts:323) both see the Archive bulk action. Selecting
clients and pressing it fires the `BulkArchiveWizard`, which calls
`bulk-archive-preflight` (returns 403) followed by `bulk` archive
(also 403). The wizard surfaces this as an opaque error.

**Fix:** mirror the `canHardDelete` pattern — compute
`const canBulkArchive = can('clients', 'delete');` near
`client-list.tsx:53` and conditionally include the Archive entry.

---

### H3. Bulk add_tag / remove_tag exposed to viewer (clients.edit:false)

- **UI:** `src/components/clients/client-list.tsx:165-181` — the "Add
  tag" / "Remove tag" bulk actions render with no permission check.
- **Server:** `src/app/api/v1/clients/bulk/route.ts:40-57` — both gate
  on `clients.edit`.

**Divergence:** A `viewer` can multi-select rows, click "Add tag" or
"Remove tag", pick a tag in the dialog, hit "Apply", and receive a 403. The standalone bulk tag dialog has no inline gating to prevent
this.

**Fix:** the bulk action menu entries should gate on
`can('clients', 'edit')`. (Sales agent and above pass; only `viewer`
and `residential_partner` see the bug.)

---

### H4. `client-merge-log.surviving_client_id` enforcement absent from per-row port check on bulk hard-delete

- **Server:** `src/lib/services/client-hard-delete.service.ts:269-272`

The bulk preflight loads **every** row in the port
(`db.select(...).from(clients).where(eq(clients.portId, args.portId))`)
into memory, then validates the requested `clientIds` against that map.
That's correct for tenant isolation — a foreign-port id can't appear in
the map — but the inner loop at lines 364-389 then re-fetches each
client by `(id, portId)` and **silently skips** rows where the second
fetch returns nothing (line 377: `if (!c) continue;`). If a client is
archived between preflight and execute by another operator, the bulk
delete reports `deletedCount` lower than the requested set with no
error — the operator has no way to tell which ids were skipped.

**Divergence (perm-adjacent):** the per-row gate is enforced for
tenancy but the failure mode masquerades as success. Combined with
the route's all-or-nothing `withPermission` at the top, a
`permanently_delete_clients`-bearing operator can quietly under-delete.

**Fix:** when `c` is null, push the id into a `skipped: string[]`
array and return it in the response so the UI can surface "3
deleted, 1 skipped (not archived / removed by another user)".

---

## MEDIUM

### M1. `external-eoi` upload allows any role with `documents.upload_signed` regardless of `interests.edit`

- **UI:** `src/components/interests/interest-detail-header.tsx:382-395`
  — `<PermissionGate resource="documents" action="upload_signed">`.
- **Server:** `src/app/api/v1/interests/[id]/external-eoi/route.ts:8`
  — `withPermission('documents', 'upload_signed', …)`.

**Divergence:** UI and server agree on the permission, but the seed
matrix has `documents.upload_signed:true` for `sales_agent` (line 264) AND any custom role with that flag — uploading an externally
signed EOI mutates the **interest** (it's the operative `signedDocument`
that flips the interest into a "signed" state inside
`uploadExternallySignedEoi`). The user only needs `documents.upload_signed`,
not `interests.edit`. A custom role with `documents.upload_signed:true`

- `interests.edit:false` can mutate the interest's effective state.

**Fix:** add a second gate inside the route handler:
`if (!ctx.isSuperAdmin && !ctx.permissions?.interests?.edit) throw new ForbiddenError(...)`.
Rationale: signing a doc against an interest is an interest-state
change, not just a document upload. Mirror the same check in
`<PermissionGate>` (use `<PermissionGate resource="interests" action="edit">`
nested inside the `documents.upload_signed` gate).

---

### M2. `change_stage` UI doesn't expose override checkbox in `InlineStagePicker` — server still accepts override

- **UI:** `src/components/interests/inline-stage-picker.tsx:52-58` —
  the inline picker (used in the detail header at
  `interest-detail-header.tsx:221`) sends only
  `{ pipelineStage, reason }` and never sets `override:true`. Users
  with `override_stage` get no UI affordance to actually use the
  permission from the inline picker; they have to open the modal
  `InterestStagePicker` (which does expose the checkbox at line 137).
  Worse, when a user picks a stage that isn't a legal forward
  transition, the inline picker just shows the toast from the server's
  `ConflictError` — instead of "you need override; toggle this box".
- **Server:** `src/app/api/v1/interests/[id]/stage/route.ts:14-22` —
  reads `body.override` and re-checks `interests.override_stage`
  permission.

**Divergence:** UI and permission map diverge in the affordance, not
the gate. End-result: the `override_stage` permission is partially
unreachable from the inline picker. Sales managers / agents can
override only via the modal picker.

**Fix:** when the inline picker sees a transition that isn't allowed
by `canTransitionStage(currentStage, newStage)`, check
`can('interests', 'override_stage')` and either auto-set
`override:true` (with a confirmation) or surface a "Use override"
secondary action. Keep the inline picker UX; just don't let the
override permission be silently inaccessible from the most-used
path.

---

### M3. `sales_agent` granted `interests.override_stage:true` — possible copy-paste from sales_manager

- **Seed:** `src/lib/db/seed-permissions.ts:253` — `SALES_AGENT_PERMISSIONS.interests.override_stage = true`.

This is identical to `SALES_MANAGER_PERMISSIONS.interests.override_stage = true`
at line 176. The same `sales_agent` block has `delete:false` for
clients/interests/yachts/companies/files/etc — all the other
"trust-elevated" flags are explicitly stripped from sales_agent. The
ability to bypass the pipeline-stage transition table is a meaningful
trust elevation: it lets an agent skip prerequisites (e.g. mark an
interest as `eoi_signed` without an actual signed doc) which has
downstream implications for the public berths feed (`Under Offer`
status), the recommender's tier ladder, and the EOI bundle.

**Divergence:** likely intent vs. permission map. Worth confirming
with a product owner; if intentional, leave a code comment. If
unintentional, flip to `false`.

**Fix:** product decision. If demoted, also update
`src/components/admin/roles/role-form.tsx → DEFAULT_PERMISSIONS`
(noted in the file header at seed-permissions.ts:9) so the UI
default for new roles matches.

---

### M4. `bulk-archive-preflight` returns dossier even when client is in another port (defense-in-depth)

- **Server:** `src/app/api/v1/clients/bulk-archive-preflight/route.ts:33-62`

The route loops through `ids` and calls `getClientArchiveDossier(id, ctx.portId)`
for each. If a `clientId` belongs to another port, `getClientArchiveDossier`
throws and the route catches it (line 52-61) and returns a fallback row
with `blockers: ['<error message>']`. This leaks **the existence of an
unknown client id** — an attacker enumerating UUIDs can distinguish
"client doesn't exist" from "client exists but you can't see it" by
parsing the blocker text. The bulk hard-delete route has the same
shape but returns `NotFoundError`.

**Divergence (perm-adjacent):** the preflight route doesn't enforce a
per-id port check before falling through to the dossier service, and
the catch block leaks the failure mode in the response.

**Fix:** in the catch block, replace the dossier error message with a
generic `'Could not load dossier'` blocker. The operator already
selected these ids so they know the count; they don't need the inner
error.

---

## LOW

### L1. `external-eoi` route doesn't enforce `interests.edit` defense-in-depth on the interest port

- **Server:** `src/app/api/v1/interests/[id]/external-eoi/route.ts:8-14`

The route receives `interestId` from the URL and passes it +
`ctx.portId` into `uploadExternallySignedEoi`. The service is
expected to enforce port isolation, but the route itself does no
upfront `(interestId, portId)` existence check before reading the
multipart body — meaning a cross-port id will fully process the
upload (read the file into memory) before the service rejects.

**Divergence:** not strictly a permission divergence; it's resource
waste from missing early port-ownership check. Low because the
service-level reject does close the security hole.

**Fix:** add a one-row `select` on `interests` matching `id` + `portId`
before parsing form data, throw `NotFoundError` on miss.

---

## Summary

- 0 critical
- 4 high (H1–H4)
- 4 medium (M1–M4)
- 1 low (L1)

Top recommendation: H1 (webhook-replay UI gate) is a
ten-line fix that closes a 403-toast UX bug. H2 + H3 (bulk-archive +
bulk-tag UI gates) are also trivial and remove the same class of bug
across the bulk actions menu. M3 (sales_agent override_stage) needs a
product decision, not code; flag it before shipping the audit.
