Files
pablan/backend/app/api/documents/access.py
T
ProfessorNovaandClaude Opus 5 97dbff309c Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
2026-09-04 08:36:17 +02:00

176 lines
6.1 KiB
Python

"""Who may read, edit and publish a document.
The read gate itself lives in `rag/permissions` as SQL, because there is one
place where "which documents may this user see" is decided. What lives here is
everything the HTTP layer needs around it: loading one document through that
gate, the write gates on top of it, and the Python mirror that can judge a
change BEFORE it is committed.
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.errors import ApiError
from app.models import (
AccessReason,
Document,
DocumentStatus,
DocumentVisibility,
User,
UserRole,
)
from app.rag.permissions import readable_documents_filter
BUILTIN_READONLY = (
"Built-in help documents are maintained with the product.",
"builtin_readonly",
)
async def readable_document(
db: AsyncSession, document_id: uuid.UUID, user: User
) -> Document:
"""One document, through the same filter the search uses."""
document = (
await db.execute(
select(Document).where(
Document.id == document_id, readable_documents_filter(user)
)
)
).scalar_one_or_none()
if document is None:
# 404 for unreadable docs: existence must not leak.
raise ApiError(404, "Document not found.", "not_found")
return document
def is_open_reviewer(document: Document, user: User) -> bool:
"""Someone asked this user to check the document and has not been answered.
Being asked is what grants the right to change it: a reviewer who spots a
wrong number should fix it, not file a second question about it.
"""
return any(review.reviewer_id == user.id for review in document.open_reviews)
def can_edit(document: Document, user: User) -> bool:
"""Mirror of `require_editor` — the UI must predict the gate, never guess
it."""
if document.is_builtin:
return False
return (
document.author_id == user.id
or user.role == UserRole.admin
or is_open_reviewer(document, user)
)
def require_editor(document: Document, user: User) -> None:
if document.is_builtin:
# Help pages ship with the product and are re-imported on start;
# an edit here would silently vanish on the next deploy.
raise ApiError(409, *BUILTIN_READONLY)
if not can_edit(document, user):
raise ApiError(403, "Not allowed to modify this document.", "forbidden")
def require_author_or_admin(document: Document, user: User) -> None:
"""Stricter than `require_editor`: for the decisions that belong to the
document's owner, like deleting it or handing out a review request."""
if document.is_builtin:
raise ApiError(409, *BUILTIN_READONLY)
if document.author_id != user.id and user.role != UserRole.admin:
raise ApiError(403, "Not allowed to modify this document.", "forbidden")
def access_reason(document: Document, user: User) -> AccessReason:
"""Most specific reason first: being the author explains access better
than the visibility level does.
Mirrors `readable_documents_filter`, where the visibility rules only apply
to a PUBLISHED document — an unpublished one is visible to its author and
to whoever was asked to check it, and to nobody else. So `review` is the
reason whenever nothing more durable carries the access, which is exactly
the case where the access ends with the answer.
"""
if document.author_id == user.id:
return AccessReason.author
published = document.status == DocumentStatus.published
if published and document.visibility == DocumentVisibility.public:
return AccessReason.public
if (
published
and document.visibility == DocumentVisibility.department
and document.department_id is not None
and document.department_id == user.department_id
):
return AccessReason.department
if is_open_reviewer(document, user):
return AccessReason.review
# Everything else that survived the permission filter came via a grant.
return AccessReason.granted
def user_can_read(
user: User,
*,
author_id: uuid.UUID | None,
visibility: DocumentVisibility,
department_id: uuid.UUID | None,
granted_department_ids: set[uuid.UUID],
) -> bool:
"""The Python mirror of `readable_documents_filter` for one document's
proposed state — so a change can be checked BEFORE it is committed. Admins
get no read-everything bypass (same as the filter).
Kept next to its only caller so the two cannot drift apart unnoticed; the
SQL it mirrors is one import away.
"""
if author_id is not None and author_id == user.id:
return True
if visibility == DocumentVisibility.public:
return True
if (
visibility == DocumentVisibility.department
and department_id is not None
and department_id == user.department_id
):
return True
return (
user.department_id is not None and user.department_id in granted_department_ids
)
def guard_self_lockout(
user: User,
*,
author_id: uuid.UUID | None,
visibility: DocumentVisibility,
department_id: uuid.UUID | None,
granted_department_ids: set[uuid.UUID],
confirm: bool,
) -> None:
"""Refuse (or, for a confirming admin, allow) a change that would remove the
editing user's own read access. An author keeps access as author, so this
only ever bites an admin editing a document they do not own."""
if user_can_read(
user,
author_id=author_id,
visibility=visibility,
department_id=department_id,
granted_department_ids=granted_department_ids,
):
return
if user.role != UserRole.admin:
# A non-author non-admin cannot reach this state through the API; a
# defensive block rather than a silent lockout.
raise ApiError(409, "This change would remove your own access.", "self_lockout")
if not confirm:
raise ApiError(
409,
"You will lose access to this document after this change.",
"self_lockout_warning",
)