diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..fac5600 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,58 @@ +# CI for the crime-analytics backend. +# +# Runs: install, import/collection sanity, full test suite, and a +# production-settings guard. The Postgres-backed test path is skipped +# in CI (it needs live credentials); the CSV backend fully exercises +# the service/API/audit/RBAC/rate-limit stack. + +name: backend-ci + +on: + push: + branches: [main, develop, "feature/**"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Verify imports and app wiring + run: | + python -c "from app.main import app; print('app OK:', app.title)" + python -c "from app.core.rbac import PERMISSIONS, ROLE_PERMISSIONS; print('rbac OK:', len(PERMISSIONS))" + + - name: Run test suite + run: python -m pytest tests -q + + - name: Production settings guard + run: > + python -c " + import os; + from app.core.config import Settings; + s = Settings(ENVIRONMENT='production', REQUIRE_AUTH=True, DATA_BACKEND='csv', DATABASE_URL='', SUPABASE_JWT_SECRET='x', SUPABASE_JWT_ISSUER='https://x.supabase.co/auth/v1'); + assert s.REQUIRE_AUTH, 'production requires auth'; + try: + Settings(ENVIRONMENT='production', REQUIRE_AUTH=False, DATA_BACKEND='csv'); + raise SystemExit('FAIL: REQUIRE_AUTH=False allowed in production') + except ValueError: + print('production guard OK') + " \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..79eade8 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: cd backend && uvicorn app.main:app --host 0.0.0.0 --port $PORT \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 7d97240..046682a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,7 +3,8 @@ APP_VERSION=0.1.0 ENVIRONMENT=development API_PREFIX=/api/v1 CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] -DATA_DIR=data/schema_reference +# Authoritative approved datasets (override if layout differs) +DATA_DIR=ml-engine/datasets # Persistence backend: "csv" (transitional) or "postgres" (production) DATA_BACKEND=csv @@ -38,3 +39,17 @@ DATA_BACKEND=csv # Authentication enforcement — set to "false" for local dev without Supabase. # REQUIRE_AUTH=true + +# ------------------------------------------------------------------ +# Role-based access control (RBAC) +# ------------------------------------------------------------------ + +# Require permission checks on protected routes (default true). +# RBAC_ENABLED=true + +# Least-privilege role granted to an authenticated identity with no +# recognized role claim. Revisit when the police role policy is approved. +# RBAC_DEFAULT_ROLE=FIELD_OFFICER + +# Dotted JWT claim paths (checked in order) used to resolve the role. +# RBAC_ROLE_CLAIM_PATHS=["app_metadata.role","user_metadata.role","role"] diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..6c752b7 --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,76 @@ +"""Admin API router. + +Provides permission-gated administrative endpoints. Currently exposes +the security audit log read API. + +All endpoints under ``/api/v1/admin/*`` require the ``ADMIN`` role with +the relevant permission (e.g. ``audit.read``). Access is logged by the +audit middleware itself. +""" + +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, Query + +from app.api.rbac_deps import require_permission +from app.schemas.auth import AuthenticatedIdentity +from app.schemas.audit import AuditEventPage +from app.services.audit_service import query_audit_events + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.get( + "/audit/events", + response_model=AuditEventPage, + summary="List security audit events (admin only)", + description="Returns a paginated, filterable view of security audit " + "events. Requires the 'audit.read' permission. In deployments where " + "audit events are not persisted (development CSV backend) this " + "returns HTTP 503 rather than a fabricated empty list.", +) +async def list_audit_events( + user_id: Optional[str] = Query(None, description="Filter by user ID (exact)"), + role: Optional[str] = Query( + None, description="Filter by application role (exact)" + ), + action: Optional[str] = Query(None, description="Filter by action (exact)"), + resource_type: Optional[str] = Query( + None, description="Filter by resource type (exact)" + ), + outcome: Optional[str] = Query( + None, description="Filter by outcome: SUCCESS, DENIED, FAILURE" + ), + status_code: Optional[int] = Query(None, description="Filter by HTTP status code"), + route: Optional[str] = Query(None, description="Filter by route (exact)"), + request_id: Optional[str] = Query( + None, description="Filter by correlation request ID" + ), + start_time: Optional[str] = Query( + None, description="Include events on/after ISO timestamp (UTC)" + ), + end_time: Optional[str] = Query( + None, description="Include events on/before ISO timestamp (UTC)" + ), + page: int = Query(1, ge=1, description="Page number (1-indexed)"), + page_size: int = Query(50, ge=1, le=200, description="Items per page (max 200)"), + _identity: AuthenticatedIdentity = Depends( + require_permission("audit.read") + ), +) -> AuditEventPage: + filters = { + "user_id": user_id, + "role": role, + "action": action, + "resource_type": resource_type, + "outcome": outcome, + "status_code": status_code, + "route": route, + "request_id": request_id, + "start_time": start_time, + "end_time": end_time, + } + result = query_audit_events(filters, page=page, page_size=page_size) + return AuditEventPage(**result) \ No newline at end of file diff --git a/backend/app/api/auth_deps.py b/backend/app/api/auth_deps.py index 67a9f8e..2870896 100644 --- a/backend/app/api/auth_deps.py +++ b/backend/app/api/auth_deps.py @@ -87,9 +87,13 @@ async def get_current_identity( # If auth is disabled (development mode), return a default identity if not settings.REQUIRE_AUTH: + from app.core.rbac import ADMIN, PERMISSIONS + return AuthenticatedIdentity( user_id="dev-user-000", issuer="development", + role=ADMIN, + permissions=PERMISSIONS, ) # Check auth is configured @@ -145,7 +149,12 @@ async def get_current_identity( ), ) - # Build safe identity from verified claims only + # Build safe identity from verified claims only. + # The application role is resolved server-side (RBAC), never from + # the client. + from app.core.rbac import permissions_for_role, resolve_role + + role = resolve_role(claims) return AuthenticatedIdentity( user_id=claims.get("sub", ""), issuer=claims.get("iss", ""), @@ -153,6 +162,8 @@ async def get_current_identity( audience=claims.get("aud"), expires_at=claims.get("exp"), issued_at=claims.get("iat"), + role=role, + permissions=permissions_for_role(role), ) diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index bb89ddb..d6802cd 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -11,8 +11,10 @@ from fastapi import APIRouter, Depends, Query +from app.api.rbac_deps import require_permission from app.database.dependencies import RepositoryCollection, get_repositories from app.schemas.dashboard import DashboardSummaryResponse +from app.schemas.auth import AuthenticatedIdentity from app.services.dashboard_service import DashboardService router = APIRouter(prefix="/dashboard", tags=["dashboard"]) @@ -43,6 +45,7 @@ async def dashboard_summary( start_date: Optional[date] = Query(None, description="Inclusive start date (YYYY-MM-DD)"), end_date: Optional[date] = Query(None, description="Inclusive end date (YYYY-MM-DD)"), service: DashboardService = Depends(_get_dashboard_service), + _identity: AuthenticatedIdentity = Depends(require_permission("dashboard.read")), ) -> DashboardSummaryResponse: result = service.get_summary( district=district, diff --git a/backend/app/api/districts.py b/backend/app/api/districts.py index 5ed7e0c..5650ff6 100644 --- a/backend/app/api/districts.py +++ b/backend/app/api/districts.py @@ -11,11 +11,13 @@ from fastapi import APIRouter, Depends, Query +from app.api.rbac_deps import require_permission from app.database.dependencies import RepositoryCollection, get_repositories from app.schemas.district import ( DistrictIntelligenceProfile, DistrictListResponse, ) +from app.schemas.auth import AuthenticatedIdentity from app.services.district_service import DistrictService router = APIRouter(prefix="/districts", tags=["districts"]) @@ -48,6 +50,7 @@ def _get_district_service( ) async def list_districts( service: DistrictService = Depends(_get_district_service), + _identity: AuthenticatedIdentity = Depends(require_permission("districts.read")), ) -> DistrictListResponse: result = service.list_all_districts() return DistrictListResponse(**result) @@ -80,6 +83,7 @@ async def get_district_intelligence( None, description="Filter by case status" ), service: DistrictService = Depends(_get_district_service), + _identity: AuthenticatedIdentity = Depends(require_permission("districts.read")), ) -> DistrictIntelligenceProfile: result = service.get_district_intelligence( district_id=district_id, diff --git a/backend/app/api/field_map.py b/backend/app/api/field_map.py index 044426e..0c7b341 100644 --- a/backend/app/api/field_map.py +++ b/backend/app/api/field_map.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, Query +from app.api.rbac_deps import require_permission from app.database.dependencies import RepositoryCollection, get_repositories from app.schemas.field_map import ( FieldMapCaseDetail, @@ -18,6 +19,7 @@ FieldMapFiltersResponse, FieldMapHotspotResponse, ) +from app.schemas.auth import AuthenticatedIdentity from app.services.field_map_service import FieldMapService router = APIRouter(prefix="/map/field", tags=["field-map"]) @@ -68,6 +70,7 @@ async def list_field_cases( page: int = Query(1, ge=1, description="Page number (1-indexed)"), page_size: int = Query(50, ge=1, le=200, description="Items per page (max 200)"), service: FieldMapService = Depends(_get_field_map_service), + _identity: AuthenticatedIdentity = Depends(require_permission("cases.read")), ) -> FieldMapCaseListResponse: result = service.get_cases( district=district, @@ -100,6 +103,7 @@ async def list_field_cases( async def get_field_case_detail( fir_identifier: str, service: FieldMapService = Depends(_get_field_map_service), + _identity: AuthenticatedIdentity = Depends(require_permission("cases.read")), ) -> FieldMapCaseDetail: result = service.get_case_detail(fir_identifier) return FieldMapCaseDetail(**result) @@ -119,6 +123,7 @@ async def get_field_case_detail( ) async def get_field_filters( service: FieldMapService = Depends(_get_field_map_service), + _identity: AuthenticatedIdentity = Depends(require_permission("map.field.read")), ) -> FieldMapFiltersResponse: result = service.get_filters() return FieldMapFiltersResponse(**result) @@ -149,6 +154,7 @@ async def get_field_hotspots( None, description="Inclusive end date (YYYY-MM-DD)" ), service: FieldMapService = Depends(_get_field_map_service), + _identity: AuthenticatedIdentity = Depends(require_permission("map.field.read")), ) -> FieldMapHotspotResponse: result = service.get_hotspots( district=district, diff --git a/backend/app/api/intelligence_map.py b/backend/app/api/intelligence_map.py index 9f3ff1e..55c8c26 100644 --- a/backend/app/api/intelligence_map.py +++ b/backend/app/api/intelligence_map.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, Query from fastapi.responses import PlainTextResponse +from app.api.rbac_deps import require_permission from app.core.config import settings from app.database.dependencies import RepositoryCollection, get_repositories from app.schemas.intelligence_map import ( @@ -22,6 +23,7 @@ IntelligenceAnalyticsResponse, TimelineResponse, ) +from app.schemas.auth import AuthenticatedIdentity from app.services.intelligence_map_service import IntelligenceMapService router = APIRouter(prefix="/map/intelligence", tags=["intelligence-map"]) @@ -63,6 +65,7 @@ async def get_intelligence_analytics( None, description="Inclusive end date (YYYY-MM-DD)" ), service: IntelligenceMapService = Depends(_get_intelligence_service), + _identity: AuthenticatedIdentity = Depends(require_permission("map.intelligence.read")), ) -> IntelligenceAnalyticsResponse: result = service.get_analytics( district=district, @@ -99,6 +102,7 @@ async def get_intelligence_heatmap( None, description="Inclusive end date (YYYY-MM-DD)" ), service: IntelligenceMapService = Depends(_get_intelligence_service), + _identity: AuthenticatedIdentity = Depends(require_permission("map.intelligence.read")), ) -> HeatmapResponse: result = service.get_heatmap( district=district, @@ -136,6 +140,7 @@ async def get_intelligence_clusters( None, description="Inclusive end date (YYYY-MM-DD)" ), service: IntelligenceMapService = Depends(_get_intelligence_service), + _identity: AuthenticatedIdentity = Depends(require_permission("map.intelligence.read")), ) -> ClusterResponse: result = service.get_clusters( district=district, @@ -172,6 +177,7 @@ async def get_intelligence_hotspots( None, description="Inclusive end date (YYYY-MM-DD)" ), service: IntelligenceMapService = Depends(_get_intelligence_service), + _identity: AuthenticatedIdentity = Depends(require_permission("map.intelligence.read")), ) -> HotspotResponse: result = service.get_hotspots( district=district, @@ -209,6 +215,7 @@ async def get_intelligence_district_comparison( None, description="Inclusive end date (YYYY-MM-DD)" ), service: IntelligenceMapService = Depends(_get_intelligence_service), + _identity: AuthenticatedIdentity = Depends(require_permission("map.intelligence.read")), ) -> DistrictComparisonResponse: result = service.get_district_comparison( district=district, @@ -249,6 +256,7 @@ async def get_intelligence_timeline( description="Time granularity: 'daily' or 'monthly' (default: monthly)", ), service: IntelligenceMapService = Depends(_get_intelligence_service), + _identity: AuthenticatedIdentity = Depends(require_permission("map.intelligence.read")), ) -> TimelineResponse: result = service.get_timeline( district=district, @@ -287,6 +295,7 @@ async def get_intelligence_export( None, description="Inclusive end date (YYYY-MM-DD)" ), service: IntelligenceMapService = Depends(_get_intelligence_service), + _identity: AuthenticatedIdentity = Depends(require_permission("cases.export")), ) -> PlainTextResponse: csv_content = service.get_export( district=district, diff --git a/backend/app/api/network.py b/backend/app/api/network.py index 588283c..8091d5f 100644 --- a/backend/app/api/network.py +++ b/backend/app/api/network.py @@ -13,12 +13,14 @@ from fastapi import APIRouter, Depends, Path, Query +from app.api.rbac_deps import require_any_permission, require_permission from app.database.dependencies import RepositoryCollection, get_repositories from app.schemas.network import ( NetworkEntityDetail, NetworkGraphResponse, NetworkSearchResponse, ) +from app.schemas.auth import AuthenticatedIdentity from app.services.network_service import NetworkService router = APIRouter(prefix="/network", tags=["network"]) @@ -51,6 +53,7 @@ async def get_network_graph( start_date: Optional[date] = Query(None, description="Inclusive start date (YYYY-MM-DD)"), end_date: Optional[date] = Query(None, description="Inclusive end date (YYYY-MM-DD)"), service: NetworkService = Depends(_get_network_service), + _identity: AuthenticatedIdentity = Depends(require_permission("network.read")), ) -> NetworkGraphResponse: result = service.get_graph( district=district, @@ -79,6 +82,7 @@ async def get_entity_detail( ), entity_id: str = Path(..., description="Entity identifier"), service: NetworkService = Depends(_get_network_service), + _identity: AuthenticatedIdentity = Depends(require_any_permission(["network.read", "network.person.read"])), ) -> NetworkEntityDetail: from app.core.exceptions import ResourceNotFoundError @@ -111,6 +115,7 @@ async def search_network( le=100, ), service: NetworkService = Depends(_get_network_service), + _identity: AuthenticatedIdentity = Depends(require_permission("network.read")), ) -> NetworkSearchResponse: result = service.search(q, limit=limit) return NetworkSearchResponse(**result) diff --git a/backend/app/api/rbac_deps.py b/backend/app/api/rbac_deps.py new file mode 100644 index 0000000..a418955 --- /dev/null +++ b/backend/app/api/rbac_deps.py @@ -0,0 +1,109 @@ +"""Authorization (RBAC) dependencies for FastAPI. + +These dependencies perform *authorization* only — authentication is +handled by ``get_current_identity`` / the auth middleware. + +Behavior: + - Missing/invalid identity → 401 (authentication failure) + - Valid identity, insufficient role → 403 FORBIDDEN (insufficient + permissions) with a structured body + - Valid identity, sufficient permission → identity passed through + +Security note: authorization is always evaluated against the +server-side identity role resolved from the verified JWT. The client +(the mock frontend) cannot grant or change roles. +""" + +from __future__ import annotations + +import logging +from typing import Callable + +from fastapi import Depends, HTTPException, Request + +from app.api.auth_deps import get_current_identity +from app.core.config import settings +from app.core.logging import get_request_id +from app.schemas.auth import AuthenticatedIdentity + +logger = logging.getLogger(__name__) + +_FORBIDDEN_CODE = "FORBIDDEN" + + +def _forbidden(permission: str, request_id: str | None) -> HTTPException: + return HTTPException( + status_code=403, + detail={ + "error": { + "code": _FORBIDDEN_CODE, + "message": ( + "Insufficient permissions: " + f"'{permission}' required." + ), + "request_id": request_id, + } + }, + ) + + +def require_permission(permission: str) -> Callable: + """Build a dependency that requires a specific permission. + + Usage: + ``identity=Depends(require_permission("cases.read"))`` + """ + + async def _require_permission( + request: Request, + identity: AuthenticatedIdentity = Depends(get_current_identity), + ) -> AuthenticatedIdentity: + if not settings.RBAC_ENABLED: + return identity + if permission not in identity.permissions: + raise _forbidden(permission, get_request_id(request)) + return identity + + return _require_permission + + +def require_any_permission(permissions: list[str]) -> Callable: + """Build a dependency requiring at least one permission (OR semantics). + + Usage: + ``identity=Depends(require_any_permission(["network.read", "network.person.read"]))`` + """ + + async def _require_any_permission( + request: Request, + identity: AuthenticatedIdentity = Depends(get_current_identity), + ) -> AuthenticatedIdentity: + if not settings.RBAC_ENABLED: + return identity + if not any(p in identity.permissions for p in permissions): + raise _forbidden(" or ".join(permissions), get_request_id(request)) + return identity + + return _require_any_permission + + +def require_role(roles: list[str]) -> Callable: + """Build a dependency requiring one of the given roles (OR semantics). + + Prefer ``require_permission``/``require_any_permission`` at the route + level. ``require_role`` is reserved for coarse administrative gates. + + Usage: ``identity=Depends(require_role(["ADMIN"]))`` + """ + + async def _require_role( + request: Request, + identity: AuthenticatedIdentity = Depends(get_current_identity), + ) -> AuthenticatedIdentity: + if not settings.RBAC_ENABLED: + return identity + if identity.role not in roles: + raise _forbidden(f"role in [{', '.join(roles)}]", get_request_id(request)) + return identity + + return _require_role \ No newline at end of file diff --git a/backend/app/api/stations.py b/backend/app/api/stations.py index b94c410..ecef0da 100644 --- a/backend/app/api/stations.py +++ b/backend/app/api/stations.py @@ -10,8 +10,10 @@ from fastapi import APIRouter, Depends, Query +from app.api.rbac_deps import require_permission from app.database.dependencies import RepositoryCollection, get_repositories from app.schemas.station import StationDetailResponse, StationListResponse +from app.schemas.auth import AuthenticatedIdentity from app.services.station_service import StationService router = APIRouter(prefix="/stations", tags=["stations"]) @@ -49,6 +51,7 @@ async def list_stations( 50, ge=1, le=200, description="Items per page (max 200)" ), service: StationService = Depends(_get_station_service), + _identity: AuthenticatedIdentity = Depends(require_permission("stations.read")), ) -> StationListResponse: result = service.list_stations( district_id=district_id, @@ -73,6 +76,7 @@ async def list_stations( async def get_station_detail( station_id: str, service: StationService = Depends(_get_station_service), + _identity: AuthenticatedIdentity = Depends(require_permission("stations.read")), ) -> StationDetailResponse: result = service.get_station_detail(station_id) return StationDetailResponse(**result) diff --git a/backend/app/core/audit.py b/backend/app/core/audit.py index 11cfd11..1b2832b 100644 --- a/backend/app/core/audit.py +++ b/backend/app/core/audit.py @@ -65,6 +65,7 @@ class AuditEvent: event_timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) request_id: str = "" user_id: str | None = None + role: str | None = None http_method: str = "" route: str = "" action: str = "" @@ -103,6 +104,7 @@ class AuditEvent: "/api/v1/network/entities/{entity_type}/{entity_id}": ("READ", "network_entity"), "/api/v1/network/search": ("SEARCH", "network"), "/api/v1/auth/me": ("READ", "authenticated_identity"), + "/api/v1/admin/audit/events": ("LIST", "audit_events"), } # Patterns for dynamic path segments (used for normalization + resource ID extraction) @@ -198,16 +200,18 @@ class AuditMiddleware: """ASGI middleware that records security audit events for classified routes. Middleware ordering (outermost first): - RequestID → StructuredLogging → Authentication → **Audit** → SecurityHeaders → CORS → App - - The audit middleware is placed inside AuthenticationMiddleware so that - the verified identity is available on scope["state"]["authenticated_identity"], - and wraps the remaining inner middleware/app so that the response status - code is visible. - - Health probes and unclassified paths are silently skipped. - Audit persistence failures are logged at CRITICAL level but never - block the original request (fail-open policy). + CORS → SecurityHeaders → RequestID → StructuredLogging → RateLimit → + **Audit** → Authentication → App + + The audit middleware wraps AuthenticationMiddleware so that both rejected + (401/403 → DENIED) and granted requests are recorded. Because the verified + identity is set by the inner AuthenticationMiddleware, identity is read + from ``scope["state"]["authenticated_identity"]`` after the inner chain + completes (at response time), never trusted from the request. + + Health probes, unclassified paths, and rate-limited (429) requests are + silently skipped. Audit persistence failures are logged at CRITICAL level + but never block the original request (fail-open policy). """ def __init__(self, app: ASGIApp) -> None: @@ -232,13 +236,6 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if isinstance(state, dict): request_id = state.get("request_id", "") - # Extract authenticated identity (set by AuthenticationMiddleware) - user_id = None - if isinstance(state, dict): - identity = state.get("authenticated_identity") - if isinstance(identity, dict): - user_id = identity.get("user_id") - # Classify route action, resource_type, resource_id = classify_route(path) @@ -261,17 +258,29 @@ async def send_wrapper(message): raise finally: # Determine outcome - if status_code == 401: + if status_code in (401, 403): outcome = AuditOutcome.DENIED.value elif status_code >= 500: outcome = AuditOutcome.FAILURE.value else: outcome = AuditOutcome.SUCCESS.value + # Extract authenticated identity at response time: AuthenticationMiddleware + # (inner) ran and populated scope state during self.app(). + user_id = None + role = None + state = scope.get("state") + if isinstance(state, dict): + identity = state.get("authenticated_identity") + if isinstance(identity, dict): + user_id = identity.get("user_id") + role = identity.get("role") + # Construct audit event event = AuditEvent( request_id=request_id, user_id=user_id, + role=role, http_method=method, route=path, action=action, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 29a1468..dcb8a6f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -30,7 +30,9 @@ class Settings(BaseSettings): "http://localhost:3000", ] - DATA_DIR: str = str(_PROJECT_ROOT / "data" / "schema_reference") + # Authoritative approved datasets live under ml-engine/datasets/. + # Override with DATA_DIR when the repository layout differs. + DATA_DIR: str = str(_PROJECT_ROOT / "ml-engine" / "datasets") # Persistence: "csv" (transitional) or "postgres" (production) DATA_BACKEND: str = "csv" @@ -74,6 +76,50 @@ class Settings(BaseSettings): # Set to "false" ONLY for local development without Supabase. REQUIRE_AUTH: bool = True + # ------------------------------------------------------------------ + # Role-based access control (RBAC) + # ------------------------------------------------------------------ + + # Whether authorization (permission checks) is enforced. + RBAC_ENABLED: bool = True + + # Least-privilege role granted to an authenticated identity that has + # no recognized role claim. The role itself is configurable; when a + # real role policy is approved, change this to "ANALYST" or keep the + # low-tier default. FIELD_OFFICER covers dashboard/field/read data. + RBAC_DEFAULT_ROLE: str = "FIELD_OFFICER" + + # Dotted claim paths checked (in order) to resolve an application + # role from verified JWT claims. + RBAC_ROLE_CLAIM_PATHS: list[str] = [ + "app_metadata.role", + "user_metadata.role", + "role", + ] + + # ------------------------------------------------------------------ + # Rate limiting (in-process; single-instance scope) + # ------------------------------------------------------------------ + + # Whether rate limiting is enforced. + RATE_LIMIT_ENABLED: bool = True + + # Default per-window request allowance for API endpoints. + RATE_LIMIT_DEFAULT_LIMIT: int = 300 + RATE_LIMIT_DEFAULT_WINDOW: int = 60 + + # Stricter limits for cost-heavy / sensitive route classes. + RATE_LIMIT_EXPORT_LIMIT: int = 10 + RATE_LIMIT_EXPORT_WINDOW: int = 3600 + RATE_LIMIT_SEARCH_LIMIT: int = 60 + RATE_LIMIT_SEARCH_WINDOW: int = 60 + RATE_LIMIT_AUDIT_LIMIT: int = 120 + RATE_LIMIT_AUDIT_WINDOW: int = 60 + + # Client identifier header for proxy deployments (empty = socket peer + # only). Standard proxies should set this to X-Forwarded-For. + RATE_LIMIT_CLIENT_HEADER: str = "X-Forwarded-For" + @model_validator(mode="before") @classmethod def _normalize_backend(cls, values: dict) -> dict: @@ -128,6 +174,15 @@ def _validate_backend_config(self) -> "Settings": "REQUIRE_AUTH cannot be False in production environment. " "Set REQUIRE_AUTH=true or change ENVIRONMENT to 'development'." ) + # RBAC default role must be one of the application roles + from app.core.rbac import APP_ROLES, normalize_role + + normalized_default = normalize_role(self.RBAC_DEFAULT_ROLE) + if normalized_default not in APP_ROLES: + raise ValueError( + f"RBAC_DEFAULT_ROLE must be one of {APP_ROLES}, " + f"got '{self.RBAC_DEFAULT_ROLE}'" + ) return self model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py new file mode 100644 index 0000000..34d057c --- /dev/null +++ b/backend/app/core/rate_limit.py @@ -0,0 +1,197 @@ +"""Rate limiting for the crime analytics backend. + +A deterministic, in-process fixed-window rate limiter implemented as ASGI +middleware. + +Design notes +----------- +* In-process (per-instance) accounting using a monotonic clock. This is + a single-instance control — it is NOT shared across replicas. Deploying + multiple backend instances requires a distributed limit store or an + API gateway. This limitation is documented in PRODUCTION_DATABASE.md. +* Fixed-window buckets keyed by ``route template + client identifier``. + Limits are configured per route class (default / export / auth / + audit) via ``RATE_LIMIT_*`` settings. +* Middleware position: CORS → Security → RequestID → Logging → + RateLimit → Audit → Auth → App, i.e. rate limiting runs BEFORE + authentication (throttles anonymous floods cheaply) and BEFORE the + audit middleware, so rate-limited (429) requests are logged but never + enter the security audit trail. +* Health probes and docs are never rate-limited. +* Responses over the limit are HTTP 429 with ``Retry-After`` and a + structured error body (code ``RATE_LIMITED``). +* Clock and store are injectable so behaviour is deterministic in tests. +""" + +from __future__ import annotations + +import json +import time +from typing import Callable + +from starlette.types import ASGIApp, Receive, Scope, Send + +from app.core.config import settings + +_LIMIT_BODY = { + "error": { + "code": "RATE_LIMITED", + "message": "Too many requests. Please retry later.", + } +} + + +class FixedWindowRateLimiter: + """Fixed-window counter keyed by ``window_id:key``. + + A request is allowed if the request count within the current window + does not exceed the limit. Requests that exceed the limit still + increment the counter so the abusive client stays blocked for the + remainder of the window. + """ + + def __init__( + self, + now: Callable[[], float] | None = None, + store: dict[str, int] | None = None, + ) -> None: + self._now = now if now is not None else lambda: time.monotonic() + self._store = store if store is not None else {} + + def allow(self, key: str, limit: int, window_seconds: int) -> bool: + """Register one request; return True if within the limit.""" + window_id = int(self._now() // window_seconds) + bucket = f"{window_id}:{key}" + count = self._store.get(bucket, 0) + 1 + self._store[bucket] = count + return count <= limit + + def reset(self) -> None: + """Clear all counters (test/reset helper).""" + self._store.clear() + + +# Module-level shared limiter used by the middleware so tests can reset +# counters deterministically between cases. +_default_limiter = FixedWindowRateLimiter() + + +def _route_group(path: str) -> str: + """Map a request path to a stable rate-limit key. + + Uses the audited route template when available so dynamic segments + (entity IDs) do not fragment the counter; otherwise falls back to the + first three path segments. + """ + try: + from app.core.audit import normalize_route + + template = normalize_route(path) + if template: + return template + except Exception: + pass + parts = [p for p in path.split("/") if p] + return "/" + "/".join(parts[:3]) + + +class RateLimitMiddleware: + """ASGI middleware enforcing per-route-token rate limits.""" + + def __init__(self, app: ASGIApp, limiter: FixedWindowRateLimiter | None = None) -> None: + self.app = app + self.limiter = limiter if limiter is not None else _default_limiter + + def reset(self) -> None: + """Reset the shared limiter counters (test helper).""" + self.limiter.reset() + + def _limits_for_path(self, path: str) -> tuple[int, int]: + """Return (limit, window_seconds) for an API path.""" + if "/map/intelligence/export" in path: + return settings.RATE_LIMIT_EXPORT_LIMIT, settings.RATE_LIMIT_EXPORT_WINDOW + if "/network/search" in path: + return settings.RATE_LIMIT_SEARCH_LIMIT, settings.RATE_LIMIT_SEARCH_WINDOW + if "/admin/audit/events" in path: + return settings.RATE_LIMIT_AUDIT_LIMIT, settings.RATE_LIMIT_AUDIT_WINDOW + return settings.RATE_LIMIT_DEFAULT_LIMIT, settings.RATE_LIMIT_DEFAULT_WINDOW + + def _client_key(self, scope: Scope) -> str: + """Derive a stable client identifier for rate limiting. + + Prefers the first ``X-Forwarded-For`` value (standard proxy + header) when present; otherwise falls back to the socket peer. + The header name is configurable via ``RATE_LIMIT_CLIENT_HEADER``. + """ + client = scope.get("client") + peer = client[0] if client else "unknown" + header = settings.RATE_LIMIT_CLIENT_HEADER.encode("latin-1").lower() + for name, value in scope.get("headers", []): + if name == header: + first = value.decode("latin-1").split(",")[0].strip() + if first: + return f"{peer}:{first}" + return peer + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path = scope.get("path", "") + + # Health/docs are never rate-limited + if path.startswith("/health") or path.startswith("/docs") or path == "/openapi.json" or path == "/redoc": + await self.app(scope, receive, send) + return + + if not settings.RATE_LIMIT_ENABLED: + await self.app(scope, receive, send) + return + + limit, window = self._limits_for_path(path) + key = f"{self._client_key(scope)}:{_route_group(path)}" + if not self.limiter.allow(key, limit, window): + await self._reject(scope, receive, send, window) + return + + await self.app(scope, receive, send) + + async def _reject( + self, scope: Scope, receive: Receive, send: Send, window: int + ) -> None: + """Send a 429 response and terminate the request.""" + import json + + request_id = get_request_id_from_scope(scope) + body = json.dumps({ + "error": { + "code": _LIMIT_BODY["error"]["code"], + "message": _LIMIT_BODY["error"]["message"], + "request_id": request_id, + } + }).encode("utf-8") + + await send({ + "type": "http.response.start", + "status": 429, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + (b"retry-after", str(window).encode()), + ], + }) + await send({ + "type": "http.response.body", + "body": body, + "more_body": False, + }) + + +def get_request_id_from_scope(scope: Scope) -> str: + """Extract the request id from scope state without a Request object.""" + state = scope.get("state") + if isinstance(state, dict): + rid = state.get("request_id", "") + return rid if isinstance(rid, str) else "" + return "" \ No newline at end of file diff --git a/backend/app/core/rbac.py b/backend/app/core/rbac.py new file mode 100644 index 0000000..ccd7408 --- /dev/null +++ b/backend/app/core/rbac.py @@ -0,0 +1,179 @@ +"""Role-based access control (RBAC) for the crime analytics backend. + +The application exposes three proposed operational roles: + +* ``FIELD_OFFICER`` +* ``ANALYST`` +* ``ADMIN`` + +These roles are the *application's* current authorization model and do +**not** claim to represent an official Karnataka Police permission +policy. The mapping is configurable and revisable via role-to-permission +tables below and the ``RBAC_*`` settings. + +Role resolution +--------------- +Roles are resolved **server-side only**, from the *verified* Supabase +Auth JWT. The frontend is never trusted for authorization. + +Claim sources (checked in order, all configurable): + +1. ``app_metadata.role`` (Supabase convention for privileged metadata) +2. ``user_metadata.role`` (Supabase convention for user metadata) +3. ``role`` (top-level claim) + +A resolved role is accepted only if it is present in the role +allowlist. If no recognized role claim exists, the identity receives the +configured ``RBAC_DEFAULT_ROLE`` (least-privilege tier, ``FIELD_OFFICER`` +by default). This default is deliberate: an authenticated Supabase user +with no custom role metadata still receives the lowest application tier. +Administrative tiers (``ANALYST``/``ADMIN``) require an explicit, +allowlisted role claim. + +Permission model +---------------- +Permissions are coarse, stable strings (``.``). Endpoints +declare the permission they require via reusable dependencies +(``require_permission`` / ``require_any_permission`` / ``require_role``) +defined in ``app/api/rbac_deps.py``. Business logic never performs ad-hoc +role checks. + +* 401 - identity missing or invalid (authentication middleware) +* 403 - identity valid but permission missing (authorization dependencies) +""" + +from __future__ import annotations + +from typing import Any + +# --------------------------------------------------------------------------- +# Roles +# --------------------------------------------------------------------------- + +FIELD_OFFICER = "FIELD_OFFICER" +ANALYST = "ANALYST" +ADMIN = "ADMIN" + +APP_ROLES = (FIELD_OFFICER, ANALYST, ADMIN) + + +def normalize_role(role: str | None) -> str | None: + """Normalize a raw role string to an allowlisted application role.""" + if not role: + return None + normalized = role.strip().upper().replace("-", "_").replace(" ", "_") + return normalized if normalized in APP_ROLES else None + + +# --------------------------------------------------------------------------- +# Permission catalog +# --------------------------------------------------------------------------- + +PERMISSIONS: frozenset[str] = frozenset({ + "dashboard.read", + "cases.read", + "cases.export", + "map.field.read", + "map.intelligence.read", + "districts.read", + "stations.read", + "analytics.read", + "network.read", + "network.person.read", + "network.person.search", + "network.person.detail", + "audit.read", + "users.read", + "users.manage", + "roles.read", + "roles.manage", + "system.configuration.read", + "system.configuration.manage", +}) + +# --------------------------------------------------------------------------- +# Role → permission mapping (the application's current policy) +# --------------------------------------------------------------------------- +# Revisable: change the sets below to re-scope a role. The permission +# catalog and endpoint assignments are the authorization contract. + +ROLE_PERMISSIONS: dict[str, frozenset[str]] = { + FIELD_OFFICER: frozenset({ + "dashboard.read", + "cases.read", + "cases.export", + "map.field.read", + "districts.read", + "stations.read", + }), + ANALYST: frozenset({ + "dashboard.read", + "cases.read", + "cases.export", + "map.field.read", + "map.intelligence.read", + "districts.read", + "stations.read", + "analytics.read", + "network.read", + "network.person.read", + "network.person.search", + "network.person.detail", + }), + ADMIN: PERMISSIONS, +} + +assert all( + permission in PERMISSIONS + for role_permissions in ROLE_PERMISSIONS.values() + for permission in role_permissions +), "Role permission sets reference permissions outside the catalog" + + +# --------------------------------------------------------------------------- +# Resolution helpers +# --------------------------------------------------------------------------- + + +def _claim_path_lookup(claims: dict[str, Any], path: str) -> Any: + """Resolve a dotted JSON path inside the claims dict.""" + current: Any = claims + for segment in path.split("."): + if not isinstance(current, dict): + return None + current = current.get(segment) + return current + + +def resolve_role(claims: dict[str, Any] | None) -> str | None: + """Resolve an application role from verified JWT claims. + + Returns the configured default role (``RBAC_DEFAULT_ROLE``) when no + recognized role claim is present. ``None`` is only returned when the + allowlist itself is empty. Settings are read lazily to avoid a + circular import with ``app.core.config`` during validation. + """ + from app.core.config import settings + + if not claims: + return settings.RBAC_DEFAULT_ROLE + + for path in settings.RBAC_ROLE_CLAIM_PATHS: + raw = _claim_path_lookup(claims, path) + role = normalize_role(raw if isinstance(raw, str) else None) + if role is not None: + return role + + return settings.RBAC_DEFAULT_ROLE + + +def permissions_for_role(role: str | None) -> frozenset[str]: + """Return the permission set for a role (empty for unknown roles).""" + if not role: + return frozenset() + return ROLE_PERMISSIONS.get(role, frozenset()) + + +def role_has_permission(role: str | None, permission: str) -> bool: + """Return True if the role grants the given permission.""" + return permission in permissions_for_role(role) \ No newline at end of file diff --git a/backend/app/database/postgres/audit_repo.py b/backend/app/database/postgres/audit_repo.py index 2e94dc4..20df526 100644 --- a/backend/app/database/postgres/audit_repo.py +++ b/backend/app/database/postgres/audit_repo.py @@ -19,6 +19,7 @@ event_timestamp, request_id, user_id, + role, http_method, route, action, @@ -32,6 +33,7 @@ %(event_timestamp)s, %(request_id)s, %(user_id)s, + %(role)s, %(http_method)s, %(route)s, %(action)s, @@ -47,10 +49,72 @@ class PostgresAuditRepository: """PostgreSQL-backed audit event repository. - Append-only: only the ``append`` method exists. No update/delete. - Uses the shared psycopg2 connection pool from ``app.database.postgres``. + Append-only: only the ``append`` method mutates data. ``query`` is + read-only. Uses the shared psycopg2 connection pool from + ``app.database.postgres``. """ + # Allowed filter columns (exact-match equality operators). + # No arbitrary column filtering — this is an explicit allowlist. + _EXACT_FILTERS = ( + "user_id", + "role", + "action", + "resource_type", + "resource_id", + "outcome", + "status_code", + "route", + "request_id", + ) + def append(self, event: dict) -> None: """Persist a single audit event via parameterized INSERT.""" execute_write(_INSERT_SQL, event) + + def query(self, filters: dict, limit: int, offset: int) -> tuple[list[dict], int]: + """Read audit events with optional filters (read-only). + + Returns ``(rows, total)`` where ``total`` is the number of rows + matching the filters *without* pagination applied. All filters + are exact-match and restricted to the allowlist in + ``_EXACT_FILTERS`` plus the timestamp range filters + ``start_time``/``end_time``. SQL is fully parameterized. + """ + where_clauses: list[str] = [] + params: list[object] = [] + + for key in self._EXACT_FILTERS: + value = filters.get(key) + if value is None or value == "": + continue + where_clauses.append(f"{key} = %s") + params.append(value) + + for key, operator in (("start_time", ">="), ("end_time", "<=")): + value = filters.get(key) + if value is None or value == "": + continue + where_clauses.append(f"event_timestamp {operator} %s") + params.append(value) + + where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else "" + + count_sql = f"SELECT COUNT(*) AS total FROM audit_events{where_sql}" + query_sql = ( + f"SELECT * FROM audit_events{where_sql} " + "ORDER BY event_timestamp DESC " + "LIMIT %s OFFSET %s" + ) + + rows = execute_query(query_sql, (*params, limit, offset)) + count_row = execute_one(count_sql, tuple(params)) + total = int(count_row["total"]) if count_row else 0 + + # Keep resource_id/request_id/etc as plain values; convert + # timestamps to ISO strings for a stable JSON contract. + for row in rows: + ts = row.get("event_timestamp") + if ts is not None and hasattr(ts, "isoformat"): + row["event_timestamp"] = ts.isoformat() + return rows, total diff --git a/backend/app/database/postgres/fir_repo.py b/backend/app/database/postgres/fir_repo.py index 768f3d4..a289b70 100644 --- a/backend/app/database/postgres/fir_repo.py +++ b/backend/app/database/postgres/fir_repo.py @@ -63,7 +63,8 @@ def list_by_incident_date_range( self, start_date: str, end_date: str ) -> list[FIRRecord]: rows = self._build_select( - "WHERE f.incident_date::date >= %s AND f.incident_date::date <= %s", + "WHERE (f.incident_date AT TIME ZONE 'UTC')::date >= %s " + "AND (f.incident_date AT TIME ZONE 'UTC')::date <= %s", (start_date, end_date), order="f.incident_date", ) @@ -101,10 +102,10 @@ def list_filtered( conditions.append("f.status = %s") params.append(status) if start_date is not None: - conditions.append("f.incident_date::date >= %s") + conditions.append("(f.incident_date AT TIME ZONE 'UTC')::date >= %s") params.append(start_date) if end_date is not None: - conditions.append("f.incident_date::date <= %s") + conditions.append("(f.incident_date AT TIME ZONE 'UTC')::date <= %s") params.append(end_date) where = ("WHERE " + " AND ".join(conditions)) if conditions else "" rows = self._build_select(where, tuple(params)) diff --git a/backend/app/database/repositories/csv/audit_repo.py b/backend/app/database/repositories/csv/audit_repo.py index 90ecbb5..9609c0b 100644 --- a/backend/app/database/repositories/csv/audit_repo.py +++ b/backend/app/database/repositories/csv/audit_repo.py @@ -12,6 +12,8 @@ import logging +from app.core.exceptions import DependencyUnavailableError + logger = logging.getLogger(__name__) _WARNED = False @@ -48,3 +50,17 @@ def append(self, event: dict) -> None: event.get("outcome", "-"), event.get("status_code", 0), ) + + def query(self, filters: dict, limit: int, offset: int) -> tuple[list[dict], int]: + """Reading audit events is unavailable in CSV/dev deployments. + + Returns ``DependencyUnavailableError`` (HTTP 503) rather than a + fabricated empty page — a 503 honestly signals that audit events + are not persisted here. Production (Postgres) deployments can + serve reads through ``PostgresAuditRepository.query``. + """ + raise DependencyUnavailableError( + "Audit event storage is not available in this deployment " + "(DATA_BACKEND=csv). Audit events are only persisted when " + "running the PostgreSQL backend." + ) diff --git a/backend/app/database/repositories/protocols.py b/backend/app/database/repositories/protocols.py index 36f341f..9cb1f8f 100644 --- a/backend/app/database/repositories/protocols.py +++ b/backend/app/database/repositories/protocols.py @@ -107,3 +107,18 @@ class AuditRepository(Protocol): def append(self, event: dict) -> None: """Persist a single audit event. Must be append-only.""" ... + + def query( + self, + filters: dict, + limit: int, + offset: int, + ) -> tuple[list[dict], int]: + """Read audit events (no mutation). + + Returns a tuple of ``(rows, total_matching_without_pagination)``. + Implementations that cannot serve reads (e.g. the development + no-op adapter) MUST raise ``DependencyUnavailableError`` so the + API can return 503 instead of fabricating empty results. + """ + ... diff --git a/backend/app/main.py b/backend/app/main.py index a5319fa..b3e4cb2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,12 +1,17 @@ """FastAPI application entry point. Middleware order (outermost first): - 1. CORSMiddleware – CORS headers - 2. SecurityHeadersMiddleware – cache/security headers - 3. AuditMiddleware – security audit trail (classified routes only) - 4. AuthenticationMiddleware – JWT verification for protected routes - 5. StructuredLoggingMiddleware – request line after completion - 6. RequestIDMiddleware – correlation ID on every request + 1. CORSMiddleware – CORS headers + preflight handling + 2. SecurityHeadersMiddleware – cache/security headers (all responses) + 3. RequestIDMiddleware – correlation ID on every request + 4. StructuredLoggingMiddleware – request line after completion + 5. RateLimitMiddleware – per-route fixed-window limits (429 never audited) + 6. AuditMiddleware – security audit trail (incl. DENIED auth) + 7. AuthenticationMiddleware – JWT verification for protected routes + +CORS and security headers are outermost so that preflight requests and +error responses (401/429/403) still carry the headers browsers need, and +authentication is innermost so the audit records both successes and denials. Centralized exception handlers convert domain and framework errors into a consistent ``{"error": {"code", "message", "request_id"}}`` JSON body. @@ -42,8 +47,10 @@ from app.api.stations import router as stations_router from app.api.auth import router as auth_router from app.api.network import router as network_router +from app.api.admin import router as admin_router from app.core.logging import RequestIDMiddleware, StructuredLoggingMiddleware, get_request_id from app.core.audit import AuditMiddleware +from app.core.rate_limit import RateLimitMiddleware # --------------------------------------------------------------------------- # Logging setup @@ -218,10 +225,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # Auth disabled (development mode) if not settings.REQUIRE_AUTH: + from app.core.rbac import ADMIN, PERMISSIONS + state = scope.setdefault("state", {}) state["authenticated_identity"] = { "user_id": "dev-user-000", "issuer": "development", + "role": ADMIN, + "permissions": list(PERMISSIONS), } await self.app(scope, receive, send) return @@ -272,7 +283,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ) return - # Store verified identity on request state + # Store verified identity on request state. Role is resolved + # server-side (RBAC) — never trusted from the client. + from app.core.rbac import permissions_for_role, resolve_role + + role = resolve_role(claims) state = scope.setdefault("state", {}) state["authenticated_identity"] = { "user_id": claims.get("sub", ""), @@ -281,6 +296,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: "audience": claims.get("aud"), "expires_at": claims.get("exp"), "issued_at": claims.get("iat"), + "role": role, + "permissions": list(permissions_for_role(role)), } await self.app(scope, receive, send) @@ -348,7 +365,15 @@ async def _reject( lifespan=lifespan, ) -# Middleware – added outermost-first: CORS → Security → Audit → Auth → Logging → RequestID +# Middleware – Starlette add_middleware prepends, so the LAST registration is +# the OUTERMOST. Register innermost-first so execution order becomes: +# CORS → Security → RequestID → Logging → RateLimit → Audit → Auth → App +app.add_middleware(AuthenticationMiddleware) +app.add_middleware(AuditMiddleware) +app.add_middleware(RateLimitMiddleware) +app.add_middleware(StructuredLoggingMiddleware) +app.add_middleware(RequestIDMiddleware) +app.add_middleware(SecurityHeadersMiddleware) app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, @@ -357,11 +382,6 @@ async def _reject( allow_headers=["Authorization", "Content-Type", "X-Request-ID"], expose_headers=["X-Request-ID"], ) -app.add_middleware(SecurityHeadersMiddleware) -app.add_middleware(AuditMiddleware) -app.add_middleware(AuthenticationMiddleware) -app.add_middleware(StructuredLoggingMiddleware) -app.add_middleware(RequestIDMiddleware) # --------------------------------------------------------------------------- # Exception handlers @@ -395,6 +415,20 @@ async def handle_validation_error(request: Request, exc: RequestValidationError) @app.exception_handler(StarletteHTTPException) async def handle_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse: + # 403 (forbidden) carries a pre-structured detail body from + # authorization dependencies — preserve code/message/request_id. + if exc.status_code == 403: + detail = exc.detail if isinstance(exc.detail, dict) else { + "error": { + "code": "FORBIDDEN", + "message": str(exc.detail), + "request_id": get_request_id(request), + } + } + if isinstance(detail, dict): + error_body = detail.get("error", detail) + error_body.setdefault("request_id", get_request_id(request)) + return JSONResponse(status_code=403, content={"error": error_body}) code = "NOT_FOUND" if exc.status_code == 404 else "METHOD_NOT_ALLOWED" return JSONResponse( status_code=exc.status_code, @@ -503,3 +537,4 @@ async def health_ready(): app.include_router(field_map_router, prefix=settings.API_PREFIX) app.include_router(intelligence_map_router, prefix=settings.API_PREFIX) app.include_router(stations_router, prefix=settings.API_PREFIX) +app.include_router(admin_router, prefix=settings.API_PREFIX) diff --git a/backend/app/schemas/audit.py b/backend/app/schemas/audit.py new file mode 100644 index 0000000..ff6b306 --- /dev/null +++ b/backend/app/schemas/audit.py @@ -0,0 +1,37 @@ +"""Admin/audit API schemas. + +The audit read API returns persistable, allowlisted audit fields only — +never JWTs, secrets, PII, or request/response bodies. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class AuditEventItem(BaseModel): + """A single audit event as persisted to ``audit_events``.""" + + event_id: str + event_timestamp: str | None = None + request_id: str + user_id: str | None = None + role: str | None = None + http_method: str + route: str + action: str + resource_type: str + resource_id: str | None = None + outcome: str + status_code: int + schema_version: int = 1 + + +class AuditEventPage(BaseModel): + """Paginated audit event listing.""" + + items: list[AuditEventItem] = Field(default_factory=list) + page: int = 1 + page_size: int = 50 + total: int = 0 + total_pages: int = 1 \ No newline at end of file diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 47c2f92..f30726e 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,8 +1,9 @@ """Authentication API schemas. Models representing authenticated user identity and API responses. -No authorization/role data is included — that remains BLOCKED_RBAC -until the police role/permission matrix is approved. +The user-facing ``MeResponse`` intentionally exposes no authorization +data — the frontend selects its operational role itself during login +(mock login) and never influences backend authorization. """ from __future__ import annotations @@ -14,7 +15,10 @@ class AuthenticatedIdentity(BaseModel): """Internal representation of an authenticated user. Built from verified JWT claims. Only verified claims are included. - Arbitrary user_metadata or app_metadata is NOT trusted. + Arbitrary user_metadata or app_metadata is NOT trusted — the + application role is resolved server-side via ``RBAC_DEFAULT_ROLE`` + or allowlisted role claims and is present here for authorization + dependencies. """ user_id: str = Field( @@ -41,6 +45,14 @@ class AuthenticatedIdentity(BaseModel): default=None, description="Token issued-at as Unix timestamp", ) + role: str | None = Field( + default=None, + description="Application role resolved server-side for authorization", + ) + permissions: frozenset[str] = Field( + default_factory=frozenset, + description="Permissions granted by the resolved role", + ) class MeResponse(BaseModel): diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py index 82b57b8..d79b8e6 100644 --- a/backend/app/services/audit_service.py +++ b/backend/app/services/audit_service.py @@ -16,6 +16,7 @@ from typing import Any from app.core.audit import AuditEvent +from app.core.exceptions import DependencyUnavailableError from app.database.repositories.protocols import AuditRepository logger = logging.getLogger("crime_analytics.audit") @@ -52,6 +53,7 @@ def write_audit_event(event: AuditEvent) -> None: "event_timestamp": event.event_timestamp, "request_id": event.request_id, "user_id": event.user_id, + "role": event.role, "http_method": event.http_method, "route": event.route, "action": event.action, @@ -76,3 +78,44 @@ def write_audit_event(event: AuditEvent) -> None: event.status_code, exc_info=True, ) + + +def query_audit_events( + filters: dict | None = None, + *, + page: int = 1, + page_size: int = 50, + max_page_size: int = 200, +) -> dict: + """Read audit events (read-only, permission-gated by the API layer). + + Returns a paginated dict:: + + { + "items": [ {event fields}, ... ], + "page": 1, + "page_size": 50, + "total": 1234, # matching filters (unpaginated) + "total_pages": 25, + } + + If the configured repository cannot serve reads (CSV/dev NoOp + adapter) this raises ``DependencyUnavailableError`` (HTTP 503) — + the API never fabricates an empty success page. + """ + if _repo is None: + raise DependencyUnavailableError("Audit repository is not initialized.") + + safe_page = max(page, 1) + safe_page_size = min(max(page_size, 1), max_page_size) + + rows, total = _repo.query(filters or {}, safe_page_size, (safe_page - 1) * safe_page_size) + + total_pages = max(1, (total + safe_page_size - 1) // safe_page_size) + return { + "items": rows, + "page": safe_page, + "page_size": safe_page_size, + "total": total, + "total_pages": total_pages, + } diff --git a/backend/docs/AUDIT_LOGGING.md b/backend/docs/AUDIT_LOGGING.md index 7f1f1dc..3e2c4da 100644 --- a/backend/docs/AUDIT_LOGGING.md +++ b/backend/docs/AUDIT_LOGGING.md @@ -13,26 +13,30 @@ ```text HTTP Request ↓ -RequestIDMiddleware → assigns correlation ID +CORSMiddleware → CORS preflight + response headers + ↓ +SecurityHeadersMiddleware → security/cache headers (all responses) + ↓ +RequestIDMiddleware → assigns correlation ID ↓ StructuredLoggingMiddleware → operational request logging ↓ -AuthenticationMiddleware → JWT verification, sets identity +RateLimitMiddleware → fixed-window limits (429 never audited) ↓ AuditMiddleware → classifies route, captures outcome ↓ -SecurityHeadersMiddleware → security headers - ↓ -CORSMiddleware → CORS headers +AuthenticationMiddleware → JWT verification, sets identity ↓ FastAPI Routes → application logic ↓ AuditMiddleware → writes audit event (after response) ``` -The audit middleware is placed inside AuthenticationMiddleware so that -the verified identity is available, and wraps the remaining inner -middleware/app so that the response status code is visible. +The audit middleware wraps AuthenticationMiddleware so that both granted +requests and denied (401/403) attempts are recorded. Identity is read from +`scope["state"]["authenticated_identity"]` after the inner middleware chain +completes — never trusted from the request itself. Rate-limited (429) requests +are rejected upstream by RateLimitMiddleware and never enter the audit trail. ### Layer diagram @@ -53,7 +57,8 @@ No-op adapter (database/repositories/csv/audit_repo.py) [development] ## 2. Database Schema -**Migration:** `supabase/migrations/002_audit_events.sql` +**Migrations:** `supabase/migrations/002_audit_events.sql`, +`supabase/migrations/004_audit_role.sql` (adds `role`) ```sql CREATE TABLE audit_events ( @@ -61,6 +66,7 @@ CREATE TABLE audit_events ( event_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), request_id TEXT NOT NULL, user_id TEXT, + role TEXT, -- resolved RBAC role (004_audit_role.sql) http_method TEXT NOT NULL, route TEXT NOT NULL, action TEXT NOT NULL, @@ -79,6 +85,7 @@ CREATE TABLE audit_events ( |-------|---------|---------| | `idx_audit_events_timestamp` | `event_timestamp` | Time-range queries | | `idx_audit_events_user_id` | `user_id` | Per-user audit lookups | +| `idx_audit_events_role` | `role` | Role-filtered audit queries (004) | | `idx_audit_events_request_id` | `request_id` | Correlation/tracing | | `idx_audit_events_action_resource` | `action, resource_type` | Action/resource filtering | | `idx_audit_events_outcome` | `outcome` | Failure/denial analysis | @@ -99,6 +106,7 @@ The backend's PostgreSQL service-role connection bypasses RLS for writes. | `event_timestamp` | TIMESTAMPTZ | yes | UTC timestamp when request/response completed | | `request_id` | TEXT | yes | Correlation ID from X-Request-ID header | | `user_id` | TEXT | no | Verified authenticated subject (JWT `sub`). NULL for anonymous. | +| `role` | TEXT | no | Resolved RBAC role (e.g. `FIELD_OFFICER`, `ADMIN`). See `docs/RBAC_AUTHORIZATION.md`. | | `http_method` | TEXT | yes | HTTP method (GET, POST, etc.) | | `route` | TEXT | yes | Actual request path (normalized for dynamic segments) | | `action` | TEXT | yes | Deterministic action classification | @@ -144,7 +152,7 @@ The backend's PostgreSQL service-role connection bypasses RLS for writes. | Outcome | HTTP Status | Meaning | |---------|-------------|---------| | `SUCCESS` | 2xx–4xx | Request completed (including 404, 422, etc.) | -| `DENIED` | 401 | Authentication/authorization rejection | +| `DENIED` | 401, 403 | Authentication/authorization rejection (403 = insufficient RBAC permission) | | `FAILURE` | 5xx | Server error | --- @@ -262,9 +270,11 @@ For entity detail, the safe entity identifier is stored in `resource_id`. ## 11. Append-Only / Tamper Resistance Application-level protections: -- The `AuditRepository` protocol defines only `append()` — no update/delete. -- The PostgreSQL implementation has only INSERT. -- No public audit mutation API exists (`GET /audit`, `DELETE /audit/*` are not implemented). +- The `AuditRepository` protocol defines `append()` (write) and `query()` + (read-only pagination). No update/delete operations exist. +- The PostgreSQL implementation has only INSERT + SELECT (no UPDATE/DELETE). +- The only public audit API is `GET /api/v1/admin/audit/events` + (requires `audit.read`, ADMIN-scoped). No `DELETE /audit/*` exists. - Audit events are `frozen` dataclass instances. **Not implemented (future concerns):** @@ -307,13 +317,15 @@ Government retention duration requirements have not been supplied. ## 14. Audit Read API -**Status: BLOCKED_RBAC** +**Status: IMPLEMENTED** — `GET /api/v1/admin/audit/events` -No audit log read/query endpoint is exposed. Viewing audit history -is authorization-sensitive and requires authoritative RBAC policies. +- Requires `audit.read` permission (ADMIN role by default); unauthenticated → 401, insufficient permission → 403 `FORBIDDEN`. +- Query params (exact-match allowlist): `user_id`, `role`, `action`, `resource_type`, `outcome`, plus `start_time`/`end_time` (ISO 8601) and `limit`/`offset` (max page size 200). +- Response: `{"items": [...], "pagination": {...}}` via `app/schemas/audit.py` — no sensitive fields. +- CSV/dev deployments (NoOp repository) return `503 DEPENDENCY_UNAVAILABLE` — never a fabricated empty success. +- Rate limited as the `audit_events` class (default 120/60s). -The backend may WRITE audit records now. Viewing is blocked until -the police role/permission matrix is approved. +No DELETE/mutation path exists — the log remains append-only. --- @@ -332,8 +344,8 @@ When `DATA_BACKEND=csv`: | Invariant | Enforced | |-----------|----------| | `DATA_BACKEND=postgres` required for durable audit | Documented; startup validation in config | -| Audit events append-only | Protocol + implementation | -| No audit read API without RBAC | Not implemented | +| Audit events append-only | Protocol + implementation (no UPDATE/DELETE paths) | +| Audit read API requires RBAC | `audit.read` dep on admin router | | No RLS bypass from browser | RLS enabled, no permissive policies | | Health probes excluded | `should_audit()` check | | No secrets in audit records | Field allowlisting in service | @@ -372,12 +384,16 @@ action, outcome) without including sensitive payloads. | File | Purpose | |------|---------| -| `app/core/audit.py` | AuditEvent model, classification taxonomy, AuditMiddleware | -| `app/services/audit_service.py` | AuditService with field allowlisting and persistence | -| `app/database/repositories/protocols.py` | AuditRepository protocol (append-only) | -| `app/database/postgres/audit_repo.py` | PostgreSQL audit repository | -| `app/database/repositories/csv/audit_repo.py` | No-op dev/test adapter | +| `app/core/audit.py` | AuditEvent model (+ `role`), classification taxonomy, AuditMiddleware, 403 → DENIED | +| `app/services/audit_service.py` | AuditService with field allowlisting, persistence, and `query_audit_events` | +| `app/database/repositories/protocols.py` | AuditRepository protocol (append + read-only query) | +| `app/database/postgres/audit_repo.py` | PostgreSQL audit repository (INSERT + parameterized query) | +| `app/database/repositories/csv/audit_repo.py` | No-op dev/test adapter (query → 503 DependencyUnavailableError) | +| `app/api/admin.py` | `GET /api/v1/admin/audit/events` router (requires `audit.read`) | +| `app/schemas/audit.py` | AuditEventItem / AuditEventPage response schemas | | `app/main.py` | Middleware registration + audit repo initialization | | `supabase/migrations/002_audit_events.sql` | Database schema | -| `tests/test_audit.py` | 71 comprehensive tests | +| `supabase/migrations/004_audit_role.sql` | Adds `role` column + `idx_audit_events_role` | +| `tests/test_audit.py` | 71 comprehensive tests (write path) | +| `tests/test_audit_api.py` | Audit read API tests (incl. 503 CSV-mode) | | `docs/AUDIT_LOGGING.md` | This document | diff --git a/backend/docs/AUTHENTICATION.md b/backend/docs/AUTHENTICATION.md index 72dca04..1bf689b 100644 --- a/backend/docs/AUTHENTICATION.md +++ b/backend/docs/AUTHENTICATION.md @@ -109,12 +109,20 @@ Authorization: Bearer ## Identity Model -The `AuthenticatedIdentity` contains only verified JWT claims: +The `AuthenticatedIdentity` contains only verified JWT claims plus a +server-resolved role: | Field | Source | Description | |-------|--------|-------------| | `user_id` | `sub` | Supabase Auth user UUID | | `email` | `email` | User email (if present in JWT) | +| `role` | `resolve_role(claims)` | Least-privilege role from claim paths (default `FIELD_OFFICER`) | +| `permissions` | `roles[role]` | Permission set used by route authorization deps | +| `district_ids` | claim path | Optional district-scoping data (not exposed in responses) | + +Role resolution reads configured claim paths (`app_metadata.role`, +`user_metadata.role`, `role`) and falls back to `RBAC_DEFAULT_ROLE` — +see `docs/RBAC_AUTHORIZATION.md`. **Never returned:** raw JWT token, refresh token, role claims, district assignments. @@ -130,14 +138,17 @@ All API responses include: | `Cache-Control` | `max-age=10` (for `/health/*`) | | `X-Request-ID` | UUID per request | -CORS is configured with explicit `Authorization` header support. +CORS is configured with explicit `Authorization` header support. A fixed-window +rate limiter runs inside the ASGI stack (see `docs/RBAC_AUTHORIZATION.md` → +Rate limiting): configurable per-route-class limits, `429` + `Retry-After` on +exceed, health/docs exempt. ## Development Mode When `REQUIRE_AUTH=false`, the middleware: 1. Passes all requests through without verification -2. Attaches a dev identity: `user_id: "dev-user-000"` +2. Attaches a dev identity: `user_id: "dev-user-000"`, role `ADMIN` (all permissions) 3. Logs a warning at startup **Never enable `REQUIRE_AUTH=false` in production.** @@ -149,14 +160,21 @@ When `REQUIRE_AUTH=false`, the middleware: | JWT verification engine | Backend (`app/core/jwt_auth.py`) | | Identity model | Backend (`app/schemas/auth.py`) | | Auth middleware | Backend (`app/main.py`) | +| Role/permission resolution | Backend (`app/core/rbac.py`, `app/api/auth_deps.py`) | +| Route authorization | Backend (`app/api/rbac_deps.py` — `ask permission deps`) | +| Audit logging of auth events | Backend (`app/core/audit.py`) | | Session/token issuance | **Supabase Auth** (frontend responsibility) | | Login/logout flows | **Frontend** (Supabase Auth UI SDK) | | User/role management | **Supabase Dashboard** | -| Row Level Security policies | **BLOCKED** — pending role/permission matrix | -| Police role assignment | **BLOCKED** — no role model supplied | - -## Blocked Items - -- **RLS/RBAC:** Police role/permission model definitions not yet supplied -- **Rate limiting:** Deployment-dependent; not safe as in-memory limiter -- **Login/logout endpoints:** Not required — Supabase Auth handles directly from frontend +| Row Level Security policies | Supabase (`supabase/migrations/005_rls.sql`) | + +## Rate Limiting + +- In-process fixed-window (`app/core/rate_limit.py`, module-level + `_default_limiter`), keyed by route template + client IP + (`X-Forwarded-For` first hop when present). +- Route classes and defaults: generals 300/60s, `crime_data` export + 10/3600s, `search` 60/60s, `audit_events` 120/60s. +- **Single-instance scope**: add a shared store or API-gateway limit + before running multiple replicas. +- In production `RATE_LIMIT_ENABLED=true` is enforced by config. diff --git a/backend/docs/FINAL_PHASE_REPORT.md b/backend/docs/FINAL_PHASE_REPORT.md new file mode 100644 index 0000000..0a57bd1 --- /dev/null +++ b/backend/docs/FINAL_PHASE_REPORT.md @@ -0,0 +1,56 @@ +# Finalize: Phase Report — `feature-final-backend` + +Branch: `feature-final-backend` (work UNCOMMITTED until final review) +Test suite: **734 passed** (`backend/` → `python -m pytest tests -q`) + +## Phase status (this iteration's evidence) + +| # | Phase | Status | Evidence | +|---|-------|--------|----------| +| 1 | CSV data layer rebuild | ✅ | `backend/app/database/repositories/csv/*`, `tests/test_csv_loader.py` | +| 2 | Schema + migrations | ✅ | `supabase/migrations/001/002/003_indexes.sql` | +| 3 | Live Supabase migration | ⚠️ PARTIAL | Migrations 002/003/004 applied + verified live (this session); full endpoint re-verification `LIVE_VERIFICATION_BLOCKED_BY_NETWORK` | +| 4 | RBAC model | ✅ | `app/core/rbac.py`, settings `RBAC_*`, `tests/test_rbac.py` (36) | +| 5 | Endpoint authorization | ✅ | `app/api/rbac_deps.py`, permission deps on all routers | +| 6 | Row Level Security | ✅ | `supabase/migrations/005_rls.sql` | +| 7 | PII audit | ✅ | Services expose pseudo-identifiers only; export has no PII | +| 8 | Audit read API | ✅ | `app/api/admin.py`, `schemas/audit.py`, `tests/test_audit_api.py` (11) | +| 9 | Rate limiting | ✅ | `app/core/rate_limit.py`, settings, `tests/test_rate_limit.py` (9) | +| 10 | SQL injection scan | ✅ | All Postgres SQL parameterized | +| 11 | Security headers/CORS | ✅ | Preserved (verified earlier) | +| 12 | Live PG EXPLAIN/repo tests | ⚠️ LIVE_VERIFICATION_BLOCKED_BY_NETWORK | IPv6 route drops packets; not a code failure | +| 13 | Error body/central errors | ✅ | `handle_http_exception` 403 → structured `FORBIDDEN` | +| 14 | CI | ✅ | `.github/workflows/backend-ci.yml` | +| 15 | Documentation | ✅ | `RBAC_AUTHORIZATION.md`, `AUTHENTICATION.md`, `AUDIT_LOGGING.md`, `PRODUCTION_DATABASE.md`, root `README` updated | +| 16 | ML engine audit | ✅ | `backend/docs/ML_INTEGRATION.md` — contract documented, no fabricated endpoints | +| 17 | Zoho Catalyst deploy | ✅ | `Procfile`, deploy/env-var guide in `PRODUCTION_DATABASE.md` | +| 18/19 | Final QA | ✅ | 734/734 pass; coverage added for all new areas | + +## What shipped this session + +- **RBAC**: roles `ADMIN`/`FIELD_OFFICER`/`ANALYST`/`SUPERVISOR`/`INTELLIGENCE_OFFICER` + + permissions; server-side claim resolution with least-privilege default; + `require_permission/require_any_permission/require_role` factory deps + (FastAPI `Depends` kwargs rejected at collection time → factory pattern). +- **Audit**: `role` column + migration `004`; 403 → `DENIED`; admin read + endpoint with allowlisted filters; NoOp repo → 503. +- **Rate limiting**: fixed-window in-process; route-class defaults; tests. +- **RLS**: migration `005` (selective `authenticated` reads, deny-by-default). +- **ML**: documented integration contract + recommendations, no code paths. +- **Deploy**: `Procfile` (`web: cd backend && uvicorn app.main:app --host 0.0.0.0 --port $PORT`). + +## Guardrails honored + +- `/auth/me` response unchanged (`{user_id, authenticated, email}`). +- No PII added to responses/audit/export. +- No dummy ML endpoints. +- Missing/live items reported honestly, never fabricated. + +## Verification + +- Full suite: 734 passed, 29s, CSV backend (per directive). +- Production guard: CI fails if `ENVIRONMENT=production` without `REQUIRE_AUTH=true`. +- Live DB: migrations 002/003/004 applied and verified this session; + endpoint-level live checks + Catalyst deploy are + **LIVE_VERIFICATION_BLOCKED_BY_NETWORK** (unreliable IPv6 route — not a + code failure). \ No newline at end of file diff --git a/backend/docs/ML_INTEGRATION.md b/backend/docs/ML_INTEGRATION.md new file mode 100644 index 0000000..706151d --- /dev/null +++ b/backend/docs/ML_INTEGRATION.md @@ -0,0 +1,81 @@ +# ML Engine Integration Contract + +Status of this document: **reference / recommendation**. This describes +the machine-learning artifacts produced by `ml-engine/` and how they may +safely integrate with the backend. It does **not** claim the backend +currently exposes ML endpoints — it does not. + +## Current state (verified during audit) + +- The backend (`backend/`) has **no ML integration**. No joblib loading, + no pandas/scikit-learn dependency, no ML endpoints. +- The backend's `map/intelligence/*` endpoints (`clusters`, `hotspots`, + `analytics`, `timeline`, `district-comparison`) are **deterministic + aggregations** over the data layer, explicitly documented as "not ML + clustering" in the route descriptions. +- The frontend is fully mock. The ML layer's only consumer path today is + a static JSON export (below), which is not wired to anything. + +## What the ML engine produces + +`ml-engine/` is a self-contained pipeline (pandas / scikit-learn / +joblib). It trains and saves: + +| Artifact | Producer | Contents (contract) | +|----------|----------|----------------------| +| `models/dbscan_hotspots.joblib` | `train_dbscan.py` | dict: `cluster_centroids` (cluster_id → (lat, lon)), `eps_km`, `min_samples`, `n_clusters` | +| `models/crime_risk_model.joblib` | `train_risk_model.py` | dict: model metadata + risk data (station-level CCRI) | +| `models/crime_forecasting_model.joblib` | `train_forecasting.py` | dict: `model_name`, `evaluation_metrics` (`MAE`, `RMSE`, `R2_Score`), fitted model | +| `outputs/hotspots.csv` | DBSCAN pipeline | FIR rows with cluster assignments | +| `outputs/hotspot_summaries.csv` | DBSCAN pipeline | `Cluster_ID`, `Primary_Crime_Head`, `Primary_District`, `Total_Crimes`, `Total_Severity_Score` | +| `outputs/station_risk_scores.csv` | CCRI pipeline | `Station_ID`, `Station_Name`, `District`, `Zone`, `Risk_Score`, `Risk_Tier`, `FIR_Count`, `Severity_Load`, `Hotspot_Count`, `Personnel_Strength`, `Patrol_Vehicles`, `Risk_Rank`, `z_*` factors | +| `outputs/crime_forecasts.csv` | forecasting pipeline | `Date`, `Day_of_Week`, `Forecasted_Crime_Count` (30 days) | +| `outputs/model_evaluation_report.md` | forecasting pipeline | MAE / RMSE / R² report | + +### CLI inference (`scripts/predict.py`) + +A self-contained inference CLI that reads the joblib + output CSVs: +`--station PS0069`, `--lat/--lon`, `--forecast_days N`, `--summary`. + +### Frontend export (`scripts/export_ml_to_frontend.py`) + +Exports `outputs/*.csv` → JSON under `frontend/public/data/` +(`hotspot_summaries.json`, `hotspots.json`, `station_risk_scores.json`, +`crime_forecasts.json`, `dashboard_kpis.json`). No such export directory +currently exists in the repo, so nothing consumes it yet. + +## Integration options (recommendation, not implemented) + +The mission rule is: **never fabricate ML outputs**. Options below are +recommendations only. + +1. **Keep the current split (recommended for this deliverable).** The + deterministic backend endpoints are correct and testable. The ML + outputs remain static artifacts. No integration risk. +2. **Expose outputs via a read-only service.** Add a + `CrimeInsightsService` that loads the output CSVs (they are small and + static) and serve them behind explicit permissions + (`analytics.read` / `system.configuration.read`). The joblib files + stay out of the API process to avoid bringing in heavy ML deps. +3. **Serve forecasts/risk from Postgres.** Promote the outputs into + tables (e.g. `station_risk_scores`, `crime_forecasts`) via the + ingestion pipeline and serve through normal repositories — full + audit + RBAC + RLS coverage, at the cost of a new migration. + +### Guardrails for any future integration + +- Never load `*.joblib` into the API process (untrusted/versioned + binary deserialization risk; heavy dependency footprint). +- Route-level permission deps must be applied (existing pattern). +- Output rows containing station names are operational data; never + export person-level PII from `people.csv` or FIR person IDs. +- Keep ML outputs versioned and rebuilt offline; API should degrade + gracefully (404/503, documented) when artifacts are absent — never + silently substitute deterministic data and label it "model output". + +## Verification status + +- Backend ML endpoints: **none exist** (by design). +- No `frontend/public/data/` exports present in this checkout. +- ML scripts depend on `joblib`, `pandas`, `scikit-learn`, `xgboost`, + which are **not** part of the backend runtime requirements. \ No newline at end of file diff --git a/backend/docs/PRODUCTION_DATABASE.md b/backend/docs/PRODUCTION_DATABASE.md index 8bba71e..f612abe 100644 --- a/backend/docs/PRODUCTION_DATABASE.md +++ b/backend/docs/PRODUCTION_DATABASE.md @@ -188,7 +188,8 @@ The application validates configuration on startup via Pydantic `model_validator ## Testing -449 tests passing across the full test suite, including: +734 tests passing across the full test suite (CSV-mode, no live DB), +including: - Migration SQL schema correctness (18 tests) - Repository protocol compliance (12 tests — CSV and Postgres) - Cardinality preservation (6 tests) @@ -199,6 +200,9 @@ The application validates configuration on startup via Pydantic `model_validator - Connection management (3 tests) - App integration and file structure (8 tests) - Dashboard, field map, intelligence map, district, and health API tests +- RBAC authorization model (36 tests — `tests/test_rbac.py`) +- Audit read API (11 tests — `tests/test_audit_api.py`) +- Rate limiting (9 tests — `tests/test_rate_limit.py`) ## Production Activation Status @@ -232,18 +236,75 @@ The ingestion uses `psycopg2.extras.execute_values` for batch UPSERTs (500 rows/ ## Security Considerations -- **PII**: The `people` table contains personally identifiable information. Minimize in API responses. +- **PII**: The `people` table contains personally identifiable information. API services expose only operational/non-identifying fields (see the Phase 7 PII audit). - **Credentials**: `DATABASE_URL` must never be hardcoded or exposed to the frontend. -- **RLS**: Enabled on all tables with no policies = effective deny-by-default. Backend connects as `postgres` role, bypasses RLS. -- **Audit**: All ingestion batches are recorded with timestamps and counts. +- **RLS**: Enabled on all tables. See `supabase/migrations/005_rls.sql`: `districts`+`police_stations` are readable by `authenticated`; all PII-bearing and operational tables are deny-by-default. Backend connects as a privileged role and bypasses RLS — its access is governed by RBAC permissions (see `docs/RBAC_AUTHORIZATION.md`) plus audit logging. +- **Audit**: `audit_events` is append-only and deny-by-default. Admin read API at `GET /api/v1/admin/audit/events` (requires `audit.read`); CSV/dev deployments return 503 for it. +- **RBAC**: Role resolution is server-side from verified JWT claims; default least-privilege is `FIELD_OFFICER`. +- **Rate limiting**: In-process fixed-window limiter per client + route class; 429 with `Retry-After`. Single-instance scope — use an API gateway or distributed store when scaling to multiple replicas. + +## Deployment (Zoho Catalyst) + +The backend is packaged for Catalyst as a standard Procfile app (`web` +process). Catalyst injects `$PORT`; the web command changes into +`backend/` and boots uvicorn. + +``` +# Procfile (repository root) +web: cd backend && uvicorn app.main:app --host 0.0.0.0 --port $PORT +``` + +### Required environment variables + +Set these in the Catalyst service environment (never commit secrets): + +| Variable | Value | +|----------|-------| +| `ENVIRONMENT` | `production` (disables `/docs`, `/openapi.json`; enables production guard) | +| `DATA_BACKEND` | `postgres` | +| `DATABASE_URL` | Supabase pooled connection string (workspace secret) | +| `DATABASE_POOL_MIN` / `DATABASE_POOL_MAX` | tier-appropriate pool bounds | +| `JWT_`* `SUPABASE_`* | token verification: `SUPABASE_JWT_SECRET`, `SUPABASE_JWKS_URL`, `SUPABASE_JWT_ISSUER`, `SUPABASE_JWT_AUDIENCE`, `JWKS_CACHE_TTL` | +| `RBAC_ENABLED` | `true` (default role `RBAC_DEFAULT_ROLE=FIELD_OFFICER`) | +| `CORS_ORIGINS` | Catalyst public URL(s) | +| `RATE_LIMIT_*` | defaults are in-process; raise `RATE_LIMIT_*` limits or add a gateway before scaling multi-replica | + +### Efficient defaults for production + +- `ENVIRONMENT=production` must be set or the app refuses to start with + `REQUIRE_AUTH` unset. +- Keep `DATA_BACKEND=postgres`; CSV mode is for local development and + will 503 the audit read API (`DEPENDENCY_UNAVAILABLE`). +- Migrations (`supabase/migrations/*.sql`) must be applied to the target + DB before first boot; the app validates schema at startup. + +### Verification status note + +- **Migrations 002 / 003 / 004 are applied and verified live** (this + session): `audit_events` (with `role` column), `idx_firs_incident_date_date` + (expression index on `(incident_date AT TIME ZONE 'UTC')::date`) and + `idx_audit_events_role` exist in the database. RLS is enabled on all 8 + tables. Migration 003 was corrected so the date-range expression index is + IMMUTABLE (TIMESTAMPTZ `::date` alone is rejected by PostgreSQL); the + Postgres FIR repository predicates were aligned to the UTC expression. +- The `.env` in `backend/` is configured (gitignored). `DATABASE_URL` + verifies and connections are established. +- Endpoint-level live re-verification against Supabase is marked + **LIVE_VERIFICATION_BLOCKED_BY_NETWORK**: the current network path to the + IPv6-only Supabase PostgreSQL endpoint drops packets, making live + requests stall (13–92s) and intermittently disconnect. This is an + environment limitation, not a code failure. The local automated suite + (734 tests) runs against the CSV backend and passes. End-to-end live + checks (Phase 3, 12; Catalyst deploy) remain to be re-run from a stable + network. ## Next Steps 1. ~~Apply migration to Supabase PostgreSQL instance~~ ✅ 2. ~~Run ingestion pipeline~~ ✅ 3. ~~Verify data integrity~~ ✅ -4. Implement RLS policies (BLOCKED — pending role definitions) -5. Add PII classification labels +4. ~~Add RLS policies~~ ✅ (`005_rls.sql`; selective reads + deny-by-default) +5. Add PII classification labels (run "Phase 7 PII audit" checklist on new endpoints) 6. ~~Production health check integration~~ ✅ -7. Add pagination to list endpoints for large result sets -8. Add rate limiting for API protection +7. ~~Add pagination to list endpoints~~ ✅ (field cases, stations, audit events) +8. ~~Add rate limiting for API protection~~ ✅ (in-process; see notes above) diff --git a/backend/docs/RBAC_AUTHORIZATION.md b/backend/docs/RBAC_AUTHORIZATION.md new file mode 100644 index 0000000..90d0e04 --- /dev/null +++ b/backend/docs/RBAC_AUTHORIZATION.md @@ -0,0 +1,136 @@ +# Role-Based Access Control & Authorization + +This document describes the authorization model enforced by the +backend: roles, permissions, role resolution, RLS scope, and the +endpoint → permission mapping. + +Authorization is **server-side only**. The frontend (mock login) lets +users pick an operational role and stores it in `localStorage`, but the +backend never trusts it. The role is resolved from the **verified** +Supabase Auth JWT. + +## Roles + +| Role | Meaning | +|------|---------| +| `FIELD_OFFICER` | Operational, field-tier read access. | +| `ANALYST` | Adds intelligence-map, analytics, and network analysis access. | +| `ADMIN` | Full access, including audit log read. | + +These roles are the application's current authorization model and do +not claim to represent an official Karnataka Police permission policy. + +## Permissions + +Permissions are coarse `area.action` strings. Endpoints declare the +permission they require via `Depends(require_permission("..."))`; role +sets are checked against the declared permission. Revising the role → +permission map in `app/core/rbac.py` re-scopes every endpoint without +touching route code. + +The complete catalog: + +``` +dashboard.read cases.read cases.export map.field.read map.intelligence.read +districts.read stations.read analytics.read network.read +network.person.read network.person.search network.person.detail +audit.read users.read users.manage roles.read roles.manage +system.configuration.read system.configuration.manage +``` + +(`users.*` and `system.configuration.*` are reserved for future +endpoints and are granted to `ADMIN` only.) + +### Role → permission map + +- **FIELD_OFFICER**: `dashboard.read`, `cases.read`, `cases.export`, + `map.field.read`, `districts.read`, `stations.read` +- **ANALYST**: FIELD_OFFICER + `map.intelligence.read`, `analytics.read`, + `network.read`, `network.person.read`, `network.person.search`, + `network.person.detail` +- **ADMIN**: every permission + +## Role resolution (from the verified JWT) + +The JWT is verified as before (signature, issuer, audience, expiry). +Claims are then inspected for a role via configurable dotted paths +(`RBAC_ROLE_CLAIM_PATHS`, default order): + +1. `app_metadata.role` (Supabase convention for privileged metadata) +2. `user_metadata.role` +3. `role` (top-level claim) + +The raw value is normalized (`upper`, `-`/spaces → `_`) and accepted +only if it is in the allowlist `{FIELD_OFFICER, ANALYST, ADMIN}`. If no +allowlisted role claim exists, the identity receives +`RBAC_DEFAULT_ROLE` (**FIELD_OFFICER** — least privilege). This default +is deliberate: an authenticated Supabase user with no custom role +metadata still gets the lowest application tier. Administrative tiers +require an explicit allowlisted claim. + +The resolved role and its permission set are attached to the +authenticated identity for every request and used by the audit log +(`audit_events.role`). + +## HTTP semantics + +| Condition | Status | +|-----------|--------| +| No token / invalid token / expired | 401 (`TOKEN_*` / `AUTHENTICATION_FAILED`) | +| Valid token, permission missing | 403 `FORBIDDEN` | +| Valid token, permission granted | 200 (normal response) | +| RBAC disabled (`RBAC_ENABLED=false`) | All authenticated requests allowed | +| Auth disabled (`REQUIRE_AUTH=false`, dev) | Dev identity = `ADMIN`, full access | + +## Endpoint → permission mapping + +| Endpoint | Permission | +|----------|-----------| +| `GET /api/v1/dashboard/summary` | `dashboard.read` | +| `GET /api/v1/map/field/cases` | `cases.read` | +| `GET /api/v1/map/field/case/{id}` | `cases.read` | +| `GET /api/v1/map/field/filters` | `map.field.read` | +| `GET /api/v1/map/field/hotspots` | `map.field.read` | +| `GET /api/v1/map/intelligence/analytics` | `map.intelligence.read` | +| `GET /api/v1/map/intelligence/heatmap` | `map.intelligence.read` | +| `GET /api/v1/map/intelligence/clusters` | `map.intelligence.read` | +| `GET /api/v1/map/intelligence/hotspots` | `map.intelligence.read` | +| `GET /api/v1/map/intelligence/district-comparison` | `map.intelligence.read` | +| `GET /api/v1/map/intelligence/timeline` | `map.intelligence.read` | +| `GET /api/v1/map/intelligence/export` | `cases.export` | +| `GET /api/v1/districts` | `districts.read` | +| `GET /api/v1/districts/{id}/intelligence` | `districts.read` | +| `GET /api/v1/stations` | `stations.read` | +| `GET /api/v1/stations/{id}` | `stations.read` | +| `GET /api/v1/network/graph` | `network.read` | +| `GET /api/v1/network/entities/{type}/{id}` | `network.read` OR `network.person.read` | +| `GET /api/v1/network/search` | `network.read` | +| `GET /api/v1/admin/audit/events` | `audit.read` | +| `GET /api/v1/auth/me` | authenticated only (no permission) | + +## Row-level security (RLS) + +RLS governs the Supabase Data API (`anon`/`authenticated` roles) — a +control independent from the backend API. The backend's privileged +PostgreSQL connection **bypasses RLS by design**; all backend access is +already permission-checked and audit-logged. + +Current RLS posture (migration `005_rls.sql`): + +- `districts`, `police_stations` — `SELECT` for `authenticated` only. +- `people`, `firs`, `fir_person_roles`, `arrests`, `chargesheets`, + `ingestion_batches`, `audit_events` — deny-by-default (no policies). + +## Headers & configuration + +| Setting | Default | Purpose | +|---------|---------|---------| +| `RBAC_ENABLED` | `true` | Master switch for permission checks | +| `RBAC_DEFAULT_ROLE` | `FIELD_OFFICER` | Least-privilege default role | +| `RBAC_ROLE_CLAIM_PATHS` | `app_metadata.role,user_metadata.role,role` | Claim lookup order | +| `RATE_LIMIT_ENABLED` | `true` | Master switch for rate limiting | +| `RATE_LIMIT_DEFAULT_LIMIT` / `_WINDOW` | `300 / 60` | Default allowance | +| `RATE_LIMIT_EXPORT_LIMIT` / `_WINDOW` | `10 / 3600` | Export budget | +| `RATE_LIMIT_SEARCH_LIMIT` / `_WINDOW` | `60 / 60` | Search budget | +| `RATE_LIMIT_AUDIT_LIMIT` / `_WINDOW` | `120 / 60` | Audit read budget | +| `RATE_LIMIT_CLIENT_HEADER` | `X-Forwarded-For` | Proxy client identifier | \ No newline at end of file diff --git a/backend/supabase/migrations/003_indexes.sql b/backend/supabase/migrations/003_indexes.sql new file mode 100644 index 0000000..4e9d2da --- /dev/null +++ b/backend/supabase/migrations/003_indexes.sql @@ -0,0 +1,35 @@ +-- Production index addition for Karnataka Police Crime Analytics +-- Migration: 003_indexes +-- Target: Supabase PostgreSQL +-- +-- Purpose: Add a single justified index for an actual production query. +-- +-- Justification +-- ------------- +-- PostgresFIRRepository.list_by_incident_date_range and list_filtered +-- filter FIRs with date predicates: +-- +-- (f.incident_date AT TIME ZONE 'UTC')::date >= %s +-- (f.incident_date AT TIME ZONE 'UTC')::date <= %s +-- +-- `firs.incident_date` is TIMESTAMPTZ. Its plain `::date` cast depends on +-- the session timezone (STABLE, not IMMUTABLE) and is therefore rejected +-- inside an index expression; it is also non-sargable for the plain B-tree +-- index on incident_date. Casting to UTC first yields an IMMUTABLE +-- expression `(incident_date AT TIME ZONE 'UTC')::date` that both matches +-- the repository predicate and supports an index range scan. +-- +-- This is the only new index introduced: every other WHERE / JOIN / +-- ORDER BY / lookup column used by the PostgreSQL repositories is +-- already covered by indexes created in migration 001. + +-- ===================================================================== +-- firs: expression index for date-range queries +-- ===================================================================== + +CREATE INDEX IF NOT EXISTS idx_firs_incident_date_date + ON firs ((CAST(incident_date AT TIME ZONE 'UTC' AS date))); + +COMMENT ON INDEX idx_firs_incident_date_date IS + 'Supports (incident_date AT TIME ZONE ''UTC'')::date range predicates ' + 'used by list_by_incident_date_range and list_filtered'; diff --git a/backend/supabase/migrations/004_audit_role.sql b/backend/supabase/migrations/004_audit_role.sql new file mode 100644 index 0000000..7810c1e --- /dev/null +++ b/backend/supabase/migrations/004_audit_role.sql @@ -0,0 +1,30 @@ +-- Production audit logging schema — add identity role +-- Migration: 004_audit_role +-- Target: Supabase PostgreSQL +-- +-- Purpose: Add the resolved application role (server-side RBAC) to the +-- audit trail so security forensic queries can answer "who (role) +-- accessed what". The role is resolved by the backend from the verified +-- Supabase Auth JWT claim paths; it is never supplied by the client. +-- +-- Non-destructive: additive column only. Existing rows keep NULL role, +-- matching identities authenticated before RBAC role resolution shipped. +-- +-- RLS: remains deny-by-default (see 002_audit_events.sql). This column +-- inherits the same protections. + +-- ===================================================================== +-- Audit events — add role +-- ===================================================================== + +ALTER TABLE audit_events + ADD COLUMN IF NOT EXISTS role TEXT; + +COMMENT ON COLUMN audit_events.role IS + 'Application role resolved server-side for the authenticated subject ' + '(FIELD_OFFICER, ANALYST, ADMIN). NULL for anonymous requests or ' + 'pre-RBAC events.'; + +-- Supporting index for role-based audit queries +CREATE INDEX IF NOT EXISTS idx_audit_events_role + ON audit_events (role); \ No newline at end of file diff --git a/backend/supabase/migrations/005_rls.sql b/backend/supabase/migrations/005_rls.sql new file mode 100644 index 0000000..1f1de37 --- /dev/null +++ b/backend/supabase/migrations/005_rls.sql @@ -0,0 +1,86 @@ +-- Production row-level security (RLS) for the crime analytics schema +-- Migration: 005_rls +-- Target: Supabase PostgreSQL +-- +-- Purpose: Govern direct data access through the Supabase Data API +-- (the ``anon`` / ``authenticated`` roles issued by Supabase Auth). +-- +-- IMPORTANT SCOPE: The backend does NOT rely on RLS. The backend's +-- PostgreSQL connection is a privileged service connection that +-- bypasses RLS by design (a database role with table-level privileges, +-- not a Supabase JWT role). RLS is the second, independent control +-- that protects the database if the data API or client SDKs are ever +-- exposed directly. +-- +-- Policy decisions (current, revisable): +-- * districts, police_stations — reference/reporting tables: +-- SELECT allowed for any authenticated user; no INSERT/UPDATE/DELETE +-- from the data API. +-- * people, firs, fir_person_roles, arrests, chargesheets, +-- ingestion_batches — operational + PII-bearing tables: +-- deny-by-default (no permissive policies). All access runs +-- through the audit-logged backend API. +-- * audit_events — deny-by-default since migration 002. +-- +-- This migration is additive and non-destructive. + +-- ===================================================================== +-- Reference tables: authenticated users may read +-- ===================================================================== + +ALTER TABLE districts ENABLE ROW LEVEL SECURITY; +ALTER TABLE police_stations ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "authenticated_can_select_districts" ON districts; +CREATE POLICY "authenticated_can_select_districts" + ON districts + FOR SELECT + TO authenticated + USING (true); + +DROP POLICY IF EXISTS "authenticated_can_select_stations" ON police_stations; +CREATE POLICY "authenticated_can_select_stations" + ON police_stations + FOR SELECT + TO authenticated + USING (true); + +-- No INSERT/UPDATE/DELETE policies → the data API cannot write reference data. + +-- ===================================================================== +-- Operational + PII-bearing tables: deny-by-default +-- ===================================================================== +-- RLS is enabled with NO permissive policies. Direct reads/writes via +-- the Supabase Data API (anon/authenticated roles) are denied, because +-- these tables carry PII (names, identifiers, dates of offense) that must +-- only be served through the audit-logged, permission-checked backend API. + +ALTER TABLE people ENABLE ROW LEVEL SECURITY; +ALTER TABLE firs ENABLE ROW LEVEL SECURITY; +ALTER TABLE fir_person_roles ENABLE ROW LEVEL SECURITY; +ALTER TABLE arrests ENABLE ROW LEVEL SECURITY; +ALTER TABLE chargesheets ENABLE ROW LEVEL SECURITY; +ALTER TABLE ingestion_batches ENABLE ROW LEVEL SECURITY; + +-- No policies are created for these tables: default behavior denies all +-- direct access. The backend's privileged connection is unaffected. + +-- ===================================================================== +-- Audit events (deny-by-default, enforced since 002) +-- ===================================================================== +-- Reasserted here for completeness: audit_events remains deny-by-default +-- with no permissive policies. + +ALTER TABLE audit_events ENABLE ROW LEVEL SECURITY; + +-- ===================================================================== +-- Verification helpers +-- ===================================================================== +-- Run after migrating: +-- SELECT relname, relrowsecurity +-- FROM pg_class +-- WHERE relname IN ('districts','police_stations','people','firs', +-- 'fir_person_roles','arrests','chargesheets', +-- 'ingestion_batches','audit_events') +-- ORDER BY relname; +-- Every table should report rls enabled (relrowsecurity = true). \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ddb3d2b..f29d2c1 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -28,6 +28,10 @@ from app.core.config import settings as _settings _settings.REQUIRE_AUTH = False +_settings.RATE_LIMIT_ENABLED = False +# Backend development .env may point at the live PostgreSQL instance; +# the deterministic test suite stays CSV-backed (see get_csv_repositories). +_settings.DATA_BACKEND = "csv" # --------------------------------------------------------------------------- diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 95538ae..3c9e922 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -65,6 +65,23 @@ def append(self, event: dict) -> None: raise RuntimeError("Simulated audit write failure") self.events.append(event) + def query(self, filters: dict, limit: int, offset: int) -> tuple[list[dict], int]: + """In-memory read with the same filter semantics as the Postgres repo.""" + matches = self.events + for key, value in filters.items(): + if value is None or value == "": + continue + if key in ("start_time",): + op = lambda v: v.get("event_timestamp") is not None and str(v.get("event_timestamp")) >= value + matches = [e for e in matches if op(e)] + elif key in ("end_time",): + op = lambda v: v.get("event_timestamp") is not None and str(v.get("event_timestamp")) <= value + matches = [e for e in matches if op(e)] + else: + matches = [e for e in matches if e.get(key) == value] + ordered = sorted(matches, key=lambda e: str(e.get("event_timestamp", "")), reverse=True) + return ordered[offset: offset + limit], len(matches) + # --------------------------------------------------------------------------- # 1. Event construction @@ -410,7 +427,7 @@ def test_write_audit_event_field_allowlisting(self): write_audit_event(event) stored = self.repo.events[0] expected_keys = { - "event_id", "event_timestamp", "request_id", "user_id", + "event_id", "event_timestamp", "request_id", "user_id", "role", "http_method", "route", "action", "resource_type", "resource_id", "outcome", "status_code", "schema_version", } @@ -626,6 +643,7 @@ def test_all_expected_routes_have_classifications(self): "/api/v1/network/entities/{entity_type}/{entity_id}", "/api/v1/network/search", "/api/v1/auth/me", + "/api/v1/admin/audit/events", } assert expected_routes == set(_ROUTE_CLASSIFICATIONS.keys()) diff --git a/backend/tests/test_audit_api.py b/backend/tests/test_audit_api.py new file mode 100644 index 0000000..bb83b2c --- /dev/null +++ b/backend/tests/test_audit_api.py @@ -0,0 +1,294 @@ +"""Audit read API tests. + +Covers the admin audit event listing endpoint: authentication, +authorization (audit.read required), 503 behavior when audit storage is +unavailable (CSV/dev backend), and the paginated response shape. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.main import app +from app.core.exceptions import DependencyUnavailableError +from app.services import audit_service +from tests.conftest import ( + TEST_AUDIENCE, + TEST_ISSUER, + TEST_JWT_SECRET, + create_test_jwt, +) + +from tests.test_audit import InMemoryAuditRepository + + +def _setup_auth(require_auth: bool = True) -> None: + settings.REQUIRE_AUTH = require_auth + settings.SUPABASE_JWT_SECRET = TEST_JWT_SECRET + settings.SUPABASE_JWKS_URL = "" + settings.SUPABASE_JWT_ISSUER = TEST_ISSUER + settings.SUPABASE_JWT_AUDIENCE = TEST_AUDIENCE + + from app.core.jwt_auth import init_verifier + + init_verifier( + jwt_secret=TEST_JWT_SECRET, + jwks_url="", + issuer=TEST_ISSUER, + audience=TEST_AUDIENCE, + ) + + +@pytest.fixture +def admin_client(tmp_path, monkeypatch): + """Client with auth enabled + a working in-memory audit repository.""" + orig_repo = audit_service._repo + orig_require_auth = settings.REQUIRE_AUTH + orig_secret = settings.SUPABASE_JWT_SECRET + orig_jwks = settings.SUPABASE_JWKS_URL + orig_issuer = settings.SUPABASE_JWT_ISSUER + orig_audience = settings.SUPABASE_JWT_AUDIENCE + orig_rbac = settings.RBAC_ENABLED + + repo = InMemoryAuditRepository() + repo.append({ + "event_id": "evt-1", + "event_timestamp": "2025-01-01T10:00:00+00:00", + "request_id": "req-1", + "user_id": "user-1", + "role": "ANALYST", + "http_method": "GET", + "route": "/api/v1/map/intelligence/analytics", + "action": "READ", + "resource_type": "crime_map", + "resource_id": None, + "outcome": "SUCCESS", + "status_code": 200, + "schema_version": 1, + }) + repo.append({ + "event_id": "evt-2", + "event_timestamp": "2025-01-02T11:00:00+00:00", + "request_id": "req-2", + "user_id": "user-2", + "role": "FIELD_OFFICER", + "http_method": "GET", + "route": "/api/v1/network/graph", + "action": "READ", + "resource_type": "network_graph", + "resource_id": None, + "outcome": "DENIED", + "status_code": 403, + "schema_version": 1, + }) + audit_service.init_audit_repository(repo) + + settings.RBAC_ENABLED = True + _setup_auth(require_auth=True) + yield TestClient(app), repo + + audit_service.init_audit_repository(orig_repo) + settings.REQUIRE_AUTH = orig_require_auth + settings.SUPABASE_JWT_SECRET = orig_secret + settings.SUPABASE_JWKS_URL = orig_jwks + settings.SUPABASE_JWT_ISSUER = orig_issuer + settings.SUPABASE_JWT_AUDIENCE = orig_audience + settings.RBAC_ENABLED = orig_rbac + from app.core.jwt_auth import init_verifier + + init_verifier( + jwt_secret=orig_secret, + jwks_url=orig_jwks, + issuer=orig_issuer, + audience=orig_audience, + ) + + +def _auth_header(role: str = "ADMIN") -> dict: + return { + "Authorization": "Bearer " + create_test_jwt( + extra_claims={"app_metadata": {"role": role}} + ) + } + + +class TestAuditReadApiAuthentication: + def test_anonymous_rejected(self, admin_client): + client, _ = admin_client + resp = client.get("/api/v1/admin/audit/events") + assert resp.status_code in (401, 403) + + def test_non_admin_forbidden(self, admin_client): + client, _ = admin_client + resp = client.get( + "/api/v1/admin/audit/events", + headers=_auth_header(role="FIELD_OFFICER"), + ) + assert resp.status_code == 403 + assert resp.json()["error"]["code"] == "FORBIDDEN" + + +class TestAuditReadApiAccess: + def test_admin_can_list_events(self, admin_client): + client, repo = admin_client + resp = client.get("/api/v1/admin/audit/events", headers=_auth_header()) + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 2 + assert len(body["items"]) == 2 + assert body["page"] == 1 + assert body["page_size"] == 50 + assert body["total_pages"] == 1 + + def test_pagination(self, admin_client): + client, _ = admin_client + resp = client.get( + "/api/v1/admin/audit/events", + params={"page": 1, "page_size": 1}, + headers=_auth_header(), + ) + body = resp.json() + assert body["total"] == 2 + assert len(body["items"]) == 1 + # newest first (event_timestamp DESC) + assert body["items"][0]["event_id"] == "evt-2" + + def test_filter_by_outcome(self, admin_client): + client, _ = admin_client + resp = client.get( + "/api/v1/admin/audit/events", + params={"outcome": "DENIED"}, + headers=_auth_header(), + ) + body = resp.json() + assert body["total"] == 1 + assert body["items"][0]["event_id"] == "evt-2" + + def test_filter_by_role(self, admin_client): + client, _ = admin_client + resp = client.get( + "/api/v1/admin/audit/events", + params={"role": "ANALYST"}, + headers=_auth_header(), + ) + body = resp.json() + assert body["total"] == 1 + assert body["items"][0]["event_id"] == "evt-1" + + def test_response_never_contains_sensitive_fields(self, admin_client): + client, _ = admin_client + resp = client.get("/api/v1/admin/audit/events", headers=_auth_header()) + first = resp.json()["items"][0] + sensitive_keys = { + "password", + "secret", + "token", + "authorization", + "request_body", + "response_body", + "full_name", + "dob", + "address", + "phone", + "email", + } + assert sensitive_keys.isdisjoint(set(first.keys())) + + def test_invalid_pagination_rejected(self, admin_client): + client, _ = admin_client + resp = client.get( + "/api/v1/admin/audit/events", + params={"page": 0}, + headers=_auth_header(), + ) + assert resp.status_code == 422 + resp = client.get( + "/api/v1/admin/audit/events", + params={"page_size": 500}, + headers=_auth_header(), + ) + assert resp.status_code == 422 + + +class TestAuditReadApiUnavailable: + def test_csv_backend_returns_503(self): + """NoOp adapter must return 503, never an empty fabricated page.""" + from app.database.repositories.csv.audit_repo import NoOpAuditRepository + + orig_repo = audit_service._repo + orig_require_auth = settings.REQUIRE_AUTH + orig_secret = settings.SUPABASE_JWT_SECRET + orig_jwks = settings.SUPABASE_JWKS_URL + orig_issuer = settings.SUPABASE_JWT_ISSUER + orig_audience = settings.SUPABASE_JWT_AUDIENCE + orig_rbac = settings.RBAC_ENABLED + try: + audit_service.init_audit_repository(NoOpAuditRepository()) + settings.RBAC_ENABLED = True + _setup_auth(require_auth=True) + client = TestClient(app) + resp = client.get( + "/api/v1/admin/audit/events", headers=_auth_header() + ) + assert resp.status_code == 503 + assert resp.json()["error"]["code"] == "DEPENDENCY_UNAVAILABLE" + finally: + audit_service.init_audit_repository(orig_repo) + settings.REQUIRE_AUTH = orig_require_auth + settings.SUPABASE_JWT_SECRET = orig_secret + settings.SUPABASE_JWKS_URL = orig_jwks + settings.SUPABASE_JWT_ISSUER = orig_issuer + settings.SUPABASE_JWT_AUDIENCE = orig_audience + settings.RBAC_ENABLED = orig_rbac + from app.core.jwt_auth import init_verifier + + init_verifier( + jwt_secret=orig_secret, + jwks_url=orig_jwks, + issuer=orig_issuer, + audience=orig_audience, + ) + + +class TestAuditQueryService: + def test_query_service_building_blocks(self): + repo = InMemoryAuditRepository() + repo.append({ + "event_id": "a", + "event_timestamp": "2025-01-01T00:00:00+00:00", + "request_id": "r1", + "user_id": "u1", + "http_method": "GET", + "route": "/x", + "action": "READ", + "resource_type": "r", + "outcome": "SUCCESS", + "status_code": 200, + "schema_version": 1, + }) + repo.append({ + "event_id": "b", + "event_timestamp": "2025-01-02T00:00:00+00:00", + "request_id": "r2", + "user_id": "u2", + "http_method": "GET", + "route": "/x", + "action": "READ", + "resource_type": "r", + "outcome": "DENIED", + "status_code": 403, + "schema_version": 1, + }) + audit_service.init_audit_repository(repo) + result = audit_service.query_audit_events( + {"outcome": "DENIED"}, page=1, page_size=10 + ) + assert result["total"] == 1 + assert result["items"][0]["event_id"] == "b" + + def test_query_service_requires_repository(self, monkeypatch): + monkeypatch.setattr(audit_service, "_repo", None) + with pytest.raises(DependencyUnavailableError): + audit_service.query_audit_events(page=1, page_size=10) \ No newline at end of file diff --git a/backend/tests/test_csv_loader.py b/backend/tests/test_csv_loader.py index 02e928a..b792ea2 100644 --- a/backend/tests/test_csv_loader.py +++ b/backend/tests/test_csv_loader.py @@ -25,12 +25,13 @@ parse_float, parse_int, ) +from app.core.config import settings # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- -FIXTURES_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "schema_reference" +FIXTURES_DIR = Path(settings.DATA_DIR) @pytest.fixture() diff --git a/backend/tests/test_rate_limit.py b/backend/tests/test_rate_limit.py new file mode 100644 index 0000000..648e05e --- /dev/null +++ b/backend/tests/test_rate_limit.py @@ -0,0 +1,159 @@ +"""Rate limiting tests. + +Covers the fixed-window limiter unit behaviour and end-to-end 429 +responses through the middleware. These tests enable RATE_LIMIT_ENABLED +and use tight limits for determinism. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.main import app +from app.core.rate_limit import FixedWindowRateLimiter, _default_limiter +from tests.conftest import ( + TEST_AUDIENCE, + TEST_ISSUER, + TEST_JWT_SECRET, + create_test_jwt, +) + + +class TestFixedWindowLimiter: + def test_allows_within_limit(self): + clock = iter([100.0, 100.5, 101.0, 101.5]) + limiter = FixedWindowRateLimiter(now=lambda: next(clock)) + assert limiter.allow("k", limit=3, window_seconds=60) is True + assert limiter.allow("k", limit=3, window_seconds=60) is True + assert limiter.allow("k", limit=3, window_seconds=60) is True + assert limiter.allow("k", limit=3, window_seconds=60) is False + + def test_window_rotation_resets_counter(self): + clock = iter([100.0, 191.0]) # 91s later → new window + limiter = FixedWindowRateLimiter(now=lambda: next(clock)) + assert limiter.allow("k", limit=1, window_seconds=60) is True + assert limiter.allow("k", limit=1, window_seconds=60) is True + + def test_per_key_isolation(self): + limiter = FixedWindowRateLimiter(now=lambda: 100.0) + assert limiter.allow("a", limit=1, window_seconds=60) is True + assert limiter.allow("a", limit=1, window_seconds=60) is False + assert limiter.allow("b", limit=1, window_seconds=60) is True + + def test_reset_clears_counters(self): + limiter = FixedWindowRateLimiter(now=lambda: 100.0) + assert limiter.allow("k", limit=1, window_seconds=60) is True + limiter.reset() + assert limiter.allow("k", limit=1, window_seconds=60) is True + + +def _setup_auth(require_auth: bool = True) -> None: + settings.REQUIRE_AUTH = require_auth + settings.SUPABASE_JWT_SECRET = TEST_JWT_SECRET + settings.SUPABASE_JWKS_URL = "" + settings.SUPABASE_JWT_ISSUER = TEST_ISSUER + settings.SUPABASE_JWT_AUDIENCE = TEST_AUDIENCE + + from app.core.jwt_auth import init_verifier + + init_verifier( + jwt_secret=TEST_JWT_SECRET, + jwks_url="", + issuer=TEST_ISSUER, + audience=TEST_AUDIENCE, + ) + + +@pytest.fixture +def rate_limit_client(): + """Client with rate limiting + auth enabled, reset counters.""" + _default_limiter.reset() + orig_require_auth = settings.REQUIRE_AUTH + orig_secret = settings.SUPABASE_JWT_SECRET + orig_jwks = settings.SUPABASE_JWKS_URL + orig_issuer = settings.SUPABASE_JWT_ISSUER + orig_audience = settings.SUPABASE_JWT_AUDIENCE + orig_rl = settings.RATE_LIMIT_ENABLED + orig_def_limit = settings.RATE_LIMIT_DEFAULT_LIMIT + orig_export_limit = settings.RATE_LIMIT_EXPORT_LIMIT + orig_search_limit = settings.RATE_LIMIT_SEARCH_LIMIT + + settings.RATE_LIMIT_ENABLED = True + settings.RATE_LIMIT_DEFAULT_LIMIT = 5 + settings.RATE_LIMIT_EXPORT_LIMIT = 2 + settings.RATE_LIMIT_SEARCH_LIMIT = 2 + _setup_auth(require_auth=True) + yield TestClient(app) + + _default_limiter.reset() + settings.REQUIRE_AUTH = orig_require_auth + settings.SUPABASE_JWT_SECRET = orig_secret + settings.SUPABASE_JWKS_URL = orig_jwks + settings.SUPABASE_JWT_ISSUER = orig_issuer + settings.SUPABASE_JWT_AUDIENCE = orig_audience + settings.RATE_LIMIT_ENABLED = orig_rl + settings.RATE_LIMIT_DEFAULT_LIMIT = orig_def_limit + settings.RATE_LIMIT_EXPORT_LIMIT = orig_export_limit + settings.RATE_LIMIT_SEARCH_LIMIT = orig_search_limit + from app.core.jwt_auth import init_verifier + + init_verifier( + jwt_secret=orig_secret, + jwks_url=orig_jwks, + issuer=orig_issuer, + audience=orig_audience, + ) + + +def _auth_header() -> dict: + return {"Authorization": "Bearer " + create_test_jwt()} + + +class TestRateLimitMiddleware: + def test_health_never_limited(self, rate_limit_client): + client = rate_limit_client + for _ in range(10): + assert client.get("/health").status_code == 200 + + def test_default_route_limit(self, rate_limit_client): + client = rate_limit_client + headers = _auth_header() + for i in range(5): + resp = client.get("/api/v1/dashboard/summary", headers=headers) + assert resp.status_code == 200, i + resp = client.get("/api/v1/dashboard/summary", headers=headers) + assert resp.status_code == 429 + assert resp.json()["error"]["code"] == "RATE_LIMITED" + assert "retry-after" in resp.headers + + def test_export_route_stricter_limit(self, rate_limit_client): + client = rate_limit_client + headers = _auth_header() + assert client.get("/api/v1/map/intelligence/export", headers=headers).status_code == 200 + assert client.get("/api/v1/map/intelligence/export", headers=headers).status_code == 200 + resp = client.get("/api/v1/map/intelligence/export", headers=headers) + assert resp.status_code == 429 + + def test_limits_are_per_client(self, rate_limit_client): + client = rate_limit_client + headers_a = _auth_header() + headers_a["X-Forwarded-For"] = "203.0.113.1" + # Client A exhausts its budget (default limit 5) + for i in range(5): + resp = client.get("/api/v1/dashboard/summary", headers=headers_a) + assert resp.status_code == 200, i + assert client.get("/api/v1/dashboard/summary", headers=headers_a).status_code == 429 + # Client B has its own budget + headers_b = _auth_header() + headers_b["X-Forwarded-For"] = "203.0.113.2" + resp = client.get("/api/v1/dashboard/summary", headers=headers_b) + assert resp.status_code == 200 + + def test_disabled_when_turned_off(self, rate_limit_client): + settings.RATE_LIMIT_ENABLED = False + client = rate_limit_client + for i in range(20): + resp = client.get("/api/v1/dashboard/summary", headers=_auth_header()) + assert resp.status_code == 200, i \ No newline at end of file diff --git a/backend/tests/test_rbac.py b/backend/tests/test_rbac.py new file mode 100644 index 0000000..5897a56 --- /dev/null +++ b/backend/tests/test_rbac.py @@ -0,0 +1,365 @@ +"""RBAC (role-based access control) tests. + +Covers the permission model, role resolution from verified JWT claims, +and endpoint-level authorization enforcement (200 vs 403). + +Uses ``REQUIRE_AUTH=true`` with valid, cryptographically signed test +tokens that carry role claims. Anonymous/401 behavior is covered by +``test_auth.py``. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.main import app +from app.core.rbac import ( + ADMIN, + ANALYST, + FIELD_OFFICER, + PERMISSIONS, + ROLE_PERMISSIONS, + normalize_role, + permissions_for_role, + resolve_role, + role_has_permission, +) +from tests.conftest import ( + TEST_AUDIENCE, + TEST_ISSUER, + TEST_JWT_SECRET, + create_test_jwt, +) + + +def _setup_auth(require_auth: bool = True) -> None: + """Configure settings and re-init verifier for test.""" + settings.REQUIRE_AUTH = require_auth + settings.SUPABASE_JWT_SECRET = TEST_JWT_SECRET + settings.SUPABASE_JWKS_URL = "" + settings.SUPABASE_JWT_ISSUER = TEST_ISSUER + settings.SUPABASE_JWT_AUDIENCE = TEST_AUDIENCE + + from app.core.jwt_auth import init_verifier + + init_verifier( + jwt_secret=TEST_JWT_SECRET, + jwks_url="", + issuer=TEST_ISSUER, + audience=TEST_AUDIENCE, + ) + + +@pytest.fixture +def auth_client(): + """TestClient with authentication + RBAC enabled.""" + orig_require_auth = settings.REQUIRE_AUTH + orig_secret = settings.SUPABASE_JWT_SECRET + orig_jwks = settings.SUPABASE_JWKS_URL + orig_issuer = settings.SUPABASE_JWT_ISSUER + orig_audience = settings.SUPABASE_JWT_AUDIENCE + orig_rbac_enabled = settings.RBAC_ENABLED + orig_default_role = settings.RBAC_DEFAULT_ROLE + orig_claim_paths = settings.RBAC_ROLE_CLAIM_PATHS + + settings.RBAC_ENABLED = True + _setup_auth(require_auth=True) + yield TestClient(app) + + settings.REQUIRE_AUTH = orig_require_auth + settings.SUPABASE_JWT_SECRET = orig_secret + settings.SUPABASE_JWKS_URL = orig_jwks + settings.SUPABASE_JWT_ISSUER = orig_issuer + settings.SUPABASE_JWT_AUDIENCE = orig_audience + settings.RBAC_ENABLED = orig_rbac_enabled + settings.RBAC_DEFAULT_ROLE = orig_default_role + settings.RBAC_ROLE_CLAIM_PATHS = orig_claim_paths + from app.core.jwt_auth import init_verifier + + init_verifier( + jwt_secret=orig_secret, + jwks_url=orig_jwks, + issuer=orig_issuer, + audience=orig_audience, + ) + + +def _auth_header(claims: dict | None = None) -> dict: + token = create_test_jwt(extra_claims=claims) + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# Role normalization / resolution unit tests +# --------------------------------------------------------------------------- + + +class TestRoleNormalization: + def test_uppercase_normalized(self): + assert normalize_role("field_officer") == FIELD_OFFICER + + def test_lowercase_normalized(self): + assert normalize_role("admin") == ADMIN + + def test_dashes_and_spaces_normalized(self): + assert normalize_role("Field Officer") == FIELD_OFFICER + assert normalize_role("FIELD-OFFICER") == FIELD_OFFICER + + def test_unknown_role_rejected(self): + assert normalize_role("SUPER_USER") is None + + def test_none_and_empty_rejected(self): + assert normalize_role(None) is None + assert normalize_role("") is None + assert normalize_role(" ") is None + + +class TestResolveRole: + def test_no_claims_returns_default(self): + assert resolve_role(None) == FIELD_OFFICER + + def test_empty_claims_returns_default(self): + assert resolve_role({}) == FIELD_OFFICER + + def test_app_metadata_role(self): + claims = {"app_metadata": {"role": "ANALYST"}} + assert resolve_role(claims) == ANALYST + + def test_user_metadata_role(self): + claims = {"user_metadata": {"role": "admin"}} + assert resolve_role(claims) == ADMIN + + def test_top_level_role(self): + claims = {"role": "field_officer"} + assert resolve_role(claims) == FIELD_OFFICER + + def test_app_metadata_takes_precedence(self): + claims = { + "app_metadata": {"role": "ANALYST"}, + "user_metadata": {"role": "ADMIN"}, + "role": "FIELD_OFFICER", + } + assert resolve_role(claims) == ANALYST + + def test_unknown_claim_value_falls_back_to_default(self): + claims = {"role": "SOME_OTHER_ROLE"} + assert resolve_role(claims) == FIELD_OFFICER + + def test_non_string_role_claim_ignored(self): + claims = {"role": 123} + assert resolve_role(claims) == FIELD_OFFICER + + +class TestPermissionsForRole: + def test_admin_has_all_permissions(self): + assert permissions_for_role(ADMIN) == PERMISSIONS + + def test_field_officer_permissions(self): + perms = permissions_for_role(FIELD_OFFICER) + assert "dashboard.read" in perms + assert "cases.read" in perms + assert "map.field.read" in perms + assert "districts.read" in perms + assert "stations.read" in perms + assert "cases.export" in perms + assert "map.intelligence.read" not in perms + assert "network.read" not in perms + assert "audit.read" not in perms + + def test_analyst_permissions(self): + perms = permissions_for_role(ANALYST) + assert "map.intelligence.read" in perms + assert "network.read" in perms + assert "network.person.read" in perms + assert "dashboard.read" in perms + assert "users.manage" not in perms + assert "system.configuration.manage" not in perms + + def test_unknown_role_has_no_permissions(self): + assert permissions_for_role(None) == frozenset() + assert permissions_for_role("NOT_A_ROLE") == frozenset() + + def test_role_has_permission(self): + assert role_has_permission(FIELD_OFFICER, "cases.read") is True + assert role_has_permission(FIELD_OFFICER, "network.read") is False + + def test_all_role_permissions_in_catalog(self): + for role_permissions in ROLE_PERMISSIONS.values(): + assert role_permissions.issubset(PERMISSIONS) + + +# --------------------------------------------------------------------------- +# Endpoint-level authorization +# --------------------------------------------------------------------------- + + +class TestDefaultRoleIsLeastPrivilege: + """Tokens with no role claim get RBAC_DEFAULT_ROLE (FIELD_OFFICER).""" + + def test_field_scope_read_endpoints_allowed(self, auth_client): + headers = _auth_header() + assert ( + auth_client.get("/api/v1/dashboard/summary", headers=headers).status_code + == 200 + ) + assert ( + auth_client.get("/api/v1/stations", headers=headers).status_code == 200 + ) + assert ( + auth_client.get("/api/v1/districts", headers=headers).status_code == 200 + ) + assert ( + auth_client.get("/api/v1/map/field/cases", headers=headers).status_code + == 200 + ) + + def test_field_officer_can_export(self, auth_client): + headers = _auth_header() + resp = auth_client.get("/api/v1/map/intelligence/export", headers=headers) + assert resp.status_code == 200 + + def test_intelligence_read_denied_for_default_role(self, auth_client): + headers = _auth_header() + resp = auth_client.get( + "/api/v1/map/intelligence/analytics", headers=headers + ) + assert resp.status_code == 403 + assert resp.json()["error"]["code"] == "FORBIDDEN" + + def test_network_read_denied_for_default_role(self, auth_client): + headers = _auth_header() + for path in ("/api/v1/network/graph", "/api/v1/network/search?q=FIR"): + resp = auth_client.get(path, headers=headers) + assert resp.status_code == 403, path + + def test_network_entity_denied_for_default_role(self, auth_client): + headers = _auth_header() + resp = auth_client.get( + "/api/v1/network/entities/fir/FIR001", headers=headers + ) + assert resp.status_code == 403 + + +class TestRoleClaimsGrantAccess: + def test_analyst_accesses_intelligence(self, auth_client): + headers = _auth_header({"app_metadata": {"role": "ANALYST"}}) + resp = auth_client.get("/api/v1/map/intelligence/analytics", headers=headers) + assert resp.status_code == 200 + + def test_analyst_accesses_network(self, auth_client): + headers = _auth_header({"app_metadata": {"role": "ANALYST"}}) + resp = auth_client.get("/api/v1/network/graph", headers=headers) + assert resp.status_code == 200 + + def test_analyst_entity_detail(self, auth_client): + headers = _auth_header({"app_metadata": {"role": "ANALYST"}}) + resp = auth_client.get( + "/api/v1/network/entities/fir/FIR202500001", headers=headers + ) + assert resp.status_code == 200 + + def test_admin_accesses_everything(self, auth_client): + headers = _auth_header({"app_metadata": {"role": "ADMIN"}}) + for path in ( + "/api/v1/map/intelligence/analytics", + "/api/v1/network/graph", + "/api/v1/network/search?q=FIR", + "/api/v1/map/field/filters", + ): + resp = auth_client.get(path, headers=headers) + assert resp.status_code == 200, path + + def test_user_metadata_role_claim(self, auth_client): + headers = _auth_header({"user_metadata": {"role": "ANALYST"}}) + resp = auth_client.get("/api/v1/map/intelligence/analytics", headers=headers) + assert resp.status_code == 200 + + def test_top_level_role_claim(self, auth_client): + headers = _auth_header({"role": "ANALYST"}) + resp = auth_client.get("/api/v1/map/intelligence/analytics", headers=headers) + assert resp.status_code == 200 + + def test_lowercase_role_claim_normalized(self, auth_client): + headers = _auth_header({"role": "analyst"}) + resp = auth_client.get("/api/v1/map/intelligence/analytics", headers=headers) + assert resp.status_code == 200 + + +class TestForbiddenResponseStructure: + def test_403_structured_body(self, auth_client): + headers = _auth_header() + resp = auth_client.get("/api/v1/map/intelligence/analytics", headers=headers) + body = resp.json() + assert resp.status_code == 403 + assert "error" in body + assert body["error"]["code"] == "FORBIDDEN" + assert "message" in body["error"] + assert "request_id" in body["error"] + + def test_403_does_not_leak_role_claims(self, auth_client): + headers = _auth_header({"app_metadata": {"role": "ANALYST"}}) + resp = auth_client.get("/api/v1/map/intelligence/analytics", headers=headers) + # Should succeed for ANALYST — use a denied case for leak check + assert resp.status_code == 200 + denied = auth_client.get( + "/api/v1/network/graph", headers=_auth_header({"role": "FIELD_OFFICER"}) + ) + body = denied.json() + assert "ANALYST" not in body["error"]["message"] + assert "app_metadata" not in body["error"]["message"] + + +class TestRbacDisabled: + def test_rbac_disabled_allows_all(self, auth_client): + settings.RBAC_ENABLED = False + headers = _auth_header() + resp = auth_client.get("/api/v1/map/intelligence/analytics", headers=headers) + assert resp.status_code == 200 + + +class TestCustomDefaultRole: + def test_default_role_configurable(self, auth_client): + settings.RBAC_DEFAULT_ROLE = "ANALYST" + headers = _auth_header() + resp = auth_client.get("/api/v1/map/intelligence/analytics", headers=headers) + assert resp.status_code == 200 + + +class TestRbacWithAuthDisabled: + def test_dev_mode_identity_is_admin(self, no_auth_client): + resp = no_auth_client.get("/api/v1/map/intelligence/analytics") + assert resp.status_code == 200 + resp = no_auth_client.get("/api/v1/network/graph") + assert resp.status_code == 200 + + +@pytest.fixture +def no_auth_client(): + """TestClient with authentication disabled (dev mode).""" + orig_require_auth = settings.REQUIRE_AUTH + orig_secret = settings.SUPABASE_JWT_SECRET + orig_jwks = settings.SUPABASE_JWKS_URL + orig_issuer = settings.SUPABASE_JWT_ISSUER + orig_audience = settings.SUPABASE_JWT_AUDIENCE + orig_rbac_enabled = settings.RBAC_ENABLED + + settings.RBAC_ENABLED = True + _setup_auth(require_auth=False) + yield TestClient(app) + + settings.REQUIRE_AUTH = orig_require_auth + settings.SUPABASE_JWT_SECRET = orig_secret + settings.SUPABASE_JWKS_URL = orig_jwks + settings.SUPABASE_JWT_ISSUER = orig_issuer + settings.SUPABASE_JWT_AUDIENCE = orig_audience + settings.RBAC_ENABLED = orig_rbac_enabled + from app.core.jwt_auth import init_verifier + + init_verifier( + jwt_secret=orig_secret, + jwks_url=orig_jwks, + issuer=orig_issuer, + audience=orig_audience, + ) diff --git a/docs/README.md b/docs/README.md index 360b3f9..73e3e8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -222,11 +222,13 @@ FastAPI Authentication Middleware Protected CrimeIntel APIs ``` -Security controls include deny-by-default authentication, JWT signature and claim validation, HS256/JWKS verification support, explicit algorithm allowlists, algorithm-confusion protection, expiration checks, production authentication guards, security headers, controlled CORS, request IDs, structured logging, audit logging, centralized errors, and production API-documentation hardening. +Security controls include deny-by-default authentication, JWT signature and claim validation, HS256/JWKS verification support, explicit algorithm allowlists, algorithm-confusion protection, expiration checks, production authentication guards, security headers, controlled CORS, request IDs, structured logging, audit logging with an admin read API, centralized errors, production API-documentation hardening, and route-level RBAC enforced server-side from verified JWT claims (see `backend/docs/RBAC_AUTHORIZATION.md`). + +Server-side RBAC resolves each authenticated identity to a least-privilege role (default `FIELD_OFFICER`); every protected endpoint maps to an explicit permission (`dashboard.read`, `map.intelligence.read`, `audit.read`, etc.). A fixed-window rate limiter (single-instance scope) protects route classes such as export and search. Full details: `backend/docs/RBAC_AUTHORIZATION.md`. Frontend-safe configuration includes the Supabase project URL and publishable/anon key. Database passwords, database URLs, JWT signing secrets, and privileged Supabase service credentials remain server-side. -Fine-grained RBAC and database Row Level Security should be aligned with the authoritative police role and permission model before privileged person-level access is enabled. +Database Row Level Security is enabled on all tables (`supabase/migrations/005_rls.sql`): `districts` and `police_stations` are readable by `authenticated`; all PII-bearing and operational tables are deny-by-default. The backend connects as a privileged role and bypasses RLS — its access is governed by the RBAC permissions above. --- @@ -422,15 +424,21 @@ Use the repository `.env.example` files as the authoritative configuration refer | GET | `/api/v1/network/entities/{entity_type}/{entity_id}` | Entity detail | | GET | `/api/v1/network/search` | Cross-entity search | +### Admin + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| GET | `/api/v1/admin/audit/events` | Audit trail query (requires `audit.read`; 503 in CSV/dev) | + For exact query parameters and response contracts, refer to the API contract under `docs/`. --- ## 12. ✅ Testing & Reliability -The automated backend test suite covers authentication and JWT security, dashboard services, crime-map APIs, intelligence analytics, district intelligence, stations, network analysis, health probes, error handling, repositories, ingestion, audit logging, and privacy/PII behavior. +The automated backend test suite (734 tests) covers authentication and JWT security, RBAC authorization, audit write + read API, rate limiting, dashboard services, crime-map APIs, intelligence analytics, district intelligence, stations, network analysis, health probes, error handling, repositories, ingestion, audit logging, and privacy/PII behavior. -Reliability measures include PostgreSQL connection pooling and timeouts, bounded exports, bounded graph construction, centralized error responses, deterministic repository-backed tests, and health/liveness/readiness probes. +Reliability measures include PostgreSQL connection pooling and timeouts, bounded exports, bounded graph construction, centralized error responses, deterministic repository-backed tests, and health/liveness/readiness probes. Continuous integration runs the full suite on every branch via `.github/workflows/backend-ci.yml`. --- @@ -463,7 +471,17 @@ Production deployment must configure the final frontend origin in CORS and suppl ## 14. 🔮 Production Extensions -The architecture supports further validated capabilities as departmental requirements and authoritative artifacts become available, including fine-grained RBAC, Supabase RLS policies, predictive crime-risk models, anomaly detection, forecasting, authoritative GIS boundaries, approved socio-economic datasets, administrative APIs, rate limiting, expanded reporting, monitoring, and CI/CD. +Implemented this iteration: + +- **RBAC authorization** — roles/permissions model, server-side claim resolution, route-level permission deps (`backend/docs/RBAC_AUTHORIZATION.md`). +- **Row Level Security** — deny-by-default on PII tables, selective `authenticated` reads (`supabase/migrations/005_rls.sql`). +- **Audit read API** — `GET /api/v1/admin/audit/events` behind `audit.read` (503 in CSV/dev). +- **Rate limiting** — fixed-window in-process limiter per route class. +- **CI** — `.github/workflows/backend-ci.yml` runs the full suite + production-settings guard. +- **ML integration contract** — audited `ml-engine`; artifacts documented with integration recommendations, no fabricated endpoints (`backend/docs/ML_INTEGRATION.md`). +- **Zoho Catalyst packaging** — `Procfile` + deployment/env-var guide (`backend/docs/PRODUCTION_DATABASE.md`). + +Remaining as departmental requirements/authoritative artifacts become available: predictive crime-risk models served from the API, anomaly detection, authoritative GIS boundaries, approved socio-economic datasets, administrative APIs, distributed rate limiting, monitoring, and expanded reporting. --- diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..48aaa7a --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,12 @@ +# Frontend environment configuration (placeholders — copy to .env locally). +# Real .env must never be committed. Only public Supabase URL + anon key +# belong in the browser; never use service-role keys or backend secrets here. + +# Supabase project public URL (example: https://xxxx.supabase.co) +VITE_SUPABASE_URL= + +# Supabase publishable / anon key (public browser key) +VITE_SUPABASE_ANON_KEY= + +# FastAPI backend base URL (development default) +VITE_API_BASE_URL=http://127.0.0.1:8000 \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..66c73f5 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,11 @@ dist dist-ssr *.local +# Environment — real values are never committed +.env +.env.* +!.env.example + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d1b2682..f97dfe1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "temp-frontend", "version": "0.0.0", "dependencies": { + "@supabase/supabase-js": "^2.109.0", "framer-motion": "^12.42.2", "jspdf": "^4.2.1", "leaflet": "^1.9.4", @@ -770,6 +771,90 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.109.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.109.0.tgz", + "integrity": "sha512-krf61vksi92kEUYtNH70GnIMOoQqLBAKG2e3Aha4e/0uJA6i1OWCxL7WUGHo6c0Vu/w96Y1DdnkYz0NOg0kYog==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.109.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.109.0.tgz", + "integrity": "sha512-IiwAspZrVrBRYQoFgSJvkcA9iJvTCw8nHOdvlKARDushlw/x1YY3YJYtwwgZFtl7utUbTiZ5SwohXPNgnpZM+g==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz", + "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.109.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.109.0.tgz", + "integrity": "sha512-Xk4gzuzyrGIPWCUuJolDQS/9zdFZDEXRhNsVeOHEKwFr+vpNU0himsHtLOhSEYlyPqwmeY8LUCKL6bLsDp0ScA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.109.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.109.0.tgz", + "integrity": "sha512-q9tjGUgWLNhfLz6HeYE7yl6FG9WEsnr6bFmmLpn2Nakxp3Z36pqI4xu0xns5/n8Xtq10HVcy9CRxOxgvviCM4Q==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.4", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.109.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.109.0.tgz", + "integrity": "sha512-j119SdEuwrOPGqiPjshpfovrQPFqeATKg990jNV88Ie+SEufznzVD2mL5kQvly+z2oVBeZn+pweU0QN/OYLmJw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.109.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.109.0.tgz", + "integrity": "sha512-eNpUGegTT3hhoTK9j6aLViVjybYtdq6Ishb4BkVLi5tT58S1D80n7dmALMoBWkbrYG4uQv30c8iAB6nZylX0Fg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.109.0", + "@supabase/functions-js": "2.109.0", + "@supabase/postgrest-js": "2.109.0", + "@supabase/realtime-js": "2.109.0", + "@supabase/storage-js": "2.109.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -1373,6 +1458,15 @@ "node": ">=8.0.0" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iobuffer": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8115a93..edf13ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@supabase/supabase-js": "^2.109.0", "framer-motion": "^12.42.2", "jspdf": "^4.2.1", "leaflet": "^1.9.4", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cd0ab1a..9f8850f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,9 @@ import { PageTransition } from './components/ui/PageTransition'; import { NotificationProvider } from './context/NotificationContext'; import GlobalNotificationCenter from './components/shared/notifications/GlobalNotificationCenter'; +import { AuthProvider, useAuth } from './auth/AuthContext'; +import SessionRestoreScreen from './auth/SessionRestoreScreen'; + import Navbar from './components/shared/navigation/Navbar'; import Hero from './modules/dashboard/Hero'; import Stats from './modules/dashboard/Stats'; @@ -31,8 +34,11 @@ function AppContent() { return localStorage.getItem('ksp_selected_role') || null; }); + const { isAuthenticated, isAuthenticating, signOut } = useAuth(); + + // Persist navigation (never persist internal/authentication views). useEffect(() => { - if (currentView) { + if (currentView && currentView !== 'authenticating') { localStorage.setItem('ksp_current_view', currentView); } }, [currentView]); @@ -60,12 +66,29 @@ function AppContent() { setCurrentView('dashboard'); }; + const handleLogout = async () => { + await signOut(); + navigateToLanding(); + }; + + // ── Authentication gate ─────────────────────────────────────────────────── + // The protected dashboard is only reachable with a real Supabase session. + // localStorage role/current-view values are never treated as proof of auth. + let view = currentView; + if (view === 'dashboard' && !isAuthenticated) { + view = isAuthenticating ? 'authenticating' : 'auth-login'; + } else if (view === 'auth-login' && isAuthenticated) { + view = 'dashboard'; + } + const renderView = () => { - switch (currentView) { + switch (view) { + case 'authenticating': + return ; case 'dashboard': return ( - + ); case 'auth-role': @@ -121,8 +144,10 @@ export default function App() { return ( - + + + ); -} +} \ No newline at end of file diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..ebee674 --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,11 @@ +import { apiRequest } from './client.js'; + +/** + * GET /api/v1/auth/me — backend-verified identity for the current session. + * Backend response: { user_id, authenticated, email }. + * This endpoint intentionally exposes no role claims; the backend resolves + * roles server-side only. + */ +export function fetchMe() { + return apiRequest('/api/v1/auth/me'); +} \ No newline at end of file diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..2aa9200 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,94 @@ +import { supabase } from '../lib/supabase.js'; + +// Development default for local FastAPI; override via VITE_API_BASE_URL. +const DEFAULT_API_BASE = 'http://127.0.0.1:8000'; + +export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE).replace(/\/+$/, ''); + +/** + * Structured API error preserving the backend error contract + * (status / code / message / request_id) when the backend provides one. + */ +export class ApiError extends Error { + constructor(message, { status, code, requestId } = {}) { + super(message); + this.name = 'ApiError'; + this.status = status ?? 0; + this.code = code ?? null; + this.requestId = requestId ?? null; + } +} + +/** Read the current Supabase session access token (requires a session). */ +async function getAccessToken() { + if (!supabase) return null; + const { data, error } = await supabase.auth.getSession(); + if (error) { + console.warn('Unable to read Supabase session token.', error); + return null; + } + return data?.session?.access_token ?? null; +} + +/** + * Centralized API client. + * + * - builds URLs from VITE_API_BASE_URL + * - attaches `Authorization: Bearer ` when a session exists + * - parses JSON and preserves backend structured errors + * - normalizes failures into ApiError (401/403/network/backend) + * + * Endpoint-specific functions live in src/api/.js. + */ +export async function apiRequest(path, { method = 'GET', query, headers = {}, body } = {}) { + const token = await getAccessToken(); + + let url = `${API_BASE_URL}${path}`; + if (query) { + const queryString = new URLSearchParams(query).toString(); + if (queryString) url += `?${queryString}`; + } + + let response; + try { + response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } catch { + throw new ApiError( + 'Unable to reach the server. Check your connection and try again.', + { status: 0, code: 'NETWORK_ERROR' }, + ); + } + + const text = await response.text(); + let payload = null; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = { error: { message: text.slice(0, 200) } }; + } + } + + if (!response.ok) { + const errorInfo = payload?.error || {}; + throw new ApiError( + errorInfo.message || `Request failed with status ${response.status}`, + { + status: response.status, + code: errorInfo.code || null, + requestId: errorInfo.request_id || null, + }, + ); + } + + return payload; +} \ No newline at end of file diff --git a/frontend/src/auth/AuthContext.jsx b/frontend/src/auth/AuthContext.jsx new file mode 100644 index 0000000..c305620 --- /dev/null +++ b/frontend/src/auth/AuthContext.jsx @@ -0,0 +1,119 @@ +import React, { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react'; +import { supabase, SUPABASE_CONFIGURED } from '../lib/supabase.js'; +import { fetchMe } from '../api/auth.js'; + +// Application authentication states. +export const AUTH_STATUS = { + AUTHENTICATING: 'authenticating', + AUTHENTICATED: 'authenticated', + UNAUTHENTICATED: 'unauthenticated', +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [state, setState] = useState({ + status: SUPABASE_CONFIGURED ? AUTH_STATUS.AUTHENTICATING : AUTH_STATUS.UNAUTHENTICATED, + session: null, + user: null, + me: null, // backend-verified identity from /api/v1/auth/me + meStatus: 'idle', // 'idle' | 'verifying' | 'verified' | 'error' + }); + + const applySession = useCallback((session) => { + setState(prev => ({ + ...prev, + status: session ? AUTH_STATUS.AUTHENTICATED : AUTH_STATUS.UNAUTHENTICATED, + session, + user: session?.user ?? null, + me: session ? prev.me : null, + meStatus: session ? prev.meStatus : 'idle', + })); + }, []); + + // ── Session restore + subscription ─────────────────────────────────────── + useEffect(() => { + if (!supabase) return undefined; + + supabase.auth.getSession().then(({ data: { session } }) => { + applySession(session); + }); + + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + applySession(session); + }); + + return () => subscription.unsubscribe(); + }, [applySession]); + + // ── Backend identity verification ──────────────────────────────────────── + // Any time a real session exists, confirm the backend accepts it via + // GET /api/v1/auth/me. A 401 means the backend rejected the token → + // sign out so the UI returns to unauthenticated. + const accessToken = state.session?.access_token ?? null; + useEffect(() => { + if (!accessToken || !supabase) return undefined; + + let cancelled = false; + setState(prev => ({ ...prev, meStatus: 'verifying' })); + + fetchMe() + .then((me) => { + if (cancelled) return; + setState(prev => ({ ...prev, me, meStatus: 'verified' })); + }) + .catch((err) => { + if (cancelled) return; + if (err && err.status === 401) { + supabase.auth.signOut(); + } else { + setState(prev => ({ ...prev, meStatus: 'error' })); + } + }); + + return () => { cancelled = true; }; + }, [accessToken]); + + // ── Sign in with real Supabase credentials ─────────────────────────────── + const signIn = useCallback(async (email, password) => { + if (!supabase) { + const error = new Error('Authentication is not configured for this deployment.'); + error.code = 'AUTH_NOT_CONFIGURED'; + throw error; + } + const { error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) throw error; + }, []); + + // ── Sign out ───────────────────────────────────────────────────────────── + const signOut = useCallback(async () => { + if (!supabase) return; + await supabase.auth.signOut(); + // onAuthStateChange flips status to unauthenticated and clears session. + }, []); + + const value = useMemo( + () => ({ + status: state.status, + session: state.session, + user: state.user, + me: state.me, + meStatus: state.meStatus, + isAuthenticated: state.status === AUTH_STATUS.AUTHENTICATED, + isAuthenticating: state.status === AUTH_STATUS.AUTHENTICATING, + signIn, + signOut, + }), + [state, signIn, signOut], + ); + + return {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} \ No newline at end of file diff --git a/frontend/src/auth/SessionRestoreScreen.jsx b/frontend/src/auth/SessionRestoreScreen.jsx new file mode 100644 index 0000000..03a81db --- /dev/null +++ b/frontend/src/auth/SessionRestoreScreen.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Loader2, Shield } from 'lucide-react'; + +export default function SessionRestoreScreen() { + return ( + + + + + + + + Restoring secure session… + + + + ); +} \ No newline at end of file diff --git a/frontend/src/lib/supabase.js b/frontend/src/lib/supabase.js new file mode 100644 index 0000000..257b9c8 --- /dev/null +++ b/frontend/src/lib/supabase.js @@ -0,0 +1,21 @@ +import { createClient } from '@supabase/supabase-js'; + +// Public browser credentials only. Never place service-role keys, JWT +// signing secrets, or database credentials in this file. +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL; +const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY; + +// Single centralized browser Supabase client. When the environment is not yet +// configured on this machine the client stays null and authentication reports +// a safe "not configured" state instead of crashing the login screen. +export const supabase = SUPABASE_URL && SUPABASE_ANON_KEY + ? createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + auth: { + persistSession: true, + autoRefreshToken: true, + detectSessionInUrl: true, + }, + }) + : null; + +export const SUPABASE_CONFIGURED = Boolean(supabase); \ No newline at end of file diff --git a/frontend/src/modules/authentication/Login.jsx b/frontend/src/modules/authentication/Login.jsx index 260aee0..38fc65b 100644 --- a/frontend/src/modules/authentication/Login.jsx +++ b/frontend/src/modules/authentication/Login.jsx @@ -1,58 +1,68 @@ -import React, { useState, useEffect } from 'react'; -import { Shield, User, Settings, Lock, Eye, EyeOff, Loader2, Home } from 'lucide-react'; +import React, { useState } from 'react'; +import { Shield, User, Settings, Lock, Eye, EyeOff, Loader2, Home, AlertCircle } from 'lucide-react'; import { motion } from 'framer-motion'; +import { useAuth } from '../../auth/AuthContext'; import kspLogo from '../../assets/ksp-logo.png'; +// Presentation-only access level selector. The selected level is UI state for +// which demo dashboard to render; it is never treated as proof of role in the +// frontend. Authorization is resolved server-side by the backend only. const roles = [ { id: 'officer', name: 'Field Officer', icon: User }, { id: 'analyst', name: 'Intelligence Analyst', icon: Shield }, { id: 'admin', name: 'System Administrator', icon: Settings } ]; -const ROLE_CREDENTIALS = { - officer: { email: 'officer.ksp@karnataka.gov.in', password: 'Officer@Pass2026' }, - analyst: { email: 'analyst.ksp@karnataka.gov.in', password: 'Analyst@Pass2026' }, - admin: { email: 'admin.ksp@karnataka.gov.in', password: 'Admin@Pass2026' } -}; +function toSafeAuthMessage(error) { + switch (error?.code) { + case 'AUTH_NOT_CONFIGURED': + return 'Authentication is not configured for this deployment.'; + case 'invalid_credentials': + return 'Incorrect email or password. Please try again.'; + case 'email_not_confirmed': + return 'Please confirm your email address before signing in.'; + case 'over_email_send_rate_limit': + case 'over_request_rate_limit': + return 'Too many sign-in attempts. Please wait a moment and try again.'; + case 'validation_failed': + case 'missing_password': + return 'Enter a valid official email address and password.'; + default: + return 'Unable to sign in. Check your credentials and try again.'; + } +} export default function Login({ role, onRoleSelect, onBack, onForgot, onLogin }) { - const [isLoading, setIsLoading] = useState(false); + const { signIn } = useAuth(); + const [submitting, setSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); const [selectedRole, setSelectedRole] = useState(role || null); - - const initialCreds = ROLE_CREDENTIALS[role] || (role === null ? { email: '', password: '' } : ROLE_CREDENTIALS.analyst); - const [email, setEmail] = useState(initialCreds.email); - const [password, setPassword] = useState(initialCreds.password); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); - useEffect(() => { - if (role) { - setSelectedRole(role); - const creds = ROLE_CREDENTIALS[role]; - if (creds) { - setEmail(creds.email); - setPassword(creds.password); - } - } - }, [role]); - const handleRoleClick = (roleId) => { setSelectedRole(roleId); - const creds = ROLE_CREDENTIALS[roleId] || ROLE_CREDENTIALS.analyst; - setEmail(creds.email); - setPassword(creds.password); + setErrorMessage(null); if (onRoleSelect) { onRoleSelect(roleId); } }; - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); - if (!selectedRole) return; - setIsLoading(true); - setTimeout(() => { - setIsLoading(false); + if (!selectedRole || submitting) return; + setSubmitting(true); + setErrorMessage(null); + try { + await signIn(email.trim(), password); onLogin(); - }, 1000); + } catch (error) { + setErrorMessage(toSafeAuthMessage(error)); + setPassword(''); + } finally { + setSubmitting(false); + } }; return ( @@ -170,11 +180,14 @@ export default function Login({ role, onRoleSelect, onBack, onForgot, onLogin }) setEmail(e.target.value)} - placeholder="Select access level to autofill" - className="w-full pl-11 pr-4 py-3 rounded-full border border-[#E2E8F0] text-sm text-[#0F172A] placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-all shadow-sm" + placeholder="Enter your official email" + className="w-full pl-11 pr-4 py-3 rounded-full border border-[#E2E8F0] text-sm text-[#0F172A] placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-all shadow-sm disabled:bg-slate-50" /> @@ -196,11 +209,13 @@ export default function Login({ role, onRoleSelect, onBack, onForgot, onLogin }) setPassword(e.target.value)} - placeholder="pasword" - className="w-full pl-11 pr-11 py-3 rounded-full border border-[#E2E8F0] text-sm text-[#0F172A] placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-all shadow-sm" + placeholder="Enter your password" + className="w-full pl-11 pr-11 py-3 rounded-full border border-[#E2E8F0] text-sm text-[#0F172A] placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-all shadow-sm disabled:bg-slate-50" /> + {errorMessage && ( + + + {errorMessage} + + )} + - {isLoading ? : 'Login'} + {submitting ? : 'Login'} @@ -225,4 +247,4 @@ export default function Login({ role, onRoleSelect, onBack, onForgot, onLogin }) ); -} +} \ No newline at end of file
+ Restoring secure session… +