Skip to content

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 under src/app/\[locale\]/admin/, and the learner auth/account flows under src/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):

  1. proxy.ts — coarse route gating by URL prefix; locale negotiation; tenant resolution from host.
  2. Server componentsrequireSession(), requireRole(...), requireCourseAccess(slug) helpers in src/auth/guards.ts.
  3. Server actions & route handlers — same guards, plus per-action permission checks; mutations re-verify against the database rather than trusting the JWT.
  4. Kysely repository layer — every query scopes by tenant_id and (where relevant) user_id; super_admin opts into cross-tenant access via an explicit withTenantOverride() helper.
  5. 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 users table in PostgreSQL (SQLite in dev), accessed via Kysely. The users row is authoritative; the JWT is a derived snapshot kept in sync via the Auth.js jwt/session callbacks and invalidated by token_version bumps.

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.ts allows 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_admin or tenant_admin).
  • RBAC: role = student; access to course content gated by an enrollments row with status ∈ {'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 where slug ∈ 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 via course_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 enforces tenant_id).
  • RBAC: role = tenant_admin; cannot cross tenants; can grant course_admin and student roles within the tenant; can grant tenant_admin to others in the same tenant.

P5b. Super administrator (super_admin)

  • Goal: Operate the platform across all tenants.
  • Allowed surfaces: everything tenant_admin sees, plus /admin/tenants (create/list tenants), cross-tenant user search, and impersonation of any non-super_admin tenant user for support.
  • Denied surfaces: none — but every cross-tenant action requires explicit withTenantOverride() and is audit-logged.
  • RBAC: role = super_admin; tenant_id is nullable; impersonation start/stop writes to the audit log.

Role Model & RBAC

Canonical roles

type Role = "student" | "course_admin" | "tenant_admin" | "super_admin";
  • student — default role on account creation; sees only enrolled courses.
  • course_admin — manages assigned courses only; assignments live in course_admin_assignments.
  • tenant_admin — full management within their tenant; cannot cross tenants.
  • super_admin — global; cross-tenant; can impersonate non-super_admin tenant 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 — enum student | course_admin | tenant_admin | super_admin. Default student.
  • users.tenant_id — required for all roles except super_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_slug is required when role = course_admin; course_title stores 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 throws AuthError; page-level callers map this to the sign-in redirect, while route handlers and server actions return 401.
  • requireRole(...allowed) calls requireSession(), verifies session.user.role is included in allowed, and throws ForbiddenError when the role is insufficient. It returns the same typed session so callers can continue with the verified identity.
  • requireCourseAccess(courseSlug) calls requireSession() and applies the course-access table used by the enrollment system: students need an active or completed enrollment; course admins need a matching course_admin_assignments row or enrollment; tenant admins pass within their tenant; super admins pass only for their session tenant unless the caller uses withTenantOverride(). It throws ForbiddenError for same-tenant denial and NotFoundError when 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", overrideTenantId identifies an existing tenant, and reason is a non-empty audit string. The helper writes an audit_log row before invoking callback with a query context whose tenantId is overrideTenantId; 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

  1. proxy.ts — public, /learn/* (session required), /admin/* (admin role required); resolves tenant from host; preserves ?next= on redirect.
  2. Server-component guards (src/auth/guards.ts):
    • requireSession() — returns the typed session or throws AuthError; page callers map it to redirect('/sign-in?next=…').
    • requireRole(...allowed: Role[]) — throws ForbiddenError, rendered as the 403 page on page surfaces.
    • requireCourseAccess(courseSlug) — for students, checks enrollments; for course_admin, checks assignedCourses and re-verifies assignments on mutation; passes for tenant_admin in the same tenant and for super_admin only with the current session tenant or an explicit withTenantOverride() context.
  3. 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.
  4. Repository layer (Kysely) — every query scopes by tenant_id (and user_id where relevant). super_admin cross-tenant queries must pass through withTenantOverride(), which is the only sanctioned bypass.
  5. 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 an admin node and the session role is one of tenant_admin, course_admin, or super_admin.
    • User menu entry data-testid="user-menu-admin" uses the same role set and links to /admin.

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.ts allows the prefix; pages render with no session calls.
  • Redirects / errors: none.

F2 — Self-service sign-up (P2 → P3)

  • Trigger: user submits /sign-up form.
  • Steps: validate input (Zod) → check users.email uniqueness within tenant → bcrypt hash password → insert row with role = student, tenant_id from 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-in form 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/dashboard for students, /admin for any admin role).
  • Errors: bad credentials → form error (no user-existence leak); tenant mismatch → form error.
  • Trigger: unauthenticated request to e.g. /learn/course/foo/entry/bar.
  • Steps: proxy.ts 302 → /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: insert enrollments with status = 'active'; paid course: insert with status = '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') → if course_admin, verify slug ∈ assignedCourses (JWT) and re-verify against course_admin_assignments on 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 }, assign course_admin to courses, invite users, edit courses and tenant settings. Role change bumps the affected user's token_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/tenants or cross-tenant search.
  • Steps: requireRole('super_admin') → list/create tenants → cross-tenant search via withTenantOverride() → start impersonation (writes audit_log, mints a scoped JWT identifying the impersonator and target) → stop impersonation (writes audit_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 invitations by token_hash, check expires_at and accepted_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 if role = course_admin) → mark accepted_at → redirect to the relevant landing (/admin for tenant/super admin, /admin/courses/\[slug\]/dashboard for course admin with course_slug, /learn/course/\[slug\] for student with optional course_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, audit admin.user.invite.resent) or revoke (delete invitation row, audit admin.user.invite.revoked).
  • Constraints: resend/revoke only apply to non-accepted invitations; accepted history remains in audit_log. Resend uses stored course_title and falls back to course_slug for 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_version mismatch 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 403 page with "switch account" link. Disambiguation: when revealing the resource's existence would itself leak tenant data (e.g., a course in another tenant), respond with 404 instead — 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 users for OAuth/SSO providers is TBD.
  • JWT ↔ users.role synchronisation — F3 issues the JWT from the row, and token_version invalidates stale JWTs, but the full set of Auth.js jwt/session callback 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.