Harden auth and security controls with session auth and docs

This commit is contained in:
2026-03-01 15:29:09 -03:00
parent 7a19f22f41
commit 0242e061c2
36 changed files with 1794 additions and 505 deletions

53
.env.example Normal file
View File

@@ -0,0 +1,53 @@
# LedgerDock environment template
# Copy to .env and adjust all secret values before first run.
# Development defaults (HTTP local stack)
APP_ENV=development
HOST_BIND_IP=127.0.0.1
POSTGRES_USER=dcm
POSTGRES_PASSWORD=ChangeMe-Postgres-Secret
POSTGRES_DB=dcm
DATABASE_URL=postgresql+psycopg://dcm:ChangeMe-Postgres-Secret@db:5432/dcm
REDIS_PASSWORD=ChangeMe-Redis-Secret
REDIS_URL=redis://:ChangeMe-Redis-Secret@redis:6379/0
REDIS_SECURITY_MODE=compat
REDIS_TLS_MODE=allow_insecure
AUTH_BOOTSTRAP_ADMIN_USERNAME=admin
AUTH_BOOTSTRAP_ADMIN_PASSWORD=ChangeMe-Admin-Password
AUTH_BOOTSTRAP_USER_USERNAME=user
AUTH_BOOTSTRAP_USER_PASSWORD=ChangeMe-User-Password
APP_SETTINGS_ENCRYPTION_KEY=ChangeMe-Settings-Encryption-Key
TYPESENSE_API_KEY=ChangeMe-Typesense-Key
PROCESSING_LOG_STORE_MODEL_IO_TEXT=false
PROCESSING_LOG_STORE_PAYLOAD_TEXT=false
CONTENT_EXPORT_MAX_DOCUMENTS=250
CONTENT_EXPORT_MAX_TOTAL_BYTES=52428800
CONTENT_EXPORT_RATE_LIMIT_PER_MINUTE=6
PROVIDER_BASE_URL_ALLOW_HTTP=true
PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK=true
PROVIDER_BASE_URL_ALLOWLIST=[]
PUBLIC_BASE_URL=http://localhost:8000
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
CORS_ALLOW_CREDENTIALS=false
VITE_API_BASE=
# Production baseline overrides (set explicitly for live deployments):
# APP_ENV=production
# HOST_BIND_IP=127.0.0.1
# REDIS_URL=rediss://:<strong-password>@redis.example.internal:6379/0
# REDIS_SECURITY_MODE=strict
# REDIS_TLS_MODE=required
# PROVIDER_BASE_URL_ALLOW_HTTP=false
# PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK=false
# PROVIDER_BASE_URL_ALLOWLIST=["api.openai.com"]
# PUBLIC_BASE_URL=https://api.example.com
# CORS_ORIGINS=["https://app.example.com"]
# CORS_ALLOW_CREDENTIALS=false
# VITE_API_BASE=https://api.example.com/api/v1

View File

@@ -50,17 +50,23 @@ Before first run, set required secrets and connection values in `.env` (or your
- `DATABASE_URL`
- `REDIS_PASSWORD`
- `REDIS_URL`
- `ADMIN_API_TOKEN`
- `USER_API_TOKEN`
- `AUTH_BOOTSTRAP_ADMIN_USERNAME`
- `AUTH_BOOTSTRAP_ADMIN_PASSWORD`
- optional `AUTH_BOOTSTRAP_USER_USERNAME`
- optional `AUTH_BOOTSTRAP_USER_PASSWORD`
- `APP_SETTINGS_ENCRYPTION_KEY`
- `TYPESENSE_API_KEY`
Start from `.env.example` to avoid missing required variables.
Open:
- Frontend: `http://localhost:5173`
- API docs: `http://localhost:8000/docs`
- Health: `http://localhost:8000/api/v1/health`
Use bootstrap credentials (`AUTH_BOOTSTRAP_ADMIN_USERNAME` and `AUTH_BOOTSTRAP_ADMIN_PASSWORD`) to sign in from the frontend login screen.
Stop the stack:
```bash
@@ -115,8 +121,8 @@ cd frontend && npm run preview
Main runtime variables are defined in `docker-compose.yml`:
- API and worker: `DATABASE_URL`, `REDIS_URL`, `REDIS_SECURITY_MODE`, `REDIS_TLS_MODE`, `STORAGE_ROOT`, `PUBLIC_BASE_URL`, `CORS_ORIGINS`, `ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS`, `TYPESENSE_*`, `APP_SETTINGS_ENCRYPTION_KEY`
- Frontend: optional `VITE_API_BASE`, optional `VITE_API_TOKEN` compatibility fallback
- API and worker: `DATABASE_URL`, `REDIS_URL`, `REDIS_SECURITY_MODE`, `REDIS_TLS_MODE`, `STORAGE_ROOT`, `PUBLIC_BASE_URL`, `CORS_ORIGINS`, `CORS_ALLOW_CREDENTIALS`, `AUTH_BOOTSTRAP_*`, `PROCESSING_LOG_STORE_*`, `CONTENT_EXPORT_*`, `TYPESENSE_*`, `APP_SETTINGS_ENCRYPTION_KEY`
- Frontend: optional `VITE_API_BASE`
When `VITE_API_BASE` is unset, the frontend uses `http://<current-hostname>:8000/api/v1`.
@@ -128,12 +134,18 @@ Provider API keys are persisted encrypted at rest (`api_key_encrypted`) and are
Settings endpoints:
- `GET/PUT /api/v1/settings`
- `GET/PATCH /api/v1/settings`
- `POST /api/v1/settings/reset`
- `POST /api/v1/settings/handwriting`
- `POST /api/v1/processing/logs/trim`
- `PATCH /api/v1/settings/handwriting`
- `POST /api/v1/processing/logs/trim` (admin only)
Note: the compose file currently includes host-specific URL values (for example `PUBLIC_BASE_URL` and `VITE_API_BASE`). Adjust these for your environment when needed.
Auth endpoints:
- `POST /api/v1/auth/login`
- `GET /api/v1/auth/me`
- `POST /api/v1/auth/logout`
Detailed DEV and LIVE environment guidance, including HTTPS reverse-proxy deployment values, is documented in `doc/operations-and-configuration.md` and `.env.example`.
## Data Persistence

View File

@@ -3,11 +3,17 @@ DATABASE_URL=postgresql+psycopg://dcm:dcm@db:5432/dcm
REDIS_URL=redis://:replace-with-redis-password@redis:6379/0
REDIS_SECURITY_MODE=auto
REDIS_TLS_MODE=auto
ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS=true
STORAGE_ROOT=/data/storage
ADMIN_API_TOKEN=replace-with-random-admin-token
USER_API_TOKEN=replace-with-random-user-token
AUTH_BOOTSTRAP_ADMIN_USERNAME=admin
AUTH_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-admin-password
AUTH_BOOTSTRAP_USER_USERNAME=user
AUTH_BOOTSTRAP_USER_PASSWORD=replace-with-random-user-password
APP_SETTINGS_ENCRYPTION_KEY=replace-with-random-settings-encryption-key
PROCESSING_LOG_STORE_MODEL_IO_TEXT=false
PROCESSING_LOG_STORE_PAYLOAD_TEXT=false
CONTENT_EXPORT_MAX_DOCUMENTS=250
CONTENT_EXPORT_MAX_TOTAL_BYTES=52428800
CONTENT_EXPORT_RATE_LIMIT_PER_MINUTE=6
MAX_UPLOAD_FILES_PER_REQUEST=50
MAX_UPLOAD_FILE_SIZE_BYTES=26214400
MAX_UPLOAD_REQUEST_SIZE_BYTES=104857600
@@ -31,3 +37,4 @@ TYPESENSE_PORT=8108
TYPESENSE_API_KEY=replace-with-random-typesense-api-key
TYPESENSE_COLLECTION_NAME=documents
PUBLIC_BASE_URL=http://localhost:8000
CORS_ALLOW_CREDENTIALS=false

View File

@@ -1,95 +1,81 @@
"""Token-based authentication and authorization dependencies for privileged API routes."""
"""Authentication and authorization dependencies for protected API routes."""
import hmac
from dataclasses import dataclass
from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.config import Settings, get_settings
from app.db.base import get_session
from app.models.auth import UserRole
from app.services.authentication import resolve_auth_session
bearer_auth = HTTPBearer(auto_error=False)
class AuthRole:
"""Declares supported authorization roles for privileged API operations."""
@dataclass(frozen=True)
class AuthContext:
"""Carries authenticated identity and role details for one request."""
ADMIN = "admin"
USER = "user"
user_id: UUID
username: str
role: UserRole
session_id: UUID
expires_at: datetime
def _raise_unauthorized() -> None:
"""Raises an HTTP 401 response with bearer authentication challenge headers."""
"""Raises a 401 challenge response for missing or invalid bearer sessions."""
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API token",
detail="Invalid or expired authentication session",
headers={"WWW-Authenticate": "Bearer"},
)
def _configured_admin_token(settings: Settings) -> str:
"""Returns required admin token or raises configuration error when unset."""
token = settings.admin_api_token.strip()
if token:
return token
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Admin API token is not configured",
)
def _resolve_token_role(token: str, settings: Settings) -> str:
"""Resolves role from a bearer token using constant-time comparisons."""
admin_token = _configured_admin_token(settings)
if hmac.compare_digest(token, admin_token):
return AuthRole.ADMIN
user_token = settings.user_api_token.strip()
if user_token and hmac.compare_digest(token, user_token):
return AuthRole.USER
_raise_unauthorized()
def get_request_role(
def get_request_auth_context(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_auth)],
settings: Annotated[Settings, Depends(get_settings)],
) -> str:
"""Authenticates request token and returns its authorization role.
Development environments can optionally allow tokenless user access for non-admin routes to
preserve local workflow compatibility while production remains token-enforced.
"""
session: Annotated[Session, Depends(get_session)],
) -> AuthContext:
"""Authenticates bearer session token and returns role-bound request identity context."""
if credentials is None:
if settings.allow_development_anonymous_user_access and settings.app_env.strip().lower() in {"development", "dev"}:
return AuthRole.USER
_raise_unauthorized()
token = credentials.credentials.strip()
if not token:
if settings.allow_development_anonymous_user_access and settings.app_env.strip().lower() in {"development", "dev"}:
return AuthRole.USER
_raise_unauthorized()
return _resolve_token_role(token=token, settings=settings)
resolved_session = resolve_auth_session(session, token=token)
if resolved_session is None or resolved_session.user is None:
_raise_unauthorized()
return AuthContext(
user_id=resolved_session.user.id,
username=resolved_session.user.username,
role=resolved_session.user.role,
session_id=resolved_session.id,
expires_at=resolved_session.expires_at,
)
def require_user_or_admin(role: Annotated[str, Depends(get_request_role)]) -> str:
"""Requires a valid user or admin token and returns resolved role."""
def require_user_or_admin(context: Annotated[AuthContext, Depends(get_request_auth_context)]) -> AuthContext:
"""Requires any authenticated user session and returns its request identity context."""
return role
return context
def require_admin(role: Annotated[str, Depends(get_request_role)]) -> str:
"""Requires admin role and rejects requests authenticated as regular users."""
def require_admin(context: Annotated[AuthContext, Depends(get_request_auth_context)]) -> AuthContext:
"""Requires authenticated admin role and rejects standard user sessions."""
if role != AuthRole.ADMIN:
if context.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin token required",
detail="Administrator role required",
)
return role
return context

View File

