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_admin—role == "admin"user.is_moderator—role 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_answerexplanationoption_explanationskey_pointsattending_tipexplanation_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.