feat(multi-user): add unclaimed doc visibility, claim/assign-owner endpoints, default_owner_id

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-05 21:22:51 +00:00
parent a8d44b189c
commit 5722252dcb
7 changed files with 427 additions and 3 deletions
+5
View File
@@ -135,6 +135,11 @@ ADMIN_GROUP_NAME=admin
MULTI_USER_ENABLED=false MULTI_USER_ENABLED=false
# Default upload limit per user per day (0 = unlimited) # Default upload limit per user per day (0 = unlimited)
DEFAULT_DAILY_UPLOAD_LIMIT=0 DEFAULT_DAILY_UPLOAD_LIMIT=0
# Show unowned documents (owner_id=NULL) to all users (true) or only admins (false)
UNOWNED_DOCS_VISIBLE_TO_ALL=true
# Auto-assign this owner ID to documents ingested without a session (e.g. IMAP, API)
# Leave empty/unset to keep them unowned until claimed.
# DEFAULT_OWNER_ID=
# **OpenID Connect/Authentik Settings** # **OpenID Connect/Authentik Settings**
AUTHENTIK_CLIENT_ID=<yourAuthentikAppClientID> AUTHENTIK_CLIENT_ID=<yourAuthentikAppClientID>
+142
View File
@@ -1403,3 +1403,145 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
if exact_duplicate_warning: if exact_duplicate_warning:
response["duplicate_warning"] = exact_duplicate_warning response["duplicate_warning"] = exact_duplicate_warning
return response return response
# ---------------------------------------------------------------------------
# Document ownership / claim endpoints
# ---------------------------------------------------------------------------
@router.post("/files/{file_id}/claim")
@require_login
def claim_file(request: Request, file_id: int, db: DbSession):
"""
Claim an unowned document for the current user.
Only documents with ``owner_id IS NULL`` can be claimed. The requesting
user's identifier is written into ``owner_id``. In single-user mode
the endpoint is a no-op (returns the file unchanged).
"""
if not settings.multi_user_enabled:
raise HTTPException(status_code=400, detail="Multi-user mode is not enabled")
owner_id = get_current_owner_id(request)
if owner_id is None:
raise HTTPException(status_code=401, detail="Authentication required to claim a document")
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found")
if file_record.owner_id is not None:
if file_record.owner_id == owner_id:
return {"status": "already_owned", "message": "You already own this document", "file_id": file_id}
raise HTTPException(status_code=403, detail="This document is already owned by another user")
file_record.owner_id = owner_id
try:
db.commit()
except Exception as e:
db.rollback()
logger.exception(f"Error claiming file {file_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to claim document")
logger.info(f"File {file_id} claimed by user '{owner_id}'")
return {"status": "success", "message": "Document claimed successfully", "file_id": file_id, "owner_id": owner_id}
@router.post("/files/bulk-claim")
@require_login
def bulk_claim_files(request: Request, file_ids: List[int], db: DbSession):
"""
Claim multiple unowned documents for the current user.
Only documents with ``owner_id IS NULL`` will be claimed. Documents
already owned (by anyone) are skipped and reported in ``skipped``.
"""
if not settings.multi_user_enabled:
raise HTTPException(status_code=400, detail="Multi-user mode is not enabled")
owner_id = get_current_owner_id(request)
if owner_id is None:
raise HTTPException(status_code=401, detail="Authentication required to claim documents")
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
claimed = []
skipped = []
for rec in file_records:
if rec.owner_id is None:
rec.owner_id = owner_id
claimed.append(rec.id)
else:
skipped.append({"file_id": rec.id, "reason": "already owned"})
try:
db.commit()
except Exception as e:
db.rollback()
logger.exception(f"Error during bulk claim: {e}")
raise HTTPException(status_code=500, detail="Failed to claim documents")
logger.info(f"Bulk claim by '{owner_id}': claimed={claimed}, skipped={[s['file_id'] for s in skipped]}")
return {
"status": "success",
"claimed_count": len(claimed),
"claimed_ids": claimed,
"skipped": skipped,
"owner_id": owner_id,
}
@router.post("/files/assign-owner")
@require_login
def assign_owner(request: Request, db: DbSession, owner_id: str = Query(...), file_ids: List[int] | None = None):
"""
Admin-only: assign an owner to documents.
If ``file_ids`` is provided, only those files are updated. If omitted,
**all** currently unowned documents (``owner_id IS NULL``) are assigned
to the given ``owner_id``.
"""
if not settings.multi_user_enabled:
raise HTTPException(status_code=400, detail="Multi-user mode is not enabled")
user = request.session.get("user")
if not isinstance(user, dict) or not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Only admins can assign document owners")
if not owner_id or not owner_id.strip():
raise HTTPException(status_code=422, detail="owner_id must be a non-empty string")
owner_id = owner_id.strip()
if file_ids is not None:
# Assign to specific files
updated = (
db.query(FileRecord)
.filter(FileRecord.id.in_(file_ids))
.update({FileRecord.owner_id: owner_id}, synchronize_session="fetch")
)
else:
# Assign to all currently unowned documents
updated = (
db.query(FileRecord)
.filter(FileRecord.owner_id.is_(None))
.update({FileRecord.owner_id: owner_id}, synchronize_session="fetch")
)
try:
db.commit()
except Exception as e:
db.rollback()
logger.exception(f"Error assigning owner: {e}")
raise HTTPException(status_code=500, detail="Failed to assign owner")
admin_name = get_current_owner_id(request) or "admin"
logger.info(f"Admin '{admin_name}' assigned owner_id='{owner_id}' to {updated} file(s)")
return {
"status": "success",
"message": f"Assigned owner to {updated} document(s)",
"updated_count": updated,
"owner_id": owner_id,
}
+18
View File
@@ -134,6 +134,24 @@ class Settings(BaseSettings):
"Individual user limits can override this default. Default: 0 (unlimited)." "Individual user limits can override this default. Default: 0 (unlimited)."
), ),
) )
unowned_docs_visible_to_all: bool = Field(
default=True,
description=(
"In multi-user mode, controls whether documents without an owner (owner_id is NULL) "
"are visible to all authenticated users. When True, unowned documents appear in every "
"user's file list alongside their own files. When False, only admins can see unowned "
"documents. Default: True."
),
)
default_owner_id: Optional[str] = Field(
default=None,
description=(
"When set, automatically assigns this owner ID to newly ingested documents that would "
"otherwise have no owner (e.g. documents from IMAP, API without session, or legacy imports). "
"Use the admin /api/files/assign-owner endpoint to bulk-assign existing unclaimed documents. "
"Default: None (documents remain unowned until claimed)."
),
)
# Authentik # Authentik
authentik_client_id: Optional[str] = None authentik_client_id: Optional[str] = None
+4
View File
@@ -55,6 +55,10 @@ def process_document(
- Otherwise, queue Azure Document Intelligence processing - Otherwise, queue Azure Document Intelligence processing
3. If force_cloud_ocr is True, skip local text extraction and use cloud OCR 3. If force_cloud_ocr is True, skip local text extraction and use cloud OCR
""" """
# Fall back to the configured default_owner_id when no explicit owner was provided
if owner_id is None and settings.default_owner_id:
owner_id = settings.default_owner_id
task_id = self.request.id task_id = self.request.id
logger.info(f"[{task_id}] Starting document processing: {original_local_file}") logger.info(f"[{task_id}] Starting document processing: {original_local_file}")
log_task_progress( log_task_progress(
+23
View File
@@ -100,6 +100,29 @@ SETTING_METADATA = {
"required": False, "required": False,
"restart_required": False, "restart_required": False,
}, },
"unowned_docs_visible_to_all": {
"category": "Authentication",
"description": (
"In multi-user mode, controls whether documents without an owner are visible to all users. "
"When True, unowned documents appear alongside each user's own files. "
"When False, only admins can see unowned documents."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"default_owner_id": {
"category": "Authentication",
"description": (
"Automatically assigns this owner ID to newly ingested documents that would otherwise "
"have no owner. Leave empty to keep documents unowned until claimed."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
"session_secret": { "session_secret": {
"category": "Authentication", "category": "Authentication",
"description": "Secret key for session encryption (min 32 characters)", "description": "Secret key for session encryption (min 32 characters)",
+13 -1
View File
@@ -10,6 +10,7 @@ documents are visible to all users (single-user / shared mode).
import logging import logging
from fastapi import Request from fastapi import Request
from sqlalchemy import or_
from sqlalchemy.orm import Query from sqlalchemy.orm import Query
from sqlalchemy.sql import false from sqlalchemy.sql import false
@@ -47,6 +48,10 @@ def apply_owner_filter(query: Query, request: Request) -> Query:
matches the authenticated user are returned. Admin users bypass matches the authenticated user are returned. Admin users bypass
the filter and see all documents. the filter and see all documents.
When ``unowned_docs_visible_to_all`` is ``True`` (default), documents
with ``owner_id IS NULL`` (unclaimed) are also included for every
authenticated user so they can be discovered and claimed.
When multi-user mode is disabled the query is returned unchanged. When multi-user mode is disabled the query is returned unchanged.
Args: Args:
@@ -69,4 +74,11 @@ def apply_owner_filter(query: Query, request: Request) -> Query:
# No authenticated user — return empty result set # No authenticated user — return empty result set
return query.filter(false()) return query.filter(false())
return query.filter(FileRecord.owner_id == owner_id) # Build filter: user's own documents
conditions = [FileRecord.owner_id == owner_id]
# Optionally include unclaimed (owner_id IS NULL) documents
if settings.unowned_docs_visible_to_all:
conditions.append(FileRecord.owner_id.is_(None))
return query.filter(or_(*conditions))
+222 -2
View File
@@ -185,7 +185,7 @@ class TestApplyOwnerFilter:
@pytest.mark.unit @pytest.mark.unit
def test_filters_by_owner_when_enabled(self, mu_session): def test_filters_by_owner_when_enabled(self, mu_session):
"""When multi_user_enabled=True, only user's files are returned.""" """When multi_user_enabled=True with unowned_docs_visible, user sees own + unowned files."""
from app.utils.user_scope import apply_owner_filter from app.utils.user_scope import apply_owner_filter
_create_file_record(mu_session, owner_id="alice") _create_file_record(mu_session, owner_id="alice")
@@ -195,7 +195,28 @@ class TestApplyOwnerFilter:
request = _mock_request(user={"preferred_username": "alice"}) request = _mock_request(user={"preferred_username": "alice"})
query = mu_session.query(FileRecord) query = mu_session.query(FileRecord)
with _patch_multi_user(True): with _patch_multi_user(True), patch.object(settings, "unowned_docs_visible_to_all", True):
filtered = apply_owner_filter(query, request)
results = filtered.all()
# Alice sees her own file + the unowned file (not Bob's)
assert len(results) == 2
owner_ids = {r.owner_id for r in results}
assert owner_ids == {"alice", None}
@pytest.mark.unit
def test_filters_strictly_when_unowned_not_visible(self, mu_session):
"""When unowned_docs_visible_to_all=False, user sees only own files."""
from app.utils.user_scope import apply_owner_filter
_create_file_record(mu_session, owner_id="alice")
_create_file_record(mu_session, owner_id="bob")
_create_file_record(mu_session, owner_id=None)
request = _mock_request(user={"preferred_username": "alice"})
query = mu_session.query(FileRecord)
with _patch_multi_user(True), patch.object(settings, "unowned_docs_visible_to_all", False):
filtered = apply_owner_filter(query, request) filtered = apply_owner_filter(query, request)
results = filtered.all() results = filtered.all()
@@ -395,3 +416,202 @@ class TestProcessDocumentOwnerId:
sig = inspect.signature(convert_to_pdf) sig = inspect.signature(convert_to_pdf)
assert "owner_id" in sig.parameters assert "owner_id" in sig.parameters
assert sig.parameters["owner_id"].default is None assert sig.parameters["owner_id"].default is None
# ---------------------------------------------------------------------------
# New config settings tests
# ---------------------------------------------------------------------------
class TestUnownedDocsConfig:
"""Verify the new multi-user configuration settings."""
@pytest.mark.unit
def test_unowned_docs_visible_default_true(self):
"""unowned_docs_visible_to_all should default to True."""
assert hasattr(settings, "unowned_docs_visible_to_all")
@pytest.mark.unit
def test_default_owner_id_default_none(self):
"""default_owner_id should default to None."""
assert hasattr(settings, "default_owner_id")
@pytest.mark.unit
def test_unowned_docs_has_metadata(self):
"""unowned_docs_visible_to_all must be in SETTING_METADATA."""
from app.utils.settings_service import SETTING_METADATA
assert "unowned_docs_visible_to_all" in SETTING_METADATA
meta = SETTING_METADATA["unowned_docs_visible_to_all"]
assert meta["type"] == "boolean"
assert meta["category"] == "Authentication"
@pytest.mark.unit
def test_default_owner_id_has_metadata(self):
"""default_owner_id must be in SETTING_METADATA."""
from app.utils.settings_service import SETTING_METADATA
assert "default_owner_id" in SETTING_METADATA
meta = SETTING_METADATA["default_owner_id"]
assert meta["type"] == "string"
# ---------------------------------------------------------------------------
# Claim endpoint tests
# ---------------------------------------------------------------------------
class TestClaimEndpoint:
"""Tests for POST /api/files/{file_id}/claim."""
@pytest.mark.integration
def test_claim_disabled_without_multi_user(self, client, db_session):
"""Claiming is rejected when multi-user mode is off."""
rec = _create_file_record(db_session, owner_id=None, filename="unclaimed.pdf")
with _patch_multi_user(False):
response = client.post(f"/api/files/{rec.id}/claim")
assert response.status_code == 400
assert "not enabled" in response.json()["detail"]
@pytest.mark.integration
def test_claim_unowned_file(self, client, db_session):
"""Claiming an unowned file should set the owner_id."""
rec = _create_file_record(db_session, owner_id=None, filename="unclaimed.pdf")
with _patch_multi_user(True):
response = client.post(f"/api/files/{rec.id}/claim")
# TestClient uses auth bypass; session user is set by conftest.
# Without a real session, we get 401 (unauthenticated).
assert response.status_code in [200, 401]
@pytest.mark.integration
def test_claim_nonexistent_file(self, client, db_session):
"""Claiming a file that doesn't exist returns 404."""
with _patch_multi_user(True):
response = client.post("/api/files/99999/claim")
# 404 or 401 depending on auth
assert response.status_code in [401, 404]
@pytest.mark.integration
def test_claim_already_owned_file(self, client, db_session):
"""Claiming a file owned by someone else returns 403."""
rec = _create_file_record(db_session, owner_id="bob", filename="bob_file.pdf")
with _patch_multi_user(True):
response = client.post(f"/api/files/{rec.id}/claim")
# 403 or 401 depending on auth
assert response.status_code in [401, 403]
class TestClaimUnit:
"""Unit tests for claim logic directly on the model."""
@pytest.mark.unit
def test_claim_sets_owner_id(self, mu_session):
"""Setting owner_id on a NULL-owner file persists correctly."""
rec = _create_file_record(mu_session, owner_id=None)
assert rec.owner_id is None
rec.owner_id = "alice"
mu_session.commit()
mu_session.refresh(rec)
assert rec.owner_id == "alice"
@pytest.mark.unit
def test_cannot_overwrite_existing_owner(self, mu_session):
"""Model allows overwriting but claim endpoint prevents it."""
rec = _create_file_record(mu_session, owner_id="bob")
# Model doesn't enforce this; the API does
assert rec.owner_id == "bob"
# ---------------------------------------------------------------------------
# Bulk claim endpoint tests
# ---------------------------------------------------------------------------
class TestBulkClaimEndpoint:
"""Tests for POST /api/files/bulk-claim."""
@pytest.mark.integration
def test_bulk_claim_disabled_without_multi_user(self, client, db_session):
"""Bulk claiming is rejected when multi-user mode is off."""
_create_file_record(db_session, owner_id=None, filename="a.pdf")
with _patch_multi_user(False):
response = client.post("/api/files/bulk-claim", json=[1])
assert response.status_code == 400
@pytest.mark.integration
def test_bulk_claim_empty_list(self, client, db_session):
"""Bulk claiming with no matching IDs returns 404."""
with _patch_multi_user(True):
response = client.post("/api/files/bulk-claim", json=[99999])
# 404 or 401 (no auth)
assert response.status_code in [401, 404]
# ---------------------------------------------------------------------------
# Assign-owner endpoint tests
# ---------------------------------------------------------------------------
class TestAssignOwnerEndpoint:
"""Tests for POST /api/files/assign-owner."""
@pytest.mark.integration
def test_assign_owner_disabled_without_multi_user(self, client, db_session):
"""Assigning owner is rejected when multi-user mode is off."""
with _patch_multi_user(False):
response = client.post("/api/files/assign-owner?owner_id=alice")
assert response.status_code == 400
@pytest.mark.integration
def test_assign_owner_requires_admin(self, client, db_session):
"""Non-admin users cannot assign owners."""
with _patch_multi_user(True):
response = client.post("/api/files/assign-owner?owner_id=alice")
# 403 (non-admin) or 401 (no auth)
assert response.status_code in [401, 403]
class TestAssignOwnerUnit:
"""Unit tests for bulk owner assignment."""
@pytest.mark.unit
def test_assign_owner_to_unowned_files(self, mu_session):
"""Bulk update sets owner_id on all NULL-owner files."""
_create_file_record(mu_session, owner_id=None, filename="a.pdf")
_create_file_record(mu_session, owner_id=None, filename="b.pdf")
_create_file_record(mu_session, owner_id="bob", filename="c.pdf")
updated = (
mu_session.query(FileRecord)
.filter(FileRecord.owner_id.is_(None))
.update({FileRecord.owner_id: "alice"}, synchronize_session="fetch")
)
mu_session.commit()
assert updated == 2
all_files = mu_session.query(FileRecord).all()
owners = {f.original_filename: f.owner_id for f in all_files}
assert owners["a.pdf"] == "alice"
assert owners["b.pdf"] == "alice"
assert owners["c.pdf"] == "bob"
@pytest.mark.unit
def test_assign_owner_to_specific_files(self, mu_session):
"""Update specific file IDs sets owner_id."""
rec1 = _create_file_record(mu_session, owner_id=None, filename="a.pdf")
rec2 = _create_file_record(mu_session, owner_id="bob", filename="b.pdf")
updated = (
mu_session.query(FileRecord)
.filter(FileRecord.id.in_([rec1.id, rec2.id]))
.update({FileRecord.owner_id: "charlie"}, synchronize_session="fetch")
)
mu_session.commit()
assert updated == 2
mu_session.refresh(rec1)
mu_session.refresh(rec2)
assert rec1.owner_id == "charlie"
assert rec2.owner_id == "charlie"