Skip to content

B2C persona-differentiated capabilities

A capability layer between roles/personas and endpoints so each B2C persona (trainer, learner, creator) gets a feature surface that matches what they actually do, and institutional roles get the same clean treatment. It turns the dormant user_type column into a real authorization signal: before this, the backend gated on role only, so a B2C self-signup saw a Presentation chip that POST /presentations/generate rejected with a 403.

How it works

Three layers, each with one job:

Layer 1: Identity
  role        :: who you are to the system (RBAC)
  user_type   :: what kind of user you say you are (B2C only; null for institutional)
Layer 2: Capabilities
  get_capabilities(user) → set[Capability]   single resolver, switch on (role, user_type)
  answers "can this account do X at all?"
Layer 3: Plan / quota
  raise_if_free_b2c, AIQuotaService, etc.
  answers "is X currently unlocked for this account?"

Invariants:

  • Layer 1 is the only place identity is read; every other module reads Layer 2.
  • Layer 2 is pure (same input, same output, no I/O), so it's easy to unit-test.
  • Layer 3 runs after Layer 2 passes. A 403 from Layer 2 means "wrong persona for this feature"; a 429 or 402 from Layer 3 means "right persona, but quota or plan blocks you."
  • Each endpoint declares one capability via Depends(require_capability(Capability.X)).

The backend serves capabilities; the frontend never re-derives them. The capability fields (user_type, capabilities, plan) are returned on GET /auth/me; the standalone GET /me/capabilities is deprecated but kept for backward compatibility. The frontend useCapabilities() hook reads via TanStack Query (5 min stale, 30 min cache). On a 403 from any capability-gated endpoint, the frontend invalidates the cache and refetches, treating the 403 as the source of truth. Embedding capabilities in the JWT was rejected because it forces a token refresh on any capability change.

Persona stays a UX-framing concept (empty-state copy, conversion nudges, signup analytics); it is not in the gate path. The matrix asymmetries (below) don't survive a clean "all learners get X" rule, so capability resolution switches on (role, user_type) directly.

The B2C self-declared signup persona is stored on users.signup_intent (trainer | learner | creator | null), set once at signup from the ?as= URL param or the persona picker (default learner) and never modifiable post-signup. A null signup_intent defaults to the B2C learner capability set (the conservative one). The naming is disambiguated: DB column signup_intent is the signup input; the API field user_type (creator | learner | operator) is the derived cross-platform UX bucket.

Capability matrix

This is the canonical spec. Gate audits must conform to it.

Capability B2B Trainer B2B Learner B2C trainer B2C learner B2C creator External educator
chat.explain
chat.research · ·
chat.exam_prep · · · ·
presentation.create ·
lesson_plan.create · ·
question_bank.create · ·
kb.build ·
kb.query
marketplace.publish · · · ·
presentation.download · ✓ (plan-gated) ✓ (plan-gated)
lesson_plan.export · ✓ (plan-gated) ·

Admin variants (platform_admin, org_admin, unit_manager) get a superset for audit/management, rendered in the admin section of the implementation, not in this product matrix.

Notable asymmetries:

  1. B2C learner gets presentation.create but B2B learner does not. Self-directed adult learners use slides as study aids; B2B learners live inside a trainer's curriculum where presentations are produced for them.
  2. B2C learner gets kb.build but B2B learner does not. A B2C learner curates their own corpus (notes, past papers); a B2B learner receives the trainer's KB.
  3. B2C trainer does not get chat.exam_prep. Exam prep is framed for the test-taker.
  4. Learners (B2B and B2C) do not get chat.research, lesson_plan.create, or question_bank.create. These are creator/trainer tools; learners are oriented toward learning loops (explain, exam prep, KB query/build). Deep research short-circuits the read-and-understand loop, and a learner generating a question bank generates their own answer key.

Where it lives

  • Backend: apps/backend/src/core/authorization/capabilities.py (Capability enum, get_capabilities, capability sets), core/authorization/dependencies.py (require_capability), api/v1/b2c_auth.py (/auth/me shape), models/user.py (UserRole enum, signup_intent column).
  • Frontend: apps/web/src/lib/capabilities.ts (useCapabilities hook, <CapabilityGuard>), services/ai-tutor/index.ts (KWILO_MODES, getModesForUser chip filter), services/auth.ts (current-user schema).