Authentication and roles
Credentials, sessions, command scope, and permission decisions
Authentication answers “who are you?” Authorization answers “what may you do?” ROUNDTABLE authenticates with NextAuth credentials (email and password) hardened with a 14-character complexity policy, bcrypt hashing at a work factor of 12, and a five-failure account lockout. Government roles are stored in Prisma and refreshed from the database on every request, so an administrator's role change or revocation takes effect on the next request rather than at token expiry. Security and compliance maps each control to its enforcing file.
The Navy terms used here are organizational shorthand: a Systems Command (SYSCOM) is a major command such as NAVSEA; a Program Executive Office (PEO) acquires and fields systems; a point of contact (POC) is a person who receives or manages work. These terms describe seeded data, not a separate identity provider.
Sign-in sequence
- The user submits email and password to the NextAuth credentials route.
authorize()trims/lowercases the email and evaluates the per-account and per-IP attempt windows; a tripped window auditsauthentication_rate_limited.bcrypt.compare()runs on every path — including an unknown email (against a random hash) and a locked account — so timing and response shape reveal nothing about which accounts exist.- A wrong password increments
failedLoginAttemptsatomically and locks the account for 15 minutes on the fifth consecutive failure (account_locked); a locked attempt auditsauthentication_locked. - Success clears the failure counter and lockout timestamp and audits
authentication_success. - NextAuth creates a JWT session with user ID, role, command ID, organization ID, rotation state, and session version.
- The JWT callback re-reads those authorization columns from Prisma on every request.
requireGovSession()orrequireIndustrySession()gates the route.
Sessions use JWT strategy and a 15-minute max age. The sign-in page is /login; the API route is /api/auth/[...nextauth].
Session revocation
User.sessionVersion binds a token to the credential that minted it. Changing a password increments the column, so every session for that account — on every device — loses its authorization claims on its next request, and the change-password page signs the caller out immediately. A token whose account no longer exists is treated the same way, and getSession() reports a claim-stripped token as unauthenticated so it can never reach a handler with an undefined identity.
Role matrix
| Action | Industry | Readonly | POC | Command lead | Gov admin | System admin |
|---|---|---|---|---|---|---|
| Read public calls/announcements | Yes | Yes | Yes | Yes | Yes | Yes |
| Register organization | Public flow | No | No | No | No | No |
| Edit own organization profile | Own org | No | No | No | No | No |
| Create own submission | Own org | No | No | No | No | No |
| Read government ledger | No | Government scope | Government scope | Government scope | Enterprise | Enterprise |
| Add engagement/note | No | No | Route policy | Own command | Government writer | Yes |
| Publish announcement/call | No | No | No | Yes | Yes | Route policy |
| Manage users | No | No | No | Own-command rules | Admin rules | Full admin rules |
| Export datasets | No | No | No | No | Yes | Yes |
| Edit command fields | No | No | No | No | No hierarchy edits | Yes |
| Reparent commands | No | No | No | No | No | Yes |
The matrix is a guide; the exact route helper remains authoritative. Some government reads allow broader admin visibility than mutation routes.
Command scope
getDescendantCommandIds(commandId) performs a breadth-first traversal over Command.parentId and returns the starting command plus descendants. It has a visited set so malformed cycles cannot loop forever.
One admin surface is narrower than the admin tier itself: TRAFFIC_ANALYTICS_ROLES in src/app/gov/admin/_lib/roles.ts contains only GOV_SYSTEM_ADMIN, so /gov/admin/traffic redirects every other role — including GOV_ADMIN — to /gov/admin/users. Enterprise visibility is not the same authority as reading site-wide traffic.
getScopedCommandIds(session) returns "ALL" for GOV_ADMIN and GOV_SYSTEM_ADMIN. For every other role it returns an array containing only session.user.commandId, or an empty array when no assignment exists. This is deliberately not downward inheritance.
assertCommandInScope(session, commandId) allows "ALL" or an exact ID. Otherwise it writes authorization_failure and returns HTTP 403. The helper is used by per-command routes and protects against trusting a URL parameter merely because the user is signed in.
Worked denial example
Suppose lead.navsea@navy.mil is assigned to NAVSEA and tries to edit the seeded NSWC DD command (Naval Surface Warfare Center Dahlgren Division). The request is denied because GOV_COMMAND_LEAD is scoped to its own commandId; it does not inherit descendants. getScopedCommandIds() returns [NAVSEA_ID], not all child IDs, and assertCommandInScope() rejects NSWC DD_ID with 403 and an audit event.
By contrast, sysadmin@navy.mil has GOV_SYSTEM_ADMIN, so the same command is enterprise-visible and hierarchy-editable. admin@navy.mil has enterprise visibility but cannot use the parent reassignment route.
Seeded command tree
This is a readable subset; COMMAND_PARENTS in prisma/seed.ts is the complete parent map. Manual detachments survive reseeding for existing commands.
Route gates
src/app/api/gov/_lib/helpers.ts defines requireAdminTier, requireSystemAdmin, and government-writer checks. A gate failure produces a generic 401 or 403 response rather than revealing whether a hidden record exists. Industry routes use requireIndustrySession; government routes use requireGovSession before applying narrower role and command checks.
Password rotation gate
Admin-created accounts always require a password change before the rest of the application becomes usable. While mustChangePassword is true, the middleware allows only /account/password, /api/account/password, /api/auth, and /login (plus their nested paths). Page requests outside that allowlist redirect to /account/password; /api/* requests return 403 with Password change required. Page-level layouts also call assertRotationComplete as a second guard. The password endpoint is documented in the account API reference.
Credential lock overrides the gate
credentialChangesLocked() in src/lib/demo-mode.ts returns true unless DEMO_LOCK_CREDENTIALS is exactly the string false — unset means locked. While it is locked:
/account/passwordrendersCREDENTIAL_CHANGES_LOCKED_MESSAGEinstead of the form, andPOST /api/account/passwordrefuses the change.- The rotation gate above is skipped entirely, because a user who cannot change a password must not be trapped behind a page demanding that they do.
mustChangePasswordaccounts land on their normal page.
Any environment with real users must therefore set DEMO_LOCK_CREDENTIALS=false; see Environment variables.
Identity integration model
Authentication is isolated behind a NextAuth provider seam, and authorization is independent of it: roles, command scope, rotation state, and audit all key off the User record rather than the credential mechanism. Certificate-based authentication such as CAC/PIV (Common Access Card / Personal Identity Verification), or federation through the Department of the Navy's FlankSpeed environment, therefore plugs in at the provider layer behind the same session contract, leaving the role model, route gates, and audit inventory unchanged.
Session troubleshooting
If a browser reaches /login but returns to the same page, inspect the browser's session cookie, NEXTAUTH_URL, and NEXTAUTH_SECRET. If the app reports a valid login but the wrong dashboard appears, inspect the User.role and organizationId/commandId in PostgreSQL; the JWT callback refreshes those values from the database.
An authorization change can therefore be tested without waiting fifteen minutes: update the user row through an approved admin route, make a new request, and observe the refreshed session attributes. A transient database lookup failure retains existing token attributes and logs an error; this avoids turning every temporary database blip into a session-resolution crash.
Authorization review checklist
- Is the route using
getSessionthrough the appropriate audience helper? - Is the role checked server-side rather than in a page component?
- Does a URL command ID pass
assertCommandInScopewhere needed? - Does an industry route use the session organization ID?
- Does a denial return 401 or 403 without leaking record details?
- Is an
authorization_failureevent written for scope denial? - Are system-admin-only hierarchy operations separate from enterprise visibility?
These checks are more reliable than copying a role name into a new route and assuming it implies the same scope.
