pdf-quiz-generator/backend/app/utils/category_grants.py
Daniel b6cfcaa1e9
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 32s
Tests / e2e (push) Failing after 26s
feat: the bank belongs to a role, not to a person
571 categories, 21 uploaded documents, 14 articles, 8 card decks, 30
shared tests and 2 questions carried somebody's name — mostly
daniel@danvics.com, which is not even the working administrator any
more. So "who may edit this" partly depended on who happened to create
it, and handing the site to somebody else would have meant rewriting
every one of those rows.

Migration q6a7b8c9d0e1 empties those owner columns and makes them
nullable, because ownerless is now a legitimate state and a NOT NULL
owner is exactly what forced a name onto every row. Nothing is deleted
and nothing moves. What keeps its owner, deliberately: attempts, notes,
favourites, collections, folders, study-plan progress, and the quizzes
that are somebody's own sittings rather than shared bank tests.
study_plans needed nothing — it never had an owner column.

Then the code, so it cannot grow back. Authorship is no longer a way in
anywhere: may_edit_question and can_edit_article ask the role and the
grants and nothing else; the article draft, status and delete paths lost
their "or you wrote it" arm; decks are the bank's, so an educator
reaches any of them and a learner reaches the shared ones; documents are
the corpus, so they are editors-only rather than "mine"; and every
creation path writes user_id NULL. The bank listing's "mine" facet went
with it — it counted nothing and could only ever count nothing.

Verified against production as a real learner account: every bank write
403s, admin settings 403, documents empty. As an admin, everything
opens.

Also: a category grant no longer offers Editorial in the menu. It offers
Questions and Images, which is what a grant covers; Editorial is the
whole library's review queue and its route is moderator-only, so the
entry was a door that answered "Not yours to open".

Six tests changed rather than deleted — they asserted the old model, and
each now asserts the new one: writing an article does not make it yours,
writing a question does not make it yours, an answer image is not opened
by authorship, the tutor is not opened by authorship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 13:26:25 +02:00

237 lines
9.9 KiB
Python

