# security — RBAC + token-management + user-management audit **Date:** 2026-08-27 · From: [engineer-20260827-180000](engineer-20260827-180000.md), [principal-20260827-175448](principal-20260827-175448.md) **Scope:** the new RBAC / API-token / user-management surface on the public domain. Read-only audit (no edits). **Out of scope (per task):** DB-superuser-in-`.env` (tracked separately), analytics math. ## Method / evidence - Read `server/auth.ts`, `server/tokens.ts`, `server/db.ts`, `index.ts`, client `App.tsx`/services. - Full route inventory via grep (`app.get|post|put|delete|use`) — 15 API routes, all accounted for below. - Grepped for the removed `ADMIN_KEY`/`x-admin-key` gate → **no matches** (fully removed). No `dangerouslySetInnerHTML` in `client/src`. - Live DB (role `forge_app`, `31.133.115.50:5440/forge`): `current_user=forge_app`; `SELECT count(*) FROM app_users WHERE role='admin'` → **1**; the sole account `admin` is role `admin` (bootstrap migrated correctly). No destructive SQL run. ## Verified correct (no action) - **Route gating matches the spec matrix.** viewer: `/api/tickets`,`/api/tickets/:number`,`/api/stats`,`/api/config` (`requireAuth`). pm: `/api/analytics/overview|sla`,`/api/insights` (`requireRole('pm')`). lead: `GET/POST /api/users`,`DELETE /api/users/:username`. admin: `GET/POST /api/tokens`,`POST /api/tokens/:id/revoke`. `/api/sync` token-only. - **401 vs 403 correct** (`auth.ts:46-61`): unauth→401, under-privileged→403. - **Role cannot be spoofed** — read from the server-side PG session store (`req.session.user`, `auth.ts:14`), never from client input. - **Admin-creation guard present** (`index.ts:249`): non-admin creating `role==='admin'` → 403. - **`isRole` validation** (`db.ts:191`): arbitrary/invalid role string → 400 `invalid_role`. - **Self-delete guard present** (`index.ts:262`). - **Tokens:** admin-only; raw `fg_…` returned once (`tokens.ts:81-89`), only `sha256(secret)` persisted; `listTokens` selects id/token_id/label/dates/revoked — **never `token_hash`** (`tokens.ts:97-106`); revoke honored (`resolveToken` filters `revoked=false AND not expired`, `tokens.ts:62`); constant-time `timingSafeEqual` compare. - **Cookie hygiene:** httpOnly, sameSite=lax, secure-in-prod, `SESSION_SECRET` required in prod (`process.exit(1)`), login rate-limited (20/15min), bcrypt dummy-hash compare for unknown users (timing-uniform), bcrypt cost 12. - **No secret leak:** `/api/users`→id/username/role/dates only (no `password_hash`); `/api/me`→`{username,role}` only. - **Client gating is defense-in-depth only** (`App.tsx` `Navigate` redirects); the server is the true gate. --- ## Ranked findings ### HIGH-1 — A `lead` can delete `admin` accounts (no target-rank check) **Where:** `index.ts:260` (`DELETE /api/users/:username` gated only at `requireRole('lead')`) + `server/db.ts:227` (`deleteUser` = unconditional `DELETE FROM app_users WHERE username=$1`). **Exploit:** The create path forbids a non-admin from making an admin (`index.ts:249`), but the delete path has **no symmetric rank check** — a `lead` (or a compromised lead session) can delete any user, admins included. A lead can therefore remove every admin even though it can never create one. **Impact:** Privilege-boundary violation. Combined with "only an admin creates an admin," a lead that deletes all admins permanently strips the org of token-management / admin-create ability until a server reboot re-seeds `AUTH_USER`. **Fix:** Enforce actor-outranks-target in the delete path — refuse deleting a user whose role rank ≥ the actor's (a lead may not delete admins/leads), or raise `DELETE /api/users/:username` to admin-only. Hand to **engineer** (route + `deleteUser`). ### HIGH-2 — Last-admin lockout (no guard on deleting/removing the final admin) **Where:** `server/db.ts:227` `deleteUser`; no count check anywhere. There is **no demote endpoint** (confirmed — only `seedUser` ever writes `role`, `db.ts:179`), so deletion is the only removal path. **Exploit:** Live state is a **single** admin (`admin`). Deleting it (reachable today by any lead per HIGH-1, or by a second admin) leaves zero admins. `/api/tokens*` then 403s for everyone; no one can create a new admin. **Impact:** Availability/integrity loss of the admin tier. Recovery requires shell/redeploy access — `seedUser` re-inserts `AUTH_USER` as admin only when its row is absent (`db.ts:176-185`), and only that one account. **Fix:** Guard against removing the last remaining admin (`SELECT count(*) … role='admin'` before delete; refuse if target is the only admin). Same for any future demote. Hand to **engineer/dba**. ### HIGH-3 — Deleting a user does not invalidate their active session **Where:** `auth.ts:46-61` (`requireAuth`/`requireRole` read identity+role from `req.session.user` set once at login, `index.ts:47`); session `maxAge` 7 days (`auth.ts:40`); session rows persist in `user_sessions`. `deleteUser` (`db.ts:227`) removes only the `app_users` row. **Exploit:** An off-boarded/removed user keeps their cookie working at their cached role for up to 7 days. A deleted **lead** still passes `requireRole('lead')` and can keep deleting users (feeding HIGH-1) until session expiry. Role is never re-validated against the DB per request. **Impact:** On a public domain, account removal is not effective access revocation; no mechanism to force-logout a user. **Fix:** On delete (and any future role change), purge that user's sessions — e.g. `DELETE FROM user_sessions WHERE (sess->'user'->>'username') = $1` — or re-check user existence/role from `app_users` per request (short-cached). Hand to **engineer/dba**. ### MEDIUM-1 — No session-fixation protection on login **Where:** `index.ts:43-49` assigns `req.session.user = ok` on the pre-existing session id; no `req.session.regenerate()` at the privilege transition. **Exploit:** Classic session fixation — an attacker who can plant a session cookie (e.g. via a sibling/adjacent context) rides the same id after the victim authenticates and it becomes privileged. Mitigated but not eliminated by httpOnly + sameSite=lax. **Fix:** Regenerate the session id on successful login before writing `user`. Hand to **engineer**. ### MEDIUM-2 — Weak password policy on a public login **Where:** `index.ts:246` — minimum 6 chars, no complexity/denylist. Throttle is per-IP only (`loginLimiter` 20/15min, `index.ts:41`). **Exploit:** Created accounts can carry trivially guessable passwords; per-IP throttling doesn't stop distributed/slow guessing against a public domain. **Fix:** Raise minimum (~12 chars) and/or add a zxcvbn/denylist check; consider per-account lockout in addition to per-IP. Hand to **engineer**. ### LOW-1 — No HTTP security headers (helmet absent) **Where:** app has no `helmet`/CSP (grep empty); serves SPA + API on a public domain. **Impact:** No `X-Frame-Options`/`frame-ancestors` (clickjacking of the authenticated UI), no HSTS, no `X-Content-Type-Options`. Adjacent to the auth perimeter, not RBAC-specific. **Fix:** Add `helmet` with a same-origin CSP + HSTS-in-prod. Hand to **engineer/devops**. ### LOW-2 — Silent no-op on delete/revoke of a non-existent id **Where:** `db.ts:227` `deleteUser`, `tokens.ts:108` `revokeToken` — no affected-row check. `DELETE /api/users/:missing` and revoke of an unknown token id both return `{success:true}`. **Impact:** Cosmetic/idempotency only (no enumeration leak — always success). Consider returning 404 when 0 rows affected so the admin UI reflects reality. Hand to **engineer**. ### LOW-3b (informational) — CSRF on state-changing POSTs rests on `sameSite=lax` State-changing routes (`/login`, `/api/users`, `/api/tokens*`, `DELETE /api/users/:username`) carry no CSRF token; cross-site protection is provided by the `sameSite=lax` cookie (`auth.ts:38`). Defensible today. If any of these routes ever move to `sameSite=none` (e.g. cross-origin embedding), add an explicit CSRF token. No fix required now. ### LOW-3 (informational) — Leads self-propagate the user-management tier **Where:** `index.ts:248-249` blocks only `role==='admin'` for non-admins; a lead may create unlimited additional `lead`s. This is per the capability spec ("leadership tops out at lead"), so it is by design — but note it compounds HIGH-1/HIGH-3: a lead can mint a co-lead, and either can delete the sole admin. No fix required; revisit if leads should not be able to grant lead. ## Next engineer/dba — fix HIGH-1 (target-rank check on delete), HIGH-2 (last-admin guard), HIGH-3 (session purge on delete). engineer — MEDIUM-1 (session regenerate), MEDIUM-2 (password policy), LOW-1/2. Then re-audit the delete path.