refactor(09-05): CODE-09 purge — Phase 8 backend sub-packages (WHAT comments removed)
- api/admin/users.py: removed module docstring + 7 WHAT function docstrings - api/admin/quotas.py: removed module docstring + 2 WHAT function docstrings - api/admin/ai.py: removed module docstring + 3 WHAT inline comments; security invariant docstrings preserved - api/admin/shared.py: unchanged (all comments are WHY — constraint notes) - api/documents/upload.py: removed 4 WHAT inline comments; T-03-05/T-03-06 WHY notes preserved - api/documents/content.py: removed WHAT function docstring + WHAT inline comment - api/documents/crud.py: removed 7 WHAT inline comments; D-16 + security constraint docstrings preserved - api/auth/shared.py: removed module docstring + 1 WHAT inline comment - api/auth/tokens.py: removed module docstring + 8 WHAT function docstrings/comments; family-revocation + SEC-02 WHY notes preserved - api/auth/totp.py: removed module docstring + 2 WHAT function docstrings + 2 WHAT inline comments - api/auth/password.py: removed module docstring + 2 WHAT function docstrings + 3 WHAT inline comments - All NO-prefix anchor comments and security-invariant WHY comments preserved - pytest: 413 passed, 1 failed (pre-existing ModuleNotFoundError unrelated to purge)
This commit is contained in:
+1
-17
@@ -1,12 +1,3 @@
|
||||
"""Admin AI configuration endpoints.
|
||||
|
||||
Handles: get_ai_config_models, test_ai_connection, get_ai_config, update_system_ai_config.
|
||||
|
||||
All handlers require get_current_admin (SEC-07, T-08-04-01).
|
||||
Sub-router has NO prefix — parent __init__.py carries /api/admin (D-04).
|
||||
|
||||
_ai_config_to_dict is local to this module (only ai.py uses it — not in shared.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
@@ -126,7 +117,7 @@ async def get_ai_config_models(
|
||||
|
||||
api_key = config.api_key if config else ""
|
||||
|
||||
# Build request headers — Anthropic uses x-api-key; all others use Bearer
|
||||
# Anthropic uses x-api-key; all others use Bearer (different auth schemes)
|
||||
if provider_id == "anthropic":
|
||||
headers = {
|
||||
"x-api-key": api_key,
|
||||
@@ -143,9 +134,6 @@ async def get_ai_config_models(
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Standard OpenAI-compat shape: {"data": [{"id": "...", ...}, ...]}
|
||||
# Anthropic shape: {"data": [{"id": "...", ...}, ...]}
|
||||
# Ollama OpenAI-compat: same shape
|
||||
raw_list = data.get("data") or data.get("models") or []
|
||||
model_ids: list[str] = sorted(
|
||||
{
|
||||
@@ -286,7 +274,6 @@ async def update_system_ai_config(
|
||||
"""
|
||||
from config import settings as _settings # noqa: PLC0415
|
||||
|
||||
# Load existing row or create a new one from PROVIDER_DEFAULTS
|
||||
stmt = select(SystemSettings).where(SystemSettings.provider_id == body.provider_id)
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
@@ -303,10 +290,8 @@ async def update_system_ai_config(
|
||||
api_key_enc=None,
|
||||
)
|
||||
|
||||
# Track which fields the caller explicitly set (for audit log — never api_key value)
|
||||
fields_changed: list[str] = []
|
||||
|
||||
# Apply provided fields
|
||||
if body.api_key is not None:
|
||||
fields_changed.append("api_key")
|
||||
if body.api_key == "":
|
||||
@@ -363,7 +348,6 @@ async def update_system_ai_config(
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Reload to pick up DB-generated updated_at after commit
|
||||
await session.refresh(row)
|
||||
|
||||
return _ai_config_to_dict(row)
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
"""Admin quota management endpoints.
|
||||
|
||||
Handles: get_user_quota, update_user_quota.
|
||||
|
||||
All handlers require get_current_admin (SEC-07, T-08-04-01).
|
||||
Sub-router has NO prefix — parent __init__.py carries /api/admin (D-04).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
@@ -44,11 +37,6 @@ async def get_user_quota(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Return quota details for a user (ADMIN-04).
|
||||
|
||||
Quota info is admin-visible operational data — no PII, no document content
|
||||
(T-02-31 disposition: accept).
|
||||
"""
|
||||
quota = await session.get(Quota, user_id)
|
||||
if quota is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quota not found")
|
||||
@@ -70,12 +58,6 @@ async def update_user_quota(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Adjust a user's storage quota (ADMIN-04).
|
||||
|
||||
If the new limit is below current usage, still applies the change but
|
||||
returns warning=True with an explanatory message. Uploads will be blocked
|
||||
but existing documents are preserved.
|
||||
"""
|
||||
quota = await session.get(Quota, user_id)
|
||||
if quota is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quota not found")
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
"""Admin user-management endpoints.
|
||||
|
||||
Handles: list_users, create_user, update_user_status, initiate_password_reset,
|
||||
update_ai_config (per-user), delete_user, create_system_topic.
|
||||
|
||||
All handlers require get_current_admin (SEC-07, T-08-04-01).
|
||||
Sub-router has NO prefix — parent __init__.py carries /api/admin (D-04).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
@@ -79,11 +71,6 @@ async def list_users(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""List all users, ordered by created_at DESC.
|
||||
|
||||
Response shape: { items: [...safe user fields...] }
|
||||
Never includes password_hash, credentials_enc, or document content (T-02-27).
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(User).order_by(User.created_at.desc())
|
||||
)
|
||||
@@ -98,14 +85,6 @@ async def create_user(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Admin creates a new user account (ADMIN-01).
|
||||
|
||||
- password_must_change=True forces the user to change their password on
|
||||
first login (T-02-32, D-06).
|
||||
- Quota row initialized at 100 MB (D-06).
|
||||
- Returns 409 if email or handle is already taken.
|
||||
"""
|
||||
# Check uniqueness
|
||||
existing_email = await session.execute(
|
||||
select(User).where(User.email == str(body.email))
|
||||
)
|
||||
@@ -172,11 +151,6 @@ async def update_user_status(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Deactivate or reactivate a user account (ADMIN-02).
|
||||
|
||||
- Prevents deactivating the last active admin (T-02-29).
|
||||
- On deactivation: all refresh tokens are revoked (family revocation).
|
||||
"""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
@@ -237,15 +211,6 @@ async def initiate_password_reset(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Admin initiates a password reset for a user (ADMIN-03).
|
||||
|
||||
Sends the reset email via Celery. Does NOT:
|
||||
- return a reset token (T-02-30)
|
||||
- grant admin access to the account
|
||||
- log in as the target user (ADMIN-07 — no impersonation)
|
||||
|
||||
Returns 202 immediately regardless of email delivery status.
|
||||
"""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
@@ -271,11 +236,6 @@ async def update_ai_config(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Assign AI provider and model for a user (ADMIN-05).
|
||||
|
||||
Users cannot change their own AI provider or model (PROJECT.md Key Decision).
|
||||
Only admins have this capability.
|
||||
"""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
@@ -313,15 +273,6 @@ async def delete_user(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> None:
|
||||
"""Delete a user account and clean up all their MinIO objects (SEC-09, D-19).
|
||||
|
||||
Security invariants:
|
||||
- Admin password verified via Argon2 before any deletion (T-05-11-01)
|
||||
- Cannot delete admin accounts (T-04-07-04)
|
||||
- MinIO objects are deleted BEFORE DB records are removed (SEC-09)
|
||||
- MinIO deletion is best-effort (try/except) — DB row is deleted regardless
|
||||
- Audit log written with event_type="admin.user_deleted"
|
||||
"""
|
||||
# T-05-11-01: Verify admin password before performing any destructive action.
|
||||
# Fail fast — no DB reads for the target user until the admin is confirmed.
|
||||
if not verify_password(body.admin_password, _admin.password_hash):
|
||||
@@ -414,15 +365,6 @@ async def create_system_topic(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Create a system topic visible to all users (D-09, DOC-04).
|
||||
|
||||
System topics have user_id = NULL, making them visible to every user as
|
||||
defaults in their topic namespace. Only admins can create system topics.
|
||||
Regular users create per-user topics via POST /api/topics.
|
||||
|
||||
Deduplication: case-insensitive match within the system namespace (user_id IS NULL).
|
||||
Returns the existing system topic if one with the same name already exists.
|
||||
"""
|
||||
from services import storage # noqa: PLC0415
|
||||
|
||||
topic = await storage.create_topic(
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
"""
|
||||
Auth API — password management endpoints.
|
||||
|
||||
Handles:
|
||||
POST /change-password — update password (requires current password)
|
||||
POST /password-reset — request a password reset email
|
||||
POST /password-reset/confirm — confirm a password reset using the token
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -43,34 +35,23 @@ async def change_password(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update the current user's password.
|
||||
|
||||
Checks:
|
||||
1. current_password matches stored hash
|
||||
2. new_password has not appeared in HIBP (SEC-03)
|
||||
3. new_password meets strength requirements (AUTH-01)
|
||||
"""
|
||||
# Verify current password
|
||||
if not auth_service.verify_password(body.current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Current password is incorrect",
|
||||
)
|
||||
|
||||
# HIBP breach check on new password (SEC-03)
|
||||
if await auth_service.check_hibp(body.new_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="This password has appeared in a data breach. Choose a different password.",
|
||||
)
|
||||
|
||||
# Password strength check
|
||||
try:
|
||||
auth_service.validate_password_strength(body.new_password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
# Update password
|
||||
_ip = get_client_ip(request)
|
||||
user = await session.get(User, current_user.id)
|
||||
user.password_hash = auth_service.hash_password(body.new_password)
|
||||
@@ -108,12 +89,7 @@ async def password_reset_request(
|
||||
body: PasswordResetRequest,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Request a password reset email.
|
||||
|
||||
Always returns 202 regardless of whether the email exists (anti-enumeration, T-02-22).
|
||||
If the user is found, a signed reset token (1-hour JWT) is generated and a Celery
|
||||
task is enqueued to send the email (D-02, D-03).
|
||||
"""
|
||||
# Always returns 202 regardless of whether email exists — anti-enumeration (T-02-22)
|
||||
from sqlalchemy import select as _select # noqa: PLC0415 (already imported above)
|
||||
|
||||
result = await session.execute(_select(User).where(User.email == str(body.email)))
|
||||
@@ -142,12 +118,7 @@ async def password_reset_confirm(
|
||||
body: PasswordResetConfirmRequest,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Confirm a password reset using the token from the email link.
|
||||
|
||||
Validates the reset token, enforces password strength + HIBP check, updates
|
||||
the password, and revokes all refresh tokens. Does NOT issue new tokens —
|
||||
the user must sign in again through /login (AUTH-05, T-02-21).
|
||||
"""
|
||||
# Does NOT issue new tokens — user must re-authenticate through /login (AUTH-05, T-02-21)
|
||||
try:
|
||||
user_id_str = auth_service.decode_password_reset_token(body.token)
|
||||
except ValueError:
|
||||
@@ -156,20 +127,17 @@ async def password_reset_confirm(
|
||||
detail="Invalid or expired reset link",
|
||||
)
|
||||
|
||||
# Password strength validation
|
||||
try:
|
||||
auth_service.validate_password_strength(body.new_password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
# HIBP breach check (SEC-03)
|
||||
if await auth_service.check_hibp(body.new_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="This password has appeared in a data breach. Choose a different password.",
|
||||
)
|
||||
|
||||
# Load user
|
||||
user = await session.get(User, uuid.UUID(user_id_str))
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
@@ -177,7 +145,6 @@ async def password_reset_confirm(
|
||||
detail="Invalid or expired reset link",
|
||||
)
|
||||
|
||||
# Update password and revoke all sessions (forces re-auth through TOTP if enabled)
|
||||
user.password_hash = auth_service.hash_password(body.new_password)
|
||||
await auth_service.revoke_all_refresh_tokens(session, user.id)
|
||||
# Revoke any pre-reset access tokens still within their TTL window (T-7.2-01)
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
"""
|
||||
Auth API — shared helpers, Pydantic request models, and the Limiter instance.
|
||||
|
||||
This module is imported by every auth sub-module (tokens.py, totp.py, password.py).
|
||||
The Limiter instance is re-exported from api/auth/__init__.py so that:
|
||||
from api.auth import limiter
|
||||
continues to work in main.py and the 5 test files without modification.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
@@ -18,7 +10,6 @@ from config import settings
|
||||
from deps.utils import get_client_ip
|
||||
|
||||
# IP-level rate limiter (SEC-02 — 10 req/min on register/login/refresh)
|
||||
# Re-exported from api/auth/__init__.py via: from api.auth.shared import limiter
|
||||
limiter = Limiter(key_func=get_client_ip)
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
"""
|
||||
Auth API — token-issuing endpoints.
|
||||
|
||||
Handles:
|
||||
POST /register — new user registration with HIBP check
|
||||
POST /login — login with optional TOTP/backup-code second factor
|
||||
POST /refresh — rotate refresh token (httpOnly cookie in/out)
|
||||
POST /logout — revoke current refresh token, clear cookie
|
||||
POST /logout-all — revoke all refresh tokens for current user
|
||||
GET /me — return current user profile
|
||||
GET /me/quota — return current user quota
|
||||
GET /me/preferences — return current user preferences
|
||||
PATCH /me/preferences — update current user preferences
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -51,27 +37,17 @@ async def register(
|
||||
body: RegisterRequest,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Register a new user account.
|
||||
|
||||
- Validates password strength (min 12 chars, upper, lower, digit, special)
|
||||
- Checks HIBP k-anonymity API for breached passwords
|
||||
- Hashes password with Argon2
|
||||
- Inserts User + Quota rows in a single transaction
|
||||
"""
|
||||
# Password strength check
|
||||
try:
|
||||
auth_service.validate_password_strength(body.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
# HIBP breach check
|
||||
if await auth_service.check_hibp(body.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="This password has appeared in a data breach. Choose a different password.",
|
||||
)
|
||||
|
||||
# Duplicate email/handle check
|
||||
result = await session.execute(
|
||||
select(User).where(
|
||||
(User.email == str(body.email)) | (User.handle == body.handle)
|
||||
@@ -83,7 +59,6 @@ async def register(
|
||||
detail="Email or handle already in use",
|
||||
)
|
||||
|
||||
# Create user and quota
|
||||
user_id = uuid.uuid4()
|
||||
new_user = User(
|
||||
id=user_id,
|
||||
@@ -132,23 +107,11 @@ async def login(
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Authenticate a user and issue tokens.
|
||||
|
||||
Per-account rate limiting (SEC-02): checks Redis counter keyed by email
|
||||
BEFORE any DB lookup to prevent enumeration timing attacks.
|
||||
|
||||
Three login flows:
|
||||
1. No TOTP enabled: password → tokens
|
||||
2. TOTP enabled, no code provided: requires_totp = True (challenge)
|
||||
3. TOTP enabled, totp_code provided: verify TOTP → tokens
|
||||
4. TOTP enabled, backup_code provided (no totp_code): verify backup → tokens
|
||||
"""
|
||||
# Per-account rate limiting (SEC-02)
|
||||
# Per-account rate limiting (SEC-02): Redis counter keyed by email, checked before any DB lookup
|
||||
redis_client = request.app.state.redis
|
||||
rate_key = f"login_attempts:{body.email}"
|
||||
count = await redis_client.incr(rate_key)
|
||||
if count == 1:
|
||||
# Set TTL only on first increment (15-minute window)
|
||||
await redis_client.expire(rate_key, 900)
|
||||
if count > 10:
|
||||
raise HTTPException(
|
||||
@@ -156,11 +119,9 @@ async def login(
|
||||
detail="Too many login attempts. Try again in 15 minutes.",
|
||||
)
|
||||
|
||||
# Look up user by email
|
||||
result = await session.execute(select(User).where(User.email == str(body.email)))
|
||||
user: Optional[User] = result.scalar_one_or_none()
|
||||
|
||||
# IP extraction for audit log (used in both success and failure paths)
|
||||
_ip = get_client_ip(request)
|
||||
|
||||
# Verify password (anti-enumeration: same error regardless of whether user exists)
|
||||
@@ -180,7 +141,6 @@ async def login(
|
||||
detail="Incorrect email or password",
|
||||
)
|
||||
|
||||
# Active check
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -191,10 +151,8 @@ async def login(
|
||||
if user.password_must_change:
|
||||
return {"requires_password_change": True, "user_id": str(user.id)}
|
||||
|
||||
# TOTP second-factor dispatch
|
||||
if user.totp_enabled:
|
||||
if body.totp_code is None and body.backup_code is None:
|
||||
# Challenge: prompt for second factor
|
||||
return {"requires_totp": True}
|
||||
|
||||
if body.totp_code is not None:
|
||||
@@ -223,7 +181,6 @@ async def login(
|
||||
ip_address=_ip,
|
||||
)
|
||||
|
||||
# Issue tokens
|
||||
access_token = auth_service.create_access_token(
|
||||
str(user.id),
|
||||
user.role,
|
||||
@@ -266,12 +223,7 @@ async def refresh_token(
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Rotate the refresh token.
|
||||
|
||||
Reads the refresh_token httpOnly cookie; on success issues a new access
|
||||
token and rotates the refresh cookie.
|
||||
On token reuse (revoked token presented), revokes entire family and raises 401.
|
||||
"""
|
||||
# Token reuse (revoked token presented) revokes entire family — family revocation security contract
|
||||
raw_token = request.cookies.get("refresh_token")
|
||||
if not raw_token:
|
||||
raise HTTPException(
|
||||
@@ -292,7 +244,6 @@ async def refresh_token(
|
||||
detail="Invalid or expired refresh token",
|
||||
) from exc
|
||||
|
||||
# Look up user for response body
|
||||
user = await session.get(User, uuid.UUID(user_id_str))
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
@@ -300,7 +251,6 @@ async def refresh_token(
|
||||
detail="User not found or deactivated",
|
||||
)
|
||||
|
||||
# Set new refresh cookie
|
||||
_set_refresh_cookie(response, new_raw)
|
||||
|
||||
access_token = auth_service.create_access_token(
|
||||
@@ -325,7 +275,6 @@ async def refresh_token(
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request, response: Response, session: AsyncSession = Depends(get_db)):
|
||||
"""Revoke current refresh token and clear the cookie."""
|
||||
import hashlib as _hashlib
|
||||
|
||||
_ip = get_client_ip(request)
|
||||
@@ -365,7 +314,6 @@ async def logout_all(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Sign out of all devices: revoke all refresh tokens for current user."""
|
||||
_ip = get_client_ip(request)
|
||||
count = await auth_service.revoke_all_refresh_tokens(session, current_user.id)
|
||||
# D-13: sign-out-all event
|
||||
@@ -387,7 +335,6 @@ async def logout_all(
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""Return the current user's profile (requires valid Bearer token)."""
|
||||
return _user_dict(current_user)
|
||||
|
||||
|
||||
@@ -398,11 +345,6 @@ async def get_my_quota(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return the current user's quota usage (STORE-04).
|
||||
|
||||
Returns {"used_bytes": int, "limit_bytes": int} for the sidebar quota bar.
|
||||
Quota row is created at registration (100 MB default — STORE-01).
|
||||
"""
|
||||
q = await session.get(Quota, current_user.id)
|
||||
if q is None:
|
||||
raise HTTPException(status_code=404, detail="Quota not found")
|
||||
@@ -415,11 +357,7 @@ async def get_my_quota(
|
||||
async def get_my_preferences(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return the current user's PDF open mode preference (D-10).
|
||||
|
||||
Both regular users and admins can read their own preferences.
|
||||
Falls back to 'in_app' if the column is absent (migration not yet run).
|
||||
"""
|
||||
# Falls back to 'in_app' if the column is absent (migration not yet run)
|
||||
try:
|
||||
pdf_open_mode = current_user.pdf_open_mode
|
||||
except AttributeError:
|
||||
@@ -435,11 +373,7 @@ async def update_my_preferences(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update the current user's PDF open mode preference (D-10).
|
||||
|
||||
Both regular users and admins can update their own preferences.
|
||||
Pydantic Literal["in_app", "new_tab"] enforces strict allowlist (T-04-05-05).
|
||||
"""
|
||||
# Pydantic Literal["in_app", "new_tab"] enforces strict allowlist (T-04-05-05)
|
||||
user = await session.get(User, current_user.id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
"""
|
||||
Auth API — TOTP endpoints.
|
||||
|
||||
Handles:
|
||||
GET /totp/setup — provision TOTP secret for current user
|
||||
POST /totp/enable — enable TOTP (verify code, generate backup codes)
|
||||
DELETE /totp — disable TOTP (clear secret, revoke other sessions)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -34,12 +26,6 @@ async def totp_setup(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Provision a TOTP secret for the current user.
|
||||
|
||||
If TOTP is already enabled, returns 400.
|
||||
Returns { provisioning_uri, secret } — the provisioning_uri is suitable
|
||||
for QR code generation. The secret is the base32-encoded TOTP secret.
|
||||
"""
|
||||
if current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -59,14 +45,7 @@ async def enable_totp(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Enable TOTP for the current user.
|
||||
|
||||
Rate-limited to 10 attempts/minute per IP (SEC-02 / T-02-25).
|
||||
Verifies the submitted 6-digit code (with Redis replay prevention, AUTH-08).
|
||||
On success: marks TOTP enabled, generates and returns 10 one-time backup codes.
|
||||
The backup codes are ONLY returned here — they are stored as Argon2 hashes
|
||||
in the DB and never returned again (T-02-19).
|
||||
"""
|
||||
# Backup codes are returned here ONLY — stored as Argon2 hashes, never returned again (T-02-19)
|
||||
redis_client = request.app.state.redis
|
||||
ok = await auth_service.verify_totp(session, current_user.id, body.code, redis_client)
|
||||
if not ok:
|
||||
@@ -75,12 +54,10 @@ async def enable_totp(
|
||||
detail="Incorrect or expired code",
|
||||
)
|
||||
|
||||
# Mark TOTP as enabled
|
||||
user = await session.get(User, current_user.id)
|
||||
user.totp_enabled = True
|
||||
await session.flush()
|
||||
|
||||
# Generate and store 10 backup codes; return plaintext to user (one-time, T-02-19)
|
||||
plain_codes = auth_service.generate_backup_codes(10)
|
||||
await auth_service.store_backup_codes(session, current_user.id, plain_codes)
|
||||
|
||||
@@ -119,16 +96,11 @@ async def disable_totp(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Disable TOTP for the current user.
|
||||
|
||||
Clears totp_secret, sets totp_enabled=False, and deletes all backup codes.
|
||||
"""
|
||||
_ip = get_client_ip(request)
|
||||
user = await session.get(User, current_user.id)
|
||||
user.totp_enabled = False
|
||||
user.totp_secret = None
|
||||
|
||||
# Delete all backup codes for this user (including unused ones)
|
||||
await session.execute(delete(BackupCode).where(BackupCode.user_id == current_user.id))
|
||||
|
||||
# Revoke other sessions; keep current one alive via skip_token_hash (CR-03)
|
||||
|
||||
@@ -62,19 +62,6 @@ async def stream_document_content(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Stream document bytes directly from MinIO (DOC-02).
|
||||
|
||||
T-04-05-01: uses get_regular_user — admin role → 403 (critical security invariant).
|
||||
T-04-05-02: bytes fetched via get_object() ONLY — presigned_get_url() never called.
|
||||
T-04-05-03: Range header validated via _parse_range(); invalid range → 416.
|
||||
T-04-05-04: access gated on ownership OR active Share.recipient_id.
|
||||
|
||||
Returns 200 (or 206 for Range requests) with:
|
||||
Content-Type: doc.content_type
|
||||
Content-Disposition: inline; filename="<filename>"
|
||||
Accept-Ranges: bytes
|
||||
Content-Length: <size>
|
||||
"""
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
@@ -97,9 +84,6 @@ async def stream_document_content(
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# Fetch bytes from the correct backend — get_storage_backend_for_document handles
|
||||
# all backends (MinIO, Google Drive, OneDrive, Nextcloud, WebDAV) transparently
|
||||
# (D-15, T-04-05-02). NEVER via presigned URL for cloud backends (D-14).
|
||||
try:
|
||||
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
|
||||
storage_backend = await _doc_pkg.get_storage_backend_for_document(doc, current_user, session)
|
||||
|
||||
@@ -99,12 +99,10 @@ async def list_documents(
|
||||
items.append(d)
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
# New path: direct ORM query with sort/filter/FTS
|
||||
from db.models import DocumentTopic, Topic # noqa: PLC0415 (avoid circular at module top)
|
||||
|
||||
stmt = select(Document).where(Document.user_id == current_user.id)
|
||||
|
||||
# Topic filter (join-based, same as list_metadata)
|
||||
if topic is not None:
|
||||
stmt = (
|
||||
stmt.join(DocumentTopic, DocumentTopic.document_id == Document.id)
|
||||
@@ -112,7 +110,6 @@ async def list_documents(
|
||||
.where(Topic.name == topic)
|
||||
)
|
||||
|
||||
# Folder filter
|
||||
if folder_id is not None:
|
||||
try:
|
||||
folder_uuid = uuid.UUID(folder_id)
|
||||
@@ -120,7 +117,6 @@ async def list_documents(
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
stmt = stmt.where(Document.folder_id == folder_uuid)
|
||||
|
||||
# Sort
|
||||
sort_col = Document.created_at # default: date
|
||||
if sort == "name":
|
||||
sort_col = Document.filename
|
||||
@@ -147,13 +143,11 @@ async def list_documents(
|
||||
result = await session.execute(stmt)
|
||||
docs_orm = result.scalars().all()
|
||||
|
||||
# is_shared subquery
|
||||
shared_result = await session.execute(
|
||||
select(Share.document_id).where(Share.owner_id == current_user.id)
|
||||
)
|
||||
shared_ids = {row[0] for row in shared_result.fetchall()}
|
||||
|
||||
# Serialize
|
||||
all_items = []
|
||||
for doc in docs_orm:
|
||||
from services.storage import _doc_to_dict, _load_topic_names # noqa: PLC0415
|
||||
@@ -199,7 +193,6 @@ async def get_document(
|
||||
|
||||
is_recipient = False
|
||||
if doc.user_id != current_user.id:
|
||||
# Allow recipients of an active share to view the document
|
||||
share_result = await session.execute(
|
||||
select(Share).where(
|
||||
Share.document_id == uid,
|
||||
@@ -250,7 +243,6 @@ async def patch_document(
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
# Require at least one field to be set (model_fields_set tracks provided fields)
|
||||
if not body.model_fields_set:
|
||||
raise HTTPException(422, "At least one field (filename, folder_id) must be provided")
|
||||
|
||||
@@ -311,7 +303,6 @@ async def delete_document(
|
||||
_doc_id = doc.id
|
||||
_ip = get_client_ip(request)
|
||||
|
||||
# Cloud routing: attempt provider delete unless remove_only is set
|
||||
if is_cloud and not remove_only:
|
||||
try:
|
||||
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
|
||||
|
||||
@@ -129,7 +129,6 @@ async def upload_document(
|
||||
"""
|
||||
request.state.current_user = current_user
|
||||
if target_backend == "minio":
|
||||
# MinIO: generate a presigned URL for client-side PUT (existing flow reused)
|
||||
doc_id = uuid.uuid4()
|
||||
suffix = Path(file.filename or "file").suffix.lower()
|
||||
object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"
|
||||
@@ -152,7 +151,6 @@ async def upload_document(
|
||||
)
|
||||
return {"upload_url": upload_url, "document_id": str(doc_id)}
|
||||
|
||||
# Cloud backend path
|
||||
if target_backend not in _CLOUD_PROVIDERS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
@@ -174,11 +172,9 @@ async def upload_document(
|
||||
detail=f"No active {target_backend} connection found. Please connect in Settings.",
|
||||
)
|
||||
|
||||
# Decrypt per-user credentials
|
||||
master_key = settings.cloud_creds_key.encode()
|
||||
credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc)
|
||||
|
||||
# Read file bytes
|
||||
file_bytes = await file.read()
|
||||
filename = file.filename or "upload"
|
||||
content_type = file.content_type or "application/octet-stream"
|
||||
@@ -186,7 +182,6 @@ async def upload_document(
|
||||
|
||||
doc_id = uuid.uuid4()
|
||||
|
||||
# Instantiate backend and upload
|
||||
if target_backend == "google_drive":
|
||||
from storage.google_drive_backend import GoogleDriveBackend # lazy import
|
||||
cloud_backend = GoogleDriveBackend(credentials)
|
||||
@@ -319,13 +314,11 @@ async def confirm_upload(
|
||||
row = result.fetchone()
|
||||
|
||||
if row is None:
|
||||
# Quota exceeded — fetch current quota state for the 413 body
|
||||
quota_result = await session.execute(
|
||||
text("SELECT used_bytes, limit_bytes FROM quotas WHERE user_id = :uid"),
|
||||
{"uid": doc.user_id.hex},
|
||||
)
|
||||
q = quota_result.fetchone()
|
||||
# Delete the pending Document row and best-effort remove the MinIO object
|
||||
await session.delete(doc)
|
||||
try:
|
||||
await get_storage_backend().delete_object(doc.object_key)
|
||||
|
||||
Reference in New Issue
Block a user