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
activeProfilerow 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-tenant → select-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 activeRoleKeysbefore 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:
- The narrowing rule guarantees
activeProfileis always present in therolesarray (employees inherit it via the auto-materialized UserRole row; referents/students get it as a one-element sentinel — see D2). - RBAC roles (
admin,principal, …) are only ever granted to employees, so a referent session'sroles = ['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.ts—PERSON_PROFILE_KEYSconst +PersonProfileKeytype +EMPLOYEE_PROFILESsubset +PERSON_PROFILE_TO_ENTITYmap.src/auth/dto/profile-selection-response.dto.ts— mirrorsTenantSelectionResponseDto.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— addsPOST /auth/login/select-profileandPOST /auth/switch-profile. Login response type widens to a 3-way union;select-tenantresponse widens to a 2-way union (AuthUserDto | ProfileSelectionResponseDto).src/auth/auth.service.ts:authenticateWithCredentials— post-1-match branch callsresolveProfileSelectionand 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 gainsactiveProfile; callsnarrowRolesForActiveProfilebefore token issuance.refresh()— readsactiveProfilefrom refresh-token payload, propagates into new access token, validates against freshgetActiveRoleKeys.- Helpers:
resolveProfileSelection(userId, tenantId): Promise<{ profiles: PersonProfileKey[] }>;narrowRolesForActiveProfile(roles, activeProfile): string[]. src/auth/auth.swagger.ts— addsApiSelectProfile,ApiSwitchProfile; updatesApiLoginandApiSelectTenantto document the widened union response.src/auth/dto/auth-response.dto.ts—UserProfileDtogainsactiveProfile.src/auth/interfaces/login-result.interface.ts— tagged union grows a third variant'profile-selection'carrying aProfileSelectionResponseDto.src/auth/interfaces/jwt-payload.interface.ts— gainsactiveProfile: PersonProfileKey.src/auth/interfaces/authenticated-user.interface.ts— gainsactiveProfile: PersonProfileKey.src/auth/strategies/jwt.strategy.ts— extractsactiveProfilefrom token, attaches torequest.user.src/permissions/permissions.service.ts—getUserPermissions(userId, tenantId, activeRoleKeys); same change forgetUserPermissionsDto. Internally,compilePermissions(orfetchUserRoles) filters UserRole rows byrole.key IN activeRoleKeysbefore scope/action compilation.src/permissions/utils/ensure-permissions-loaded.ts— passesrequest.user.rolesas the third argument.src/common/constants/error-codes.ts+error-examples.ts— addsACTIVE_PROFILE_NOT_AVAILABLE.- Selection-token generator/verifier in
auth.service.ts— adds a parallel pairgenerateProfileSelectionToken(userId, tenantId, availableProfiles)/verifyProfileSelectionToken(token)with a distinct subject constant (PROFILE_SELECTION_TOKEN_SUBJECT = 'profile-selection') signed by the sameJwtServicewith the existing 60-second TTL. The existingtenant-selectiontoken verifier rejects the new subject and vice versa, preventing mix-up. prisma/schema.prisma—RefreshTokenmodel gainsactiveProfile String?(see migration note below).prisma/migrations/<timestamp>_add_active_profile_to_refresh_token/migration.sql—ALTER 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¶
RecordAccessContextinterface and@AccessContext()decorator —rolesalready 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) andFieldFilterInterceptor— 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; invalidactiveProfile→ throwsACTIVE_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 → throwsACTIVE_PROFILE_NOT_AVAILABLE.refresh: readsactiveProfilefrom the storedRefreshTokenrow; the new refresh-token row issued in the rotation transaction also hasactiveProfilepopulated; missing-from-fresh-roles → throwsACTIVE_PROFILE_NOT_AVAILABLE(401); legacy row withactiveProfile = NULL→ throwsACTIVE_PROFILE_NOT_AVAILABLE(401).narrowRolesForActiveProfile(pure helper):activeProfile∈EMPLOYEE_PROFILES(teacher, staff) → returns input roles, defensively prependingactiveProfileif missing (so the profile sentinel is always present regardless of whether the auto-materialized UserRole row exists).activeProfile∉EMPLOYEE_PROFILES(referent, student) → returns[activeProfile], dropping every other role regardless of input.- All four
PersonProfileKeyvalues 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/loginreturns one of three response shapes perLoginResult.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 byrole.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. getActiveRoleKeysis unchanged.
8.4 Unit — students.queries.spec.ts (extend)¶
studentsForAccessContext: a context withroles: ['referent'](multi-profile user logged in as referent) hits the referent branch — "linked children only". Same context withroles: ['teacher','admin'](same user, switched to teacher) hits the teacher branch — full tenant view. Verifies the contract under the narrowerrolessemantics.
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
activeProfileand narrowedroles. - 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
availableProfilesin/auth/meso the frontend can render the switch UI without an extra round-trip. Lean yes — cheap addition, but kept out of this scope to avoid wideningUserProfileDtomid-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.