Project ROUNDTABLE Docs

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

  1. The user submits email and password to the NextAuth credentials route.
  2. authorize() trims/lowercases the email and finds the User.
  3. bcrypt.compare() checks the password hash.
  4. Authentication success or failure creates an AuditLog.
  5. NextAuth creates a JWT session with user ID, role, command ID, and organization ID.
  6. The JWT callback re-reads authorization columns from Prisma on every request.
  7. requireGovSession() or requireIndustrySession() 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

ActionIndustryReadonlyPOCCommand leadGov adminSystem admin
Read public calls/announcementsYesYesYesYesYesYes
Register organizationPublic flowNoNoNoNoNo
Edit own organization profileOwn orgNoNoNoNoNo
Create own submissionOwn orgNoNoNoNoNo
Read government ledgerNoGovernment scopeGovernment scopeGovernment scopeEnterpriseEnterprise
Add engagement/noteNoNoRoute policyOwn commandGovernment writerYes
Publish announcement/callNoNoNoYesYesRoute policy
Manage usersNoNoNoOwn-command rulesAdmin rulesFull admin rules
Export datasetsNoNoNoNoYesYes
Edit command fieldsNoNoNoNoNo hierarchy editsYes
Reparent commandsNoNoNoNoNoYes

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 getSession through the appropriate audience helper?
  • Is the role checked server-side rather than in a page component?
  • Does a URL command ID pass assertCommandInScope where needed?
  • Does an industry route use the session organization ID?
  • Does a denial return 401 or 403 without leaking record details?
  • Is an authorization_failure event 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.