Files
pn-new-crm/docs/audit-permissions-2026-05-06.md
Matt a0e68eb060 docs: comprehensive audits + Documenso build plan + admin UX backlog
Six audit documents capture the 2026-05-06 review pass (comprehensive,
frontend, missing-features, permissions, reliability) along with the
Documenso integration audit + locked build plan that drove the bulk
of subsequent feature work.

Adds `docs/admin-ux-backlog.md` as a living tracker for the autonomous
push — every item marked DONE or REMAINING with file pointers and
scope estimates so future sessions can pick up where this one stopped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:57:53 +02:00

267 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (H1H4)
- 4 medium (M1M4)
- 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.