Authentication and roles
Credentials, sessions, command scope, and permission decisions
Authentication answers “who are you?” Authorization answers “what may you do?” ROUNDTABLE currently uses NextAuth credentials with email and password. Government roles are stored in Prisma and refreshed from the database on every request so an administrator's role change takes effect without waiting for a JWT to expire.
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 finds theUser.bcrypt.compare()checks the password hash.- Authentication success or failure creates an
AuditLog. - NextAuth creates a JWT session with user ID, role, command ID, and organization ID.
- The JWT callback re-reads 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].
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.
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.
Future identity work
CAC means Common Access Card. FlankSpeed is the Department of the Navy's identity and collaboration environment. Impact Level 5 (IL5) is a cloud security impact level for controlled unclassified information workloads. None is implemented by this credentials flow. The current schema has no certificate subject, federation provider, or CAC mapping; those are future backfills and must not be described as current capability.
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.