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
176 lines
6.1 KiB
Python
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",
|
|
)
|