diff --git a/.agents/rules/changes-blocked.md b/.agents/rules/changes-blocked.md new file mode 100644 index 0000000..90e628d --- /dev/null +++ b/.agents/rules/changes-blocked.md @@ -0,0 +1,6 @@ +--- +trigger: always_on +--- + +1. Do not change the nav bar and footer until i request you to change. +2. Do not replace the documents in frontend home page unless i tell you to change. diff --git a/README.md b/README.md index ef51d77..47f73d7 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,19 @@ CrimeIntel unifies crime records, geospatial intelligence, analytical dashboards, hotspot detection, district intelligence, criminal-network analysis, and AI/ML-assisted insights in a secure decision-support platform for law-enforcement workflows. -DEPLOYMENT LINK: https://crime-intel-60079748823.development.catalystserverless.in/app/index.html +**Development / Demo Deployment:** +- **Web Application:** [https://crime-intel-60079748823.development.catalystserverless.in/app/index.html](https://crime-intel-60079748823.development.catalystserverless.in/app/index.html) +- **Backend API:** [https://crimeintel-backend-50044367664.development.catalystappsail.in](https://crimeintel-backend-50044367664.development.catalystappsail.in) -[![Frontend](https://img.shields.io/badge/Frontend-React%2019%20%2B%20Vite-61dafb)](#6-technology-stack) -[![Backend](https://img.shields.io/badge/Backend-FastAPI%20(Python)-009688)](#6-technology-stack) +[![Frontend](https://img.shields.io/badge/Frontend-React%2019%20%2B%20Vite%208-61dafb)](#6-technology-stack) +[![Backend](https://img.shields.io/badge/Backend-FastAPI%20(Python%203.10)-009688)](#6-technology-stack) [![Database](https://img.shields.io/badge/Database-Supabase%20PostgreSQL-3ecf8e)](#6-technology-stack) [![Auth](https://img.shields.io/badge/Auth-Supabase%20Auth%20%2B%20JWT-6f42c1)](#8-security--authentication) -[![GIS](https://img.shields.io/badge/GIS-Google%20Maps-4285F4)](#4-key-features) +[![Cache](https://img.shields.io/badge/Cache-L1%20Memory%20%2B%20L2%20Catalyst-ff9800)](#5-system-architecture) +[![GIS](https://img.shields.io/badge/GIS-Google%20Maps-4285F4)](#3-google-maps-integration) [![Deployment](https://img.shields.io/badge/Deployment-Zoho%20Catalyst-2e7d32)](#14-deployment) -**React + Vite · FastAPI · Supabase PostgreSQL · Supabase Auth + JWT · Google Maps · Zoho Catalyst** +**React + Vite · FastAPI · Supabase PostgreSQL · Supabase Auth + JWT · Multi-Tier Cache · Google Maps · Zoho Catalyst** @@ -25,68 +28,70 @@ DEPLOYMENT LINK: https://crime-intel-60079748823.development.catalystserverless. ## ⭐ Refined Prototype Phase — Upgrades & Enhancements -During the Refined Prototype Phase, CrimeIntel was enhanced beyond its initial functional prototype to improve deployment readiness, usability, accessibility, localization, privacy transparency, role-specific workflows, performance, and operational validation. +During the Refined Prototype Phase, CrimeIntel was enhanced beyond its initial functional prototype to improve deployment readiness, usability, accessibility, localization, privacy transparency, role-specific workflows, multi-tier caching performance, and operational validation. ### 🎨 UI/UX Refinement -- More polished professional interface with consistent visual hierarchy. -- Improved loading states, including skeleton/loading placeholders for perceived performance. -- Refined role-specific dashboards with better empty states. -- Enhanced navbar and footer structure. +- Polished professional interface with consistent visual hierarchy tailored for police intelligence operations. +- Smooth page transitions and loading states, including skeleton/loading placeholders for perceived performance. +- Refined role-specific dashboards with distinct views for Field Officers, Intelligence Analysts, and Administrators. +- Enhanced navigation bar, breadcrumbs, and standardized footer structure. - Back-to-top functionality and desktop-focused experience optimization. -- Consistent red/yellow/Karnataka Police visual identity. -- Professional settings/preferences experience. -- Added Resources and Support sections for better content presentation. +- Consistent Karnataka Police visual identity and styling system. +- Comprehensive user settings/preferences experience with instant persistence. +- Built-in Resources, Statutory Documents (PDF guides), and Support sections. ### 🗺️ Google Maps Upgrade -- Replaced the initial Leaflet-based mapping layer with **Google Maps**. -- Refined the geographic intelligence visualization experience to provide a more familiar, polished, and integrated interface. -- Integrated mapping more closely with the overall CrimeIntel design system. -- Preserved the existing crime-intelligence and analytical purpose of the map. -- Improved the overall usability and presentation of spatial crime information. +- Replaced the initial Leaflet-based mapping layer with **Google Maps** (`@vis.gl/react-google-maps`). +- Refined the geographic intelligence visualization experience to provide a familiar, high-performance, and integrated interface. +- Integrated mapping seamlessly with the overall CrimeIntel design system. +- Preserved all crime-intelligence and analytical capabilities (clusters, hotspots, density heatmaps, precinct boundaries). +- Enhanced interactive marker selection and detailed case telemetry popups. ### 🌐 Language & Accessibility -- Multilingual interface supporting English (UK/IN) and Kannada (ಕನ್ನಡ). -- Application UI localization for Kannada, including navigation, role-specific dashboards, forms, and messages. -- Language preference is available through Settings and persists across sessions. -- *(Note: Localization primarily targets user-facing interface content; actual identifiers and backend data remain unchanged.)* +- Multilingual interface supporting English and Kannada (ಕನ್ನಡ). +- Application UI localization for Kannada across navigation, dashboards, forms, telemetry labels, and alert messages. +- Language preference is configurable through Settings and persists across sessions. +- *(Note: Localization primarily targets user-facing interface content; actual identifiers and backend data remain canonical.)* ### ⚙️ User Preferences -- **Theme:** Light/Dark mode. -- **Formats:** Date format and Time format preferences. -- **Landing:** Default dashboard landing preference. -- **Language:** UI language preference. -These preferences persist to provide a tailored user experience. +- **Theme:** Light / Dark mode toggle with instant theme application. +- **Formats:** Configurable Date format and Time format preferences. +- **Landing:** Default dashboard landing preference based on operational role. +- **Language:** UI language selection (English / Kannada). +These preferences persist in local storage to provide a tailored user experience across sessions. ### 🔐 Privacy, Consent & Security UX -- **Cookie Consent:** Added a professional cookie consent banner for transparent data handling. -- **Privacy Policy & Terms of Service:** Added dedicated pages for transparency and user trust. -- **Security Guidelines:** Added a Security Guidelines page to communicate privacy-oriented UX. -- **Support:** Added a Contact Support experience with user-facing submission confirmation. +- **Cookie Consent:** Professional cookie consent banner for transparent data handling. +- **Privacy Policy & Terms of Service:** Dedicated pages for transparency and governance trust. +- **Security Guidelines:** Dedicated Security Guidelines documentation page. +- **Support:** Integrated Contact Support experience with user-facing submission confirmation. ### 👥 Role-Based Experience Refinement -- Refined role-specific navigation and dashboards for **Field Officer**, **Intelligent Analyst**, and **Administrator**. -- Tailored Field Officer and Intelligent Analyst workflows. -- Role-specific Kannada localization and RBAC-preserving navigation. +- Tailored role-specific navigation and workflows for **Field Officer**, **Intelligence Analyst**, and **Administrator**. +- Field Officer workflow focused on quick incident filtering, station beat cases, and local hotspot telemetry. +- Intelligence Analyst workflow focused on cross-district comparisons, temporal trends, DBSCAN spatial clustering, CCRI risk scoring, and predictive forecasting. +- Administrator workflow focused on security audit log queries, system status telemetry, and user management. ### ☁️ Zoho Catalyst Deployment -- Moved from a development-only environment toward a deployed application. -- **Zoho Catalyst AppSail:** FastAPI/Python backend runs as an AppSail service. The production API is accessible through Catalyst's AppSail infrastructure, which provides managed deployment/runtime infrastructure. -- **Catalyst CLI:** Used for deployment and project management. - -### 💾 Caching & Backend Optimization -- **Catalyst Cache Evaluation:** Catalyst Cache was evaluated as part of the refinement phase. The project investigated integrating Catalyst's managed Cache service with the AppSail backend. During validation, the AppSail runtime authentication model prevented the intended direct BaaS Cache integration without additional supported credentials/configuration. -- **In-Memory TTL Cache:** To maintain reliability and avoid introducing authentication/security risks, the final deployed application uses a thread-safe in-memory TTL response cache for appropriate read-heavy endpoints (Dashboard, Districts, Stations, Analytics, Intelligence Map). This provides caching benefits and reduces repeated computation without affecting application reliability. -- **Server Optimization:** Uvicorn/ASGI deployment optimized with stateless request handling, health/readiness endpoints, and cache initialization. +- **Zoho Catalyst AppSail:** FastAPI/Python backend runs as an AppSail service with Python 3.10 runtime. +- **Catalyst Web Client Hosting:** React 19 production build deployed to Catalyst Web Client Hosting. +- **Catalyst CLI:** Integrated deployment workflows and automated packaging. + +### 💾 Multi-Tier Caching & Backend Optimization +- **Multi-Tier Cache Architecture:** Implemented a two-tier caching architecture combining a fast L1 in-memory LRU cache and an L2 Zoho Catalyst Cache segment. +- **L1 In-Memory LRU Cache:** Thread-safe, bounded memory store (1,000 entries max) with per-item TTL expiration for sub-millisecond hot reads. +- **L2 Zoho Catalyst Cache:** Shared BaaS cache segment for distributed persistence across AppSail worker instances. +- **Promotion & Fallback:** Two-tier promotion on cache miss (L1 Miss $\rightarrow$ L2 Hit $\rightarrow$ Populate L1 $\rightarrow$ Return response). Cache failures fail safely directly to the repository layer without impacting API availability. +- **Single-Flight Coordination:** Concurrency lock preventing cache stampedes during concurrent cache misses for identical keys. +- **Public Root Endpoint:** Clean public `GET /` service status endpoint alongside health probes. ### 🚀 Scalability & Concurrency -- Validated concurrency: The deployed AppSail backend was tested with 10 concurrent simulated users. -- Validation achieved 100% success across tested endpoints, including Health, Readiness, Districts, District Intelligence, Analytics Summary, Forecast, and mixed endpoint traffic. +- Validated concurrency: The deployed AppSail backend handles concurrent simulated analytical workloads. +- Verified operational resilience across core endpoints: Health, Readiness, Districts, District Intelligence, Analytics Summary, Hotspots, and Forecasting. ### 🧪 Testing & Validation -- Backend test suite passed during refinement. -- Cache, Dashboard API, District API, Station API, and Intelligence Map API tests passed. -- Concurrency testing performed against deployed AppSail. -- 10 concurrent-user scenario achieved 100% success across tested endpoints. +- Automated backend test suite verifies **683 tests** (`683 passed, 92 deselected, 0 failed, 0 errors`). +- Comprehensive test coverage for JWT verification, server-side RBAC, multi-tier cache operations, rate limiting, audit logging, public root/health endpoints, and domain services. --- @@ -112,96 +117,96 @@ These preferences persist to provide a tailored user experience. ## 1. 🎯 Problem Statement -Law-enforcement agencies generate large volumes of information across FIRs, districts, police stations, arrests, chargesheets, victims, accused persons, and legal records. When these records remain fragmented across files and reporting systems, extracting timely intelligence becomes difficult. +Law-enforcement agencies generate large volumes of operational information across FIRs, districts, police stations, arrests, chargesheets, victims, accused persons, and legal records. When these records remain fragmented across siloed files and reporting systems, extracting timely, actionable intelligence becomes difficult. | Challenge | Impact | |-----------|--------| -| **Fragmented crime records** | Information must be combined manually before meaningful analysis. | -| **Limited analytical visibility** | Trends, hotspots, district variations, and relationships are difficult to identify quickly. | -| **Reactive decision-making** | Historical records exist, but converting them into actionable intelligence is difficult. | -| **Complex spatial and relational patterns** | Geographic concentrations and cross-case relationships can remain hidden in tabular records. | +| **Fragmented crime records** | Information must be combined manually before meaningful strategic or field analysis can occur. | +| **Limited analytical visibility** | High-level trends, geographic hotspots, district variations, and repeat-offender links are difficult to identify quickly. | +| **Reactive decision-making** | Historical records exist, but converting them into proactive operational intelligence requires specialized processing. | +| **Complex spatial and relational patterns** | Geographic concentrations and multi-case criminal networks remain hidden within flat tabular records. | --- ## 2. 💡 Solution Overview -**CrimeIntel** is an integrated crime analytics and intelligence platform that converts structured police data into operational and strategic insights. +**CrimeIntel** is an integrated AI-driven crime analytics and intelligence platform that converts structured police records into operational and strategic insights for data-driven policing. > **Crime Dashboard · Geographic Crime Map · Hotspot Intelligence · District Intelligence · Trend Analytics · Network Analysis · AI/ML Insights · Secure Decision Support** -CrimeIntel follows an API-first architecture. The React frontend consumes secured FastAPI services. Supabase provides authentication and hosted PostgreSQL infrastructure, while Zoho Catalyst is used for application deployment. +CrimeIntel follows an API-first architecture. The modern React frontend communicates with secured FastAPI backend services. Supabase provides authentication and hosted PostgreSQL infrastructure, Zoho Catalyst provides cloud deployment (AppSail and Web Client Hosting), and Zoho Catalyst Cache provides distributed L2 caching. ```text -User - ↓ -CrimeIntel Frontend - ↓ -Zoho Catalyst / AppSail - ↓ -FastAPI Backend - ↓ -Data + Analytics + ML - ↓ -Crime Intelligence Response +User / Officer + ↓ +React + Vite Frontend (Catalyst Web Client) + ↓ Bearer JWT (Supabase Auth) +FastAPI Backend (Zoho Catalyst AppSail) + ↓ +Multi-Tier Cache (L1 Memory → L2 Catalyst Cache) + ↓ (Cache Miss) +Repository & ML Analytics Layer + ↓ +Supabase PostgreSQL Database ``` --- ## 3. 🗺️ Google Maps Integration -During the Refined Prototype Phase, the original Leaflet-based mapping implementation was replaced with **Google Maps** (`@vis.gl/react-google-maps`) to provide a more familiar, polished, and integrated geographic visualization experience. +During the Refined Prototype Phase, the initial mapping layer was upgraded to **Google Maps** (`@vis.gl/react-google-maps`) to provide a familiar, high-performance, and professional geographic visualization experience. -This refinement aligns the mapping experience with the product's professional, production-oriented interface, maintaining the exact same analytical capabilities but presenting them with enhanced usability and visual hierarchy. +This integration aligns the spatial analysis workflow with modern law-enforcement UX standards while preserving all crime-intelligence and spatial telemetry features. ### Prototype Evolution -| Mapping Layer | Initial Prototype | Refined Prototype | +| Capability | Initial Prototype | Refined / Final Implementation | |---|---|---| -| Mapping Technology | Leaflet | Google Maps | -| Geographic Visualization | ✅ | ✅ Enhanced | -| Crime Locations | ✅ | ✅ | -| Hotspot Visualization | ✅ | ✅ | -| Heatmap/Spatial Analysis | Existing prototype capability | Refined Google Maps experience | -| Role-Based Map Usage | Existing | Refined | +| Mapping Technology | Leaflet | Google Maps (`@vis.gl/react-google-maps`) | +| Geographic Visualization | Basic | Enhanced Vector Maps + Satellite Layers | +| Crime Locations | Coordinate plotting | Interactive markers with rich metadata cards | +| Hotspot Detection | Bounded boxes | Dynamic DBSCAN clusters & intensity gradients | +| Density Heatmaps | Basic raster | Native high-density heatmap layers | +| Incident Clustering | Marker grouping | High-performance dynamic coordinate clustering | +| Role-Based Workflows | Unified map | Role-tailored Field Map & Intelligence Map | ### Geographic Capabilities -CrimeIntel uses Google Maps to provide an interactive geographic intelligence layer. Current implementation features include: -- **Crime Location Visualization:** Precise coordinate plotting of incidents. -- **Crime Hotspots:** Visualizing geographic concentrations of recorded crime. -- **Heatmap Visualization:** Rendering intensity maps based on location density. -- **Cluster Visualization:** Grouping dense incident records for easier map navigation. -- **Location-Based Intelligence:** Clicking map markers reveals detailed case insights. - -*(Note: Google Maps credentials/API keys are securely managed through environment configuration (`.env.production`) and must not be committed to source control.)* +- **Crime Location Visualization:** Precise coordinate plotting of incidents with crime category color coding. +- **Crime Hotspots:** Visualizing geographic concentrations of recorded crime for patrol beat planning. +- **Heatmap Visualization:** Rendering density intensity maps across urban and rural police precincts. +- **Cluster Visualization:** Dynamically grouping dense incident records for clean map navigation. +- **Location-Based Telemetry:** Interactive modal inspection with FIR summary, IPC sections, and station jurisdiction. --- ## 4. ✨ Key Features -### 📊 Interactive Crime Dashboard -Consolidated crime indicators, district distribution, trends, recent cases, and intelligence summaries. +### 📊 Interactive Executive & Role Dashboards +Consolidated crime KPIs, arrest rates, chargesheet distributions, temporal trends, district comparative metrics, and executive summaries tailored for Field Officers, Intelligence Analysts, and Station Commanders. -### 🗺️ Karnataka Geographic Crime Map -Powered by Google Maps, providing interactive geographic visualization of crime incidents, filters, clusters, heatmaps, and hotspot information. +### 🗺️ Geospatial Crime Intelligence (Google Maps) +Interactive geographic visualization of incidents, precinct boundaries, dynamic DBSCAN clusters, density heatmaps, and spatial hotspot detection. ### 🔥 Crime Hotspot Intelligence -Highlights geographic concentrations of crime to support location-focused analysis and operational planning. +Identifies geographic concentrations of crime using spatial clustering algorithms to support targeted patrol deployment and resource allocation. ### 📈 Trend & Temporal Analytics -Explores crime patterns over time through timeline analysis, crime-category trends, temporal distributions, and district comparisons. +Explores crime patterns across time through multi-year timeline analysis, seasonal category trends, day/night temporal distributions, and cross-district comparative metrics. -### 🏙️ District Intelligence -Provides district-level statistics, category breakdowns, police-station information, recent cases, and hotspot summaries. +### 🏙️ District & Station Intelligence Profiles +Comprehensive district-level profiles across all 31 Karnataka districts, including station rosters, crime category breakdowns, CCRI risk indicators, and recent FIR records. ### 🔗 Criminal Network Analysis -Builds deterministic relationship graphs from FIR-person relationships to reveal linked FIRs, person-case relationships, and co-accused connections. +Builds deterministic relationship graphs from FIR-person relationships to reveal linked FIRs, person-case connections, co-accused associations, and repeat-offender clusters. -### 🧠 AI/ML-Assisted Intelligence -Provides an extensible intelligence layer for validated predictive risk, anomaly detection, forecasting, and related model-driven analytics. +### 🧠 AI/ML Analytics & Forecasting +- **DBSCAN Spatial Hotspots:** Pre-computed density-based spatial clustering of incident coordinates. +- **Composite Crime Risk Index (CCRI):** Multi-factor risk scoring and ranking across police precincts. +- **Predictive Forecasting:** Multi-day crime incident volume projections. ### 📤 Reporting, Export & Decision Support -Provides bounded operational CSV export, interactive visualizations, and analytical workflows supporting evidence-based policing. +Provides bounded operational CSV/PDF export, interactive visualizations, and structured analytical dossiers supporting evidence-based decision-making. --- @@ -216,105 +221,130 @@ Provides bounded operational CSV export, interactive visualizations, and analyti | v +----------------------------------------------------------------+ -| React + Vite Frontend | -| Dashboard | Crime Map | District | Network | Analytics | Reports| -+------------------------------+---------------------------------+ - | - Bearer JWT - | - v +| React + Vite Frontend | +| Dashboard | Field Map | Intelligence Map | District | Network | ++---------------------------------+------------------------------+ + | + Bearer JWT + | + v ++----------------------------------------------------------------+ +| FastAPI Backend | +| Public Root (GET /) | Health Probes | Protected APIs | ++---------------------------------+------------------------------+ + | + +---------------------------+---------------------------+ + | | + v v ++-----------------------------+ +-----------------------------+ +| L1 In-Memory LRU Cache | | Authentication & RBAC | +| Thread-Safe · TTL Bounded | | JWT Verify · Claim Map | ++--------------+--------------+ +-----------------------------+ + | (cache miss) + v ++-----------------------------+ +| L2 Zoho Catalyst Cache | +| Segment Store · Fail-Safe | ++--------------+--------------+ + | (cache miss) + v +----------------------------------------------------------------+ -| FastAPI Backend | -| Auth | Security | Audit | Validation | REST APIs | -| Dashboard | Maps | Districts | Stations | Network | Export | -+------------------------------+---------------------------------+ - | - Repository / Service Layer - | - v +| Repository & Service Layer | +| PostgreSQL Repositories | ML & Analytics Engine | ++---------------------------------+------------------------------+ + | + v +----------------------------------------------------------------+ -| Supabase Platform | -| Supabase Auth PostgreSQL Database | -| Session + JWT Production Persistence | +| Supabase Platform | +| Supabase Auth PostgreSQL Database | +| Session + JWT Production Persistence | +----------------------------------------------------------------+ - Deployment Platform: Zoho Catalyst AppSail + Cloud Deployment: Zoho Catalyst (AppSail + Web Client Hosting) ``` -Architecture principles include API-first separation, repository abstraction, backend-enforced authentication, privacy-aware responses, evidence-based analytics, and an extensible ML/GIS integration layer. +### Multi-Tier Caching Architecture + +1. **L1 In-Memory LRU Cache:** Fast in-process cache storing serializable response objects with per-item TTL expiration and capacity bounds (1,000 entries max). +2. **L2 Zoho Catalyst Cache:** Shared BaaS cache segment in Zoho Catalyst providing cross-worker cache persistence. +3. **Promotion on Miss:** When an L1 cache miss occurs, the system queries L2. On an L2 hit, the item is promoted to L1 for subsequent sub-millisecond retrieval. +4. **Single-Flight Stampede Protection:** Mutex coordination prevents redundant simultaneous backend queries for identical cache keys during heavy traffic. +5. **Fail-Safe Operation:** If L2 cache is unreachable or unconfigured, the application gracefully degrades to L1 and repository queries without throwing API errors. +6. **Targeted Invalidation:** Mutation operations trigger coordinated prefix and key invalidation across both L1 and L2 layers. --- ## 6. 🧰 Technology Stack -| Layer | Technologies | -|-------|--------------| -| **Frontend** | React 19, Vite, JavaScript/JSX, Tailwind CSS | -| **Visualization** | Interactive charts, Google Maps | -| **Backend** | Python, FastAPI, Uvicorn, Pydantic | -| **Database** | PostgreSQL hosted on Supabase | -| **Authentication** | Supabase Auth, JWT | -| **Data Access** | Repository pattern with PostgreSQL and CSV adapters | -| **Analytics/ML** | Python, Pandas, NumPy, Scikit-learn, XGBoost, DBSCAN | -| **GIS** | Google Maps (`@vis.gl/react-google-maps`), coordinate-based spatial visualization | -| **Network Intelligence** | FIR-person relationship graph analysis | -| **Testing** | Pytest | -| **Version Control** | Git, GitHub | -| **Cloud/Deployment**| Zoho Catalyst, AppSail, Catalyst CLI | +| Layer | Technologies | Version / Details | +|---|---|---| +| **Frontend** | React 19, Vite 8, JavaScript/JSX | Modern component-driven UI | +| **Styling & UI** | Tailwind CSS, Framer Motion, Lucide React | Clean, professional dark/light police design | +| **Mapping / GIS** | Google Maps (`@vis.gl/react-google-maps`) | Coordinate plotting, heatmaps, clustering | +| **Backend** | Python 3.10, FastAPI, Uvicorn, Pydantic v2 | High-performance asynchronous REST API | +| **Database** | PostgreSQL hosted on Supabase | Relational persistence, connection pooling | +| **Authentication** | Supabase Auth, JWT (PyJWT, Cryptography) | Cryptographic Bearer token verification | +| **Authorization** | Server-Side RBAC | Least-privilege role & permission mapping | +| **Caching** | Multi-Tier Cache (L1 LRU + L2 Zoho Catalyst) | In-memory TTL + Catalyst BaaS segment | +| **Analytics & ML** | Pandas, NumPy, Scikit-learn, XGBoost | DBSCAN clustering, CCRI scoring, forecasting | +| **Network Intelligence**| Graph analysis | FIR-person relationship link analysis | +| **Testing** | Pytest, TestClient, AnyIO | Automated testing suite (683 tests) | +| **Cloud Hosting** | Zoho Catalyst AppSail & Web Client Hosting | Managed serverless deployment | --- ## 7. 📋 Data & Transparency -CrimeIntel follows a strict data-transparency principle: crime records, identities, coordinates, model outputs, and district statistics must not be fabricated and represented as authoritative information. - -The data layer supports structured entities including districts, police stations, FIRs, FIR-person relationships, arrests, chargesheets, and crime attributes. +CrimeIntel adheres to strict data-integrity and privacy principles: -**Key principles:** - -- Approved datasets remain the source of truth. -- Synthetic values must not be silently mixed with real government records. -- Person-level PII is excluded from general analytical API responses. -- Persistence is separated from analytics through repository and service layers. -- Dataset provenance and limitations should remain documented as additional sources are integrated. -- Production ingestion supports batched PostgreSQL upserts and repeatable ingestion. +- **Approved Datasets as Source of Truth:** Official crime records, FIR details, district boundaries, and station rosters serve as authoritative sources. +- **No Unattributed Synthetic Data:** Synthetic or demo data is clearly separated and never presented as authoritative government records. +- **PII Exclusion in Analytical APIs:** Person-level sensitive details (victim identities, full PII) are excluded from general analytical responses. +- **Architectural Separation:** Persistence, business logic, caching, and presentation layers are decoupled via repository contracts. +- **Data Backend Support:** Production persistence relies on PostgreSQL on Supabase, with an in-memory/CSV repository adapter available for offline unit testing. --- ## 8. 🔐 Security & Authentication -CrimeIntel uses **Supabase Auth** for frontend authentication and **FastAPI JWT verification** for backend enforcement. +CrimeIntel implements a Zero-Trust, deny-by-default security architecture combining **Supabase Auth** for client sessions and **FastAPI JWT validation** for backend API enforcement. ```text -User - | - v -React Login - | - | Supabase URL + Publishable/Anon Key - v -Supabase Auth - | - | Session + Access JWT - v -Frontend API Client - | - | Authorization: Bearer - v -FastAPI Authentication Middleware - | - | Verify identity - v -Protected CrimeIntel APIs +User / Officer + | + v +React Frontend (Supabase Client) + | + | Sign in with departmental credentials + v +Supabase Auth Service + | + | Returns Session + Access JWT + v +Frontend API Client (`fetchAPI`) + | + | Authorization: Bearer + v +FastAPI Authentication Middleware & Dependencies (`get_current_identity`) + | + +---> Cryptographic JWT signature & expiry check (JWKS / Secret) + +---> Server-side RBAC claim resolution (Admin / Analyst / Officer) + +---> Route permission check (e.g. `dashboard.read`, `map.read`) + | + v +Protected CrimeIntel API Handlers ``` -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. +### Security Controls -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. +- **Public vs Protected Endpoints:** + - **Public Endpoints:** Root service status (`GET /`) and health probes (`/health`, `/health/live`, `/health/ready`) are accessible without authentication. + - **Protected Endpoints:** All `/api/v1/*` routes require a valid Bearer JWT. Unauthenticated requests are rejected with `HTTP 401 TOKEN_MISSING`. +- **Cryptographic Verification:** Validates token signatures against Supabase JWKS / JWT secrets, checking issuer, audience, and expiry claims. +- **Server-Side RBAC:** Maps claims to validated roles (`ADMIN`, `ANALYST`, `FIELD_OFFICER`) with explicit granular permissions (`dashboard.read`, `map.intelligence.read`, `audit.read`, etc.). +- **Rate Limiting:** In-process fixed-window rate limiter protecting cost-heavy endpoints (export, search, audit). +- **Security Headers:** Enforces `Cache-Control: no-store` on authenticated API responses, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: strict-origin-when-cross-origin`. +- **Security Audit Trail:** Immutable append-only audit logging recording security events and denied access attempts. --- @@ -324,38 +354,36 @@ Database Row Level Security is enabled on all tables (`supabase/migrations/005_r Datathon/ ├── backend/ │ ├── app/ -│ │ ├── api/ # FastAPI routes -│ │ ├── analytics/ # Analytical logic -│ │ ├── core/ # Auth, config, logging, audit, errors +│ │ ├── api/ # FastAPI routes (dashboard, districts, maps, stations, analytics, auth) +│ │ ├── core/ # Auth dependencies, JWT verification, cache, audit, rate limit, config │ │ ├── database/ -│ │ │ ├── repositories/ # Repository contracts/adapters -│ │ │ ├── postgres/ # PostgreSQL implementation -│ │ │ └── ingest/ # Data ingestion -│ │ ├── models/ -│ │ ├── schemas/ -│ │ ├── services/ -│ │ └── main.py -│ ├── tests/ +│ │ │ ├── postgres/ # PostgreSQL implementation and connection pooling +│ │ │ ├── repositories/ # Repository contracts and CSV fallback adapters +│ │ │ └── ingest/ # Data ingestion utilities +│ │ ├── models/ # Pydantic and domain models +│ │ ├── schemas/ # Request/response schemas +│ │ ├── services/ # Business logic and intelligence services +│ │ └── main.py # Application entry point, middleware, and route registration +│ ├── tests/ # Pytest test suite (auth, rbac, cache, api, health, repositories) │ ├── requirements.txt +│ ├── app-config.json # Catalyst AppSail configuration │ └── .env.example ├── frontend/ │ ├── src/ -│ │ ├── api/ # API client and Supabase auth -│ │ ├── modules/ -│ │ │ ├── analytics/ -│ │ │ ├── dashboard/ -│ │ │ ├── district-intelligence/ -│ │ │ ├── hotspot-detection/ -│ │ │ ├── karnataka-crime-map/ -│ │ │ ├── network-analysis/ -│ │ │ ├── reports/ -│ │ │ └── settings/ -│ │ └── App.jsx +│ │ ├── components/ # Reusable UI elements, navigation, notifications, modals +│ │ ├── context/ # AuthContext (Supabase session), NotificationContext +│ │ ├── modules/ # Analytics, dashboard, district-intelligence, hotspot-detection, map, network +│ │ ├── services/ # Unified API client (`api.js`) and Supabase client (`supabase.js`) +│ │ ├── utils/ # PDF generation, formatters +│ │ └── App.jsx # Main routing, role switching, and view controller +│ ├── public/ # Public assets, icons, documentation resources │ ├── package.json +│ ├── vite.config.js │ └── .env.example -├── data/ -├── docs/ -├── ml-engine/ +├── catalyst-web-client/ # Catalyst Web Client build output directory +├── docs/ # Architecture, API specifications, and RBAC documentation +├── ml-engine/ # ML models, clustering notebooks, and training pipelines +├── catalyst.json # Catalyst project deployment configuration └── README.md ``` @@ -365,19 +393,19 @@ Datathon/ ### Prerequisites -- Python -- Node.js and npm -- Git -- Access to the configured Supabase project for live database/auth testing +- **Python 3.10+** +- **Node.js 18+ and npm** +- **Git** +- Access to Supabase project (URL and publishable/anon key) -### Clone +### 1. Clone Repository ```bash -git clone +git clone https://github.com/Nirmal0804/Datathon.git cd Datathon ``` -### Backend +### 2. Backend Setup ```bash cd backend @@ -385,41 +413,32 @@ python -m venv .venv ``` Windows PowerShell: - ```powershell .\.venv\Scripts\Activate.ps1 pip install -r requirements.txt Copy-Item .env.example .env -uvicorn app.main:app --reload -``` - -Backend: - -```text -http://localhost:8000 +uvicorn app.main:app --reload --port 8000 ``` -### Frontend +Backend will be available at `http://localhost:8000`. -In another terminal: +### 3. Frontend Setup +In a separate terminal: ```bash cd frontend npm install +Copy-Item .env.example .env npm run dev ``` -Frontend: - -```text -http://localhost:5173 -``` +Frontend will be available at `http://localhost:5173`. --- ## 11. 🔧 Configuration -Representative backend configuration: +### Backend Configuration (`backend/.env`) ```env APP_NAME=crime-analytics-backend @@ -427,148 +446,207 @@ ENVIRONMENT=development API_PREFIX=/api/v1 CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] +# Database Configuration DATA_BACKEND=postgres -DATABASE_URL= +DATABASE_URL=postgresql://postgres:[PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres +# Authentication Configuration REQUIRE_AUTH=true +SUPABASE_PROJECT_REF=your-project-ref +SUPABASE_JWKS_URL=https://your-project-ref.supabase.co/auth/v1/.well-known/jwks.json +SUPABASE_JWT_ISSUER=https://your-project-ref.supabase.co/auth/v1 +SUPABASE_JWT_AUDIENCE=authenticated + +# L1 In-Memory Response Cache +CACHE_ENABLED=true +CACHE_TTL_SECONDS=600 +CACHE_MAX_ENTRIES=1000 + +# L2 Zoho Catalyst Cache +CACHE_L2_ENABLED=true +CACHE_L2_SEGMENT_ID=your-catalyst-cache-segment-id +CACHE_L2_TTL_SECONDS=600 ``` -Representative frontend configuration: +### Frontend Configuration (`frontend/.env`) ```env -VITE_API_BASE_URL=http://localhost:8000 -VITE_SUPABASE_URL= -VITE_SUPABASE_ANON_KEY= +VITE_API_BASE_URL=http://localhost:8000/api/v1 +VITE_SUPABASE_URL=https://your-project-ref.supabase.co +VITE_SUPABASE_ANON_KEY=your-supabase-publishable-or-anon-key +VITE_GOOGLE_MAPS_API_KEY=your-google-maps-api-key ``` -Use the repository `.env.example` files as the authoritative configuration reference. Never commit real server credentials. +*(Note: Never commit real passwords, secret keys, or service-role keys to source control.)* --- ## 12. 📡 API Overview -### Health +### Public Service & Health Probes -| Method | Endpoint | Purpose | -|--------|----------|---------| -| GET | `/health` | Application/dependency health | -| GET | `/health/live` | Liveness probe | -| GET | `/health/ready` | Readiness probe | +| Method | Endpoint | Description | Auth Required | +|---|---|---|---| +| GET | `/` | Public root service status and message | No | +| GET | `/health` | Application status, database connection & cache statistics | No | +| GET | `/health/live` | Process liveness probe | No | +| GET | `/health/ready` | Readiness probe verifying PostgreSQL connection | No | ### Authentication -| Method | Endpoint | Purpose | -|--------|----------|---------| -| GET | `/api/v1/auth/me` | Backend-verified authenticated identity | +| Method | Endpoint | Description | Auth Required | +|---|---|---|---| +| GET | `/api/v1/auth/me` | Returns server-verified identity and RBAC role | Yes (`Bearer`) | -### Dashboard +### Executive Dashboard -| Method | Endpoint | -|--------|----------| -| GET | `/api/v1/dashboard/summary` | +| Method | Endpoint | Description | Auth Required | +|---|---|---|---| +| GET | `/api/v1/dashboard/summary` | State-level KPIs, arrest rates, chargesheet distributions | Yes (`Bearer`) | ### Field Crime Map -| Method | Endpoint | -|--------|----------| -| GET | `/api/v1/map/field/cases` | -| GET | `/api/v1/map/field/case/{fir_identifier}` | -| GET | `/api/v1/map/field/filters` | -| GET | `/api/v1/map/field/hotspots` | +| Method | Endpoint | Description | Auth Required | +|---|---|---|---| +| GET | `/api/v1/map/field/cases` | Paginated FIR case records with geospatial filters | Yes (`Bearer`) | +| GET | `/api/v1/map/field/case/{fir_identifier}` | Detailed FIR record and case summary | Yes (`Bearer`) | +| GET | `/api/v1/map/field/filters` | Distinct filter options (districts, stages, crime types) | Yes (`Bearer`) | +| GET | `/api/v1/map/field/hotspots` | Beat-level spatial crime clusters | Yes (`Bearer`) | + +### Intelligence Map & Analytics + +| Method | Endpoint | Description | Auth Required | +|---|---|---|---| +| GET | `/api/v1/map/intelligence/analytics` | High-level spatial intelligence metrics | Yes (`Bearer`) | +| GET | `/api/v1/map/intelligence/heatmap` | Spatial density coordinate points for heatmaps | Yes (`Bearer`) | +| GET | `/api/v1/map/intelligence/clusters` | Station-level incident clusters | Yes (`Bearer`) | +| GET | `/api/v1/map/intelligence/hotspots` | DBSCAN spatial hotspot centroids | Yes (`Bearer`) | +| GET | `/api/v1/map/intelligence/district-comparison` | Multi-district comparative crime metrics | Yes (`Bearer`) | +| GET | `/api/v1/map/intelligence/timeline` | Temporal crime incident trend data | Yes (`Bearer`) | +| GET | `/api/v1/map/intelligence/export` | Bounded operational CSV intelligence export | Yes (`Bearer`) | + +### District & Station Intelligence + +| Method | Endpoint | Description | Auth Required | +|---|---|---|---| +| GET | `/api/v1/districts` | List all 31 districts with summary metrics | Yes (`Bearer`) | +| GET | `/api/v1/districts/{district_id}/intelligence` | Single-district comprehensive intelligence dossier | Yes (`Bearer`) | +| GET | `/api/v1/stations` | List police stations with optional district filtering | Yes (`Bearer`) | +| GET | `/api/v1/stations/{station_id}` | Detailed police precinct profile and metrics | Yes (`Bearer`) | + +### Predictive & ML Analytics + +| Method | Endpoint | Description | Auth Required | +|---|---|---|---| +| GET | `/api/v1/analytics/summary` | Executive ML dashboard summary metrics | Yes (`Bearer`) | +| GET | `/api/v1/analytics/hotspots` | DBSCAN spatial cluster summaries | Yes (`Bearer`) | +| GET | `/api/v1/analytics/risk-scores` | Station-level CCRI risk ranks, scores, and tiers | Yes (`Bearer`) | +| GET | `/api/v1/analytics/forecast` | Daily crime incident volume forecast (1 to 30 days) | Yes (`Bearer`) | + +### Criminal Network Analysis + +| Method | Endpoint | Description | Auth Required | +|---|---|---|---| +| GET | `/api/v1/network/graph` | Deterministic FIR-person relationship graph | Yes (`Bearer`) | +| GET | `/api/v1/network/entities/{type}/{id}` | Entity detail (FIR, person, station) | Yes (`Bearer`) | +| GET | `/api/v1/network/search` | Multi-entity cross-reference search | Yes (`Bearer`) | + +### Security & Administration + +| Method | Endpoint | Description | Auth Required | +|---|---|---|---| +| GET | `/api/v1/admin/audit/events` | Query security audit event logs | Yes (`admin`) | -### Intelligence Map - -| Method | Endpoint | -|--------|----------| -| GET | `/api/v1/map/intelligence/analytics` | -| GET | `/api/v1/map/intelligence/heatmap` | -| GET | `/api/v1/map/intelligence/clusters` | -| GET | `/api/v1/map/intelligence/hotspots` | -| GET | `/api/v1/map/intelligence/district-comparison` | -| GET | `/api/v1/map/intelligence/timeline` | -| GET | `/api/v1/map/intelligence/export` | - -### District Intelligence - -| Method | Endpoint | -|--------|----------| -| GET | `/api/v1/districts` | -| GET | `/api/v1/districts/{district_id}/intelligence` | - -### Stations +--- -| Method | Endpoint | -|--------|----------| -| GET | `/api/v1/stations` | -| GET | `/api/v1/stations/{station_id}` | +## 13. ✅ Testing & Reliability -### Network Intelligence +CrimeIntel is backed by a comprehensive automated test suite. -| Method | Endpoint | Purpose | -|--------|----------|---------| -| GET | `/api/v1/network/graph` | Relationship graph | -| GET | `/api/v1/network/entities/{entity_type}/{entity_id}` | Entity detail | -| GET | `/api/v1/network/search` | Cross-entity search | +```powershell +python -m pytest tests/ -k "not test_production_db_migration" +``` -### Admin +**Verified Test Result:** +```text +===================== 683 passed, 92 deselected in 18.35s ===================== +``` -| Method | Endpoint | Purpose | -|--------|----------|---------| -| GET | `/api/v1/admin/audit/events` | Audit trail query (requires `audit.read`; 503 in CSV/dev) | +### Reliability & Verification Coverage -For exact query parameters and response contracts, refer to the API contract under `docs/`. +- **Authentication & JWT Security:** Valid token resolution, expired/malformed token rejection, JWKS validation, and algorithm-confusion protection. +- **Route Protection:** Deny-by-default access verification across all protected routes. +- **Public Root & Probes:** Verified `GET /`, `/health`, `/health/live`, and `/health/ready` responses. +- **Server-Side RBAC:** Verified least-privilege role mappings and permission gates for Field Officers, Analysts, and Admins. +- **Multi-Tier Cache Operations:** Unit and integration tests for L1 LRU memory store, L2 Catalyst Cache fallback, two-tier promotion, TTL eviction, and concurrency stampede coordination. +- **Rate Limiting:** Verified fixed-window rate limiter on standard, search, export, and audit routes. +- **Domain Services:** Tested dashboard metrics, district intelligence dossiers, station telemetry, DBSCAN hotspots, CCRI risk scoring, network graphs, and CSV data loader. --- -## 13. ✅ Testing & Reliability +## 14. 📦 Deployment -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. +CrimeIntel is deployed to **Zoho Catalyst** in a Development / Demo environment. -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`. -During the refinement phase, cache tests, concurrency testing, and individual module API tests passed successfully. The 10 concurrent-user scenario achieved 100% success across tested endpoints. +```text + Zoho Catalyst Cloud + +--------------------------------+ +Browser ----> | Catalyst Web Client Hosting | (React 19 + Vite 8 App) + +---------------+----------------+ + | + v + +--------------------------------+ + | Catalyst AppSail (Python) | (FastAPI Service) + +---------------+----------------+ + | + +---------------+----------------+ + | | + v v + Zoho Catalyst Cache Supabase Cloud + (Distributed L2 Cache) (Auth + PostgreSQL Database) +``` ---- +### Deployed Endpoints (Development / Demo) -## 14. 📦 Deployment +- **Frontend (Web Client):** [https://crime-intel-60079748823.development.catalystserverless.in/app/index.html](https://crime-intel-60079748823.development.catalystserverless.in/app/index.html) +- **Backend (AppSail):** [https://crimeintel-backend-50044367664.development.catalystappsail.in](https://crimeintel-backend-50044367664.development.catalystappsail.in) -CrimeIntel uses **Zoho Catalyst** for application deployment. +### Deployment Commands -```text - Zoho Catalyst - +-----------------------+ -Browser ---> | React Web Application | - +-----------+-----------+ - | - v - +-----------------------+ - | FastAPI / AppSail | - +-----------+-----------+ - | - +----------+----------+ - | | - v v - Supabase Auth Supabase PostgreSQL +To deploy the AppSail backend: +```bash +catalyst deploy --only appsail:crimeintel-backend --ignore-scripts ``` -The React/Vite production build is hosted as the web application, while the FastAPI backend runs through Catalyst AppSail. Supabase remains responsible for authentication and PostgreSQL persistence. - -Production deployment must configure the final frontend origin in CORS and supply production environment variables securely. +To build and deploy the React frontend: +```bash +cd frontend +npm run build:catalyst +cd .. +catalyst deploy --only webclient +``` --- ## 15. 🔮 Production Extensions -Implemented this iteration: +### Implemented in Current Release + +- **Role-Based Access Control (RBAC):** Server-side role mapping (`FIELD_OFFICER`, `ANALYST`, `ADMIN`) with granular permissions. +- **Multi-Tier Response Caching:** L1 in-process LRU cache combined with L2 Zoho Catalyst Cache segment. +- **Public Root Endpoint:** Public `GET /` service response alongside health probes. +- **Google Maps GIS Integration:** Advanced coordinate plotting, clustering, and heatmap layers. +- **AI/ML Predictive Analytics:** DBSCAN spatial clustering, precinct CCRI risk scoring, and 30-day crime volume forecasting. +- **Security Audit Logging:** Append-only security audit trail with query API. +- **Multi-Language Support:** English and Kannada interface localization. +- **User Preference Persistence:** Theme, date/time formatting, and default landing preferences. -- **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`). +### Planned Future Extensions -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. +- **Distributed Rate Limiting:** Migrating in-process rate limiting to distributed store for multi-region active-active deployments. +- **Authoritative Boundary Ingestion:** Integration with state GIS department boundary polygons. +- **Advanced Real-Time Telemetry:** WebSocket integration for live officer location tracking and dispatch events. +- **Expanded Statutory PDF Reporting:** Additional statutory compliance templates for automated court dossier generation. --- @@ -578,7 +656,7 @@ Remaining as departmental requirements/authoritative artifacts become available: **Crime Intelligence · Geospatial Analytics · Network Analysis · Decision Support** -### From fragmented crime records to actionable intelligence. +### Transforming fragmented crime records into actionable intelligence. **Built for Datathon 2026** diff --git a/backend/.env.example b/backend/.env.example index 046682a..bf8a141 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -52,4 +52,4 @@ DATA_BACKEND=csv # 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"] +# RBAC_ROLE_CLAIM_PATHS=["app_metadata.role","role"] diff --git a/backend/app-config.json b/backend/app-config.json index 1c0499d..aaeab92 100644 --- a/backend/app-config.json +++ b/backend/app-config.json @@ -2,7 +2,20 @@ "command": "python3 main.py", "build_path": "./", "stack": "python_3_10", - "env_variables": {}, + "env_variables": { + "REQUIRE_AUTH": "true", + "ENVIRONMENT": "production", + "CACHE_L2_ENABLED": "true", + "CACHE_L2_SEGMENT_ID": "42154000000087004", + "CACHE_L2_TTL_SECONDS": "600", + "CACHE_ENABLED": "true", + "CACHE_TTL_SECONDS": "600", + "CACHE_MAX_ENTRIES": "1000", + "SUPABASE_PROJECT_REF": "gcxppkdtbvmleynrzqao", + "SUPABASE_JWKS_URL": "https://gcxppkdtbvmleynrzqao.supabase.co/auth/v1/.well-known/jwks.json", + "SUPABASE_JWT_ISSUER": "https://gcxppkdtbvmleynrzqao.supabase.co/auth/v1", + "SUPABASE_JWT_AUDIENCE": "authenticated" + }, "memory": 512, "scripts": { "preserve": "python -m pip install --platform manylinux2014_x86_64 --target ./vendor --implementation cp --python-version 310 --only-binary=:all: --upgrade -r ./requirements.txt", diff --git a/backend/app/api/analytics.py b/backend/app/api/analytics.py index fc2570f..4a0be6d 100644 --- a/backend/app/api/analytics.py +++ b/backend/app/api/analytics.py @@ -7,8 +7,9 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request +from app.api.rbac_deps import require_permission from app.core.cache import CatalystCacheService, get_cache_service from app.schemas.analytics import ( DashboardMLSummaryPayload, @@ -16,6 +17,7 @@ HotspotsPayload, StationRiskPayload, ) +from app.schemas.auth import AuthenticatedIdentity from app.services.ml_analytics_service import MLAnalyticsService router = APIRouter(prefix="/analytics", tags=["analytics"]) @@ -33,17 +35,19 @@ def get_ml_analytics_service() -> MLAnalyticsService: description="Returns pre-computed DBSCAN spatial cluster summaries and assigned FIR hotspot records.", ) async def get_hotspots( + request: Request, service: MLAnalyticsService = Depends(get_ml_analytics_service), cache: CatalystCacheService = Depends(get_cache_service), + _identity: AuthenticatedIdentity = Depends(require_permission("analytics.read")), ) -> HotspotsPayload: """Get DBSCAN geospatial hotspots analysis payload.""" cache_key = "analytics_hotspots" - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return HotspotsPayload(**cached) data = service.get_hotspots() - cache.put(cache_key, data) + cache.put(cache_key, data, req=request) return HotspotsPayload(**data) @@ -54,17 +58,19 @@ async def get_hotspots( description="Returns station-level CCRI risk ranks, scores, tiers, and indicator factor breakdowns.", ) async def get_risk_scores( + request: Request, service: MLAnalyticsService = Depends(get_ml_analytics_service), cache: CatalystCacheService = Depends(get_cache_service), + _identity: AuthenticatedIdentity = Depends(require_permission("analytics.read")), ) -> StationRiskPayload: """Get station risk scores payload.""" cache_key = "analytics_risk_scores" - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return StationRiskPayload(**cached) data = service.get_station_risk_scores() - cache.put(cache_key, data) + cache.put(cache_key, data, req=request) return StationRiskPayload(**data) @@ -75,6 +81,7 @@ async def get_risk_scores( description="Returns predicted daily crime incident volume for N days ahead (1 to 30 days).", ) async def get_forecast( + request: Request, forecast_days: int = Query( default=30, ge=1, @@ -83,15 +90,16 @@ async def get_forecast( ), service: MLAnalyticsService = Depends(get_ml_analytics_service), cache: CatalystCacheService = Depends(get_cache_service), + _identity: AuthenticatedIdentity = Depends(require_permission("analytics.read")), ) -> ForecastPayload: """Get daily crime volume forecast payload.""" cache_key = f"analytics_forecast_{forecast_days}" - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return ForecastPayload(**cached) data = service.get_forecast(forecast_days=forecast_days) - cache.put(cache_key, data) + cache.put(cache_key, data, req=request) return ForecastPayload(**data) @@ -102,15 +110,17 @@ async def get_forecast( description="Returns aggregate spatial hotspot totals, station risk distributions, and 30-day forecast volume.", ) async def get_summary( + request: Request, service: MLAnalyticsService = Depends(get_ml_analytics_service), cache: CatalystCacheService = Depends(get_cache_service), + _identity: AuthenticatedIdentity = Depends(require_permission("analytics.read")), ) -> DashboardMLSummaryPayload: """Get executive ML summary payload.""" cache_key = "analytics_summary" - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return DashboardMLSummaryPayload(**cached) data = service.get_dashboard_ml_summary() - cache.put(cache_key, data) + cache.put(cache_key, data, req=request) return DashboardMLSummaryPayload(**data) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 4366051..db24a92 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -29,9 +29,10 @@ async def get_me( identity: AuthenticatedIdentity = Depends(get_current_identity), ) -> MeResponse: - """Return verified user identity from the validated JWT.""" + """Return verified user identity and role from the validated JWT.""" return MeResponse( user_id=identity.user_id, authenticated=True, email=identity.email, + role=identity.role, ) diff --git a/backend/app/api/auth_deps.py b/backend/app/api/auth_deps.py index 2870896..da47eb4 100644 --- a/backend/app/api/auth_deps.py +++ b/backend/app/api/auth_deps.py @@ -85,7 +85,20 @@ async def get_current_identity( request_id = get_request_id(request) - # If auth is disabled (development mode), return a default identity + # Production safety guard: dev-user-000 bypass is strictly prohibited in production + if settings.ENVIRONMENT == "production" and not settings.REQUIRE_AUTH: + logger.error("Security violation: REQUIRE_AUTH=false is prohibited in production environment") + raise HTTPException( + status_code=401, + headers={"WWW-Authenticate": "Bearer"}, + detail=_build_auth_error( + AUTH_NOT_CONFIGURED, + "Authentication enforcement is required in production.", + request_id, + ), + ) + + # If auth is disabled (local development / offline test mode only), return mock dev identity if not settings.REQUIRE_AUTH: from app.core.rbac import ADMIN, PERMISSIONS diff --git a/backend/app/api/districts.py b/backend/app/api/districts.py index 3d61826..c4224d3 100644 --- a/backend/app/api/districts.py +++ b/backend/app/api/districts.py @@ -9,7 +9,7 @@ from datetime import date from typing import Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from app.api.rbac_deps import require_permission from app.core.cache import CatalystCacheService, get_cache_service @@ -50,17 +50,18 @@ def _get_district_service( "transactional data are included with zero-valued statistics.", ) async def list_districts( + request: Request, service: DistrictService = Depends(_get_district_service), cache: CatalystCacheService = Depends(get_cache_service), _identity: AuthenticatedIdentity = Depends(require_permission("districts.read")), ) -> DistrictListResponse: cache_key = "districts_list" - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return DistrictListResponse(**cached) result = service.list_all_districts() - cache.put(cache_key, result) + cache.put(cache_key, result, req=request) return DistrictListResponse(**result) @@ -78,6 +79,7 @@ async def list_districts( ) async def get_district_intelligence( district_id: int, + request: Request, start_date: Optional[date] = Query( None, description="Inclusive start date (YYYY-MM-DD)" ), @@ -101,7 +103,7 @@ async def get_district_intelligence( crime_head=crime_head, status=status, ) - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return DistrictIntelligenceProfile(**cached) @@ -112,5 +114,5 @@ async def get_district_intelligence( crime_head=crime_head, status=status, ) - cache.put(cache_key, result) + cache.put(cache_key, result, req=request) return DistrictIntelligenceProfile(**result) diff --git a/backend/app/api/intelligence_map.py b/backend/app/api/intelligence_map.py index 076bb77..d2e8d3f 100644 --- a/backend/app/api/intelligence_map.py +++ b/backend/app/api/intelligence_map.py @@ -9,7 +9,7 @@ from datetime import date from typing import Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import PlainTextResponse from app.api.rbac_deps import require_permission @@ -55,6 +55,7 @@ def _get_intelligence_service( "All filters are optional and combine with AND semantics.", ) async def get_intelligence_analytics( + request: Request, district: Optional[str] = Query(None, description="Filter by district name"), station_id: Optional[str] = Query(None, description="Filter by station ID"), crime_head: Optional[str] = Query(None, description="Filter by crime category"), @@ -78,7 +79,7 @@ async def get_intelligence_analytics( start_date=start_date, end_date=end_date, ) - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return IntelligenceAnalyticsResponse(**cached) @@ -90,7 +91,7 @@ async def get_intelligence_analytics( start_date=start_date, end_date=end_date, ) - cache.put(cache_key, result) + cache.put(cache_key, result, req=request) return IntelligenceAnalyticsResponse(**result) @@ -107,6 +108,7 @@ async def get_intelligence_analytics( "All filters are optional and combine with AND semantics.", ) async def get_intelligence_heatmap( + request: Request, district: Optional[str] = Query(None, description="Filter by district name"), station_id: Optional[str] = Query(None, description="Filter by station ID"), crime_head: Optional[str] = Query(None, description="Filter by crime category"), @@ -130,7 +132,7 @@ async def get_intelligence_heatmap( start_date=start_date, end_date=end_date, ) - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return HeatmapResponse(**cached) @@ -142,7 +144,7 @@ async def get_intelligence_heatmap( start_date=start_date, end_date=end_date, ) - cache.put(cache_key, result) + cache.put(cache_key, result, req=request) return HeatmapResponse(**result) @@ -160,6 +162,7 @@ async def get_intelligence_heatmap( "Not ML clustering.", ) async def get_intelligence_clusters( + request: Request, district: Optional[str] = Query(None, description="Filter by district name"), station_id: Optional[str] = Query(None, description="Filter by station ID"), crime_head: Optional[str] = Query(None, description="Filter by crime category"), @@ -183,7 +186,7 @@ async def get_intelligence_clusters( start_date=start_date, end_date=end_date, ) - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return ClusterResponse(**cached) @@ -195,7 +198,7 @@ async def get_intelligence_clusters( start_date=start_date, end_date=end_date, ) - cache.put(cache_key, result) + cache.put(cache_key, result, req=request) return ClusterResponse(**result) @@ -212,6 +215,7 @@ async def get_intelligence_clusters( "Shared implementation with field officer hotspots.", ) async def get_intelligence_hotspots( + request: Request, district: Optional[str] = Query(None, description="Filter by district name"), station_id: Optional[str] = Query(None, description="Filter by station ID"), crime_head: Optional[str] = Query(None, description="Filter by crime category"), @@ -235,7 +239,7 @@ async def get_intelligence_hotspots( start_date=start_date, end_date=end_date, ) - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return HotspotResponse(**cached) @@ -247,7 +251,7 @@ async def get_intelligence_hotspots( start_date=start_date, end_date=end_date, ) - cache.put(cache_key, result) + cache.put(cache_key, result, req=request) return HotspotResponse(**result) @@ -265,6 +269,7 @@ async def get_intelligence_hotspots( "status breakdown.", ) async def get_intelligence_district_comparison( + request: Request, district: Optional[str] = Query(None, description="Filter by district name"), station_id: Optional[str] = Query(None, description="Filter by station ID"), crime_head: Optional[str] = Query(None, description="Filter by crime category"), @@ -288,7 +293,7 @@ async def get_intelligence_district_comparison( start_date=start_date, end_date=end_date, ) - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return DistrictComparisonResponse(**cached) @@ -300,7 +305,7 @@ async def get_intelligence_district_comparison( start_date=start_date, end_date=end_date, ) - cache.put(cache_key, result) + cache.put(cache_key, result, req=request) return DistrictComparisonResponse(**result) @@ -317,6 +322,7 @@ async def get_intelligence_district_comparison( "Supports 'daily' and 'monthly' granularity (default: monthly).", ) async def get_intelligence_timeline( + request: Request, district: Optional[str] = Query(None, description="Filter by district name"), station_id: Optional[str] = Query(None, description="Filter by station ID"), crime_head: Optional[str] = Query(None, description="Filter by crime category"), @@ -345,7 +351,7 @@ async def get_intelligence_timeline( end_date=end_date, granularity=granularity, ) - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return TimelineResponse(**cached) @@ -358,7 +364,7 @@ async def get_intelligence_timeline( end_date=end_date, granularity=granularity, ) - cache.put(cache_key, result) + cache.put(cache_key, result, req=request) return TimelineResponse(**result) diff --git a/backend/app/api/stations.py b/backend/app/api/stations.py index c6e69fe..72c7137 100644 --- a/backend/app/api/stations.py +++ b/backend/app/api/stations.py @@ -8,7 +8,7 @@ from typing import Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from app.api.rbac_deps import require_permission from app.core.cache import CatalystCacheService, get_cache_service @@ -44,6 +44,7 @@ def _get_station_service( "All filters are optional. Pagination defaults to 50 per page.", ) async def list_stations( + request: Request, district_id: Optional[int] = Query( None, description="Filter by district ID" ), @@ -61,7 +62,7 @@ async def list_stations( page=page, page_size=page_size, ) - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return StationListResponse(**cached) @@ -70,7 +71,7 @@ async def list_stations( page=page, page_size=page_size, ) - cache.put(cache_key, result) + cache.put(cache_key, result, req=request) return StationListResponse(**result) @@ -88,15 +89,16 @@ async def list_stations( ) async def get_station_detail( station_id: str, + request: Request, service: StationService = Depends(_get_station_service), cache: CatalystCacheService = Depends(get_cache_service), _identity: AuthenticatedIdentity = Depends(require_permission("stations.read")), ) -> StationDetailResponse: cache_key = f"station_detail_{station_id}" - cached = cache.get(cache_key) + cached = cache.get(cache_key, req=request) if cached is not None: return StationDetailResponse(**cached) result = service.get_station_detail(station_id) - cache.put(cache_key, result) + cache.put(cache_key, result, req=request) return StationDetailResponse(**result) diff --git a/backend/app/core/cache.py b/backend/app/core/cache.py index 430cd07..2d6810d 100644 --- a/backend/app/core/cache.py +++ b/backend/app/core/cache.py @@ -1,10 +1,12 @@ -"""In-Memory Response Cache Service with per-item TTL expiration. - -Provides high-performance key-value caching for read-heavy CrimeIntel endpoints: -- Thread-safe storage with fine-grained locking -- Deterministic cache key generation with query parameter normalization -- Per-item configurable TTL (default: 600s / 10 minutes) -- Structured non-sensitive performance logging +"""Multi-Tier Response Cache Service: L1 In-Memory LRU + L2 Zoho Catalyst Cache. + +Provides high-performance multi-tier caching for read-heavy CrimeIntel endpoints: +- L1: Process-local thread-safe OrderedDict LRU with fine-grained locking and fast access (<0.1ms) +- L2: Optional shared Zoho Catalyst Cache segment with safe failover and payload protection +- Two-Tier Promotion: L1 Miss -> L2 Hit -> Populate L1 -> Return result +- Single-flight stampede coordination for concurrent duplicate requests +- Multi-tier invalidation (single-key, prefix-based, clear) executing across L1 and L2 +- Non-sensitive operational statistics and telemetry """ from __future__ import annotations @@ -13,13 +15,18 @@ import logging import threading import time +from collections import OrderedDict +from contextlib import contextmanager from datetime import date, datetime -from typing import Any, Optional +from typing import Any, Callable, Generator, Optional, Protocol, runtime_checkable from app.core.config import settings logger = logging.getLogger("crimeintel.cache") +# Catalyst Cache single-value size safety limit (512 KB) +MAX_CATALYST_PAYLOAD_BYTES = 512 * 1024 + class JSONEncoderWithDates(json.JSONEncoder): """Custom JSON encoder to handle date, datetime, and Pydantic models.""" @@ -32,44 +39,363 @@ def default(self, obj: Any) -> Any: return super().default(obj) -class InMemoryCacheStore: - """Thread-safe in-memory cache store with per-item TTL expiration.""" +@runtime_checkable +class CacheBackend(Protocol): + """Abstract protocol for cache storage backends.""" + + def get(self, key: str) -> Optional[str]: ... + + def put(self, key: str, value: str, ttl_seconds: int) -> bool: ... + + def invalidate(self, key: str) -> bool: ... + + def invalidate_prefix(self, prefix: str) -> int: ... - def __init__(self) -> None: - self._store: dict[str, tuple[float, str]] = {} + def clear(self) -> None: ... + + +class InMemoryCacheStore: + """Thread-safe LRU in-memory cache store (L1) with per-item TTL expiration.""" + + def __init__(self, max_entries: Optional[int] = None) -> None: + self._max_entries = ( + max_entries + if max_entries is not None + else getattr(settings, "CACHE_MAX_ENTRIES", 1000) + ) + self._store: OrderedDict[str, tuple[float, str]] = OrderedDict() self._lock = threading.Lock() + # Telemetry counters + self._hits: int = 0 + self._misses: int = 0 + self._sets: int = 0 + self._evictions: int = 0 + self._invalidations: int = 0 + self._expired_evictions: int = 0 + self._stampede_prevented: int = 0 + def get(self, key: str) -> Optional[str]: + """Retrieve a value by key. Updates LRU order on hit.""" with self._lock: entry = self._store.get(key) if entry is None: + self._misses += 1 return None + expiry, value = entry if time.time() > expiry: del self._store[key] + self._expired_evictions += 1 + self._misses += 1 return None + + # Mark as most recently used + self._store.move_to_end(key) + self._hits += 1 return value - def put(self, key: str, value: str, ttl_seconds: int) -> None: + def put(self, key: str, value: str, ttl_seconds: int) -> bool: + """Store a key-value pair with TTL and enforce LRU capacity bounds.""" with self._lock: - # Evict expired items periodically if cache grows large - if len(self._store) > 1000: - now = time.time() - self._store = {k: v for k, v in self._store.items() if v[0] > now} - self._store[key] = (time.time() + ttl_seconds, value) + now = time.time() + + # If existing key is updated, move to most recent + if key in self._store: + self._store.move_to_end(key) + + self._store[key] = (now + ttl_seconds, value) + self._sets += 1 + + # Bounded memory enforcement (LRU eviction) + if len(self._store) > self._max_entries: + # First pass: clean expired entries + expired_keys = [k for k, v in self._store.items() if v[0] <= now] + for k in expired_keys: + del self._store[k] + self._expired_evictions += 1 + + # Second pass: evict oldest entries until within capacity + while len(self._store) > self._max_entries: + self._store.popitem(last=False) + self._evictions += 1 + return True + + def invalidate(self, key: str) -> bool: + """Invalidate a specific cache key.""" + with self._lock: + if key in self._store: + del self._store[key] + self._invalidations += 1 + return True + return False + + def invalidate_prefix(self, prefix: str) -> int: + """Invalidate all cache entries matching a prefix.""" + with self._lock: + matching_keys = [k for k in self._store if k.startswith(prefix)] + for k in matching_keys: + del self._store[k] + self._invalidations += len(matching_keys) + return len(matching_keys) def clear(self) -> None: + """Clear all cached entries.""" with self._lock: + count = len(self._store) self._store.clear() + self._invalidations += count + + def get_stats(self) -> dict[str, int]: + """Return operational L1 statistics.""" + with self._lock: + return { + "hits": self._hits, + "misses": self._misses, + "sets": self._sets, + "evictions": self._evictions, + "invalidations": self._invalidations, + "expired_evictions": self._expired_evictions, + "stampede_prevented": self._stampede_prevented, + "current_entries": len(self._store), + "max_entries": self._max_entries, + } + + +class CatalystCacheStore: + """Zoho Catalyst L2 Cache adapter with fail-safe error handling.""" + + def __init__( + self, + enabled: Optional[bool] = None, + segment_id: Optional[str] = None, + default_ttl: Optional[int] = None, + client: Optional[Any] = None, + ) -> None: + self._enabled = ( + enabled + if enabled is not None + else getattr(settings, "CATALYST_CACHE_ENABLED", False) + ) + self._segment_id = ( + segment_id + if segment_id is not None + else getattr(settings, "CATALYST_CACHE_SEGMENT_ID", "") + ) + self._default_ttl = ( + default_ttl + if default_ttl is not None + else getattr(settings, "CATALYST_CACHE_TTL_SECONDS", 600) + ) + self._client = client + self._lock = threading.Lock() + + # Telemetry counters + self._hits: int = 0 + self._misses: int = 0 + self._sets: int = 0 + self._errors: int = 0 + self._invalidations: int = 0 + + @property + def is_enabled(self) -> bool: + return self._enabled + + def _get_segment(self, req: Optional[Any] = None) -> Any: + """Resolve Catalyst Cache segment instance via client or zcatalyst_sdk.""" + if self._client is not None: + return self._client + + try: + import zcatalyst_sdk + + app = zcatalyst_sdk.initialize(req=req) if req else zcatalyst_sdk.initialize() + cache_service = app.cache() + return ( + cache_service.segment(self._segment_id) + if self._segment_id + else cache_service.segment() + ) + except Exception as exc: + with self._lock: + self._errors += 1 + logger.debug("Catalyst Cache SDK unavailable: %s", exc) + return None + + def get(self, key: str, req: Optional[Any] = None) -> Optional[str]: + """Retrieve value from Catalyst Cache segment. Returns None on miss or error.""" + if not self._enabled: + return None + + try: + segment = self._get_segment(req=req) + if segment is None: + return None + + # Catalyst Python SDK uses get_value(key) or get(key) + if hasattr(segment, "get_value"): + val = segment.get_value(key) + elif hasattr(segment, "get"): + val = segment.get(key) + else: + return None + + if val is not None: + with self._lock: + self._hits += 1 + logger.info("CATALYST CACHE L2 HIT: %s", key) + return str(val) + + with self._lock: + self._misses += 1 + return None + except Exception as exc: + with self._lock: + self._errors += 1 + logger.warning("Catalyst Cache L2 get error for key %s (falling back to L1/DB): %s", key, exc) + return None + + def put( + self, + key: str, + value: str, + ttl_seconds: Optional[int] = None, + req: Optional[Any] = None, + ) -> bool: + """Store value in Catalyst Cache segment with payload size validation.""" + if not self._enabled: + return False + + # Payload size safeguard: prevent storing oversized blobs in Catalyst segment + if len(value.encode("utf-8")) > MAX_CATALYST_PAYLOAD_BYTES: + logger.debug("Payload for key %s exceeds Catalyst Cache limit (%d bytes), skipping L2", key, len(value)) + return False + + ttl = ttl_seconds if ttl_seconds is not None else self._default_ttl + + try: + segment = self._get_segment(req=req) + if segment is None: + return False + + # In Catalyst SDK, put accepts key and value string with optional expiry + if hasattr(segment, "put"): + try: + # Pass ttl if supported by SDK implementation + segment.put(key, value, expiry_in_hours=max(1, ttl // 3600)) + except TypeError: + segment.put(key, value) + else: + return False + + with self._lock: + self._sets += 1 + logger.info("CATALYST CACHE L2 STORE: %s (TTL %ds)", key, ttl) + return True + except Exception as exc: + with self._lock: + self._errors += 1 + logger.warning("Catalyst Cache L2 put error for key %s: %s", key, exc) + return False + + def invalidate(self, key: str, req: Optional[Any] = None) -> bool: + """Delete a key from Catalyst Cache segment.""" + if not self._enabled: + return False + + try: + segment = self._get_segment(req=req) + if segment is None: + return False + + if hasattr(segment, "delete"): + segment.delete(key) + elif hasattr(segment, "delete_value"): + segment.delete_value(key) + else: + return False + + with self._lock: + self._invalidations += 1 + return True + except Exception as exc: + with self._lock: + self._errors += 1 + logger.warning("Catalyst Cache L2 invalidate error for key %s: %s", key, exc) + return False + + def invalidate_prefix(self, prefix: str) -> int: + """Purge prefix in Catalyst Cache if client supports key enumeration or clear.""" + if not self._enabled: + return 0 + + # Most remote key-value cache segments don't support full namespace scans without segment reset + # If the client provides an invalidation or key scan, invoke it safely + try: + if self._client and hasattr(self._client, "invalidate_prefix"): + count = self._client.invalidate_prefix(prefix) + with self._lock: + self._invalidations += count + return count + return 0 + except Exception as exc: + with self._lock: + self._errors += 1 + logger.warning("Catalyst Cache L2 invalidate_prefix error: %s", exc) + return 0 + + def clear(self) -> None: + """Reset or clear L2 segment if client supports segment clearing.""" + if not self._enabled: + return + + try: + if self._client and hasattr(self._client, "clear"): + self._client.clear() + except Exception as exc: + with self._lock: + self._errors += 1 + logger.warning("Catalyst Cache L2 clear error: %s", exc) + + def get_stats(self) -> dict[str, Any]: + """Return operational L2 statistics.""" + with self._lock: + return { + "enabled": self._enabled, + "segment_id": self._segment_id or "default", + "hits": self._hits, + "misses": self._misses, + "sets": self._sets, + "errors": self._errors, + "invalidations": self._invalidations, + } class CacheService: - """Manages thread-safe in-memory response caching for CrimeIntel APIs.""" + """Manages multi-tier (L1 In-Memory + L2 Catalyst) response caching for CrimeIntel APIs.""" - def __init__(self) -> None: - self._enabled = settings.CACHE_ENABLED - self._default_ttl = settings.CACHE_TTL_SECONDS - self._store = InMemoryCacheStore() + def __init__( + self, + enabled: Optional[bool] = None, + default_ttl: Optional[int] = None, + max_entries: Optional[int] = None, + l2_store: Optional[CatalystCacheStore] = None, + ) -> None: + self._enabled = ( + enabled if enabled is not None else settings.CACHE_ENABLED + ) + self._default_ttl = ( + default_ttl + if default_ttl is not None + else settings.CACHE_TTL_SECONDS + ) + self._store = InMemoryCacheStore(max_entries=max_entries) + self._l1 = self._store + self._l2 = l2_store if l2_store is not None else CatalystCacheStore() + + # Single-flight coordination locks + self._flight_locks: dict[str, threading.Lock] = {} + self._flight_meta_lock = threading.Lock() def make_cache_key(self, prefix: str, **params: Any) -> str: """Create a deterministic cache key from prefix and sorted query parameters.""" @@ -95,20 +421,38 @@ def make_cache_key(self, prefix: str, **params: Any) -> str: return f"{prefix}:{param_str}" def get(self, key: str, req: Optional[Any] = None) -> Optional[Any]: - """Retrieve a cached value by key. Returns parsed JSON or None on miss.""" + """Retrieve a cached value across L1 and L2 layers. Returns parsed JSON or None on miss.""" if not self._enabled: logger.debug("CACHE BYPASS: caching disabled") return None - raw_val = self._store.get(key) - if raw_val is not None: - logger.info("CACHE HIT: %s", key) + # 1. Check L1 Cache + try: + raw_l1 = self._l1.get(key) + except Exception as exc: + logger.error("L1 Cache get error for key %s: %s", key, exc) + raw_l1 = None + + if raw_l1 is not None: + logger.info("CACHE L1 HIT: %s", key) try: - return json.loads(raw_val) + return json.loads(raw_l1) except Exception: - return raw_val - - logger.info("CACHE MISS: %s", key) + return raw_l1 + + # 2. Check L2 Catalyst Cache (if active) + if self._l2 and self._l2.is_enabled: + raw_l2 = self._l2.get(key, req=req) + if raw_l2 is not None: + logger.info("CACHE L2 HIT -> POPULATING L1: %s", key) + # Populate L1 on L2 hit + self._l1.put(key, raw_l2, self._default_ttl) + try: + return json.loads(raw_l2) + except Exception: + return raw_l2 + + logger.info("CACHE MISS (L1 & L2): %s", key) return None def put( @@ -118,7 +462,7 @@ def put( ttl_seconds: Optional[int] = None, req: Optional[Any] = None, ) -> bool: - """Store a value in cache with TTL. Returns True if stored successfully.""" + """Store a value in both L1 and L2 caches with TTL. Returns True if stored in at least L1.""" if not self._enabled: return False @@ -130,13 +474,109 @@ def put( logger.error("Failed to serialize value for cache key %s: %s", key, exc) return False - self._store.put(key, serialized, ttl) - logger.info("CACHE STORE: %s (TTL %ds)", key, ttl) - return True + # Store in L1 + l1_ok = False + try: + self._l1.put(key, serialized, ttl) + l1_ok = True + logger.info("CACHE STORE (L1): %s (TTL %ds)", key, ttl) + except Exception as exc: + logger.error("L1 cache store error for key %s: %s", key, exc) + + # Store in L2 (if enabled) + if self._l2 and self._l2.is_enabled: + try: + self._l2.put(key, serialized, ttl, req=req) + except Exception as exc: + logger.warning("L2 cache store failed for key %s: %s", key, exc) + + return l1_ok + + def invalidate(self, key: str, req: Optional[Any] = None) -> bool: + """Invalidate a specific cache key in both L1 and L2.""" + l1_res = self._l1.invalidate(key) + l2_res = self._l2.invalidate(key, req=req) if (self._l2 and self._l2.is_enabled) else False + return l1_res or l2_res + + def invalidate_prefix(self, prefix: str) -> int: + """Invalidate all keys matching a prefix across L1 and L2.""" + l1_count = self._l1.invalidate_prefix(prefix) + l2_count = self._l2.invalidate_prefix(prefix) if (self._l2 and self._l2.is_enabled) else 0 + return l1_count + l2_count def clear(self) -> None: - """Clear all cached entries (useful for test isolation).""" - self._store.clear() + """Clear all cached entries in both L1 and L2.""" + self._l1.clear() + if self._l2 and self._l2.is_enabled: + self._l2.clear() + + def get_stats(self) -> dict[str, Any]: + """Return combined operational cache statistics for L1 and L2.""" + l1_stats = self._l1.get_stats() + l2_stats = self._l2.get_stats() if self._l2 else {} + + return { + "l1": l1_stats, + "l2": l2_stats, + # Backward-compatible flat keys + "hits": l1_stats["hits"] + l2_stats.get("hits", 0), + "misses": l1_stats["misses"], + "sets": l1_stats["sets"], + "evictions": l1_stats["evictions"], + "invalidations": l1_stats["invalidations"] + l2_stats.get("invalidations", 0), + "expired_evictions": l1_stats["expired_evictions"], + "stampede_prevented": l1_stats["stampede_prevented"], + "current_entries": l1_stats["current_entries"], + "max_entries": l1_stats["max_entries"], + } + + @contextmanager + def single_flight(self, key: str) -> Generator[bool, None, None]: + """Context manager coordinating single-flight execution for identical concurrent keys.""" + with self._flight_meta_lock: + if key not in self._flight_locks: + self._flight_locks[key] = threading.Lock() + key_lock = self._flight_locks[key] + + with key_lock: + try: + # If value is now in cache, computation is not needed + if self.get(key) is not None: + with self._l1._lock: + self._l1._stampede_prevented += 1 + logger.info("CACHE STAMPEDE PREVENTED: %s", key) + yield False + else: + yield True + finally: + with self._flight_meta_lock: + if key in self._flight_locks and not key_lock.locked(): + self._flight_locks.pop(key, None) + + def get_or_compute( + self, + key: str, + compute_fn: Callable[[], Any], + ttl_seconds: Optional[int] = None, + req: Optional[Any] = None, + ) -> Any: + """Retrieve from cache or compute once using single-flight stampede protection.""" + val = self.get(key, req=req) + if val is not None: + return val + + if not self._enabled: + return compute_fn() + + with self.single_flight(key) as should_compute: + if not should_compute: + cached_val = self.get(key, req=req) + if cached_val is not None: + return cached_val + + result = compute_fn() + self.put(key, result, ttl_seconds=ttl_seconds, req=req) + return result # Backward compatibility aliases diff --git a/backend/app/core/config.py b/backend/app/core/config.py index fd68678..eeab09d 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -3,7 +3,7 @@ import os from pathlib import Path -from pydantic import model_validator +from pydantic import AliasChoices, Field, model_validator from pydantic_settings import BaseSettings _BACKEND_DIR = Path(__file__).resolve().parent.parent.parent @@ -114,10 +114,9 @@ class Settings(BaseSettings): RBAC_DEFAULT_ROLE: str = "FIELD_OFFICER" # Dotted claim paths checked (in order) to resolve an application - # role from verified JWT claims. + # role from verified JWT claims. Server-trusted claims only. RBAC_ROLE_CLAIM_PATHS: list[str] = [ "app_metadata.role", - "user_metadata.role", "role", ] @@ -149,10 +148,34 @@ class Settings(BaseSettings): # ------------------------------------------------------------------ CACHE_ENABLED: bool = True CACHE_TTL_SECONDS: int = 600 + CACHE_MAX_ENTRIES: int = 1000 + + # Zoho Catalyst L2 Cache Settings + CATALYST_CACHE_ENABLED: bool = Field( + default=False, + validation_alias=AliasChoices("CATALYST_CACHE_ENABLED", "CACHE_L2_ENABLED", "APP_CATALYST_CACHE_ENABLED"), + ) + CATALYST_CACHE_SEGMENT_ID: str = Field( + default="", + validation_alias=AliasChoices("CATALYST_CACHE_SEGMENT_ID", "CACHE_L2_SEGMENT_ID", "APP_CATALYST_CACHE_SEGMENT_ID"), + ) + CATALYST_CACHE_TTL_SECONDS: int = Field( + default=600, + validation_alias=AliasChoices("CATALYST_CACHE_TTL_SECONDS", "CACHE_L2_TTL_SECONDS", "APP_CATALYST_CACHE_TTL_SECONDS"), + ) @model_validator(mode="before") @classmethod def _normalize_backend(cls, values: dict) -> dict: + # Allow L2 cache settings to be set via non-reserved env vars (Catalyst blocks CATALYST_ prefix) + for prefix in ("APP_CATALYST_CACHE_", "CACHE_L2_"): + if f"{prefix}ENABLED" in values and "CATALYST_CACHE_ENABLED" not in values: + values["CATALYST_CACHE_ENABLED"] = values[f"{prefix}ENABLED"] + if f"{prefix}SEGMENT_ID" in values and "CATALYST_CACHE_SEGMENT_ID" not in values: + values["CATALYST_CACHE_SEGMENT_ID"] = values[f"{prefix}SEGMENT_ID"] + if f"{prefix}TTL_SECONDS" in values and "CATALYST_CACHE_TTL_SECONDS" not in values: + values["CATALYST_CACHE_TTL_SECONDS"] = values[f"{prefix}TTL_SECONDS"] + if "DATA_BACKEND" in values and isinstance(values["DATA_BACKEND"], str): values["DATA_BACKEND"] = values["DATA_BACKEND"].lower().strip() # Auto-derive Supabase project ref from DATABASE_URL if not set @@ -215,7 +238,7 @@ def _validate_backend_config(self) -> "Settings": ) return self - model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} + model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} def _extract_project_ref(database_url: str) -> str: diff --git a/backend/app/core/jwt_auth.py b/backend/app/core/jwt_auth.py index 7aad37d..cc2c9ed 100644 --- a/backend/app/core/jwt_auth.py +++ b/backend/app/core/jwt_auth.py @@ -270,15 +270,18 @@ def _verify_with_jwks(self, token: str) -> dict[str, Any]: # Build verification key try: - public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key) + public_key = jwt.PyJWK.from_dict(key).key except Exception: try: - public_key = jwt_algorithms.ECAlgorithm.from_jwk(key) + public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key) except Exception: - raise AuthenticationError( - VERIFICATION_FAILED, - "Unable to process signing key.", - ) + try: + public_key = jwt_algorithms.ECAlgorithm.from_jwk(key) + except Exception: + raise AuthenticationError( + VERIFICATION_FAILED, + "Unable to process signing key.", + ) # Verify kwargs: dict[str, Any] = { diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py index 34d057c..bb02989 100644 --- a/backend/app/core/rate_limit.py +++ b/backend/app/core/rate_limit.py @@ -140,8 +140,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 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": + # Health/docs/root are never rate-limited + if path == "/" or path.startswith("/health") or path.startswith("/docs") or path == "/openapi.json" or path == "/redoc": await self.app(scope, receive, send) return diff --git a/backend/app/core/rbac.py b/backend/app/core/rbac.py index ccd7408..478c817 100644 --- a/backend/app/core/rbac.py +++ b/backend/app/core/rbac.py @@ -16,11 +16,10 @@ 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): +Claim sources (checked in order, server-trusted only): 1. ``app_metadata.role`` (Supabase convention for privileged metadata) -2. ``user_metadata.role`` (Supabase convention for user metadata) -3. ``role`` (top-level claim) +2. ``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 diff --git a/backend/app/database/ingest/__init__.py b/backend/app/database/ingest/__init__.py index 8505a8d..0aa4dfe 100644 --- a/backend/app/database/ingest/__init__.py +++ b/backend/app/database/ingest/__init__.py @@ -446,6 +446,23 @@ def ingest_all( conn.commit() + # Invalidate cached analytics only after confirmed successful database commit + try: + from app.core.cache import get_cache_service + cache = get_cache_service() + invalidated_count = ( + cache.invalidate_prefix("dashboard_summary") + + cache.invalidate_prefix("districts_list") + + cache.invalidate_prefix("district_intelligence_") + + cache.invalidate_prefix("map_intelligence_") + + cache.invalidate_prefix("stations_list") + + cache.invalidate_prefix("station_detail_") + + cache.invalidate_prefix("analytics_") + ) + logger.info("Cache invalidated after batch %s (%d entries purged)", batch_id, invalidated_count) + except Exception as cache_exc: + logger.warning("Cache invalidation notice after ingestion: %s", cache_exc) + summary["total_records"] = total summary["status"] = "success" summary["completed_at"] = datetime.now(timezone.utc).isoformat() diff --git a/backend/app/database/postgres/__init__.py b/backend/app/database/postgres/__init__.py index 134058e..911718b 100644 --- a/backend/app/database/postgres/__init__.py +++ b/backend/app/database/postgres/__init__.py @@ -93,3 +93,44 @@ def get_cursor( raise finally: cur.close() + + +def execute_query( + query: str, + params: Any = None, + cursor_factory: Any = None, +) -> list[dict]: + """Execute a SELECT query and return all rows as dicts.""" + with get_cursor(commit=False, cursor_factory=cursor_factory) as cur: + cur.execute(query, params) + return cur.fetchall() + + +def execute_one( + query: str, + params: Any = None, + cursor_factory: Any = None, +) -> Optional[dict]: + """Execute a SELECT query and return a single row as a dict, or None.""" + with get_cursor(commit=False, cursor_factory=cursor_factory) as cur: + cur.execute(query, params) + return cur.fetchone() + + +def execute_write( + query: str, + params: Any = None, +) -> None: + """Execute an INSERT, UPDATE, or DELETE statement within a transaction.""" + with get_cursor(commit=True) as cur: + cur.execute(query, params) + + +def execute_many( + query: str, + params_list: list[Any], +) -> None: + """Execute a batch INSERT/UPDATE within a transaction.""" + with get_cursor(commit=True) as cur: + cur.executemany(query, params_list) + diff --git a/backend/app/main.py b/backend/app/main.py index 8c43916..1cf5dd8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -68,6 +68,7 @@ # --------------------------------------------------------------------------- _PUBLIC_PATHS: list[str | re.Pattern] = [ + "/", "/health", "/health/live", "/health/ready", @@ -184,8 +185,8 @@ async def send_wrapper(message): if path.startswith("/api/v1/"): # Protected API responses: no-store headers.append((b"cache-control", b"no-store")) - elif path.startswith("/health"): - # Health endpoints: short cache acceptable + elif path.startswith("/health") or path == "/": + # Health/root endpoints: short cache acceptable headers.append((b"cache-control", b"max-age=10")) message["headers"] = headers @@ -232,7 +233,17 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) return - # Auth disabled (development mode) + # Production safety guard: dev-user-000 bypass is strictly prohibited in production + if settings.ENVIRONMENT == "production" and not settings.REQUIRE_AUTH: + logger.error("Security violation: REQUIRE_AUTH=false is prohibited in production environment") + await self._reject( + scope, receive, send, + code="AUTH_NOT_CONFIGURED", + message="Authentication enforcement is required in production.", + ) + return + + # Auth disabled (local development / offline test mode only) if not settings.REQUIRE_AUTH: from app.core.rbac import ADMIN, PERMISSIONS @@ -456,10 +467,20 @@ async def handle_uncaught_exception(request: Request, exc: Exception) -> JSONRes # --------------------------------------------------------------------------- -# Routes: Health (public) +# Routes: Public Root & Health # --------------------------------------------------------------------------- +@app.get("/", tags=["public"]) +async def root(): + """Public root endpoint. Reports service name and status.""" + return { + "service": "CrimeIntel API", + "status": "online", + "message": "Authentication required for API access.", + } + + @app.get("/health") async def health(): """Health check endpoint. Reports backend status.""" @@ -479,6 +500,12 @@ async def health(): health_status["database"] = "disconnected" health_status["status"] = "degraded" + try: + from app.core.cache import get_cache_service + health_status["cache"] = get_cache_service().get_stats() + except Exception: + pass + return health_status diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index f30726e..6d2c0c8 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -74,6 +74,10 @@ class MeResponse(BaseModel): default=None, description="User email if available in verified token", ) + role: str | None = Field( + default=None, + description="Server-resolved application role (FIELD_OFFICER, ANALYST, ADMIN)", + ) class AuthErrorResponse(BaseModel): diff --git a/backend/docs/AUTHENTICATION.md b/backend/docs/AUTHENTICATION.md index 1bf689b..e0117f6 100644 --- a/backend/docs/AUTHENTICATION.md +++ b/backend/docs/AUTHENTICATION.md @@ -121,7 +121,7 @@ server-resolved role: | `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` — +`role`) and falls back to `RBAC_DEFAULT_ROLE` — see `docs/RBAC_AUTHORIZATION.md`. **Never returned:** raw JWT token, refresh token, role claims, district assignments. diff --git a/backend/docs/RBAC_AUTHORIZATION.md b/backend/docs/RBAC_AUTHORIZATION.md index 90d0e04..493e8a4 100644 --- a/backend/docs/RBAC_AUTHORIZATION.md +++ b/backend/docs/RBAC_AUTHORIZATION.md @@ -57,8 +57,7 @@ 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) +2. `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 @@ -127,7 +126,7 @@ Current RLS posture (migration `005_rls.sql`): |---------|---------|---------| | `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 | +| `RBAC_ROLE_CLAIM_PATHS` | `app_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 | diff --git a/backend/requirements.txt b/backend/requirements.txt index 55a9fc7..b17baf0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,4 +5,5 @@ pydantic-settings==2.7.1 python-dotenv==1.0.1 httpx==0.28.1 PyJWT==2.10.1 +cryptography>=42.0.0 psycopg2-binary>=2.9.9 \ No newline at end of file diff --git a/backend/supabase/migrations/006_user_profiles.sql b/backend/supabase/migrations/006_user_profiles.sql new file mode 100644 index 0000000..896bf46 --- /dev/null +++ b/backend/supabase/migrations/006_user_profiles.sql @@ -0,0 +1,243 @@ +-- Production user profiles and role authorization schema +-- Migration: 006_user_profiles +-- Target: Supabase PostgreSQL (Project ref: gcxppkdtbvmleynrzqao) +-- +-- Purpose: +-- 1. Create public.user_profiles referencing auth.users(id). +-- 2. Strict foreign keys to police_stations(station_id TEXT) and districts(district_id INTEGER). +-- 3. Row Level Security (RLS) ensuring least privilege and privacy. +-- 4. Non-recursive SECURITY DEFINER is_admin() helper. +-- 5. Automatic user profile synchronization via trigger on auth.users (app_metadata only). +-- 6. Departmental role provisioning for official accounts. + +-- ===================================================================== +-- 1. Create user_profiles Table +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS public.user_profiles ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + email TEXT NOT NULL, + full_name TEXT NOT NULL, + badge_number TEXT, + role TEXT NOT NULL CHECK (role IN ('FIELD_OFFICER', 'ANALYST', 'ADMIN')), + police_station_id TEXT REFERENCES public.police_stations(station_id), + district_id INTEGER REFERENCES public.districts(district_id), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE public.user_profiles IS 'Operational user profiles and departmental role mappings for CrimeIntel.'; +COMMENT ON COLUMN public.user_profiles.user_id IS 'References auth.users(id) primary key.'; +COMMENT ON COLUMN public.user_profiles.role IS 'Authoritative role: FIELD_OFFICER, ANALYST, ADMIN.'; +COMMENT ON COLUMN public.user_profiles.police_station_id IS 'Assigned station identifier (PS0001-PS0250).'; +COMMENT ON COLUMN public.user_profiles.district_id IS 'Assigned district identifier (1-31).'; + +CREATE INDEX IF NOT EXISTS idx_user_profiles_role ON public.user_profiles(role); +CREATE INDEX IF NOT EXISTS idx_user_profiles_email ON public.user_profiles(email); +CREATE INDEX IF NOT EXISTS idx_user_profiles_station ON public.user_profiles(police_station_id); +CREATE INDEX IF NOT EXISTS idx_user_profiles_district ON public.user_profiles(district_id); + +-- ===================================================================== +-- 2. Security Helper: is_admin() (Non-recursive, SECURITY DEFINER) +-- ===================================================================== + +CREATE OR REPLACE FUNCTION public.is_admin() +RETURNS BOOLEAN +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ + SELECT ( + COALESCE((auth.jwt() -> 'app_metadata' ->> 'role') = 'ADMIN', FALSE) + OR EXISTS ( + SELECT 1 FROM public.user_profiles + WHERE user_id = auth.uid() AND role = 'ADMIN' + ) + ); +$$; + +GRANT EXECUTE ON FUNCTION public.is_admin() TO authenticated; +GRANT EXECUTE ON FUNCTION public.is_admin() TO service_role; + +-- ===================================================================== +-- 3. Row Level Security (RLS) +-- ===================================================================== + +ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY; + +-- Drop existing policies if already defined +DROP POLICY IF EXISTS "user_profiles_select_own" ON public.user_profiles; +DROP POLICY IF EXISTS "user_profiles_update_own" ON public.user_profiles; +DROP POLICY IF EXISTS "user_profiles_admin_all" ON public.user_profiles; + +-- Authenticated users can read their own profile +CREATE POLICY "user_profiles_select_own" + ON public.user_profiles + FOR SELECT + TO authenticated + USING (auth.uid() = user_id); + +-- Authenticated users can update permitted non-role display fields of their own profile +CREATE POLICY "user_profiles_update_own" + ON public.user_profiles + FOR UPDATE + TO authenticated + USING (auth.uid() = user_id) + WITH CHECK ( + auth.uid() = user_id + AND role = (SELECT p.role FROM public.user_profiles p WHERE p.user_id = auth.uid()) + ); + +-- System administrators can view and manage all profiles via safe helper +CREATE POLICY "user_profiles_admin_all" + ON public.user_profiles + FOR ALL + TO authenticated + USING (public.is_admin()) + WITH CHECK (public.is_admin()); + +-- ===================================================================== +-- 4. Automatic Profile Creation Trigger on auth.users +-- ===================================================================== + +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + assigned_role TEXT; + user_full_name TEXT; + user_badge TEXT; + user_station TEXT; + user_district INTEGER; +BEGIN + -- Determine role ONLY from server-trusted app_metadata (never raw_user_meta_data) + assigned_role := COALESCE( + NULLIF(TRIM(NEW.raw_app_meta_data->>'role'), ''), + 'FIELD_OFFICER' + ); + + -- Normalize role to valid enum set + IF UPPER(assigned_role) IN ('FIELD_OFFICER', 'OFFICER') THEN + assigned_role := 'FIELD_OFFICER'; + ELSIF UPPER(assigned_role) IN ('ANALYST', 'INTELLIGENCE_ANALYST') THEN + assigned_role := 'ANALYST'; + ELSIF UPPER(assigned_role) IN ('ADMIN', 'SYSTEM_ADMINISTRATOR') THEN + assigned_role := 'ADMIN'; + ELSE + assigned_role := 'FIELD_OFFICER'; + END IF; + + user_full_name := COALESCE( + NULLIF(TRIM(NEW.raw_user_meta_data->>'full_name'), ''), + NULLIF(TRIM(NEW.raw_user_meta_data->>'name'), ''), + split_part(NEW.email, '@', 1) + ); + + user_badge := NULLIF(TRIM(NEW.raw_user_meta_data->>'badge_number'), ''); + user_station := NULLIF(TRIM(NEW.raw_user_meta_data->>'police_station_id'), ''); + + BEGIN + user_district := (NEW.raw_user_meta_data->>'district_id')::INTEGER; + EXCEPTION WHEN OTHERS THEN + user_district := NULL; + END; + + INSERT INTO public.user_profiles ( + user_id, + email, + full_name, + badge_number, + role, + police_station_id, + district_id, + is_active + ) VALUES ( + NEW.id, + NEW.email, + user_full_name, + user_badge, + assigned_role, + user_station, + user_district, + TRUE + ) + ON CONFLICT (user_id) DO UPDATE SET + email = EXCLUDED.email, + full_name = EXCLUDED.full_name, + updated_at = NOW(); + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- ===================================================================== +-- 5. Backfill / Provisioning for the Three Existing Official Accounts +-- ===================================================================== + +DO $$ +DECLARE + fo_id UUID; + ia_id UUID; + sa_id UUID; +BEGIN + -- Locate Field Officer by confirmed official address + SELECT id INTO fo_id FROM auth.users WHERE email = 'crimeintel.officer@gmail.com' LIMIT 1; + IF fo_id IS NOT NULL THEN + UPDATE auth.users + SET raw_app_meta_data = COALESCE(raw_app_meta_data, '{}'::jsonb) || '{"role": "FIELD_OFFICER"}'::jsonb + WHERE id = fo_id; + + INSERT INTO public.user_profiles ( + user_id, email, full_name, role, is_active + ) VALUES ( + fo_id, 'crimeintel.officer@gmail.com', 'Field Officer', 'FIELD_OFFICER', TRUE + ) + ON CONFLICT (user_id) DO UPDATE SET + role = 'FIELD_OFFICER', + updated_at = NOW(); + END IF; + + -- Locate Intelligence Analyst by confirmed official address + SELECT id INTO ia_id FROM auth.users WHERE email = 'crimeintel.analystt@gmail.com' LIMIT 1; + IF ia_id IS NOT NULL THEN + UPDATE auth.users + SET raw_app_meta_data = COALESCE(raw_app_meta_data, '{}'::jsonb) || '{"role": "ANALYST"}'::jsonb + WHERE id = ia_id; + + INSERT INTO public.user_profiles ( + user_id, email, full_name, role, is_active + ) VALUES ( + ia_id, 'crimeintel.analystt@gmail.com', 'Intelligence Analyst', 'ANALYST', TRUE + ) + ON CONFLICT (user_id) DO UPDATE SET + role = 'ANALYST', + updated_at = NOW(); + END IF; + + -- Locate System Administrator by confirmed official address + SELECT id INTO sa_id FROM auth.users WHERE email = 'crimeintel.admin@gmail.com' LIMIT 1; + IF sa_id IS NOT NULL THEN + UPDATE auth.users + SET raw_app_meta_data = COALESCE(raw_app_meta_data, '{}'::jsonb) || '{"role": "ADMIN"}'::jsonb + WHERE id = sa_id; + + INSERT INTO public.user_profiles ( + user_id, email, full_name, role, is_active + ) VALUES ( + sa_id, 'crimeintel.admin@gmail.com', 'System Administrator', 'ADMIN', TRUE + ) + ON CONFLICT (user_id) DO UPDATE SET + role = 'ADMIN', + updated_at = NOW(); + END IF; +END $$; diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 205e7d8..a190734 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -295,7 +295,15 @@ def test_401_error_code_is_stable(self, auth_client): class TestPublicEndpoints: - """Health probes and docs must remain public.""" + """Health probes, root, and docs must remain public.""" + + def test_root_public(self, auth_client): + resp = auth_client.get("/") + assert resp.status_code == 200 + body = resp.json() + assert body["service"] == "CrimeIntel API" + assert body["status"] == "online" + assert "message" in body def test_health_public(self, auth_client): resp = auth_client.get("/health") @@ -318,6 +326,59 @@ def test_openapi_public(self, auth_client): assert resp.status_code == 200 +# --------------------------------------------------------------------------- +# 3b. Root endpoint & Protected Route Security Verification +# --------------------------------------------------------------------------- + + +class TestPublicRootAndRouteSecurity: + """Explicit verification for public root vs protected endpoints.""" + + def test_root_public_returns_200(self, auth_client): + """TEST 1: GET / returns 200 without Authorization header.""" + resp = auth_client.get("/") + assert resp.status_code == 200 + body = resp.json() + assert body["service"] == "CrimeIntel API" + assert body["status"] == "online" + assert "message" in body + + def test_dashboard_summary_unauthenticated_returns_token_missing(self, auth_client): + """TEST 2: GET /api/v1/dashboard/summary without Authorization returns 401 TOKEN_MISSING.""" + resp = auth_client.get("/api/v1/dashboard/summary") + assert resp.status_code == 401 + body = resp.json() + assert body["error"]["code"] == "TOKEN_MISSING" + + def test_districts_unauthenticated_returns_token_missing(self, auth_client): + """TEST 3: GET /api/v1/districts without Authorization returns 401 TOKEN_MISSING.""" + resp = auth_client.get("/api/v1/districts") + assert resp.status_code == 401 + body = resp.json() + assert body["error"]["code"] == "TOKEN_MISSING" + + def test_stations_unauthenticated_returns_token_missing(self, auth_client): + """TEST 4: GET /api/v1/stations without Authorization returns 401 TOKEN_MISSING.""" + resp = auth_client.get("/api/v1/stations") + assert resp.status_code == 401 + body = resp.json() + assert body["error"]["code"] == "TOKEN_MISSING" + + def test_authenticated_requests_continue_returning_200(self, auth_client): + """TEST 5: Existing authenticated requests continue returning HTTP 200.""" + token = create_test_jwt() + headers = {"Authorization": f"Bearer {token}"} + + resp_dash = auth_client.get("/api/v1/dashboard/summary", headers=headers) + assert resp_dash.status_code == 200 + + resp_dist = auth_client.get("/api/v1/districts", headers=headers) + assert resp_dist.status_code == 200 + + resp_stat = auth_client.get("/api/v1/stations", headers=headers) + assert resp_stat.status_code == 200 + + # --------------------------------------------------------------------------- # 4. Valid token — access granted # --------------------------------------------------------------------------- @@ -501,8 +562,9 @@ def test_no_raw_claims_trusted(self, auth_client): headers={"Authorization": f"Bearer {token}"}, ) body = resp.json() - # Only safe fields should be present - assert set(body.keys()) == {"user_id", "authenticated", "email"} + # Only safe verified fields should be present; unverified raw claims like district_id are excluded + assert set(body.keys()) == {"user_id", "authenticated", "email", "role"} + assert "district_id" not in body def test_request_id_present(self, auth_client): token = create_test_jwt() diff --git a/backend/tests/test_catalyst_cache.py b/backend/tests/test_catalyst_cache.py index 63230ec..4b87dc6 100644 --- a/backend/tests/test_catalyst_cache.py +++ b/backend/tests/test_catalyst_cache.py @@ -1,11 +1,22 @@ -"""Automated tests for In-Memory Response Cache Service and cached API endpoints.""" +"""Automated tests for Multi-Tier Cache Service: L1 In-Memory LRU + L2 Catalyst Cache, +single-flight stampede protection, fail-safe degradation, invalidation, telemetry, and RBAC order. +""" +import concurrent.futures import time +from unittest.mock import MagicMock import pytest from fastapi.testclient import TestClient from app.main import app -from app.core.cache import get_cache_service, CacheService +from app.core.cache import ( + get_cache_service, + CacheService, + InMemoryCacheStore, + CatalystCacheStore, + MAX_CATALYST_PAYLOAD_BYTES, +) +from app.core.config import settings @pytest.fixture(scope="module") @@ -14,6 +25,11 @@ def client(): yield test_client +# --------------------------------------------------------------------------- +# 1. Key generation & order determinism +# --------------------------------------------------------------------------- + + def test_cache_service_key_generation(): cache = CacheService() key1 = cache.make_cache_key("summary", district="Bengaluru", year=2024) @@ -24,6 +40,21 @@ def test_cache_service_key_generation(): assert "year=2024" in key1 +def test_cache_key_isolation(): + cache = CacheService() + key_a = cache.make_cache_key("summary", district="Bengaluru", status="Active") + key_b = cache.make_cache_key("summary", district="Mysuru", status="Active") + key_c = cache.make_cache_key("summary", district="Bengaluru", status="Closed") + assert key_a != key_b + assert key_a != key_c + assert key_b != key_c + + +# --------------------------------------------------------------------------- +# 2. Put / Get flow & Miss (L1) +# --------------------------------------------------------------------------- + + def test_cache_put_get_flow(): cache = CacheService() key = "test_item_123" @@ -35,6 +66,16 @@ def test_cache_put_get_flow(): assert cached == payload +def test_cache_miss(): + cache = CacheService() + assert cache.get("non_existent_key_999") is None + + +# --------------------------------------------------------------------------- +# 3. TTL expiration +# --------------------------------------------------------------------------- + + def test_cache_ttl_expiration(): cache = CacheService() key = "test_ttl_item" @@ -48,13 +89,333 @@ def test_cache_ttl_expiration(): assert cache.get(key) is None +# --------------------------------------------------------------------------- +# 4. Disabled cache mode +# --------------------------------------------------------------------------- + + +def test_disabled_cache_behavior(): + cache = CacheService(enabled=False) + key = "disabled_test_key" + payload = {"val": 123} + + assert cache.put(key, payload, ttl_seconds=60) is False + assert cache.get(key) is None + assert cache.get_stats()["sets"] == 0 + + +# --------------------------------------------------------------------------- +# 5. LRU memory eviction (L1) +# --------------------------------------------------------------------------- + + +def test_lru_eviction(): + # Cache with capacity of 3 items + store = InMemoryCacheStore(max_entries=3) + store.put("k1", "v1", ttl_seconds=60) + store.put("k2", "v2", ttl_seconds=60) + store.put("k3", "v3", ttl_seconds=60) + + # Access k1 to make it most recently used (order becomes k2, k3, k1) + assert store.get("k1") == "v1" + + # Insert k4 -> should evict least recently used (k2) + store.put("k4", "v4", ttl_seconds=60) + + assert store.get("k2") is None # Evicted + assert store.get("k1") == "v1" # Retained + assert store.get("k3") == "v3" # Retained + assert store.get("k4") == "v4" # Retained + + stats = store.get_stats() + assert stats["evictions"] == 1 + assert stats["current_entries"] == 3 + + +# --------------------------------------------------------------------------- +# 6. L2 Catalyst Cache Store & Multi-Tier Hierarchy +# --------------------------------------------------------------------------- + + +def test_l1_hit_prevents_l2_lookup(): + mock_l2_client = MagicMock() + l2_store = CatalystCacheStore(enabled=True, client=mock_l2_client) + cache = CacheService(l2_store=l2_store) + + # Put into L1 + cache.put("hot_key", {"data": "fast"}, ttl_seconds=60) + mock_l2_client.get_value.reset_mock() + mock_l2_client.get.reset_mock() + + # Get should be served directly from L1 without querying L2 + res = cache.get("hot_key") + assert res == {"data": "fast"} + mock_l2_client.get_value.assert_not_called() + mock_l2_client.get.assert_not_called() + + +def test_l1_miss_and_l2_hit_populates_l1(): + mock_l2_client = MagicMock() + mock_l2_client.get_value.return_value = '{"from": "catalyst_l2"}' + l2_store = CatalystCacheStore(enabled=True, client=mock_l2_client) + cache = CacheService(l2_store=l2_store) + + # First access: L1 misses, L2 hits + res1 = cache.get("l2_key") + assert res1 == {"from": "catalyst_l2"} + assert mock_l2_client.get_value.call_count == 1 + + # Second access: should now HIT L1 directly without querying L2 again + mock_l2_client.get_value.reset_mock() + res2 = cache.get("l2_key") + assert res2 == {"from": "catalyst_l2"} + mock_l2_client.get_value.assert_not_called() + + +def test_l1_miss_and_l2_miss(): + mock_l2_client = MagicMock() + mock_l2_client.get_value.return_value = None + l2_store = CatalystCacheStore(enabled=True, client=mock_l2_client) + cache = CacheService(l2_store=l2_store) + + assert cache.get("completely_missing_key") is None + assert l2_store.get_stats()["misses"] == 1 + + +def test_l2_unavailable_graceful_fallback(): + # Simulate L2 raising a network timeout / connection error + mock_l2_client = MagicMock() + mock_l2_client.get_value.side_effect = TimeoutError("Catalyst Cache network timeout") + l2_store = CatalystCacheStore(enabled=True, client=mock_l2_client) + cache = CacheService(l2_store=l2_store) + + # Must NOT throw 500 or crash; should return None and log warning + assert cache.get("timeout_key") is None + assert l2_store.get_stats()["errors"] >= 1 + + +def test_l2_oversized_payload_protection(): + mock_l2_client = MagicMock() + l2_store = CatalystCacheStore(enabled=True, client=mock_l2_client) + + # Value larger than 512KB limit + large_val = "x" * (MAX_CATALYST_PAYLOAD_BYTES + 1024) + res = l2_store.put("big_key", large_val, ttl_seconds=60) + + # L2 skips storing oversized payload safely + assert res is False + mock_l2_client.put.assert_not_called() + + +# --------------------------------------------------------------------------- +# 7. Prefix and single-key invalidation (L1 + L2) +# --------------------------------------------------------------------------- + + +def test_single_key_invalidation(): + cache = CacheService() + cache.put("user_101", {"name": "Officer A"}, ttl_seconds=60) + assert cache.get("user_101") is not None + + assert cache.invalidate("user_101") is True + assert cache.get("user_101") is None + assert cache.invalidate("user_101") is False + + +def test_prefix_invalidation(): + cache = CacheService() + cache.put("dashboard_summary:d1", {"count": 10}, ttl_seconds=60) + cache.put("dashboard_summary:d2", {"count": 20}, ttl_seconds=60) + cache.put("map_intelligence_hotspots:d1", {"count": 5}, ttl_seconds=60) + + purged = cache.invalidate_prefix("dashboard_summary") + assert purged == 2 + + assert cache.get("dashboard_summary:d1") is None + assert cache.get("dashboard_summary:d2") is None + assert cache.get("map_intelligence_hotspots:d1") is not None + + +def test_cache_clear(): + cache = CacheService() + cache.put("a", 1, ttl_seconds=60) + cache.put("b", 2, ttl_seconds=60) + assert cache.get("a") == 1 + + cache.clear() + assert cache.get("a") is None + assert cache.get("b") is None + assert cache.get_stats()["current_entries"] == 0 + + +# --------------------------------------------------------------------------- +# 8. Operational statistics (L1 + L2 breakdown) +# --------------------------------------------------------------------------- + + +def test_cache_statistics(): + cache = CacheService(max_entries=5) + cache.clear() + + cache.put("s1", "v1", ttl_seconds=60) + cache.put("s2", "v2", ttl_seconds=60) + + # 1 hit, 1 miss + _ = cache.get("s1") + _ = cache.get("s3_missing") + + stats = cache.get_stats() + assert "l1" in stats + assert "l2" in stats + assert stats["hits"] >= 1 + assert stats["misses"] >= 1 + assert stats["sets"] >= 2 + assert stats["current_entries"] == 2 + assert stats["max_entries"] == 5 + + +# --------------------------------------------------------------------------- +# 9. Single-flight stampede protection +# --------------------------------------------------------------------------- + + +def test_single_flight_stampede_protection(): + cache = CacheService() + key = "expensive_calc_key" + call_count = 0 + + def expensive_computation(): + nonlocal call_count + call_count += 1 + time.sleep(0.1) # Simulate expensive database scan + return {"data": 12345, "version": call_count} + + # Execute 5 concurrent requests for the exact same key + with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: + futures = [ + executor.submit(cache.get_or_compute, key, expensive_computation, 60) + for _ in range(5) + ] + results = [f.result() for f in futures] + + # All 5 requests should get identical data, and computation must only run once + assert call_count == 1 + for res in results: + assert res == {"data": 12345, "version": 1} + + stats = cache.get_stats() + assert stats["stampede_prevented"] >= 1 + + +def test_single_flight_different_keys_parallel(): + cache = CacheService() + start_time = time.time() + + def slow_compute(val): + time.sleep(0.1) + return val + + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + f1 = executor.submit(cache.get_or_compute, "key_x", lambda: slow_compute(100), 60) + f2 = executor.submit(cache.get_or_compute, "key_y", lambda: slow_compute(200), 60) + f3 = executor.submit(cache.get_or_compute, "key_z", lambda: slow_compute(300), 60) + res = [f1.result(), f2.result(), f3.result()] + + duration = time.time() - start_time + assert res == [100, 200, 300] + # Parallel execution of different keys should complete in ~0.15s, not sequentially (0.3s+) + assert duration < 0.25 + + +def test_single_flight_exception_safety(): + cache = CacheService() + key = "failing_key" + + def faulty_compute(): + raise RuntimeError("Database query timeout") + + with pytest.raises(RuntimeError): + cache.get_or_compute(key, faulty_compute) + + # After an exception, lock must be released, and subsequent call should be able to try again + def working_compute(): + return {"recovered": True} + + res = cache.get_or_compute(key, working_compute) + assert res == {"recovered": True} + + +# --------------------------------------------------------------------------- +# 10. Database mutation post-commit invalidation vs failure safety +# --------------------------------------------------------------------------- + + +def test_mutation_invalidation_logic(): + cache = get_cache_service() + cache.put("dashboard_summary:all", {"total": 500}, ttl_seconds=600) + cache.put("districts_list", {"districts": []}, ttl_seconds=600) + + assert cache.get("dashboard_summary:all") is not None + + # Simulate successful transaction commit + commit_success = True + if commit_success: + cache.invalidate_prefix("dashboard_summary") + cache.invalidate_prefix("districts_list") + + assert cache.get("dashboard_summary:all") is None + assert cache.get("districts_list") is None + + +def test_failed_mutation_does_not_invalidate(): + cache = get_cache_service() + cache.put("dashboard_summary:preserved", {"total": 999}, ttl_seconds=600) + + # Simulate failed transaction (rollback) + try: + raise ValueError("Simulated DB write constraint violation") + except ValueError: + # Rollback path -> NO cache invalidation + pass + + assert cache.get("dashboard_summary:preserved") == {"total": 999} + + +# --------------------------------------------------------------------------- +# 11. Security & RBAC order verification +# --------------------------------------------------------------------------- + + +def test_analytics_rbac_protection(client): + res1 = client.get("/api/v1/analytics/summary") + assert res1.status_code == 200 + res2 = client.get("/api/v1/analytics/summary") + assert res2.status_code == 200 + assert res1.json() == res2.json() + + +def test_cache_does_not_contain_secrets(): + cache = get_cache_service() + cache.put("test_obj", {"token": "sensitive_val", "score": 10}, ttl_seconds=60) + + stats = cache.get_stats() + # Stats dict must contain strictly non-sensitive telemetry + assert "hits" in stats + assert "misses" in stats + assert "l1" in stats + assert "l2" in stats + + +# --------------------------------------------------------------------------- +# 12. Existing route integration tests +# --------------------------------------------------------------------------- + + def test_dashboard_summary_caching(client): - # First request -> Cache MISS & store res1 = client.get("/api/v1/dashboard/summary") assert res1.status_code == 200 data1 = res1.json() - # Second request -> Cache HIT res2 = client.get("/api/v1/dashboard/summary") assert res2.status_code == 200 data2 = res2.json() @@ -86,10 +447,86 @@ def test_map_intelligence_caching(client): assert res1.json() == res2.json() +def test_stations_caching(client): + res1 = client.get("/api/v1/stations") + assert res1.status_code == 200 + res2 = client.get("/api/v1/stations") + assert res2.status_code == 200 + assert res1.json() == res2.json() + + def test_health_endpoints(client): res = client.get("/health") assert res.status_code == 200 + data = res.json() + assert "cache" in data + assert "hits" in data["cache"] + assert "current_entries" in data["cache"] + res_live = client.get("/health/live") assert res_live.status_code == 200 res_ready = client.get("/health/ready") assert res_ready.status_code == 200 + + +def test_catalyst_sdk_initialize_with_request(): + """Verify that CatalystCacheStore._get_segment passes req into zcatalyst_sdk.initialize.""" + import sys + from unittest.mock import MagicMock + + mock_sdk = MagicMock() + mock_app = MagicMock() + mock_cache = MagicMock() + mock_segment = MagicMock() + + mock_sdk.initialize.return_value = mock_app + mock_app.cache.return_value = mock_cache + mock_cache.segment.return_value = mock_segment + + mock_req = MagicMock() + + # Temporarily inject mock_sdk into sys.modules + sys.modules["zcatalyst_sdk"] = mock_sdk + try: + store = CatalystCacheStore(segment_id="test_segment_123") + segment = store._get_segment(req=mock_req) + assert segment == mock_segment + mock_sdk.initialize.assert_called_with(req=mock_req) + finally: + sys.modules.pop("zcatalyst_sdk", None) + + +def test_endpoint_request_context_propagation_to_cache(client): + """Verify all cached endpoints pass request context to cache service.""" + from unittest.mock import patch + + cache_svc = get_cache_service() + with patch.object(cache_svc, "get", wraps=cache_svc.get) as mock_get: + # 1. Dashboard + client.get("/api/v1/dashboard/summary") + assert mock_get.called + assert mock_get.call_args.kwargs.get("req") is not None + + # 2. Districts + mock_get.reset_mock() + client.get("/api/v1/districts") + assert mock_get.called + assert mock_get.call_args.kwargs.get("req") is not None + + # 3. Analytics + mock_get.reset_mock() + client.get("/api/v1/analytics/summary") + assert mock_get.called + assert mock_get.call_args.kwargs.get("req") is not None + + # 4. Intelligence Map + mock_get.reset_mock() + client.get("/api/v1/map/intelligence/analytics") + assert mock_get.called + assert mock_get.call_args.kwargs.get("req") is not None + + # 5. Stations + mock_get.reset_mock() + client.get("/api/v1/stations") + assert mock_get.called + assert mock_get.call_args.kwargs.get("req") is not None diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 33e26c4..e8f5914 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,6 +4,37 @@ from app.main import app +# --------------------------------------------------------------------------- +# GET / — Public Root / Status +# --------------------------------------------------------------------------- + + +def test_root_returns_200(): + client = TestClient(app) + response = client.get("/") + assert response.status_code == 200 + + +def test_root_returns_expected_structure(): + client = TestClient(app) + response = client.get("/") + body = response.json() + assert body["service"] == "CrimeIntel API" + assert body["status"] == "online" + assert "message" in body + + +def test_root_is_get_only(): + client = TestClient(app) + response = client.post("/") + assert response.status_code == 405 + + +# --------------------------------------------------------------------------- +# GET /health — Comprehensive health check +# --------------------------------------------------------------------------- + + def test_health_returns_200(): client = TestClient(app) response = client.get("/health") diff --git a/backend/tests/test_rbac.py b/backend/tests/test_rbac.py index 5897a56..2db6609 100644 --- a/backend/tests/test_rbac.py +++ b/backend/tests/test_rbac.py @@ -127,8 +127,13 @@ def test_app_metadata_role(self): claims = {"app_metadata": {"role": "ANALYST"}} assert resolve_role(claims) == ANALYST - def test_user_metadata_role(self): + def test_user_metadata_role_cannot_elevate_privileges(self): + # user_metadata is client-controlled and must NOT be trusted; falls back to default claims = {"user_metadata": {"role": "admin"}} + assert resolve_role(claims) == FIELD_OFFICER + + def test_app_metadata_role_grants_role(self): + claims = {"app_metadata": {"role": "admin"}} assert resolve_role(claims) == ADMIN def test_top_level_role(self): @@ -271,10 +276,11 @@ def test_admin_accesses_everything(self, auth_client): resp = auth_client.get(path, headers=headers) assert resp.status_code == 200, path - def test_user_metadata_role_claim(self, auth_client): + def test_user_metadata_role_claim_denied(self, auth_client): + # user_metadata is not trusted; token receives FIELD_OFFICER, which is denied for analyst endpoints headers = _auth_header({"user_metadata": {"role": "ANALYST"}}) resp = auth_client.get("/api/v1/map/intelligence/analytics", headers=headers) - assert resp.status_code == 200 + assert resp.status_code == 403 def test_top_level_role_claim(self, auth_client): headers = _auth_header({"role": "ANALYST"}) diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..e9868c4 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,7 @@ +# CrimeIntel Frontend Environment Configuration Reference +VITE_API_BASE_URL=http://localhost:8000/api/v1 +VITE_GOOGLE_MAPS_API_KEY=AIzaSyDNBYOp5ScuCCQrrtkulY1k0IJZ49nKo40 + +# Supabase Auth Public Configuration (Safe for client-side browser exposure) +VITE_SUPABASE_URL=https://gcxppkdtbvmleynrzqao.supabase.co +VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImdjeHBwa2R0YnZtbGV5bnJ6cWFvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODQ5NjY5MDYsImV4cCI6MjEwMDU0MjkwNn0.iO6ORQCWe7_zHUAtvW1Rl7rN5YebvMIGAAsLs2oy7tA diff --git a/frontend/package-lock.json b/frontend/package-lock.json index db6a841..9f6706d 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.114.0", "@vis.gl/react-google-maps": "^1.9.0", "framer-motion": "^12.42.2", "jspdf": "^4.2.1", @@ -780,6 +781,98 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.114.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.114.0.tgz", + "integrity": "sha512-7pdAE31YHynM1b2rbrbVtQc4ug5LcUvATe9u3EazlCZMY6jar7Z5JHJlj7/qcO5Z6KAYT26sTr5mvVHePeuC/g==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.114.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.114.0.tgz", + "integrity": "sha512-N3hzlq2xr6IYkkgj0tC4j0U3LWVshr5U/DAvmD5Bu8bW1ne2XOwxOx3ps6Y3NbptBAlzDrezmeJevlZR4AqsLA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.114.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.114.0.tgz", + "integrity": "sha512-yKAe5Tc47+LLm6YU1OAHWqwkxidlnl/vHDMHx+VANcUaeMfNzDKrXTUuZ5BKCGM4+1gJWm/U3n4W+2aRn8d8dA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.114.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.114.0.tgz", + "integrity": "sha512-gSkuQrM/etAlUnw9YMDA2e+iNMCLezf0IHyOzmetOqy9KPsGe2C3puTd/4xalirnko+p3BEykFVliHaY2yXVrw==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.114.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.114.0.tgz", + "integrity": "sha512-leK7YsDVqIKd9hQRJeK03QRZ8Slg2JExmNIj+BL7dkD3m+ZNREJ7ITQdrc7VhNhh8+Eb/ebm1Ts4AOoY4LFSiA==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.114.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.114.0.tgz", + "integrity": "sha512-uvmqk2yxVp77c/LjWzJaw1/HId+2a3sck4idbBy3nOTro36l7nVcHdT/XENHj7Fi/IJAvAsJrTSgTEegbYTxvQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.114.0", + "@supabase/functions-js": "2.114.0", + "@supabase/postgrest-js": "2.114.0", + "@supabase/realtime-js": "2.114.0", + "@supabase/storage-js": "2.114.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -1413,6 +1506,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 4727c8b..4cdcc5b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "preview": "vite preview" }, "dependencies": { + "@supabase/supabase-js": "^2.114.0", "@vis.gl/react-google-maps": "^1.9.0", "framer-motion": "^12.42.2", "jspdf": "^4.2.1", diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..d2283ef Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000..0e2b7e2 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 6893eb1..553642a 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index dffc4d9..92d30af 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ import About from './modules/dashboard/About'; import Footer from './components/shared/navigation/Footer'; // Auth +import { AuthProvider, useAuth } from './context/AuthContext'; import Login from './modules/authentication/Login'; import ForgotPassword from './modules/authentication/ForgotPassword'; @@ -82,32 +83,44 @@ const VIEW_PATH_MAP = { }; function resolveInitialView() { - const pathname = window.location.pathname.replace(/\/+$/, '') || '/'; const hash = window.location.hash.replace(/^#\/?/, '/').replace(/\/+$/, ''); + const pathname = window.location.pathname.replace(/\/+$/, '') || '/'; - if (ROUTE_PATH_MAP[pathname]) { - return ROUTE_PATH_MAP[pathname]; - } if (ROUTE_PATH_MAP[hash]) { return ROUTE_PATH_MAP[hash]; } + if (ROUTE_PATH_MAP[pathname]) { + return ROUTE_PATH_MAP[pathname]; + } return localStorage.getItem('ksp_current_view') || 'landing'; } function AppContent() { const [currentView, setCurrentView] = useState(resolveInitialView); - const [selectedRole, setSelectedRole] = useState(() => { - return localStorage.getItem('ksp_selected_role') || null; - }); + const { session, role: authRole, logout, isLoading } = useAuth(); + const [selectedRoleIntent, setSelectedRoleIntent] = useState(null); + + // Authoritative role from verified Supabase session + const effectiveRole = authRole || selectedRoleIntent; const navigateTo = useCallback((viewOrPath) => { const resolvedView = ROUTE_PATH_MAP[viewOrPath] || viewOrPath; setCurrentView(resolvedView); const targetPath = VIEW_PATH_MAP[resolvedView] || (resolvedView === 'landing' ? '/' : null); - if (targetPath && window.location.pathname !== targetPath) { + if (targetPath) { try { - window.history.pushState({ view: resolvedView }, '', targetPath); + const targetHash = targetPath === '/' ? '' : `#${targetPath}`; + const targetUrl = targetHash + ? `${window.location.pathname}${window.location.search}${targetHash}` + : `${window.location.pathname}${window.location.search}`; + + const currentNormalizedHash = window.location.hash.replace(/^#\/?/, '/').replace(/\/+$/, ''); + const targetNormalizedHash = targetPath === '/' ? '' : targetPath; + + if (currentNormalizedHash !== targetNormalizedHash) { + window.history.pushState({ view: resolvedView }, '', targetUrl); + } } catch { // Fallback for isolated webview contexts } @@ -148,23 +161,31 @@ function AppContent() { return () => window.removeEventListener('ksp_preferences_updated', applySavedTheme); }, []); - // Listen to browser forward/backward buttons + // Listen to browser forward/backward buttons and hash changes useEffect(() => { - const handlePopState = (e) => { + const handleUrlChange = (e) => { + const hash = window.location.hash.replace(/^#\/?/, '/').replace(/\/+$/, ''); const pathname = window.location.pathname.replace(/\/+$/, '') || '/'; - if (ROUTE_PATH_MAP[pathname]) { - setCurrentView(ROUTE_PATH_MAP[pathname]); - } else if (e.state?.view) { + + if (ROUTE_PATH_MAP[hash]) { + setCurrentView(ROUTE_PATH_MAP[hash]); + } else if (e?.state?.view) { setCurrentView(e.state.view); + } else if (ROUTE_PATH_MAP[pathname]) { + setCurrentView(ROUTE_PATH_MAP[pathname]); } else if (pathname === '/dashboard') { setCurrentView('dashboard'); - } else if (pathname === '/' || pathname === '') { + } else { setCurrentView('landing'); } }; - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); + window.addEventListener('popstate', handleUrlChange); + window.addEventListener('hashchange', handleUrlChange); + return () => { + window.removeEventListener('popstate', handleUrlChange); + window.removeEventListener('hashchange', handleUrlChange); + }; }, []); useEffect(() => { @@ -173,18 +194,22 @@ function AppContent() { } }, [currentView]); + // Protect dashboard view — require active Supabase session once initialization finishes useEffect(() => { - if (selectedRole) { - localStorage.setItem('ksp_selected_role', selectedRole); + if (!isLoading && currentView === 'dashboard' && !session) { + navigateTo('auth-login'); } - }, [selectedRole]); + }, [currentView, session, isLoading, navigateTo]); const navigateToAuth = () => navigateTo('auth-login'); - const handleLogout = () => { - setSelectedRole(null); - localStorage.removeItem('ksp_selected_role'); - localStorage.removeItem('ksp_active_module'); + const handleLogout = async () => { + try { + await logout(); + } catch { + // Safe fallback + } + setSelectedRoleIntent(null); navigateTo('landing'); }; @@ -193,12 +218,12 @@ function AppContent() { }; const handleRoleSelect = (role) => { - setSelectedRole(role); + setSelectedRoleIntent(role); }; const handleLogin = (role) => { if (role) { - setSelectedRole(role); + setSelectedRoleIntent(role); } navigateTo('dashboard'); }; @@ -211,7 +236,7 @@ function AppContent() { ); @@ -222,7 +247,7 @@ function AppContent() { ); @@ -233,7 +258,7 @@ function AppContent() { ); @@ -244,7 +269,7 @@ function AppContent() { ); @@ -255,7 +280,7 @@ function AppContent() { ); @@ -266,7 +291,7 @@ function AppContent() { ); @@ -277,7 +302,7 @@ function AppContent() { ); @@ -288,7 +313,7 @@ function AppContent() { ); @@ -299,7 +324,7 @@ function AppContent() { ); @@ -310,18 +335,28 @@ function AppContent() { ); case 'dashboard': + if (isLoading) { + return ( + +
+
+

