Skip to content

activeProfile in the login flow

Date: 2026-04-27 Status: Proposed


1. Goal

Disambiguate session context for users linked to multiple person-entity rows in the same tenant (Teacher + Referent, Staff + Referent). Today, a multi-profile user's JWT carries roles: ['teacher', 'referent'] and downstream code that branches on profile membership (e.g. studentsForAccessContext) hits whichever branch evaluates first — silently choosing one profile's record-level filter and leaking the other's privileges. The frontend has no signal for which dashboard to render.

This spec adds an explicit per-session activeProfile chosen at login (or switched mid-session via a new endpoint), narrows the JWT's roles array to the active RBAC role set for the session (dropping non-active person-profile keys), and threads that through the existing permission-compilation path. Record-level helpers, guards, and the FieldFilterInterceptor remain unchanged — they consume roles as before, with the contents simply narrowed.


2. Non-goals

  • The Teacher + Staff prohibition. A separate validation owns enforcing that a User can hold at most one of {Teacher, Staff}. This design assumes the constraint and consumes its result.
  • Cross-tenant identity. Each tenant's profile choice is independent; switching tenants is a separate flow (existing tenant-selection step).
  • Identity unification across denormalized rows. A user who is both a Teacher and a Referent has two separately-edited records. Deduplication is out of scope.
  • Frontend implementation of the chooser UI. This spec ships the API contract; the frontend designs its own UX.
  • Forced re-authentication on profile gain/loss. A user who gains a new profile mid-session keeps the current one; they must explicitly switch. A user whose activeProfile row is deleted mid-session fails next refresh and re-logs in.

3. Decisions

# Decision Rationale
D1 New JWT field activeProfile: PersonProfileKey (singular, non-null on every authenticated session). Every User has at least one linked person-entity row by tenant invariant; "no active profile" is not a real state.
D2 The JWT roles array is narrowed at issue time by profile class: employee profiles (teacher, staff) pass through their full UserRole set; non-RBAC profiles (referent, student) get roles = [activeProfile] — just the profile key as a session sentinel, no RBAC roles. Conceptual model from clarification: roles are RBAC capabilities (admin, principal, hr, …) only ever granted to employees in practice. Referents/students hold no RBAC roles, so their compiled permission set is empty by construction. Keeping activeProfile in the array preserves the existing ctx.roles.includes('referent') pattern in record-level helpers — no helper change needed.
D3 Constants: PERSON_PROFILE_KEYS = ['teacher', 'staff', 'referent', 'student'] (closed enum / type); EMPLOYEE_PROFILES = ['teacher', 'staff'] (the subset that passes roles through). Single source of truth for both the type and the runtime narrowing rule.
D4 Login becomes a 3-step state machine, extending the existing 2-step tenant-selection. Mirrors the existing pattern symmetrically (select-tenantselect-profile); single-link users keep the 1-step path.
D5 A new authenticated POST /auth/switch-profile endpoint re-issues both tokens with the new activeProfile. The frontend needs a "switch role" UI without forcing logout; rotating both tokens preserves existing refresh-token rotation policy.
D6 PermissionsService.getUserPermissions accepts a third argument activeRoleKeys: string[] and filters UserRole rows accordingly. The JWT's roles becomes the source of truth for active session; DB stays the source of truth for available roles. Compilation respects the active subset, eliminating cross-profile privilege bleed.
D7 Refresh preserves activeProfile by reading it from the RefreshToken row (a new column on that table). Refresh tokens are opaque hashes stored in DB, not JWTs — the row is the only place per-token state lives. Refreshing should never change the active profile silently; only login or switch can.
D8 Idempotent switch to the current activeProfile returns the current session unchanged. Avoids token rotation thrash from frontend race conditions; semantically a no-op.
D9 Single new error code ACTIVE_PROFILE_NOT_AVAILABLE covers all "user picked a profile they don't have" paths (selection, switch, refresh). One concept, three call sites; HTTP status (400 vs 401) distinguishes context.

4. Architecture overview

4.1 Token shape

The access token is a JWT; its payload gains activeProfile:

type JwtPayload = {
  sub: string;          // userId
  tenantId: string;
  roles: string[];      // narrowed: [activeProfile, ...nonPersonProfileRoles]
  activeProfile: PersonProfileKey;
  isPlatformAdmin: boolean;
  // ... existing fields (iat, exp, etc.)
};

