An official website of the United States GovernmentUnclassified
Seal of the Department of the NavyDepartment of the NavyProject ROUNDTABLE Docs

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

  1. The user submits email and password to the NextAuth credentials route.
  2. authorize() trims/lowercases the email and evaluates the per-account and per-IP attempt windows; a tripped window audits authentication_rate_limited.
  3. 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.
  4. A wrong password increments failedLoginAttempts atomically and locks the account for 15 minutes on the fifth consecutive failure (account_locked); a locked attempt audits authentication_locked.
  5. Success clears the failure counter and lockout timestamp and audits authentication_success.
  6. NextAuth creates a JWT session with user ID, role, command ID, organization ID, rotation state, and session version.
  7. The JWT callback re-reads those authorization columns from Prisma on every request.
  8. 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].

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

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.

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/password renders CREDENTIAL_CHANGES_LOCKED_MESSAGE instead of the form, and POST /api/account/password refuses 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. mustChangePassword accounts 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 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.