feat(14-03): cache settings/status API and security tests
- 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)
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user