Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
@@ -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')
"
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: cd backend && uvicorn app.main:app --host 0.0.0.0 --port $PORT
17 changes: 16 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
76 changes: 76 additions & 0 deletions backend/app/api/admin.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 12 additions & 1 deletion backend/app/api/auth_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -145,14 +149,21 @@ 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", ""),
email=claims.get("email"),
audience=claims.get("aud"),
expires_at=claims.get("exp"),
issued_at=claims.get("iat"),
role=role,
permissions=permissions_for_role(role),
)


Expand Down
3 changes: 3 additions & 0 deletions backend/app/api/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions backend/app/api/districts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions backend/app/api/field_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@

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,
FieldMapCaseListResponse,
FieldMapFiltersResponse,
FieldMapHotspotResponse,
)
from app.schemas.auth import AuthenticatedIdentity
from app.services.field_map_service import FieldMapService

router = APIRouter(prefix="/map/field", tags=["field-map"])
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions backend/app/api/intelligence_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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"])
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading