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
190 lines
8.7 KiB
Python
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)
|