@@ -2,7 +2,8 @@
from fastapi import APIRouter, Depends
from app.api.auth import require_admin, require_user_or_admin
from app.api.auth import require_admin
from app.api.routes_auth import router as auth_router
from app.api.routes_documents import router as documents_router
from app.api.routes_health import router as health_router
from app.api.routes_processing_logs import router as processing_logs_router
@@ -12,11 +13,11 @@ from app.api.routes_settings import router as settings_router
api_router = APIRouter()
api_router.include_router(health_router)
api_router.include_router(auth_router)
api_router.include_router(
documents_router,
prefix="/documents",
tags=["documents"],
dependencies=[Depends(require_user_or_admin)],
)
api_router.include_router(
processing_logs_router,
@@ -28,7 +29,6 @@ api_router.include_router(
search_router,
prefix="/search",
tags=["search"],
dependencies=[Depends(require_user_or_admin)],
)
api_router.include_router(
settings_router,

View File

@@ -0,0 +1,94 @@
"""Authentication endpoints for credential login, session introspection, and logout."""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.api.auth import AuthContext, require_user_or_admin
from app.db.base import get_session
from app.schemas.auth import (
AuthLoginRequest,
AuthLoginResponse,
AuthLogoutResponse,
AuthSessionResponse,
AuthUserResponse,
)
from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session
router = APIRouter(prefix="/auth", tags=["auth"])
def _request_ip_address(request: Request) -> str | None:
"""Returns best-effort client IP extracted from the request transport context."""
return request.client.host if request.client is not None else None
def _request_user_agent(request: Request) -> str | None:
"""Returns best-effort user-agent metadata for created auth sessions."""
user_agent = request.headers.get("user-agent", "").strip()
return user_agent[:512] if user_agent else None
@router.post("/login", response_model=AuthLoginResponse)
def login(
payload: AuthLoginRequest,
request: Request,
session: Session = Depends(get_session),
) -> AuthLoginResponse:
"""Authenticates username and password and returns an issued bearer session token."""
user = authenticate_user(
session,
username=payload.username,
password=payload.password,
)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
issued_session = issue_user_session(
session,
user=user,
user_agent=_request_user_agent(request),
ip_address=_request_ip_address(request),
)
session.commit()
return AuthLoginResponse(
access_token=issued_session.token,
expires_at=issued_session.expires_at,
user=AuthUserResponse.model_validate(user),
)
@router.get("/me", response_model=AuthSessionResponse)
def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResponse:
"""Returns current authenticated session identity and expiration metadata."""
return AuthSessionResponse(
expires_at=context.expires_at,
user=AuthUserResponse(
id=context.user_id,
username=context.username,
role=context.role,
),
)
@router.post("/logout", response_model=AuthLogoutResponse)
def logout(
context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> AuthLogoutResponse:
"""Revokes current bearer session token and confirms logout state."""
revoked = revoke_auth_session(
session,
session_id=context.session_id,
)
if revoked:
session.commit()
return AuthLogoutResponse(revoked=revoked)

View File

@@ -1,12 +1,12 @@
"""Authenticated document CRUD, lifecycle, metadata, file access, and content export endpoints."""
import io
import re
import tempfile
import unicodedata
import zipfile
from datetime import datetime, time
from pathlib import Path
from typing import Annotated, Literal
from typing import Annotated, BinaryIO, Iterator, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
@@ -14,8 +14,10 @@ from fastapi.responses import FileResponse, Response, StreamingResponse
from sqlalchemy import or_, func, select
from sqlalchemy.orm import Session
from app.api.auth import AuthContext, require_user_or_admin
from app.core.config import get_settings, is_inline_preview_mime_type_safe
from app.db.base import get_session
from app.models.auth import UserRole
from app.models.document import Document, DocumentStatus
from app.schemas.documents import (
ContentExportRequest,
@@ -30,6 +32,7 @@ from app.services.app_settings import read_predefined_paths_settings, read_prede
from app.services.extractor import sniff_mime
from app.services.handwriting_style import delete_many_handwriting_style_documents
from app.services.processing_logs import log_processing_event, set_processing_log_autocommit
from app.services.rate_limiter import increment_rate_limit
from app.services.storage import absolute_path, compute_sha256, store_bytes
from app.services.typesense_index import delete_many_documents_index, upsert_document_index
from app.worker.queue import get_processing_queue
@@ -39,6 +42,59 @@ router = APIRouter()
settings = get_settings()
def _scope_document_statement_for_auth_context(statement, auth_context: AuthContext):
"""Restricts document statements to caller-owned rows for non-admin users."""
if auth_context.role == UserRole.ADMIN:
return statement
return statement.where(Document.owner_user_id == auth_context.user_id)
def _ensure_document_access(document: Document, auth_context: AuthContext) -> None:
"""Enforces owner-level access for non-admin users and raises not-found on violations."""
if auth_context.role == UserRole.ADMIN:
return
if document.owner_user_id != auth_context.user_id:
raise HTTPException(status_code=404, detail="Document not found")
def _stream_binary_file_chunks(handle: BinaryIO, *, chunk_bytes: int) -> Iterator[bytes]:
"""Streams binary file-like content in bounded chunks and closes handle after completion."""
try:
while True:
chunk = handle.read(chunk_bytes)
if not chunk:
break
yield chunk
finally:
handle.close()
def _enforce_content_export_rate_limit(auth_context: AuthContext) -> None:
"""Applies per-user fixed-window rate limiting for markdown export requests."""
try:
current_count, limit = increment_rate_limit(
scope="content-md-export",
subject=str(auth_context.user_id),
limit=settings.content_export_rate_limit_per_minute,
window_seconds=60,
)
except RuntimeError as error:
raise HTTPException(
status_code=503,
detail="Rate limiter backend unavailable",
) from error
if limit > 0 and current_count > limit:
raise HTTPException(
status_code=429,
detail=f"Export rate limit exceeded ({limit} requests per minute)",
)
def _parse_csv(value: str | None) -> list[str]:
"""Parses comma-separated query values into a normalized non-empty list."""
@@ -296,6 +352,7 @@ def list_documents(
type_filter: str | None = Query(default=None),
processed_from: str | None = Query(default=None),
processed_to: str | None = Query(default=None),
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> DocumentsListResponse:
"""Returns paginated documents ordered by newest upload timestamp."""
@@ -305,6 +362,7 @@ def list_documents(
include_trashed=include_trashed,
path_prefix=path_prefix,
)
base_statement = _scope_document_statement_for_auth_context(base_statement, auth_context)
base_statement = _apply_discovery_filters(
base_statement,
path_filter=path_filter,
@@ -326,11 +384,13 @@ def list_documents(
@router.get("/tags")
def list_tags(
include_trashed: bool = Query(default=False),
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> dict[str, list[str]]:
"""Returns distinct tags currently assigned across all matching documents."""
statement = select(Document.tags)
statement = _scope_document_statement_for_auth_context(statement, auth_context)
if not include_trashed:
statement = statement.where(Document.status != DocumentStatus.TRASHED)
@@ -348,11 +408,13 @@ def list_tags(
@router.get("/paths")
def list_paths(
include_trashed: bool = Query(default=False),
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> dict[str, list[str]]:
"""Returns distinct logical paths currently assigned across all matching documents."""
statement = select(Document.logical_path)
statement = _scope_document_statement_for_auth_context(statement, auth_context)
if not include_trashed:
statement = statement.where(Document.status != DocumentStatus.TRASHED)
@@ -370,11 +432,13 @@ def list_paths(
@router.get("/types")
def list_types(
include_trashed: bool = Query(default=False),
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> dict[str, list[str]]:
"""Returns distinct document type values from extension, MIME, and image text type."""
statement = select(Document.extension, Document.mime_type, Document.image_text_type)
statement = _scope_document_statement_for_auth_context(statement, auth_context)
if not include_trashed:
statement = statement.where(Document.status != DocumentStatus.TRASHED)
rows = session.execute(statement).all()
@@ -390,16 +454,20 @@ def list_types(
@router.post("/content-md/export")
def export_contents_markdown(
payload: ContentExportRequest,
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> StreamingResponse:
"""Exports extracted contents for selected documents as individual markdown files in a ZIP archive."""
_enforce_content_export_rate_limit(auth_context)
has_document_ids = len(payload.document_ids) > 0
has_path_prefix = bool(payload.path_prefix and payload.path_prefix.strip())
if not has_document_ids and not has_path_prefix:
raise HTTPException(status_code=400, detail="Provide document_ids or path_prefix for export")
statement = select(Document)
statement = _scope_document_statement_for_auth_context(statement, auth_context)
if has_document_ids:
statement = statement.where(Document.id.in_(payload.document_ids))
if has_path_prefix:
@@ -409,37 +477,82 @@ def export_contents_markdown(
elif not payload.include_trashed:
statement = statement.where(Document.status != DocumentStatus.TRASHED)
documents = session.execute(statement.order_by(Document.logical_path.asc(), Document.created_at.asc())).scalars().all()
max_documents = max(1, int(settings.content_export_max_documents))
ordered_statement = statement.order_by(Document.logical_path.asc(), Document.created_at.asc()).limit(max_documents + 1)
documents = session.execute(ordered_statement).scalars().all()
if len(documents) > max_documents:
raise HTTPException(
status_code=413,
detail=f"Export exceeds maximum document count ({len(documents)} > {max_documents})",
)
if not documents:
raise HTTPException(status_code=404, detail="No matching documents found for export")
archive_buffer = io.BytesIO()
max_total_bytes = max(1, int(settings.content_export_max_total_bytes))
max_spool_memory = max(64 * 1024, int(settings.content_export_spool_max_memory_bytes))
archive_file = tempfile.SpooledTemporaryFile(max_size=max_spool_memory, mode="w+b")
total_export_bytes = 0
used_entries: set[str] = set()
with zipfile.ZipFile(archive_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
try:
with zipfile.ZipFile(archive_file, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
for document in documents:
markdown_bytes = _markdown_for_document(document).encode("utf-8")
total_export_bytes += len(markdown_bytes)
if total_export_bytes > max_total_bytes:
raise HTTPException(
status_code=413,
detail=(
"Export exceeds total markdown size limit "
f"({total_export_bytes} > {max_total_bytes} bytes)"
),
)
entry_name = _zip_entry_name(document, used_entries)
archive.writestr(entry_name, _markdown_for_document(document))
archive.writestr(entry_name, markdown_bytes)
archive_file.seek(0)
except Exception:
archive_file.close()
raise
archive_buffer.seek(0)
chunk_bytes = max(4 * 1024, int(settings.content_export_stream_chunk_bytes))
headers = {"Content-Disposition": 'attachment; filename="document-contents-md.zip"'}
return StreamingResponse(archive_buffer, media_type="application/zip", headers=headers)
return StreamingResponse(
_stream_binary_file_chunks(archive_file, chunk_bytes=chunk_bytes),
media_type="application/zip",
headers=headers,
)
@router.get("/{document_id}", response_model=DocumentDetailResponse)
def get_document(document_id: UUID, session: Session = Depends(get_session)) -> DocumentDetailResponse:
def get_document(
document_id: UUID,
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> DocumentDetailResponse:
"""Returns one document by unique identifier."""
document = session.execute(select(Document).where(Document.id == document_id)).scalar_one_or_none()
statement = _scope_document_statement_for_auth_context(
select(Document).where(Document.id == document_id),
auth_context,
)
document = session.execute(statement).scalar_one_or_none()
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentDetailResponse.model_validate(document)
@router.get("/{document_id}/download")
def download_document(document_id: UUID, session: Session = Depends(get_session)) -> FileResponse:
def download_document(
document_id: UUID,
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> FileResponse:
"""Downloads original document bytes for the requested document identifier."""
document = session.execute(select(Document).where(Document.id == document_id)).scalar_one_or_none()
statement = _scope_document_statement_for_auth_context(
select(Document).where(Document.id == document_id),
auth_context,
)
document = session.execute(statement).scalar_one_or_none()
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
file_path = absolute_path(document.stored_relative_path)
@@ -447,10 +560,18 @@ def download_document(document_id: UUID, session: Session = Depends(get_session)
@router.get("/{document_id}/preview")
def preview_document(document_id: UUID, session: Session = Depends(get_session)) -> FileResponse:
def preview_document(
document_id: UUID,
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> FileResponse:
"""Streams trusted-safe MIME types inline and forces attachment for active script-capable types."""
document = session.execute(select(Document).where(Document.id == document_id)).scalar_one_or_none()
statement = _scope_document_statement_for_auth_context(
select(Document).where(Document.id == document_id),
auth_context,
)
document = session.execute(statement).scalar_one_or_none()
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
@@ -467,10 +588,18 @@ def preview_document(document_id: UUID, session: Session = Depends(get_session))
@router.get("/{document_id}/thumbnail")
def thumbnail_document(document_id: UUID, session: Session = Depends(get_session)) -> FileResponse:
def thumbnail_document(
document_id: UUID,
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> FileResponse:
"""Returns a generated thumbnail image for dashboard card previews."""
document = session.execute(select(Document).where(Document.id == document_id)).scalar_one_or_none()
statement = _scope_document_statement_for_auth_context(
select(Document).where(Document.id == document_id),
auth_context,
)
document = session.execute(statement).scalar_one_or_none()
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
@@ -485,10 +614,18 @@ def thumbnail_document(document_id: UUID, session: Session = Depends(get_session
@router.get("/{document_id}/content-md")
def download_document_content_markdown(document_id: UUID, session: Session = Depends(get_session)) -> Response:
def download_document_content_markdown(
document_id: UUID,
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> Response:
"""Downloads extracted content for one document as a markdown file."""
document = session.execute(select(Document).where(Document.id == document_id)).scalar_one_or_none()
statement = _scope_document_statement_for_auth_context(
select(Document).where(Document.id == document_id),
auth_context,
)
document = session.execute(statement).scalar_one_or_none()
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
@@ -505,6 +642,7 @@ async def upload_documents(
logical_path: Annotated[str, Form()] = "Inbox",
tags: Annotated[str | None, Form()] = None,
conflict_mode: Annotated[Literal["ask", "replace", "duplicate"], Form()] = "ask",
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> UploadResponse:
"""Uploads files, records metadata, and enqueues asynchronous extraction tasks."""
@@ -562,7 +700,11 @@ async def upload_documents(
}
)
existing = session.execute(select(Document).where(Document.sha256 == sha256)).scalar_one_or_none()
existing_statement = _scope_document_statement_for_auth_context(
select(Document).where(Document.sha256 == sha256),
auth_context,
)
existing = session.execute(existing_statement).scalar_one_or_none()
if existing and conflict_mode == "ask":
log_processing_event(
session=session,
@@ -589,9 +731,11 @@ async def upload_documents(
return UploadResponse(uploaded=[], conflicts=conflicts)
for prepared in prepared_uploads:
existing = session.execute(
select(Document).where(Document.sha256 == str(prepared["sha256"]))
).scalar_one_or_none()
existing_statement = _scope_document_statement_for_auth_context(
select(Document).where(Document.sha256 == str(prepared["sha256"])),
auth_context,
)
existing = session.execute(existing_statement).scalar_one_or_none()
replaces_document_id = existing.id if existing and conflict_mode == "replace" else None
stored_relative_path = store_bytes(str(prepared["filename"]), bytes(prepared["data"]))
@@ -606,6 +750,7 @@ async def upload_documents(
size_bytes=len(bytes(prepared["data"])),
logical_path=logical_path,
tags=list(normalized_tags),
owner_user_id=auth_context.user_id,
replaces_document_id=replaces_document_id,
metadata_json={"upload": "web"},
)
@@ -637,11 +782,16 @@ async def upload_documents(
def update_document(
document_id: UUID,
payload: DocumentUpdateRequest,
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> DocumentResponse:
"""Updates document metadata and refreshes semantic index representation."""
document = session.execute(select(Document).where(Document.id == document_id)).scalar_one_or_none()
statement = _scope_document_statement_for_auth_context(
select(Document).where(Document.id == document_id),
auth_context,
)
document = session.execute(statement).scalar_one_or_none()
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
@@ -663,10 +813,18 @@ def update_document(
@router.post("/{document_id}/trash", response_model=DocumentResponse)
def trash_document(document_id: UUID, session: Session = Depends(get_session)) -> DocumentResponse:
def trash_document(
document_id: UUID,
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> DocumentResponse:
"""Marks a document as trashed without deleting files from storage."""
document = session.execute(select(Document).where(Document.id == document_id)).scalar_one_or_none()
statement = _scope_document_statement_for_auth_context(
select(Document).where(Document.id == document_id),
auth_context,
)
document = session.execute(statement).scalar_one_or_none()
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
@@ -687,10 +845,18 @@ def trash_document(document_id: UUID, session: Session = Depends(get_session)) -
@router.post("/{document_id}/restore", response_model=DocumentResponse)
def restore_document(document_id: UUID, session: Session = Depends(get_session)) -> DocumentResponse:
def restore_document(
document_id: UUID,
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> DocumentResponse:
"""Restores a trashed document to its previous lifecycle status."""
document = session.execute(select(Document).where(Document.id == document_id)).scalar_one_or_none()
statement = _scope_document_statement_for_auth_context(
select(Document).where(Document.id == document_id),
auth_context,
)
document = session.execute(statement).scalar_one_or_none()
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
@@ -712,16 +878,27 @@ def restore_document(document_id: UUID, session: Session = Depends(get_session))
@router.delete("/{document_id}")
def delete_document(document_id: UUID, session: Session = Depends(get_session)) -> dict[str, int]:
def delete_document(
document_id: UUID,
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> dict[str, int]:
"""Permanently deletes a document and all descendant archive members including stored files."""
root = session.execute(select(Document).where(Document.id == document_id)).scalar_one_or_none()
root_statement = _scope_document_statement_for_auth_context(
select(Document).where(Document.id == document_id),
auth_context,
)
root = session.execute(root_statement).scalar_one_or_none()
if root is None:
raise HTTPException(status_code=404, detail="Document not found")
if root.status != DocumentStatus.TRASHED:
raise HTTPException(status_code=400, detail="Move document to trash before permanent deletion")
document_tree = _collect_document_tree(session=session, root_document_id=document_id)
if auth_context.role != UserRole.ADMIN:
for _, document in document_tree:
_ensure_document_access(document, auth_context)
document_ids = [document.id for _, document in document_tree]
try:
delete_many_documents_index([str(current_id) for current_id in document_ids])
@@ -752,10 +929,18 @@ def delete_document(document_id: UUID, session: Session = Depends(get_session))
@router.post("/{document_id}/reprocess", response_model=DocumentResponse)
def reprocess_document(document_id: UUID, session: Session = Depends(get_session)) -> DocumentResponse:
def reprocess_document(
document_id: UUID,
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> DocumentResponse:
"""Re-enqueues a document for extraction and suggestion processing."""
document = session.execute(select(Document).where(Document.id == document_id)).scalar_one_or_none()
statement = _scope_document_statement_for_auth_context(
select(Document).where(Document.id == document_id),
auth_context,
)
document = session.execute(statement).scalar_one_or_none()
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
if document.status == DocumentStatus.TRASHED:

View File

@@ -4,7 +4,8 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import Text, cast, func, select
from sqlalchemy.orm import Session
from app.api.routes_documents import _apply_discovery_filters
from app.api.auth import AuthContext, require_user_or_admin
from app.api.routes_documents import _apply_discovery_filters, _scope_document_statement_for_auth_context
from app.db.base import get_session
from app.models.document import Document, DocumentStatus
from app.schemas.documents import DocumentResponse, SearchResponse
@@ -25,6 +26,7 @@ def search_documents(
type_filter: str | None = Query(default=None),
processed_from: str | None = Query(default=None),
processed_to: str | None = Query(default=None),
auth_context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session),
) -> SearchResponse:
"""Searches documents using PostgreSQL full-text ranking plus metadata matching."""
@@ -50,6 +52,7 @@ def search_documents(
)
statement = select(Document).where(search_filter)
statement = _scope_document_statement_for_auth_context(statement, auth_context)
if only_trashed:
statement = statement.where(Document.status == DocumentStatus.TRASHED)
elif not include_trashed:
@@ -67,6 +70,7 @@ def search_documents(
items = session.execute(statement).scalars().all()
count_statement = select(func.count(Document.id)).where(search_filter)
count_statement = _scope_document_statement_for_auth_context(count_statement, auth_context)
if only_trashed:
count_statement = count_statement.where(Document.status == DocumentStatus.TRASHED)
elif not include_trashed:

View File

@@ -21,12 +21,24 @@ class Settings(BaseSettings):
redis_url: str = "redis://redis:6379/0"
redis_security_mode: str = "auto"
redis_tls_mode: str = "auto"
allow_development_anonymous_user_access: bool = True
auth_bootstrap_admin_username: str = "admin"
auth_bootstrap_admin_password: str = ""
auth_bootstrap_user_username: str = ""
auth_bootstrap_user_password: str = ""
auth_session_ttl_minutes: int = 720
auth_password_pbkdf2_iterations: int = 390000
auth_session_token_bytes: int = 32
auth_session_pepper: str = ""
storage_root: Path = Path("/data/storage")
upload_chunk_size: int = 4 * 1024 * 1024
max_upload_files_per_request: int = 50
max_upload_file_size_bytes: int = 25 * 1024 * 1024
max_upload_request_size_bytes: int = 100 * 1024 * 1024
content_export_max_documents: int = 250
content_export_max_total_bytes: int = 50 * 1024 * 1024
content_export_rate_limit_per_minute: int = 6
content_export_stream_chunk_bytes: int = 256 * 1024
content_export_spool_max_memory_bytes: int = 2 * 1024 * 1024
max_zip_members: int = 250
max_zip_depth: int = 2
max_zip_descendants_per_root: int = 1000
@@ -34,8 +46,6 @@ class Settings(BaseSettings):
max_zip_total_uncompressed_bytes: int = 150 * 1024 * 1024
max_zip_compression_ratio: float = 120.0
max_text_length: int = 500_000
admin_api_token: str = ""
user_api_token: str = ""
provider_base_url_allowlist: list[str] = Field(default_factory=lambda: ["api.openai.com"])
provider_base_url_allow_http: bool = False
provider_base_url_allow_private_network: bool = False
@@ -43,6 +53,8 @@ class Settings(BaseSettings):
processing_log_max_unbound_entries: int = 400
processing_log_max_payload_chars: int = 4096
processing_log_max_text_chars: int = 12000
processing_log_store_model_io_text: bool = False
processing_log_store_payload_text: bool = False
default_openai_base_url: str = "https://api.openai.com/v1"
default_openai_model: str = "gpt-4.1-mini"
default_openai_timeout_seconds: int = 45
@@ -60,6 +72,7 @@ class Settings(BaseSettings):
typesense_num_retries: int = 0
public_base_url: str = "http://localhost:8000"
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173", "http://localhost:3000"])
cors_allow_credentials: bool = False
LOCAL_HOSTNAME_SUFFIXES = (".local", ".internal", ".home.arpa")

View File

@@ -10,6 +10,7 @@ from app.api.router import api_router
from app.core.config import get_settings
from app.db.base import init_db
from app.services.app_settings import ensure_app_settings
from app.services.authentication import ensure_bootstrap_users
from app.services.handwriting_style import ensure_handwriting_style_collection
from app.services.storage import ensure_storage
from app.services.typesense_index import ensure_typesense_collection
@@ -18,7 +19,6 @@ from app.services.typesense_index import ensure_typesense_collection
settings = get_settings()
UPLOAD_ENDPOINT_PATH = "/api/v1/documents/upload"
UPLOAD_ENDPOINT_METHOD = "POST"
CORS_HTTP_ORIGIN_REGEX = r"^https?://[^/]+$"
def _is_upload_size_guard_target(request: Request) -> bool:
@@ -35,11 +35,11 @@ def create_app() -> FastAPI:
"""Builds and configures the FastAPI application instance."""
app = FastAPI(title="DCM DMS API", version="0.1.0")
allowed_origins = [origin.strip() for origin in settings.cors_origins if isinstance(origin, str) and origin.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_origin_regex=CORS_HTTP_ORIGIN_REGEX,
allow_credentials=True,
allow_origins=allowed_origins,
allow_credentials=bool(getattr(settings, "cors_allow_credentials", False)),
allow_methods=["*"],
allow_headers=["*"],
)
@@ -82,8 +82,9 @@ def create_app() -> FastAPI:
"""Initializes storage directories and database schema on service startup."""
ensure_storage()
ensure_app_settings()
init_db()
ensure_bootstrap_users()
ensure_app_settings()
try:
ensure_typesense_collection()
except Exception:

View File

@@ -1,6 +1,7 @@
"""Model exports for ORM metadata discovery."""
from app.models.auth import AppUser, AuthSession, UserRole
from app.models.document import Document, DocumentStatus
from app.models.processing_log import ProcessingLogEntry
__all__ = ["Document", "DocumentStatus", "ProcessingLogEntry"]
__all__ = ["AppUser", "AuthSession", "Document", "DocumentStatus", "ProcessingLogEntry", "UserRole"]

View File

@@ -0,0 +1,66 @@
"""Data models for authenticated users and issued API sessions."""
import uuid
from datetime import UTC, datetime
from enum import Enum
from sqlalchemy import Boolean, DateTime, Enum as SqlEnum, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class UserRole(str, Enum):
"""Declares authorization roles used for API route access control."""
ADMIN = "admin"
USER = "user"
class AppUser(Base):
"""Stores one authenticatable user account with role-bound authorization."""
__tablename__ = "app_users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(512), nullable=False)
role: Mapped[UserRole] = mapped_column(SqlEnum(UserRole), nullable=False, default=UserRole.USER)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
sessions: Mapped[list["AuthSession"]] = relationship(
"AuthSession",
back_populates="user",
cascade="all, delete-orphan",
)
class AuthSession(Base):
"""Stores one issued bearer session token for a specific authenticated user."""
__tablename__ = "auth_sessions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("app_users.id", ondelete="CASCADE"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
user: Mapped[AppUser] = relationship("AppUser", back_populates="sessions")

View File

@@ -38,6 +38,12 @@ class Document(Base):
suggested_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
tags: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=list)
suggested_tags: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=list)
owner_user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("app_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
metadata_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
extracted_text: Mapped[str] = mapped_column(Text, nullable=False, default="")
image_text_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
@@ -63,3 +69,4 @@ class Document(Base):
foreign_keys=[parent_document_id],
post_update=True,
)
owner_user: Mapped["AppUser | None"] = relationship("AppUser", foreign_keys=[owner_user_id], post_update=True)

View File

@@ -0,0 +1,48 @@
"""Pydantic schemas for authentication and session API payloads."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
from app.models.auth import UserRole
class AuthLoginRequest(BaseModel):
"""Represents credential input used to create one authenticated API session."""
username: str = Field(min_length=1, max_length=128)
password: str = Field(min_length=1, max_length=256)
class AuthUserResponse(BaseModel):
"""Represents one authenticated user identity and authorization role."""
id: UUID
username: str
role: UserRole
class Config:
"""Enables ORM object parsing for SQLAlchemy model instances."""
from_attributes = True
class AuthSessionResponse(BaseModel):
"""Represents active session metadata for one authenticated user."""
user: AuthUserResponse
expires_at: datetime
class AuthLoginResponse(AuthSessionResponse):
"""Represents one newly issued bearer token and associated user context."""
access_token: str
token_type: str = "bearer"
class AuthLogoutResponse(BaseModel):
"""Represents logout outcome after current session revocation attempt."""
revoked: bool

View File

@@ -11,6 +11,14 @@ import secrets
from pathlib import Path
from typing import Any
try:
from cryptography.fernet import Fernet, InvalidToken
except Exception: # pragma: no cover - dependency failures are surfaced at runtime usage.
Fernet = None # type: ignore[assignment]
class InvalidToken(Exception):
"""Fallback InvalidToken type used when cryptography dependency import fails."""
from app.core.config import get_settings, normalize_and_validate_provider_base_url
@@ -63,12 +71,13 @@ DEFAULT_ROUTING_PROMPT = (
"Confidence must be between 0 and 1."
)
PROVIDER_API_KEY_CIPHERTEXT_PREFIX = "enc-v1"
PROVIDER_API_KEY_CIPHERTEXT_PREFIX = "enc-v2"
PROVIDER_API_KEY_LEGACY_CIPHERTEXT_PREFIX = "enc-v1"
PROVIDER_API_KEY_KEYFILE_NAME = ".settings-api-key"
PROVIDER_API_KEY_STREAM_CONTEXT = b"dcm-provider-api-key-stream"
PROVIDER_API_KEY_AUTH_CONTEXT = b"dcm-provider-api-key-auth"
PROVIDER_API_KEY_NONCE_BYTES = 16
PROVIDER_API_KEY_TAG_BYTES = 32
PROVIDER_API_KEY_LEGACY_STREAM_CONTEXT = b"dcm-provider-api-key-stream"
PROVIDER_API_KEY_LEGACY_AUTH_CONTEXT = b"dcm-provider-api-key-auth"
PROVIDER_API_KEY_LEGACY_NONCE_BYTES = 16
PROVIDER_API_KEY_LEGACY_TAG_BYTES = 32
def _settings_api_key_path() -> Path:
@@ -128,14 +137,14 @@ def _derive_provider_api_key_key() -> bytes:
return generated
def _xor_bytes(left: bytes, right: bytes) -> bytes:
"""Applies byte-wise XOR for equal-length byte sequences."""
def _legacy_xor_bytes(left: bytes, right: bytes) -> bytes:
"""Applies byte-wise XOR for equal-length byte sequences used by legacy ciphertext migration."""
return bytes(first ^ second for first, second in zip(left, right))
def _derive_stream_cipher_bytes(master_key: bytes, nonce: bytes, length: int) -> bytes:
"""Derives deterministic stream bytes from HMAC-SHA256 blocks for payload masking."""
def _legacy_derive_stream_cipher_bytes(master_key: bytes, nonce: bytes, length: int) -> bytes:
"""Derives legacy deterministic stream bytes from HMAC-SHA256 blocks for migration reads."""
stream = bytearray()
counter = 0
@@ -143,7 +152,7 @@ def _derive_stream_cipher_bytes(master_key: bytes, nonce: bytes, length: int) ->
counter_bytes = counter.to_bytes(4, "big")
block = hmac.new(
master_key,
PROVIDER_API_KEY_STREAM_CONTEXT + nonce + counter_bytes,
PROVIDER_API_KEY_LEGACY_STREAM_CONTEXT + nonce + counter_bytes,
hashlib.sha256,
).digest()
stream.extend(block)
@@ -151,6 +160,33 @@ def _derive_stream_cipher_bytes(master_key: bytes, nonce: bytes, length: int) ->
return bytes(stream[:length])
def _provider_key_fernet(master_key: bytes) -> Fernet:
"""Builds Fernet instance from 32-byte symmetric key material."""
if Fernet is None:
raise AppSettingsValidationError("cryptography dependency is not available")
fernet_key = base64.urlsafe_b64encode(master_key[:32])
return Fernet(fernet_key)
def _encrypt_provider_api_key_fallback(value: str) -> str:
"""Encrypts provider keys with legacy HMAC stream construction when cryptography is unavailable."""
plaintext = value.encode("utf-8")
master_key = _derive_provider_api_key_key()
nonce = secrets.token_bytes(PROVIDER_API_KEY_LEGACY_NONCE_BYTES)
keystream = _legacy_derive_stream_cipher_bytes(master_key, nonce, len(plaintext))
ciphertext = _legacy_xor_bytes(plaintext, keystream)
tag = hmac.new(
master_key,
PROVIDER_API_KEY_LEGACY_AUTH_CONTEXT + nonce + ciphertext,
hashlib.sha256,
).digest()
payload = nonce + ciphertext + tag
encoded = _urlsafe_b64encode_no_padding(payload)
return f"{PROVIDER_API_KEY_CIPHERTEXT_PREFIX}:{encoded}"
def _encrypt_provider_api_key(value: str) -> str:
"""Encrypts one provider API key for at-rest JSON persistence."""
@@ -158,19 +194,52 @@ def _encrypt_provider_api_key(value: str) -> str:
if not normalized:
return ""
plaintext = normalized.encode("utf-8")
if Fernet is None:
return _encrypt_provider_api_key_fallback(normalized)
master_key = _derive_provider_api_key_key()
nonce = secrets.token_bytes(PROVIDER_API_KEY_NONCE_BYTES)
keystream = _derive_stream_cipher_bytes(master_key, nonce, len(plaintext))
ciphertext = _xor_bytes(plaintext, keystream)
tag = hmac.new(
token = _provider_key_fernet(master_key).encrypt(normalized.encode("utf-8")).decode("ascii")
return f"{PROVIDER_API_KEY_CIPHERTEXT_PREFIX}:{token}"
def _decrypt_provider_api_key_legacy_payload(encoded_payload: str) -> str:
"""Decrypts legacy stream-cipher payload bytes used for migration and fallback reads."""
if not encoded_payload:
raise AppSettingsValidationError("Provider API key ciphertext is missing payload bytes")
try:
payload = _urlsafe_b64decode_no_padding(encoded_payload)
except (binascii.Error, ValueError) as error:
raise AppSettingsValidationError("Provider API key ciphertext is not valid base64") from error
minimum_length = PROVIDER_API_KEY_LEGACY_NONCE_BYTES + PROVIDER_API_KEY_LEGACY_TAG_BYTES
if len(payload) < minimum_length:
raise AppSettingsValidationError("Provider API key ciphertext payload is truncated")
nonce = payload[:PROVIDER_API_KEY_LEGACY_NONCE_BYTES]
ciphertext = payload[PROVIDER_API_KEY_LEGACY_NONCE_BYTES:-PROVIDER_API_KEY_LEGACY_TAG_BYTES]
received_tag = payload[-PROVIDER_API_KEY_LEGACY_TAG_BYTES:]
master_key = _derive_provider_api_key_key()
expected_tag = hmac.new(
master_key,
PROVIDER_API_KEY_AUTH_CONTEXT + nonce + ciphertext,
PROVIDER_API_KEY_LEGACY_AUTH_CONTEXT + nonce + ciphertext,
hashlib.sha256,
).digest()
payload = nonce + ciphertext + tag
encoded = _urlsafe_b64encode_no_padding(payload)
return f"{PROVIDER_API_KEY_CIPHERTEXT_PREFIX}:{encoded}"
if not hmac.compare_digest(received_tag, expected_tag):
raise AppSettingsValidationError("Provider API key ciphertext integrity check failed")
keystream = _legacy_derive_stream_cipher_bytes(master_key, nonce, len(ciphertext))
plaintext = _legacy_xor_bytes(ciphertext, keystream)
try:
return plaintext.decode("utf-8").strip()
except UnicodeDecodeError as error:
raise AppSettingsValidationError("Provider API key ciphertext is not valid UTF-8") from error
def _decrypt_provider_api_key_legacy(value: str) -> str:
"""Decrypts legacy `enc-v1` payloads to support non-breaking key migration."""
encoded_payload = value.split(":", 1)[1]
return _decrypt_provider_api_key_legacy_payload(encoded_payload)
def _decrypt_provider_api_key(value: str) -> str:
@@ -179,35 +248,23 @@ def _decrypt_provider_api_key(value: str) -> str:
normalized = value.strip()
if not normalized:
return ""
if not normalized.startswith(f"{PROVIDER_API_KEY_CIPHERTEXT_PREFIX}:"):
if not normalized.startswith(f"{PROVIDER_API_KEY_CIPHERTEXT_PREFIX}:") and not normalized.startswith(
f"{PROVIDER_API_KEY_LEGACY_CIPHERTEXT_PREFIX}:"
):
return normalized
encoded_payload = normalized.split(":", 1)[1]
if not encoded_payload:
if normalized.startswith(f"{PROVIDER_API_KEY_LEGACY_CIPHERTEXT_PREFIX}:"):
return _decrypt_provider_api_key_legacy(normalized)
token = normalized.split(":", 1)[1].strip()
if not token:
raise AppSettingsValidationError("Provider API key ciphertext is missing payload bytes")
if Fernet is None:
return _decrypt_provider_api_key_legacy_payload(token)
try:
payload = _urlsafe_b64decode_no_padding(encoded_payload)
except (binascii.Error, ValueError) as error:
raise AppSettingsValidationError("Provider API key ciphertext is not valid base64") from error
minimum_length = PROVIDER_API_KEY_NONCE_BYTES + PROVIDER_API_KEY_TAG_BYTES
if len(payload) < minimum_length:
raise AppSettingsValidationError("Provider API key ciphertext payload is truncated")
nonce = payload[:PROVIDER_API_KEY_NONCE_BYTES]
ciphertext = payload[PROVIDER_API_KEY_NONCE_BYTES:-PROVIDER_API_KEY_TAG_BYTES]
received_tag = payload[-PROVIDER_API_KEY_TAG_BYTES:]
master_key = _derive_provider_api_key_key()
expected_tag = hmac.new(
master_key,
PROVIDER_API_KEY_AUTH_CONTEXT + nonce + ciphertext,
hashlib.sha256,
).digest()
if not hmac.compare_digest(received_tag, expected_tag):
raise AppSettingsValidationError("Provider API key ciphertext integrity check failed")
keystream = _derive_stream_cipher_bytes(master_key, nonce, len(ciphertext))
plaintext = _xor_bytes(ciphertext, keystream)
plaintext = _provider_key_fernet(_derive_provider_api_key_key()).decrypt(token.encode("ascii"))
except (InvalidToken, ValueError, UnicodeEncodeError) as error:
raise AppSettingsValidationError("Provider API key ciphertext integrity check failed") from error
try:
return plaintext.decode("utf-8").strip()
except UnicodeDecodeError as error:

View File

@@ -0,0 +1,289 @@
"""Authentication services for user credential validation and session issuance."""
import base64
import binascii
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import hashlib
import hmac
import secrets
import uuid
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.core.config import Settings, get_settings
from app.db.base import SessionLocal
from app.models.auth import AppUser, AuthSession, UserRole
PASSWORD_HASH_SCHEME = "pbkdf2_sha256"
DEFAULT_AUTH_FALLBACK_SECRET = "dcm-session-secret"
@dataclass(frozen=True)
class IssuedSession:
"""Represents one newly issued bearer session token and expiration timestamp."""
token: str
expires_at: datetime
def normalize_username(username: str) -> str:
"""Normalizes usernames to a stable lowercase identity key."""
return username.strip().lower()
def _urlsafe_b64encode_no_padding(data: bytes) -> str:
"""Encodes bytes to compact URL-safe base64 without padding."""
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _urlsafe_b64decode_no_padding(data: str) -> bytes:
"""Decodes URL-safe base64 values that may omit trailing padding characters."""
padded = data + "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(padded.encode("ascii"))
def _password_iterations(settings: Settings) -> int:
"""Returns PBKDF2 iteration count clamped to a secure operational range."""
return max(200_000, min(1_200_000, int(settings.auth_password_pbkdf2_iterations)))
def hash_password(password: str, settings: Settings | None = None) -> str:
"""Derives and formats a PBKDF2-SHA256 password hash for persisted user credentials."""
resolved_settings = settings or get_settings()
normalized_password = password.strip()
if not normalized_password:
raise ValueError("Password must not be empty")
iterations = _password_iterations(resolved_settings)
salt = secrets.token_bytes(16)
derived = hashlib.pbkdf2_hmac(
"sha256",
normalized_password.encode("utf-8"),
salt,
iterations,
dklen=32,
)
return (
f"{PASSWORD_HASH_SCHEME}$"
f"{iterations}$"
f"{_urlsafe_b64encode_no_padding(salt)}$"
f"{_urlsafe_b64encode_no_padding(derived)}"
)
def verify_password(password: str, stored_hash: str, settings: Settings | None = None) -> bool:
"""Verifies one plaintext password against persisted PBKDF2-SHA256 hash material."""
resolved_settings = settings or get_settings()
normalized_password = password.strip()
if not normalized_password:
return False
parts = stored_hash.strip().split("$")
if len(parts) != 4:
return False
scheme, iterations_text, salt_text, digest_text = parts
if scheme != PASSWORD_HASH_SCHEME:
return False
try:
iterations = int(iterations_text)
except ValueError:
return False
if iterations < 200_000 or iterations > 2_000_000:
return False
try:
salt = _urlsafe_b64decode_no_padding(salt_text)
expected_digest = _urlsafe_b64decode_no_padding(digest_text)
except (binascii.Error, ValueError):
return False
derived_digest = hashlib.pbkdf2_hmac(
"sha256",
normalized_password.encode("utf-8"),
salt,
iterations,
dklen=len(expected_digest),
)
if not hmac.compare_digest(expected_digest, derived_digest):
return False
return iterations >= _password_iterations(resolved_settings)
def _auth_session_secret(settings: Settings) -> bytes:
"""Resolves a stable secret used to hash issued bearer session tokens."""
candidate = settings.auth_session_pepper.strip() or settings.app_settings_encryption_key.strip()
if not candidate:
candidate = DEFAULT_AUTH_FALLBACK_SECRET
return hashlib.sha256(candidate.encode("utf-8")).digest()
def _hash_session_token(token: str, settings: Settings | None = None) -> str:
"""Derives a deterministic SHA256 token hash guarded by secret pepper material."""
resolved_settings = settings or get_settings()
secret = _auth_session_secret(resolved_settings)
digest = hmac.new(secret, token.encode("utf-8"), hashlib.sha256).hexdigest()
return digest
def _new_session_token(settings: Settings) -> str:
"""Creates a random URL-safe bearer token for one API session."""
token_bytes = max(24, min(128, int(settings.auth_session_token_bytes)))
return secrets.token_urlsafe(token_bytes)
def _resolve_optional_user_credentials(username: str, password: str) -> tuple[str, str] | None:
"""Returns optional user credentials only when both username and password are configured."""
normalized_username = normalize_username(username)
normalized_password = password.strip()
if not normalized_username and not normalized_password:
return None
if not normalized_username or not normalized_password:
raise ValueError("Optional bootstrap user requires both username and password")
return normalized_username, normalized_password
def _upsert_bootstrap_user(session: Session, *, username: str, password: str, role: UserRole) -> AppUser:
"""Creates or updates one bootstrap account with deterministic role assignment."""
existing = session.execute(select(AppUser).where(AppUser.username == username)).scalar_one_or_none()
password_hash = hash_password(password)
if existing is None:
user = AppUser(
username=username,
password_hash=password_hash,
role=role,
is_active=True,
)
session.add(user)
return user
existing.password_hash = password_hash
existing.role = role
existing.is_active = True
return existing
def ensure_bootstrap_users() -> None:
"""Creates or refreshes bootstrap user accounts from runtime environment credentials."""
settings = get_settings()
admin_username = normalize_username(settings.auth_bootstrap_admin_username)
admin_password = settings.auth_bootstrap_admin_password.strip()
if not admin_username:
raise RuntimeError("AUTH_BOOTSTRAP_ADMIN_USERNAME must not be empty")
if not admin_password:
raise RuntimeError("AUTH_BOOTSTRAP_ADMIN_PASSWORD must not be empty")
optional_user_credentials = _resolve_optional_user_credentials(
username=settings.auth_bootstrap_user_username,
password=settings.auth_bootstrap_user_password,
)
with SessionLocal() as session:
_upsert_bootstrap_user(
session,
username=admin_username,
password=admin_password,
role=UserRole.ADMIN,
)
if optional_user_credentials is not None:
user_username, user_password = optional_user_credentials
if user_username == admin_username:
raise RuntimeError("AUTH_BOOTSTRAP_USER_USERNAME must differ from admin username")
_upsert_bootstrap_user(
session,
username=user_username,
password=user_password,
role=UserRole.USER,
)
session.commit()
def authenticate_user(session: Session, *, username: str, password: str) -> AppUser | None:
"""Authenticates one username/password pair and returns active account on success."""
normalized_username = normalize_username(username)
if not normalized_username:
return None
user = session.execute(select(AppUser).where(AppUser.username == normalized_username)).scalar_one_or_none()
if user is None or not user.is_active:
return None
if not verify_password(password, user.password_hash):
return None
return user
def issue_user_session(
session: Session,
*,
user: AppUser,
user_agent: str | None = None,
ip_address: str | None = None,
) -> IssuedSession:
"""Issues one new bearer token session for a validated user account."""
settings = get_settings()
now = datetime.now(UTC)
ttl_minutes = max(5, min(7 * 24 * 60, int(settings.auth_session_ttl_minutes)))
expires_at = now + timedelta(minutes=ttl_minutes)
token = _new_session_token(settings)
token_hash = _hash_session_token(token, settings)
session.execute(
delete(AuthSession).where(
AuthSession.user_id == user.id,
AuthSession.expires_at <= now,
)
)
session_entry = AuthSession(
user_id=user.id,
token_hash=token_hash,
expires_at=expires_at,
user_agent=(user_agent or "").strip()[:512] or None,
ip_address=(ip_address or "").strip()[:64] or None,
)
session.add(session_entry)
return IssuedSession(token=token, expires_at=expires_at)
def resolve_auth_session(session: Session, *, token: str) -> AuthSession | None:
"""Resolves one non-revoked and non-expired session from a bearer token value."""
normalized = token.strip()
if not normalized:
return None
token_hash = _hash_session_token(normalized)
now = datetime.now(UTC)
session_entry = session.execute(
select(AuthSession).where(
AuthSession.token_hash == token_hash,
AuthSession.revoked_at.is_(None),
AuthSession.expires_at > now,
)
).scalar_one_or_none()
if session_entry is None or session_entry.user is None:
return None
if not session_entry.user.is_active:
return None
return session_entry
def revoke_auth_session(session: Session, *, session_id: uuid.UUID) -> bool:
"""Revokes one active session by identifier and returns whether a change was applied."""
existing = session.execute(select(AuthSession).where(AuthSession.id == session_id)).scalar_one_or_none()
if existing is None or existing.revoked_at is not None:
return False
existing.revoked_at = datetime.now(UTC)
return True

View File

@@ -6,10 +6,13 @@ from uuid import UUID
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.document import Document
from app.models.processing_log import ProcessingLogEntry
settings = get_settings()
MAX_STAGE_LENGTH = 64
MAX_EVENT_LENGTH = 256
MAX_LEVEL_LENGTH = 16
@@ -37,9 +40,49 @@ def _trim(value: str | None, max_length: int) -> str | None:
def _safe_payload(payload_json: dict[str, Any] | None) -> dict[str, Any]:
"""Ensures payload values are persisted as dictionaries."""
"""Normalizes payload persistence mode using metadata-only defaults for sensitive content."""
return payload_json if isinstance(payload_json, dict) else {}
if not isinstance(payload_json, dict):
return {}
if settings.processing_log_store_payload_text:
return payload_json
return _metadata_only_payload(payload_json)
def _metadata_only_payload(payload_json: dict[str, Any]) -> dict[str, Any]:
"""Converts payload content into metadata descriptors without persisting raw text values."""
metadata: dict[str, Any] = {}
for index, (raw_key, raw_value) in enumerate(payload_json.items()):
if index >= 80:
break
key = str(raw_key)
metadata[key] = _metadata_only_payload_value(raw_value)
return metadata
def _metadata_only_payload_value(value: Any) -> Any:
"""Converts one payload value into non-sensitive metadata representation."""
if isinstance(value, dict):
return _metadata_only_payload(value)
if isinstance(value, (list, tuple)):
items = list(value)
return {
"item_count": len(items),
"items_preview": [_metadata_only_payload_value(item) for item in items[:20]],
}
if isinstance(value, str):
normalized = value.strip()
return {
"text_chars": len(normalized),
"text_omitted": bool(normalized),
}
if isinstance(value, bytes):
return {"binary_bytes": len(value)}
if isinstance(value, (int, float, bool)) or value is None:
return value
return {"value_type": type(value).__name__}
def set_processing_log_autocommit(session: Session, enabled: bool) -> None:
@@ -82,8 +125,8 @@ def log_processing_event(
document_filename=_trim(resolved_document_filename, MAX_DOCUMENT_FILENAME_LENGTH),
provider_id=_trim(provider_id, MAX_PROVIDER_LENGTH),
model_name=_trim(model_name, MAX_MODEL_LENGTH),
prompt_text=_trim(prompt_text, MAX_PROMPT_LENGTH),
response_text=_trim(response_text, MAX_RESPONSE_LENGTH),
prompt_text=_trim(prompt_text, MAX_PROMPT_LENGTH) if settings.processing_log_store_model_io_text else None,
response_text=_trim(response_text, MAX_RESPONSE_LENGTH) if settings.processing_log_store_model_io_text else None,
payload_json=_safe_payload(payload_json),
)
session.add(entry)

View File

@@ -0,0 +1,42 @@
"""Redis-backed fixed-window rate limiter helpers for sensitive API operations."""
import time
from redis.exceptions import RedisError
from app.worker.queue import get_redis
def _rate_limit_key(*, scope: str, subject: str, window_id: int) -> str:
"""Builds a stable Redis key for one scope, subject, and fixed time window."""
return f"dcm:rate-limit:{scope}:{subject}:{window_id}"
def increment_rate_limit(
*,
scope: str,
subject: str,
limit: int,
window_seconds: int = 60,
) -> tuple[int, int]:
"""Increments one rate bucket and returns current count with configured limit."""
bounded_limit = max(0, int(limit))
if bounded_limit == 0:
return (0, 0)
bounded_window = max(1, int(window_seconds))
current_window = int(time.time() // bounded_window)
key = _rate_limit_key(scope=scope, subject=subject, window_id=current_window)
redis_client = get_redis()
try:
pipeline = redis_client.pipeline(transaction=True)
pipeline.incr(key, 1)
pipeline.expire(key, bounded_window + 5)
count_value, _ = pipeline.execute()
except RedisError as error:
raise RuntimeError("Rate limiter backend unavailable") from error
return (int(count_value), bounded_limit)

View File

@@ -0,0 +1,26 @@
"""Worker entrypoint that enforces Redis URL security checks before queue consumption."""
from redis import Redis
from rq import Worker
from app.core.config import get_settings, validate_redis_url_security
def _build_worker_connection() -> Redis:
"""Builds validated Redis connection used by RQ worker runtime."""
settings = get_settings()
secure_redis_url = validate_redis_url_security(settings.redis_url)
return Redis.from_url(secure_redis_url)
def run_worker() -> None:
"""Runs the RQ worker loop for the configured DCM processing queue."""
connection = _build_worker_connection()
worker = Worker(["dcm"], connection=connection)
worker.work()
if __name__ == "__main__":
run_worker()

View File

@@ -143,6 +143,7 @@ def _create_archive_member_document(
size_bytes=len(member_data),
logical_path=parent.logical_path,
tags=list(parent.tags),
owner_user_id=parent.owner_user_id,
metadata_json={
"origin": "archive",
"parent": str(parent.id),

View File

@@ -16,3 +16,4 @@ orjson==3.11.3
openai==1.107.2
typesense==1.1.1
tiktoken==0.11.0
cryptography==46.0.1

View File

@@ -272,10 +272,10 @@ if "app.services.routing_pipeline" not in sys.modules:
sys.modules["app.services.routing_pipeline"] = routing_pipeline_stub
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials
from app.api.auth import AuthRole, get_request_role, require_admin
from app.api.auth import AuthContext, require_admin
from app.core import config as config_module
from app.models.auth import UserRole
from app.models.processing_log import sanitize_processing_log_payload_value, sanitize_processing_log_text
from app.schemas.processing_logs import ProcessingLogEntryResponse
from app.services import extractor as extractor_module
@@ -298,52 +298,34 @@ def _security_settings(
class AuthDependencyTests(unittest.TestCase):
"""Verifies token authentication and admin authorization behavior."""
def test_get_request_role_accepts_admin_token(self) -> None:
"""Admin token resolves admin role."""
settings = SimpleNamespace(
admin_api_token="admin-token",
user_api_token="user-token",
allow_development_anonymous_user_access=False,
app_env="production",
)
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="admin-token")
role = get_request_role(credentials=credentials, settings=settings)
self.assertEqual(role, AuthRole.ADMIN)
def test_get_request_role_rejects_missing_credentials(self) -> None:
"""Missing bearer credentials return 401."""
settings = SimpleNamespace(
admin_api_token="admin-token",
user_api_token="user-token",
allow_development_anonymous_user_access=False,
app_env="production",
)
with self.assertRaises(HTTPException) as context:
get_request_role(credentials=None, settings=settings)
self.assertEqual(context.exception.status_code, 401)
def test_get_request_role_allows_tokenless_user_access_in_development(self) -> None:
"""Development mode can allow tokenless user role for compatibility."""
settings = SimpleNamespace(
admin_api_token="admin-token",
user_api_token="user-token",
allow_development_anonymous_user_access=True,
app_env="development",
)
role = get_request_role(credentials=None, settings=settings)
self.assertEqual(role, AuthRole.USER)
"""Verifies role-based admin authorization behavior."""
def test_require_admin_rejects_user_role(self) -> None:
"""User role cannot access admin-only endpoints."""
with self.assertRaises(HTTPException) as context:
require_admin(role=AuthRole.USER)
self.assertEqual(context.exception.status_code, 403)
auth_context = AuthContext(
user_id=uuid.uuid4(),
username="user",
role=UserRole.USER,
session_id=uuid.uuid4(),
expires_at=datetime.now(UTC),
)
with self.assertRaises(HTTPException) as raised:
require_admin(context=auth_context)
self.assertEqual(raised.exception.status_code, 403)
def test_require_admin_accepts_admin_role(self) -> None:
"""Admin role is accepted for admin-only endpoints."""
auth_context = AuthContext(
user_id=uuid.uuid4(),
username="admin",
role=UserRole.ADMIN,
session_id=uuid.uuid4(),
expires_at=datetime.now(UTC),
)
resolved = require_admin(context=auth_context)
self.assertEqual(resolved.role, UserRole.ADMIN)
class ProviderBaseUrlValidationTests(unittest.TestCase):
@@ -559,6 +541,7 @@ class ArchiveLineagePropagationTests(unittest.TestCase):
source_relative_path="uploads/root.zip",
logical_path="Inbox",
tags=["finance"],
owner_user_id=uuid.uuid4(),
)
with (
@@ -578,6 +561,7 @@ class ArchiveLineagePropagationTests(unittest.TestCase):
self.assertEqual(child.metadata_json.get(worker_tasks_module.ARCHIVE_ROOT_ID_METADATA_KEY), str(parent_id))
self.assertEqual(child.metadata_json.get(worker_tasks_module.ARCHIVE_DEPTH_METADATA_KEY), 1)
self.assertTrue(child.is_archive_member)
self.assertEqual(child.owner_user_id, parent.owner_user_id)
def test_resolve_archive_lineage_prefers_existing_metadata(self) -> None:
"""Existing archive lineage metadata is reused without traversing parent relationships."""

View File

@@ -6,7 +6,8 @@ This directory contains technical documentation for DMS.
- `../README.md` - project overview, setup, and quick operations
- `architecture-overview.md` - backend, frontend, and infrastructure architecture
- `api-contract.md` - API endpoint contract grouped by route module, including token auth roles, upload limits, and settings or processing-log security constraints
- `api-contract.md` - API endpoint contract grouped by route module, including session auth, role and ownership scope, upload limits, and settings or processing-log security constraints
- `data-model-reference.md` - database entity definitions and lifecycle states
- `operations-and-configuration.md` - runtime operations, hardened compose defaults, security environment variables, and persisted settings configuration and read-sanitization behavior
- `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, authenticated media delivery under API token auth, processing-log timeline behavior, and settings helper-copy guidance
- `operations-and-configuration.md` - runtime operations, hardened compose defaults, DEV and LIVE security values, and persisted settings configuration behavior
- `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, authenticated media delivery under session auth, processing-log timeline behavior, and settings helper-copy guidance
- `../.env.example` - repository-level environment template with local defaults and production override guidance

View File

@@ -4,6 +4,7 @@ Base URL prefix: `/api/v1`
Primary implementation modules:
- `backend/app/api/router.py`
- `backend/app/api/routes_auth.py`
- `backend/app/api/routes_health.py`
- `backend/app/api/routes_documents.py`
- `backend/app/api/routes_search.py`
@@ -12,15 +13,32 @@ Primary implementation modules:
## Authentication And Authorization
- Protected endpoints require `Authorization: Bearer <token>` in production.
- Development deployments can allow tokenless user-role access for `documents/*` and `search/*` when `ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS=true`.
- `ADMIN_API_TOKEN` is required for all privileged access and acts as fail-closed root credential.
- `USER_API_TOKEN` is optional and, when configured, grants access to document endpoints only.
- Authorization matrix:
- `documents/*`: `admin` or `user`
- `search/*`: `admin` or `user`
- `settings/*`: `admin` only
- `processing/logs/*`: `admin` only
- Authentication is session-based bearer auth.
- Clients authenticate with `POST /auth/login` using username and password.
- Backend issues per-user bearer session tokens and stores hashed session state server-side.
- Clients send issued tokens as `Authorization: Bearer <token>`.
- `GET /auth/me` returns current identity and role.
- `POST /auth/logout` revokes current session token.
Role matrix:
- `documents/*`: `admin` or `user`
- `search/*`: `admin` or `user`
- `settings/*`: `admin` only
- `processing/logs/*`: `admin` only
Ownership rules:
- `user` role is restricted to its own documents.
- `admin` role can access all documents.
## Auth
- `POST /auth/login`
- Body model: `AuthLoginRequest`
- Response model: `AuthLoginResponse`
- `GET /auth/me`
- Response model: `AuthSessionResponse`
- `POST /auth/logout`
- Response model: `AuthLogoutResponse`
## Health
@@ -30,9 +48,6 @@ Primary implementation modules:
## Documents
- Access: admin or user token required (production)
- Access: admin or user token, or development tokenless user fallback when enabled
### Collection and metadata helpers
- `GET /documents`
@@ -50,6 +65,11 @@ Primary implementation modules:
- `POST /documents/content-md/export`
- Body model: `ContentExportRequest`
- Response: ZIP stream containing one markdown file per matched document
- Limits:
- hard cap on matched document count (`CONTENT_EXPORT_MAX_DOCUMENTS`)
- hard cap on cumulative markdown bytes (`CONTENT_EXPORT_MAX_TOTAL_BYTES`)
- per-user rate limit (`CONTENT_EXPORT_RATE_LIMIT_PER_MINUTE`)
- Behavior: archive is streamed from spool file instead of unbounded in-memory buffer
### Per-document operations
@@ -89,7 +109,7 @@ Primary implementation modules:
- `conflict_mode` (`ask`, `replace`, `duplicate`)
- Response model: `UploadResponse`
- Behavior:
- `ask`: returns `conflicts` if duplicate checksum is detected
- `ask`: returns `conflicts` if duplicate checksum is detected for caller-visible documents
- `replace`: creates new document linked to replaced document id
- `duplicate`: creates additional document record
- upload `POST` request rejected with `411` when `Content-Length` is missing
@@ -98,16 +118,14 @@ Primary implementation modules:
## Search
- Access: admin or user token required
- `GET /search`
- Query: `query` (min length 2), `offset`, `limit`, `include_trashed`, `only_trashed`, `path_filter`, `tag_filter`, `type_filter`, `processed_from`, `processed_to`
- Response model: `SearchResponse`
- Behavior: PostgreSQL full-text and metadata ranking
- Behavior: PostgreSQL full-text and metadata ranking with role-based ownership scope
## Processing Logs
- Access: admin token required
- Access: admin only
- `GET /processing/logs`
- Query: `offset`, `limit`, `document_id`
@@ -122,9 +140,13 @@ Primary implementation modules:
- `POST /processing/logs/clear`
- Response: clear counters
Persistence mode:
- default is metadata-only logging (`PROCESSING_LOG_STORE_MODEL_IO_TEXT=false`, `PROCESSING_LOG_STORE_PAYLOAD_TEXT=false`)
- full prompt/response or payload content storage requires explicit operator opt-in
## Settings
- Access: admin token required
- Access: admin only
- `GET /settings`
- Response model: `AppSettingsResponse`
@@ -145,6 +167,13 @@ Primary implementation modules:
## Schema Families
Auth schemas in `backend/app/schemas/auth.py`:
- `AuthLoginRequest`
- `AuthUserResponse`
- `AuthSessionResponse`
- `AuthLoginResponse`
- `AuthLogoutResponse`
Document schemas in `backend/app/schemas/documents.py`:
- `DocumentResponse`
- `DocumentDetailResponse`
@@ -160,4 +189,4 @@ Processing log schemas in `backend/app/schemas/processing_logs.py`:
- `ProcessingLogListResponse`
Settings schemas in `backend/app/schemas/settings.py`:
- Provider, task, upload-default, display, processing-log retention, predefined paths or tags, handwriting-style, and legacy handwriting models grouped under `AppSettingsResponse` and `AppSettingsUpdateRequest`.
- provider, task, upload-default, display, processing-log retention, predefined paths or tags, handwriting-style, and legacy handwriting models grouped under `AppSettingsResponse` and `AppSettingsUpdateRequest`.

View File

@@ -16,16 +16,16 @@ Backend source root: `backend/app/`
Main boundaries:
- `api/` route handlers and HTTP contract
- `services/` domain logic (storage, extraction, routing, settings, processing logs, Typesense)
- `services/` domain logic (authentication, storage, extraction, routing, settings, processing logs, Typesense)
- `db/` SQLAlchemy base, engine, and session lifecycle
- `models/` persistence entities (`Document`, `ProcessingLogEntry`)
- `models/` persistence entities (`AppUser`, `AuthSession`, `Document`, `ProcessingLogEntry`)
- `schemas/` Pydantic response and request schemas
- `worker/` RQ queue integration and background processing tasks
Application bootstrap in `backend/app/main.py`:
- mounts routers under `/api/v1`
- configures CORS from settings
- initializes storage, settings, database schema, and Typesense collection on startup
- initializes storage, database schema, bootstrap users, settings, and Typesense collection on startup
## Processing Lifecycle
@@ -48,11 +48,12 @@ Core structure:
- `design-foundation.css` and `styles.css` define design tokens and global/component styling
Main user flows:
- Login and role-gated navigation (`admin` and `user`)
- Upload and conflict resolution
- Search and filtered document browsing
- Metadata editing and lifecycle actions (trash, restore, delete, reprocess)
- Settings management for providers, tasks, and UI defaults
- Processing log review
- Settings management for providers, tasks, and UI defaults (admin only)
- Processing log review (admin only)
## Persistence and State
@@ -66,6 +67,11 @@ Transient runtime state:
- frontend local component state drives active filters, selection, and modal flows
Security-sensitive runtime behavior:
- API access is session-based with per-user server-issued bearer tokens and role checks.
- Document and search reads for `user` role are owner-scoped via `owner_user_id`; `admin` can access global scope.
- Redis connection URLs are validated by backend queue helpers with environment-aware auth and TLS policy enforcement.
- Worker startup runs through `python -m app.worker.run_worker`, which validates Redis URL policy before queue consumption.
- Inline preview is limited to safe MIME types and script-capable content is served as attachment-only.
- Archive fan-out processing propagates root and depth lineage metadata and enforces depth and per-root descendant caps.
- Markdown export applies per-user rate limits, hard document-count and total-byte caps, and spool-file streaming.
- Processing logs default to metadata-only persistence, with explicit operator toggles required to store model IO text.

View File

@@ -2,6 +2,38 @@
Primary SQLAlchemy models are defined in `backend/app/models/`.
## app_users
Model: `AppUser` in `backend/app/models/auth.py`
Purpose:
- Stores authenticatable user identities for session-based API access.
Core fields:
- Identity and credentials: `id`, `username`, `password_hash`
- Authorization and lifecycle: `role`, `is_active`
- Audit timestamps: `created_at`, `updated_at`
Enum `UserRole`:
- `admin`
- `user`
## auth_sessions
Model: `AuthSession` in `backend/app/models/auth.py`
Purpose:
- Stores issued bearer sessions linked to user identities.
Core fields:
- Identity and linkage: `id`, `user_id`, `token_hash`
- Session lifecycle: `expires_at`, `revoked_at`
- Request context: `user_agent`, `ip_address`
- Audit timestamps: `created_at`, `updated_at`
Foreign keys:
- `user_id` references `app_users.id` with `ON DELETE CASCADE`.
## documents
Model: `Document` in `backend/app/models/document.py`
@@ -12,7 +44,7 @@ Purpose:
Core fields:
- Identity and source: `id`, `original_filename`, `source_relative_path`, `stored_relative_path`
- File attributes: `mime_type`, `extension`, `sha256`, `size_bytes`
- Organization: `logical_path`, `suggested_path`, `tags`, `suggested_tags`
- Ownership and organization: `owner_user_id`, `logical_path`, `suggested_path`, `tags`, `suggested_tags`
- Processing outputs: `extracted_text`, `image_text_type`, `handwriting_style_id`, `preview_available`
- Lifecycle and relations: `status`, `is_archive_member`, `archived_member_path`, `parent_document_id`, `replaces_document_id`
- Metadata and timestamps: `metadata_json`, `created_at`, `processed_at`, `updated_at`
@@ -24,8 +56,12 @@ Enum `DocumentStatus`:
- `error`
- `trashed`
Foreign keys:
- `owner_user_id` references `app_users.id` with `ON DELETE SET NULL`.
Relationships:
- Self-referential `parent_document` relationship for archive extraction trees.
- `owner_user` relationship to `AppUser`.
## processing_logs
@@ -47,7 +83,10 @@ Foreign keys:
## Model Lifecycle Notes
- Upload inserts a `Document` row in `queued` state and enqueues background processing.
- Worker updates extraction results and final status (`processed`, `unsupported`, or `error`).
- API startup initializes schema and creates or refreshes bootstrap users from auth environment variables.
- `POST /auth/login` validates `AppUser` credentials, creates `AuthSession` with hashed token, and returns bearer token once.
- Upload inserts `Document` row in `queued` state, assigns `owner_user_id`, and enqueues background processing.
- Worker updates extraction results and final status (`processed`, `unsupported`, or `error`), preserving ownership on archive descendants.
- User-role queries are owner-scoped; admin-role queries can access all documents.
- Trash and restore operations toggle `status` while preserving source files until permanent delete.
- Permanent delete removes the document tree (including archive descendants) and associated stored files.

View File

@@ -52,7 +52,8 @@ Do not hardcode new palette or spacing values in component styles when a token a
## Authenticated Media Delivery
- Document previews and thumbnails must load through authenticated fetch flows in `frontend/src/lib/api.ts`, then render via temporary object URLs.
- Runtime auth should prefer per-user token resolution (`setApiTokenResolver` and `setRuntimeApiToken`) rather than static build-time token distribution, with `VITE_API_TOKEN` used only as fallback compatibility.
- Runtime auth uses server-issued per-user session tokens persisted with `setRuntimeApiToken` and read by `getRuntimeApiToken`.
- Static build-time token distribution is not supported.
- Direct `window.open` calls for protected media endpoints are not allowed because browser navigation requests do not include the API token header.
- Download actions for original files and markdown exports must use authenticated blob fetches plus controlled browser download triggers.
- Revoke all temporary object URLs after replacement, unmount, or completion to prevent browser memory leaks.

View File

@@ -2,15 +2,13 @@
## Runtime Services
`docker-compose.yml` defines the runtime stack:
- `db` (Postgres 16, internal network only)
- `redis` (Redis 7, internal network only, password-protected)
- `typesense` (Typesense 29, internal network only)
- `api` (FastAPI backend, host-bound port `8000`)
- `worker` (RQ background worker)
- `frontend` (Vite UI, host-bound port `5173`)
## Named Volumes
`docker-compose.yml` defines:
- `db` (Postgres 16)
- `redis` (Redis 7)
- `typesense` (Typesense 29)
- `api` (FastAPI backend)
- `worker` (RQ worker via `python -m app.worker.run_worker`)
- `frontend` (Vite React UI)
Persistent volumes:
- `db-data`
@@ -24,15 +22,15 @@ Reset all persisted runtime data:
docker compose down -v
```
## Operational Commands
## Core Commands
Start or rebuild stack:
Start or rebuild:
```bash
docker compose up --build -d
```
Stop stack:
Stop:
```bash
docker compose down
@@ -44,151 +42,81 @@ Tail logs:
docker compose logs -f
```
Before running compose, provide required credentials in your shell or project `.env` file:
## Authentication Model
```bash
export POSTGRES_USER="dcm"
export POSTGRES_PASSWORD="<random-postgres-password>"
export POSTGRES_DB="dcm"
export DATABASE_URL="postgresql+psycopg://<user>:<password>@db:5432/<db>"
export REDIS_PASSWORD="<random-redis-password>"
export REDIS_URL="redis://:<password>@redis:6379/0"
export ADMIN_API_TOKEN="<random-admin-token>"
export USER_API_TOKEN="<random-user-token>"
export APP_SETTINGS_ENCRYPTION_KEY="<random-settings-encryption-key>"
export TYPESENSE_API_KEY="<random-typesense-key>"
```
- Legacy shared build-time frontend token behavior was removed.
- API now uses server-issued per-user bearer sessions.
- Bootstrap users are provisioned from environment:
- `AUTH_BOOTSTRAP_ADMIN_USERNAME`
- `AUTH_BOOTSTRAP_ADMIN_PASSWORD`
- optional `AUTH_BOOTSTRAP_USER_USERNAME`
- optional `AUTH_BOOTSTRAP_USER_PASSWORD`
- Frontend signs in through `/api/v1/auth/login` and stores issued session token in browser session storage.
Compose fails fast when required credential variables are missing.
## DEV And LIVE Configuration Matrix
## Backend Configuration
Use `.env.example` as baseline. The table below documents user-managed settings and recommended values.
Settings source:
- Runtime settings class: `backend/app/core/config.py`
- API settings persistence: `backend/app/services/app_settings.py`
| Variable | Local DEV (HTTP, docker-only) | LIVE (HTTPS behind reverse proxy) |
| --- | --- | --- |
| `APP_ENV` | `development` | `production` |
| `HOST_BIND_IP` | `127.0.0.1` or local LAN bind if needed | `127.0.0.1` (publish behind proxy only) |
| `PUBLIC_BASE_URL` | `http://localhost:8000` | `https://api.example.com` |
| `VITE_API_BASE` | empty for host-derived `http://<frontend-host>:8000/api/v1`, or explicit local URL | `https://api.example.com/api/v1` |
| `CORS_ORIGINS` | `["http://localhost:5173","http://localhost:3000"]` | exact frontend origins only, for example `["https://app.example.com"]` |
| `CORS_ALLOW_CREDENTIALS` | `false` | `false` (Authorization header flow does not need credentialed CORS) |
| `REDIS_URL` | `redis://:<password>@redis:6379/0` in isolated local network | `rediss://:<password>@redis.internal:6379/0` |
| `REDIS_SECURITY_MODE` | `compat` or `auto` | `strict` |
| `REDIS_TLS_MODE` | `allow_insecure` or `auto` | `required` |
| `PROVIDER_BASE_URL_ALLOW_HTTP` | `true` only when intentionally testing local HTTP provider endpoints | `false` |
| `PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK` | `true` only for trusted local development targets | `false` |
| `PROVIDER_BASE_URL_ALLOWLIST` | allow needed test hosts | explicit production allowlist, for example `["api.openai.com"]` |
| `PROCESSING_LOG_STORE_MODEL_IO_TEXT` | `false` by default; temporary `true` only for controlled debugging | `false` |
| `PROCESSING_LOG_STORE_PAYLOAD_TEXT` | `false` by default; temporary `true` only for controlled debugging | `false` |
| `CONTENT_EXPORT_MAX_DOCUMENTS` | default `250` or lower based on host memory | tuned to production capacity |
| `CONTENT_EXPORT_MAX_TOTAL_BYTES` | default `52428800` (50 MiB) or lower | tuned to production capacity |
| `CONTENT_EXPORT_RATE_LIMIT_PER_MINUTE` | default `6` | tuned to API throughput and abuse model |
Key environment variables used by `api` and `worker` in compose:
- `APP_ENV`
- `DATABASE_URL`
- `REDIS_URL`
- `REDIS_SECURITY_MODE`
- `REDIS_TLS_MODE`
- `ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS`
- `STORAGE_ROOT`
- `ADMIN_API_TOKEN`
- `USER_API_TOKEN`
- `APP_SETTINGS_ENCRYPTION_KEY`
- `PUBLIC_BASE_URL`
- `CORS_ORIGINS` (API service)
- `PROVIDER_BASE_URL_ALLOWLIST`
- `PROVIDER_BASE_URL_ALLOW_HTTP`
- `PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK`
- `TYPESENSE_PROTOCOL`
- `TYPESENSE_HOST`
- `TYPESENSE_PORT`
- `TYPESENSE_API_KEY`
- `TYPESENSE_COLLECTION_NAME`
## HTTPS Proxy Deployment Notes
Selected defaults from `Settings` (`backend/app/core/config.py`):
- `upload_chunk_size = 4194304`
- `max_upload_files_per_request = 50`
- `max_upload_file_size_bytes = 26214400`
- `max_upload_request_size_bytes = 104857600`
- `max_zip_members = 250`
- `max_zip_depth = 2`
- `max_zip_descendants_per_root = 1000`
- `max_zip_member_uncompressed_bytes = 26214400`
- `max_zip_total_uncompressed_bytes = 157286400`
- `max_zip_compression_ratio = 120.0`
- `max_text_length = 500000`
- `processing_log_max_document_sessions = 20`
- `processing_log_max_unbound_entries = 400`
- `default_openai_model = "gpt-4.1-mini"`
- `default_openai_timeout_seconds = 45`
- `default_summary_model = "gpt-4.1-mini"`
- `default_routing_model = "gpt-4.1-mini"`
- `typesense_timeout_seconds = 120`
- `typesense_num_retries = 0`
This application supports both:
- local HTTP-only operation (no TLS termination in containers)
- HTTPS deployment behind a reverse proxy that handles TLS
## Frontend Configuration
Frontend runtime API target:
- `VITE_API_BASE` in `docker-compose.yml` frontend service (optional override)
- `VITE_API_TOKEN` in `docker-compose.yml` frontend service (optional compatibility fallback only)
When `VITE_API_BASE` is unset, frontend API helpers resolve to:
- `http://<current-frontend-hostname>:8000/api/v1`
Frontend API authentication behavior:
- `frontend/src/lib/api.ts` resolves bearer tokens at request time in this order:
- custom runtime resolver (`setApiTokenResolver`)
- runtime global token (`window.__DCM_API_TOKEN__`)
- session token (`setRuntimeApiToken`)
- legacy `VITE_API_TOKEN` fallback
- requests are sent without authorization only when no runtime or fallback token source is available
Frontend container runtime behavior:
- the container runs as non-root `node`
- `/app` is owned by `node` in `frontend/Dockerfile` so Vite can create runtime temp config files under `/app`
Frontend local commands:
```bash
cd frontend && npm run dev
cd frontend && npm run build
cd frontend && npm run preview
```
## Settings Persistence
Application-level settings managed from the UI are persisted by backend settings service:
- file path: `<STORAGE_ROOT>/settings.json`
- endpoints: `/api/v1/settings`, `/api/v1/settings/reset`, `/api/v1/settings/handwriting`
Settings include:
- upload defaults
- display options
- processing-log retention options (`keep_document_sessions`, `keep_unbound_entries`)
- provider configuration
- OCR, summary, and routing task settings
- predefined paths and tags
- handwriting-style clustering settings
Read sanitization is resilient to corrupt persisted provider rows. If a persisted provider entry fails URL validation, the entry is skipped and defaults are used when no valid provider remains. This prevents unrelated read endpoints from failing due to stale invalid provider data.
Provider API keys are persisted as encrypted payloads (`api_key_encrypted`) and plaintext `api_key` values are no longer written to disk.
Retention settings are used by worker cleanup and by `POST /api/v1/processing/logs/trim` when trim query values are not provided.
Recommended LIVE pattern:
1. Proxy terminates TLS and forwards to `api` and `frontend` internal HTTP endpoints.
2. Keep container published ports bound to localhost or internal network.
3. Set `PUBLIC_BASE_URL` and `VITE_API_BASE` to final HTTPS URLs.
4. Set `CORS_ORIGINS` to exact HTTPS frontend origins.
5. Keep `CORS_ALLOW_CREDENTIALS=false` for bearer header flow.
## Security Controls
- Privileged APIs are token-gated with bearer auth:
- `documents` endpoints: user token or admin token
- `settings` and `processing/logs` endpoints: admin token only
- Development environments can allow tokenless user-role access for document/search routes via `ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS=true`; production remains token-enforced.
- CORS allows HTTP and HTTPS origins by regex in addition to explicit `CORS_ORIGINS`, so LAN and public-domain frontend origins are accepted.
- Authentication fails closed when `ADMIN_API_TOKEN` is not configured and admin access is requested.
- Document preview endpoint blocks inline rendering for script-capable MIME types and forces attachment responses for active content.
- Provider base URLs are validated on settings updates and before outbound model calls:
- optional allowlist enforcement (`PROVIDER_BASE_URL_ALLOWLIST`)
- optional scheme restrictions (`PROVIDER_BASE_URL_ALLOW_HTTP`)
- optional private-network restrictions (`PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK`)
- per-request DNS revalidation checks for outbound runtime calls, including OCR provider path
- Upload and archive safety guards are enforced:
- `POST /api/v1/documents/upload` requires `Content-Length` and enforces file-count, per-file size, and total request size limits
- `OPTIONS /api/v1/documents/upload` CORS preflight is excluded from `Content-Length` enforcement
- ZIP member count, per-member uncompressed size, total decompressed size, compression-ratio guards, max depth, and per-root descendant fan-out cap
- Redis queue security checks enforce URL scheme/auth/TLS policy at runtime with production fail-closed defaults.
- Processing logs redact sensitive payload and text fields, and trim endpoints enforce retention caps from runtime config.
- Compose hardening defaults:
- only `api` and `frontend` publish host ports; `db`, `redis`, and `typesense` stay internal-only
- `api`, `worker`, and `frontend` drop all Linux capabilities and set `no-new-privileges`
- backend and frontend containers run as non-root users by default
- CORS uses explicit origin allowlist only; broad origin regex matching is removed.
- Worker Redis startup validates URL auth and TLS policy before consuming jobs.
- Provider API keys are encrypted at rest with standard AEAD (`cryptography` Fernet).
- legacy `enc-v1` payloads are read for backward compatibility
- new writes use `enc-v2`
- Processing logs default to metadata-only persistence.
- Markdown export enforces:
- max document count
- max total markdown bytes
- per-user Redis-backed rate limit
- spool-file streaming to avoid unbounded memory archives
- User-role document access is owner-scoped for non-admin accounts.
## Frontend Runtime
- Frontend no longer consumes `VITE_API_TOKEN`.
- Session token storage key is `dcm.access_token` in browser session storage.
- Protected media and file download flows still use authenticated fetch plus blob/object URL handling.
## Validation Checklist
After operational or configuration changes, verify:
- `GET /api/v1/health` is healthy
- frontend can list, upload, and search documents
- processing worker logs show successful task execution
- settings save or reset works and persists after restart
After configuration changes:
- `GET /api/v1/health` returns healthy response
- login succeeds for bootstrap admin user
- admin can upload, search, open preview, download, and export markdown
- user account can only access its own documents
- admin-only settings and processing logs are not accessible by user role
- `docker compose logs -f api worker` shows no startup validation failures

View File

@@ -42,14 +42,21 @@ services:
REDIS_URL: ${REDIS_URL:?REDIS_URL must be set}
REDIS_SECURITY_MODE: ${REDIS_SECURITY_MODE:-auto}
REDIS_TLS_MODE: ${REDIS_TLS_MODE:-auto}
ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS: ${ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS:-true}
STORAGE_ROOT: /data/storage
ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?ADMIN_API_TOKEN must be set}
USER_API_TOKEN: ${USER_API_TOKEN:?USER_API_TOKEN must be set}
AUTH_BOOTSTRAP_ADMIN_USERNAME: ${AUTH_BOOTSTRAP_ADMIN_USERNAME:?AUTH_BOOTSTRAP_ADMIN_USERNAME must be set}
AUTH_BOOTSTRAP_ADMIN_PASSWORD: ${AUTH_BOOTSTRAP_ADMIN_PASSWORD:?AUTH_BOOTSTRAP_ADMIN_PASSWORD must be set}
AUTH_BOOTSTRAP_USER_USERNAME: ${AUTH_BOOTSTRAP_USER_USERNAME:-}
AUTH_BOOTSTRAP_USER_PASSWORD: ${AUTH_BOOTSTRAP_USER_PASSWORD:-}
APP_SETTINGS_ENCRYPTION_KEY: ${APP_SETTINGS_ENCRYPTION_KEY:?APP_SETTINGS_ENCRYPTION_KEY must be set}
PROVIDER_BASE_URL_ALLOWLIST: '${PROVIDER_BASE_URL_ALLOWLIST:-[]}'
PROVIDER_BASE_URL_ALLOW_HTTP: ${PROVIDER_BASE_URL_ALLOW_HTTP:-true}
PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK: ${PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK:-true}
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false}
PROCESSING_LOG_STORE_MODEL_IO_TEXT: ${PROCESSING_LOG_STORE_MODEL_IO_TEXT:-false}
PROCESSING_LOG_STORE_PAYLOAD_TEXT: ${PROCESSING_LOG_STORE_PAYLOAD_TEXT:-false}
CONTENT_EXPORT_MAX_DOCUMENTS: ${CONTENT_EXPORT_MAX_DOCUMENTS:-250}
CONTENT_EXPORT_MAX_TOTAL_BYTES: ${CONTENT_EXPORT_MAX_TOTAL_BYTES:-52428800}
CONTENT_EXPORT_RATE_LIMIT_PER_MINUTE: ${CONTENT_EXPORT_RATE_LIMIT_PER_MINUTE:-6}
OCR_LANGUAGES: eng,deu
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:8000}
CORS_ORIGINS: '${CORS_ORIGINS:-["http://localhost:5173","http://localhost:3000"]}'
@@ -78,23 +85,22 @@ services:
worker:
build:
context: ./backend
command: ["sh", "-c", "rq worker dcm --url \"$REDIS_URL\""]
command: ["python", "-m", "app.worker.run_worker"]
environment:
APP_ENV: ${APP_ENV:-development}
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
REDIS_URL: ${REDIS_URL:?REDIS_URL must be set}
REDIS_SECURITY_MODE: ${REDIS_SECURITY_MODE:-auto}
REDIS_TLS_MODE: ${REDIS_TLS_MODE:-auto}
ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS: ${ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS:-true}
STORAGE_ROOT: /data/storage
ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?ADMIN_API_TOKEN must be set}
USER_API_TOKEN: ${USER_API_TOKEN:?USER_API_TOKEN must be set}
APP_SETTINGS_ENCRYPTION_KEY: ${APP_SETTINGS_ENCRYPTION_KEY:?APP_SETTINGS_ENCRYPTION_KEY must be set}
PROVIDER_BASE_URL_ALLOWLIST: '${PROVIDER_BASE_URL_ALLOWLIST:-[]}'
PROVIDER_BASE_URL_ALLOW_HTTP: ${PROVIDER_BASE_URL_ALLOW_HTTP:-true}
PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK: ${PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK:-true}
PROCESSING_LOG_STORE_MODEL_IO_TEXT: ${PROCESSING_LOG_STORE_MODEL_IO_TEXT:-false}
PROCESSING_LOG_STORE_PAYLOAD_TEXT: ${PROCESSING_LOG_STORE_PAYLOAD_TEXT:-false}
OCR_LANGUAGES: eng,deu
PUBLIC_BASE_URL: http://localhost:8000
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:8000}
TYPESENSE_PROTOCOL: http
TYPESENSE_HOST: typesense
TYPESENSE_PORT: 8108
@@ -120,7 +126,6 @@ services:
context: ./frontend
environment:
VITE_API_BASE: ${VITE_API_BASE:-}
VITE_API_TOKEN: ${VITE_API_TOKEN:-}
ports:
- "${HOST_BIND_IP:-127.0.0.1}:5173:5173"
volumes:

View File

@@ -6,6 +6,7 @@ import type { JSX } from 'react';
import ActionModal from './components/ActionModal';
import DocumentGrid from './components/DocumentGrid';
import LoginScreen from './components/LoginScreen';
import DocumentViewer from './components/DocumentViewer';
import PathInput from './components/PathInput';
import ProcessingLogPanel from './components/ProcessingLogPanel';
@@ -17,22 +18,28 @@ import {
downloadBlobFile,
deleteDocument,
exportContentsMarkdown,
getCurrentAuthSession,
getRuntimeApiToken,
getAppSettings,
listDocuments,
listPaths,
listProcessingLogs,
listTags,
listTypes,
loginWithPassword,
logoutCurrentSession,
resetAppSettings,
setRuntimeApiToken,
searchDocuments,
trashDocument,
updateAppSettings,
uploadDocuments,
} from './lib/api';
import type { AppSettings, AppSettingsUpdate, DmsDocument, ProcessingLogEntry } from './types';
import type { AppSettings, AppSettingsUpdate, AuthUser, DmsDocument, ProcessingLogEntry } from './types';
type AppScreen = 'documents' | 'settings';
type DocumentView = 'active' | 'trash';
type AuthPhase = 'checking' | 'unauthenticated' | 'authenticated';
interface DialogOption {
key: string;
@@ -51,6 +58,10 @@ interface DialogState {
*/
export default function App(): JSX.Element {
const DEFAULT_PAGE_SIZE = 12;
const [authPhase, setAuthPhase] = useState<AuthPhase>('checking');
const [authUser, setAuthUser] = useState<AuthUser | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState<boolean>(false);
const [screen, setScreen] = useState<AppScreen>('documents');
const [documentView, setDocumentView] = useState<DocumentView>('active');
const [documents, setDocuments] = useState<DmsDocument[]>([]);
@@ -82,6 +93,7 @@ export default function App(): JSX.Element {
const [error, setError] = useState<string | null>(null);
const [dialogState, setDialogState] = useState<DialogState | null>(null);
const dialogResolverRef = useRef<((value: string) => void) | null>(null);
const isAdmin = authUser?.role === 'admin';
const pageSize = useMemo(() => {
const configured = appSettings?.display?.cards_per_page;
@@ -118,6 +130,74 @@ export default function App(): JSX.Element {
}
}, []);
/**
* Clears workspace state when authentication context changes or session is revoked.
*/
const resetApplicationState = useCallback((): void => {
setScreen('documents');
setDocumentView('active');
setDocuments([]);
setTotalDocuments(0);
setCurrentPage(1);
setSearchText('');
setActiveSearchQuery('');
setSelectedDocumentId(null);
setSelectedDocumentIds([]);
setExportPathInput('');
setTagFilter('');
setTypeFilter('');
setPathFilter('');
setProcessedFrom('');
setProcessedTo('');
setKnownTags([]);
setKnownPaths([]);
setKnownTypes([]);
setAppSettings(null);
setSettingsSaveAction(null);
setProcessingLogs([]);
setProcessingLogError(null);
setError(null);
}, []);
/**
* Exchanges submitted credentials for server-issued bearer session and activates app shell.
*/
const handleLogin = useCallback(async (username: string, password: string): Promise<void> => {
setIsAuthenticating(true);
setAuthError(null);
try {
const payload = await loginWithPassword(username, password);
setRuntimeApiToken(payload.access_token);
setAuthUser(payload.user);
setAuthPhase('authenticated');
setError(null);
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : 'Login failed';
setAuthError(message);
setRuntimeApiToken(null);
setAuthUser(null);
setAuthPhase('unauthenticated');
resetApplicationState();
} finally {
setIsAuthenticating(false);
}
}, [resetApplicationState]);
/**
* Revokes current session server-side when possible and always clears local auth state.
*/
const handleLogout = useCallback(async (): Promise<void> => {
setError(null);
try {
await logoutCurrentSession();
} catch {}
setRuntimeApiToken(null);
setAuthUser(null);
setAuthError(null);
setAuthPhase('unauthenticated');
resetApplicationState();
}, [resetApplicationState]);
const loadCatalogs = useCallback(async (): Promise<void> => {
const [tags, paths, types] = await Promise.all([listTags(true), listPaths(true), listTypes(true)]);
setKnownTags(tags);
@@ -185,6 +265,10 @@ export default function App(): JSX.Element {
]);
const loadSettings = useCallback(async (): Promise<void> => {
if (!isAdmin) {
setAppSettings(null);
return;
}
setError(null);
try {
const payload = await getAppSettings();
@@ -192,9 +276,14 @@ export default function App(): JSX.Element {
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to load settings');
}
}, []);
}, [isAdmin]);
const loadProcessingTimeline = useCallback(async (options?: { silent?: boolean }): Promise<void> => {
if (!isAdmin) {
setProcessingLogs([]);
setProcessingLogError(null);
return;
}
const silent = options?.silent ?? false;
if (!silent) {
setIsLoadingLogs(true);
@@ -210,18 +299,52 @@ export default function App(): JSX.Element {
setIsLoadingLogs(false);
}
}
}, []);
}, [isAdmin]);
useEffect(() => {
const existingToken = getRuntimeApiToken();
if (!existingToken) {
setAuthPhase('unauthenticated');
setAuthUser(null);
return;
}
const resolveSession = async (): Promise<void> => {
try {
const sessionPayload = await getCurrentAuthSession();
setAuthUser(sessionPayload.user);
setAuthError(null);
setAuthPhase('authenticated');
} catch {
setRuntimeApiToken(null);
setAuthUser(null);
setAuthPhase('unauthenticated');
resetApplicationState();
}
};
void resolveSession();
}, [resetApplicationState]);
useEffect(() => {
if (authPhase !== 'authenticated') {
return;
}
const bootstrap = async (): Promise<void> => {
try {
if (isAdmin) {
await Promise.all([loadDocuments(), loadCatalogs(), loadSettings(), loadProcessingTimeline()]);
return;
}
await Promise.all([loadDocuments(), loadCatalogs()]);
setAppSettings(null);
setProcessingLogs([]);
setProcessingLogError(null);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to initialize application');
}
};
void bootstrap();
}, [loadCatalogs, loadDocuments, loadProcessingTimeline, loadSettings]);
}, [authPhase, isAdmin, loadCatalogs, loadDocuments, loadProcessingTimeline, loadSettings]);
useEffect(() => {
setSelectedDocumentIds([]);
@@ -229,13 +352,25 @@ export default function App(): JSX.Element {
}, [documentView, pageSize]);
useEffect(() => {
if (!isAdmin && screen === 'settings') {
setScreen('documents');
}
}, [isAdmin, screen]);
useEffect(() => {
if (authPhase !== 'authenticated') {
return;
}
if (screen !== 'documents') {
return;
}
void loadDocuments();
}, [loadDocuments, screen]);
}, [authPhase, loadDocuments, screen]);
useEffect(() => {
if (authPhase !== 'authenticated') {
return;
}
if (screen !== 'documents') {
return;
}
@@ -243,9 +378,12 @@ export default function App(): JSX.Element {
void loadDocuments({ silent: true });
}, 3000);
return () => window.clearInterval(pollInterval);
}, [loadDocuments, screen]);
}, [authPhase, loadDocuments, screen]);
useEffect(() => {
if (authPhase !== 'authenticated' || !isAdmin) {
return;
}
if (screen !== 'documents') {
return;
}
@@ -254,7 +392,7 @@ export default function App(): JSX.Element {
void loadProcessingTimeline({ silent: true });
}, 1500);
return () => window.clearInterval(pollInterval);
}, [loadProcessingTimeline, screen]);
}, [authPhase, isAdmin, loadProcessingTimeline, screen]);
const selectedDocument = useMemo(
() => documents.find((document) => document.id === selectedDocumentId) ?? null,
@@ -299,13 +437,17 @@ export default function App(): JSX.Element {
});
}
if (isAdmin) {
await Promise.all([loadDocuments(), loadCatalogs(), loadProcessingTimeline()]);
} else {
await Promise.all([loadDocuments(), loadCatalogs()]);
}
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Upload failed');
} finally {
setIsUploading(false);
}
}, [appSettings, loadCatalogs, loadDocuments, loadProcessingTimeline, presentDialog]);
}, [appSettings, isAdmin, loadCatalogs, loadDocuments, loadProcessingTimeline, presentDialog]);
const handleSearch = useCallback(async (): Promise<void> => {
setSelectedDocumentIds([]);
@@ -579,6 +721,21 @@ export default function App(): JSX.Element {
setCurrentPage(1);
}, []);
if (authPhase === 'checking') {
return (
<main className="auth-shell">
<section className="auth-card">
<h1>LedgerDock</h1>
<p>Checking current session...</p>
</section>
</main>
);
}
if (authPhase !== 'authenticated') {
return <LoginScreen error={authError} isSubmitting={isAuthenticating} onSubmit={handleLogin} />;
}
return (
<main className="app-shell">
<header className="topbar">
@@ -608,6 +765,7 @@ export default function App(): JSX.Element {
>
Trash
</button>
{isAdmin && (
<button
type="button"
className={screen === 'settings' ? 'active-view-button' : 'secondary-action'}
@@ -615,6 +773,16 @@ export default function App(): JSX.Element {
>
Settings
</button>
)}
</div>
<div className="topbar-auth-group">
<span className="auth-user-badge">
{authUser?.username} ({authUser?.role})
</span>
<button type="button" className="secondary-action" onClick={() => void handleLogout()}>
Sign Out
</button>
</div>
{screen === 'documents' && (
@@ -623,7 +791,7 @@ export default function App(): JSX.Element {
</div>
)}
{screen === 'settings' && (
{screen === 'settings' && isAdmin && (
<div className="topbar-settings-group">
<button type="button" className="secondary-action" onClick={() => void handleResetSettings()} disabled={isSavingSettings}>
Reset To Defaults
@@ -638,7 +806,7 @@ export default function App(): JSX.Element {
{error && <p className="error-banner">{error}</p>}
{screen === 'settings' && (
{screen === 'settings' && isAdmin && (
<SettingsScreen
settings={appSettings}
isSaving={isSavingSettings}
@@ -762,7 +930,8 @@ export default function App(): JSX.Element {
requestConfirmation={requestConfirmation}
/>
</section>
{processingLogError && <p className="error-banner">{processingLogError}</p>}
{isAdmin && processingLogError && <p className="error-banner">{processingLogError}</p>}
{isAdmin && (
<ProcessingLogPanel
entries={processingLogs}
isLoading={isLoadingLogs}
@@ -772,6 +941,7 @@ export default function App(): JSX.Element {
typingAnimationEnabled={typingAnimationEnabled}
onClear={() => void handleClearProcessingLogs()}
/>
)}
</>
)}

View File

@@ -0,0 +1,71 @@
/**
* Login screen for session-based authentication before loading protected application views.
*/
import { FormEvent, useState } from 'react';
import type { JSX } from 'react';
interface LoginScreenProps {
error: string | null;
isSubmitting: boolean;
onSubmit: (username: string, password: string) => Promise<void>;
}
/**
* Renders credential form used to issue per-user API bearer sessions.
*/
export default function LoginScreen({
error,
isSubmitting,
onSubmit,
}: LoginScreenProps): JSX.Element {
const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
/**
* Submits credentials and leaves result handling to parent application orchestration.
*/
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
if (isSubmitting) {
return;
}
void onSubmit(username, password);
};
return (
<main className="auth-shell">
<section className="auth-card">
<h1>LedgerDock</h1>
<p>Sign in with your account to access documents and role-scoped controls.</p>
<form onSubmit={handleSubmit} className="auth-form">
<label>
Username
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
required
disabled={isSubmitting}
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
disabled={isSubmitting}
/>
</label>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing In...' : 'Sign In'}
</button>
</form>
{error && <p className="error-banner">{error}</p>}
</section>
</main>
);
}

View File

@@ -1,5 +1,16 @@
// @ts-expect-error Node strip-types runtime requires explicit .ts extension in ESM imports.
import { API_TOKEN_RUNTIME_GLOBAL_KEY, downloadDocumentContentMarkdown, downloadDocumentFile, getDocumentPreviewBlob, getDocumentThumbnailBlob, setApiTokenResolver, setRuntimeApiToken, updateDocumentMetadata } from './api.ts';
// @ts-ignore Node strip-types runtime requires explicit .ts extension in ESM imports.
import {
downloadDocumentContentMarkdown,
downloadDocumentFile,
getCurrentAuthSession,
getDocumentPreviewBlob,
getDocumentThumbnailBlob,
getRuntimeApiToken,
loginWithPassword,
logoutCurrentSession,
setRuntimeApiToken,
updateDocumentMetadata,
} from './api.ts';
/**
* Throws when a test condition is false.
@@ -65,12 +76,10 @@ function createMemorySessionStorage(): Storage {
}
/**
* Runs API helper tests for authenticated media and download flows.
* Runs API helper tests for authenticated media and auth session workflows.
*/
async function runApiTests(): Promise<void> {
const originalFetch = globalThis.fetch;
const runtimeGlobalSource = globalThis as typeof globalThis & Record<string, unknown>;
const originalRuntimeGlobalToken = runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
const sessionStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
try {
@@ -79,9 +88,7 @@ async function runApiTests(): Promise<void> {
writable: true,
value: createMemorySessionStorage(),
});
setApiTokenResolver(null);
setRuntimeApiToken(null);
delete runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
const requestUrls: string[] = [];
const requestAuthHeaders: Array<string | null> = [];
@@ -108,6 +115,7 @@ async function runApiTests(): Promise<void> {
assert(requestAuthHeaders[1] === null, `Expected no auth header for preview request, got "${requestAuthHeaders[1]}"`);
setRuntimeApiToken('session-user-token');
assert(getRuntimeApiToken() === 'session-user-token', 'Expected session token readback to match persisted token');
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const authHeader = new Headers(init?.headers).get('Authorization');
assert(authHeader === 'Bearer session-user-token', `Expected session token auth header, got "${authHeader}"`);
@@ -115,16 +123,6 @@ async function runApiTests(): Promise<void> {
}) as typeof fetch;
await getDocumentPreviewBlob('doc-session-auth');
setRuntimeApiToken('session-user-token');
runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY] = 'runtime-global-token';
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const authHeader = new Headers(init?.headers).get('Authorization');
assert(authHeader === 'Bearer runtime-global-token', `Expected global runtime token auth header, got "${authHeader}"`);
return new Response('preview-bytes', { status: 200 });
}) as typeof fetch;
await getDocumentPreviewBlob('doc-global-auth');
setApiTokenResolver(() => 'resolver-token');
let mergedContentType: string | null = null;
let mergedAuthorization: string | null = null;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
@@ -135,19 +133,47 @@ async function runApiTests(): Promise<void> {
}) as typeof fetch;
await updateDocumentMetadata('doc-headers', { original_filename: 'renamed.pdf' });
assert(mergedContentType === 'application/json', `Expected JSON content type to be preserved, got "${mergedContentType}"`);
assert(mergedAuthorization === 'Bearer resolver-token', `Expected resolver token auth header, got "${mergedAuthorization}"`);
assert(mergedAuthorization === 'Bearer session-user-token', `Expected auth header, got "${mergedAuthorization}"`);
setApiTokenResolver(() => ' ');
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const authHeader = new Headers(init?.headers).get('Authorization');
assert(authHeader === 'Bearer runtime-global-token', `Expected fallback runtime global token auth header, got "${authHeader}"`);
return new Response('preview-bytes', { status: 200 });
globalThis.fetch = (async (): Promise<Response> => {
return new Response(
JSON.stringify({
access_token: 'issued-session-token',
token_type: 'bearer',
expires_at: '2026-03-01T10:30:00Z',
user: {
id: '3a42f5e0-b1ad-4f68-b2f4-3fa8c2fb31c9',
username: 'admin',
role: 'admin',
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof fetch;
await getDocumentPreviewBlob('doc-resolver-fallback');
const loginPayload = await loginWithPassword('admin', 'password');
assert(loginPayload.access_token === 'issued-session-token', 'Unexpected issued session token in login payload');
assert(loginPayload.user.username === 'admin', 'Unexpected login user payload');
setApiTokenResolver(null);
setRuntimeApiToken(null);
delete runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
globalThis.fetch = (async (): Promise<Response> => {
return new Response(
JSON.stringify({
expires_at: '2026-03-01T10:30:00Z',
user: {
id: '3a42f5e0-b1ad-4f68-b2f4-3fa8c2fb31c9',
username: 'admin',
role: 'admin',
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof fetch;
const sessionPayload = await getCurrentAuthSession();
assert(sessionPayload.user.role === 'admin', 'Expected admin role from auth session payload');
globalThis.fetch = (async (): Promise<Response> => {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}) as typeof fetch;
await logoutCurrentSession();
globalThis.fetch = (async (): Promise<Response> => {
return new Response('file-bytes', {
@@ -176,13 +202,7 @@ async function runApiTests(): Promise<void> {
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
} finally {
setApiTokenResolver(null);
setRuntimeApiToken(null);
if (originalRuntimeGlobalToken === undefined) {
delete runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
} else {
runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY] = originalRuntimeGlobalToken;
}
if (sessionStorageDescriptor) {
Object.defineProperty(globalThis, 'sessionStorage', sessionStorageDescriptor);
} else {

View File

@@ -4,6 +4,8 @@
import type {
AppSettings,
AppSettingsUpdate,
AuthLoginResponse,
AuthSessionInfo,
DocumentListResponse,
DmsDocument,
DmsDocumentDetail,
@@ -33,27 +35,10 @@ function resolveApiBase(): string {
const API_BASE = resolveApiBase();
/**
* Legacy environment token fallback used only when no runtime token source is available.
*/
const LEGACY_API_TOKEN = normalizeBearerToken(import.meta.env?.VITE_API_TOKEN);
/**
* Global property name used for runtime token injection.
*/
export const API_TOKEN_RUNTIME_GLOBAL_KEY = '__DCM_API_TOKEN__';
/**
* Session storage key used for per-user runtime token persistence.
*/
export const API_TOKEN_RUNTIME_STORAGE_KEY = 'dcm.api_token';
/**
* Resolves a bearer token dynamically at request time.
*/
export type ApiTokenResolver = () => string | null | undefined;
let runtimeTokenResolver: ApiTokenResolver | null = null;
export const API_TOKEN_RUNTIME_STORAGE_KEY = 'dcm.access_token';
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
@@ -71,17 +56,9 @@ function normalizeBearerToken(candidate: unknown): string | undefined {
}
/**
* Resolves runtime token from mutable global injection points when available.
* Resolves bearer token persisted for current browser session.
*/
function resolveGlobalRuntimeToken(): string | undefined {
const source = globalThis as typeof globalThis & Record<string, unknown>;
return normalizeBearerToken(source[API_TOKEN_RUNTIME_GLOBAL_KEY]);
}
/**
* Resolves runtime token from session storage where per-user state can be isolated by browser session.
*/
function resolveSessionStorageToken(): string | undefined {
export function getRuntimeApiToken(): string | undefined {
if (typeof globalThis.sessionStorage === 'undefined') {
return undefined;
}
@@ -93,31 +70,10 @@ function resolveSessionStorageToken(): string | undefined {
}
/**
* Resolves bearer token using runtime sources first, then legacy environment fallback for compatibility.
* Resolves bearer token from authenticated browser-session storage.
*/
function resolveApiToken(): string | undefined {
const resolverToken = normalizeBearerToken(runtimeTokenResolver?.());
if (resolverToken) {
return resolverToken;
}
const globalRuntimeToken = resolveGlobalRuntimeToken();
if (globalRuntimeToken) {
return globalRuntimeToken;
}
const sessionStorageToken = resolveSessionStorageToken();
if (sessionStorageToken) {
return sessionStorageToken;
}
return LEGACY_API_TOKEN;
}
/**
* Registers or clears a request-time bearer token resolver used by API helpers.
*
* @param resolver Function returning a token for each request, or `null` to remove custom resolution.
*/
export function setApiTokenResolver(resolver: ApiTokenResolver | null): void {
runtimeTokenResolver = resolver;
return getRuntimeApiToken();
}
/**
@@ -226,6 +182,59 @@ export function downloadBlobFile(blob: Blob, filename: string): void {
}, 0);
}
/**
* Authenticates one user and returns issued bearer token plus role-bound session metadata.
*/
export async function loginWithPassword(username: string, password: string): Promise<AuthLoginResponse> {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.trim(),
password,
}),
});
if (!response.ok) {
const detail = await responseErrorDetail(response);
if (detail) {
throw new Error(detail);
}
throw new Error('Login failed');
}
return response.json() as Promise<AuthLoginResponse>;
}
/**
* Loads currently authenticated user session metadata.
*/
export async function getCurrentAuthSession(): Promise<AuthSessionInfo> {
const response = await apiRequest(`${API_BASE}/auth/me`);
if (!response.ok) {
const detail = await responseErrorDetail(response);
if (detail) {
throw new Error(detail);
}
throw new Error('Failed to load authentication session');
}
return response.json() as Promise<AuthSessionInfo>;
}
/**
* Revokes the current authenticated bearer session.
*/
export async function logoutCurrentSession(): Promise<void> {
const response = await apiRequest(`${API_BASE}/auth/logout`, {
method: 'POST',
});
if (!response.ok && response.status !== 401) {
const detail = await responseErrorDetail(response);
if (detail) {
throw new Error(detail);
}
throw new Error('Failed to logout');
}
}
/**
* Loads documents from the backend list endpoint.
*/

View File

@@ -9,6 +9,53 @@
gap: var(--space-3);
}
.auth-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: var(--space-4) var(--space-2);
}
.auth-card {
width: min(430px, 100%);
display: grid;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-lg);
background: linear-gradient(180deg, rgba(28, 42, 63, 0.95) 0%, rgba(20, 30, 47, 0.95) 100%);
box-shadow: var(--shadow-soft);
}
.auth-card h1 {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.4rem, 2.1vw, 2rem);
}
.auth-card p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.88rem;
}
.auth-form {
display: grid;
gap: var(--space-2);
}
.auth-form label {
display: grid;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.auth-form button {
margin-top: 0.25rem;
min-height: 2.1rem;
}
.app-shell > * {
animation: rise-in 220ms ease both;
}
@@ -57,6 +104,7 @@
}
.topbar-nav-group,
.topbar-auth-group,
.topbar-document-group,
.topbar-settings-group {
display: flex;
@@ -65,6 +113,22 @@
gap: var(--space-2);
}
.topbar-auth-group {
align-items: center;
}
.auth-user-badge {
display: inline-flex;
align-items: center;
padding: 0.28rem 0.5rem;
border-radius: var(--radius-xs);
border: 1px solid rgba(108, 135, 184, 0.7);
background: rgba(17, 28, 44, 0.85);
color: var(--color-text-muted);
font-size: 0.74rem;
font-family: var(--font-mono);
}
.topbar-document-group .upload-actions-inline {
display: flex;
gap: var(--space-2);

View File

@@ -58,6 +58,31 @@ export interface SearchResponse {
items: DmsDocument[];
}
/**
* Represents one authenticated user identity returned by backend auth endpoints.
*/
export interface AuthUser {
id: string;
username: string;
role: 'admin' | 'user';
}
/**
* Represents active authentication session metadata.
*/
export interface AuthSessionInfo {
user: AuthUser;
expires_at: string;
}
/**
* Represents login response payload with issued bearer token and session metadata.
*/
export interface AuthLoginResponse extends AuthSessionInfo {
access_token: string;
token_type: 'bearer';
}
/**
* Represents distinct document type values available for filter controls.
*/

View File

@@ -15,5 +15,6 @@
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "react", "react-dom"]
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}