refactor(backend): extract shared helper modules per architecture rules
- Add backend/ai/utils.py — parse_classification, parse_suggestions, strip_code_fences shared by all AI providers; removes duplicated private functions from anthropic_provider.py and openai_provider.py - Add backend/deps/utils.py — get_client_ip, parse_uuid request-parsing helpers; removes local _ip() variants from admin.py, auth.py, shares.py, folders.py - Add backend/storage/exceptions.py — canonical CloudConnectionError definition; all routers and backends import from here instead of redefining - Move validate_password_strength to backend/services/auth.py; removes duplicated _validate_password_strength from admin.py and auth.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+8
-52
@@ -23,7 +23,6 @@ Security invariants:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
@@ -36,8 +35,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from db.models import CloudConnection, Document, Quota, RefreshToken, Topic, User
|
||||
from deps.auth import get_current_admin
|
||||
from deps.db import get_db
|
||||
from deps.utils import get_client_ip
|
||||
from services.audit import write_audit_log
|
||||
from services.auth import hash_password, revoke_all_refresh_tokens, verify_password
|
||||
from services.auth import hash_password, revoke_all_refresh_tokens, validate_password_strength, verify_password
|
||||
from storage import get_storage_backend, get_storage_backend_for_document
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
@@ -46,28 +46,6 @@ router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
_DEFAULT_QUOTA_BYTES = 104857600 # 100 MB free-tier default (D-06)
|
||||
|
||||
_PASSWORD_DETAIL = (
|
||||
"Password must be at least 12 characters and include uppercase, "
|
||||
"lowercase, a number, and a special character."
|
||||
)
|
||||
|
||||
|
||||
# ── IP extraction helper ──────────────────────────────────────────────────────
|
||||
|
||||
def _ip(request: Request) -> Optional[str]:
|
||||
"""Extract best-effort client IP from request for audit logging.
|
||||
|
||||
TRUST BOUNDARY: X-Forwarded-For is a client-controlled header and can be
|
||||
forged by any caller. This value is used for forensic audit logging only —
|
||||
not for authentication or access control decisions. In production, deploy
|
||||
behind a trusted reverse proxy (e.g. nginx with
|
||||
`proxy_set_header X-Forwarded-For $remote_addr;`) which overwrites this
|
||||
header with the real remote IP before it reaches FastAPI, or use a
|
||||
trusted-proxy middleware that validates the source CIDR.
|
||||
"""
|
||||
return request.headers.get("X-Forwarded-For") or (
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
|
||||
|
||||
# ── Safe response helper ──────────────────────────────────────────────────────
|
||||
@@ -90,25 +68,6 @@ def _user_to_dict(user: User) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── Password strength helper ──────────────────────────────────────────────────
|
||||
|
||||
def _validate_password_strength(password: str) -> None:
|
||||
"""Raise ValueError with the spec message if password fails any strength rule.
|
||||
|
||||
Rules (AUTH-01): min 12 chars, has uppercase, has lowercase, has digit,
|
||||
has special char (non-alphanumeric).
|
||||
"""
|
||||
if len(password) < 12:
|
||||
raise ValueError(_PASSWORD_DETAIL)
|
||||
if not re.search(r"[A-Z]", password):
|
||||
raise ValueError(_PASSWORD_DETAIL)
|
||||
if not re.search(r"[a-z]", password):
|
||||
raise ValueError(_PASSWORD_DETAIL)
|
||||
if not re.search(r"[0-9]", password):
|
||||
raise ValueError(_PASSWORD_DETAIL)
|
||||
if not re.search(r"[^A-Za-z0-9]", password):
|
||||
raise ValueError(_PASSWORD_DETAIL)
|
||||
|
||||
|
||||
# ── Request models ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -121,10 +80,7 @@ class UserCreate(BaseModel):
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_strength(cls, v: str) -> str:
|
||||
try:
|
||||
_validate_password_strength(v)
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
validate_password_strength(v)
|
||||
return v
|
||||
|
||||
|
||||
@@ -264,7 +220,7 @@ async def create_user(
|
||||
session.add(quota)
|
||||
await session.flush() # persist User + Quota before audit_log FK references them
|
||||
# D-13: admin user created event
|
||||
_ip_addr = _ip(request)
|
||||
_ip_addr = get_client_ip(request)
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="admin.user_created",
|
||||
@@ -316,7 +272,7 @@ async def update_user_status(
|
||||
detail="Cannot deactivate the only admin",
|
||||
)
|
||||
|
||||
_ip_addr = _ip(request)
|
||||
_ip_addr = get_client_ip(request)
|
||||
user.is_active = body.is_active
|
||||
|
||||
if not body.is_active:
|
||||
@@ -426,7 +382,7 @@ async def update_user_quota(
|
||||
else None
|
||||
)
|
||||
|
||||
_ip_addr = _ip(request)
|
||||
_ip_addr = get_client_ip(request)
|
||||
old_limit = quota.limit_bytes
|
||||
quota.limit_bytes = body.limit_bytes
|
||||
session.add(quota)
|
||||
@@ -471,7 +427,7 @@ async def update_ai_config(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
_ip_addr = _ip(request)
|
||||
_ip_addr = get_client_ip(request)
|
||||
user.ai_provider = body.ai_provider
|
||||
user.ai_model = body.ai_model
|
||||
session.add(user)
|
||||
@@ -532,7 +488,7 @@ async def delete_user(
|
||||
detail="Cannot delete admin accounts",
|
||||
)
|
||||
|
||||
_ip_addr = _ip(request)
|
||||
_ip_addr = get_client_ip(request)
|
||||
|
||||
# SEC-09 (cloud): purge cloud-stored documents and credentials BEFORE DB delete.
|
||||
# Must run before MinIO cleanup so that credentials are still available to build
|
||||
|
||||
Reference in New Issue
Block a user