request.user (via JwtStrategy) gains activeProfile. AuthenticatedUser and AuthenticatedRequest types extend accordingly.

The refresh token is opaque random bytes; the server stores a SHA-256 hash plus metadata in the RefreshToken table. It has no JWT payload. To preserve the active profile across refreshes, the RefreshToken model gains an activeProfile column (non-null going forward — see migration note in §5.2). On refresh, the value is read from the stored row and re-applied to the new access token's roles narrowing.

4.2 Login state machine

POST /auth/login
  ├─ 0 matches    → 401 INVALID_CREDENTIALS                   (existing)
  ├─ 1 match  + 1 profile → 200 AuthUserDto (cookies set)     (existing path)
  ├─ 1 match  + 2 profiles → 200 ProfileSelectionResponseDto  (NEW)
  └─ 2+ matches → 200 TenantSelectionResponseDto              (existing)

POST /auth/login/select-tenant {selectionToken, tenantId}
  ├─ tenant + 1 profile → 200 AuthUserDto                     (existing)
  └─ tenant + 2 profiles → 200 ProfileSelectionResponseDto    (NEW)

POST /auth/login/select-profile {selectionToken, activeProfile}
  ├─ valid → 200 AuthUserDto                                  (NEW)
  └─ invalid → 400 ACTIVE_PROFILE_NOT_AVAILABLE | token errors (NEW)

Each step returns either a terminal session (AuthUserDto) or a selection prompt with a fresh 60-second selection token. The frontend treats the response shape as the next state.

4.3 Switch endpoint

POST /auth/switch-profile {activeProfile}        (JwtAuthGuard required)
  ├─ requestedProfile == currentActiveProfile → 200 AuthUserDto (idempotent no-op)
  ├─ valid switch → 200 AuthUserDto              (cookies rotated)
  └─ invalid → 400 ACTIVE_PROFILE_NOT_AVAILABLE

Validates the user has a person-entity row of the requested type, revokes the current refresh token (existing rotation policy), issues new access + refresh tokens with the new activeProfile and correspondingly narrowed roles.

4.4 Permission compilation

PermissionsService.getUserPermissions(userId, tenantId, activeRoleKeys):

  • Queries UserRole rows for the user as today.
  • Filters by role.key IN activeRoleKeys before compiling.
  • Compiles scope and action grants only for the active subset.

ensurePermissionsLoaded reads request.user.roles and passes it through. The four guards (ScopeGuard, ActionGuard, FieldWriteGuard, RolesGuard) and FieldFilterInterceptor are untouched — they consume request.permissions (compiled from the narrower input) and request.user.roles (already narrowed) as before.

4.5 Record-level access — no change

studentsForAccessContext, students.service.ts:442 (updateForAccessContext), and referents.service.ts:296 continue to consult ctx.roles.includes(...) exactly as today. Two facts make this work:

  1. The narrowing rule guarantees activeProfile is always present in the roles array (employees inherit it via the auto-materialized UserRole row; referents/students get it as a one-element sentinel — see D2).
  2. RBAC roles (admin, principal, …) are only ever granted to employees, so a referent session's roles = ['referent'] carries no privilege bleed.

roles.includes('referent') therefore evaluates to true only when the user is actively logged in as referent. No code change to these helpers.


5. Components

5.1 New files

  • src/common/constants/person-profiles.tsPERSON_PROFILE_KEYS const + PersonProfileKey type + EMPLOYEE_PROFILES subset + PERSON_PROFILE_TO_ENTITY map.
  • src/auth/dto/profile-selection-response.dto.ts — mirrors TenantSelectionResponseDto.
  • src/auth/dto/select-profile.dto.ts{ selectionToken, activeProfile }.
  • src/auth/dto/switch-profile.dto.ts{ activeProfile }.