Verifying session credentials...

+
+ + ); + } return ( ); @@ -331,7 +366,7 @@ function AppContent() { return ( navigateTo('auth-forgot')} @@ -352,13 +387,13 @@ function AppContent() {
navigateTo('dashboard') : navigateToAuth} + onLoginClick={session && effectiveRole ? () => navigateTo('dashboard') : navigateToAuth} onHomeClick={navigateToLanding} - role={selectedRole} + role={effectiveRole} />
navigateTo('dashboard') : navigateToAuth} + onLoginClick={session && effectiveRole ? () => navigateTo('dashboard') : navigateToAuth} onNavigate={navigateTo} /> @@ -368,9 +403,9 @@ function AppContent() {
navigateTo('dashboard') : navigateToAuth} + onLoginClick={session && effectiveRole ? () => navigateTo('dashboard') : navigateToAuth} onNavigate={navigateTo} - role={selectedRole} + role={effectiveRole} />
@@ -379,7 +414,7 @@ function AppContent() { }; return ( - + {renderView()} @@ -395,7 +430,9 @@ export default function App() { return ( - + + + ); diff --git a/frontend/src/components/shared/navigation/Footer.jsx b/frontend/src/components/shared/navigation/Footer.jsx index a875b6d..614d259 100644 --- a/frontend/src/components/shared/navigation/Footer.jsx +++ b/frontend/src/components/shared/navigation/Footer.jsx @@ -60,7 +60,7 @@ export default function Footer({ onLoginClick, rounded = false, role = null, act KSP

- {t('auth.portalName', 'AI-Driven Crime Analytics Platform')} + {t('auth.portalName', 'Crime Analytics Platform')}

@@ -232,7 +232,7 @@ export default function Footer({ onLoginClick, rounded = false, role = null, act

- © {new Date().getFullYear()} {t('auth.kspTitle', 'Karnataka State Police')}. {t('common.allRightsReserved', 'All rights reserved.')} + © {new Date().getFullYear()} {t('auth.kspTitle', 'KARNATAKA POLICE')}. {t('common.allRightsReserved', 'All rights reserved.')}

diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..7925d38 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -0,0 +1,222 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { supabase, isSupabaseConfigured } from '../services/supabase'; + +const AuthContext = createContext(null); + +export function normalizeAppRole(rawRole) { + if (!rawRole) return null; + const upper = String(rawRole).trim().toUpperCase().replace(/-/g, '_').replace(/ /g, '_'); + if (upper === 'FIELD_OFFICER' || upper === 'OFFICER') return 'officer'; + if (upper === 'ANALYST' || upper === 'INTELLIGENCE_ANALYST') return 'analyst'; + if (upper === 'ADMIN' || upper === 'SYSTEM_ADMINISTRATOR') return 'admin'; + return null; +} + +export function resolveAccountRole(user, profileData = null) { + if (!user) return null; + + // 1. Authoritative: public.user_profiles table row + if (profileData?.role) { + const fromProfile = normalizeAppRole(profileData.role); + if (fromProfile) return fromProfile; + } + + // 2. Authoritative: Supabase JWT server-verified app_metadata.role + const fromAppMetadata = normalizeAppRole(user.app_metadata?.role); + if (fromAppMetadata) return fromAppMetadata; + + // 3. Authoritative mapping for verified official departmental accounts + const normalizedEmail = (user.email || '').trim().toLowerCase(); + if (normalizedEmail === 'crimeintel.admin@gmail.com') return 'admin'; + if (normalizedEmail === 'crimeintel.analystt@gmail.com') return 'analyst'; + if (normalizedEmail === 'crimeintel.officer@gmail.com') return 'officer'; + + // 4. Safe least-privilege default + return 'officer'; +} + +export function AuthProvider({ children }) { + const [session, setSession] = useState(null); + const [user, setUser] = useState(null); + const [profile, setProfile] = useState(null); + const [role, setRole] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Fetch or resolve user profile from Supabase + const loadProfile = useCallback(async (activeUser, activeSession) => { + if (!activeUser) { + setProfile(null); + setRole(null); + return; + } + + let profileData = null; + + // 1. Query public.user_profiles if Supabase client is configured + try { + if (isSupabaseConfigured && activeSession) { + const { data, error } = await supabase + .from('user_profiles') + .select('*') + .eq('user_id', activeUser.id) + .maybeSingle(); + + if (!error && data) { + profileData = data; + } + } + } catch (err) { + console.warn('[AuthContext]: Profile table lookup notice:', err); + } + + // 2. Resolve authoritative role + const resolvedRole = resolveAccountRole(activeUser, profileData) || 'officer'; + setRole(resolvedRole); + setProfile(profileData || { + user_id: activeUser.id, + email: activeUser.email, + full_name: activeUser.user_metadata?.full_name || activeUser.user_metadata?.name || activeUser.email?.split('@')[0], + badge_number: activeUser.user_metadata?.badge_number || null, + role: resolvedRole.toUpperCase(), + }); + }, []); + + // Initialize and listen to active Supabase session + useEffect(() => { + let isMounted = true; + + async function initializeAuth() { + try { + if (!isSupabaseConfigured) { + // If Supabase keys are not set, stop loading + if (isMounted) setIsLoading(false); + return; + } + + const { data: { session: initialSession }, error } = await supabase.auth.getSession(); + if (error) { + console.warn('[AuthContext]: Session retrieval warning:', error); + } + + if (isMounted) { + setSession(initialSession); + setUser(initialSession?.user || null); + if (initialSession?.user) { + await loadProfile(initialSession.user, initialSession); + } + setIsLoading(false); + } + } catch (err) { + console.error('[AuthContext]: Auth initialization error:', err); + if (isMounted) setIsLoading(false); + } + } + + initializeAuth(); + + // Subscribe to auth events (SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, USER_UPDATED) + const { data: { subscription } } = supabase.auth.onAuthStateChange( + async (event, newSession) => { + if (!isMounted) return; + + setSession(newSession); + setUser(newSession?.user || null); + + if (newSession?.user) { + await loadProfile(newSession.user, newSession); + } else { + setProfile(null); + setRole(null); + } + + setIsLoading(false); + } + ); + + return () => { + isMounted = false; + subscription?.unsubscribe(); + }; + }, [loadProfile]); + + // Login with official departmental credentials + const login = async (email, password) => { + if (!isSupabaseConfigured) { + throw new Error('Supabase authentication is not configured. Please define VITE_SUPABASE_ANON_KEY.'); + } + + const { data, error } = await supabase.auth.signInWithPassword({ + email: email.trim(), + password, + }); + + if (error) { + throw error; + } + + setSession(data.session); + setUser(data.user); + await loadProfile(data.user, data.session); + return data; + }; + + // Sign out cleanly + const logout = async () => { + try { + if (isSupabaseConfigured) { + await supabase.auth.signOut(); + } + } catch (err) { + console.warn('[AuthContext]: SignOut error:', err); + } finally { + setSession(null); + setUser(null); + setProfile(null); + setRole(null); + localStorage.removeItem('ksp_selected_role'); + localStorage.removeItem('ksp_active_module'); + } + }; + + // Send password reset email + const resetPassword = async (email) => { + if (!isSupabaseConfigured) { + throw new Error('Supabase authentication is not configured. Please define VITE_SUPABASE_ANON_KEY.'); + } + + const redirectUrl = `${window.location.origin}/login`; + const { data, error } = await supabase.auth.resetPasswordForEmail(email.trim(), { + redirectTo: redirectUrl, + }); + + if (error) { + throw error; + } + + return data; + }; + + const value = { + session, + user, + profile, + role, + isLoading, + login, + logout, + resetPassword, + isConfigured: isSupabaseConfigured, + }; + + return {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} + +export default AuthContext; diff --git a/frontend/src/i18n/locales/en.js b/frontend/src/i18n/locales/en.js index 45f2526..63ef889 100644 --- a/frontend/src/i18n/locales/en.js +++ b/frontend/src/i18n/locales/en.js @@ -1033,15 +1033,15 @@ export default { // Authentication & Login auth: { - kspTitle: 'Karnataka State Police', - portalName: 'Crime Intelligence & Predictive Analytics Platform', + kspTitle: 'KARNATAKA POLICE', + portalName: 'Crime Analytics Platform', officerPortal: 'Field Officer Portal', analystPortal: 'Intelligence Analyst Portal', adminPortal: 'System Administrator Portal', signIn: 'Secure Portal Login', portalSubtitle: 'Select your access level and enter credentials to authenticate.', email: 'Official ID / Email', - emailPlaceholder: 'Select access level to autofill', + emailPlaceholder: 'Enter official email', password: 'Password', passwordPlaceholder: 'Enter password', rememberMe: 'Remember this terminal', diff --git a/frontend/src/i18n/locales/kn.js b/frontend/src/i18n/locales/kn.js index e72b085..9f19c1d 100644 --- a/frontend/src/i18n/locales/kn.js +++ b/frontend/src/i18n/locales/kn.js @@ -1163,15 +1163,15 @@ export default { // Authentication & Login auth: { - kspTitle: 'ಕರ್ನಾಟಕ ರಾಜ್ಯ ಪೊಲೀಸ್', - portalName: 'ಅಪರಾಧ ಗುಪ್ತಚರ & ಮುನ್ಸೂಚನಾ ವಿಶ್ಲೇಷಣಾ ವೇದಿಕೆ', + kspTitle: 'ಕರ್ನಾಟಕ ಪೊಲೀಸ್', + portalName: 'ಅಪರಾಧ ವಿಶ್ಲೇಷಣಾ ವೇದಿಕೆ', officerPortal: 'ಫೀಲ್ಡ್ ಆಫೀಸರ್ ಪೋರ್ಟಲ್', analystPortal: 'ಇಂಟೆಲಿಜೆನ್ಸ್ ಅನಲಿಸ್ಟ್ ಪೋರ್ಟಲ್', adminPortal: 'ಸಿಸ್ಟಮ್ ಅಡ್ಮಿನಿಸ್ಟ್ರೇಟರ್ ಪೋರ್ಟಲ್', signIn: 'ಸುರಕ್ಷಿತ ಪೋರ್ಟಲ್ ಲಾಗಿನ್', portalSubtitle: 'ಪ್ರವೇಶ ಮಟ್ಟವನ್ನು ಆಯ್ಕೆಮಾಡಿ ಮತ್ತು ದೃಢೀಕರಿಸಲು ರುಜುವಾತುಗಳನ್ನು ನಮೂದಿಸಿ.', email: 'ಅಧಿಕೃತ ಐಡಿ / ಇಮೇಲ್', - emailPlaceholder: 'ಸ್ವಯಂ ತುಂಬಲು ಪ್ರವೇಶ ಮಟ್ಟ ಆಯ್ಕೆಮಾಡಿ', + emailPlaceholder: 'ಅಧಿಕೃತ ಇಮೇಲ್ ನಮೂದಿಸಿ', password: 'ಪಾಸ್‌ವರ್ಡ್', passwordPlaceholder: 'ಪಾಸ್‌ವರ್ಡ್ ನಮೂದಿಸಿ', rememberMe: 'ಈ ಟರ್ಮಿನಲ್ ಅನ್ನು ನೆನಪಿಡಿ', diff --git a/frontend/src/modules/authentication/ForgotPassword.jsx b/frontend/src/modules/authentication/ForgotPassword.jsx index 4066df0..a726757 100644 --- a/frontend/src/modules/authentication/ForgotPassword.jsx +++ b/frontend/src/modules/authentication/ForgotPassword.jsx @@ -1,23 +1,47 @@ import React, { useState } from 'react'; -import { Shield, Mail, Loader2, CheckCircle2, ArrowLeft } from 'lucide-react'; -import { motion } from 'framer-motion'; +import { Shield, Mail, Loader2, CheckCircle2, ArrowLeft, AlertCircle } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; import kspLogo from '../../assets/ksp-official-logo.webp'; import LazyImage from '../../components/ui/LazyImage'; import { useTranslation } from '../../i18n'; +import { useAuth } from '../../context/AuthContext'; +import { useToast } from '../../components/ui/Toast'; export default function ForgotPassword({ onBack }) { const { t } = useTranslation(); + const { resetPassword } = useAuth(); + const { addToast } = useToast(); const [isLoading, setIsLoading] = useState(false); const [isSent, setIsSent] = useState(false); const [email, setEmail] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); + if (!email || !email.trim()) return; + setIsLoading(true); - setTimeout(() => { + setErrorMessage(''); + + try { + await resetPassword(email); setIsLoading(false); setIsSent(true); - }, 1500); + addToast({ + title: t('auth.resetSuccessTitle', 'Reset Link Dispatched'), + message: t('auth.resetSuccessMsg', 'Please check your official email for further instructions.'), + type: 'success', + }); + } catch (err) { + setIsLoading(false); + const msg = err?.message || 'Failed to dispatch reset request. Please verify your official email address.'; + setErrorMessage(msg); + addToast({ + title: 'Reset Error', + message: msg, + type: 'error', + }); + } }; return ( diff --git a/frontend/src/modules/authentication/Login.jsx b/frontend/src/modules/authentication/Login.jsx index 9d36981..d8f6eb3 100644 --- a/frontend/src/modules/authentication/Login.jsx +++ b/frontend/src/modules/authentication/Login.jsx @@ -6,40 +6,37 @@ import LazyImage from '../../components/ui/LazyImage'; import { useToast } from '../../components/ui/Toast'; import { useTranslation } from '../../i18n'; +import { useAuth, resolveAccountRole } from '../../context/AuthContext'; + const roles = [ { id: 'officer', nameKey: 'auth.roleFieldOfficer', name: 'Field Officer', icon: User }, { id: 'analyst', nameKey: 'auth.roleAnalyst', name: 'Intelligence Analyst', icon: Shield }, { id: 'admin', nameKey: 'auth.roleAdmin', name: 'System Administrator', icon: Settings } ]; -export const ROLE_CREDENTIALS = { +export const DEMO_PREFILL_CREDENTIALS = { officer: { email: 'crimeintel.officer@gmail.com', - password: 'Officer@Pass2026', - name: 'Field Officer', - badge: 'KSP-FO-4892' + password: 'Techfortune@123', }, analyst: { email: 'crimeintel.analystt@gmail.com', - password: 'Analyst@Pass2026', - name: 'Intelligence Analyst', - badge: 'KSP-IA-1044' + password: 'Techfortune@123', }, admin: { email: 'crimeintel.admin@gmail.com', - password: 'Admin@Pass2026', - name: 'System Administrator', - badge: 'KSP-ADM-001' - } + password: 'Techfortune@123', + }, }; export default function Login({ role, onRoleSelect, onBack, onForgot, onLogin }) { const { addToast } = useToast(); const { t } = useTranslation(); + const { login } = useAuth(); const [isLoading, setIsLoading] = useState(false); const [selectedRole, setSelectedRole] = useState(role || null); - const initialCreds = role && ROLE_CREDENTIALS[role] ? ROLE_CREDENTIALS[role] : { email: '', password: '' }; + const initialCreds = role && DEMO_PREFILL_CREDENTIALS[role] ? DEMO_PREFILL_CREDENTIALS[role] : { email: '', password: '' }; const [email, setEmail] = useState(initialCreds.email); const [password, setPassword] = useState(initialCreds.password); const [showPassword, setShowPassword] = useState(false); @@ -48,10 +45,10 @@ export default function Login({ role, onRoleSelect, onBack, onForgot, onLogin }) useEffect(() => { if (role) { setSelectedRole(role); - const creds = ROLE_CREDENTIALS[role]; - if (creds) { - setEmail(creds.email); - setPassword(creds.password); + const prefill = DEMO_PREFILL_CREDENTIALS[role]; + if (prefill) { + setEmail(prefill.email); + setPassword(prefill.password); } setErrorMessage(''); } @@ -59,16 +56,18 @@ export default function Login({ role, onRoleSelect, onBack, onForgot, onLogin }) const handleRoleClick = (roleId) => { setSelectedRole(roleId); - const creds = ROLE_CREDENTIALS[roleId] || { email: '', password: '' }; - setEmail(creds.email); - setPassword(creds.password); + const prefill = DEMO_PREFILL_CREDENTIALS[roleId]; + if (prefill) { + setEmail(prefill.email); + setPassword(prefill.password); + } setErrorMessage(''); if (onRoleSelect) { onRoleSelect(roleId); } }; - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); if (!selectedRole) { addToast({ @@ -101,40 +100,44 @@ export default function Login({ role, onRoleSelect, onBack, onForgot, onLogin }) return; } - const expected = ROLE_CREDENTIALS[selectedRole]; - const normalizedEnteredEmail = email.trim().toLowerCase(); - const normalizedExpectedEmail = expected.email.toLowerCase(); - - if (normalizedEnteredEmail !== normalizedExpectedEmail || password !== expected.password) { - const errorMsg = t('auth.authDeniedMsg', 'Invalid credentials. Please verify your official email and password for the selected access level.'); - setErrorMessage(errorMsg); - addToast({ - title: t('auth.authDenied', 'Authentication Denied'), - message: errorMsg, - type: 'error', - }); - return; - } - setErrorMessage(''); setIsLoading(true); - setTimeout(() => { + try { + const authData = await login(email, password); + const userObj = authData?.user; + + // Extract verified authoritative role from authenticated identity + const normalizedRole = resolveAccountRole(userObj) || 'officer'; + setIsLoading(false); + const userName = userObj?.user_metadata?.full_name || userObj?.user_metadata?.name || email.split('@')[0]; + const userBadge = userObj?.user_metadata?.badge_number || 'KSP-AUTH'; + addToast({ title: t('auth.authSuccess', 'Authentication Successful'), - message: `Welcome back, ${expected.name} (${expected.badge}).`, + message: `Welcome back, ${userName} (${userBadge}). Authorized level: ${normalizedRole.toUpperCase()}.`, type: 'success', }); + if (onLogin) { - onLogin(selectedRole, { - email: expected.email, - name: expected.name, - role: selectedRole, - badge: expected.badge + onLogin(normalizedRole, { + email: userObj?.email, + name: userName, + role: normalizedRole, + badge: userBadge, }); } - }, 600); + } catch (err) { + setIsLoading(false); + const errorMsg = err?.message || t('auth.authDeniedMsg', 'Invalid credentials. Please verify your official email and password.'); + setErrorMessage(errorMsg); + addToast({ + title: t('auth.authDenied', 'Authentication Denied'), + message: errorMsg, + type: 'error', + }); + } }; return ( @@ -266,7 +269,7 @@ export default function Login({ role, onRoleSelect, onBack, onForgot, onLogin }) required value={email} onChange={(e) => setEmail(e.target.value)} - placeholder={t('auth.emailPlaceholder', 'Select access level to autofill')} + placeholder={t('auth.emailPlaceholder', 'Enter official email')} className="w-full pl-11 pr-4 py-3 rounded-full border border-slate-200 text-sm text-[#142B45] placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#E00000] focus:border-transparent transition-all shadow-2xs font-medium" /> diff --git a/frontend/src/modules/dashboard/Hero.jsx b/frontend/src/modules/dashboard/Hero.jsx index 3b27226..3ad56c5 100644 --- a/frontend/src/modules/dashboard/Hero.jsx +++ b/frontend/src/modules/dashboard/Hero.jsx @@ -4,13 +4,13 @@ import { ArrowRight, ShieldCheck, FileText } from 'lucide-react'; import kspLogo from '../../assets/ksp-official-logo.webp'; import vidhanSoudha from '../../assets/vidhan-soudha-exact.webp'; import { useTranslation } from '../../i18n'; -import { downloadArchitectureDocumentation } from '../../utils/documentationPdf'; +import { downloadArchitectureDocumentation, openDocumentInNewTab } from '../../utils/documentationPdf'; export default function Hero({ onLoginClick, onNavigate }) { const { t } = useTranslation(); const handleReadDocumentation = () => { - downloadArchitectureDocumentation(); + openDocumentInNewTab('crimeintel-architecture-documentation.pdf'); }; return ( diff --git a/frontend/src/modules/dashboard/Workflow.jsx b/frontend/src/modules/dashboard/Workflow.jsx index 3e34899..c142dae 100644 --- a/frontend/src/modules/dashboard/Workflow.jsx +++ b/frontend/src/modules/dashboard/Workflow.jsx @@ -3,7 +3,7 @@ import { motion } from 'framer-motion'; import { Database, BrainCircuit, TrendingUp, Target, FileText, ArrowRight } from 'lucide-react'; import workflowBg from '../../assets/workflow-bg.webp'; import { useTranslation } from '../../i18n'; -import { downloadArchitectureDocumentation } from '../../utils/documentationPdf'; +import { downloadArchitectureDocumentation, openDocumentInNewTab } from '../../utils/documentationPdf'; const steps = [ { @@ -82,7 +82,7 @@ export default function Workflow() { {/* Read Architecture Docs Button */}