feat(multi-user): add multi-user feature flag, owner_id model field, and user-scoped queries
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -129,6 +129,13 @@ ADMIN_USERNAME=admin
|
|||||||
ADMIN_PASSWORD=your_secure_password
|
ADMIN_PASSWORD=your_secure_password
|
||||||
ADMIN_GROUP_NAME=admin
|
ADMIN_GROUP_NAME=admin
|
||||||
|
|
||||||
|
# **Multi-User Mode**
|
||||||
|
# When enabled, each user has their own document space with isolated uploads,
|
||||||
|
# search, and file management. Requires AUTH_ENABLED=true.
|
||||||
|
MULTI_USER_ENABLED=false
|
||||||
|
# Default upload limit per user per day (0 = unlimited)
|
||||||
|
DEFAULT_DAILY_UPLOAD_LIMIT=0
|
||||||
|
|
||||||
# **OpenID Connect/Authentik Settings**
|
# **OpenID Connect/Authentik Settings**
|
||||||
AUTHENTIK_CLIENT_ID=<yourAuthentikAppClientID>
|
AUTHENTIK_CLIENT_ID=<yourAuthentikAppClientID>
|
||||||
AUTHENTIK_CLIENT_SECRET=<yourAuthentikClientSecret>
|
AUTHENTIK_CLIENT_SECRET=<yourAuthentikClientSecret>
|
||||||
|
|||||||
+26
-10
@@ -99,8 +99,11 @@ def list_files_api(
|
|||||||
validate_sort_order(sort_order)
|
validate_sort_order(sort_order)
|
||||||
search = validate_search_query(search)
|
search = validate_search_query(search)
|
||||||
|
|
||||||
# Start with base query
|
# Start with base query, scoped to the current user in multi-user mode
|
||||||
|
from app.utils.user_scope import apply_owner_filter
|
||||||
|
|
||||||
query = db.query(FileRecord)
|
query = db.query(FileRecord)
|
||||||
|
query = apply_owner_filter(query, request)
|
||||||
|
|
||||||
# Apply search filter
|
# Apply search filter
|
||||||
if search:
|
if search:
|
||||||
@@ -241,8 +244,12 @@ def get_file_details(request: Request, file_id: int, db: DbSession):
|
|||||||
"""
|
"""
|
||||||
Get detailed information about a specific file including processing history.
|
Get detailed information about a specific file including processing history.
|
||||||
"""
|
"""
|
||||||
# Find the file record
|
# Find the file record, scoped to the current user in multi-user mode
|
||||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
from app.utils.user_scope import apply_owner_filter
|
||||||
|
|
||||||
|
query = db.query(FileRecord).filter(FileRecord.id == file_id)
|
||||||
|
query = apply_owner_filter(query, request)
|
||||||
|
file_record = query.first()
|
||||||
|
|
||||||
if not file_record:
|
if not file_record:
|
||||||
raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found")
|
raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found")
|
||||||
@@ -300,8 +307,12 @@ def delete_file_record(request: Request, file_id: int, db: DbSession):
|
|||||||
raise HTTPException(status_code=403, detail="File deletion is disabled in the configuration")
|
raise HTTPException(status_code=403, detail="File deletion is disabled in the configuration")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Find the file record
|
# Find the file record, scoped to the current user in multi-user mode
|
||||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
from app.utils.user_scope import apply_owner_filter
|
||||||
|
|
||||||
|
query = db.query(FileRecord).filter(FileRecord.id == file_id)
|
||||||
|
query = apply_owner_filter(query, request)
|
||||||
|
file_record = query.first()
|
||||||
|
|
||||||
if not file_record:
|
if not file_record:
|
||||||
raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found")
|
raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found")
|
||||||
@@ -1286,6 +1297,11 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
|
|||||||
mime_type, _ = mimetypes.guess_type(target_path)
|
mime_type, _ = mimetypes.guess_type(target_path)
|
||||||
file_ext = os.path.splitext(target_path)[1].lower()
|
file_ext = os.path.splitext(target_path)[1].lower()
|
||||||
|
|
||||||
|
# Determine the owner_id for multi-user document isolation
|
||||||
|
from app.utils.user_scope import get_current_owner_id
|
||||||
|
|
||||||
|
upload_owner_id = get_current_owner_id(request) if settings.multi_user_enabled else None
|
||||||
|
|
||||||
# Check if it's a PDF by extension or MIME type
|
# Check if it's a PDF by extension or MIME type
|
||||||
is_pdf = file_ext == ".pdf" or mime_type == "application/pdf"
|
is_pdf = file_ext == ".pdf" or mime_type == "application/pdf"
|
||||||
|
|
||||||
@@ -1310,7 +1326,7 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
|
|||||||
task_ids = []
|
task_ids = []
|
||||||
for split_file in split_files:
|
for split_file in split_files:
|
||||||
split_filename = os.path.basename(split_file)
|
split_filename = os.path.basename(split_file)
|
||||||
task = process_document.delay(split_file, original_filename=split_filename)
|
task = process_document.delay(split_file, original_filename=split_filename, owner_id=upload_owner_id)
|
||||||
task_ids.append(task.id)
|
task_ids.append(task.id)
|
||||||
logger.info(f"Enqueued split PDF part for processing: {split_file}")
|
logger.info(f"Enqueued split PDF part for processing: {split_file}")
|
||||||
|
|
||||||
@@ -1333,7 +1349,7 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
|
|||||||
|
|
||||||
if is_pdf and not should_split:
|
if is_pdf and not should_split:
|
||||||
# If it's a PDF, process directly
|
# If it's a PDF, process directly
|
||||||
task = process_document.delay(target_path, original_filename=safe_filename)
|
task = process_document.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id)
|
||||||
logger.info(f"Enqueued PDF for processing: {target_path}")
|
logger.info(f"Enqueued PDF for processing: {target_path}")
|
||||||
elif mime_type in IMAGE_MIME_TYPES or file_ext in {
|
elif mime_type in IMAGE_MIME_TYPES or file_ext in {
|
||||||
".jpg",
|
".jpg",
|
||||||
@@ -1347,16 +1363,16 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
|
|||||||
".svg",
|
".svg",
|
||||||
}:
|
}:
|
||||||
# If it's an image, convert to PDF first
|
# If it's an image, convert to PDF first
|
||||||
task = convert_to_pdf.delay(target_path, original_filename=safe_filename)
|
task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id)
|
||||||
logger.info(f"Enqueued image for PDF conversion: {target_path}")
|
logger.info(f"Enqueued image for PDF conversion: {target_path}")
|
||||||
elif mime_type in ALLOWED_MIME_TYPES or file_ext in ALLOWED_EXTENSIONS:
|
elif mime_type in ALLOWED_MIME_TYPES or file_ext in ALLOWED_EXTENSIONS:
|
||||||
# Office document, HTML, Markdown, or other Gotenberg-supported format
|
# Office document, HTML, Markdown, or other Gotenberg-supported format
|
||||||
task = convert_to_pdf.delay(target_path, original_filename=safe_filename)
|
task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id)
|
||||||
logger.info(f"Enqueued document for PDF conversion: {target_path}")
|
logger.info(f"Enqueued document for PDF conversion: {target_path}")
|
||||||
else:
|
else:
|
||||||
# For any other file type, attempt conversion but log a warning
|
# For any other file type, attempt conversion but log a warning
|
||||||
logger.warning(f"Unsupported MIME type {mime_type} for {target_path}, attempting conversion")
|
logger.warning(f"Unsupported MIME type {mime_type} for {target_path}, attempting conversion")
|
||||||
task = convert_to_pdf.delay(target_path, original_filename=safe_filename)
|
task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id)
|
||||||
|
|
||||||
# Check for exact duplicates (same SHA-256 hash) before returning.
|
# Check for exact duplicates (same SHA-256 hash) before returning.
|
||||||
# This gives the caller an immediate warning without waiting for the pipeline.
|
# This gives the caller an immediate warning without waiting for the pipeline.
|
||||||
|
|||||||
@@ -116,6 +116,25 @@ class Settings(BaseSettings):
|
|||||||
session_secret: Optional[str] = None
|
session_secret: Optional[str] = None
|
||||||
admin_group_name: str = "admin"
|
admin_group_name: str = "admin"
|
||||||
|
|
||||||
|
# Multi-user settings
|
||||||
|
multi_user_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Enable multi-user mode with individual document spaces per user. "
|
||||||
|
"When enabled, each authenticated user sees only their own documents, "
|
||||||
|
"uploads, and search results. Shared settings (AI, OCR) remain global. "
|
||||||
|
"Requires auth_enabled=True. Default: False (single-user/shared mode)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
default_daily_upload_limit: int = Field(
|
||||||
|
default=0,
|
||||||
|
description=(
|
||||||
|
"Default maximum number of document uploads allowed per user per day "
|
||||||
|
"in multi-user mode. Set to 0 for unlimited. "
|
||||||
|
"Individual user limits can override this default. Default: 0 (unlimited)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Authentik
|
# Authentik
|
||||||
authentik_client_id: Optional[str] = None
|
authentik_client_id: Optional[str] = None
|
||||||
authentik_client_secret: Optional[str] = None
|
authentik_client_secret: Optional[str] = None
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class FileRecord(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Owner identifier for multi-user mode.
|
||||||
|
# Stores the user's unique identifier (e.g. email or OAuth sub claim).
|
||||||
|
# NULL means the file belongs to the shared/global space (single-user mode).
|
||||||
|
owner_id = Column(String, nullable=True, index=True)
|
||||||
|
|
||||||
# Hash of the file content (e.g. SHA-256)
|
# Hash of the file content (e.g. SHA-256)
|
||||||
# Note: duplicates are allowed so filehash is not unique
|
# Note: duplicates are allowed so filehash is not unique
|
||||||
filehash = Column(String, index=True, nullable=False)
|
filehash = Column(String, index=True, nullable=False)
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ def _build_filename(file_path: str, original_filename: Optional[str], file_ext:
|
|||||||
|
|
||||||
|
|
||||||
@shared_task(bind=True)
|
@shared_task(bind=True)
|
||||||
def convert_to_pdf(self, file_path: str, original_filename: Optional[str] = None) -> Optional[str]:
|
def convert_to_pdf(
|
||||||
|
self, file_path: str, original_filename: Optional[str] = None, owner_id: Optional[str] = None
|
||||||
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Converts a file to PDF using Gotenberg's API.
|
Converts a file to PDF using Gotenberg's API.
|
||||||
Determines the appropriate Gotenberg endpoint based on the file's MIME type.
|
Determines the appropriate Gotenberg endpoint based on the file's MIME type.
|
||||||
@@ -133,6 +135,7 @@ def convert_to_pdf(self, file_path: str, original_filename: Optional[str] = None
|
|||||||
Args:
|
Args:
|
||||||
file_path: Path to the file to convert
|
file_path: Path to the file to convert
|
||||||
original_filename: Optional original filename (if different from path basename)
|
original_filename: Optional original filename (if different from path basename)
|
||||||
|
owner_id: Optional user identifier forwarded to process_document for multi-user mode.
|
||||||
"""
|
"""
|
||||||
task_id = self.request.id
|
task_id = self.request.id
|
||||||
logger.info(f"[{task_id}] Starting PDF conversion: {file_path}")
|
logger.info(f"[{task_id}] Starting PDF conversion: {file_path}")
|
||||||
@@ -332,9 +335,9 @@ def convert_to_pdf(self, file_path: str, original_filename: Optional[str] = None
|
|||||||
# Change extension to .pdf for the original filename
|
# Change extension to .pdf for the original filename
|
||||||
original_base = os.path.splitext(original_filename)[0]
|
original_base = os.path.splitext(original_filename)[0]
|
||||||
pdf_original_filename = f"{original_base}.pdf"
|
pdf_original_filename = f"{original_base}.pdf"
|
||||||
process_document.delay(converted_file_path, original_filename=pdf_original_filename)
|
process_document.delay(converted_file_path, original_filename=pdf_original_filename, owner_id=owner_id)
|
||||||
else:
|
else:
|
||||||
process_document.delay(converted_file_path)
|
process_document.delay(converted_file_path, owner_id=owner_id)
|
||||||
|
|
||||||
return converted_file_path
|
return converted_file_path
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def process_document(
|
def process_document(
|
||||||
self, original_local_file: str, original_filename: str = None, file_id: int = None, force_cloud_ocr: bool = False
|
self,
|
||||||
|
original_local_file: str,
|
||||||
|
original_filename: str = None,
|
||||||
|
file_id: int = None,
|
||||||
|
force_cloud_ocr: bool = False,
|
||||||
|
owner_id: str = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Process a document file and trigger appropriate text extraction.
|
Process a document file and trigger appropriate text extraction.
|
||||||
@@ -37,6 +42,8 @@ def process_document(
|
|||||||
detection and reuses the existing record (used for reprocessing).
|
detection and reuses the existing record (used for reprocessing).
|
||||||
force_cloud_ocr: If True, forces Azure Document Intelligence OCR processing
|
force_cloud_ocr: If True, forces Azure Document Intelligence OCR processing
|
||||||
regardless of embedded text quality. Used for re-processing.
|
regardless of embedded text quality. Used for re-processing.
|
||||||
|
owner_id: Optional user identifier for multi-user mode. When provided, the
|
||||||
|
created FileRecord is associated with this user.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Check if we have a FileRecord entry (via SHA-256 hash). If found, skip re-processing.
|
1. Check if we have a FileRecord entry (via SHA-256 hash). If found, skip re-processing.
|
||||||
@@ -141,6 +148,7 @@ def process_document(
|
|||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
is_duplicate=True,
|
is_duplicate=True,
|
||||||
duplicate_of_id=existing.id,
|
duplicate_of_id=existing.id,
|
||||||
|
owner_id=owner_id,
|
||||||
)
|
)
|
||||||
db.add(duplicate_record)
|
db.add(duplicate_record)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -191,6 +199,7 @@ def process_document(
|
|||||||
file_size=file_size,
|
file_size=file_size,
|
||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
is_duplicate=False,
|
is_duplicate=False,
|
||||||
|
owner_id=owner_id,
|
||||||
)
|
)
|
||||||
db.add(new_record)
|
db.add(new_record)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -78,6 +78,28 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": True,
|
"restart_required": True,
|
||||||
},
|
},
|
||||||
|
"multi_user_enabled": {
|
||||||
|
"category": "Authentication",
|
||||||
|
"description": (
|
||||||
|
"Enable multi-user mode with individual document spaces per user. "
|
||||||
|
"Each user sees only their own documents, uploads, and search results. "
|
||||||
|
"Requires auth_enabled=True."
|
||||||
|
),
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"default_daily_upload_limit": {
|
||||||
|
"category": "Authentication",
|
||||||
|
"description": (
|
||||||
|
"Default maximum document uploads allowed per user per day in multi-user mode. Set to 0 for unlimited."
|
||||||
|
),
|
||||||
|
"type": "integer",
|
||||||
|
"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)",
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
User-scoping utilities for multi-user document isolation.
|
||||||
|
|
||||||
|
When ``multi_user_enabled`` is ``True`` in settings, every document query
|
||||||
|
is filtered by the authenticated user's identifier so that each user sees
|
||||||
|
only their own documents. When the flag is ``False`` (default), all
|
||||||
|
documents are visible to all users (single-user / shared mode).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from sqlalchemy.orm import Query
|
||||||
|
|
||||||
|
from app.models import FileRecord
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_owner_id(request: Request) -> str | None:
|
||||||
|
"""Extract the owner identifier for the current authenticated user.
|
||||||
|
|
||||||
|
The owner ID is derived from the user's session data. It uses the
|
||||||
|
``sub`` claim (OAuth subject) when available, falling back to
|
||||||
|
``preferred_username`` or ``email``. Returns ``None`` when no user
|
||||||
|
is authenticated.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The current FastAPI request with session data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A stable string identifier for the user, or ``None``.
|
||||||
|
"""
|
||||||
|
user = request.session.get("user")
|
||||||
|
if not user or not isinstance(user, dict):
|
||||||
|
return None
|
||||||
|
# Prefer 'sub' (OAuth subject), then 'preferred_username', then 'email', then 'id'
|
||||||
|
return user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_owner_filter(query: Query, request: Request) -> Query:
|
||||||
|
"""Conditionally filter a ``FileRecord`` query by the current user.
|
||||||
|
|
||||||
|
When multi-user mode is enabled, only files whose ``owner_id``
|
||||||
|
matches the authenticated user are returned. Admin users bypass
|
||||||
|
the filter and see all documents.
|
||||||
|
|
||||||
|
When multi-user mode is disabled the query is returned unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: A SQLAlchemy query selecting ``FileRecord`` rows.
|
||||||
|
request: The current FastAPI request (for session inspection).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The (possibly filtered) query.
|
||||||
|
"""
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
if not settings.multi_user_enabled:
|
||||||
|
return query
|
||||||
|
|
||||||
|
user = request.session.get("user")
|
||||||
|
if isinstance(user, dict) and user.get("is_admin"):
|
||||||
|
# Admins see all documents in multi-user mode
|
||||||
|
return query
|
||||||
|
|
||||||
|
owner_id = get_current_owner_id(request)
|
||||||
|
if owner_id is None:
|
||||||
|
# No authenticated user — return empty result set
|
||||||
|
return query.filter(FileRecord.id < 0)
|
||||||
|
|
||||||
|
return query.filter(FileRecord.owner_id == owner_id)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Add multi-user support: owner_id column on files table
|
||||||
|
|
||||||
|
Revision ID: 012_add_multi_user_support
|
||||||
|
Revises: 011_add_pdfa_paths
|
||||||
|
Create Date: 2026-03-05
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "012_add_multi_user_support"
|
||||||
|
down_revision: Union[str, None] = "011_add_pdfa_paths"
|
||||||
|
depends_on: Union[str, None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add owner_id column to files table for multi-user document isolation."""
|
||||||
|
op.add_column("files", sa.Column("owner_id", sa.String(), nullable=True))
|
||||||
|
op.create_index("ix_files_owner_id", "files", ["owner_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove owner_id column from files table."""
|
||||||
|
op.drop_index("ix_files_owner_id", table_name="files")
|
||||||
|
op.drop_column("files", "owner_id")
|
||||||
Reference in New Issue
Block a user