Refactor backend and frontend cleanup paths
This commit is contained in:
+41
-166
@@ -1,27 +1,7 @@
|
||||
"""
|
||||
AI provider configuration service for DocuVault.
|
||||
|
||||
Provides HKDF/Fernet encryption helpers, a provider config loader that reads
|
||||
from the system_settings DB table, and a startup seed function that populates
|
||||
the default provider row from env vars on first boot.
|
||||
|
||||
Security design (D-05, T-07-02):
|
||||
HKDF domain separation — the info bytes b"ai-provider-settings" differ from
|
||||
b"cloud-credentials" used by storage/cloud_utils.py. Both use the same master
|
||||
key (settings.cloud_creds_key) but produce DIFFERENT derived Fernet keys, so
|
||||
a leaked cloud credential cannot decrypt an AI API key and vice versa.
|
||||
|
||||
AlreadyFinalized warning (RESEARCH.md Pitfall 2 / .continue-here.md anti-pattern):
|
||||
The cryptography library raises AlreadyFinalized if .derive() is called twice
|
||||
on the same HKDF instance. _derive_ai_settings_key() creates a FRESH HKDF(...)
|
||||
object on every call — never cache or reuse the HKDF object between calls.
|
||||
|
||||
Pattern reference: storage/cloud_utils.py:_derive_fernet_key().
|
||||
"""
|
||||
"""AI provider configuration and API-key encryption helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
@@ -36,167 +16,78 @@ from config import settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
AI_SETTINGS_KEY_INFO = b"ai-provider-settings"
|
||||
|
||||
# ── HKDF key derivation ───────────────────────────────────────────────────────
|
||||
|
||||
def _derive_ai_settings_key(master_key: bytes, provider_id: str) -> Fernet:
|
||||
"""Derive a per-provider Fernet encryption key using HKDF-SHA256.
|
||||
|
||||
Security notes:
|
||||
- A FRESH HKDF instance is created on every call. The cryptography library
|
||||
raises AlreadyFinalized if .derive() is called twice on the same instance.
|
||||
Never cache or reuse the HKDF object (RESEARCH.md Pitfall 2).
|
||||
- salt = provider_id.encode("utf-8") provides per-provider derivation
|
||||
(deterministic: same provider → same key for encrypt/decrypt consistency).
|
||||
- info = b"ai-provider-settings" provides domain separation from
|
||||
b"cloud-credentials" — same master key, different derived keys.
|
||||
A leaked cloud credential cannot decrypt an AI API key (T-07-02 mitigated).
|
||||
|
||||
Args:
|
||||
master_key: The CLOUD_CREDS_KEY env var as bytes.
|
||||
provider_id: The provider slug, e.g. "openai", "anthropic" (used as HKDF salt).
|
||||
|
||||
Returns:
|
||||
A Fernet instance ready for encrypt/decrypt operations.
|
||||
"""
|
||||
# Create a FRESH HKDF instance — never cache (AlreadyFinalized guard)
|
||||
"""Derive a per-provider Fernet key with domain-separated HKDF-SHA256."""
|
||||
hkdf = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=provider_id.encode("utf-8"),
|
||||
info=b"ai-provider-settings", # domain-separated from b"cloud-credentials"
|
||||
info=AI_SETTINGS_KEY_INFO,
|
||||
)
|
||||
raw_key: bytes = hkdf.derive(master_key)
|
||||
fernet_key = base64.urlsafe_b64encode(raw_key)
|
||||
return Fernet(fernet_key)
|
||||
|
||||
|
||||
# ── Encryption helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def encrypt_api_key(master_key: bytes, provider_id: str, api_key: str) -> str:
|
||||
"""Encrypt a plaintext API key string to a Fernet token.
|
||||
|
||||
The returned string is safe to store in system_settings.api_key_enc.
|
||||
No JSON wrapping — the raw API key string is encrypted directly.
|
||||
|
||||
Args:
|
||||
master_key: The CLOUD_CREDS_KEY env var as bytes.
|
||||
provider_id: The provider slug (used as HKDF salt for key derivation).
|
||||
api_key: The plaintext API key, e.g. "sk-proj-...".
|
||||
|
||||
Returns:
|
||||
A URL-safe base64 Fernet token (str).
|
||||
"""
|
||||
"""Encrypt a plaintext API key for storage in system_settings.api_key_enc."""
|
||||
f = _derive_ai_settings_key(master_key, provider_id)
|
||||
return f.encrypt(api_key.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_api_key(master_key: bytes, provider_id: str, api_key_enc: str) -> str:
|
||||
"""Decrypt a Fernet token back to the original plaintext API key.
|
||||
|
||||
Args:
|
||||
master_key: The CLOUD_CREDS_KEY env var as bytes.
|
||||
provider_id: The provider slug (used as HKDF salt for key derivation).
|
||||
api_key_enc: The Fernet token string from the database.
|
||||
|
||||
Returns:
|
||||
The original plaintext API key string.
|
||||
"""
|
||||
"""Decrypt a stored API-key token."""
|
||||
f = _derive_ai_settings_key(master_key, provider_id)
|
||||
return f.decrypt(api_key_enc.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
# ── Provider config loader ────────────────────────────────────────────────────
|
||||
def _config_from_settings_row(row) -> ProviderConfig:
|
||||
api_key = ""
|
||||
if row.api_key_enc:
|
||||
master_key = settings.cloud_creds_key.encode("utf-8")
|
||||
try:
|
||||
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ai_config: failed to decrypt api_key_enc",
|
||||
provider_id=row.provider_id,
|
||||
)
|
||||
|
||||
return ProviderConfig(
|
||||
provider_id=row.provider_id,
|
||||
api_key=api_key,
|
||||
base_url=row.base_url,
|
||||
model=row.model_name,
|
||||
context_chars=row.context_chars,
|
||||
)
|
||||
|
||||
|
||||
async def _load_settings_row(session: AsyncSession, *criteria):
|
||||
from db.models import SystemSettings # local import avoids circular deps
|
||||
|
||||
stmt = select(SystemSettings)
|
||||
if criteria:
|
||||
stmt = stmt.where(*criteria)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def load_provider_config(session: AsyncSession) -> Optional[ProviderConfig]:
|
||||
"""Load the active AI provider config from the system_settings table.
|
||||
|
||||
Returns a ProviderConfig built from the row where is_active=True,
|
||||
decrypting api_key_enc when present. Returns None when no active row exists.
|
||||
|
||||
Args:
|
||||
session: An open AsyncSession.
|
||||
|
||||
Returns:
|
||||
A ProviderConfig if an active row exists; None if the table is empty or
|
||||
no row is marked active.
|
||||
"""
|
||||
from db.models import SystemSettings # local import to avoid circular deps
|
||||
|
||||
stmt = select(SystemSettings).where(SystemSettings.is_active.is_(True))
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
row = await _load_settings_row(session, SystemSettings.is_active.is_(True))
|
||||
return _config_from_settings_row(row) if row else None
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
# Decrypt API key if present
|
||||
api_key = ""
|
||||
if row.api_key_enc:
|
||||
master_key = settings.cloud_creds_key.encode("utf-8")
|
||||
try:
|
||||
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ai_config.load_provider_config: failed to decrypt api_key_enc",
|
||||
provider_id=row.provider_id,
|
||||
)
|
||||
|
||||
return ProviderConfig(
|
||||
provider_id=row.provider_id,
|
||||
api_key=api_key,
|
||||
base_url=row.base_url,
|
||||
model=row.model_name,
|
||||
context_chars=row.context_chars,
|
||||
)
|
||||
|
||||
|
||||
# ── Provider config loader by ID ─────────────────────────────────────────────
|
||||
|
||||
async def load_provider_config_by_id(session: AsyncSession, provider_id: str) -> Optional[ProviderConfig]:
|
||||
"""Load an AI provider config from system_settings by provider_id.
|
||||
|
||||
Unlike load_provider_config(), this function does NOT require is_active=True.
|
||||
Used by the admin test-connection endpoint so admins can test inactive rows.
|
||||
|
||||
Args:
|
||||
session: An open AsyncSession.
|
||||
provider_id: The provider slug to load (e.g. "openai", "lmstudio").
|
||||
|
||||
Returns:
|
||||
A ProviderConfig if a row with the given provider_id exists; None otherwise.
|
||||
"""
|
||||
from db.models import SystemSettings # local import to avoid circular deps
|
||||
|
||||
stmt = select(SystemSettings).where(SystemSettings.provider_id == provider_id)
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
row = await _load_settings_row(session, SystemSettings.provider_id == provider_id)
|
||||
return _config_from_settings_row(row) if row else None
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
# Decrypt API key if present
|
||||
api_key = ""
|
||||
if row.api_key_enc:
|
||||
master_key = settings.cloud_creds_key.encode("utf-8")
|
||||
try:
|
||||
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ai_config.load_provider_config_by_id: failed to decrypt api_key_enc",
|
||||
provider_id=row.provider_id,
|
||||
)
|
||||
|
||||
return ProviderConfig(
|
||||
provider_id=row.provider_id,
|
||||
api_key=api_key,
|
||||
base_url=row.base_url,
|
||||
model=row.model_name,
|
||||
context_chars=row.context_chars,
|
||||
)
|
||||
|
||||
|
||||
# ── Provider ID validator (D-11 migration) ───────────────────────────────────
|
||||
|
||||
def validate_provider_id(v: str) -> str:
|
||||
"""Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule."""
|
||||
@@ -207,21 +98,8 @@ def validate_provider_id(v: str) -> str:
|
||||
return v
|
||||
|
||||
|
||||
# ── Startup seed ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def seed_system_settings_from_env(session: AsyncSession) -> None:
|
||||
"""Populate system_settings with a default provider row on first boot.
|
||||
|
||||
Reads settings.default_ai_provider and settings.default_ai_model from config.
|
||||
If no row exists for that provider_id, inserts one with is_active=True.
|
||||
Never overwrites an existing row — idempotent across restarts (D-04).
|
||||
|
||||
This function is called from the FastAPI lifespan in main.py after the
|
||||
session factory is available. Caller is responsible for committing.
|
||||
|
||||
Args:
|
||||
session: An open AsyncSession.
|
||||
"""
|
||||
"""Seed the default provider row from env settings if it does not exist."""
|
||||
from db.models import SystemSettings # local import to avoid circular deps
|
||||
|
||||
provider_id = settings.default_ai_provider
|
||||
@@ -232,13 +110,10 @@ async def seed_system_settings_from_env(session: AsyncSession) -> None:
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
# Row already exists — never overwrite (idempotent)
|
||||
return
|
||||
|
||||
# Use PROVIDER_DEFAULTS context_chars for the provider, fallback to 8000
|
||||
context_chars = PROVIDER_DEFAULTS.get(provider_id, {}).get("context_chars", 8000)
|
||||
|
||||
# Insert default row with no API key (local providers like Ollama don't need one)
|
||||
row = SystemSettings(
|
||||
provider_id=provider_id,
|
||||
model_name=model_name,
|
||||
|
||||
+49
-73
@@ -1,20 +1,4 @@
|
||||
"""
|
||||
Auth service — pure Python, no FastAPI coupling.
|
||||
|
||||
Handles:
|
||||
- Password hashing (Argon2 via pwdlib) and constant-time verification (SEC-06)
|
||||
- JWT access token creation/decode (PyJWT)
|
||||
- Refresh token lifecycle with family revocation on reuse (AUTH-07, RFC 9700)
|
||||
- TOTP provisioning and verification with replay prevention (AUTH-08)
|
||||
- Backup code generation, storage, and constant-time verification (AUTH-02)
|
||||
- HaveIBeenPwned k-anonymity check (SEC-03)
|
||||
- Admin account bootstrap (D-04, D-05, D-06)
|
||||
|
||||
Security invariants:
|
||||
- All token/code comparisons use hmac.compare_digest (constant-time, SEC-06)
|
||||
- No function raises HTTPException — callers (api/) map ValueError to HTTP errors
|
||||
- refresh token family revocation enqueues send_security_alert_email.delay (AUTH-07)
|
||||
"""
|
||||
"""Authentication, token, TOTP, and account bootstrap services."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
@@ -32,7 +16,7 @@ import jwt
|
||||
import pyotp
|
||||
from pwdlib import PasswordHash
|
||||
from pwdlib.hashers.argon2 import Argon2Hasher
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
@@ -45,8 +29,6 @@ _PASSWORD_DETAIL = (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Password hashing ────────────────────────────────────────────────────────────
|
||||
# Single shared PasswordHash instance; Argon2 is the only enabled hasher.
|
||||
_pwd = PasswordHash([Argon2Hasher()])
|
||||
|
||||
|
||||
@@ -82,10 +64,8 @@ def validate_password_strength(password: str) -> None:
|
||||
raise ValueError(_PASSWORD_DETAIL)
|
||||
|
||||
|
||||
# ── JWT helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _compute_fgp(user_agent: str, accept_lang: str) -> str:
|
||||
"""Return 16-char hex fingerprint binding a token to its client context (D-04)."""
|
||||
"""Return a short fingerprint binding a token to its client context."""
|
||||
return hmac.new(
|
||||
settings.secret_key.encode(),
|
||||
(user_agent + "\x00" + accept_lang).encode(),
|
||||
@@ -93,6 +73,36 @@ def _compute_fgp(user_agent: str, accept_lang: str) -> str:
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
def _jwt_private_key() -> str:
|
||||
return base64.b64decode(settings.jwt_private_key).decode()
|
||||
|
||||
|
||||
def _jwt_public_key() -> str:
|
||||
return base64.b64decode(settings.jwt_public_key).decode()
|
||||
|
||||
|
||||
def _encode_jwt(payload: dict) -> str:
|
||||
return jwt.encode(payload, _jwt_private_key(), algorithm="ES256")
|
||||
|
||||
|
||||
def _decode_jwt(
|
||||
token: str,
|
||||
expected_type: str,
|
||||
expired_message: str,
|
||||
invalid_prefix: str,
|
||||
) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, _jwt_public_key(), algorithms=["ES256"])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise ValueError(expired_message) from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise ValueError(f"{invalid_prefix}: {exc}") from exc
|
||||
|
||||
if payload.get("typ") != expected_type:
|
||||
raise ValueError(f"Token type mismatch: expected '{expected_type}'")
|
||||
return payload
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: str,
|
||||
role: str,
|
||||
@@ -114,8 +124,7 @@ def create_access_token(
|
||||
"jti": str(uuid.uuid4()),
|
||||
"fgp": _compute_fgp(user_agent, accept_lang),
|
||||
}
|
||||
private_pem = base64.b64decode(settings.jwt_private_key).decode()
|
||||
return jwt.encode(payload, private_pem, algorithm="ES256")
|
||||
return _encode_jwt(payload)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
@@ -124,17 +133,7 @@ def decode_access_token(token: str) -> dict:
|
||||
Verifies: signature, expiry, and typ='access' (T-02-01 — prevents password-reset
|
||||
tokens from being used as access tokens).
|
||||
"""
|
||||
try:
|
||||
public_pem = base64.b64decode(settings.jwt_public_key).decode()
|
||||
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise ValueError("Token has expired") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise ValueError(f"Invalid token: {exc}") from exc
|
||||
|
||||
if payload.get("typ") != "access":
|
||||
raise ValueError("Token type mismatch: expected 'access'")
|
||||
return payload
|
||||
return _decode_jwt(token, "access", "Token has expired", "Invalid token")
|
||||
|
||||
|
||||
def create_password_reset_token(user_id: str) -> str:
|
||||
@@ -149,8 +148,7 @@ def create_password_reset_token(user_id: str) -> str:
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=3600),
|
||||
}
|
||||
private_pem = base64.b64decode(settings.jwt_private_key).decode()
|
||||
return jwt.encode(payload, private_pem, algorithm="ES256")
|
||||
return _encode_jwt(payload)
|
||||
|
||||
|
||||
def decode_password_reset_token(token: str) -> str:
|
||||
@@ -158,21 +156,15 @@ def decode_password_reset_token(token: str) -> str:
|
||||
|
||||
Returns the user_id string.
|
||||
"""
|
||||
try:
|
||||
public_pem = base64.b64decode(settings.jwt_public_key).decode()
|
||||
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise ValueError("Reset token has expired") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise ValueError(f"Invalid reset token: {exc}") from exc
|
||||
|
||||
if payload.get("typ") != "password-reset":
|
||||
raise ValueError("Token type mismatch: expected 'password-reset'")
|
||||
payload = _decode_jwt(
|
||||
token,
|
||||
"password-reset",
|
||||
"Reset token has expired",
|
||||
"Invalid reset token",
|
||||
)
|
||||
return payload["sub"]
|
||||
|
||||
|
||||
# ── Refresh token lifecycle ─────────────────────────────────────────────────────
|
||||
|
||||
async def create_refresh_token(
|
||||
session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False
|
||||
) -> str:
|
||||
@@ -181,8 +173,8 @@ async def create_refresh_token(
|
||||
The raw token is returned to the caller and set as an httpOnly cookie.
|
||||
Only the SHA-256 hash is stored in the database.
|
||||
|
||||
remember_me=False (default): TTL = refresh_token_expire_hours (16h short session, D-09, D-10)
|
||||
remember_me=True: TTL = refresh_token_expire_days (30d extended session, D-11)
|
||||
remember_me=False uses the short-session TTL; remember_me=True uses the
|
||||
extended-session TTL.
|
||||
"""
|
||||
raw = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(raw.encode()).hexdigest()
|
||||
@@ -231,9 +223,7 @@ async def rotate_refresh_token(
|
||||
raise ValueError("Refresh token has expired")
|
||||
|
||||
if row.revoked:
|
||||
# T-02-02: reuse of revoked token — family revocation
|
||||
await revoke_all_refresh_tokens(session, row.user_id)
|
||||
# Enqueue security alert email (deferred import to avoid circular dependency)
|
||||
from tasks.email_tasks import send_security_alert_email # noqa: PLC0415
|
||||
send_security_alert_email.delay(str(row.user_id))
|
||||
raise ValueError("token_family_revoked")
|
||||
@@ -273,8 +263,6 @@ async def revoke_all_refresh_tokens(
|
||||
return count
|
||||
|
||||
|
||||
# ── TOTP provisioning ───────────────────────────────────────────────────────────
|
||||
|
||||
async def provision_totp(
|
||||
session: AsyncSession, user_id: uuid.UUID
|
||||
) -> tuple[str, str]:
|
||||
@@ -329,13 +317,10 @@ async def verify_totp(
|
||||
return True
|
||||
|
||||
|
||||
# ── Backup codes ────────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_backup_codes(n: int = 10) -> list[str]:
|
||||
"""Return *n* random 8-character uppercase alphanumeric backup codes."""
|
||||
codes = []
|
||||
for _ in range(n):
|
||||
# secrets.token_hex(4) returns 8 hex chars; uppercase for readability
|
||||
code = secrets.token_hex(4).upper()
|
||||
codes.append(code)
|
||||
return codes
|
||||
@@ -349,7 +334,6 @@ async def store_backup_codes(
|
||||
Each code is stored as an Argon2 hash (never plaintext, T-02-03).
|
||||
Existing unused codes are deleted first to prevent accumulation.
|
||||
"""
|
||||
# Delete existing unused codes
|
||||
result = await session.execute(
|
||||
select(BackupCode).where(
|
||||
BackupCode.user_id == user_id,
|
||||
@@ -360,7 +344,6 @@ async def store_backup_codes(
|
||||
await session.delete(row)
|
||||
await session.flush()
|
||||
|
||||
# Insert new hashed codes
|
||||
for code in codes:
|
||||
row = BackupCode(
|
||||
id=uuid.uuid4(),
|
||||
@@ -392,21 +375,17 @@ async def verify_backup_code(
|
||||
|
||||
matched_row: Optional[BackupCode] = None
|
||||
for row in rows:
|
||||
# Always call verify_password for ALL rows (constant-time: no early exit)
|
||||
if verify_password(code, row.code_hash):
|
||||
matched_row = row # record match but keep iterating
|
||||
matched_row = row # keep iterating for constant-time behavior
|
||||
|
||||
if matched_row is None:
|
||||
return False
|
||||
|
||||
# Mark the matched code as used
|
||||
matched_row.used_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# ── HaveIBeenPwned check ────────────────────────────────────────────────────────
|
||||
|
||||
async def check_hibp(password: str) -> bool:
|
||||
"""Check if password appears in HaveIBeenPwned using the k-anonymity model.
|
||||
|
||||
@@ -442,19 +421,17 @@ async def check_hibp(password: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ── Admin bootstrap ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def bootstrap_admin(session: AsyncSession) -> None:
|
||||
"""Idempotent admin account bootstrap (D-04, D-05, D-06).
|
||||
"""Idempotent admin account bootstrap.
|
||||
|
||||
If the users table is empty AND settings.admin_email and settings.admin_password
|
||||
are both non-empty, creates an admin User row with a Quota row.
|
||||
|
||||
Logs a WARNING if env vars are missing (D-05) but never raises.
|
||||
Logs a warning if env vars are missing but never raises.
|
||||
"""
|
||||
if not settings.admin_email or not settings.admin_password:
|
||||
logger.warning(
|
||||
"Admin bootstrap skipped: ADMIN_EMAIL and/or ADMIN_PASSWORD not set (D-05). "
|
||||
"Admin bootstrap skipped: ADMIN_EMAIL and/or ADMIN_PASSWORD not set. "
|
||||
"Set both env vars to seed the first admin account on startup."
|
||||
)
|
||||
return
|
||||
@@ -462,7 +439,6 @@ async def bootstrap_admin(session: AsyncSession) -> None:
|
||||
# Check if any users exist
|
||||
result = await session.execute(select(User).limit(1))
|
||||
if result.scalar_one_or_none() is not None:
|
||||
# Users already exist — idempotent, skip (D-04)
|
||||
return
|
||||
|
||||
admin_id = uuid.uuid4()
|
||||
@@ -477,7 +453,7 @@ async def bootstrap_admin(session: AsyncSession) -> None:
|
||||
)
|
||||
quota = Quota(
|
||||
user_id=admin_id,
|
||||
limit_bytes=104857600, # 100 MB default (D-06)
|
||||
limit_bytes=104857600,
|
||||
used_bytes=0,
|
||||
)
|
||||
session.add(admin_user)
|
||||
|
||||
+28
-100
@@ -1,21 +1,4 @@
|
||||
"""
|
||||
Async document/topic/settings storage service for DocuVault.
|
||||
|
||||
This module replaces the legacy flat-file + filelock implementation with:
|
||||
- Async SQLAlchemy ORM for document and topic persistence (PostgreSQL)
|
||||
- MinIO SDK (via asyncio.to_thread) for binary object storage
|
||||
|
||||
Public function names are PRESERVED from the old flat-file implementation so
|
||||
that api/documents.py and api/topics.py can be updated in Plan 05 with minimal
|
||||
changes (async def + await + session parameter).
|
||||
|
||||
Phase 3 D-12: load_settings / save_settings / mask_api_key / settings_masked removed.
|
||||
All AI config comes from DB (users.ai_provider / users.ai_model set by admin).
|
||||
|
||||
D-05: Storage service layer switched to PostgreSQL + MinIO.
|
||||
D-06: Object key schema: {user_id}/{document_id}/{uuid4()}{ext} — human filename in DB only.
|
||||
D-03: documents.user_id is None (nullable) in Phase 1 — no auth system yet.
|
||||
"""
|
||||
"""Async document and topic storage helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
@@ -31,26 +14,17 @@ from db.models import Document, DocumentTopic, Topic
|
||||
from storage import get_storage_backend
|
||||
|
||||
|
||||
# ── Lazy singleton storage backend ────────────────────────────────────────────
|
||||
|
||||
_storage = None
|
||||
|
||||
|
||||
def _backend():
|
||||
"""Return the lazily-instantiated StorageBackend singleton.
|
||||
|
||||
Mirrors the module-level singleton behaviour of the old filelock objects so
|
||||
the MinIO client is created once per process, not once per request.
|
||||
"""
|
||||
"""Return the lazily-instantiated StorageBackend singleton."""
|
||||
global _storage
|
||||
_storage = _storage or get_storage_backend()
|
||||
return _storage
|
||||
|
||||
|
||||
# ── Private helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _doc_to_dict(doc: Document, topic_names: list) -> dict:
|
||||
"""Convert a Document ORM row + resolved topic names to the legacy dict shape."""
|
||||
return {
|
||||
"id": str(doc.id),
|
||||
"original_name": doc.filename,
|
||||
@@ -65,8 +39,22 @@ def _doc_to_dict(doc: Document, topic_names: list) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _topic_to_dict(topic: Topic) -> dict:
|
||||
return {
|
||||
"id": str(topic.id),
|
||||
"name": topic.name,
|
||||
"description": topic.description,
|
||||
"color": topic.color,
|
||||
}
|
||||
|
||||
|
||||
def _topic_namespace_filter(name: str, user_id: Optional[uuid.UUID]):
|
||||
criteria = [sql_func.lower(Topic.name) == name.lower()]
|
||||
criteria.append(Topic.user_id.is_(None) if user_id is None else Topic.user_id == user_id)
|
||||
return criteria
|
||||
|
||||
|
||||
async def _load_topic_names(session: AsyncSession, doc_id: uuid.UUID) -> list:
|
||||
"""Return the list of topic names for a given document UUID."""
|
||||
q = await session.execute(
|
||||
select(Topic.name)
|
||||
.join(DocumentTopic, DocumentTopic.topic_id == Topic.id)
|
||||
@@ -75,9 +63,6 @@ async def _load_topic_names(session: AsyncSession, doc_id: uuid.UUID) -> list:
|
||||
return [row[0] for row in q]
|
||||
|
||||
|
||||
# ── Documents ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def save_metadata(session: AsyncSession, meta: dict) -> None:
|
||||
"""Update a Document row from the legacy metadata dict shape.
|
||||
|
||||
@@ -120,10 +105,7 @@ async def get_metadata(session: AsyncSession, doc_id: str) -> Optional[dict]:
|
||||
async def list_metadata(
|
||||
session: AsyncSession, user_id: uuid.UUID, topic: Optional[str] = None
|
||||
) -> list:
|
||||
"""Return a list of metadata dicts for a specific user, optionally filtered by topic name.
|
||||
|
||||
D-16: always filters by user_id — a user can only see their own documents.
|
||||
"""
|
||||
"""Return metadata dicts for a user's documents, optionally filtered by topic."""
|
||||
stmt = select(Document).where(Document.user_id == user_id).order_by(Document.created_at.desc())
|
||||
if topic is not None:
|
||||
stmt = (
|
||||
@@ -176,10 +158,6 @@ async def delete_document(
|
||||
print(f"[storage] WARNING: MinIO delete_object failed for {doc.object_key!r}: {exc}", file=sys.stderr)
|
||||
|
||||
if not skip_quota:
|
||||
# Atomic quota decrement (STORE-06, D-07).
|
||||
# user_id is always set post-migration (Plan 03-03+) — guard removed.
|
||||
# Use CASE WHEN instead of GREATEST() for SQLite compatibility
|
||||
# (PostgreSQL supports both; SQLite lacks the GREATEST scalar function).
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE quotas "
|
||||
@@ -212,12 +190,10 @@ async def update_document_topics(
|
||||
if doc is None:
|
||||
return None
|
||||
|
||||
# Remove all existing associations
|
||||
await session.execute(
|
||||
delete(DocumentTopic).where(DocumentTopic.document_id == uid)
|
||||
)
|
||||
|
||||
# Re-insert, deduplicating by name
|
||||
seen: set = set()
|
||||
for name in topics:
|
||||
if name in seen:
|
||||
@@ -257,15 +233,10 @@ async def remove_topic_from_all_documents(
|
||||
return result.rowcount
|
||||
|
||||
|
||||
# ── Topics ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def load_topics(session: AsyncSession) -> list:
|
||||
"""Return all topics ordered by name."""
|
||||
q = await session.execute(select(Topic).order_by(Topic.name))
|
||||
return [
|
||||
{"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
|
||||
for t in q.scalars()
|
||||
]
|
||||
return [_topic_to_dict(t) for t in q.scalars()]
|
||||
|
||||
|
||||
async def load_topics_for_user(session: AsyncSession, user_id: uuid.UUID) -> list:
|
||||
@@ -280,17 +251,11 @@ async def load_topics_for_user(session: AsyncSession, user_id: uuid.UUID) -> lis
|
||||
or_(Topic.user_id == user_id, Topic.user_id.is_(None))
|
||||
).order_by(Topic.name)
|
||||
)
|
||||
return [
|
||||
{"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
|
||||
for t in q.scalars()
|
||||
]
|
||||
return [_topic_to_dict(t) for t in q.scalars()]
|
||||
|
||||
|
||||
async def save_topics(session: AsyncSession, topics: list) -> None:
|
||||
"""Idempotent bulk replace — delete all Topic rows then insert the list.
|
||||
|
||||
# legacy: not used by current endpoints; preserved for API compatibility.
|
||||
"""
|
||||
"""Idempotent bulk replace; kept for compatibility with older callers."""
|
||||
await session.execute(delete(Topic))
|
||||
for t in topics:
|
||||
session.add(
|
||||
@@ -313,7 +278,7 @@ async def get_topic(session: AsyncSession, topic_id: str) -> Optional[dict]:
|
||||
t = await session.get(Topic, uid)
|
||||
if t is None:
|
||||
return None
|
||||
return {"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
|
||||
return _topic_to_dict(t)
|
||||
|
||||
|
||||
async def create_topic(
|
||||
@@ -323,51 +288,16 @@ async def create_topic(
|
||||
color: str = "#6366f1",
|
||||
user_id: Optional[uuid.UUID] = None,
|
||||
) -> dict:
|
||||
"""Create a topic, or return the existing one (case-insensitive, namespace-scoped dedup).
|
||||
|
||||
D-08: user_id=None creates a system topic (visible to all users).
|
||||
D-08: user_id=<uuid> creates a per-user topic (visible only to that user).
|
||||
|
||||
Deduplication is scoped by user_id namespace:
|
||||
- System topics (user_id=None) dedup against other system topics only
|
||||
- Per-user topics dedup within that user's namespace only
|
||||
This allows "Finance" to exist as both a system topic and a per-user topic.
|
||||
|
||||
SQLite note: Uses a branching approach instead of IS NOT DISTINCT FROM
|
||||
(SQLite doesn't support that PostgreSQL construct for NULL comparison).
|
||||
"""
|
||||
if user_id is None:
|
||||
q = await session.execute(
|
||||
select(Topic).where(
|
||||
sql_func.lower(Topic.name) == name.lower(),
|
||||
Topic.user_id.is_(None),
|
||||
)
|
||||
)
|
||||
else:
|
||||
q = await session.execute(
|
||||
select(Topic).where(
|
||||
sql_func.lower(Topic.name) == name.lower(),
|
||||
Topic.user_id == user_id,
|
||||
)
|
||||
)
|
||||
"""Create a topic, or return an existing case-insensitive namespace match."""
|
||||
q = await session.execute(select(Topic).where(*_topic_namespace_filter(name, user_id)))
|
||||
existing = q.scalars().first()
|
||||
if existing is not None:
|
||||
return {
|
||||
"id": str(existing.id),
|
||||
"name": existing.name,
|
||||
"description": existing.description,
|
||||
"color": existing.color,
|
||||
}
|
||||
return _topic_to_dict(existing)
|
||||
|
||||
topic = Topic(name=name, description=description, color=color, user_id=user_id)
|
||||
session.add(topic)
|
||||
await session.commit()
|
||||
return {
|
||||
"id": str(topic.id),
|
||||
"name": topic.name,
|
||||
"description": topic.description,
|
||||
"color": topic.color,
|
||||
}
|
||||
return _topic_to_dict(topic)
|
||||
|
||||
|
||||
async def update_topic(
|
||||
@@ -392,7 +322,7 @@ async def update_topic(
|
||||
if color is not None:
|
||||
t.color = color
|
||||
await session.commit()
|
||||
return {"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
|
||||
return _topic_to_dict(t)
|
||||
|
||||
|
||||
async def delete_topic(session: AsyncSession, topic_id: str) -> Optional[str]:
|
||||
@@ -408,7 +338,7 @@ async def delete_topic(session: AsyncSession, topic_id: str) -> Optional[str]:
|
||||
if t is None:
|
||||
return None
|
||||
name = t.name
|
||||
await session.delete(t) # ondelete="CASCADE" removes DocumentTopic rows
|
||||
await session.delete(t)
|
||||
await session.commit()
|
||||
return name
|
||||
|
||||
@@ -437,8 +367,6 @@ async def topic_doc_counts(
|
||||
return {name: count for name, count in q}
|
||||
|
||||
|
||||
# ── Public surface ─────────────────────────────────────────────────────────────
|
||||
|
||||
__all__ = [
|
||||
"save_metadata",
|
||||
"get_metadata",
|
||||
|
||||
Reference in New Issue
Block a user