pdf-quiz-generator/backend/app/routers/access.py
Daniel 12453ba473
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 28s
Tests / e2e (push) Failing after 34s
fix: a role is not set here while the provider holds it
Two endpoints still set a role locally — PUT /access/{id}/role and PUT
/admin/users/{id}/role. With OIDC_ROLE_CLAIM configured, my sync applies
the person's groups on every sign-in, so a role set in the app lasts
until they next sign in and is then overwritten. That is the worst kind
of setting: it appears to work, holds for an afternoon, and quietly
undoes itself.

Both now answer 409 naming where the switch actually is. With no mapping
configured they behave exactly as before, which is what a site with no
provider needs.

The two existing tests now patch is_configured explicitly rather than
inheriting whatever the container's .env happens to say — a test that
reads differently on two machines is not a test.

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

190 lines
8.7 KiB
Python

"""Who may edit what — every kind of access in one place.
Access was granted in three separate screens over two tables: category grants
from the question manager, media-library grants from the image bank, and
nothing at all for articles. Nobody could see what one person actually had.
This is one surface over the same tables. It does not invent a new permission
model; it reads and writes the grants that already exist, and adds the one
answer that was missing — "everything", which is not a grant but the moderator
role, and says so rather than pretending otherwise.
Inheritance is already how category grants work: a grant on a branch covers
every category beneath it, the questions filed under them, and now the articles
too.
A folder is the exception to inheritance, and the reason it exists: it covers
exactly the questions somebody put in it. A branch is a description of where a
question is filed, so it keeps covering whatever is filed there next; a folder
is a list, so it covers that list and nothing else.
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.database import get_db
from app.services import sso_roles
from app.models.article import Article
from app.models.category_grant import CategoryGrant
from app.models.folder import QuestionFolder, QuestionFolderQuestion
from app.models.media import MediaAsset, MediaLibrary, MediaLibraryGrant
from app.models.question import Question
from app.models.question_category import QuestionCategory
from app.models.user import User
from app.utils.auth import require_moderator
from app.utils.category_grants import _descendants
router = APIRouter()
class GrantWrite(BaseModel):
kind: str # category | library | folder
target_id: int
class RoleWrite(BaseModel):
#: "moderator" is everything; "user" is only what the grants below cover.
role: str
def _user_json(user: User) -> dict:
return {"id": user.id, "name": user.name, "email": user.email, "role": user.role}
@router.get("/")
def access_overview(db: Session = Depends(get_db), current_user: User = Depends(require_moderator)):
"""Everyone who can be given access, and what each of them holds."""
users = db.query(User).order_by(User.name).all()
category_grants: dict[int, list[int]] = {}
for user_id, category_id in db.query(CategoryGrant.user_id, CategoryGrant.category_id).filter(
CategoryGrant.category_id.isnot(None)).all():
category_grants.setdefault(user_id, []).append(category_id)
library_grants: dict[int, list[int]] = {}
for user_id, library_id in db.query(MediaLibraryGrant.user_id, MediaLibraryGrant.library_id).all():
library_grants.setdefault(user_id, []).append(library_id)
folder_grants: dict[int, list[int]] = {}
for user_id, folder_id in db.query(CategoryGrant.user_id, CategoryGrant.folder_id).filter(
CategoryGrant.folder_id.isnot(None)).all():
folder_grants.setdefault(user_id, []).append(folder_id)
rows = []
for user in users:
categories = category_grants.get(user.id, [])
# What the grants actually reach, once the tree is walked.
covered = _descendants(db, set(categories))
rows.append({
**_user_json(user),
"everything": bool(user.is_moderator),
"categories": sorted(categories),
"categories_covered": len(covered),
"libraries": sorted(library_grants.get(user.id, [])),
"folders": sorted(folder_grants.get(user.id, [])),
})
return {"users": rows}
@router.get("/tree")
def access_tree(db: Session = Depends(get_db), current_user: User = Depends(require_moderator)):
"""What there is to grant: the category tree, the image libraries, the folders."""
direct = dict(db.query(Question.question_category_id, func.count(Question.id))
.filter(Question.question_category_id.isnot(None))
.group_by(Question.question_category_id).all())
articles = dict(db.query(Article.category_id, func.count(Article.id))
.filter(Article.category_id.isnot(None))
.group_by(Article.category_id).all())
categories = [{
"id": cat.id,
"name": cat.name,
"parent_id": cat.parent_id,
"questions": direct.get(cat.id, 0),
"articles": articles.get(cat.id, 0),
} for cat in db.query(QuestionCategory).order_by(QuestionCategory.name).all()]
assets = dict(db.query(MediaAsset.library_id, func.count(MediaAsset.id))
.filter(MediaAsset.library_id.isnot(None))
.group_by(MediaAsset.library_id).all())
libraries = [{"id": lib.id, "name": lib.name, "assets": assets.get(lib.id, 0)}
for lib in db.query(MediaLibrary).order_by(MediaLibrary.name).all()]
held = dict(db.query(QuestionFolderQuestion.folder_id, func.count(QuestionFolderQuestion.id))
.group_by(QuestionFolderQuestion.folder_id).all())
folders = [{"id": folder.id, "name": folder.name, "description": folder.description,
"questions": held.get(folder.id, 0)}
for folder in db.query(QuestionFolder).order_by(QuestionFolder.name).all()]
return {"categories": categories, "libraries": libraries, "folders": folders}
def _target_user(db: Session, user_id: int) -> User:
user = db.get(User, user_id)
if not user:
raise HTTPException(404, "User not found")
return user
@router.post("/{user_id}/grants", status_code=201)
def add_grant(user_id: int, data: GrantWrite, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
user = _target_user(db, user_id)
if user.is_moderator:
raise HTTPException(400, "This person already has everything; a grant would add nothing")
if data.kind == "category":
if not db.get(QuestionCategory, data.target_id):
raise HTTPException(404, "Category not found")
if db.query(CategoryGrant.id).filter_by(category_id=data.target_id, user_id=user_id).first():
raise HTTPException(409, "Already granted")
db.add(CategoryGrant(category_id=data.target_id, user_id=user_id, granted_by=current_user.id))
elif data.kind == "library":
if not db.get(MediaLibrary, data.target_id):
raise HTTPException(404, "Library not found")
if db.query(MediaLibraryGrant.id).filter_by(library_id=data.target_id, user_id=user_id).first():
raise HTTPException(409, "Already granted")
db.add(MediaLibraryGrant(library_id=data.target_id, user_id=user_id, granted_by=current_user.id))
elif data.kind == "folder":
if not db.get(QuestionFolder, data.target_id):
raise HTTPException(404, "Folder not found")
if db.query(CategoryGrant.id).filter_by(folder_id=data.target_id, user_id=user_id).first():
raise HTTPException(409, "Already granted")
db.add(CategoryGrant(folder_id=data.target_id, user_id=user_id, granted_by=current_user.id))
else:
raise HTTPException(400, "kind must be category, library or folder")
db.commit()
return {"granted": data.kind, "target_id": data.target_id, "user_id": user_id}
@router.delete("/{user_id}/grants/{kind}/{target_id}", status_code=204)
def remove_grant(user_id: int, kind: str, target_id: int, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
model = {"category": CategoryGrant, "library": MediaLibraryGrant,
"folder": CategoryGrant}.get(kind)
if model is None:
raise HTTPException(400, "kind must be category, library or folder")
column = {"category": "category_id", "library": "library_id", "folder": "folder_id"}[kind]
grant = db.query(model).filter_by(user_id=user_id, **{column: target_id}).first()
if not grant:
raise HTTPException(404, "Grant not found")
db.delete(grant)
db.commit()
@router.put("/{user_id}/role")
def set_role(user_id: int, data: RoleWrite, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""Give or take away "everything".
This is a role change, not a grant, and the two are kept distinct on
purpose: a wildcard grant that silently meant everything would be the kind
of permission nobody can audit.
"""
sso_roles.refuse_local_role_change()
if data.role not in ("user", "moderator"):
raise HTTPException(400, "role must be user or moderator")
user = _target_user(db, user_id)
if user.role == "admin":
raise HTTPException(400, "An administrator's role is not changed here")
if user.id == current_user.id:
raise HTTPException(400, "Change someone else's access, not your own")
user.role = data.role
db.commit()
return _user_json(user)