1
0
Fork 0

docs: record permission tier, panel gate, and field access gotchas

This commit is contained in:
Jason Fraley 2026-09-22 14:52:48 -04:00
parent 529a9573b2
commit 858cce4c40
3 changed files with 18 additions and 0 deletions

View file

@ -345,6 +345,9 @@ Username-based login (no email login). Users log in via Payload admin with `user
- **Return-URL flow**: the `LandingPage` login CTA is `LoginLink` (`src/components/frontend/auth/LoginLink.tsx` — a client component using `usePathname()`), which links to `/login?returnTo=<current path>`. The login page awaits `searchParams`, sanitizes `returnTo` via `safeReturnTo` (must start with `/`, must not start with `//` — open-redirect guard), and passes it to `LoginForm`. Already-authed users hitting `/login?returnTo=X` are redirected straight to `X`. The path is attached client-side because server components cannot reliably know the current path (see "Next.js 16 hard rules"). Full design story: `docs/case-studies/login-return-url.md`.
- Logout lives in `NavUser` (sidebar) → `/api/users/logout`.
- Tip: a corrupted `payload-token` cookie causes an infinite login loop (`Unexpected end of JSON input` on `/api/users/me`) — clear cookies/use incognito. Dev user `dev` / `Test123`.
- **Admin panel access is two separate gates, do not collapse them**: (1) panel ENTRY is the Users collection `access.admin` (`src/collections/users/Users.ts`) and uses `canAccessAdminPanel` (superuser OR any `admin:<slug>:manage`); (2) per-page sidebar visibility is `requireAdminPageAccess` (applied centrally in `payload.config.ts`), requiring BOTH `<slug>:read` AND `admin:<slug>:manage` per collection.
- **Permission tiers**: `system:admin-access` (registry label "Superuser Tier (user management, notification oversight, final approval states)") is the functional superuser tier: final approval states (`approvalStatusFieldAccess` in `src/utils/access-control/divisionAccess.ts`, wired to the `approvalStatus` field on Assets/Resources/Vehicles/Technologies), the Users admin page, and read-all notifications. Only the built-in `admin` role and superuser roles hold it. NEVER grant it to division roles; they get panel access through `admin:<slug>:manage`. The dev DB once had it hand-granted to Logistics/Intelligence Officer, which silently made officers approval-superusers.
- **Impersonation**: `/api/impersonation/start` (POST `{ userId }`, operator must be a DYNAMIC superuser: `roleDocs` → Roles collection with `isSuperuser: true`; the dev user's legacy `role: developer` does NOT pass and gets 403). Swaps `payload-token` for a JWT signed as the target (a fresh session `sid` is added to the target's `sessions` first, or `auth()` returns null) and stashes the original token in the `ptf-impersonation-original` cookie. Stop via `/api/impersonation/stop`.
## Database
@ -426,6 +429,10 @@ Whenever creating a new component or refactoring an existing component's styling
- Payload admin layout and importMap are auto-generated — do not edit by hand.
- `.npmrc` sets `legacy-peer-deps=true` for dependency resolution compatibility.
- `bun run db push` fails with `must be owner of table spatial_ref_sys` (a PostGIS table owned by the DB superuser) — drizzle-kit push does a full-schema diff and trips on it. Workaround: apply the needed `ALTER TABLE` directly (via node + `pg` reading `DATABASE_URI` from `.env`) or use Payload's migration runner (`bun run payload migrate`).
- **Payload 3 field-level access never rejects an update request**: when a field's `access` fn returns `false`, Payload silently deletes that field from the incoming data (`node_modules/payload/dist/fields/hooks/beforeValidate/promise.js`) and the save SUCCEEDS with the previous value. No 403, "Updated successfully." toast, other fields still save. It is enforcement without feedback; if you need a loud rejection or hidden dropdown options, that requires a collection `beforeChange` hook or a custom admin field component.
- **Admin-panel-visible access bugs that contradict passing API tests**: check the actual role permission rows in the DB first, then the field-strip semantics above. The dev DB once had `system:admin-access` hand-granted to division roles (tests build clean roles, so tests pass while the panel misbehaves). The 30s `loadUserPermissions` cache is per-user-id and safe, but remember it exists when editing role data.
- PATCHing a document that is open in someone's admin edit tab returns **423 Locked** (Payload document locks). Test with a different document or wait for the lock to clear.
- For replaying admin-panel requests from the terminal: `/api/access` returns the caller's resolved permission map (collection `read` results are either a bare boolean like `true` or `{ permission, where }`; handle both shapes), and an officer-grade JWT can be crafted exactly like the impersonation route does (add a session `sid` to the target user, then `jwtSign` with `getFieldsToSign`).
## Server Actions convention

View file

@ -60,6 +60,11 @@ Collections define `access` at field + document level using helpers from `src/ut
Admin group access: `developer` only for destructive operations, `admin` for read/write.
Permission tiers and panel gates:
- `system:admin-access` ("Superuser Tier" label) = functional superuser tier: final approval states (`approvalStatusFieldAccess`), Users admin page, read-all notifications. Only built-in `admin` + superuser roles hold it; NEVER grant to division roles.
- Admin panel ENTRY: Users collection `access.admin` uses `canAccessAdminPanel` (superuser OR any `admin:<slug>:manage`). Per-page sidebar visibility: `requireAdminPageAccess` (`<slug>:read` + `admin:<slug>:manage`). Two gates, do not collapse them.
- Payload 3 field-level `access` returning `false` on update silently drops the field (no 403, success toast); the document saves with the previous value. Loud rejection or hidden options require a `beforeChange` hook or custom admin field component.
## Hooks with side effects
- `Structures` `beforeChange`: emits `structure:resize`; blueprint "Staffing Defaults" group via `staffingFields.ts`

View file

@ -28,6 +28,12 @@ Lightweight role checks. Used for quick conditional rendering (e.g., `isRole("ad
### 3. `hasLogisticsQualification()` / `hasIntelligenceQualification()`
Queries `Profiles.progression.qualifications` for specific qualification strings (case-insensitive). Admin/developer always pass. Used to gate logistics-only and intelligence-only UI sections.
### Permission tiers and admin panel gates
- `system:admin-access` (registry label "Superuser Tier (user management, notification oversight, final approval states)") is the functional superuser tier: final approval states (`approvalStatusFieldAccess` in `divisionAccess.ts`), the Users admin page, read-all notifications. Held ONLY by the built-in `admin` role and superuser roles. NEVER grant it to division roles; a hand grant once made officers approval-superusers.
- Admin panel ENTRY is Users collection `access.admin` and uses `canAccessAdminPanel` (superuser OR any `admin:<slug>:manage`). Per-page sidebar visibility is `requireAdminPageAccess` (`<slug>:read` + `admin:<slug>:manage`). Two gates, do not collapse them.
- Payload 3 field-level access never rejects an update: a `false` return silently strips the field (`node_modules/payload/dist/fields/hooks/beforeValidate/promise.js`) and the save succeeds with the old value. No 403, success toast, other fields save.
- Access bugs visible in the admin panel but contradicted by passing API tests: inspect the real role permission rows in the DB first (hand-granted data has diverged from the registry before), then the field-strip semantics. Tests build clean roles, so they cannot catch stray grants.
## Event log system
### Emitter: `emitGameEvent(payload, { type, message, ... })`