From 916c0911742647b240e673eaee4df0e6a849ea51 Mon Sep 17 00:00:00 2001 From: spelech <28486500+spelech@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:21:46 +0000 Subject: [PATCH 1/4] fix(auth): enforce role permissions on REST storage and settings mutations Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- REQUIREMENTS.md | 8 +- app/api/routers/files.py | 15 +- app/api/routers/graph.py | 14 +- app/api/routers/ingestion.py | 9 +- app/api/routers/navigator.py | 24 ++- app/api/routers/repositories.py | 94 +++++++++--- app/api/routers/settings.py | 99 ++++++++++--- app/api/routers/storage.py | 33 ++++- tests/test_rest_role_permissions.py | 222 ++++++++++++++++++++++++++++ 9 files changed, 457 insertions(+), 61 deletions(-) create mode 100644 tests/test_rest_role_permissions.py diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 59d9c13..809c4c4 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -2,7 +2,7 @@ > **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`. -**Test Verification Baseline:** **1052 Automated Tests** (699 Pytest Backend + 303 Vitest Frontend + 50 Playwright E2E). +**Test Verification Baseline:** **1056 Automated Tests** (703 Pytest Backend + 303 Vitest Frontend + 50 Playwright E2E). --- @@ -1192,6 +1192,12 @@ and leaves the prior indexed state intact without data loss._ - `test_large_file_auto_summarization` - `test_large_file_disabled_summarization` +#### `tests/test_rest_role_permissions.py` (4 tests) +- `test_anonymous_and_invalid_key_requests_denied` - _Anonymous or invalid/revoked requests receive 401 Unauthorized._ +- `test_viewer_role_permissions` - _Viewer key can read resources but is denied content mutations & administrative settings (403)._ +- `test_editor_role_permissions` - _Editor key can perform content mutations and syncs, but is denied administrative settings (403)._ +- `test_admin_role_permissions` - _Admin key has full permissions across configuration, editor, and viewer endpoints._ + #### `tests/test_storage_api_routes.py` (4 tests) - `test_storage_upload_and_get_file` - `test_storage_upload_multipart_and_put` diff --git a/app/api/routers/files.py b/app/api/routers/files.py index a366523..2174545 100644 --- a/app/api/routers/files.py +++ b/app/api/routers/files.py @@ -1,12 +1,13 @@ import logging from typing import Optional from pydantic import BaseModel, Field -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, Depends from fastapi.responses import JSONResponse from app.services.file_reader import get_file_reader_service from app.services.summarizer import get_summarizer_service -from app.services.auth import ForbiddenError +from app.services.auth import Role, ForbiddenError +from app.api.routers.auth import require_role logger = logging.getLogger("contextcortex.api.files") router = APIRouter() @@ -18,7 +19,10 @@ class FileSummarizePayload(BaseModel): force_refresh: bool = Field(False, description="Whether to bypass cache and regenerate summary") -@router.get("/admin/api/files/read") +@router.get( + "/admin/api/files/read", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_read_file( path: str = Query(..., description="Target file path (relative to repo/storage or absolute within indexed root)"), repo: Optional[str] = Query(None, description="Optional repo or storage namespace"), @@ -57,7 +61,10 @@ async def api_read_file( return JSONResponse(status_code=500, content={"error": "Internal server error reading file."}) -@router.post("/admin/api/files/summarize") +@router.post( + "/admin/api/files/summarize", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_summarize_file(payload: FileSummarizePayload): try: raw_path = payload.path.strip() if payload.path else "" diff --git a/app/api/routers/graph.py b/app/api/routers/graph.py index 59c601f..e3796eb 100644 --- a/app/api/routers/graph.py +++ b/app/api/routers/graph.py @@ -4,16 +4,21 @@ import sqlite3 import logging from typing import Optional -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, Depends from fastapi.responses import JSONResponse +from app.services.auth import Role +from app.api.routers.auth import require_role import app.services.topology as topo_service logger = logging.getLogger("contextcortex.api") router = APIRouter() -@router.get("/admin/api/graph/topology") +@router.get( + "/admin/api/graph/topology", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_graph_topology( repo: str, view_type: str = "files", @@ -44,7 +49,10 @@ async def api_get_graph_topology( logger.error(f"Error generating topology graph: {e}") return JSONResponse(status_code=500, content={"error": "Failed to generate topology graph."}) -@router.get("/admin/api/graph/node-details") +@router.get( + "/admin/api/graph/node-details", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_graph_node_details(id: str): try: details = topo_service.get_node_details(id) diff --git a/app/api/routers/ingestion.py b/app/api/routers/ingestion.py index 7381335..bb9d88a 100644 --- a/app/api/routers/ingestion.py +++ b/app/api/routers/ingestion.py @@ -1,15 +1,20 @@ import logging from typing import Optional -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, Depends from fastapi.responses import JSONResponse +from app.services.auth import Role +from app.api.routers.auth import require_role from app.services.database import get_db_connection from app.services.local_storage import get_local_storage_service logger = logging.getLogger("contextcortex.api.ingestion") router = APIRouter() -@router.get("/admin/api/ingestion/catalog") +@router.get( + "/admin/api/ingestion/catalog", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_ingestion_catalog( source_type: str = Query("all", description="Source type filter"), repo_name: Optional[str] = Query(None, description="Repository filter"), diff --git a/app/api/routers/navigator.py b/app/api/routers/navigator.py index 4b9cb8e..b33434b 100644 --- a/app/api/routers/navigator.py +++ b/app/api/routers/navigator.py @@ -1,8 +1,10 @@ import logging from typing import Optional -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, Depends from fastapi.responses import JSONResponse +from app.services.auth import Role +from app.api.routers.auth import require_role import app.services.navigator as nav_service logger = logging.getLogger("contextcortex.api.navigator") @@ -10,7 +12,10 @@ router = APIRouter() -@router.get("/admin/api/navigator/tree") +@router.get( + "/admin/api/navigator/tree", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_navigator_tree( repo: str = Query(..., description="Repository name or '__all__'") ): @@ -27,7 +32,10 @@ async def api_get_navigator_tree( return JSONResponse(status_code=500, content={"error": "Failed to generate navigator tree."}) -@router.get("/admin/api/navigator/file-outline") +@router.get( + "/admin/api/navigator/file-outline", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_file_outline( repo: str = Query(..., description="Repository name or '__all__'"), filepath: str = Query(..., description="Relative file path") @@ -45,7 +53,10 @@ async def api_get_file_outline( return JSONResponse(status_code=500, content={"error": "Failed to retrieve file outline."}) -@router.get("/admin/api/navigator/symbol-impact") +@router.get( + "/admin/api/navigator/symbol-impact", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_symbol_impact( repo: str = Query(..., description="Repository name or '__all__'"), symbol_id: int = Query(..., description="AST symbol ID") @@ -63,7 +74,10 @@ async def api_get_symbol_impact( return JSONResponse(status_code=500, content={"error": "Failed to retrieve symbol impact."}) -@router.get("/admin/api/navigator/omni-search") +@router.get( + "/admin/api/navigator/omni-search", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_omni_search( repo: str = Query(..., description="Repository name or '__all__'"), q: str = Query("", description="Search query string"), diff --git a/app/api/routers/repositories.py b/app/api/routers/repositories.py index 27770f6..51dc292 100644 --- a/app/api/routers/repositories.py +++ b/app/api/routers/repositories.py @@ -6,8 +6,10 @@ import logging import asyncio from typing import Optional -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, Depends from fastapi.responses import JSONResponse, StreamingResponse +from app.services.auth import Role +from app.api.routers.auth import require_role from app.models.schemas import RepoConfig, LocalPathConfig, AutoSyncToggleRequest, KeepShallowToggleRequest, SearchRequest import app.services.database as db_service @@ -21,7 +23,10 @@ router = APIRouter() -@router.get("/admin/api/repos") +@router.get( + "/admin/api/repos", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_repos(): try: with db_service.get_db_connection() as conn: @@ -45,7 +50,10 @@ async def api_get_repos(): logger.error(f"Error fetching repos: {e}") return JSONResponse(status_code=500, content={"error": "Failed to fetch repositories."}) -@router.post("/admin/api/repos") +@router.post( + "/admin/api/repos", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_add_repo(repo: RepoConfig): if not repo.name or not repo.name.strip() or not repo.url or not repo.url.strip(): return JSONResponse(status_code=400, content={"error": "Name and URL are required"}) @@ -74,7 +82,10 @@ async def api_add_repo(repo: RepoConfig): logger.error(f"Error adding repo: {e}") return JSONResponse(status_code=500, content={"error": "Failed to add repository."}) -@router.patch("/admin/api/repos/{repo_id}/auto-sync") +@router.patch( + "/admin/api/repos/{repo_id}/auto-sync", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_toggle_repo_auto_sync(repo_id: int, payload: AutoSyncToggleRequest): try: updated = db_service.set_repo_auto_sync(repo_id, payload.auto_sync) @@ -85,7 +96,10 @@ async def api_toggle_repo_auto_sync(repo_id: int, payload: AutoSyncToggleRequest logger.error(f"Error toggling auto-sync for repo {repo_id}: {e}") return JSONResponse(status_code=500, content={"error": "Failed to toggle auto-sync for repository."}) -@router.patch("/admin/api/repos/{repo_id}/keep-shallow") +@router.patch( + "/admin/api/repos/{repo_id}/keep-shallow", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_toggle_repo_keep_shallow(repo_id: int, payload: KeepShallowToggleRequest): try: with db_service.get_db_connection() as conn: @@ -100,8 +114,14 @@ async def api_toggle_repo_keep_shallow(repo_id: int, payload: KeepShallowToggleR logger.error(f"Error toggling keep-shallow for repo {repo_id}: {e}") return JSONResponse(status_code=500, content={"error": "Failed to toggle keep-shallow for repository."}) -@router.post("/admin/api/repos/{repo_id}/sync") -@router.post("/admin/api/repos/sync/{repo_id}") +@router.post( + "/admin/api/repos/{repo_id}/sync", + dependencies=[Depends(require_role(Role.EDITOR))], +) +@router.post( + "/admin/api/repos/sync/{repo_id}", + dependencies=[Depends(require_role(Role.EDITOR))], +) async def api_sync_repo(repo_id: int): try: with db_service.get_db_connection() as conn: @@ -122,11 +142,17 @@ async def api_sync_repo(repo_id: int): STREAM_KEEPALIVE_TIMEOUT = 15.0 -@router.get("/admin/api/repos/sync-status") +@router.get( + "/admin/api/repos/sync-status", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_all_sync_status(): return progress_tracker.get_snapshot() -@router.get("/admin/api/repos/sync/stream") +@router.get( + "/admin/api/repos/sync/stream", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_stream_repo_sync(request: Request): queue = progress_tracker.subscribe() @@ -159,21 +185,30 @@ async def event_generator(): } ) -@router.get("/admin/api/repos/{repo_id}/sync-status") +@router.get( + "/admin/api/repos/{repo_id}/sync-status", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_repo_sync_status(repo_id: int): snapshot = progress_tracker.get_snapshot(repo_id) if not snapshot: return JSONResponse(status_code=404, content={"error": f"No active sync job for repo {repo_id}"}) return snapshot -@router.post("/admin/api/repos/{repo_id}/cancel-sync") +@router.post( + "/admin/api/repos/{repo_id}/cancel-sync", + dependencies=[Depends(require_role(Role.EDITOR))], +) async def api_cancel_repo_sync(repo_id: int): cancelled = progress_tracker.cancel_job(repo_id) if not cancelled: return JSONResponse(status_code=400, content={"error": f"Repo {repo_id} is not currently syncing"}) return {"status": "cancelled", "repo_id": repo_id} -@router.delete("/admin/api/repos/{repo_id}") +@router.delete( + "/admin/api/repos/{repo_id}", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_delete_repo(repo_id: int): try: with db_service.get_db_connection() as conn: @@ -206,7 +241,10 @@ async def api_delete_repo(repo_id: int): logger.error(f"Error deleting repo {repo_id}: {e}") return JSONResponse(status_code=500, content={"error": "Failed to delete repository."}) -@router.get("/admin/api/paths") +@router.get( + "/admin/api/paths", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_paths(): try: with db_service.get_db_connection() as conn: @@ -216,7 +254,10 @@ async def api_get_paths(): logger.error(f"Error getting paths: {e}") return JSONResponse(status_code=500, content={"error": "Failed to get paths."}) -@router.post("/admin/api/paths") +@router.post( + "/admin/api/paths", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_add_path(config: LocalPathConfig): try: raw_path = config.path.strip() if config.path else "" @@ -252,7 +293,10 @@ async def api_add_path(config: LocalPathConfig): logger.error(f"Error adding path: {e}") return JSONResponse(status_code=500, content={"error": "Failed to add path."}) -@router.delete("/admin/api/paths/{path_id}") +@router.delete( + "/admin/api/paths/{path_id}", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_delete_path(path_id: int): try: with db_service.get_db_connection() as conn: @@ -276,8 +320,14 @@ async def api_delete_path(path_id: int): logger.error(f"Error deleting path {path_id}: {e}") return JSONResponse(status_code=500, content={"error": "Failed to delete path."}) -@router.post("/admin/api/sync") -@router.post("/admin/api/reindex") +@router.post( + "/admin/api/sync", + dependencies=[Depends(require_role(Role.EDITOR))], +) +@router.post( + "/admin/api/reindex", + dependencies=[Depends(require_role(Role.EDITOR))], +) async def api_trigger_sync(): is_idx = idx_service.is_indexing if (is_idx() if callable(is_idx) else is_idx): @@ -286,7 +336,10 @@ async def api_trigger_sync(): threading.Thread(target=idx_service.run_full_indexing, daemon=True).start() return {"status": "success", "message": "Background ingestion pipeline dispatched."} -@router.post("/admin/api/search/test") +@router.post( + "/admin/api/search/test", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_test_search(payload: SearchRequest): try: query = payload.query.strip() if payload.query else "" @@ -326,7 +379,10 @@ async def api_test_search(payload: SearchRequest): logger.error(f"Error testing search: {e}") return JSONResponse(status_code=500, content={"error": "Failed to execute search test."}) -@router.get("/admin/api/browse") +@router.get( + "/admin/api/browse", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_browse_dir(path: str = "/"): cleaned = path.strip() if path else "/" if "\x00" in cleaned or any(part in ("..", ".") for part in cleaned.replace("\\", "/").split("/") if part): diff --git a/app/api/routers/settings.py b/app/api/routers/settings.py index 77b2fa2..8815ae3 100644 --- a/app/api/routers/settings.py +++ b/app/api/routers/settings.py @@ -6,8 +6,10 @@ import tempfile from typing import Optional from urllib.parse import urlsplit -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, Depends from fastapi.responses import JSONResponse +from app.services.auth import Role +from app.api.routers.auth import require_role from app.models.schemas import ( TokenRequest, HostCredentialRequest, @@ -27,7 +29,10 @@ router = APIRouter() -@router.get("/admin/api/stats") +@router.get( + "/admin/api/stats", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_stats(): try: with db_service.get_db_connection() as conn: @@ -140,7 +145,10 @@ def _count(table): logger.error(f"Error fetching stats: {e}") return JSONResponse(status_code=500, content={"error": "Failed to fetch stats."}) -@router.get("/admin/api/settings/hosts") +@router.get( + "/admin/api/settings/hosts", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_get_host_credentials(): try: hosts = db_service.list_git_host_credentials() @@ -159,7 +167,10 @@ async def api_get_host_credentials(): logger.error(f"Error listing host credentials: {e}") return JSONResponse(status_code=500, content={"error": "Failed to list host credentials."}) -@router.post("/admin/api/settings/hosts") +@router.post( + "/admin/api/settings/hosts", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_save_host_credential(payload: HostCredentialRequest): try: host_id = db_service.save_git_host_credential( @@ -175,7 +186,10 @@ async def api_save_host_credential(payload: HostCredentialRequest): logger.error(f"Error saving host credential: {e}") return JSONResponse(status_code=500, content={"error": "Failed to save host credential."}) -@router.delete("/admin/api/settings/hosts/{host_id}") +@router.delete( + "/admin/api/settings/hosts/{host_id}", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_delete_host_credential(host_id: int): try: deleted = db_service.delete_git_host_credential(host_id) @@ -186,7 +200,10 @@ async def api_delete_host_credential(host_id: int): logger.error(f"Error deleting host credential: {e}") return JSONResponse(status_code=500, content={"error": "Failed to delete host credential."}) -@router.post("/admin/api/settings/token") +@router.post( + "/admin/api/settings/token", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_save_token(payload: TokenRequest): try: if payload.github_token is not None: @@ -217,7 +234,10 @@ async def api_save_token(payload: TokenRequest): logger.error(f"Error saving tokens: {e}") return JSONResponse(status_code=500, content={"error": "Failed to save tokens."}) -@router.get("/admin/api/settings/auto-sync") +@router.get( + "/admin/api/settings/auto-sync", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_get_auto_sync_settings(): try: interval = db_service.get_auto_sync_interval() @@ -231,7 +251,10 @@ async def api_get_auto_sync_settings(): logger.error(f"Error loading auto-sync settings: {e}") return JSONResponse(status_code=500, content={"error": "Failed to load auto-sync settings."}) -@router.post("/admin/api/settings/auto-sync") +@router.post( + "/admin/api/settings/auto-sync", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_save_auto_sync_settings(payload: AutoSyncSettingsRequest): try: if payload.interval_mins is not None: @@ -251,7 +274,10 @@ async def api_save_auto_sync_settings(payload: AutoSyncSettingsRequest): logger.error(f"Error saving auto-sync settings: {e}") return JSONResponse(status_code=500, content={"error": "Failed to save auto-sync settings."}) -@router.get("/admin/api/logs") +@router.get( + "/admin/api/logs", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_get_logs(limit: int = 200, level: Optional[str] = None, search: Optional[str] = None): try: return log_service.get_diagnostic_logs(limit=limit, level=level, search=search) @@ -259,7 +285,10 @@ async def api_get_logs(limit: int = 200, level: Optional[str] = None, search: Op logger.error(f"Error fetching diagnostic logs: {e}") return JSONResponse(status_code=500, content={"error": "Failed to fetch diagnostic logs."}) -@router.delete("/admin/api/logs") +@router.delete( + "/admin/api/logs", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_clear_logs(): try: log_service.clear_diagnostic_logs() @@ -268,7 +297,10 @@ async def api_clear_logs(): logger.error(f"Error clearing diagnostic logs: {e}") return JSONResponse(status_code=500, content={"error": "Failed to clear diagnostic logs."}) -@router.get("/admin/api/vector-store") +@router.get( + "/admin/api/vector-store", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_get_vector_store(): try: cfg = vs_service.get_vector_store_config() @@ -300,7 +332,10 @@ def _validate_vector_storage_path(storage_path: Optional[str]) -> Optional[str]: raise ValueError(f"Storage path '{sp}' is outside authorized directories.") -@router.post("/admin/api/vector-store/test") +@router.post( + "/admin/api/vector-store/test", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_test_vector_store(payload: VectorStoreTestRequest): try: safe_sp = payload.storage_path @@ -328,7 +363,10 @@ async def api_test_vector_store(payload: VectorStoreTestRequest): logger.error(f"Error testing vector store: {e}") return JSONResponse(status_code=500, content={"success": False, "error": "Failed to test vector store.", "message": "Failed to test vector store."}) -@router.post("/admin/api/vector-store/switch") +@router.post( + "/admin/api/vector-store/switch", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_switch_vector_store(payload: VectorStoreSwitchRequest): try: safe_sp = payload.storage_path @@ -364,7 +402,10 @@ def _reindex(): logger.error(f"Error switching vector store backend: {e}") return JSONResponse(status_code=500, content={"status": "error", "error": "Failed to switch vector store backend.", "message": "Failed to switch vector store backend."}) -@router.get("/admin/api/models/discover") +@router.get( + "/admin/api/models/discover", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_discover_models(url: Optional[str] = None, api_key: Optional[str] = None): try: if url: @@ -386,7 +427,10 @@ async def api_discover_models(url: Optional[str] = None, api_key: Optional[str] logger.error(f"Error discovering models: {e}") return JSONResponse(status_code=500, content={"status": "error", "error": "Failed to discover models."}) -@router.get("/admin/api/settings/ai-gateway") +@router.get( + "/admin/api/settings/ai-gateway", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_get_ai_gateway_settings(): try: cfg = db_service.get_ai_gateway_config() @@ -395,7 +439,10 @@ async def api_get_ai_gateway_settings(): logger.error(f"Error reading AI gateway settings: {e}") return JSONResponse(status_code=500, content={"error": "Failed to read AI gateway settings."}) -@router.post("/admin/api/settings/ai-gateway") +@router.post( + "/admin/api/settings/ai-gateway", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_save_ai_gateway_settings(payload: AIGatewaySettingsRequest): try: updated = db_service.set_ai_gateway_config( @@ -414,7 +461,10 @@ async def api_save_ai_gateway_settings(payload: AIGatewaySettingsRequest): logger.error(f"Error saving AI gateway settings: {e}") return JSONResponse(status_code=500, content={"status": "error", "error": "Failed to save AI gateway settings."}) -@router.get("/admin/api/settings/embedding") +@router.get( + "/admin/api/settings/embedding", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_get_embedding_settings(): try: cfg = emb_service.get_embedding_config() @@ -423,7 +473,10 @@ async def api_get_embedding_settings(): logger.error(f"Error reading embedding settings: {e}") return JSONResponse(status_code=500, content={"error": "Failed to read embedding settings."}) -@router.post("/admin/api/settings/embedding") +@router.post( + "/admin/api/settings/embedding", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_save_embedding_settings(payload: EmbeddingSettingsRequest): try: updated_cfg = emb_service.update_embedding_config( @@ -446,7 +499,10 @@ async def api_save_embedding_settings(payload: EmbeddingSettingsRequest): logger.error(f"Error updating embedding settings: {e}") return JSONResponse(status_code=500, content={"status": "error", "error": "Failed to update embedding settings.", "message": "Failed to update embedding settings."}) -@router.get("/admin/api/settings/files") +@router.get( + "/admin/api/settings/files", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_get_file_settings(): try: cfg = db_service.get_file_settings() @@ -455,7 +511,10 @@ async def api_get_file_settings(): logger.error(f"Error reading file settings: {e}") return JSONResponse(status_code=500, content={"error": "Failed to read file settings."}) -@router.post("/admin/api/settings/files") +@router.post( + "/admin/api/settings/files", + dependencies=[Depends(require_role(Role.ADMIN))], +) async def api_save_file_settings(payload: FileSettingsRequest): try: data = payload.model_dump(exclude_unset=True) diff --git a/app/api/routers/storage.py b/app/api/routers/storage.py index 9e18114..0545166 100644 --- a/app/api/routers/storage.py +++ b/app/api/routers/storage.py @@ -2,12 +2,13 @@ import logging from typing import Optional from pydantic import BaseModel, Field -from fastapi import APIRouter, Request, UploadFile, File, Form, Query +from fastapi import APIRouter, Request, UploadFile, File, Form, Query, Depends from fastapi.responses import JSONResponse from dataclasses import asdict from app.services.local_storage import get_local_storage_service from app.services.auth import Role, enforce_tool_permission +from app.api.routers.auth import require_role from app.services.pdf_extractor import extract_pdf_pages, MAX_PDF_SIZE_BYTES logger = logging.getLogger("contextcortex.api.storage") @@ -19,7 +20,10 @@ class FileUploadPayload(BaseModel): repo: Optional[str] = "local_storage" category: Optional[str] = None -@router.post("/admin/api/storage/pdf/preview") +@router.post( + "/admin/api/storage/pdf/preview", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_preview_pdf( file: UploadFile = File(...), ocr_fallback: bool = Form(True) @@ -51,7 +55,10 @@ async def api_preview_pdf( logger.error(f"Error previewing PDF file: {e}") return JSONResponse(status_code=500, content={"error": "Failed to preview PDF file."}) -@router.post("/admin/api/storage/upload") +@router.post( + "/admin/api/storage/upload", + dependencies=[Depends(require_role(Role.EDITOR))], +) async def api_upload_storage_file(request: Request): try: storage = get_local_storage_service() @@ -100,7 +107,10 @@ async def api_upload_storage_file(request: Request): logger.error(f"Error uploading storage file: {e}") return JSONResponse(status_code=500, content={"error": "Failed to upload storage file."}) -@router.put("/admin/api/storage/file") +@router.put( + "/admin/api/storage/file", + dependencies=[Depends(require_role(Role.EDITOR))], +) async def api_replace_storage_file(payload: FileUploadPayload): try: storage = get_local_storage_service() @@ -113,7 +123,10 @@ async def api_replace_storage_file(payload: FileUploadPayload): logger.error(f"Error replacing storage file: {e}") return JSONResponse(status_code=500, content={"error": "Failed to replace storage file."}) -@router.get("/admin/api/storage/file") +@router.get( + "/admin/api/storage/file", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_storage_file(path: str = Query(..., description="Relative file path")): try: storage = get_local_storage_service() @@ -127,7 +140,10 @@ async def api_get_storage_file(path: str = Query(..., description="Relative file logger.error(f"Error reading storage file '{path}': {e}") return JSONResponse(status_code=500, content={"error": "Failed to read storage file."}) -@router.delete("/admin/api/storage/file") +@router.delete( + "/admin/api/storage/file", + dependencies=[Depends(require_role(Role.EDITOR))], +) async def api_delete_storage_file(path: str = Query(..., description="Relative file path")): try: storage = get_local_storage_service() @@ -140,7 +156,10 @@ async def api_delete_storage_file(path: str = Query(..., description="Relative f logger.error(f"Error deleting storage file '{path}': {e}") return JSONResponse(status_code=500, content={"error": "Failed to delete storage file."}) -@router.get("/admin/api/storage/tree") +@router.get( + "/admin/api/storage/tree", + dependencies=[Depends(require_role(Role.VIEWER))], +) async def api_get_storage_tree(folder: Optional[str] = Query(None, description="Subfolder to inspect")): try: storage = get_local_storage_service() diff --git a/tests/test_rest_role_permissions.py b/tests/test_rest_role_permissions.py new file mode 100644 index 0000000..9d72ec6 --- /dev/null +++ b/tests/test_rest_role_permissions.py @@ -0,0 +1,222 @@ +""" +Role-Matrix Regression Tests for ContextCortex REST API Endpoints. +Verifies least-privilege role policies across Anonymous, Viewer, Editor, and Admin principals +when AUTH_ENABLED=true. +""" + +import pytest +from httpx import AsyncClient, ASGITransport +from main import app +from app.services.auth.models import Role +from app.services.auth.service import AuthService, get_auth_service +from app.services.auth.key_service import ApiKeyService +from app.services.database.engine import get_db_engine, init_db +import app.services.local_storage as ls_mod +from app.services.local_storage import LocalStorageService + + +@pytest.fixture(autouse=True) +def isolated_auth_env(tmp_path, monkeypatch): + """Sets up an isolated database, auth service, and storage root with AUTH_ENABLED=true.""" + db_file = tmp_path / "test_rest_roles.db" + db_url = f"sqlite:///{db_file}" + monkeypatch.setenv("DATABASE_URL", db_url) + monkeypatch.setenv("AUTH_ENABLED", "true") + monkeypatch.setenv("AUTH_RESOURCE_INDICATOR", "https://contextcortex.local") + + engine = get_db_engine(db_url, reset=True) + init_db(engine=engine) + + key_service = ApiKeyService(engine=engine) + auth_service = AuthService( + auth_enabled=True, + resource_indicator="https://contextcortex.local", + key_service=key_service, + ) + get_auth_service(reset=True, auth_enabled=True, key_service=key_service) + + storage_dir = tmp_path / "storage" + storage_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(ls_mod, "_storage_service", LocalStorageService(storage_root=str(storage_dir))) + + # Bootstrap roles + admin_key = auth_service.issue_api_key(name="Admin Key", role=Role.ADMIN) + editor_key = auth_service.issue_api_key(name="Editor Key", role=Role.EDITOR) + viewer_key = auth_service.issue_api_key(name="Viewer Key", role=Role.VIEWER) + + yield { + "engine": engine, + "key_service": key_service, + "auth_service": auth_service, + "admin_key": admin_key, + "editor_key": editor_key, + "viewer_key": viewer_key, + "storage_dir": storage_dir, + } + + from app.services.auth.service import set_current_auth_context + set_current_auth_context(None) + monkeypatch.delenv("AUTH_ENABLED", raising=False) + get_auth_service(reset=True, auth_enabled=False) + + +@pytest.mark.asyncio +async def test_anonymous_and_invalid_key_requests_denied(isolated_auth_env): + """Anonymous or invalid/revoked requests receive 401 Unauthorized.""" + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + # Anonymous + res = await client.post("/admin/api/storage/upload", json={"path": "a.txt", "content": "hello"}) + assert res.status_code == 401 + assert "WWW-Authenticate" in res.headers + + # Invalid token + res_inv = await client.get("/admin/api/stats", headers={"Authorization": "Bearer cc_live_invalid_key"}) + assert res_inv.status_code == 401 + + # Revoked token + srv = isolated_auth_env["auth_service"] + revoked_key = srv.issue_api_key(name="Temp Key", role=Role.EDITOR) + srv.revoke_api_key(revoked_key.id) + + res_rev = await client.post( + "/admin/api/storage/upload", + json={"path": "b.txt", "content": "test"}, + headers={"Authorization": f"Bearer {revoked_key.secret_key}"} + ) + assert res_rev.status_code == 401 + + +@pytest.mark.asyncio +async def test_viewer_role_permissions(isolated_auth_env): + """Viewer key can read resources but is denied content mutations & administrative settings (403).""" + viewer_hdr = {"Authorization": f"Bearer {isolated_auth_env['viewer_key'].secret_key}"} + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + # 1. Permitted Viewer Reads -> HTTP 200 + assert (await client.get("/admin/api/stats", headers=viewer_hdr)).status_code == 200 + assert (await client.get("/admin/api/repos", headers=viewer_hdr)).status_code == 200 + assert (await client.get("/admin/api/paths", headers=viewer_hdr)).status_code == 200 + assert (await client.get("/admin/api/ingestion/catalog", headers=viewer_hdr)).status_code == 200 + assert (await client.get("/admin/api/storage/tree", headers=viewer_hdr)).status_code == 200 + + # 2. Denied Storage Content Mutations -> HTTP 403 + res_up = await client.post( + "/admin/api/storage/upload", + json={"path": "viewer_file.txt", "content": "viewer write attempt"}, + headers=viewer_hdr + ) + assert res_up.status_code == 403 + + res_put = await client.put( + "/admin/api/storage/file", + json={"path": "viewer_file.txt", "content": "replace attempt"}, + headers=viewer_hdr + ) + assert res_put.status_code == 403 + + res_del = await client.delete( + "/admin/api/storage/file?path=viewer_file.txt", + headers=viewer_hdr + ) + assert res_del.status_code == 403 + + # Verify side effect was blocked (file was not created) + assert not (isolated_auth_env["storage_dir"] / "viewer_file.txt").exists() + + # 3. Denied Admin Settings -> HTTP 403 + res_sync_cfg = await client.post( + "/admin/api/settings/auto-sync", + json={"interval_mins": 45}, + headers=viewer_hdr + ) + assert res_sync_cfg.status_code == 403 + + assert (await client.get("/admin/api/settings/hosts", headers=viewer_hdr)).status_code == 403 + assert (await client.get("/admin/api/settings/auto-sync", headers=viewer_hdr)).status_code == 403 + assert (await client.get("/admin/api/settings/ai-gateway", headers=viewer_hdr)).status_code == 403 + assert (await client.get("/admin/api/settings/embedding", headers=viewer_hdr)).status_code == 403 + assert (await client.get("/admin/api/logs", headers=viewer_hdr)).status_code == 403 + + # 4. Denied Repo / Path Configuration -> HTTP 403 + res_repo = await client.post( + "/admin/api/repos", + json={"name": "forbidden_repo", "url": "https://github.com/org/repo"}, + headers=viewer_hdr + ) + assert res_repo.status_code == 403 + + +@pytest.mark.asyncio +async def test_editor_role_permissions(isolated_auth_env): + """Editor key can perform content mutations and syncs, but is denied administrative settings (403).""" + editor_hdr = {"Authorization": f"Bearer {isolated_auth_env['editor_key'].secret_key}"} + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + # 1. Permitted Content Writes & Reads -> HTTP 200 + up_res = await client.post( + "/admin/api/storage/upload", + json={"path": "editor_doc.txt", "content": "editor content"}, + headers=editor_hdr + ) + assert up_res.status_code == 200 + + read_res = await client.get("/admin/api/storage/file?path=editor_doc.txt", headers=editor_hdr) + assert read_res.status_code == 200 + assert read_res.json()["content"] == "editor content" + + put_res = await client.put( + "/admin/api/storage/file", + json={"path": "editor_doc.txt", "content": "updated content"}, + headers=editor_hdr + ) + assert put_res.status_code == 200 + + del_res = await client.delete("/admin/api/storage/file?path=editor_doc.txt", headers=editor_hdr) + assert del_res.status_code == 200 + + # 2. Denied Admin Settings -> HTTP 403 + assert (await client.get("/admin/api/settings/hosts", headers=editor_hdr)).status_code == 403 + assert (await client.post( + "/admin/api/settings/auto-sync", + json={"interval_mins": 60}, + headers=editor_hdr + )).status_code == 403 + + assert (await client.post( + "/admin/api/repos", + json={"name": "editor_repo", "url": "https://github.com/org/repo"}, + headers=editor_hdr + )).status_code == 403 + + +@pytest.mark.asyncio +async def test_admin_role_permissions(isolated_auth_env): + """Admin key has full permissions across configuration, editor, and viewer endpoints.""" + admin_hdr = {"Authorization": f"Bearer {isolated_auth_env['admin_key'].secret_key}"} + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + # Admin can update auto-sync setting + save_sync_res = await client.post( + "/admin/api/settings/auto-sync", + json={"interval_mins": 120}, + headers=admin_hdr + ) + assert save_sync_res.status_code == 200 + assert save_sync_res.json()["interval_mins"] == 120 + + # Admin can read auto-sync setting + get_sync_res = await client.get("/admin/api/settings/auto-sync", headers=admin_hdr) + assert get_sync_res.status_code == 200 + assert get_sync_res.json()["interval_mins"] == 120 + + # Admin can upload file + up_res = await client.post( + "/admin/api/storage/upload", + json={"path": "admin_file.txt", "content": "admin content"}, + headers=admin_hdr + ) + assert up_res.status_code == 200 + + # Admin can read stats + stats_res = await client.get("/admin/api/stats", headers=admin_hdr) + assert stats_res.status_code == 200 From 9a148d4f5792ec98bf3cd8da09020a597d3d3f18 Mon Sep 17 00:00:00 2001 From: spelech <28486500+spelech@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:33:48 +0000 Subject: [PATCH 2/4] fix(auth): enforce role permissions on REST storage and settings mutations Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- coverage/python-coverage.xml | 8336 ++++++++++++++++++++++++++++++++++ pytest.ini | 2 + 2 files changed, 8338 insertions(+) create mode 100644 coverage/python-coverage.xml diff --git a/coverage/python-coverage.xml b/coverage/python-coverage.xml new file mode 100644 index 0000000..c40a50b --- /dev/null +++ b/coverage/python-coverage.xml @@ -0,0 +1,8336 @@ + + + + + + /app/app + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pytest.ini b/pytest.ini index 00b9ce4..e09351b 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,9 @@ [pytest] pythonpath = . +asyncio_default_fixture_loop_scope = function filterwarnings = error + ignore:.*asyncio_default_fixture_loop_scope.*:pytest.PytestDeprecationWarning ignore:.*Field 'lifespan' has an incomplete definition.*:UserWarning ignore:.*Field 'lifespan' has an incomplete definition.*:Warning ignore:.*unauthenticated requests to the HF Hub.*:UserWarning From 068ed17ecc1717b63a3fe833f22f3bf5274c2484 Mon Sep 17 00:00:00 2001 From: spelech <28486500+spelech@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:57:44 +0000 Subject: [PATCH 3/4] fix(auth): enforce role permissions on REST storage and settings mutations Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- coverage/python-coverage.xml | 8336 ---------------------------------- 1 file changed, 8336 deletions(-) delete mode 100644 coverage/python-coverage.xml diff --git a/coverage/python-coverage.xml b/coverage/python-coverage.xml deleted file mode 100644 index c40a50b..0000000 --- a/coverage/python-coverage.xml +++ /dev/null @@ -1,8336 +0,0 @@ - - - - - - /app/app - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 98b15c8b878d18febbcc5d58ac809249740ced69 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Tue, 27 Oct 2026 21:30:35 -0500 Subject: [PATCH 4/4] chore(git): ignore coverage directory and coverage.xml --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index a25f681..81921da 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ venv/ .worktrees/ .coverage .coverage.* +coverage/ +coverage.xml .pytest_cache/ htmlcov/ *.log