feat: category scopes, group-admin role, and permission model

- Three category scopes: personal / group / system (watch)
- PascalCase-with-dashes naming convention enforced at backend + frontend
- is_group_admin flag on GroupMembership; PATCH endpoint for admins to toggle it
- Categories router: scope-based list/create/rename/delete with _check_can_manage_cat
- Documents router: delete uses is_admin + can_delete share flag + group-admin check; remove_category requires doc ownership; assign_category accepts group/system categories
- Proxy layers inject x-user-is-admin and x-user-admin-groups headers
- Frontend: ManageCategoriesDialog grouped by scope with lock icons; SourcePanel scope picker + client-side name validation; AdminGroupsPage group-admin checkbox

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-18 22:16:49 +02:00
parent 05d79d3d21
commit fec3953009
22 changed files with 691 additions and 155 deletions
+19
View File
@@ -21,3 +21,22 @@ async def get_user_groups(x_user_groups: str = Header(default="")) -> list[str]:
if not x_user_groups:
return []
return [g.strip() for g in x_user_groups.split(",") if g.strip()]
async def get_user_is_admin(x_user_is_admin: str = Header(default="false")) -> bool:
"""
Extract the superuser flag injected by the main backend proxy.
Returns True only when the header value is exactly "true".
"""
return x_user_is_admin.lower() == "true"
async def get_user_admin_groups(x_user_admin_groups: str = Header(default="")) -> list[str]:
"""
Extract the group IDs for which the current user is a group admin.
Injected by the main backend proxy from GroupMembership.is_group_admin.
Returns an empty list if absent or empty.
"""
if not x_user_admin_groups:
return []
return [g.strip() for g in x_user_admin_groups.split(",") if g.strip()]
@@ -13,6 +13,8 @@ class DocumentCategory(Base):
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="personal", server_default="personal")
group_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+82 -14
View File
@@ -1,5 +1,6 @@
import difflib
import json
import re
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select
@@ -8,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import or_
from app.database import AsyncSessionLocal, get_db
from app.deps import get_user_id
from app.deps import get_user_admin_groups, get_user_groups, get_user_id, get_user_is_admin
from app.models.category import DocumentCategory
from app.models.category_assignment import CategoryAssignment
from app.models.document import Document
@@ -22,14 +23,31 @@ _WATCH_USER_ID = "watch"
_SIMILARITY_THRESHOLD = 0.4
# PascalCase-with-dashes: each word starts with a capital, words joined by '-'
# Valid: Invoices, Vendor-Invoices, Q1-Reports
# Invalid: invoices, Invoice Reports, Invoice-reports
_NAME_RE = re.compile(r'^[A-Z][a-zA-Z0-9]*(-[A-Z][a-zA-Z0-9]*)*$')
def _validate_name(name: str) -> None:
if not _NAME_RE.match(name):
raise HTTPException(
status_code=422,
detail=(
"Category name must start with a capital letter. "
"Multiple words are joined with dashes, each starting with a capital "
"(e.g. Invoices, Vendor-Invoices, Q1-Reports)."
),
)
def _name_similarity(a: str, b: str) -> float:
"""Return similarity score (01) between two category names."""
a_low = a.lower()
b_low = b.lower()
# Word overlap is a strong signal
a_words = set(a_low.split())
b_words = set(b_low.split())
a_words = set(a_low.split("-"))
b_words = set(b_low.split("-"))
if a_words & b_words:
return 0.9
# Fallback: character sequence ratio
@@ -81,15 +99,44 @@ async def _reanalyze_documents_for_new_category(
pass
async def _check_can_manage_cat(
cat: DocumentCategory,
user_id: str,
is_admin: bool,
user_admin_groups: list[str],
) -> None:
"""Raise 403/404 if the current user may not rename or delete this category."""
if is_admin:
return # superuser can manage anything
if cat.scope == "system":
raise HTTPException(status_code=403, detail="Only admins can modify system categories")
if cat.scope == "personal" and cat.user_id != user_id:
raise HTTPException(status_code=404, detail="Category not found")
if cat.scope == "group" and cat.group_id not in user_admin_groups:
raise HTTPException(status_code=403, detail="Only group admins can modify group categories")
@router.get("", response_model=list[CategoryOut])
async def list_categories(
user_id: str = Depends(get_user_id),
user_groups: list[str] = Depends(get_user_groups),
db: AsyncSession = Depends(get_db),
) -> list[DocumentCategory]:
# Include watch-ingested categories so they appear in the sidebar/filter
"""
Return all categories visible to the current user:
- personal (owned by the user)
- group (any group the user belongs to)
- system (watch-ingested, scope='system')
"""
result = await db.execute(
select(DocumentCategory)
.where(or_(DocumentCategory.user_id == user_id, DocumentCategory.user_id == _WATCH_USER_ID))
.where(
or_(
DocumentCategory.user_id == user_id, # personal
DocumentCategory.group_id.in_(user_groups), # group
DocumentCategory.scope == "system", # system
)
)
.order_by(DocumentCategory.name)
)
return result.scalars().all()
@@ -100,18 +147,32 @@ async def create_category(
body: CategoryCreate,
background_tasks: BackgroundTasks,
user_id: str = Depends(get_user_id),
user_groups: list[str] = Depends(get_user_groups),
db: AsyncSession = Depends(get_db),
) -> DocumentCategory:
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="Category name cannot be empty")
_validate_name(name)
if body.group_id:
# User must be a member of the target group
if body.group_id not in user_groups:
raise HTTPException(status_code=403, detail="You are not a member of that group")
cat = DocumentCategory(
user_id=user_id,
name=name[:128],
scope="group",
group_id=body.group_id,
)
else:
cat = DocumentCategory(user_id=user_id, name=name[:128], scope="personal")
cat = DocumentCategory(user_id=user_id, name=name[:128])
db.add(cat)
await db.commit()
await db.refresh(cat)
# Find existing categories with similar names
# Find existing personal categories with similar names for background AI reanalysis
result = await db.execute(
select(DocumentCategory)
.where(DocumentCategory.user_id == user_id)
@@ -140,12 +201,18 @@ async def rename_category(
cat_id: str,
body: CategoryUpdate,
user_id: str = Depends(get_user_id),
is_admin: bool = Depends(get_user_is_admin),
user_admin_groups: list[str] = Depends(get_user_admin_groups),
db: AsyncSession = Depends(get_db),
) -> DocumentCategory:
cat = await _get_user_cat(cat_id, user_id, db)
cat = await _fetch_visible_cat(cat_id, user_id, db)
await _check_can_manage_cat(cat, user_id, is_admin, user_admin_groups)
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="Category name cannot be empty")
_validate_name(name)
cat.name = name[:128]
await db.commit()
await db.refresh(cat)
@@ -156,19 +223,20 @@ async def rename_category(
async def delete_category(
cat_id: str,
user_id: str = Depends(get_user_id),
is_admin: bool = Depends(get_user_is_admin),
user_admin_groups: list[str] = Depends(get_user_admin_groups),
db: AsyncSession = Depends(get_db),
) -> None:
cat = await _get_user_cat(cat_id, user_id, db)
cat = await _fetch_visible_cat(cat_id, user_id, db)
await _check_can_manage_cat(cat, user_id, is_admin, user_admin_groups)
await db.delete(cat)
await db.commit()
async def _get_user_cat(cat_id: str, user_id: str, db: AsyncSession) -> DocumentCategory:
async def _fetch_visible_cat(cat_id: str, user_id: str, db: AsyncSession) -> DocumentCategory:
"""Fetch a category that the user can see (personal/group/system). 404 if absent."""
result = await db.execute(
select(DocumentCategory).where(
DocumentCategory.id == cat_id,
DocumentCategory.user_id == user_id,
)
select(DocumentCategory).where(DocumentCategory.id == cat_id)
)
cat = result.scalar_one_or_none()
if cat is None:
+62 -3
View File
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import AsyncSessionLocal, get_db
from app.deps import get_user_groups, get_user_id
from app.deps import get_user_admin_groups, get_user_groups, get_user_id, get_user_is_admin
from app.models.category import DocumentCategory
from app.models.category_assignment import CategoryAssignment
from app.models.document import Document
@@ -479,14 +479,59 @@ async def reprocess_document(
async def delete_document(
doc_id: str,
user_id: str = Depends(get_user_id),
is_admin: bool = Depends(get_user_is_admin),
user_groups: list[str] = Depends(get_user_groups),
user_admin_groups: list[str] = Depends(get_user_admin_groups),
db: AsyncSession = Depends(get_db),
) -> None:
# Fetch the document (owner, watch, or shared with user's groups)
result = await db.execute(
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
select(Document).where(
Document.id == doc_id,
or_(
Document.user_id == user_id,
Document.user_id == _WATCH_USER_ID,
Document.id.in_(
select(DocumentShare.document_id).where(
DocumentShare.group_id.in_(user_groups)
)
) if user_groups else False,
),
)
)
doc = result.scalar_one_or_none()
if doc is None:
raise HTTPException(status_code=404, detail="Document not found")
is_owner = doc.user_id == user_id
if not is_owner and not is_admin:
# Check: shared with a group where the user has can_delete=True
can_delete_via_share = False
if user_groups:
share_result = await db.execute(
select(DocumentShare).where(
DocumentShare.document_id == doc_id,
DocumentShare.group_id.in_(user_groups),
DocumentShare.can_delete.is_(True),
)
)
can_delete_via_share = share_result.scalar_one_or_none() is not None
# Check: user is a group admin for a group the doc is shared with
can_delete_as_group_admin = False
if user_admin_groups:
admin_share_result = await db.execute(
select(DocumentShare).where(
DocumentShare.document_id == doc_id,
DocumentShare.group_id.in_(user_admin_groups),
)
)
can_delete_as_group_admin = admin_share_result.scalar_one_or_none() is not None
if not can_delete_via_share and not can_delete_as_group_admin:
raise HTTPException(status_code=403, detail="Not allowed to delete this document")
delete_file(doc.file_path)
await db.delete(doc)
await db.commit()
@@ -537,6 +582,7 @@ async def assign_category(
doc_id: str,
cat_id: str,
user_id: str = Depends(get_user_id),
user_groups: list[str] = Depends(get_user_groups),
db: AsyncSession = Depends(get_db),
) -> None:
doc_result = await db.execute(
@@ -548,9 +594,15 @@ async def assign_category(
if doc_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Document not found")
# Accept personal, group (user is a member), or system categories
cat_result = await db.execute(
select(DocumentCategory).where(
DocumentCategory.id == cat_id, DocumentCategory.user_id == user_id
DocumentCategory.id == cat_id,
or_(
DocumentCategory.user_id == user_id,
DocumentCategory.group_id.in_(user_groups) if user_groups else False,
DocumentCategory.scope == "system",
),
)
)
if cat_result.scalar_one_or_none() is None:
@@ -574,6 +626,13 @@ async def remove_category(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db),
) -> None:
# Only the document owner may remove a category assignment
doc_result = await db.execute(
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
)
if doc_result.scalar_one_or_none() is None:
raise HTTPException(status_code=403, detail="Only the document owner can remove category assignments")
result = await db.execute(
select(CategoryAssignment).where(
CategoryAssignment.document_id == doc_id,
@@ -7,6 +7,8 @@ class CategoryOut(BaseModel):
id: str
user_id: str
name: str
scope: str = "personal"
group_id: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
@@ -14,6 +16,7 @@ class CategoryOut(BaseModel):
class CategoryCreate(BaseModel):
name: str
group_id: str | None = None
class CategoryUpdate(BaseModel):