"""Scoped editorial access for non-moderator educators.
A moderator or admin manages every question. Everyone else manages only what
their grants cover. A grant names any combination of exam, discipline tag,
category and folder; it covers the questions matching *all* the dimensions it
sets, and an unset dimension means "any". Holding several grants is a union of
their coverage.
A folder is the one dimension that is a list rather than a description: it
covers exactly the questions somebody put in it, which is the shape the other
three cannot express.
`question_scope_predicate` returns None to mean "no restriction".
"""
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.category_grant import CategoryGrant
from app.models.folder import QuestionFolderQuestion
from app.models.question import Question
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.user import User
_TAG_LINKS = None
# A predicate that matches no question, for a user whose grants cover nothing.
_NOTHING = Question.id.is_(None)
def _descendants(db: Session, roots: set[int]) -> set[int]:
"""Expand category ids to include every category beneath them."""
if not roots:
return set()
children: dict[int, list[int]] = {}
for cid, parent_id in db.query(QuestionCategory.id, QuestionCategory.parent_id).all():
if parent_id is not None:
children.setdefault(parent_id, []).append(cid)
seen, stack = set(roots), list(roots)
while stack:
for child in children.get(stack.pop(), ()):
if child not in seen:
seen.add(child)
stack.append(child)
return seen
def granted_category_scope(db: Session, user: User) -> set[int] | None:
"""Categories this user's grants cover, including everything beneath them.
None means every category — a moderator. An empty set means none.
"""
if user.is_moderator:
return None
roots = {row[0] for row in db.query(CategoryGrant.category_id).filter(
CategoryGrant.user_id == user.id, CategoryGrant.category_id.isnot(None)).all()}
return _descendants(db, roots)
def can_edit_article(db: Session, user: User, article) -> bool:
"""Whether this user may change this article.
A moderator may. Beyond that, an article is filed under a category, so a
grant over that branch covers the reading in it as well as the questions —
otherwise an educator given a branch can edit its questions and not the
article they are meant to be read with.
Authorship is not a way in. An article belongs to the library, not to
whoever typed it, and the address that wrote most of this one is not even
the working administrator any more.
"""
if user.is_moderator:
return True
if article.category_id is None:
return False
scope = granted_category_scope(db, user)
return scope is not None and article.category_id in scope
def question_scope_predicate(db: Session, user: User):
"""A filter over Question covering everything this user may edit, or None for all.
Each grant contributes an AND of the dimensions it sets; the grants are ORed
together. A category dimension includes that category's descendants, and
matches a question through its primary category or an additional link.
"""
from sqlalchemy import or_, select
from app.models.exam import QuestionExamLink
if user.is_moderator:
return None
grants = db.query(CategoryGrant).filter(CategoryGrant.user_id == user.id).all()
if not grants:
return _NOTHING
clauses = []
for grant in grants:
parts = []
if grant.category_id is not None:
ids = _descendants(db, {grant.category_id})
parts.append(or_(
Question.question_category_id.in_(ids),
Question.id.in_(select(QuestionCategoryLink.question_id).where(
QuestionCategoryLink.category_id.in_(ids))),
))
if grant.exam_id is not None:
parts.append(Question.id.in_(select(QuestionExamLink.question_id).where(
QuestionExamLink.exam_id == grant.exam_id)))
if grant.tag_id is not None:
parts.append(Question.id.in_(
select(_tag_links().c.question_id).where(_tag_links().c.tag_id == grant.tag_id)))
if grant.folder_id is not None:
parts.append(Question.id.in_(select(QuestionFolderQuestion.question_id).where(
QuestionFolderQuestion.folder_id == grant.folder_id)))
if not parts:
continue # a grant naming nothing grants nothing
clause = parts[0]
for extra in parts[1:]:
clause = clause & extra
clauses.append(clause)
if not clauses:
return _NOTHING
combined = clauses[0]
for extra in clauses[1:]:
combined = combined | extra
return combined
def _tag_links():
"""question_tag_links is created by raw DDL, so it is reflected, not mapped."""
from sqlalchemy import Column, Integer, MetaData, Table
global _TAG_LINKS
if _TAG_LINKS is None:
_TAG_LINKS = Table(
"question_tag_links", MetaData(),
Column("question_id", Integer), Column("tag_id", Integer),
)
return _TAG_LINKS
def granted_folder_ids(db: Session, user: User) -> set[int]:
"""Folders whose questions this user has been granted. Empty for a moderator.
A moderator reaches everything without a grant, so asking which folders they
hold is the wrong question; callers check `is_moderator` first.
"""
return {row[0] for row in db.query(CategoryGrant.folder_id).filter(
CategoryGrant.user_id == user.id, CategoryGrant.folder_id.isnot(None)).all()}
def manageable_categories(db: Session, user: User) -> set[int] | None:
"""Category ids this user may edit questions in; None means all of them.
Categories only. Someone whose access comes entirely from a folder or an
exam grant gets an empty set from this and still manages questions — use
`question_scope_predicate` to ask what a person reaches, and this only where
the answer genuinely has to be a category (filling a category picker,
checking where a new question may be filed).
"""
if user.is_moderator:
return None
# Grants naming only an exam, a tag or a folder contribute no category, and
# a None left in this set turns into `IN (NULL)` further down, which matches
# nothing but reads like it might.
roots = {row[0] for row in db.query(CategoryGrant.category_id).filter(
CategoryGrant.user_id == user.id, CategoryGrant.category_id.isnot(None)).all()}
return _descendants(db, roots)
def is_question_manager(db: Session, user: User) -> bool:
"""True when the user may edit questions somewhere."""
if user.is_moderator:
return True
return db.query(CategoryGrant.id).filter(CategoryGrant.user_id == user.id).first() is not None
def require_question_manager(db: Session, user: User) -> set[int] | None:
"""Gate a question-management route; returns the caller's category scope."""
if not is_question_manager(db, user):
raise HTTPException(403, "Question management requires moderator access or a grant")
return manageable_categories(db, user)
def assert_can_manage_category(scope: set[int] | None, category_id: int | None, what: str = "category") -> None:
if scope is None:
return
if category_id is None or category_id not in scope:
raise HTTPException(403, f"You do not have an editorial grant for this {what}")
def assert_user_can_manage(db: Session, user: User, question_ids: list[int]) -> None:
"""Every target question must fall inside one of the user's grants.
Checked against the grant predicate rather than a category set, so exam and
discipline dimensions are honoured, not just categories.
"""
predicate = question_scope_predicate(db, user)
if predicate is None or not question_ids:
return
allowed = {
row[0] for row in db.query(Question.id).filter(
Question.id.in_(question_ids)).filter(predicate).all()
}
existing = {row[0] for row in db.query(Question.id).filter(Question.id.in_(question_ids)).all()}
# A missing row is the caller's 404 to report, not a permission error.
for question_id in question_ids:
if question_id in existing and question_id not in allowed:
raise HTTPException(403, "You do not have an editorial grant for one of these questions")
def assert_can_manage_questions(db: Session, scope: set[int] | None, question_ids: list[int]) -> None:
"""Every target question must sit in a granted category (primary or additional)."""
if scope is None or not question_ids:
return
primary = dict(db.query(Question.id, Question.question_category_id).filter(
Question.id.in_(question_ids)).all())
extra: dict[int, set[int]] = {}
for qid, cid in db.query(QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).filter(
QuestionCategoryLink.question_id.in_(question_ids)).all():
extra.setdefault(qid, set()).add(cid)
for question_id in question_ids:
if question_id not in primary:
continue # a missing row is reported as 404 by the caller, not as a permission error
owned = ({primary[question_id]} if primary[question_id] else set()) | extra.get(question_id, set())
if not (owned & scope):
raise HTTPException(403, "You do not have an editorial grant for one of these questions")
def question_in_scope(db: Session, scope: set[int], question: Question) -> bool:
"""True when a question sits in a granted category (primary or additional)."""
owned = {question.question_category_id} if question.question_category_id else set()
owned |= {row[0] for row in db.query(QuestionCategoryLink.category_id).filter(
QuestionCategoryLink.question_id == question.id).all()}
return bool(owned & scope)