User Management & Access¶
Status: Authoritative reference for personas, RBAC, and access flows. Core implementation has shipped:
src/proxy.ts,src/auth/guards.ts,src/auth/config.ts,src/db/queries/, the admin shell undersrc/app/\[locale\]/admin/, and the learner auth/account flows undersrc/app/\[locale\]/learn/all enforce the contract documented here. Where this document and the code disagree, treat the code as authoritative and update this document.Audience: Developers maintaining authentication, authorization, and user administration.
Scope: Personas, RBAC, and access flows. Email architecture is documented separately in Email System.
Overview¶
Access is enforced in five layers (defence in depth):
proxy.ts— coarse route gating by URL prefix; locale negotiation; tenant resolution from host.- Server components —
requireSession(),requireRole(...),requireCourseAccess(slug)helpers insrc/auth/guards.ts. - Server actions & route handlers — same guards, plus per-action permission checks; mutations re-verify against the database rather than trusting the JWT.
- Kysely repository layer — every query scopes by
tenant_idand (where relevant)user_id;super_adminopts into cross-tenant access via an explicitwithTenantOverride()helper. - UI — hides controls the user cannot use, but is never the sole gate.
Authentication uses Auth.js (NextAuth v5) with the Credentials provider and a JWT session strategy. The JWT carries userId, tenantId, primary role, derived permissions[], assignedCourses[] (course slugs the user can administer), and tokenVersion. Bumping users.token_version invalidates outstanding JWTs (used on role change, password reset, forced sign-out).
Authentication & user records¶
- Provider: Auth.js (NextAuth v5) with the Credentials provider. Additional OAuth providers may be added later.
- Storage: User profiles and role assignments live in the
userstable in PostgreSQL (SQLite in dev), accessed via Kysely. Theusersrow is authoritative; the JWT is a derived snapshot kept in sync via the Auth.jsjwt/sessioncallbacks and invalidated bytoken_versionbumps.
Personas¶
Five personas are recognised. Each entry lists the persona's goal, what they can see, what they cannot see, their entry points, and the RBAC dependencies that enforce the boundary.
P1. Public visitor (unauthenticated)¶
- Goal: Browse the marketing site, blog, and course catalog without committing to an account.
- Allowed surfaces:
/,/\[slug\],/blog,/blog/\[slug\],/learn(course catalog with public metadata only — no enrolment status, no progress). - Denied surfaces: anything under
/learn/course/\[slug\]/...beyond the public landing page; everything under/admin;/learn/dashboard. - Entry points: direct URL, search engine, marketing campaign.
- Exits: click sign-in / sign-up / enrol → becomes a Prospective Student.
- RBAC: no session;
proxy.tsallows public prefixes and bounces protected ones to/sign-in?next=….
P2. Prospective student (unauthenticated, intending to enrol)¶
- Goal: Create an account (or accept an invite) and enrol in a course.
- Allowed surfaces: everything P1 sees, plus
/sign-in,/sign-up,/invite/\[token\], the public landing of/learn/course/\[slug\]with an "Enrol" CTA. - Denied surfaces: course content, dashboards, admin areas.
- Entry points: "Enrol" CTA, invitation email, direct sign-up.
- Exits: completes sign-up or sign-in → becomes Authenticated Student.
- RBAC: identical to P1 until session is issued. Account creation is permitted via two paths: self-service sign-up and invitation acceptance.
P3. Authenticated student¶
- Goal: Enrol in a course and progress through it; see personal dashboard.
- Allowed surfaces: everything P1/P2 see, plus
/learn/course/\[slug\]/enroll,/learn/course/\[slug\](course home, once enrolled),/learn/course/\[slug\]/entry/\[slug\],/learn/dashboard. - Denied surfaces: content for courses they have not enrolled in (redirect to enrol page); all
/admin/*routes (403); other tenants' content (404 — see error taxonomy). - Entry points: sign-in, deep-link replay after sign-in, dashboard CTA.
- Exits: logout, session expiry, role grant (becomes also a
course_adminortenant_admin). - RBAC:
role = student; access to course content gated by anenrollmentsrow withstatus ∈ {'active','completed'}.
P4. Course administrator¶
- Goal: Manage students and view results for a specific course they are assigned to.
- Allowed surfaces: everything P3 sees, plus
/admin/courses/\[slug\]/students,/admin/courses/\[slug\]/results,/admin/courses/\[slug\]/dashboard— but only for courses whereslug ∈ assignedCourses. - Denied surfaces: course content editing, user role management, tenant settings, courses they are not assigned to (403), other tenants' courses (404).
- Entry points: invitation acceptance, role grant by
tenant_admin, sign-in landing on/admin. - Exits: assignment revoked → loses dashboard access on next JWT refresh.
- RBAC:
role = course_admin; per-course scope enforced viacourse_admin_assignments(user_id,tenant_id,course_slug). The JWT carries the assignment list; mutations re-verify against the table.
P5. LMS administrator¶
Two tiers — both surface the same /admin shell, but with different capability sets.
P5a. Tenant administrator (tenant_admin)¶
- Goal: Run their tenant — manage users, courses, enrolments, course-admin assignments.
- Allowed surfaces:
/admin,/admin/users,/admin/users/invite,/admin/users/invitations,/admin/courses,/admin/courses/\[slug\]/...for any course in the tenant,/admin/settings. - Denied surfaces:
/admin/tenants(super-admin only), other tenants' data (404 — query layer enforcestenant_id). - RBAC:
role = tenant_admin; cannot cross tenants; can grantcourse_adminandstudentroles within the tenant; can granttenant_adminto others in the same tenant.
P5b. Super administrator (super_admin)¶
- Goal: Operate the platform across all tenants.
- Allowed surfaces: everything
tenant_adminsees, plus/admin/tenants(create/list tenants), cross-tenant user search, and impersonation of any non-super_admintenant user for support. - Denied surfaces: none — but every cross-tenant action requires explicit
withTenantOverride()and is audit-logged. - RBAC:
role = super_admin;tenant_idis nullable; impersonation start/stop writes to the audit log.
Role Model & RBAC¶
Canonical roles¶
student— default role on account creation; sees only enrolled courses.course_admin— manages assigned courses only; assignments live incourse_admin_assignments.tenant_admin— full management within their tenant; cannot cross tenants.super_admin— global; cross-tenant; can impersonate non-super_admintenant users for support.
Roles are non-exclusive at the schema level (a user may simultaneously be a student enrolled in courses and a course_admin for other courses). The JWT stores a single primary role plus a derived permissions[] array and the assignedCourses[] list.
Permission matrix¶
| Action | student | course_admin (assigned) | tenant_admin | super_admin |
|---|---|---|---|---|
| View public site | ✅ | ✅ | ✅ | ✅ |
| Sign up / accept invite | ✅ | ✅ | ✅ | ✅ |
| Enrol in a course | ✅ | ✅ | ✅ | ✅ |
| View enrolled course content | ✅ | ✅ | ✅ | ✅ |
| View own dashboard | ✅ | ✅ | ✅ | ✅ |
| View student roster (assigned course) | ❌ | ✅ | ✅ | ✅ |
| View results dashboard (assigned course) | ❌ | ✅ | ✅ | ✅ |
| Archive a course | ❌ | ❌ | ✅ | ✅ |
| Promote / demote users in tenant | ❌ | ❌ | ✅ | ✅ |
Assign course_admin to a course |
❌ | ❌ | ✅ | ✅ |
| Invite users (any role within tenant) | ❌ | ❌ | ✅ | ✅ |
| Edit tenant settings | ❌ | ❌ | ✅ | ✅ |
| Edit course settings (feature flags) | ❌ | ✅ | ✅ | ✅ |
| Manage tenants (create / suspend) | ❌ | ❌ | ❌ | ✅ |
| Cross-tenant user search | ❌ | ❌ | ❌ | ✅ |
| Impersonate user (audit-logged) | ❌ | ❌ | ❌ | ✅ |
super_admin users cannot impersonate another super_admin, and deactivated target users are refused.
Data Model Additions¶
The following tables/columns extend the base schema sketched in implementation.md.
users.role— enumstudent | course_admin | tenant_admin | super_admin. Defaultstudent.users.tenant_id— required for all roles exceptsuper_admin(nullable for super_admin).users.token_version— integer, incremented on role change / forced sign-out to invalidate outstanding JWTs.course_admin_assignments(id, user_id, tenant_id, course_slug, assigned_by, assigned_at)— composite unique on(user_id, tenant_id, course_slug).invitations(id, email, tenant_id, role, course_slug?, course_title?, token_hash, invited_by, expires_at, accepted_at)— supports student, course_admin, and tenant_admin invitations.course_slugis required whenrole = course_admin;course_titlestores the selected course name for invite email copy.audit_log(id, actor_user_id, action, target_kind, target_id, tenant_id, metadata, created_at)— required at minimum for impersonation events; broader use TBD.
Helpers¶
The following helpers live in src/auth/guards.ts unless otherwise noted. They are the canonical API for server components, server actions, and route handlers; callers must not duplicate their role or tenant checks inline.
type Role = "student" | "course_admin" | "tenant_admin" | "super_admin";
interface AuthenticatedSession {
user: {
id: bigint;
tenantId: bigint | null;
role: Role;
assignedCourses: string[];
tokenVersion: number;
};
}
interface TenantScopedQueryContext {
tenantId: bigint;
actorUserId: bigint;
}
interface TenantOverrideInput {
actorSession: AuthenticatedSession;
overrideTenantId: bigint;
reason: string;
}
async function requireSession(): Promise<AuthenticatedSession>;
async function requireRole(...allowed: Role[]): Promise<AuthenticatedSession>;
async function requireCourseAccess(
courseSlug: string,
): Promise<AuthenticatedSession>;
async function withTenantOverride<T>(
input: TenantOverrideInput,
callback: (context: TenantScopedQueryContext) => Promise<T>,
): Promise<T>;
requireSession()resolves the Auth.js session and returns the typed session above. If no valid session exists, it throwsAuthError; page-level callers map this to the sign-in redirect, while route handlers and server actions return 401.requireRole(...allowed)callsrequireSession(), verifiessession.user.roleis included inallowed, and throwsForbiddenErrorwhen the role is insufficient. It returns the same typed session so callers can continue with the verified identity.requireCourseAccess(courseSlug)callsrequireSession()and applies the course-access table used by the enrollment system: students need anactiveorcompletedenrollment; course admins need a matchingcourse_admin_assignmentsrow or enrollment; tenant admins pass within their tenant; super admins pass only for their session tenant unless the caller useswithTenantOverride(). It throwsForbiddenErrorfor same-tenant denial andNotFoundErrorwhen exposing the resource would leak another tenant's data.withTenantOverride()lives with the tenant-scoped Kysely helpers. It is the only sanctioned cross-tenant query bypass. Preconditions:input.actorSession.user.role === "super_admin",overrideTenantIdidentifies an existing tenant, andreasonis a non-empty audit string. The helper writes anaudit_logrow before invokingcallbackwith a query context whosetenantIdisoverrideTenantId; if the audit write fails, the cross-tenant operation is refused.
Example:
const users = await withTenantOverride(
{
actorSession: session,
overrideTenantId: targetTenantId,
reason: "support cross-tenant user search",
},
(context) => listUsersForTenant(context.tenantId),
);
Enforcement Layers¶
proxy.ts— public,/learn/*(session required),/admin/*(admin role required); resolves tenant from host; preserves?next=on redirect.- Server-component guards (
src/auth/guards.ts):requireSession()— returns the typed session or throwsAuthError; page callers map it toredirect('/sign-in?next=…').requireRole(...allowed: Role[])— throwsForbiddenError, rendered as the 403 page on page surfaces.requireCourseAccess(courseSlug)— for students, checksenrollments; forcourse_admin, checksassignedCoursesand re-verifies assignments on mutation; passes fortenant_adminin the same tenant and forsuper_adminonly with the current session tenant or an explicitwithTenantOverride()context.
- Server actions / route handlers — same guards, plus action-specific permission checks. Writes always re-verify against the DB; never trust the JWT alone for mutations.
- Repository layer (Kysely) — every query scopes by
tenant_id(anduser_idwhere relevant).super_admincross-tenant queries must pass throughwithTenantOverride(), which is the only sanctioned bypass. - UI — hides disallowed actions, but is never the only gate.
- Site header top-nav
data-testid="main-nav-link-admin"appears only when the CMS site hierarchy includes anadminnode and the session role is one oftenant_admin,course_admin, orsuper_admin. - User menu entry
data-testid="user-menu-admin"uses the same role set and links to/admin.
- Site header top-nav
Flows¶
Each flow lists trigger, preconditions, numbered steps, redirects, and error states.
F1 — Public browsing (P1)¶
- Trigger: any unauthenticated request to a public route.
- Steps:
proxy.tsallows the prefix; pages render with no session calls. - Redirects / errors: none.
F2 — Self-service sign-up (P2 → P3)¶
- Trigger: user submits
/sign-upform. - Steps: validate input (Zod) → check
users.emailuniqueness within tenant → bcrypt hash password → insert row withrole = student,tenant_idfrom host and notification preferences → send verification email inline → auto sign-in → redirect to?next=or/learn/dashboard. - Errors: duplicate email → form error; invalid tenant → 404.
F3 — Sign-in (any persona)¶
- Trigger:
/sign-inform submission. - Steps: Credentials provider validates email + password → loads
role,tenant_id,assignedCourses[],token_version→ issues JWT → redirect to?next=(if same-origin and authorised) else role-based default landing (/learn/dashboardfor students,/adminfor any admin role). - Errors: bad credentials → form error (no user-existence leak); tenant mismatch → form error.
F4 — Deep-link to a protected route¶
- Trigger: unauthenticated request to e.g.
/learn/course/foo/entry/bar. - Steps:
proxy.ts302 →/sign-in?next=/learn/course/foo/entry/bar→ after F3, replay original URL. - Errors: if the replayed URL is now disallowed by role/enrolment, fall through to F13.
F5 — Enrolment (P2/P3)¶
- Trigger: "Enrol" CTA on
/learn/course/\[slug\]. - Steps: if unauthenticated, F2 or F3 with
next=set to the enrol page → enrolment server action → free course: insertenrollmentswithstatus = 'active'; paid course: insert withstatus = 'pending'and trigger payment stub → redirect to/learn/course/\[slug\]. - Errors: course unpublished → 404; already enrolled → idempotent redirect to course home; payment failure → status remains
pending, user sees retry CTA.
F6 — Course consumption (P3)¶
- Trigger: student navigates within
/learn/course/\[slug\]. - Steps:
requireCourseAccess(slug)→ render course home / entry →entry/\[slug\]records progress on view / completion. - Errors: unenrolled student → 302
/learn/course/\[slug\]/enroll; entry from a different course → 404.
F7 — Course admin dashboard (P4)¶
- Trigger: course_admin opens
/admin/courses/\[slug\]/.... - Steps:
requireRole('course_admin','tenant_admin','super_admin')→ ifcourse_admin, verifyslug ∈ assignedCourses(JWT) and re-verify againstcourse_admin_assignmentson any mutation → render roster / results / per-entry analytics. - Errors: unassigned slug → 403; cross-tenant slug → 404.
F8 — Tenant administrator flows (P5a)¶
- Trigger: tenant_admin opens
/admin/users,/admin/courses, etc. - Steps:
requireRole('tenant_admin','super_admin')→ list/edit users, promote/demote within{ student, course_admin, tenant_admin }, assigncourse_adminto courses, invite users, edit courses and tenant settings. Role change bumps the affected user'stoken_version(forces JWT refresh on next request). - Errors: attempt to grant
super_admin→ 403; attempt to act on a user from another tenant → 404.
F9 — Super administrator flows (P5b)¶
- Trigger: super_admin opens
/admin/tenantsor cross-tenant search. - Steps:
requireRole('super_admin')→ list/create tenants → cross-tenant search viawithTenantOverride()→ start impersonation (writesaudit_log, mints a scoped JWT identifying the impersonator and target) → stop impersonation (writesaudit_log, restores original JWT). See Impersonation for the operator workflow. - Errors: missing audit-log write → impersonation refused.
F10 — Invitation acceptance¶
- Trigger: click on
/invite/\[token\]link from email. - Steps: look up
invitationsbytoken_hash, checkexpires_atandaccepted_at IS NULL→ if email has no user in the tenant: render sign-up prefilled with email and target role → if user exists in the same tenant: require sign-in then auto-grant the role (and course assignment ifrole = course_admin) → markaccepted_at→ redirect to the relevant landing (/adminfor tenant/super admin,/admin/courses/\[slug\]/dashboardfor course admin withcourse_slug,/learn/course/\[slug\]for student with optionalcourse_slug, otherwise/learn/dashboard). - Email copy note: when an invitation includes a course, the email states that course using stored
course_title. This is informational only for students; it does not auto-enrol the user. - Errors: expired or already-accepted → friendly error page with "request a new invitation" CTA; email mismatch when an existing user is signed in → ask user to sign out and accept with the invited email.
- Administrator workflow: see Invite and manage users for role/course choices, recipient guidance, and troubleshooting.
F10a — Invitation management (tenant/super admin)¶
- Trigger: admin opens
/admin/users/invitations. - Steps: list tenant-scoped invitations with search (
email,role,course_slug) and derived status (pending,accepted,expired) → use row actions to resend (rotate token hash + expiry, re-send email, auditadmin.user.invite.resent) or revoke (delete invitation row, auditadmin.user.invite.revoked). - Constraints: resend/revoke only apply to non-accepted invitations; accepted history remains in
audit_log. Resend uses storedcourse_titleand falls back tocourse_slugfor legacy rows without title data. - Known limitation: invite/resend email localization currently follows the inviting administrator's locale.
- Administrator workflow: see Invite and manage users.
F11 — Logout¶
- Trigger: user clicks "Sign out".
- Steps: Auth.js clears the session cookie → redirect to
/. Active server actions complete; subsequent requests are unauthenticated.
F12 — Session expiry / forced sign-out¶
- Trigger: stale JWT (expiry reached, or
token_versionmismatch after a role change / forced sign-out). - Steps: server action returns 401 → client redirects to
/sign-in?next=<current URL>→ after F3, replay.
F13 — Denied access¶
- Trigger: authenticated request that fails a role or assignment check.
- Steps: render
403page with "switch account" link. Disambiguation: when revealing the resource's existence would itself leak tenant data (e.g., a course in another tenant), respond with404instead — see error taxonomy.
Error & Redirect Taxonomy¶
| Condition | Response |
|---|---|
| Unauthenticated on protected route | 302 → /sign-in?next=<original> |
Authenticated, wrong role on /admin/* |
Render 403 page (no redirect) |
course_admin on a course not in assignedCourses |
403 page |
| Cross-tenant resource (course, user, enrolment) | 404 — never 403, to avoid leaking existence |
Enrolment missing on /learn/course/\[slug\]/* |
302 → /learn/course/\[slug\]/enroll |
Tenant mismatch (host tenant ≠ JWT tenantId) |
Force sign-out, 302 → /sign-in |
Stale JWT (token_version mismatch) on a server action |
401 → client redirects to /sign-in?next=<current> |
| Expired or already-accepted invitation | Render friendly error page with re-request CTA |
Open Questions / Future Work¶
- Email notifications — transactional mail is implemented; persistence, retry, unsubscribe, webhooks, and optional notifications remain tracked in T-026 Email Platform Roadmap. Current behavior is in Email System.
- Payment integration — paid-enrolment flow is a stub; provider TBD.
- Audit log — schema is sketched only for impersonation; broader coverage (role changes, course-admin assignment, forced sign-out) to be defined.
- Multiple roles per user — schema permits it via assignments, but UX for a user who is simultaneously a student and a course_admin (e.g., an account switcher or a unified shell) is not yet designed.
- First sign-in auto-provisioning — F2 covers self-service sign-up; auto-provisioning into
usersfor OAuth/SSO providers is TBD. - JWT ↔
users.rolesynchronisation — F3 issues the JWT from the row, andtoken_versioninvalidates stale JWTs, but the full set of Auth.jsjwt/sessioncallback responsibilities (which fields are refreshed, when, and on what trigger) is not yet specified end-to-end. - User deactivation — soft-delete vs hard-delete; effect on enrolments, course-admin assignments, and outstanding invitations.
- Data retention policy — how long deactivated users, completed enrolments, and audit-log entries are retained, and what is purged versus anonymised.