5.2 Modified files

  • src/auth/auth.controller.ts — adds POST /auth/login/select-profile and POST /auth/switch-profile. Login response type widens to a 3-way union; select-tenant response widens to a 2-way union (AuthUserDto | ProfileSelectionResponseDto).
  • src/auth/auth.service.ts:
  • authenticateWithCredentials — post-1-match branch calls resolveProfileSelection and either auto-assigns or returns a profile-selection result.
  • completeTenantSelection — same post-tenant branching.
  • completeProfileSelection(selectionToken, activeProfile) — new method.
  • switchActiveProfile(userId, tenantId, currentActiveProfile, requestedProfile, …) — new method.
  • login() — internal signature gains activeProfile; calls narrowRolesForActiveProfile before token issuance.
  • refresh() — reads activeProfile from refresh-token payload, propagates into new access token, validates against fresh getActiveRoleKeys.
  • Helpers: resolveProfileSelection(userId, tenantId): Promise<{ profiles: PersonProfileKey[] }>; narrowRolesForActiveProfile(roles, activeProfile): string[].
  • src/auth/auth.swagger.ts — adds ApiSelectProfile, ApiSwitchProfile; updates ApiLogin and ApiSelectTenant to document the widened union response.
  • src/auth/dto/auth-response.dto.tsUserProfileDto gains activeProfile.
  • src/auth/interfaces/login-result.interface.ts — tagged union grows a third variant 'profile-selection' carrying a ProfileSelectionResponseDto.
  • src/auth/interfaces/jwt-payload.interface.ts — gains activeProfile: PersonProfileKey.
  • src/auth/interfaces/authenticated-user.interface.ts — gains activeProfile: PersonProfileKey.
  • src/auth/strategies/jwt.strategy.ts — extracts activeProfile from token, attaches to request.user.
  • src/permissions/permissions.service.tsgetUserPermissions(userId, tenantId, activeRoleKeys); same change for getUserPermissionsDto. Internally, compilePermissions (or fetchUserRoles) filters UserRole rows by role.key IN activeRoleKeys before scope/action compilation.
  • src/permissions/utils/ensure-permissions-loaded.ts — passes request.user.roles as the third argument.
  • src/common/constants/error-codes.ts + error-examples.ts — adds ACTIVE_PROFILE_NOT_AVAILABLE.
  • Selection-token generator/verifier in auth.service.ts — adds a parallel pair generateProfileSelectionToken(userId, tenantId, availableProfiles) / verifyProfileSelectionToken(token) with a distinct subject constant (PROFILE_SELECTION_TOKEN_SUBJECT = 'profile-selection') signed by the same JwtService with the existing 60-second TTL. The existing tenant-selection token verifier rejects the new subject and vice versa, preventing mix-up.
  • prisma/schema.prismaRefreshToken model gains activeProfile String? (see migration note below).
  • prisma/migrations/<timestamp>_add_active_profile_to_refresh_token/migration.sqlALTER TABLE refresh_tokens ADD COLUMN active_profile TEXT NULL.

Migration note: existing refresh tokens issued before this change have no activeProfile. The column is nullable to avoid breaking pre-existing rows. The refresh() flow must reject any row with activeProfile = NULL with ACTIVE_PROFILE_NOT_AVAILABLE (401), forcing affected users to re-login. New tokens (issued post-deploy by login, select-tenant, select-profile, switch-profile, and refresh itself) always populate the column. After a grace window, the column can be tightened to NOT NULL in a follow-up migration once all in-flight tokens have rotated. Per docs/12-migrations.md, audit the generated SQL before committing.

5.3 Unmodified

  • RecordAccessContext interface and @AccessContext() decorator — roles already on the interface; the contents are simply narrower.
  • studentsForAccessContext, students.service.ts:442, referents.service.ts:296 — see §4.5.
  • All four guards (JwtAuthGuard, ScopeGuard, ActionGuard, FieldWriteGuard, RolesGuard) and FieldFilterInterceptor — no signature changes.

6. Data flow

