"""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", )