- api/cloud/cache.py: GET /analysis/cache, PATCH /analysis/cache/settings - api/cloud/analysis.py: analysis router aggregator (Phase 14 parent) - schemas.py: CacheStatusOut, CacheSettingsUpdateRequest (explicit allowlists) - __init__.py: register analysis_router under /api/cloud - 7 new security tests: admin block, unauthenticated block, object_key exclusion, response structure, enum validation, tier cap enforcement (T-14-01..02, T-14-08) - All 47 plan target tests pass (test_cloud_cache.py + test_cloud_security.py)
138 lines
4.8 KiB
Python
138 lines
4.8 KiB
Python
"""
|
|
Cache settings and status endpoint — Phase 14.
|
|
|
|
Owner-scoped endpoints for the byte cache. Admin users are blocked by
|
|
get_regular_user (T-14-01, T-14-06).
|
|
|
|
Route family:
|
|
GET /analysis/cache — current cache usage and user settings
|
|
PATCH /analysis/cache/settings — update analysis preferences and cache limit
|
|
|
|
Security invariants (T-14-02, T-14-08):
|
|
- object_key, credentials_enc, and raw provider URLs must never appear in any
|
|
response body.
|
|
- All queries are scoped to current_user.id — no cross-user access is possible.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from api.cloud.schemas import CacheStatusOut, CacheSettingsUpdateRequest
|
|
from db.models import CloudByteCacheEntry, User
|
|
from deps.auth import get_regular_user
|
|
from deps.db import get_db
|
|
from services.cloud_analysis_settings import (
|
|
DEFAULT_MAX_CACHE_LIMIT_BYTES,
|
|
get_or_create_user_analysis_settings,
|
|
update_user_analysis_settings,
|
|
)
|
|
from services.rate_limiting import account_limiter
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/analysis/cache", response_model=CacheStatusOut)
|
|
@account_limiter.limit("120/minute")
|
|
async def get_cache_status(
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> CacheStatusOut:
|
|
"""Return cache usage totals and analysis settings for the current user.
|
|
|
|
Returns aggregate byte/count totals (no object_key or credentials).
|
|
Blocked for admin accounts (T-14-01).
|
|
"""
|
|
request.state.current_user = current_user
|
|
|
|
# Aggregate active (non-evicted) cache entries for this user
|
|
result = await session.execute(
|
|
select(
|
|
func.count(CloudByteCacheEntry.id).label("entry_count"),
|
|
func.coalesce(func.sum(CloudByteCacheEntry.size_bytes), 0).label("total_bytes"),
|
|
).where(
|
|
CloudByteCacheEntry.user_id == current_user.id,
|
|
CloudByteCacheEntry.evicted_at.is_(None),
|
|
)
|
|
)
|
|
row = result.one()
|
|
entry_count = int(row.entry_count)
|
|
total_bytes = int(row.total_bytes)
|
|
|
|
# Fetch or create settings (never fails — creates defaults on first access)
|
|
settings = await get_or_create_user_analysis_settings(
|
|
session, user_id=current_user.id
|
|
)
|
|
|
|
return CacheStatusOut(
|
|
entry_count=entry_count,
|
|
total_bytes=total_bytes,
|
|
cache_limit_bytes=settings.cloud_cache_limit_bytes,
|
|
tier_cap_bytes=DEFAULT_MAX_CACHE_LIMIT_BYTES,
|
|
analysis_progress_detail=settings.analysis_progress_detail,
|
|
analysis_failure_behavior=settings.analysis_failure_behavior,
|
|
)
|
|
|
|
|
|
@router.patch("/analysis/cache/settings", response_model=CacheStatusOut)
|
|
@account_limiter.limit("30/minute")
|
|
async def update_cache_settings(
|
|
request: Request,
|
|
body: CacheSettingsUpdateRequest,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> CacheStatusOut:
|
|
"""Update analysis preferences and/or the user's cache byte limit.
|
|
|
|
Only provided (non-None) fields are updated. Enum and bounds validation
|
|
is performed by the service layer (raises ValueError; we re-raise as 422).
|
|
Blocked for admin accounts (T-14-01).
|
|
"""
|
|
request.state.current_user = current_user
|
|
|
|
try:
|
|
await update_user_analysis_settings(
|
|
session,
|
|
user_id=current_user.id,
|
|
analysis_progress_detail=body.analysis_progress_detail,
|
|
analysis_failure_behavior=body.analysis_failure_behavior,
|
|
cloud_cache_limit_bytes=body.cloud_cache_limit_bytes,
|
|
max_cache_limit_bytes=DEFAULT_MAX_CACHE_LIMIT_BYTES,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
await session.commit()
|
|
|
|
# Return fresh status after update
|
|
result = await session.execute(
|
|
select(
|
|
func.count(CloudByteCacheEntry.id).label("entry_count"),
|
|
func.coalesce(func.sum(CloudByteCacheEntry.size_bytes), 0).label("total_bytes"),
|
|
).where(
|
|
CloudByteCacheEntry.user_id == current_user.id,
|
|
CloudByteCacheEntry.evicted_at.is_(None),
|
|
)
|
|
)
|
|
row = result.one()
|
|
|
|
settings = await get_or_create_user_analysis_settings(
|
|
session, user_id=current_user.id
|
|
)
|
|
|
|
return CacheStatusOut(
|
|
entry_count=int(row.entry_count),
|
|
total_bytes=int(row.total_bytes),
|
|
cache_limit_bytes=settings.cloud_cache_limit_bytes,
|
|
tier_cap_bytes=DEFAULT_MAX_CACHE_LIMIT_BYTES,
|
|
analysis_progress_detail=settings.analysis_progress_detail,
|
|
analysis_failure_behavior=settings.analysis_failure_behavior,
|
|
)
|