6.1 Single-tenant, single-profile login (today's path, untouched)

POST /auth/login {email, password}
  → validateCredentials → 1 match
  → resolveProfileSelection(userId, tenantId) → ['teacher']
  → narrowRolesForActiveProfile(['teacher','admin'], 'teacher') → ['teacher','admin']    (employee profile → passthrough)
  → login(userId, tenantId, narrowedRoles, 'teacher', …)
  → 200 AuthUserDto, cookies set

6.2 Multi-profile login

POST /auth/login {email, password}
  → 1 tenant match
  → resolveProfileSelection → ['teacher', 'referent']
  → 200 ProfileSelectionResponseDto { requiresProfileSelection: true,
                                      profiles: ['teacher', 'referent'],
                                      selectionToken }

POST /auth/login/select-profile {selectionToken, activeProfile: 'referent'}
  → verifySelectionToken → { userId, tenantId, availableProfiles }
  → assert 'referent' ∈ availableProfiles  (else 400 ACTIVE_PROFILE_NOT_AVAILABLE)
  → permissionsService.getActiveRoleKeys → ['teacher','admin']    (full DB grants)
  → narrowRolesForActiveProfile(['teacher','admin'], 'referent') → ['referent']    (non-RBAC profile → sentinel only)
  → login(…, 'referent', …)
  → 200 AuthUserDto, cookies set

6.3 Three-step flow (multi-tenant + multi-profile)

POST /auth/login → TenantSelectionResponseDto  (existing)
POST /auth/login/select-tenant → ProfileSelectionResponseDto  (NEW branch)
POST /auth/login/select-profile → AuthUserDto

6.4 Switch profile

POST /auth/switch-profile {activeProfile: 'teacher'}
  → JwtAuthGuard validates current access token
  → req.user.activeProfile === 'teacher'?  → 200 (idempotent, no rotation)
  → switchActiveProfile(userId, tenantId, 'referent', 'teacher', …)
    → validate Teacher row exists for this user in this tenant
    → revoke current refresh token (DB)
    → getActiveRoleKeys → fresh list
    → narrowRolesForActiveProfile → ['teacher','admin']
    → issue new access + refresh, cookies set
  → 200 AuthUserDto

6.5 Refresh

POST /auth/refresh (refresh_token cookie)
  → look up RefreshToken row by tokenHash (existing flow)
  → row.activeProfile  (read column; may be NULL on legacy rows)
  → if row.activeProfile is NULL → 401 ACTIVE_PROFILE_NOT_AVAILABLE   (legacy token, force re-login)
  → getActiveRoleKeys(userId, tenantId) → fresh list
  → if row.activeProfile ∉ fresh list → 401 ACTIVE_PROFILE_NOT_AVAILABLE   (profile deleted mid-session)
  → narrowRolesForActiveProfile(freshRoles, row.activeProfile) → narrowed
  → issue new access + refresh, store row.activeProfile on the new RefreshToken row
  → 200 AuthUserDto

6.6 Per-request (referent session — multi-profile user logged in as referent)

HTTP request → JwtAuthGuard
  → request.user = { userId, tenantId, roles: ['referent'],
                     activeProfile: 'referent', isPlatformAdmin }
  → ScopeGuard → ensurePermissionsLoaded
    → getUserPermissions(userId, tenantId, ['referent'])
    → DB: filter UserRole rows by role.key IN ['referent']
    → request.permissions = compiled set (empty in practice — no UserRole rows match
                                           since referents aren't granted RBAC roles)
  → ActionGuard, FieldWriteGuard, FieldFilterInterceptor consume request.permissions
  → Service / queries → AccessContext { roles: ['referent'], … }
    → studentsForAccessContext: ctx.roles.includes('referent') → linked-children branch
      ctx.roles.includes('teacher') → false → teacher branch correctly skipped

For a teacher session by the same user, roles = ['teacher','admin'] (full passthrough), permission compilation hits the DB-granted rows, and roles.includes('teacher') selects the teacher branch.

6.7 Edge cases

Scenario Behavior
User gains a new profile mid-session Session continues with current activeProfile; new profile becomes available on next refresh's getActiveRoleKeys call but activeProfile doesn't change. User must explicitly switch.
User's activeProfile row deleted mid-session Next refresh detects via getActiveRoleKeys; throws 401 ACTIVE_PROFILE_NOT_AVAILABLE; frontend forces re-login.
Idempotent switch (requestedProfile === currentActiveProfile) Returns 200 with current session; no token rotation.
User has 2 profiles but selection token contains stale availableProfiles Selection token is 60-second TTL; the window is small. If a profile gets deleted in that window, the post-selection validation against the user's actual rows will fail with 400.

7. Error handling

Code Status Trigger
ACTIVE_PROFILE_NOT_AVAILABLE (NEW) 400 select-profile / switch-profile with a profile the user doesn't have
ACTIVE_PROFILE_NOT_AVAILABLE (NEW) 401 Refresh detects the user's activeProfile row was deleted, OR the refresh-token row predates this change (legacy activeProfile = NULL)
Existing token errors 401 Selection token expired/tampered (reuses tenant-selection error paths)

ACTIVE_PROFILE_NOT_AVAILABLE carries optional data: { requested, available } for server-side debug logs but the API response keeps it generic to avoid leaking profile-presence across an unauthenticated boundary.

No new error parameters needed in ErrorCode typing (the existing ErrorCodeParams shape covers the optional data field).


8. Testing

8.1 Unit — auth.service.spec.ts (extend)

  • authenticateWithCredentials: 1 tenant + 1 profile → 'authenticated'; 1 tenant + 2 profiles → 'profile-selection'; 2+ tenants → 'tenant-selection' (existing).
  • completeTenantSelection: 1 profile in chosen tenant → 'authenticated'; 2 profiles → 'profile-selection'.
  • completeProfileSelection: valid token + valid profile → 'authenticated' with narrowed roles; invalid activeProfile → throws ACTIVE_PROFILE_NOT_AVAILABLE; expired/tampered token → existing token errors.
  • switchActiveProfile: same-as-current → idempotent 200; valid switch → new tokens, refresh rotated, narrowed roles match the new profile; profile not available → throws ACTIVE_PROFILE_NOT_AVAILABLE.
  • refresh: reads activeProfile from the stored RefreshToken row; the new refresh-token row issued in the rotation transaction also has activeProfile populated; missing-from-fresh-roles → throws ACTIVE_PROFILE_NOT_AVAILABLE (401); legacy row with activeProfile = NULL → throws ACTIVE_PROFILE_NOT_AVAILABLE (401).
  • narrowRolesForActiveProfile (pure helper):
  • activeProfileEMPLOYEE_PROFILES (teacher, staff) → returns input roles, defensively prepending activeProfile if missing (so the profile sentinel is always present regardless of whether the auto-materialized UserRole row exists).
  • activeProfileEMPLOYEE_PROFILES (referent, student) → returns [activeProfile], dropping every other role regardless of input.
  • All four PersonProfileKey values produce the expected output for the same starting role set ['teacher','admin']. Idempotent on already-narrowed input.

8.2 Unit — auth.controller.spec.ts (extend)

  • POST /auth/login returns one of three response shapes per LoginResult.type. Cookies set only on 'authenticated'.
  • POST /auth/login/select-profile: happy path, invalid token, invalid profile, expired token.
  • POST /auth/switch-profile: happy path, idempotent, 401 when no JWT, 400 when profile unavailable.

8.3 Unit — permissions.service.spec.ts (extend)

  • getUserPermissions(userId, tenantId, activeRoleKeys): filters DB UserRole rows by role.key IN activeRoleKeys; user with DB rows for ['teacher','referent','admin'] who passes ['referent','admin'] gets only referent + admin compiled grants.
  • Empty activeRoleKeys → empty compiled permissions.
  • getActiveRoleKeys is unchanged.

8.4 Unit — students.queries.spec.ts (extend)

  • studentsForAccessContext: a context with roles: ['referent'] (multi-profile user logged in as referent) hits the referent branch — "linked children only". Same context with roles: ['teacher','admin'] (same user, switched to teacher) hits the teacher branch — full tenant view. Verifies the contract under the narrower roles semantics.

8.5 Integration — auth.controller.spec.ts e2e (extend or add)

  • 3-step flow end-to-end: multi-tenant + multi-profile user logs in → tenant selection → profile selection → authenticated; decoded JWT contains expected activeProfile and narrowed roles.
  • Switch flow: authenticated session → switch endpoint → second authenticated session, old refresh token revoked.

8.6 Out of scope

  • E2E security checks (logged-as-Teacher vs logged-as-Referent field visibility) are valuable but should be scripted manually once during rollout, not pinned as automated tests at this stage. The unit-level contracts above already verify the compilation and filtering rules end-to-end.

9. Open questions

None blocking. Surface during implementation:

  • Whether to also expose availableProfiles in /auth/me so the frontend can render the switch UI without an extra round-trip. Lean yes — cheap addition, but kept out of this scope to avoid widening UserProfileDto mid-spec. Add as a follow-up if the frontend asks.

10. Dependencies and follow-ups

Depends on (separate spec, not blocking): - Multi-profile constraint enforcement — the validation that prevents Teacher + Staff combos. This design assumes the constraint exists upstream. See memory/project_profile_constraint.md.

Follow-ups expected after this lands: - The deferred data-collection endpoint (GET /data-collection) consumes activeProfile to determine me. That spec was paused mid-brainstorm to address this dependency. - Optional availableProfiles on /auth/me (above). - Frontend implementation of the role-selection UI and the switch menu — separate frontend spec.