Skip to content

Access and permissions

Three roles, and four kinds of grant. Nothing else decides who may do what.

To hand somebody access rather than to understand the model, go straight to Giving somebody access.

The roles

User.role is a plain string column, one of admin, moderator, user.

Role Reaches
user Their own sittings, notes, starred questions, collections and folders. Reads published articles, studies shared decks. No bank content unless a grant says otherwise.
moderator The whole bank — every question, article, category, deck, image library — and the editorial queues. Nothing that configures the site.
admin Everything, including AI models, people, site policy and the Handbook.

Two derived properties do nearly all the checking in the code:

  • user.is_adminrole == "admin"
  • user.is_moderatorrole in ("admin", "moderator")

Read is_moderator as "moderator or admin" everywhere in this documentation and everywhere in the code. An administrator is never refused something a moderator may do.

Roles come from the identity provider

With OIDC_ROLE_CLAIM set and at least one of OIDC_ADMIN_GROUPS / OIDC_MODERATOR_GROUPS populated, group membership at the provider decides the role, and it is reapplied at every sign-in. Removing somebody from a group at the provider takes the role away here at their next sign-in.

While that mapping is on, two endpoints refuse to set a role in-app and answer 409 instead, because anything set here would be silently overwritten:

Endpoint Gate
PUT /api/v1/admin/users/{user_id}/role admin
PUT /api/v1/access/{user_id}/role moderator

The message names the fix: change the person's group at the provider. The sync refuses exactly one demotion — the last administrator.

With no role claim configured, both endpoints work normally, and everybody who signs in through SSO is a learner.

The two role endpoints are not equivalent

PUT /access/{user_id}/role accepts only user or moderator — it cannot make an administrator. PUT /admin/users/{user_id}/role accepts all three and is admin-gated.

Grants

A grant is the per-user half, handed out inside the app by a moderator or an admin. It is never self-assignable and never comes from a claim. See Grants for what each kind reaches.

A grant is edit only. No creating, no deleting, no publishing — those decide what the library contains and stay with moderators, on both the question and the article side. What a grant does not carry is left off the page rather than shown and refused, so a grantee is not offered buttons that answer 403.

The bank has no owners

Questions, articles, categories, documents, media and shared quizzes carry user_id = NULL. Authorship confers no rights: may_edit_question and can_edit_article ask the role and the grants and nothing else.

One creation path does still stamp a name

POST /questions/create writes user_id=None explicitly, but services/draft_questions.accept — the path every extracted, imported and QTI question comes in by — writes user_id=user.id. Nothing reads it: the question permission functions never look at the column. It is a discrepancy in the code rather than a second rule.

What keeps an owner is what is genuinely one person's — attempts, notes, favourites, collections, folders, study-plan progress, and the unshared quizzes that are somebody's own sittings.

The one exception in code is a flashcard deck: can_edit_deck still honours deck.user_id == user.id as a way in, alongside moderator status and deck grants.

Answer-side redaction

A question's stem is bank content; the answer beside it is not.

GET /questions/detail/{id} returns the stem, the options, the question type and the filing to anybody, and nulls these for anyone who may not edit that question:

  • correct_answer
  • explanation
  • option_explanations
  • key_points
  • attending_tip
  • explanation_image_path

Figures are not nulled but filtered — a non-editor sees only figures whose role is stem. The response carries can_edit explicitly so a client can tell "redacted" from "genuinely blank".

Outside an authorised attempt the answer side is refused rather than redacted: require_question_access raises 403 Sit this question to see its answer unless the caller may edit it. Answer-side media additionally needs ?attempt_id= on the /uploads/... request, which is how the server knows the person asking is the person who sat it.

Where the checks live

Concern Function File
Role dependencies require_admin, require_moderator, get_current_user backend/app/utils/auth.py
Category branch reach granted_category_scope, _descendants backend/app/utils/category_grants.py
Article editing can_edit_article backend/app/utils/category_grants.py
Question editing may_edit_question, question_scope_predicate backend/app/utils/quiz_access.py, category_grants.py
Deck editing can_edit_deck, granted_deck_ids, may_start_a_deck backend/app/services/deck_access.py
Image libraries readable_libraries, assert_can_use backend/app/routers/media.py
Role sync apply, refuse_local_role_change backend/app/services/sso_roles.py

A grep for Depends(require_moderator) undercounts moderator-only endpoints: several routes inline if not current_user.is_moderator: raise 403 in the body instead — article delete, article status, question create and permanent delete, deck delete, and the trashed-deck listing among them.