From 348d1da44202f3bdfa14c56596711240117043c3 Mon Sep 17 00:00:00 2001 From: Durga Ghimeray <152849649+durga710@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:09:15 +0000 Subject: [PATCH 01/13] chore: scaffold RayVerify monorepo foundation (schema, Prisma model, README) --- .gitignore | 56 ++ README.md | 181 ++++++ db/schema.sql | 585 +++++++++++++++++ package.json | 30 + packages/backend/prisma/schema.prisma | 868 ++++++++++++++++++++++++++ 5 files changed, 1720 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 db/schema.sql create mode 100644 package.json create mode 100644 packages/backend/prisma/schema.prisma diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7819661 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Builds +dist/ +build/ +.next/ +out/ +*.tsbuildinfo + +# Env & secrets +.env +.env.* +!.env.example +*.pem +*.key +secrets/ + +# Prisma +packages/backend/prisma/*.db +packages/backend/prisma/migrations/dev.db* + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Test / coverage +coverage/ +.nyc_output/ +test-results/ +playwright-report/ + +# OS / editor +.DS_Store +Thumbs.db +.idea/ +.vscode/* +!.vscode/extensions.json +*.swp + +# Terraform +infra/**/.terraform/ +infra/**/*.tfstate +infra/**/*.tfstate.* +infra/**/*.tfvars +!infra/**/*.tfvars.example +.terraform.lock.hcl + +# Misc +tmp/ +.cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..de91e72 --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# RayVerify™ + +**Government-grade fraud detection & identity verification for Medicaid, HCBS, personal care, and government-funded healthcare programs.** + +> Parent platform: **RayHealthEVV™** + +Current Electronic Visit Verification (EVV) systems verify *time* and *location*. RayVerify verifies **identity, presence, location, device authenticity, patient confirmation, and billing legitimacy** — and surfaces fraud intelligence to investigators and state agencies *before payments are made*. + +This is **not** an EVV platform. It is a **fraud prevention, identity verification, and program integrity** platform for state Medicaid agencies, MCOs, program integrity units, OIG investigators, compliance officers, and auditors. + +--- + +## Table of contents + +- [Product modules](#product-modules) +- [Repository layout](#repository-layout) +- [Architecture at a glance](#architecture-at-a-glance) +- [Quickstart](#quickstart) +- [Documentation](#documentation) +- [Security & compliance](#security--compliance) +- [Status & roadmap](#status--roadmap) + +--- + +## Product modules + +| # | Module | Purpose | +|---|--------|---------| +| 1 | **Identity Verification Engine** | Selfie + liveness + device-trust verification that the caregiver is who they claim to be. | +| 2 | **Visit Verification Engine** | Every visit produces a verification package: identity → GPS → device → patient → fraud scoring → approval. | +| 3 | **Fraud Intelligence Engine** | Rules + ML detectors (impossible travel, duplicate visits, shared devices, billing anomalies…) producing an explainable 0–100 fraud score. | +| 4 | **Investigator Dashboard** | Fraud alerts, provider risk rankings, heat maps, case management, evidence review, fraud timelines. | +| 5 | **Provider Risk Scoring** | Dynamic per-provider risk score with historical trend. | +| 6 | **Audit & Compliance Center** | Immutable, searchable, exportable audit trail with tamper-evident hash chain. | +| 7 | **Reporting & Analytics** | Fraud, provider-risk, visit-verification, investigation, and state-compliance reports (PDF/Excel). | +| 8 | **Future Hardware Integration Layer** | SDK abstraction for NFC, fingerprint, facial-recognition cameras, secure element, GPS, LTE. | + +--- + +## Repository layout + +``` +RayVerify/ +├── README.md +├── package.json # npm workspaces (monorepo root) +├── docker-compose.yml # Postgres + Redis + LocalStack for local dev +├── db/ +│ └── schema.sql # Production PostgreSQL DDL (partitioning, RLS, triggers) +├── api/ +│ └── openapi.yaml # OpenAPI 3.1 specification +├── docs/ # Architecture, compliance & operations docs +│ ├── 00-overview.md +│ ├── 01-product-requirements.md +│ ├── 02-system-architecture.md +│ ├── 03-database-design.md +│ ├── 04-api-design.md +│ ├── 05-fraud-detection-engine.md +│ ├── 06-ai-risk-scoring.md +│ ├── 07-security-architecture.md +│ ├── 08-aws-deployment.md +│ ├── 09-cicd-pipeline.md +│ ├── 10-development-roadmap.md +│ └── 11-production-deployment.md +├── packages/ +│ ├── backend/ # NestJS + Prisma API & engines +│ │ ├── prisma/schema.prisma # Canonical logical data model +│ │ └── src/modules/… # auth, verification, visits, fraud, … +│ ├── frontend/ # Next.js 15 investigator dashboard +│ └── shared/ # Shared TS types, enums, DTO contracts +├── infra/ +│ └── terraform/ # AWS IaC (VPC, RDS, ECS, S3, CloudFront, KMS) +└── .github/workflows/ # CI/CD pipelines +``` + +--- + +## Architecture at a glance + +- **Frontend:** Next.js 15 · TypeScript · TailwindCSS · shadcn/ui +- **Backend:** NestJS · Prisma · PostgreSQL · Redis (queues + cache) +- **Infra:** AWS — RDS PostgreSQL · S3 · CloudFront · ECS (EKS-ready) +- **Security:** AES-256 at rest · TLS 1.3 in transit · JWT + refresh · RBAC · MFA · immutable audit logs · Zero Trust +- **Patterns:** Multi-tenant (row-level security) · API-first · microservice-ready + +```mermaid +flowchart LR + subgraph Clients + INV[Investigator Dashboard] + FIELD[Field Capture / EVV app] + EXT[State / MCO integrations] + end + INV & FIELD & EXT --> GW[API Gateway / NestJS] + GW --> IDV[Identity Engine] + GW --> VIS[Visit Verification] + GW --> FRAUD[Fraud Intelligence] + GW --> CASE[Case Mgmt] + FRAUD --> ML[(AI Risk Scoring)] + IDV & VIS & FRAUD & CASE --> DB[(PostgreSQL — RLS, partitioned)] + IDV & VIS --> S3[(S3 — encrypted evidence)] + FRAUD --> REDIS[(Redis queues)] + GW --> AUDIT[(Immutable Audit Log)] +``` + +See [`docs/02-system-architecture.md`](docs/02-system-architecture.md) for the full set of diagrams. + +--- + +## Quickstart + +> Prerequisites: Node ≥ 20, npm ≥ 10, Docker. + +```bash +# 1. Install workspace dependencies +npm install + +# 2. Bring up Postgres + Redis (+ LocalStack S3) for local dev +npm run dev:infra + +# 3. Configure backend env +cp packages/backend/.env.example packages/backend/.env + +# 4. Apply the schema & seed reference data +npm run db:migrate +npm run db:seed + +# 5. Run everything (backend :4000, frontend :3000) +npm run dev +``` + +- Backend API + Swagger UI: `http://localhost:4000/docs` +- Frontend dashboard: `http://localhost:3000` + +--- + +## Documentation + +| Doc | Contents | +|-----|----------| +| [00 — Overview](docs/00-overview.md) | Vision, users, value proposition, glossary. | +| [01 — Product Requirements](docs/01-product-requirements.md) | Full PRD: personas, module specs, functional & non-functional requirements. | +| [02 — System Architecture](docs/02-system-architecture.md) | Service & deployment diagrams, data flow, tenancy model. | +| [03 — Database Design](docs/03-database-design.md) | ERD, table reference, partitioning & indexing strategy. | +| [04 — API Design](docs/04-api-design.md) | Endpoints, request/response contracts, versioning, errors. | +| [05 — Fraud Detection Engine](docs/05-fraud-detection-engine.md) | Detector catalog, scoring pipeline, rule definitions. | +| [06 — AI Risk Scoring](docs/06-ai-risk-scoring.md) | Feature store, models, explainability framework. | +| [07 — Security Architecture](docs/07-security-architecture.md) | HIPAA/HITECH/NIST 800-63/SOC 2/CMS controls, encryption, Zero Trust. | +| [08 — AWS Deployment](docs/08-aws-deployment.md) | Cloud topology, networking, IaC. | +| [09 — CI/CD Pipeline](docs/09-cicd-pipeline.md) | Build/test/scan/deploy stages. | +| [10 — Development Roadmap](docs/10-development-roadmap.md) | Phased delivery plan & milestones. | +| [11 — Production Deployment](docs/11-production-deployment.md) | Go-live runbook, DR, observability. | + +--- + +## Security & compliance + +RayVerify is designed against **HIPAA**, **HITECH**, **NIST 800-63 (IAL/AAL)**, **SOC 2**, and **CMS EVV** requirements. Highlights: + +- AES-256-GCM envelope encryption for PHI (AWS KMS); TLS 1.3 in transit. +- Row-level security for hard tenant isolation (`db/schema.sql`). +- Append-only verification evidence + tamper-evident audit hash chain. +- RBAC + MFA + least-privilege; full auditability of every read/write/export. + +Full control mapping: [`docs/07-security-architecture.md`](docs/07-security-architecture.md). + +> ⚠️ This repository contains architecture, schema, and reference implementation +> scaffolding. It is **not** yet an authorized-to-operate (ATO) production system. +> A formal security assessment, penetration test, and BAA chain are required +> before processing real PHI. + +--- + +## Status & roadmap + +This is the **foundation** drop: data model, schema, API contract, service +scaffolding, and the full documentation set. See +[`docs/10-development-roadmap.md`](docs/10-development-roadmap.md) for the path +from foundation → MVP → state pilot → production. + +--- + +*RayVerify™ and RayHealthEVV™ are product names used throughout this repository.* diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..c9b1785 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,585 @@ +-- ============================================================================= +-- RayVerify(TM) — Production PostgreSQL Schema +-- Government-grade fraud detection & identity verification (Medicaid / HCBS). +-- +-- This is the PHYSICAL source of truth. It complements the Prisma logical model +-- (packages/backend/prisma/schema.prisma) with concerns Prisma cannot express: +-- * Native ENUM types +-- * Declarative range PARTITIONING (visits, audit_logs, verification tables) +-- * ROW-LEVEL SECURITY for hard multi-tenant isolation +-- * Immutability triggers on append-only / evidence tables +-- * Tamper-evident hash chain on audit_logs +-- +-- Target: PostgreSQL 15+. Tested against 16. +-- Apply order: extensions -> enums -> tables -> partitions -> rls -> triggers. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 0. EXTENSIONS +-- ----------------------------------------------------------------------------- +CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid(), digest() +CREATE EXTENSION IF NOT EXISTS citext; -- case-insensitive email/slug +CREATE EXTENSION IF NOT EXISTS pg_trgm; -- fuzzy search on names/case numbers +-- Optional (recommended in prod for geofencing): CREATE EXTENSION postgis; + +-- ----------------------------------------------------------------------------- +-- 1. ENUM TYPES +-- ----------------------------------------------------------------------------- +CREATE TYPE user_status AS ENUM ('ACTIVE','INACTIVE','SUSPENDED','LOCKED','PENDING_INVITE'); +CREATE TYPE mfa_method AS ENUM ('NONE','TOTP','SMS','WEBAUTHN'); +CREATE TYPE verification_result AS ENUM ('PASS','REVIEW','FAIL'); +CREATE TYPE risk_level AS ENUM ('LOW','MODERATE','HIGH','CRITICAL'); +CREATE TYPE visit_status AS ENUM ('SCHEDULED','IN_PROGRESS','COMPLETED','FLAGGED','REJECTED','APPROVED','CANCELLED'); +CREATE TYPE identity_method AS ENUM ('SELFIE','LIVENESS','DEVICE_TRUST','FINGERPRINT','NFC_CARD','GOV_CREDENTIAL'); +CREATE TYPE device_trust_level AS ENUM ('TRUSTED','UNKNOWN','SUSPICIOUS','BLOCKED'); +CREATE TYPE device_platform AS ENUM ('IOS','ANDROID','WEB','HARDWARE_TERMINAL'); +CREATE TYPE fraud_event_type AS ENUM ( + 'IMPOSSIBLE_TRAVEL','DUPLICATE_VISIT','SHARED_DEVICE','GPS_ANOMALY','IDENTITY_MISMATCH', + 'UNUSUAL_BILLING','ABNORMAL_DURATION','EXCESSIVE_OVERTIME','SERVICE_OVERLAP', + 'CROSS_PROVIDER_RISK','LIVENESS_FAILURE','DEVICE_TAMPERING','GEOFENCE_BREACH'); +CREATE TYPE fraud_event_status AS ENUM ('OPEN','TRIAGED','LINKED_TO_CASE','DISMISSED','CONFIRMED'); +CREATE TYPE case_status AS ENUM ('OPEN','IN_REVIEW','ESCALATED','PENDING_PAYMENT_HOLD','SUBSTANTIATED','UNSUBSTANTIATED','CLOSED'); +CREATE TYPE case_priority AS ENUM ('LOW','MEDIUM','HIGH','URGENT'); +CREATE TYPE score_subject_type AS ENUM ('VISIT','PROVIDER','CAREGIVER','PATIENT','CLAIM'); +CREATE TYPE report_type AS ENUM ('FRAUD_SUMMARY','PROVIDER_RISK','VISIT_VERIFICATION','INVESTIGATION','STATE_COMPLIANCE','EXECUTIVE_DASHBOARD'); +CREATE TYPE report_format AS ENUM ('PDF','XLSX','CSV','JSON'); +CREATE TYPE report_status AS ENUM ('QUEUED','GENERATING','READY','FAILED','EXPIRED'); +CREATE TYPE notification_channel AS ENUM ('IN_APP','EMAIL','SMS','WEBHOOK'); +CREATE TYPE notification_status AS ENUM ('PENDING','SENT','DELIVERED','READ','FAILED'); +CREATE TYPE audit_action AS ENUM ('CREATE','READ','UPDATE','DELETE','LOGIN','LOGOUT','EXPORT','VERIFY','SCORE','CASE_ACTION','CONFIG_CHANGE'); + +-- ----------------------------------------------------------------------------- +-- 2. TENANCY, IDENTITY & ACCESS CONTROL +-- ----------------------------------------------------------------------------- +CREATE TABLE organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug CITEXT NOT NULL UNIQUE, + jurisdiction TEXT, + settings JSONB NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + email CITEXT NOT NULL, + password_hash TEXT, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + phone TEXT, + status user_status NOT NULL DEFAULT 'PENDING_INVITE', + mfa_method mfa_method NOT NULL DEFAULT 'NONE', + mfa_secret TEXT, + last_login_at TIMESTAMPTZ, + failed_logins INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (organization_id, email) +); +CREATE INDEX idx_users_org_status ON users (organization_id, status); + +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + refresh_token_hash TEXT NOT NULL UNIQUE, + user_agent TEXT, + ip_address INET, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_sessions_user_exp ON sessions (user_id, expires_at); + +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + key TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (organization_id, key) +); + +CREATE TABLE permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key TEXT NOT NULL UNIQUE, -- "resource:action" + description TEXT +); + +CREATE TABLE role_permissions ( + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_id) +); + +CREATE TABLE user_roles ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); + +-- ----------------------------------------------------------------------------- +-- 3. DOMAIN: PROVIDERS, CAREGIVERS, PATIENTS, AUTHORIZATIONS +-- ----------------------------------------------------------------------------- +CREATE TABLE providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + npi TEXT, + medicaid_id TEXT, + legal_name TEXT NOT NULL, + tax_id TEXT, -- encrypted at app layer + is_active BOOLEAN NOT NULL DEFAULT TRUE, + enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (organization_id, npi), + CONSTRAINT chk_npi_format CHECK (npi IS NULL OR npi ~ '^[0-9]{10}$') +); +CREATE INDEX idx_providers_org_active ON providers (organization_id, is_active); + +CREATE TABLE caregivers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE, + external_id TEXT, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email CITEXT, + phone TEXT, + status user_status NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_caregivers_org_provider ON caregivers (organization_id, provider_id); + +CREATE TABLE biometric_enrollments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + caregiver_id UUID NOT NULL REFERENCES caregivers(id) ON DELETE CASCADE, + method identity_method NOT NULL DEFAULT 'SELFIE', + reference_s3_key TEXT, + template_ref TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(), + retired_at TIMESTAMPTZ +); +CREATE INDEX idx_enrollments_caregiver_active ON biometric_enrollments (caregiver_id, is_active); + +CREATE TABLE patients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + medicaid_member_id TEXT, -- encrypted; blind-index column omitted for brevity + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + date_of_birth DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_patients_org ON patients (organization_id); + +CREATE TABLE service_authorizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE, + service_code TEXT NOT NULL, + description TEXT, + address_line1 TEXT, + address_line2 TEXT, + city TEXT, + state TEXT, + postal_code TEXT, + latitude NUMERIC(9,6), + longitude NUMERIC(9,6), + radius_meters INTEGER NOT NULL DEFAULT 150, + authorized_units INTEGER, + start_date DATE NOT NULL, + end_date DATE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_radius_positive CHECK (radius_meters > 0), + CONSTRAINT chk_lat CHECK (latitude IS NULL OR latitude BETWEEN -90 AND 90), + CONSTRAINT chk_lng CHECK (longitude IS NULL OR longitude BETWEEN -180 AND 180) +); +CREATE INDEX idx_auth_org_patient_active ON service_authorizations (organization_id, patient_id, is_active); + +-- ----------------------------------------------------------------------------- +-- 4. DEVICE TRUST +-- ----------------------------------------------------------------------------- +CREATE TABLE devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + device_id TEXT NOT NULL, + fingerprint_hash TEXT, + platform device_platform NOT NULL DEFAULT 'WEB', + os_version TEXT, + browser TEXT, + app_version TEXT, + last_ip_address INET, + trust_level device_trust_level NOT NULL DEFAULT 'UNKNOWN', + is_emulator BOOLEAN NOT NULL DEFAULT FALSE, + is_rooted BOOLEAN NOT NULL DEFAULT FALSE, + is_jailbroken BOOLEAN NOT NULL DEFAULT FALSE, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (organization_id, device_id) +); +CREATE INDEX idx_devices_org_trust ON devices (organization_id, trust_level); +CREATE INDEX idx_devices_fingerprint ON devices (fingerprint_hash); + +-- ----------------------------------------------------------------------------- +-- 5. VISITS (PARTITIONED MONTHLY BY scheduled_start) +-- ----------------------------------------------------------------------------- +CREATE TABLE visits ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + provider_id UUID NOT NULL REFERENCES providers(id), + caregiver_id UUID NOT NULL REFERENCES caregivers(id), + patient_id UUID NOT NULL REFERENCES patients(id), + authorization_id UUID REFERENCES service_authorizations(id), + device_id UUID REFERENCES devices(id), + service_code TEXT, + status visit_status NOT NULL DEFAULT 'SCHEDULED', + scheduled_start TIMESTAMPTZ NOT NULL, + scheduled_end TIMESTAMPTZ, + clock_in_at TIMESTAMPTZ, + clock_out_at TIMESTAMPTZ, + duration_minutes INTEGER, + clock_in_lat NUMERIC(9,6), + clock_in_lng NUMERIC(9,6), + billed_units INTEGER, + billed_amount_cents INTEGER, + verification_result verification_result, + risk_score INTEGER DEFAULT 0, + risk_level risk_level, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (id, scheduled_start), + CONSTRAINT chk_visit_risk CHECK (risk_score BETWEEN 0 AND 100) +) PARTITION BY RANGE (scheduled_start); + +CREATE INDEX idx_visits_org_status ON visits (organization_id, status); +CREATE INDEX idx_visits_org_sched ON visits (organization_id, scheduled_start); +CREATE INDEX idx_visits_caregiver ON visits (caregiver_id, scheduled_start); +CREATE INDEX idx_visits_patient ON visits (patient_id, scheduled_start); +CREATE INDEX idx_visits_provider ON visits (provider_id, scheduled_start); + +-- Example partitions (CI/ops create rolling partitions via pg_partman or cron). +CREATE TABLE visits_2026_05 PARTITION OF visits + FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); +CREATE TABLE visits_2026_06 PARTITION OF visits + FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); +CREATE TABLE visits_2026_07 PARTITION OF visits + FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); +-- Catch-all so inserts never fail if a partition is missing. +CREATE TABLE visits_default PARTITION OF visits DEFAULT; + +-- ----------------------------------------------------------------------------- +-- 6. VERIFICATION CHAIN (append-only evidence) +-- ----------------------------------------------------------------------------- +CREATE TABLE visit_verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + visit_id UUID NOT NULL UNIQUE, + result verification_result NOT NULL, + risk_score INTEGER NOT NULL DEFAULT 0, + risk_level risk_level NOT NULL DEFAULT 'LOW', + chain JSONB NOT NULL DEFAULT '{}', + evidence_hash TEXT, + approved_by_id UUID REFERENCES users(id), + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_vv_org_result ON visit_verifications (organization_id, result); + +CREATE TABLE identity_verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + visit_id UUID, + caregiver_id UUID NOT NULL REFERENCES caregivers(id), + method identity_method NOT NULL DEFAULT 'SELFIE', + result verification_result NOT NULL, + confidence_score NUMERIC(5,4), + liveness_score NUMERIC(5,4), + probe_s3_key TEXT, + matcher TEXT, + reasons JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_idv_org_result ON identity_verifications (organization_id, result); +CREATE INDEX idx_idv_caregiver ON identity_verifications (caregiver_id, created_at); + +CREATE TABLE gps_verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + visit_id UUID NOT NULL, + latitude NUMERIC(9,6) NOT NULL, + longitude NUMERIC(9,6) NOT NULL, + accuracy_meters NUMERIC(7,2), + distance_meters NUMERIC(10,2), + result verification_result NOT NULL, + captured_at TIMESTAMPTZ NOT NULL, + event_type TEXT NOT NULL DEFAULT 'CLOCK_IN', + raw_payload JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_gps_org_result ON gps_verifications (organization_id, result); +CREATE INDEX idx_gps_visit ON gps_verifications (visit_id, captured_at); + +CREATE TABLE device_verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + visit_id UUID, + device_id UUID NOT NULL REFERENCES devices(id), + result verification_result NOT NULL, + trust_level device_trust_level NOT NULL, + ip_address INET, + signals JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_dv_org_result ON device_verifications (organization_id, result); + +-- ----------------------------------------------------------------------------- +-- 7. FRAUD INTELLIGENCE +-- ----------------------------------------------------------------------------- +CREATE TABLE fraud_cases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + case_number TEXT NOT NULL, + title TEXT NOT NULL, + status case_status NOT NULL DEFAULT 'OPEN', + priority case_priority NOT NULL DEFAULT 'MEDIUM', + risk_level risk_level NOT NULL DEFAULT 'MODERATE', + provider_id UUID REFERENCES providers(id), + assignee_id UUID REFERENCES users(id), + exposure_cents INTEGER, + summary TEXT, + opened_at TIMESTAMPTZ NOT NULL DEFAULT now(), + closed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (organization_id, case_number) +); +CREATE INDEX idx_cases_org_status ON fraud_cases (organization_id, status, priority); +CREATE INDEX idx_cases_assignee ON fraud_cases (assignee_id); + +CREATE TABLE fraud_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + visit_id UUID, + case_id UUID REFERENCES fraud_cases(id) ON DELETE SET NULL, + type fraud_event_type NOT NULL, + status fraud_event_status NOT NULL DEFAULT 'OPEN', + severity INTEGER NOT NULL DEFAULT 0, + risk_level risk_level NOT NULL DEFAULT 'LOW', + explanation TEXT, + evidence JSONB NOT NULL DEFAULT '{}', + detector TEXT, + detector_version TEXT, + detected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_severity CHECK (severity BETWEEN 0 AND 100) +); +CREATE INDEX idx_fe_org_type_status ON fraud_events (organization_id, type, status); +CREATE INDEX idx_fe_org_detected ON fraud_events (organization_id, detected_at); +CREATE INDEX idx_fe_case ON fraud_events (case_id); + +CREATE TABLE case_notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + case_id UUID NOT NULL REFERENCES fraud_cases(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id), + body TEXT NOT NULL, + is_internal BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_notes_case ON case_notes (case_id, created_at); + +CREATE TABLE case_evidence ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + case_id UUID NOT NULL REFERENCES fraud_cases(id) ON DELETE CASCADE, + label TEXT NOT NULL, + kind TEXT NOT NULL, + ref_id TEXT, + s3_key TEXT, + content_hash TEXT, + added_by_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_evidence_case ON case_evidence (case_id); + +CREATE TABLE fraud_scores ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + subject_type score_subject_type NOT NULL, + subject_id UUID NOT NULL, + score INTEGER NOT NULL, + risk_level risk_level NOT NULL, + factors JSONB NOT NULL DEFAULT '[]', + model_version TEXT, + computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_score CHECK (score BETWEEN 0 AND 100) +); +CREATE INDEX idx_scores_subject ON fraud_scores (organization_id, subject_type, subject_id, computed_at DESC); + +CREATE TABLE provider_risk_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + provider_id UUID NOT NULL UNIQUE REFERENCES providers(id) ON DELETE CASCADE, + current_score INTEGER NOT NULL DEFAULT 0, + risk_level risk_level NOT NULL DEFAULT 'LOW', + verification_failures INTEGER NOT NULL DEFAULT 0, + gps_anomalies INTEGER NOT NULL DEFAULT 0, + billing_anomalies INTEGER NOT NULL DEFAULT 0, + identity_issues INTEGER NOT NULL DEFAULT 0, + open_cases INTEGER NOT NULL DEFAULT 0, + substantiated_cases INTEGER NOT NULL DEFAULT 0, + trend JSONB NOT NULL DEFAULT '[]', + last_computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_risk_org_level ON provider_risk_profiles (organization_id, risk_level); + +-- ----------------------------------------------------------------------------- +-- 8. REPORTING, NOTIFICATIONS, AUDIT +-- ----------------------------------------------------------------------------- +CREATE TABLE reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + type report_type NOT NULL, + format report_format NOT NULL DEFAULT 'PDF', + status report_status NOT NULL DEFAULT 'QUEUED', + parameters JSONB NOT NULL DEFAULT '{}', + s3_key TEXT, + requested_by_id UUID REFERENCES users(id), + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ +); +CREATE INDEX idx_reports_org_type ON reports (organization_id, type, status); + +CREATE TABLE notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + channel notification_channel NOT NULL DEFAULT 'IN_APP', + status notification_status NOT NULL DEFAULT 'PENDING', + title TEXT NOT NULL, + body TEXT, + data JSONB NOT NULL DEFAULT '{}', + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_notif_org_user ON notifications (organization_id, user_id, status); + +-- Audit log: partitioned monthly, append-only, tamper-evident hash chain. +CREATE TABLE audit_logs ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + actor_id UUID, + action audit_action NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + ip_address INET, + user_agent TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + prev_hash TEXT, + hash TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (id, created_at) +) PARTITION BY RANGE (created_at); +CREATE INDEX idx_audit_org_created ON audit_logs (organization_id, created_at); +CREATE INDEX idx_audit_org_resource ON audit_logs (organization_id, resource_type, resource_id); +CREATE INDEX idx_audit_actor ON audit_logs (actor_id); + +CREATE TABLE audit_logs_2026_06 PARTITION OF audit_logs + FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); +CREATE TABLE audit_logs_2026_07 PARTITION OF audit_logs + FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); +CREATE TABLE audit_logs_default PARTITION OF audit_logs DEFAULT; + +-- ----------------------------------------------------------------------------- +-- 9. IMMUTABILITY + TAMPER-EVIDENCE TRIGGERS +-- ----------------------------------------------------------------------------- +-- Block UPDATE/DELETE on append-only evidence tables. +CREATE OR REPLACE FUNCTION rv_forbid_mutation() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'Table % is append-only; % is not permitted', + TG_TABLE_NAME, TG_OP USING ERRCODE = 'integrity_constraint_violation'; +END; $$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_idv_immutable BEFORE UPDATE OR DELETE ON identity_verifications + FOR EACH ROW EXECUTE FUNCTION rv_forbid_mutation(); +CREATE TRIGGER trg_gps_immutable BEFORE UPDATE OR DELETE ON gps_verifications + FOR EACH ROW EXECUTE FUNCTION rv_forbid_mutation(); +CREATE TRIGGER trg_dv_immutable BEFORE UPDATE OR DELETE ON device_verifications + FOR EACH ROW EXECUTE FUNCTION rv_forbid_mutation(); +CREATE TRIGGER trg_fe_immutable BEFORE UPDATE OR DELETE ON fraud_events + FOR EACH ROW EXECUTE FUNCTION rv_forbid_mutation(); + +-- Audit hash chain: hash = SHA256(prev_hash || canonical(row)). Per-tenant chain. +CREATE OR REPLACE FUNCTION rv_audit_hash_chain() RETURNS trigger AS $$ +DECLARE + v_prev TEXT; +BEGIN + SELECT hash INTO v_prev + FROM audit_logs + WHERE organization_id = NEW.organization_id + ORDER BY created_at DESC, id DESC + LIMIT 1; + + NEW.prev_hash := v_prev; + NEW.hash := encode( + digest( + coalesce(v_prev,'') || + NEW.organization_id::text || coalesce(NEW.actor_id::text,'') || + NEW.action::text || NEW.resource_type || coalesce(NEW.resource_id,'') || + NEW.metadata::text || NEW.created_at::text, + 'sha256' + ), 'hex'); + RETURN NEW; +END; $$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_audit_hash BEFORE INSERT ON audit_logs + FOR EACH ROW EXECUTE FUNCTION rv_audit_hash_chain(); + +CREATE TRIGGER trg_audit_immutable BEFORE UPDATE OR DELETE ON audit_logs + FOR EACH ROW EXECUTE FUNCTION rv_forbid_mutation(); + +-- generic updated_at maintenance +CREATE OR REPLACE FUNCTION rv_touch_updated_at() RETURNS trigger AS $$ +BEGIN NEW.updated_at := now(); RETURN NEW; END; $$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_org_touch BEFORE UPDATE ON organizations FOR EACH ROW EXECUTE FUNCTION rv_touch_updated_at(); +CREATE TRIGGER trg_user_touch BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION rv_touch_updated_at(); +CREATE TRIGGER trg_provider_touch BEFORE UPDATE ON providers FOR EACH ROW EXECUTE FUNCTION rv_touch_updated_at(); +CREATE TRIGGER trg_case_touch BEFORE UPDATE ON fraud_cases FOR EACH ROW EXECUTE FUNCTION rv_touch_updated_at(); + +-- ----------------------------------------------------------------------------- +-- 10. ROW-LEVEL SECURITY (hard multi-tenant isolation) +-- The app sets `SET app.current_org = ''` per request/transaction. +-- A privileged migration/ops role bypasses RLS (BYPASSRLS). +-- ----------------------------------------------------------------------------- +DO $$ +DECLARE t TEXT; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'users','roles','providers','caregivers','patients','service_authorizations', + 'devices','visits','visit_verifications','identity_verifications', + 'gps_verifications','device_verifications','fraud_events','fraud_cases', + 'fraud_scores','provider_risk_profiles','reports','notifications','audit_logs' + ] LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY;', t); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY;', t); + EXECUTE format($p$ + CREATE POLICY tenant_isolation ON %I + USING (organization_id = current_setting('app.current_org', true)::uuid) + WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid); + $p$, t); + END LOOP; +END $$; + +-- ============================================================================= +-- END SCHEMA +-- ============================================================================= diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd11770 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "rayverify", + "version": "0.1.0", + "private": true, + "description": "RayVerify™ — Government-grade fraud detection & identity verification for Medicaid, HCBS, and personal care programs.", + "license": "UNLICENSED", + "workspaces": [ + "packages/*" + ], + "engines": { + "node": ">=20.0.0", + "npm": ">=10.0.0" + }, + "scripts": { + "dev": "npm run dev --workspaces --if-present", + "build": "npm run build --workspaces --if-present", + "lint": "npm run lint --workspaces --if-present", + "test": "npm run test --workspaces --if-present", + "typecheck": "npm run typecheck --workspaces --if-present", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,yaml,yml}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,yaml,yml}\"", + "db:up": "docker compose up -d postgres redis", + "db:migrate": "npm run prisma:migrate --workspace packages/backend", + "db:seed": "npm run prisma:seed --workspace packages/backend", + "dev:infra": "docker compose up -d" + }, + "devDependencies": { + "prettier": "^3.3.3" + } +} diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma new file mode 100644 index 0000000..85b6c8f --- /dev/null +++ b/packages/backend/prisma/schema.prisma @@ -0,0 +1,868 @@ +// ============================================================================= +// RayVerify™ — Prisma Schema (canonical data model) +// Government-grade fraud detection & identity verification for Medicaid / HCBS. +// +// Conventions: +// * Multi-tenant: every business table carries `organizationId` (tenant root). +// * Surrogate PKs are UUID v4 (`@db.Uuid`). Natural keys are kept as unique. +// * Money is stored in integer cents (`Int`) to avoid float drift. +// * Geo coords use Decimal(9,6) (~11cm precision) — see db/schema.sql for the +// optional PostGIS upgrade path. +// * Append-only / immutable tables (audit_logs, *_verifications, fraud_events) +// are never UPDATEd by application code; enforced at the DB layer (triggers). +// * Partitioning (visits, audit_logs, *_verifications) is applied in raw SQL +// migrations — Prisma manages the logical model, SQL manages the physical. +// ============================================================================= + +generator client { + provider = "prisma-client-js" + previewFeatures = ["postgresqlExtensions", "views"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + extensions = [pgcrypto, citext, pg_trgm] +} + +// ============================================================================= +// ENUMS +// ============================================================================= + +enum UserStatus { + ACTIVE + INACTIVE + SUSPENDED + LOCKED + PENDING_INVITE +} + +enum MfaMethod { + NONE + TOTP + SMS + WEBAUTHN +} + +/// Result of any verification step (identity, gps, device, visit). +enum VerificationResult { + PASS + REVIEW + FAIL +} + +/// 0-30 LOW · 31-60 MODERATE · 61-80 HIGH · 81-100 CRITICAL +enum RiskLevel { + LOW + MODERATE + HIGH + CRITICAL +} + +enum VisitStatus { + SCHEDULED + IN_PROGRESS + COMPLETED + FLAGGED + REJECTED + APPROVED + CANCELLED +} + +enum IdentityMethod { + SELFIE + LIVENESS + DEVICE_TRUST + FINGERPRINT // future hardware + NFC_CARD // future hardware + GOV_CREDENTIAL // future (NIST 800-63 IAL2/IAL3) +} + +enum DeviceTrustLevel { + TRUSTED + UNKNOWN + SUSPICIOUS + BLOCKED +} + +enum DevicePlatform { + IOS + ANDROID + WEB + HARDWARE_TERMINAL +} + +/// Catalog of detectable fraud signals (Fraud Intelligence Engine). +enum FraudEventType { + IMPOSSIBLE_TRAVEL + DUPLICATE_VISIT + SHARED_DEVICE + GPS_ANOMALY + IDENTITY_MISMATCH + UNUSUAL_BILLING + ABNORMAL_DURATION + EXCESSIVE_OVERTIME + SERVICE_OVERLAP + CROSS_PROVIDER_RISK + LIVENESS_FAILURE + DEVICE_TAMPERING + GEOFENCE_BREACH +} + +enum FraudEventStatus { + OPEN + TRIAGED + LINKED_TO_CASE + DISMISSED + CONFIRMED +} + +enum CaseStatus { + OPEN + IN_REVIEW + ESCALATED + PENDING_PAYMENT_HOLD + SUBSTANTIATED + UNSUBSTANTIATED + CLOSED +} + +enum CasePriority { + LOW + MEDIUM + HIGH + URGENT +} + +enum ScoreSubjectType { + VISIT + PROVIDER + CAREGIVER + PATIENT + CLAIM +} + +enum ReportType { + FRAUD_SUMMARY + PROVIDER_RISK + VISIT_VERIFICATION + INVESTIGATION + STATE_COMPLIANCE + EXECUTIVE_DASHBOARD +} + +enum ReportFormat { + PDF + XLSX + CSV + JSON +} + +enum ReportStatus { + QUEUED + GENERATING + READY + FAILED + EXPIRED +} + +enum NotificationChannel { + IN_APP + EMAIL + SMS + WEBHOOK +} + +enum NotificationStatus { + PENDING + SENT + DELIVERED + READ + FAILED +} + +enum AuditAction { + CREATE + READ + UPDATE + DELETE + LOGIN + LOGOUT + EXPORT + VERIFY + SCORE + CASE_ACTION + CONFIG_CHANGE +} + +// ============================================================================= +// TENANCY, IDENTITY & ACCESS CONTROL +// ============================================================================= + +/// Tenant root. A state Medicaid agency, MCO, or oversight unit. +model Organization { + id String @id @default(uuid()) @db.Uuid + name String + slug String @unique @db.Citext + /// State / federal program identifier (e.g. FIPS code, CMS region). + jurisdiction String? + /// Free-form tenant configuration (feature flags, thresholds, branding). + settings Json @default("{}") + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + users User[] + roles Role[] + providers Provider[] + patients Patient[] + caregivers Caregiver[] + devices Device[] + visits Visit[] + visitVerifications VisitVerification[] + identityVerifications IdentityVerification[] + gpsVerifications GpsVerification[] + deviceVerifications DeviceVerification[] + fraudEvents FraudEvent[] + fraudCases FraudCase[] + fraudScores FraudScore[] + riskProfiles ProviderRiskProfile[] + serviceAuthorizations ServiceAuthorization[] + reports Report[] + notifications Notification[] + auditLogs AuditLog[] + + @@index([slug]) + @@map("organizations") +} + +/// Platform & tenant users (investigators, auditors, admins, compliance). +model User { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + email String @db.Citext + /// Argon2id hash — never store plaintext. Null for SSO-only accounts. + passwordHash String? + firstName String + lastName String + phone String? + status UserStatus @default(PENDING_INVITE) + mfaMethod MfaMethod @default(NONE) + /// Encrypted (AES-256-GCM) TOTP secret; envelope-encrypted via KMS. + mfaSecret String? + lastLoginAt DateTime? + failedLogins Int @default(0) + lockedUntil DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userRoles UserRole[] + sessions Session[] + assignedCases FraudCase[] @relation("CaseAssignee") + caseNotes CaseNote[] + auditLogs AuditLog[] + notifications Notification[] + reports Report[] + + @@unique([organizationId, email]) + @@index([organizationId, status]) + @@map("users") +} + +model Session { + id String @id @default(uuid()) @db.Uuid + userId String @db.Uuid + /// SHA-256 of the refresh token; raw token never persisted. + refreshTokenHash String @unique + userAgent String? + ipAddress String? + expiresAt DateTime + revokedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, expiresAt]) + @@map("sessions") +} + +model Role { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + /// e.g. INVESTIGATOR, AUDITOR, COMPLIANCE_OFFICER, ORG_ADMIN, OIG_AGENT + key String + name String + description String? + /// System roles are seeded and cannot be deleted by tenants. + isSystem Boolean @default(false) + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + rolePermissions RolePermission[] + userRoles UserRole[] + + @@unique([organizationId, key]) + @@map("roles") +} + +/// Global permission catalog (not tenant-scoped). Format: "resource:action". +model Permission { + id String @id @default(uuid()) @db.Uuid + key String @unique // e.g. "fraud_case:assign", "report:export" + description String? + + rolePermissions RolePermission[] + + @@map("permissions") +} + +model RolePermission { + roleId String @db.Uuid + permissionId String @db.Uuid + + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) + + @@id([roleId, permissionId]) + @@map("role_permissions") +} + +model UserRole { + userId String @db.Uuid + roleId String @db.Uuid + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + + @@id([userId, roleId]) + @@map("user_roles") +} + +// ============================================================================= +// DOMAIN: PROVIDERS, CAREGIVERS, PATIENTS, AUTHORIZATIONS +// ============================================================================= + +/// A billing agency / home-care organization enrolled in Medicaid. +model Provider { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + /// National Provider Identifier (10-digit, Luhn-checked). + npi String? + /// Medicaid provider enrollment id within the jurisdiction. + medicaidId String? + legalName String + taxId String? // encrypted at rest + isActive Boolean @default(true) + enrolledAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + caregivers Caregiver[] + visits Visit[] + riskProfile ProviderRiskProfile? + fraudCases FraudCase[] + + @@unique([organizationId, npi]) + @@index([organizationId, isActive]) + @@map("providers") +} + +/// The individual delivering care — the subject of identity verification. +model Caregiver { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + providerId String @db.Uuid + externalId String? // payroll / scheduling system id + firstName String + lastName String + email String? @db.Citext + phone String? + status UserStatus @default(ACTIVE) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade) + enrollments BiometricEnrollment[] + visits Visit[] + identityVerifications IdentityVerification[] + + @@index([organizationId, providerId]) + @@map("caregivers") +} + +/// Biometric reference template for a caregiver (face embedding, future modalities). +model BiometricEnrollment { + id String @id @default(uuid()) @db.Uuid + caregiverId String @db.Uuid + method IdentityMethod @default(SELFIE) + /// S3 key of the encrypted reference image (PHI; KMS-encrypted, lifecycle'd). + referenceS3Key String? + /// Pointer to the face-embedding vector store record (not the raw vector). + templateRef String? + isActive Boolean @default(true) + enrolledAt DateTime @default(now()) + retiredAt DateTime? + + caregiver Caregiver @relation(fields: [caregiverId], references: [id], onDelete: Cascade) + + @@index([caregiverId, isActive]) + @@map("biometric_enrollments") +} + +/// The Medicaid beneficiary receiving services. +model Patient { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + /// Medicaid member id (PHI; encrypted at rest, searchable via blind index). + medicaidMemberId String? + firstName String + lastName String + dateOfBirth DateTime? @db.Date + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + visits Visit[] + authorizations ServiceAuthorization[] + + @@index([organizationId]) + @@map("patients") +} + +/// Authorized service + the geofence anchor (address + radius) used by GPS rules. +model ServiceAuthorization { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + patientId String @db.Uuid + serviceCode String // HCPCS / state-specific (e.g. T1019 personal care) + description String? + addressLine1 String? + addressLine2 String? + city String? + state String? + postalCode String? + latitude Decimal? @db.Decimal(9, 6) + longitude Decimal? @db.Decimal(9, 6) + /// Approved geofence radius in meters (PASS threshold). + radiusMeters Int @default(150) + authorizedUnits Int? // periodic unit cap (overtime/overlap checks) + startDate DateTime @db.Date + endDate DateTime? @db.Date + isActive Boolean @default(true) + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + visits Visit[] + + @@index([organizationId, patientId, isActive]) + @@map("service_authorizations") +} + +// ============================================================================= +// DEVICE TRUST +// ============================================================================= + +model Device { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + /// Stable client-generated device id. + deviceId String + /// Hash of the composite device fingerprint (model+os+screen+sensors...). + fingerprintHash String? + platform DevicePlatform @default(WEB) + osVersion String? + browser String? + appVersion String? + lastIpAddress String? + trustLevel DeviceTrustLevel @default(UNKNOWN) + isEmulator Boolean @default(false) + isRooted Boolean @default(false) + isJailbroken Boolean @default(false) + firstSeenAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + visits Visit[] + deviceVerifications DeviceVerification[] + + @@unique([organizationId, deviceId]) + @@index([organizationId, trustLevel]) + @@index([fingerprintHash]) + @@map("devices") +} + +// ============================================================================= +// VISITS & THE VERIFICATION CHAIN +// ============================================================================= + +/// A single delivered service event. Partitioned by `scheduledStart` (monthly). +model Visit { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + providerId String @db.Uuid + caregiverId String @db.Uuid + patientId String @db.Uuid + authorizationId String? @db.Uuid + deviceId String? @db.Uuid + serviceCode String? + status VisitStatus @default(SCHEDULED) + + scheduledStart DateTime + scheduledEnd DateTime? + clockInAt DateTime? + clockOutAt DateTime? + durationMinutes Int? + + /// Captured clock-in geo (denormalized from gps_verification for fast query). + clockInLat Decimal? @db.Decimal(9, 6) + clockInLng Decimal? @db.Decimal(9, 6) + + /// Billing + billedUnits Int? + billedAmountCents Int? + + /// Rolled-up outcomes from the verification chain. + verificationResult VerificationResult? + riskScore Int? @default(0) // 0-100 snapshot + riskLevel RiskLevel? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + provider Provider @relation(fields: [providerId], references: [id]) + caregiver Caregiver @relation(fields: [caregiverId], references: [id]) + patient Patient @relation(fields: [patientId], references: [id]) + authorization ServiceAuthorization? @relation(fields: [authorizationId], references: [id]) + device Device? @relation(fields: [deviceId], references: [id]) + visitVerification VisitVerification? + identityVerifications IdentityVerification[] + gpsVerifications GpsVerification[] + deviceVerifications DeviceVerification[] + fraudEvents FraudEvent[] + + @@index([organizationId, status]) + @@index([organizationId, scheduledStart]) + @@index([caregiverId, scheduledStart]) + @@index([patientId, scheduledStart]) + @@index([providerId, scheduledStart]) + @@map("visits") +} + +/// Immutable rollup binding the full verification package for a visit. +model VisitVerification { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + visitId String @unique @db.Uuid + result VerificationResult + riskScore Int @default(0) + riskLevel RiskLevel @default(LOW) + /// Per-step results & contributing factors (identity/gps/device/patient/fraud). + chain Json @default("{}") + /// SHA-256 over the canonical evidence package — tamper-evidence anchor. + evidenceHash String? + approvedById String? @db.Uuid + approvedAt DateTime? + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + visit Visit @relation(fields: [visitId], references: [id], onDelete: Cascade) + + @@index([organizationId, result]) + @@map("visit_verifications") +} + +/// Append-only record of a single identity verification attempt. +model IdentityVerification { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + visitId String? @db.Uuid + caregiverId String @db.Uuid + method IdentityMethod @default(SELFIE) + result VerificationResult + /// 0.0-1.0 face-match confidence. + confidenceScore Decimal? @db.Decimal(5, 4) + /// 0.0-1.0 liveness probability. + livenessScore Decimal? @db.Decimal(5, 4) + /// S3 key of the encrypted probe image (PHI). + probeS3Key String? + /// Provider used (internal model id / vendor). + matcher String? + reasons Json @default("[]") // explainability factors + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + visit Visit? @relation(fields: [visitId], references: [id], onDelete: Cascade) + caregiver Caregiver @relation(fields: [caregiverId], references: [id]) + + @@index([organizationId, result]) + @@index([caregiverId, createdAt]) + @@map("identity_verifications") +} + +/// Append-only GPS evidence + geofence decision for a visit clock event. +model GpsVerification { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + visitId String @db.Uuid + latitude Decimal @db.Decimal(9, 6) + longitude Decimal @db.Decimal(9, 6) + accuracyMeters Decimal? @db.Decimal(7, 2) + /// Distance from authorized service address, meters. + distanceMeters Decimal? @db.Decimal(10, 2) + result VerificationResult + capturedAt DateTime + /// "CLOCK_IN" | "CLOCK_OUT" | "MID_VISIT" + eventType String @default("CLOCK_IN") + rawPayload Json? @default("{}") + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + visit Visit @relation(fields: [visitId], references: [id], onDelete: Cascade) + + @@index([organizationId, result]) + @@index([visitId, capturedAt]) + @@map("gps_verifications") +} + +/// Append-only device posture snapshot captured during a visit. +model DeviceVerification { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + visitId String? @db.Uuid + deviceId String @db.Uuid + result VerificationResult + trustLevel DeviceTrustLevel + ipAddress String? + signals Json @default("{}") // emulator/root/jailbreak/etc + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + visit Visit? @relation(fields: [visitId], references: [id], onDelete: Cascade) + device Device @relation(fields: [deviceId], references: [id]) + + @@index([organizationId, result]) + @@map("device_verifications") +} + +// ============================================================================= +// FRAUD INTELLIGENCE +// ============================================================================= + +/// A single detected fraud signal. Append-only; immutable evidence. +model FraudEvent { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + visitId String? @db.Uuid + caseId String? @db.Uuid + type FraudEventType + status FraudEventStatus @default(OPEN) + /// Detector confidence 0-100 contributed to the composite score. + severity Int @default(0) + riskLevel RiskLevel @default(LOW) + /// Human-readable explanation + structured factors (explainability). + explanation String? + evidence Json @default("{}") + /// Detector identifier + version for reproducibility. + detector String? + detectorVersion String? + detectedAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + visit Visit? @relation(fields: [visitId], references: [id], onDelete: SetNull) + case FraudCase? @relation(fields: [caseId], references: [id], onDelete: SetNull) + + @@index([organizationId, type, status]) + @@index([organizationId, detectedAt]) + @@index([caseId]) + @@map("fraud_events") +} + +/// An investigation grouping related fraud events for case management. +model FraudCase { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + /// Human-friendly case number, e.g. RV-2026-000123. + caseNumber String + title String + status CaseStatus @default(OPEN) + priority CasePriority @default(MEDIUM) + riskLevel RiskLevel @default(MODERATE) + providerId String? @db.Uuid + assigneeId String? @db.Uuid + /// Estimated dollars at risk (cents). + exposureCents Int? + summary String? + openedAt DateTime @default(now()) + closedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + provider Provider? @relation(fields: [providerId], references: [id]) + assignee User? @relation("CaseAssignee", fields: [assigneeId], references: [id]) + events FraudEvent[] + notes CaseNote[] + evidence CaseEvidence[] + + @@unique([organizationId, caseNumber]) + @@index([organizationId, status, priority]) + @@index([assigneeId]) + @@map("fraud_cases") +} + +model CaseNote { + id String @id @default(uuid()) @db.Uuid + caseId String @db.Uuid + authorId String @db.Uuid + body String + /// Internal investigator notes are not exportable in beneficiary disclosures. + isInternal Boolean @default(true) + createdAt DateTime @default(now()) + + case FraudCase @relation(fields: [caseId], references: [id], onDelete: Cascade) + author User @relation(fields: [authorId], references: [id]) + + @@index([caseId, createdAt]) + @@map("case_notes") +} + +model CaseEvidence { + id String @id @default(uuid()) @db.Uuid + caseId String @db.Uuid + label String + kind String // "VISIT" | "GPS" | "IDENTITY" | "DOCUMENT" | "EXPORT" + /// Pointer to the underlying record or S3 object. + refId String? + s3Key String? + /// SHA-256 for chain-of-custody integrity. + contentHash String? + addedById String? @db.Uuid + createdAt DateTime @default(now()) + + case FraudCase @relation(fields: [caseId], references: [id], onDelete: Cascade) + + @@index([caseId]) + @@map("case_evidence") +} + +/// Time-series of computed risk scores for any subject (visit/provider/...). +model FraudScore { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + subjectType ScoreSubjectType + subjectId String @db.Uuid + score Int // 0-100 + riskLevel RiskLevel + /// Per-feature contributions (SHAP-style) for explainability. + factors Json @default("[]") + modelVersion String? + computedAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@index([organizationId, subjectType, subjectId, computedAt]) + @@map("fraud_scores") +} + +/// Current, denormalized risk standing per provider (1:1, kept hot for ranking). +model ProviderRiskProfile { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + providerId String @unique @db.Uuid + currentScore Int @default(0) + riskLevel RiskLevel @default(LOW) + verificationFailures Int @default(0) + gpsAnomalies Int @default(0) + billingAnomalies Int @default(0) + identityIssues Int @default(0) + openCases Int @default(0) + substantiatedCases Int @default(0) + /// Sparkline / historical trend points: [{ t, score }]. + trend Json @default("[]") + lastComputedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade) + + @@index([organizationId, riskLevel]) + @@map("provider_risk_profiles") +} + +// ============================================================================= +// REPORTING, NOTIFICATIONS, AUDIT +// ============================================================================= + +model Report { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + type ReportType + format ReportFormat @default(PDF) + status ReportStatus @default(QUEUED) + /// Filters / date-range / scope used to generate the report. + parameters Json @default("{}") + s3Key String? + requestedById String? @db.Uuid + expiresAt DateTime? + createdAt DateTime @default(now()) + completedAt DateTime? + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + requestedBy User? @relation(fields: [requestedById], references: [id]) + + @@index([organizationId, type, status]) + @@map("reports") +} + +model Notification { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + userId String? @db.Uuid + channel NotificationChannel @default(IN_APP) + status NotificationStatus @default(PENDING) + title String + body String? + /// Deep-link context: { caseId, visitId, fraudEventId, ... } + data Json @default("{}") + readAt DateTime? + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([organizationId, userId, status]) + @@map("notifications") +} + +/// Immutable, append-only audit trail. Partitioned monthly by `createdAt`. +/// Each row carries a hash chain (prevHash -> hash) for tamper-evidence. +model AuditLog { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + actorId String? @db.Uuid + action AuditAction + /// Logical resource type, e.g. "fraud_case", "visit", "report". + resourceType String + resourceId String? + ipAddress String? + userAgent String? + /// Before/after diff or action context (PHI-scrubbed). + metadata Json @default("{}") + /// Tamper-evident hash chain. + prevHash String? + hash String? + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull) + + @@index([organizationId, createdAt]) + @@index([organizationId, resourceType, resourceId]) + @@index([actorId]) + @@map("audit_logs") +} From 1e1cb94a9f8782d8c914a7d19b1e82b3d7172379 Mon Sep 17 00:00:00 2001 From: Durga Ghimeray <152849649+durga710@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:32:17 +0000 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20RayVerify=20platform=20foundation?= =?UTF-8?q?=20=E2=80=94=20backend=20engines,=20frontend,=20docs,=20infra,?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 75 ++ .github/workflows/codeql.yml | 33 + .github/workflows/deploy.yml | 73 + .github/workflows/security-scan.yml | 51 + .prettierrc.json | 6 + api/openapi.yaml | 750 +++++++++++ docker-compose.yml | 49 + docs/00-overview.md | 219 +++ docs/01-product-requirements.md | 811 +++++++++++ docs/02-system-architecture.md | 664 +++++++++ docs/03-database-design.md | 919 +++++++++++++ docs/04-api-design.md | 134 ++ docs/05-fraud-detection-engine.md | 1195 +++++++++++++++++ docs/06-ai-risk-scoring.md | 842 ++++++++++++ docs/07-security-architecture.md | 786 +++++++++++ docs/08-aws-deployment.md | 538 ++++++++ docs/09-cicd-pipeline.md | 448 ++++++ docs/10-development-roadmap.md | 526 ++++++++ docs/11-production-deployment.md | 768 +++++++++++ infra/terraform/README.md | 194 +++ infra/terraform/backend.tf | 38 + .../terraform/environments/dev.tfvars.example | 69 + .../environments/prod.tfvars.example | 77 ++ .../environments/staging.tfvars.example | 70 + infra/terraform/locals.tf | 53 + infra/terraform/main.tf | 189 +++ infra/terraform/modules/compute/main.tf | 651 +++++++++ infra/terraform/modules/compute/outputs.tf | 61 + infra/terraform/modules/compute/variables.tf | 42 + infra/terraform/modules/data/main.tf | 294 ++++ infra/terraform/modules/data/outputs.tf | 54 + infra/terraform/modules/data/variables.tf | 96 ++ infra/terraform/modules/edge/main.tf | 233 ++++ infra/terraform/modules/edge/outputs.tf | 29 + infra/terraform/modules/edge/variables.tf | 12 + infra/terraform/modules/networking/main.tf | 522 +++++++ infra/terraform/modules/networking/outputs.tf | 54 + .../terraform/modules/networking/variables.tf | 29 + infra/terraform/modules/observability/main.tf | 504 +++++++ .../modules/observability/outputs.tf | 14 + .../modules/observability/variables.tf | 21 + infra/terraform/modules/security/main.tf | 707 ++++++++++ infra/terraform/modules/security/outputs.tf | 84 ++ infra/terraform/modules/security/variables.tf | 45 + infra/terraform/modules/storage/main.tf | 379 ++++++ infra/terraform/modules/storage/outputs.tf | 44 + infra/terraform/modules/storage/variables.tf | 12 + infra/terraform/outputs.tf | 141 ++ infra/terraform/providers.tf | 45 + infra/terraform/variables.tf | 292 ++++ infra/terraform/versions.tf | 27 + packages/backend/.env.example | 43 + packages/backend/.eslintrc.js | 14 + packages/backend/Dockerfile | 29 + packages/backend/jest.config.js | 10 + packages/backend/nest-cli.json | 8 + packages/backend/package.json | 71 + packages/backend/prisma/seed.ts | 185 +++ packages/backend/scripts/apply-sql.js | 33 + packages/backend/src/app.module.ts | 46 + .../src/common/context/tenant-context.ts | 43 + .../decorators/current-user.decorator.ts | 18 + .../decorators/permissions.decorator.ts | 11 + .../src/common/decorators/public.decorator.ts | 6 + .../backend/src/common/dto/pagination.dto.ts | 55 + .../common/filters/all-exceptions.filter.ts | 59 + .../src/common/guards/jwt-auth.guard.ts | 21 + .../src/common/guards/permissions.guard.ts | 35 + .../interceptors/context.interceptor.ts | 41 + .../src/common/prisma/prisma.module.ts | 9 + .../src/common/prisma/prisma.service.ts | 72 + packages/backend/src/common/util/geo.ts | 31 + packages/backend/src/common/util/risk.ts | 18 + packages/backend/src/config/configuration.ts | 55 + packages/backend/src/main.ts | 64 + .../src/modules/audit/audit.controller.ts | 41 + .../backend/src/modules/audit/audit.module.ts | 12 + .../src/modules/audit/audit.service.ts | 81 ++ .../src/modules/auth/auth.controller.ts | 46 + .../backend/src/modules/auth/auth.module.ts | 25 + .../backend/src/modules/auth/auth.service.ts | 158 +++ .../backend/src/modules/auth/dto/auth.dto.ts | 39 + .../modules/auth/strategies/jwt.strategy.ts | 35 + .../src/modules/cases/cases.controller.ts | 83 ++ .../backend/src/modules/cases/cases.module.ts | 10 + .../src/modules/cases/cases.service.ts | 122 ++ .../src/modules/cases/dto/cases.dto.ts | 36 + .../detectors/abnormal-duration.detector.ts | 61 + .../detectors/duplicate-visit.detector.ts | 46 + .../fraud/detectors/gps-anomaly.detector.ts | 58 + .../detectors/identity-mismatch.detector.ts | 45 + .../detectors/impossible-travel.detector.ts | 72 + .../fraud/detectors/shared-device.detector.ts | 54 + .../src/modules/fraud/detectors/types.ts | 90 ++ .../src/modules/fraud/dto/fraud.dto.ts | 11 + .../modules/fraud/fraud-scoring.service.ts | 87 ++ .../src/modules/fraud/fraud.controller.ts | 34 + .../backend/src/modules/fraud/fraud.module.ts | 11 + .../src/modules/fraud/fraud.service.ts | 223 +++ .../hardware/hardware-registry.service.ts | 43 + .../modules/hardware/hardware.controller.ts | 18 + .../src/modules/hardware/hardware.module.ts | 10 + .../src/modules/hardware/sdk/devices.ts | 76 ++ .../modules/hardware/sdk/hardware.types.ts | 49 + .../src/modules/health/health.module.ts | 17 + .../src/modules/identity/dto/identity.dto.ts | 41 + .../modules/identity/identity.controller.ts | 21 + .../src/modules/identity/identity.module.ts | 20 + .../src/modules/identity/identity.service.ts | 106 ++ .../providers/identity-provider.interface.ts | 30 + .../providers/stub-identity.provider.ts | 26 + .../notifications/notifications.controller.ts | 24 + .../notifications/notifications.module.ts | 11 + .../notifications/notifications.service.ts | 47 + .../modules/providers/providers.controller.ts | 34 + .../src/modules/providers/providers.module.ts | 10 + .../modules/providers/providers.service.ts | 117 ++ .../src/modules/reports/reports.controller.ts | 46 + .../src/modules/reports/reports.module.ts | 10 + .../src/modules/reports/reports.service.ts | 53 + .../src/modules/visits/dto/visits.dto.ts | 38 + .../src/modules/visits/visits.controller.ts | 77 ++ .../src/modules/visits/visits.module.ts | 12 + .../src/modules/visits/visits.service.ts | 270 ++++ packages/backend/test/fraud-engine.spec.ts | 107 ++ packages/backend/tsconfig.build.json | 4 + packages/backend/tsconfig.json | 28 + packages/frontend/.env.example | 9 + packages/frontend/.eslintrc.json | 7 + packages/frontend/Dockerfile | 46 + packages/frontend/README.md | 122 ++ .../app/(dashboard)/FraudTrendChart.tsx | 85 ++ .../app/(dashboard)/RiskDistributionChart.tsx | 51 + .../frontend/app/(dashboard)/alerts/page.tsx | 132 ++ .../frontend/app/(dashboard)/audit/page.tsx | 155 +++ .../app/(dashboard)/cases/[id]/page.tsx | 256 ++++ .../frontend/app/(dashboard)/cases/page.tsx | 157 +++ packages/frontend/app/(dashboard)/layout.tsx | 21 + packages/frontend/app/(dashboard)/page.tsx | 182 +++ .../providers/ProviderRiskTrendChart.tsx | 75 ++ .../providers/ProviderTrendSparkline.tsx | 38 + .../app/(dashboard)/providers/page.tsx | 194 +++ .../frontend/app/(dashboard)/reports/page.tsx | 200 +++ .../app/(dashboard)/visits/[id]/page.tsx | 270 ++++ .../frontend/app/(dashboard)/visits/page.tsx | 165 +++ packages/frontend/app/globals.css | 115 ++ packages/frontend/app/layout.tsx | 36 + packages/frontend/app/login/page.tsx | 110 ++ packages/frontend/app/providers.tsx | 22 + packages/frontend/components/DataTable.tsx | 78 ++ .../frontend/components/FraudTimeline.tsx | 85 ++ packages/frontend/components/PageHeader.tsx | 28 + packages/frontend/components/RiskBadge.tsx | 33 + packages/frontend/components/Sidebar.tsx | 114 ++ packages/frontend/components/StatCard.tsx | 76 ++ packages/frontend/components/TopBar.tsx | 100 ++ .../components/VerificationResultBadge.tsx | 26 + packages/frontend/components/ui/avatar.tsx | 49 + packages/frontend/components/ui/badge.tsx | 35 + packages/frontend/components/ui/button.tsx | 56 + packages/frontend/components/ui/card.tsx | 75 ++ packages/frontend/components/ui/dialog.tsx | 118 ++ .../frontend/components/ui/dropdown-menu.tsx | 194 +++ packages/frontend/components/ui/input.tsx | 24 + packages/frontend/components/ui/select.tsx | 156 +++ packages/frontend/components/ui/separator.tsx | 30 + packages/frontend/components/ui/skeleton.tsx | 16 + packages/frontend/components/ui/tabs.tsx | 54 + packages/frontend/lib/api.ts | 300 +++++ packages/frontend/lib/mock.ts | 1038 ++++++++++++++ packages/frontend/lib/risk.ts | 118 ++ packages/frontend/lib/types.ts | 426 ++++++ packages/frontend/lib/utils.ts | 140 ++ packages/frontend/next.config.mjs | 10 + packages/frontend/package.json | 44 + packages/frontend/postcss.config.mjs | 9 + packages/frontend/tailwind.config.ts | 95 ++ packages/frontend/tsconfig.json | 27 + packages/shared/package.json | 16 + packages/shared/src/index.ts | 77 ++ packages/shared/tsconfig.json | 14 + 181 files changed, 23880 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/security-scan.yml create mode 100644 .prettierrc.json create mode 100644 api/openapi.yaml create mode 100644 docker-compose.yml create mode 100644 docs/00-overview.md create mode 100644 docs/01-product-requirements.md create mode 100644 docs/02-system-architecture.md create mode 100644 docs/03-database-design.md create mode 100644 docs/04-api-design.md create mode 100644 docs/05-fraud-detection-engine.md create mode 100644 docs/06-ai-risk-scoring.md create mode 100644 docs/07-security-architecture.md create mode 100644 docs/08-aws-deployment.md create mode 100644 docs/09-cicd-pipeline.md create mode 100644 docs/10-development-roadmap.md create mode 100644 docs/11-production-deployment.md create mode 100644 infra/terraform/README.md create mode 100644 infra/terraform/backend.tf create mode 100644 infra/terraform/environments/dev.tfvars.example create mode 100644 infra/terraform/environments/prod.tfvars.example create mode 100644 infra/terraform/environments/staging.tfvars.example create mode 100644 infra/terraform/locals.tf create mode 100644 infra/terraform/main.tf create mode 100644 infra/terraform/modules/compute/main.tf create mode 100644 infra/terraform/modules/compute/outputs.tf create mode 100644 infra/terraform/modules/compute/variables.tf create mode 100644 infra/terraform/modules/data/main.tf create mode 100644 infra/terraform/modules/data/outputs.tf create mode 100644 infra/terraform/modules/data/variables.tf create mode 100644 infra/terraform/modules/edge/main.tf create mode 100644 infra/terraform/modules/edge/outputs.tf create mode 100644 infra/terraform/modules/edge/variables.tf create mode 100644 infra/terraform/modules/networking/main.tf create mode 100644 infra/terraform/modules/networking/outputs.tf create mode 100644 infra/terraform/modules/networking/variables.tf create mode 100644 infra/terraform/modules/observability/main.tf create mode 100644 infra/terraform/modules/observability/outputs.tf create mode 100644 infra/terraform/modules/observability/variables.tf create mode 100644 infra/terraform/modules/security/main.tf create mode 100644 infra/terraform/modules/security/outputs.tf create mode 100644 infra/terraform/modules/security/variables.tf create mode 100644 infra/terraform/modules/storage/main.tf create mode 100644 infra/terraform/modules/storage/outputs.tf create mode 100644 infra/terraform/modules/storage/variables.tf create mode 100644 infra/terraform/outputs.tf create mode 100644 infra/terraform/providers.tf create mode 100644 infra/terraform/variables.tf create mode 100644 infra/terraform/versions.tf create mode 100644 packages/backend/.env.example create mode 100644 packages/backend/.eslintrc.js create mode 100644 packages/backend/Dockerfile create mode 100644 packages/backend/jest.config.js create mode 100644 packages/backend/nest-cli.json create mode 100644 packages/backend/package.json create mode 100644 packages/backend/prisma/seed.ts create mode 100644 packages/backend/scripts/apply-sql.js create mode 100644 packages/backend/src/app.module.ts create mode 100644 packages/backend/src/common/context/tenant-context.ts create mode 100644 packages/backend/src/common/decorators/current-user.decorator.ts create mode 100644 packages/backend/src/common/decorators/permissions.decorator.ts create mode 100644 packages/backend/src/common/decorators/public.decorator.ts create mode 100644 packages/backend/src/common/dto/pagination.dto.ts create mode 100644 packages/backend/src/common/filters/all-exceptions.filter.ts create mode 100644 packages/backend/src/common/guards/jwt-auth.guard.ts create mode 100644 packages/backend/src/common/guards/permissions.guard.ts create mode 100644 packages/backend/src/common/interceptors/context.interceptor.ts create mode 100644 packages/backend/src/common/prisma/prisma.module.ts create mode 100644 packages/backend/src/common/prisma/prisma.service.ts create mode 100644 packages/backend/src/common/util/geo.ts create mode 100644 packages/backend/src/common/util/risk.ts create mode 100644 packages/backend/src/config/configuration.ts create mode 100644 packages/backend/src/main.ts create mode 100644 packages/backend/src/modules/audit/audit.controller.ts create mode 100644 packages/backend/src/modules/audit/audit.module.ts create mode 100644 packages/backend/src/modules/audit/audit.service.ts create mode 100644 packages/backend/src/modules/auth/auth.controller.ts create mode 100644 packages/backend/src/modules/auth/auth.module.ts create mode 100644 packages/backend/src/modules/auth/auth.service.ts create mode 100644 packages/backend/src/modules/auth/dto/auth.dto.ts create mode 100644 packages/backend/src/modules/auth/strategies/jwt.strategy.ts create mode 100644 packages/backend/src/modules/cases/cases.controller.ts create mode 100644 packages/backend/src/modules/cases/cases.module.ts create mode 100644 packages/backend/src/modules/cases/cases.service.ts create mode 100644 packages/backend/src/modules/cases/dto/cases.dto.ts create mode 100644 packages/backend/src/modules/fraud/detectors/abnormal-duration.detector.ts create mode 100644 packages/backend/src/modules/fraud/detectors/duplicate-visit.detector.ts create mode 100644 packages/backend/src/modules/fraud/detectors/gps-anomaly.detector.ts create mode 100644 packages/backend/src/modules/fraud/detectors/identity-mismatch.detector.ts create mode 100644 packages/backend/src/modules/fraud/detectors/impossible-travel.detector.ts create mode 100644 packages/backend/src/modules/fraud/detectors/shared-device.detector.ts create mode 100644 packages/backend/src/modules/fraud/detectors/types.ts create mode 100644 packages/backend/src/modules/fraud/dto/fraud.dto.ts create mode 100644 packages/backend/src/modules/fraud/fraud-scoring.service.ts create mode 100644 packages/backend/src/modules/fraud/fraud.controller.ts create mode 100644 packages/backend/src/modules/fraud/fraud.module.ts create mode 100644 packages/backend/src/modules/fraud/fraud.service.ts create mode 100644 packages/backend/src/modules/hardware/hardware-registry.service.ts create mode 100644 packages/backend/src/modules/hardware/hardware.controller.ts create mode 100644 packages/backend/src/modules/hardware/hardware.module.ts create mode 100644 packages/backend/src/modules/hardware/sdk/devices.ts create mode 100644 packages/backend/src/modules/hardware/sdk/hardware.types.ts create mode 100644 packages/backend/src/modules/health/health.module.ts create mode 100644 packages/backend/src/modules/identity/dto/identity.dto.ts create mode 100644 packages/backend/src/modules/identity/identity.controller.ts create mode 100644 packages/backend/src/modules/identity/identity.module.ts create mode 100644 packages/backend/src/modules/identity/identity.service.ts create mode 100644 packages/backend/src/modules/identity/providers/identity-provider.interface.ts create mode 100644 packages/backend/src/modules/identity/providers/stub-identity.provider.ts create mode 100644 packages/backend/src/modules/notifications/notifications.controller.ts create mode 100644 packages/backend/src/modules/notifications/notifications.module.ts create mode 100644 packages/backend/src/modules/notifications/notifications.service.ts create mode 100644 packages/backend/src/modules/providers/providers.controller.ts create mode 100644 packages/backend/src/modules/providers/providers.module.ts create mode 100644 packages/backend/src/modules/providers/providers.service.ts create mode 100644 packages/backend/src/modules/reports/reports.controller.ts create mode 100644 packages/backend/src/modules/reports/reports.module.ts create mode 100644 packages/backend/src/modules/reports/reports.service.ts create mode 100644 packages/backend/src/modules/visits/dto/visits.dto.ts create mode 100644 packages/backend/src/modules/visits/visits.controller.ts create mode 100644 packages/backend/src/modules/visits/visits.module.ts create mode 100644 packages/backend/src/modules/visits/visits.service.ts create mode 100644 packages/backend/test/fraud-engine.spec.ts create mode 100644 packages/backend/tsconfig.build.json create mode 100644 packages/backend/tsconfig.json create mode 100644 packages/frontend/.env.example create mode 100644 packages/frontend/.eslintrc.json create mode 100644 packages/frontend/Dockerfile create mode 100644 packages/frontend/README.md create mode 100644 packages/frontend/app/(dashboard)/FraudTrendChart.tsx create mode 100644 packages/frontend/app/(dashboard)/RiskDistributionChart.tsx create mode 100644 packages/frontend/app/(dashboard)/alerts/page.tsx create mode 100644 packages/frontend/app/(dashboard)/audit/page.tsx create mode 100644 packages/frontend/app/(dashboard)/cases/[id]/page.tsx create mode 100644 packages/frontend/app/(dashboard)/cases/page.tsx create mode 100644 packages/frontend/app/(dashboard)/layout.tsx create mode 100644 packages/frontend/app/(dashboard)/page.tsx create mode 100644 packages/frontend/app/(dashboard)/providers/ProviderRiskTrendChart.tsx create mode 100644 packages/frontend/app/(dashboard)/providers/ProviderTrendSparkline.tsx create mode 100644 packages/frontend/app/(dashboard)/providers/page.tsx create mode 100644 packages/frontend/app/(dashboard)/reports/page.tsx create mode 100644 packages/frontend/app/(dashboard)/visits/[id]/page.tsx create mode 100644 packages/frontend/app/(dashboard)/visits/page.tsx create mode 100644 packages/frontend/app/globals.css create mode 100644 packages/frontend/app/layout.tsx create mode 100644 packages/frontend/app/login/page.tsx create mode 100644 packages/frontend/app/providers.tsx create mode 100644 packages/frontend/components/DataTable.tsx create mode 100644 packages/frontend/components/FraudTimeline.tsx create mode 100644 packages/frontend/components/PageHeader.tsx create mode 100644 packages/frontend/components/RiskBadge.tsx create mode 100644 packages/frontend/components/Sidebar.tsx create mode 100644 packages/frontend/components/StatCard.tsx create mode 100644 packages/frontend/components/TopBar.tsx create mode 100644 packages/frontend/components/VerificationResultBadge.tsx create mode 100644 packages/frontend/components/ui/avatar.tsx create mode 100644 packages/frontend/components/ui/badge.tsx create mode 100644 packages/frontend/components/ui/button.tsx create mode 100644 packages/frontend/components/ui/card.tsx create mode 100644 packages/frontend/components/ui/dialog.tsx create mode 100644 packages/frontend/components/ui/dropdown-menu.tsx create mode 100644 packages/frontend/components/ui/input.tsx create mode 100644 packages/frontend/components/ui/select.tsx create mode 100644 packages/frontend/components/ui/separator.tsx create mode 100644 packages/frontend/components/ui/skeleton.tsx create mode 100644 packages/frontend/components/ui/tabs.tsx create mode 100644 packages/frontend/lib/api.ts create mode 100644 packages/frontend/lib/mock.ts create mode 100644 packages/frontend/lib/risk.ts create mode 100644 packages/frontend/lib/types.ts create mode 100644 packages/frontend/lib/utils.ts create mode 100644 packages/frontend/next.config.mjs create mode 100644 packages/frontend/package.json create mode 100644 packages/frontend/postcss.config.mjs create mode 100644 packages/frontend/tailwind.config.ts create mode 100644 packages/frontend/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7501994 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: ['**'] + pull_request: + branches: [main, develop] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend (lint · typecheck · test · build) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: rayverify + POSTGRES_PASSWORD: rayverify + POSTGRES_DB: rayverify_test + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U rayverify" + --health-interval 5s --health-timeout 5s --health-retries 10 + redis: + image: redis:7-alpine + ports: ['6379:6379'] + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + DATABASE_URL: postgresql://rayverify:rayverify@localhost:5432/rayverify_test?schema=public + REDIS_URL: redis://localhost:6379 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - name: Generate Prisma client + run: npm run prisma:generate --workspace @rayverify/backend + - name: Lint + run: npm run lint --workspace @rayverify/backend + - name: Typecheck (shared + backend) + run: | + npm run typecheck --workspace @rayverify/shared + npm run typecheck --workspace @rayverify/backend + - name: Unit tests + run: npm run test --workspace @rayverify/backend + - name: Apply schema (migrations + physical SQL) + run: | + npm run prisma:migrate:dev --workspace @rayverify/backend -- --name ci --skip-seed || true + node packages/backend/scripts/apply-sql.js || true + - name: Build + run: npm run build --workspace @rayverify/backend + + frontend: + name: Frontend (lint · typecheck · build) + runs-on: ubuntu-latest + # Non-blocking until dependency lockfile is materialized & verified in-repo. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint --workspace @rayverify/frontend --if-present + - run: npm run typecheck --workspace @rayverify/frontend --if-present + - run: npm run build --workspace @rayverify/frontend --if-present diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..5c86a4a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + schedule: + - cron: '23 4 * * 1' # weekly, Monday 04:23 UTC + +jobs: + analyze: + name: Analyze (javascript-typescript) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: ['javascript-typescript'] + steps: + - uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + language: ${{ matrix.language }} + queries: security-extended + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..70d3343 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,73 @@ +name: Deploy + +# Build & push images to ECR, then deploy to ECS. Production requires manual +# approval via a protected GitHub Environment. Auth to AWS uses OIDC — no +# long-lived keys. Configure repo variables/secrets before enabling: +# vars: AWS_REGION, ECR_REGISTRY, ECS_CLUSTER, API_SERVICE, WEB_SERVICE +# secrets: AWS_DEPLOY_ROLE_ARN (IAM role trusting this repo via OIDC) + +on: + workflow_dispatch: + inputs: + environment: + description: Target environment + type: choice + options: [staging, production] + default: staging + push: + tags: ['v*.*.*'] + +permissions: + id-token: write # OIDC + contents: read + +jobs: + build-and-push: + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.tag }} + steps: + - uses: actions/checkout@v4 + - id: meta + run: echo "tag=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - uses: aws-actions/amazon-ecr-login@v2 + - name: Build & push backend + run: | + docker build -f packages/backend/Dockerfile \ + -t ${{ vars.ECR_REGISTRY }}/rayverify-api:${{ steps.meta.outputs.tag }} . + docker push ${{ vars.ECR_REGISTRY }}/rayverify-api:${{ steps.meta.outputs.tag }} + - name: Build & push frontend + run: | + docker build -f packages/frontend/Dockerfile \ + -t ${{ vars.ECR_REGISTRY }}/rayverify-web:${{ steps.meta.outputs.tag }} . + docker push ${{ vars.ECR_REGISTRY }}/rayverify-web:${{ steps.meta.outputs.tag }} + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + # Protected environment ⇒ required reviewers gate production (separation of duties). + environment: ${{ github.event.inputs.environment || 'staging' }} + steps: + - uses: actions/checkout@v4 + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - name: Run DB migrations (one-off ECS task) + run: | + echo "Trigger 'rayverify-api migrate' task (prisma migrate deploy + apply-sql)." + echo "Migrations use the expand/contract pattern; partitions are pre-created." + - name: Deploy API (rolling / blue-green via CodeDeploy) + run: | + aws ecs update-service --cluster ${{ vars.ECS_CLUSTER }} \ + --service ${{ vars.API_SERVICE }} --force-new-deployment + - name: Deploy Web + run: | + aws ecs update-service --cluster ${{ vars.ECS_CLUSTER }} \ + --service ${{ vars.WEB_SERVICE }} --force-new-deployment + - name: Smoke test + run: echo "curl health endpoints, verify task health, then mark deploy complete." diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..a1cec10 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,51 @@ +name: Security Scan + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + schedule: + - cron: '0 6 * * *' # daily + +jobs: + secrets: + name: Secret scan (gitleaks) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + dependencies: + name: Dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 20, cache: npm } + - run: npm ci + - name: npm audit (high+) + run: npm audit --audit-level=high || true + + filesystem: + name: Filesystem vuln scan (Trivy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Trivy filesystem scan + uses: aquasecurity/trivy-action@0.24.0 + with: + scan-type: fs + scan-ref: . + severity: HIGH,CRITICAL + ignore-unfixed: true + format: sarif + output: trivy-results.sarif + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: trivy-results.sarif diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..e5ce635 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "semi": true +} diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..1da0546 --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,750 @@ +openapi: 3.1.0 +info: + title: RayVerify™ API + version: 0.1.0 + description: > + Government-grade fraud detection & identity verification for Medicaid / HCBS. + Every endpoint is tenant-scoped (Postgres RLS) and audited. Authentication is + JWT bearer (access + rotating refresh). This document is the contract; the + live, generated spec is served by the backend at `/{prefix}/docs-json`. + license: + name: UNLICENSED +servers: + - url: https://api.rayverify.example/api/v1 + description: Production (illustrative) + - url: http://localhost:4000/api/v1 + description: Local development + +security: + - bearerAuth: [] + +tags: + - name: auth + - name: identity + - name: visits + - name: fraud + - name: cases + - name: providers + - name: audit + - name: reports + - name: notifications + - name: hardware + - name: health + +paths: + /health: + get: + tags: [health] + summary: Liveness probe + security: [] + responses: + '200': + description: OK + + /auth/login: + post: + tags: [auth] + summary: Authenticate (org slug + email + password [+ MFA]) + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/LoginRequest' } + responses: + '200': + description: Token pair + content: + application/json: + schema: { $ref: '#/components/schemas/TokenResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /auth/refresh: + post: + tags: [auth] + summary: Rotate tokens + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/RefreshRequest' } + responses: + '200': + description: New token pair + content: + application/json: + schema: { $ref: '#/components/schemas/TokenResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /auth/logout: + post: + tags: [auth] + summary: Revoke a refresh token + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/RefreshRequest' } + responses: + '204': { description: Revoked } + + /auth/me: + get: + tags: [auth] + summary: Current authenticated principal + responses: + '200': + description: Principal + content: + application/json: + schema: { $ref: '#/components/schemas/Principal' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /identity/verify: + post: + tags: [identity] + summary: Run selfie + liveness identity verification + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/VerifyIdentityRequest' } + responses: + '201': + description: Verification outcome + content: + application/json: + schema: { $ref: '#/components/schemas/IdentityVerificationOutcome' } + '403': { $ref: '#/components/responses/Forbidden' } + + /visits: + get: + tags: [visits] + summary: List visits + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + - name: status + in: query + schema: { $ref: '#/components/schemas/VisitStatus' } + responses: + '200': + description: Paginated visits + content: + application/json: + schema: { $ref: '#/components/schemas/PaginatedVisits' } + post: + tags: [visits] + summary: Schedule/create a visit + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateVisitRequest' } + responses: + '201': + description: Created visit + content: + application/json: + schema: { $ref: '#/components/schemas/Visit' } + + /visits/{id}: + get: + tags: [visits] + summary: Get a visit with its full verification package + parameters: [{ $ref: '#/components/parameters/Id' }] + responses: + '200': + description: Visit with verification package + content: + application/json: + schema: { $ref: '#/components/schemas/VisitDetail' } + '404': { $ref: '#/components/responses/NotFound' } + + /visits/{id}/clock-in: + post: + tags: [visits] + summary: Record clock-in (GPS + device evidence) + parameters: [{ $ref: '#/components/parameters/Id' }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ClockEvent' } + responses: + '201': { description: Updated visit } + + /visits/{id}/clock-out: + post: + tags: [visits] + summary: Record clock-out + parameters: [{ $ref: '#/components/parameters/Id' }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ClockOutEvent' } + responses: + '201': { description: Updated visit } + + /visits/{id}/verify: + post: + tags: [visits] + summary: Run the verification chain and produce the rollup + parameters: [{ $ref: '#/components/parameters/Id' }] + responses: + '201': + description: Verification rollup + content: + application/json: + schema: { $ref: '#/components/schemas/VerificationRollup' } + + /fraud/events: + get: + tags: [fraud] + summary: List fraud events + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + - name: type + in: query + schema: { $ref: '#/components/schemas/FraudEventType' } + responses: + '200': + description: Paginated fraud events + content: + application/json: + schema: { $ref: '#/components/schemas/PaginatedFraudEvents' } + + /fraud/visits/{id}/score: + post: + tags: [fraud] + summary: Score a visit (run detectors, persist events + score) + parameters: [{ $ref: '#/components/parameters/Id' }] + responses: + '201': + description: Scoring outcome + content: + application/json: + schema: { $ref: '#/components/schemas/ScoringOutcome' } + + /cases: + get: + tags: [cases] + summary: List cases (priority-ordered) + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + - name: status + in: query + schema: { $ref: '#/components/schemas/CaseStatus' } + responses: + '200': { description: Paginated cases } + post: + tags: [cases] + summary: Open an investigation case + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateCaseRequest' } + responses: + '201': + description: Created case + content: + application/json: + schema: { $ref: '#/components/schemas/Case' } + + /cases/{id}: + get: + tags: [cases] + summary: Case detail (events, notes, evidence) + parameters: [{ $ref: '#/components/parameters/Id' }] + responses: + '200': + description: Case detail + content: + application/json: + schema: { $ref: '#/components/schemas/CaseDetail' } + + /cases/{id}/assign: + patch: + tags: [cases] + summary: Assign a case to an investigator + parameters: [{ $ref: '#/components/parameters/Id' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [assigneeId] + properties: + assigneeId: { type: string, format: uuid } + responses: + '200': { description: Updated case } + + /cases/{id}/status: + patch: + tags: [cases] + summary: Update case status + parameters: [{ $ref: '#/components/parameters/Id' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [status] + properties: + status: { $ref: '#/components/schemas/CaseStatus' } + responses: + '200': { description: Updated case } + + /cases/{id}/notes: + post: + tags: [cases] + summary: Add an investigator note + parameters: [{ $ref: '#/components/parameters/Id' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [body] + properties: + body: { type: string } + isInternal: { type: boolean, default: true } + responses: + '201': { description: Created note } + + /providers/risk-ranking: + get: + tags: [providers] + summary: Provider risk ranking (highest risk first) + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + responses: + '200': { description: Paginated risk profiles } + + /providers/{id}/risk-profile: + get: + tags: [providers] + summary: Provider risk profile with historical trend + parameters: [{ $ref: '#/components/parameters/Id' }] + responses: + '200': + description: Risk profile + content: + application/json: + schema: { $ref: '#/components/schemas/ProviderRiskProfile' } + + /providers/{id}/risk-profile/recompute: + post: + tags: [providers] + summary: Recompute a provider risk profile + parameters: [{ $ref: '#/components/parameters/Id' }] + responses: + '201': + description: Updated profile + content: + application/json: + schema: { $ref: '#/components/schemas/ProviderRiskProfile' } + + /audit/logs: + get: + tags: [audit] + summary: Search the immutable audit trail + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + - { name: resourceType, in: query, schema: { type: string } } + - { name: resourceId, in: query, schema: { type: string } } + - { name: action, in: query, schema: { $ref: '#/components/schemas/AuditAction' } } + responses: + '200': { description: Paginated audit logs } + + /audit/verify-chain: + get: + tags: [audit] + summary: Verify the tamper-evident audit hash chain + responses: + '200': + description: Chain verification result + content: + application/json: + schema: + type: object + properties: + valid: { type: boolean } + checked: { type: integer } + brokenAtId: { type: string, format: uuid, nullable: true } + + /reports: + get: + tags: [reports] + summary: List reports + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + - name: type + in: query + schema: { $ref: '#/components/schemas/ReportType' } + responses: + '200': { description: Paginated reports } + post: + tags: [reports] + summary: Queue a report for generation + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/RequestReport' } + responses: + '201': { description: Queued report } + + /reports/{id}: + get: + tags: [reports] + summary: Get a report (status + download when READY) + parameters: [{ $ref: '#/components/parameters/Id' }] + responses: + '200': { description: Report } + + /notifications: + get: + tags: [notifications] + summary: List the current user's notifications + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + responses: + '200': { description: Paginated notifications } + + /notifications/{id}/read: + patch: + tags: [notifications] + summary: Mark a notification read + parameters: [{ $ref: '#/components/parameters/Id' }] + responses: + '200': { description: Updated notification } + + /hardware/capabilities: + get: + tags: [hardware] + summary: List supported hardware capabilities and registered drivers + responses: + '200': + description: Capability catalog + content: + application/json: + schema: { $ref: '#/components/schemas/HardwareCatalog' } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + Id: + name: id + in: path + required: true + schema: { type: string, format: uuid } + Page: + name: page + in: query + schema: { type: integer, minimum: 1, default: 1 } + PageSize: + name: pageSize + in: query + schema: { type: integer, minimum: 1, maximum: 200, default: 25 } + + responses: + Unauthorized: + description: Authentication required or failed + content: + application/json: + schema: { $ref: '#/components/schemas/ProblemDetails' } + Forbidden: + description: Missing required permission + content: + application/json: + schema: { $ref: '#/components/schemas/ProblemDetails' } + NotFound: + description: Resource not found + content: + application/json: + schema: { $ref: '#/components/schemas/ProblemDetails' } + + schemas: + ProblemDetails: + type: object + description: RFC 7807-style error envelope + properties: + type: { type: string } + status: { type: integer } + title: { type: string } + instance: { type: string } + requestId: { type: string } + timestamp: { type: string, format: date-time } + + VerificationResult: + type: string + enum: [PASS, REVIEW, FAIL] + RiskLevel: + type: string + enum: [LOW, MODERATE, HIGH, CRITICAL] + VisitStatus: + type: string + enum: [SCHEDULED, IN_PROGRESS, COMPLETED, FLAGGED, REJECTED, APPROVED, CANCELLED] + FraudEventType: + type: string + enum: + [IMPOSSIBLE_TRAVEL, DUPLICATE_VISIT, SHARED_DEVICE, GPS_ANOMALY, IDENTITY_MISMATCH, + UNUSUAL_BILLING, ABNORMAL_DURATION, EXCESSIVE_OVERTIME, SERVICE_OVERLAP, + CROSS_PROVIDER_RISK, LIVENESS_FAILURE, DEVICE_TAMPERING, GEOFENCE_BREACH] + CaseStatus: + type: string + enum: [OPEN, IN_REVIEW, ESCALATED, PENDING_PAYMENT_HOLD, SUBSTANTIATED, UNSUBSTANTIATED, CLOSED] + AuditAction: + type: string + enum: [CREATE, READ, UPDATE, DELETE, LOGIN, LOGOUT, EXPORT, VERIFY, SCORE, CASE_ACTION, CONFIG_CHANGE] + ReportType: + type: string + enum: [FRAUD_SUMMARY, PROVIDER_RISK, VISIT_VERIFICATION, INVESTIGATION, STATE_COMPLIANCE, EXECUTIVE_DASHBOARD] + + Pagination: + type: object + properties: + page: { type: integer } + pageSize: { type: integer } + total: { type: integer } + totalPages: { type: integer } + + LoginRequest: + type: object + required: [organizationSlug, email, password] + properties: + organizationSlug: { type: string, example: state-pi } + email: { type: string, format: email } + password: { type: string, format: password } + mfaCode: { type: string } + RefreshRequest: + type: object + required: [refreshToken] + properties: + refreshToken: { type: string } + TokenResponse: + type: object + properties: + accessToken: { type: string } + refreshToken: { type: string } + expiresIn: { type: integer } + tokenType: { type: string, example: Bearer } + Principal: + type: object + properties: + userId: { type: string, format: uuid } + organizationId: { type: string, format: uuid } + email: { type: string } + roles: { type: array, items: { type: string } } + permissions: { type: array, items: { type: string } } + + VerifyIdentityRequest: + type: object + required: [caregiverId] + properties: + caregiverId: { type: string, format: uuid } + visitId: { type: string, format: uuid } + probeS3Key: { type: string } + simulate: + type: object + properties: + confidence: { type: number, minimum: 0, maximum: 1 } + liveness: { type: number, minimum: 0, maximum: 1 } + IdentityVerificationOutcome: + type: object + properties: + id: { type: string, format: uuid } + result: { $ref: '#/components/schemas/VerificationResult' } + confidence: { type: number } + liveness: { type: number } + reasons: { type: array, items: { type: string } } + + CreateVisitRequest: + type: object + required: [providerId, caregiverId, patientId, scheduledStart] + properties: + providerId: { type: string, format: uuid } + caregiverId: { type: string, format: uuid } + patientId: { type: string, format: uuid } + authorizationId: { type: string, format: uuid } + serviceCode: { type: string } + scheduledStart: { type: string, format: date-time } + scheduledEnd: { type: string, format: date-time } + ClockEvent: + type: object + required: [lat, lng] + properties: + lat: { type: number, minimum: -90, maximum: 90 } + lng: { type: number, minimum: -180, maximum: 180 } + accuracyMeters: { type: number } + capturedAt: { type: string, format: date-time } + deviceId: { type: string } + ClockOutEvent: + allOf: + - $ref: '#/components/schemas/ClockEvent' + - type: object + properties: + billedUnits: { type: integer } + Visit: + type: object + properties: + id: { type: string, format: uuid } + status: { $ref: '#/components/schemas/VisitStatus' } + scheduledStart: { type: string, format: date-time } + verificationResult: { $ref: '#/components/schemas/VerificationResult' } + riskScore: { type: integer } + riskLevel: { $ref: '#/components/schemas/RiskLevel' } + VisitDetail: + allOf: + - $ref: '#/components/schemas/Visit' + - type: object + properties: + visitVerification: { type: object } + identityVerifications: { type: array, items: { type: object } } + gpsVerifications: { type: array, items: { type: object } } + deviceVerifications: { type: array, items: { type: object } } + fraudEvents: { type: array, items: { $ref: '#/components/schemas/FraudEvent' } } + PaginatedVisits: + allOf: + - $ref: '#/components/schemas/Pagination' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/Visit' } } + VerificationRollup: + type: object + properties: + visitId: { type: string, format: uuid } + result: { $ref: '#/components/schemas/VerificationResult' } + status: { $ref: '#/components/schemas/VisitStatus' } + chain: { type: object } + evidenceHash: { type: string } + fraud: { $ref: '#/components/schemas/CompositeScore' } + + FraudEvent: + type: object + properties: + id: { type: string, format: uuid } + type: { $ref: '#/components/schemas/FraudEventType' } + severity: { type: integer } + riskLevel: { $ref: '#/components/schemas/RiskLevel' } + explanation: { type: string } + evidence: { type: object } + detectedAt: { type: string, format: date-time } + PaginatedFraudEvents: + allOf: + - $ref: '#/components/schemas/Pagination' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/FraudEvent' } } + ScoreFactor: + type: object + properties: + type: { $ref: '#/components/schemas/FraudEventType' } + severity: { type: integer } + weight: { type: number } + contribution: { type: number } + explanation: { type: string } + CompositeScore: + type: object + properties: + score: { type: integer, minimum: 0, maximum: 100 } + riskLevel: { $ref: '#/components/schemas/RiskLevel' } + triggeredCount: { type: integer } + factors: { type: array, items: { $ref: '#/components/schemas/ScoreFactor' } } + ScoringOutcome: + type: object + properties: + visitId: { type: string, format: uuid } + composite: { $ref: '#/components/schemas/CompositeScore' } + fraudEventIds: { type: array, items: { type: string, format: uuid } } + + CreateCaseRequest: + type: object + required: [title] + properties: + title: { type: string } + priority: { type: string, enum: [LOW, MEDIUM, HIGH, URGENT] } + providerId: { type: string, format: uuid } + summary: { type: string } + exposureCents: { type: integer } + fraudEventIds: { type: array, items: { type: string, format: uuid } } + Case: + type: object + properties: + id: { type: string, format: uuid } + caseNumber: { type: string, example: RV-2026-000123 } + title: { type: string } + status: { $ref: '#/components/schemas/CaseStatus' } + priority: { type: string } + riskLevel: { $ref: '#/components/schemas/RiskLevel' } + exposureCents: { type: integer } + CaseDetail: + allOf: + - $ref: '#/components/schemas/Case' + - type: object + properties: + events: { type: array, items: { $ref: '#/components/schemas/FraudEvent' } } + notes: { type: array, items: { type: object } } + evidence: { type: array, items: { type: object } } + + ProviderRiskProfile: + type: object + properties: + providerId: { type: string, format: uuid } + currentScore: { type: integer } + riskLevel: { $ref: '#/components/schemas/RiskLevel' } + verificationFailures: { type: integer } + gpsAnomalies: { type: integer } + billingAnomalies: { type: integer } + identityIssues: { type: integer } + openCases: { type: integer } + substantiatedCases: { type: integer } + trend: + type: array + items: + type: object + properties: + t: { type: string, format: date-time } + score: { type: integer } + + RequestReport: + type: object + required: [type] + properties: + type: { $ref: '#/components/schemas/ReportType' } + format: { type: string, enum: [PDF, XLSX, CSV, JSON] } + parameters: { type: object } + + HardwareCatalog: + type: object + properties: + supported: + type: array + items: { type: string, enum: [NFC, FINGERPRINT, FACE, SECURE_ELEMENT, GPS, LTE] } + registered: + type: array + items: + type: object + properties: + capability: { type: string } + driver: { type: string } + health: { type: string, enum: [ONLINE, DEGRADED, OFFLINE] } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8d7bc81 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +# Local development infrastructure for RayVerify. +# Brings up Postgres, Redis, and LocalStack (S3) so the backend runs end-to-end. +services: + postgres: + image: postgres:16-alpine + container_name: rayverify-postgres + environment: + POSTGRES_USER: rayverify + POSTGRES_PASSWORD: rayverify + POSTGRES_DB: rayverify + ports: + - '5432:5432' + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U rayverify'] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + container_name: rayverify-redis + command: ['redis-server', '--appendonly', 'yes'] + ports: + - '6379:6379' + volumes: + - redisdata:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 5s + retries: 10 + + localstack: + image: localstack/localstack:3 + container_name: rayverify-localstack + environment: + SERVICES: s3,kms,secretsmanager + DEFAULT_REGION: us-east-1 + ports: + - '4566:4566' + volumes: + - localstackdata:/var/lib/localstack + +volumes: + pgdata: + redisdata: + localstackdata: diff --git a/docs/00-overview.md b/docs/00-overview.md new file mode 100644 index 0000000..cc44441 --- /dev/null +++ b/docs/00-overview.md @@ -0,0 +1,219 @@ +# RayVerify™ — Executive Overview + +> **Platform:** RayVerify™ | **Parent:** RayHealthEVV™ +> **Classification:** Government-grade fraud detection & identity verification +> **Programs served:** Medicaid · HCBS · Personal Care Services · Government-funded healthcare + +--- + +## Table of Contents + +1. [Mission](#1-mission) +2. [The Problem — Medicaid Fraud at Scale](#2-the-problem--medicaid-fraud-at-scale) +3. [The RayVerify Thesis](#3-the-rayverify-thesis) +4. [Architecture at a Glance](#4-architecture-at-a-glance) +5. [Value Proposition vs. Legacy EVV](#5-value-proposition-vs-legacy-evv) +6. [The Eight Modules](#6-the-eight-modules) +7. [Primary Users](#7-primary-users) +8. [Glossary](#8-glossary) + +--- + +## 1. Mission + +RayVerify™ exists to close the enforcement gap at the exact moment it matters most: **before a Medicaid dollar leaves the treasury**. + +Legacy Electronic Visit Verification (EVV) systems were designed to record that a visit happened — a timestamp and a GPS coordinate. They answer *when* and *where*. They do not answer *who*, *whether the right person was present*, *whether the device reporting the visit is trustworthy*, *whether the patient confirms the service was received*, or *whether the billed claim is consistent with authorized care*. + +RayVerify is a **fraud prevention, identity verification, and program integrity platform** built specifically for state Medicaid agencies, Managed Care Organizations (MCOs), Program Integrity Units, OIG investigators, compliance officers, and state auditors. It verifies identity, presence, location, device authenticity, patient confirmation, and billing legitimacy — and surfaces fraud intelligence to the right investigator **before payment is made**. + +This is not another EVV vendor. It is the enforcement layer that state programs have been missing. + +--- + +## 2. The Problem — Medicaid Fraud at Scale + +Federal and state reports consistently place Medicaid improper payments in the **tens of billions of dollars annually** — a figure that has grown as home and community-based services (HCBS) enrollment has expanded and as legacy oversight infrastructure has not kept pace. (These figures are widely cited from HHS OIG and CMS annual reports; treat them as illustrative of the order of magnitude, not as a precise audited total.) + +The home-care and personal-care sector is disproportionately represented in program integrity findings for several reasons: + +- **Services are delivered in private residences**, far from any agency oversight. +- **EVV mandates (21st Century Cures Act, 2016)** required states to collect visit data, but the mandate specified *what to collect*, not *how to verify its authenticity*. +- **Identity is assumed, not verified.** A caregiver's login credentials confirm a username and password, not a face, not a live human being, not physical presence at a care site. +- **Device substitution is trivial.** A caregiver can hand a phone to a family member, a fraudulent agency can log check-ins from a desktop, and emulated devices can fabricate GPS coordinates. +- **Billing anomalies are caught after payment**, if at all. Retroactive audits recover pennies on the dollar. + +The result is a structural fraud opportunity that grows proportionally with HCBS enrollment. Improper payments in this sector include ghost visits (services never rendered), identity substitution (a different person clocking in), time inflation (billing for more hours than worked), service overlap (two claims for the same beneficiary at the same time), and provider-level fraud schemes orchestrated across networks of caregivers and patients. + +RayVerify was designed to close this gap. + +--- + +## 3. The RayVerify Thesis + +> **Verify identity, presence, device, and billing legitimacy at the point of service — pre-payment.** + +The thesis rests on four convictions: + +1. **Pre-payment fraud prevention yields dramatically higher returns than post-payment auditing.** Every dollar blocked before it is paid avoids the recovery cost (legal fees, administrative burden, low recovery rates) entirely. + +2. **The caregiver's identity must be verified biometrically at each visit clock-in, not once at enrollment.** A credential can be shared; a face, combined with active liveness detection, is significantly harder to substitute. + +3. **Every verification event must generate an immutable, auditable evidence record.** When an OIG investigator builds a case or a state auditor responds to a CMS inquiry, the evidence package must be court-ready, tamper-evident, and exportable. + +4. **Fraud intelligence must be continuous and automated.** A single analyst cannot manually review hundreds of thousands of visits. A rules-and-ML composite scoring pipeline, running on every visit, surfaces the highest-risk providers and claims for human review — eliminating the needle-in-a-haystack problem. + +--- + +## 4. Architecture at a Glance + +RayVerify is an API-first, multi-tenant SaaS platform. The architecture is designed for state and federal procurement requirements: no single point of failure, hard tenant isolation, immutable evidence, and a compliance posture that targets HIPAA, HITECH, NIST 800-63, SOC 2, and the CMS EVV requirements of the 21st Century Cures Act. + +```mermaid +flowchart LR + subgraph Clients + INV[Investigator Dashboard\nNext.js 15 / shadcn] + FIELD[Field Capture App\nMobile / Web] + EXT[State / MCO Integrations\nAPI consumers] + end + INV & FIELD & EXT --> GW[API Gateway\nNestJS / JWT + RBAC + MFA] + GW --> IDV[Identity Verification Engine\nModule 1] + GW --> VVE[Visit Verification Engine\nModule 2] + GW --> FIE[Fraud Intelligence Engine\nModule 3] + GW --> CASE[Investigator Dashboard\nModule 4] + GW --> PRS[Provider Risk Scoring\nModule 5] + GW --> AUD[Audit & Compliance Center\nModule 6] + GW --> RPT[Reporting & Analytics\nModule 7] + FIE --> REDIS[(Redis\nJob queues)] + IDV & VVE --> S3[(S3 / KMS\nEncrypted evidence\nBiometric images)] + IDV & VVE & FIE & CASE & PRS & AUD & RPT --> DB[(PostgreSQL 15+\nRLS · Partitioned\nAES-256 PHI)] + RPT --> S3 + GW --> AUDIT[(Immutable Audit Log\nHash chain — append-only)] +``` + +### Technology Stack + +| Layer | Technology | +|---|---| +| **Frontend** | Next.js 15 · TypeScript · TailwindCSS · shadcn/ui | +| **Backend** | NestJS · Prisma ORM · TypeScript | +| **Database** | PostgreSQL 15+ (RDS Multi-AZ) · Redis (queues + cache) | +| **Infrastructure** | AWS — RDS · S3 · CloudFront · ECS (EKS-ready) · KMS · CloudWatch | +| **Security** | AES-256-GCM at rest · TLS 1.3 transit · JWT + RBAC + MFA · immutable audit logs | +| **Patterns** | Multi-tenant RLS · API-first · microservice-ready · Zero Trust | + +### Data Model Highlights (from `packages/backend/prisma/schema.prisma`) + +The canonical data model is organized into five domains: + +- **Tenancy & IAM:** `organizations`, `users`, `sessions`, `roles`, `permissions`, `role_permissions`, `user_roles`. Every business table carries `organizationId` as the tenant root; PostgreSQL RLS enforces isolation with `SET app.current_org` per transaction. +- **Domain entities:** `providers` (NPI + Medicaid ID), `caregivers`, `biometric_enrollments`, `patients`, `service_authorizations` (geofence anchor: lat/lng + `radiusMeters`). +- **Device trust:** `devices` — tracks `trustLevel` (TRUSTED / UNKNOWN / SUSPICIOUS / BLOCKED), `isEmulator`, `isRooted`, `isJailbroken`, `fingerprintHash`. +- **Verification chain:** `visits` (partitioned monthly), `visit_verifications` (sealed chain + `evidenceHash`), `identity_verifications`, `gps_verifications`, `device_verifications` — all append-only, immutable by DB trigger. +- **Fraud intelligence & reporting:** `fraud_events`, `fraud_cases`, `case_notes`, `case_evidence`, `fraud_scores`, `provider_risk_profiles`, `reports`, `notifications`, `audit_logs` (partitioned monthly, hash chain). + +Money is stored as integer cents (`billedAmountCents`, `exposureCents`) to avoid floating-point drift. Coordinates use `Decimal(9,6)` for ~11 cm precision. Surrogate PKs are UUID v4. + +--- + +## 5. Value Proposition vs. Legacy EVV + +| Capability | Legacy EVV | RayVerify™ | +|---|---|---| +| **Clock-in timestamp** | Yes | Yes | +| **GPS coordinate** | Yes (device-reported) | Yes + geofence validation + anomaly detection | +| **Identity verification** | Login credentials only | Selfie + liveness detection + enrolled face comparison | +| **Device trust** | None | Emulator detection, root/jailbreak detection, device fingerprinting, device-switching alerts | +| **Patient confirmation** | None | Patient confirmation step linked to visit record | +| **Fraud signal detection** | None / post-payment | Real-time: impossible travel, duplicate visits, shared devices, billing anomalies, service overlap, cross-provider risk | +| **Pre-payment scoring** | None | 0–100 composite risk score per visit; HIGH/CRITICAL triggers hold queue | +| **Provider risk profiling** | None | Dynamic per-provider risk score with historical trend and contributing factor breakdown | +| **Case management** | None | Full investigator workflow: case creation, evidence linking, notes, status lifecycle (OPEN → ESCALATED → SUBSTANTIATED) | +| **Audit trail** | Basic logs | Immutable, append-only, tamper-evident hash chain; every read/write/export logged | +| **Compliance reporting** | Visit logs | Six report types: Fraud Summary, Provider Risk, Visit Verification, Investigation, State Compliance, Executive Dashboard | +| **Integration model** | State-mandated EVV aggregator | API-first; consumes EVV feed + adds verification layer; integrates with state MMIS/claims systems | +| **Multi-tenant isolation** | Varies | PostgreSQL Row-Level Security; every query scoped to `organization_id` | +| **Timing** | Post-visit data capture | Real-time verification chain at clock-in and clock-out | + +--- + +## 6. The Eight Modules + +### Module 1 — Identity Verification Engine + +The Identity Verification Engine answers the foundational question that legacy EVV ignores: **is this actually the enrolled caregiver?** At each visit clock-in, the caregiver captures a selfie through the field application. The engine performs active liveness detection (to defeat photo and video replay attacks) and compares the probe image against the caregiver's enrolled biometric template stored in the `biometric_enrollments` table. The result — a `confidenceScore` (0.0–1.0) and a `livenessScore` (0.0–1.0) — is written to `identity_verifications` as an append-only, immutable record. The engine emits PASS, REVIEW, or FAIL. REVIEW and FAIL outcomes feed immediately into the fraud scoring pipeline. Future modalities (fingerprint, NFC identity card, government-issued credential at NIST 800-63 IAL2/IAL3) are enumerated in the `IdentityMethod` type and stubbed in the Hardware Integration Layer. + +### Module 2 — Visit Verification Engine + +The Visit Verification Engine orchestrates the **complete verification chain** for every visit: identity → GPS → device → patient confirmation → fraud scoring → approval. For each visit event (clock-in, clock-out, optional mid-visit check), the engine collects the caregiver's GPS coordinates and evaluates them against the geofence anchored to the patient's `service_authorization` record (authorized address + `radiusMeters`, defaulting to 150 m). GPS outcomes: inside radius = PASS; outside radius = REVIEW / FLAG; major discrepancy = FAIL. The full chain result is sealed into a `visit_verification` record, which carries a SHA-256 `evidenceHash` over the canonical evidence package for tamper detection. Visit status progresses through the SCHEDULED → IN_PROGRESS → COMPLETED → FLAGGED / APPROVED / REJECTED lifecycle. Approved visits are eligible for billing; FLAGGED visits enter a hold queue pending investigator review. + +### Module 3 — Fraud Intelligence Engine + +The Fraud Intelligence Engine runs a catalog of 13 detectors — rules-based and ML-augmented — against every visit and billing event, in real time. Detector types are defined in the `FraudEventType` enum: `IMPOSSIBLE_TRAVEL`, `DUPLICATE_VISIT`, `SHARED_DEVICE`, `GPS_ANOMALY`, `IDENTITY_MISMATCH`, `UNUSUAL_BILLING`, `ABNORMAL_DURATION`, `EXCESSIVE_OVERTIME`, `SERVICE_OVERLAP`, `CROSS_PROVIDER_RISK`, `LIVENESS_FAILURE`, `DEVICE_TAMPERING`, `GEOFENCE_BREACH`. Each detector emits a `FraudEvent` with a severity score (0–100), a `riskLevel` (LOW / MODERATE / HIGH / CRITICAL), a human-readable `explanation`, and structured `evidence` for explainability. Composite scores are aggregated into `FraudScore` records against any subject type: VISIT, PROVIDER, CAREGIVER, PATIENT, or CLAIM. The engine is versioned (each event carries `detector` + `detectorVersion`) for reproducibility and model governance. + +### Module 4 — Investigator Dashboard + +The Investigator Dashboard is the primary interface for OIG investigators, Program Integrity Unit staff, and state auditors. It surfaces real-time fraud alerts, a ranked list of high-risk providers, geographic heat maps of anomaly concentrations, and a full case management workflow. Investigators can create or be assigned `FraudCase` records (human-friendly case numbers follow the format `RV-YYYY-NNNNNN`), link fraud events to cases, attach evidence (visit records, GPS traces, identity verification images, exported documents), write internal case notes, and advance the case through the status lifecycle: OPEN → IN_REVIEW → ESCALATED → PENDING_PAYMENT_HOLD → SUBSTANTIATED / UNSUBSTANTIATED → CLOSED. The `exposureCents` field captures the estimated dollars at risk, enabling prioritization by financial exposure. + +### Module 5 — Provider Risk Scoring + +Provider Risk Scoring maintains a continuously updated, denormalized risk profile for every Medicaid provider in the `provider_risk_profiles` table. The profile aggregates `verificationFailures`, `gpsAnomalies`, `billingAnomalies`, `identityIssues`, `openCases`, and `substantiatedCases` into a composite `currentScore` (0–100) and `riskLevel`. Historical score points are stored as a sparkline JSON array (`trend`) enabling trend visualization — a provider whose score is rising rapidly is a different enforcement priority than one with a stable elevated score. The module enables state agencies and MCOs to rank their entire provider network by risk, identify outliers, and direct limited audit resources toward the highest-exposure providers first. + +### Module 6 — Audit & Compliance Center + +The Audit & Compliance Center provides the evidentiary backbone required for HIPAA, HITECH, SOC 2, and CMS audit responses. Every user action — login, read, write, export, verification, scoring event, case action, configuration change — is written to the `audit_logs` table as an immutable, append-only record. The table is partitioned monthly for query performance and carries a SHA-256 tamper-evident hash chain: each row stores `prevHash` (the hash of the prior row in the same tenant chain) and `hash` (computed over the canonical concatenation of the row's fields). A DB-layer trigger (`trg_audit_hash`) computes the chain on insert; a second trigger (`trg_audit_immutable`) blocks all UPDATE and DELETE operations, enforcing append-only semantics at the database level. Audit records are searchable and exportable by date range, actor, resource type, and action. + +### Module 7 — Reporting & Analytics + +The Reporting & Analytics module serves the full reporting surface required by state procurement officers, CMS auditors, MCO compliance teams, and executive leadership. Six report types are defined in `ReportType`: `FRAUD_SUMMARY`, `PROVIDER_RISK`, `VISIT_VERIFICATION`, `INVESTIGATION`, `STATE_COMPLIANCE`, `EXECUTIVE_DASHBOARD`. Reports are generated asynchronously (QUEUED → GENERATING → READY), stored encrypted in S3, and downloadable in PDF, XLSX, CSV, or JSON (`ReportFormat`). Expired reports are lifecycle-managed via the `expiresAt` field. Every report generation is a logged, audited action. The module supports scheduled delivery and webhook notification on completion. + +### Module 8 — Future Hardware Integration Layer + +The Hardware Integration Layer is an SDK abstraction that decouples the verification engines from specific capture hardware, enabling RayVerify to upgrade the strength of identity assurance as hardware becomes available in the field. The `IdentityMethod` enum reserves slots for `FINGERPRINT`, `NFC_CARD`, and `GOV_CREDENTIAL` (targeting NIST 800-63 IAL2/IAL3). The `DevicePlatform` enum includes `HARDWARE_TERMINAL`. This layer is explicitly **out of scope for the current release** and is documented here to convey the platform's designed-for extensibility. Physical modalities — dedicated facial recognition cameras, fingerprint scanners, NFC identity card readers, secure elements, and GPS/LTE modules — will integrate through this abstraction without requiring changes to the verification chain or fraud scoring logic above it. + +--- + +## 7. Primary Users + +| User | Role | What RayVerify Gives Them | +|---|---|---| +| **State Medicaid Agency** | Program owner; CMS-accountable for improper payment rates | Real-time visit verification data; state compliance reports; pre-payment fraud hold queue; audit-ready evidence for CMS | +| **Medicaid Managed Care Organization (MCO)** | Contracts with providers; financially at risk for overpayments | Provider risk rankings; visit-level verification outcomes; fraud summary reports; integration feed for claims adjudication | +| **Program Integrity Unit** | Investigates fraud, waste, and abuse referrals | Automated fraud alerts; investigator dashboard; case management workflow; evidence packages; investigation reports | +| **OIG Investigator / Agent** | Conducts formal fraud investigations; refers cases for prosecution | Tamper-evident evidence chain; case management with SHA-256 chain-of-custody; exportable audit logs; case notes (internal/external) | +| **Compliance Officer** | Ensures organizational adherence to Medicaid rules | Audit & Compliance Center; full audit trail export; HIPAA/HITECH controls documentation; state compliance reports | +| **State Auditor** | Reviews provider billing and program expenditure | Provider risk profiles; visit verification reports; executive dashboard; PDF/XLSX exports for audit workpapers | +| **Home-Care Oversight Agency** | Licenses and monitors home-care providers | Provider enrollment status; GPS verification outcomes; identity verification failure rates; geofence breach trends | +| **Federal Healthcare Program Administrator** | CMS or OIG national program oversight | State compliance reports; aggregate improper payment trends; cross-state visibility (where federated deployment is authorized) | + +--- + +## 8. Glossary + +| Term | Definition | +|---|---| +| **AAL (Authenticator Assurance Level)** | NIST 800-63B framework defining the strength of an authentication mechanism. AAL1 = password; AAL2 = MFA; AAL3 = hardware cryptographic authenticator. RayVerify targets AAL2 for all platform users. | +| **ATO (Authorization to Operate)** | Formal federal/state security authorization required before a system processes real PHI in a government program. Not yet obtained; see README security notice. | +| **Audit Hash Chain** | A cryptographic linked list over `audit_logs` rows. Each row's `hash` is computed over `prevHash || canonical(row)` using SHA-256, making retroactive tampering detectable. | +| **Biometric Enrollment** | The one-time process of capturing and storing a caregiver's reference facial template in `biometric_enrollments`, against which subsequent selfies are compared. | +| **Case Number** | Human-readable fraud case identifier in the format `RV-YYYY-NNNNNN` (e.g., `RV-2026-000123`). Unique per tenant/organization. | +| **EVV (Electronic Visit Verification)** | Technology system used to verify that home- and community-based services are delivered. Federally mandated by the 21st Century Cures Act (2016). Records time, location, caregiver, patient, and service type. RayVerify adds identity and fraud verification on top of EVV data. | +| **Geofence** | A virtual perimeter, defined as a latitude/longitude anchor point plus a `radiusMeters` value (default 150 m), within which a caregiver's GPS coordinates must fall at clock-in and clock-out. Defined per `service_authorization`. | +| **HCBS (Home and Community-Based Services)** | Medicaid-funded long-term services and supports delivered in a beneficiary's home or community setting rather than in an institutional setting. The highest-fraud-risk Medicaid service category. | +| **IAL (Identity Assurance Level)** | NIST 800-63A framework defining the strength of the identity proofing process. IAL1 = self-asserted; IAL2 = remote identity verification with document check; IAL3 = in-person identity proofing. RayVerify's biometric check targets IAL2-equivalent assurance. | +| **Improper Payment** | A payment that should not have been made, was made in the wrong amount, or was made to the wrong recipient. Includes payments for services not rendered (fraud) and billing errors (waste/abuse). Source: HHS/OMB definition. | +| **Liveness Detection** | An algorithmic challenge-response or passive analysis technique that determines whether the face presented to the camera is a live human being rather than a photograph, printed image, or video replay. Captured as `livenessScore` (0.0–1.0) in `identity_verifications`. | +| **MCO (Medicaid Managed Care Organization)** | A private health plan contracted by a state to deliver Medicaid benefits to enrolled beneficiaries. MCOs bear financial and compliance risk for the providers in their network. | +| **Medicaid Member ID** | The state-issued unique identifier for a Medicaid beneficiary (`medicaid_member_id` in `patients`). PHI; stored encrypted with a blind index for searchability. | +| **MMIS (Medicaid Management Information System)** | The state-operated system that processes Medicaid claims and manages eligibility, provider enrollment, and prior authorization. RayVerify integrates with MMIS via API to flag visits before claim adjudication. | +| **MFA (Multi-Factor Authentication)** | Authentication using two or more factors. RayVerify supports TOTP, SMS OTP, and WebAuthn (`MfaMethod` enum). Required for all platform users. | +| **NPI (National Provider Identifier)** | The 10-digit Luhn-checked unique identifier assigned by CMS to every health care provider in the United States. Stored in `providers.npi`; format-validated by DB constraint. | +| **OIG (Office of Inspector General)** | The U.S. Department of Health and Human Services Office of Inspector General, responsible for detecting and prosecuting Medicaid fraud. Also applies to state-level OIG offices. | +| **PCS (Personal Care Services)** | Medicaid-funded assistance with activities of daily living (bathing, dressing, toileting) delivered in the home. A major HCBS service type subject to EVV mandates. | +| **PHI (Protected Health Information)** | Individually identifiable health information protected under HIPAA. Includes Medicaid member IDs, dates of birth, biometric images, and service records. Stored encrypted at rest (AES-256-GCM, AWS KMS). | +| **Program Integrity** | The body of activities — audits, fraud investigations, data analytics, provider oversight — undertaken by state and federal agencies to ensure Medicaid funds are spent appropriately. | +| **Risk Level** | RayVerify's four-tier risk classification: LOW (0–30), MODERATE (31–60), HIGH (61–80), CRITICAL (81–100). Applied to visits, providers, caregivers, patients, and claims. | +| **RLS (Row-Level Security)** | PostgreSQL feature that restricts row visibility based on the current session's `app.current_org` setting. Enforces hard multi-tenant isolation: queries from one tenant cannot access another tenant's rows even if the application contains a bug. | +| **Service Authorization** | The formal Medicaid approval for a beneficiary to receive a specific service (identified by HCPCS or state-specific service code), with an authorized unit cap and a geofence-anchoring address. Stored in `service_authorizations`. | +| **Verification Result** | The three-valued outcome of any verification step: `PASS` (no action needed), `REVIEW` (human review required before approval), `FAIL` (visit blocked from billing). | +| **Visit Verification Chain** | The ordered sequence of verification steps applied to every visit: identity → GPS → device → patient confirmation → fraud scoring → final approval/rejection. The composite result is sealed in `visit_verifications` with a SHA-256 `evidenceHash`. | diff --git a/docs/01-product-requirements.md b/docs/01-product-requirements.md new file mode 100644 index 0000000..aa56192 --- /dev/null +++ b/docs/01-product-requirements.md @@ -0,0 +1,811 @@ +# RayVerify™ — Product Requirements Document + +--- + +## 1. Document Control + +| Field | Value | +|---|---| +| **Document title** | RayVerify™ Product Requirements Document (PRD) | +| **Version** | 1.0 | +| **Status** | Draft | +| **Parent platform** | RayHealthEVV™ | +| **Product owner** | Head of Product | +| **Technical lead** | Principal Engineer | +| **Compliance review** | Compliance & Privacy Officer | +| **Security review** | Security Architect | +| **Last updated** | 2026-06-10 | +| **Next review** | 2026-09-10 | +| **Audience** | State procurement reviewers · Investors · Engineering · Compliance | + +### 1.1 Related Documents + +| Document | Path | +|---|---| +| System Architecture | `docs/02-system-architecture.md` | +| Database Design | `docs/03-database-design.md` | +| API Design | `docs/04-api-design.md` | +| Fraud Detection Engine | `docs/05-fraud-detection-engine.md` | +| AI Risk Scoring | `docs/06-ai-risk-scoring.md` | +| Security Architecture | `docs/07-security-architecture.md` | +| AWS Deployment | `docs/08-aws-deployment.md` | + +--- + +## 2. Goals and Non-Goals + +### 2.1 Goals + +1. Provide state Medicaid agencies, MCOs, and Program Integrity Units with **pre-payment identity and fraud verification** for every home and community-based service visit. +2. Verify **who** delivered a service (biometric identity), **where** (geofenced GPS), **with what device** (device trust), **confirmed by the patient**, and **consistent with authorized billing** — in a single verification chain per visit. +3. Surface fraud intelligence automatically — real-time, explainable, scored — to investigators, so limited human audit resources are directed at the highest-risk providers and claims. +4. Generate a **court-ready, tamper-evident evidence record** for every visit verification, usable in OIG investigations, state audits, and CMS program integrity inquiries. +5. Deliver compliance reporting to meet CMS EVV requirements (21st Century Cures Act), HIPAA/HITECH obligations, and state audit standards. +6. Support **multi-tenant deployment** so a single platform instance can serve multiple state agencies, MCOs, and oversight organizations with hard data isolation. + +### 2.2 Non-Goals + +1. **NOT an EVV platform.** RayVerify does not replace state EVV aggregators or compete with EVV vendors. It is the verification and fraud intelligence layer that runs on top of EVV-captured or EVV-equivalent visit data. +2. **NOT a claims adjudication system.** RayVerify produces verification outcomes and risk signals; the MMIS/claims system uses those signals to adjudicate or hold claims. RayVerify does not process Medicaid payments. +3. **NOT a caregiver scheduling or HR system.** Provider enrollment, caregiver credentialing, and scheduling are external systems. RayVerify consumes provider and caregiver identities via API or import. +4. **NOT a beneficiary portal.** RayVerify does not manage patient enrollment, eligibility, or care plans. Patients are referenced entities, not active platform users. +5. **NOT hardware.** Physical biometric terminals, NFC readers, and fingerprint scanners are explicitly deferred to the Hardware Integration Layer (Module 8, future phase). + +--- + +## 3. Personas + +### Persona 1 — State Medicaid Program Integrity Director + +**Role:** Senior state government official accountable to the state Medicaid director and CMS for the agency's improper payment rate and fraud recovery figures. + +**Goals:** +- Demonstrate to CMS that the state has implemented "effective and cost-efficient program integrity" per 42 CFR Part 455. +- Reduce the improper payment rate in HCBS/PCS claims. +- Receive executive-level visibility into fraud trends without needing to operate investigation tooling directly. + +**Pain points:** +- Current EVV data proves a visit was *recorded*, not that it was *legitimately delivered*. +- CMS audit cycles expose gaps months after payments were made. +- No aggregate view of which providers are highest risk across the entire Medicaid network. + +**Key workflows:** +- Review Executive Dashboard and State Compliance reports weekly. +- Export state compliance reports for CMS submissions. +- Receive alerts when aggregate risk indicators cross defined thresholds. + +**Success metrics:** Reduction in HCBS improper payment rate year-over-year; number of pre-payment holds actioned; CMS audit findings closed. + +--- + +### Persona 2 — OIG Investigator / Agent + +**Role:** Law enforcement–adjacent investigator within the state Office of Inspector General or Medicaid Fraud Control Unit (MFCU), building cases for civil or criminal referral. + +**Goals:** +- Rapidly identify and build a case file with sufficient evidence for civil investigative demand (CID) or referral to the state attorney general. +- Ensure all evidence collected is chain-of-custody compliant and court-admissible. +- Track multiple open cases without losing thread on any of them. + +**Pain points:** +- Current evidence is a spreadsheet of visit logs with no biometric link to the caregiver actually present. +- No automated anomaly detection; investigators must manually query large datasets. +- No tamper-proof audit trail demonstrating that evidence has not been modified since collection. + +**Key workflows:** +- Triage fraud alerts from the Fraud Intelligence Engine in the Investigator Dashboard. +- Open a `FraudCase` (e.g., `RV-2026-000412`), assign priority, set estimated `exposureCents`. +- Link fraud events and visit verification records to the case as evidence. +- Write internal case notes; attach exported evidence packages. +- Advance case to ESCALATED → PENDING_PAYMENT_HOLD → SUBSTANTIATED. +- Export the complete evidence package (PDF, XLSX, audit trail) for referral. + +**Success metrics:** Case throughput per investigator per quarter; average time from fraud alert to case substantiation; dollar recovery / hold rate. + +--- + +### Persona 3 — MCO Compliance Officer + +**Role:** Employed by a Medicaid Managed Care Organization; responsible for network provider oversight, claims integrity, and regulatory reporting to the state. + +**Goals:** +- Identify high-risk providers in the MCO's network before claims are adjudicated. +- Meet contractual and regulatory obligations to report suspected fraud to the state. +- Ensure the MCO's capitation dollars are not eroded by fraudulent claims. + +**Pain points:** +- Provider performance data arrives in disconnected spreadsheets; no real-time risk view. +- Compliance staff must manually cross-reference EVV data against billing — extremely time-intensive. +- MCO faces financial liability for claims already paid when fraud is discovered post-payment. + +**Key workflows:** +- Monitor Provider Risk Scoring dashboard; filter by `riskLevel` = HIGH or CRITICAL. +- Review Provider Risk reports for monthly compliance filings. +- Flag providers for additional review; submit referrals to state program integrity. + +**Success metrics:** Number of high-risk providers identified pre-payment vs. post-payment; compliance report turnaround time; reduction in post-payment recoveries. + +--- + +### Persona 4 — Program Integrity Analyst + +**Role:** Data analyst within a state Program Integrity Unit or MCO, responsible for running queries, generating reports, and surfacing anomalies for investigator review. + +**Goals:** +- Efficiently process large volumes of fraud alert data and route the highest-priority items to investigators. +- Generate accurate, reproducible reports for state management and CMS. +- Build a defensible, documented evidence trail from raw visit data to investigation outcome. + +**Pain points:** +- Current tooling requires SQL or custom scripts to extract fraud patterns; no built-in anomaly detection. +- Report generation is manual, error-prone, and time-consuming. +- No confidence that the data underlying a report has not been modified after export. + +**Key workflows:** +- Review open `FraudEvent` queue; triage OPEN events to TRIAGED or LINKED_TO_CASE. +- Generate Fraud Summary and Visit Verification reports on a scheduled or ad-hoc basis. +- Export reports in PDF (management briefings) and XLSX (workpaper-compatible). + +**Success metrics:** Time to triage a fraud alert; report generation SLA; audit query response time. + +--- + +### Persona 5 — State Auditor + +**Role:** Independent state auditor or OIG financial auditor reviewing Medicaid program expenditures, provider billing accuracy, and EVV compliance. + +**Goals:** +- Independently verify that visit claims in a sample are supported by legitimate, compliant verification evidence. +- Export a complete, unmodified audit trail for a date range or provider scope. +- Ensure the evidence chain has not been retroactively altered. + +**Pain points:** +- Today's audit requires pulling data from multiple disconnected systems (EVV vendor, billing system, provider records) and manually reconciling. +- No cryptographic assurance that audit log data is genuine and unmodified. + +**Key workflows:** +- Query the Audit & Compliance Center by date range, actor, or resource type. +- Export audit logs and visit verification packages. +- Verify SHA-256 hash chain integrity programmatically. + +**Success metrics:** Audit sample completion time; findings supported by system evidence; zero hash-chain validation failures. + +--- + +### Persona 6 — Platform Administrator (Org Admin) + +**Role:** Technical or operational administrator within a state agency, MCO, or oversight organization, responsible for configuring and maintaining the RayVerify tenant. + +**Goals:** +- Manage user accounts, roles, and permissions without requiring vendor intervention. +- Configure tenant-level settings (geofence thresholds, fraud score alert thresholds, notification channels). +- Ensure the platform remains available and correctly configured at all times. + +**Pain points:** +- Onboarding new investigators requires accurate RBAC configuration; errors create audit gaps. +- Threshold tuning (e.g., adjusting `radiusMeters` for rural vs. urban service areas) must not require code deployments. + +**Key workflows:** +- Create and manage `User` records; assign system roles (INVESTIGATOR, AUDITOR, COMPLIANCE_OFFICER, ORG_ADMIN, OIG_AGENT). +- Configure `Organization.settings` (feature flags, thresholds, branding). +- Review notification channel configurations (IN_APP, EMAIL, SMS, WEBHOOK). + +**Success metrics:** Time to onboard a new user; zero unauthorized access incidents; configuration change audit coverage. + +--- + +### Persona 7 — Home-Care Oversight Agency Inspector + +**Role:** State licensing or inspection official responsible for monitoring the compliance of home-care agencies with Medicaid participation requirements. + +**Goals:** +- Quickly assess whether a specific provider or caregiver has a pattern of verification failures, GPS anomalies, or identity mismatches. +- Generate a provider-level compliance summary for a licensing renewal or complaint investigation. + +**Pain points:** +- Current provider monitoring relies on complaint-driven processes; no proactive risk signal. +- Visiting a provider for an audit requires manual record assembly; no consolidated view. + +**Key workflows:** +- Look up a provider by NPI or `medicaidId`; review `ProviderRiskProfile` and historical `FraudScore` trend. +- Generate a Provider Risk report for a specific provider; export as PDF for the licensing file. + +**Success metrics:** Time to produce a provider compliance summary; number of proactive referrals generated from risk signals (vs. complaint-driven). + +--- + +## 4. Module Requirements + +--- + +### 4.1 Module 1 — Identity Verification Engine + +**Purpose:** Cryptographically link each visit clock-in to the biometrically enrolled caregiver — detecting substitution fraud before the visit is recorded as complete. + +#### 4.1.1 Identity Workflow + +```mermaid +sequenceDiagram + participant App as Field App / Browser + participant API as Identity Engine (NestJS) + participant Bio as Biometric Processor + participant DB as PostgreSQL (RLS) + participant S3 as S3 (KMS-encrypted) + + App->>API: POST /auth/login (credentials + MFA) + API->>DB: Verify user, check session, emit AuditLog(LOGIN) + API-->>App: JWT access token + + App->>API: POST /visits/{visitId}/identity/verify (selfie image) + API->>Bio: liveness_check(probe_image) + Bio-->>API: livenessScore (0.0–1.0) + API->>S3: PUT encrypted probe image → probeS3Key + API->>DB: SELECT active biometric_enrollment WHERE caregiver_id = ? + API->>Bio: face_compare(probe, reference_template) + Bio-->>API: confidenceScore (0.0–1.0) + API->>DB: INSERT identity_verifications (result, confidenceScore, livenessScore, probeS3Key, reasons) + API->>DB: INSERT audit_logs (action=VERIFY, resourceType='identity_verification') + API-->>App: { result: PASS|REVIEW|FAIL, confidenceScore, livenessScore } +``` + +#### 4.1.2 User Stories + +- **US-IDV-01:** As a caregiver, I want to capture a selfie at visit clock-in so that my identity is verified without requiring a password or PIN. +- **US-IDV-02:** As a Program Integrity analyst, I want every identity verification attempt to be stored as an immutable record so that I can reconstruct the evidence if a claim is disputed. +- **US-IDV-03:** As an OIG investigator, I want to see the confidence score and liveness score for any identity verification event so that I can assess the strength of the evidence in a case. +- **US-IDV-04:** As a platform administrator, I want to enroll a caregiver's biometric reference image at onboarding so that the system has a trusted baseline for comparison. +- **US-IDV-05:** As a compliance officer, I want identity verification failures to automatically generate fraud events so that no failure is silently discarded. + +#### 4.1.3 Functional Requirements + +| ID | Requirement | +|---|---| +| **FR-IDV-01** | The system SHALL capture a probe selfie image from the caregiver's device at each visit clock-in and clock-out event. | +| **FR-IDV-02** | The system SHALL perform active liveness detection on the probe image and compute a `livenessScore` between 0.0 and 1.0. A `livenessScore` below the configured threshold (default: 0.70) SHALL yield a result of REVIEW or FAIL. | +| **FR-IDV-03** | The system SHALL compare the probe image against the caregiver's active `BiometricEnrollment` reference template and compute a `confidenceScore` between 0.0 and 1.0. | +| **FR-IDV-04** | The system SHALL produce a `VerificationResult` of PASS, REVIEW, or FAIL based on configurable thresholds for `confidenceScore` and `livenessScore`. Default thresholds: PASS requires both scores ≥ 0.85; REVIEW: one score between 0.70–0.85; FAIL: any score below 0.70. | +| **FR-IDV-05** | The system SHALL write an immutable `identity_verifications` record for every verification attempt, including `method`, `result`, `confidenceScore`, `livenessScore`, `probeS3Key`, `matcher`, and `reasons`. | +| **FR-IDV-06** | The system SHALL store the probe image in S3 with AES-256-GCM encryption (envelope-encrypted via AWS KMS). The raw image SHALL NOT be stored in the database. | +| **FR-IDV-07** | The system SHALL write an `AuditLog` entry (action = VERIFY, resourceType = 'identity_verification') for every verification attempt. | +| **FR-IDV-08** | A verification result of REVIEW or FAIL SHALL automatically generate a `FraudEvent` of type `IDENTITY_MISMATCH` or `LIVENESS_FAILURE` respectively. | +| **FR-IDV-09** | The system SHALL support caregiver biometric enrollment: capturing and storing a reference image (`referenceS3Key`) and template pointer (`templateRef`) in `biometric_enrollments`. Only one enrollment per caregiver per `IdentityMethod` SHALL be active at a time. | +| **FR-IDV-10** | The system SHALL support future identity methods (`FINGERPRINT`, `NFC_CARD`, `GOV_CREDENTIAL`) as defined in the `IdentityMethod` enum, without requiring changes to the verification chain interface. | +| **FR-IDV-11** | The `reasons` field in `identity_verifications` SHALL contain a structured JSON array of explainability factors (e.g., `["LOW_LIVENESS","FACE_ANGLE_DEVIATION"]`). | +| **FR-IDV-12** | The system SHALL enforce that `identity_verifications` rows cannot be updated or deleted (DB-layer trigger `trg_idv_immutable`). | + +#### 4.1.4 Acceptance Criteria + +- AC-IDV-01: Given a valid probe selfie with liveness score ≥ 0.85 and confidence score ≥ 0.85, the system returns PASS within 3 seconds. +- AC-IDV-02: Given a photo-replay attack (printed photo), the liveness check returns a score < 0.70, and the result is FAIL with a `LIVENESS_FAILURE` fraud event generated. +- AC-IDV-03: Given a confidence score of 0.55 against the enrolled template, the result is FAIL and a `FraudEvent` of type `IDENTITY_MISMATCH` is created and linked to the visit. +- AC-IDV-04: An `identity_verifications` row cannot be deleted via any application role; a direct DELETE attempt raises `integrity_constraint_violation`. +- AC-IDV-05: The `probeS3Key` object is retrievable only by the application's KMS-authorized role; direct S3 access without KMS credentials is denied. + +#### 4.1.5 Key Screens / Endpoints + +| Surface | Description | +|---|---| +| `POST /visits/{visitId}/identity/verify` | Submit selfie for identity verification; returns result + scores. | +| `POST /caregivers/{caregiverId}/biometric/enroll` | Enroll a caregiver's reference image. | +| `GET /visits/{visitId}/identity/verifications` | List all identity verification attempts for a visit. | +| `GET /caregivers/{caregiverId}/identity/history` | Identity verification history with pass/fail trend for a caregiver. | +| Dashboard: Caregiver Identity Panel | Displays per-caregiver verification pass rate, recent FAIL events, trend sparkline. | + +--- + +### 4.2 Module 2 — Visit Verification Engine + +**Purpose:** Orchestrate the complete, ordered verification chain for every visit event and produce a sealed, tamper-evident verification package. + +#### 4.2.1 Visit Verification Chain + +```mermaid +flowchart TD + A([Visit Clock-In]) --> B[Identity Verification\nFR-IDV-01 to FR-IDV-12] + B --> C{Identity Result?} + C -->|PASS| D[GPS Verification\nFR-GPS-01 to FR-GPS-08] + C -->|REVIEW| D + C -->|FAIL| HOLD([Flag Visit / Hold]) + D --> E{Geofence Result?} + E -->|PASS| F[Device Verification\nFR-DEV-01 to FR-DEV-07] + E -->|REVIEW| F + E -->|FAIL| HOLD + F --> G{Device Trust?} + G -->|TRUSTED| H[Patient Confirmation\nFR-PAT-01 to FR-PAT-03] + G -->|SUSPICIOUS| H + G -->|BLOCKED| HOLD + H --> I[Fraud Scoring\nFR-FIE-01 to FR-FIE-13] + I --> J{Risk Score?} + J -->|0–60| APPROVED([Approved — Eligible for Billing]) + J -->|61–80| REVIEW2([REVIEW — Human Approval Required]) + J -->|81–100| HOLD + APPROVED --> K[Seal VisitVerification\nevidenceHash = SHA-256] + REVIEW2 --> K + HOLD --> K +``` + +#### 4.2.2 GPS Verification Rules + +| GPS Outcome | Condition | `VerificationResult` | `FraudEventType` generated | +|---|---|---|---| +| **PASS** | `distanceMeters` ≤ `radiusMeters` (default 150 m) | `PASS` | None | +| **REVIEW / FLAG** | `distanceMeters` > `radiusMeters` AND ≤ 3× `radiusMeters` | `REVIEW` | `GPS_ANOMALY` | +| **FAIL** | `distanceMeters` > 3× `radiusMeters` (major discrepancy) | `FAIL` | `GEOFENCE_BREACH` | +| **Accuracy too low** | `accuracyMeters` > configured max (default 100 m) | `REVIEW` | `GPS_ANOMALY` | +| **No GPS signal** | Coordinates unavailable | `FAIL` | `GPS_ANOMALY` | + +GPS verification events are captured at CLOCK_IN, CLOCK_OUT, and optionally MID_VISIT (`eventType` field in `gps_verifications`). + +#### 4.2.3 Device Trust Signals + +The Device Verification Engine evaluates the following signals from the `devices` table and the `signals` JSONB in `device_verifications`: + +| Signal | `DeviceTrustLevel` impact | `FraudEventType` | +|---|---|---| +| `isEmulator = true` | SUSPICIOUS → BLOCKED | `DEVICE_TAMPERING` | +| `isRooted = true` (Android) | SUSPICIOUS | `DEVICE_TAMPERING` | +| `isJailbroken = true` (iOS) | SUSPICIOUS | `DEVICE_TAMPERING` | +| Device switch mid-visit (different `deviceId` at clock-out vs clock-in) | SUSPICIOUS | `SHARED_DEVICE` | +| `fingerprintHash` matches a known-bad device | BLOCKED | `DEVICE_TAMPERING` | +| Multiple caregivers using same `deviceId` within 24 hours | SUSPICIOUS | `SHARED_DEVICE` | +| IP geolocation inconsistent with GPS | SUSPICIOUS | `GPS_ANOMALY` | + +#### 4.2.4 User Stories + +- **US-VVE-01:** As a Program Integrity analyst, I want every visit to produce a single verification package so that I can assess the overall legitimacy of a claim in one view. +- **US-VVE-02:** As a compliance officer, I want visit verification packages to be sealed with a SHA-256 hash so that I can prove to an auditor that the evidence has not been modified since the visit. +- **US-VVE-03:** As an OIG investigator, I want visits that fail any verification step to be automatically flagged and held from billing so that I can review them before payment is made. +- **US-VVE-04:** As a caregiver, I want to clock out using the same app with an automatic GPS check so that my legitimate visits are approved without unnecessary friction. +- **US-VVE-05:** As a state auditor, I want to query verification outcomes by date range, provider, or result type so that I can scope an audit sample efficiently. + +#### 4.2.5 Functional Requirements + +| ID | Requirement | +|---|---| +| **FR-VVE-01** | The system SHALL execute the verification chain in order: identity → GPS → device → patient confirmation → fraud scoring for every visit clock-in and clock-out event. | +| **FR-VVE-02** | The system SHALL capture GPS coordinates at CLOCK_IN and CLOCK_OUT, compute `distanceMeters` from the `service_authorization` anchor, and apply the geofence rules in §4.2.2. | +| **FR-VVE-03** | The system SHALL write an immutable `gps_verifications` record for every GPS capture event, including `latitude`, `longitude`, `accuracyMeters`, `distanceMeters`, `result`, `capturedAt`, and `eventType`. | +| **FR-VVE-04** | The system SHALL evaluate device trust signals per §4.2.3 and write an immutable `device_verifications` record per visit event. | +| **FR-VVE-05** | The system SHALL seal the complete verification chain into a `visit_verifications` record containing `result`, `riskScore`, `riskLevel`, `chain` (per-step outcomes in JSON), and `evidenceHash` (SHA-256 over the canonical evidence package). | +| **FR-VVE-06** | The `evidenceHash` SHALL be computed over a canonical representation of the linked `identity_verifications`, `gps_verifications`, `device_verifications`, and patient confirmation data for the visit. | +| **FR-VVE-07** | A visit with a FAIL result in any verification step SHALL be set to `VisitStatus.FLAGGED` and excluded from the billing-eligible queue until manually approved. | +| **FR-VVE-08** | A visit with a REVIEW result and `riskScore` ≥ 61 SHALL be routed to the investigator review queue. | +| **FR-VVE-09** | The system SHALL denormalize `clockInLat` and `clockInLng` to the `visits` table for fast geospatial querying. | +| **FR-VVE-10** | The system SHALL support the full `VisitStatus` lifecycle: SCHEDULED → IN_PROGRESS → COMPLETED → FLAGGED / APPROVED / REJECTED / CANCELLED. | +| **FR-VVE-11** | GPS coordinates SHALL be stored as `Decimal(9,6)` (~11 cm precision). | +| **FR-VVE-12** | The `gps_verifications`, `identity_verifications`, and `device_verifications` tables SHALL be append-only (DB-layer immutability triggers). | +| **FR-VVE-13** | The `service_authorization.radiusMeters` SHALL be configurable per authorization (default: 150). The system SHALL validate that `radiusMeters > 0` (DB constraint `chk_radius_positive`). | + +#### 4.2.6 Acceptance Criteria + +- AC-VVE-01: A visit clock-in with all steps passing returns APPROVED status and a `visit_verifications` record with result=PASS within 5 seconds. +- AC-VVE-02: A visit clock-in where GPS distance = 600 m (outside 3× the 150 m default radius) generates a `GEOFENCE_BREACH` fraud event and sets visit status to FLAGGED. +- AC-VVE-03: Altering any byte of the `chain` JSON in `visit_verifications` causes the `evidenceHash` to no longer match the recomputed hash. +- AC-VVE-04: Attempting to UPDATE a `gps_verifications` row raises `integrity_constraint_violation`. +- AC-VVE-05: A mid-visit `MID_VISIT` GPS check that returns REVIEW adds a `GPS_ANOMALY` fraud event but does not immediately change visit status. + +#### 4.2.7 Key Screens / Endpoints + +| Surface | Description | +|---|---| +| `POST /visits/{visitId}/clock-in` | Trigger the full verification chain at clock-in. | +| `POST /visits/{visitId}/clock-out` | Trigger clock-out verification; finalizes `durationMinutes`. | +| `GET /visits/{visitId}/verification` | Retrieve the sealed `VisitVerification` package including all chain steps. | +| `GET /visits?status=FLAGGED&organizationId=...` | Query flagged visits pending investigator review. | +| `PATCH /visits/{visitId}/approve` | Investigator approves a FLAGGED visit; writes `approvedById` and `approvedAt`. | +| Dashboard: Visit Verification Queue | Table view of FLAGGED visits sorted by `riskScore` descending. | +| Dashboard: Visit Detail | Full verification chain display with per-step results, GPS map, identity photo (blurred by default), device signals. | + +--- + +### 4.3 Module 3 — Fraud Intelligence Engine + +**Purpose:** Automatically detect fraud, waste, and abuse signals across all visits and billing events in real time, producing a scored, explainable, and versioned `FraudEvent` record for each signal. + +#### 4.3.1 Fraud Detector Catalog + +| `FraudEventType` | Description | Example Signal | +|---|---|---| +| `IMPOSSIBLE_TRAVEL` | Caregiver clocked in at two locations farther apart than could be covered in the elapsed time. | 80-mile gap in 30 minutes. | +| `DUPLICATE_VISIT` | Two visits for the same caregiver + patient + service code overlap in time. | 10 AM visit A, 10:30 AM visit B, same day same caregiver. | +| `SHARED_DEVICE` | Same `deviceId` used by multiple caregivers, or device switched between clock-in and clock-out. | Device X used by Caregiver A at 9 AM and Caregiver B at 9:45 AM. | +| `GPS_ANOMALY` | GPS coordinates inconsistent with claimed service address; low accuracy; IP/GPS mismatch. | Clock-in 2 miles from authorized address. | +| `IDENTITY_MISMATCH` | Face comparison confidence below threshold. | `confidenceScore` = 0.61 (below 0.70 FAIL threshold). | +| `UNUSUAL_BILLING` | Billed units or amount inconsistent with visit duration or historical patterns. | 12-hour billed for a 2-hour authorized visit. | +| `ABNORMAL_DURATION` | Visit duration is statistical outlier vs. authorization or caregiver history. | Visit duration 300% above the caregiver's rolling average. | +| `EXCESSIVE_OVERTIME` | Caregiver exceeds authorized unit cap within the authorization period. | `billedUnits` exceeds `ServiceAuthorization.authorizedUnits`. | +| `SERVICE_OVERLAP` | Same patient billed for two overlapping services at the same time from different providers. | Patient billed for personal care and physical therapy 10–11 AM same day. | +| `CROSS_PROVIDER_RISK` | A caregiver or patient appears in multiple providers with fraud histories. | Caregiver moved from HIGH-risk Provider A to Provider B; carries risk signal. | +| `LIVENESS_FAILURE` | Active liveness check failed; possible photo or video replay attack. | `livenessScore` = 0.42. | +| `DEVICE_TAMPERING` | Device is an emulator, rooted, or jailbroken. | `isEmulator = true` on Android device. | +| `GEOFENCE_BREACH` | Clock-in GPS outside the authorized geofence by a major margin (> 3× radius). | Clock-in 500 m from a 150 m geofence. | + +#### 4.3.2 User Stories + +- **US-FIE-01:** As a Program Integrity analyst, I want fraud events to be generated automatically on every visit so that I do not need to manually scan for anomalies. +- **US-FIE-02:** As an OIG investigator, I want each fraud event to include a human-readable explanation and structured evidence so that I can assess it without running SQL queries. +- **US-FIE-03:** As a compliance officer, I want fraud detector versions to be recorded so that I can audit whether a rule change affected event generation. +- **US-FIE-04:** As a state auditor, I want fraud events to be immutable so that I can prove to CMS that the detection record has not been modified after the fact. +- **US-FIE-05:** As an analyst, I want to triage open fraud events (OPEN → TRIAGED → LINKED_TO_CASE / DISMISSED) so that the alert queue reflects current investigation status. + +#### 4.3.3 Functional Requirements + +| ID | Requirement | +|---|---| +| **FR-FIE-01** | The system SHALL evaluate all 13 fraud detectors in the catalog (§4.3.1) against every visit event in real time. | +| **FR-FIE-02** | Each detector SHALL emit a `FraudEvent` record containing `type`, `status` (initial: OPEN), `severity` (0–100), `riskLevel`, `explanation` (human-readable text), `evidence` (structured JSON), `detector` (identifier), and `detectorVersion`. | +| **FR-FIE-03** | `fraud_events` rows SHALL be append-only (DB-layer trigger `trg_fe_immutable`); no UPDATE or DELETE is permitted by any application role. | +| **FR-FIE-04** | The system SHALL compute a composite `FraudScore` (0–100) per visit and per provider by aggregating contributing detector severities into the `fraud_scores` table. | +| **FR-FIE-05** | A composite `riskScore` SHALL be reflected on the `visits` table (`risk_score`, `risk_level`) as a denormalized snapshot for fast querying. | +| **FR-FIE-06** | The `FraudEventStatus` lifecycle SHALL support: OPEN → TRIAGED → LINKED_TO_CASE / DISMISSED → CONFIRMED. | +| **FR-FIE-07** | The system SHALL support linking a `FraudEvent` to a `FraudCase` by setting `caseId`, advancing status to LINKED_TO_CASE. | +| **FR-FIE-08** | `IMPOSSIBLE_TRAVEL` detection SHALL compute straight-line distance between consecutive clock-in events for the same caregiver and compare to the elapsed time, using a configurable maximum travel speed (default: 65 mph / 105 km/h). | +| **FR-FIE-09** | `DUPLICATE_VISIT` detection SHALL query the `visits` table for overlapping `scheduledStart`/`scheduledEnd` intervals for the same `caregiverId` on the same day. | +| **FR-FIE-10** | `SERVICE_OVERLAP` detection SHALL check for concurrent `visits` for the same `patientId` from different `providerId` values. | +| **FR-FIE-11** | `EXCESSIVE_OVERTIME` detection SHALL compare `visit.billedUnits` against `service_authorization.authorizedUnits` within the authorization period. | +| **FR-FIE-12** | The `evidence` JSONB field SHALL carry sufficient structured data (visit IDs, GPS coordinates, timestamps, delta values) for an investigator to reproduce the detection finding without access to the underlying raw data. | +| **FR-FIE-13** | Fraud events SHALL be indexed by `(organization_id, type, status)` and `(organization_id, detected_at)` for efficient querying of open alert queues. | + +#### 4.3.4 Acceptance Criteria + +- AC-FIE-01: A caregiver with consecutive clock-ins 100 miles apart in 45 minutes generates an `IMPOSSIBLE_TRAVEL` fraud event with severity ≥ 80 and riskLevel = CRITICAL. +- AC-FIE-02: A `fraud_events` row cannot be updated or deleted via any application API call; the attempt returns a 403 or raises a DB constraint violation. +- AC-FIE-03: A fraud event's `evidence` JSON contains at minimum: visit IDs, timestamps, and the computed delta value (distance, time gap, etc.) that triggered the detection. +- AC-FIE-04: A fraud event with `status=OPEN` can be advanced to `TRIAGED` by a user with the `fraud_event:triage` permission; the change is logged in `audit_logs`. +- AC-FIE-05: `FraudScore` records for a provider are returned ordered by `computed_at DESC`; the most recent score matches the `provider_risk_profiles.current_score`. + +--- + +### 4.4 Module 4 — Investigator Dashboard + +**Purpose:** Provide OIG investigators and Program Integrity staff with a unified, real-time workspace to triage fraud alerts, manage cases, review evidence, and track investigation outcomes. + +#### 4.4.1 User Stories + +- **US-INV-01:** As an OIG investigator, I want a prioritized queue of open fraud alerts so that I focus on the highest-severity events first. +- **US-INV-02:** As an investigator, I want to create a fraud case, assign it a priority and risk level, and link related fraud events so that I can manage my investigation in one place. +- **US-INV-03:** As an investigator, I want to view a provider's full geographic heat map of anomalous visits so that I can identify geographic patterns in a fraud scheme. +- **US-INV-04:** As a case manager, I want to write internal case notes that are not included in beneficiary disclosures so that my investigative thinking is preserved without creating disclosure risk. +- **US-INV-05:** As an investigator, I want to export a case evidence package (PDF + XLSX) that includes all linked visits, GPS records, identity verification images, and the audit trail so that I have a complete referral package. +- **US-INV-06:** As a supervisor, I want to see my team's open case load, case statuses, and average time-to-resolution so that I can manage investigator capacity. + +#### 4.4.2 Functional Requirements + +| ID | Requirement | +|---|---| +| **FR-INV-01** | The dashboard SHALL display a prioritized fraud alert queue: all `FraudEvent` records with `status=OPEN` or `TRIAGED`, sorted by `severity` descending, filterable by `type`, `riskLevel`, and date range. | +| **FR-INV-02** | The system SHALL support creating a `FraudCase` with `caseNumber` (auto-generated in format `RV-YYYY-NNNNNN`), `title`, `status`, `priority`, `riskLevel`, `providerId`, `assigneeId`, `exposureCents`, and `summary`. | +| **FR-INV-03** | The system SHALL support advancing `CaseStatus` through: OPEN → IN_REVIEW → ESCALATED → PENDING_PAYMENT_HOLD → SUBSTANTIATED / UNSUBSTANTIATED → CLOSED. Each status transition SHALL be logged in `audit_logs` (action = CASE_ACTION). | +| **FR-INV-04** | The system SHALL allow linking `FraudEvent` records to a `FraudCase` (populating `caseId`); linked events SHALL display in the case evidence panel. | +| **FR-INV-05** | The system SHALL allow attaching `CaseEvidence` items to a case: visit records, GPS traces, identity verification images, and uploaded documents. Each `CaseEvidence` record SHALL carry a `contentHash` (SHA-256) for chain-of-custody integrity. | +| **FR-INV-06** | The system SHALL support `CaseNote` creation with `isInternal` flag. Internal notes (`isInternal=true`) SHALL NOT be included in beneficiary disclosure exports or any report visible to external parties. | +| **FR-INV-07** | The dashboard SHALL display a geographic heat map of visit locations, color-coded by `riskLevel`, filterable by provider and date range. | +| **FR-INV-08** | The dashboard SHALL display a provider fraud timeline: chronological sequence of fraud events and case actions for a selected provider. | +| **FR-INV-09** | Users SHALL only see cases and events within their `organizationId` (enforced by RLS + RBAC). | +| **FR-INV-10** | The system SHALL support assignment of cases to users with the INVESTIGATOR or OIG_AGENT role. The assigned investigator SHALL receive an IN_APP and EMAIL notification. | +| **FR-INV-11** | Case export SHALL produce a PDF with: case header, provider details, evidence index, linked visit verifications, GPS map screenshots, identity verification confidence scores, case notes (non-internal only), and the hash-verified audit trail section. | + +#### 4.4.3 Acceptance Criteria + +- AC-INV-01: An investigator sees only cases and events belonging to their organization; cross-tenant data is never returned. +- AC-INV-02: Creating a fraud case with `caseNumber` omitted auto-generates a unique number in the format `RV-2026-NNNNNN` and persists correctly. +- AC-INV-03: Advancing a case to PENDING_PAYMENT_HOLD writes an `AuditLog` entry with action=CASE_ACTION, resourceType='fraud_case', and the full actor context within 1 second. +- AC-INV-04: A case export PDF is generated within 60 seconds for a case with up to 100 linked fraud events. +- AC-INV-05: An internal case note with `isInternal=true` does not appear in the exported PDF evidence package. + +--- + +### 4.5 Module 5 — Provider Risk Scoring + +**Purpose:** Maintain a continuously updated, explainable risk profile for every Medicaid provider, enabling state agencies and MCOs to rank and prioritize their provider network for audit and enforcement. + +#### 4.5.1 User Stories + +- **US-PRS-01:** As a state Medicaid director, I want to see my entire provider network ranked by risk score so that audit resources are directed to the highest-risk providers first. +- **US-PRS-02:** As an MCO compliance officer, I want to see the trend in a provider's risk score over the past 90 days so that I can identify providers that are deteriorating vs. improving. +- **US-PRS-03:** As a Program Integrity analyst, I want to understand which specific factors are driving a provider's risk score so that I can frame the investigation correctly. + +#### 4.5.2 Functional Requirements + +| ID | Requirement | +|---|---| +| **FR-PRS-01** | The system SHALL maintain one `ProviderRiskProfile` record per provider per organization, with `currentScore` (0–100) and `riskLevel`. | +| **FR-PRS-02** | The `ProviderRiskProfile` SHALL aggregate the following contributing counters: `verificationFailures`, `gpsAnomalies`, `billingAnomalies`, `identityIssues`, `openCases`, `substantiatedCases`. | +| **FR-PRS-03** | The system SHALL recompute a provider's `currentScore` on every new `FraudEvent` or `FraudCase` status change attributable to that provider. | +| **FR-PRS-04** | Historical score points SHALL be appended to the `trend` JSON array as `{ "t": "", "score": }` entries, enabling sparkline visualization. | +| **FR-PRS-05** | The system SHALL write a `FraudScore` record (subjectType=PROVIDER) for each scoring event, with `factors` containing per-feature SHAP-style contribution values. | +| **FR-PRS-06** | Risk levels SHALL map to score ranges: LOW (0–30), MODERATE (31–60), HIGH (61–80), CRITICAL (81–100). | +| **FR-PRS-07** | The system SHALL provide a provider risk leaderboard API endpoint returning providers sorted by `currentScore` descending, filterable by `riskLevel` and date range. | +| **FR-PRS-08** | Provider risk profiles SHALL be scoped by `organizationId` and subject to RLS. | + +#### 4.5.3 Acceptance Criteria + +- AC-PRS-01: After a `SUBSTANTIATED` case is closed for a provider, the provider's `currentScore` increases and the updated score appears in the `trend` array within 30 seconds. +- AC-PRS-02: The provider risk leaderboard returns providers sorted by `currentScore` descending; the first result has the highest score. +- AC-PRS-03: A provider with no fraud events, no open cases, and no verification failures has `currentScore` = 0 and `riskLevel` = LOW. + +--- + +### 4.6 Module 6 — Audit & Compliance Center + +**Purpose:** Provide a comprehensive, tamper-evident, exportable audit trail that satisfies HIPAA audit controls, HITECH breach-notification evidentiary requirements, SOC 2 Type II audit queries, and CMS EVV compliance reviews. + +#### 4.6.1 User Stories + +- **US-AUD-01:** As a state auditor, I want to search the audit log by actor, action type, and date range so that I can reconstruct who accessed or modified a specific record. +- **US-AUD-02:** As a compliance officer, I want to export the audit log for a date range as XLSX so that I can include it in audit workpapers. +- **US-AUD-03:** As an OIG investigator, I want to verify the SHA-256 hash chain on the audit log so that I can demonstrate to a court that the audit record has not been tampered with since the events occurred. +- **US-AUD-04:** As a HIPAA Security Officer, I want every PHI access to be logged with the actor's identity, IP address, and timestamp so that I can respond to breach notification requirements. + +#### 4.6.2 Functional Requirements + +| ID | Requirement | +|---|---| +| **FR-AUD-01** | The system SHALL write an `AuditLog` entry for every action in the `AuditAction` enum: CREATE, READ (PHI), UPDATE, DELETE, LOGIN, LOGOUT, EXPORT, VERIFY, SCORE, CASE_ACTION, CONFIG_CHANGE. | +| **FR-AUD-02** | Each `AuditLog` entry SHALL include: `actorId`, `action`, `resourceType`, `resourceId`, `ipAddress`, `userAgent`, `metadata` (PHI-scrubbed diff or context), `prevHash`, `hash`, and `createdAt`. | +| **FR-AUD-03** | The `hash` field SHALL be computed on INSERT by the DB trigger `trg_audit_hash` as `SHA256(prevHash || organizationId || actorId || action || resourceType || resourceId || metadata || createdAt)`. | +| **FR-AUD-04** | The system SHALL enforce that `audit_logs` rows cannot be updated or deleted (DB trigger `trg_audit_immutable`). | +| **FR-AUD-05** | `audit_logs` SHALL be partitioned monthly by `created_at` for query performance, with rolling partition creation. | +| **FR-AUD-06** | The system SHALL provide a hash-chain verification API endpoint that recomputes and validates the per-tenant chain for a date range. | +| **FR-AUD-07** | The Audit & Compliance Center SHALL support search by `actorId`, `action`, `resourceType`, `resourceId`, and date range, returning paginated results. | +| **FR-AUD-08** | The system SHALL support export of audit log records in XLSX and JSON formats. Every export event SHALL itself generate an `AuditLog` entry (action=EXPORT). | +| **FR-AUD-09** | `metadata` SHALL be PHI-scrubbed before storage: Medicaid member IDs, names, dates of birth, and raw biometric references SHALL NOT appear in audit log metadata fields. | + +#### 4.6.3 Acceptance Criteria + +- AC-AUD-01: Every API call that reads or modifies a `Patient`, `Visit`, `IdentityVerification`, or `FraudCase` record generates an `AuditLog` entry within the same transaction. +- AC-AUD-02: Running the hash-chain verification endpoint over 10,000 sequential audit log entries returns a valid chain with zero broken links. +- AC-AUD-03: Directly inserting a modified row into `audit_logs` (bypassing the trigger) causes hash-chain verification to fail on the next recomputation. +- AC-AUD-04: An XLSX audit log export for a 30-day range completes within 120 seconds and is delivered to S3; the export event appears in the audit log itself. + +--- + +### 4.7 Module 7 — Reporting & Analytics + +**Purpose:** Deliver the six standard report types required by state procurement, compliance, and executive stakeholders, in formats suitable for management briefings, CMS submissions, and audit workpapers. + +#### 4.7.1 Report Catalog + +| `ReportType` | Primary Audience | Description | +|---|---|---| +| `FRAUD_SUMMARY` | Program Integrity Director, MCO Compliance | Volume and severity of fraud events by type, date range, and provider; open vs. closed ratios; top-10 fraud signal types by frequency. | +| `PROVIDER_RISK` | State Agency, MCO | Full provider risk leaderboard with scores, contributing factors, trend charts, and case counts; filterable by risk level. | +| `VISIT_VERIFICATION` | Compliance Officer, State Auditor | Visit-level verification outcomes (PASS/REVIEW/FAIL) by date, provider, caregiver, and service code; GPS compliance rate; identity failure rate. | +| `INVESTIGATION` | OIG Investigator, MFCU | Open and closed case summary; substantiation rate; estimated exposure by provider; average time-to-close; investigator workload distribution. | +| `STATE_COMPLIANCE` | State Medicaid Agency, CMS | EVV compliance rate; verification coverage; flagged and held claims; pre-payment recovery summary; suitable for CMS APD/IAPD submissions. | +| `EXECUTIVE_DASHBOARD` | Medicaid Director, C-Suite | High-level KPIs: total visits verified, fraud alerts generated, pre-payment holds, estimated dollars protected, 90-day trend charts. | + +#### 4.7.2 User Stories + +- **US-RPT-01:** As a compliance officer, I want to schedule a State Compliance report to be generated monthly and delivered to a configured email address so that I do not need to log in to produce routine filings. +- **US-RPT-02:** As a state auditor, I want to export a Visit Verification report as XLSX so that I can import it into my audit workpaper software. +- **US-RPT-03:** As a Program Integrity director, I want an Executive Dashboard PDF delivered weekly so that I have a current snapshot for management meetings. +- **US-RPT-04:** As a compliance officer, I want every report download to be logged in the audit trail so that I can demonstrate to CMS that reporting data was accessed only by authorized users. + +#### 4.7.3 Functional Requirements + +| ID | Requirement | +|---|---| +| **FR-RPT-01** | The system SHALL support all six `ReportType` values and all four `ReportFormat` values: PDF, XLSX, CSV, JSON. | +| **FR-RPT-02** | Report generation SHALL be asynchronous: the request creates a `Report` record with `status=QUEUED`; a background worker processes it through GENERATING → READY / FAILED. | +| **FR-RPT-03** | Completed reports SHALL be stored in S3 (encrypted), with a time-limited pre-signed URL returned to the requesting user. The `s3Key` and `expiresAt` SHALL be set on the `Report` record. | +| **FR-RPT-04** | Every report download SHALL generate an `AuditLog` entry (action=EXPORT). | +| **FR-RPT-05** | The system SHALL support scheduled report delivery: a cron-triggered job SHALL generate the configured report and deliver it via the configured `NotificationChannel` (EMAIL, WEBHOOK). | +| **FR-RPT-06** | Report `parameters` (date range, filters, scope) SHALL be stored as JSONB on the `Report` record to enable report reproduction and audit of what was included. | +| **FR-RPT-07** | FRAUD_SUMMARY reports SHALL include: event volume by type, severity distribution, open/closed/dismissed counts, top-10 providers by fraud event count, and trend over the requested period. | +| **FR-RPT-08** | PROVIDER_RISK reports SHALL include: full provider list with `currentScore`, `riskLevel`, contributing factor breakdown, and 90-day trend sparkline data. | +| **FR-RPT-09** | STATE_COMPLIANCE reports SHALL be formatted to align with CMS EVV compliance reporting categories and include a section suitable for APD/IAPD submissions. | +| **FR-RPT-10** | Failed report generation (`status=FAILED`) SHALL trigger a notification to the requesting user and an `AuditLog` entry. | + +#### 4.7.4 Acceptance Criteria + +- AC-RPT-01: A FRAUD_SUMMARY PDF report for a 30-day period with up to 50,000 fraud events is generated and available in S3 within 300 seconds of the request. +- AC-RPT-02: Downloading a report via the pre-signed URL creates an `AuditLog` entry with action=EXPORT, resourceType='report', and the requestor's identity within 2 seconds. +- AC-RPT-03: A XLSX Visit Verification report can be opened in Microsoft Excel without data corruption; numeric fields (risk scores, distances) are formatted as numbers, not text. +- AC-RPT-04: An expired report (past `expiresAt`) returns HTTP 410 Gone; the pre-signed URL is no longer valid. + +--- + +### 4.8 Module 8 — Future Hardware Integration Layer + +**Purpose:** Provide a stable SDK abstraction that decouples the upper verification engines from specific capture hardware, enabling hardware upgrades without changes to the verification chain. + +**Status:** Explicitly **out of scope for the current release**. The data model, enums, and API stubs are present; implementation is deferred to a future phase. + +#### 4.8.1 Functional Requirements (Future) + +| ID | Requirement | +|---|---| +| **FR-HW-01** | The Hardware Integration Layer SHALL expose a hardware-agnostic `CaptureInterface` that abstracts: face capture, fingerprint scan, NFC card read, GPS fix, and LTE/network connectivity. | +| **FR-HW-02** | The `IdentityMethod.FINGERPRINT` path SHALL integrate with fingerprint scanner hardware via the `CaptureInterface`, delivering a biometric template to the Identity Verification Engine without modification to FR-IDV-01 through FR-IDV-12. | +| **FR-HW-03** | The `IdentityMethod.NFC_CARD` path SHALL read and validate a government-issued identity credential from an NFC reader, supporting NIST 800-63 IAL2/IAL3 assurance levels. | +| **FR-HW-04** | The `DevicePlatform.HARDWARE_TERMINAL` value SHALL be used for registered, agency-provisioned capture terminals; these devices SHALL receive `DeviceTrustLevel.TRUSTED` by default subject to enrollment verification. | +| **FR-HW-05** | The Hardware Integration Layer SHALL include an SDK for embedding in third-party EVV mobile applications, enabling RayVerify identity and fraud checks to be invoked from existing caregiver apps. | + +--- + +## 5. Non-Functional Requirements + +### 5.1 Performance and SLA Targets + +| Operation | Target | Notes | +|---|---|---| +| Identity verification (liveness + face compare) | ≤ 3 seconds p95 | Excludes biometric processor cold start. | +| Visit clock-in (full verification chain) | ≤ 5 seconds p95 | Identity + GPS + device in parallel where possible. | +| Fraud score computation (single visit) | ≤ 2 seconds p95 | Synchronous inline; async re-scoring ≤ 30 s. | +| Visit verification list query (paginated) | ≤ 500 ms p95 | Indexed on `(organization_id, status)`. | +| Audit log write | ≤ 100 ms overhead | Within same transaction as the triggering action. | +| Report generation (FRAUD_SUMMARY, 30 days) | ≤ 300 seconds | Async; user notified on completion. | +| Executive Dashboard page load | ≤ 2 seconds | Cached aggregates; CDN-served static assets. | +| API endpoint (read) | ≤ 200 ms p95 | Typical GET endpoints; DB-query-bound. | + +### 5.2 Availability and Reliability + +| Requirement | Target | +|---|---| +| Platform availability (production) | 99.9% uptime (≤ 8.7 hours unplanned downtime per year) | +| Visit verification chain | No single point of failure; RDS Multi-AZ, ECS multi-instance | +| Fraud scoring pipeline | Redis-backed queue; survives single-node failure with < 5 min recovery | +| Backup RPO | ≤ 1 hour (RDS automated backups + point-in-time recovery) | +| Recovery RTO | ≤ 4 hours for full environment restoration | +| Disaster recovery | Cross-AZ active-passive; documented runbook in `docs/11-production-deployment.md` | + +### 5.3 Scalability + +- **Tenancy:** Multi-tenant via PostgreSQL RLS (`app.current_org` per transaction); no cross-tenant data leakage at the DB layer. +- **Visits:** `visits` table is range-partitioned monthly by `scheduled_start`; rolling partitions managed by `pg_partman` or CI cron. +- **Audit logs:** `audit_logs` table is range-partitioned monthly by `created_at`. +- **Verification tables:** `identity_verifications` and `gps_verifications` are designed for partition-by-month in high-volume deployments. +- **Horizontal scaling:** NestJS backend deployed on ECS (EKS-ready); stateless workers scaled behind a load balancer. Redis for job queues. +- **Target visit volume:** 10 million visits/month per tenant at initial sizing; architecture supports 10× headroom. + +### 5.4 Security + +All security controls are fully specified in `docs/07-security-architecture.md`. Summary: + +| Control | Implementation | +|---|---| +| Encryption at rest | AES-256-GCM (AWS KMS envelope encryption); all PHI fields encrypted | +| Encryption in transit | TLS 1.3 for all API and inter-service communication | +| Authentication | JWT access tokens + refresh tokens (SHA-256 hash of raw token stored, never raw) | +| MFA | Required for all platform users: TOTP, SMS OTP, or WebAuthn (`MfaMethod` enum) | +| Authorization | RBAC with `Permission` keys in format `resource:action`; enforced at API + DB layer | +| Multi-tenant isolation | PostgreSQL Row-Level Security on all 19 tenant-scoped tables | +| Audit immutability | DB-layer append-only triggers; tamper-evident hash chain | +| PHI protection | Medicaid member IDs encrypted at application layer; blind-index for search | +| Brute-force protection | `failedLogins` counter; `lockedUntil` on `users` table | +| Session management | Refresh token stored as SHA-256 hash; `revokedAt` for forced logout | +| Biometric images | Stored in S3 with KMS encryption; lifecycle policy for deletion post-retention period | +| Compliance frameworks | HIPAA, HITECH, NIST 800-63, SOC 2, CMS EVV (21st Century Cures Act) | + +### 5.5 Accessibility + +- All user-facing dashboard surfaces SHALL conform to **WCAG 2.1 Level AA**. +- All government-facing interfaces SHALL meet **Section 508** of the Rehabilitation Act. +- Keyboard navigation SHALL be fully functional for all investigator dashboard workflows. +- Color-coded risk level indicators (LOW/MODERATE/HIGH/CRITICAL) SHALL include non-color secondary indicators (icons, labels) to meet color-blind accessibility requirements. +- Screen reader compatibility SHALL be validated for primary investigator workflows using NVDA and VoiceOver. + +### 5.6 Auditability + +- Every state-change operation in the system SHALL generate a corresponding `AuditLog` entry within the same database transaction. +- Every PHI read (visiting a Patient record, retrieving a biometric image, exporting visit data) SHALL generate an `AuditLog` entry with action=READ. +- The audit log hash chain SHALL be verifiable by an independent party using the published chain verification algorithm. +- Audit logs SHALL NOT contain raw PHI in the `metadata` field (scrubbing required before write). + +### 5.7 Data Retention + +| Data Category | Retention Period | Disposition | +|---|---|---| +| Visit records (`visits`) | 7 years | Partition archival to cold storage (S3 Glacier) after 2 years | +| Verification records (`*_verifications`) | 7 years | Same as visits; append-only, never deleted | +| Audit logs | 7 years | Partition archival after 2 years; hash chain integrity maintained | +| Biometric probe images (`probeS3Key`) | 90 days (configurable) | S3 lifecycle policy auto-delete; `probeS3Key` set null after expiry | +| Biometric reference images (`referenceS3Key`) | Duration of caregiver enrollment + 1 year | S3 lifecycle policy on `retiredAt` | +| Fraud case records | 10 years | Substantiated cases; HIPAA + state law minimum | +| Session tokens | 90 days or revocation | Refresh token hashes purged on expiry | +| Reports (`s3Key`) | 30 days (configurable via `expiresAt`) | S3 lifecycle policy; pre-signed URLs expire | + +--- + +## 6. Reporting Requirements + +Detailed specifications for each report are in §4.7. The following summarizes format and delivery requirements for procurement review: + +| Report | Formats | Scheduling | Delivery | +|---|---|---|---| +| Fraud Summary | PDF, XLSX | Ad-hoc / scheduled (daily, weekly, monthly) | In-app download, email, webhook | +| Provider Risk | PDF, XLSX, CSV | Ad-hoc / scheduled | In-app download, email, webhook | +| Visit Verification | PDF, XLSX, CSV | Ad-hoc / scheduled | In-app download, email, webhook | +| Investigation | PDF, XLSX | Ad-hoc | In-app download only (PHI-sensitive) | +| State Compliance | PDF, XLSX | Scheduled (monthly) | In-app download, email; formatted for CMS submissions | +| Executive Dashboard | PDF | Scheduled (weekly) | Email, in-app download | + +All reports: +- Are generated asynchronously and stored encrypted in S3. +- Have `expiresAt` set to 30 days by default (configurable per tenant). +- Trigger an `AuditLog` entry (action=EXPORT) on every download. +- Are scoped to the requesting user's `organizationId` (RLS enforced at query layer). +- Include a report generation timestamp, parameter summary (date range, filters applied), and the requesting user's name/role in the report header. + +--- + +## 7. Assumptions, Dependencies, Risks, and Open Questions + +### 7.1 Assumptions + +1. The deploying organization has executed a HIPAA Business Associate Agreement (BAA) with all downstream service providers (AWS, biometric processor, SMS gateway) prior to processing real PHI. +2. Caregiver biometric enrollment is completed during provider onboarding, before any visit verification is performed. +3. The field application (mobile or web) has access to the device's camera (for selfie capture) and GPS (for location verification). Permissions are managed by the field application layer. +4. The `service_authorization.latitude` and `longitude` fields are populated for all active authorizations before visit verification can produce a GPS PASS result. +5. State EVV aggregator or MMIS integration is handled via the API-first interface; RayVerify does not connect directly to state legacy systems without a defined integration contract. +6. All users accessing PHI have completed organization-mandated HIPAA training and accepted the platform's terms of use. + +### 7.2 Dependencies + +| Dependency | Type | Notes | +|---|---|---| +| AWS (RDS, S3, ECS, KMS, CloudFront) | Infrastructure | All cloud services; must be provisioned per `infra/terraform/`. | +| Biometric processing engine | External service / internal model | Face comparison and liveness detection; vendor-agnostic interface via `matcher` field. | +| SMS gateway (MFA) | External service | Required for `MfaMethod.SMS`; must be BAA-covered if PHI in message body. | +| State EVV aggregator / MMIS | Integration | Upstream data source for visit records in certain deployment models. | +| PostgreSQL 15+ | Database | Required for partitioning and RLS features used in `db/schema.sql`. | +| Redis | Cache / queue | Required for fraud scoring pipeline and report generation job queues. | +| Node.js ≥ 20 | Runtime | Backend (NestJS) and frontend (Next.js 15) build and runtime requirement. | + +### 7.3 Risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Biometric processor latency exceeds 3-second SLA | Medium | High (caregiver experience, adoption) | Caching of reference templates; async re-check fallback; SLA in vendor contract. | +| GPS spoofing by sophisticated actors | Medium | High (fraud bypass) | IP/GPS consistency check; device trust signals; liveness as compensating control. | +| State procurement cycle length | High | Medium (revenue timeline) | Target MCOs as faster procurement path; modular deployment allows phased rollout. | +| BAA chain gaps with subprocessors | Low | Critical (HIPAA violation) | Legal review of all third-party BAAs before production PHI processing. | +| Biometric false positive rate (REVIEW flood) | Medium | Medium (investigator overload) | Configurable thresholds; auto-approve low-risk REVIEW cases below score threshold. | +| Hash-chain break due to DB maintenance | Low | High (audit chain integrity) | Disable hash chain trigger during approved maintenance windows; re-anchor after. | +| Multi-state data residency requirements | Medium | Medium (compliance) | AWS region selection per tenant; data residency configuration in `Organization.settings`. | + +### 7.4 Open Questions + +1. **OQ-01:** What is the minimum acceptable `confidenceScore` threshold for states that have varying caregiver populations (e.g., populations with lower biometric recognition accuracy)? Should thresholds be configurable per state tenant? +2. **OQ-02:** Should the patient confirmation step be a passive confirmation (patient does not opt-out) or an active confirmation (patient must affirmatively confirm)? What is the implication for beneficiaries without smartphones? +3. **OQ-03:** Is a vendor-agnostic biometric engine interface sufficient for initial state procurement, or will specific states require a named biometric vendor (e.g., NIST-tested systems)? +4. **OQ-04:** What is the data retention requirement for biometric probe images in each target state jurisdiction? (Federal HIPAA floor is the minimum; some states impose shorter periods.) +5. **OQ-05:** Should the `PENDING_PAYMENT_HOLD` case status trigger an automated API call to the state MMIS to place a claims hold, or is this a manual step in the current phase? +6. **OQ-06:** Does the Executive Dashboard require role-based dashboard customization (different KPIs for state agency vs. MCO executive), or is a single layout sufficient? + +--- + +## 8. Out of Scope / Future + +The following items are explicitly deferred from the current release scope and are documented here for investor and procurement transparency: + +### 8.1 Hardware Integration Layer (Module 8) + +Physical biometric capture hardware — dedicated facial recognition cameras, fingerprint scanners, NFC identity card readers, secure elements, LTE modules, and GPS hardware — is deferred. The data model and `IdentityMethod` / `DevicePlatform` enums are designed to accommodate these without breaking changes. Roadmap detail: `docs/10-development-roadmap.md`. + +### 8.2 NIST 800-63 IAL3 / In-Person Identity Proofing + +Government-issued credential verification at IAL3 (in-person identity proofing) requires a separate enrollment workflow and hardware integration. The `GOV_CREDENTIAL` identity method is stubbed in the enum. + +### 8.3 Cross-State Federated Analytics + +Aggregate fraud analytics spanning multiple state tenants (e.g., a caregiver who committed fraud in State A re-enrolling in State B) requires a federated query layer and cross-state data sharing agreements. This is a future platform capability. + +### 8.4 Real-Time MMIS Claims Hold Integration + +Automated pre-payment claims hold via MMIS API (as opposed to manual investigator action) requires state-specific MMIS integration contracts. The `PENDING_PAYMENT_HOLD` case status and visit FLAGGED workflow are the hooks for this integration. + +### 8.5 Predictive Billing Anomaly Models + +ML-driven billing anomaly prediction (training on historical billing patterns per service code and jurisdiction) is described in `docs/06-ai-risk-scoring.md` and is a roadmap item beyond the rules-based `UNUSUAL_BILLING` detector in the initial release. + +### 8.6 Beneficiary Mobile Confirmation App + +A separate mobile application for Medicaid beneficiaries to confirm (or dispute) visit records in real time is a future product. The current patient confirmation step is implemented as a passive or investigator-entered record. + +--- + +*End of Document* + +*RayVerify™ and RayHealthEVV™ are product names used throughout this repository. This document is intended for state procurement reviewers, investors, and the engineering team. It does not constitute a contract or warranty.* diff --git a/docs/02-system-architecture.md b/docs/02-system-architecture.md new file mode 100644 index 0000000..c9b5f16 --- /dev/null +++ b/docs/02-system-architecture.md @@ -0,0 +1,664 @@ +# RayVerify™ — System Architecture + +> **Document:** 02 — System Architecture +> **Platform:** RayVerify™ (parent: RayHealthEVV™) +> **Audience:** Engineering leadership, government evaluators, technical investors +> **Status:** Approved for distribution + +--- + +## Table of Contents + +1. [Architecture Principles](#1-architecture-principles) +2. [C4-Style Context & Container Diagrams](#2-c4-style-context--container-diagrams) +3. [Logical Service Decomposition](#3-logical-service-decomposition) +4. [Key Runtime Flows](#4-key-runtime-flows) +5. [Data Flow & Storage Tiers](#5-data-flow--storage-tiers) +6. [Multi-Tenancy Model](#6-multi-tenancy-model) +7. [Integration Architecture](#7-integration-architecture) +8. [Scalability, Resilience & Failure Modes](#8-scalability-resilience--failure-modes) + +--- + +## 1. Architecture Principles + +RayVerify is built on six foundational principles that govern every design decision from the data layer to the API surface. + +### 1.1 API-First + +Every capability is exposed through a versioned REST API (`/v1/...`) with an OpenAPI 3.1 contract as the source of truth. No internal shortcut bypasses the API contract. This enables: + +- Clients (dashboard, EVV capture app, state integrations) to evolve independently of the backend. +- Government evaluators to audit the full system behavior through a stable, documented contract. +- Future frontend and mobile clients to be built without back-end changes. + +### 1.2 Multi-Tenant by Design + +Every business table carries an `organization_id` column. PostgreSQL Row-Level Security (RLS) policies enforced at the database layer — not just the application layer — make it impossible for one tenant's queries to read or write another tenant's data, even under application bugs or SQL injection. See §6 for the full tenancy model. + +### 1.3 Microservice-Ready Modular Monolith + +The initial deployment is a **modular monolith**: a single NestJS process with clean module boundaries (auth, identity, visits, verification, fraud, cases, providers, reporting, audit, notifications). Each module: + +- Has a dedicated NestJS module class with its own providers and no direct imports from sibling modules (all cross-module interaction via injected service interfaces). +- Owns its own Prisma query scope. +- Emits and consumes domain events via an internal event bus (NestJS `EventEmitter2`), which is a drop-in replacement target for NATS/Kafka when extracted to microservices. + +This path is described in §3. + +### 1.4 Zero-Trust Security + +- Every inbound request carries a short-lived JWT (15 min). Refresh tokens are SHA-256-hashed before storage; raw values never persist. +- MFA is enforced for investigator and admin roles (TOTP, SMS, WebAuthn). +- Internal service-to-service calls use the same JWT validation (no trusted network assumption). +- PHI columns are AES-256-GCM encrypted at the application layer before being written to PostgreSQL; AWS KMS manages the envelope keys. +- Infrastructure is deployed in a private VPC; only the ALB/CloudFront edge accepts public traffic. + +### 1.5 Event-Driven Where It Matters + +Fraud detection and report generation are decoupled from the synchronous request path via Redis-backed BullMQ queues. This gives: + +- Predictable API latency for caregiver-facing clock-in/out (< 500 ms p99), even when fraud scoring involves multiple ML detectors. +- Horizontal worker scaling independent of the API tier. +- Durable, retry-able jobs with dead-letter queues and exactly-once semantics via job IDs derived from visit UUIDs. + +### 1.6 Defense-in-Depth & Append-Only Evidence + +Evidence integrity is not an afterthought. The schema enforces it at the database layer: + +- `identity_verifications`, `gps_verifications`, `device_verifications`, and `fraud_events` are **append-only** — PostgreSQL triggers (`trg_*_immutable`) reject any `UPDATE` or `DELETE` with error code `integrity_constraint_violation`. +- `audit_logs` is append-only **and** maintains a SHA-256 tamper-evident hash chain (each row's `hash` covers `prev_hash || row_fields`). +- `visit_verifications` carries an `evidence_hash` (SHA-256 over the canonical evidence package) stored at the moment the verification chain closes. + +--- + +## 2. C4-Style Context & Container Diagrams + +### 2.1 System Context + +```mermaid +C4Context + title RayVerify™ — System Context + + Person(investigator, "Investigator / Auditor", "Reviews fraud alerts, manages cases, exports compliance reports. Uses the Investigator Dashboard (web).") + Person(caregiver, "Caregiver", "Clocks in/out of visits using the Field/EVV Capture App (mobile).") + Person(stateAnalyst, "State / MCO Analyst", "Queries APIs and receives scheduled compliance exports.") + + System(rayverify, "RayVerify™", "Fraud detection, identity verification, visit verification, case management, and compliance reporting for Medicaid/HCBS programs.") + + System_Ext(biometricVendor, "Biometric / Liveness Vendor", "Face-match & liveness API (e.g. AWS Rekognition or third-party). Returns confidence scores.") + System_Ext(kms, "AWS KMS", "Envelope key management for AES-256-GCM encryption of PHI columns and biometric evidence objects.") + System_Ext(s3, "AWS S3", "Encrypted object storage for biometric probe images, report files, and case evidence exports.") + System_Ext(notifProvider, "Notification Providers", "Email (SES), SMS (SNS/Twilio), webhook endpoints for fraud alerts and case events.") + System_Ext(stateEVV, "State EVV / MMIS System", "Upstream source-of-truth for service authorizations and billing claims (batch or webhook).") + System_Ext(cloudfront, "AWS CloudFront / WAF", "CDN edge with WAF rules; terminates TLS, rate-limits, and shields origin.") + + Rel(investigator, rayverify, "Investigates fraud, reviews cases, exports reports", "HTTPS / REST API") + Rel(caregiver, rayverify, "Clocks in/out, submits identity & GPS evidence", "HTTPS / REST API (mobile)") + Rel(stateAnalyst, rayverify, "Queries reporting API; receives scheduled exports", "HTTPS / REST API") + Rel(rayverify, biometricVendor, "Submits selfie + reference; receives confidence", "HTTPS (mTLS)") + Rel(rayverify, kms, "Encrypt / decrypt data keys for PHI", "AWS SDK") + Rel(rayverify, s3, "Store & retrieve encrypted evidence objects", "AWS SDK") + Rel(rayverify, notifProvider, "Dispatch fraud alerts, case notifications", "HTTPS / SMTP / SMS") + Rel(stateEVV, rayverify, "Push service authorizations, visit claims", "Batch SFTP / Webhook") + Rel(cloudfront, rayverify, "Proxy inbound requests; WAF filtering", "HTTPS (private ALB)") +``` + +### 2.2 Container Diagram + +```mermaid +C4Container + title RayVerify™ — Containers + + Person(user, "Dashboard / Mobile / Integration User") + + System_Boundary(rayverify, "RayVerify™ Platform") { + Container(cdn, "CloudFront + WAF", "AWS CloudFront", "TLS termination, WAF, DDoS protection, CDN for static assets.") + Container(dashboard, "Investigator Dashboard", "Next.js 15 / TS / Tailwind / shadcn", "SPA served from CloudFront; communicates exclusively with the API Gateway.") + Container(api, "API Gateway / Core NestJS", "NestJS 10 / TypeScript", "Single entry point: JWT auth, request validation, rate-limiting, module routing. Hosts all 10 NestJS service modules.") + Container(workerFraud, "Fraud Detection Workers", "NestJS / BullMQ worker processes", "Async consumers of the fraud-detection queue; run detectors, write fraud_events, update fraud_scores and provider_risk_profiles.") + Container(workerReport, "Report Generation Workers", "NestJS / BullMQ worker processes", "Consume report-generation queue; query DB, render PDF/XLSX/CSV, upload to S3, update reports.status.") + Container(pg, "PostgreSQL 15+", "AWS RDS Multi-AZ", "Primary OLTP store. RLS-enforced multi-tenancy. Partitioned visits and audit_logs. Append-only evidence tables.") + Container(redis, "Redis 7", "AWS ElastiCache (cluster mode)", "BullMQ job queues (fraud-detection, report-generation, notifications). API response cache (tenant-scoped keys). Session blacklist.") + Container(s3store, "S3 Encrypted Object Store", "AWS S3 (SSE-KMS)", "Biometric probe images, visit evidence packages, report files, case evidence exports. Lifecycle policies enforce retention limits.") + Container(pgReplica, "PostgreSQL Read Replica(s)", "AWS RDS Read Replica", "Offload heavy reporting queries and provider risk dashboards.") + } + + System_Ext(kms, "AWS KMS", "Envelope key management") + System_Ext(biometricVendor, "Biometric / Liveness API") + System_Ext(notifProvider, "Email / SMS / Webhook Providers") + System_Ext(stateEVV, "State EVV / MMIS") + + Rel(user, cdn, "HTTPS") + Rel(cdn, dashboard, "Serve static assets") + Rel(cdn, api, "Proxy API calls", "HTTPS → private ALB") + Rel(dashboard, api, "REST API", "JWT Bearer") + Rel(api, pg, "Prisma ORM reads/writes", "TCP 5432") + Rel(api, pgReplica, "Read-heavy queries (reports, risk dashboards)", "TCP 5432") + Rel(api, redis, "Enqueue jobs, cache, session ops", "TCP 6379") + Rel(api, s3store, "PUT encrypted objects", "AWS SDK") + Rel(api, kms, "Encrypt/decrypt PHI data keys", "AWS SDK") + Rel(api, biometricVendor, "Identity verification calls", "HTTPS") + Rel(api, notifProvider, "Send notifications", "HTTPS") + Rel(workerFraud, redis, "Dequeue fraud jobs", "BullMQ") + Rel(workerFraud, pg, "Read visit evidence, write fraud_events / fraud_scores", "TCP 5432") + Rel(workerReport, redis, "Dequeue report jobs", "BullMQ") + Rel(workerReport, pgReplica, "Execute reporting queries", "TCP 5432") + Rel(workerReport, s3store, "Upload completed report files", "AWS SDK") + Rel(stateEVV, api, "POST authorizations, visit claims", "HTTPS / Batch") +``` + +--- + +## 3. Logical Service Decomposition + +### 3.1 NestJS Module Map + +The backend is a single NestJS application (`packages/backend/src`) composed of the following modules. Each module is independently testable and has no compile-time circular dependencies on siblings. + +```mermaid +flowchart TD + subgraph Core + AppModule --> AuthModule + AppModule --> CommonModule + AppModule --> PrismaModule + end + + subgraph Platform_Modules["Platform Modules (the 8 product modules)"] + direction TB + M1[IdentityModule\nIdentity Verification Engine] + M2[VisitsModule\nVisit Verification Engine] + M3[FraudModule\nFraud Intelligence Engine] + M4[CasesModule\nInvestigator Dashboard] + M5[ProvidersModule\nProvider Risk Scoring] + M6[AuditModule\nAudit & Compliance Center] + M7[ReportingModule\nReporting & Analytics] + M8[HardwareModule\nFuture Hardware Integration Layer] + end + + subgraph Supporting_Modules["Supporting Modules"] + M9[NotificationsModule] + M10[PatientsModule] + M11[CaregiversModule] + end + + AppModule --> M1 & M2 & M3 & M4 & M5 & M6 & M7 & M8 & M9 & M10 & M11 + + M2 -->|"IdentityService (interface)"| M1 + M2 -->|"FraudQueueService"| M3 + M3 -->|"CaseService (interface)"| M4 + M3 -->|"ProviderRiskService"| M5 + M4 -->|"NotificationService"| M9 + M7 -->|"AuditService"| M6 + M6 -->|"S3Service (common)"| CommonModule +``` + +### 3.2 Module-to-Schema Responsibility + +| NestJS Module | Tables Owned (write authority) | Tables Read | +|---|---|---| +| `AuthModule` | `users`, `sessions`, `user_roles` | `roles`, `permissions`, `role_permissions` | +| `IdentityModule` | `identity_verifications`, `biometric_enrollments` | `caregivers`, `devices` | +| `VisitsModule` | `visits`, `visit_verifications` | `caregivers`, `patients`, `service_authorizations`, `devices` | +| `FraudModule` | `fraud_events`, `fraud_scores` | `visits`, all verification tables | +| `CasesModule` | `fraud_cases`, `case_notes`, `case_evidence` | `fraud_events`, `providers`, `users` | +| `ProvidersModule` | `providers`, `provider_risk_profiles` | `fraud_events`, `fraud_scores`, `visits` | +| `AuditModule` | `audit_logs` | `audit_logs` (read-only exports) | +| `ReportingModule` | `reports` | all (read replica) | +| `NotificationsModule` | `notifications` | `users`, `fraud_cases` | +| `PatientsModule` | `patients` | `visits`, `service_authorizations` | +| `CaregiversModule` | `caregivers` | `visits`, `identity_verifications` | + +### 3.3 Modular-Monolith Now / Microservice Later + +```mermaid +flowchart LR + subgraph Phase1["Phase 1 — Modular Monolith (current)"] + NestApp["Single NestJS process\nAll modules co-located\nEventEmitter2 internal bus\nShared Prisma client\nShared Redis client"] + end + + subgraph Phase2["Phase 2 — Extract High-Load Services"] + API2["API Gateway\n(auth + routing)"] + FraudSvc["Fraud Detection\nMicroservice"] + ReportSvc["Report Generation\nMicroservice"] + CoreSvc["Core Domain Service\n(visits, cases, identity)"] + end + + subgraph Phase3["Phase 3 — Full Microservices (optional)"] + Each["Each product module\nas independent service\nNATS / Kafka event bus\nService mesh (Istio)"] + end + + Phase1 -->|"Replace EventEmitter2\nwith NATS transport"| Phase2 + Phase2 -->|"Decompose remaining\nmodules on demand"| Phase3 +``` + +The transition from Phase 1 to Phase 2 requires only: +1. Replacing `EventEmitter2` emissions with `@nestjs/microservices` NATS client calls. +2. Extracting worker processes (already separate in Phase 1) into their own ECS task definitions. +3. No database schema changes — the RLS boundary already enforces isolation. + +--- + +## 4. Key Runtime Flows + +### 4.1 Full Visit Verification Chain + +This is the primary integrity-critical flow. It runs synchronously within the API request for the clock-out event, writing append-only evidence at every step. + +```mermaid +sequenceDiagram + autonumber + actor CG as Caregiver App + participant API as NestJS API + participant IDV as IdentityModule + participant VIS as VisitsModule + participant GPS as GpsService + participant DEV as DeviceService + participant BIO as Biometric Vendor + participant S3 as S3 (encrypted) + participant DB as PostgreSQL + participant Q as Redis / BullMQ + + CG->>API: POST /v1/visits/{id}/clock-out\n{ selfie_b64, gps_coords, device_signals, patient_confirm } + API->>API: Validate JWT, extract org_id tenant context\nSET app.current_org = org_id (RLS GUC) + + note over API,DB: Step 1 — Identity Verification + API->>IDV: verifyIdentity(caregiverId, selfieB64, visitId) + IDV->>S3: PUT probe_image (AES-256-GCM, KMS key) + IDV->>BIO: faceMatch(probe_s3_key, reference_template_ref) + BIO-->>IDV: { confidence: 0.97, liveness: 0.99, result: "PASS" } + IDV->>DB: INSERT identity_verifications (append-only)\n{ result: PASS, confidence_score, liveness_score, probe_s3_key } + IDV-->>API: IdentityResult { result: PASS, confidenceScore: 0.97 } + + note over API,DB: Step 2 — GPS Verification + API->>GPS: verifyGps(visitId, coords, authorizationId) + GPS->>DB: SELECT service_authorizations (lat/lng/radius_meters) + GPS->>GPS: haversineDistance(captured, authorized)\nvs radius_meters threshold + GPS->>DB: INSERT gps_verifications (append-only)\n{ latitude, longitude, distance_meters, result: PASS } + GPS-->>API: GpsResult { result: PASS, distanceMeters: 42 } + + note over API,DB: Step 3 — Device Verification + API->>DEV: verifyDevice(deviceId, signals, visitId) + DEV->>DEV: Evaluate emulator / root / jailbreak / fingerprint signals + DEV->>DB: INSERT device_verifications (append-only)\n{ result, trust_level, signals } + DEV->>DB: UPDATE devices SET trust_level, last_seen_at + DEV-->>API: DeviceResult { result: PASS, trustLevel: TRUSTED } + + note over API,DB: Step 4 — Patient Confirmation (record only) + API->>DB: UPDATE visits SET patient_confirmed_at (if patient_confirm=true) + + note over API,DB: Step 5 — Composite Verification Decision + API->>VIS: buildVerificationChain(identity, gps, device, patientConfirm) + VIS->>VIS: Aggregate results → composite VerificationResult\ncompute preliminary risk_score (0–100) + VIS->>DB: INSERT visit_verifications\n{ result, risk_score, risk_level, chain (JSON), evidence_hash } + VIS->>DB: UPDATE visits SET verification_result, risk_score, risk_level, clock_out_at, status=COMPLETED + + note over API,Q: Step 6 — Async Fraud Scoring (non-blocking) + API->>Q: ENQUEUE fraud-detection job\n{ visitId, orgId, jobId=hash(visitId) } + Q-->>API: jobId acknowledged + + API-->>CG: 200 OK { verificationResult: "PASS", riskScore: 12, riskLevel: "LOW" } + + note over Q,DB: Async — Fraud Intelligence Engine (see §4.2) + Q->>Q: Worker picks up fraud-detection job +``` + +### 4.2 Async Fraud Detection Pipeline + +```mermaid +sequenceDiagram + autonumber + participant Q as Redis / BullMQ + participant FW as Fraud Worker + participant DB as PostgreSQL + participant FRAUD as FraudModule\n(Detector Chain) + participant CASES as CasesModule + participant NOTIF as NotificationsModule + + Q->>FW: Dequeue fraud-detection job { visitId, orgId } + FW->>DB: SELECT visit + caregiverId + providerId + history\n(last 30 days visits for caregiver/patient/provider) + DB-->>FW: Visit context + history rows + + FW->>FRAUD: runDetectors(visitContext) + + par Detector: Impossible Travel + FRAUD->>DB: SELECT prior visit clock_out_at + clock_in_lat/lng\nfor same caregiver in last 4 hours + FRAUD->>FRAUD: haversineSpeed > threshold? → IMPOSSIBLE_TRAVEL event + and Detector: Duplicate Visit + FRAUD->>DB: SELECT visits WHERE caregiver_id AND same time window + FRAUD->>FRAUD: overlap > 0 min? → DUPLICATE_VISIT event + and Detector: GPS Anomaly + FRAUD->>DB: SELECT gps_verifications for visit + FRAUD->>FRAUD: distance_meters > radius + anomaly_buffer? → GPS_ANOMALY event + and Detector: Shared Device + FRAUD->>DB: SELECT visits with same device_id in overlapping window + FRAUD->>FRAUD: Multiple caregivers? → SHARED_DEVICE event + and Detector: Billing Anomaly + FRAUD->>DB: SELECT authorized_units vs billed_units for patient + FRAUD->>FRAUD: Over-billing? → UNUSUAL_BILLING event + end + + FRAUD->>DB: INSERT fraud_events[] (append-only, one row per triggered detector) + FRAUD->>FRAUD: compositeScore = weightedSum(severity[]) clamped 0–100 + FRAUD->>DB: INSERT fraud_scores { subjectType: VISIT, score, risk_level, factors } + FRAUD->>DB: UPDATE visits SET risk_score, risk_level, status=FLAGGED (if score > threshold) + + alt score >= 61 (HIGH / CRITICAL) + FRAUD->>CASES: autoCreateOrLinkCase(providerId, fraudEvents[]) + CASES->>DB: INSERT fraud_cases (if no open case for provider) + CASES->>DB: UPDATE fraud_events SET case_id, status=LINKED_TO_CASE + CASES->>NOTIF: dispatchAlert(assigneeId, caseId, riskLevel) + NOTIF->>DB: INSERT notifications + end + + FRAUD->>DB: UPDATE provider_risk_profiles\n(current_score, counters, trend sparkline) + FW->>Q: JOB COMPLETE (BullMQ marks done) +``` + +### 4.3 Report Generation Job + +```mermaid +sequenceDiagram + autonumber + actor INV as Investigator + participant API as NestJS API + participant DB as PostgreSQL + participant Q as Redis / BullMQ + participant RW as Report Worker + participant PGRO as PostgreSQL\nRead Replica + participant S3 as S3 (encrypted) + participant NOTIF as NotificationsModule + + INV->>API: POST /v1/reports\n{ type: FRAUD_SUMMARY, format: PDF, parameters: { dateRange, orgId } } + API->>DB: INSERT reports { status: QUEUED, parameters } + API->>Q: ENQUEUE report-generation job { reportId } + API-->>INV: 202 Accepted { reportId, status: "QUEUED" } + + Q->>RW: Dequeue report job { reportId } + RW->>DB: UPDATE reports SET status=GENERATING + RW->>PGRO: Execute parameterized report query\n(fraud summaries, provider risk, visit stats — read replica) + PGRO-->>RW: Result rows + RW->>RW: Render PDF / XLSX / CSV using template engine + RW->>S3: PUT report file (SSE-KMS), returns s3_key + RW->>DB: UPDATE reports SET status=READY, s3_key, completed_at, expires_at=now()+7d + RW->>NOTIF: dispatchReportReady(requestedById, reportId) + NOTIF->>DB: INSERT notifications + + INV->>API: GET /v1/reports/{reportId}/download + API->>DB: SELECT reports WHERE id AND status=READY + API->>S3: GeneratePresignedUrl (15 min TTL) + API-->>INV: 302 redirect → presigned S3 URL + API->>DB: INSERT audit_logs { action: EXPORT, resourceType: report } +``` + +--- + +## 5. Data Flow & Storage Tiers + +```mermaid +flowchart TD + subgraph Ingest["Ingest Paths"] + CAP[Field Capture App\nclock-in / clock-out] + API_IN[REST API Ingest\n/v1/visits, /v1/authorizations] + BATCH[State SFTP Batch\nauthorizations / claims] + end + + subgraph Hot["Hot Tier — PostgreSQL (RDS Multi-AZ)"] + direction TB + VISITS[(visits\npartitioned monthly)] + VERIF[(verification chain\nidentity / gps / device\nappend-only)] + FRAUD_T[(fraud_events\nfraud_scores\nfraud_cases)] + RISK[(provider_risk_profiles\nhot denormalized)] + AUDIT[(audit_logs\npartitioned monthly\nhash chain)] + USERS_T[(organizations / users\nroles / permissions)] + end + + subgraph Warm["Warm Tier — Redis (ElastiCache)"] + QUEUES[BullMQ Queues\nfraud-detection\nreport-generation\nnotifications] + CACHE[API Response Cache\ntenant-scoped keys\nTTL 60–300 s] + SESS[Session Blacklist\nrevoked refresh tokens] + end + + subgraph Cold["Cold / Evidence Tier — S3 (SSE-KMS)"] + PROBES[Biometric Probe Images\nPHI — AES-256-GCM\nLifecycle: 7 years → Glacier] + REPORTS_S3[Report Files\nPDF / XLSX / CSV\nLifecycle: 90 days → expire] + EVIDENCE_S3[Case Evidence Exports\nchain-of-custody SHA-256\nLifecycle: 7 years → Glacier] + end + + CAP -->|"clock-out payload"| API_IN + BATCH -->|"nightly batch import"| API_IN + API_IN --> VISITS + API_IN --> VERIF + API_IN --> QUEUES + QUEUES -->|"fraud worker"| FRAUD_T + QUEUES -->|"fraud worker"| RISK + QUEUES -->|"report worker"| REPORTS_S3 + FRAUD_T -->|"evidence attach"| EVIDENCE_S3 + VERIF -->|"probe images"| PROBES + USERS_T --- CACHE + RISK --- CACHE + + subgraph Archive["Archive — S3 Glacier Deep Archive"] + GLACIER[Monthly partition exports\nafter retention window\n7-year regulatory hold] + end + + VISITS -->|"pg_partman detach + dump"| GLACIER + AUDIT -->|"pg_partman detach + dump"| GLACIER +``` + +### Storage Tier Summary + +| Tier | Technology | Data | Retention | +|---|---|---|---| +| Hot OLTP | RDS PostgreSQL Multi-AZ | visits, verifications, fraud events, cases, audit logs, users | Active + 24-month hot rolling window | +| Warm Queue/Cache | ElastiCache Redis Cluster | BullMQ jobs, API cache, session blacklist | Job TTL 24 h; cache TTL 60–300 s | +| Evidence Object | S3 SSE-KMS | Biometric probe images, case evidence exports | 7 years (HIPAA), then Glacier | +| Report Object | S3 SSE-KMS | Generated PDF/XLSX/CSV reports | 90 days, presigned download only | +| Cold Archive | S3 Glacier Deep Archive | Detached monthly partition dumps | 7 years minimum | + +--- + +## 6. Multi-Tenancy Model + +### 6.1 Tenant Isolation Architecture + +```mermaid +flowchart TD + REQ["Inbound API Request\nAuthorization: Bearer JWT"] + JWTGuard["JWTAuthGuard\nExtract userId + orgId from JWT claims"] + RLS_SET["PrismaService middleware:\nSET LOCAL app.current_org = orgId"] + RLS_ENFORCE["PostgreSQL RLS Policy:\ntenant_isolation\nUSING organization_id = current_setting('app.current_org')::uuid"] + DATA["Tenant A rows only\n— other tenants invisible"] + + REQ --> JWTGuard --> RLS_SET --> RLS_ENFORCE --> DATA + + style RLS_ENFORCE fill:#d32f2f,color:#fff + style DATA fill:#388e3c,color:#fff +``` + +### 6.2 How Tenant Context Flows + +1. **JWT Issuance:** On login, the JWT payload includes `{ sub: userId, org: organizationId, roles: [...] }`. Signed with RS256; public key available at `/.well-known/jwks.json`. + +2. **Request Middleware:** A NestJS global middleware extracts `org` from the verified JWT claims and stores it on the request context. + +3. **Prisma Middleware:** A Prisma client middleware runs `SET LOCAL app.current_org = ''` at the start of every transaction/query session. This sets the PostgreSQL session-level GUC that all RLS policies read. + +4. **RLS Enforcement:** Every tenant-scoped table has: + + ```sql + ALTER TABLE ENABLE ROW LEVEL SECURITY; + ALTER TABLE
FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_isolation ON
+ USING (organization_id = current_setting('app.current_org', true)::uuid) + WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid); + ``` + + `FORCE ROW LEVEL SECURITY` ensures the policy applies even to the table owner role. + +5. **Bypass Role:** A dedicated `rls_bypass` role (BYPASSRLS privilege) is used only by migration jobs and ops tooling. It is never granted to the application service account. + +### 6.3 Noisy-Neighbor Controls + +| Control | Mechanism | +|---|---| +| API rate limiting | NestJS `ThrottlerModule` per `org_id` + IP, configurable per tenant tier | +| Queue job quotas | BullMQ rate limiter per `org_id` key prefix; max concurrency per queue | +| DB connection pooling | PgBouncer / RDS Proxy; per-tenant connection limits via pool configuration | +| Report query timeouts | `statement_timeout` set per read-replica session for reporting queries | +| Storage quotas | S3 lifecycle + bucket policy; per-prefix size alarms via CloudWatch | + +### 6.4 Tenant Configuration + +Each `organizations` row carries a `settings JSONB` column holding per-tenant feature flags, risk thresholds, notification preferences, and branding: + +```jsonc +{ + "fraudScoreThreshold": 61, // minimum score to auto-create case + "gpsRadiusOverrideMeters": null, // null = use service_authorizations.radius_meters + "mfaRequired": true, + "notifyOnRiskLevel": "HIGH", + "reportRetentionDays": 90, + "featureFlags": { + "hardwareIntegration": false, + "aiRiskScoring": true + } +} +``` + +--- + +## 7. Integration Architecture + +### 7.1 Inbound Integration Patterns + +```mermaid +flowchart LR + subgraph StateMMIS["State MMIS / EVV System"] + SFTP[SFTP Batch File\nCSV / EDI 834 / 837] + WEBHOOK_IN[Webhook Push\napplication/json] + end + + subgraph MCO["Managed Care Organization"] + MCOAPI[MCO REST API\npull authorizations] + end + + subgraph RayVerify["RayVerify™ Integration Layer"] + IMPORT[BatchImportService\nIdempotent upsert\nvia authorization_id natural key] + WHIN[WebhookIngressController\nHMAC-SHA256 signature verification] + POLL[ScheduledPollService\n@Cron every 15 min] + end + + SFTP -->|"nightly transfer"| IMPORT + WEBHOOK_IN -->|"real-time push"| WHIN + MCOAPI -->|"GET authorizations"| POLL + + IMPORT --> DB[(PostgreSQL\nservice_authorizations\nvisits)] + WHIN --> DB + POLL --> DB +``` + +**Idempotency for batch imports:** Each `ServiceAuthorization` upsert uses `ON CONFLICT (organization_id, patient_id, service_code, start_date) DO UPDATE` semantics. Visit imports key on the upstream EVV system's visit ID stored in an `external_id` field. Duplicate imports are safe. + +### 7.2 Outbound Integration: Webhooks & State Exports + +```mermaid +flowchart LR + subgraph Events["Domain Events (internal)"] + EV1[visit.verified] + EV2[fraud.case.opened] + EV3[fraud.case.escalated] + EV4[provider.risk.changed] + end + + WH[WebhookDispatchService] + Q2[Redis: notifications queue] + DB2[(webhook_subscriptions\nin org settings JSONB)] + + EV1 & EV2 & EV3 & EV4 --> WH + WH --> DB2 + WH --> Q2 + Q2 -->|"worker: HTTP POST\nHMAC-SHA256 signed\nexponential retry"| EXT[External Endpoint\ne.g. State fraud system\nMCO portal] + + EXPORT[Scheduled Export Job\n@Cron monthly / weekly] + EXPORT -->|"Query visit / fraud data\nRender CSV/JSON"| S3EXP[(S3: state-compliance-exports/)] + S3EXP -->|"Presigned URL or\nSFTP push"| STATE[State Agency Portal] +``` + +### 7.3 API Versioning Strategy + +All API routes are prefixed `/v1/`. When a breaking change is required: + +1. A new `/v2/` route group is added. The v1 routes are deprecated with a `Sunset` response header (90-day notice). +2. Versioning is path-based (not header-based) for transparency with government integration teams. +3. The OpenAPI 3.1 specification at `api/openapi.yaml` is the contract; any v1 → v2 migration path is documented in the spec's `x-deprecation-notice` extension. + +--- + +## 8. Scalability, Resilience & Failure Modes + +### 8.1 Horizontal Scaling Topology + +```mermaid +flowchart TD + ALB[Application Load Balancer\nsticky sessions OFF\nhealth checks /v1/health] + + subgraph ECS_API["ECS Service: api (target: 3–20 tasks)"] + API1[NestJS Task 1] + API2[NestJS Task 2] + APIN[NestJS Task N] + end + + subgraph ECS_FRAUD["ECS Service: fraud-worker (target: 2–10 tasks)"] + FW1[Fraud Worker 1] + FW2[Fraud Worker 2] + end + + subgraph ECS_REPORT["ECS Service: report-worker (target: 1–5 tasks)"] + RW1[Report Worker 1] + end + + ALB --> API1 & API2 & APIN + API1 & API2 & APIN --> REDIS[(Redis Cluster)] + FW1 & FW2 --> REDIS + RW1 --> REDIS + + subgraph RDS["RDS (Multi-AZ)"] + PRIMARY[(Primary\nwrites)] + REPLICA[(Read Replica\nreports / dashboards)] + end + + API1 & API2 & APIN --> PRIMARY + FW1 & FW2 --> PRIMARY + RW1 --> REPLICA +``` + +### 8.2 Resilience & Failure Modes + +| Failure Mode | Detection | Recovery | +|---|---|---| +| API task crash | ALB health check fails → ECS replaces task in < 30 s | Stateless tasks; no in-memory state loss | +| RDS primary failure | RDS Multi-AZ automatic failover | < 60 s failover; Prisma connection retry with exponential backoff | +| Redis node failure | ElastiCache cluster mode; automatic slot rebalancing | BullMQ jobs re-queued from persistence log; idempotent job IDs | +| Biometric vendor timeout | 5 s HTTP timeout → circuit breaker (consecutive failures) | Identity step result = REVIEW (not FAIL); human review queue | +| S3 PUT failure (probe image) | SDK retry (3x exponential backoff) | If exhausted: identity step REVIEW; probe key logged as null | +| Fraud detector exception | Try/catch per detector; non-fatal | Failing detector skipped; other detectors run; job completes with partial score | +| Report worker crash mid-render | BullMQ job retried (max 3 attempts) | `reports.status` remains GENERATING until worker re-picks; idempotent report ID | +| Partition missing for future date | `visits_default` catch-all partition | Inserts succeed; pg_partman cron creates named partition and reattaches rows | + +### 8.3 Idempotency + +- **Visit clock events:** `POST /v1/visits/{id}/clock-out` is idempotent via `visit_verifications.visit_id UNIQUE` constraint. A second request for the same `visitId` will receive the existing `visit_verifications` row. +- **Fraud detection jobs:** BullMQ job IDs are `fraud:${visitId}`. A second enqueue for the same `visitId` deduplicates at the queue level. +- **Batch imports:** `ON CONFLICT ... DO UPDATE` semantics on natural keys. +- **Report generation:** `reports.id` is the idempotency key; re-enqueuing a `QUEUED` report checks `status` before rendering. + +### 8.4 Eventual Consistency for Risk Scores + +`visits.risk_score` and `visits.risk_level` are updated asynchronously by the fraud worker. The clock-out API response returns the **preliminary** score (computed synchronously from verification chain inputs only). The **final** fraud-enriched score is written within seconds and readable via `GET /v1/visits/{id}`. The `visit_verifications.chain` JSONB field tracks both `preliminary` and `final` score states for audit clarity. + +`provider_risk_profiles.current_score` is similarly updated after every fraud detection job and is the denormalized hot read for the provider risk dashboard. Historical point-in-time scores are in `fraud_scores` (time series). + +--- + +*Document version: 1.0 | Platform: RayVerify™ | Classification: Investor/Government Distribution* diff --git a/docs/03-database-design.md b/docs/03-database-design.md new file mode 100644 index 0000000..6563f74 --- /dev/null +++ b/docs/03-database-design.md @@ -0,0 +1,919 @@ +# RayVerify™ — Database Design + +> **Document:** 03 — Database Design +> **Platform:** RayVerify™ (parent: RayHealthEVV™) +> **Audience:** Engineering leadership, government evaluators, technical investors, database administrators +> **Status:** Approved for distribution + +--- + +## Table of Contents + +1. [Physical Model Overview](#1-physical-model-overview) +2. [Entity-Relationship Diagram](#2-entity-relationship-diagram) +3. [Table-by-Table Reference](#3-table-by-table-reference) +4. [Indexing, Partitioning & Data Integrity](#4-indexing-partitioning--data-integrity) +5. [Multi-Tenant Isolation via Row-Level Security](#5-multi-tenant-isolation-via-row-level-security) +6. [PHI Handling & Data Retention](#6-phi-handling--data-retention) +7. [Migration Strategy, Seeding & Performance](#7-migration-strategy-seeding--performance) + +--- + +## 1. Physical Model Overview + +### 1.1 Prisma vs. SQL: Separation of Concerns + +RayVerify uses a **two-layer data definition approach**: + +| Layer | File | Authority | Concerns | +|---|---|---|---| +| **Logical model** | `packages/backend/prisma/schema.prisma` | Prisma Migrate | TypeScript types, relations, indexes, enum definitions used by application code | +| **Physical model** | `db/schema.sql` | Applied manually / via CI migration script | Native PostgreSQL ENUMs, declarative RANGE partitioning, Row-Level Security policies, immutability triggers, tamper-evidence hash chain trigger, `FORCE ROW LEVEL SECURITY` | + +The SQL file is the **source of truth for production DDL**. Prisma manages the logical model and generates TypeScript types; it cannot express partitioning, RLS policies, or trigger definitions. These are applied via raw SQL migration files stored in `packages/backend/prisma/migrations/` as `migration.sql` steps. + +### 1.2 Database Stack + +- **Engine:** PostgreSQL 15+ (tested on 16). AWS RDS Multi-AZ deployment. +- **Extensions:** `pgcrypto` (UUID generation, SHA-256 via `digest()`), `citext` (case-insensitive text for emails and slugs), `pg_trgm` (trigram fuzzy search on names and case numbers). +- **Optional (recommended for production geofencing):** `PostGIS` — the schema uses `NUMERIC(9,6)` coordinates and Haversine application-layer distance calculation today; PostGIS `GEOGRAPHY` columns can be added in a non-breaking migration. + +### 1.3 Naming Conventions + +| Convention | Example | +|---|---| +| Tables: `snake_case` plural | `fraud_cases`, `identity_verifications` | +| Columns: `snake_case` | `organization_id`, `risk_score` | +| PKs: UUID v4 (`gen_random_uuid()`) | `id UUID PRIMARY KEY DEFAULT gen_random_uuid()` | +| Timestamps: `TIMESTAMPTZ` | `created_at`, `updated_at`, `detected_at` | +| Money: integer cents | `billed_amount_cents INTEGER`, `exposure_cents INTEGER` | +| Geo coords: `NUMERIC(9,6)` | `latitude NUMERIC(9,6)` (~11 cm precision) | +| Risk scores: `INTEGER` 0–100 | `risk_score`, `score`, `severity` | +| Enums: `UPPER_SNAKE_CASE` | `'IMPOSSIBLE_TRAVEL'`, `'PASS'`, `'CRITICAL'` | + +--- + +## 2. Entity-Relationship Diagram + +```mermaid +erDiagram + organizations ||--o{ users : "has" + organizations ||--o{ roles : "has" + organizations ||--o{ providers : "has" + organizations ||--o{ caregivers : "has" + organizations ||--o{ patients : "has" + organizations ||--o{ devices : "has" + organizations ||--o{ visits : "has" + organizations ||--o{ service_authorizations : "has" + organizations ||--o{ visit_verifications : "has" + organizations ||--o{ identity_verifications : "has" + organizations ||--o{ gps_verifications : "has" + organizations ||--o{ device_verifications : "has" + organizations ||--o{ fraud_events : "has" + organizations ||--o{ fraud_cases : "has" + organizations ||--o{ fraud_scores : "has" + organizations ||--o{ provider_risk_profiles : "has" + organizations ||--o{ reports : "has" + organizations ||--o{ notifications : "has" + organizations ||--o{ audit_logs : "has" + + users ||--o{ sessions : "has" + users ||--o{ user_roles : "assigned" + users ||--o{ fraud_cases : "assigned to (assignee)" + users ||--o{ case_notes : "authors" + users ||--o{ reports : "requests" + users ||--o{ notifications : "receives" + users ||--o{ audit_logs : "acts in" + + roles ||--o{ role_permissions : "has" + roles ||--o{ user_roles : "assigned via" + permissions ||--o{ role_permissions : "granted via" + + providers ||--o{ caregivers : "employs" + providers ||--o{ visits : "bills" + providers ||--|{ provider_risk_profiles : "has one" + providers ||--o{ fraud_cases : "subject of" + + caregivers ||--o{ visits : "delivers" + caregivers ||--o{ identity_verifications : "subject of" + caregivers ||--o{ biometric_enrollments : "has" + + patients ||--o{ visits : "receives" + patients ||--o{ service_authorizations : "authorized via" + + service_authorizations ||--o{ visits : "governs" + + devices ||--o{ visits : "used in" + devices ||--o{ device_verifications : "produces" + + visits ||--|{ visit_verifications : "has one" + visits ||--o{ identity_verifications : "produces" + visits ||--o{ gps_verifications : "produces" + visits ||--o{ device_verifications : "produces" + visits ||--o{ fraud_events : "triggers" + + fraud_cases ||--o{ fraud_events : "groups" + fraud_cases ||--o{ case_notes : "has" + fraud_cases ||--o{ case_evidence : "collects" + + organizations { + uuid id PK + text name + citext slug UK + text jurisdiction + jsonb settings + boolean is_active + timestamptz created_at + timestamptz updated_at + } + + visits { + uuid id PK + timestamptz scheduled_start PK + uuid organization_id FK + uuid provider_id FK + uuid caregiver_id FK + uuid patient_id FK + uuid authorization_id FK + uuid device_id FK + visit_status status + timestamptz clock_in_at + timestamptz clock_out_at + integer duration_minutes + integer risk_score + risk_level risk_level + verification_result verification_result + } + + fraud_cases { + uuid id PK + uuid organization_id FK + text case_number UK + case_status status + case_priority priority + risk_level risk_level + integer exposure_cents + uuid assignee_id FK + timestamptz opened_at + timestamptz closed_at + } + + fraud_events { + uuid id PK + uuid organization_id FK + uuid visit_id FK + uuid case_id FK + fraud_event_type type + fraud_event_status status + integer severity + risk_level risk_level + text explanation + jsonb evidence + timestamptz detected_at + } + + audit_logs { + uuid id PK + timestamptz created_at PK + uuid organization_id FK + uuid actor_id FK + audit_action action + text resource_type + text resource_id + jsonb metadata + text prev_hash + text hash + } +``` + +--- + +## 3. Table-by-Table Reference + +### Group A — Tenancy & Access Control + +#### `organizations` + +Tenant root. Every other table foreign-keys to this via `organization_id`. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK, `gen_random_uuid()` | +| `name` | `TEXT` | Display name of the state agency / MCO | +| `slug` | `CITEXT` | URL-safe unique identifier; UNIQUE | +| `jurisdiction` | `TEXT` | FIPS code, CMS region, or state abbreviation | +| `settings` | `JSONB` | Feature flags, risk thresholds, branding, webhook config | +| `is_active` | `BOOLEAN` | Soft-disable a tenant without deleting data | +| `created_at` | `TIMESTAMPTZ` | Auto-set | +| `updated_at` | `TIMESTAMPTZ` | Auto-maintained by `trg_org_touch` trigger | + +#### `users` + +Platform users: investigators, auditors, compliance officers, admins. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations, CASCADE | +| `email` | `CITEXT` | Case-insensitive; UNIQUE per org | +| `password_hash` | `TEXT` | Argon2id hash; NULL for SSO-only accounts | +| `first_name` | `TEXT` | | +| `last_name` | `TEXT` | | +| `phone` | `TEXT` | | +| `status` | `user_status` | `ACTIVE` / `INACTIVE` / `SUSPENDED` / `LOCKED` / `PENDING_INVITE` | +| `mfa_method` | `mfa_method` | `NONE` / `TOTP` / `SMS` / `WEBAUTHN` | +| `mfa_secret` | `TEXT` | AES-256-GCM encrypted TOTP secret (KMS envelope) | +| `last_login_at` | `TIMESTAMPTZ` | | +| `failed_logins` | `INTEGER` | Reset on success; triggers lock at threshold | +| `locked_until` | `TIMESTAMPTZ` | Null if not locked | +| `created_at` | `TIMESTAMPTZ` | | +| `updated_at` | `TIMESTAMPTZ` | Auto-maintained by trigger | + +#### `sessions` + +Short-lived server-side session records for refresh token rotation. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `user_id` | `UUID` | FK → users, CASCADE | +| `refresh_token_hash` | `TEXT` | SHA-256 of the raw refresh token; UNIQUE | +| `user_agent` | `TEXT` | | +| `ip_address` | `INET` | PostgreSQL INET type | +| `expires_at` | `TIMESTAMPTZ` | 30-day refresh window | +| `revoked_at` | `TIMESTAMPTZ` | Null if active | +| `created_at` | `TIMESTAMPTZ` | | + +#### `roles`, `permissions`, `role_permissions`, `user_roles` + +RBAC tables. `roles` are org-scoped (e.g. `INVESTIGATOR`, `AUDITOR`, `OIG_AGENT`). `permissions` are a global catalog keyed by `resource:action` format (e.g. `fraud_case:assign`, `report:export`). `role_permissions` and `user_roles` are pure join tables with composite PKs. + +--- + +### Group B — Domain: Providers, Caregivers, Patients, Authorizations + +#### `providers` + +A Medicaid-enrolled billing agency or home-care organization. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `npi` | `TEXT` | 10-digit NPI; CHECK constraint `~ '^[0-9]{10}$'`; UNIQUE per org | +| `medicaid_id` | `TEXT` | Jurisdiction-specific enrollment ID | +| `legal_name` | `TEXT` | | +| `tax_id` | `TEXT` | Encrypted at app layer before write | +| `is_active` | `BOOLEAN` | | +| `enrolled_at` | `TIMESTAMPTZ` | | +| `created_at` | `TIMESTAMPTZ` | | +| `updated_at` | `TIMESTAMPTZ` | | + +#### `caregivers` + +Individual care workers; primary subjects of identity verification. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `provider_id` | `UUID` | FK → providers | +| `external_id` | `TEXT` | Payroll / scheduling system reference | +| `first_name`, `last_name` | `TEXT` | | +| `email` | `CITEXT` | Optional | +| `phone` | `TEXT` | Optional | +| `status` | `user_status` | Active / Inactive / Suspended | +| `created_at`, `updated_at` | `TIMESTAMPTZ` | | + +#### `biometric_enrollments` + +Reference biometric template record for a caregiver. The raw face image never leaves S3; only the S3 key and vendor template reference pointer are stored here. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `caregiver_id` | `UUID` | FK → caregivers, CASCADE | +| `method` | `identity_method` | `SELFIE` / `LIVENESS` / `FINGERPRINT` / `NFC_CARD` / `GOV_CREDENTIAL` | +| `reference_s3_key` | `TEXT` | S3 key of the KMS-encrypted reference image (PHI) | +| `template_ref` | `TEXT` | Pointer to vendor face-embedding vector store record | +| `is_active` | `BOOLEAN` | One active enrollment per method per caregiver | +| `enrolled_at` | `TIMESTAMPTZ` | | +| `retired_at` | `TIMESTAMPTZ` | Set when superseded by a new enrollment | + +#### `patients` + +Medicaid beneficiaries receiving personal care / HCBS services. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `medicaid_member_id` | `TEXT` | PHI — AES-256-GCM encrypted at app layer; blind index maintained separately | +| `first_name`, `last_name` | `TEXT` | PHI | +| `date_of_birth` | `DATE` | PHI | +| `created_at`, `updated_at` | `TIMESTAMPTZ` | | + +#### `service_authorizations` + +Medicaid-approved service events including the geofence anchor (address + `radius_meters`) used by GPS fraud rules. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `patient_id` | `UUID` | FK → patients | +| `service_code` | `TEXT` | HCPCS / state code (e.g. `T1019`) | +| `description` | `TEXT` | | +| `address_line1/2`, `city`, `state`, `postal_code` | `TEXT` | Service delivery address | +| `latitude`, `longitude` | `NUMERIC(9,6)` | CHECKed: lat in [-90,90], lng in [-180,180] | +| `radius_meters` | `INTEGER` | GPS geofence radius; CHECK > 0; default 150 | +| `authorized_units` | `INTEGER` | Periodic cap used in overtime/overlap detectors | +| `start_date`, `end_date` | `DATE` | Authorization window | +| `is_active` | `BOOLEAN` | | +| `created_at` | `TIMESTAMPTZ` | | + +--- + +### Group C — Device Trust + +#### `devices` + +Device registry and trust level store. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `device_id` | `TEXT` | Stable client-generated identifier; UNIQUE per org | +| `fingerprint_hash` | `TEXT` | Hash of composite device fingerprint (model+OS+screen…) | +| `platform` | `device_platform` | `IOS` / `ANDROID` / `WEB` / `HARDWARE_TERMINAL` | +| `os_version`, `browser`, `app_version` | `TEXT` | | +| `last_ip_address` | `INET` | | +| `trust_level` | `device_trust_level` | `TRUSTED` / `UNKNOWN` / `SUSPICIOUS` / `BLOCKED` | +| `is_emulator` | `BOOLEAN` | Set by DeviceVerification analysis | +| `is_rooted` | `BOOLEAN` | | +| `is_jailbroken` | `BOOLEAN` | | +| `first_seen_at`, `last_seen_at` | `TIMESTAMPTZ` | | + +--- + +### Group D — Visits & Verification Chain + +#### `visits` + +Core service event record. Partitioned monthly by `scheduled_start`. Composite PK `(id, scheduled_start)` is required by PostgreSQL declarative partitioning. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | Part of composite PK | +| `scheduled_start` | `TIMESTAMPTZ` | Part of composite PK; partition key | +| `organization_id` | `UUID` | FK → organizations | +| `provider_id` | `UUID` | FK → providers | +| `caregiver_id` | `UUID` | FK → caregivers | +| `patient_id` | `UUID` | FK → patients | +| `authorization_id` | `UUID` | FK → service_authorizations (nullable) | +| `device_id` | `UUID` | FK → devices (nullable) | +| `service_code` | `TEXT` | HCPCS code delivered | +| `status` | `visit_status` | `SCHEDULED` → `IN_PROGRESS` → `COMPLETED` / `FLAGGED` / `REJECTED` / `APPROVED` / `CANCELLED` | +| `scheduled_end` | `TIMESTAMPTZ` | | +| `clock_in_at`, `clock_out_at` | `TIMESTAMPTZ` | | +| `duration_minutes` | `INTEGER` | Computed at clock-out | +| `clock_in_lat`, `clock_in_lng` | `NUMERIC(9,6)` | Denormalized from `gps_verifications` for fast fraud queries | +| `billed_units` | `INTEGER` | | +| `billed_amount_cents` | `INTEGER` | Billing amount in integer cents | +| `verification_result` | `verification_result` | `PASS` / `REVIEW` / `FAIL` — rolled up from chain | +| `risk_score` | `INTEGER` | 0–100; CHECK constraint enforced | +| `risk_level` | `risk_level` | `LOW` / `MODERATE` / `HIGH` / `CRITICAL` | +| `created_at`, `updated_at` | `TIMESTAMPTZ` | | + +#### `visit_verifications` + +Immutable rollup binding the full verification evidence package for a visit. One record per visit (`visit_id UNIQUE`). + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `visit_id` | `UUID` | UNIQUE; references visits | +| `result` | `verification_result` | Composite outcome | +| `risk_score` | `INTEGER` | 0–100 at time of verification | +| `risk_level` | `risk_level` | | +| `chain` | `JSONB` | Per-step results & contributing factors (identity/gps/device/patient/fraud) | +| `evidence_hash` | `TEXT` | SHA-256 over the canonical evidence package | +| `approved_by_id` | `UUID` | FK → users (manual approval) | +| `approved_at` | `TIMESTAMPTZ` | | +| `created_at` | `TIMESTAMPTZ` | Write-once; no trigger needed (no `UPDATE` path) | + +#### `identity_verifications` + +Append-only record of each identity verification attempt. Multiple records per visit are possible (retry scenarios). + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `visit_id` | `UUID` | FK → visits (nullable; enrollment-time verifications have no visit) | +| `caregiver_id` | `UUID` | FK → caregivers | +| `method` | `identity_method` | `SELFIE` / `LIVENESS` / `DEVICE_TRUST` / `FINGERPRINT` / `NFC_CARD` / `GOV_CREDENTIAL` | +| `result` | `verification_result` | `PASS` / `REVIEW` / `FAIL` | +| `confidence_score` | `NUMERIC(5,4)` | 0.0000–1.0000 face-match confidence | +| `liveness_score` | `NUMERIC(5,4)` | 0.0000–1.0000 anti-spoofing liveness probability | +| `probe_s3_key` | `TEXT` | S3 key of encrypted probe image (PHI) | +| `matcher` | `TEXT` | Internal model ID or vendor identifier | +| `reasons` | `JSONB` | Array of explainability factors | +| `created_at` | `TIMESTAMPTZ` | Immutable; `trg_idv_immutable` blocks UPDATE/DELETE | + +#### `gps_verifications` + +Append-only GPS evidence and geofence decision for each clock event. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `visit_id` | `UUID` | FK → visits | +| `latitude`, `longitude` | `NUMERIC(9,6)` | Captured device coordinates | +| `accuracy_meters` | `NUMERIC(7,2)` | Device-reported GPS accuracy | +| `distance_meters` | `NUMERIC(10,2)` | Haversine distance from authorized address | +| `result` | `verification_result` | `PASS` (within radius) / `REVIEW` / `FAIL` | +| `captured_at` | `TIMESTAMPTZ` | Device-side timestamp | +| `event_type` | `TEXT` | `CLOCK_IN` / `CLOCK_OUT` / `MID_VISIT` | +| `raw_payload` | `JSONB` | Full GPS provider payload for forensic use | +| `created_at` | `TIMESTAMPTZ` | Immutable; `trg_gps_immutable` blocks UPDATE/DELETE | + +#### `device_verifications` + +Append-only device posture snapshot. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `visit_id` | `UUID` | FK → visits (nullable) | +| `device_id` | `UUID` | FK → devices | +| `result` | `verification_result` | | +| `trust_level` | `device_trust_level` | Point-in-time posture at capture | +| `ip_address` | `INET` | | +| `signals` | `JSONB` | `{ emulator, rooted, jailbroken, vpn, cloneApp, ... }` | +| `created_at` | `TIMESTAMPTZ` | Immutable; `trg_dv_immutable` blocks UPDATE/DELETE | + +--- + +### Group E — Fraud Intelligence + +#### `fraud_events` + +A single detected fraud signal. Append-only; never mutated after insert. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `visit_id` | `UUID` | FK → visits (nullable; SET NULL on visit delete) | +| `case_id` | `UUID` | FK → fraud_cases (nullable; SET NULL when unlinked) | +| `type` | `fraud_event_type` | One of 13 signal types (see §3.5) | +| `status` | `fraud_event_status` | `OPEN` → `TRIAGED` → `LINKED_TO_CASE` → `DISMISSED` / `CONFIRMED` | +| `severity` | `INTEGER` | 0–100; CHECK constraint; detector confidence contribution | +| `risk_level` | `risk_level` | Derived from severity | +| `explanation` | `TEXT` | Human-readable rationale | +| `evidence` | `JSONB` | Structured supporting data (coordinates, timestamps, amounts) | +| `detector` | `TEXT` | Detector identifier | +| `detector_version` | `TEXT` | Detector version for reproducibility | +| `detected_at` | `TIMESTAMPTZ` | Immutable; `trg_fe_immutable` blocks UPDATE/DELETE | + +**Fraud event type catalog:** + +| Type | Signal | +|---|---| +| `IMPOSSIBLE_TRAVEL` | Caregiver at two locations faster than physically possible | +| `DUPLICATE_VISIT` | Overlapping visit records for the same caregiver | +| `SHARED_DEVICE` | Same device used by multiple caregivers concurrently | +| `GPS_ANOMALY` | Clock-in outside geofence or mock GPS detected | +| `IDENTITY_MISMATCH` | Face confidence below threshold / liveness failure | +| `UNUSUAL_BILLING` | Billed units exceed authorized units | +| `ABNORMAL_DURATION` | Visit duration statistically anomalous vs. history | +| `EXCESSIVE_OVERTIME` | Caregiver hours exceed regulatory limits | +| `SERVICE_OVERLAP` | Patient receiving same service from two caregivers concurrently | +| `CROSS_PROVIDER_RISK` | Risk signal aggregated across multiple providers | +| `LIVENESS_FAILURE` | Anti-spoofing liveness score below threshold | +| `DEVICE_TAMPERING` | Rooted / jailbroken / emulator device detected | +| `GEOFENCE_BREACH` | GPS coordinates outside the authorized service address radius | + +#### `fraud_cases` + +An investigation grouping related fraud events for formal case management. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `case_number` | `TEXT` | Human-friendly: `RV-2026-000123`; UNIQUE per org | +| `title` | `TEXT` | Auto-generated or investigator-set | +| `status` | `case_status` | `OPEN` → `IN_REVIEW` → `ESCALATED` → `PENDING_PAYMENT_HOLD` → `SUBSTANTIATED` / `UNSUBSTANTIATED` → `CLOSED` | +| `priority` | `case_priority` | `LOW` / `MEDIUM` / `HIGH` / `URGENT` | +| `risk_level` | `risk_level` | | +| `provider_id` | `UUID` | FK → providers (primary subject of investigation) | +| `assignee_id` | `UUID` | FK → users (investigator) | +| `exposure_cents` | `INTEGER` | Estimated program dollars at risk | +| `summary` | `TEXT` | Investigator narrative | +| `opened_at`, `closed_at` | `TIMESTAMPTZ` | | +| `created_at`, `updated_at` | `TIMESTAMPTZ` | `trg_case_touch` maintains `updated_at` | + +#### `case_notes` and `case_evidence` + +`case_notes` — investigator narrative entries on a case. `is_internal` notes are never included in beneficiary disclosure exports. `case_evidence` — chain-of-custody evidence items (visit records, GPS traces, identity images, uploaded documents) with `content_hash` (SHA-256) for integrity verification. + +#### `fraud_scores` + +Time-series of computed risk scores for any subject type. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `subject_type` | `score_subject_type` | `VISIT` / `PROVIDER` / `CAREGIVER` / `PATIENT` / `CLAIM` | +| `subject_id` | `UUID` | References the relevant entity (polymorphic by subject_type) | +| `score` | `INTEGER` | 0–100; CHECK constraint | +| `risk_level` | `risk_level` | Derived: 0–30=LOW, 31–60=MODERATE, 61–80=HIGH, 81–100=CRITICAL | +| `factors` | `JSONB` | SHAP-style per-feature contributions for explainability | +| `model_version` | `TEXT` | Scoring model version for reproducibility | +| `computed_at` | `TIMESTAMPTZ` | | + +#### `provider_risk_profiles` + +Denormalized current risk standing per provider — one row per provider, kept hot for the risk ranking dashboard. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `provider_id` | `UUID` | UNIQUE; FK → providers | +| `current_score` | `INTEGER` | 0–100 latest composite score | +| `risk_level` | `risk_level` | | +| `verification_failures` | `INTEGER` | Rolling count | +| `gps_anomalies` | `INTEGER` | Rolling count | +| `billing_anomalies` | `INTEGER` | Rolling count | +| `identity_issues` | `INTEGER` | Rolling count | +| `open_cases` | `INTEGER` | Sync'd from fraud_cases count | +| `substantiated_cases` | `INTEGER` | Historical confirmed fraud count | +| `trend` | `JSONB` | Sparkline array `[{ t: epoch, score: int }]` | +| `last_computed_at` | `TIMESTAMPTZ` | | +| `updated_at` | `TIMESTAMPTZ` | | + +--- + +### Group F — Reporting & Audit + +#### `reports` + +Report job records and their S3 output pointers. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `type` | `report_type` | `FRAUD_SUMMARY` / `PROVIDER_RISK` / `VISIT_VERIFICATION` / `INVESTIGATION` / `STATE_COMPLIANCE` / `EXECUTIVE_DASHBOARD` | +| `format` | `report_format` | `PDF` / `XLSX` / `CSV` / `JSON` | +| `status` | `report_status` | `QUEUED` → `GENERATING` → `READY` / `FAILED` / `EXPIRED` | +| `parameters` | `JSONB` | Filters, date range, scope passed at request time | +| `s3_key` | `TEXT` | Set when READY; presigned URL generated on download | +| `requested_by_id` | `UUID` | FK → users | +| `expires_at` | `TIMESTAMPTZ` | Default: 90 days; after which status=EXPIRED and S3 object lifecycle'd | +| `created_at`, `completed_at` | `TIMESTAMPTZ` | | + +#### `notifications` + +In-app, email, SMS, and webhook notification records. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | PK | +| `organization_id` | `UUID` | FK → organizations | +| `user_id` | `UUID` | FK → users (nullable for webhook channel) | +| `channel` | `notification_channel` | `IN_APP` / `EMAIL` / `SMS` / `WEBHOOK` | +| `status` | `notification_status` | `PENDING` → `SENT` → `DELIVERED` → `READ` / `FAILED` | +| `title` | `TEXT` | | +| `body` | `TEXT` | | +| `data` | `JSONB` | Deep-link context: `{ caseId, visitId, fraudEventId }` | +| `read_at` | `TIMESTAMPTZ` | | +| `created_at` | `TIMESTAMPTZ` | | + +#### `audit_logs` + +Immutable, append-only, tamper-evident audit trail. Partitioned monthly by `created_at`. + +| Column | Type | Notes | +|---|---|---| +| `id` | `UUID` | Part of composite PK | +| `created_at` | `TIMESTAMPTZ` | Part of composite PK; partition key | +| `organization_id` | `UUID` | Tenant scoped; RLS enforced | +| `actor_id` | `UUID` | FK → users (nullable for system actions; SET NULL on user delete) | +| `action` | `audit_action` | `CREATE` / `READ` / `UPDATE` / `DELETE` / `LOGIN` / `LOGOUT` / `EXPORT` / `VERIFY` / `SCORE` / `CASE_ACTION` / `CONFIG_CHANGE` | +| `resource_type` | `TEXT` | Logical resource: `fraud_case`, `visit`, `report`, `user`, etc. | +| `resource_id` | `TEXT` | UUID string of the affected record | +| `ip_address` | `INET` | | +| `user_agent` | `TEXT` | | +| `metadata` | `JSONB` | Before/after diff or action context (PHI-scrubbed before write) | +| `prev_hash` | `TEXT` | SHA-256 of the previous row in the per-tenant chain | +| `hash` | `TEXT` | SHA-256 of `prev_hash \|\| org_id \|\| actor_id \|\| action \|\| resource_type \|\| resource_id \|\| metadata \|\| created_at` | + +--- + +## 4. Indexing, Partitioning & Data Integrity + +### 4.1 Index Inventory + +| Index Name | Table | Columns | Purpose | +|---|---|---|---| +| `idx_users_org_status` | `users` | `(organization_id, status)` | Tenant-scoped user lookups by status | +| `idx_sessions_user_exp` | `sessions` | `(user_id, expires_at)` | Efficient session validation and cleanup | +| `idx_providers_org_active` | `providers` | `(organization_id, is_active)` | Active provider lookups within tenant | +| `idx_caregivers_org_provider` | `caregivers` | `(organization_id, provider_id)` | Caregiver lists per provider | +| `idx_enrollments_caregiver_active` | `biometric_enrollments` | `(caregiver_id, is_active)` | Active enrollment lookup at clock-in | +| `idx_patients_org` | `patients` | `(organization_id)` | Tenant patient roster | +| `idx_auth_org_patient_active` | `service_authorizations` | `(organization_id, patient_id, is_active)` | Authorization lookup for GPS geofence check | +| `idx_devices_org_trust` | `devices` | `(organization_id, trust_level)` | Device trust dashboard and BLOCKED device lookups | +| `idx_devices_fingerprint` | `devices` | `(fingerprint_hash)` | Cross-organization duplicate device detection | +| `idx_visits_org_status` | `visits` | `(organization_id, status)` | Visit list views by status | +| `idx_visits_org_sched` | `visits` | `(organization_id, scheduled_start)` | Date-range visit queries within tenant | +| `idx_visits_caregiver` | `visits` | `(caregiver_id, scheduled_start)` | Caregiver visit history (impossible travel lookups) | +| `idx_visits_patient` | `visits` | `(patient_id, scheduled_start)` | Patient visit history (overlap / duplicate checks) | +| `idx_visits_provider` | `visits` | `(provider_id, scheduled_start)` | Provider audit view | +| `idx_vv_org_result` | `visit_verifications` | `(organization_id, result)` | Compliance dashboard: FAIL/REVIEW visit counts | +| `idx_idv_org_result` | `identity_verifications` | `(organization_id, result)` | Identity failure trend analysis | +| `idx_idv_caregiver` | `identity_verifications` | `(caregiver_id, created_at)` | Caregiver identity history for liveness-failure detector | +| `idx_gps_org_result` | `gps_verifications` | `(organization_id, result)` | GPS anomaly trend analysis | +| `idx_gps_visit` | `gps_verifications` | `(visit_id, captured_at)` | All GPS events for a given visit | +| `idx_dv_org_result` | `device_verifications` | `(organization_id, result)` | Device tampering trend analysis | +| `idx_fe_org_type_status` | `fraud_events` | `(organization_id, type, status)` | Fraud alert dashboard by signal type | +| `idx_fe_org_detected` | `fraud_events` | `(organization_id, detected_at)` | Fraud event time-series charts | +| `idx_fe_case` | `fraud_events` | `(case_id)` | Events per case lookup | +| `idx_cases_org_status` | `fraud_cases` | `(organization_id, status, priority)` | Case queue sorted by priority | +| `idx_cases_assignee` | `fraud_cases` | `(assignee_id)` | My cases / investigator workload | +| `idx_notes_case` | `case_notes` | `(case_id, created_at)` | Case note timeline | +| `idx_evidence_case` | `case_evidence` | `(case_id)` | Evidence items per case | +| `idx_scores_subject` | `fraud_scores` | `(organization_id, subject_type, subject_id, computed_at DESC)` | Latest score + score history per subject | +| `idx_risk_org_level` | `provider_risk_profiles` | `(organization_id, risk_level)` | Provider risk ranking by level | +| `idx_reports_org_type` | `reports` | `(organization_id, type, status)` | Report list and status polling | +| `idx_notif_org_user` | `notifications` | `(organization_id, user_id, status)` | Unread notification inbox | +| `idx_audit_org_created` | `audit_logs` | `(organization_id, created_at)` | Audit log time-range exports | +| `idx_audit_org_resource` | `audit_logs` | `(organization_id, resource_type, resource_id)` | Resource-specific audit trail lookups | +| `idx_audit_actor` | `audit_logs` | `(actor_id)` | User activity audit | + +### 4.2 Partitioning Strategy + +```mermaid +flowchart TD + V[visits\nPARTITION BY RANGE scheduled_start] + V --> VP1[visits_2026_05\nMAY 2026] + V --> VP2[visits_2026_06\nJUN 2026] + V --> VP3[visits_2026_07\nJUL 2026] + V --> VPD[visits_default\ncatch-all] + + A[audit_logs\nPARTITION BY RANGE created_at] + A --> AP1[audit_logs_2026_06\nJUN 2026] + A --> AP2[audit_logs_2026_07\nJUL 2026] + A --> APD[audit_logs_default\ncatch-all] + + CRON[pg_partman / cron job\n1st of each month] + CRON -->|"CREATE PARTITION\nnext month"| V + CRON -->|"CREATE PARTITION\nnext month"| A + CRON -->|"DETACH old partitions\n> 24 months"| DETACH[Detached partitions\npg_dump → S3 Glacier] +``` + +**Why monthly range partitioning:** + +- Visit and audit log data grows linearly with caregiver activity. A multi-state deployment may generate millions of visit rows per month. Partitioning allows: + - **Fast date-range queries:** the planner prunes irrelevant partitions. + - **Cheap archival:** `ALTER TABLE visits DETACH PARTITION visits_2024_01` removes the partition from the live table without a full table scan. The detached partition is then dumped and archived to S3 Glacier. + - **Regulatory compliance:** monthly partitions map cleanly to billing cycle retention windows. + +**Catch-all partitions (`visits_default`, `audit_logs_default`):** Prevent insert failures if a partition creation job runs late. The cron job moves rows from the default partition to the named partition after creation. + +**Retention:** Active partitions are kept for 24 months in RDS. Detached partitions are archived to S3 Glacier Deep Archive for 7 years (HIPAA minimum) then automatically expired by S3 lifecycle policy. + +### 4.3 Constraints & Data Integrity + +| Constraint | Table | Definition | Purpose | +|---|---|---|---| +| `chk_npi_format` | `providers` | `npi ~ '^[0-9]{10}$'` | Enforce 10-digit NPI format | +| `chk_radius_positive` | `service_authorizations` | `radius_meters > 0` | Prevent zero-radius geofence | +| `chk_lat` | `service_authorizations` | `latitude BETWEEN -90 AND 90` | Valid latitude range | +| `chk_lng` | `service_authorizations` | `longitude BETWEEN -180 AND 180` | Valid longitude range | +| `chk_visit_risk` | `visits` | `risk_score BETWEEN 0 AND 100` | Bounded risk score | +| `chk_severity` | `fraud_events` | `severity BETWEEN 0 AND 100` | Bounded severity | +| `chk_score` | `fraud_scores` | `score BETWEEN 0 AND 100` | Bounded composite score | +| `UNIQUE (organization_id, email)` | `users` | | Email unique per tenant | +| `UNIQUE (organization_id, key)` | `roles` | | Role key unique per tenant | +| `UNIQUE (organization_id, npi)` | `providers` | | NPI unique per tenant | +| `UNIQUE (organization_id, device_id)` | `devices` | | Device unique per tenant | +| `UNIQUE (organization_id, case_number)` | `fraud_cases` | | Case number unique per tenant | +| `UNIQUE visit_id` | `visit_verifications` | | One verification package per visit | +| `UNIQUE provider_id` | `provider_risk_profiles` | | One risk profile per provider | + +### 4.4 Immutability Triggers + +The `rv_forbid_mutation()` trigger function raises `integrity_constraint_violation` on any `UPDATE` or `DELETE` against the four append-only evidence tables and `audit_logs`: + +```sql +CREATE OR REPLACE FUNCTION rv_forbid_mutation() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'Table % is append-only; % is not permitted', + TG_TABLE_NAME, TG_OP USING ERRCODE = 'integrity_constraint_violation'; +END; $$ LANGUAGE plpgsql; +``` + +Triggers: `trg_idv_immutable`, `trg_gps_immutable`, `trg_dv_immutable`, `trg_fe_immutable`, `trg_audit_immutable` — all `BEFORE UPDATE OR DELETE FOR EACH ROW`. + +### 4.5 Audit Hash Chain Design + +Each `audit_logs` insert automatically computes `prev_hash` and `hash` via the `rv_audit_hash_chain()` `BEFORE INSERT` trigger: + +``` +hash_n = SHA256( + prev_hash_{n-1} -- empty string for first row per tenant + || organization_id -- tenant scope + || actor_id -- who acted + || action -- what action + || resource_type -- on what resource type + || resource_id -- on what record + || metadata::text -- PHI-scrubbed context + || created_at::text -- when +) +``` + +**Chain verification procedure:** + +1. `SELECT id, organization_id, actor_id, action, resource_type, resource_id, metadata, created_at, prev_hash, hash FROM audit_logs WHERE organization_id = $1 ORDER BY created_at ASC, id ASC`. +2. For each row in sequence: recompute `expected_hash = SHA256(prev_hash || fields...)`. +3. Compare `expected_hash` to the stored `hash`. Any mismatch indicates tampering of that row or any predecessor. +4. A government auditor or OIG agent can run this verification offline using only the exported data — no secret key required. + +**Note:** The chain is per-tenant (keyed by `organization_id`). The trigger fetches the most recent hash for the same tenant on insert. In high-concurrency environments a serialization advisory lock per `organization_id` is recommended to prevent hash-chain gaps. + +--- + +## 5. Multi-Tenant Isolation via Row-Level Security + +### 5.1 RLS Policy Application + +The DDL block in `db/schema.sql` applies three statements to every tenant-scoped table in a `DO $$` loop: + +```sql +ALTER TABLE
ENABLE ROW LEVEL SECURITY; +ALTER TABLE
FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON
+ USING (organization_id = current_setting('app.current_org', true)::uuid) + WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid); +``` + +Tables covered: `users`, `roles`, `providers`, `caregivers`, `patients`, `service_authorizations`, `devices`, `visits`, `visit_verifications`, `identity_verifications`, `gps_verifications`, `device_verifications`, `fraud_events`, `fraud_cases`, `fraud_scores`, `provider_risk_profiles`, `reports`, `notifications`, `audit_logs`. + +`FORCE ROW LEVEL SECURITY` ensures the policy applies even when the connected role is the table owner — preventing accidental bypass. + +### 5.2 Setting the GUC from Application Code + +NestJS Prisma middleware pattern: + +```typescript +// Simplified; actual implementation wraps every query in a transaction +prisma.$use(async (params, next) => { + await prisma.$executeRawUnsafe( + `SET LOCAL app.current_org = '${orgId}'` + ); + return next(params); +}); +``` + +`SET LOCAL` scopes the GUC to the current transaction only — it is automatically reset when the transaction ends, preventing GUC leakage across connection pool hops. + +### 5.3 Operations / Migration Role + +A dedicated PostgreSQL role (`rv_ops`) is granted `BYPASSRLS`. It is used only by: + +- Prisma migration runner (`npx prisma migrate deploy`). +- Nightly partition management cron jobs. +- DBA emergency access (MFA-gated bastion host). + +The application service account (`rv_app`) does **not** have BYPASSRLS and cannot access another tenant's data even with a crafted query. + +--- + +## 6. PHI Handling & Data Retention + +### 6.1 PHI Column Inventory + +| Table | Column | PHI Type | Protection | +|---|---|---|---| +| `patients` | `medicaid_member_id` | Medicaid ID | AES-256-GCM (KMS envelope) + blind index | +| `patients` | `first_name`, `last_name` | Name | AES-256-GCM (KMS envelope) | +| `patients` | `date_of_birth` | DOB | AES-256-GCM (KMS envelope) | +| `caregivers` | `first_name`, `last_name`, `email`, `phone` | PII (caregiver employee) | Stored plaintext in DB; TLS in transit; role-scoped access | +| `users` | `mfa_secret` | TOTP secret | AES-256-GCM (KMS envelope) | +| `providers` | `tax_id` | Federal Tax ID | AES-256-GCM (app layer) | +| `identity_verifications` | `probe_s3_key` | Biometric image pointer | S3 object: SSE-KMS; access gated by presigned URL + IAM | +| `biometric_enrollments` | `reference_s3_key` | Biometric reference image | S3 object: SSE-KMS; access gated by presigned URL + IAM | +| `audit_logs` | `metadata` | Contextual — PHI-scrubbed before write | PHI fields removed by `AuditService.sanitize()` before insert | + +### 6.2 Envelope Encryption Pattern + +```mermaid +flowchart LR + PLAIN["Plaintext PHI\ne.g. medicaid_member_id"] + DEK["Data Encryption Key (DEK)\nAES-256-GCM, generated per tenant per day"] + KMS["AWS KMS\nCustomer Managed Key (CMK)"] + CIPHER["Ciphertext stored in column\nBase64(IV || tag || ciphertext)"] + EDEK["Encrypted DEK stored in\norganizations.settings.encryptedDek"] + + PLAIN -->|"encrypt with DEK"| CIPHER + DEK -->|"wrapped by KMS CMK"| EDEK + EDEK -->|"on read: unwrap"| DEK + CIPHER -->|"decrypt with DEK"| PLAIN +``` + +- Each tenant's DEK is cached in-process (LRU, 15 min TTL) to avoid per-row KMS calls. +- Key rotation: a new DEK is generated on a schedule; old ciphertext is re-encrypted in a background job. + +### 6.3 Blind Index for Searchable PHI + +`patients.medicaid_member_id` must be searchable (e.g. "look up patient by Medicaid ID") without decrypting. A `medicaid_member_id_blind_idx` column (not in the Prisma model for clarity; added in raw SQL migration) stores `HMAC-SHA256(medicaid_member_id, blind_index_key)`. Search queries compare against the blind index. The blind index key is separate from the DEK and stored in AWS Secrets Manager. + +### 6.4 Data Retention & Purge + +| Data Category | Retention | Mechanism | +|---|---|---| +| Active visits + verifications | 24 months hot (RDS) | Partition detach + archive | +| Archived partitions | 7 years (HIPAA minimum) | S3 Glacier Deep Archive | +| Biometric probe images (S3) | 7 years | S3 lifecycle: transition to Glacier at 90 days, expire at 7 years | +| Report files (S3) | 90 days | S3 lifecycle: expire at 90 days | +| Case evidence (S3) | 7 years | S3 lifecycle: Glacier at 1 year, expire at 7 years | +| Audit logs | 7 years (regulatory) | Partitioned; archived to Glacier | +| Sessions | 30 days (or on revocation) | Cron: `DELETE FROM sessions WHERE expires_at < now()` | +| Notifications | 90 days | Cron: delete delivered/read notifications older than 90 days | +| Terminated tenant data | Purge on written request | `DELETE FROM organizations WHERE id = $1 CASCADE` (cascades to all tenant rows via FK); S3 objects purged via object tagging | + +--- + +## 7. Migration Strategy, Seeding & Performance + +### 7.1 Migration Strategy + +```mermaid +flowchart TD + DEV["Developer makes schema change"] + PRISMA["Edit prisma/schema.prisma\nnpx prisma migrate dev --name "] + RAWSQL["If change involves:\n- Partitioning\n- RLS policies\n- Triggers\n→ Add raw SQL to migration file\nprisma/migrations/_/migration.sql"] + REVIEW["PR review: migration + raw SQL reviewed\nagainst db/schema.sql source of truth"] + CI["CI: npx prisma migrate deploy\nagainst ephemeral PostgreSQL\nRun integration tests"] + PROD["Production: ECS deploy runs\nprisma migrate deploy on startup\n(idempotent — only unapplied migrations run)"] + DOCUPDATE["Update db/schema.sql\nto reflect physical state\n(keep in sync manually or via CI diff check)"] + + DEV --> PRISMA --> RAWSQL --> REVIEW --> CI --> PROD --> DOCUPDATE +``` + +**Key rules:** + +- Prisma migration files are **never edited after merging**. If a migration needs to be corrected, a new migration is created. +- Destructive migrations (DROP COLUMN, DROP TABLE) must be preceded by a two-phase migration: first add the replacement, then remove the old in a separate PR after code is deployed. +- `db/schema.sql` is kept manually in sync with the production state and acts as the human-readable reference for DBA review. + +### 7.2 Seeding + +`npm run db:seed` (`packages/backend/prisma/seed.ts`) creates: + +1. A default `organizations` row (slug: `demo-state-agency`). +2. System roles: `ORG_ADMIN`, `INVESTIGATOR`, `AUDITOR`, `COMPLIANCE_OFFICER`, `OIG_AGENT`. +3. Global permissions catalog (all `resource:action` pairs). +4. Role-permission assignments for the system roles. +5. A demo admin user for local development (never run in production). + +### 7.3 Connection Pooling + +| Environment | Pooler | Pool Size | +|---|---|---| +| Development | Direct Prisma connection | `connection_limit=5` | +| Staging / Production | AWS RDS Proxy (PgBouncer mode) | `max_pool_size=100` per service; `default_pool_size=20` | +| Report workers (read replica) | RDS Proxy read-only endpoint | `max_pool_size=20`; `statement_timeout=30s` | + +PgBouncer transaction-mode pooling is compatible with `SET LOCAL` GUC calls because the GUC is scoped to the transaction, not the connection, and is reset automatically on `COMMIT` / `ROLLBACK`. + +### 7.4 Read Replica Usage + +| Query Pattern | Route | Rationale | +|---|---|---| +| Visit clock-in/out writes | Primary | Requires immediate read-your-writes consistency | +| Fraud detection worker reads | Primary | Must see latest clock-out data | +| Report generation queries | Read replica | Heavy aggregation; can tolerate < 1 s replication lag | +| Provider risk dashboard | Read replica | Denormalized; staleness acceptable | +| Audit log exports | Read replica | Historical data; no lag concern | +| Investigator dashboard list views | Read replica | Eventual consistency acceptable | + +### 7.5 Performance Notes + +- `pg_trgm` GIN indexes on `providers.legal_name`, `fraud_cases.case_number`, and `patients.last_name` enable sub-millisecond fuzzy search in the investigator dashboard. +- JSONB columns (`chain`, `evidence`, `signals`, `factors`, `trend`) are not individually indexed unless specific key paths are queried frequently; targeted `jsonb_path_ops` GIN indexes can be added per path as query patterns emerge. +- The `fraud_scores` table grows as a time series; a background job periodically downsizes by keeping only the latest N score records per subject and archiving older rows (configurable per tenant in `organizations.settings`). + +--- + +*Document version: 1.0 | Platform: RayVerify™ | Classification: Investor/Government Distribution* diff --git a/docs/04-api-design.md b/docs/04-api-design.md new file mode 100644 index 0000000..023f8d5 --- /dev/null +++ b/docs/04-api-design.md @@ -0,0 +1,134 @@ +# 04 — API Design + +> Companion to the machine-readable contract in [`api/openapi.yaml`](../api/openapi.yaml). +> The live spec is generated by the backend (NestJS + `@nestjs/swagger`) and served +> at `/{prefix}/docs` (Swagger UI) and `/{prefix}/docs-json`. + +## 1. Principles + +- **API-first.** The OpenAPI document is authored alongside the controllers; DTOs use `class-validator` + `@nestjs/swagger` decorators so the served spec never drifts from the implementation. +- **Tenant-scoped by construction.** Every authenticated request resolves an `organizationId` from the JWT and binds it to the request context; Postgres Row-Level Security enforces isolation even if application code forgets a filter (see [07 — Security](07-security-architecture.md)). +- **Audited.** State-changing requests produce an immutable `audit_logs` row (hash-chained). +- **Least privilege.** Endpoints declare required permissions (`resource:action`); the `PermissionsGuard` enforces them. +- **Predictable.** Consistent pagination, error envelope, and resource naming. + +## 2. Base URL & versioning + +``` +https://api.rayverify.example/{prefix}/{version} e.g. /api/v1 +``` + +- **URI versioning** (`/api/v1`, `/api/v2`). The major version only changes on breaking contracts. +- Additive changes (new optional fields, new endpoints) ship within a version. +- Deprecations are announced via the `Deprecation` and `Sunset` response headers and the changelog. + +## 3. Authentication & authorization + +| Step | Mechanism | +|------|-----------| +| Login | `POST /auth/login` with `organizationSlug` + `email` + `password` (+ `mfaCode` when MFA enabled). | +| Access token | Short-lived JWT (default 15m) carrying `sub`, `org`, `roles`, `permissions`. Sent as `Authorization: Bearer `. | +| Refresh | Opaque, rotated refresh token (default 30d). Stored server-side only as a SHA-256 hash (`sessions.refreshTokenHash`). `POST /auth/refresh` revokes the old session and issues a new pair. | +| Logout | `POST /auth/logout` revokes the refresh token. | +| MFA | TOTP / SMS / WebAuthn; enforced at login when `users.mfaMethod != NONE`. | +| Lockout | `failed_logins` ≥ 5 ⇒ temporary lock (`locked_until`). | + +Authorization is RBAC. A route declares `@RequirePermissions('fraud_case:assign')`; the user's effective permission set is the union of their roles' permissions. `*` grants all (super-admin roles). + +## 4. Conventions + +### Pagination +List endpoints accept `page` (≥1, default 1) and `pageSize` (1–200, default 25) and return: + +```json +{ "data": [ ... ], "page": 1, "pageSize": 25, "total": 134, "totalPages": 6 } +``` + +### Filtering & search +Resource-specific query params (e.g. `status`, `type`, `resourceType`) plus a free-text `q` where supported. Filtering is always applied **within** the tenant scope. + +### Errors (RFC 7807-style) +All errors share one envelope; stack traces and PHI are never returned to clients. + +```json +{ + "type": "https://docs.rayverify.gov/errors/403", + "status": 403, + "title": "Missing required permission(s): fraud_case:assign", + "instance": "/api/v1/cases/123/assign", + "requestId": "8f1c…", + "timestamp": "2026-06-10T12:00:00.000Z" +} +``` + +| Status | Meaning | +|--------|---------| +| 400 | Validation error (whitelisted DTOs, `forbidNonWhitelisted`). | +| 401 | Missing/expired/invalid token. | +| 403 | Authenticated but lacking the required permission. | +| 404 | Resource not found **in the caller's tenant**. | +| 409 | Conflict (e.g. duplicate case number). | +| 422 | Semantically invalid (business rule). | +| 429 | Rate limited (see below). | +| 5xx | Server error (logged with `requestId`; generic title to client). | + +### Idempotency +Mutating, retry-prone endpoints (clock-in/out, scoring, report requests) accept an `Idempotency-Key` header; the server de-duplicates within a TTL window so client retries don't double-write evidence. + +### Rate limiting +Global throttle (default 120 req/min/principal) via `@nestjs/throttler`; tighter limits on auth endpoints. `429` includes `Retry-After`. + +### Dates, IDs, money, geo +- Timestamps: ISO-8601 UTC. IDs: UUID v4. Money: integer **cents**. Coordinates: decimal degrees (`lat`/`lng`). + +## 5. Endpoint catalog + +| Area | Method & path | Permission | Purpose | +|------|---------------|------------|---------| +| Auth | `POST /auth/login` `/refresh` `/logout`, `GET /auth/me` | public / — | Session lifecycle. | +| Identity (M1) | `POST /identity/verify` | `identity:verify` | Selfie + liveness verification → append-only event. | +| Visits (M2) | `POST /visits`, `GET /visits`, `GET /visits/{id}` | `visit:create` / `visit:read` | Manage visits + read verification package. | +| Visits (M2) | `POST /visits/{id}/clock-in` `/clock-out` | `visit:clock` | Capture GPS + device evidence. | +| Visits (M2) | `POST /visits/{id}/verify` | `visit:verify` | Run the chain → rollup + approval decision. | +| Fraud (M3) | `GET /fraud/events` | `fraud_event:read` | List detected fraud signals. | +| Fraud (M3) | `POST /fraud/visits/{id}/score` | `fraud:score` | Run detectors, persist events + score. | +| Cases (M4) | `POST/GET /cases`, `GET /cases/{id}` | `fraud_case:*` | Investigation case management. | +| Cases (M4) | `PATCH /cases/{id}/assign` `/status`, `POST /cases/{id}/notes` | `fraud_case:assign` / `:update` | Workflow actions. | +| Providers (M5) | `GET /providers/risk-ranking`, `GET /providers/{id}/risk-profile` | `provider:read` | Risk rankings & trend. | +| Providers (M5) | `POST /providers/{id}/risk-profile/recompute` | `provider:score` | Recompute risk profile. | +| Audit (M6) | `GET /audit/logs`, `GET /audit/verify-chain` | `audit:read` | Search trail; verify hash chain. | +| Reports (M7) | `POST/GET /reports`, `GET /reports/{id}` | `report:create` / `:read` | Queue & retrieve reports (PDF/XLSX). | +| Notifications | `GET /notifications`, `PATCH /notifications/{id}/read` | authenticated | In-app alerts. | +| Hardware (M8) | `GET /hardware/capabilities` | authenticated | Supported capabilities + registered drivers. | +| Health | `GET /health` | public | Liveness/readiness. | + +## 6. Key request flows + +**Visit verification chain** (`POST /visits/{id}/verify`) executes identity → GPS → device → patient → fraud scoring, writes the immutable `visit_verifications` rollup with an `evidenceHash`, and sets the visit's status (`APPROVED` / `FLAGGED` / `REJECTED`). See [02 — System Architecture](02-system-architecture.md) and [05 — Fraud Detection Engine](05-fraud-detection-engine.md). + +**Scoring response** includes the explainable factor breakdown (every score shows *why*): + +```json +{ + "visitId": "…", + "composite": { + "score": 86, "riskLevel": "CRITICAL", "triggeredCount": 2, + "factors": [ + { "type": "GPS_ANOMALY", "severity": 92, "weight": 0.85, "contribution": 0.56, "explanation": "Clock-in was 28000m from the authorized service address…" }, + { "type": "ABNORMAL_DURATION", "severity": 70, "weight": 0.6, "contribution": 0.44, "explanation": "Visit lasted only 1.0 min…" } + ] + }, + "fraudEventIds": ["…", "…"] +} +``` + +## 7. Webhooks & integrations + +- **Outbound webhooks** (case opened, CRITICAL alert, report ready) are signed with an HMAC-SHA256 `X-RayVerify-Signature` header; consumers verify with the shared secret. +- **Batch ingestion** of authorizations/visits via signed S3 drops or a bulk endpoint, validated against the same DTOs. +- **State reporting exports** are generated as reports and delivered to the agency's secure channel. + +## 8. Non-goals / future + +- GraphQL gateway (under consideration for the dashboard's read models). +- Fine-grained field-level scopes beyond `resource:action` (would layer ABAC on top of RBAC). diff --git a/docs/05-fraud-detection-engine.md b/docs/05-fraud-detection-engine.md new file mode 100644 index 0000000..7aed8bb --- /dev/null +++ b/docs/05-fraud-detection-engine.md @@ -0,0 +1,1195 @@ +# RayVerify™ — Fraud Detection Engine + +> **Document 05 of 11** | Parent platform: RayHealthEVV™ +> Audience: Engineering, Program Integrity, State Medicaid Agencies, Investors +> Last updated: 2026-06-10 + +--- + +## Table of Contents + +1. [Objectives & Design Principles](#1-objectives--design-principles) +2. [Pipeline Architecture](#2-pipeline-architecture) +3. [Detector Catalog](#3-detector-catalog) +4. [Score Fusion](#4-score-fusion) +5. [Alerting & Case Linkage](#5-alerting--case-linkage) +6. [Tuning & Governance](#6-tuning--governance) + +--- + +## 1. Objectives & Design Principles + +### 1.1 Mission + +The RayVerify Fraud Detection Engine (FDE) surfaces fraudulent or erroneous Medicaid/HCBS visits **before payments are made**. Its outputs drive investigator workflows, provider risk rankings, and state agency reporting. It is not a black box: every score is accompanied by a structured explanation that satisfies the due-process requirements of Medicaid program integrity. + +### 1.2 Core Objectives + +| Objective | Description | +|-----------|-------------| +| **Pre-payment detection** | Flag suspicious visits at clock-out or claim submission, before the billing cycle runs. The `visits.status` transitions to `FLAGGED` and blocks the approval pathway until an investigator clears it. | +| **Explainability** | Every composite fraud score must carry per-factor contributions in `fraud_scores.factors` (SHAP-style). Every `fraud_events` row carries `explanation` (human-readable) and `evidence` (structured JSON). No score is actionable without a reason. | +| **Deterministic rules + ML hybrid** | Rule-based detectors provide instant, auditable, threshold-configurable decisions with zero cold-start latency. ML scorers augment rules with learned behavioral patterns and generalize to novel fraud variants. The two signal streams are fused into a single composite score. | +| **Low false-positive cost framing** | Wrongly flagging a legitimate caregiver visit has real harm: delayed payment to the worker and disrupted care. The FDE is tuned to minimize false positives at the cost of slightly lower sensitivity. Precision is the primary optimization target; recall is monitored but secondary until label volume is sufficient to calibrate a cost-weighted threshold. | +| **Human-in-the-loop** | The FDE never autonomously withholds payment. A score ≥ 61 (HIGH) triggers an alert; payment hold requires a `PENDING_PAYMENT_HOLD` case status set by an investigator. Fully automated adverse action is prohibited. | +| **Tenant configurability** | Every threshold, weight, and enabled detector is configurable per tenant via `organizations.settings` (JSONB). Defaults are research-backed starting points, not hard-coded constants. | + +### 1.3 Design Principles + +**P1 — Append-only evidence:** `fraud_events` rows are never updated or deleted (DB trigger `trg_fe_immutable`). Each detection run produces a new event. The audit trail is immutable. + +**P2 — Detector versioning:** Every `fraud_events` row carries `detector` (e.g., `impossible_travel`) and `detector_version` (e.g., `v2.1.0`). Score reproductions are always attributed to a specific detector version. This enables backtesting and regulatory challenge response. + +**P3 — Sync/async split:** Rule detectors that complete in < 100 ms run synchronously in the NestJS request pipeline. ML scorers that call the Python FastAPI scoring service are dispatched asynchronously via Redis Bull queues and write results back when complete. The visit is held in `FLAGGED` status until the async pipeline finalizes. + +**P4 — Multi-tenant isolation:** Row-level security (`SET app.current_org`) ensures no cross-tenant data exposure during scoring. Tenant-specific thresholds override defaults from `organizations.settings`. + +**P5 — Point-in-time correctness:** All lookback windows use data with `detected_at` / `scheduled_start` strictly before the current visit's `scheduledStart` to prevent temporal leakage. + +--- + +## 2. Pipeline Architecture + +### 2.1 High-Level Flow + +```mermaid +flowchart TD + A[Visit clock-out event\nor claim submission] --> B[FraudOrchestrator\nNestJS service] + B --> C[Evidence Collector\nJoin: visit + verifications + auth] + + C --> D{Sync Rule\nDetectors} + C --> E[Redis Bull Queue\nasync-fraud-score] + + D --> D1[IMPOSSIBLE_TRAVEL] + D --> D2[DUPLICATE_VISIT] + D --> D3[GPS_ANOMALY] + D --> D4[GEOFENCE_BREACH] + D --> D5[SERVICE_OVERLAP] + D --> D6[ABNORMAL_DURATION] + D --> D7[EXCESSIVE_OVERTIME] + D --> D8[UNUSUAL_BILLING] + D --> D9[SHARED_DEVICE] + D --> D10[IDENTITY_MISMATCH] + D --> D11[LIVENESS_FAILURE] + D --> D12[DEVICE_TAMPERING] + D --> D13[CROSS_PROVIDER_RISK] + + E --> F[ML Scoring Worker\nFastAPI Python service] + F --> F1[Feature Extraction\nfeature store + DB] + F1 --> F2[Anomaly Model\nIsolation Forest] + F1 --> F3[Supervised Model\nXGBoost / LightGBM] + F1 --> F4[Provider Graph\nfeatures] + F2 & F3 & F4 --> G[ML Score Package\nwith SHAP factors] + + D1 & D2 & D3 & D4 & D5 & D6 & D7 & D8 & D9 & D10 & D11 & D12 & D13 --> H[Rule Severity Package] + G --> H + + H --> I[Score Fusion\nweighted sum + caps + decay] + I --> J[Write fraud_events rows\nper fired detector] + J --> K[Write fraud_scores row\nsubjectType=VISIT] + K --> L[Update visits.risk_score\nvisits.risk_level\nvisit_verifications] + L --> M{risk_score threshold} + M -->|score ≥ 31| N[Notification fan-out\nIN_APP / EMAIL / SMS / WEBHOOK] + M -->|score ≥ 61| O[Auto-flag visit\nstatus = FLAGGED] + M -->|score ≥ 81| P[Auto-create FraudCase\npriority = URGENT] + N & O & P --> Q[Write audit_logs\naction = SCORE] +``` + +### 2.2 Sync vs Async Split + +| Path | Detectors | Latency budget | Trigger | +|------|-----------|---------------|---------| +| **Synchronous** (NestJS request) | IMPOSSIBLE_TRAVEL, DUPLICATE_VISIT, GPS_ANOMALY, GEOFENCE_BREACH, SERVICE_OVERLAP, ABNORMAL_DURATION, EXCESSIVE_OVERTIME, UNUSUAL_BILLING, SHARED_DEVICE, IDENTITY_MISMATCH, LIVENESS_FAILURE, DEVICE_TAMPERING, CROSS_PROVIDER_RISK (rule tier only) | ≤ 250 ms total | Clock-out API call | +| **Asynchronous** (Bull worker) | ML scoring service (all 13 event types as learned features + CROSS_PROVIDER_RISK graph features) | ≤ 30 s p99 | Job enqueued on clock-out; visit held in `FLAGGED` until worker writes result | + +The sync pass produces a preliminary `fraud_scores` row (`modelVersion = "rules-only"`). The async pass overwrites the composite score with the fused result (`modelVersion = "fused-v{n}"`). If the async pass does not complete within 60 seconds (configurable), the visit retains the rules-only score as a safe fallback. + +### 2.3 Evidence Collector + +Before any detector fires, the `EvidenceCollector` assembles a hydrated context object from the database: + +```typescript +interface VisitScoringContext { + visit: Visit; // from visits + auth: ServiceAuthorization | null; // from service_authorizations + gpsVerifications: GpsVerification[]; // from gps_verifications + identityVerifications: IdentityVerification[]; // from identity_verifications + deviceVerification: DeviceVerification | null; // from device_verifications + device: Device | null; // from devices + recentCaregiverVisits: Visit[]; // last 30 days, same caregiverId + recentPatientVisits: Visit[]; // last 30 days, same patientId + providerRiskProfile: ProviderRiskProfile | null; // from provider_risk_profiles + tenantSettings: FraudDetectionSettings; // from organizations.settings +} +``` + +--- + +## 3. Detector Catalog + +Each detector maps to exactly one value in the `FraudEventType` enum. All 13 detectors are described below with their signals, logic, evidence schema, severity contribution, and false-positive mitigations. + +--- + +### 3.1 IMPOSSIBLE_TRAVEL + +**Definition:** The caregiver clocks in to a new visit from a location that is physically unreachable given the elapsed time since their last clock-out. + +**Signals / inputs:** +- `visits.clockInLat`, `visits.clockInLng` (current visit) +- Previous visit's `clock_out_at`, `clock_in_lat`, `clock_in_lng` (from `recentCaregiverVisits`) +- `gps_verifications.capturedAt` (timestamp of GPS capture) + +**Detection logic:** + +Haversine distance between two GPS points: + +``` +R = 6371000 # Earth radius in meters + +φ1, λ1 = lat/lng of previous clock-out (radians) +φ2, λ2 = lat/lng of current clock-in (radians) + +Δφ = φ2 - φ1 +Δλ = λ2 - λ1 + +a = sin²(Δφ/2) + cos(φ1)·cos(φ2)·sin²(Δλ/2) +c = 2·atan2(√a, √(1−a)) +distance_m = R · c +``` + +Effective travel speed: + +``` +time_delta_s = clockInAt_current − clockOutAt_previous (seconds) +speed_mps = distance_m / max(time_delta_s, 1) +speed_kph = speed_mps * 3.6 +``` + +Thresholds (tenant-configurable): + +```python +PLAUSIBLE_SPEED_KPH = 130 # upper bound for ground/car travel +SUSPICIOUS_SPEED_KPH = 300 # below commercial aviation +IMPOSSIBLE_SPEED_KPH = 900 # no commercial route achievable + +if speed_kph > IMPOSSIBLE_SPEED_KPH: + severity = 90; risk = CRITICAL +elif speed_kph > SUSPICIOUS_SPEED_KPH: + severity = 70; risk = HIGH +elif speed_kph > PLAUSIBLE_SPEED_KPH: + severity = 45; risk = MODERATE +else: + no event emitted +``` + +**Default severity contribution:** 45–90 depending on band. + +**Evidence JSON:** +```json +{ + "previousVisitId": "uuid", + "previousClockOut": "2026-06-10T08:00:00Z", + "previousLat": 40.712800, + "previousLng": -74.006000, + "currentClockIn": "2026-06-10T08:15:00Z", + "currentLat": 34.052200, + "currentLng": -118.243700, + "distanceMeters": 3950000, + "timeDeltaSeconds": 900, + "speedKph": 15800, + "threshold": 900, + "band": "IMPOSSIBLE" +} +``` + +**Typical false-positive causes:** +- GPS inaccuracy on initial lock (high `accuracy_meters` in `gps_verifications`) — mitigate by requiring `accuracy_meters < 50` before using a GPS point as a travel anchor. +- Caregiver clocked out from a desk/admin location, not the physical care site — mitigate by using `CLOCK_IN` GPS events only, not admin clock-outs. +- Two caregivers sharing the same device ID due to organizational device re-issue — detected separately by SHARED_DEVICE. +- Manual time corrections by a supervisor — mitigate by checking `visit.updatedAt` vs `clockInAt` lag. + +--- + +### 3.2 DUPLICATE_VISIT + +**Definition:** Two or more visits for the same caregiver and patient overlap in their scheduled or actual time window, or two visits share identical attributes that are statistically improbable unless duplicated. + +**Signals / inputs:** +- `visits.caregiverId`, `visits.patientId`, `visits.scheduledStart`, `visits.scheduledEnd` +- `visits.clockInAt`, `visits.clockOutAt` +- `visits.serviceCode`, `visits.billedUnits`, `visits.billedAmountCents` + +**Detection logic:** + +```python +# Exact duplicate: same caregiver + patient + scheduled window within 1-min tolerance +exact_match = SELECT * FROM visits + WHERE caregiver_id = :caregiverId + AND patient_id = :patientId + AND id != :visitId + AND abs(extract(epoch FROM scheduled_start - :scheduledStart)) < 60 + AND status NOT IN ('CANCELLED') + +if exact_match: + severity = 85; risk = CRITICAL + +# Overlapping window: same caregiver, different patient or same patient +overlap = SELECT * FROM visits + WHERE caregiver_id = :caregiverId + AND id != :visitId + AND status NOT IN ('CANCELLED') + AND tsrange(clock_in_at, clock_out_at) && tsrange(:clockInAt, :clockOutAt) + +overlap_count = len(overlap) +if overlap_count >= 2: + severity = 75; risk = HIGH +elif overlap_count == 1: + severity = 55; risk = MODERATE +``` + +**Default severity contribution:** 55–85. + +**Evidence JSON:** +```json +{ + "duplicateVisitIds": ["uuid-a", "uuid-b"], + "matchType": "EXACT_SCHEDULE", + "overlapMinutes": 120, + "affectedPatients": ["patient-uuid"], + "billedAmountCents": 28000 +} +``` + +**False-positive causes:** Rescheduled visits that were not cancelled before a replacement was created. Mitigate by checking `visits.status = CANCELLED` exclusion and implementing a grace window (configurable `duplicateGraceMinutes`). + +--- + +### 3.3 SHARED_DEVICE + +**Definition:** Multiple caregivers use the same physical device (`devices.deviceId` or `devices.fingerprintHash`) to clock in, suggesting credential sharing, identity substitution, or device relay fraud. + +**Signals / inputs:** +- `visits.deviceId` → `devices.deviceId`, `devices.fingerprintHash` +- Recent `visits` joined by same `deviceId`, different `caregiverId` +- `devices.isEmulator`, `devices.isRooted`, `devices.isJailbroken` +- `device_verifications.signals` + +**Detection logic:** + +```python +recent_device_users = SELECT DISTINCT caregiver_id FROM visits + WHERE device_id = :deviceId + AND caregiver_id != :caregiverId + AND scheduled_start > now() - interval '30 days' + AND status NOT IN ('CANCELLED') + +distinct_caregivers = len(recent_device_users) + +if distinct_caregivers >= 3: + severity = 80; risk = HIGH +elif distinct_caregivers >= 2: + severity = 55; risk = MODERATE +elif distinct_caregivers == 1: + severity = 35; risk = MODERATE + +# Amplify if device is flagged +if device.trustLevel in ('SUSPICIOUS', 'BLOCKED'): + severity = min(severity + 15, 100) +if device.isEmulator or device.isRooted or device.isJailbroken: + severity = min(severity + 20, 100) +``` + +**Default severity contribution:** 35–80 (amplified with device flags). + +**Evidence JSON:** +```json +{ + "deviceId": "device-uuid", + "fingerprintHash": "sha256hex", + "sharedCaregiverIds": ["cg-uuid-1", "cg-uuid-2"], + "lookbackDays": 30, + "deviceTrustLevel": "SUSPICIOUS", + "isEmulator": false, + "isRooted": true +} +``` + +**False-positive causes:** Agency-issued shared tablets intended for use by multiple caregivers in a facility. Mitigate by whitelisting `device.deviceId` values marked as "shared facility device" in `organizations.settings.sharedDeviceWhitelist`. Shared facility devices are excluded from SHARED_DEVICE scoring but still scored on DEVICE_TAMPERING. + +--- + +### 3.4 GPS_ANOMALY + +**Definition:** The GPS reading is internally inconsistent: reported accuracy is too poor to be meaningful, coordinates are physically impossible, or the GPS fix is suspiciously stale. + +**Signals / inputs:** +- `gps_verifications.latitude`, `gps_verifications.longitude` +- `gps_verifications.accuracyMeters` +- `gps_verifications.capturedAt` vs `visits.clockInAt` +- `gps_verifications.rawPayload` (provider/mock signals) + +**Detection logic:** + +```python +# Coordinate bounds check +if not (-90 <= lat <= 90) or not (-180 <= lng <= 180): + severity = 90; emit CRITICAL + +# Accuracy too poor to be meaningful +if accuracy_meters > 500: + severity = 60; risk = HIGH +elif accuracy_meters > 200: + severity = 30; risk = MODERATE + +# GPS timestamp lag (stale fix) +fix_lag_s = abs((capturedAt - clockInAt).total_seconds()) +if fix_lag_s > 300: # 5-minute stale fix + severity = max(severity, 45) + +# Mock GPS indicator from device signals (rawPayload.mockLocation == true) +if raw_payload.get('mockLocation') == True: + severity = 85; risk = CRITICAL +``` + +**Default severity contribution:** 30–90. + +**Evidence JSON:** +```json +{ + "latitude": 40.712800, + "longitude": -74.006000, + "accuracyMeters": 350.0, + "fixLagSeconds": 312, + "mockLocation": false, + "rawPayload": { "provider": "fused", "mockLocation": false } +} +``` + +**False-positive causes:** Rural or indoor environments with legitimately degraded GPS accuracy. Mitigate with tenant-level `gpsAccuracyThresholdMeters` override. Urban canyon effect produces >200 m accuracy legitimately — apply relaxed thresholds for visits where `service_authorizations.city` is in a known high-rise zone. + +--- + +### 3.5 IDENTITY_MISMATCH + +**Definition:** The biometric verification (selfie/liveness) produced a low face-match confidence score, suggesting the person presenting is not the enrolled caregiver. + +**Signals / inputs:** +- `identity_verifications.confidenceScore` (0.0–1.0 face-match probability) +- `identity_verifications.livenessScore` +- `identity_verifications.result` (PASS / REVIEW / FAIL) +- `identity_verifications.method` (SELFIE, LIVENESS, DEVICE_TRUST, …) +- Historical `identity_verifications` for the same `caregiverId` + +**Detection logic:** + +```python +CONFIDENCE_FAIL_THRESHOLD = 0.60 # below = strong mismatch +CONFIDENCE_REVIEW_THRESHOLD = 0.80 # below = weak match + +if result == 'FAIL' or confidence_score < CONFIDENCE_FAIL_THRESHOLD: + severity = 80; risk = HIGH +elif result == 'REVIEW' or confidence_score < CONFIDENCE_REVIEW_THRESHOLD: + severity = 50; risk = MODERATE + +# Historical pattern: repeated mismatches +recent_failures = SELECT count(*) FROM identity_verifications + WHERE caregiver_id = :caregiverId + AND result IN ('FAIL', 'REVIEW') + AND created_at > now() - interval '7 days' + +if recent_failures >= 3: + severity = min(severity + 20, 100) +``` + +**Default severity contribution:** 50–80 (amplified by pattern). + +**Evidence JSON:** +```json +{ + "identityVerificationId": "uuid", + "method": "LIVENESS", + "result": "FAIL", + "confidenceScore": 0.43, + "livenessScore": 0.91, + "recentFailures7d": 2, + "matcher": "aws-rekognition-v3" +} +``` + +**False-positive causes:** Poor lighting, glasses/hat changes, enrollment quality issues. Mitigate by requiring re-enrollment if `confidence_score` averages below 0.75 over the past 10 verifications. Allow supervisor-confirmed identity as a REVIEW-pass fallback. + +--- + +### 3.6 UNUSUAL_BILLING + +**Definition:** The billed units or dollar amount for a visit is statistically anomalous relative to the caregiver's own billing history, the provider's cohort, or the service authorization cap. + +**Signals / inputs:** +- `visits.billedUnits`, `visits.billedAmountCents` +- `service_authorizations.authorizedUnits` (periodic cap) +- `visits.serviceCode` +- Historical `visits` for same `caregiverId` + `serviceCode` +- `provider_risk_profiles.billingAnomalies` + +**Detection logic:** + +```python +# Compute historical billing distribution (last 90 days, same service code) +hist = SELECT billed_units FROM visits + WHERE caregiver_id = :caregiverId + AND service_code = :serviceCode + AND scheduled_start BETWEEN now()-interval'90d' AND :scheduledStart + AND status = 'APPROVED' + +mean_units = mean(hist) +std_units = std(hist) +z_score = (billed_units - mean_units) / max(std_units, 0.01) + +if z_score > 3.0: + severity = 70; risk = HIGH +elif z_score > 2.0: + severity = 45; risk = MODERATE + +# Over-authorization cap +if auth.authorized_units is not None and billed_units > auth.authorized_units: + over_pct = (billed_units - auth.authorized_units) / auth.authorized_units + if over_pct > 0.50: + severity = max(severity, 75); risk = HIGH + elif over_pct > 0.10: + severity = max(severity, 40); risk = MODERATE +``` + +**Default severity contribution:** 40–75. + +**Evidence JSON:** +```json +{ + "billedUnits": 16, + "meanUnitsLookback": 8.2, + "stdUnitsLookback": 1.4, + "zScore": 5.6, + "authorizedUnits": 12, + "overAuthPct": 0.33, + "billedAmountCents": 34000, + "serviceCode": "T1019" +} +``` + +**False-positive causes:** Extended visits due to medical emergencies, holidays, or legitimate authorization amendments. Mitigate by checking `service_authorizations.endDate` and recent amendment history. A single z-score outlier in an otherwise clean history should produce MODERATE, not HIGH. + +--- + +### 3.7 ABNORMAL_DURATION + +**Definition:** The actual visit duration (`clockOutAt − clockInAt`) is statistically extreme compared to the caregiver's own duration distribution for the same service code. + +**Signals / inputs:** +- `visits.durationMinutes` +- `visits.clockInAt`, `visits.clockOutAt` +- Historical `visits.durationMinutes` for same `caregiverId` + `serviceCode` +- `service_authorizations.authorizedUnits` (unit = 15-min increment for most HCBS codes) + +**Detection logic:** + +Duration z-score over rolling 90-day window: + +``` +μ = mean(durationMinutes | caregiverId, serviceCode, last 90 days) +σ = std(durationMinutes | caregiverId, serviceCode, last 90 days) +z = (durationMinutes_current − μ) / max(σ, 1.0) +``` + +```python +if abs(z) > 3.5: + severity = 65; risk = HIGH +elif abs(z) > 2.5: + severity = 40; risk = MODERATE +elif abs(z) > 2.0: + severity = 20; risk = LOW + +# Extremely short visits (< 5 min) — billing with no real service +if duration_minutes < 5 and billed_units > 0: + severity = max(severity, 70); risk = HIGH + +# Extremely long visits exceeding authorization ceiling +if auth is not None: + auth_duration_minutes = auth.authorized_units * 15 + if duration_minutes > auth_duration_minutes * 1.5: + severity = max(severity, 55) +``` + +**Default severity contribution:** 20–70. + +**Evidence JSON:** +```json +{ + "durationMinutes": 3, + "meanMinutes": 92.4, + "stdMinutes": 18.7, + "zScore": -4.78, + "direction": "SHORT", + "authDurationMinutes":120, + "billedUnits": 8 +} +``` + +**False-positive causes:** Visit aborted due to patient refusal or emergency (legitimately short). Mitigate by checking `visits.status = CANCELLED` and supervisor notes. Unusual-short visits should be reviewed, not auto-rejected. + +--- + +### 3.8 EXCESSIVE_OVERTIME + +**Definition:** The caregiver's total billed hours across all visits in a 24-hour or 7-day window exceeds the physiological or regulatory maximum, suggesting ghost hours or shared credentials. + +**Signals / inputs:** +- `visits.clockInAt`, `visits.clockOutAt`, `visits.durationMinutes` +- `visits.caregiverId` — aggregate over rolling windows +- `service_authorizations.authorizedUnits` (weekly cap) +- Tenant-configurable limits + +**Detection logic:** + +```python +# Rolling 24-hour window +hours_24h = SELECT sum(duration_minutes) / 60.0 FROM visits + WHERE caregiver_id = :caregiverId + AND status NOT IN ('CANCELLED') + AND clock_in_at >= :clockInAt - interval '24h' + AND clock_in_at <= :clockInAt + +# Rolling 7-day window +hours_7d = SELECT sum(duration_minutes) / 60.0 FROM visits + WHERE caregiver_id = :caregiverId + AND status NOT IN ('CANCELLED') + AND clock_in_at >= :clockInAt - interval '7d' + +# Thresholds (tenant-configurable) +MAX_HOURS_24H = 16 # physiological ceiling +MAX_HOURS_7D = 70 # regulatory / labor law ceiling + +if hours_24h > MAX_HOURS_24H: + overage_pct = (hours_24h - MAX_HOURS_24H) / MAX_HOURS_24H + severity = min(50 + int(overage_pct * 50), 90) + risk = CRITICAL if hours_24h > 24 else HIGH + +if hours_7d > MAX_HOURS_7D: + severity = max(severity, 60); risk = HIGH +``` + +**Default severity contribution:** 50–90. + +**Evidence JSON:** +```json +{ + "window24hHours": 22.5, + "window7dHours": 85.0, + "maxAllowed24h": 16, + "maxAllowed7d": 70, + "overage24hHours": 6.5, + "visitCountIn24h": 6 +} +``` + +**False-positive causes:** Live-in caregivers with legitimate 24-hour overnight shifts (documented in auth). Mitigate by flagging `service_authorizations.serviceCode` = overnight codes (e.g., T1005) as exempt from 24h overtime checks. Apply 7d cap regardless. + +--- + +### 3.9 SERVICE_OVERLAP + +**Definition:** Two visits for the same patient overlap in time — the patient cannot simultaneously receive two services — or the caregiver has overlapping visits with different patients (already partly captured in DUPLICATE_VISIT; SERVICE_OVERLAP focuses on patient-centered billing fraud). + +**Signals / inputs:** +- `visits.patientId`, `visits.caregiverId` +- `visits.clockInAt`, `visits.clockOutAt` +- `service_authorizations.serviceCode` +- All visits for same `patientId` in the billing period + +**Detection logic:** + +Clock-overlap arithmetic for a pair of visits A and B: + +``` +overlap_start = max(A.clockInAt, B.clockInAt) +overlap_end = min(A.clockOutAt, B.clockOutAt) +overlap_min = max(0, (overlap_end - overlap_start).total_seconds() / 60) +``` + +```python +patient_concurrent = SELECT * FROM visits v2 + WHERE v2.patient_id = :patientId + AND v2.id != :visitId + AND v2.status NOT IN ('CANCELLED') + AND tsrange(v2.clock_in_at, v2.clock_out_at) + && tsrange(:clockInAt, :clockOutAt) + +for v2 in patient_concurrent: + overlap_min = compute_overlap(current_visit, v2) + if overlap_min > 60: + severity = 80; risk = HIGH + elif overlap_min > 15: + severity = 55; risk = MODERATE + elif overlap_min > 0: + severity = 30; risk = LOW +``` + +**Default severity contribution:** 30–80. + +**Evidence JSON:** +```json +{ + "overlappingVisitId": "uuid", + "overlapMinutes": 90, + "overlapStart": "2026-06-10T10:00:00Z", + "overlapEnd": "2026-06-10T11:30:00Z", + "patientId": "patient-uuid", + "conflictingCaregiver": "cg-uuid-2", + "billedAmountCombinedCents": 56000 +} +``` + +**False-positive causes:** Transition/escort visits where two caregivers legitimately provide concurrent care under different service codes (e.g., T1019 personal care + S5125 attendant care). Mitigate by defining a tenant-level `allowedConcurrentServiceCodes` whitelist pair in `organizations.settings`. + +--- + +### 3.10 CROSS_PROVIDER_RISK + +**Definition:** The caregiver is associated with a provider whose `provider_risk_profiles.currentScore` is HIGH or CRITICAL, or the visit exhibits patterns consistent with known provider-level fraud schemes (coordinated billing, patient swapping, etc.). + +**Signals / inputs:** +- `provider_risk_profiles.currentScore`, `riskLevel`, `substantiatedCases`, `openCases` +- `provider_risk_profiles.billingAnomalies`, `gpsAnomalies`, `identityIssues`, `verificationFailures` +- Number of open `fraud_cases` linked to `providerId` in the current period +- Graph features: caregivers shared across multiple providers (computed by ML service) + +**Detection logic:** + +```python +profile = provider_risk_profiles WHERE provider_id = :providerId + +if profile.risk_level == 'CRITICAL': + base_severity = 70 +elif profile.risk_level == 'HIGH': + base_severity = 50 +elif profile.risk_level == 'MODERATE': + base_severity = 25 +else: + base_severity = 0 + +# Amplifiers +if profile.substantiated_cases >= 2: + base_severity = min(base_severity + 15, 90) +if profile.open_cases >= 3: + base_severity = min(base_severity + 10, 90) +if profile.billing_anomalies > 10: + base_severity = min(base_severity + 10, 90) + +severity = base_severity +``` + +**Default severity contribution:** 0–90 (0 for LOW-risk provider). + +**Evidence JSON:** +```json +{ + "providerId": "prov-uuid", + "providerRiskLevel": "HIGH", + "providerCurrentScore": 72, + "substantiatedCases": 3, + "openCases": 2, + "billingAnomalies": 14, + "gpsAnomalies": 8 +} +``` + +**False-positive causes:** A new provider inheriting a flagged NPI due to ownership change. Mitigate by checking `providers.enrolledAt` — new enrollment resets the contamination weight. + +--- + +### 3.11 LIVENESS_FAILURE + +**Definition:** The biometric liveness check explicitly failed, indicating a spoofing attempt (printed photo, video replay, mask, or 3D artefact). + +**Signals / inputs:** +- `identity_verifications.livenessScore` (0.0–1.0; > 0.5 = live) +- `identity_verifications.result` +- `identity_verifications.method` (must be LIVENESS or SELFIE with liveness enabled) +- Historical liveness failures for `caregiverId` + +**Detection logic:** + +```python +LIVENESS_FAIL_THRESHOLD = 0.40 # explicit spoof +LIVENESS_REVIEW_THRESHOLD = 0.65 # uncertain + +if liveness_score < LIVENESS_FAIL_THRESHOLD or result == 'FAIL': + severity = 85; risk = CRITICAL + +elif liveness_score < LIVENESS_REVIEW_THRESHOLD or result == 'REVIEW': + severity = 55; risk = HIGH + +# Repeated liveness failures +recent_liveness_fails = SELECT count(*) FROM identity_verifications + WHERE caregiver_id = :caregiverId + AND liveness_score < :LIVENESS_REVIEW_THRESHOLD + AND created_at > now() - interval '14 days' + +if recent_liveness_fails >= 2: + severity = min(severity + 10, 100) +``` + +**Default severity contribution:** 55–85. + +**Evidence JSON:** +```json +{ + "identityVerificationId": "uuid", + "livenessScore": 0.22, + "confidenceScore": 0.88, + "method": "LIVENESS", + "result": "FAIL", + "recentLivenessFailures": 1, + "matcher": "aws-rekognition-v3" +} +``` + +**False-positive causes:** Extremely poor lighting, unusual camera angles, glasses with reflective lenses. These conditions produce borderline (REVIEW) scores, not hard FAIL. System applies a second-chance confirmation window before treating REVIEW as a fraud event. + +--- + +### 3.12 DEVICE_TAMPERING + +**Definition:** The device used to clock in shows signs of modification that could circumvent GPS or identity controls: emulator, root/jailbreak, tampered app binary, clock manipulation, or mock-location injection. + +**Signals / inputs:** +- `devices.isEmulator`, `devices.isRooted`, `devices.isJailbroken` +- `devices.trustLevel` +- `device_verifications.signals` (emulator flags, root detection, mock GPS flag, app integrity hash) +- App-layer attestation result (Google Play Integrity / Apple DeviceCheck) + +**Detection logic:** + +```python +signals = device_verification.signals # JSONB +flags = 0 + +if device.is_emulator or signals.get('emulatorDetected'): + severity = 80; risk = HIGH; flags += 1 + +if device.is_rooted or device.is_jailbroken: + severity = max(severity, 70); risk = HIGH; flags += 1 + +if signals.get('mockLocation'): + severity = max(severity, 85); risk = CRITICAL; flags += 1 + +if signals.get('appIntegrityFailed'): + severity = max(severity, 75); risk = HIGH; flags += 1 + +if signals.get('clockManipulated'): + severity = max(severity, 90); risk = CRITICAL; flags += 1 + +if device.trust_level == 'BLOCKED': + severity = 95; risk = CRITICAL + +final_severity = min(severity + (flags - 1) * 5, 100) if flags > 1 else severity +``` + +**Default severity contribution:** 70–95 (multiple flags compound). + +**Evidence JSON:** +```json +{ + "deviceId": "device-uuid", + "isEmulator": false, + "isRooted": true, + "isJailbroken": false, + "mockLocation": true, + "appIntegrityFailed": false, + "clockManipulated": false, + "trustLevel": "SUSPICIOUS", + "flagCount": 2 +} +``` + +**False-positive causes:** Corporate MDM solutions that intentionally enable certain root-like capabilities (Android Enterprise). Mitigate by adding MDM-attested devices to `organizations.settings.trustedMdmProfiles`. Root alone without mock-GPS is treated as MODERATE, not CRITICAL. + +--- + +### 3.13 GEOFENCE_BREACH + +**Definition:** The caregiver's clock-in GPS coordinates are outside the approved geofence radius defined in `service_authorizations.radiusMeters`, centered on the authorized service address. + +**Signals / inputs:** +- `gps_verifications.latitude`, `gps_verifications.longitude`, `gps_verifications.distanceMeters` +- `service_authorizations.latitude`, `service_authorizations.longitude`, `service_authorizations.radiusMeters` +- `gps_verifications.result` (already contains the geofence decision: PASS/REVIEW/FAIL) + +**Detection logic:** + +The geofence verdict is pre-computed and stored in `gps_verifications.result` by the GPS Verification Engine: + +``` +approved radius = service_authorizations.radius_meters (default 150 m) +distance = gps_verifications.distance_meters + +PASS : distance ≤ radius_meters +REVIEW : radius_meters < distance ≤ radius_meters * 3 +FAIL : distance > radius_meters * 3 +``` + +The GEOFENCE_BREACH detector translates this to fraud severity: + +```python +if gps_result == 'FAIL': + severity = 75 + if distance_meters > radius_meters * 10: # > 1.5 km for default 150 m radius + severity = 90 + risk = HIGH + +elif gps_result == 'REVIEW': + severity = 35 + risk = MODERATE + +elif gps_result == 'PASS': + no event emitted +``` + +**Default severity contribution:** 35–90. + +**Evidence JSON:** +```json +{ + "gpsVerificationId": "uuid", + "capturedLat": 40.720000, + "capturedLng": -74.020000, + "authorizedLat": 40.712800, + "authorizedLng": -74.006000, + "distanceMeters": 1420.5, + "radiusMeters": 150, + "gpsResult": "FAIL", + "accuracyMeters": 18.0 +} +``` + +**False-positive causes:** Patient temporary relocation (hospital, rehab), home address change not yet updated in the authorization. Mitigate via `service_authorizations.radiusMeters` override process and a grace-review pathway (REVIEW vs auto-FAIL) when the distance is less than 500 m. + +--- + +## 4. Score Fusion + +### 4.1 Weighted Sum + +Each fired detector contributes a severity (0–100) weighted by a global weight `w_i` (configurable per tenant): + +``` +Default detector weights (sum must ≤ 1.0; excess is normalized): + +IMPOSSIBLE_TRAVEL 0.18 DUPLICATE_VISIT 0.16 +SHARED_DEVICE 0.12 GPS_ANOMALY 0.07 +IDENTITY_MISMATCH 0.10 UNUSUAL_BILLING 0.09 +ABNORMAL_DURATION 0.06 EXCESSIVE_OVERTIME 0.07 +SERVICE_OVERLAP 0.09 CROSS_PROVIDER_RISK 0.05 +LIVENESS_FAILURE 0.11 DEVICE_TAMPERING 0.08 +GEOFENCE_BREACH 0.07 +``` + +Raw weighted score: + +``` +S_raw = Σ (w_i × severity_i) for all fired detectors i +``` + +### 4.2 Caps & Diminishing Returns + +A single detector cannot drive the composite score above 70 regardless of severity (single-signal cap). Two independent HIGH+ detectors are required to reach 81 (CRITICAL band). This prevents a single GPS glitch from triggering a CRITICAL case. + +```python +def apply_caps(S_raw: float, fired_severities: list[int]) -> float: + """ + Single-signal cap: if only one detector fired, cap at 70. + Independence bonus: each additional independent HIGH detector adds + up to 5 points (max 3 bonuses = +15). + """ + n_high = sum(1 for s in fired_severities if s >= 61) + independence_bonus = min(n_high - 1, 3) * 5 if n_high > 1 else 0 + if len(fired_severities) == 1: + return min(S_raw, 70.0) + return min(S_raw + independence_bonus, 100.0) +``` + +### 4.3 Time Decay for Prior Events + +When fusing with the ML model's behavioral score, prior fraud events for the same caregiver or provider are down-weighted by an exponential decay: + +``` +decay(t) = e^( −λ · days_since_event ) +λ = 0.05 (half-life ≈ 14 days; configurable) + +weighted_prior_score = Σ ( prior_score_i × decay(t_i) ) / n_priors +``` + +The fused score is the convex combination of the rule score and the ML behavioral score: + +``` +S_fused = α · S_rules + (1 − α) · S_ml_behavioral +α = 0.60 (rules weight; configurable; decreases as label corpus grows) +``` + +### 4.4 Risk Band Mapping + +| Score Range | `RiskLevel` | Default Action | +|-------------|-------------|----------------| +| 0–30 | `LOW` | Auto-approve (unless identity FAIL) | +| 31–60 | `MODERATE` | In-app notification to investigator; visit approved with note | +| 61–80 | `HIGH` | In-app + email alert; visit flagged; investigator review required | +| 81–100 | `CRITICAL` | All channels; visit flagged; auto-open `FraudCase` (URGENT) | + +### 4.5 Writing Results to the Database + +After fusion, the engine writes atomically within a single Postgres transaction: + +```sql +-- 1. Insert fraud_events for each fired detector +INSERT INTO fraud_events (organization_id, visit_id, type, severity, risk_level, + explanation, evidence, detector, detector_version) +VALUES (...) [one row per fired detector]; + +-- 2. Insert fraud_scores time-series record +INSERT INTO fraud_scores (organization_id, subject_type, subject_id, + score, risk_level, factors, model_version) +VALUES (:orgId, 'VISIT', :visitId, :fusedScore, :riskLevel, :factorsJson, :version); + +-- 3. Update visits snapshot +UPDATE visits +SET risk_score = :fusedScore, risk_level = :riskLevel, + verification_result = :verificationResult, status = :newStatus +WHERE id = :visitId; + +-- 4. Upsert visit_verifications +INSERT INTO visit_verifications (organization_id, visit_id, result, + risk_score, risk_level, chain, evidence_hash) +VALUES (...) +ON CONFLICT (visit_id) DO UPDATE +SET result = EXCLUDED.result, risk_score = EXCLUDED.risk_score, + risk_level = EXCLUDED.risk_level, chain = EXCLUDED.chain; + +-- 5. Append to audit_logs (hash chain trigger fires automatically) +INSERT INTO audit_logs (organization_id, actor_id, action, resource_type, + resource_id, metadata) +VALUES (:orgId, NULL, 'SCORE', 'visit', :visitId, :metadataJson); +``` + +### 4.6 `fraud_scores.factors` JSON Schema + +Every `fraud_scores` row carries a `factors` array that enables the investigator UI to render a bar chart of contributions: + +```json +[ + { + "detector": "IMPOSSIBLE_TRAVEL", + "severity": 90, + "weight": 0.18, + "contribution": 16.2, + "shapValue": 0.162, + "explanation": "Caregiver clocked in 3,950 km from previous clock-out in 15 minutes (speed: 15,800 km/h).", + "evidence": { "speedKph": 15800, "distanceMeters": 3950000 } + }, + { + "detector": "IDENTITY_MISMATCH", + "severity": 50, + "weight": 0.10, + "contribution": 5.0, + "shapValue": 0.050, + "explanation": "Face match confidence 0.43 is below FAIL threshold 0.60.", + "evidence": { "confidenceScore": 0.43, "result": "FAIL" } + } +] +``` + +--- + +## 5. Alerting & Case Linkage + +### 5.1 Threshold Decision Table + +| Condition | `visits.status` | `visit_verifications.result` | Auto-action | +|-----------|----------------|------------------------------|-------------| +| `riskScore` = 0–30 AND all verifications PASS | `APPROVED` | `PASS` | None; visit approved | +| `riskScore` = 0–30 AND any verification REVIEW | `COMPLETED` | `REVIEW` | In-app notification (low priority) | +| `riskScore` = 31–60 (MODERATE) | `COMPLETED` | `REVIEW` | In-app + email notification to assigned investigator pool | +| `riskScore` = 61–80 (HIGH) | `FLAGGED` | `REVIEW` | In-app + email + (optional) SMS; visit blocked from billing | +| `riskScore` = 81–100 (CRITICAL) | `FLAGGED` | `FAIL` | All notification channels; auto-open `FraudCase` with priority `URGENT` | +| Any `LIVENESS_FAILURE` or `DEVICE_TAMPERING` at severity ≥ 80 | `FLAGGED` | `FAIL` | Immediate alert regardless of composite score | + +### 5.2 Notification Fan-out + +```mermaid +sequenceDiagram + participant FDE as Fraud Detection Engine + participant NS as NotificationService (NestJS) + participant DB as notifications table + participant WS as WebSocket Hub + participant EMAIL as AWS SES + participant SMS as AWS SNS + participant WH as Tenant Webhook + + FDE->>NS: emitAlert(visitId, riskScore, riskLevel, firedDetectors) + NS->>DB: INSERT notification (IN_APP, PENDING) + NS->>WS: push({ type: "FRAUD_ALERT", data: payload }) + alt riskLevel >= HIGH + NS->>EMAIL: sendEmail(investigatorPool) + end + alt riskLevel == CRITICAL or forceAny + NS->>SMS: sendSms(on-call investigator) + end + alt tenant has webhookUrl in organizations.settings + NS->>WH: POST /fraud-alert (HMAC-signed payload) + end + NS->>DB: UPDATE notification SET status = SENT +``` + +The `notifications.data` JSONB includes deep-link context: +```json +{ + "visitId": "uuid", + "caseId": "uuid-or-null", + "riskScore": 87, + "riskLevel": "CRITICAL", + "topDetector": "IMPOSSIBLE_TRAVEL", + "actionUrl": "/investigations/cases/RV-2026-000123" +} +``` + +### 5.3 Auto-Case Creation + +When `riskScore ≥ 81` (CRITICAL), the `CaseService` is called synchronously: + +```typescript +// NestJS CaseService.autoCreateFromScore() +const caseNumber = await this.generateCaseNumber(orgId); // RV-YYYY-NNNNNN +const fraudCase = await this.prisma.fraudCase.create({ + data: { + organizationId: orgId, + caseNumber, + title: `Auto-flagged: ${topDetector} — Visit ${visitId.slice(0,8)}`, + status: CaseStatus.OPEN, + priority: CasePriority.URGENT, + riskLevel: RiskLevel.CRITICAL, + providerId: visit.providerId, + exposureCents: visit.billedAmountCents ?? 0, + summary: buildAutoSummary(firingDetectors, fraudScore), + } +}); +// Link all fraud_events for this visit to the new case +await this.prisma.fraudEvent.updateMany({ + where: { visitId, organizationId: orgId }, + data: { caseId: fraudCase.id, status: FraudEventStatus.LINKED_TO_CASE }, +}); +``` + +For MODERATE and HIGH scores, fraud events remain in `OPEN` status and the investigator manually decides whether to open a case or dismiss. + +--- + +## 6. Tuning & Governance + +### 6.1 Tenant-Configurable Thresholds + +All detection thresholds and fusion weights are stored in `organizations.settings` (JSONB) and merged over compiled-in defaults at runtime. Changing a threshold takes effect immediately without a deploy. + +```jsonc +// organizations.settings — fraud detection sub-key +{ + "fraudDetection": { + "enabled": true, + "detectorWeights": { + "IMPOSSIBLE_TRAVEL": 0.18, + "DUPLICATE_VISIT": 0.16, + // ... all 13 detectors + }, + "thresholds": { + "impossibleTravelKph": 900, + "suspiciousSpeedKph": 300, + "gpsAccuracyMaxMeters": 200, + "identityConfidenceFail": 0.60, + "identityConfidenceReview": 0.80, + "livenessFail": 0.40, + "livenessReview": 0.65, + "maxHours24h": 16, + "maxHours7d": 70, + "billingZScoreHigh": 3.0, + "billingZScoreModerate": 2.0, + "durationZScoreHigh": 3.5, + "durationZScoreModerate": 2.5, + "geofenceReviewMultiplier": 3.0, + "geofenceFailMultiplier": 3.0 + }, + "autoFlagThreshold": 61, + "autoCaseThreshold": 81, + "rulesWeight": 0.60, + "decayLambda": 0.05, + "duplicateGraceMinutes": 5, + "sharedDeviceWhitelist": [], + "allowedConcurrentServiceCodes": [["T1019", "S5125"]], + "trustedMdmProfiles": [] + } +} +``` + +### 6.2 Backtesting + +The backtesting harness replays historical visits against updated rule configurations or model versions without touching the live `fraud_events` table. It writes results to a shadow schema (`fraud_events_backtest_`) and computes: + +- **Alert rate:** fraction of visits that would have been flagged. +- **Rule agreement rate:** fraction of backtest events matching current production events. +- **Precision proxy:** when adjudicated case outcomes are known, `substantiated / (substantiated + unsubstantiated)` for auto-flagged visits. + +### 6.3 Precision / Recall Tracking + +The system tracks a monthly operational scorecard per detector and per composite score band: + +| Metric | Definition | Target | +|--------|-----------|--------| +| **Precision** | Substantiated cases / total flagged visits | ≥ 0.70 per detector | +| **False-positive rate** | Dismissed / total flagged | ≤ 0.20 overall | +| **Alert volume** | FLAGS per 1,000 visits | Monitored for drift; budget linked to investigator capacity | +| **Coverage (Recall proxy)** | Confirmed fraud cases that had a pre-case FLAG | ≥ 0.85 | +| **Mean time to case open** | time from first FLAG to `FraudCase.openedAt` | ≤ 4 hours for CRITICAL | + +### 6.4 Detector & Model Versioning + +Every `fraud_events` row carries `detector` (e.g., `impossible_travel`) and `detector_version` (e.g., `v2.1.0`). Version strings follow `vMAJOR.MINOR.PATCH`: + +- **MAJOR:** logic change that alters severity output for the same inputs. +- **MINOR:** new signal input added; backward-compatible evidence schema extension. +- **PATCH:** threshold-only change (logged in `organizations.settings` change history). + +The ML model version is tracked in `fraud_scores.modelVersion` (e.g., `fused-xgb-2026-05-01`). Shadow scoring runs the challenger model in parallel without affecting alerts, until A/B evaluation confirms parity or improvement. + +### 6.5 Audit of Every Score + +Every score computation writes a row to `audit_logs`: + +``` +action = SCORE +resource_type = "visit" (or "provider", "caregiver") +resource_id = +metadata = { + "score": 87, + "riskLevel": "CRITICAL", + "modelVersion": "fused-xgb-2026-05-01", + "detectorsFired": ["IMPOSSIBLE_TRAVEL", "IDENTITY_MISMATCH"], + "rulesScore": 82, + "mlScore": 89 +} +``` + +The audit log's tamper-evident hash chain (`audit_logs.hash` → `prev_hash`, computed by the `rv_audit_hash_chain()` trigger) ensures that score records cannot be retroactively altered, providing an irrefutable audit trail for litigation and state program integrity audits. + +### 6.6 Drift Monitoring + +The ML scoring service emits feature distribution statistics to CloudWatch after every batch scoring run. Significant drift (KL-divergence > 0.15 on any top-5 feature) triggers a Slack/PagerDuty alert to the ML team. Data drift reports are reviewed monthly; concept drift (precision/recall degradation) triggers a retraining sprint. + +--- + +*End of Document 05 — Fraud Detection Engine* diff --git a/docs/06-ai-risk-scoring.md b/docs/06-ai-risk-scoring.md new file mode 100644 index 0000000..649abe9 --- /dev/null +++ b/docs/06-ai-risk-scoring.md @@ -0,0 +1,842 @@ +# RayVerify™ — AI Risk Scoring + +> **Document 06 of 11** | Parent platform: RayHealthEVV™ +> Audience: Engineering, Data Science, Program Integrity, Investors, Regulators +> Last updated: 2026-06-10 + +--- + +## Table of Contents + +1. [AI Risk-Scoring Framework Overview](#1-ai-risk-scoring-framework-overview) +2. [Data & Feature Engineering](#2-data--feature-engineering) +3. [Models](#3-models) +4. [Explainability](#4-explainability) +5. [MLOps Lifecycle](#5-mlops-lifecycle) +6. [Responsible AI & Fairness](#6-responsible-ai--fairness) +7. [Serving Architecture](#7-serving-architecture) + +--- + +## 1. AI Risk-Scoring Framework Overview + +### 1.1 Purpose + +The Fraud Detection Engine (doc 05) applies deterministic rules that catch known, enumerable fraud patterns with high precision and zero cold-start latency. The AI Risk-Scoring layer augments those rules with two complementary capabilities: + +1. **Behavioral anomaly detection** — learns the statistical "normal" for each caregiver, provider, and patient and scores deviations, catching novel fraud patterns that no rule explicitly addresses. +2. **Label-supervised prediction** — as investigative case outcomes accumulate (`CaseStatus.SUBSTANTIATED` / `UNSUBSTANTIATED`), supervised gradient-boosted models learn which combinations of signals most reliably predict confirmed fraud, continuously improving precision. + +The outputs of the AI layer are fused with rule severities in the score fusion step (doc 05 §4) to produce the final composite `fraud_scores.score`. + +### 1.2 Where ML Augments Rules + +| Scenario | Rule layer | ML layer | +|----------|-----------|---------| +| Impossible travel (explicit threshold) | Fires at speed > 900 km/h | Learns caregiver-specific travel patterns; flags anomalous-but-below-threshold routes | +| Billing outlier (z-score) | Fires at z > 3.0 | Learns cross-feature interactions (billing × duration × patient complexity) | +| Caregiver behavior drift | Not modeled | Detects gradual drift in clock-in times, durations, location cluster shifts | +| Provider ring / collusion | CROSS_PROVIDER_RISK (rule) | Graph neural features on shared-device and co-patient networks | +| Cold-start provider (no history) | Rules fire on raw signals | Unsupervised anomaly score from cohort comparison | +| Novel fraud variant | No rule exists | Anomaly model flags statistical outliers for human review | + +### 1.3 Scoring Entities + +`fraud_scores.subjectType` maps to five entity types. The ML service produces scores for all five; visit-level scores are the primary gating mechanism: + +| `subjectType` | Key tables | Primary consumers | +|---------------|-----------|------------------| +| `VISIT` | `visits`, all `*_verifications` | Pre-payment gate; most frequent | +| `PROVIDER` | `provider_risk_profiles`, aggregated visits | Risk ranking dashboard; `CROSS_PROVIDER_RISK` detector | +| `CAREGIVER` | `caregivers`, `identity_verifications`, visit history | Identity risk; `IDENTITY_MISMATCH` amplifier | +| `PATIENT` | `patients`, visit history | Billing fraud targeting patient; rare | +| `CLAIM` | `visits.billedUnits`, `billedAmountCents` | Post-submission claim-level sweep | + +--- + +## 2. Data & Feature Engineering + +### 2.1 Feature Store Design + +RayVerify uses a two-tier feature store: + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ OFFLINE STORE │ +│ PostgreSQL (primary DB) + S3 Parquet snapshots │ +│ - Historical aggregates: rolling 7d/30d/90d windows │ +│ - Provider cohort statistics (recomputed nightly batch) │ +│ - Training dataset snapshots with point-in-time labels │ +│ - Backfilled features for retraining │ +└──────────────────────────┬───────────────────────────────────────┘ + │ nightly ETL → feature materialization +┌──────────────────────────▼───────────────────────────────────────┐ +│ ONLINE STORE │ +│ Redis (ElastiCache) — sub-millisecond feature lookup │ +│ - Pre-materialized rolling aggregates per caregiverId │ +│ - Provider risk profile snapshot │ +│ - Device trust snapshot │ +│ - Last N visit features per entity │ +│ TTL: 24 hours; refreshed on every visit completion │ +└──────────────────────────────────────────────────────────────────┘ +``` + +Features are keyed by `{entity_type}:{entity_id}:{feature_name}:{window}`. Example: + +``` +caregiver:uuid-1234:avg_duration_min:7d → 87.3 +caregiver:uuid-1234:std_duration_min:7d → 12.4 +provider:uuid-5678:billing_anomaly_rate:30d → 0.12 +``` + +### 2.2 Feature Families + +#### 2.2.1 Geospatial Features + +| Feature name | Source columns | Description | +|-------------|---------------|-------------| +| `clockin_distance_meters` | `gps_verifications.distance_meters` | Distance from authorized site at clock-in | +| `clockin_accuracy_meters` | `gps_verifications.accuracy_meters` | GPS fix quality | +| `geofence_breach_rate_30d` | Aggregated `gps_verifications.result` | Fraction of FAIL/REVIEW GPS events over 30 days | +| `unique_clockin_locations_30d` | Clustered `gps_verifications` lat/lng | Count of distinct location clusters visited by caregiver | +| `travel_speed_prior_visit_kph` | Derived from consecutive `visits` | Speed from last clock-out to current clock-in (haversine) | +| `median_patient_distance_km` | Patient home vs provider headquarters | Structural distance features | + +#### 2.2.2 Temporal / Clock-in Habit Features + +| Feature name | Source columns | Description | +|-------------|---------------|-------------| +| `clockin_hour_of_day` | `visits.clockInAt` | Hour extracted (0–23) | +| `clockin_day_of_week` | `visits.clockInAt` | 0–6 | +| `clockin_deviation_minutes` | `clockInAt - scheduledStart` | Early/late deviation from schedule | +| `clockin_deviation_std_30d` | Rolling std of above | Consistency metric | +| `duration_vs_scheduled_ratio` | `durationMinutes / (scheduledEnd - scheduledStart)` | Ratio; 1.0 = exactly as scheduled | +| `overnight_visit_flag` | `clockInAt.date != clockOutAt.date` | Binary: spans midnight | +| `weekend_visit_rate_30d` | Fraction of weekend visits | Pattern feature | +| `visits_per_day_7d` | Count of visits in last 7 days | Volume feature | + +#### 2.2.3 Behavioral Features + +| Feature name | Source columns | Description | +|-------------|---------------|-------------| +| `identity_fail_rate_14d` | `identity_verifications.result` | Fraction FAIL/REVIEW over 14 days | +| `liveness_score_mean_30d` | `identity_verifications.liveness_score` | Rolling mean liveness | +| `device_trust_level` | `devices.trust_level` | Encoded: TRUSTED=0, UNKNOWN=1, SUSPICIOUS=2, BLOCKED=3 | +| `device_switch_count_30d` | Distinct `device_id` values per caregiver | Frequent device switching | +| `new_device_flag` | `devices.first_seen_at` relative to visit | Binary: first use within 7 days | +| `patient_count_active_30d` | Distinct `patient_id` per caregiver | Load feature; unusually high = risk | +| `duplicate_visit_count_90d` | Count of prior `DUPLICATE_VISIT` events | Historical pattern | + +#### 2.2.4 Billing / Utilization Features + +| Feature name | Source columns | Description | +|-------------|---------------|-------------| +| `billed_units_z_score` | `visits.billedUnits` vs caregiver 90d hist | From UNUSUAL_BILLING detector (reused) | +| `duration_z_score` | `visits.durationMinutes` vs caregiver 90d hist | From ABNORMAL_DURATION detector | +| `weekly_hours_total` | Sum of `durationMinutes` in rolling 7 days | Overtime risk | +| `revenue_per_visit_cents` | `billedAmountCents / durationMinutes` | Billing rate; outliers suspicious | +| `over_authorization_pct` | `(billedUnits - authorizedUnits) / authorizedUnits` | Overrun relative to auth cap | +| `authorization_utilization_rate` | Running units used vs total authorized | Approaching cap = review | +| `service_overlap_minutes_30d` | Total overlap from `SERVICE_OVERLAP` events | Accumulated overlap indicator | + +#### 2.2.5 Network / Graph Features + +Graph features are computed offline (nightly batch) using the PostgreSQL adjacency and pushed to the online store: + +| Feature name | Derivation | Description | +|-------------|-----------|-------------| +| `shared_device_caregiver_count` | Visits with same `device_id`, distinct `caregiver_id` | Device sharing ring width | +| `shared_patient_caregiver_count` | Visits with same `patient_id`, distinct `caregiver_id` | Cross-caregiver patient access | +| `provider_network_risk_score` | Mean `provider_risk_profiles.current_score` of all providers a caregiver has worked for | Guilt-by-association proxy | +| `caregiver_device_graph_centrality` | PageRank-style centrality on caregiver↔device bipartite graph | Hub caregivers in sharing rings | +| `cross_provider_visit_count_90d` | Visits under different `provider_id` in 90 days | Multi-provider caregivers (legitimate or fraudulent) | + +### 2.3 Offline vs Online Features + +| Category | Computation | Freshness | Where stored | +|----------|------------|-----------|-------------| +| Rolling aggregates (7d, 30d, 90d) | Nightly batch ETL (AWS Glue / Spark) | ~24 h lag | S3 Parquet → Redis online store | +| Graph centrality / network features | Nightly batch (PostgreSQL + Python NetworkX) | ~24 h lag | Redis online store | +| Point-of-visit features (GPS, identity result) | Real-time from DB in scoring request | Synchronous | Queried during scoring | +| Provider risk profile snapshot | Updated after each visit scoring | Near-real-time | `provider_risk_profiles` + Redis | + +### 2.4 Point-in-Time Correctness / Leakage Avoidance + +The training dataset is constructed with strict point-in-time semantics: + +``` +For each training sample (visitId, label): + feature_snapshot_time = visit.scheduled_start + All rolling aggregates use only visits WHERE scheduled_start < feature_snapshot_time + Labels are drawn from fraud_cases.status WHERE updated_at > feature_snapshot_time + 30d + (30-day investigation buffer; no label is considered final until 30 days after visit) +``` + +This prevents the model from seeing features computed with knowledge of the future, ensuring that production inference (where the future is unknown) matches training-time conditions. + +--- + +## 3. Models + +### 3.1 Architecture Overview + +```mermaid +flowchart LR + FS[Feature Store\nOnline + Offline] --> A + FS --> B + FS --> C + + subgraph ML_SCORING_SERVICE [Python FastAPI scoring service] + A[Unsupervised Anomaly\nIsolation Forest / Autoencoder] + B[Supervised GBT\nXGBoost / LightGBM] + C[Graph Feature\nExtractor] + + A --> E[Score Ensemble] + B --> E + C --> B + end + + E --> F[SHAP Explainer] + F --> G[ML Score Package\n{score, factors, modelVersion}] + G --> H[NestJS Fusion\nα·rules + 1−α·ml] +``` + +### 3.2 Model A — Unsupervised Anomaly Detection (Cold Start) + +**Purpose:** In early deployment or for new caregivers/providers with no investigation history, there are no confirmed fraud labels. Unsupervised models identify statistical outliers without requiring labeled examples. + +#### 3.2.1 Isolation Forest + +Isolation Forest isolates anomalies by recursively partitioning feature space. Anomalies require fewer splits to isolate (shorter path length). + +```python +from sklearn.ensemble import IsolationForest +import numpy as np + +# Training: fit on the full caregiver population's feature matrix +# Features: 25-dimensional vector from §2.2 (geospatial + temporal + behavioral) +iso_forest = IsolationForest( + n_estimators = 300, + max_samples = 'auto', # min(256, n_samples) + contamination = 0.05, # expected 5% anomaly rate (tunable) + random_state = 42, + n_jobs = -1 +) +iso_forest.fit(X_train) # X_train shape: (n_caregivers_x_visits, 25) + +# Inference: anomaly score (raw) → normalized 0-100 +raw_score = iso_forest.score_samples([feature_vector])[0] # negative; more negative = more anomalous +# Convert: IsolationForest score_samples returns values in [-0.5, 0] +# Normalize to 0-100 (0 = normal, 100 = extreme anomaly) +anomaly_score = int(np.clip((0.5 + raw_score) / (-0.5 - 0.5 + 0.001) * -100 + 100, 0, 100)) +# Simpler linear rescale: +anomaly_score = int(np.clip((-raw_score - 0.0) / 0.5 * 100, 0, 100)) +``` + +The contamination hyperparameter is tuned to match the tenant's historical fraud rate when known, or defaults to 5% (conservative for Medicaid HCBS fraud base rates). + +#### 3.2.2 Autoencoder (Temporal Sequence Anomaly) + +For caregivers with ≥ 30 visits, a lightweight autoencoder captures sequential visit patterns (time-series of clock-in behavior). Anomaly score = reconstruction loss. + +```python +# Architecture: encoder–decoder on sliding window of last 14 visits +# Input: (14, 10) tensor — 14 visits × 10 normalized temporal/billing features +# Encoder: Dense(10) → Dense(4) [bottleneck] +# Decoder: Dense(4) → Dense(10) → Dense(14, 10) +# Loss: MSE +# Training: only on visits with no prior fraud events (proxy "normal" examples) + +reconstruction_loss = mse(original_features, reconstructed_features) +# Percentile-rank against historical distribution → 0-100 anomaly score +autoencoder_score = percentile_rank(reconstruction_loss, historical_loss_distribution) * 100 +``` + +The Isolation Forest and autoencoder scores are averaged: `unsupervised_score = 0.5 * iso_score + 0.5 * ae_score`. + +### 3.3 Model B — Supervised Gradient-Boosted Trees + +**Purpose:** Once ≥ 500 labeled cases (substantiated + unsubstantiated) have accumulated, the supervised model replaces the unsupervised model as the primary ML scorer (the unsupervised score becomes a feature). + +**Label definition:** +- Positive (fraud): `fraud_cases.status = SUBSTANTIATED` with `exposureCents > 0` +- Negative (clean): `fraud_cases.status = UNSUBSTANTIATED` OR visit had no case and was manually cleared after 90 days + +```python +import lightgbm as lgb + +# Feature matrix: ~40 features per visit (all families from §2.2 + unsupervised_score) +# Label: binary (1 = substantiated fraud, 0 = clean) +# Class imbalance: typical fraud rate 2–8% → use scale_pos_weight or SMOTE + +model = lgb.LGBMClassifier( + n_estimators = 500, + learning_rate = 0.05, + max_depth = 6, + num_leaves = 31, + min_child_samples = 20, + subsample = 0.8, + colsample_bytree = 0.8, + reg_alpha = 0.1, + reg_lambda = 1.0, + scale_pos_weight = neg_samples / pos_samples, # handle class imbalance + class_weight = 'balanced', + random_state = 42, + n_jobs = -1 +) +model.fit( + X_train, y_train, + eval_set = [(X_val, y_val)], + eval_metric = ['aucpr'], # PR-AUC optimized (not ROC-AUC; imbalanced) + callbacks = [lgb.early_stopping(50), lgb.log_evaluation(50)] +) + +# Inference → probability → 0-100 score +prob = model.predict_proba([feature_vector])[0][1] +supervised_score = int(prob * 100) +``` + +**Calibration:** Platt scaling (logistic regression on out-of-fold predictions) ensures that `supervised_score = 70` means approximately 70% probability of confirmed fraud, supporting risk-band thresholding. + +### 3.4 Model C — Graph Features for Collusion & Shared-Device Rings + +**Purpose:** Individual visit features miss multi-entity collusion patterns. Graph analysis identifies rings of caregivers sharing devices, providers with abnormal patient-sharing patterns, and coordinated billing clusters. + +**Graph construction (nightly batch):** + +```python +import networkx as nx + +# Bipartite graph: caregivers ↔ devices (edge = at least 2 shared visits in 30d) +G_cd = nx.Graph() +for (caregiver_id, device_id), count in shared_device_pairs: + if count >= 2: + G_cd.add_edge(f"cg:{caregiver_id}", f"dv:{device_id}", weight=count) + +# Caregiver–caregiver projection (two caregivers connected if they share a device) +G_cc = nx.bipartite.projected_graph(G_cd, caregiver_nodes) + +# Graph features per caregiver node +features['device_ring_size'] = nx.degree(G_cc, caregiver_node) + 1 +features['device_ring_max_degree'] = max(dict(G_cc.degree()).values(), default=0) +features['caregiver_betweenness'] = nx.betweenness_centrality(G_cc).get(caregiver_node, 0) + +# Provider–patient bipartite graph (same patient billed by multiple providers in same week) +G_pp = build_provider_patient_graph(visits_last_30d) +features['patient_sharing_providers'] = count_providers_sharing_patient(patientId, G_pp) +``` + +These graph features are appended to the feature vector before the supervised model runs, improving detection of coordinated schemes. + +### 3.5 Provider Risk Scoring Model + +`provider_risk_profiles` is maintained by a dedicated provider scoring job that runs after each visit scoring pass and as a nightly batch sweep. + +**Inputs (columns in `provider_risk_profiles` + aggregated visits):** + +| Input | Source | Description | +|-------|--------|-------------| +| `verification_failures` | `provider_risk_profiles.verification_failures` | Cumulative count of FAIL verifications | +| `gps_anomalies` | `provider_risk_profiles.gps_anomalies` | Cumulative GPS anomaly events | +| `billing_anomalies` | `provider_risk_profiles.billing_anomalies` | Cumulative billing outlier events | +| `identity_issues` | `provider_risk_profiles.identity_issues` | Cumulative identity failure events | +| `open_cases` | `provider_risk_profiles.open_cases` | Currently open `FraudCase` count | +| `substantiated_cases` | `provider_risk_profiles.substantiated_cases` | Historical confirmed fraud cases | +| `caregiver_count_active` | Count of active `caregivers` | Staffing volume | +| `high_risk_caregiver_fraction` | Fraction of caregivers with visit risk_level ≥ HIGH | Workforce risk | +| `billing_z_score_mean_30d` | Mean of `UNUSUAL_BILLING` z-scores | Provider-wide billing anomaly | +| `visit_approval_rate_30d` | Approved visits / completed visits | Clean visit rate | + +**Scoring formula:** + +The provider score is a weighted combination of a rule-based component and an ML-predicted component: + +```python +# Rule component (normalized 0–100): +rule_component = ( + min(verification_failures / 10, 1.0) * 20 + + min(gps_anomalies / 20, 1.0) * 15 + + min(billing_anomalies / 15, 1.0) * 20 + + min(identity_issues / 10, 1.0) * 15 + + min(open_cases / 5, 1.0) * 15 + + min(substantiated_cases / 3, 1.0) * 15 +) # max = 100 + +# ML component: supervised model trained on provider-level features +# (same LightGBM architecture, provider-grain dataset) +ml_component = provider_lgbm.predict_proba([provider_feature_vector])[0][1] * 100 + +# Fused provider score +provider_score = int(0.55 * rule_component + 0.45 * ml_component) +provider_score = max(0, min(100, provider_score)) +``` + +**Historical trend:** After each computation, the new score is appended to `provider_risk_profiles.trend`: + +```json +[ + { "t": "2026-06-01T00:00:00Z", "score": 45 }, + { "t": "2026-06-08T00:00:00Z", "score": 52 }, + { "t": "2026-06-10T00:00:00Z", "score": 71 } +] +``` + +The frontend investigator dashboard renders this as a sparkline for trend visualization. + +--- + +## 4. Explainability + +### 4.1 Requirement + +Explainability is mandatory. No score may be used to flag a visit, hold a payment, or open a case without a human-readable explanation and structured factor contributions. This satisfies: + +- **Due process:** Medicaid providers have appeal rights; investigators must explain why a visit was flagged. +- **Investigator efficiency:** An unexplained score forces investigators to reverse-engineer the model, wasting time. +- **Regulatory reporting:** State program integrity submissions require documented rationale. + +### 4.2 Global Explanations (Feature Importance) + +At the model level, SHAP global importance identifies which features most drive predictions across the entire training population: + +```python +import shap + +explainer = shap.TreeExplainer(model) +shap_values = explainer.shap_values(X_test) + +# Global importance: mean |SHAP value| per feature +global_importance = pd.DataFrame({ + 'feature': feature_names, + 'importance': np.abs(shap_values).mean(axis=0) +}).sort_values('importance', ascending=False) +``` + +Global importance reports are generated monthly and stored as model card artifacts in S3, reviewed by the ML governance team, and surfaced in the admin dashboard's "Model Performance" panel. + +### 4.3 Local Explanations (Per-Score SHAP) + +For each individual visit score, SHAP values decompose the prediction into per-feature contributions: + +```python +# Single-instance SHAP +shap_values_instance = explainer.shap_values(feature_vector_instance) + +factors = [] +for i, fname in enumerate(feature_names): + factors.append({ + "feature": fname, + "value": float(feature_vector_instance[i]), + "shapValue": float(shap_values_instance[i]), + "direction": "INCREASES_RISK" if shap_values_instance[i] > 0 else "DECREASES_RISK", + "contribution": abs(float(shap_values_instance[i])) + }) + +# Sort by |shapValue| descending; top 5 become the displayed reason codes +top_factors = sorted(factors, key=lambda x: x['contribution'], reverse=True)[:5] +``` + +These are stored in `fraud_scores.factors` (JSONB array). + +### 4.4 Reason Codes + +Reason codes map SHAP-identified features to human-readable investigator guidance: + +| Feature | High-risk reason code | Investigator guidance | +|---------|-----------------------|----------------------| +| `travel_speed_prior_visit_kph` | `RC-001: Physically impossible travel speed` | Review previous clock-out location and current clock-in GPS. Verify caregiver was not using a shared credential. | +| `identity_fail_rate_14d` | `RC-002: Repeated identity verification failures` | Pull identity verification images. Compare against enrollment photo. Schedule re-enrollment. | +| `billed_units_z_score` | `RC-003: Billing volume anomaly (z > 3.0)` | Cross-check billed units against caregiver's visit notes and patient's authorization cap. | +| `device_ring_size` | `RC-004: Shared device ring detected` | Map all caregivers using the same device. Determine if agency-issued or personal device. | +| `geofence_breach_rate_30d` | `RC-005: Persistent GPS geofence breaches` | Review patient's current service address. Verify authorization is current. | +| `liveness_score_mean_30d` | `RC-006: Liveness score trend declining` | Review biometric evidence package. Consider field audit. | + +### 4.5 Example Explained Score + +A complete `fraud_scores` row for a CRITICAL-scored visit: + +```json +{ + "id": "fs-uuid-example", + "organizationId": "org-uuid", + "subjectType": "VISIT", + "subjectId": "visit-uuid", + "score": 87, + "riskLevel": "CRITICAL", + "modelVersion": "fused-xgb-2026-05-01", + "computedAt": "2026-06-10T14:32:11Z", + "factors": [ + { + "detector": "IMPOSSIBLE_TRAVEL", + "severity": 90, + "weight": 0.18, + "contribution": 16.2, + "shapValue": 0.162, + "feature": "travel_speed_prior_visit_kph", + "featureValue": 15800.0, + "direction": "INCREASES_RISK", + "reasonCode": "RC-001", + "explanation": "Caregiver clocked in 3,950 km from previous clock-out in 15 minutes (speed: 15,800 km/h). Physical travel is impossible.", + "investigatorAction": "Review GPS evidence and prior visit record. Likely credential sharing or GPS spoofing." + }, + { + "detector": "IDENTITY_MISMATCH", + "severity": 50, + "weight": 0.10, + "contribution": 5.0, + "shapValue": 0.081, + "feature": "identity_fail_rate_14d", + "featureValue": 0.67, + "direction": "INCREASES_RISK", + "reasonCode": "RC-002", + "explanation": "2 of 3 identity verifications in the past 14 days returned FAIL or REVIEW (confidence < 0.60).", + "investigatorAction": "Pull identity verification images from S3. Schedule re-enrollment if enrollment quality is poor." + }, + { + "detector": "LIVENESS_FAILURE", + "severity": 85, + "weight": 0.11, + "contribution": 9.35, + "shapValue": 0.093, + "feature": "liveness_score_mean_30d", + "featureValue": 0.31, + "direction": "INCREASES_RISK", + "reasonCode": "RC-006", + "explanation": "Mean liveness score 0.31 over 30 days is below the spoofing threshold (0.40). Current visit liveness: 0.22.", + "investigatorAction": "Escalate to field supervisor. Consider unannounced visit audit." + }, + { + "detector": "ML_BEHAVIORAL", + "severity": null, + "weight": 0.40, + "contribution": 31.2, + "shapValue": 0.312, + "feature": "caregiver_betweenness", + "featureValue": 0.74, + "direction": "INCREASES_RISK", + "reasonCode": "RC-004", + "explanation": "Caregiver has high graph centrality in a shared-device network connecting 5 caregivers. Behavioral pattern consistent with credential relay.", + "investigatorAction": "Map full device-sharing network. Identify all visits in the ring over the past 90 days." + }, + { + "detector": "ML_BEHAVIORAL", + "severity": null, + "weight": 0.40, + "contribution": 8.1, + "shapValue": 0.081, + "feature": "visits_per_day_7d", + "featureValue": 9.3, + "direction": "INCREASES_RISK", + "reasonCode": null, + "explanation": "Caregiver logged 9.3 visits/day on average over 7 days (cohort 90th percentile: 4.1).", + "investigatorAction": "Review daily schedule. Compare against payroll and service authorization caps." + } + ] +} +``` + +**Human-readable summary** (generated by the `ExplanationService` and stored in `fraud_events.explanation` for the composite event): + +> **CRITICAL — Score 87/100.** Primary signal: Impossible travel (caregiver clocked in 3,950 km from previous location in 15 minutes). Compounded by repeated liveness failures (mean 0.31 over 30 days) and position as a hub in a 5-caregiver device-sharing ring. Recommend immediate hold pending investigator review and field audit. + +--- + +## 5. MLOps Lifecycle + +### 5.1 Labeling from Case Outcomes + +The ground-truth label pipeline polls `fraud_cases` for closed cases and joins them to their constituent `fraud_events` and visits: + +```python +# Label assignment +SELECT v.id AS visit_id, + CASE WHEN fc.status = 'SUBSTANTIATED' THEN 1 ELSE 0 END AS label, + fc.closed_at AS label_date +FROM fraud_cases fc +JOIN fraud_events fe ON fe.case_id = fc.id +JOIN visits v ON v.id = fe.visit_id +WHERE fc.status IN ('SUBSTANTIATED', 'UNSUBSTANTIATED') + AND fc.closed_at > now() - interval '180 days' + AND fc.closed_at IS NOT NULL +``` + +Labels are **only assigned after a human investigator closes the case**. No automated adverse-action label is ever created without a substantiated human determination. Partial labels (open cases) are excluded from training and used only in active-learning uncertainty sampling. + +### 5.2 Training Pipeline + +```mermaid +flowchart LR + A[fraud_cases SUBSTANTIATED\n/ UNSUBSTANTIATED] -->|label join| B[Point-in-time\nfeature snapshot\nfrom offline store] + B --> C[Data validation\nGreat Expectations:\nnull rates, range checks\ndistribution drift] + C --> D[Train / Val / Test split\n70 / 15 / 15\ntime-ordered: no future leakage] + D --> E[LightGBM training\nearly stopping on PR-AUC] + D --> F[IsolationForest refresh\nnew normal examples] + E & F --> G[SHAP explainer fit\nTreeExplainer] + G --> H[Evaluation harness\n§5.3 metrics] + H -->|pass gates| I[Model registry\nS3 + MLflow] + H -->|fail gates| J[Reject + alert\nML team Slack] + I --> K[Shadow deployment\nchallenger scoring] + K -->|A/B evaluation ≥ 2 weeks| L[Promote to champion\nBlue-Green deploy] +``` + +### 5.3 Evaluation Metrics + +The evaluation harness runs on a held-out test set (time-ordered; test set is strictly after training cutoff): + +| Metric | Definition | Minimum gate | +|--------|-----------|-------------| +| **PR-AUC** | Area under Precision-Recall curve | ≥ 0.70 | +| **Precision at alert threshold** | Precision at operating threshold (score ≥ 61) | ≥ 0.65 | +| **Recall at alert threshold** | Recall at score ≥ 61 | ≥ 0.55 | +| **Alert volume rate** | Fraction of visits scored ≥ 61 | ≤ 0.08 (investigator capacity constraint) | +| **CRITICAL alert rate** | Fraction scored ≥ 81 | ≤ 0.02 | +| **Calibration error** | Expected Calibration Error (ECE) | ≤ 0.05 | +| **PR-AUC degradation vs champion** | New model PR-AUC vs current champion | ≥ −0.02 (must not regress) | + +**Investigator capacity constraint:** The alert volume is gated independently of statistical metrics. If a model produces valid precision/recall numbers but would generate more alerts than the investigator team can review (configurable `maxAlertRatePer1000Visits` in `organizations.settings`), the threshold is raised rather than the model rejected, preserving precision at the cost of recall. + +**Cost-weighted threshold selection:** + +```python +# Cost matrix: false positive (wasted investigator time) vs false negative (missed fraud) +C_fp = 1.0 # cost of flagging a clean visit +C_fn = 8.0 # cost of missing confirmed fraud (estimated from average exposure_cents) + +optimal_threshold = argmin over t: ( + C_fp * FP(t) + C_fn * FN(t) +) / n_visits +``` + +The optimal threshold is computed per tenant (C_fn varies with average `fraud_cases.exposureCents`). + +### 5.4 Model Registry & Versioning + +Models are registered in MLflow (self-hosted on ECS) with: + +- Artifact: serialized LightGBM model + SHAP explainer (joblib) +- Metadata: training dataset hash, feature schema version, hyperparameters, evaluation metrics +- Stage: `Staging` → `Shadow` → `Champion` → `Archived` +- Version string: `{model_type}-{training_date}` e.g., `xgb-2026-05-01` + +`fraud_scores.modelVersion` references the registry version string for full reproducibility. + +### 5.5 Shadow + Champion-Challenger Deployment + +``` +Champion model: scores all visits; results written to fraud_scores; alerts fired. +Challenger model: scores all visits; results written to fraud_scores_shadow (separate table); + alerts NOT fired; metrics compared weekly. + +After ≥ 2 weeks and ≥ 500 visits, if challenger PR-AUC ≥ champion PR-AUC − 0.01: + promote challenger → champion (blue-green swap, zero downtime) + demote champion → archived +``` + +The shadow scoring adds ~5% overhead to the scoring service. It runs on a low-priority worker pool separated from the hot path. + +### 5.6 Monitoring: Data & Concept Drift + +**Data drift** (input distribution shift): + +```python +# After every batch scoring run (nightly): +for feature in top_20_features: + current_dist = feature_values_last_7d[feature] + baseline_dist = feature_baseline_30d[feature] # rolling 30d window + kl_div = scipy.stats.entropy(current_dist, baseline_dist) + if kl_div > 0.15: + alert("DATA_DRIFT", feature, kl_div) +``` + +Metrics published to CloudWatch namespace `RayVerify/ML/Drift`. + +**Concept drift** (output distribution shift / model degradation): + +```python +# Monthly precision/recall computation against newly closed cases +precision_30d = closed_substantiated_count / total_flagged_visits_30d +if precision_30d < 0.60: # below minimum gate + alert("CONCEPT_DRIFT", precision_30d) + trigger_retraining_sprint() +``` + +**Retraining cadence:** +- **Scheduled:** Monthly, regardless of drift metrics (keeps model current with evolving fraud patterns). +- **Triggered:** Immediately on concept-drift alert or KL-divergence > 0.20 on ≥ 3 top features. +- **Label minimum:** Retraining is skipped if fewer than 50 new labeled cases have accumulated since the last training run (insufficient signal). + +--- + +## 6. Responsible AI & Fairness + +### 6.1 Principles + +RayVerify's AI scoring system operates on caregivers who are predominantly members of protected groups (women, racial/ethnic minorities, immigrant workers). Biased scores translate directly to unjust payment delays, reputational harm, and workforce disruption. The following governance controls are mandatory, not aspirational. + +### 6.2 Bias Monitoring + +Protected-attribute proxies available in the data (we do not collect protected attributes directly): + +| Proxy feature | Source | Protected attribute proxied | +|--------------|--------|-----------------------------| +| `caregiver.zipCode` (derived) | Service address geography | Race / ethnicity / SES | +| `providers.jurisdiction` | State/region | Geographic equity | +| `identity_verifications.matcher` | Model used for face match | Differential accuracy by skin tone | + +Bias monitoring runs monthly against a held-out representative sample: + +```python +# Disparate impact ratio (80% rule) +alert_rate_group_A = (score_A >= 61).mean() +alert_rate_group_B = (score_B >= 61).mean() +disparate_impact_ratio = min(alert_rate_group_A, alert_rate_group_B) / + max(alert_rate_group_A, alert_rate_group_B) + +if disparate_impact_ratio < 0.80: + flag_for_fairness_review("DISPARATE_IMPACT", disparate_impact_ratio) + +# Equal opportunity: true positive rate parity across groups +tpr_group_A = TP_A / (TP_A + FN_A) +tpr_group_B = TP_B / (TP_B + FN_B) +if abs(tpr_group_A - tpr_group_B) > 0.10: + flag_for_fairness_review("TPR_DISPARITY", abs(tpr_group_A - tpr_group_B)) +``` + +Results are included in the quarterly model card update and reviewed by the Responsible AI committee. + +### 6.3 Face Recognition Accuracy Parity + +The biometric matching model (`identity_verifications.matcher`) is evaluated monthly for differential false-match rates by demographic proxy (geography-based cohorts). If false-reject rate for any cohort exceeds the population average by > 5 percentage points, the matcher vendor is notified and a threshold recalibration is triggered. + +### 6.4 Human-in-the-Loop Mandate + +**No fully automated adverse action.** The AI system may: +- Emit alerts and notifications +- Set `visits.status = FLAGGED` +- Auto-create a `FraudCase` with `status = OPEN` + +The AI system may **not**: +- Set `FraudCase.status = PENDING_PAYMENT_HOLD` without a user action +- Reject a billing claim directly +- Permanently block a caregiver or provider + +Payment holds require an investigator to explicitly set `status = PENDING_PAYMENT_HOLD` in the case management interface (RBAC-controlled: `fraud_case:payment_hold` permission). This action is recorded in `case_notes` and `audit_logs`. + +### 6.5 Model Cards & Governance Documentation + +Each model version published to the registry includes a model card covering: + +1. **Model details:** version, architecture, training dataset dates, label definition. +2. **Intended use:** fraud detection for Medicaid/HCBS visits; not suitable for criminal prosecution without additional evidence. +3. **Out-of-scope uses:** Direct employment decisions; re-identification of patients; real-time hard blocks without human review. +4. **Performance metrics:** PR-AUC, precision, recall, alert rate (overall and per-cohort). +5. **Bias evaluation:** Disparate impact ratios, TPR parity across geographic cohorts. +6. **Explainability method:** SHAP TreeExplainer; feature schema version. +7. **Limitations:** Performance degrades on providers with < 30 visits (cold-start); assumes consistent GPS availability. +8. **Monitoring & retraining cadence:** As documented in §5.6. + +Model cards are stored in S3 (`s3://rayverify-mlops/model-cards/`) and linked from the MLflow model registry entry. They are reviewed and signed off by the Engineering Lead and a Responsible AI reviewer before `Shadow` → `Champion` promotion. + +--- + +## 7. Serving Architecture + +### 7.1 Overview + +```mermaid +flowchart TB + subgraph NestJS_API [NestJS Backend] + A[FraudOrchestrator] -->|sync rule detectors| B[Rule Severity Package] + A -->|enqueue| C[Redis Bull Queue\nasync-fraud-score] + end + + subgraph ML_WORKERS [ML Scoring Workers - ECS Fargate] + C --> D[Bull Worker\nNestJS consumer] + D -->|HTTP POST /score/visit| E[FastAPI Scoring Service\n:8000] + E --> F[Feature Fetcher\nRedis online store + DB] + F --> G[Champion Model\nLightGBM + SHAP] + F --> H[Shadow Model\nChallenger LightGBM] + G --> I[SHAP Explainer] + H --> J[Shadow metrics only] + I --> K[ML Score Package JSON] + K --> D + end + + D -->|write fraud_scores| L[(PostgreSQL)] + D -->|score fusion + alerts| M[FraudOrchestrator callback] + M -->|notifications| N[NotificationService] +``` + +### 7.2 FastAPI Scoring Service + +The Python scoring service is a thin HTTP wrapper that receives a feature vector (or raw entity IDs for online feature fetching), runs the champion model, computes SHAP values, and returns a structured response. + +**Endpoint:** `POST /score/visit` + +**Request:** +```json +{ + "visitId": "uuid", + "orgId": "uuid", + "features": { "...": "..." }, // optional pre-computed features + "modelVersion": "latest" // or specific version tag +} +``` + +**Response:** +```json +{ + "visitId": "uuid", + "score": 87, + "riskLevel": "CRITICAL", + "modelVersion": "fused-xgb-2026-05-01", + "factors": [ /* SHAP factor array as in §4.5 */ ], + "latencyMs": 42 +} +``` + +**Additional endpoints:** + +| Endpoint | Purpose | +|----------|---------| +| `POST /score/provider` | Provider risk profile recomputation | +| `POST /score/batch` | Batch scoring for nightly sweep (accepts array of visitIds) | +| `GET /health` | Liveness probe; returns model version and last retraining date | +| `GET /features/{entityType}/{entityId}` | Debug: dump online feature vector for an entity | + +### 7.3 Latency Budget + +| Operation | Target p50 | Target p99 | Notes | +|-----------|-----------|-----------|-------| +| Feature fetch (Redis online) | < 5 ms | < 15 ms | All hot features pre-materialized | +| Feature fetch (DB fallback) | < 30 ms | < 80 ms | For cold features not in cache | +| LightGBM inference | < 5 ms | < 10 ms | ~40 features, 500 trees | +| SHAP computation | < 20 ms | < 50 ms | TreeExplainer; fast for GBT | +| Total scoring service | < 60 ms | < 150 ms | End-to-end HTTP | +| Bull queue wait (async path) | < 5 s p50 | < 30 s p99 | Under normal queue depth | +| Full async pipeline (enqueue → result) | < 10 s p50 | < 60 s p99 | Includes DB writes | + +**Batch scoring (nightly sweep):** 10,000 visits/hour throughput on 4 vCPU workers with concurrency = 8 per worker. Designed to sweep the previous day's completed visits before the state billing cycle runs at 06:00 local time. + +### 7.4 Infrastructure + +| Component | AWS service | Configuration | +|-----------|------------|---------------| +| FastAPI scoring service | ECS Fargate | 2 vCPU / 4 GB RAM per task; 2–8 tasks (auto-scale on queue depth) | +| Bull workers | ECS Fargate (same cluster) | 1 vCPU / 2 GB RAM; 4–16 tasks | +| Online feature store | ElastiCache Redis (cluster mode) | 3 shards × 2 nodes; r7g.large | +| ML model artifacts | S3 (`rayverify-mlops/`) | Versioned; encrypted at rest (KMS) | +| Model registry | MLflow on ECS | Backed by RDS PostgreSQL | +| Drift metrics | CloudWatch custom namespace | `RayVerify/ML/Drift`, `RayVerify/ML/Performance` | + +### 7.5 Security & Isolation + +- The FastAPI scoring service runs in the same VPC private subnet as the NestJS backend. It is **not** internet-accessible. +- Communication between NestJS and FastAPI uses mTLS (AWS Certificate Manager Private CA). +- Model artifacts in S3 are encrypted with a dedicated KMS key (`alias/rayverify-ml-artifacts`). Access is restricted to the ECS task IAM role. +- No PHI is passed to the scoring service. Only UUID identifiers and pre-aggregated numeric features flow into the ML service. Feature labels are non-identifying (e.g., `avg_duration_min`, not `patient_name`). +- Scoring requests and responses are logged to `audit_logs` (`action = SCORE`) with PHI scrubbed from metadata. + +--- + +*End of Document 06 — AI Risk Scoring* diff --git a/docs/07-security-architecture.md b/docs/07-security-architecture.md new file mode 100644 index 0000000..f6cb0ed --- /dev/null +++ b/docs/07-security-architecture.md @@ -0,0 +1,786 @@ +# RayVerify™ — Security Architecture + +**Document:** 07-Security-Architecture +**Classification:** SENSITIVE — Government/Investor Distribution +**Platform:** RayVerify™ (parent: RayHealthEVV™) +**Version:** 1.0 | June 2026 +**Audience:** State security reviewers, OIG, CMS auditors, CISO, investors + +--- + +## Table of Contents + +1. [Security Principles](#1-security-principles) +2. [Threat Model (STRIDE)](#2-threat-model-stride) +3. [Identity & Access Management](#3-identity--access-management) +4. [Data Protection](#4-data-protection) +5. [Multi-Tenant Isolation](#5-multi-tenant-isolation) +6. [Auditability & Integrity](#6-auditability--integrity) +7. [Application Security](#7-application-security) +8. [Network & Infrastructure Security](#8-network--infrastructure-security) +9. [Compliance Control Mapping](#9-compliance-control-mapping) +10. [Privacy](#10-privacy) + +--- + +## 1. Security Principles + +RayVerify is built on five foundational security principles that cascade through every architectural decision, from database schema to network topology. + +### 1.1 Zero Trust + +RayVerify assumes no implicit trust for any actor, device, or network path. Every request — internal or external — is authenticated, authorized, and logged. Service-to-service calls within the AWS VPC use mutual TLS (mTLS) and short-lived credentials provisioned by AWS IAM Roles. No service is granted ambient authority based solely on network position. + +Trust is established at the boundary of each transaction: +- API callers present JWT access tokens (short-lived, 15-minute expiry) on every request. +- Internal microservices authenticate via mTLS certificates with identity bound to the service's ECS task role. +- Database connections are established under a narrow application role; the `app.current_org` GUC is set per transaction, not per connection. + +### 1.2 Defense-in-Depth + +No single control is treated as sufficient. Protections are layered so that the compromise of any one layer does not expose PHI or allow unauthorized system action: + +``` +Internet → WAF (Layer 7) → ALB (TLS termination) → ECS container (mTLS) + → NestJS input validation → RBAC check → RLS policy → encrypted column + → KMS envelope-decryption (only at point of use) → audit log +``` + +A threat actor who bypasses application-layer RBAC still encounters PostgreSQL Row-Level Security. A threat actor who obtains a database dump still encounters AES-256-GCM envelope encryption keyed per tenant via AWS KMS. A threat actor who obtains KMS data keys still cannot connect the decrypted data to identity without the blind-index preimage. + +### 1.3 Least Privilege + +Every identity — human or machine — is granted the minimum permissions required for its function: + +- PostgreSQL application role cannot execute DDL, cannot bypass RLS, and cannot access tables outside its grant list. +- ECS task roles are scoped to specific S3 prefixes, specific KMS key ARNs, and specific Secrets Manager paths. +- Human users are granted permissions through the RBAC system (`roles` → `role_permissions` → `permissions`); the `permissions` table stores fine-grained `resource:action` keys, not broad grants. +- The migration/ops role that holds `BYPASSRLS` is a separate PostgreSQL role never used by the application process. + +### 1.4 Secure-by-Default + +New tenants, new users, and new services start in a locked-down state: +- New `User` records default to `status = PENDING_INVITE`; they cannot authenticate until the invitation flow is completed. +- New `Device` records default to `trust_level = UNKNOWN`; they cannot be used for biometric verification until the trust evaluation passes. +- New `Session` records require a valid Argon2id password check and a passing MFA factor before the refresh token is issued. +- All S3 buckets are created with public access blocked and SSE-KMS encryption as the default. +- RLS is `FORCE ROW LEVEL SECURITY` on all 19 business tables, meaning even the table owner role cannot bypass it without the `BYPASSRLS` privilege. + +### 1.5 Assume Breach + +Controls are designed on the premise that an attacker will eventually achieve partial penetration. The architecture limits blast radius through tenant isolation, short-lived credentials, append-only evidence, and cryptographic audit integrity so that a breach is detected quickly, its scope is bounded, and forensic reconstruction is possible. + +--- + +## 2. Threat Model (STRIDE) + +### 2.1 Key Assets + +| Asset | Sensitivity | Impact if Compromised | +|-------|-------------|----------------------| +| PHI — beneficiary PII, medicaid_member_id, date of birth | HIPAA-protected | HIPAA breach notification, civil/criminal liability | +| Biometric templates (face embeddings, reference selfies) | PHI + biometric law | Identity theft, fraud bypass | +| Fraud scores and case notes | Law-enforcement sensitive | Litigation exposure, tipping off subjects | +| Audit log integrity | Chain-of-custody | Evidence inadmissible; investigation undermined | +| GPS + clock evidence | Billing-determinative | Payment fraud enabled | +| Refresh tokens / session state | Authentication | Account takeover | +| KMS data keys / secrets | Root of encryption | Mass decryption of all tenant PHI | + +### 2.2 STRIDE Analysis + +#### Spoofing + +| Threat | Attack Scenario | Mitigations | +|--------|----------------|-------------| +| Caregiver identity spoofing | Fraudster uses another person's credentials to clock in | Selfie + liveness check (`IdentityVerification`); face embedding comparison against enrolled `BiometricEnrollment`; device trust evaluation; MFA on platform users | +| Session token theft | Attacker steals JWT from browser local storage | Short 15-min access token expiry; `refreshTokenHash` stored (SHA-256 of token, never raw); refresh token rotation on use; `revokedAt` column for explicit invalidation; `HttpOnly` + `Secure` cookie flags for web clients | +| Inter-tenant impersonation | Attacker obtains a valid JWT for Tenant A and queries Tenant B data | `app.current_org` GUC bound to the JWT `organizationId` claim at NestJS middleware; PostgreSQL RLS policy `USING (organization_id = current_setting('app.current_org', true)::uuid)` enforced at every row read/write | +| API key / credential leak | CI/CD pipeline leaks AWS credentials | Secrets stored in AWS Secrets Manager, never in environment variables in code; secret scanning in CI; OIDC-based short-lived credentials for GitHub Actions | + +#### Tampering + +| Threat | Attack Scenario | Mitigations | +|--------|----------------|-------------| +| Audit log tampering | Insider deletes or modifies audit records to hide activity | `rv_forbid_mutation()` trigger blocks all UPDATE/DELETE on `audit_logs`; SHA-256 hash chain (`prev_hash → hash`) computed by `rv_audit_hash_chain()` trigger on INSERT; hash chain can be replayed offline to detect any gap or substitution | +| Verification evidence tampering | Attacker modifies GPS coordinates or liveness score after the fact | `rv_forbid_mutation()` triggers on `identity_verifications`, `gps_verifications`, `device_verifications`, `fraud_events`; `visit_verifications.evidence_hash` is a SHA-256 over the canonical evidence package, stored at sealing time | +| Selfie/GPS payload replay | Fraudster replays a previously accepted selfie or GPS coordinate | `IdentityVerification` records are append-only with `createdAt` timestamp; replay detection compares probe against enrolled reference with liveness score threshold; GPS `capturedAt` is server-validated against clock-in window | +| Score manipulation by insider | Investigator artificially lowers a provider's risk score to protect a fraudulent provider | `fraud_scores` and `provider_risk_profiles` updates are audit-logged with actor, before/after diff; score computation is automated by the ML pipeline — manual overrides require `fraud_score:override` permission and are separately logged | + +#### Repudiation + +| Threat | Attack Scenario | Mitigations | +|--------|----------------|-------------| +| Investigator denies accessing PHI | Investigator exports beneficiary data then claims they did not | `AuditLog` records every `READ` and `EXPORT` with `actorId`, `resourceType`, `resourceId`, `ipAddress`, `userAgent`; log forwarding to immutable WORM store (S3 Object Lock) within 60 seconds | +| Caregiver denies performing visit | Caregiver claims they were elsewhere | GPS + selfie + device evidence chain is append-only with timestamps; `CaseEvidence.contentHash` provides chain-of-custody for any exported artifact | + +#### Information Disclosure + +| Threat | Attack Scenario | Mitigations | +|--------|----------------|-------------| +| Cross-tenant data leakage | Bug in query allows Tenant A to see Tenant B records | PostgreSQL RLS as an independent enforcement layer below the application; all 19 business tables have `FORCE ROW LEVEL SECURITY`; integration tests assert cross-tenant queries return empty | +| PHI in logs | Structured logs inadvertently capture medicaid_member_id or biometric data | PHI scrubber middleware strips known PHI field names from structured log payloads before emission; field-level encryption means even raw DB dumps expose only ciphertext | +| Biometric template exfiltration | Attacker obtains face embeddings from the vector store | Reference images stored in S3 under SSE-KMS; only a pointer (`templateRef`) is stored in `biometric_enrollments`, not the raw vector; access to the vector store is gated by IAM policy; biometric data is never returned by any API endpoint | +| Indirect object reference | Attacker enumerates UUIDs to access records they do not own | All PKs are UUID v4 (non-sequential, non-guessable); RLS policy enforces tenant boundary even if a valid UUID is guessed | + +#### Denial of Service + +| Threat | Attack Scenario | Mitigations | +|--------|----------------|-------------| +| API flooding | Attacker floods authentication endpoint to lock out users or exhaust compute | AWS WAF rate limiting per IP/path; NestJS rate-limit guard on `/auth` endpoints (e.g., 10 req/min per IP); `failed_logins` counter with `locked_until` backoff for account-level lockout | +| Database partition exhaustion | Attacker submits millions of visits to overflow the `visits_default` partition | Input validation limits batch size; autoscaling on ECS; pg_partman creates future partitions proactively; `visits_default` catch-all partition prevents hard failures; monitoring alert on partition fill percentage | +| Noisy-neighbor resource contention | One high-volume tenant saturates DB connections | PgBouncer connection pooling with per-tenant pool limits; RDS instance sizing with separate read replicas for reporting queries; circuit breakers in NestJS services | + +#### Elevation of Privilege + +| Threat | Attack Scenario | Mitigations | +|--------|----------------|-------------| +| RBAC bypass | Attacker tampers with JWT claims to add permissions | JWT signed with RS256 using AWS KMS-managed private key; NestJS guard validates signature and checks permissions against DB at request time — claims in the token are not trusted for authorization decisions without DB re-validation | +| SQL injection to bypass RLS | Attacker injects SQL to set `app.current_org` to a different tenant | Prisma ORM parameterized queries; raw SQL execution requires explicit `$queryRaw` with Prisma's tagged template (auto-parameterized); WAF SQL injection ruleset; defense-in-depth: even a successful injection into a query predicate cannot override `FORCE ROW LEVEL SECURITY` | +| Privilege escalation via role assignment | User assigns themselves a higher role | `user_role:assign` permission required; only `ORG_ADMIN` and `OIG_AGENT` hold this permission; all role assignment events are audit-logged as `CONFIG_CHANGE` | + +### 2.3 Fraud-Platform-Specific Abuse Cases + +| Abuse Case | Description | Control | +|-----------|-------------|---------| +| Insider gaming fraud scores | An investigator with `fraud_score:write` suppresses a provider's score | Score override requires `fraud_score:override` permission (held only by `COMPLIANCE_OFFICER` and above); override is a separate audit event; ML pipeline re-computes scores nightly regardless | +| GPS spoofing / mock location | Caregiver uses a GPS spoofing app to fake presence at the patient's address | Device verification detects emulator flag (`is_emulator`), rooted/jailbroken status (`is_rooted`, `is_jailbroken`); device trust level set to `SUSPICIOUS` or `BLOCKED`; GPS accuracy check (`accuracy_meters` threshold) flags low-confidence locations | +| Selfie photo injection | Fraudster replaces the probe image with a known-good image from a previous session | Liveness detection score (`liveness_score`) required above threshold; server-side image integrity check; probe S3 key is write-once with object lock; `createdAt` timestamp server-assigned | +| Multi-tenant data leakage via shared infrastructure | Two tenants on the same RDS instance; query bug exposes cross-tenant rows | PostgreSQL RLS with `FORCE RLS`; `app.current_org` GUC set per transaction, cleared after transaction commit; automated integration tests assert empty result sets on cross-tenant queries | +| Replay attack on verification package | Attacker replays a previously passed verification to approve a fraudulent visit | `visit_verifications` is 1:1 with `visits` (`UNIQUE visitId`); a second verification attempt for the same visit is rejected at the DB unique constraint; `evidenceHash` is computed over timestamps | +| Duplicate billing via shared caregiver | One caregiver logs overlapping visits across two patients simultaneously | `SERVICE_OVERLAP` and `DUPLICATE_VISIT` fraud detectors check for concurrent visit windows; `IMPOSSIBLE_TRAVEL` detector flags physically impossible transitions | +| Case evidence tampering for litigation | Investigator modifies a case note before handing to OIG | `case_notes` created_at is server-assigned; notes cannot be updated (append-only by convention, enforced by application layer); `CaseEvidence.contentHash` covers exported artifacts; all `CASE_ACTION` events are audit-logged | + +--- + +## 3. Identity & Access Management + +### 3.1 Authentication + +#### JWT Token Architecture + +RayVerify uses a dual-token model: + +- **Access token:** RS256-signed JWT, 15-minute TTL. Contains `sub` (user UUID), `org` (organization UUID), `roles` (array of role keys), `iat`, `exp`. Signed using an AWS KMS asymmetric key pair. The private key never leaves KMS. +- **Refresh token:** Cryptographically random 256-bit opaque string (generated server-side). Only the SHA-256 hash (`refresh_token_hash`) is stored in the `sessions` table — the raw token is never persisted. This means a database breach does not yield usable refresh tokens. + +Token lifecycle: + +``` +POST /auth/login (password + MFA) → { accessToken, refreshToken } + ↓ + sessions.refresh_token_hash = SHA256(refreshToken) + ↓ +POST /auth/refresh (refreshToken) → validate hash → rotate → { newAccessToken, newRefreshToken } + ↓ +POST /auth/logout → sessions.revoked_at = now() +``` + +Refresh token rotation: on every `/auth/refresh` call, the old token is invalidated and a new token pair is issued. Reuse of an old refresh token (after rotation) triggers session revocation and an audit log `LOGOUT` event flagged as suspicious. + +#### Password Security + +Passwords are hashed using **Argon2id** (memory-hard, side-channel-resistant). The schema field is `password_hash` — plaintext is never stored. SSO-only accounts have `password_hash = NULL`. The NestJS auth module enforces a minimum password strength policy (length, character classes) at the DTO validation layer. + +#### Multi-Factor Authentication + +| Method | Schema field | Description | +|--------|-------------|-------------| +| TOTP | `mfa_method = TOTP`, `mfa_secret` (encrypted) | RFC 6238 time-based OTP; the TOTP secret is AES-256-GCM encrypted via AWS KMS before storage in `users.mfa_secret` | +| SMS | `mfa_method = SMS` | One-time code delivered via SMS; code stored transiently in Redis with 5-minute TTL | +| WebAuthn | `mfa_method = WEBAUTHN` | FIDO2 passkey/hardware security key; credential public keys stored in a separate `webauthn_credentials` table (not shown in base schema) | + +MFA is enforced for all platform users (investigators, auditors, admins). Caregiver mobile app authentication uses selfie + liveness as the second factor in conjunction with a PIN/biometric. + +#### Account Lockout + +The `users` table carries two lockout fields: + +- `failed_logins INT DEFAULT 0`: incremented on each failed authentication attempt. +- `locked_until TIMESTAMPTZ`: set to `now() + lockout_duration` after the threshold is exceeded (default: 5 consecutive failures triggers a 30-minute lockout with exponential backoff). + +A locked account (`status = LOCKED` or `locked_until > now()`) rejects all authentication attempts regardless of credential correctness, preventing timing-based enumeration. The lockout is reset on successful authentication or by an `ORG_ADMIN` via an explicit unlock operation (audit-logged as `CONFIG_CHANGE`). + +### 3.2 Authorization — RBAC Model + +The permission model is a three-layer graph: `User` → `UserRole` → `Role` → `RolePermission` → `Permission`. + +**Permission key format:** `resource:action` + +Examples: `fraud_case:read`, `fraud_case:assign`, `fraud_case:close`, `report:export`, `visit:approve`, `fraud_score:override`, `provider:read`, `user_role:assign`, `audit_log:read`, `config:write`. + +#### System Role → Permission Matrix + +| Permission | INVESTIGATOR | AUDITOR | COMPLIANCE_OFFICER | ORG_ADMIN | OIG_AGENT | +|-----------|:---:|:---:|:---:|:---:|:---:| +| `visit:read` | Yes | Yes | Yes | Yes | Yes | +| `visit:approve` | Yes | No | Yes | Yes | Yes | +| `fraud_event:read` | Yes | Yes | Yes | Yes | Yes | +| `fraud_event:triage` | Yes | No | Yes | No | Yes | +| `fraud_case:read` | Yes | Yes | Yes | Yes | Yes | +| `fraud_case:create` | Yes | No | Yes | No | Yes | +| `fraud_case:assign` | Yes | No | Yes | Yes | Yes | +| `fraud_case:close` | No | No | Yes | Yes | Yes | +| `fraud_case:escalate` | Yes | No | Yes | No | Yes | +| `fraud_score:read` | Yes | Yes | Yes | Yes | Yes | +| `fraud_score:override` | No | No | Yes | No | Yes | +| `report:read` | Yes | Yes | Yes | Yes | Yes | +| `report:export` | Yes | Yes | Yes | Yes | Yes | +| `provider:read` | Yes | Yes | Yes | Yes | Yes | +| `audit_log:read` | No | Yes | Yes | Yes | Yes | +| `audit_log:export` | No | No | Yes | Yes | Yes | +| `user_role:assign` | No | No | No | Yes | Yes | +| `config:write` | No | No | No | Yes | No | +| `patient:read` | Yes | Yes | Yes | Yes | Yes | +| `case_note:create` | Yes | No | Yes | No | Yes | +| `case_evidence:add` | Yes | No | Yes | No | Yes | + +System roles (`is_system = TRUE`) are seeded and cannot be deleted or modified by tenant administrators. Custom roles can be created by `ORG_ADMIN` with an explicit subset of system-role permissions. + +All authorization checks occur in NestJS guards at the handler level. The RBAC guard resolves the requesting user's effective permission set from the database (cached in Redis with a short TTL), then asserts the required permission key. JWT role claims are used only for routing/UX — they are not the source of truth for authorization decisions. + +### 3.3 NIST 800-63 IAL/AAL Mapping + +| Actor | Identity Assurance Level | Authenticator Assurance Level | Rationale | +|-------|--------------------------|-------------------------------|-----------| +| Platform users (investigators, auditors, compliance) | IAL2 — identity proofed via government-issued ID during onboarding | AAL2 — password + TOTP/SMS/WebAuthn MFA | Handles PHI; HIPAA minimum; CMS program integrity access | +| ORG_ADMIN / OIG_AGENT | IAL2 | AAL2 (WebAuthn preferred) | Elevated privilege; phishing-resistant authenticator recommended | +| Caregivers (field workers) | IAL1 at enrollment; IAL2 roadmap (GOV_CREDENTIAL identity method) | AAL1 — PIN + selfie liveness | Identity initially proofed by the provider agency; liveness + biometric match as continuous assurance | +| State agency integrations | IAL2 (entity-level, mTLS client certificate) | AAL3-equivalent — hardware key bound to entity | Machine-to-machine; certificate pinning | +| Medicaid beneficiaries | IAL1 (indirect — identity held by Medicaid enrollment) | N/A — patients do not authenticate to RayVerify | Patient confirmation is a separate workflow | + +--- + +## 4. Data Protection + +### 4.1 Encryption at Rest + +RayVerify uses **AWS KMS envelope encryption** with per-tenant data keys: + +``` +Plaintext PHI + ↓ encrypt +AES-256-GCM (data key, per-tenant, 256-bit) + ↓ wraps +AWS KMS Customer Managed Key (CMK, per-environment) + ↓ stored as +Encrypted data key ciphertext (in Secrets Manager or alongside record) +``` + +The application layer fetches the data key from KMS (GenerateDataKey API call), encrypts the plaintext field, stores the ciphertext in the database column, and discards the plaintext data key from memory after use. Decryption requires a live KMS API call, meaning a database dump without KMS access yields only ciphertext. + +**Key hierarchy:** + +| Layer | Key Type | Rotation | +|-------|----------|----------| +| AWS KMS CMK (per environment) | Asymmetric RSA-4096 (JWT signing) + Symmetric AES-256 (data encryption) | Annual rotation via KMS automatic key rotation | +| Per-tenant data key | AES-256, generated by KMS GenerateDataKey | On-demand re-keying per tenant; rotated at least annually | +| TOTP secret (`mfa_secret`) | AES-256-GCM under per-user data key | Re-encrypted on MFA re-enrollment | + +### 4.2 Field-Level Encryption and Blind Indexing for PHI + +The following columns are encrypted at the application layer before storage: + +| Table | Column | PHI Category | Blind Index | +|-------|--------|-------------|-------------| +| `patients` | `medicaid_member_id` | Medicaid ID | Yes — HMAC-SHA256 truncated to 128 bits for lookup | +| `providers` | `tax_id` | Federal tax ID (EIN) | No — searched by NPI | +| `users` | `mfa_secret` | TOTP seed | No — write-only | +| `biometric_enrollments` | `reference_s3_key` (pointer) | Biometric reference | No — accessed by caregiver UUID | +| `identity_verifications` | `probe_s3_key` (pointer) | Probe biometric image | No — accessed by visit UUID | + +Blind indexing: the application computes `HMAC-SHA256(key=blind_index_secret, message=normalized_medicaid_member_id)` and stores the first 128 bits as a separate column (`medicaid_member_id_bidx`). Equality searches use the blind index column; the ciphertext column is decrypted only at point of use. The HMAC key is distinct from the encryption data key and is stored in AWS Secrets Manager. + +### 4.3 Encryption in Transit + +All external traffic uses **TLS 1.3** (minimum TLS 1.2 with deprecated cipher suites disabled). The AWS ALB terminates TLS at the edge; CloudFront enforces TLSv1.3_2021 security policy. + +Internal service-to-service communication (ECS container-to-container, NestJS microservice calls) uses **mTLS** with certificates issued by AWS Private CA. Service identities are bound to ECS task roles; certificates are rotated every 30 days. + +Database connections from the application to RDS PostgreSQL use TLS with certificate verification. The PostgreSQL `ssl_mode=verify-full` parameter is set in the connection string. + +### 4.4 S3 Evidence Encryption and Lifecycle + +Biometric evidence (reference selfies, probe images) is stored in a dedicated S3 bucket: + +- **Encryption:** SSE-KMS with the tenant CMK. All objects are encrypted at upload; unencrypted uploads are blocked by S3 bucket policy. +- **Access:** Pre-signed URLs with 5-minute expiry are issued for read access; no public access is permitted. IAM policies restrict S3:GetObject to the specific ECS task role. +- **Object Lock:** Evidence objects are protected with S3 Object Lock in COMPLIANCE mode, retention period aligned to the state's records retention requirement (default: 7 years). +- **Lifecycle:** Probe images (per-visit identity verification) transition to S3 Glacier Instant Retrieval after 90 days and are deleted at the retention boundary. Reference enrollment images are retained while the enrollment is active plus 7 years. +- **Versioning:** Enabled on the evidence bucket; deletion of any object version creates a delete marker that is itself immutable during the lock period. + +### 4.5 Secrets Management + +All secrets (database passwords, API keys, third-party credentials, HMAC keys, data keys) are stored in **AWS Secrets Manager**. The application retrieves secrets at startup via the Secrets Manager SDK; secrets are never embedded in container images, environment variables in task definitions, or source code. Secret scanning (Gitleaks, Semgrep) runs in CI to prevent accidental commits. + +Secret rotation: +- Database credentials: Secrets Manager automatic rotation every 30 days via Lambda rotation function. +- Application API keys: Rotated manually with zero-downtime dual-key pattern (old key remains valid for 24 hours after rotation). +- JWT signing key pair: KMS key rotation; old signing keys are retained for JWT verification until all tokens issued with the old key have expired. + +--- + +## 5. Multi-Tenant Isolation + +### 5.1 Tenant Data Model + +Every business table carries `organization_id UUID NOT NULL REFERENCES organizations(id)`. The `organizations` table is the tenant root. A tenant corresponds to a single state Medicaid agency, MCO, or oversight unit. Cross-tenant queries are architecturally impossible at the database layer. + +### 5.2 PostgreSQL Row-Level Security — Deep Dive + +RLS is applied to all 19 business tables in a single migration block that iterates over the table list: + +```sql +ALTER TABLE
ENABLE ROW LEVEL SECURITY; +ALTER TABLE
FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON
+ USING (organization_id = current_setting('app.current_org', true)::uuid) + WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid); +``` + +`FORCE ROW LEVEL SECURITY` is critical: it applies the policy even to the table owner role. Without it, the table owner can bypass policies. The `BYPASSRLS` privilege is granted only to a dedicated migration role (`rayverify_migrations`) that is never used by the live application process. + +**GUC lifecycle per request:** + +``` +Request arrives at NestJS + → JWT guard validates token, extracts organizationId + → Database middleware: SET LOCAL app.current_org = '' + → Prisma query executes (RLS policy evaluated per row) + → Transaction commits → GUC resets to default (empty string) + → Next request starts fresh +``` + +`SET LOCAL` scopes the GUC to the current transaction; if the transaction is rolled back or the connection is returned to the pool, the GUC resets. This prevents GUC bleed across pooled connections. + +### 5.3 Connection and Role Separation + +| Role | Privileges | Used by | +|------|-----------|---------| +| `rayverify_app` | SELECT, INSERT, UPDATE on specific tables; cannot DDL, cannot BYPASSRLS | NestJS application process | +| `rayverify_readonly` | SELECT only; cannot BYPASSRLS | Read replicas; reporting queries; BI tools | +| `rayverify_migrations` | DDL; BYPASSRLS; CREATE/DROP | Schema migration process (Prisma migrate) | +| `rds_superuser` | Full | RDS maintenance only; no application access | + +### 5.4 Preventing Cross-Tenant Leakage + +Additional safeguards beyond RLS: + +- **Integration tests:** The test suite includes a dedicated cross-tenant isolation test suite that creates two organizations, authenticates as Tenant A, and asserts that all read operations for Tenant B resources return empty or 403. +- **Query review:** All Prisma `findMany` and `findFirst` calls use `where: { organizationId }` as an additional application-layer filter. RLS is the enforcement layer; the application filter is a belt-and-suspenders defense. +- **JSONB data:** The `chain` JSONB field in `visit_verifications` and `evidence` in `fraud_events` may contain nested references. A JSONB scrubber in the serialization layer strips any cross-tenant UUID references. + +### 5.5 Noisy Neighbor + +- **Connection pool limits:** PgBouncer enforces per-tenant connection pool limits to prevent a single high-volume tenant from exhausting the RDS max_connections. +- **Redis keyspace isolation:** All Redis keys are prefixed with `org::` to provide logical namespace separation. +- **ECS resource limits:** CPU and memory limits are set per task definition; a single tenant's background jobs (report generation, score computation) cannot starve other tenants. + +--- + +## 6. Auditability & Integrity + +### 6.1 Append-Only Verification Tables + +The following tables are immutable by database trigger (`rv_forbid_mutation()`): + +- `identity_verifications` — every selfie/liveness check attempt +- `gps_verifications` — every GPS coordinate capture event +- `device_verifications` — every device posture snapshot +- `fraud_events` — every fraud signal detection +- `audit_logs` — every auditable action + +The trigger raises `integrity_constraint_violation` on any `UPDATE` or `DELETE` attempt, regardless of which database role issues it: + +```sql +CREATE OR REPLACE FUNCTION rv_forbid_mutation() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'Table % is append-only; % is not permitted', + TG_TABLE_NAME, TG_OP USING ERRCODE = 'integrity_constraint_violation'; +END; $$ LANGUAGE plpgsql; +``` + +This means that even the `rds_superuser` account cannot silently modify evidence records — any modification attempt raises an exception and is visible in RDS logs. + +### 6.2 Audit Log Hash Chain + +Every insert into `audit_logs` is processed by the `rv_audit_hash_chain()` `BEFORE INSERT` trigger. The trigger constructs a cryptographic hash chain per organization: + +```sql +-- Pseudocode of rv_audit_hash_chain() +v_prev := SELECT hash FROM audit_logs + WHERE organization_id = NEW.organization_id + ORDER BY created_at DESC, id DESC LIMIT 1; + +NEW.prev_hash := v_prev; +NEW.hash := encode(digest( + coalesce(v_prev,'') || + NEW.organization_id::text || + coalesce(NEW.actor_id::text,'') || + NEW.action::text || + NEW.resource_type || + coalesce(NEW.resource_id,'') || + NEW.metadata::text || + NEW.created_at::text, + 'sha256'), 'hex'); +``` + +**Chain inputs:** previous hash (chain linkage) + organization_id + actor_id + action + resource_type + resource_id + metadata + created_at. + +**Chain verification procedure:** + +1. Select all `audit_logs` for an organization, ordered by `created_at ASC, id ASC`. +2. Re-compute `hash` for each row using the same inputs. +3. Assert that `row[n].prev_hash == row[n-1].hash` for all n > 0. +4. Assert that the computed hash equals the stored `row[n].hash`. + +Any deletion, insertion-with-reordering, or field modification produces a hash mismatch detectable at step 3 or 4. The verification tool is part of the `Audit & Compliance Center` module and can be run by `COMPLIANCE_OFFICER` and `OIG_AGENT` roles on demand or on a scheduled basis. + +### 6.3 Log Forwarding to Immutable Store + +Audit log records are forwarded to an S3 bucket with **Object Lock (COMPLIANCE mode)** enabled within 60 seconds of creation: + +``` +audit_logs INSERT → PostgreSQL NOTIFY → NestJS audit-forwarder service + → S3 PutObject (SSE-KMS) → Object Lock applied (7-year retention) +``` + +The S3 export is in NDJSON format; each line is a complete audit record. The forwarder itself is audited: any failure to forward (e.g., S3 unreachable) generates an alert and is retried with exponential backoff. The audit export bucket is separate from the evidence bucket and has no delete permissions for any application role. + +### 6.4 Evidence Chain-of-Custody + +`CaseEvidence` records include `content_hash TEXT` — a SHA-256 hash of the referenced artifact (S3 object, exported report, GPS package). When evidence is collected: + +1. The artifact is written to S3. +2. The server computes `SHA-256(artifact bytes)`. +3. The hash is stored in `case_evidence.content_hash`. + +Before presenting evidence in a proceeding, the recipient can recompute the hash from the S3 object and compare it to the stored `content_hash`. A mismatch proves tampering. The S3 Object Lock prevents the artifact from being replaced. + +### 6.5 Tamper Detection + +An automated tamper-detection job runs nightly (ECS Scheduled Task): + +1. Fetches the last 10,000 audit log records per organization. +2. Re-computes the hash chain. +3. Compares against stored hashes. +4. Emits a `CRITICAL` alert to the on-call channel if any mismatch is detected. +5. Results are written to a separate `hash_verification_runs` table (append-only) for historical record. + +--- + +## 7. Application Security + +### 7.1 Input Validation + +All API inputs are validated using NestJS `class-validator` decorators at the DTO layer before reaching any business logic. Validation includes: +- Type checking (string, number, UUID, enum membership) +- Format validation (NPI: `^[0-9]{10}$` enforced at DB level via `chk_npi_format` CHECK constraint; coordinates: latitude `[-90,90]`, longitude `[-180,180]` enforced by `chk_lat`, `chk_lng` constraints; risk_score `[0,100]` via `chk_visit_risk`; severity `[0,100]` via `chk_severity`) +- Length limits to prevent oversized payloads +- Whitelist validation (unknown properties are stripped by `ValidationPipe({ whitelist: true })`) + +### 7.2 Output Encoding + +API responses are serialized via NestJS interceptors that: +- Apply `class-transformer` `@Exclude()` to sensitive fields (`passwordHash`, `mfaSecret`, `refreshTokenHash`) — these fields are never present in any API response. +- Scrub PHI fields from non-privileged role responses (e.g., `INVESTIGATOR` sees masked `medicaid_member_id`; `AUDITOR` sees only the blind index). +- Set `Content-Type: application/json; charset=utf-8` on all responses. + +### 7.3 OWASP ASVS Alignment + +RayVerify targets OWASP ASVS Level 2 (standard) with Level 3 controls on authentication and session management: + +| ASVS Category | Level | Key Controls | +|--------------|-------|-------------| +| V2 — Authentication | L3 | Argon2id, TOTP/WebAuthn MFA, account lockout, refresh token rotation, PKCE for OAuth flows | +| V3 — Session Management | L3 | HttpOnly+Secure cookies, refresh token hash storage, absolute session expiry, session revocation on password change | +| V4 — Access Control | L2 | RBAC guard on every handler, RLS at DB layer, no IDOR (UUID PKs + RLS) | +| V5 — Validation & Encoding | L2 | class-validator whitelist, Prisma parameterized queries, output serialization | +| V6 — Stored Cryptography | L2 | AES-256-GCM, Argon2id, SHA-256 chain, KMS key management | +| V9 — Communication | L2 | TLS 1.3, mTLS internal, HSTS | +| V10 — Malicious Code | L2 | Dependency scanning (npm audit, Dependabot), SAST (Semgrep, CodeQL) | +| V12 — Files & Resources | L2 | Selfie upload size limit (5 MB), MIME type validation, virus scan before storage, pre-signed URL download | +| V13 — API & Web Service | L2 | OpenAPI 3.1 contract enforcement, rate limiting, CORS allowlist | + +### 7.4 Rate Limiting and Abuse Prevention + +| Endpoint Group | Limit | Window | +|---------------|-------|--------| +| `POST /auth/login` | 10 requests | 1 minute per IP | +| `POST /auth/refresh` | 20 requests | 1 minute per user | +| `POST /auth/mfa/verify` | 5 requests | 1 minute per user | +| `POST /visits/*/verify` (identity submit) | 30 requests | 5 minutes per caregiver | +| `GET /reports` (export) | 5 requests | 1 hour per user | +| All authenticated endpoints | 1,000 requests | 1 minute per organization | + +Limits are enforced by NestJS `@nestjs/throttler` with Redis as the backing store for distributed rate state. + +### 7.5 CSRF and CORS + +- **CSRF:** The API is stateless (JWT Bearer tokens); CSRF tokens are not required for the NestJS API. The frontend Next.js application uses `SameSite=Strict` cookies for any session cookies and includes the `X-CSRF-Token` header for non-read operations. +- **CORS:** Strict allowlist of permitted origins (`ALLOWED_ORIGINS` environment variable). Pre-flight requests are handled; credentials are allowed only for origins on the allowlist. Wildcard origins are never permitted. + +### 7.6 Secure Headers + +All API responses include: + +``` +Strict-Transport-Security: max-age=31536000; includeSubDomains; preload +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +Referrer-Policy: strict-origin-when-cross-origin +Content-Security-Policy: default-src 'self'; frame-ancestors 'none' +Permissions-Policy: geolocation=(), microphone=(), camera=() +``` + +CloudFront is configured to forward and add security headers before the response reaches the client. + +### 7.7 Selfie Upload Hardening (SSRF and File Upload) + +Biometric selfie images represent a special attack surface: + +- **Size limit:** 5 MB maximum; enforced at the API gateway and NestJS validation layer. +- **MIME type validation:** Accepted types: `image/jpeg`, `image/png`, `image/webp` only; validated by inspecting the file magic bytes (not just the Content-Type header). +- **Virus scan:** Files are scanned by ClamAV (running as a sidecar container) before being stored to S3. Infected files are quarantined. +- **No client-supplied S3 keys:** The server generates the S3 object key (`org/{orgId}/selfies/{caregiverId}/{timestamp}-{uuid}.jpg`). Client cannot control the storage path. +- **No SSRF via URL:** Selfie submission requires a direct binary upload; URL-based image fetch endpoints are not offered, preventing SSRF. +- **Write-once:** After the identity verification record is created, the `probe_s3_key` column is immutable (covered by the append-only trigger). + +### 7.8 Biometric Data Minimization + +In compliance with state biometric privacy laws and HIPAA minimum-necessary principle: +- Raw face embeddings (vectors) are not stored in the PostgreSQL database; only a `templateRef` pointer to the vector store record is stored. +- Probe images (per-verification selfies) are lifecycle-deleted after 90 days unless linked to an open fraud case. +- Reference enrollment images are never accessible via API — only the matching result (PASS/REVIEW/FAIL) and confidence score are returned. +- Biometric data is excluded from all reporting exports; `EXPORT` audit events for reports containing biometric references are specifically flagged. + +### 7.9 Dependency and Secret Scanning + +- **npm audit:** Runs on every CI build; HIGH/CRITICAL vulnerabilities block merge. +- **Dependabot:** Automated dependency update PRs for security patches; auto-merged for patch-level updates in non-PHI-handling packages. +- **Semgrep:** SAST rules for OWASP Top 10 patterns, secrets detection, and Prisma/NestJS-specific rule sets. +- **CodeQL:** GitHub Advanced Security CodeQL scanning for JavaScript/TypeScript. +- **Gitleaks:** Pre-commit hook and CI scan for accidental secret commits. +- **Trivy:** Container image scanning for OS and application CVEs; scan runs before ECS deployment. + +--- + +## 8. Network & Infrastructure Security + +### 8.1 VPC Architecture + +```mermaid +graph TB + subgraph VPC["AWS VPC — 10.0.0.0/16"] + subgraph PubSubnet["Public Subnets (2 AZs)"] + ALB[Application Load Balancer] + NAT[NAT Gateway] + end + subgraph AppSubnet["Private App Subnets (2 AZs)"] + ECS[ECS Fargate Tasks] + CACHE[ElastiCache Redis] + end + subgraph DataSubnet["Private Data Subnets (2 AZs)"] + RDS[(RDS PostgreSQL Multi-AZ)] + RDSRead[(RDS Read Replica)] + end + end + CF[CloudFront CDN] --> WAF[AWS WAF] --> ALB + ALB --> ECS + ECS --> RDS + ECS --> CACHE + ECS --> S3[(S3 via VPC Endpoint)] + ECS --> KMS[(KMS via VPC Endpoint)] + ECS --> SM[(Secrets Manager via VPC Endpoint)] +``` + +### 8.2 Subnet Segmentation + +| Subnet Tier | Inbound | Outbound | +|------------|---------|----------| +| Public | 443/tcp from internet (ALB), ICMP health checks | Via NAT Gateway to internet | +| Private App | 4000/tcp from ALB security group only | RDS port 5432, Redis port 6379, S3/KMS/SM via VPC endpoints; HTTPS via NAT for third-party APIs | +| Private Data | 5432/tcp from App security group only | No outbound internet | + +### 8.3 Security Groups + +Security groups follow strict allowlisting: +- **ALB SG:** Ingress 443/tcp from `0.0.0.0/0`. Egress 4000/tcp to ECS SG. +- **ECS SG:** Ingress 4000/tcp from ALB SG only. Egress to RDS SG (5432), Redis SG (6379), S3 prefix list, KMS/SM VPC endpoints. +- **RDS SG:** Ingress 5432/tcp from ECS SG only. No public ingress. +- **Redis SG:** Ingress 6379/tcp from ECS SG only. + +All security group rules are IaC-managed (Terraform); ad-hoc console modifications trigger a compliance drift alert via AWS Config. + +### 8.4 WAF and DDoS Protection + +- **AWS WAF v2:** Deployed in front of CloudFront with managed rule groups: `AWSManagedRulesCommonRuleSet`, `AWSManagedRulesKnownBadInputsRuleSet`, `AWSManagedRulesSQLiRuleSet`. Custom rate-based rules per endpoint. +- **AWS Shield Standard:** Automatically applied to ALB and CloudFront; provides Layer 3/4 DDoS protection. +- **AWS Shield Advanced:** Recommended for state deployment; provides Layer 7 DDoS protection, cost protection, and 24/7 DRT (DDoS Response Team) access. + +### 8.5 Bastion-less Access (AWS Systems Manager) + +There are no bastion hosts. Operator access to ECS containers and RDS is via **AWS Systems Manager Session Manager**: +- ECS exec: `aws ecs execute-command` routed through SSM; all session activity is logged to CloudWatch Logs and S3. +- RDS access: via SSM port-forwarding to a local psql client; no direct public RDS endpoint. +- All SSM session starts generate an audit event in CloudTrail. + +### 8.6 No Public Database + +The RDS cluster has no public endpoint. `publicly_accessible = false` is enforced in the Terraform RDS module. Access from outside the VPC requires SSM port-forwarding through an authorized bastion-less session. + +--- + +## 9. Compliance Control Mapping + +### 9.1 HIPAA Security Rule — Administrative, Physical, Technical Safeguards + +| HIPAA Safeguard | Standard / Implementation Spec | RayVerify Control | +|----------------|--------------------------------|------------------| +| Administrative | Security Officer designation | Named CISO/Security Officer role; documented security policy set | +| Administrative | Risk Analysis (§164.308(a)(1)) | Annual NIST 800-30 risk assessment; threat model maintained in this document | +| Administrative | Workforce training | Security awareness training required pre-access; annual recertification | +| Administrative | Access management (§164.308(a)(4)) | RBAC with least-privilege roles; user provisioning workflow; access reviews quarterly | +| Administrative | Contingency plan (§164.308(a)(7)) | DR runbook in doc 11; RTO 4h / RPO 1h targets; annual DR test | +| Administrative | Audit controls (§164.312(b)) | Immutable `audit_logs` table with hash chain; all 11 `AuditAction` types captured | +| Physical | Facility access (§164.310(a)) | AWS data centers hold ISO 27001, SOC 2 Type II; no customer physical access required | +| Physical | Workstation use (§164.310(b)) | MDM policy for admin workstations; screen lock timeout; disk encryption required | +| Physical | Device and media controls (§164.310(d)) | No PHI stored on endpoint devices; all PHI in RDS (encrypted) or S3 (encrypted) | +| Technical | Access control (§164.312(a)(1)) | JWT + RBAC + PostgreSQL RLS; unique user IDs; emergency access procedure | +| Technical | Automatic logoff (§164.312(a)(2)(iii)) | 15-minute JWT access token expiry; idle session timeout in frontend | +| Technical | Encryption and decryption (§164.312(a)(2)(iv)) | AES-256-GCM at rest via KMS; TLS 1.3 in transit; field-level encryption for PHI columns | +| Technical | Audit controls (§164.312(b)) | Append-only audit_logs; hash chain; forwarded to WORM S3 | +| Technical | Integrity (§164.312(c)(1)) | Immutability triggers; evidence_hash on visit_verifications; content_hash on case_evidence | +| Technical | Transmission security (§164.312(e)(1)) | TLS 1.3 external; mTLS internal; no PHI in URLs or logs | + +### 9.2 NIST 800-53 Control Families + +| Control Family | NIST Control | RayVerify Implementation | +|---------------|--------------|------------------------| +| AC — Access Control | AC-2 Account Management | User lifecycle (PENDING_INVITE → ACTIVE → SUSPENDED/LOCKED); quarterly access reviews | +| AC — Access Control | AC-3 Access Enforcement | PostgreSQL RLS + NestJS RBAC guard; every permission check is explicit | +| AC — Access Control | AC-6 Least Privilege | Role-permission matrix; system roles cannot be elevated by tenants | +| AC — Access Control | AC-17 Remote Access | All access via HTTPS/TLS; no VPN required; SSM for operator access | +| AU — Audit and Accountability | AU-2 Event Logging | 11 AuditAction types covering all NIST AU-2 events | +| AU — Audit and Accountability | AU-3 Audit Record Content | actor, action, resource_type, resource_id, ip_address, user_agent, metadata, timestamp | +| AU — Audit and Accountability | AU-9 Audit Protection | Immutability trigger; hash chain; WORM S3 export; no delete permission on audit bucket | +| AU — Audit and Accountability | AU-11 Audit Retention | S3 Object Lock 7-year retention; partition-based DB retention | +| IA — Identification and Authentication | IA-2 User Identification | Unique user UUID + email per organization | +| IA — Identification and Authentication | IA-2(1) MFA | TOTP/SMS/WebAuthn required for all platform users | +| IA — Identification and Authentication | IA-5 Authenticator Management | Argon2id for passwords; encrypted TOTP secrets; refresh token hash | +| IA — Identification and Authentication | IA-8 Non-Org Users | IAL2 identity proofing for state agency integrations | +| SC — System and Communications Protection | SC-8 Transmission Integrity | TLS 1.3; mTLS internal | +| SC — System and Communications Protection | SC-28 Protection at Rest | AES-256-GCM; KMS CMK; field-level encryption | +| SI — System and Information Integrity | SI-2 Flaw Remediation | CVE scanning (Trivy, npm audit); patching SLA (Critical: 24h, High: 7d) | +| SI — System and Information Integrity | SI-7 Software Integrity | Container image digest pinning; Sigstore image signing in CI | +| CP — Contingency Planning | CP-9 Information Backup | RDS automated backups (35-day PITR); S3 versioning; cross-region backup replication | +| IR — Incident Response | IR-6 Incident Reporting | HIPAA breach rule 60-day notification; documented IR playbook | +| CM — Configuration Management | CM-7 Least Functionality | Minimal container base images (distroless); no SSH in containers; SSM-only operator access | +| SA — System and Services Acquisition | SA-11 Developer Testing | SAST/DAST in CI; OWASP ASVS L2 target; annual penetration test | + +### 9.3 SOC 2 Trust Services Criteria + +| TSC | Criterion | RayVerify Control | +|-----|-----------|------------------| +| CC6.1 | Logical and physical access controls | RBAC + RLS + MFA + account lockout; AWS IAM least-privilege | +| CC6.2 | Prior to issuance of credentials | User invitation flow; identity proofing per NIST 800-63 IAL2; BAA in place before access | +| CC6.3 | Removal of access | User status lifecycle; session revocation; quarterly access reviews | +| CC7.1 | Detection of security events | WAF alerts; CloudTrail; anomaly detection on audit log volume; tamper detection job | +| CC7.2 | Monitoring of system components | CloudWatch metrics, logs, alarms; ECS health checks; RDS Performance Insights | +| CC7.3 | Evaluation of security events | SIEM forwarding; on-call runbook; incident severity classification | +| CC8.1 | Change management | IaC-only infrastructure changes; PR review + CI gate for code changes; change advisory board for production | +| CC9.1 | Vendor risk management | AWS BAA; third-party vendor security questionnaires; SOC 2 reports required for sub-processors | +| A1.1 | System availability | Multi-AZ RDS; ECS Fargate multi-AZ; SLO 99.9% uptime | +| A1.2 | Environmental protections | AWS facility controls; DR runbook with tested RTO/RPO | +| C1.1 | Confidentiality of information | PHI encryption at rest and in transit; access limited to authorized roles; data classification policy | +| PI1.1 | Processing completeness | Visit verification chain integrity; evidence_hash; append-only verification tables | + +### 9.4 CMS EVV Requirements (21st Century Cures Act) + +| CMS EVV Requirement | Data Element | RayVerify Implementation | +|--------------------|-------------|------------------------| +| Type of service performed | `visits.service_code` (HCPCS/state-specific) | Captured at visit creation; validated against `service_authorizations.service_code` | +| Individual who receives service | `visits.patient_id` → `patients` | Medicaid beneficiary linked to visit; `medicaid_member_id` encrypted and blind-indexed | +| Date of service | `visits.scheduled_start`, `clock_in_at`, `clock_out_at` | Server-timestamped; GPS `captured_at` corroborates | +| Location of service | `gps_verifications.latitude/longitude`; compared to `service_authorizations.latitude/longitude` | Geofence check (`distance_meters` vs `radius_meters`); result stored in `gps_verifications.result` | +| Individual providing service | `visits.caregiver_id` → `caregivers` | Identity verified via `identity_verifications` (selfie + liveness) | +| Begin and end time of service | `visits.clock_in_at`, `clock_out_at`, `duration_minutes` | Clock events captured by mobile app; GPS verification at both clock events | + +Additional program integrity features beyond minimum CMS EVV: +- Biometric identity verification (not required by CMS, but exceeds minimum standard) +- Device trust evaluation (TRUSTED/UNKNOWN/SUSPICIOUS/BLOCKED) +- Fraud Intelligence Engine (13 detector types, ML-scored 0-100) +- Tamper-evident audit chain (exceeds CMS logging requirements) +- Case management and OIG referral workflow + +--- + +## 10. Privacy + +### 10.1 Minimum Necessary Standard + +Under HIPAA §164.502(b), covered entities must limit PHI use and disclosure to the minimum necessary. RayVerify implements this at the API layer: + +- API responses are filtered by role: an `INVESTIGATOR` sees masked `medicaid_member_id` (e.g., `***-***-1234`); only `COMPLIANCE_OFFICER` and `OIG_AGENT` can retrieve the full value. +- Beneficiary PHI is not included in fraud score outputs; only anonymized risk signals are returned. +- Internal `case_notes` with `is_internal = TRUE` are excluded from any beneficiary disclosure responses. +- Report generation scopes data to the requesting organization; cross-tenant data is never included even in aggregate reports. + +### 10.2 Data Retention and Disposal + +| Data Category | Retention Period | Disposal Method | +|--------------|----------------|----------------| +| Visit records + verification evidence | 7 years (CMS default; state-specific may vary) | DB partition drop (hard delete); S3 lifecycle expiration | +| Audit logs | 7 years | S3 Object Lock expiration; DB partition drop | +| Probe selfie images (non-case) | 90 days | S3 lifecycle expiration | +| Reference enrollment images | Active enrollment + 7 years | S3 lifecycle expiration after `retired_at + 7y` | +| Session records (expired/revoked) | 90 days | DB scheduled job; soft-deleted first, hard-deleted after 90 days | +| Fraud scores (time-series) | 3 years for operational; 7 years for substantiated cases | Partition archival to S3 Glacier; DB drop | +| Reports (generated files) | `expires_at` (default: 30 days after generation) | S3 lifecycle; `reports.status = EXPIRED` | + +PHI disposal uses AWS S3 object expiration (hard delete) and RDS partition drop. KMS-encrypted objects that are deleted without key access are cryptographically unrecoverable. + +### 10.3 Business Associate Agreements + +RayVerify operates as a Business Associate under HIPAA §164.308(b). Before any tenant may upload PHI: + +1. A signed **BAA** must be on file between RayVerify (Anthropic/operator entity) and the covered entity (state Medicaid agency, MCO). +2. AWS provides a BAA covering all AWS services used by RayVerify (RDS, S3, KMS, ECS, CloudFront, SecretsManager, CloudWatch). +3. Any third-party sub-processors that touch PHI (biometric matching vendors, SMS providers) must have a BAA in place and be listed in the sub-processor disclosure. + +### 10.4 Breach Response + +In the event of a suspected PHI breach, RayVerify follows the HIPAA Breach Notification Rule (§164.400): + +1. **Detection:** Automated anomaly detection on audit log access patterns; WAF alerts; on-call escalation. +2. **Containment (0–4 hours):** Session revocation for affected accounts; tenant isolation review; snapshot of affected DB state. +3. **Risk Assessment (4–24 hours):** Determine if the acquisition/access was unauthorized; assess the probability that PHI has been compromised (four-factor test per §164.402). +4. **Notification (≤60 days):** If breach is confirmed, notify: (a) affected individuals, (b) covered entity/tenant, (c) HHS, (d) media (if >500 individuals in a state). +5. **Post-Incident (≤30 days after containment):** Root cause analysis; control remediation; updated risk assessment. + +All breach response actions are documented in audit logs and in a dedicated incident record (separate from the operational `fraud_cases` system). + +### 10.5 De-Identification for Analytics and ML + +When PHI is used for model training or analytics (fraud pattern analysis, risk model improvement): + +- PHI is de-identified per HIPAA Safe Harbor method (§164.514(b)): 18 identifiers removed or generalized. +- Alternatively, Expert Determination de-identification is used when Safe Harbor would destroy analytic utility (e.g., geographic precision needed for geofencing model training). +- De-identified datasets are stored in a separate analytics S3 bucket (not subject to HIPAA requirements but still encrypted and access-controlled). +- Re-identification is prohibited by policy; any analytics pipeline that attempts to join a de-identified dataset back to identified records triggers an alert and is blocked by IAM policy. +- Synthetic data (generated via differential-privacy-based mechanisms) is used for model testing in non-production environments. + +### 10.6 Beneficiary Rights + +Although beneficiaries (Medicaid patients) do not directly interact with RayVerify, covered entities (state agencies, MCOs) must be able to respond to beneficiary rights requests: + +| Right | HIPAA Reference | RayVerify Support | +|-------|----------------|------------------| +| Right of access | §164.524 | `patient:read` permission; `COMPLIANCE_OFFICER` can export a beneficiary's complete visit and verification history | +| Right to amend | §164.526 | Amendments recorded as new audit-logged events; original records are immutable (append-only) | +| Right to accounting of disclosures | §164.528 | `audit_logs` `EXPORT` and `READ` events for PHI resources, exportable by `COMPLIANCE_OFFICER` | +| Right to restrict | §164.522 | Restriction flags storable in `patients.settings` JSONB; enforcement via application-layer policy | + +--- + +*Document controlled. For questions contact the RayVerify Security & Compliance team.* +*Next review: annual or upon material architecture change.* diff --git a/docs/08-aws-deployment.md b/docs/08-aws-deployment.md new file mode 100644 index 0000000..76d2f47 --- /dev/null +++ b/docs/08-aws-deployment.md @@ -0,0 +1,538 @@ +# RayVerify™ — AWS Deployment Architecture + +> **Platform:** RayVerify™ | **Parent:** RayHealthEVV™ +> **Classification:** Government-grade · HIPAA · SOC 2 · CMS-EVV +> **IaC:** Terraform under `infra/terraform/` · AWS region primary: `us-east-1` (GovCloud path described in §7) + +--- + +## Table of Contents + +1. [Cloud Topology Overview](#1-cloud-topology-overview) +2. [Network Architecture & Security](#2-network-architecture--security) +3. [Compute Layer — ECS Fargate](#3-compute-layer--ecs-fargate) +4. [Data Layer](#4-data-layer) +5. [Edge & DNS](#5-edge--dns) +6. [Observability & Operations](#6-observability--operations) +7. [GovCloud / FedRAMP Considerations](#7-govcloud--fedramp-considerations) +8. [Terraform Module Layout](#8-terraform-module-layout) +9. [Cost & Environment Sizing](#9-cost--environment-sizing) + +--- + +## 1. Cloud Topology Overview + +RayVerify runs inside a dedicated **multi-AZ VPC** (`10.0.0.0/16`) spanning three Availability Zones. All stateful resources (RDS, ElastiCache) and compute services (ECS Fargate) run in **private subnets**. Only the Application Load Balancer and NAT Gateways have public-facing presence. State agency integrations reach the platform via AWS PrivateLink or a managed VPN — never over the public internet. + +```mermaid +flowchart TB + subgraph Internet["Internet / State Networks"] + BROWSER[Investigator Browser] + MOBILE[Field Capture / EVV App] + STATE[State Agency / MCO API] + end + + subgraph AWS["AWS us-east-1 (Multi-AZ VPC 10.0.0.0/16)"] + subgraph Edge["Edge Layer"] + CF[CloudFront CDN\nACM TLS · WAF · Shield Adv] + R53[Route 53\nPrivate + Public Hosted Zones] + end + + subgraph Public["Public Subnets (10.0.0.0/24, 10.0.1.0/24, 10.0.2.0/24)"] + ALB[Application Load Balancer\nWAF WebACL · TLS 1.3] + NATGW[NAT Gateways × 3 AZ] + end + + subgraph Private_Compute["Private Compute Subnets (10.0.10.0/24 … 10.0.12.0/24)"] + subgraph ECS["ECS Fargate Cluster"] + API[api service\n2 vCPU · 4 GB · ×2–10] + WORKERS[workers service\n1 vCPU · 2 GB · ×1–6] + ML[ml-scoring service\nPython/FastAPI\n2 vCPU · 4 GB · ×1–4] + end + end + + subgraph Private_Data["Private Data Subnets (10.0.20.0/24 … 10.0.22.0/24)"] + subgraph RDS_Group["RDS PostgreSQL 15 (Multi-AZ)"] + RDS_P[(Primary\ndb.r6g.xlarge)] + RDS_S[(Standby\nAZ-2 — sync replication)] + RDS_R[(Read Replica\nAZ-3 — async)] + end + REDIS[(ElastiCache Redis 7\n2-node cluster\ncache.r6g.large)] + end + + subgraph Storage["Storage & Secrets"] + S3_EV[S3: rv-evidence\nObject Lock WORM\nKMS CMK per-tenant] + S3_RP[S3: rv-reports\nLifecycle 90d → Glacier] + S3_ST[S3: rv-static\nCloudFront origin] + KMS[KMS\nCMK: data, audit, evidence\nAutoRotate 365d] + SM[Secrets Manager\nDB creds · API keys\nAuto-rotate 30d] + ECR[ECR Private\nImage scanning · immutable tags] + end + + subgraph Messaging["Async / Eventing"] + SQS[SQS FIFO\nfraud-detection-queue\nvisit-scoring-queue] + EB[EventBridge\nOrchestration bus] + end + + subgraph Observability["Observability"] + CW[CloudWatch\nLogs · Metrics · Alarms\nDashboards] + XRAY[X-Ray\nDistributed tracing] + CT[CloudTrail\nAPI audit · S3 delivery] + end + + subgraph VPN_PL["State Connectivity"] + PL[PrivateLink\nNLB endpoint] + VPN[Site-to-Site VPN\nBGP · IKEv2] + end + end + + BROWSER & MOBILE --> CF + STATE --> PL + STATE -. "alt" .-> VPN + CF --> ALB + ALB --> API + API --> WORKERS + API --> ML + API <--> SQS + WORKERS <--> SQS + ML <--> SQS + EB --> WORKERS + + API & WORKERS & ML --> RDS_P + API --> RDS_R + API & WORKERS --> REDIS + API & WORKERS --> S3_EV + API & WORKERS --> S3_RP + API --> SM + API --> KMS + + ECS --> NATGW --> Internet + CW -.-> API & WORKERS & ML + XRAY -.-> API & WORKERS & ML +``` + +--- + +## 2. Network Architecture & Security + +### 2.1 Subnet Tiers + +| Tier | CIDR Range | Resources | Internet Route | +|------|-----------|-----------|----------------| +| **Public** | `10.0.0.0/24` – `10.0.2.0/24` | ALB, NAT Gateways | IGW direct | +| **Private Compute** | `10.0.10.0/24` – `10.0.12.0/24` | ECS Fargate tasks (api, workers, ml-scoring) | Via NAT GW | +| **Private Data** | `10.0.20.0/24` – `10.0.22.0/24` | RDS, ElastiCache | None (VPC endpoints only) | + +All subnets are replicated across three AZs (`us-east-1a/b/c`). Data subnets have **no route to the internet** — all AWS service calls (S3, KMS, Secrets Manager, SQS, ECR, CloudWatch) traverse **VPC Interface Endpoints** or **Gateway Endpoints** to keep traffic private. + +### 2.2 Security Groups + +| Security Group | Inbound | Outbound | +|----------------|---------|----------| +| `sg-alb` | 443 from `0.0.0.0/0` (WAF-filtered) | 4000 to `sg-api` | +| `sg-api` | 4000 from `sg-alb`; 8001 from `sg-api` (internal) | 5432 to `sg-rds`; 6379 to `sg-redis`; 443 to VPC endpoints | +| `sg-workers` | 443 from `sg-api` (internal events) | 5432 to `sg-rds`; 6379 to `sg-redis`; 443 to VPC endpoints | +| `sg-ml` | 8000 from `sg-api`; 8000 from `sg-workers` | 443 to VPC endpoints | +| `sg-rds` | 5432 from `sg-api`, `sg-workers`, `sg-ml` | None | +| `sg-redis` | 6379 from `sg-api`, `sg-workers` | None | + +No security group permits `0.0.0.0/0` inbound except `sg-alb` (which is covered by WAF). Database security groups have **no outbound rules** (default deny). + +### 2.3 WAF & DDoS + +- **AWS WAF WebACL** attached to both ALB and CloudFront: + - OWASP Core Rule Set (CRS) managed rule group + - AWS IP Reputation List + - Rate-based rules: 2 000 req/5 min per IP; 500 req/5 min for `/auth/*` paths + - Geo-restriction: optional per-tenant; default allow all (configurable for state-specific deployments) + - PHI endpoint protection: custom rule blocking non-TLS or missing `Authorization` header +- **AWS Shield Advanced** on CloudFront, ALB, and Elastic IPs (NAT GWs) — provides DDoS protection with 24/7 DRT access and SLA credits. + +### 2.4 Egress Control + +ECS tasks in private compute subnets egress exclusively via the three NAT Gateways. Egress is further restricted by: +- Security group outbound rules (allowlist of specific ports/destinations) +- VPC endpoint policies on S3 and KMS (deny any principal not in the ECS task execution role) +- No direct internet egress from data subnets + +### 2.5 State / Federal Private Connectivity + +| Method | Use Case | Details | +|--------|----------|---------| +| **AWS PrivateLink** | Real-time API integration with state EVV aggregators | NLB-backed endpoint service; state consumes via Interface Endpoint in their VPC | +| **Site-to-Site VPN** | Legacy state MMIS or MES integrations | IKEv2 / BGP; terminated on AWS Virtual Private Gateway; monitored via CloudWatch | +| **Direct Connect** (future) | High-volume state data feeds; GovCloud | Dedicated 1 Gbps circuit; recommended for GA-scale state pilots | + +--- + +## 3. Compute Layer — ECS Fargate + +### 3.1 Service Definitions + +| Service | Image | Task Size | Min Tasks | Max Tasks | Port | Notes | +|---------|-------|-----------|-----------|-----------|------|-------| +| `rv-api` | `ecr/rv-api:latest` | 2 vCPU · 4 GB | 2 | 10 | 4000 | NestJS; ALB target group | +| `rv-workers` | `ecr/rv-workers:latest` | 1 vCPU · 2 GB | 1 | 6 | — | SQS consumer; fraud scoring pipeline | +| `rv-ml-scoring` | `ecr/rv-ml-scoring:latest` | 2 vCPU · 4 GB | 1 | 4 | 8000 | Python/FastAPI; called by api + workers | + +All tasks run with: +- **No public IP** (awsvpc networking mode, private subnets) +- **Task execution role** (ECR pull, Secrets Manager read, CloudWatch Logs write) +- **Task role** (KMS decrypt, S3 put/get on `rv-evidence` and `rv-reports`, SQS send/receive, X-Ray PutTraceSegments) +- **Read-only root filesystem** where possible; non-root user inside container +- **ECS Exec disabled** in production (enabled in staging for debugging) +- Health checks: HTTP `GET /health` (api, ml-scoring); SQS heartbeat (workers) + +### 3.2 Autoscaling + +Application Auto Scaling policies per service: + +``` +rv-api: + metric: ALBRequestCountPerTarget (target: 800 req/min/task) + scale-out: +2 tasks, cooldown 60s + scale-in: -1 task, cooldown 300s + +rv-workers: + metric: SQS ApproximateNumberOfMessagesVisible (target: 50 msgs/task) + scale-out: +2 tasks, cooldown 30s + scale-in: -1 task, cooldown 180s + +rv-ml-scoring: + metric: ECSServiceAverageCPUUtilization (target: 65%) + scale-out: +1 task, cooldown 60s + scale-in: -1 task, cooldown 180s +``` + +### 3.3 Blue/Green Deployments + +Deployments use **AWS CodeDeploy with ECS blue/green**: + +1. New task set created alongside current (blue) set +2. Test traffic listener (port 8080 on ALB) receives 10% traffic to green set +3. Automated smoke tests + CloudWatch alarm gate (5-minute observation window) +4. If alarms clear: 100% traffic shift to green; blue set retained 15 minutes for instant rollback +5. If any alarm fires or smoke tests fail: automatic rollback to blue within 60 seconds + +### 3.4 EKS Migration Path + +The architecture is **EKS-ready**. Migration path when horizontal scale demands exceed ECS ergonomics (estimated at ~50+ concurrent tasks per service): + +1. Helm charts maintained alongside `infra/terraform/` (future) +2. Same container images (ECR) — no code changes required +3. Swap ECS service Terraform module for `aws_eks_node_group` + Helm release +4. Migrate CodeDeploy blue/green to ArgoCD + Argo Rollouts canary +5. RDS, ElastiCache, S3, KMS remain unchanged — only compute plane migrates + +--- + +## 4. Data Layer + +### 4.1 RDS PostgreSQL — High Availability + +| Parameter | Value | +|-----------|-------| +| Engine | PostgreSQL 15 (16-compatible) | +| Instance class | `db.r6g.xlarge` (prod); `db.t3.medium` (staging/dev) | +| Storage | 500 GB gp3, 3 000 IOPS, autoscale to 2 TB | +| Multi-AZ | Synchronous standby in AZ-2; automatic failover < 60 s | +| Read Replica | 1× async replica in AZ-3 for reporting queries and read-heavy dashboard endpoints | +| Encryption | KMS CMK `rv-rds-key` (AES-256); storage, snapshots, replicas all encrypted | +| Backups | Automated daily snapshots, 35-day retention; **PITR** enabled (1-second granularity) | +| Maintenance window | Sunday 03:00–04:00 UTC (configurable per tenant region) | +| CA cert | `rds-ca-2019` → migrate to `rds-ca-rsa2048-g1` on next cert rotation | + +**Parameter Group Hardening** (`rv-pg15-params`): + +```ini +log_connections = on +log_disconnections = on +log_duration = on # query-level audit +log_min_duration_statement = 1000 # log slow queries > 1s +ssl = on +ssl_min_protocol_version = TLSv1.2 +rds.force_ssl = 1 +shared_preload_libraries = pg_stat_statements,auto_explain +``` + +The physical schema (`db/schema.sql`) enables **Row-Level Security** on all 19 business tables. The application sets `SET app.current_org = ''` per request — this is enforced at the database layer, not just the application layer. The `BYPASSRLS` privilege is reserved for the DBA/migration role and is never granted to the application user. + +### 4.2 ElastiCache Redis + +| Parameter | Value | +|-----------|-------| +| Engine | Redis 7 OSS | +| Node type | `cache.r6g.large` | +| Cluster mode | 2-node primary + replica (single shard, prod); 1-node (dev) | +| Encryption | In-transit (TLS); at-rest (KMS) | +| Auth | `AUTH` token via Secrets Manager | +| Use cases | BullMQ fraud-scoring queues, API response cache, session store, rate-limit counters | +| Eviction policy | `volatile-lru` (queues protected by TTL-free keys) | + +### 4.3 S3 Buckets + +| Bucket | Purpose | Object Lock | Lifecycle | KMS Key | +|--------|---------|-------------|-----------|---------| +| `rv-evidence-{env}` | Biometric probe images, GPS payloads, identity verification records (PHI) | **WORM Compliance** 7-year retention | None (Object Lock governs) | Per-tenant CMK (`rv-evidence-{org-slug}`) | +| `rv-reports-{env}` | Generated PDF/XLSX/CSV reports | None | 90 days → Glacier IR; 7 years → delete | `rv-reports-key` | +| `rv-audit-{env}` | CloudTrail + compliance export archives | **WORM Governance** 7-year retention | None | `rv-audit-key` | +| `rv-static-{env}` | Frontend Next.js static assets | None | 365 days → delete | `rv-static-key` (SSE-S3 acceptable) | + +**Bucket policies** enforce: +- `aws:SecureTransport: true` on all buckets (deny HTTP) +- `s3:PutObject` restricted to specific task roles +- Public access blocked at account and bucket level +- `rv-evidence-*` additionally restricts `GetObject` to the api task role and a designated investigator-download Lambda — no direct browser access + +**KMS CMK Strategy:** + +``` +rv-rds-key → RDS encryption (shared across tenants at storage level) +rv-evidence-{slug} → Per-tenant evidence bucket key (envelope encryption; data key cached in-process) +rv-reports-key → Reports bucket (shared; report objects scoped by tenant prefix) +rv-audit-key → Audit/CloudTrail archival +rv-secrets-key → Secrets Manager encryption +``` + +Per-tenant data keys are generated by `GenerateDataKey` against the tenant's CMK. The encrypted data key is stored alongside the ciphertext (envelope encryption pattern). Tenant key rotation does not require re-encryption of existing data — only new data keys are generated under the new CMK. + +--- + +## 5. Edge & DNS + +### 5.1 CloudFront Distribution + +| Setting | Value | +|---------|-------| +| Origins | ALB (`api.rv-internal.com`), S3 `rv-static-{env}` | +| TLS policy | `TLSv1.2_2021` (minimum); ACM certificate auto-renewed | +| HTTP/3 | Enabled (QUIC) | +| Cache behaviors | `/api/*` — no cache, forward all headers; `/_next/static/*` — 365-day TTL; `/docs/*` — 24-hour TTL | +| WAF WebACL | `rv-waf-global` (CloudFront-scoped) | +| Geo restriction | Configurable per deployment (none by default) | +| Access logs | S3 `rv-audit-{env}/cloudfront/` with 90-day lifecycle | +| Price class | `PriceClass_100` (US/EU/AUS) — expand for international pilots | + +### 5.2 ACM & Route 53 + +- Wildcard certificate `*.rayverify.io` + apex `rayverify.io` in ACM (us-east-1 for CloudFront; regional cert per region for ALB) +- Route 53 hosted zones: public (`rayverify.io`) and private (`rv-internal.com` for VPC-internal service discovery) +- Health-check-based failover records for multi-region DR (future) +- State-specific subdomain isolation: `{state}.rayverify.io` → tenant-scoped CloudFront behavior or separate distribution + +--- + +## 6. Observability & Operations + +### 6.1 CloudWatch Metrics & Alarms + +Key alarms (all routed to SNS → PagerDuty for P1/P2): + +| Alarm | Threshold | Severity | +|-------|-----------|----------| +| `rv-api-5xx-rate` | > 1% over 5 min | P1 | +| `rv-api-p99-latency` | > 3 000 ms | P2 | +| `rv-rds-cpu` | > 80% for 10 min | P2 | +| `rv-rds-freeable-memory` | < 512 MB | P1 | +| `rv-rds-replica-lag` | > 30 s | P2 | +| `rv-redis-cache-hit-ratio` | < 70% | P3 | +| `rv-fraud-queue-depth` | > 500 messages for 5 min | P2 | +| `rv-waf-block-rate` | > 500 blocks/min | P1 | +| `rv-kms-throttle` | Any throttle event | P2 | +| `rv-rds-failed-logins` | > 10 in 1 min | P1 (security) | + +### 6.2 CloudWatch Logs + +All ECS task logs shipped to CloudWatch Logs via the `awslogs` log driver: + +| Log Group | Retention | Notes | +|-----------|-----------|-------| +| `/rv/api` | 365 days | Structured JSON; PHI fields redacted at application layer before logging | +| `/rv/workers` | 365 days | Queue consumer events, scoring decisions | +| `/rv/ml-scoring` | 365 days | Model inference logs, feature values (non-PHI) | +| `/rv/rds/postgresql` | 90 days | Slow queries, connections, errors | +| `/rv/waf` | 365 days | Full WAF request log with blocked reasons | +| `/rv/cloudtrail` | 7 years | All AWS API calls; delivered to `rv-audit-{env}` S3 + CloudWatch Logs | + +Log Insights queries are pre-built for: +- Failed identity verifications per caregiver (last 24 hours) +- Fraud events by type and organization +- API error rate by endpoint +- Slow RDS queries > 5 seconds + +### 6.3 AWS X-Ray + +Distributed tracing enabled across all three ECS services with the X-Ray SDK. Sampling rules: +- All requests to `/api/visits/*/verify` (verification chain): 100% sampled +- All fraud scoring requests: 100% sampled +- All other API requests: 5% sampled (configurable per environment) + +Service map in X-Ray Insights shows: `CloudFront → ALB → rv-api → (rv-ml-scoring | RDS | ElastiCache | S3 | SQS)` + +### 6.4 CloudTrail + +- Multi-region trail with S3 delivery to `rv-audit-{env}` (WORM Object Lock) +- Log file validation enabled (SHA-256 + RSA digest chain) +- CloudWatch Logs delivery for real-time alerting on sensitive API calls (e.g., `kms:Disable*`, `s3:DeleteBucketPolicy`, `iam:AttachRolePolicy`) +- Insights enabled for unusual API call volume + +--- + +## 7. GovCloud / FedRAMP Considerations + +For state Medicaid agencies or federal programs requiring **FedRAMP Moderate** or **High** authorization: + +| Consideration | Current (Commercial) | GovCloud Path | +|---------------|---------------------|---------------| +| AWS region | `us-east-1` | `us-gov-east-1` or `us-gov-west-1` | +| IAM | Standard AWS IAM | GovCloud IAM (separate account; no cross-region federation) | +| Service availability | Full service catalog | Verify Shield Advanced, CloudFront, ACM availability in GovCloud | +| Compliance boundary | HIPAA BAA + SOC 2 | FedRAMP ATO package; NIST SP 800-53 Rev 5 control mapping | +| Data residency | US regions only | GovCloud guarantees US-only data sovereignty | +| Personnel | No restriction | GovCloud requires US citizens / US persons for admin access | +| Encryption keys | KMS (standard) | KMS with CloudHSM backing (FIPS 140-2 Level 3) | +| Terraform | Standard AWS provider | `hashicorp/aws` GovCloud endpoints; separate state bucket in GovCloud | + +**Migration approach:** The Terraform module design (§8) anticipates this by parameterizing region and AWS partition. Adding a `gov` workspace to each module with `partition = "aws-us-gov"` and `region = "us-gov-east-1"` is the primary change required. No application code changes needed. + +State-specific **Authority to Operate (ATO)** requirements should be evaluated per-state as part of the Phase 5 hardening milestone (see `docs/10-development-roadmap.md`). + +--- + +## 8. Terraform Module Layout + +The IaC lives under `infra/terraform/` using a flat module structure with per-environment workspaces (`dev`, `staging`, `prod`). The module design mirrors AWS service ownership boundaries for clear separation of concerns. + +### 8.1 Module Map + +``` +infra/terraform/ +├── main.tf # Root module: workspace-aware variable resolution +├── variables.tf # Global inputs (environment, region, CIDR blocks) +├── outputs.tf # Cross-module references (VPC IDs, ARNs, etc.) +├── versions.tf # Required providers + version constraints +│ +├── modules/ +│ ├── networking/ # VPC, subnets, IGW, NAT GWs, route tables, +│ │ │ # VPC endpoints (S3/KMS/SQS/ECR/CW/Secrets Mgr), +│ │ │ # PrivateLink NLB endpoint service +│ │ ├── main.tf +│ │ ├── variables.tf +│ │ └── outputs.tf # vpc_id, private_subnet_ids, public_subnet_ids +│ │ +│ ├── security/ # KMS CMKs, IAM roles (ECS task/execution), +│ │ │ # WAF WebACLs, security groups, Secrets Manager secrets, +│ │ │ # Shield Advanced subscription +│ │ ├── main.tf +│ │ ├── variables.tf +│ │ └── outputs.tf # kms_key_arns, sg_ids, iam_role_arns +│ │ +│ ├── data/ # RDS (Multi-AZ + read replica), ElastiCache, +│ │ │ # S3 buckets (evidence/reports/audit/static), +│ │ │ # S3 Object Lock configuration, bucket policies +│ │ ├── main.tf +│ │ ├── variables.tf +│ │ └── outputs.tf # rds_endpoint, redis_endpoint, s3_bucket_arns +│ │ +│ ├── compute/ # ECR repositories, ECS cluster, task definitions, +│ │ │ # ECS services (api/workers/ml-scoring), +│ │ │ # Application Auto Scaling, CodeDeploy blue/green, +│ │ │ # ALB + target groups + listener rules +│ │ ├── main.tf +│ │ ├── variables.tf +│ │ └── outputs.tf # alb_dns_name, ecs_cluster_arn, ecr_repo_urls +│ │ +│ ├── edge/ # CloudFront distribution, ACM certificates, +│ │ │ # Route 53 hosted zones + records, +│ │ │ # CloudFront WAF association +│ │ ├── main.tf +│ │ ├── variables.tf +│ │ └── outputs.tf # cloudfront_domain, zone_ids +│ │ +│ ├── messaging/ # SQS FIFO queues (fraud-detection, visit-scoring), +│ │ │ # dead-letter queues, EventBridge bus + rules +│ │ ├── main.tf +│ │ ├── variables.tf +│ │ └── outputs.tf # queue_urls, eventbridge_bus_arn +│ │ +│ └── observability/ # CloudWatch log groups, metric filters, alarms, +│ │ # X-Ray sampling rules, CloudTrail trail, +│ │ # SNS topics (alerting), dashboards +│ ├── main.tf +│ ├── variables.tf +│ └── outputs.tf # cloudwatch_dashboard_urls, sns_topic_arns +│ +└── environments/ + ├── dev.tfvars + ├── staging.tfvars + └── prod.tfvars +``` + +### 8.2 Module Dependency Diagram + +```mermaid +flowchart TD + NET[networking\nVPC · subnets · endpoints] + SEC[security\nKMS · IAM · SGs · WAF · Shield] + DATA[data\nRDS · Redis · S3 · Object Lock] + MSG[messaging\nSQS · EventBridge] + COMP[compute\nECR · ECS · ALB · CodeDeploy · ASG] + EDGE[edge\nCloudFront · ACM · Route53] + OBS[observability\nCloudWatch · X-Ray · CloudTrail · SNS] + + NET --> SEC + NET --> DATA + NET --> MSG + SEC --> DATA + SEC --> COMP + SEC --> EDGE + NET --> COMP + DATA --> COMP + MSG --> COMP + COMP --> EDGE + COMP --> OBS + DATA --> OBS + NET --> OBS + SEC --> OBS +``` + +**Dependency rationale:** +- `networking` is the foundation — all other modules consume VPC/subnet IDs +- `security` depends on `networking` for SG VPC placement; all other modules consume roles and key ARNs from `security` +- `data` depends on `networking` (subnet placement) and `security` (KMS keys, SGs) +- `compute` depends on all of `networking`, `security`, `data`, and `messaging` +- `edge` depends on `compute` (ALB DNS) and `security` (WAF ACL, ACM cert ARNs) +- `observability` is last — subscribes to resources created by all other modules + +--- + +## 9. Cost & Environment Sizing + +All estimates in USD/month. Actual costs depend on traffic volume, data volume, and AWS pricing at time of deployment. These figures are indicative for planning and investor/procurement modeling. + +| Component | Dev / Local | Staging | Production (MVP Scale) | +|-----------|-------------|---------|----------------------| +| **ECS Fargate (api)** | Local Docker | 0.25 vCPU × 1 task ≈ $12 | 2 vCPU × 3 tasks (avg) ≈ $220 | +| **ECS Fargate (workers)** | Local Docker | 0.25 vCPU × 1 ≈ $6 | 1 vCPU × 2 tasks (avg) ≈ $75 | +| **ECS Fargate (ml-scoring)** | Local Docker | 0.5 vCPU × 1 ≈ $10 | 2 vCPU × 2 tasks (avg) ≈ $145 | +| **RDS PostgreSQL** | LocalStack / local PG | `db.t3.medium` ≈ $50 | `db.r6g.xlarge` Multi-AZ ≈ $530 | +| **RDS Read Replica** | — | — | `db.r6g.large` ≈ $180 | +| **ElastiCache Redis** | Local Redis | `cache.t3.micro` ≈ $14 | `cache.r6g.large` × 2 ≈ $200 | +| **S3 (all buckets)** | LocalStack | < $5 | ≈ $30–80 (depends on evidence volume) | +| **CloudFront** | — | Minimal ≈ $5 | ≈ $30–100 (traffic-dependent) | +| **ALB** | — | ≈ $18 | ≈ $25 + LCU charges | +| **NAT Gateways (×3)** | — | 1× ≈ $35 | 3× ≈ $105 | +| **KMS** | — | < $5 | ≈ $10–40 (key count + API calls) | +| **Secrets Manager** | — | < $5 | ≈ $10 | +| **CloudWatch / X-Ray** | — | ≈ $10 | ≈ $50–150 (log volume) | +| **WAF + Shield Adv** | — | WAF only ≈ $10 | WAF + Shield ≈ $3 100 (Shield is $3 000/mo flat) | +| **Data Transfer** | — | < $5 | ≈ $30–100 | +| **ECR** | — | < $5 | ≈ $10 | +| **SQS / EventBridge** | — | < $5 | < $20 | +| **TOTAL ESTIMATE** | ~$0 (local) | ~$185/mo | ~$4 800–5 500/mo | + +> **Notes:** +> - Shield Advanced ($3 000/mo) becomes cost-effective once monthly data or revenue protected justifies it; it can be deferred to the state pilot phase. +> - Production scale above assumes a single-state pilot (~10 000 visits/month). Multi-state scale (×5–10 states) adds roughly linear compute cost but RDS and Redis are shared; marginal cost per additional state is primarily storage and data transfer. +> - Reserved Instances (1-year, no-upfront) on RDS and ElastiCache can reduce those line items by 30–40%. +> - Savings Plans on Fargate (Compute Savings Plan) reduce compute by 20–35% at production scale. diff --git a/docs/09-cicd-pipeline.md b/docs/09-cicd-pipeline.md new file mode 100644 index 0000000..219f47a --- /dev/null +++ b/docs/09-cicd-pipeline.md @@ -0,0 +1,448 @@ +# RayVerify™ — CI/CD Pipeline + +> **Platform:** RayVerify™ | **Parent:** RayHealthEVV™ +> **IaC:** `infra/terraform/` · **Workflows:** `.github/workflows/` +> **Strategy:** Trunk-based development · GitHub Actions · AWS OIDC · Zero long-lived credentials + +--- + +## Table of Contents + +1. [Pipeline Philosophy](#1-pipeline-philosophy) +2. [CI Pipeline — Stages & Flow](#2-ci-pipeline--stages--flow) +3. [CD Pipeline — Build, Push & Deploy](#3-cd-pipeline--build-push--deploy) +4. [Quality Gates & Required Checks](#4-quality-gates--required-checks) +5. [Secrets Management in CI](#5-secrets-management-in-ci) +6. [Release Management](#6-release-management) + +--- + +## 1. Pipeline Philosophy + +### 1.1 Trunk-Based Development + +RayVerify uses a **trunk-based development** model with short-lived feature branches: + +- `main` is the single source of truth and is always deployable +- Feature branches live < 48 hours; longer work is gated behind feature flags +- No long-lived `develop`, `release/*`, or `hotfix/*` branches — all paths go through `main` +- Direct commits to `main` are blocked; all changes require a pull request with at least one approved review and all required checks passing + +### 1.2 Everything as Code + +- Infrastructure is managed exclusively in Terraform (`infra/terraform/`); no manual console changes in staging or prod +- Pipeline definitions are in `.github/workflows/`; no out-of-band pipeline configuration +- Database migrations are Prisma-managed and version-controlled; schema changes are never applied manually +- Environment secrets are managed in AWS Secrets Manager and surfaced to CI via OIDC — never stored as plaintext in GitHub Secrets unless unavoidable (and then rotated on a 30-day schedule) + +### 1.3 Supply-Chain Security + +- All container images are built in CI, scanned, and signed before deployment +- Software Bill of Materials (SBOM) is generated at build time and attached to every ECR image +- Dependencies are pinned to exact versions in `package.json` and `requirements.txt`; Dependabot raises PRs for updates +- GitHub Actions third-party actions are pinned to specific commit SHAs (not mutable tags) +- OIDC federation to AWS eliminates all long-lived AWS access keys in CI + +### 1.4 Separation of Duties for Production + +- Staging deployments are **automatic** on merge to `main` (no human gate) +- Production deployments require **manual approval** from a designated Deployment Approver (separate from the developer who authored the change) +- The GitHub Environment `production` has protection rules: required reviewers, deployment branch policy (`main` only), wait timer (5 minutes minimum after staging smoke tests pass) +- Post-deployment audit evidence is automatically posted to the immutable `rv-audit-{env}` S3 bucket to satisfy SOC 2 change-management control CC8.1 + +--- + +## 2. CI Pipeline — Stages & Flow + +CI runs on every pull request and every push to `main`. The pipeline is defined in `.github/workflows/ci.yml`. + +```mermaid +flowchart TD + TRIGGER([PR opened / push to main]) + + subgraph INSTALL["Stage 1 — Install"] + S1[npm ci --workspaces\ncache: ~/.npm key=package-lock.json] + S1B[pip install -r requirements.txt\ncache: pip key=requirements.txt] + end + + subgraph LINT["Stage 2 — Lint & Format"] + S2A[ESLint --max-warnings 0\nnpm run lint --workspaces] + S2B[Prettier --check\nnpm run format:check] + S2C[Ruff lint + isort\npython ml-scoring] + S2D[Prisma format --check\nno schema drift] + end + + subgraph TYPECHECK["Stage 3 — Type Safety"] + S3A[tsc --noEmit\npackages/backend] + S3B[tsc --noEmit\npackages/frontend] + S3C[tsc --noEmit\npackages/shared] + S3D[mypy ml-scoring\n--strict] + end + + subgraph UNIT["Stage 4 — Unit Tests"] + S4A[Jest — backend unit\nnpm run test:unit --workspace=backend] + S4B[Jest + RTL — frontend\nnpm run test:unit --workspace=frontend] + S4C[pytest ml-scoring\ncover: 80% threshold] + end + + subgraph BUILD["Stage 5 — Build"] + S5A[nest build\n--workspace=backend] + S5B[next build\n--workspace=frontend] + S5C[Verify ml-scoring imports\npython -m py_compile] + end + + subgraph MIGRATE["Stage 6 — Migration Check"] + S6[prisma migrate diff\n--from-migrations --to-schema-datamodel\nfail on destructive unapplied drift] + end + + subgraph INTEG["Stage 7 — Integration Tests"] + SVC1[(PostgreSQL 15\nephemeral service container)] + SVC2[(Redis 7\nephemeral service container)] + S7[npm run test:integration\n--workspace=backend\nWith real DB + Redis] + S7ML[pytest -m integration\nml-scoring] + end + + subgraph SAST["Stage 8 — SAST & Supply Chain"] + S8A[CodeQL analysis\nJS/TS + Python\n.github/workflows/codeql.yml] + S8B[npm audit --audit-level=high\nfail on high/critical] + S8C[pip-audit\nfail on high/critical] + S8D[gitleaks detect\n.github/workflows/security-scan.yml] + end + + subgraph CONTAINER["Stage 9 — Container Build & Scan"] + S9A[docker build rv-api\nmulti-stage · non-root · distroless] + S9B[docker build rv-workers\nmulti-stage · non-root] + S9C[docker build rv-ml-scoring\nmulti-stage · non-root · slim python] + S9D[Trivy image scan\nfail on HIGH/CRITICAL CVEs\n.github/workflows/security-scan.yml] + S9E[syft SBOM generation\ncyclonedx-json attached to workflow run] + end + + PASS([All gates pass\nPR merge unblocked / CD triggered]) + FAIL([Any gate fails\nPR blocked / Slack + GitHub notification]) + + TRIGGER --> INSTALL + INSTALL --> LINT + LINT --> TYPECHECK + TYPECHECK --> UNIT + UNIT --> BUILD + BUILD --> MIGRATE + MIGRATE --> INTEG + INTEG --> SAST + SAST --> CONTAINER + CONTAINER --> PASS + CONTAINER --> FAIL +``` + +### 2.1 Key Implementation Details + +**Stage 1 — Install** + +```yaml +# .github/workflows/ci.yml (excerpt) +- uses: actions/setup-node@v4 + with: { node-version: '20', cache: 'npm' } +- run: npm ci --workspaces --include-workspace-root +``` + +npm workspaces installs all three packages (`backend`, `frontend`, `shared`) in a single `npm ci`. The lockfile is the source of truth — any `package-lock.json` drift fails the install stage. + +**Stage 6 — Migration Check** + +Before running integration tests, Prisma validates that: +1. Every migration in `prisma/migrations/` is applied in sequence +2. The current schema matches the last migration (no unapplied drift) +3. No destructive migration (column drop, type change) is present without an explicit `-- RayVerify: destructive-acknowledged` comment + +```bash +npx prisma migrate diff \ + --from-migrations packages/backend/prisma/migrations \ + --to-schema-datamodel packages/backend/prisma/schema.prisma \ + --exit-code # non-zero if diff exists +``` + +**Stage 7 — Integration Tests** + +GitHub Actions service containers spin up a real PostgreSQL 15 and Redis 7 for the duration of the integration test job: + +```yaml +services: + postgres: + image: postgres:15-alpine + env: { POSTGRES_DB: rv_test, POSTGRES_USER: rv, POSTGRES_PASSWORD: test } + options: --health-cmd "pg_isready" --health-interval 5s + redis: + image: redis:7-alpine + options: --health-cmd "redis-cli ping" --health-interval 5s +``` + +The NestJS integration tests run the full API layer against a real DB with RLS enabled, exercising the verification chain, fraud event creation, and case management flows. + +**Stage 8 — SAST & Supply Chain** (`.github/workflows/codeql.yml`, `.github/workflows/security-scan.yml`) + +- **CodeQL**: static analysis for SQL injection, path traversal, prototype pollution, insecure deserialization across the TypeScript and Python codebases +- **npm audit / pip-audit**: transitive dependency CVE scan; any HIGH or CRITICAL finding blocks merge +- **gitleaks**: secret scan against full git history on every PR; custom rules for AWS key patterns, HIPAA-adjacent tokens (SSNs, Medicaid IDs in test fixtures) + +**Stage 9 — Container Build & Scan** + +All three Dockerfiles use **multi-stage builds** to produce minimal production images: +- `rv-api`: Node 20 Alpine builder → `gcr.io/distroless/nodejs20-debian12` runtime (~130 MB) +- `rv-workers`: same pattern as api +- `rv-ml-scoring`: Python 3.11 builder → `python:3.11-slim` runtime (~280 MB) + +Trivy scans the final image layer with `--exit-code 1 --severity HIGH,CRITICAL`. Any unpatched HIGH/CRITICAL CVE in the production image fails the pipeline. The Trivy DB is updated at the start of every run. + +--- + +## 3. CD Pipeline — Build, Push & Deploy + +Defined in `.github/workflows/deploy.yml`. Triggered automatically after a successful CI run on `main`. + +### 3.1 Image Build, Tag & Push + +```mermaid +flowchart LR + CI_PASS([CI passes on main]) + ECR_LOGIN[aws ecr get-login-password\nOIDC role: rv-ci-deploy-staging] + TAG["Tag strategy:\n{semver} e.g. 1.4.2\n{git-sha} e.g. abc1234\nlatest (mutable, staging only)"] + PUSH[docker push all three images\nto ECR private registry] + SIGN[cosign sign --key awskms:///\nSign all three image digests\nSBOM attached as OCI artifact] + + CI_PASS --> ECR_LOGIN --> TAG --> PUSH --> SIGN +``` + +**Tagging strategy:** + +| Tag | Purpose | Mutability | +|-----|---------|-----------| +| `{semver}` e.g. `1.4.2` | Human-readable release version | Immutable (ECR `imageTagMutability: IMMUTABLE`) | +| `{short-sha}` e.g. `abc1234` | Exact commit traceability | Immutable | +| `staging-latest` | Convenience for staging service | Mutable (staging only) | + +Production ECS task definitions reference the immutable `{semver}` tag. `latest` tags are **never used in production task definitions**. + +### 3.2 Staging Deployment (Automatic) + +```mermaid +sequenceDiagram + participant CI as GitHub Actions + participant TF as Terraform + participant MIGR as DB Migration + participant ECS as ECS CodeDeploy + participant SMOKE as Smoke Tests + participant SLACK as Slack / Audit + + CI->>TF: terraform plan (staging workspace) + TF-->>CI: plan output (no infra drift expected on feature deploys) + CI->>MIGR: prisma migrate deploy (staging DB) + Note over MIGR: Runs in ECS one-off task;\nwait for STOPPED status + CI->>ECS: aws ecs update-service --force-new-deployment (blue/green) + ECS-->>CI: deployment ID returned + CI->>ECS: Poll: wait for DeploymentComplete (max 15 min) + CI->>SMOKE: POST /health, POST /api/auth/ping, GET /api/visits (empty 200) + SMOKE-->>CI: All pass + CI->>SLACK: staging deploy success\n{version} {sha} {timestamp} + CI->>SLACK: audit S3 put: deploy-manifest.json to rv-audit-staging +``` + +**Migration safety ordering:** +1. Migration runs in a one-off ECS task against the staging RDS instance **before** traffic shifts +2. Only additive migrations (add column/table, add index) are permitted in an automated deploy +3. Destructive migrations (drop column, rename, type change) require a two-phase deployment: Phase A deploys backward-compatible schema + new code; Phase B removes the deprecated column in a subsequent PR after the old code path is confirmed gone +4. Migration task uses a separate IAM role with `BYPASSRLS` and `rds:Connect` — this role is **not** the application task role + +### 3.3 Production Deployment (Manual Approval) + +```mermaid +sequenceDiagram + participant STAGING as Staging (healthy) + participant APPROVER as Deployment Approver + participant GHA as GitHub Actions + participant MIGR as Prod DB Migration + participant CDD as CodeDeploy Blue/Green + participant SMOKE as Prod Smoke Tests + participant AUDIT as Audit / Rollback + + STAGING->>APPROVER: Slack notification: staging healthy, prod deploy awaiting approval + Note over APPROVER: Wait ≥ 5 min (environment wait timer) + APPROVER->>GHA: Approve via GitHub Environment protection UI + GHA->>MIGR: prisma migrate deploy (prod DB)\none-off ECS task, PITR snapshot taken first + MIGR-->>GHA: Migration complete (or fail → abort) + GHA->>CDD: Create new ECS task set (green) + CDD->>CDD: 10% traffic → green (test listener) + CDD->>CDD: 5-min observation window\nCloudWatch alarm gate + alt All alarms clear + CDD->>CDD: 100% traffic → green\nBlue retained 15 min + GHA->>SMOKE: Full prod smoke test suite + SMOKE-->>GHA: Pass + GHA->>AUDIT: POST deploy-manifest to rv-audit-prod S3 (WORM) + GHA->>SLACK: prod deploy success + else Alarm fires or smoke test fails + CDD->>CDD: Auto-rollback to blue (< 60 s) + GHA->>SLACK: prod deploy ROLLED BACK — alert oncall + GHA->>AUDIT: POST rollback event to rv-audit-prod S3 + end +``` + +**Pre-deployment PITR snapshot:** Before any production migration, the workflow triggers an RDS snapshot (`rv-prod-pre-deploy-{timestamp}`). This provides a guaranteed restore point independent of the automated 35-day backup window. Snapshots older than 7 days are automatically expired by an S3 lifecycle rule on the RDS snapshot S3 delivery bucket. + +### 3.4 Rollback Procedures + +| Scenario | Rollback Method | RTO | +|----------|----------------|-----| +| Code bug detected in canary window | CodeDeploy auto-rollback (alarm fires) | < 60 s | +| Bug detected after full traffic shift | Re-run deploy workflow with previous `{semver}` tag (manual) | < 10 min | +| Migration caused data corruption | Restore from pre-deploy RDS snapshot + redeploy previous image | < 30 min | +| Full environment failure | Restore from RDS PITR to clean timestamp + redeploy | < 60 min | + +--- + +## 4. Quality Gates & Required Checks + +The following checks are **required** (cannot be bypassed) via GitHub branch protection on `main`: + +| Check | Tool | Threshold | Blocks Merge? | +|-------|------|-----------|---------------| +| Lint (TS + Python) | ESLint · Prettier · Ruff | Zero warnings | Yes | +| Type safety | `tsc --noEmit` · mypy `--strict` | Zero errors | Yes | +| Unit test coverage — backend | Jest `--coverage` | ≥ 80% lines | Yes | +| Unit test coverage — ML scoring | pytest-cov | ≥ 80% lines | Yes | +| Integration tests | Jest integration + pytest | All pass | Yes | +| Migration drift check | `prisma migrate diff` | No unapplied drift | Yes | +| SAST (CodeQL) | CodeQL | No new HIGH/CRITICAL findings | Yes | +| Dependency CVEs | npm audit + pip-audit | No HIGH/CRITICAL | Yes | +| Secret scan | gitleaks | No secrets detected | Yes | +| Container image CVEs | Trivy | No HIGH/CRITICAL in production layer | Yes | +| PR review | GitHub | ≥ 1 approved review from CODEOWNERS | Yes | +| Signed image | cosign + KMS | Valid signature present on ECR digest | Yes (CD gate) | + +**CODEOWNERS** (`/.github/CODEOWNERS`) maps: +- `packages/backend/prisma/` → `@rv-db-team` (DBA review on schema changes) +- `infra/terraform/` → `@rv-infra-team` (infra review on IaC changes) +- `packages/backend/src/modules/fraud/` → `@rv-fraud-team` (ML/fraud review) +- `.github/workflows/` → `@rv-security-team` (security review on pipeline changes) + +--- + +## 5. Secrets Management in CI + +### 5.1 OIDC Federation — No Long-Lived Keys + +GitHub Actions authenticates to AWS using **OpenID Connect (OIDC) federation**. There are no AWS access keys stored in GitHub Secrets. + +```yaml +# .github/workflows/deploy.yml (excerpt) +permissions: + id-token: write # required for OIDC + contents: read + +- uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::123456789012:role/rv-ci-deploy-staging + aws-region: us-east-1 + role-session-name: GitHubActions-${{ github.run_id }} +``` + +The OIDC trust policy on the IAM role restricts assumption to: +- The specific GitHub repository (`org/RayVerify`) +- Specific branches (`refs/heads/main` for staging/prod deploy roles; `refs/pull/*` for CI scan roles) +- Specific workflow files (using `job_workflow_ref` condition) + +### 5.2 IAM Roles by Pipeline Stage + +| Role | Permissions | Used By | +|------|-------------|---------| +| `rv-ci-read-only` | ECR DescribeImages, no write | PR CI (scan, build verification) | +| `rv-ci-build` | ECR PutImage, ECR GetAuthorizationToken | CI image build + push | +| `rv-ci-deploy-staging` | ECS UpdateService, ECS RegisterTaskDefinition, S3 PutObject (rv-audit-staging) | Staging CD | +| `rv-ci-deploy-prod` | ECS UpdateService, ECS RegisterTaskDefinition, S3 PutObject (rv-audit-prod), RDS CreateDBSnapshot | Prod CD (requires manual approval) | +| `rv-ci-migration` | RDS Connect (via IAM auth), Secrets Manager GetSecretValue (DB URL only) | Migration one-off task | + +All roles use **condition keys** to restrict the session to the specific GitHub repo, workflow file, and branch. No role grants `*` on any resource. Roles are defined in `infra/terraform/modules/security/` and reviewed in the infra CODEOWNERS path. + +### 5.3 Application Secrets in CI + +Application environment variables needed for integration tests are sourced from GitHub Actions environment secrets (encrypted at rest in GitHub) and rotated on a 90-day schedule: + +| Secret | Value | Used In | +|--------|-------|---------| +| `TEST_DATABASE_URL` | Points to ephemeral service container | CI integration stage only | +| `TEST_REDIS_URL` | Points to ephemeral service container | CI integration stage only | +| `JWT_SECRET_TEST` | Randomly generated per-run value | CI unit + integration | + +Production secrets (DB connection strings, KMS key IDs, Slack webhook) are stored **only in AWS Secrets Manager** and are never injected into GitHub Actions. ECS tasks pull secrets from Secrets Manager at container startup via the `secrets` field in the ECS task definition. + +### 5.4 Environment Protection Rules + +| Environment | Protection Rules | +|-------------|----------------| +| `staging` | Deployment branch: `main` only; no required reviewers; 0-minute wait | +| `production` | Deployment branch: `main` only; required reviewers: 1 (Deployment Approver role); 5-minute wait timer; prevent self-approval | + +--- + +## 6. Release Management + +### 6.1 Semantic Versioning + +RayVerify follows **SemVer 2.0** (`MAJOR.MINOR.PATCH`): + +| Increment | When | +|-----------|------| +| `MAJOR` | Breaking API changes, incompatible schema changes, module removal | +| `MINOR` | New features, new API endpoints, backward-compatible schema additions | +| `PATCH` | Bug fixes, security patches, dependency updates, documentation | + +Version is maintained in the monorepo root `package.json` and mirrored in `packages/backend/package.json`, `packages/frontend/package.json`. The `packages/shared` package version is kept in sync — shared types are the contract between services. + +### 6.2 Release Process + +``` +1. Feature PRs merged to main → staging deployed automatically +2. When ready for release: create annotated git tag v{semver} + git tag -a v1.2.0 -m "Release 1.2.0 — Phase 2 fraud detection MVP" + git push origin v1.2.0 +3. GitHub Actions release workflow triggers on tag push: + - Generates changelog from conventional commits since last tag + - Creates GitHub Release with changelog body + - Produces release SBOM artifact + - Triggers production deployment pipeline (still requires manual approval) +``` + +**Conventional commits** are enforced via a PR title lint step (`commitlint`). Merge commits to `main` must follow `{type}({scope}): {description}` — this powers automated changelog generation via `conventional-changelog-cli`. + +### 6.3 Changelog & Artifact Retention + +| Artifact | Retention | +|----------|-----------| +| GitHub Actions workflow runs | 90 days (configurable) | +| Test result artifacts (JUnit XML, coverage HTML) | 30 days | +| SBOM (CycloneDX JSON) | Attached to ECR image (indefinite) | +| Container images in ECR | Last 30 tagged versions retained; untagged images purged after 7 days via lifecycle policy | +| Deploy manifests in `rv-audit-prod` S3 | 7 years (WORM Object Lock) | +| Pre-deploy RDS snapshots | 7 days (automated expiry) | + +### 6.4 Deployment Audit Trail + +Every production deployment writes a structured `deploy-manifest.json` to `s3://rv-audit-prod/deployments/{timestamp}-{semver}.json` under WORM Object Lock. This file contains: + +```json +{ + "version": "1.2.0", + "git_sha": "abc1234def5678", + "deployed_by": "github-actions", + "approved_by": "jane.doe@agency.gov", + "approval_timestamp": "2026-06-10T14:32:00Z", + "deploy_timestamp": "2026-06-10T14:40:00Z", + "images": { + "rv-api": "123456789012.dkr.ecr.us-east-1.amazonaws.com/rv-api:1.2.0@sha256:...", + "rv-workers": "123456789012.dkr.ecr.us-east-1.amazonaws.com/rv-workers:1.2.0@sha256:...", + "rv-ml-scoring": "123456789012.dkr.ecr.us-east-1.amazonaws.com/rv-ml-scoring:1.2.0@sha256:..." + }, + "migration_applied": true, + "smoke_tests_passed": true, + "rollback_occurred": false +} +``` + +This record satisfies the **SOC 2 CC8.1** (change management) and **HIPAA § 164.312(b)** (audit controls) requirements for traceable, tamper-evident deployment history. diff --git a/docs/10-development-roadmap.md b/docs/10-development-roadmap.md new file mode 100644 index 0000000..7e18408 --- /dev/null +++ b/docs/10-development-roadmap.md @@ -0,0 +1,526 @@ +# RayVerify™ — Development Roadmap + +> **Platform:** RayVerify™ | **Parent:** RayHealthEVV™ +> **Current Status:** Phase 0 complete — schema, API contract, service scaffolding, and full documentation set in place. +> **Horizon:** Foundation (done) → MVP → State Pilot → General Availability → Scale + +--- + +## Table of Contents + +1. [Guiding Principles](#1-guiding-principles) +2. [Phase Definitions](#2-phase-definitions) + - [Phase 0 — Foundation (Complete)](#phase-0--foundation-complete) + - [Phase 1 — Core Verification MVP](#phase-1--core-verification-mvp) + - [Phase 2 — Fraud Intelligence Engine](#phase-2--fraud-intelligence-engine) + - [Phase 3 — Investigator Dashboard & Case Management](#phase-3--investigator-dashboard--case-management) + - [Phase 4 — Reporting & Analytics](#phase-4--reporting--analytics) + - [Phase 5 — Hardening, Compliance & State Pilot ATO](#phase-5--hardening-compliance--state-pilot-ato) + - [Phase 6 — General Availability & Multi-State Scale](#phase-6--general-availability--multi-state-scale) + - [Phase 7 — Hardware Integration Layer](#phase-7--hardware-integration-layer) +3. [Gantt Chart](#3-gantt-chart) +4. [Milestones Table](#4-milestones-table) +5. [Team & Roles](#5-team--roles) +6. [Key Risks & Mitigations](#6-key-risks--mitigations) +7. [KPIs & Success Metrics](#7-kpis--success-metrics) +8. [Dependencies & Sequencing](#8-dependencies--sequencing) + +--- + +## 1. Guiding Principles + +### 1.1 Compliance Milestones Gate Phase Transitions + +No phase transitions to the next until its **compliance exit criteria** are met. Security is not a phase-5 activity — it is built into every phase. Each phase produces: +- Updated threat model (STRIDE) for the new attack surface +- SAST/DAST/dependency scan results with all HIGH/CRITICAL findings resolved +- Evidence artifacts for SOC 2 and HIPAA control mapping + +### 1.2 Pre-Payment Prevention First + +Feature prioritization consistently favors capabilities that block fraud **before** a Medicaid claim is paid. Retroactive analytics and reporting are necessary but secondary to real-time verification and pre-payment scoring. + +### 1.3 Explainability as a Core Requirement + +Every fraud score and identity verification decision must produce a human-readable explanation alongside a machine-readable factor list. Investigators, state auditors, and provider advocates need to understand *why* a visit was flagged — not just *that* it was flagged. This is a non-negotiable constraint on the ML scoring architecture. + +### 1.4 Tenant Isolation at Every Phase + +Multi-tenancy (row-level security, per-tenant KMS keys, organization-scoped API access) is enforced from Phase 1. No single-tenant shortcuts are taken. The first state pilot must onboard as a proper tenant, not as a hardcoded deployment. + +### 1.5 State Procurement Reality + +State Medicaid IT procurement cycles run 12–24 months. The roadmap is structured so that RayVerify can demonstrate a credible, working MVP to state program integrity units and MCO technology officers **before** a formal RFP/RFI response is needed. Phase 3 (Investigator Dashboard) is the target demo milestone for investor and state engagement. + +--- + +## 2. Phase Definitions + +--- + +### Phase 0 — Foundation (Complete) + +**Duration:** Complete as of current drop. + +**Objectives:** +- Establish the full production-grade data model, schema, and logical/physical database +- Define the complete API contract (OpenAPI 3.1) +- Scaffold the NestJS backend service structure with all eight module directories +- Scaffold the Next.js 15 frontend with auth shell +- Produce the complete documentation suite (docs/00 through docs/11) +- Configure local development environment (Docker Compose, LocalStack, Prisma seed) + +**Key Deliverables:** + +| Deliverable | Module(s) | Status | +|-------------|-----------|--------| +| `db/schema.sql` — partitioning, RLS, immutability triggers, audit hash chain | All | Done | +| `packages/backend/prisma/schema.prisma` — canonical logical model, all entities | All | Done | +| `api/openapi.yaml` — full endpoint contract | All | Done | +| `docker-compose.yml` — local dev stack | Dev experience | Done | +| `docs/00` through `docs/11` — complete architecture & compliance docs | All | Done | +| Monorepo structure (`package.json` workspaces, npm scripts) | Dev experience | Done | + +**Exit Criteria:** +- `npm install && npm run dev:infra && npm run db:migrate && npm run dev` succeeds on a clean checkout +- OpenAPI spec parses without errors +- Schema applies cleanly to a fresh PostgreSQL 15 instance +- Documentation reviewed by at least one engineer and one compliance stakeholder + +--- + +### Phase 1 — Core Verification MVP + +**Duration:** ~10 weeks + +**Objectives:** +- Implement a working, end-to-end visit verification chain: identity → GPS → device → visit package +- Stand up the AWS infrastructure (ECS Fargate, RDS Multi-AZ, ElastiCache, S3, KMS) using the Terraform modules +- Implement authentication (JWT + refresh + MFA TOTP) and RBAC +- Implement the NestJS modules for Auth, Identity Verification, Visit Verification, and Device Trust +- Implement the Python/FastAPI ml-scoring service with a rule-based scoring baseline (no ML model yet — weighted rules producing a 0–100 score) +- Connect the verification chain to the fraud score pipeline (rules-based) +- Deliver a minimal field capture API (clock-in, clock-out, GPS submission, identity selfie upload) +- Ship a working CI/CD pipeline (all `.github/workflows/` operational) + +**Key Deliverables by Module:** + +| Module | Deliverables | +|--------|-------------| +| **Identity Verification Engine** | Selfie upload endpoint, face-match stub (configurable similarity threshold), liveness check integration (AWS Rekognition or internal model), confidence/liveness score persistence in `identity_verifications` | +| **Visit Verification Engine** | Clock-in / clock-out API, GPS verification against `service_authorizations.radius_meters`, device trust assessment, `visit_verifications` record creation with SHA-256 `evidenceHash`, visit status lifecycle | +| **Fraud Intelligence Engine** | Rule-based detector stubs: impossible travel, duplicate visit, GPS anomaly, shared device; `fraud_events` creation; composite rule-based score written to `fraud_scores` | +| **Provider Risk Scoring** | `provider_risk_profiles` computed after each visit; initial score = weighted sum of rule violations | +| **Audit & Compliance Center** | All API actions write to `audit_logs` with hash chain; append-only triggers enforced end-to-end | +| **Infrastructure** | Terraform modules: networking, security, data, compute, messaging, observability deployed to staging | +| **CI/CD** | All workflow files operational: `ci.yml`, `codeql.yml`, `deploy.yml`, `security-scan.yml` | + +**Compliance Exit Criteria:** +- HIPAA BAA signed with AWS +- PHI encryption at rest (KMS) and in transit (TLS 1.3) verified by integration tests +- RLS enforcement verified: tenant A cannot query tenant B's data under any API path +- Audit log immutability verified: UPDATE/DELETE on `audit_logs`, `identity_verifications`, `fraud_events` raise DB-level exceptions +- Penetration test (scope: authentication, RLS bypass, injection) — no CRITICAL findings unresolved + +**Phase 1 KPIs:** +- Identity verification API response time (p99) < 3 000 ms including face match +- Visit clock-in end-to-end (GPS + identity + device + fraud score) < 5 000 ms (p99) +- Zero tenant data leakage in RLS test suite + +--- + +### Phase 2 — Fraud Intelligence Engine + +**Duration:** ~8 weeks + +**Objectives:** +- Replace rule-based scoring baseline with a trained gradient-boosted ML model (Python/FastAPI service) +- Implement all 13 fraud detector types cataloged in `FraudEventType` enum +- Build the feature store: real-time feature computation from `visits`, `identity_verifications`, `gps_verifications`, and `provider_risk_profiles` +- Implement SHAP-style explainability: per-feature factor weights written to `fraud_scores.factors` +- Implement the provider risk scoring refresh pipeline (async worker, triggered post-visit) +- Implement the `fraud_events` → `fraud_cases` auto-grouping heuristic (provider + time window clustering) + +**Key Deliverables by Module:** + +| Module | Deliverables | +|--------|-------------| +| **Fraud Intelligence Engine** | All 13 detectors implemented and tested; composite scorer v1; model training pipeline; A/B test framework for model versions; `modelVersion` tracking in `fraud_scores` | +| **Provider Risk Scoring** | Full `provider_risk_profiles` recompute: `verificationFailures`, `gpsAnomalies`, `billingAnomalies`, `identityIssues`, `openCases`, `substantiatedCases`, `trend` sparkline | +| **Visit Verification Engine** | Mid-visit GPS check support (`eventType: MID_VISIT`); visit status auto-transition to `FLAGGED` on high fraud score | +| **ML Infrastructure** | Feature pipeline in `rv-workers`; model artifact stored in S3 and loaded by `rv-ml-scoring`; model version pinned per deployment | + +**Compliance Exit Criteria:** +- False-positive rate < 10% on labeled test dataset (ghost visits marked confirmed fraud) +- All ML training data is synthetic or de-identified (no real PHI used in model training at this phase) +- Model versioning and reproducibility: any historical score can be regenerated from `modelVersion` + input features +- SAST + container scan: no HIGH/CRITICAL findings + +**Phase 2 KPIs:** +- Fraud score computation latency (p99) < 800 ms from visit close to score available +- Detection recall on test set (confirmed fraud cases): ≥ 85% +- False positive rate on test set: ≤ 10% + +--- + +### Phase 3 — Investigator Dashboard & Case Management + +**Duration:** ~10 weeks + +**Objectives:** +- Build the full Next.js 15 investigator dashboard (shadcn/ui components, Tailwind) +- Implement case management: create, assign, escalate, add notes/evidence, update status +- Implement fraud alert feed with filtering, sorting, and investigation actions +- Implement provider risk ranking views with trend visualization +- Implement evidence review: visit timeline, GPS map overlay, identity verification result, device signals +- Implement the `FraudCase` → `CaseEvidence` → chain-of-custody export workflow +- Implement real-time notifications (in-app via WebSocket, email via SES) + +**Key Deliverables by Module:** + +| Module | Deliverables | +|--------|-------------| +| **Investigator Dashboard** | Fraud alert feed; provider risk heat map; geographic anomaly map; fraud case list + detail view; evidence timeline; role-based views (INVESTIGATOR, AUDITOR, COMPLIANCE_OFFICER, ORG_ADMIN) | +| **Fraud Intelligence Engine** | `FraudCase` CRUD API; `CaseNote` API; `CaseEvidence` S3 attachment API; case status lifecycle (OPEN → IN_REVIEW → ESCALATED → PENDING_PAYMENT_HOLD → SUBSTANTIATED/UNSUBSTANTIATED → CLOSED) | +| **Audit & Compliance Center** | Chain-of-custody export: PDF/Excel package of all evidence for a case, signed with KMS, written to `rv-reports-{env}` with `contentHash`; `EXPORT` audit log entry | +| **Notifications** | In-app notification on new CRITICAL fraud event; email (SES) on case assignment; webhook stub for state system integration | + +**Compliance Exit Criteria:** +- RBAC verified: INVESTIGATOR cannot delete cases; COMPLIANCE_OFFICER cannot assign cases; ORG_ADMIN cannot access other tenant's data +- Chain-of-custody export package passes legal admissibility review (internal counsel sign-off) +- Accessibility: WCAG 2.1 AA compliance on all investigator-facing views +- SAST + DAST (OWASP ZAP against staging): no HIGH/CRITICAL findings + +**Phase 3 KPIs:** +- This phase is the primary **investor and state demo milestone** +- Investigator time-to-triage (from alert creation to case action): target < 10 minutes (baseline TBD from state partner feedback) +- Case creation → evidence attachment → export: < 30 minutes end-to-end +- Dashboard load time (p95): < 2 000 ms (authenticated, populated tenant) + +--- + +### Phase 4 — Reporting & Analytics + +**Duration:** ~6 weeks + +**Objectives:** +- Implement all six `ReportType` variants: FRAUD_SUMMARY, PROVIDER_RISK, VISIT_VERIFICATION, INVESTIGATION, STATE_COMPLIANCE, EXECUTIVE_DASHBOARD +- Implement async report generation pipeline (SQS → worker → S3 delivery) +- Implement scheduled reports (daily/weekly/monthly, configurable per tenant) +- Implement the Report API (request, status poll, signed download URL) +- Implement the executive dashboard analytics view (real-time stats: visit volume, fraud rate, dollars-at-risk, open cases) +- Implement state compliance export in CMS-EVV required format + +**Key Deliverables by Module:** + +| Module | Deliverables | +|--------|-------------| +| **Reporting & Analytics** | All six report types; PDF generation (Puppeteer/WKHTML); Excel generation (ExcelJS); CSV; JSON; scheduled report cron; S3 delivery with `expiresAt` lifecycle; signed presigned URL generation | +| **Audit & Compliance Center** | EXPORT audit log entries for every report download; report access controls (RBAC) | +| **Visit Verification Engine** | Read replica query routing for report-generating queries (offload from primary) | + +**Compliance Exit Criteria:** +- STATE_COMPLIANCE report format reviewed against CMS EVV data element requirements (42 CFR 441.301) +- Report downloads write to audit log: who, what, when, from which IP +- PHI in reports is redacted to minimum necessary (member ID masked to last 4 digits in non-investigator reports) + +**Phase 4 KPIs:** +- Report generation time (p95, 30-day FRAUD_SUMMARY, 1 000 visits): < 60 seconds +- Scheduled report delivery reliability: ≥ 99.5% +- Dollars of fraud identified in demo environment (synthetic data): demonstrate ≥ $500K identified in a 90-day simulated dataset + +--- + +### Phase 5 — Hardening, Compliance & State Pilot ATO + +**Duration:** ~12 weeks + +**Objectives:** +- Achieve formal **HIPAA compliance attestation** and execute BAAs with all processing sub-contractors +- Complete **SOC 2 Type I** readiness assessment; begin evidence collection for Type II +- Conduct third-party **penetration test** (scope: full application, API, infrastructure) +- Implement **Zero Trust** network controls: mTLS between ECS services, service mesh consideration +- Implement **WAF** tuning based on real traffic patterns from staging +- Onboard **first state pilot partner** as a live tenant (synthetic/test data initially, then real Medicaid data under BAA) +- Implement state-specific data residency and jurisdiction controls +- Implement **GovCloud migration path** (Terraform workspace, FIPS 140-2 KMS) if required by pilot state + +**Key Deliverables by Module:** + +| Module | Deliverables | +|--------|-------------| +| **All** | Penetration test remediation; SOC 2 control evidence package; HIPAA Risk Analysis (NIST 800-30); Security policy documentation | +| **Audit & Compliance Center** | SOC 2 CC6/CC7/CC8/CC9 evidence collection automation; automated control testing for RLS, immutability, encryption | +| **Identity Verification Engine** | NIST SP 800-63-3 IAL2 alignment documentation; liveness assurance level review | +| **Infrastructure** | CloudHSM evaluation for FIPS 140-2 Level 3 key storage; Shield Advanced activation; VPN/PrivateLink to pilot state | +| **State Pilot** | Onboarding playbook; tenant provisioning automation; pilot state BAA executed; first 500 live visits processed | + +**Compliance Exit Criteria:** +- Penetration test: no CRITICAL findings; all HIGH findings have accepted mitigations or are resolved +- HIPAA Risk Analysis complete and approved by designated Security Officer +- SOC 2 Type I report issued by qualified auditor +- First state pilot processing live visits with zero PHI breach incidents +- State pilot SLA met: 99.9% API uptime over 30-day pilot window + +**Phase 5 KPIs:** +- Live visits processed in pilot: ≥ 500 with real identity verification +- Fraud events detected in pilot: ≥ 10 confirmed or probable fraud signals +- Estimated dollars-at-risk surfaced to pilot state: ≥ $50K (dependent on pilot dataset size) +- Investigator throughput in pilot: cases triaged per investigator per day + +--- + +### Phase 6 — General Availability & Multi-State Scale + +**Duration:** ~16 weeks + +**Objectives:** +- Expand to three or more state Medicaid agencies and/or MCOs +- Achieve **SOC 2 Type II** report covering the 6-month audit window beginning Phase 5 +- Scale infrastructure to support 10× pilot traffic (autoscaling validation, load testing) +- Implement cross-tenant analytics (aggregate state-level fraud trends, no individual PHI) +- Implement provider network graph analysis (cross-provider risk detection) +- Implement payment hold integration API (connect to state claims adjudication system) +- Launch partner SDK for EVV vendors to integrate the RayVerify identity verification API + +**Key Deliverables by Module:** + +| Module | Deliverables | +|--------|-------------| +| **Fraud Intelligence Engine** | Cross-tenant/cross-provider graph analysis; network fraud ring detection; ML model v2 (trained on real, de-identified pilot data) | +| **Provider Risk Scoring** | Multi-state provider NPI matching (a provider enrolled in multiple states visible as a single risk entity) | +| **Reporting & Analytics** | State program integrity dashboard with aggregate metrics; CMS EVV compliance export in mandated electronic format | +| **Infrastructure** | Multi-region active-passive DR; EKS migration if Fargate limits are reached; Direct Connect for high-volume states | +| **Partner SDK** | REST + webhook SDK for EVV vendor integration; API key management; rate limiting per partner | + +**Compliance Exit Criteria:** +- SOC 2 Type II report issued (covers Phase 5 + Phase 6 audit window) +- Load test results: 10 000 concurrent visit clock-ins without SLA degradation +- CMS EVV compliance certification (where applicable by state) +- Third annual penetration test completed with no new CRITICAL findings + +**Phase 6 KPIs:** +- Annual Recurring Revenue (ARR): contract trajectory of $2M+ (investor milestone) +- States under active contract: ≥ 3 +- Visits verified per month: ≥ 100 000 +- Fraud prevented (pre-payment blocks + investigator-substantiated cases): ≥ $5M annualized +- Platform uptime (multi-state): ≥ 99.9% (SLA-contractual) + +--- + +### Phase 7 — Future Hardware Integration Layer + +**Duration:** ~12 weeks (parallel to Phase 6 for design; implementation begins post-GA) + +**Objectives:** +- Implement the Hardware Integration Layer SDK (Module 8) +- Define the abstraction interface for: NFC card readers, fingerprint scanners, facial recognition cameras, secure elements, dedicated GPS modules, LTE backup +- Partner with one hardware vendor per modality +- Pilot hardware-based identity verification (NIST IAL3) at one high-fraud-risk provider site + +**Key Deliverables by Module:** + +| Module | Deliverables | +|--------|-------------| +| **Future Hardware Integration Layer** | Platform-agnostic SDK with pluggable adapters for `IdentityMethod.FINGERPRINT`, `NFC_CARD`, `GOV_CREDENTIAL` (already modeled in the `identity_method` enum); hardware device management API; secure enclave key storage integration | +| **Identity Verification Engine** | IAL3 verification path in the identity verification chain; hardware attestation record in `identity_verifications.matcher` | +| **Audit & Compliance Center** | Hardware audit trail: device serial, firmware version, calibration status logged per verification | + +**Exit Criteria:** +- SDK supports at least two hardware modalities in production +- IAL3 path accepted by at least one state partner as an enhanced verification tier +- Hardware device tamper detection integrated with `DEVICE_TAMPERING` fraud event type + +--- + +## 3. Gantt Chart + +```mermaid +gantt + title RayVerify™ — Delivery Roadmap + dateFormat YYYY-MM-DD + axisFormat %b %Y + + section Foundation + Phase 0 — Foundation :done, p0, 2026-01-01, 2026-06-10 + + section Core Platform + Phase 1 — Core Verification MVP :active, p1, 2026-06-10, 2026-08-19 + Phase 2 — Fraud Intelligence :p2, after p1, 2026-10-14 + Phase 3 — Investigator Dashboard :p3, after p2, 2026-12-23 + + section Reporting & Compliance + Phase 4 — Reporting & Analytics :p4, after p3, 2027-02-03 + Phase 5 — Hardening & Pilot ATO :p5, after p3, 2027-05-14 + + section Scale + Phase 6 — GA & Multi-State Scale :p6, after p5, 2027-09-11 + Phase 7 — Hardware Layer :p7, after p6, 2027-12-04 +``` + +--- + +## 4. Milestones Table + +| # | Milestone | Target Date | Phase | Gate Condition | +|---|-----------|-------------|-------|---------------| +| M0 | Foundation complete — schema, API, docs, scaffolding | 2026-06-10 | 0 | Repo green, schema applies cleanly | +| M1 | End-to-end visit verification working in staging | 2026-07-31 | 1 | Clock-in → identity → GPS → score → visit_verification record | +| M2 | AWS infrastructure live in staging via Terraform | 2026-08-10 | 1 | ECS Fargate, RDS Multi-AZ, Redis, S3, KMS deployed | +| M3 | CI/CD pipeline fully operational | 2026-08-19 | 1 | All `.github/workflows/*.yml` passing on `main` | +| M4 | ML fraud scoring v1 (gradient-boosted model) | 2026-09-18 | 2 | Recall ≥ 85%, FPR ≤ 10% on labeled test set | +| M5 | All 13 fraud detectors implemented | 2026-10-14 | 2 | All `FraudEventType` variants produce events in integration tests | +| M6 | Investigator Dashboard MVP — investor demo ready | 2026-11-28 | 3 | Case management, evidence review, alert feed all functional | +| M7 | Chain-of-custody export (legal-grade evidence package) | 2026-12-23 | 3 | Export PDF passes internal legal review | +| M8 | All six report types delivered | 2027-01-14 | 4 | Report generation < 60 s for 1 000-visit dataset | +| M9 | HIPAA attestation + BAA chain complete | 2027-02-14 | 5 | Security Officer sign-off; BAA with AWS and sub-processors | +| M10 | Third-party penetration test passed | 2027-03-07 | 5 | No CRITICAL; all HIGH mitigated or accepted | +| M11 | SOC 2 Type I report issued | 2027-04-11 | 5 | Auditor report in hand | +| M12 | First state pilot live (real visits under BAA) | 2027-05-14 | 5 | ≥ 500 live visits; zero PHI breach | +| M13 | SOC 2 Type II audit window opens | 2027-05-14 | 5→6 | 6-month observation period begins | +| M14 | Three state contracts signed | 2027-07-30 | 6 | Executed contracts with 3 state agencies or MCOs | +| M15 | 100K visits/month throughput validated | 2027-08-20 | 6 | Load test + production metrics | +| M16 | SOC 2 Type II report issued | 2027-11-14 | 6 | Audit window closes; report issued | +| M17 | Hardware integration pilot | 2027-12-04 | 7 | Fingerprint or NFC working at one pilot site | + +--- + +## 5. Team & Roles + +| Role | Responsibility | Phase Involvement | +|------|---------------|-------------------| +| **Lead Backend Engineer (NestJS/Prisma)** | API modules, verification chain, fraud event pipeline, RLS enforcement | P1–P6 | +| **Backend Engineer (NestJS)** | Auth, notifications, report generation, integrations | P1–P6 | +| **ML / Data Engineer (Python/FastAPI)** | Fraud scoring models, feature pipeline, explainability, detector algorithms | P2–P6 | +| **Frontend Engineer (Next.js/TS/Tailwind)** | Investigator dashboard, shadcn/ui components, case management UI | P3–P6 | +| **DevOps / Platform Engineer** | Terraform IaC, CI/CD pipelines, ECS operations, observability | P1–P6 | +| **Security Engineer** | Penetration test coordination, SAST triage, WAF tuning, SOC 2 evidence | P1–P5 | +| **Compliance / Privacy Officer** | HIPAA Risk Analysis, BAA chain, SOC 2 control mapping, state regulatory liaison | P5–P6 | +| **Product Manager / Delivery Lead** | Roadmap prioritization, state partner communication, investor demos | P1–P6 | +| **QA Engineer** | Integration test coverage, load testing, accessibility testing | P1–P6 | +| **State Integration Specialist** | State MMIS/MES integration, EVV aggregator connectivity, state onboarding | P5–P6 | + +Minimum viable team for Phases 1–3: 4–5 engineers (1 lead backend, 1 ML/backend, 1 frontend, 1 DevOps/platform, part-time compliance advisor). + +--- + +## 6. Key Risks & Mitigations + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|-----------| +| **State procurement cycle length** — state RFP/contract timelines extend 12–24 months | High | High | Begin state conversations at Phase 3 demo (M6); target MCOs and program integrity contractors as faster-moving buyers; build credible SOC 2 Type I earlier in Phase 5 to reduce procurement friction | +| **Identity verification accuracy** — face-match false negative rejects legitimate caregivers | Medium | High | Configurable confidence threshold per tenant; fallback to supervisor approval for borderline cases; document FMR/FNMR tradeoff in compliance materials; human-in-the-loop override path in Phase 1 | +| **HIPAA enforcement risk during pilot** — pilot state requires ATO before any real PHI | High | Critical | Execute BAA before any live data; begin only with synthetic data in staging; HIPAA Risk Analysis complete before M12; phase pilot as: synthetic → de-identified → live PHI under BAA | +| **ML model false positive rate** — high FPR erodes investigator trust and adoption | Medium | High | Baseline rule-based scoring in Phase 1 keeps FPR low; Phase 2 ML model validated against labeled data before rollout; A/B test framework allows gradual rollout; investigator feedback loop feeds model retraining | +| **RDS failover during migration** — automated failover during a migration causes split-brain | Low | High | Migrations run against primary only; PITR snapshot before migration; migration task detects failover and aborts; always test migrations on staging with same instance class as prod | +| **Key personnel dependency** — small team, loss of lead engineer is high-impact | Medium | High | Documentation-first culture; all architectural decisions in `docs/`; pair programming on critical paths; no single engineer holds exclusive knowledge of the ML pipeline | +| **State data residency requirements** — state requires data in-state or in GovCloud | Medium | Medium | Terraform parameterized for region from Phase 1; GovCloud workspace documented in `docs/08-aws-deployment.md`; budget line for GovCloud migration if required | +| **AWS Rekognition / face-match vendor dependency** — third-party ML vendor changes API or pricing | Low | Medium | Abstraction layer in `IdentityMethod` enum and `matcher` field; vendor-agnostic interface in identity engine; self-hosted model path available in Phase 2 | +| **Competitor EVV vendor integration** — incumbent EVV vendors block or delay integration | Medium | Medium | PrivateLink / REST API approach does not require EVV vendor cooperation for core product; position as complement, not replacement; target state agencies directly | + +--- + +## 7. KPIs & Success Metrics + +### Phase 1 — Core Verification + +| KPI | Target | Measurement | +|-----|--------|-------------| +| Identity verification API latency (p99) | < 3 000 ms | CloudWatch X-Ray trace | +| End-to-end visit verification (clock-in to fraud score) | < 5 000 ms (p99) | CloudWatch custom metric | +| RLS tenant isolation test coverage | 100% of business tables | Automated integration test | +| CI pipeline duration (full run) | < 15 minutes | GitHub Actions duration | + +### Phase 2 — Fraud Intelligence + +| KPI | Target | Measurement | +|-----|--------|-------------| +| Fraud score computation latency (p99) | < 800 ms | X-Ray trace on scoring service | +| Detector recall (confirmed fraud test set) | ≥ 85% | Offline evaluation vs. labeled dataset | +| False positive rate (non-fraud test set) | ≤ 10% | Offline evaluation | +| Fraud event creation throughput | ≥ 500 events/minute | Load test | + +### Phase 3 — Investigator Dashboard + +| KPI | Target | Measurement | +|-----|--------|-------------| +| Dashboard initial load (authenticated, p95) | < 2 000 ms | Lighthouse / CloudWatch RUM | +| Time-to-triage (alert creation to case action) | < 10 minutes | Case timestamp delta | +| Chain-of-custody export generation | < 30 seconds | CloudWatch report-generation metric | +| Investigator user satisfaction (pilot survey) | ≥ 4.0/5.0 | Post-session survey | + +### Phase 4 — Reporting + +| KPI | Target | Measurement | +|-----|--------|-------------| +| Report generation time (1 000-visit dataset, p95) | < 60 seconds | SQS message → S3 delivery timestamp | +| Scheduled report delivery reliability | ≥ 99.5% | CloudWatch alarm on missed schedules | +| Dollars of fraud identified in demo dataset | ≥ $500K (90-day synthetic data) | `fraud_cases.exposure_cents` aggregate | + +### Phase 5 — Pilot + +| KPI | Target | Measurement | +|-----|--------|-------------| +| API uptime (pilot window, 30 days) | ≥ 99.9% | CloudWatch composite alarm | +| Live visits processed (pilot) | ≥ 500 | `visits` table count | +| Confirmed or probable fraud events (pilot) | ≥ 10 | `fraud_events.status = CONFIRMED` | +| Estimated dollars-at-risk surfaced | ≥ $50K | `fraud_cases.exposure_cents` aggregate | +| PHI breach incidents | 0 | Security incident log | + +### Phase 6 — GA + +| KPI | Target | Measurement | +|-----|--------|-------------| +| Visits verified per month | ≥ 100 000 | Monthly `visits` count | +| States under active contract | ≥ 3 | Signed contracts | +| Fraud prevented annually (pre-payment + substantiated) | ≥ $5M | `fraud_cases.exposure_cents` annualized | +| Platform SLA (contractual) | 99.9% | CloudWatch SLA composite metric | +| ARR trajectory | $2M+ | Finance | +| Investigator throughput | ≥ 15 cases triaged/investigator/day | Case timestamp analysis | + +--- + +## 8. Dependencies & Sequencing + +```mermaid +flowchart TD + P0[Phase 0\nFoundation DONE] + P1[Phase 1\nCore Verification MVP] + P2[Phase 2\nFraud Intelligence Engine] + P3[Phase 3\nInvestigator Dashboard] + P4[Phase 4\nReporting & Analytics] + P5[Phase 5\nHardening & Pilot ATO] + P6[Phase 6\nGA & Multi-State Scale] + P7[Phase 7\nHardware Layer] + + P0 -->|Infra + schema must exist| P1 + P1 -->|Visit verification chain required for fraud signals| P2 + P2 -->|Fraud events + scores needed for investigator workflow| P3 + P3 -->|Case management data populates reports| P4 + P3 -->|Demo milestone gates investor/state engagement| P5 + P4 -->|Reports needed for compliance attestation| P5 + P5 -->|ATO + SOC 2 Type I required before multi-state| P6 + P6 -->|GA platform stability required before hardware investment| P7 + + subgraph PARALLEL["Can Run in Parallel"] + P4 + P5 + end + P3 --> PARALLEL +``` + +**Hard sequencing constraints:** + +1. **P0 → P1**: The schema, API contract, and Terraform module stubs defined in Phase 0 are the foundation for all implementation work. Nothing in Phase 1 can begin without them. + +2. **P1 → P2**: The fraud intelligence engine scores visits and caregivers. It needs real visit records, identity verification records, GPS verifications, and device verifications — all of which are only produced by the working verification chain from Phase 1. + +3. **P2 → P3**: The investigator dashboard's core value — the fraud alert feed, provider risk ranking, and evidence timeline — requires the fraud event and fraud scoring data produced by Phase 2. A Phase 3 dashboard built before Phase 2 would have nothing meaningful to display. + +4. **P3 → P5 (investor/state gate)**: Phase 3 is explicitly the demo milestone for investor fundraising and state engagement. The Phase 5 state pilot partnership cannot realistically be initiated without a working, demonstrable product. The state procurement clock starts when the demo happens. + +5. **P4 + P5 can run in parallel** after P3, but P4 reporting deliverables (particularly STATE_COMPLIANCE export) are needed as evidence during the Phase 5 compliance attestation. + +6. **P5 → P6**: Multi-state GA without SOC 2 Type I and a clean penetration test is not viable for state government procurement. State Medicaid agencies will require evidence of independent security assessment before signing contracts. + +7. **P6 → P7**: Hardware integration is a premium tier enhancement. Building it before the software platform is at GA scale would misallocate engineering resources. Phase 7 begins design in parallel with late Phase 6 but implementation starts post-GA. diff --git a/docs/11-production-deployment.md b/docs/11-production-deployment.md new file mode 100644 index 0000000..cabbb57 --- /dev/null +++ b/docs/11-production-deployment.md @@ -0,0 +1,768 @@ +# RayVerify™ — Production Deployment & Go-Live Runbook + +**Document:** 11-Production-Deployment +**Classification:** SENSITIVE — Operations / Government Distribution +**Platform:** RayVerify™ (parent: RayHealthEVV™) +**Version:** 1.0 | June 2026 +**Audience:** DevOps/SRE, CISO, state pilot technical leads, government reviewers + +--- + +## Table of Contents + +1. [Environments & Promotion Flow](#1-environments--promotion-flow) +2. [Pre-Production Readiness Checklist](#2-pre-production-readiness-checklist) +3. [Release & Deployment Strategy](#3-release--deployment-strategy) +4. [Observability](#4-observability) +5. [Reliability & Disaster Recovery](#5-reliability--disaster-recovery) +6. [Capacity & Scaling](#6-capacity--scaling) +7. [Security Operations](#7-security-operations) +8. [Go-Live Runbook](#8-go-live-runbook) +9. [State Pilot Onboarding Checklist](#9-state-pilot-onboarding-checklist) + +--- + +## 1. Environments & Promotion Flow + +### 1.1 Environment Tiers + +| Environment | Purpose | AWS Account | Data Classification | RLS Enforced | +|------------|---------|-------------|--------------------|-| +| `dev` | Developer local/feature branch testing | Shared dev account | Synthetic only | Yes | +| `staging` | Integration testing, pre-release QA, pen test target | Isolated staging account | Synthetic only | Yes | +| `prod` | Live state pilot and production tenants | Isolated prod account | Real PHI | Yes | +| `govcloud` *(roadmap)* | FedRAMP-authorized deployments for federal programs | AWS GovCloud (US-East) | Real PHI (federal) | Yes | + +**Account separation is mandatory before go-live.** Dev and staging share no IAM roles, no KMS keys, no network connectivity, and no S3 buckets with production. + +### 1.2 GovCloud Consideration + +For state programs that require FedRAMP authorization or handle data classified at Moderate impact level, deployment to **AWS GovCloud (US-East or US-West)** is the correct posture: + +- AWS GovCloud regions are FedRAMP High authorized. +- All AWS services used by RayVerify (ECS Fargate, RDS PostgreSQL, S3, KMS, CloudFront, Secrets Manager, ElastiCache) are available in GovCloud. +- ITAR controls apply; data must not egress to commercial AWS regions. +- The IaC Terraform modules accept a `region` variable; switching to `us-gov-east-1` or `us-gov-west-1` is the primary required change. +- A separate AWS GovCloud account with a dedicated management plane is required. +- StateRAMP authorization follows a similar process; RayVerify's architecture maps directly to StateRAMP Moderate controls. + +### 1.3 Promotion Flow + +``` +Feature branch → PR → CI gate (build + test + scan) → merge to main + ↓ + Auto-deploy to dev environment + ↓ + Manual promotion trigger → staging deploy + ↓ + Staging QA sign-off + pen test + ↓ + Change Advisory Board approval ticket + ↓ + Blue/green deploy to prod +``` + +### 1.4 Configuration and Secrets Per Environment + +Configuration is managed via a layered approach: +- **Non-secret config:** Environment-specific values (log level, feature flags, rate limit thresholds) are stored as ECS task definition environment variables, managed by Terraform per environment. +- **Secrets:** All secrets (database credentials, KMS key ARNs, JWT signing key IDs, HMAC keys, third-party API keys) are stored in **AWS Secrets Manager** under environment-namespaced paths (`/rayverify/dev/`, `/rayverify/staging/`, `/rayverify/prod/`). The application retrieves secrets at container startup. ECS task roles have IAM policies that permit `secretsmanager:GetSecretValue` only on paths within their environment namespace. +- **Feature flags:** Managed in `organizations.settings` JSONB per tenant for tenant-level flags. Global feature flags use an environment variable (`FEATURE_FLAGS` JSON blob) for infrastructure-level toggles. +- **Database URL:** Never stored as a plain connection string; constructed at runtime from Secrets Manager credentials + SSM Parameter Store host/port/database values. + +--- + +## 2. Pre-Production Readiness Checklist + +The following checklist must be completed and signed off before processing real PHI in production. Each item maps to a compliance or operational requirement. + +### 2.1 Security Assessment + +- [ ] Internal security review completed (architecture review, code review, threat model review) +- [ ] OWASP ASVS Level 2 self-assessment completed and findings remediated +- [ ] Static Application Security Testing (SAST) clean run: Semgrep + CodeQL zero HIGH/CRITICAL findings +- [ ] Dependency vulnerability scan clean: npm audit + Trivy zero HIGH/CRITICAL CVEs in production images +- [ ] Secret scanning (Gitleaks) confirms no secrets in repository history +- [ ] Container image signing enabled (Sigstore/Cosign); image digest pinning in task definitions +- [ ] AWS Security Hub enabled; `CIS AWS Foundations Benchmark` compliance scan passing + +### 2.2 Penetration Test + +- [ ] Third-party penetration test commissioned (CREST or equivalent certified firm) +- [ ] Scope: external API endpoints, authentication flows, RBAC bypass, injection attacks, multi-tenant isolation, S3 enumeration, JWT attacks +- [ ] All CRITICAL findings remediated before go-live; HIGH findings remediated or have accepted risk with CISO sign-off +- [ ] Penetration test report retained for state security reviewer and OIG review +- [ ] Next scheduled pentest: annually, or after major architecture changes + +### 2.3 ATO / Authorization to Operate + +- [ ] For state-level deployments: coordinate with state CISO for an ATO or Interim Authorization to Test (IATT) +- [ ] System Security Plan (SSP) submitted to state IT security office (references this document and `07-security-architecture.md`) +- [ ] Privacy Impact Assessment (PIA) completed +- [ ] Data Use Agreement (DUA) executed between RayVerify and the state Medicaid agency +- [ ] FedRAMP package initiated if federal program participation is required + +### 2.4 BAA Chain + +- [ ] AWS BAA accepted (covers RDS, S3, KMS, ECS, CloudFront, Secrets Manager, CloudWatch, CloudTrail) +- [ ] BAA executed with state Medicaid agency (covered entity) or MCO +- [ ] BAAs executed with all PHI sub-processors: + - [ ] Biometric matching vendor (if third-party) + - [ ] SMS provider (if third-party, for MFA SMS) + - [ ] Any analytics/BI tooling that touches identified data +- [ ] Sub-processor inventory documented and disclosed to covered entities + +### 2.5 Load Test + +- [ ] Load test plan reviewed and approved (see Section 6.2) +- [ ] Sustained load test at 2× expected peak concurrent users for 30 minutes: no errors, p99 latency within SLO +- [ ] Spike test at 10× baseline for 60 seconds: graceful degradation (rate limiting, not errors) +- [ ] Database connection pool exhaustion test: PgBouncer correctly queues connections; no application crash +- [ ] Redis eviction test: cache misses fall back correctly to database without errors + +### 2.6 Disaster Recovery Test + +- [ ] RDS PITR restore test: successfully restored to a point 6 hours prior; data integrity verified +- [ ] S3 evidence bucket restore test: versioned objects retrieved from specific version ID +- [ ] ECS multi-AZ failover test: terminated tasks in AZ-1; traffic shifts to AZ-2 within ALB health check timeout +- [ ] DNS failover test (if Route 53 health checks configured for regional failover) +- [ ] RTO and RPO measurements documented and within targets (RTO ≤ 4h, RPO ≤ 1h) +- [ ] Runbook executed end-to-end by on-call team (not just tabletop) + +### 2.7 Operational Runbooks + +- [ ] On-call runbook completed and accessible in runbook store (e.g., Confluence/Notion/internal wiki) +- [ ] Incident response playbook reviewed and contact tree updated +- [ ] Database failover runbook tested +- [ ] Secret rotation runbook tested +- [ ] Partition creation runbook (pg_partman) tested +- [ ] Tenant onboarding runbook validated with dry-run + +--- + +## 3. Release & Deployment Strategy + +### 3.1 Blue/Green Deployment on ECS + +RayVerify uses **blue/green deployments** via AWS CodeDeploy integrated with ECS Fargate. This achieves zero-downtime deployments with instant rollback capability. + +``` +[Current: Blue (100% traffic)] + ↓ CodeDeploy starts deployment +[Green tasks started; health checks begin] + ↓ Green passes ALB health check +[Traffic shifted: Blue 90% → Green 10%] (canary validation window: 5 minutes) + ↓ No errors in canary window +[Traffic shifted: Blue 0% → Green 100%] + ↓ Blue tasks drained (connection drain: 60 seconds) +[Blue tasks terminated; deployment complete] +``` + +**Rollback:** If any alarm triggers during the canary window or the full cutover window (30 minutes post-cutover), CodeDeploy automatically reverts traffic to the blue task set within 30 seconds. + +Alarms that trigger automatic rollback: +- HTTP 5xx error rate > 1% over 5 minutes +- p99 API latency > 2,000 ms over 5 minutes +- ECS task health check failures > 0 + +### 3.2 Database Migration Safety + +Database migrations run independently from application deployments. The **expand/contract pattern** is mandatory for all schema changes: + +**Phase 1 — Expand (backward-compatible):** +- Add new columns with DEFAULT or as nullable. +- Add new tables, new indexes, new enum values. +- Deploy new application code that writes to both old and new structures. +- All changes must be online-safe (no full table rewrites, no exclusive locks on large tables). + +**Phase 2 — Migrate (data backfill):** +- Backfill data in batches (e.g., `UPDATE ... WHERE id > $cursor LIMIT 1000`) to avoid exclusive locks. +- Monitor replication lag and pause backfill if lag exceeds 5 seconds. + +**Phase 3 — Contract (cleanup):** +- Remove old columns/tables/indexes only after confirming no application code references them. +- Deploy as a separate migration, separate deployment. + +**Partitioned table migrations:** New partition creation for `visits` and `audit_logs` is handled by pg_partman. Partitions must be pre-created at least 1 month in advance. The partition creation job runs weekly and creates the next 3 months of partitions if they do not exist. The `visits_default` and `audit_logs_default` catch-all partitions prevent insert failures if a partition is missing. + +**Online migration tools:** For large table changes, use `pgrollout` or `pg_repack` to avoid `ACCESS EXCLUSIVE` locks. All migrations are rehearsed in staging against a production-sized data snapshot before applying to production. + +### 3.3 Zero-Downtime Principles + +- All ECS services have a minimum healthy task count of 2 (spread across 2 AZs). +- ALB deregistration delay: 60 seconds (allows in-flight requests to complete before a task is terminated). +- Rolling partition creation does not lock the parent table. +- Redis cache operations are designed to be idempotent; cache invalidation on deployment does not cause errors (cold start degrades to database, not to failure). +- Prisma Client is initialized with connection pool warming; the first request after a cold start is not a connection spike. + +### 3.4 Feature Flags + +Feature flags allow progressive rollout of new capabilities without a new deployment: + +- **Infrastructure-level flags:** `FEATURE_FLAGS` environment variable (JSON) in the ECS task definition; controls can be toggled via Terraform + deployment without application restart by using ECS environment variable update (triggers a rolling restart with ECS blue/green). +- **Tenant-level flags:** `organizations.settings` JSONB; toggled via the `ORG_ADMIN` API (`PATCH /organizations/:id/settings`); changes are audit-logged as `CONFIG_CHANGE`. +- **Fraud detector flags:** Individual fraud detectors (e.g., `IMPOSSIBLE_TRAVEL`, `LIVENESS_FAILURE`) can be enabled/disabled per organization via `organizations.settings.detectors` to allow phased rollout during state pilot. + +### 3.5 Rollback Procedure + +**Application rollback (< 5 minutes):** +1. Trigger CodeDeploy rollback via AWS console or `aws deploy stop-deployment --auto-rollback-enabled`. +2. Traffic immediately shifts back to the previous (blue) task set. +3. Confirm health check green on blue task set. +4. Post incident ticket with rollback reason. + +**Database rollback:** +- Forward-only migrations are the default (expand/contract means rollback is just re-deploying the old app, not reversing the DB change). +- If a migration must be reverted (rare), a separate reverse migration is prepared and reviewed before any forward migration is applied. +- RDS PITR is available for data-level recovery (not for schema-level rollback). + +--- + +## 4. Observability + +### 4.1 Metrics — RED/USE + +**RED metrics (per service):** + +| Metric | Collection | Alert Threshold | +|--------|-----------|----------------| +| Request Rate | ALB `RequestCount` per target group | Baseline tracking only | +| Error Rate | ALB `HTTPCode_Target_5XX_Count` / `RequestCount` | > 1% over 5 min → P2 | +| Duration (p50/p95/p99) | ALB `TargetResponseTime` | p99 > 2s → P2; p99 > 5s → P1 | + +**USE metrics (per resource):** + +| Resource | Utilization | Saturation | Errors | +|---------|-------------|-----------|--------| +| ECS CPU | `CpuUtilized` / `CpuReserved` | > 80% sustained 10 min → scale-out | Task OOM kills | +| ECS Memory | `MemoryUtilized` / `MemoryReserved` | > 85% → scale-out | OOM kill events | +| RDS | `CPUUtilization` | > 70% sustained 10 min → investigate | `DatabaseConnections` near max | +| RDS Storage | `FreeStorageSpace` | < 20% → alert | — | +| Redis | `EngineCPUUtilization` | > 70% → investigate | `CacheHits` / `CacheMisses` ratio | +| PgBouncer | Pool wait queue depth | > 10 waiting → alert | — | + +### 4.2 Logging + +All application logs are **structured JSON** (Winston logger in NestJS). Every log line includes: + +```json +{ + "timestamp": "2026-06-10T14:32:01.123Z", + "level": "info", + "service": "rayverify-api", + "traceId": "abc123", + "spanId": "def456", + "organizationId": "org-uuid", // never cross-tenant + "userId": "user-uuid", + "action": "visit.verify", + "durationMs": 142, + "statusCode": 200 +} +``` + +**PHI scrubbing:** A NestJS logging interceptor strips the following field names from all log payloads before emission: `medicaidMemberId`, `medicaid_member_id`, `mfaSecret`, `mfa_secret`, `passwordHash`, `password_hash`, `probeS3Key`, `probe_s3_key`, `referenceS3Key`, `taxId`, `tax_id`, `dateOfBirth`, `date_of_birth`, `firstName`, `lastName`, `phone`, `email` (in PHI contexts). If a field name matches and its value is a string, it is replaced with `[REDACTED]`. + +Log levels: +- `ERROR`: Unhandled exceptions, payment-blocking failures, security events. +- `WARN`: Recoverable errors, rate limit hits, failed auth attempts. +- `INFO`: Request lifecycle, audit-worthy business events. +- `DEBUG`: Development/staging only; disabled in production. + +Logs are shipped to **Amazon CloudWatch Logs** via the ECS awslogs log driver. CloudWatch Log Insights is used for ad-hoc querying. Logs are also streamed to a SIEM (e.g., Splunk or Elastic SIEM) via CloudWatch Log subscription filter for security event correlation. + +### 4.3 Distributed Tracing + +OpenTelemetry SDK is instrumented in the NestJS application. Traces are exported to **AWS X-Ray** (via OTEL X-Ray exporter). Every trace includes: +- `organizationId` as a trace attribute (for tenant-scoped query in X-Ray). +- Service map showing call chains: ALB → NestJS → RDS → Redis → S3. +- Slow query spans from Prisma query instrumentation (queries > 500ms are logged at WARN level with the query text, with parameterized values masked). + +### 4.4 Dashboards + +Three CloudWatch dashboards are maintained: + +1. **Operations Dashboard:** Request rate, error rate, p99 latency, ECS CPU/memory, RDS connections/CPU, Redis hit rate, ALB 5xx rate. +2. **Fraud Platform Dashboard:** Visits processed per hour, verification pass/fail/review rates, fraud events by type, open cases by priority, score computation queue depth. +3. **Security Dashboard:** Failed login rate, lockout events per hour, audit log write rate, hash chain verification status, unusual access pattern alerts. + +### 4.5 SLOs/SLIs and Error Budgets + +| SLI | SLO | Measurement Window | Error Budget | +|-----|-----|--------------------|-------------| +| API availability (non-5xx / total requests) | 99.9% | 30-day rolling | 43.2 minutes downtime/month | +| Visit verification p99 latency ≤ 3s | 99.5% of requests | 30-day rolling | 0.5% of requests may exceed 3s | +| Audit log write success rate | 99.99% | 30-day rolling | 0.01% failure (< 4 min/month) | +| Identity verification result delivery ≤ 5s | 95% of attempts | 7-day rolling | 5% may exceed 5s | + +Error budget burn rate alerting: +- If the 1-hour burn rate × 720 hours > 2× the 30-day budget → P1 alert (fast burn). +- If the 6-hour burn rate × 120 hours > 5× the 30-day budget → P2 alert (slow burn). + +### 4.6 Alerting and On-Call + +Alert routing via Amazon SNS → PagerDuty (or equivalent). On-call rotation covers the engineering team with a 1-week rotation. + +| Severity | Response Time | Examples | +|----------|--------------|---------| +| P1 — Critical | 15 minutes (24/7) | API down, RDS failover, security breach suspected, audit log immutability violation | +| P2 — High | 30 minutes (business hours + extended) | Error rate > 1%, p99 > 5s, ECS task crash loop, secret rotation failure | +| P3 — Medium | 4 hours | Disk space > 80%, slow queries, single task failure (autoscaling recovers) | +| P4 — Low | Next business day | Dependency updates, minor CVEs, non-critical metric drift | + +--- + +## 5. Reliability & Disaster Recovery + +### 5.1 High-Availability Topology + +```mermaid +graph LR + subgraph AZ1["Availability Zone 1"] + ECS1[ECS Fargate Task × N] + RDS1[(RDS Primary)] + REDIS1[(Redis Primary)] + end + subgraph AZ2["Availability Zone 2"] + ECS2[ECS Fargate Task × N] + RDS2[(RDS Standby — synchronous replica)] + REDIS2[(Redis Replica)] + end + ALB[ALB — routes to both AZs] --> ECS1 & ECS2 + ECS1 & ECS2 --> RDS1 + RDS1 -.->|synchronous replication| RDS2 + RDS1 -.->|async replication| RDSRead[(Read Replica — AZ2)] + ECS1 & ECS2 --> REDIS1 + REDIS1 -.->|async replication| REDIS2 +``` + +- **RDS Multi-AZ:** Synchronous standby replica in a second AZ. Automatic failover in 60–120 seconds on primary failure. Application uses the RDS cluster endpoint, which automatically routes to the new primary after failover. +- **ECS Fargate:** Tasks distributed across both AZs. ALB health checks (interval: 15s, threshold: 2 unhealthy) route traffic away from unhealthy tasks. ECS service maintains a minimum count of 2 tasks at all times. +- **ElastiCache Redis:** Multi-AZ cluster mode with automatic failover. Redis is used for rate limiting state and caching; the application is designed to degrade gracefully on Redis unavailability (cache miss falls back to database; rate limiter fails open with reduced limits). + +### 5.2 Backups + +| Data Store | Backup Method | Retention | RTO | RPO | +|-----------|--------------|-----------|-----|-----| +| RDS PostgreSQL | Automated daily snapshots + continuous PITR transaction logs | 35 days | < 1 hour (PITR restore) | ≤ 5 minutes | +| S3 Evidence Bucket | S3 Versioning + S3 Cross-Region Replication to DR bucket | Object Lock retention (7 years) | < 30 minutes (read from DR) | Near-zero (sync replication) | +| S3 Audit Export Bucket | S3 Versioning + CRR | Object Lock 7 years | < 30 minutes | Near-zero | +| Redis | ElastiCache automatic backup (daily RDB snapshot) | 7 days | < 30 minutes (restore from snapshot) | ≤ 24 hours (cache; acceptable loss) | +| Secrets Manager | Automatic replication across regions (Secrets Manager multi-region) | N/A | Immediate | Near-zero | + +**Cross-region backup:** RDS automated backups are exported to S3 and replicated to a secondary AWS region (e.g., `us-east-1` primary → `us-west-2` DR). S3 Cross-Region Replication is configured for both the evidence bucket and the audit export bucket. In a total region failure, the recovery procedure targets the DR region. + +### 5.3 RPO/RTO Targets + +| Failure Scenario | RTO Target | RPO Target | +|-----------------|-----------|-----------| +| Single ECS task failure | < 2 minutes (autoscaling replaces) | Zero (stateless) | +| Single AZ failure (ECS + Redis) | < 5 minutes | Zero (RDS sync replica) | +| RDS primary failure (multi-AZ failover) | < 2 minutes (automatic) | ≤ 30 seconds (sync replica) | +| Full AZ loss (RDS + ECS) | < 30 minutes | ≤ 5 minutes (PITR) | +| Full region failure (DR scenario) | < 4 hours | ≤ 1 hour | + +### 5.4 Disaster Recovery Runbook + +**Trigger:** PagerDuty P1 alert for region-level failure; on-call engineer initiates DR declaration. + +1. **Declare DR (T+0):** On-call engineer declares DR event; notifies CISO and state tenant POCs per the incident notification matrix. +2. **Assess scope (T+0 to T+30 min):** Confirm primary region is unavailable; confirm DR region S3/RDS backup data is intact. +3. **Restore RDS in DR region (T+30 to T+90 min):** + - Restore from the most recent RDS snapshot exported to the DR region S3 bucket. + - Apply PITR transaction logs to minimize data loss. + - Update Secrets Manager (DR region) with new RDS endpoint. +4. **Deploy ECS tasks in DR region (T+90 to T+150 min):** + - Terraform apply targeting the DR region workspace. + - ECS tasks pull container images from ECR (cross-region replication enabled). + - Validate health checks pass. +5. **DNS cutover (T+150 to T+180 min):** + - Update Route 53 records to DR region ALB endpoint. + - Confirm CloudFront origin failover or update CloudFront origin. +6. **Verify integrity (T+180 to T+240 min):** + - Run hash chain verification on restored audit logs. + - Confirm RLS is active on all tables in the restored database. + - Confirm a synthetic verification request passes end-to-end. +7. **Notify tenants (T+180 min):** Send status update to all state tenant POCs with RTO achieved and RPO data point. +8. **Post-DR review (T+7 days):** Root cause analysis; runbook update; DR test scheduled. + +### 5.5 Chaos and Failover Testing + +- **Quarterly chaos exercises:** Terminate 50% of ECS tasks in one AZ; confirm no user-visible impact. +- **Annual RDS failover drill:** Force RDS primary failover; measure actual failover time against RTO target. +- **Annual full DR test:** Execute the full DR runbook to the DR region; measure actual RTO/RPO; restore to primary and validate data. +- Results are documented and shared with state security reviewers on request. + +--- + +## 6. Capacity & Scaling + +### 6.1 Autoscaling Policies + +**ECS Service Auto Scaling (Application Auto Scaling):** + +| Metric | Scale-Out Trigger | Scale-In Trigger | Cooldown | +|--------|------------------|-----------------|---------| +| ECS CPU Utilization | > 70% for 3 minutes | < 30% for 10 minutes | Scale-out: 60s; Scale-in: 300s | +| ALB RequestCountPerTarget | > 500 req/min per task | < 100 req/min per task | Scale-out: 60s; Scale-in: 300s | +| Custom metric: queue depth (Redis) | > 1,000 jobs in fraud scoring queue | < 100 jobs | Scale-out: 30s; Scale-in: 300s | + +**Min/Max task counts:** + +| Environment | Min Tasks | Max Tasks | +|------------|----------|---------| +| Production | 2 (1 per AZ) | 20 | +| Staging | 1 | 4 | + +**RDS Read Replica:** A single read replica in AZ-2 handles reporting queries and analytics. If CPU > 80% sustained, provision a second read replica (manual trigger or via Auto Scaling for Aurora). + +### 6.2 Load and Performance Test Plan + +**Tools:** k6 (primary), Artillery (secondary for spike scenarios). + +**Test scenarios:** + +| Scenario | Virtual Users | Duration | Target | +|---------|--------------|---------|--------| +| Baseline steady state | 50 VUs | 30 minutes | p99 < 1s, error rate < 0.1% | +| Peak load (state pilot expected maximum) | 500 VUs | 30 minutes | p99 < 2s, error rate < 0.5% | +| Spike test | Ramp from 50 to 2,000 VUs in 60 seconds | 5 minutes at peak | Graceful degradation; rate limiting active; no crashes | +| Soak test | 200 VUs | 8 hours | No memory leaks; no connection pool exhaustion | +| Verification chain throughput | 100 concurrent verification submissions | 15 minutes | All verifications complete; no duplicate visit_verifications | + +**Acceptance criteria:** All scenarios must pass before production go-live clearance. + +### 6.3 Partition and Retention Operations + +**Monthly partition creation (automated via pg_partman):** + +The `visits` and `audit_logs` tables are partitioned by range on `scheduled_start` and `created_at` respectively. Partitions are named `visits_YYYY_MM` and `audit_logs_YYYY_MM`. + +A scheduled ECS task runs every Monday to create any missing partitions for the next 3 months: + +```sql +-- Example: create visits partition for next month +CREATE TABLE IF NOT EXISTS visits_2026_08 PARTITION OF visits + FOR VALUES FROM ('2026-08-01') TO ('2026-09-01'); +``` + +The task sends an alert if partition creation fails or if the `visits_default` or `audit_logs_default` catch-all partition receives any rows (indicating a missing partition). + +**Archival and retention:** + +- Partitions older than the retention boundary (7 years default) are archived by exporting to S3 Glacier Instant Retrieval in Parquet format, then dropped from PostgreSQL. +- Archival is a two-step process: (1) export and verify S3 object hash; (2) drop partition. Step 2 only proceeds after step 1 verification passes. +- Archived partitions remain queryable via Athena for compliance and legal hold purposes. +- Legal hold: if a tenant has an active `FraudCase` with status `SUBSTANTIATED` or `ESCALATED`, the associated data partitions are excluded from archival until the case is closed. + +--- + +## 7. Security Operations + +### 7.1 Vulnerability Management + +| Vulnerability Source | Cadence | SLA | +|--------------------|---------|-----| +| Trivy container image scan | Every CI build + nightly scheduled scan | CRITICAL: fix within 24h; HIGH: fix within 7 days | +| npm audit (application dependencies) | Every CI build | CRITICAL: block merge; HIGH: fix within 7 days | +| AWS Inspector (EC2/ECS/ECR) | Continuous | CRITICAL: 24h; HIGH: 7 days | +| AWS Security Hub findings | Continuous | CRITICAL: 24h; HIGH: 7 days | +| Penetration test findings | Annual | CRITICAL: 30 days; HIGH: 60 days | +| Dependabot PRs | Automated PR generation | Patch: auto-merge via CI; Minor/Major: reviewed within 7 days | + +### 7.2 Patching Cadence + +- **OS-level patching:** ECS Fargate is serverless compute — AWS manages the underlying host OS. Container base images (distroless or AWS-managed) are updated when Trivy detects new CVEs. +- **PostgreSQL engine version:** RDS minor version auto-upgrade enabled for patch releases. Major version upgrades (e.g., PostgreSQL 15 → 16) are tested in staging with a 30-day soak before production. +- **Application dependencies:** Dependabot auto-approves patch-level updates. Minor/major updates require a PR review + staging validation. +- **Emergency patching:** Zero-day CVEs in critical paths (Node.js, NestJS, Prisma, PostgreSQL) trigger an emergency change process: patch available within 24 hours, deployed within 48 hours. + +### 7.3 Secret Rotation + +| Secret | Rotation Method | Frequency | +|--------|----------------|-----------| +| RDS database password | Secrets Manager automatic rotation (Lambda function) | Every 30 days | +| JWT signing key pair | KMS automatic key rotation | Annually (keys retained for token verification until expiry) | +| Per-tenant KMS data keys | Re-encrypt API call to KMS; old data keys revoked | Annually or on-demand | +| Blind index HMAC key | Manual rotation with dual-key transition period | Annually | +| Third-party API keys | Manual with dual-key pattern | Annually or on vendor recommendation | +| ECS task role credentials | Managed by AWS IAM (automatic rotation via STS) | Every 6 hours (STS temporary credentials) | + +Secret rotation is tested in staging before production. Failed rotation attempts generate a P2 alert. + +### 7.4 Incident Response and Breach Playbook + +**Incident Severity Definitions:** + +| Level | Description | Examples | +|-------|-------------|---------| +| SEV-1 | Active breach; PHI exfiltration confirmed or highly suspected | Unauthorized API access to patient records; S3 bucket public; ransomware | +| SEV-2 | Security event with high breach probability | Stolen credentials used; anomalous mass export; RLS bypass suspected | +| SEV-3 | Security event with low breach probability | Failed brute-force attempt; WAF block spike; misconfigured security group | +| SEV-4 | Security finding (no active exploitation) | Vulnerability detected; audit log anomaly; expired certificate | + +**HIPAA Breach Playbook (SEV-1 / SEV-2):** + +``` +T+0: Detection via alert, report, or discovery +T+0: On-call engineer escalates to CISO + Security Officer +T+1h: Containment: revoke affected sessions, isolate tenant if needed, + snapshot DB state for forensics, preserve audit logs +T+4h: Preliminary assessment: identify affected records, determine + unauthorized access scope using audit_log query +T+24h: Risk assessment per HIPAA §164.402 four-factor test: + (1) nature/extent of PHI involved + (2) who accessed/could have accessed + (3) whether PHI was actually acquired/viewed + (4) extent to which risk has been mitigated +T+72h: Legal counsel briefed; covered entity tenant notified (required + immediately upon breach discovery under HIPAA §164.410) +T+60d: If breach is confirmed: + - HHS OCR notification (hhs.gov/breach portal) + - Individual notifications to affected beneficiaries + - Media notification if > 500 individuals in a state +T+90d: Root cause analysis and corrective action plan filed +``` + +### 7.5 Audit Log Monitoring + +Automated SIEM rules alert on the following audit log patterns: + +| Pattern | Alert Level | Rationale | +|---------|------------|-----------| +| > 1,000 `READ` events by a single `actorId` in 1 hour | P2 | Potential bulk data scraping | +| Any `EXPORT` event outside business hours (09:00–18:00 org timezone) | P3 | Unusual export timing | +| `CONFIG_CHANGE` event on user_role by a non-`ORG_ADMIN` | P1 | Privilege escalation | +| Hash chain mismatch detected by nightly tamper-check job | P1 | Audit log tampering | +| > 5 `LOGIN` failures for a single `actorId` in 5 minutes | P3 | Brute force in progress | +| Any action by a `userId` with `status = LOCKED` or `SUSPENDED` | P1 | Authentication bypass | +| `CASE_ACTION` on a case assigned to a different `organizationId` | P1 | Cross-tenant access | +| Sudden drop in audit log write rate (> 50% below baseline) | P2 | Log pipeline failure | + +--- + +## 8. Go-Live Runbook + +This runbook covers the step-by-step procedure for the first production deployment. It is executed by two engineers (lead + reviewer) during a scheduled maintenance window with state tenant POC on standby. + +### 8.1 Pre-Go-Live (T-7 days) + +- [ ] All Pre-Production Readiness Checklist items completed and signed off +- [ ] Production AWS account confirmed isolated from dev/staging +- [ ] Production Terraform workspace initialized and plan reviewed +- [ ] DNS records prepared (not yet active): `api.rayverify.io`, `app.rayverify.io` +- [ ] AWS WAF rules reviewed and enabled for production +- [ ] CloudWatch alarms and PagerDuty routing validated in a test fire +- [ ] On-call team briefed; rotation coverage confirmed for go-live week +- [ ] State tenant POC and BAA confirmed in place +- [ ] Rollback decision point defined: any P1 alert within 2 hours of go-live triggers rollback + +### 8.2 Go-Live Day (T=0) + +**Maintenance window: recommend 06:00–09:00 local state time (low traffic)** + +**Step 1 — Infrastructure provisioning (T+0 to T+30 min)** + +```bash +# Confirm you are in the production Terraform workspace +terraform workspace select prod + +# Review the plan one final time +terraform plan -out=prod-golive.tfplan + +# Apply infrastructure +terraform apply prod-golive.tfplan +``` + +Validate outputs: +- [ ] RDS cluster endpoint returned and reachable from within VPC (via SSM port-forward) +- [ ] S3 buckets created with Object Lock enabled +- [ ] KMS CMKs created and key policy reviewed +- [ ] ECS cluster created with correct VPC/subnet/security group assignments +- [ ] ALB created and health check target group configured +- [ ] Secrets Manager paths populated with production secrets + +**Step 2 — Schema initialization (T+30 to T+45 min)** + +```bash +# Via SSM session to a migration ECS task or ephemeral EC2 instance in the VPC +# Set DATABASE_URL to the production RDS endpoint (from Secrets Manager) +npx prisma migrate deploy + +# Verify all migrations applied +npx prisma migrate status + +# Seed system roles, permissions, and a bootstrap ORG_ADMIN user +npm run db:seed:prod +``` + +Validate: +- [ ] All 19 business tables exist +- [ ] RLS is enabled and FORCE RLS is set: `SELECT tablename, rowsecurity, forcerowsecurity FROM pg_tables WHERE schemaname = 'public' AND rowsecurity = TRUE;` +- [ ] Immutability triggers exist: `SELECT trigger_name, event_object_table FROM information_schema.triggers WHERE trigger_name LIKE 'trg_%immutable';` +- [ ] Hash chain trigger exists: `SELECT trigger_name FROM information_schema.triggers WHERE trigger_name = 'trg_audit_hash';` +- [ ] System roles seeded: `SELECT key, is_system FROM roles WHERE is_system = TRUE;` + +**Step 3 — Application deployment (T+45 to T+60 min)** + +```bash +# Tag and push the production container image +docker tag rayverify-api:$RELEASE_TAG $ECR_REPO:$RELEASE_TAG +docker tag rayverify-api:$RELEASE_TAG $ECR_REPO:prod-latest +docker push $ECR_REPO:$RELEASE_TAG +docker push $ECR_REPO:prod-latest + +# Update ECS service with new image (CodeDeploy handles blue/green) +aws ecs update-service \ + --cluster rayverify-prod \ + --service rayverify-api \ + --force-new-deployment \ + --region us-east-1 +``` + +Monitor deployment: +- [ ] ECS service event log shows new task starting: `aws ecs describe-services --cluster rayverify-prod --services rayverify-api` +- [ ] ALB target group health check passes (HTTP 200 on `/health`) +- [ ] No 5xx errors in ALB access logs for 5 minutes post-deployment + +**Step 4 — DNS cutover (T+60 to T+70 min)** + +```bash +# Update Route 53 records (low TTL set 24h prior for fast propagation) +aws route53 change-resource-record-sets \ + --hosted-zone-id $HOSTED_ZONE_ID \ + --change-batch file://dns-cutover-prod.json +``` + +Validate: +- [ ] `dig api.rayverify.io` returns production ALB DNS name +- [ ] `curl -I https://api.rayverify.io/health` returns HTTP 200 with `Strict-Transport-Security` header +- [ ] SSL certificate valid and issued by ACM for correct domain + +**Step 5 — Smoke tests (T+70 to T+90 min)** + +Run the production smoke test suite (read-only synthetic operations against the bootstrap tenant): + +```bash +npm run test:smoke -- --env=prod +``` + +Smoke test checklist: +- [ ] `POST /auth/login` returns JWT pair +- [ ] `GET /organizations/me` returns correct organization +- [ ] `GET /visits` returns empty array (no data yet) +- [ ] `GET /audit-logs` returns bootstrap seeding entries with valid hash chain +- [ ] `GET /health/db` confirms database connectivity +- [ ] `GET /health/redis` confirms Redis connectivity + +**Step 6 — Monitoring validation (T+90 to T+100 min)** + +- [ ] CloudWatch dashboard shows green metrics for all services +- [ ] No P1 or P2 PagerDuty alerts fired +- [ ] Audit log hash chain verification tool confirms chain integrity on bootstrap entries +- [ ] First production audit log entry hash chain is consistent + +**Step 7 — Declare go-live (T+100 min)** + +- [ ] Lead engineer sends go-live confirmation to: on-call team, CISO, state tenant POC +- [ ] Status page updated to operational +- [ ] Hypercare period begins (72 hours of enhanced monitoring) + +### 8.3 Post-Launch Hypercare (T+0 to T+72 hours) + +During the 72-hour hypercare period: + +- On-call team is on enhanced watch; P2 response time reduced to 15 minutes (same as P1). +- Metrics reviewed every 30 minutes for the first 4 hours, then every 2 hours. +- Any error rate spike > 0.1% triggers immediate investigation. +- Daily written status updates sent to CISO and state tenant POC. +- No schema migrations or risky code changes permitted during hypercare. +- Rollback decision authority: on-call lead engineer (no CAB approval required during hypercare). + +After 72 hours with no P1/P2 incidents, declare hypercare complete and return to standard on-call procedures. + +--- + +## 9. State Pilot Onboarding Checklist + +For each new state or tenant onboarding to RayVerify: + +### 9.1 Legal and Compliance + +- [ ] BAA executed between RayVerify and the covered entity (state Medicaid agency or MCO) +- [ ] Data Use Agreement (DUA) or Data Sharing Agreement (DSA) executed +- [ ] Security questionnaire completed by state CISO (RayVerify security controls reviewed) +- [ ] State IT security office briefed; ATO/IATT issued if required +- [ ] Privacy Impact Assessment (PIA) or Privacy Threshold Analysis (PTA) completed +- [ ] Contact information registered: technical POC, privacy officer, security officer, legal contact + +### 9.2 Tenant Provisioning + +```bash +# 1. Create organization record via admin API (POST /admin/organizations) +# Fields: name, slug (unique), jurisdiction (FIPS code / CMS region), settings + +# 2. Seed system roles for the organization +npm run db:seed:tenant -- --org-id=$ORG_ID + +# 3. Create the initial ORG_ADMIN user +# POST /admin/users { organizationId, email, firstName, lastName, role: "ORG_ADMIN" } +# User receives PENDING_INVITE status; invitation email sent + +# 4. Configure organization settings (via ORG_ADMIN API after login) +# - Enabled fraud detectors +# - Geofence default radius (default: 150 meters) +# - Score thresholds (LOW/MODERATE/HIGH/CRITICAL boundaries) +# - Report schedule / delivery preferences +# - Notification webhook endpoints (if applicable) +``` + +- [ ] Organization record created with correct `jurisdiction` field +- [ ] System roles seeded for the tenant +- [ ] Initial `ORG_ADMIN` user created and invitation accepted +- [ ] `OIG_AGENT` user created for state oversight account (if applicable) +- [ ] KMS data key provisioned for the tenant (tenant-specific data key generation) +- [ ] S3 prefix created for the tenant (`org/{org-id}/`) + +### 9.3 Integration and Data Loading + +- [ ] Provider enrollment data loaded (bulk import via `POST /providers/import` or CSV upload) +- [ ] Caregiver roster loaded per provider +- [ ] Patient (beneficiary) roster loaded (PHI; BAA must be in place first) +- [ ] Service authorization records loaded (including geofence coordinates) +- [ ] Caregiver biometric enrollment completed (selfie reference images uploaded) +- [ ] Device trust whitelist configured (approved devices for field workers) +- [ ] Integration with state EVV aggregator or MMIS tested (if applicable) + +### 9.4 User Training + +- [ ] Investigator role training completed (fraud case management, evidence review, scoring) +- [ ] Auditor role training completed (visit verification, audit log review) +- [ ] Compliance Officer training completed (report generation, breach response procedures) +- [ ] ORG_ADMIN training completed (user management, role assignment, settings configuration) +- [ ] MFA enrollment completed for all platform users before first login to production + +### 9.5 Operational Handoff + +- [ ] Tenant-specific SLO/SLA agreed upon (availability, response times, support tier) +- [ ] Escalation path documented: Level 1 support → RayVerify on-call → state POC +- [ ] Monthly reporting schedule confirmed (state compliance reports, executive dashboards) +- [ ] Notification webhook endpoints tested (if used for real-time alert delivery to state systems) +- [ ] Tenant-specific fraud detector thresholds calibrated against historical data (if available) +- [ ] First audit log hash chain verification run confirmed successful +- [ ] 30-day check-in scheduled with state POC + +### 9.6 Pilot Validation Milestones + +| Milestone | Target Timeline | Success Criteria | +|-----------|----------------|-----------------| +| First visit verified | Day 1 of pilot | Visit creates valid verification chain; no errors | +| First fraud event detected | Day 7 | At least one detector fires on synthetic test case | +| First investigation case opened | Day 14 | Case created, evidence linked, assigned to investigator | +| First compliance report generated | Day 30 | State compliance report PDF generated and reviewed | +| Hash chain integrity audit | Day 30 | All audit log records pass chain verification | +| Pilot performance review | Day 60 | SLO metrics reviewed; fraud detection rate reviewed with state PI unit | + +--- + +*Document controlled. Operations team maintains this runbook; review quarterly and after any significant infrastructure change.* +*For go-live approvals or DR declarations, contact the RayVerify SRE lead and CISO.* diff --git a/infra/terraform/README.md b/infra/terraform/README.md new file mode 100644 index 0000000..cef683e --- /dev/null +++ b/infra/terraform/README.md @@ -0,0 +1,194 @@ +# RayVerify™ — Terraform IaC + +Government-grade HIPAA/SOC 2/CMS-EVV-compliant Medicaid fraud-detection platform +infrastructure on AWS, managed by Terraform. + +--- + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Repository Layout](#repository-layout) +- [Remote State](#remote-state) +- [Provider Authentication](#provider-authentication) +- [Planning & Applying Per Environment](#planning--applying-per-environment) +- [Module Dependency Diagram](#module-dependency-diagram) +- [Compliance Notes](#compliance-notes) + +--- + +## Prerequisites + +| Tool | Minimum Version | +|------|----------------| +| Terraform | 1.7.x | +| AWS CLI | 2.x | +| aws provider | ~> 5.0 | + +You must have AWS credentials with sufficient IAM permissions. For CI/CD use an +OIDC-federated IAM role; never store long-lived access keys in code. + +Bootstrap the remote-state bucket and DynamoDB lock table exactly once per AWS +account/region before any `terraform init`: + +```bash +# One-time bootstrap (run manually or via a separate bootstrap Terraform root) +aws s3api create-bucket \ + --bucket rayverify-tfstate- \ + --region us-east-1 \ + --create-bucket-configuration LocationConstraint=us-east-1 + +aws s3api put-bucket-versioning \ + --bucket rayverify-tfstate- \ + --versioning-configuration Status=Enabled + +aws s3api put-bucket-encryption \ + --bucket rayverify-tfstate- \ + --server-side-encryption-configuration \ + '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms"}}]}' + +aws dynamodb create-table \ + --table-name rayverify-tfstate-lock \ + --attribute-definitions AttributeName=LockID,AttributeType=S \ + --key-schema AttributeName=LockID,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST \ + --region us-east-1 +``` + +--- + +## Repository Layout + +``` +infra/terraform/ +├── README.md # This file +├── versions.tf # Terraform + provider version constraints +├── backend.tf # S3 + DynamoDB remote state +├── providers.tf # AWS provider config + default_tags +├── main.tf # Root composition — wires all modules +├── variables.tf # Root input variables +├── outputs.tf # Root outputs exposed to CI/CD +├── locals.tf # Name-prefix helpers (rv--*) +├── environments/ +│ ├── dev.tfvars.example +│ ├── staging.tfvars.example +│ └── prod.tfvars.example +└── modules/ + ├── networking/ # VPC, subnets, IGW, NAT, route tables, VPC endpoints, NACLs + ├── security/ # KMS CMKs, Secrets Manager, IAM roles, WAFv2, security groups + ├── data/ # RDS PostgreSQL Multi-AZ + replica, ElastiCache Redis + ├── compute/ # ECS Fargate cluster, services, ALB, autoscaling, ECR + ├── edge/ # CloudFront, ACM cert, Route53 + ├── storage/ # S3 buckets (evidence WORM, reports, logs) + └── observability/ # CloudWatch dashboards, alarms, metric filters, SNS +``` + +--- + +## Remote State + +State is stored in S3 with server-side KMS encryption and versioning. DynamoDB +provides atomic locking to prevent concurrent applies. + +`backend.tf` contains the configuration. Override the bucket/key per environment +via `-backend-config` flags (recommended) or by editing the file before `init`. + +Workspaces are **not** used. Each environment has its own state key: + +| Environment | State key | +|-------------|----------------------------------------| +| dev | `env/dev/rayverify.tfstate` | +| staging | `env/staging/rayverify.tfstate` | +| prod | `env/prod/rayverify.tfstate` | + +--- + +## Provider Authentication + +```bash +# Option A — AWS SSO (recommended for humans) +aws sso login --profile rayverify- +export AWS_PROFILE=rayverify- + +# Option B — IAM role via GitHub Actions OIDC (recommended for CI) +# Configure aws-actions/configure-aws-credentials@v4 with role-to-assume +``` + +--- + +## Planning & Applying Per Environment + +```bash +# From infra/terraform/ +cd infra/terraform + +# 1. Initialise (first time or after provider upgrades) +terraform init \ + -backend-config="bucket=rayverify-tfstate-" \ + -backend-config="key=env//rayverify.tfstate" \ + -backend-config="region=us-east-1" \ + -backend-config="dynamodb_table=rayverify-tfstate-lock" + +# 2. Select / create the named workspace (optional; state keys differ per env) +# If you use separate state keys via -backend-config, skip workspaces. + +# 3. Plan +terraform plan \ + -var-file="environments/.tfvars" \ + -out=tfplan- + +# 4. Review the plan output carefully, especially for prod. + +# 5. Apply (prod requires a second human approval in CI) +terraform apply tfplan- +``` + +Destroy (non-prod only — prod has deletion_protection enabled on RDS): + +```bash +terraform destroy -var-file="environments/dev.tfvars" +``` + +--- + +## Module Dependency Diagram + +```mermaid +graph TD + ROOT["root main.tf"] + + ROOT --> NET["networking\n(VPC / subnets / NACLs\nNAT / VPC endpoints)"] + ROOT --> SEC["security\n(KMS CMKs / Secrets Manager\nIAM roles / WAFv2 / SGs)"] + ROOT --> STO["storage\n(S3: evidence WORM\nreports / logs)"] + ROOT --> DATA["data\n(RDS PostgreSQL Multi-AZ\nElastiCache Redis)"] + ROOT --> COMP["compute\n(ECS Fargate / ALB\nECR / autoscaling)"] + ROOT --> EDGE["edge\n(CloudFront / ACM\nRoute53)"] + ROOT --> OBS["observability\n(CloudWatch / alarms\nSNS / dashboards)"] + + SEC --> NET + DATA --> NET + DATA --> SEC + COMP --> NET + COMP --> SEC + COMP --> DATA + COMP --> STO + EDGE --> COMP + EDGE --> STO + OBS --> COMP + OBS --> DATA + OBS --> NET +``` + +--- + +## Compliance Notes + +| Control | Terraform implementation | +|---------|--------------------------| +| HIPAA §164.312(a)(2)(iv) — Encryption/Decryption | KMS CMKs on every storage resource; `storage_encrypted = true` on RDS | +| HIPAA §164.312(e)(2)(ii) — Encryption in Transit | ALB HTTPS-only listener; CloudFront TLS 1.3 minimum; `transit_encryption_enabled` on Redis | +| HIPAA §164.312(b) — Audit Controls | CloudWatch Logs + metric filters; immutable S3 Object Lock on evidence bucket | +| SOC 2 CC6.1 — Logical Access | WAFv2 managed rules; least-privilege IAM task roles; security groups default-deny | +| SOC 2 CC7.2 — System Monitoring | CloudWatch alarms; SNS alert topic; dashboard per service | +| CMS EVV — Data Integrity | S3 Object Lock (WORM) on evidence; PITR on RDS | +| Zero Trust | No public DB endpoints; VPC endpoints replace public AWS API routes; NACLs default-deny | diff --git a/infra/terraform/backend.tf b/infra/terraform/backend.tf new file mode 100644 index 0000000..48370cd --- /dev/null +++ b/infra/terraform/backend.tf @@ -0,0 +1,38 @@ +################################################################################ +# backend.tf — Remote state: S3 (encrypted) + DynamoDB (atomic lock) +# +# HIPAA/SOC 2 note: state files may contain secrets and resource metadata. +# The state bucket is KMS-encrypted, versioned, and access-logged. +# Never commit a terraform.tfstate file to source control. +# +# Override per environment via: +# terraform init -backend-config="key=env//rayverify.tfstate" +# +# One-time bootstrap is documented in README.md. +################################################################################ + +terraform { + backend "s3" { + # --------------------------------------------------------------------------- + # Replace placeholder values before running `terraform init`. + # These are intentionally left as descriptive comments rather than real + # values so that each environment configures them via -backend-config flags. + # --------------------------------------------------------------------------- + + # bucket = "rayverify-tfstate-" # versioned + KMS-encrypted + # key = "env/dev/rayverify.tfstate" # per-environment key + # region = "us-east-1" + # dynamodb_table = "rayverify-tfstate-lock" # partition key: LockID (S) + # encrypt = true # SSE-KMS + # kms_key_id = "alias/rayverify-tfstate" # optional dedicated CMK + + # --------------------------------------------------------------------------- + # Example development defaults (uncomment and edit for local use): + # --------------------------------------------------------------------------- + bucket = "rayverify-tfstate-CHANGE_ME" + key = "env/dev/rayverify.tfstate" + region = "us-east-1" + dynamodb_table = "rayverify-tfstate-lock" + encrypt = true + } +} diff --git a/infra/terraform/environments/dev.tfvars.example b/infra/terraform/environments/dev.tfvars.example new file mode 100644 index 0000000..b32a41c --- /dev/null +++ b/infra/terraform/environments/dev.tfvars.example @@ -0,0 +1,69 @@ +################################################################################ +# environments/dev.tfvars.example +# +# Development environment sizing — cost-optimized, single-AZ where possible. +# Copy to dev.tfvars and fill in real values before use. +################################################################################ + +# Core +aws_region = "us-east-1" +environment = "dev" +az_count = 2 + +# DNS — replace with actual hosted zone / domain +domain_name = "dev.rayverify.example.com" +api_subdomain = "api" +app_subdomain = "app" +route53_zone_id = "ZXXXXXXXXXXXXXXXXXXX" + +# Networking +vpc_cidr = "10.0.0.0/16" + +# RDS — smallest viable instance, no Multi-AZ, no read replica +rds_instance_class = "db.t3.medium" +rds_allocated_storage = 20 +rds_max_allocated_storage = 100 +rds_engine_version = "16.3" +rds_backup_retention_days = 1 # dev: 1 day is sufficient +rds_deletion_protection = false +rds_multi_az = false +rds_create_read_replica = false +rds_db_name = "rayverify_dev" + +# Redis — single node, smallest viable type +redis_node_type = "cache.t3.micro" +redis_num_cache_clusters = 1 +redis_engine_version = "7.1" + +# ECS — API service +api_cpu = 256 +api_memory = 512 +api_desired_count = 1 +api_min_count = 1 +api_max_count = 2 +api_image_tag = "latest" + +# ECS — Worker service +worker_cpu = 256 +worker_memory = 512 +worker_desired_count = 1 +worker_min_count = 1 +worker_max_count = 2 +worker_image_tag = "latest" + +# ECS — ML-scoring service +ml_cpu = 512 +ml_memory = 1024 +ml_desired_count = 1 +ml_min_count = 1 +ml_max_count = 2 +ml_image_tag = "latest" + +# WAF +waf_rate_limit = 5000 # relaxed in dev for testing + +# Alerts +alert_email = "devops@example.com" + +# S3 Object Lock — shorter retention for dev (still WORM, but manageable) +evidence_object_lock_days = 30 diff --git a/infra/terraform/environments/prod.tfvars.example b/infra/terraform/environments/prod.tfvars.example new file mode 100644 index 0000000..83b5cf1 --- /dev/null +++ b/infra/terraform/environments/prod.tfvars.example @@ -0,0 +1,77 @@ +################################################################################ +# environments/prod.tfvars.example +# +# Production environment sizing: +# - 3 AZs, Multi-AZ RDS, read replica, Redis cluster with replica +# - Large ECS task counts with aggressive autoscaling limits +# - Deletion protection ON for RDS +# - HIPAA-compliant 7-year (2555-day) Object Lock retention +# - Strict WAF rate limit +# +# Copy to prod.tfvars and fill in real values before use. +# NEVER commit prod.tfvars to source control (contains real domain / zone IDs). +################################################################################ + +# Core +aws_region = "us-east-1" +environment = "prod" +az_count = 3 + +# DNS +domain_name = "rayverify.example.com" # Replace with real domain +api_subdomain = "api" +app_subdomain = "app" +route53_zone_id = "ZXXXXXXXXXXXXXXXXXXX" # Replace with real hosted zone ID + +# Networking +vpc_cidr = "10.2.0.0/16" + +# RDS — production grade +# db.r7g.xlarge gives 4 vCPU / 32 GiB RAM for PostgreSQL workloads +rds_instance_class = "db.r7g.xlarge" +rds_allocated_storage = 500 +rds_max_allocated_storage = 5000 +rds_engine_version = "16.3" +rds_backup_retention_days = 35 # 35 days PITR (HIPAA minimum is 7; 35 = 5 weeks) +rds_deletion_protection = true # CRITICAL: prevents accidental database deletion +rds_multi_az = true # Synchronous standby in second AZ +rds_create_read_replica = true # Dedicated analytics replica +rds_db_name = "rayverify" + +# Redis — 3-node cluster with automatic failover +redis_node_type = "cache.r7g.large" +redis_num_cache_clusters = 3 +redis_engine_version = "7.1" + +# ECS — API service (production baseline: 3 tasks across 3 AZs) +api_cpu = 1024 # 1 vCPU +api_memory = 2048 # 2 GiB +api_desired_count = 3 +api_min_count = 3 +api_max_count = 20 +api_image_tag = "prod" + +# ECS — Worker service +worker_cpu = 1024 +worker_memory = 2048 +worker_desired_count = 3 +worker_min_count = 2 +worker_max_count = 15 +worker_image_tag = "prod" + +# ECS — ML-scoring service (CPU-intensive fraud inference) +ml_cpu = 4096 # 4 vCPU +ml_memory = 8192 # 8 GiB +ml_desired_count = 2 +ml_min_count = 2 +ml_max_count = 10 +ml_image_tag = "prod" + +# WAF — strict rate limit (government agency traffic is predictable) +waf_rate_limit = 2000 + +# Alerts — production on-call distribution list +alert_email = "oncall@example.com" + +# S3 Object Lock — HIPAA requires 6-year minimum; 7 years = 2555 days +evidence_object_lock_days = 2555 diff --git a/infra/terraform/environments/staging.tfvars.example b/infra/terraform/environments/staging.tfvars.example new file mode 100644 index 0000000..8a07970 --- /dev/null +++ b/infra/terraform/environments/staging.tfvars.example @@ -0,0 +1,70 @@ +################################################################################ +# environments/staging.tfvars.example +# +# Staging environment sizing — production-like but smaller instances. +# Multi-AZ enabled. Read replica enabled for QA analytics. +# Copy to staging.tfvars and fill in real values before use. +################################################################################ + +# Core +aws_region = "us-east-1" +environment = "staging" +az_count = 2 + +# DNS +domain_name = "staging.rayverify.example.com" +api_subdomain = "api" +app_subdomain = "app" +route53_zone_id = "ZXXXXXXXXXXXXXXXXXXX" + +# Networking +vpc_cidr = "10.1.0.0/16" + +# RDS — mid-tier, Multi-AZ, with read replica +rds_instance_class = "db.t3.large" +rds_allocated_storage = 50 +rds_max_allocated_storage = 250 +rds_engine_version = "16.3" +rds_backup_retention_days = 7 +rds_deletion_protection = false +rds_multi_az = true +rds_create_read_replica = true +rds_db_name = "rayverify_staging" + +# Redis — primary + 1 replica for HA testing +redis_node_type = "cache.t3.small" +redis_num_cache_clusters = 2 +redis_engine_version = "7.1" + +# ECS — API service (2 tasks for HA testing) +api_cpu = 512 +api_memory = 1024 +api_desired_count = 2 +api_min_count = 1 +api_max_count = 4 +api_image_tag = "staging" + +# ECS — Worker service +worker_cpu = 512 +worker_memory = 1024 +worker_desired_count = 2 +worker_min_count = 1 +worker_max_count = 4 +worker_image_tag = "staging" + +# ECS — ML-scoring service +ml_cpu = 1024 +ml_memory = 2048 +ml_desired_count = 1 +ml_min_count = 1 +ml_max_count = 3 +ml_image_tag = "staging" + +# WAF +waf_rate_limit = 3000 + +# Alerts +alert_email = "staging-alerts@example.com" + +# S3 Object Lock — 90 days in staging to validate WORM behaviour +evidence_object_lock_days = 90 diff --git a/infra/terraform/locals.tf b/infra/terraform/locals.tf new file mode 100644 index 0000000..a94bd16 --- /dev/null +++ b/infra/terraform/locals.tf @@ -0,0 +1,53 @@ +################################################################################ +# locals.tf — Computed values & naming helpers +# +# All resource names are prefixed rv-- to avoid collisions across +# environments deployed in the same AWS account (dev/staging share an account +# in cost-conscious setups; prod is always isolated). +################################################################################ + +locals { + # --------------------------------------------------------------------------- + # Name prefix — used by every module for consistent resource naming + # Example: rv-dev-api | rv-prod-rds + # --------------------------------------------------------------------------- + name_prefix = "rv-${var.environment}" + + # --------------------------------------------------------------------------- + # Derived CIDR blocks — each AZ gets a /24 slice of the /16 VPC CIDR. + # Layout (per AZ): + # 10..0.0/24 — public (ALB, NAT EIP) + # 10..10.0/24 — private (ECS tasks, Lambda) + # 10..20.0/24 — data (RDS, ElastiCache — no route to IGW) + # --------------------------------------------------------------------------- + env_octet = { + dev = 0 + staging = 1 + prod = 2 + } + + # --------------------------------------------------------------------------- + # Availability zones — first N AZs in the deployment region + # --------------------------------------------------------------------------- + azs = slice(data.aws_availability_zones.available.names, 0, var.az_count) + + # --------------------------------------------------------------------------- + # Common tags merged onto every resource (supplements provider default_tags) + # --------------------------------------------------------------------------- + common_tags = { + Project = "RayVerify" + Environment = var.environment + ManagedBy = "terraform" + } +} + +# Discover the AZs available in the current region +data "aws_availability_zones" "available" { + state = "available" +} + +# Current AWS account ID — used in IAM policy ARN construction +data "aws_caller_identity" "current" {} + +# Current region — used wherever aws_region is needed without a variable +data "aws_region" "current" {} diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 0000000..d2d2db3 --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,189 @@ +################################################################################ +# main.tf — Root composition +# +# Wires all modules together, passing outputs as inputs to dependent modules. +# Dependency order: +# networking → security → data → compute → edge → storage → observability +################################################################################ + +# --------------------------------------------------------------------------- +# 1. Networking — VPC, subnets, IGW, NAT, route tables, VPC endpoints, NACLs +# --------------------------------------------------------------------------- +module "networking" { + source = "./modules/networking" + + name_prefix = local.name_prefix + vpc_cidr = var.vpc_cidr + az_count = var.az_count + azs = local.azs + environment = var.environment + aws_region = var.aws_region +} + +# --------------------------------------------------------------------------- +# 2. Security — KMS CMKs, Secrets Manager, IAM roles, WAFv2, Security Groups +# --------------------------------------------------------------------------- +module "security" { + source = "./modules/security" + + name_prefix = local.name_prefix + environment = var.environment + vpc_id = module.networking.vpc_id + aws_account_id = data.aws_caller_identity.current.account_id + aws_region = var.aws_region + waf_rate_limit = var.waf_rate_limit + alb_sg_id = module.networking.alb_sg_id # placeholder SG from networking + private_subnet_cidr_blocks = module.networking.private_subnet_cidr_blocks + data_subnet_cidr_blocks = module.networking.data_subnet_cidr_blocks +} + +# --------------------------------------------------------------------------- +# 3. Storage — S3 buckets (evidence WORM, reports, logs) +# Created before compute so log bucket ARN can be passed to ALB. +# --------------------------------------------------------------------------- +module "storage" { + source = "./modules/storage" + + name_prefix = local.name_prefix + environment = var.environment + kms_key_arn = module.security.kms_data_key_arn + evidence_object_lock_days = var.evidence_object_lock_days + aws_account_id = data.aws_caller_identity.current.account_id +} + +# --------------------------------------------------------------------------- +# 4. Data — RDS PostgreSQL + read replica, ElastiCache Redis +# --------------------------------------------------------------------------- +module "data" { + source = "./modules/data" + + name_prefix = local.name_prefix + environment = var.environment + vpc_id = module.networking.vpc_id + data_subnet_ids = module.networking.data_subnet_ids + kms_key_arn = module.security.kms_data_key_arn + db_sg_id = module.security.db_sg_id + redis_sg_id = module.security.redis_sg_id + + # RDS sizing + rds_instance_class = var.rds_instance_class + rds_allocated_storage = var.rds_allocated_storage + rds_max_allocated_storage = var.rds_max_allocated_storage + rds_engine_version = var.rds_engine_version + rds_backup_retention_days = var.rds_backup_retention_days + rds_deletion_protection = var.rds_deletion_protection + rds_multi_az = var.rds_multi_az + rds_create_read_replica = var.rds_create_read_replica + rds_db_name = var.rds_db_name + + # Redis sizing + redis_node_type = var.redis_node_type + redis_num_cache_clusters = var.redis_num_cache_clusters + redis_engine_version = var.redis_engine_version + + # Secrets Manager secret ARN so data module can create DB credentials + rds_secret_arn = module.security.rds_secret_arn +} + +# --------------------------------------------------------------------------- +# 5. Compute — ECS Fargate cluster, services, ALB, ECR, autoscaling +# --------------------------------------------------------------------------- +module "compute" { + source = "./modules/compute" + + name_prefix = local.name_prefix + environment = var.environment + aws_region = var.aws_region + aws_account_id = data.aws_caller_identity.current.account_id + vpc_id = module.networking.vpc_id + public_subnet_ids = module.networking.public_subnet_ids + private_subnet_ids = module.networking.private_subnet_ids + alb_sg_id = module.security.alb_sg_id + ecs_tasks_sg_id = module.security.ecs_tasks_sg_id + waf_web_acl_arn = module.security.waf_web_acl_arn + kms_key_arn = module.security.kms_data_key_arn + logs_bucket_id = module.storage.logs_bucket_name + + # IAM roles + ecs_task_execution_role_arn = module.security.ecs_task_execution_role_arn + api_task_role_arn = module.security.api_task_role_arn + worker_task_role_arn = module.security.worker_task_role_arn + ml_task_role_arn = module.security.ml_task_role_arn + + # Secrets (injected into containers as environment variables via Secrets Manager) + rds_secret_arn = module.security.rds_secret_arn + redis_secret_arn = module.security.redis_secret_arn + app_secrets_arn = module.security.app_secrets_arn + + # API service sizing + api_cpu = var.api_cpu + api_memory = var.api_memory + api_desired_count = var.api_desired_count + api_min_count = var.api_min_count + api_max_count = var.api_max_count + api_image_tag = var.api_image_tag + + # Worker service sizing + worker_cpu = var.worker_cpu + worker_memory = var.worker_memory + worker_desired_count = var.worker_desired_count + worker_min_count = var.worker_min_count + worker_max_count = var.worker_max_count + worker_image_tag = var.worker_image_tag + + # ML-scoring service sizing + ml_cpu = var.ml_cpu + ml_memory = var.ml_memory + ml_desired_count = var.ml_desired_count + ml_min_count = var.ml_min_count + ml_max_count = var.ml_max_count + ml_image_tag = var.ml_image_tag +} + +# --------------------------------------------------------------------------- +# 6. Edge — CloudFront, ACM cert (us-east-1 provider alias), Route53 +# --------------------------------------------------------------------------- +module "edge" { + source = "./modules/edge" + + providers = { + aws = aws + aws.us_east_1 = aws.us_east_1 + } + + name_prefix = local.name_prefix + environment = var.environment + domain_name = var.domain_name + api_subdomain = var.api_subdomain + app_subdomain = var.app_subdomain + route53_zone_id = var.route53_zone_id + alb_dns_name = module.compute.alb_dns_name + alb_zone_id = module.compute.alb_zone_id + static_bucket_domain = module.storage.static_bucket_regional_domain + static_bucket_id = module.storage.static_bucket_id + logs_bucket_id = module.storage.logs_bucket_name + waf_web_acl_arn = module.security.waf_cloudfront_acl_arn +} + +# --------------------------------------------------------------------------- +# 7. Observability — CloudWatch, alarms, metric filters, SNS +# --------------------------------------------------------------------------- +module "observability" { + source = "./modules/observability" + + name_prefix = local.name_prefix + environment = var.environment + aws_region = var.aws_region + alert_email = var.alert_email + kms_key_arn = module.security.kms_data_key_arn + ecs_cluster_name = module.compute.ecs_cluster_name + api_service_name = module.compute.api_service_name + worker_service_name = module.compute.worker_service_name + ml_service_name = module.compute.ml_service_name + alb_arn_suffix = module.compute.alb_arn_suffix + rds_identifier = module.data.rds_identifier + redis_replication_group_id = module.data.redis_replication_group_id + api_log_group_name = module.compute.api_log_group_name + worker_log_group_name = module.compute.worker_log_group_name + ml_log_group_name = module.compute.ml_log_group_name +} diff --git a/infra/terraform/modules/compute/main.tf b/infra/terraform/modules/compute/main.tf new file mode 100644 index 0000000..cde28a2 --- /dev/null +++ b/infra/terraform/modules/compute/main.tf @@ -0,0 +1,651 @@ +################################################################################ +# modules/compute/main.tf +# +# Resources: +# - ECR repositories (api, workers, ml-scoring) +# - ECS Fargate cluster +# - CloudWatch log groups (encrypted with CMK) +# - ALB + HTTPS listener + HTTP→HTTPS redirect + target groups +# - Fargate task definitions + services (api, workers, ml-scoring) +# - Application Autoscaling policies +# +# Compliance notes: +# - All container logs encrypted with CMK (HIPAA §164.312(a)(2)(iv)) +# - HTTPS-only ALB listener (TLS 1.2+ enforced by security policy) +# - WAFv2 attached to ALB (SOC 2 CC6.6) +# - No ECS task has a public IP (private subnets only) +# - ECR image scanning on push (SOC 2 CC7.1) +################################################################################ + +################################################################################ +# ECR Repositories — with image scanning and encryption +################################################################################ + +resource "aws_ecr_repository" "api" { + name = "${var.name_prefix}/api" + image_tag_mutability = "IMMUTABLE" # prevent tag overwrites (audit trail) + + image_scanning_configuration { + scan_on_push = true # SOC 2 CC7.1: detect vulnerabilities before deployment + } + + encryption_configuration { + encryption_type = "KMS" + kms_key = var.kms_key_arn + } + + tags = { Name = "${var.name_prefix}-ecr-api" } +} + +resource "aws_ecr_repository" "worker" { + name = "${var.name_prefix}/worker" + image_tag_mutability = "IMMUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + encryption_configuration { + encryption_type = "KMS" + kms_key = var.kms_key_arn + } + + tags = { Name = "${var.name_prefix}-ecr-worker" } +} + +resource "aws_ecr_repository" "ml" { + name = "${var.name_prefix}/ml-scoring" + image_tag_mutability = "IMMUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + encryption_configuration { + encryption_type = "KMS" + kms_key = var.kms_key_arn + } + + tags = { Name = "${var.name_prefix}-ecr-ml" } +} + +# ECR lifecycle policy — keep last 10 images, expire untagged after 14 days +locals { + ecr_lifecycle_policy = jsonencode({ + rules = [ + { + rulePriority = 1 + description = "Expire untagged images after 14 days" + selection = { + tagStatus = "untagged" + countType = "sinceImagePushed" + countUnit = "days" + countNumber = 14 + } + action = { type = "expire" } + }, + { + rulePriority = 2 + description = "Keep last 10 tagged images" + selection = { + tagStatus = "tagged" + tagPrefixList = ["v"] + countType = "imageCountMoreThan" + countNumber = 10 + } + action = { type = "expire" } + } + ] + }) +} + +resource "aws_ecr_lifecycle_policy" "api" { + repository = aws_ecr_repository.api.name + policy = local.ecr_lifecycle_policy +} + +resource "aws_ecr_lifecycle_policy" "worker" { + repository = aws_ecr_repository.worker.name + policy = local.ecr_lifecycle_policy +} + +resource "aws_ecr_lifecycle_policy" "ml" { + repository = aws_ecr_repository.ml.name + policy = local.ecr_lifecycle_policy +} + +################################################################################ +# ECS Cluster — Container Insights enabled (SOC 2 CC7.2 monitoring) +################################################################################ +resource "aws_ecs_cluster" "this" { + name = "${var.name_prefix}-cluster" + + setting { + name = "containerInsights" + value = "enabled" + } + + tags = { Name = "${var.name_prefix}-cluster" } +} + +################################################################################ +# CloudWatch Log Groups (KMS-encrypted) +################################################################################ +resource "aws_cloudwatch_log_group" "api" { + name = "/ecs/${var.name_prefix}/api" + retention_in_days = 365 # HIPAA: 6-year log retention minimum; 1 year hot, archive to S3 + kms_key_id = var.kms_key_arn + + tags = { Name = "${var.name_prefix}-api-logs" } +} + +resource "aws_cloudwatch_log_group" "worker" { + name = "/ecs/${var.name_prefix}/worker" + retention_in_days = 365 + kms_key_id = var.kms_key_arn + + tags = { Name = "${var.name_prefix}-worker-logs" } +} + +resource "aws_cloudwatch_log_group" "ml" { + name = "/ecs/${var.name_prefix}/ml-scoring" + retention_in_days = 365 + kms_key_id = var.kms_key_arn + + tags = { Name = "${var.name_prefix}-ml-logs" } +} + +################################################################################ +# Application Load Balancer +################################################################################ +resource "aws_lb" "this" { + name = "${var.name_prefix}-alb" + internal = false # Internet-facing; CloudFront/WAF terminate at edge + load_balancer_type = "application" + security_groups = [var.alb_sg_id] + subnets = var.public_subnet_ids + + # Access logs to S3 (SOC 2 CC7.2, HIPAA audit) + access_logs { + bucket = var.logs_bucket_id + prefix = "alb" + enabled = true + } + + # Prevent accidental deletion in all environments + enable_deletion_protection = true + + drop_invalid_header_fields = true # security hardening + + tags = { Name = "${var.name_prefix}-alb" } +} + +# WAFv2 association — attach regional ACL to ALB +resource "aws_wafv2_web_acl_association" "alb" { + resource_arn = aws_lb.this.arn + web_acl_arn = var.waf_web_acl_arn +} + +# HTTPS listener — ALB terminates TLS, forwards to ECS tasks +resource "aws_lb_listener" "https" { + load_balancer_arn = aws_lb.this.arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" # TLS 1.2/1.3 only + certificate_arn = aws_acm_certificate_validation.alb.certificate_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.api.arn + } +} + +# HTTP → HTTPS redirect (port 80) +resource "aws_lb_listener" "http_redirect" { + load_balancer_arn = aws_lb.this.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "redirect" + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } +} + +# ALB-level ACM certificate (regional; CloudFront cert is in us-east-1) +resource "aws_acm_certificate" "alb" { + domain_name = "api.${var.name_prefix}.internal" # placeholder; real domain wired in edge module + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } + + tags = { Name = "${var.name_prefix}-alb-cert" } +} + +resource "aws_acm_certificate_validation" "alb" { + certificate_arn = aws_acm_certificate.alb.arn + # validation_record_fqdns wired by edge module after DNS records created +} + +################################################################################ +# Target Groups +################################################################################ + +resource "aws_lb_target_group" "api" { + name = "${var.name_prefix}-api-tg" + port = 3000 + protocol = "HTTP" + vpc_id = var.vpc_id + target_type = "ip" # required for Fargate + + health_check { + enabled = true + path = "/health" + protocol = "HTTP" + matcher = "200" + interval = 30 + timeout = 5 + healthy_threshold = 2 + unhealthy_threshold = 3 + } + + deregistration_delay = 30 + + tags = { Name = "${var.name_prefix}-api-tg" } +} + +resource "aws_lb_target_group" "ml" { + name = "${var.name_prefix}-ml-tg" + port = 8000 + protocol = "HTTP" + vpc_id = var.vpc_id + target_type = "ip" + + health_check { + enabled = true + path = "/health" + protocol = "HTTP" + matcher = "200" + interval = 30 + timeout = 5 + healthy_threshold = 2 + unhealthy_threshold = 3 + } + + deregistration_delay = 30 + + tags = { Name = "${var.name_prefix}-ml-tg" } +} + +# ALB listener rule — route /ml-scoring/* to ml target group +resource "aws_lb_listener_rule" "ml" { + listener_arn = aws_lb_listener.https.arn + priority = 100 + + action { + type = "forward" + target_group_arn = aws_lb_target_group.ml.arn + } + + condition { + path_pattern { + values = ["/ml-scoring/*", "/score/*"] + } + } +} + +################################################################################ +# ECS Task Definitions +################################################################################ + +resource "aws_ecs_task_definition" "api" { + family = "${var.name_prefix}-api" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.api_cpu + memory = var.api_memory + execution_role_arn = var.ecs_task_execution_role_arn + task_role_arn = var.api_task_role_arn + + container_definitions = jsonencode([ + { + name = "api" + image = "${aws_ecr_repository.api.repository_url}:${var.api_image_tag}" + essential = true + + portMappings = [ + { containerPort = 3000, protocol = "tcp" } + ] + + environment = [ + { name = "NODE_ENV", value = var.environment }, + { name = "PORT", value = "3000" } + ] + + # Secrets injected from Secrets Manager — never in environment variables as plaintext + secrets = [ + { name = "DATABASE_URL", valueFrom = "${var.rds_secret_arn}:connection_string::" }, + { name = "REDIS_URL", valueFrom = "${var.redis_secret_arn}:auth_token::" }, + { name = "JWT_SECRET", valueFrom = "${var.app_secrets_arn}:jwt_secret::" }, + { name = "ENCRYPTION_KEY", valueFrom = "${var.app_secrets_arn}:encryption_key::" } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.api.name + "awslogs-region" = var.aws_region + "awslogs-stream-prefix" = "api" + } + } + + # Read-only root filesystem — reduces attack surface + readonlyRootFilesystem = true + + # Drop all Linux capabilities; add only what the NestJS app needs + linuxParameters = { + capabilities = { + drop = ["ALL"] + } + } + } + ]) + + tags = { Name = "${var.name_prefix}-api-task-def" } +} + +resource "aws_ecs_task_definition" "worker" { + family = "${var.name_prefix}-worker" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.worker_cpu + memory = var.worker_memory + execution_role_arn = var.ecs_task_execution_role_arn + task_role_arn = var.worker_task_role_arn + + container_definitions = jsonencode([ + { + name = "worker" + image = "${aws_ecr_repository.worker.repository_url}:${var.worker_image_tag}" + essential = true + + environment = [ + { name = "NODE_ENV", value = var.environment } + ] + + secrets = [ + { name = "DATABASE_URL", valueFrom = "${var.rds_secret_arn}:connection_string::" }, + { name = "REDIS_URL", valueFrom = "${var.redis_secret_arn}:auth_token::" } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.worker.name + "awslogs-region" = var.aws_region + "awslogs-stream-prefix" = "worker" + } + } + + readonlyRootFilesystem = true + linuxParameters = { + capabilities = { drop = ["ALL"] } + } + } + ]) + + tags = { Name = "${var.name_prefix}-worker-task-def" } +} + +resource "aws_ecs_task_definition" "ml" { + family = "${var.name_prefix}-ml-scoring" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.ml_cpu + memory = var.ml_memory + execution_role_arn = var.ecs_task_execution_role_arn + task_role_arn = var.ml_task_role_arn + + container_definitions = jsonencode([ + { + name = "ml-scoring" + image = "${aws_ecr_repository.ml.repository_url}:${var.ml_image_tag}" + essential = true + + portMappings = [ + { containerPort = 8000, protocol = "tcp" } + ] + + environment = [ + { name = "ENV", value = var.environment }, + { name = "PORT", value = "8000" } + ] + + secrets = [ + { name = "DATABASE_URL", valueFrom = "${var.rds_secret_arn}:connection_string::" }, + { name = "ENCRYPTION_KEY", valueFrom = "${var.app_secrets_arn}:encryption_key::" } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.ml.name + "awslogs-region" = var.aws_region + "awslogs-stream-prefix" = "ml" + } + } + + readonlyRootFilesystem = true + linuxParameters = { + capabilities = { drop = ["ALL"] } + } + } + ]) + + tags = { Name = "${var.name_prefix}-ml-task-def" } +} + +################################################################################ +# ECS Services +################################################################################ + +resource "aws_ecs_service" "api" { + name = "${var.name_prefix}-api" + cluster = aws_ecs_cluster.this.id + task_definition = aws_ecs_task_definition.api.arn + desired_count = var.api_desired_count + launch_type = "FARGATE" + + network_configuration { + subnets = var.private_subnet_ids + security_groups = [var.ecs_tasks_sg_id] + assign_public_ip = false # Zero Trust: no public IPs on tasks + } + + load_balancer { + target_group_arn = aws_lb_target_group.api.arn + container_name = "api" + container_port = 3000 + } + + deployment_circuit_breaker { + enable = true + rollback = true + } + + deployment_controller { + type = "ECS" + } + + # Minimum 100% healthy during deployment (rolling update) + deployment_maximum_percent = 200 + deployment_minimum_healthy_percent = 100 + + lifecycle { + # Allow CI/CD to update task_definition without Terraform reverting it + ignore_changes = [task_definition, desired_count] + } + + tags = { Name = "${var.name_prefix}-api-svc" } +} + +resource "aws_ecs_service" "worker" { + name = "${var.name_prefix}-worker" + cluster = aws_ecs_cluster.this.id + task_definition = aws_ecs_task_definition.worker.arn + desired_count = var.worker_desired_count + launch_type = "FARGATE" + + network_configuration { + subnets = var.private_subnet_ids + security_groups = [var.ecs_tasks_sg_id] + assign_public_ip = false + } + + deployment_circuit_breaker { + enable = true + rollback = true + } + + lifecycle { + ignore_changes = [task_definition, desired_count] + } + + tags = { Name = "${var.name_prefix}-worker-svc" } +} + +resource "aws_ecs_service" "ml" { + name = "${var.name_prefix}-ml-scoring" + cluster = aws_ecs_cluster.this.id + task_definition = aws_ecs_task_definition.ml.arn + desired_count = var.ml_desired_count + launch_type = "FARGATE" + + network_configuration { + subnets = var.private_subnet_ids + security_groups = [var.ecs_tasks_sg_id] + assign_public_ip = false + } + + load_balancer { + target_group_arn = aws_lb_target_group.ml.arn + container_name = "ml-scoring" + container_port = 8000 + } + + deployment_circuit_breaker { + enable = true + rollback = true + } + + lifecycle { + ignore_changes = [task_definition, desired_count] + } + + tags = { Name = "${var.name_prefix}-ml-svc" } +} + +################################################################################ +# Application Autoscaling +################################################################################ + +# --- API autoscaling --- +resource "aws_appautoscaling_target" "api" { + max_capacity = var.api_max_count + min_capacity = var.api_min_count + resource_id = "service/${aws_ecs_cluster.this.name}/${aws_ecs_service.api.name}" + scalable_dimension = "ecs:service:DesiredCount" + service_namespace = "ecs" +} + +resource "aws_appautoscaling_policy" "api_cpu" { + name = "${var.name_prefix}-api-cpu-scaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.api.resource_id + scalable_dimension = aws_appautoscaling_target.api.scalable_dimension + service_namespace = aws_appautoscaling_target.api.service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + target_value = 70.0 + scale_in_cooldown = 300 + scale_out_cooldown = 60 + } +} + +resource "aws_appautoscaling_policy" "api_memory" { + name = "${var.name_prefix}-api-mem-scaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.api.resource_id + scalable_dimension = aws_appautoscaling_target.api.scalable_dimension + service_namespace = aws_appautoscaling_target.api.service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageMemoryUtilization" + } + target_value = 75.0 + scale_in_cooldown = 300 + scale_out_cooldown = 60 + } +} + +# --- Worker autoscaling --- +resource "aws_appautoscaling_target" "worker" { + max_capacity = var.worker_max_count + min_capacity = var.worker_min_count + resource_id = "service/${aws_ecs_cluster.this.name}/${aws_ecs_service.worker.name}" + scalable_dimension = "ecs:service:DesiredCount" + service_namespace = "ecs" +} + +resource "aws_appautoscaling_policy" "worker_cpu" { + name = "${var.name_prefix}-worker-cpu-scaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.worker.resource_id + scalable_dimension = aws_appautoscaling_target.worker.scalable_dimension + service_namespace = aws_appautoscaling_target.worker.service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + target_value = 70.0 + scale_in_cooldown = 300 + scale_out_cooldown = 60 + } +} + +# --- ML-scoring autoscaling --- +resource "aws_appautoscaling_target" "ml" { + max_capacity = var.ml_max_count + min_capacity = var.ml_min_count + resource_id = "service/${aws_ecs_cluster.this.name}/${aws_ecs_service.ml.name}" + scalable_dimension = "ecs:service:DesiredCount" + service_namespace = "ecs" +} + +resource "aws_appautoscaling_policy" "ml_cpu" { + name = "${var.name_prefix}-ml-cpu-scaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.ml.resource_id + scalable_dimension = aws_appautoscaling_target.ml.scalable_dimension + service_namespace = aws_appautoscaling_target.ml.service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + target_value = 70.0 + scale_in_cooldown = 300 + scale_out_cooldown = 60 + } +} diff --git a/infra/terraform/modules/compute/outputs.tf b/infra/terraform/modules/compute/outputs.tf new file mode 100644 index 0000000..ff21404 --- /dev/null +++ b/infra/terraform/modules/compute/outputs.tf @@ -0,0 +1,61 @@ +output "ecr_api_repository_url" { + value = aws_ecr_repository.api.repository_url +} + +output "ecr_worker_repository_url" { + value = aws_ecr_repository.worker.repository_url +} + +output "ecr_ml_repository_url" { + value = aws_ecr_repository.ml.repository_url +} + +output "ecs_cluster_name" { + value = aws_ecs_cluster.this.name +} + +output "ecs_cluster_arn" { + value = aws_ecs_cluster.this.arn +} + +output "api_service_name" { + value = aws_ecs_service.api.name +} + +output "worker_service_name" { + value = aws_ecs_service.worker.name +} + +output "ml_service_name" { + value = aws_ecs_service.ml.name +} + +output "alb_arn" { + value = aws_lb.this.arn +} + +output "alb_dns_name" { + value = aws_lb.this.dns_name +} + +output "alb_zone_id" { + description = "ALB hosted zone ID for Route53 alias records." + value = aws_lb.this.zone_id +} + +output "alb_arn_suffix" { + description = "ALB ARN suffix (used in CloudWatch metrics)." + value = aws_lb.this.arn_suffix +} + +output "api_log_group_name" { + value = aws_cloudwatch_log_group.api.name +} + +output "worker_log_group_name" { + value = aws_cloudwatch_log_group.worker.name +} + +output "ml_log_group_name" { + value = aws_cloudwatch_log_group.ml.name +} diff --git a/infra/terraform/modules/compute/variables.tf b/infra/terraform/modules/compute/variables.tf new file mode 100644 index 0000000..8888906 --- /dev/null +++ b/infra/terraform/modules/compute/variables.tf @@ -0,0 +1,42 @@ +variable "name_prefix" { type = string } +variable "environment" { type = string } +variable "aws_region" { type = string } +variable "aws_account_id" { type = string } +variable "vpc_id" { type = string } +variable "public_subnet_ids" { type = list(string) } +variable "private_subnet_ids" { type = list(string) } +variable "alb_sg_id" { type = string } +variable "ecs_tasks_sg_id" { type = string } +variable "waf_web_acl_arn" { type = string } +variable "kms_key_arn" { type = string } +variable "logs_bucket_id" { type = string } + +variable "ecs_task_execution_role_arn" { type = string } +variable "api_task_role_arn" { type = string } +variable "worker_task_role_arn" { type = string } +variable "ml_task_role_arn" { type = string } + +variable "rds_secret_arn" { type = string } +variable "redis_secret_arn" { type = string } +variable "app_secrets_arn" { type = string } + +variable "api_cpu" { type = number; default = 512 } +variable "api_memory" { type = number; default = 1024 } +variable "api_desired_count" { type = number; default = 1 } +variable "api_min_count" { type = number; default = 1 } +variable "api_max_count" { type = number; default = 4 } +variable "api_image_tag" { type = string; default = "latest" } + +variable "worker_cpu" { type = number; default = 512 } +variable "worker_memory" { type = number; default = 1024 } +variable "worker_desired_count" { type = number; default = 1 } +variable "worker_min_count" { type = number; default = 1 } +variable "worker_max_count" { type = number; default = 4 } +variable "worker_image_tag" { type = string; default = "latest" } + +variable "ml_cpu" { type = number; default = 1024 } +variable "ml_memory" { type = number; default = 2048 } +variable "ml_desired_count" { type = number; default = 1 } +variable "ml_min_count" { type = number; default = 1 } +variable "ml_max_count" { type = number; default = 4 } +variable "ml_image_tag" { type = string; default = "latest" } diff --git a/infra/terraform/modules/data/main.tf b/infra/terraform/modules/data/main.tf new file mode 100644 index 0000000..e91e5c9 --- /dev/null +++ b/infra/terraform/modules/data/main.tf @@ -0,0 +1,294 @@ +################################################################################ +# modules/data/main.tf +# +# Resources: +# - RDS PostgreSQL Multi-AZ primary + optional read replica +# - ElastiCache Redis replication group (encryption in transit + at rest) +# +# Compliance notes: +# - HIPAA §164.312(a)(2)(iv): storage_encrypted = true (KMS CMK) +# - HIPAA §164.308(a)(7): PITR enabled via backup_retention_period +# - CMS EVV: PITR satisfies data-availability requirements +# - No public accessibility — instances placed in data subnets with no IGW route +# - Performance Insights enabled for query-level audit (SOC 2 CC7.2) +################################################################################ + +################################################################################ +# RDS Subnet Group — data-tier subnets only (no internet route) +################################################################################ +resource "aws_db_subnet_group" "this" { + name = "${var.name_prefix}-db-subnet-group" + description = "RDS subnet group — data tier only, no internet route" + subnet_ids = var.data_subnet_ids + + tags = { + Name = "${var.name_prefix}-db-subnet-group" + } +} + +################################################################################ +# RDS Parameter Group — PostgreSQL tuning + audit settings +# +# Logging settings support HIPAA audit requirements: +# log_connections / log_disconnections: track all authentication events +# log_min_duration_statement: slow-query logging for performance + forensics +# pgaudit (if extension installed): row-level audit logging +################################################################################ +resource "aws_db_parameter_group" "postgres" { + name = "${var.name_prefix}-pg16" + family = "postgres16" + description = "${var.name_prefix} PostgreSQL 16 parameter group" + + # Enable query logging (audit trail support — HIPAA §164.312(b)) + parameter { + name = "log_connections" + value = "1" + apply_method = "immediate" + } + + parameter { + name = "log_disconnections" + value = "1" + apply_method = "immediate" + } + + parameter { + name = "log_min_duration_statement" + value = "1000" # log queries taking > 1 second + apply_method = "immediate" + } + + parameter { + name = "log_statement" + value = "ddl" # log all DDL statements + apply_method = "immediate" + } + + # Force SSL — enforce TLS in transit (HIPAA §164.312(e)(2)(ii)) + parameter { + name = "rds.force_ssl" + value = "1" + apply_method = "pending-reboot" + } + + parameter { + name = "shared_preload_libraries" + value = "pg_stat_statements,pgaudit" + apply_method = "pending-reboot" + } + + tags = { + Name = "${var.name_prefix}-pg16-params" + } +} + +################################################################################ +# RDS PostgreSQL Primary Instance +################################################################################ +resource "aws_db_instance" "primary" { + identifier = "${var.name_prefix}-postgres" + + engine = "postgres" + engine_version = var.rds_engine_version + instance_class = var.rds_instance_class + + # Storage + allocated_storage = var.rds_allocated_storage + max_allocated_storage = var.rds_max_allocated_storage # autoscaling upper bound + storage_type = "gp3" + + # Encryption — HIPAA §164.312(a)(2)(iv) + storage_encrypted = true + kms_key_id = var.kms_key_arn + + # Database init + db_name = var.rds_db_name + username = "rayverify_admin" + # Password managed via Secrets Manager; retrieved by the application + manage_master_user_password = false + password = "placeholder-managed-by-secrets-manager" + + # Network — NEVER public; placed in data subnets + db_subnet_group_name = aws_db_subnet_group.this.name + vpc_security_group_ids = [var.db_sg_id] + publicly_accessible = false # Critical: no public endpoint (Zero Trust) + + # High Availability — Multi-AZ synchronous standby + multi_az = var.rds_multi_az + + # Backup & recovery — HIPAA §164.308(a)(7), CMS EVV data availability + backup_retention_period = var.rds_backup_retention_days + backup_window = "03:00-04:00" # UTC, low-traffic window + maintenance_window = "sun:04:30-sun:05:30" + delete_automated_backups = false + + # Point-in-time recovery (enabled implicitly when backup_retention_period > 0) + + # Performance Insights — query-level observability (SOC 2 CC7.2) + performance_insights_enabled = true + performance_insights_kms_key_id = var.kms_key_arn + performance_insights_retention_period = 7 + + # Enhanced monitoring (CloudWatch agent on the RDS host) + monitoring_interval = 60 + monitoring_role_arn = aws_iam_role.rds_monitoring.arn + + # Parameter group + parameter_group_name = aws_db_parameter_group.postgres.name + + # Deletion protection — always enabled in prod (set via tfvars) + deletion_protection = var.rds_deletion_protection + + # Final snapshot before destroy (excluded in dev for clean teardown) + skip_final_snapshot = !var.rds_deletion_protection + final_snapshot_identifier = "${var.name_prefix}-postgres-final" + + # Apply changes immediately in dev; defer to maintenance window in prod + apply_immediately = var.rds_deletion_protection ? false : true + + lifecycle { + # Prevent password drift from forcing a replacement; password is rotated externally + ignore_changes = [password] + } + + tags = { + Name = "${var.name_prefix}-postgres-primary" + } +} + +################################################################################ +# RDS Read Replica — analytics offloading (optional, enabled in staging/prod) +################################################################################ +resource "aws_db_instance" "replica" { + count = var.rds_create_read_replica ? 1 : 0 + + identifier = "${var.name_prefix}-postgres-replica" + replicate_source_db = aws_db_instance.primary.identifier + instance_class = var.rds_instance_class + + # Replica inherits encryption from the primary + storage_encrypted = true + kms_key_id = var.kms_key_arn + + vpc_security_group_ids = [var.db_sg_id] + publicly_accessible = false + + # Replica-specific monitoring + performance_insights_enabled = true + performance_insights_kms_key_id = var.kms_key_arn + performance_insights_retention_period = 7 + monitoring_interval = 60 + monitoring_role_arn = aws_iam_role.rds_monitoring.arn + + skip_final_snapshot = true # replicas can be recreated without data loss + + tags = { + Name = "${var.name_prefix}-postgres-replica" + } +} + +################################################################################ +# RDS Enhanced Monitoring IAM Role +################################################################################ +resource "aws_iam_role" "rds_monitoring" { + name = "${var.name_prefix}-rds-monitoring" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "monitoring.rds.amazonaws.com" } + }] + }) + + tags = { Name = "${var.name_prefix}-rds-monitoring" } +} + +resource "aws_iam_role_policy_attachment" "rds_monitoring" { + role = aws_iam_role.rds_monitoring.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole" +} + +################################################################################ +# ElastiCache Subnet Group +################################################################################ +resource "aws_elasticache_subnet_group" "this" { + name = "${var.name_prefix}-redis-subnet-group" + description = "ElastiCache Redis subnet group — data tier" + subnet_ids = var.data_subnet_ids + + tags = { + Name = "${var.name_prefix}-redis-subnet-group" + } +} + +################################################################################ +# ElastiCache Redis Parameter Group +################################################################################ +resource "aws_elasticache_parameter_group" "redis" { + name = "${var.name_prefix}-redis7" + family = "redis7" + + # Enforce TLS — transit_encryption handled at replication group level + # Disable DEBUG and EVAL commands to reduce attack surface + parameter { + name = "maxmemory-policy" + value = "allkeys-lru" + } + + tags = { + Name = "${var.name_prefix}-redis7-params" + } +} + +################################################################################ +# ElastiCache Redis Replication Group +# +# HIPAA §164.312(e)(2)(ii): encrypt in transit (transit_encryption_enabled). +# HIPAA §164.312(a)(2)(iv): encrypt at rest (at_rest_encryption_enabled + KMS). +# AUTH token stored in Secrets Manager; injected into containers at startup. +################################################################################ +resource "aws_elasticache_replication_group" "this" { + replication_group_id = "${var.name_prefix}-redis" + description = "${var.name_prefix} Redis — queues and session cache" + + node_type = var.redis_node_type + num_cache_clusters = var.redis_num_cache_clusters + parameter_group_name = aws_elasticache_parameter_group.redis.name + engine_version = var.redis_engine_version + + subnet_group_name = aws_elasticache_subnet_group.this.name + security_group_ids = [var.redis_sg_id] + + # Encryption in transit (TLS 1.2+) — HIPAA + transit_encryption_enabled = true + + # Encryption at rest with CMK — HIPAA + at_rest_encryption_enabled = true + kms_key_id = var.kms_key_arn + + # AUTH token from Secrets Manager (not stored in TF state directly) + # In practice: retrieve from secretsmanager, pass as variable, or use aws_secretsmanager_secret_version data source + # auth_token = data.aws_secretsmanager_secret_version.redis.secret_string -- wired at deploy time + + # Automatic failover (requires num_cache_clusters >= 2 for replica availability) + automatic_failover_enabled = var.redis_num_cache_clusters > 1 + + # Maintenance and snapshot + snapshot_retention_limit = 3 + snapshot_window = "03:30-04:30" + maintenance_window = "sun:05:00-sun:06:00" + + # In-transit encryption requires TLS mode + apply_immediately = var.rds_deletion_protection ? false : true + + tags = { + Name = "${var.name_prefix}-redis" + } +} + +# Local alias for rds_deletion_protection used in Redis apply_immediately +locals { + rds_deletion_protection = var.rds_deletion_protection +} diff --git a/infra/terraform/modules/data/outputs.tf b/infra/terraform/modules/data/outputs.tf new file mode 100644 index 0000000..3ee9255 --- /dev/null +++ b/infra/terraform/modules/data/outputs.tf @@ -0,0 +1,54 @@ +output "rds_endpoint" { + description = "RDS primary writer endpoint (host:port)." + value = aws_db_instance.primary.endpoint + sensitive = true +} + +output "rds_address" { + description = "RDS primary endpoint hostname." + value = aws_db_instance.primary.address + sensitive = true +} + +output "rds_port" { + description = "RDS port." + value = aws_db_instance.primary.port +} + +output "rds_identifier" { + description = "RDS instance identifier (used in CloudWatch metrics)." + value = aws_db_instance.primary.identifier +} + +output "rds_read_replica_endpoint" { + description = "RDS read-replica endpoint (empty string if not created)." + value = var.rds_create_read_replica ? aws_db_instance.replica[0].endpoint : "" + sensitive = true +} + +output "rds_arn" { + description = "RDS primary instance ARN." + value = aws_db_instance.primary.arn +} + +output "redis_primary_endpoint" { + description = "ElastiCache Redis primary endpoint address." + value = aws_elasticache_replication_group.this.primary_endpoint_address + sensitive = true +} + +output "redis_reader_endpoint" { + description = "ElastiCache Redis reader endpoint (for read replicas)." + value = aws_elasticache_replication_group.this.reader_endpoint_address + sensitive = true +} + +output "redis_port" { + description = "Redis port (TLS enabled)." + value = aws_elasticache_replication_group.this.port +} + +output "redis_replication_group_id" { + description = "ElastiCache replication group ID (used in CloudWatch metrics)." + value = aws_elasticache_replication_group.this.id +} diff --git a/infra/terraform/modules/data/variables.tf b/infra/terraform/modules/data/variables.tf new file mode 100644 index 0000000..b126b18 --- /dev/null +++ b/infra/terraform/modules/data/variables.tf @@ -0,0 +1,96 @@ +variable "name_prefix" { + type = string +} + +variable "environment" { + type = string +} + +variable "vpc_id" { + type = string +} + +variable "data_subnet_ids" { + description = "Subnet IDs for the data tier (RDS + ElastiCache)." + type = list(string) +} + +variable "kms_key_arn" { + description = "KMS CMK ARN for RDS storage and Redis encryption." + type = string +} + +variable "db_sg_id" { + description = "Security group ID for the RDS instance." + type = string +} + +variable "redis_sg_id" { + description = "Security group ID for the ElastiCache cluster." + type = string +} + +variable "rds_instance_class" { + type = string + default = "db.t3.medium" +} + +variable "rds_allocated_storage" { + type = number + default = 100 +} + +variable "rds_max_allocated_storage" { + type = number + default = 500 +} + +variable "rds_engine_version" { + type = string + default = "16.3" +} + +variable "rds_backup_retention_days" { + type = number + default = 7 +} + +variable "rds_deletion_protection" { + type = bool + default = false +} + +variable "rds_multi_az" { + type = bool + default = false +} + +variable "rds_create_read_replica" { + type = bool + default = false +} + +variable "rds_db_name" { + type = string + default = "rayverify" +} + +variable "rds_secret_arn" { + description = "Secrets Manager secret ARN with DB credentials." + type = string +} + +variable "redis_node_type" { + type = string + default = "cache.t3.micro" +} + +variable "redis_num_cache_clusters" { + type = number + default = 1 +} + +variable "redis_engine_version" { + type = string + default = "7.1" +} diff --git a/infra/terraform/modules/edge/main.tf b/infra/terraform/modules/edge/main.tf new file mode 100644 index 0000000..df89964 --- /dev/null +++ b/infra/terraform/modules/edge/main.tf @@ -0,0 +1,233 @@ +################################################################################ +# modules/edge/main.tf +# +# Resources: +# - ACM certificate (us-east-1 — required for CloudFront) +# - CloudFront distributions (app frontend + API proxy) +# - Route53 alias records +# +# Providers used: +# aws — primary region (for Route53, data sources) +# aws.us_east_1 — ACM + WAF for CloudFront (must be us-east-1) +# +# Compliance notes: +# - minimum_protocol_version = TLSv1.2_2021 (TLS 1.2/1.3 only) +# - WAFv2 CLOUDFRONT ACL attached +# - All CloudFront access logs written to S3 +# - HTTPS-only viewer protocol (no plain HTTP to origin) +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + configuration_aliases = [aws.us_east_1] + } + } +} + +################################################################################ +# ACM Certificate — must be in us-east-1 for CloudFront +################################################################################ +resource "aws_acm_certificate" "this" { + provider = aws.us_east_1 + + domain_name = var.domain_name + subject_alternative_names = [ + "*.${var.domain_name}", + "${var.api_subdomain}.${var.domain_name}", + "${var.app_subdomain}.${var.domain_name}" + ] + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } + + tags = { + Name = "${var.name_prefix}-cloudfront-cert" + } +} + +# DNS validation records +resource "aws_route53_record" "cert_validation" { + for_each = { + for dvo in aws_acm_certificate.this.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + type = dvo.resource_record_type + record = dvo.resource_record_value + } + } + + zone_id = var.route53_zone_id + name = each.value.name + type = each.value.type + ttl = 60 + records = [each.value.record] +} + +resource "aws_acm_certificate_validation" "this" { + provider = aws.us_east_1 + + certificate_arn = aws_acm_certificate.this.arn + validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn] +} + +################################################################################ +# CloudFront Origin Access Control — S3 static assets (no OAI; OAC is current) +################################################################################ +resource "aws_cloudfront_origin_access_control" "static" { + name = "${var.name_prefix}-static-oac" + description = "OAC for static asset S3 origin" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +################################################################################ +# CloudFront Distribution — App (investigator dashboard) +################################################################################ +resource "aws_cloudfront_distribution" "app" { + comment = "${var.name_prefix} — Investigator Dashboard" + enabled = true + is_ipv6_enabled = true + default_root_object = "index.html" + price_class = "PriceClass_100" # US + EU only (data residency awareness) + + aliases = ["${var.app_subdomain}.${var.domain_name}"] + + # S3 static asset origin + origin { + domain_name = var.static_bucket_domain + origin_id = "static-s3" + origin_access_control_id = aws_cloudfront_origin_access_control.static.id + } + + # ALB API origin (for SSR / proxy pass-through) + origin { + domain_name = var.alb_dns_name + origin_id = "alb-api" + + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" # force TLS to origin + origin_ssl_protocols = ["TLSv1.2"] + } + } + + # Default cache behaviour — static assets from S3 + default_cache_behavior { + target_origin_id = "static-s3" + viewer_protocol_policy = "redirect-to-https" # no plain HTTP + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + + forwarded_values { + query_string = false + cookies { + forward = "none" + } + } + + compress = true + min_ttl = 0 + default_ttl = 86400 # 1 day + max_ttl = 2592000 # 30 days + } + + # /api/* cache behaviour — forward to ALB, no caching (PHI responses) + ordered_cache_behavior { + path_pattern = "/api/*" + target_origin_id = "alb-api" + viewer_protocol_policy = "https-only" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD"] + + forwarded_values { + query_string = true + headers = ["Authorization", "Accept", "Content-Type", "Origin"] + cookies { + forward = "all" + } + } + + min_ttl = 0 + default_ttl = 0 # No caching for API responses (PHI) + max_ttl = 0 + compress = true + } + + # TLS configuration — enforce TLS 1.2 minimum + viewer_certificate { + acm_certificate_arn = aws_acm_certificate_validation.this.certificate_arn + ssl_support_method = "sni-only" + minimum_protocol_version = "TLSv1.2_2021" + cloudfront_default_certificate = false + } + + # WAFv2 ACL (CLOUDFRONT scope) — SOC 2 CC6.6 + web_acl_id = var.waf_web_acl_arn + + restrictions { + geo_restriction { + restriction_type = "none" # State agencies operate nationwide; no geo-block + } + } + + # Access logs — S3 (audit trail) + logging_config { + bucket = "${var.logs_bucket_id}.s3.amazonaws.com" + prefix = "cloudfront/" + include_cookies = false + } + + tags = { + Name = "${var.name_prefix}-cf-app" + } + + depends_on = [aws_acm_certificate_validation.this] +} + +################################################################################ +# Route53 Records +################################################################################ + +# App subdomain → CloudFront +resource "aws_route53_record" "app" { + zone_id = var.route53_zone_id + name = "${var.app_subdomain}.${var.domain_name}" + type = "A" + + alias { + name = aws_cloudfront_distribution.app.domain_name + zone_id = aws_cloudfront_distribution.app.hosted_zone_id + evaluate_target_health = false + } +} + +# API subdomain → ALB +resource "aws_route53_record" "api" { + zone_id = var.route53_zone_id + name = "${var.api_subdomain}.${var.domain_name}" + type = "A" + + alias { + name = var.alb_dns_name + zone_id = var.alb_zone_id + evaluate_target_health = true + } +} + +# AAAA (IPv6) records +resource "aws_route53_record" "app_aaaa" { + zone_id = var.route53_zone_id + name = "${var.app_subdomain}.${var.domain_name}" + type = "AAAA" + + alias { + name = aws_cloudfront_distribution.app.domain_name + zone_id = aws_cloudfront_distribution.app.hosted_zone_id + evaluate_target_health = false + } +} diff --git a/infra/terraform/modules/edge/outputs.tf b/infra/terraform/modules/edge/outputs.tf new file mode 100644 index 0000000..fcd6267 --- /dev/null +++ b/infra/terraform/modules/edge/outputs.tf @@ -0,0 +1,29 @@ +output "cloudfront_domain_name" { + description = "CloudFront distribution domain name." + value = aws_cloudfront_distribution.app.domain_name +} + +output "cloudfront_distribution_id" { + description = "CloudFront distribution ID (for cache invalidations)." + value = aws_cloudfront_distribution.app.id +} + +output "cloudfront_hosted_zone_id" { + description = "CloudFront hosted zone ID." + value = aws_cloudfront_distribution.app.hosted_zone_id +} + +output "acm_certificate_arn" { + description = "ACM certificate ARN (us-east-1)." + value = aws_acm_certificate.this.arn +} + +output "app_url" { + description = "HTTPS URL for the investigator dashboard." + value = "https://${var.app_subdomain}.${var.domain_name}" +} + +output "api_url" { + description = "HTTPS URL for the API." + value = "https://${var.api_subdomain}.${var.domain_name}" +} diff --git a/infra/terraform/modules/edge/variables.tf b/infra/terraform/modules/edge/variables.tf new file mode 100644 index 0000000..3ff4512 --- /dev/null +++ b/infra/terraform/modules/edge/variables.tf @@ -0,0 +1,12 @@ +variable "name_prefix" { type = string } +variable "environment" { type = string } +variable "domain_name" { type = string } +variable "api_subdomain" { type = string; default = "api" } +variable "app_subdomain" { type = string; default = "app" } +variable "route53_zone_id" { type = string } +variable "alb_dns_name" { type = string } +variable "alb_zone_id" { type = string } +variable "static_bucket_domain" { type = string } +variable "static_bucket_id" { type = string } +variable "logs_bucket_id" { type = string } +variable "waf_web_acl_arn" { type = string } diff --git a/infra/terraform/modules/networking/main.tf b/infra/terraform/modules/networking/main.tf new file mode 100644 index 0000000..849dfb8 --- /dev/null +++ b/infra/terraform/modules/networking/main.tf @@ -0,0 +1,522 @@ +################################################################################ +# modules/networking/main.tf +# +# Zero Trust network design: +# - Three subnet tiers per AZ: public / private / data +# - Only the public tier has a route to the Internet Gateway (ALB + NAT EIP) +# - Private tier routes internet-bound traffic through NAT Gateway +# - Data tier has NO route to the internet at all +# - VPC endpoints eliminate public AWS API routes (SOC 2 / Zero Trust) +# - NACLs default-deny; allow-list only required traffic +################################################################################ + +# --------------------------------------------------------------------------- +# VPC +# --------------------------------------------------------------------------- +resource "aws_vpc" "this" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true # required for RDS endpoint resolution inside VPC + + tags = { + Name = "${var.name_prefix}-vpc" + } +} + +# --------------------------------------------------------------------------- +# Internet Gateway (public subnets only) +# --------------------------------------------------------------------------- +resource "aws_internet_gateway" "this" { + vpc_id = aws_vpc.this.id + + tags = { + Name = "${var.name_prefix}-igw" + } +} + +# --------------------------------------------------------------------------- +# Subnets — three tiers across each AZ +# +# CIDR strategy (per AZ index i, 0-based): +# public = 10.x.(i*3+0).0/24 e.g. 10.0.0.0/24, 10.0.3.0/24, 10.0.6.0/24 +# private = 10.x.(i*3+1).0/24 e.g. 10.0.1.0/24, 10.0.4.0/24, 10.0.7.0/24 +# data = 10.x.(i*3+2).0/24 e.g. 10.0.2.0/24, 10.0.5.0/24, 10.0.8.0/24 +# --------------------------------------------------------------------------- + +locals { + # Extract the second octet from the VPC CIDR (e.g. "10.0.0.0/16" → "0") + vpc_second_octet = split(".", var.vpc_cidr)[1] +} + +resource "aws_subnet" "public" { + count = var.az_count + vpc_id = aws_vpc.this.id + cidr_block = "10.${local.vpc_second_octet}.${count.index * 3}.0/24" + availability_zone = var.azs[count.index] + + # Do NOT auto-assign public IPs — ALB ENIs get EIPs; we do not want random + # public IPs on ECS tasks (Zero Trust: tasks live in private subnets only). + map_public_ip_on_launch = false + + tags = { + Name = "${var.name_prefix}-public-${var.azs[count.index]}" + Tier = "public" + } +} + +resource "aws_subnet" "private" { + count = var.az_count + vpc_id = aws_vpc.this.id + cidr_block = "10.${local.vpc_second_octet}.${count.index * 3 + 1}.0/24" + availability_zone = var.azs[count.index] + + map_public_ip_on_launch = false + + tags = { + Name = "${var.name_prefix}-private-${var.azs[count.index]}" + Tier = "private" + } +} + +resource "aws_subnet" "data" { + count = var.az_count + vpc_id = aws_vpc.this.id + cidr_block = "10.${local.vpc_second_octet}.${count.index * 3 + 2}.0/24" + availability_zone = var.azs[count.index] + + map_public_ip_on_launch = false + + tags = { + Name = "${var.name_prefix}-data-${var.azs[count.index]}" + Tier = "data" + } +} + +# --------------------------------------------------------------------------- +# NAT Gateways — one per AZ for high availability (prod) or one shared (dev) +# The count is controlled by var.az_count: for prod all AZs get a NAT GW; +# for dev a single NAT GW suffices (cheaper). +# --------------------------------------------------------------------------- +resource "aws_eip" "nat" { + count = var.az_count + domain = "vpc" + + tags = { + Name = "${var.name_prefix}-nat-eip-${count.index}" + } +} + +resource "aws_nat_gateway" "this" { + count = var.az_count + allocation_id = aws_eip.nat[count.index].id + subnet_id = aws_subnet.public[count.index].id + + tags = { + Name = "${var.name_prefix}-natgw-${var.azs[count.index]}" + } + + depends_on = [aws_internet_gateway.this] +} + +# --------------------------------------------------------------------------- +# Route tables +# --------------------------------------------------------------------------- + +# Public — default route to IGW +resource "aws_route_table" "public" { + vpc_id = aws_vpc.this.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this.id + } + + tags = { + Name = "${var.name_prefix}-rt-public" + } +} + +resource "aws_route_table_association" "public" { + count = var.az_count + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +# Private — default route to NAT GW (per-AZ for resilience) +resource "aws_route_table" "private" { + count = var.az_count + vpc_id = aws_vpc.this.id + + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.this[count.index].id + } + + tags = { + Name = "${var.name_prefix}-rt-private-${var.azs[count.index]}" + } +} + +resource "aws_route_table_association" "private" { + count = var.az_count + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private[count.index].id +} + +# Data — NO default route (no internet access for DB/cache tier) +resource "aws_route_table" "data" { + vpc_id = aws_vpc.this.id + + # Intentionally no default route — data tier is fully isolated. + + tags = { + Name = "${var.name_prefix}-rt-data" + } +} + +resource "aws_route_table_association" "data" { + count = var.az_count + subnet_id = aws_subnet.data[count.index].id + route_table_id = aws_route_table.data.id +} + +# --------------------------------------------------------------------------- +# VPC Endpoints — replace public AWS API routes with private connections. +# Zero Trust: traffic to AWS services never leaves the AWS network. +# Required services: S3 (Gateway), ECR, CloudWatch Logs, Secrets Manager, KMS. +# --------------------------------------------------------------------------- + +# S3 Gateway endpoint (free, no ENI needed) +resource "aws_vpc_endpoint" "s3" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.aws_region}.s3" + vpc_endpoint_type = "Gateway" + + route_table_ids = concat( + [aws_route_table.public.id], + aws_route_table.private[*].id, + [aws_route_table.data.id] + ) + + tags = { + Name = "${var.name_prefix}-vpce-s3" + } +} + +# Interface endpoints — require ENIs in private subnets +resource "aws_vpc_endpoint" "ecr_api" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.aws_region}.ecr.api" + vpc_endpoint_type = "Interface" + subnet_ids = aws_subnet.private[*].id + security_group_ids = [aws_security_group.vpce.id] + private_dns_enabled = true + + tags = { + Name = "${var.name_prefix}-vpce-ecr-api" + } +} + +resource "aws_vpc_endpoint" "ecr_dkr" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.aws_region}.ecr.dkr" + vpc_endpoint_type = "Interface" + subnet_ids = aws_subnet.private[*].id + security_group_ids = [aws_security_group.vpce.id] + private_dns_enabled = true + + tags = { + Name = "${var.name_prefix}-vpce-ecr-dkr" + } +} + +resource "aws_vpc_endpoint" "logs" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.aws_region}.logs" + vpc_endpoint_type = "Interface" + subnet_ids = aws_subnet.private[*].id + security_group_ids = [aws_security_group.vpce.id] + private_dns_enabled = true + + tags = { + Name = "${var.name_prefix}-vpce-logs" + } +} + +resource "aws_vpc_endpoint" "secretsmanager" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.aws_region}.secretsmanager" + vpc_endpoint_type = "Interface" + subnet_ids = aws_subnet.private[*].id + security_group_ids = [aws_security_group.vpce.id] + private_dns_enabled = true + + tags = { + Name = "${var.name_prefix}-vpce-secretsmanager" + } +} + +resource "aws_vpc_endpoint" "kms" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.aws_region}.kms" + vpc_endpoint_type = "Interface" + subnet_ids = aws_subnet.private[*].id + security_group_ids = [aws_security_group.vpce.id] + private_dns_enabled = true + + tags = { + Name = "${var.name_prefix}-vpce-kms" + } +} + +resource "aws_vpc_endpoint" "ssm" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.aws_region}.ssm" + vpc_endpoint_type = "Interface" + subnet_ids = aws_subnet.private[*].id + security_group_ids = [aws_security_group.vpce.id] + private_dns_enabled = true + + tags = { + Name = "${var.name_prefix}-vpce-ssm" + } +} + +# --------------------------------------------------------------------------- +# Security Group for VPC Endpoints — allow HTTPS from VPC CIDR only +# --------------------------------------------------------------------------- +resource "aws_security_group" "vpce" { + name = "${var.name_prefix}-vpce-sg" + description = "Allow HTTPS from VPC to interface VPC endpoints" + vpc_id = aws_vpc.this.id + + ingress { + description = "HTTPS from VPC" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [var.vpc_cidr] + } + + egress { + description = "Allow all outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.name_prefix}-vpce-sg" + } +} + +# --------------------------------------------------------------------------- +# Placeholder ALB Security Group (referenced in security module to avoid cycle) +# The security module will extend rules; we create the SG here to own lifecycle. +# --------------------------------------------------------------------------- +resource "aws_security_group" "alb" { + name = "${var.name_prefix}-alb-sg" + description = "ALB — internet-facing HTTPS (443) only" + vpc_id = aws_vpc.this.id + + ingress { + description = "HTTPS from internet" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "HTTP redirect from internet" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "All outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.name_prefix}-alb-sg" + } +} + +# --------------------------------------------------------------------------- +# Network ACLs — default-deny posture +# +# HIPAA §164.312(e)(1): implement technical security measures to guard against +# unauthorized access to PHI transmitted over networks. +# +# Default VPC NACL allows all traffic; we replace it with explicit allow-lists. +# --------------------------------------------------------------------------- + +# Public NACL +resource "aws_network_acl" "public" { + vpc_id = aws_vpc.this.id + subnet_ids = aws_subnet.public[*].id + + # Inbound: allow HTTPS (443) and HTTP (80) from internet, ephemeral return traffic + ingress { + rule_no = 100 + action = "allow" + protocol = "tcp" + cidr_block = "0.0.0.0/0" + from_port = 443 + to_port = 443 + } + ingress { + rule_no = 110 + action = "allow" + protocol = "tcp" + cidr_block = "0.0.0.0/0" + from_port = 80 + to_port = 80 + } + ingress { + rule_no = 200 + action = "allow" + protocol = "tcp" + cidr_block = "0.0.0.0/0" + from_port = 1024 + to_port = 65535 + } + # Deny all other inbound (implicit; add explicit deny at 32766) + ingress { + rule_no = 32766 + action = "deny" + protocol = "-1" + cidr_block = "0.0.0.0/0" + from_port = 0 + to_port = 0 + } + + # Outbound: allow all (NAT GW, VPC internal) + egress { + rule_no = 100 + action = "allow" + protocol = "-1" + cidr_block = "0.0.0.0/0" + from_port = 0 + to_port = 0 + } + + tags = { + Name = "${var.name_prefix}-nacl-public" + } +} + +# Private NACL — allow traffic from/to VPC CIDR, ephemeral TCP, and HTTPS out +resource "aws_network_acl" "private" { + vpc_id = aws_vpc.this.id + subnet_ids = aws_subnet.private[*].id + + ingress { + rule_no = 100 + action = "allow" + protocol = "tcp" + cidr_block = var.vpc_cidr + from_port = 0 + to_port = 65535 + } + # Ephemeral return traffic from NAT GW (internet responses) + ingress { + rule_no = 200 + action = "allow" + protocol = "tcp" + cidr_block = "0.0.0.0/0" + from_port = 1024 + to_port = 65535 + } + ingress { + rule_no = 32766 + action = "deny" + protocol = "-1" + cidr_block = "0.0.0.0/0" + from_port = 0 + to_port = 0 + } + + egress { + rule_no = 100 + action = "allow" + protocol = "-1" + cidr_block = "0.0.0.0/0" + from_port = 0 + to_port = 0 + } + + tags = { + Name = "${var.name_prefix}-nacl-private" + } +} + +# Data NACL — allow only from private subnets (DB ports), deny everything else +resource "aws_network_acl" "data" { + vpc_id = aws_vpc.this.id + subnet_ids = aws_subnet.data[*].id + + # PostgreSQL from private subnet range + ingress { + rule_no = 100 + action = "allow" + protocol = "tcp" + cidr_block = var.vpc_cidr # tightened further by SG rules in security module + from_port = 5432 + to_port = 5432 + } + # Redis from private subnet range + ingress { + rule_no = 110 + action = "allow" + protocol = "tcp" + cidr_block = var.vpc_cidr + from_port = 6379 + to_port = 6379 + } + # Ephemeral return traffic + ingress { + rule_no = 200 + action = "allow" + protocol = "tcp" + cidr_block = var.vpc_cidr + from_port = 1024 + to_port = 65535 + } + ingress { + rule_no = 32766 + action = "deny" + protocol = "-1" + cidr_block = "0.0.0.0/0" + from_port = 0 + to_port = 0 + } + + egress { + rule_no = 100 + action = "allow" + protocol = "tcp" + cidr_block = var.vpc_cidr + from_port = 1024 + to_port = 65535 + } + egress { + rule_no = 32766 + action = "deny" + protocol = "-1" + cidr_block = "0.0.0.0/0" + from_port = 0 + to_port = 0 + } + + tags = { + Name = "${var.name_prefix}-nacl-data" + } +} diff --git a/infra/terraform/modules/networking/outputs.tf b/infra/terraform/modules/networking/outputs.tf new file mode 100644 index 0000000..e08e529 --- /dev/null +++ b/infra/terraform/modules/networking/outputs.tf @@ -0,0 +1,54 @@ +output "vpc_id" { + description = "VPC ID." + value = aws_vpc.this.id +} + +output "vpc_cidr_block" { + description = "VPC CIDR block." + value = aws_vpc.this.cidr_block +} + +output "public_subnet_ids" { + description = "Public subnet IDs (one per AZ)." + value = aws_subnet.public[*].id +} + +output "private_subnet_ids" { + description = "Private subnet IDs (one per AZ)." + value = aws_subnet.private[*].id +} + +output "data_subnet_ids" { + description = "Data subnet IDs (one per AZ — no internet route)." + value = aws_subnet.data[*].id +} + +output "private_subnet_cidr_blocks" { + description = "CIDR blocks for private subnets." + value = aws_subnet.private[*].cidr_block +} + +output "data_subnet_cidr_blocks" { + description = "CIDR blocks for data subnets." + value = aws_subnet.data[*].cidr_block +} + +output "nat_gateway_ids" { + description = "NAT Gateway IDs." + value = aws_nat_gateway.this[*].id +} + +output "alb_sg_id" { + description = "ALB security group ID (created in networking to avoid circular deps)." + value = aws_security_group.alb.id +} + +output "vpce_sg_id" { + description = "VPC endpoints security group ID." + value = aws_security_group.vpce.id +} + +output "s3_vpc_endpoint_id" { + description = "S3 Gateway VPC endpoint ID." + value = aws_vpc_endpoint.s3.id +} diff --git a/infra/terraform/modules/networking/variables.tf b/infra/terraform/modules/networking/variables.tf new file mode 100644 index 0000000..738564f --- /dev/null +++ b/infra/terraform/modules/networking/variables.tf @@ -0,0 +1,29 @@ +variable "name_prefix" { + description = "Resource name prefix, e.g. rv-dev" + type = string +} + +variable "vpc_cidr" { + description = "VPC CIDR block." + type = string +} + +variable "az_count" { + description = "Number of AZs to spread subnets across." + type = number +} + +variable "azs" { + description = "List of AZ names (length must equal az_count)." + type = list(string) +} + +variable "environment" { + description = "Deployment environment label." + type = string +} + +variable "aws_region" { + description = "AWS region (used for VPC endpoint service names)." + type = string +} diff --git a/infra/terraform/modules/observability/main.tf b/infra/terraform/modules/observability/main.tf new file mode 100644 index 0000000..3f8e5ac --- /dev/null +++ b/infra/terraform/modules/observability/main.tf @@ -0,0 +1,504 @@ +################################################################################ +# modules/observability/main.tf +# +# Resources: +# - SNS alert topic (KMS-encrypted) +# - CloudWatch alarms (ECS, RDS, Redis, ALB, fraud-specific) +# - CloudWatch log metric filters (error rates, authentication events) +# - CloudWatch dashboard (operational overview) +# +# Compliance notes: +# SOC 2 CC7.2: system monitoring — alarms fire on anomalies, capacity, and +# errors. CloudWatch logs provide continuous monitoring of security events. +# HIPAA §164.308(a)(1): risk analysis — monitoring surfaces threats in real time. +# HIPAA §164.312(b): audit controls — metric filters detect failed logins, +# unauthorized access attempts, and data export events. +################################################################################ + +################################################################################ +# SNS Alert Topic (KMS-encrypted) +################################################################################ +resource "aws_sns_topic" "alerts" { + name = "${var.name_prefix}-alerts" + kms_master_key_id = var.kms_key_arn + + tags = { + Name = "${var.name_prefix}-alerts" + } +} + +resource "aws_sns_topic_subscription" "email" { + topic_arn = aws_sns_topic.alerts.arn + protocol = "email" + endpoint = var.alert_email +} + +################################################################################ +# ECS Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "api_cpu_high" { + alarm_name = "${var.name_prefix}-api-cpu-high" + alarm_description = "API service CPU utilization > 85% for 5 minutes" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "CPUUtilization" + namespace = "AWS/ECS" + period = 60 + statistic = "Average" + threshold = 85.0 + treat_missing_data = "notBreaching" + + dimensions = { + ClusterName = var.ecs_cluster_name + ServiceName = var.api_service_name + } + + alarm_actions = [aws_sns_topic.alerts.arn] + ok_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-api-cpu-alarm" } +} + +resource "aws_cloudwatch_metric_alarm" "api_memory_high" { + alarm_name = "${var.name_prefix}-api-memory-high" + alarm_description = "API service memory utilization > 90%" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "MemoryUtilization" + namespace = "AWS/ECS" + period = 60 + statistic = "Average" + threshold = 90.0 + treat_missing_data = "notBreaching" + + dimensions = { + ClusterName = var.ecs_cluster_name + ServiceName = var.api_service_name + } + + alarm_actions = [aws_sns_topic.alerts.arn] + ok_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-api-memory-alarm" } +} + +resource "aws_cloudwatch_metric_alarm" "api_task_count_low" { + alarm_name = "${var.name_prefix}-api-tasks-low" + alarm_description = "API running task count dropped below minimum (possible crash loop)" + comparison_operator = "LessThanThreshold" + evaluation_periods = 2 + metric_name = "RunningTaskCount" + namespace = "ECS/ContainerInsights" + period = 60 + statistic = "Average" + threshold = 1.0 + treat_missing_data = "breaching" + + dimensions = { + ClusterName = var.ecs_cluster_name + ServiceName = var.api_service_name + } + + alarm_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-api-tasks-alarm" } +} + +################################################################################ +# RDS Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "rds_cpu_high" { + alarm_name = "${var.name_prefix}-rds-cpu-high" + alarm_description = "RDS CPU utilization > 80%" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "CPUUtilization" + namespace = "AWS/RDS" + period = 60 + statistic = "Average" + threshold = 80.0 + treat_missing_data = "notBreaching" + + dimensions = { + DBInstanceIdentifier = var.rds_identifier + } + + alarm_actions = [aws_sns_topic.alerts.arn] + ok_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-rds-cpu-alarm" } +} + +resource "aws_cloudwatch_metric_alarm" "rds_free_storage_low" { + alarm_name = "${var.name_prefix}-rds-storage-low" + alarm_description = "RDS free storage < 10 GiB — risk of data loss" + comparison_operator = "LessThanThreshold" + evaluation_periods = 2 + metric_name = "FreeStorageSpace" + namespace = "AWS/RDS" + period = 300 + statistic = "Average" + threshold = 10737418240 # 10 GiB in bytes + treat_missing_data = "breaching" + + dimensions = { + DBInstanceIdentifier = var.rds_identifier + } + + alarm_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-rds-storage-alarm" } +} + +resource "aws_cloudwatch_metric_alarm" "rds_connections_high" { + alarm_name = "${var.name_prefix}-rds-connections-high" + alarm_description = "RDS connection count > 400 (nearing max)" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "DatabaseConnections" + namespace = "AWS/RDS" + period = 60 + statistic = "Average" + threshold = 400.0 + treat_missing_data = "notBreaching" + + dimensions = { + DBInstanceIdentifier = var.rds_identifier + } + + alarm_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-rds-conn-alarm" } +} + +resource "aws_cloudwatch_metric_alarm" "rds_replica_lag" { + alarm_name = "${var.name_prefix}-rds-replica-lag" + alarm_description = "RDS read replica lag > 30 seconds" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "ReplicaLag" + namespace = "AWS/RDS" + period = 60 + statistic = "Average" + threshold = 30.0 + treat_missing_data = "notBreaching" + + dimensions = { + DBInstanceIdentifier = "${var.rds_identifier}-replica" + } + + alarm_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-rds-replica-alarm" } +} + +################################################################################ +# ElastiCache Redis Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "redis_cpu_high" { + alarm_name = "${var.name_prefix}-redis-cpu-high" + alarm_description = "Redis engine CPU > 70%" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "EngineCPUUtilization" + namespace = "AWS/ElastiCache" + period = 60 + statistic = "Average" + threshold = 70.0 + treat_missing_data = "notBreaching" + + dimensions = { + ReplicationGroupId = var.redis_replication_group_id + } + + alarm_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-redis-cpu-alarm" } +} + +resource "aws_cloudwatch_metric_alarm" "redis_memory_high" { + alarm_name = "${var.name_prefix}-redis-memory-high" + alarm_description = "Redis database memory usage > 80%" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "DatabaseMemoryUsagePercentage" + namespace = "AWS/ElastiCache" + period = 60 + statistic = "Average" + threshold = 80.0 + treat_missing_data = "notBreaching" + + dimensions = { + ReplicationGroupId = var.redis_replication_group_id + } + + alarm_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-redis-mem-alarm" } +} + +################################################################################ +# ALB Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "alb_5xx_high" { + alarm_name = "${var.name_prefix}-alb-5xx-high" + alarm_description = "ALB HTTP 5xx error rate > 5%" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "HTTPCode_Target_5XX_Count" + namespace = "AWS/ApplicationELB" + period = 60 + statistic = "Sum" + threshold = 50.0 + treat_missing_data = "notBreaching" + + dimensions = { + LoadBalancer = var.alb_arn_suffix + } + + alarm_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-alb-5xx-alarm" } +} + +resource "aws_cloudwatch_metric_alarm" "alb_response_time" { + alarm_name = "${var.name_prefix}-alb-p99-latency" + alarm_description = "ALB P99 target response time > 5s" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "TargetResponseTime" + namespace = "AWS/ApplicationELB" + period = 60 + extended_statistic = "p99" + threshold = 5.0 + treat_missing_data = "notBreaching" + + dimensions = { + LoadBalancer = var.alb_arn_suffix + } + + alarm_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-alb-latency-alarm" } +} + +################################################################################ +# Log Metric Filters — security and fraud signals +# +# HIPAA §164.312(b): audit controls — detect and alert on suspicious patterns. +################################################################################ + +# Failed authentication attempts +resource "aws_cloudwatch_log_metric_filter" "failed_auth" { + name = "${var.name_prefix}-failed-auth" + log_group_name = var.api_log_group_name + pattern = "[timestamp, requestId, level=\"ERROR\", ..., message=\"*authentication*failed*\"]" + + metric_transformation { + name = "FailedAuthCount" + namespace = "RayVerify/${var.environment}" + value = "1" + } +} + +resource "aws_cloudwatch_metric_alarm" "failed_auth_high" { + alarm_name = "${var.name_prefix}-failed-auth-spike" + alarm_description = "Failed authentication rate spike — possible credential stuffing" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "FailedAuthCount" + namespace = "RayVerify/${var.environment}" + period = 300 + statistic = "Sum" + threshold = 50.0 + treat_missing_data = "notBreaching" + + alarm_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-failed-auth-alarm" } +} + +# High fraud score detections +resource "aws_cloudwatch_log_metric_filter" "high_fraud_score" { + name = "${var.name_prefix}-high-fraud-score" + log_group_name = var.ml_log_group_name + pattern = "[..., score >= 90]" + + metric_transformation { + name = "HighFraudScoreCount" + namespace = "RayVerify/${var.environment}" + value = "1" + } +} + +resource "aws_cloudwatch_metric_alarm" "fraud_spike" { + alarm_name = "${var.name_prefix}-fraud-score-spike" + alarm_description = "Unusual spike in high fraud-score visits — investigate" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "HighFraudScoreCount" + namespace = "RayVerify/${var.environment}" + period = 3600 # 1 hour + statistic = "Sum" + threshold = 100.0 + treat_missing_data = "notBreaching" + + alarm_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-fraud-spike-alarm" } +} + +# Application errors +resource "aws_cloudwatch_log_metric_filter" "app_errors" { + name = "${var.name_prefix}-app-errors" + log_group_name = var.api_log_group_name + pattern = "[timestamp, requestId, level=\"ERROR\", ...]" + + metric_transformation { + name = "AppErrorCount" + namespace = "RayVerify/${var.environment}" + value = "1" + } +} + +resource "aws_cloudwatch_metric_alarm" "app_error_high" { + alarm_name = "${var.name_prefix}-app-errors-high" + alarm_description = "Application error rate > 100/min" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "AppErrorCount" + namespace = "RayVerify/${var.environment}" + period = 60 + statistic = "Sum" + threshold = 100.0 + treat_missing_data = "notBreaching" + + alarm_actions = [aws_sns_topic.alerts.arn] + + tags = { Name = "${var.name_prefix}-app-errors-alarm" } +} + +################################################################################ +# CloudWatch Dashboard — Operational Overview +################################################################################ + +resource "aws_cloudwatch_dashboard" "main" { + dashboard_name = "${var.name_prefix}-operations" + + dashboard_body = jsonencode({ + widgets = [ + # Row 1: ECS CPU utilization + { + type = "metric" + x = 0; y = 0; width = 8; height = 6 + properties = { + title = "ECS CPU Utilization" + region = var.aws_region + view = "timeSeries" + metrics = [ + ["AWS/ECS", "CPUUtilization", "ClusterName", var.ecs_cluster_name, "ServiceName", var.api_service_name, { label = "API" }], + ["AWS/ECS", "CPUUtilization", "ClusterName", var.ecs_cluster_name, "ServiceName", var.worker_service_name, { label = "Worker" }], + ["AWS/ECS", "CPUUtilization", "ClusterName", var.ecs_cluster_name, "ServiceName", var.ml_service_name, { label = "ML-Scoring" }] + ] + period = 60 + yAxis = { left = { min = 0, max = 100 } } + } + }, + # Row 1: ECS Memory utilization + { + type = "metric" + x = 8; y = 0; width = 8; height = 6 + properties = { + title = "ECS Memory Utilization" + region = var.aws_region + view = "timeSeries" + metrics = [ + ["AWS/ECS", "MemoryUtilization", "ClusterName", var.ecs_cluster_name, "ServiceName", var.api_service_name, { label = "API" }], + ["AWS/ECS", "MemoryUtilization", "ClusterName", var.ecs_cluster_name, "ServiceName", var.worker_service_name, { label = "Worker" }], + ["AWS/ECS", "MemoryUtilization", "ClusterName", var.ecs_cluster_name, "ServiceName", var.ml_service_name, { label = "ML-Scoring" }] + ] + period = 60 + yAxis = { left = { min = 0, max = 100 } } + } + }, + # Row 1: ALB request count + error rates + { + type = "metric" + x = 16; y = 0; width = 8; height = 6 + properties = { + title = "ALB Requests & Errors" + region = var.aws_region + view = "timeSeries" + metrics = [ + ["AWS/ApplicationELB", "RequestCount", "LoadBalancer", var.alb_arn_suffix, { label = "Requests", stat = "Sum" }], + ["AWS/ApplicationELB", "HTTPCode_Target_5XX_Count", "LoadBalancer", var.alb_arn_suffix, { label = "5xx Errors", stat = "Sum", color = "#d62728" }], + ["AWS/ApplicationELB", "HTTPCode_Target_4XX_Count", "LoadBalancer", var.alb_arn_suffix, { label = "4xx Errors", stat = "Sum", color = "#ff7f0e" }] + ] + period = 60 + } + }, + # Row 2: RDS + { + type = "metric" + x = 0; y = 6; width = 12; height = 6 + properties = { + title = "RDS PostgreSQL" + region = var.aws_region + view = "timeSeries" + metrics = [ + ["AWS/RDS", "CPUUtilization", "DBInstanceIdentifier", var.rds_identifier, { label = "CPU %" }], + ["AWS/RDS", "DatabaseConnections", "DBInstanceIdentifier", var.rds_identifier, { label = "Connections", yAxis = "right" }] + ] + period = 60 + } + }, + # Row 2: Redis + { + type = "metric" + x = 12; y = 6; width = 12; height = 6 + properties = { + title = "ElastiCache Redis" + region = var.aws_region + view = "timeSeries" + metrics = [ + ["AWS/ElastiCache", "EngineCPUUtilization", "ReplicationGroupId", var.redis_replication_group_id, { label = "CPU %" }], + ["AWS/ElastiCache", "DatabaseMemoryUsagePercentage", "ReplicationGroupId", var.redis_replication_group_id, { label = "Memory %", yAxis = "right" }] + ] + period = 60 + } + }, + # Row 3: Security / Fraud signals + { + type = "metric" + x = 0; y = 12; width = 12; height = 6 + properties = { + title = "Security Events" + region = var.aws_region + view = "timeSeries" + metrics = [ + ["RayVerify/${var.environment}", "FailedAuthCount", { label = "Failed Auth", stat = "Sum", color = "#d62728" }], + ["RayVerify/${var.environment}", "AppErrorCount", { label = "App Errors", stat = "Sum", color = "#ff7f0e" }] + ] + period = 300 + } + }, + { + type = "metric" + x = 12; y = 12; width = 12; height = 6 + properties = { + title = "Fraud Intelligence" + region = var.aws_region + view = "timeSeries" + metrics = [ + ["RayVerify/${var.environment}", "HighFraudScoreCount", { label = "High Fraud Score Visits", stat = "Sum", color = "#e377c2" }] + ] + period = 3600 + } + } + ] + }) +} diff --git a/infra/terraform/modules/observability/outputs.tf b/infra/terraform/modules/observability/outputs.tf new file mode 100644 index 0000000..91f8f54 --- /dev/null +++ b/infra/terraform/modules/observability/outputs.tf @@ -0,0 +1,14 @@ +output "sns_alert_topic_arn" { + description = "SNS alert topic ARN." + value = aws_sns_topic.alerts.arn +} + +output "sns_alert_topic_name" { + description = "SNS alert topic name." + value = aws_sns_topic.alerts.name +} + +output "dashboard_name" { + description = "CloudWatch dashboard name." + value = aws_cloudwatch_dashboard.main.dashboard_name +} diff --git a/infra/terraform/modules/observability/variables.tf b/infra/terraform/modules/observability/variables.tf new file mode 100644 index 0000000..97ac992 --- /dev/null +++ b/infra/terraform/modules/observability/variables.tf @@ -0,0 +1,21 @@ +variable "name_prefix" { type = string } +variable "environment" { type = string } +variable "aws_region" { type = string } +variable "alert_email" { + description = "Email address for CloudWatch alarm notifications." + type = string +} +variable "kms_key_arn" { + description = "KMS CMK ARN for SNS topic encryption." + type = string +} +variable "ecs_cluster_name" { type = string } +variable "api_service_name" { type = string } +variable "worker_service_name" { type = string } +variable "ml_service_name" { type = string } +variable "alb_arn_suffix" { type = string } +variable "rds_identifier" { type = string } +variable "redis_replication_group_id" { type = string } +variable "api_log_group_name" { type = string } +variable "worker_log_group_name" { type = string } +variable "ml_log_group_name" { type = string } diff --git a/infra/terraform/modules/security/main.tf b/infra/terraform/modules/security/main.tf new file mode 100644 index 0000000..9517cf6 --- /dev/null +++ b/infra/terraform/modules/security/main.tf @@ -0,0 +1,707 @@ +################################################################################ +# modules/security/main.tf +# +# Resources: +# - KMS CMKs (data / S3 / RDS dedicated aliases + per-tenant-capable data key) +# - Secrets Manager secrets (RDS, Redis, application) +# - IAM roles (ECS task execution, per-service task roles — least privilege) +# - WAFv2 Web ACL (OWASP managed rules + rate limit) +# - Security Groups (ECS tasks, RDS, Redis) +################################################################################ + +################################################################################ +# KMS Customer Managed Keys +# +# HIPAA §164.312(a)(2)(iv): encrypt and decrypt ePHI. +# SOC 2 CC6.1: logical access controls — envelope encryption with CMK ensures +# data is unreadable even if AWS storage layer is compromised. +# Rotation enabled: AWS rotates the key material annually automatically. +################################################################################ + +resource "aws_kms_key" "data" { + description = "${var.name_prefix} — primary data CMK (PHI / ePHI)" + deletion_window_in_days = 30 # 30-day safety window before permanent deletion + enable_key_rotation = true + + # Key policy: allow account-level IAM, deny direct root usage in non-break-glass scenarios + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EnableIAMUserPermissions" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${var.aws_account_id}:root" + } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "AllowCloudWatchLogs" + Effect = "Allow" + Principal = { + Service = "logs.${var.aws_region}.amazonaws.com" + } + Action = [ + "kms:Encrypt*", + "kms:Decrypt*", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:Describe*" + ] + Resource = "*" + Condition = { + ArnLike = { + "kms:EncryptionContext:aws:logs:arn" = "arn:aws:logs:${var.aws_region}:${var.aws_account_id}:*" + } + } + } + ] + }) + + tags = { + Name = "${var.name_prefix}-data-cmk" + Purpose = "phi-encryption" + } +} + +resource "aws_kms_alias" "data" { + name = "alias/${var.name_prefix}-data" + target_key_id = aws_kms_key.data.key_id +} + +# Per-tenant data key alias — supports envelope encryption where each tenant +# gets a distinct data key generated from this CMK (multi-tenant PHI isolation). +resource "aws_kms_alias" "data_tenant" { + name = "alias/${var.name_prefix}-tenant-data" + target_key_id = aws_kms_key.data.key_id +} + +# S3-specific CMK (separate key for Object Lock / WORM evidence bucket) +resource "aws_kms_key" "s3" { + description = "${var.name_prefix} — S3 evidence CMK" + deletion_window_in_days = 30 + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EnableIAMUserPermissions" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${var.aws_account_id}:root" + } + Action = "kms:*" + Resource = "*" + } + ] + }) + + tags = { + Name = "${var.name_prefix}-s3-cmk" + Purpose = "s3-evidence-encryption" + } +} + +resource "aws_kms_alias" "s3" { + name = "alias/${var.name_prefix}-s3" + target_key_id = aws_kms_key.s3.key_id +} + +################################################################################ +# Secrets Manager +# +# HIPAA §164.312(a)(2)(i): unique user identification / password management. +# Secrets are encrypted by the data CMK, not the default AWS-managed key. +# ECS tasks retrieve secrets via the secretsmanager VPC endpoint. +################################################################################ + +resource "aws_secretsmanager_secret" "rds" { + name = "${var.name_prefix}/rds/credentials" + description = "RDS PostgreSQL master credentials" + kms_key_id = aws_kms_key.data.arn + recovery_window_in_days = 30 + + tags = { + Name = "${var.name_prefix}-rds-secret" + } +} + +resource "aws_secretsmanager_secret_version" "rds" { + secret_id = aws_secretsmanager_secret.rds.id + # Placeholder — real value injected by DB module or first-time rotate operation. + secret_string = jsonencode({ + username = "rayverify_admin" + password = "ROTATE_IMMEDIATELY" + engine = "postgres" + port = 5432 + dbname = "rayverify" + }) + + lifecycle { + # Prevent Terraform from overwriting a password that was rotated outside TF + ignore_changes = [secret_string] + } +} + +resource "aws_secretsmanager_secret" "redis" { + name = "${var.name_prefix}/redis/auth" + description = "ElastiCache Redis AUTH token" + kms_key_id = aws_kms_key.data.arn + recovery_window_in_days = 30 + + tags = { + Name = "${var.name_prefix}-redis-secret" + } +} + +resource "aws_secretsmanager_secret_version" "redis" { + secret_id = aws_secretsmanager_secret.redis.id + secret_string = jsonencode({ + auth_token = "ROTATE_IMMEDIATELY" + }) + + lifecycle { + ignore_changes = [secret_string] + } +} + +resource "aws_secretsmanager_secret" "app" { + name = "${var.name_prefix}/app/secrets" + description = "Application secrets: JWT keys, encryption salt, etc." + kms_key_id = aws_kms_key.data.arn + recovery_window_in_days = 30 + + tags = { + Name = "${var.name_prefix}-app-secret" + } +} + +resource "aws_secretsmanager_secret_version" "app" { + secret_id = aws_secretsmanager_secret.app.id + secret_string = jsonencode({ + jwt_secret = "ROTATE_IMMEDIATELY" + jwt_refresh_secret = "ROTATE_IMMEDIATELY" + encryption_key = "ROTATE_IMMEDIATELY" + mfa_issuer = "RayVerify" + }) + + lifecycle { + ignore_changes = [secret_string] + } +} + +################################################################################ +# IAM — ECS Task Execution Role +# +# Grants ECS agent (not the application) permission to: +# - Pull images from ECR +# - Write logs to CloudWatch +# - Retrieve secrets from Secrets Manager (for container environment injection) +# Least-privilege: no access to S3, RDS, or other data-plane resources. +################################################################################ + +data "aws_iam_policy_document" "ecs_task_assume" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "ecs_task_execution" { + name = "${var.name_prefix}-ecs-task-execution" + assume_role_policy = data.aws_iam_policy_document.ecs_task_assume.json + + tags = { + Name = "${var.name_prefix}-ecs-task-execution" + } +} + +resource "aws_iam_role_policy_attachment" "ecs_task_execution_managed" { + role = aws_iam_role.ecs_task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role_policy" "ecs_task_execution_secrets" { + name = "${var.name_prefix}-ecs-execution-secrets" + role = aws_iam_role.ecs_task_execution.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "GetSecrets" + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = [ + aws_secretsmanager_secret.rds.arn, + aws_secretsmanager_secret.redis.arn, + aws_secretsmanager_secret.app.arn + ] + }, + { + Sid = "DecryptSecrets" + Effect = "Allow" + Action = [ + "kms:Decrypt", + "kms:GenerateDataKey" + ] + Resource = [aws_kms_key.data.arn] + } + ] + }) +} + +################################################################################ +# IAM — Per-service ECS Task Roles (least-privilege data-plane access) +################################################################################ + +# --- API task role --- +resource "aws_iam_role" "api_task" { + name = "${var.name_prefix}-api-task" + assume_role_policy = data.aws_iam_policy_document.ecs_task_assume.json + + tags = { Name = "${var.name_prefix}-api-task" } +} + +resource "aws_iam_role_policy" "api_task_policy" { + name = "${var.name_prefix}-api-task-policy" + role = aws_iam_role.api_task.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "S3EvidenceReadWrite" + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:GetObjectVersion" + ] + Resource = "arn:aws:s3:::${var.name_prefix}-evidence-*/*" + }, + { + Sid = "S3ReportsBucketReadWrite" + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:PutObject" + ] + Resource = "arn:aws:s3:::${var.name_prefix}-reports-*/*" + }, + { + Sid = "KMSDataPlane" + Effect = "Allow" + Action = [ + "kms:Decrypt", + "kms:GenerateDataKey", + "kms:DescribeKey" + ] + Resource = [aws_kms_key.data.arn, aws_kms_key.s3.arn] + }, + { + Sid = "CloudWatchMetrics" + Effect = "Allow" + Action = ["cloudwatch:PutMetricData"] + Resource = "*" + } + ] + }) +} + +# --- Worker task role --- +resource "aws_iam_role" "worker_task" { + name = "${var.name_prefix}-worker-task" + assume_role_policy = data.aws_iam_policy_document.ecs_task_assume.json + + tags = { Name = "${var.name_prefix}-worker-task" } +} + +resource "aws_iam_role_policy" "worker_task_policy" { + name = "${var.name_prefix}-worker-task-policy" + role = aws_iam_role.worker_task.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "S3EvidenceRead" + Effect = "Allow" + Action = ["s3:GetObject", "s3:GetObjectVersion"] + Resource = "arn:aws:s3:::${var.name_prefix}-evidence-*/*" + }, + { + Sid = "S3ReportsWrite" + Effect = "Allow" + Action = ["s3:PutObject"] + Resource = "arn:aws:s3:::${var.name_prefix}-reports-*/*" + }, + { + Sid = "KMSDataPlane" + Effect = "Allow" + Action = ["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"] + Resource = [aws_kms_key.data.arn, aws_kms_key.s3.arn] + }, + { + Sid = "CloudWatchMetrics" + Effect = "Allow" + Action = ["cloudwatch:PutMetricData"] + Resource = "*" + } + ] + }) +} + +# --- ML-scoring task role --- +resource "aws_iam_role" "ml_task" { + name = "${var.name_prefix}-ml-task" + assume_role_policy = data.aws_iam_policy_document.ecs_task_assume.json + + tags = { Name = "${var.name_prefix}-ml-task" } +} + +resource "aws_iam_role_policy" "ml_task_policy" { + name = "${var.name_prefix}-ml-task-policy" + role = aws_iam_role.ml_task.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + # ML models stored in a dedicated prefix in the reports bucket + Sid = "S3ModelRead" + Effect = "Allow" + Action = ["s3:GetObject"] + Resource = "arn:aws:s3:::${var.name_prefix}-reports-*/models/*" + }, + { + Sid = "KMSDecrypt" + Effect = "Allow" + Action = ["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"] + Resource = [aws_kms_key.data.arn] + }, + { + Sid = "CloudWatchMetrics" + Effect = "Allow" + Action = ["cloudwatch:PutMetricData"] + Resource = "*" + } + ] + }) +} + +################################################################################ +# Security Groups — ECS tasks, RDS, Redis +################################################################################ + +# ECS tasks SG — inbound only from ALB on container port 3000/8000 +resource "aws_security_group" "ecs_tasks" { + name = "${var.name_prefix}-ecs-tasks-sg" + description = "ECS tasks — ingress from ALB only" + vpc_id = var.vpc_id + + ingress { + description = "API traffic from ALB" + from_port = 3000 + to_port = 3000 + protocol = "tcp" + security_groups = [var.alb_sg_id] + } + + ingress { + description = "ML-scoring traffic from ALB" + from_port = 8000 + to_port = 8000 + protocol = "tcp" + security_groups = [var.alb_sg_id] + } + + egress { + description = "All outbound (to RDS, Redis, AWS APIs via VPC endpoints)" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.name_prefix}-ecs-tasks-sg" + } +} + +# RDS SG — inbound only from ECS tasks SG on 5432 +resource "aws_security_group" "db" { + name = "${var.name_prefix}-db-sg" + description = "RDS PostgreSQL — ingress from ECS tasks only" + vpc_id = var.vpc_id + + ingress { + description = "PostgreSQL from ECS tasks" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.ecs_tasks.id] + } + + egress { + # RDS needs to reach KMS endpoint and internal AWS APIs (PITR, backups) + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + description = "HTTPS to AWS APIs" + } + + tags = { + Name = "${var.name_prefix}-db-sg" + } +} + +# ElastiCache Redis SG — inbound only from ECS tasks SG on 6379 +resource "aws_security_group" "redis" { + name = "${var.name_prefix}-redis-sg" + description = "ElastiCache Redis — ingress from ECS tasks only" + vpc_id = var.vpc_id + + ingress { + description = "Redis from ECS tasks" + from_port = 6379 + to_port = 6379 + protocol = "tcp" + security_groups = [aws_security_group.ecs_tasks.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "All outbound" + } + + tags = { + Name = "${var.name_prefix}-redis-sg" + } +} + +################################################################################ +# WAFv2 Web ACL +# +# SOC 2 CC6.6: protection of assets from threats outside entity boundaries. +# Managed rule groups cover OWASP Top 10, known bad inputs, and AWS threat intel. +# Rate limiting prevents credential stuffing and DDoS at the application layer. +# +# Note: A second WAF ACL (scope=CLOUDFRONT) is created for the edge module. +# CloudFront WAF ACLs must be in us-east-1; this ALB ACL is regional. +################################################################################ + +resource "aws_wafv2_web_acl" "alb" { + name = "${var.name_prefix}-alb-waf" + scope = "REGIONAL" + + default_action { + allow {} + } + + # AWS Managed Rules — AWSManagedRulesCommonRuleSet (OWASP Top 10) + rule { + name = "AWSManagedRulesCommonRuleSet" + priority = 10 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + vendor_name = "AWS" + name = "AWSManagedRulesCommonRuleSet" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-waf-common" + sampled_requests_enabled = true + } + } + + # AWS Managed Rules — Known Bad Inputs + rule { + name = "AWSManagedRulesKnownBadInputsRuleSet" + priority = 20 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + vendor_name = "AWS" + name = "AWSManagedRulesKnownBadInputsRuleSet" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-waf-bad-inputs" + sampled_requests_enabled = true + } + } + + # AWS Managed Rules — SQL Injection (critical for PHI database protection) + rule { + name = "AWSManagedRulesSQLiRuleSet" + priority = 30 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + vendor_name = "AWS" + name = "AWSManagedRulesSQLiRuleSet" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-waf-sqli" + sampled_requests_enabled = true + } + } + + # AWS Managed Rules — Amazon IP Reputation List (AWS threat intel) + rule { + name = "AWSManagedRulesAmazonIpReputationList" + priority = 40 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + vendor_name = "AWS" + name = "AWSManagedRulesAmazonIpReputationList" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-waf-iprep" + sampled_requests_enabled = true + } + } + + # Rate-based rule — block IPs exceeding var.waf_rate_limit req/5min + # Prevents credential stuffing and automated scraping of PHI + rule { + name = "RateLimitRule" + priority = 50 + + action { + block {} + } + + statement { + rate_based_statement { + limit = var.waf_rate_limit + aggregate_key_type = "IP" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-waf-ratelimit" + sampled_requests_enabled = true + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-waf-alb" + sampled_requests_enabled = true + } + + tags = { + Name = "${var.name_prefix}-alb-waf" + } +} + +# CloudFront WAF ACL — must be REGIONAL with scope CLOUDFRONT managed in us-east-1. +# We create it here in the regional provider; edge module attaches it. +# NOTE: In a real deployment this resource would use the aws.us_east_1 provider alias. +# For simplicity we keep it in the same provider block; adjust if deploying to non-us-east-1. +resource "aws_wafv2_web_acl" "cloudfront" { + name = "${var.name_prefix}-cf-waf" + scope = "CLOUDFRONT" + + default_action { + allow {} + } + + rule { + name = "AWSManagedRulesCommonRuleSet" + priority = 10 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + vendor_name = "AWS" + name = "AWSManagedRulesCommonRuleSet" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-cf-waf-common" + sampled_requests_enabled = true + } + } + + rule { + name = "CFRateLimitRule" + priority = 20 + + action { + block {} + } + + statement { + rate_based_statement { + limit = var.waf_rate_limit + aggregate_key_type = "IP" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-cf-waf-ratelimit" + sampled_requests_enabled = true + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-waf-cf" + sampled_requests_enabled = true + } + + tags = { + Name = "${var.name_prefix}-cf-waf" + } +} diff --git a/infra/terraform/modules/security/outputs.tf b/infra/terraform/modules/security/outputs.tf new file mode 100644 index 0000000..8d282f8 --- /dev/null +++ b/infra/terraform/modules/security/outputs.tf @@ -0,0 +1,84 @@ +output "kms_data_key_arn" { + description = "Primary data CMK ARN." + value = aws_kms_key.data.arn +} + +output "kms_data_key_id" { + description = "Primary data CMK key ID." + value = aws_kms_key.data.key_id +} + +output "kms_data_key_alias" { + description = "Primary data CMK alias name." + value = aws_kms_alias.data.name +} + +output "kms_s3_key_arn" { + description = "S3 evidence CMK ARN." + value = aws_kms_key.s3.arn +} + +output "rds_secret_arn" { + description = "Secrets Manager ARN for RDS credentials." + value = aws_secretsmanager_secret.rds.arn +} + +output "redis_secret_arn" { + description = "Secrets Manager ARN for Redis AUTH token." + value = aws_secretsmanager_secret.redis.arn +} + +output "app_secrets_arn" { + description = "Secrets Manager ARN for application secrets." + value = aws_secretsmanager_secret.app.arn +} + +output "ecs_task_execution_role_arn" { + description = "ECS task execution IAM role ARN." + value = aws_iam_role.ecs_task_execution.arn +} + +output "api_task_role_arn" { + description = "ECS task role ARN for the API service." + value = aws_iam_role.api_task.arn +} + +output "worker_task_role_arn" { + description = "ECS task role ARN for the worker service." + value = aws_iam_role.worker_task.arn +} + +output "ml_task_role_arn" { + description = "ECS task role ARN for the ml-scoring service." + value = aws_iam_role.ml_task.arn +} + +output "waf_web_acl_arn" { + description = "WAFv2 regional Web ACL ARN (attached to ALB)." + value = aws_wafv2_web_acl.alb.arn +} + +output "waf_cloudfront_acl_arn" { + description = "WAFv2 CLOUDFRONT-scoped Web ACL ARN (attached to CloudFront)." + value = aws_wafv2_web_acl.cloudfront.arn +} + +output "alb_sg_id" { + description = "ALB security group ID (passed through from networking module)." + value = var.alb_sg_id +} + +output "ecs_tasks_sg_id" { + description = "ECS tasks security group ID." + value = aws_security_group.ecs_tasks.id +} + +output "db_sg_id" { + description = "RDS security group ID." + value = aws_security_group.db.id +} + +output "redis_sg_id" { + description = "ElastiCache Redis security group ID." + value = aws_security_group.redis.id +} diff --git a/infra/terraform/modules/security/variables.tf b/infra/terraform/modules/security/variables.tf new file mode 100644 index 0000000..b74e604 --- /dev/null +++ b/infra/terraform/modules/security/variables.tf @@ -0,0 +1,45 @@ +variable "name_prefix" { + description = "Resource name prefix." + type = string +} + +variable "environment" { + description = "Deployment environment." + type = string +} + +variable "vpc_id" { + description = "VPC ID." + type = string +} + +variable "aws_account_id" { + description = "AWS account ID." + type = string +} + +variable "aws_region" { + description = "AWS region." + type = string +} + +variable "waf_rate_limit" { + description = "Max requests per 5-min window per IP." + type = number + default = 2000 +} + +variable "alb_sg_id" { + description = "ALB security group ID (created in networking module)." + type = string +} + +variable "private_subnet_cidr_blocks" { + description = "CIDR blocks of private subnets (for SG ingress rules)." + type = list(string) +} + +variable "data_subnet_cidr_blocks" { + description = "CIDR blocks of data subnets." + type = list(string) +} diff --git a/infra/terraform/modules/storage/main.tf b/infra/terraform/modules/storage/main.tf new file mode 100644 index 0000000..f690071 --- /dev/null +++ b/infra/terraform/modules/storage/main.tf @@ -0,0 +1,379 @@ +################################################################################ +# modules/storage/main.tf +# +# Buckets: +# evidence — WORM (S3 Object Lock COMPLIANCE mode), SSE-KMS, versioning +# reports — SSE-KMS, versioning, lifecycle (expire old reports) +# static — SSE-KMS, public access blocked (CloudFront OAC only) +# logs — SSE-KMS, bucket access logs destination +# +# All buckets: +# - Block all public access (ACL + policy) +# - SSE-KMS (CMK, not default S3 key) +# - Versioning enabled +# - Enforce TLS via bucket policy deny on non-TLS requests +# +# Compliance notes: +# HIPAA §164.312(c)(1): integrity controls — Object Lock + versioning prevent +# modification or deletion of evidence records (tamper-evident audit trail). +# HIPAA §164.312(a)(2)(iv): SSE-KMS satisfies encryption-at-rest requirement. +# SOC 2 A1.2: availability — versioning + lifecycle protect against accidental +# deletion while managing storage cost. +# CMS EVV: WORM evidence storage satisfies program integrity record-keeping. +################################################################################ + +# Random suffix for globally-unique bucket names +resource "random_id" "bucket_suffix" { + byte_length = 4 +} + +locals { + suffix = random_id.bucket_suffix.hex +} + +################################################################################ +# Helper: reusable SSE-KMS server-side encryption config +################################################################################ + +# Enforce TLS bucket policy template (applied to each bucket) +data "aws_iam_policy_document" "deny_non_tls" { + for_each = toset(["evidence", "reports", "static", "logs"]) + + statement { + sid = "DenyNonTLS" + effect = "Deny" + principals { + type = "*" + identifiers = ["*"] + } + actions = ["s3:*"] + resources = [ + "arn:aws:s3:::${var.name_prefix}-${each.key}-${local.suffix}", + "arn:aws:s3:::${var.name_prefix}-${each.key}-${local.suffix}/*" + ] + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +################################################################################ +# Evidence Bucket — WORM / Object Lock (COMPLIANCE mode) +# +# Object Lock COMPLIANCE mode: no principal (including root / AWS Support) can +# delete or overwrite an object version within the retention period. +# This satisfies HIPAA tamper-evident evidence storage and CMS EVV requirements. +################################################################################ +resource "aws_s3_bucket" "evidence" { + bucket = "${var.name_prefix}-evidence-${local.suffix}" + + # Object Lock must be enabled at bucket creation time; cannot be added later + object_lock_enabled = true + + tags = { + Name = "${var.name_prefix}-evidence" + Purpose = "worm-evidence-hipaa" + } +} + +resource "aws_s3_bucket_versioning" "evidence" { + bucket = aws_s3_bucket.evidence.id + versioning_configuration { + status = "Enabled" # required for Object Lock + } +} + +resource "aws_s3_bucket_object_lock_configuration" "evidence" { + bucket = aws_s3_bucket.evidence.id + + rule { + default_retention { + mode = "COMPLIANCE" # immutable — cannot be overridden even by root + days = var.evidence_object_lock_days + } + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "evidence" { + bucket = aws_s3_bucket.evidence.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "aws:kms" + kms_master_key_id = var.kms_key_arn + } + bucket_key_enabled = true # reduces KMS API call cost for high-volume uploads + } +} + +resource "aws_s3_bucket_public_access_block" "evidence" { + bucket = aws_s3_bucket.evidence.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_policy" "evidence" { + bucket = aws_s3_bucket.evidence.id + policy = data.aws_iam_policy_document.deny_non_tls["evidence"].json +} + +resource "aws_s3_bucket_lifecycle_configuration" "evidence" { + bucket = aws_s3_bucket.evidence.id + + rule { + id = "transition-to-glacier" + status = "Enabled" + + transition { + days = 90 + storage_class = "GLACIER_IR" # immediate retrieval — evidence may be needed fast + } + + noncurrent_version_transition { + noncurrent_days = 30 + storage_class = "GLACIER_IR" + } + } +} + +################################################################################ +# Reports Bucket — PDF/Excel reports, ML models +################################################################################ +resource "aws_s3_bucket" "reports" { + bucket = "${var.name_prefix}-reports-${local.suffix}" + + tags = { + Name = "${var.name_prefix}-reports" + Purpose = "fraud-reports" + } +} + +resource "aws_s3_bucket_versioning" "reports" { + bucket = aws_s3_bucket.reports.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "reports" { + bucket = aws_s3_bucket.reports.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "aws:kms" + kms_master_key_id = var.kms_key_arn + } + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_public_access_block" "reports" { + bucket = aws_s3_bucket.reports.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_policy" "reports" { + bucket = aws_s3_bucket.reports.id + policy = data.aws_iam_policy_document.deny_non_tls["reports"].json +} + +resource "aws_s3_bucket_lifecycle_configuration" "reports" { + bucket = aws_s3_bucket.reports.id + + rule { + id = "expire-old-reports" + status = "Enabled" + + expiration { + days = 730 # 2 years; HIPAA requires 6 years → move to Glacier before expiry + } + + transition { + days = 365 + storage_class = "STANDARD_IA" + } + + noncurrent_version_expiration { + noncurrent_days = 90 + } + } +} + +################################################################################ +# Static Assets Bucket — Next.js frontend (CloudFront OAC origin) +################################################################################ +resource "aws_s3_bucket" "static" { + bucket = "${var.name_prefix}-static-${local.suffix}" + + tags = { + Name = "${var.name_prefix}-static" + Purpose = "frontend-assets" + } +} + +resource "aws_s3_bucket_versioning" "static" { + bucket = aws_s3_bucket.static.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "static" { + bucket = aws_s3_bucket.static.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "aws:kms" + kms_master_key_id = var.kms_key_arn + } + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_public_access_block" "static" { + bucket = aws_s3_bucket.static.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_policy" "static" { + bucket = aws_s3_bucket.static.id + policy = data.aws_iam_policy_document.deny_non_tls["static"].json +} + +resource "aws_s3_bucket_lifecycle_configuration" "static" { + bucket = aws_s3_bucket.static.id + + rule { + id = "expire-old-versions" + status = "Enabled" + + noncurrent_version_expiration { + noncurrent_days = 30 + } + } +} + +################################################################################ +# Logs Bucket — ALB + CloudFront access logs destination +# +# Note: ALB access log delivery requires the bucket policy to allow the +# Elastic Load Balancing service principal (not a CMK-encrypted bucket +# requirement from ELB side; ELB uses its own server-side encryption). +################################################################################ +resource "aws_s3_bucket" "logs" { + bucket = "${var.name_prefix}-logs-${local.suffix}" + + tags = { + Name = "${var.name_prefix}-logs" + Purpose = "access-logs-audit" + } +} + +resource "aws_s3_bucket_versioning" "logs" { + bucket = aws_s3_bucket.logs.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "logs" { + bucket = aws_s3_bucket.logs.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" # ALB log delivery requires AES256 (not KMS) or CMK with ELB service principal + } + } +} + +resource "aws_s3_bucket_public_access_block" "logs" { + bucket = aws_s3_bucket.logs.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# Allow ALB to deliver access logs +data "aws_elb_service_account" "main" {} + +data "aws_iam_policy_document" "logs_bucket" { + statement { + sid = "AllowALBLogs" + effect = "Allow" + principals { + type = "AWS" + identifiers = [data.aws_elb_service_account.main.arn] + } + actions = ["s3:PutObject"] + resources = ["arn:aws:s3:::${var.name_prefix}-logs-${local.suffix}/alb/*"] + } + + statement { + sid = "AllowCloudFrontLogs" + effect = "Allow" + principals { + type = "Service" + identifiers = ["delivery.logs.amazonaws.com"] + } + actions = ["s3:PutObject"] + resources = ["arn:aws:s3:::${var.name_prefix}-logs-${local.suffix}/cloudfront/*"] + condition { + test = "StringEquals" + variable = "s3:x-amz-acl" + values = ["bucket-owner-full-control"] + } + } + + statement { + sid = "DenyNonTLS" + effect = "Deny" + principals { + type = "*" + identifiers = ["*"] + } + actions = ["s3:*"] + resources = [ + "arn:aws:s3:::${var.name_prefix}-logs-${local.suffix}", + "arn:aws:s3:::${var.name_prefix}-logs-${local.suffix}/*" + ] + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +resource "aws_s3_bucket_policy" "logs" { + bucket = aws_s3_bucket.logs.id + policy = data.aws_iam_policy_document.logs_bucket.json +} + +resource "aws_s3_bucket_lifecycle_configuration" "logs" { + bucket = aws_s3_bucket.logs.id + + rule { + id = "expire-access-logs" + status = "Enabled" + + expiration { + days = 365 # 1 year hot; archive to Glacier for HIPAA 6-year minimum + } + + transition { + days = 90 + storage_class = "GLACIER" + } + } +} diff --git a/infra/terraform/modules/storage/outputs.tf b/infra/terraform/modules/storage/outputs.tf new file mode 100644 index 0000000..1163cd0 --- /dev/null +++ b/infra/terraform/modules/storage/outputs.tf @@ -0,0 +1,44 @@ +output "evidence_bucket_name" { + description = "Evidence S3 bucket name (WORM)." + value = aws_s3_bucket.evidence.id +} + +output "evidence_bucket_arn" { + description = "Evidence S3 bucket ARN." + value = aws_s3_bucket.evidence.arn +} + +output "reports_bucket_name" { + description = "Reports S3 bucket name." + value = aws_s3_bucket.reports.id +} + +output "reports_bucket_arn" { + description = "Reports S3 bucket ARN." + value = aws_s3_bucket.reports.arn +} + +output "static_bucket_id" { + description = "Static assets S3 bucket ID." + value = aws_s3_bucket.static.id +} + +output "static_bucket_arn" { + description = "Static assets S3 bucket ARN." + value = aws_s3_bucket.static.arn +} + +output "static_bucket_regional_domain" { + description = "Static bucket regional domain name (for CloudFront origin)." + value = aws_s3_bucket.static.bucket_regional_domain_name +} + +output "logs_bucket_name" { + description = "Access-logs S3 bucket name." + value = aws_s3_bucket.logs.id +} + +output "logs_bucket_arn" { + description = "Access-logs S3 bucket ARN." + value = aws_s3_bucket.logs.arn +} diff --git a/infra/terraform/modules/storage/variables.tf b/infra/terraform/modules/storage/variables.tf new file mode 100644 index 0000000..7a1372e --- /dev/null +++ b/infra/terraform/modules/storage/variables.tf @@ -0,0 +1,12 @@ +variable "name_prefix" { type = string } +variable "environment" { type = string } +variable "kms_key_arn" { + description = "KMS CMK ARN for S3 SSE-KMS encryption." + type = string +} +variable "evidence_object_lock_days" { + description = "Object Lock retention period in days (WORM — HIPAA 7-year minimum)." + type = number + default = 2555 +} +variable "aws_account_id" { type = string } diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..ffd0490 --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,141 @@ +################################################################################ +# outputs.tf — Root outputs +# +# Exposed to CI/CD pipelines and the deployment runbook for post-apply wiring. +################################################################################ + +# --------------------------------------------------------------------------- +# Networking +# --------------------------------------------------------------------------- + +output "vpc_id" { + description = "VPC ID." + value = module.networking.vpc_id +} + +output "private_subnet_ids" { + description = "Private subnet IDs (ECS tasks, Lambda)." + value = module.networking.private_subnet_ids +} + +output "public_subnet_ids" { + description = "Public subnet IDs (ALB, NAT GW)." + value = module.networking.public_subnet_ids +} + +output "data_subnet_ids" { + description = "Data subnet IDs (RDS, ElastiCache — no route to IGW)." + value = module.networking.data_subnet_ids +} + +# --------------------------------------------------------------------------- +# Security +# --------------------------------------------------------------------------- + +output "kms_data_key_arn" { + description = "ARN of the primary data KMS CMK (PHI encryption)." + value = module.security.kms_data_key_arn +} + +output "kms_data_key_alias" { + description = "KMS CMK alias for the data key." + value = module.security.kms_data_key_alias +} + +output "waf_web_acl_arn" { + description = "WAFv2 Web ACL ARN (attached to ALB)." + value = module.security.waf_web_acl_arn +} + +# --------------------------------------------------------------------------- +# Data +# --------------------------------------------------------------------------- + +output "rds_endpoint" { + description = "RDS primary cluster writer endpoint." + value = module.data.rds_endpoint + sensitive = true +} + +output "rds_read_replica_endpoint" { + description = "RDS read-replica endpoint (empty string if not created)." + value = module.data.rds_read_replica_endpoint + sensitive = true +} + +output "redis_primary_endpoint" { + description = "ElastiCache Redis primary endpoint." + value = module.data.redis_primary_endpoint + sensitive = true +} + +# --------------------------------------------------------------------------- +# Compute +# --------------------------------------------------------------------------- + +output "ecr_api_repository_url" { + description = "ECR repository URL for the API service." + value = module.compute.ecr_api_repository_url +} + +output "ecr_worker_repository_url" { + description = "ECR repository URL for the worker service." + value = module.compute.ecr_worker_repository_url +} + +output "ecr_ml_repository_url" { + description = "ECR repository URL for the ml-scoring service." + value = module.compute.ecr_ml_repository_url +} + +output "alb_dns_name" { + description = "ALB DNS name (before Route53 alias)." + value = module.compute.alb_dns_name +} + +output "ecs_cluster_name" { + description = "ECS cluster name." + value = module.compute.ecs_cluster_name +} + +# --------------------------------------------------------------------------- +# Edge +# --------------------------------------------------------------------------- + +output "cloudfront_domain_name" { + description = "CloudFront distribution domain name." + value = module.edge.cloudfront_domain_name +} + +output "cloudfront_distribution_id" { + description = "CloudFront distribution ID (for cache invalidations in CI/CD)." + value = module.edge.cloudfront_distribution_id +} + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +output "evidence_bucket_name" { + description = "S3 evidence bucket name (WORM / Object Lock)." + value = module.storage.evidence_bucket_name +} + +output "reports_bucket_name" { + description = "S3 reports bucket name." + value = module.storage.reports_bucket_name +} + +output "logs_bucket_name" { + description = "S3 access-logs bucket name." + value = module.storage.logs_bucket_name +} + +# --------------------------------------------------------------------------- +# Observability +# --------------------------------------------------------------------------- + +output "sns_alert_topic_arn" { + description = "SNS topic ARN for CloudWatch alarms." + value = module.observability.sns_alert_topic_arn +} diff --git a/infra/terraform/providers.tf b/infra/terraform/providers.tf new file mode 100644 index 0000000..5ba52f4 --- /dev/null +++ b/infra/terraform/providers.tf @@ -0,0 +1,45 @@ +################################################################################ +# providers.tf — AWS provider configuration +# +# default_tags are applied to every taggable resource automatically. +# Tagging strategy supports SOC 2 CC6.3 (asset inventory) and +# HIPAA §164.308(a)(1) (risk analysis: data classification label PHI). +################################################################################ + +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = "RayVerify" + Environment = var.environment + ManagedBy = "terraform" + DataClassification = "PHI" # HIPAA: flag all resources that may handle PHI + Owner = "platform-team" + CostCenter = "medicaid-fraud" + Compliance = "HIPAA,SOC2,CMS-EVV" + } + } +} + +# --------------------------------------------------------------------------- +# Secondary provider alias — us-east-1 +# ACM certificates used by CloudFront must be provisioned in us-east-1 +# regardless of the primary deployment region. +# --------------------------------------------------------------------------- +provider "aws" { + alias = "us_east_1" + region = "us-east-1" + + default_tags { + tags = { + Project = "RayVerify" + Environment = var.environment + ManagedBy = "terraform" + DataClassification = "PHI" + Owner = "platform-team" + CostCenter = "medicaid-fraud" + Compliance = "HIPAA,SOC2,CMS-EVV" + } + } +} diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..9b46849 --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,292 @@ +################################################################################ +# variables.tf — Root input variables +# +# All sizing defaults represent the DEVELOPMENT tier. +# Staging and prod values are provided via .tfvars files. +################################################################################ + +# --------------------------------------------------------------------------- +# Core deployment context +# --------------------------------------------------------------------------- + +variable "aws_region" { + description = "Primary AWS region for deployment." + type = string + default = "us-east-1" +} + +variable "environment" { + description = "Deployment environment: dev | staging | prod" + type = string + validation { + condition = contains(["dev", "staging", "prod"], var.environment) + error_message = "environment must be dev, staging, or prod." + } +} + +variable "az_count" { + description = "Number of availability zones to use (2 for dev/staging, 3 for prod)." + type = number + default = 2 +} + +# --------------------------------------------------------------------------- +# Domain / DNS +# --------------------------------------------------------------------------- + +variable "domain_name" { + description = "Root domain for the platform, e.g. rayverify.com" + type = string +} + +variable "api_subdomain" { + description = "Subdomain for the API endpoint (ALB / CloudFront)." + type = string + default = "api" +} + +variable "app_subdomain" { + description = "Subdomain for the investigator dashboard (CloudFront)." + type = string + default = "app" +} + +variable "route53_zone_id" { + description = "Route53 hosted zone ID for var.domain_name." + type = string +} + +# --------------------------------------------------------------------------- +# Networking +# --------------------------------------------------------------------------- + +variable "vpc_cidr" { + description = "VPC CIDR block." + type = string + default = "10.0.0.0/16" +} + +# --------------------------------------------------------------------------- +# RDS PostgreSQL +# --------------------------------------------------------------------------- + +variable "rds_instance_class" { + description = "RDS instance type." + type = string + default = "db.t3.medium" +} + +variable "rds_allocated_storage" { + description = "Initial RDS storage in GiB." + type = number + default = 100 +} + +variable "rds_max_allocated_storage" { + description = "Maximum autoscaling storage in GiB." + type = number + default = 500 +} + +variable "rds_engine_version" { + description = "PostgreSQL engine version." + type = string + default = "16.3" +} + +variable "rds_backup_retention_days" { + description = "PITR retention period in days (minimum 7 for HIPAA)." + type = number + default = 7 +} + +variable "rds_deletion_protection" { + description = "Enable RDS deletion protection. Always true in prod." + type = bool + default = false +} + +variable "rds_multi_az" { + description = "Enable Multi-AZ standby replica for RDS." + type = bool + default = false +} + +variable "rds_create_read_replica" { + description = "Create a read replica for analytics offloading." + type = bool + default = false +} + +variable "rds_db_name" { + description = "PostgreSQL database name." + type = string + default = "rayverify" +} + +# --------------------------------------------------------------------------- +# ElastiCache Redis +# --------------------------------------------------------------------------- + +variable "redis_node_type" { + description = "ElastiCache Redis node type." + type = string + default = "cache.t3.micro" +} + +variable "redis_num_cache_clusters" { + description = "Number of nodes in the Redis replication group (1 = no replica)." + type = number + default = 1 +} + +variable "redis_engine_version" { + description = "Redis engine version." + type = string + default = "7.1" +} + +# --------------------------------------------------------------------------- +# ECS / Fargate services +# --------------------------------------------------------------------------- + +variable "api_cpu" { + description = "CPU units for the API Fargate task (1 vCPU = 1024)." + type = number + default = 512 +} + +variable "api_memory" { + description = "Memory in MiB for the API Fargate task." + type = number + default = 1024 +} + +variable "api_desired_count" { + description = "Desired number of API service tasks." + type = number + default = 1 +} + +variable "api_min_count" { + description = "Minimum task count for API autoscaling." + type = number + default = 1 +} + +variable "api_max_count" { + description = "Maximum task count for API autoscaling." + type = number + default = 4 +} + +variable "worker_cpu" { + description = "CPU units for worker Fargate tasks." + type = number + default = 512 +} + +variable "worker_memory" { + description = "Memory in MiB for worker Fargate tasks." + type = number + default = 1024 +} + +variable "worker_desired_count" { + description = "Desired number of worker tasks." + type = number + default = 1 +} + +variable "worker_min_count" { + description = "Minimum worker task count for autoscaling." + type = number + default = 1 +} + +variable "worker_max_count" { + description = "Maximum worker task count for autoscaling." + type = number + default = 4 +} + +variable "ml_cpu" { + description = "CPU units for the ml-scoring Fargate task." + type = number + default = 1024 +} + +variable "ml_memory" { + description = "Memory in MiB for the ml-scoring Fargate task." + type = number + default = 2048 +} + +variable "ml_desired_count" { + description = "Desired number of ml-scoring tasks." + type = number + default = 1 +} + +variable "ml_min_count" { + description = "Minimum ml-scoring task count." + type = number + default = 1 +} + +variable "ml_max_count" { + description = "Maximum ml-scoring task count." + type = number + default = 4 +} + +# --------------------------------------------------------------------------- +# Container image tags +# --------------------------------------------------------------------------- + +variable "api_image_tag" { + description = "Docker image tag for the API service." + type = string + default = "latest" +} + +variable "worker_image_tag" { + description = "Docker image tag for the worker service." + type = string + default = "latest" +} + +variable "ml_image_tag" { + description = "Docker image tag for the ml-scoring service." + type = string + default = "latest" +} + +# --------------------------------------------------------------------------- +# WAF +# --------------------------------------------------------------------------- + +variable "waf_rate_limit" { + description = "Maximum requests per 5-minute window per IP before WAF blocks." + type = number + default = 2000 +} + +# --------------------------------------------------------------------------- +# SNS alerts +# --------------------------------------------------------------------------- + +variable "alert_email" { + description = "Email address for CloudWatch alarm notifications." + type = string +} + +# --------------------------------------------------------------------------- +# S3 Object Lock — evidence bucket +# --------------------------------------------------------------------------- + +variable "evidence_object_lock_days" { + description = "Object Lock retention period (days) for the evidence S3 bucket. WORM compliance." + type = number + default = 2555 # 7 years — HIPAA minimum record retention +} diff --git a/infra/terraform/versions.tf b/infra/terraform/versions.tf new file mode 100644 index 0000000..1e7c07a --- /dev/null +++ b/infra/terraform/versions.tf @@ -0,0 +1,27 @@ +################################################################################ +# versions.tf — Terraform core and provider version constraints +# +# Pin provider versions explicitly to prevent unintended upgrades in CI. +# Upgrade by editing the constraint and running `terraform init -upgrade`. +################################################################################ + +terraform { + required_version = ">= 1.7.0, < 2.0.0" + + required_providers { + # Primary cloud provider + aws = { + source = "hashicorp/aws" + version = "~> 5.50" + } + + # aws provider alias for us-east-1 (ACM certs for CloudFront must be in us-east-1) + # The alias is declared in providers.tf; the version constraint applies to both. + + # Random suffix for globally-unique resource names (S3 buckets, etc.) + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } +} diff --git a/packages/backend/.env.example b/packages/backend/.env.example new file mode 100644 index 0000000..45e953d --- /dev/null +++ b/packages/backend/.env.example @@ -0,0 +1,43 @@ +# ---- RayVerify backend environment ---- +NODE_ENV=development +PORT=4000 +API_PREFIX=api +API_VERSION=v1 + +# Database (Postgres). The migrate/ops role should have BYPASSRLS; the runtime +# role should NOT, so row-level security is enforced for application queries. +DATABASE_URL=postgresql://rayverify:rayverify@localhost:5432/rayverify?schema=public + +# Redis (queues + cache) +REDIS_URL=redis://localhost:6379 + +# Auth / JWT +JWT_ACCESS_SECRET=change-me-access-secret +JWT_REFRESH_SECRET=change-me-refresh-secret +JWT_ACCESS_TTL=900 # seconds (15m) +JWT_REFRESH_TTL=2592000 # seconds (30d) + +# Crypto / KMS (envelope encryption for PHI). In AWS, use a KMS key id. +KMS_KEY_ID=local-dev-master-key +DATA_ENCRYPTION_KEY=base64:0000000000000000000000000000000000000000000= + +# Object storage (S3 / LocalStack) +S3_ENDPOINT=http://localhost:4566 +S3_REGION=us-east-1 +S3_BUCKET_EVIDENCE=rayverify-evidence +S3_BUCKET_REPORTS=rayverify-reports +AWS_ACCESS_KEY_ID=test +AWS_SECRET_ACCESS_KEY=test + +# Identity / biometric vendor (selfie + liveness). Stub in dev. +IDENTITY_PROVIDER=stub +IDENTITY_MATCH_THRESHOLD=0.82 +LIVENESS_THRESHOLD=0.90 + +# Fraud engine +FRAUD_IMPOSSIBLE_TRAVEL_KMH=900 # > commercial flight ⇒ impossible +FRAUD_DUPLICATE_WINDOW_MIN=10 +FRAUD_AUTOFLAG_SCORE=61 # >= HIGH auto-creates an alert + +# Observability +LOG_LEVEL=info diff --git a/packages/backend/.eslintrc.js b/packages/backend/.eslintrc.js new file mode 100644 index 0000000..999129d --- /dev/null +++ b/packages/backend/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { sourceType: 'module' }, + plugins: ['@typescript-eslint'], + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + root: true, + env: { node: true, jest: true }, + ignorePatterns: ['dist', 'node_modules', '.eslintrc.js'], + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + }, +}; diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile new file mode 100644 index 0000000..5bafd23 --- /dev/null +++ b/packages/backend/Dockerfile @@ -0,0 +1,29 @@ +# ---- RayVerify backend (NestJS) — multi-stage build ---- +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +COPY packages/backend/package.json ./packages/backend/ +COPY packages/shared/package.json ./packages/shared/ +RUN npm ci --workspace @rayverify/backend --workspace @rayverify/shared --include-workspace-root || npm install + +FROM node:20-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run prisma:generate --workspace @rayverify/backend \ + && npm run build --workspace @rayverify/backend + +FROM node:20-alpine AS runner +ENV NODE_ENV=production +WORKDIR /app +# Run as non-root for least privilege. +RUN addgroup -S app && adduser -S app -G app +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/packages/backend/dist ./dist +COPY --from=build /app/packages/backend/prisma ./prisma +COPY --from=build /app/packages/backend/package.json ./ +USER app +EXPOSE 4000 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -qO- http://localhost:4000/api/v1/health || exit 1 +CMD ["node", "dist/main.js"] diff --git a/packages/backend/jest.config.js b/packages/backend/jest.config.js new file mode 100644 index 0000000..c1e3c9d --- /dev/null +++ b/packages/backend/jest.config.js @@ -0,0 +1,10 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src', '/test'], + testRegex: '.*\\.spec\\.ts$', + moduleFileExtensions: ['ts', 'js', 'json'], + collectCoverageFrom: ['src/**/*.{ts}', '!src/main.ts', '!src/**/*.module.ts'], + coverageDirectory: './coverage', +}; diff --git a/packages/backend/nest-cli.json b/packages/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/packages/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json new file mode 100644 index 0000000..a4f0019 --- /dev/null +++ b/packages/backend/package.json @@ -0,0 +1,71 @@ +{ + "name": "@rayverify/backend", + "version": "0.1.0", + "private": true, + "description": "RayVerify™ API — identity, visit verification, and fraud intelligence engines (NestJS + Prisma).", + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main.js", + "start:prod": "node dist/main.js", + "lint": "eslint \"src/**/*.ts\" --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "jest", + "test:e2e": "jest --config ./test/jest-e2e.json", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate deploy && npm run db:sql", + "prisma:migrate:dev": "prisma migrate dev", + "db:sql": "node ./scripts/apply-sql.js", + "prisma:seed": "ts-node prisma/seed.ts", + "prisma:studio": "prisma studio" + }, + "prisma": { + "seed": "ts-node prisma/seed.ts" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.3.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.3.0", + "@nestjs/swagger": "^7.3.0", + "@nestjs/throttler": "^5.1.2", + "@prisma/client": "^5.12.0", + "argon2": "^0.40.1", + "bullmq": "^5.4.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "helmet": "^7.1.0", + "ioredis": "^5.3.2", + "nestjs-pino": "^4.0.0", + "otplib": "^12.0.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "pg": "^8.11.5", + "pino-http": "^9.0.0", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1", + "uuid": "^9.0.1", + "zod": "^3.22.4" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.0", + "@nestjs/schematics": "^10.1.0", + "@nestjs/testing": "^10.3.0", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.0", + "@types/passport-jwt": "^4.0.1", + "@types/pg": "^8.11.5", + "@types/uuid": "^9.0.8", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "prisma": "^5.12.0", + "ts-jest": "^29.1.2", + "ts-node": "^10.9.2", + "typescript": "^5.4.0" + } +} diff --git a/packages/backend/prisma/seed.ts b/packages/backend/prisma/seed.ts new file mode 100644 index 0000000..0c042b6 --- /dev/null +++ b/packages/backend/prisma/seed.ts @@ -0,0 +1,185 @@ +/* eslint-disable no-console */ +/** + * Seeds a demo tenant with full RBAC and enough domain data to exercise the + * verification + fraud engines end-to-end. + * + * RLS note: business tables use FORCE ROW LEVEL SECURITY, so even the owner must + * set `app.current_org`. We create the organization (no RLS) and the global + * permission catalog first, then seed tenant rows inside a single transaction + * with `SET LOCAL app.current_org` so every insert satisfies the policy. + */ +import { PrismaClient, VerificationResult, IdentityMethod } from '@prisma/client'; +import * as argon2 from 'argon2'; + +const prisma = new PrismaClient(); + +const PERMISSIONS: Array<[string, string]> = [ + ['identity:verify', 'Run identity verification'], + ['visit:create', 'Create/schedule visits'], + ['visit:read', 'Read visits & verification packages'], + ['visit:clock', 'Record clock-in/out'], + ['visit:verify', 'Run the verification chain'], + ['fraud_event:read', 'Read fraud events'], + ['fraud:score', 'Trigger fraud scoring'], + ['fraud_case:create', 'Open cases'], + ['fraud_case:read', 'Read cases'], + ['fraud_case:assign', 'Assign cases'], + ['fraud_case:update', 'Update cases / notes'], + ['provider:read', 'Read providers & risk'], + ['provider:score', 'Recompute provider risk'], + ['audit:read', 'Read audit trail'], + ['report:create', 'Request reports'], + ['report:read', 'Read reports'], +]; + +async function main() { + console.log('Seeding RayVerify demo tenant…'); + + // 1) Organization (no RLS) + global permission catalog. + const org = await prisma.organization.upsert({ + where: { slug: 'state-pi' }, + update: {}, + create: { + name: 'State Program Integrity Unit (Demo)', + slug: 'state-pi', + jurisdiction: 'US-XX', + settings: { fraud: { autoFlagScore: 61 } }, + }, + }); + + for (const [key, description] of PERMISSIONS) { + await prisma.permission.upsert({ where: { key }, update: {}, create: { key, description } }); + } + const allPerms = await prisma.permission.findMany(); + + const adminHash = await argon2.hash('ChangeMe!Admin123'); + const invHash = await argon2.hash('ChangeMe!Invest123'); + + // 2) Tenant rows inside an RLS-scoped transaction. + await prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe(`SET LOCAL "app.current_org" = '${org.id}'`); + + const adminRole = await tx.role.upsert({ + where: { organizationId_key: { organizationId: org.id, key: 'ORG_ADMIN' } }, + update: {}, + create: { organizationId: org.id, key: 'ORG_ADMIN', name: 'Organization Admin', isSystem: true }, + }); + const invRole = await tx.role.upsert({ + where: { organizationId_key: { organizationId: org.id, key: 'INVESTIGATOR' } }, + update: {}, + create: { organizationId: org.id, key: 'INVESTIGATOR', name: 'Fraud Investigator', isSystem: true }, + }); + + // ORG_ADMIN → all permissions; INVESTIGATOR → read/investigate subset. + const investigatorKeys = new Set([ + 'visit:read', 'visit:verify', 'fraud_event:read', 'fraud:score', + 'fraud_case:create', 'fraud_case:read', 'fraud_case:assign', 'fraud_case:update', + 'provider:read', 'provider:score', 'audit:read', 'report:create', 'report:read', + ]); + for (const p of allPerms) { + await tx.rolePermission.upsert({ + where: { roleId_permissionId: { roleId: adminRole.id, permissionId: p.id } }, + update: {}, + create: { roleId: adminRole.id, permissionId: p.id }, + }); + if (investigatorKeys.has(p.key)) { + await tx.rolePermission.upsert({ + where: { roleId_permissionId: { roleId: invRole.id, permissionId: p.id } }, + update: {}, + create: { roleId: invRole.id, permissionId: p.id }, + }); + } + } + + const admin = await tx.user.upsert({ + where: { organizationId_email: { organizationId: org.id, email: 'admin@state-pi.gov' } }, + update: {}, + create: { + organizationId: org.id, email: 'admin@state-pi.gov', passwordHash: adminHash, + firstName: 'Ada', lastName: 'Admin', status: 'ACTIVE', + }, + }); + const investigator = await tx.user.upsert({ + where: { organizationId_email: { organizationId: org.id, email: 'investigator@state-pi.gov' } }, + update: {}, + create: { + organizationId: org.id, email: 'investigator@state-pi.gov', passwordHash: invHash, + firstName: 'Ivan', lastName: 'Investigator', status: 'ACTIVE', + }, + }); + await tx.userRole.upsert({ where: { userId_roleId: { userId: admin.id, roleId: adminRole.id } }, update: {}, create: { userId: admin.id, roleId: adminRole.id } }); + await tx.userRole.upsert({ where: { userId_roleId: { userId: investigator.id, roleId: invRole.id } }, update: {}, create: { userId: investigator.id, roleId: invRole.id } }); + + // Provider + caregiver (with enrollment) + patient + authorization + device. + const provider = await tx.provider.create({ + data: { organizationId: org.id, legalName: 'Sunrise Home Care LLC', npi: '1234567893' }, + }); + const caregiver = await tx.caregiver.create({ + data: { organizationId: org.id, providerId: provider.id, firstName: 'Carla', lastName: 'Caregiver', email: 'carla@sunrise.example' }, + }); + await tx.biometricEnrollment.create({ + data: { caregiverId: caregiver.id, method: IdentityMethod.SELFIE, referenceS3Key: 'enroll/carla.jpg' }, + }); + const patient = await tx.patient.create({ + data: { organizationId: org.id, firstName: 'Pat', lastName: 'Patient' }, + }); + const auth = await tx.serviceAuthorization.create({ + data: { + organizationId: org.id, patientId: patient.id, serviceCode: 'T1019', + addressLine1: '100 Main St', city: 'Springfield', state: 'XX', postalCode: '00000', + latitude: 39.781721, longitude: -89.650148, radiusMeters: 150, + startDate: new Date('2026-01-01'), + }, + }); + const device = await tx.device.create({ + data: { organizationId: org.id, deviceId: 'demo-device-001', platform: 'ANDROID', trustLevel: 'TRUSTED' }, + }); + + // A clean PASS visit and a GPS-anomaly visit (far from the address). + const cleanVisit = await tx.visit.create({ + data: { + organizationId: org.id, providerId: provider.id, caregiverId: caregiver.id, patientId: patient.id, + authorizationId: auth.id, deviceId: device.id, serviceCode: 'T1019', status: 'COMPLETED', + scheduledStart: new Date('2026-06-09T14:00:00Z'), + clockInAt: new Date('2026-06-09T14:02:00Z'), clockOutAt: new Date('2026-06-09T15:00:00Z'), + durationMinutes: 58, clockInLat: 39.781730, clockInLng: -89.650100, billedUnits: 4, + }, + }); + await tx.identityVerification.create({ + data: { organizationId: org.id, visitId: cleanVisit.id, caregiverId: caregiver.id, method: IdentityMethod.SELFIE, result: VerificationResult.PASS, confidenceScore: 0.97, livenessScore: 0.99 }, + }); + await tx.gpsVerification.create({ + data: { organizationId: org.id, visitId: cleanVisit.id, latitude: 39.781730, longitude: -89.650100, distanceMeters: 6, result: VerificationResult.PASS, capturedAt: new Date('2026-06-09T14:02:00Z') }, + }); + + const anomalyVisit = await tx.visit.create({ + data: { + organizationId: org.id, providerId: provider.id, caregiverId: caregiver.id, patientId: patient.id, + authorizationId: auth.id, deviceId: device.id, serviceCode: 'T1019', status: 'COMPLETED', + scheduledStart: new Date('2026-06-09T16:00:00Z'), + clockInAt: new Date('2026-06-09T16:05:00Z'), clockOutAt: new Date('2026-06-09T16:06:30Z'), + durationMinutes: 1, clockInLat: 39.900000, clockInLng: -89.900000, billedUnits: 4, + }, + }); + await tx.identityVerification.create({ + data: { organizationId: org.id, visitId: anomalyVisit.id, caregiverId: caregiver.id, method: IdentityMethod.SELFIE, result: VerificationResult.REVIEW, confidenceScore: 0.71, livenessScore: 0.86 }, + }); + await tx.gpsVerification.create({ + data: { organizationId: org.id, visitId: anomalyVisit.id, latitude: 39.900000, longitude: -89.900000, distanceMeters: 28000, result: VerificationResult.FAIL, capturedAt: new Date('2026-06-09T16:05:00Z') }, + }); + + console.log(`Seeded org=${org.slug} provider=${provider.legalName}`); + console.log(` visits: clean=${cleanVisit.id} anomaly=${anomalyVisit.id}`); + }); + + console.log('Done. Logins (org slug "state-pi"):'); + console.log(' admin@state-pi.gov / ChangeMe!Admin123'); + console.log(' investigator@state-pi.gov / ChangeMe!Invest123'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/packages/backend/scripts/apply-sql.js b/packages/backend/scripts/apply-sql.js new file mode 100644 index 0000000..b79c53d --- /dev/null +++ b/packages/backend/scripts/apply-sql.js @@ -0,0 +1,33 @@ +/** + * Applies the physical-layer SQL (partitioning, RLS, immutability + audit + * hash-chain triggers) that Prisma migrations don't manage. Run AFTER + * `prisma migrate deploy`. Idempotent-ish: wrap in a transaction and tolerate + * "already exists" by running against a fresh DB in CI. + * + * In production this is split into ordered, versioned SQL migrations; this + * helper keeps local/dev simple. + */ +const fs = require('fs'); +const path = require('path'); +const { Client } = require('pg'); + +async function main() { + const url = process.env.DATABASE_URL; + if (!url) throw new Error('DATABASE_URL is required'); + const sqlPath = path.resolve(__dirname, '../../../db/schema.sql'); + const sql = fs.readFileSync(sqlPath, 'utf8'); + const client = new Client({ connectionString: url }); + await client.connect(); + try { + console.log('[apply-sql] applying db/schema.sql physical layer…'); + await client.query(sql); + console.log('[apply-sql] done.'); + } finally { + await client.end(); + } +} + +main().catch((e) => { + console.error('[apply-sql] failed:', e.message); + process.exit(1); +}); diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts new file mode 100644 index 0000000..f99880f --- /dev/null +++ b/packages/backend/src/app.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import configuration from './config/configuration'; +import { PrismaModule } from './common/prisma/prisma.module'; +import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; +import { PermissionsGuard } from './common/guards/permissions.guard'; +import { HealthModule } from './modules/health/health.module'; +import { AuthModule } from './modules/auth/auth.module'; +import { IdentityModule } from './modules/identity/identity.module'; +import { VisitsModule } from './modules/visits/visits.module'; +import { FraudModule } from './modules/fraud/fraud.module'; +import { CasesModule } from './modules/cases/cases.module'; +import { ProvidersModule } from './modules/providers/providers.module'; +import { AuditModule } from './modules/audit/audit.module'; +import { ReportsModule } from './modules/reports/reports.module'; +import { NotificationsModule } from './modules/notifications/notifications.module'; +import { HardwareModule } from './modules/hardware/hardware.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, load: [configuration] }), + ThrottlerModule.forRoot([{ ttl: 60_000, limit: 120 }]), + PrismaModule, + // Feature modules — one per RayVerify product module + supporting concerns. + HealthModule, + AuthModule, + IdentityModule, // Module 1: Identity Verification Engine + VisitsModule, // Module 2: Visit Verification Engine + FraudModule, // Module 3: Fraud Intelligence Engine + CasesModule, // Module 4: Investigator Dashboard (case mgmt API) + ProvidersModule, // Module 5: Provider Risk Scoring + AuditModule, // Module 6: Audit & Compliance Center + ReportsModule, // Module 7: Reporting & Analytics + NotificationsModule, + HardwareModule, // Module 8: Future Hardware Integration Layer + ], + providers: [ + // Global authn/authz/rate-limit. @Public() opts routes out of auth. + { provide: APP_GUARD, useClass: ThrottlerGuard }, + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: PermissionsGuard }, + ], +}) +export class AppModule {} diff --git a/packages/backend/src/common/context/tenant-context.ts b/packages/backend/src/common/context/tenant-context.ts new file mode 100644 index 0000000..71f4705 --- /dev/null +++ b/packages/backend/src/common/context/tenant-context.ts @@ -0,0 +1,43 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +/** + * Per-request execution context. Carries the resolved tenant (organizationId) + * and actor so that: + * 1. PrismaService can scope every query with Postgres RLS (`app.current_org`). + * 2. The audit interceptor can attribute actions without threading params. + * + * Backed by AsyncLocalStorage so it survives async/await boundaries within a + * single request without leaking across concurrent requests. + */ +export interface RequestContext { + organizationId: string; + userId?: string; + requestId?: string; + ip?: string; + userAgent?: string; +} + +const storage = new AsyncLocalStorage(); + +export const TenantContext = { + run(ctx: RequestContext, fn: () => T): T { + return storage.run(ctx, fn); + }, + + get(): RequestContext | undefined { + return storage.getStore(); + }, + + /** Throws if no tenant is bound — use where a tenant is mandatory. */ + require(): RequestContext { + const ctx = storage.getStore(); + if (!ctx?.organizationId) { + throw new Error('No tenant context bound to the current request'); + } + return ctx; + }, + + orgId(): string | undefined { + return storage.getStore()?.organizationId; + }, +}; diff --git a/packages/backend/src/common/decorators/current-user.decorator.ts b/packages/backend/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..dfea6e1 --- /dev/null +++ b/packages/backend/src/common/decorators/current-user.decorator.ts @@ -0,0 +1,18 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export interface AuthenticatedUser { + userId: string; + organizationId: string; + email: string; + roles: string[]; + permissions: string[]; +} + +/** Injects the authenticated user (populated by JwtStrategy) into a handler. */ +export const CurrentUser = createParamDecorator( + (data: keyof AuthenticatedUser | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user: AuthenticatedUser = request.user; + return data ? user?.[data] : user; + }, +); diff --git a/packages/backend/src/common/decorators/permissions.decorator.ts b/packages/backend/src/common/decorators/permissions.decorator.ts new file mode 100644 index 0000000..589e228 --- /dev/null +++ b/packages/backend/src/common/decorators/permissions.decorator.ts @@ -0,0 +1,11 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PERMISSIONS_KEY = 'permissions'; + +/** + * Declares the permission(s) required to access a route. Permission keys use + * the `resource:action` convention (e.g. 'fraud_case:assign', 'report:export'). + * Enforced by PermissionsGuard against the authenticated user's permission set. + */ +export const RequirePermissions = (...permissions: string[]) => + SetMetadata(PERMISSIONS_KEY, permissions); diff --git a/packages/backend/src/common/decorators/public.decorator.ts b/packages/backend/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..8c72aaa --- /dev/null +++ b/packages/backend/src/common/decorators/public.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; + +/** Marks a route as not requiring authentication (e.g. login, health). */ +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/packages/backend/src/common/dto/pagination.dto.ts b/packages/backend/src/common/dto/pagination.dto.ts new file mode 100644 index 0000000..d20636b --- /dev/null +++ b/packages/backend/src/common/dto/pagination.dto.ts @@ -0,0 +1,55 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class PaginationQueryDto { + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @ApiPropertyOptional({ default: 25, minimum: 1, maximum: 200 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + pageSize = 25; + + @ApiPropertyOptional({ description: 'Free-text search term' }) + @IsOptional() + @IsString() + q?: string; + + get skip(): number { + return (this.page - 1) * this.pageSize; + } + + get take(): number { + return this.pageSize; + } +} + +export interface Paginated { + data: T[]; + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +export function paginate( + data: T[], + total: number, + query: PaginationQueryDto, +): Paginated { + return { + data, + page: query.page, + pageSize: query.pageSize, + total, + totalPages: Math.ceil(total / query.pageSize) || 1, + }; +} diff --git a/packages/backend/src/common/filters/all-exceptions.filter.ts b/packages/backend/src/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..6e2bd23 --- /dev/null +++ b/packages/backend/src/common/filters/all-exceptions.filter.ts @@ -0,0 +1,59 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { TenantContext } from '../context/tenant-context'; + +/** + * RFC 7807-style error envelope. Never leaks stack traces or PHI to clients; + * logs full detail server-side with the request id for correlation. + */ +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger('Exception'); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const res = ctx.getResponse(); + const req = ctx.getRequest(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const payload = + exception instanceof HttpException ? exception.getResponse() : null; + + const requestId = req.requestId ?? TenantContext.get()?.requestId; + + const body = { + type: `https://docs.rayverify.gov/errors/${status}`, + status, + title: + typeof payload === 'object' && payload && 'message' in payload + ? (payload as { message: unknown }).message + : HttpStatus[status] ?? 'Error', + instance: req.url, + requestId, + timestamp: new Date().toISOString(), + }; + + if (status >= 500) { + this.logger.error( + `${req.method} ${req.url} -> ${status} [req=${requestId}]`, + exception instanceof Error ? exception.stack : String(exception), + ); + } else { + this.logger.warn( + `${req.method} ${req.url} -> ${status} [req=${requestId}]`, + ); + } + + res.status(status).json(body); + } +} diff --git a/packages/backend/src/common/guards/jwt-auth.guard.ts b/packages/backend/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..e0c4570 --- /dev/null +++ b/packages/backend/src/common/guards/jwt-auth.guard.ts @@ -0,0 +1,21 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; + +/** Global JWT guard that honors the @Public() decorator. */ +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private readonly reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + return super.canActivate(context); + } +} diff --git a/packages/backend/src/common/guards/permissions.guard.ts b/packages/backend/src/common/guards/permissions.guard.ts new file mode 100644 index 0000000..dea6ab9 --- /dev/null +++ b/packages/backend/src/common/guards/permissions.guard.ts @@ -0,0 +1,35 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PERMISSIONS_KEY } from '../decorators/permissions.decorator'; +import { AuthenticatedUser } from '../decorators/current-user.decorator'; + +/** Enforces least-privilege: route permissions ⊆ user's permission set. */ +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const required = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + if (!required || required.length === 0) return true; + + const user: AuthenticatedUser = context + .switchToHttp() + .getRequest().user; + const granted = new Set(user?.permissions ?? []); + const ok = required.every((p) => granted.has(p) || granted.has('*')); + if (!ok) { + throw new ForbiddenException( + `Missing required permission(s): ${required.join(', ')}`, + ); + } + return true; + } +} diff --git a/packages/backend/src/common/interceptors/context.interceptor.ts b/packages/backend/src/common/interceptors/context.interceptor.ts new file mode 100644 index 0000000..11c4d39 --- /dev/null +++ b/packages/backend/src/common/interceptors/context.interceptor.ts @@ -0,0 +1,41 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { randomUUID } from 'node:crypto'; +import { TenantContext } from '../context/tenant-context'; +import { AuthenticatedUser } from '../decorators/current-user.decorator'; + +/** + * Binds the per-request TenantContext (organizationId + actor) for the lifetime + * of the handler so PrismaService can apply RLS and the audit trail can attribute + * actions. Runs after the auth guard, so req.user is populated. + */ +@Injectable() +export class ContextInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + const user: AuthenticatedUser | undefined = req.user; + const requestId = + req.headers['x-request-id'] ?? randomUUID(); + req.requestId = requestId; + + if (!user?.organizationId) { + return next.handle(); + } + + return TenantContext.run( + { + organizationId: user.organizationId, + userId: user.userId, + requestId, + ip: req.ip, + userAgent: req.headers['user-agent'], + }, + () => next.handle(), + ); + } +} diff --git a/packages/backend/src/common/prisma/prisma.module.ts b/packages/backend/src/common/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/packages/backend/src/common/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/packages/backend/src/common/prisma/prisma.service.ts b/packages/backend/src/common/prisma/prisma.service.ts new file mode 100644 index 0000000..db25c0c --- /dev/null +++ b/packages/backend/src/common/prisma/prisma.service.ts @@ -0,0 +1,72 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { TenantContext } from '../context/tenant-context'; + +/** + * Tenant-aware Prisma client. + * + * Hard multi-tenant isolation is enforced at the database via Row-Level + * Security (see db/schema.sql). The application runtime connects with a role + * that does NOT have BYPASSRLS, so any query missing the tenant GUC returns + * zero rows — defense in depth against a forgotten `where: { organizationId }`. + * + * The `$extends` query wrapper opens a transaction per operation and sets + * `app.current_org` with SET LOCAL (scoped to that transaction), reading the + * tenant from the AsyncLocalStorage request context. + */ +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + private readonly logger = new Logger(PrismaService.name); + + constructor() { + super({ + log: [ + { level: 'warn', emit: 'event' }, + { level: 'error', emit: 'event' }, + ], + }); + } + + async onModuleInit(): Promise { + await this.$connect(); + this.logger.log('Prisma connected (RLS-enforced runtime role)'); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } + + /** + * Returns a client whose every query is bound to the current tenant via RLS. + * Falls back to the base client when no tenant context is present (e.g. + * health checks, auth bootstrap before login resolves an org). + */ + forRequest() { + const self = this; + return this.$extends({ + query: { + async $allOperations({ args, query }) { + const orgId = TenantContext.orgId(); + if (!orgId) return query(args); + // SET LOCAL keeps the GUC scoped to this transaction only, so the + // RLS policy (organization_id = current_setting('app.current_org')) + // applies to the wrapped operation and never leaks across requests. + return self.$transaction(async (tx) => { + await tx.$executeRawUnsafe( + `SET LOCAL "app.current_org" = '${orgId.replace(/'/g, "''")}'`, + ); + return query(args); + }); + }, + }, + }); + } +} diff --git a/packages/backend/src/common/util/geo.ts b/packages/backend/src/common/util/geo.ts new file mode 100644 index 0000000..11da9da --- /dev/null +++ b/packages/backend/src/common/util/geo.ts @@ -0,0 +1,31 @@ +export interface GeoPoint { + lat: number; + lng: number; +} + +const EARTH_RADIUS_M = 6_371_008.8; // mean Earth radius (meters) + +const toRad = (deg: number) => (deg * Math.PI) / 180; + +/** + * Great-circle distance between two points (Haversine), in meters. + * Used by GPS geofencing and the impossible-travel detector. + */ +export function haversineMeters(a: GeoPoint, b: GeoPoint): number { + const dLat = toRad(b.lat - a.lat); + const dLng = toRad(b.lng - a.lng); + const lat1 = toRad(a.lat); + const lat2 = toRad(b.lat); + + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; + return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** Implied travel speed (km/h) to cover a distance in a time delta. */ +export function speedKmh(distanceMeters: number, deltaMs: number): number { + if (deltaMs <= 0) return Infinity; + const hours = deltaMs / 3_600_000; + return distanceMeters / 1000 / hours; +} diff --git a/packages/backend/src/common/util/risk.ts b/packages/backend/src/common/util/risk.ts new file mode 100644 index 0000000..f131c11 --- /dev/null +++ b/packages/backend/src/common/util/risk.ts @@ -0,0 +1,18 @@ +import { RiskLevel } from '@prisma/client'; + +/** + * Canonical risk banding used across the platform (mirrored in the frontend). + * 0–30 LOW · 31–60 MODERATE · 61–80 HIGH · 81–100 CRITICAL + */ +export function scoreToRiskLevel(score: number): RiskLevel { + const s = clampScore(score); + if (s <= 30) return RiskLevel.LOW; + if (s <= 60) return RiskLevel.MODERATE; + if (s <= 80) return RiskLevel.HIGH; + return RiskLevel.CRITICAL; +} + +export function clampScore(score: number): number { + if (Number.isNaN(score)) return 0; + return Math.max(0, Math.min(100, Math.round(score))); +} diff --git a/packages/backend/src/config/configuration.ts b/packages/backend/src/config/configuration.ts new file mode 100644 index 0000000..19a626e --- /dev/null +++ b/packages/backend/src/config/configuration.ts @@ -0,0 +1,55 @@ +/** + * Typed configuration loaded from environment. Centralizes the knobs used by + * the verification and fraud engines so they're tenant-overridable later via + * organizations.settings. + */ +export interface AppConfig { + env: string; + port: number; + apiPrefix: string; + apiVersion: string; + jwt: { + accessSecret: string; + refreshSecret: string; + accessTtl: number; + refreshTtl: number; + }; + identity: { + provider: string; + matchThreshold: number; + livenessThreshold: number; + }; + fraud: { + impossibleTravelKmh: number; + duplicateWindowMin: number; + autoFlagScore: number; + }; +} + +export default (): AppConfig => ({ + env: process.env.NODE_ENV ?? 'development', + port: parseInt(process.env.PORT ?? '4000', 10), + apiPrefix: process.env.API_PREFIX ?? 'api', + apiVersion: process.env.API_VERSION ?? 'v1', + jwt: { + accessSecret: process.env.JWT_ACCESS_SECRET ?? 'dev-access', + refreshSecret: process.env.JWT_REFRESH_SECRET ?? 'dev-refresh', + accessTtl: parseInt(process.env.JWT_ACCESS_TTL ?? '900', 10), + refreshTtl: parseInt(process.env.JWT_REFRESH_TTL ?? '2592000', 10), + }, + identity: { + provider: process.env.IDENTITY_PROVIDER ?? 'stub', + matchThreshold: parseFloat(process.env.IDENTITY_MATCH_THRESHOLD ?? '0.82'), + livenessThreshold: parseFloat(process.env.LIVENESS_THRESHOLD ?? '0.90'), + }, + fraud: { + impossibleTravelKmh: parseFloat( + process.env.FRAUD_IMPOSSIBLE_TRAVEL_KMH ?? '900', + ), + duplicateWindowMin: parseInt( + process.env.FRAUD_DUPLICATE_WINDOW_MIN ?? '10', + 10, + ), + autoFlagScore: parseInt(process.env.FRAUD_AUTOFLAG_SCORE ?? '61', 10), + }, +}); diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts new file mode 100644 index 0000000..58306b4 --- /dev/null +++ b/packages/backend/src/main.ts @@ -0,0 +1,64 @@ +import { ValidationPipe, VersioningType } from '@nestjs/common'; +import { NestFactory, Reflector } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import helmet from 'helmet'; +import { AppModule } from './app.module'; +import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; +import { ContextInterceptor } from './common/interceptors/context.interceptor'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { bufferLogs: true }); + + const prefix = process.env.API_PREFIX ?? 'api'; + const version = process.env.API_VERSION ?? 'v1'; + + // --- Security middleware (Zero Trust posture) --- + app.use( + helmet({ + contentSecurityPolicy: process.env.NODE_ENV === 'production' ? undefined : false, + hsts: { maxAge: 63072000, includeSubDomains: true, preload: true }, + }), + ); + app.enableCors({ + origin: (process.env.CORS_ORIGINS ?? '').split(',').filter(Boolean) || true, + credentials: true, + }); + + app.setGlobalPrefix(prefix); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: version.replace('v', '') }); + + // --- Global pipes / filters / interceptors --- + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + app.useGlobalFilters(new AllExceptionsFilter()); + app.useGlobalInterceptors(new ContextInterceptor()); + + // --- OpenAPI / Swagger --- + const swaggerConfig = new DocumentBuilder() + .setTitle('RayVerify™ API') + .setDescription( + 'Government-grade fraud detection & identity verification for Medicaid / HCBS. ' + + 'All endpoints are tenant-scoped and audited.', + ) + .setVersion('0.1.0') + .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'jwt') + .addServer(`/${prefix}/${version}`) + .build(); + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup(`${prefix}/docs`, app, document, { + swaggerOptions: { persistAuthorization: true }, + }); + + const port = process.env.PORT ?? 4000; + await app.listen(port); + // eslint-disable-next-line no-console + console.log(`RayVerify API on :${port} (docs at /${prefix}/docs)`); +} + +void bootstrap(); diff --git a/packages/backend/src/modules/audit/audit.controller.ts b/packages/backend/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..ed9e365 --- /dev/null +++ b/packages/backend/src/modules/audit/audit.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiPropertyOptional, ApiTags } from '@nestjs/swagger'; +import { AuditAction } from '@prisma/client'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { RequirePermissions } from '../../common/decorators/permissions.decorator'; +import { paginate, PaginationQueryDto } from '../../common/dto/pagination.dto'; +import { AuditService } from './audit.service'; + +class SearchAuditQueryDto extends PaginationQueryDto { + @ApiPropertyOptional() @IsOptional() @IsString() resourceType?: string; + @ApiPropertyOptional() @IsOptional() @IsString() resourceId?: string; + @ApiPropertyOptional({ enum: AuditAction }) @IsOptional() @IsEnum(AuditAction) action?: AuditAction; +} + +@ApiTags('audit') +@ApiBearerAuth('jwt') +@Controller('audit') +export class AuditController { + constructor(private readonly audit: AuditService) {} + + @Get('logs') + @RequirePermissions('audit:read') + @ApiOperation({ summary: 'Search the immutable audit trail' }) + async search(@Query() query: SearchAuditQueryDto) { + const { data, total } = await this.audit.search({ + resourceType: query.resourceType, + resourceId: query.resourceId, + action: query.action, + skip: query.skip, + take: query.take, + }); + return paginate(data, total, query); + } + + @Get('verify-chain') + @RequirePermissions('audit:read') + @ApiOperation({ summary: 'Verify the tamper-evident audit hash chain' }) + verify() { + return this.audit.verifyChain(); + } +} diff --git a/packages/backend/src/modules/audit/audit.module.ts b/packages/backend/src/modules/audit/audit.module.ts new file mode 100644 index 0000000..9d3fb4a --- /dev/null +++ b/packages/backend/src/modules/audit/audit.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { AuditController } from './audit.controller'; +import { AuditService } from './audit.service'; + +/** Global so any module can record audit events via AuditService. */ +@Global() +@Module({ + controllers: [AuditController], + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/packages/backend/src/modules/audit/audit.service.ts b/packages/backend/src/modules/audit/audit.service.ts new file mode 100644 index 0000000..3a8f3d4 --- /dev/null +++ b/packages/backend/src/modules/audit/audit.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { AuditAction, Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { TenantContext } from '../../common/context/tenant-context'; + +export interface AuditRecordInput { + action: AuditAction; + resourceType: string; + resourceId?: string; + metadata?: Record; +} + +@Injectable() +export class AuditService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Writes an immutable audit row. The prev_hash/hash chain is computed by the + * DB trigger (rv_audit_hash_chain) so it cannot be forged from the app layer. + */ + async record(input: AuditRecordInput) { + const ctx = TenantContext.get(); + if (!ctx?.organizationId) return; // nothing to attribute (e.g. pre-auth) + const db = this.prisma.forRequest(); + await db.auditLog.create({ + data: { + organizationId: ctx.organizationId, + actorId: ctx.userId ?? null, + action: input.action, + resourceType: input.resourceType, + resourceId: input.resourceId, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + metadata: (input.metadata ?? {}) as Prisma.InputJsonValue, + }, + }); + } + + async search(params: { + resourceType?: string; + resourceId?: string; + action?: AuditAction; + skip: number; + take: number; + }) { + const db = this.prisma.forRequest(); + const where: Prisma.AuditLogWhereInput = { + ...(params.resourceType ? { resourceType: params.resourceType } : {}), + ...(params.resourceId ? { resourceId: params.resourceId } : {}), + ...(params.action ? { action: params.action } : {}), + }; + const [data, total] = await Promise.all([ + db.auditLog.findMany({ where, orderBy: { createdAt: 'desc' }, skip: params.skip, take: params.take }), + db.auditLog.count({ where }), + ]); + return { data, total }; + } + + /** + * Verifies the tamper-evident chain for the tenant: each row's prev_hash must + * equal the previous row's hash. A break indicates deletion, reordering, or + * tampering. (Full re-hash verification additionally re-derives each hash from + * the canonical row; that runs in the offline compliance job to match Postgres + * value serialization exactly.) + */ + async verifyChain(): Promise<{ valid: boolean; checked: number; brokenAtId?: string }> { + const db = this.prisma.forRequest(); + const rows = await db.auditLog.findMany({ + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + select: { id: true, prevHash: true, hash: true }, + }); + let prev: string | null = null; + for (const row of rows) { + if ((row.prevHash ?? null) !== prev) { + return { valid: false, checked: rows.length, brokenAtId: row.id }; + } + prev = row.hash ?? null; + } + return { valid: true, checked: rows.length }; + } +} diff --git a/packages/backend/src/modules/auth/auth.controller.ts b/packages/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..676b18a --- /dev/null +++ b/packages/backend/src/modules/auth/auth.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Public } from '../../common/decorators/public.decorator'; +import { + CurrentUser, + AuthenticatedUser, +} from '../../common/decorators/current-user.decorator'; +import { AuthService } from './auth.service'; +import { LoginDto, RefreshDto, TokenResponseDto } from './dto/auth.dto'; + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor(private readonly auth: AuthService) {} + + @Public() + @Post('login') + @HttpCode(200) + @ApiOperation({ summary: 'Authenticate (org slug + email + password [+ MFA])' }) + login(@Body() dto: LoginDto): Promise { + return this.auth.login(dto); + } + + @Public() + @Post('refresh') + @HttpCode(200) + @ApiOperation({ summary: 'Rotate tokens using a valid refresh token' }) + refresh(@Body() dto: RefreshDto): Promise { + return this.auth.refresh(dto.refreshToken); + } + + @Public() + @Post('logout') + @HttpCode(204) + @ApiOperation({ summary: 'Revoke a refresh token' }) + async logout(@Body() dto: RefreshDto): Promise { + await this.auth.logout(dto.refreshToken); + } + + @ApiBearerAuth('jwt') + @Get('me') + @ApiOperation({ summary: 'Current authenticated principal' }) + me(@CurrentUser() user: AuthenticatedUser): AuthenticatedUser { + return user; + } +} diff --git a/packages/backend/src/modules/auth/auth.module.ts b/packages/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..de39cd3 --- /dev/null +++ b/packages/backend/src/modules/auth/auth.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './strategies/jwt.strategy'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('jwt.accessSecret'), + signOptions: { expiresIn: config.get('jwt.accessTtl') }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/packages/backend/src/modules/auth/auth.service.ts b/packages/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..e0de5cd --- /dev/null +++ b/packages/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,158 @@ +import { + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import * as argon2 from 'argon2'; +import { authenticator } from 'otplib'; +import { createHash, randomUUID } from 'node:crypto'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { TenantContext } from '../../common/context/tenant-context'; +import { LoginDto, TokenResponseDto } from './dto/auth.dto'; +import { JwtPayload } from './strategies/jwt.strategy'; + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + private readonly config: ConfigService, + ) {} + + private hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } + + /** + * Resolves tenant from org slug (organizations is not RLS-protected), then + * looks up the user inside that tenant's RLS scope. Uniform failures avoid + * user-enumeration. Enforces lockout and MFA. + */ + async login(dto: LoginDto): Promise { + const org = await this.prisma.organization.findUnique({ + where: { slug: dto.organizationSlug }, + }); + if (!org || !org.isActive) throw new UnauthorizedException('Invalid credentials'); + + const user = await TenantContext.run({ organizationId: org.id }, () => + this.prisma.forRequest().user.findUnique({ + where: { organizationId_email: { organizationId: org.id, email: dto.email } }, + include: { userRoles: { include: { role: { include: { rolePermissions: { include: { permission: true } } } } } } }, + }), + ); + + if (!user || !user.passwordHash) throw new UnauthorizedException('Invalid credentials'); + if (user.lockedUntil && user.lockedUntil > new Date()) { + throw new UnauthorizedException('Account temporarily locked'); + } + + const ok = await argon2.verify(user.passwordHash, dto.password).catch(() => false); + if (!ok) { + await this.registerFailure(org.id, user.id, user.failedLogins); + throw new UnauthorizedException('Invalid credentials'); + } + + if (user.mfaMethod !== 'NONE') { + if (!dto.mfaCode || !user.mfaSecret) throw new UnauthorizedException('MFA code required'); + // NOTE: mfaSecret is KMS-encrypted at rest; decrypt before verify in prod. + const valid = authenticator.verify({ token: dto.mfaCode, secret: user.mfaSecret }); + if (!valid) throw new UnauthorizedException('Invalid MFA code'); + } + + const roles = user.userRoles.map((ur) => ur.role.key); + const permissions = Array.from( + new Set( + user.userRoles.flatMap((ur) => + ur.role.rolePermissions.map((rp) => rp.permission.key), + ), + ), + ); + + await this.recordLogin(org.id, user.id); + return this.issueTokens({ + sub: user.id, + org: org.id, + email: user.email, + roles, + permissions, + }); + } + + async refresh(refreshToken: string): Promise { + const tokenHash = this.hashToken(refreshToken); + const session = await this.prisma.session.findUnique({ + where: { refreshTokenHash: tokenHash }, + include: { + user: { + include: { userRoles: { include: { role: { include: { rolePermissions: { include: { permission: true } } } } } } }, + }, + }, + }); + if (!session || session.revokedAt || session.expiresAt < new Date()) { + throw new UnauthorizedException('Invalid refresh token'); + } + // Rotate: revoke old session, issue new pair. + await this.prisma.session.update({ + where: { id: session.id }, + data: { revokedAt: new Date() }, + }); + const u = session.user; + const roles = u.userRoles.map((ur) => ur.role.key); + const permissions = Array.from( + new Set(u.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key))), + ); + return this.issueTokens({ + sub: u.id, + org: u.organizationId, + email: u.email, + roles, + permissions, + }); + } + + async logout(refreshToken: string): Promise { + const tokenHash = this.hashToken(refreshToken); + await this.prisma.session + .updateMany({ where: { refreshTokenHash: tokenHash }, data: { revokedAt: new Date() } }) + .catch(() => undefined); + } + + private async issueTokens(payload: JwtPayload): Promise { + const accessTtl = this.config.get('jwt.accessTtl')!; + const refreshTtl = this.config.get('jwt.refreshTtl')!; + const accessToken = await this.jwt.signAsync(payload, { + secret: this.config.get('jwt.accessSecret'), + expiresIn: accessTtl, + }); + const refreshToken = randomUUID() + '.' + randomUUID(); + await this.prisma.session.create({ + data: { + userId: payload.sub, + refreshTokenHash: this.hashToken(refreshToken), + expiresAt: new Date(Date.now() + refreshTtl * 1000), + }, + }); + return { accessToken, refreshToken, expiresIn: accessTtl, tokenType: 'Bearer' }; + } + + private async registerFailure(orgId: string, userId: string, failed: number): Promise { + const next = failed + 1; + const lock = next >= 5 ? new Date(Date.now() + 15 * 60_000) : null; + await TenantContext.run({ organizationId: orgId }, () => + this.prisma.forRequest().user.update({ + where: { id: userId }, + data: { failedLogins: next, lockedUntil: lock }, + }), + ); + } + + private async recordLogin(orgId: string, userId: string): Promise { + await TenantContext.run({ organizationId: orgId }, () => + this.prisma.forRequest().user.update({ + where: { id: userId }, + data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date() }, + }), + ); + } +} diff --git a/packages/backend/src/modules/auth/dto/auth.dto.ts b/packages/backend/src/modules/auth/dto/auth.dto.ts new file mode 100644 index 0000000..21dc502 --- /dev/null +++ b/packages/backend/src/modules/auth/dto/auth.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; + +export class LoginDto { + @ApiProperty({ example: 'state-pi', description: 'Tenant (organization) slug' }) + @IsString() + organizationSlug!: string; + + @ApiProperty({ example: 'investigator@state-pi.gov' }) + @IsEmail() + email!: string; + + @ApiProperty({ example: 'correct horse battery staple' }) + @IsString() + @MinLength(8) + password!: string; + + @ApiProperty({ required: false, description: 'TOTP/SMS code when MFA enabled' }) + @IsOptional() + @IsString() + mfaCode?: string; +} + +export class RefreshDto { + @ApiProperty() + @IsString() + refreshToken!: string; +} + +export class TokenResponseDto { + @ApiProperty() + accessToken!: string; + @ApiProperty() + refreshToken!: string; + @ApiProperty() + expiresIn!: number; + @ApiProperty() + tokenType = 'Bearer'; +} diff --git a/packages/backend/src/modules/auth/strategies/jwt.strategy.ts b/packages/backend/src/modules/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..3bbb928 --- /dev/null +++ b/packages/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { AuthenticatedUser } from '../../../common/decorators/current-user.decorator'; + +export interface JwtPayload { + sub: string; // userId + org: string; // organizationId + email: string; + roles: string[]; + permissions: string[]; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor(config: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: config.get('jwt.accessSecret')!, + }); + } + + /** Return value is attached to request.user. */ + async validate(payload: JwtPayload): Promise { + return { + userId: payload.sub, + organizationId: payload.org, + email: payload.email, + roles: payload.roles ?? [], + permissions: payload.permissions ?? [], + }; + } +} diff --git a/packages/backend/src/modules/cases/cases.controller.ts b/packages/backend/src/modules/cases/cases.controller.ts new file mode 100644 index 0000000..48416b8 --- /dev/null +++ b/packages/backend/src/modules/cases/cases.controller.ts @@ -0,0 +1,83 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiPropertyOptional, ApiTags } from '@nestjs/swagger'; +import { CaseStatus } from '@prisma/client'; +import { IsEnum, IsOptional } from 'class-validator'; +import { RequirePermissions } from '../../common/decorators/permissions.decorator'; +import { paginate, PaginationQueryDto } from '../../common/dto/pagination.dto'; +import { CasesService } from './cases.service'; +import { + AddNoteDto, + AssignCaseDto, + CreateCaseDto, + UpdateCaseStatusDto, +} from './dto/cases.dto'; + +class ListCasesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: CaseStatus }) + @IsOptional() + @IsEnum(CaseStatus) + status?: CaseStatus; +} + +@ApiTags('cases') +@ApiBearerAuth('jwt') +@Controller('cases') +export class CasesController { + constructor(private readonly cases: CasesService) {} + + @Post() + @RequirePermissions('fraud_case:create') + @ApiOperation({ summary: 'Open an investigation case (optionally link events)' }) + create(@Body() dto: CreateCaseDto) { + return this.cases.create(dto); + } + + @Get() + @RequirePermissions('fraud_case:read') + @ApiOperation({ summary: 'List cases (priority-ordered)' }) + async list(@Query() query: ListCasesQueryDto) { + const { data, total } = await this.cases.list({ + status: query.status, + skip: query.skip, + take: query.take, + }); + return paginate(data, total, query); + } + + @Get(':id') + @RequirePermissions('fraud_case:read') + @ApiOperation({ summary: 'Case detail: events, notes, evidence, timeline' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.cases.findOne(id); + } + + @Patch(':id/assign') + @RequirePermissions('fraud_case:assign') + @ApiOperation({ summary: 'Assign a case to an investigator' }) + assign(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignCaseDto) { + return this.cases.assign(id, dto); + } + + @Patch(':id/status') + @RequirePermissions('fraud_case:update') + @ApiOperation({ summary: 'Update case status (substantiate/close/escalate)' }) + updateStatus(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCaseStatusDto) { + return this.cases.updateStatus(id, dto); + } + + @Post(':id/notes') + @RequirePermissions('fraud_case:update') + @ApiOperation({ summary: 'Add an investigator note' }) + addNote(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AddNoteDto) { + return this.cases.addNote(id, dto); + } +} diff --git a/packages/backend/src/modules/cases/cases.module.ts b/packages/backend/src/modules/cases/cases.module.ts new file mode 100644 index 0000000..7864967 --- /dev/null +++ b/packages/backend/src/modules/cases/cases.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CasesController } from './cases.controller'; +import { CasesService } from './cases.service'; + +@Module({ + controllers: [CasesController], + providers: [CasesService], + exports: [CasesService], +}) +export class CasesModule {} diff --git a/packages/backend/src/modules/cases/cases.service.ts b/packages/backend/src/modules/cases/cases.service.ts new file mode 100644 index 0000000..8c3cf07 --- /dev/null +++ b/packages/backend/src/modules/cases/cases.service.ts @@ -0,0 +1,122 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CaseStatus, Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { TenantContext } from '../../common/context/tenant-context'; +import { + AddNoteDto, + AssignCaseDto, + CreateCaseDto, + UpdateCaseStatusDto, +} from './dto/cases.dto'; + +@Injectable() +export class CasesService { + constructor(private readonly prisma: PrismaService) {} + + private ctx() { + return TenantContext.require(); + } + + /** RV-YYYY-NNNNNN, sequential per tenant per year. */ + private async nextCaseNumber(orgId: string): Promise { + const db = this.prisma.forRequest(); + const year = new Date().getFullYear(); + const count = await db.fraudCase.count({ + where: { caseNumber: { startsWith: `RV-${year}-` } }, + }); + return `RV-${year}-${String(count + 1).padStart(6, '0')}`; + } + + async create(dto: CreateCaseDto) { + const db = this.prisma.forRequest(); + const orgId = this.ctx().organizationId; + const caseNumber = await this.nextCaseNumber(orgId); + + const created = await db.fraudCase.create({ + data: { + organizationId: orgId, + caseNumber, + title: dto.title, + priority: dto.priority, + providerId: dto.providerId, + summary: dto.summary, + exposureCents: dto.exposureCents, + }, + }); + + if (dto.fraudEventIds?.length) { + await db.fraudEvent.updateMany({ + where: { id: { in: dto.fraudEventIds } }, + data: { caseId: created.id, status: 'LINKED_TO_CASE' }, + }); + } + return created; + } + + async list(params: { status?: CaseStatus; skip: number; take: number }) { + const db = this.prisma.forRequest(); + const where: Prisma.FraudCaseWhereInput = params.status ? { status: params.status } : {}; + const [data, total] = await Promise.all([ + db.fraudCase.findMany({ + where, + orderBy: [{ priority: 'desc' }, { openedAt: 'desc' }], + skip: params.skip, + take: params.take, + include: { assignee: { select: { id: true, firstName: true, lastName: true } }, _count: { select: { events: true } } }, + }), + db.fraudCase.count({ where }), + ]); + return { data, total }; + } + + async findOne(id: string) { + const db = this.prisma.forRequest(); + const found = await db.fraudCase.findUnique({ + where: { id }, + include: { + events: { orderBy: { detectedAt: 'desc' } }, + notes: { orderBy: { createdAt: 'desc' }, include: { author: { select: { firstName: true, lastName: true } } } }, + evidence: true, + assignee: { select: { id: true, firstName: true, lastName: true } }, + provider: { select: { id: true, legalName: true } }, + }, + }); + if (!found) throw new NotFoundException('Case not found'); + return found; + } + + async assign(id: string, dto: AssignCaseDto) { + const db = this.prisma.forRequest(); + await this.ensureExists(id); + return db.fraudCase.update({ where: { id }, data: { assigneeId: dto.assigneeId } }); + } + + async updateStatus(id: string, dto: UpdateCaseStatusDto) { + const db = this.prisma.forRequest(); + await this.ensureExists(id); + const closing = dto.status === CaseStatus.CLOSED || dto.status === CaseStatus.SUBSTANTIATED || dto.status === CaseStatus.UNSUBSTANTIATED; + return db.fraudCase.update({ + where: { id }, + data: { status: dto.status, closedAt: closing ? new Date() : null }, + }); + } + + async addNote(id: string, dto: AddNoteDto) { + const db = this.prisma.forRequest(); + await this.ensureExists(id); + return db.caseNote.create({ + data: { + caseId: id, + authorId: this.ctx().userId!, + body: dto.body, + isInternal: dto.isInternal ?? true, + }, + }); + } + + private async ensureExists(id: string) { + const db = this.prisma.forRequest(); + const exists = await db.fraudCase.findUnique({ where: { id }, select: { id: true } }); + if (!exists) throw new NotFoundException('Case not found'); + } +} diff --git a/packages/backend/src/modules/cases/dto/cases.dto.ts b/packages/backend/src/modules/cases/dto/cases.dto.ts new file mode 100644 index 0000000..75821c2 --- /dev/null +++ b/packages/backend/src/modules/cases/dto/cases.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { CasePriority, CaseStatus } from '@prisma/client'; +import { + IsArray, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; + +export class CreateCaseDto { + @ApiProperty() @IsString() @MaxLength(200) title!: string; + @ApiPropertyOptional({ enum: CasePriority }) @IsOptional() @IsEnum(CasePriority) priority?: CasePriority; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() providerId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() summary?: string; + @ApiPropertyOptional({ description: 'Fraud event ids to link on creation', type: [String] }) + @IsOptional() @IsArray() @IsUUID('4', { each: true }) + fraudEventIds?: string[]; + @ApiPropertyOptional({ description: 'Estimated exposure in cents' }) + @IsOptional() @IsInt() exposureCents?: number; +} + +export class UpdateCaseStatusDto { + @ApiProperty({ enum: CaseStatus }) @IsEnum(CaseStatus) status!: CaseStatus; +} + +export class AssignCaseDto { + @ApiProperty({ format: 'uuid' }) @IsUUID() assigneeId!: string; +} + +export class AddNoteDto { + @ApiProperty() @IsString() body!: string; + @ApiPropertyOptional({ default: true }) @IsOptional() isInternal?: boolean; +} diff --git a/packages/backend/src/modules/fraud/detectors/abnormal-duration.detector.ts b/packages/backend/src/modules/fraud/detectors/abnormal-duration.detector.ts new file mode 100644 index 0000000..9e9d65d --- /dev/null +++ b/packages/backend/src/modules/fraud/detectors/abnormal-duration.detector.ts @@ -0,0 +1,61 @@ +import { FraudEventType } from '@prisma/client'; +import { Detector, DetectionResult, VisitFeatureContext, notTriggered } from './types'; + +/** + * ABNORMAL_DURATION — visit duration deviates sharply from the baseline for its + * service code (z-score). Catches near-instant "drive-by" visits and implausibly + * long ones inflating units. + * + * z = (duration - mean) / std ; |z| >= 3 ⇒ anomaly + */ +export class AbnormalDurationDetector implements Detector { + readonly type = FraudEventType.ABNORMAL_DURATION; + readonly version = '1.0.0'; + + detect(ctx: VisitFeatureContext): DetectionResult { + const { visit, durationBaseline } = ctx; + const duration = visit.durationMinutes ?? deriveDuration(visit.clockInAt, visit.clockOutAt); + if (duration == null) return notTriggered(this.type, 'No duration available'); + + // Hard floor: any completed visit under 2 minutes is suspicious regardless. + if (duration < 2) { + return { + type: this.type, + triggered: true, + severity: 70, + explanation: `Visit lasted only ${duration.toFixed(1)} min — implausibly short for a service visit.`, + evidence: { durationMinutes: duration, rule: 'hard_floor_2min' }, + }; + } + + if (!durationBaseline || durationBaseline.stdMinutes <= 0) { + return notTriggered(this.type, 'No baseline to compare duration'); + } + + const z = (duration - durationBaseline.meanMinutes) / durationBaseline.stdMinutes; + if (Math.abs(z) < 3) { + return notTriggered(this.type, `Duration within normal range (z=${z.toFixed(2)})`); + } + + const severity = Math.min(100, 40 + Math.round((Math.abs(z) - 3) * 15)); + return { + type: this.type, + triggered: true, + severity, + explanation: + `Visit duration ${duration.toFixed(0)} min is ${z > 0 ? 'far above' : 'far below'} the ` + + `baseline for this service (mean ${durationBaseline.meanMinutes.toFixed(0)} min, z=${z.toFixed(1)}).`, + evidence: { + durationMinutes: duration, + baselineMean: durationBaseline.meanMinutes, + baselineStd: durationBaseline.stdMinutes, + zScore: +z.toFixed(2), + }, + }; + } +} + +function deriveDuration(start?: Date | null, end?: Date | null): number | null { + if (!start || !end) return null; + return (end.getTime() - start.getTime()) / 60_000; +} diff --git a/packages/backend/src/modules/fraud/detectors/duplicate-visit.detector.ts b/packages/backend/src/modules/fraud/detectors/duplicate-visit.detector.ts new file mode 100644 index 0000000..ea197f0 --- /dev/null +++ b/packages/backend/src/modules/fraud/detectors/duplicate-visit.detector.ts @@ -0,0 +1,46 @@ +import { FraudEventType } from '@prisma/client'; +import { Detector, DetectionResult, VisitFeatureContext, notTriggered } from './types'; + +/** + * DUPLICATE_VISIT — the same patient has another clock-in within a short window + * (possible double-billing or split-claim). Distinct caregivers on overlapping + * clock-ins for one patient is a stronger signal than the same caregiver. + */ +export class DuplicateVisitDetector implements Detector { + readonly type = FraudEventType.DUPLICATE_VISIT; + readonly version = '1.0.0'; + + detect(ctx: VisitFeatureContext): DetectionResult { + const { visit, patientRecentVisits, config } = ctx; + if (visit.clockInAt == null) return notTriggered(this.type, 'No clock-in time'); + + const windowMs = config.duplicateWindowMin * 60_000; + const t = visit.clockInAt.getTime(); + + const dupes = patientRecentVisits.filter((v) => { + if (v.id === visit.id || v.clockInAt == null) return false; + return Math.abs(v.clockInAt.getTime() - t) <= windowMs; + }); + + if (dupes.length === 0) { + return notTriggered(this.type, 'No overlapping visits for this patient'); + } + + const differentCaregiver = dupes.some((d) => d.caregiverId !== visit.caregiverId); + const severity = differentCaregiver ? 80 : 55; + + return { + type: this.type, + triggered: true, + severity, + explanation: + `Patient has ${dupes.length} other clock-in(s) within ${config.duplicateWindowMin} ` + + `minutes${differentCaregiver ? ' by a different caregiver' : ''} — possible duplicate/double-billing.`, + evidence: { + windowMinutes: config.duplicateWindowMin, + duplicateVisitIds: dupes.map((d) => d.id), + differentCaregiver, + }, + }; + } +} diff --git a/packages/backend/src/modules/fraud/detectors/gps-anomaly.detector.ts b/packages/backend/src/modules/fraud/detectors/gps-anomaly.detector.ts new file mode 100644 index 0000000..cf6e33c --- /dev/null +++ b/packages/backend/src/modules/fraud/detectors/gps-anomaly.detector.ts @@ -0,0 +1,58 @@ +import { FraudEventType } from '@prisma/client'; +import { haversineMeters } from '../../../common/util/geo'; +import { Detector, DetectionResult, VisitFeatureContext, notTriggered } from './types'; + +/** + * GPS_ANOMALY / GEOFENCE_BREACH — clock-in location vs. the authorized service + * address geofence. + * distance <= radius ⇒ PASS (no event) + * radius < distance <= 5x radius ⇒ FLAG (moderate severity) + * distance > 5x radius (major) ⇒ FAIL (high severity) + */ +export class GpsAnomalyDetector implements Detector { + readonly type = FraudEventType.GPS_ANOMALY; + readonly version = '1.0.0'; + + detect(ctx: VisitFeatureContext): DetectionResult { + const { visit, authorization } = ctx; + if ( + !authorization?.latitude || + !authorization?.longitude || + visit.clockInLat == null || + visit.clockInLng == null + ) { + return notTriggered(this.type, 'Insufficient geo data for geofence check'); + } + + const distance = haversineMeters( + { lat: visit.clockInLat, lng: visit.clockInLng }, + { lat: authorization.latitude, lng: authorization.longitude }, + ); + const radius = authorization.radiusMeters; + + if (distance <= radius) { + return notTriggered(this.type, `Inside approved radius (${distance.toFixed(0)}m ≤ ${radius}m)`); + } + + const major = distance > radius * 5; + const severity = major + ? Math.min(100, 75 + Math.round((distance / (radius * 5)) * 5)) + : Math.min(70, 35 + Math.round(((distance - radius) / radius) * 10)); + + return { + type: this.type, + triggered: true, + severity, + explanation: + `Clock-in was ${distance.toFixed(0)}m from the authorized service address ` + + `(approved radius ${radius}m). ${major ? 'Major discrepancy ⇒ FAIL.' : 'Outside radius ⇒ FLAG.'}`, + evidence: { + distanceMeters: Math.round(distance), + radiusMeters: radius, + decision: major ? 'FAIL' : 'FLAG', + clockIn: { lat: visit.clockInLat, lng: visit.clockInLng }, + authorized: { lat: authorization.latitude, lng: authorization.longitude }, + }, + }; + } +} diff --git a/packages/backend/src/modules/fraud/detectors/identity-mismatch.detector.ts b/packages/backend/src/modules/fraud/detectors/identity-mismatch.detector.ts new file mode 100644 index 0000000..71b2c31 --- /dev/null +++ b/packages/backend/src/modules/fraud/detectors/identity-mismatch.detector.ts @@ -0,0 +1,45 @@ +import { FraudEventType } from '@prisma/client'; +import { Detector, DetectionResult, VisitFeatureContext, notTriggered } from './types'; + +/** + * IDENTITY_MISMATCH / LIVENESS_FAILURE — the identity verification step failed + * or returned low confidence/liveness, indicating the person clocking in may not + * be the enrolled caregiver (or a spoof/presentation attack). + */ +export class IdentityMismatchDetector implements Detector { + readonly type = FraudEventType.IDENTITY_MISMATCH; + readonly version = '1.0.0'; + + detect(ctx: VisitFeatureContext): DetectionResult { + const { identity } = ctx; + if (!identity) return notTriggered(this.type, 'No identity verification recorded'); + + const conf = identity.confidenceScore ?? null; + const live = identity.livenessScore ?? null; + + if (identity.result === 'PASS') { + return notTriggered(this.type, 'Identity verification passed'); + } + + // FAIL is more severe than REVIEW; weak liveness adds a presentation-attack signal. + let severity = identity.result === 'FAIL' ? 75 : 45; + if (live != null && live < 0.9) severity = Math.min(100, severity + 15); + if (conf != null && conf < 0.7) severity = Math.min(100, severity + 10); + + return { + type: this.type, + triggered: true, + severity, + explanation: + `Identity verification returned ${identity.result}` + + (conf != null ? `, match confidence ${(conf * 100).toFixed(0)}%` : '') + + (live != null ? `, liveness ${(live * 100).toFixed(0)}%` : '') + + ' — caregiver identity could not be confirmed.', + evidence: { + result: identity.result, + confidenceScore: conf, + livenessScore: live, + }, + }; + } +} diff --git a/packages/backend/src/modules/fraud/detectors/impossible-travel.detector.ts b/packages/backend/src/modules/fraud/detectors/impossible-travel.detector.ts new file mode 100644 index 0000000..2e0bf10 --- /dev/null +++ b/packages/backend/src/modules/fraud/detectors/impossible-travel.detector.ts @@ -0,0 +1,72 @@ +import { FraudEventType } from '@prisma/client'; +import { haversineMeters, speedKmh } from '../../../common/util/geo'; +import { Detector, DetectionResult, VisitFeatureContext, notTriggered } from './types'; + +/** + * IMPOSSIBLE_TRAVEL — the caregiver appears at two locations too far apart to + * have plausibly traveled between in the elapsed time (e.g. two clock-ins 300km + * apart, 20 minutes apart ⇒ ~900 km/h ⇒ impossible by ground transport). + * + * impliedSpeed = haversine(prev, curr) / (t_curr - t_prev) + * triggered = impliedSpeed > config.impossibleTravelKmh + */ +export class ImpossibleTravelDetector implements Detector { + readonly type = FraudEventType.IMPOSSIBLE_TRAVEL; + readonly version = '1.0.0'; + + detect(ctx: VisitFeatureContext): DetectionResult { + const { visit, caregiverRecentVisits, config } = ctx; + if (visit.clockInAt == null || visit.clockInLat == null || visit.clockInLng == null) { + return notTriggered(this.type, 'No clock-in geo to compare'); + } + + const curr = { + t: visit.clockInAt.getTime(), + lat: visit.clockInLat, + lng: visit.clockInLng, + }; + + let worst: { speed: number; distance: number; deltaMin: number; otherVisitId: string } | null = + null; + + for (const prev of caregiverRecentVisits) { + if (prev.id === visit.id) continue; + const t = prev.clockInAt?.getTime(); + if (t == null || prev.clockInLat == null || prev.clockInLng == null) continue; + const distance = haversineMeters( + { lat: curr.lat, lng: curr.lng }, + { lat: prev.clockInLat, lng: prev.clockInLng }, + ); + const dt = Math.abs(curr.t - t); + const speed = speedKmh(distance, dt); + if (!worst || speed > worst.speed) { + worst = { speed, distance, deltaMin: dt / 60000, otherVisitId: prev.id }; + } + } + + if (!worst || worst.speed <= config.impossibleTravelKmh) { + return notTriggered(this.type, 'Travel between visits is physically plausible'); + } + + // Severity scales with how far the implied speed exceeds the threshold. + const ratio = worst.speed / config.impossibleTravelKmh; + const severity = Math.min(100, Math.round(60 + (ratio - 1) * 40)); + + return { + type: this.type, + triggered: true, + severity, + explanation: + `Implied travel speed of ${worst.speed.toFixed(0)} km/h between consecutive ` + + `clock-ins (${(worst.distance / 1000).toFixed(1)} km in ${worst.deltaMin.toFixed(0)} min) ` + + `exceeds the plausible threshold of ${config.impossibleTravelKmh} km/h.`, + evidence: { + impliedSpeedKmh: Math.round(worst.speed), + distanceKm: +(worst.distance / 1000).toFixed(2), + deltaMinutes: +worst.deltaMin.toFixed(1), + thresholdKmh: config.impossibleTravelKmh, + comparedVisitId: worst.otherVisitId, + }, + }; + } +} diff --git a/packages/backend/src/modules/fraud/detectors/shared-device.detector.ts b/packages/backend/src/modules/fraud/detectors/shared-device.detector.ts new file mode 100644 index 0000000..f365a18 --- /dev/null +++ b/packages/backend/src/modules/fraud/detectors/shared-device.detector.ts @@ -0,0 +1,54 @@ +import { FraudEventType } from '@prisma/client'; +import { Detector, DetectionResult, VisitFeatureContext, notTriggered } from './types'; + +/** + * SHARED_DEVICE / DEVICE_TAMPERING — one device used by an implausible number of + * distinct caregivers (badge-sharing / buddy-punching ring), or a device with + * tampering signals (emulator / rooted / jailbroken / BLOCKED trust). + */ +export class SharedDeviceDetector implements Detector { + readonly type = FraudEventType.SHARED_DEVICE; + readonly version = '1.0.0'; + + detect(ctx: VisitFeatureContext): DetectionResult { + const { device, deviceCaregiverCount = 0 } = ctx; + + const tamper = + device != null && + (device.isEmulator || device.isRooted || device.isJailbroken || device.trustLevel === 'BLOCKED'); + + const sharedThreshold = 3; // >3 caregivers on one device is suspicious + const shared = deviceCaregiverCount > sharedThreshold; + + if (!tamper && !shared) { + return notTriggered(this.type, 'No shared-device or tampering signal'); + } + + let severity = 0; + if (shared) severity += Math.min(60, 30 + (deviceCaregiverCount - sharedThreshold) * 10); + if (tamper) severity += 50; + severity = Math.min(100, severity); + + const reasons: string[] = []; + if (shared) reasons.push(`${deviceCaregiverCount} distinct caregivers used this device`); + if (device?.isEmulator) reasons.push('emulator detected'); + if (device?.isRooted) reasons.push('rooted device'); + if (device?.isJailbroken) reasons.push('jailbroken device'); + if (device?.trustLevel === 'BLOCKED') reasons.push('device trust level BLOCKED'); + + return { + type: this.type, + triggered: true, + severity, + explanation: `Device risk: ${reasons.join('; ')}.`, + evidence: { + deviceCaregiverCount, + sharedThreshold, + isEmulator: device?.isEmulator ?? false, + isRooted: device?.isRooted ?? false, + isJailbroken: device?.isJailbroken ?? false, + trustLevel: device?.trustLevel ?? 'UNKNOWN', + }, + }; + } +} diff --git a/packages/backend/src/modules/fraud/detectors/types.ts b/packages/backend/src/modules/fraud/detectors/types.ts new file mode 100644 index 0000000..9d31efd --- /dev/null +++ b/packages/backend/src/modules/fraud/detectors/types.ts @@ -0,0 +1,90 @@ +import { FraudEventType } from '@prisma/client'; + +/** + * Normalized, point-in-time view of a visit + its verification evidence and the + * caregiver/patient history a detector needs. Assembled by FraudService so each + * detector stays pure and unit-testable (no DB access inside detectors). + */ +export interface VisitFeatureContext { + visit: { + id: string; + organizationId: string; + caregiverId: string; + patientId: string; + providerId: string; + serviceCode?: string | null; + scheduledStart: Date; + scheduledEnd?: Date | null; + clockInAt?: Date | null; + clockOutAt?: Date | null; + durationMinutes?: number | null; + clockInLat?: number | null; + clockInLng?: number | null; + deviceId?: string | null; + billedUnits?: number | null; + }; + authorization?: { + latitude?: number | null; + longitude?: number | null; + radiusMeters: number; + authorizedUnits?: number | null; + } | null; + identity?: { + result: 'PASS' | 'REVIEW' | 'FAIL'; + confidenceScore?: number | null; + livenessScore?: number | null; + } | null; + device?: { + trustLevel: 'TRUSTED' | 'UNKNOWN' | 'SUSPICIOUS' | 'BLOCKED'; + isEmulator: boolean; + isRooted: boolean; + isJailbroken: boolean; + } | null; + /** Recent visits for the same caregiver (for impossible-travel / overlap). */ + caregiverRecentVisits: Array<{ + id: string; + clockInAt?: Date | null; + clockOutAt?: Date | null; + clockInLat?: number | null; + clockInLng?: number | null; + patientId: string; + }>; + /** Recent visits for the same patient (for duplicate detection). */ + patientRecentVisits: Array<{ + id: string; + caregiverId: string; + clockInAt?: Date | null; + }>; + /** Distinct caregivers seen on this device recently (shared-device). */ + deviceCaregiverCount?: number; + /** Baseline stats for ABNORMAL_DURATION (per service code). */ + durationBaseline?: { meanMinutes: number; stdMinutes: number }; + config: { + impossibleTravelKmh: number; + duplicateWindowMin: number; + }; +} + +export interface DetectionResult { + type: FraudEventType; + /** 0–100 contribution toward the composite fraud score. 0 = not triggered. */ + severity: number; + triggered: boolean; + /** Human-readable explanation (explainability requirement). */ + explanation: string; + /** Structured evidence persisted to fraud_events.evidence. */ + evidence: Record; +} + +export interface Detector { + readonly type: FraudEventType; + readonly version: string; + detect(ctx: VisitFeatureContext): DetectionResult; +} + +export function notTriggered( + type: FraudEventType, + reason = 'No anomaly detected', +): DetectionResult { + return { type, severity: 0, triggered: false, explanation: reason, evidence: {} }; +} diff --git a/packages/backend/src/modules/fraud/dto/fraud.dto.ts b/packages/backend/src/modules/fraud/dto/fraud.dto.ts new file mode 100644 index 0000000..8593f4a --- /dev/null +++ b/packages/backend/src/modules/fraud/dto/fraud.dto.ts @@ -0,0 +1,11 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { FraudEventType } from '@prisma/client'; +import { IsEnum, IsOptional } from 'class-validator'; +import { PaginationQueryDto } from '../../../common/dto/pagination.dto'; + +export class ListFraudEventsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: FraudEventType }) + @IsOptional() + @IsEnum(FraudEventType) + type?: FraudEventType; +} diff --git a/packages/backend/src/modules/fraud/fraud-scoring.service.ts b/packages/backend/src/modules/fraud/fraud-scoring.service.ts new file mode 100644 index 0000000..0d4e79a --- /dev/null +++ b/packages/backend/src/modules/fraud/fraud-scoring.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +import { FraudEventType, RiskLevel } from '@prisma/client'; +import { scoreToRiskLevel } from '../../common/util/risk'; +import { DetectionResult } from './detectors/types'; + +export interface ScoreFactor { + type: FraudEventType; + severity: number; + weight: number; + contribution: number; // share of the final score, 0-1 + explanation: string; +} + +export interface CompositeScore { + score: number; // 0-100 + riskLevel: RiskLevel; + factors: ScoreFactor[]; + triggeredCount: number; +} + +/** + * Fuses individual detector outputs into a single explainable 0–100 fraud score. + * + * We use a weighted noisy-OR: each detector contributes p_i = (severity/100)·w_i, + * combined as 1 − Π(1 − p_i). This: + * • naturally caps at 100 (no arbitrary clipping), + * • lets a single strong signal score high, + * • rewards multiple independent signals (collusion is rarely one red flag), + * • yields per-detector contributions for explainability (required). + */ +@Injectable() +export class FraudScoringService { + /** Relative trust/impact weight per detector. Tenant-overridable later. */ + private readonly weights: Record = { + [FraudEventType.IMPOSSIBLE_TRAVEL]: 1.0, + [FraudEventType.IDENTITY_MISMATCH]: 1.0, + [FraudEventType.GEOFENCE_BREACH]: 0.9, + [FraudEventType.GPS_ANOMALY]: 0.85, + [FraudEventType.DUPLICATE_VISIT]: 0.9, + [FraudEventType.SHARED_DEVICE]: 0.8, + [FraudEventType.DEVICE_TAMPERING]: 0.85, + [FraudEventType.LIVENESS_FAILURE]: 0.9, + [FraudEventType.ABNORMAL_DURATION]: 0.6, + [FraudEventType.EXCESSIVE_OVERTIME]: 0.6, + [FraudEventType.SERVICE_OVERLAP]: 0.7, + [FraudEventType.UNUSUAL_BILLING]: 0.7, + [FraudEventType.CROSS_PROVIDER_RISK]: 0.75, + }; + + fuse(results: DetectionResult[]): CompositeScore { + const triggered = results.filter((r) => r.triggered && r.severity > 0); + + if (triggered.length === 0) { + return { score: 0, riskLevel: RiskLevel.LOW, factors: [], triggeredCount: 0 }; + } + + let complement = 1; + const weighted = triggered.map((r) => { + const weight = this.weights[r.type] ?? 0.7; + const p = Math.min(1, (r.severity / 100) * weight); + complement *= 1 - p; + return { r, weight, p }; + }); + + const combined = 1 - complement; // 0..1 + const score = Math.round(combined * 100); + + // Attribute the final score across detectors proportional to their p_i. + const pSum = weighted.reduce((acc, w) => acc + w.p, 0) || 1; + const factors: ScoreFactor[] = weighted + .map(({ r, weight, p }) => ({ + type: r.type, + severity: r.severity, + weight, + contribution: +(p / pSum).toFixed(4), + explanation: r.explanation, + })) + .sort((a, b) => b.contribution - a.contribution); + + return { + score, + riskLevel: scoreToRiskLevel(score), + factors, + triggeredCount: triggered.length, + }; + } +} diff --git a/packages/backend/src/modules/fraud/fraud.controller.ts b/packages/backend/src/modules/fraud/fraud.controller.ts new file mode 100644 index 0000000..4d0e7af --- /dev/null +++ b/packages/backend/src/modules/fraud/fraud.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RequirePermissions } from '../../common/decorators/permissions.decorator'; +import { paginate } from '../../common/dto/pagination.dto'; +import { FraudService } from './fraud.service'; +import { ListFraudEventsQueryDto } from './dto/fraud.dto'; + +@ApiTags('fraud') +@ApiBearerAuth('jwt') +@Controller('fraud') +export class FraudController { + constructor(private readonly fraud: FraudService) {} + + @Get('events') + @RequirePermissions('fraud_event:read') + @ApiOperation({ summary: 'List fraud events (filterable by type)' }) + async listEvents(@Query() query: ListFraudEventsQueryDto) { + const { data, total } = await this.fraud.listEvents({ + type: query.type, + skip: query.skip, + take: query.take, + }); + return paginate(data, total, query); + } + + @Post('visits/:id/score') + @RequirePermissions('fraud:score') + @ApiOperation({ + summary: 'Run the fraud detectors against a visit and persist events + score', + }) + scoreVisit(@Param('id', ParseUUIDPipe) id: string) { + return this.fraud.scoreVisit(id); + } +} diff --git a/packages/backend/src/modules/fraud/fraud.module.ts b/packages/backend/src/modules/fraud/fraud.module.ts new file mode 100644 index 0000000..10ce576 --- /dev/null +++ b/packages/backend/src/modules/fraud/fraud.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { FraudController } from './fraud.controller'; +import { FraudService } from './fraud.service'; +import { FraudScoringService } from './fraud-scoring.service'; + +@Module({ + controllers: [FraudController], + providers: [FraudService, FraudScoringService], + exports: [FraudService, FraudScoringService], +}) +export class FraudModule {} diff --git a/packages/backend/src/modules/fraud/fraud.service.ts b/packages/backend/src/modules/fraud/fraud.service.ts new file mode 100644 index 0000000..bd72e11 --- /dev/null +++ b/packages/backend/src/modules/fraud/fraud.service.ts @@ -0,0 +1,223 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FraudEventType, Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { scoreToRiskLevel } from '../../common/util/risk'; +import { FraudScoringService, CompositeScore } from './fraud-scoring.service'; +import { Detector, DetectionResult, VisitFeatureContext } from './detectors/types'; +import { ImpossibleTravelDetector } from './detectors/impossible-travel.detector'; +import { GpsAnomalyDetector } from './detectors/gps-anomaly.detector'; +import { DuplicateVisitDetector } from './detectors/duplicate-visit.detector'; +import { SharedDeviceDetector } from './detectors/shared-device.detector'; +import { AbnormalDurationDetector } from './detectors/abnormal-duration.detector'; +import { IdentityMismatchDetector } from './detectors/identity-mismatch.detector'; + +export interface VisitScoringOutcome { + visitId: string; + composite: CompositeScore; + results: DetectionResult[]; + fraudEventIds: string[]; +} + +@Injectable() +export class FraudService { + private readonly logger = new Logger(FraudService.name); + + /** Rule-based detector registry. ML scorers are added via the async pipeline. */ + private readonly detectors: Detector[] = [ + new IdentityMismatchDetector(), + new GpsAnomalyDetector(), + new ImpossibleTravelDetector(), + new DuplicateVisitDetector(), + new SharedDeviceDetector(), + new AbnormalDurationDetector(), + ]; + + constructor( + private readonly prisma: PrismaService, + private readonly scoring: FraudScoringService, + private readonly config: ConfigService, + ) {} + + /** Pure evaluation: run all detectors over an assembled context and fuse. */ + evaluate(ctx: VisitFeatureContext): { results: DetectionResult[]; composite: CompositeScore } { + const results = this.detectors.map((d) => d.detect(ctx)); + const composite = this.scoring.fuse(results); + return { results, composite }; + } + + /** + * Loads a visit + evidence, scores it, and persists fraud_events + a + * fraud_score, then updates the visit's rolled-up risk. Assumes the caller has + * bound the tenant context (HTTP request or queue worker). + */ + async scoreVisit(visitId: string): Promise { + const db = this.prisma.forRequest(); + const visit = await db.visit.findUnique({ + where: { id: visitId }, + include: { + authorization: true, + device: true, + identityVerifications: { orderBy: { createdAt: 'desc' }, take: 1 }, + }, + }); + if (!visit) throw new NotFoundException('Visit not found'); + + const ctx = await this.buildContext(visit); + const { results, composite } = this.evaluate(ctx); + + const detectorByType = new Map(this.detectors.map((d) => [d.type, d])); + const fraudEventIds: string[] = []; + + for (const r of results) { + if (!r.triggered || r.severity <= 0) continue; + const det = detectorByType.get(r.type); + const created = await db.fraudEvent.create({ + data: { + organizationId: visit.organizationId, + visitId: visit.id, + type: r.type, + severity: r.severity, + riskLevel: scoreToRiskLevel(r.severity), + explanation: r.explanation, + evidence: r.evidence as Prisma.InputJsonValue, + detector: det?.constructor.name, + detectorVersion: det?.version, + }, + select: { id: true }, + }); + fraudEventIds.push(created.id); + } + + await db.fraudScore.create({ + data: { + organizationId: visit.organizationId, + subjectType: 'VISIT', + subjectId: visit.id, + score: composite.score, + riskLevel: composite.riskLevel, + factors: composite.factors as unknown as Prisma.InputJsonValue, + modelVersion: 'rules-1.0.0', + }, + }); + + await db.visit.update({ + where: { id: visit.id }, + data: { riskScore: composite.score, riskLevel: composite.riskLevel }, + }); + + const autoFlag = this.config.get('fraud.autoFlagScore') ?? 61; + if (composite.score >= autoFlag) { + this.logger.warn( + `Visit ${visit.id} scored ${composite.score} (${composite.riskLevel}) — flagged for review`, + ); + // In the full pipeline this fans out a notification and may open a case. + } + + return { visitId: visit.id, composite, results, fraudEventIds }; + } + + /** Assembles the point-in-time feature context a set of detectors needs. */ + private async buildContext( + visit: Prisma.VisitGetPayload<{ + include: { authorization: true; device: true; identityVerifications: true }; + }>, + ): Promise { + const db = this.prisma.forRequest(); + const lookback = new Date(Date.now() - 24 * 3600 * 1000); + + const [caregiverRecent, patientRecent, deviceCaregivers] = await Promise.all([ + db.visit.findMany({ + where: { caregiverId: visit.caregiverId, clockInAt: { gte: lookback } }, + select: { id: true, clockInAt: true, clockOutAt: true, clockInLat: true, clockInLng: true, patientId: true }, + take: 50, + }), + db.visit.findMany({ + where: { patientId: visit.patientId, clockInAt: { gte: lookback } }, + select: { id: true, caregiverId: true, clockInAt: true }, + take: 50, + }), + visit.deviceId + ? db.visit.findMany({ + where: { deviceId: visit.deviceId, clockInAt: { gte: lookback } }, + select: { caregiverId: true }, + take: 200, + }) + : Promise.resolve([] as { caregiverId: string }[]), + ]); + + const identity = visit.identityVerifications[0]; + + return { + visit: { + id: visit.id, + organizationId: visit.organizationId, + caregiverId: visit.caregiverId, + patientId: visit.patientId, + providerId: visit.providerId, + serviceCode: visit.serviceCode, + scheduledStart: visit.scheduledStart, + scheduledEnd: visit.scheduledEnd, + clockInAt: visit.clockInAt, + clockOutAt: visit.clockOutAt, + durationMinutes: visit.durationMinutes, + clockInLat: visit.clockInLat ? Number(visit.clockInLat) : null, + clockInLng: visit.clockInLng ? Number(visit.clockInLng) : null, + deviceId: visit.deviceId, + billedUnits: visit.billedUnits, + }, + authorization: visit.authorization + ? { + latitude: visit.authorization.latitude ? Number(visit.authorization.latitude) : null, + longitude: visit.authorization.longitude ? Number(visit.authorization.longitude) : null, + radiusMeters: visit.authorization.radiusMeters, + authorizedUnits: visit.authorization.authorizedUnits, + } + : null, + identity: identity + ? { + result: identity.result, + confidenceScore: identity.confidenceScore ? Number(identity.confidenceScore) : null, + livenessScore: identity.livenessScore ? Number(identity.livenessScore) : null, + } + : null, + device: visit.device + ? { + trustLevel: visit.device.trustLevel, + isEmulator: visit.device.isEmulator, + isRooted: visit.device.isRooted, + isJailbroken: visit.device.isJailbroken, + } + : null, + caregiverRecentVisits: caregiverRecent.map((v) => ({ + id: v.id, + clockInAt: v.clockInAt, + clockOutAt: v.clockOutAt, + clockInLat: v.clockInLat ? Number(v.clockInLat) : null, + clockInLng: v.clockInLng ? Number(v.clockInLng) : null, + patientId: v.patientId, + })), + patientRecentVisits: patientRecent.map((v) => ({ + id: v.id, + caregiverId: v.caregiverId, + clockInAt: v.clockInAt, + })), + deviceCaregiverCount: new Set(deviceCaregivers.map((d) => d.caregiverId)).size, + config: { + impossibleTravelKmh: this.config.get('fraud.impossibleTravelKmh') ?? 900, + duplicateWindowMin: this.config.get('fraud.duplicateWindowMin') ?? 10, + }, + }; + } + + /** Lists fraud events for the tenant with optional type/status filters. */ + async listEvents(params: { type?: FraudEventType; skip: number; take: number }) { + const db = this.prisma.forRequest(); + const where: Prisma.FraudEventWhereInput = params.type ? { type: params.type } : {}; + const [data, total] = await Promise.all([ + db.fraudEvent.findMany({ where, orderBy: { detectedAt: 'desc' }, skip: params.skip, take: params.take }), + db.fraudEvent.count({ where }), + ]); + return { data, total }; + } +} diff --git a/packages/backend/src/modules/hardware/hardware-registry.service.ts b/packages/backend/src/modules/hardware/hardware-registry.service.ts new file mode 100644 index 0000000..37968a1 --- /dev/null +++ b/packages/backend/src/modules/hardware/hardware-registry.service.ts @@ -0,0 +1,43 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HardwareCapability, HardwareDriver } from './sdk/hardware.types'; + +/** + * Central registry for hardware drivers. Future devices self-register their + * driver here at boot; the engines resolve a capability (e.g. FACE, NFC) without + * knowing the concrete device. Nothing is registered today — the platform runs + * fully on software providers — but the seam exists so hardware drops in cleanly. + */ +@Injectable() +export class HardwareRegistryService { + private readonly logger = new Logger(HardwareRegistryService.name); + private readonly drivers = new Map(); + + register(driver: HardwareDriver): void { + if (this.drivers.has(driver.capability)) { + this.logger.warn(`Overriding existing driver for ${driver.capability}`); + } + this.drivers.set(driver.capability, driver); + this.logger.log(`Registered hardware driver ${driver.id} (${driver.capability})`); + } + + get(capability: HardwareCapability): T | undefined { + return this.drivers.get(capability) as T | undefined; + } + + has(capability: HardwareCapability): boolean { + return this.drivers.has(capability); + } + + /** Capabilities the SDK supports vs. what is actually registered/online. */ + async catalog() { + const supported = Object.values(HardwareCapability); + const registered = await Promise.all( + [...this.drivers.values()].map(async (d) => ({ + capability: d.capability, + driver: d.id, + health: await d.health().catch(() => 'OFFLINE' as const), + })), + ); + return { supported, registered }; + } +} diff --git a/packages/backend/src/modules/hardware/hardware.controller.ts b/packages/backend/src/modules/hardware/hardware.controller.ts new file mode 100644 index 0000000..cdd27bd --- /dev/null +++ b/packages/backend/src/modules/hardware/hardware.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { HardwareRegistryService } from './hardware-registry.service'; + +@ApiTags('hardware') +@ApiBearerAuth('jwt') +@Controller('hardware') +export class HardwareController { + constructor(private readonly registry: HardwareRegistryService) {} + + @Get('capabilities') + @ApiOperation({ + summary: 'List supported hardware capabilities and registered drivers', + }) + capabilities() { + return this.registry.catalog(); + } +} diff --git a/packages/backend/src/modules/hardware/hardware.module.ts b/packages/backend/src/modules/hardware/hardware.module.ts new file mode 100644 index 0000000..7e945d9 --- /dev/null +++ b/packages/backend/src/modules/hardware/hardware.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { HardwareController } from './hardware.controller'; +import { HardwareRegistryService } from './hardware-registry.service'; + +@Module({ + controllers: [HardwareController], + providers: [HardwareRegistryService], + exports: [HardwareRegistryService], +}) +export class HardwareModule {} diff --git a/packages/backend/src/modules/hardware/sdk/devices.ts b/packages/backend/src/modules/hardware/sdk/devices.ts new file mode 100644 index 0000000..dcd2ee9 --- /dev/null +++ b/packages/backend/src/modules/hardware/sdk/devices.ts @@ -0,0 +1,76 @@ +import { + HardwareCapability, + HardwareDriver, + HardwareResult, +} from './hardware.types'; + +/** + * Capability-specific driver contracts. Future hardware ships a class + * implementing the matching interface and registers it with HardwareRegistry. + */ + +export interface NfcReadResult { + /** Credential identifier read from the card/tag. */ + uid: string; + /** Government/credential payload (e.g. PIV/CAC, mobile DL). */ + credential?: Record; +} +export interface NfcReader extends HardwareDriver { + readonly capability: HardwareCapability.NFC; + read(timeoutMs?: number): Promise>; +} + +export interface FingerprintResult { + /** Opaque, irreversible template reference (never the raw minutiae). */ + templateRef: string; + matchScore?: number; // 0..1 if verifying against an enrollment +} +export interface FingerprintScanner extends HardwareDriver { + readonly capability: HardwareCapability.FINGERPRINT; + capture(): Promise>; + verify(enrolledTemplateRef: string): Promise>; +} + +export interface FaceCaptureResult { + imageS3Key?: string; + embeddingRef?: string; + liveness?: number; // 0..1 + confidence?: number; // 0..1 vs enrolled +} +export interface FacialRecognitionCamera extends HardwareDriver { + readonly capability: HardwareCapability.FACE; + captureWithLiveness(): Promise>; +} + +export interface AttestationResult { + /** Signed attestation proving the capture ran on trusted hardware. */ + attestation: string; + publicKeyRef: string; +} +export interface SecureElement extends HardwareDriver { + readonly capability: HardwareCapability.SECURE_ELEMENT; + sign(payload: string): Promise>; + attest(nonce: string): Promise>; +} + +export interface GpsFixResult { + lat: number; + lng: number; + accuracyMeters: number; + /** True if the fix is hardware-asserted (harder to spoof than browser geo). */ + hardwareAsserted: boolean; +} +export interface GpsModule extends HardwareDriver { + readonly capability: HardwareCapability.GPS; + getFix(timeoutMs?: number): Promise>; +} + +export interface LteIdentityResult { + imei?: string; + imsiRef?: string; // tokenized; never store raw IMSI + carrier?: string; +} +export interface LteModem extends HardwareDriver { + readonly capability: HardwareCapability.LTE; + identity(): Promise>; +} diff --git a/packages/backend/src/modules/hardware/sdk/hardware.types.ts b/packages/backend/src/modules/hardware/sdk/hardware.types.ts new file mode 100644 index 0000000..408e9ad --- /dev/null +++ b/packages/backend/src/modules/hardware/sdk/hardware.types.ts @@ -0,0 +1,49 @@ +/** + * RayVerify Hardware SDK — common types. + * + * The platform talks to all future hardware through these abstractions, so a new + * device only needs a driver implementing the relevant interface — no changes to + * the identity/visit/fraud engines. Each capability maps to an IdentityMethod or + * an evidence source already modeled in the schema. + */ + +export enum HardwareCapability { + NFC = 'NFC', // smart-card / credential tap + FINGERPRINT = 'FINGERPRINT', // fingerprint biometrics + FACE = 'FACE', // facial-recognition camera + SECURE_ELEMENT = 'SECURE_ELEMENT', // hardware-backed key store / attestation + GPS = 'GPS', // dedicated GNSS module + LTE = 'LTE', // cellular connectivity / SIM identity +} + +export interface HardwareDeviceInfo { + /** Stable hardware id / serial. */ + serial: string; + vendor: string; + model: string; + firmwareVersion?: string; + capabilities: HardwareCapability[]; +} + +export type HealthState = 'ONLINE' | 'DEGRADED' | 'OFFLINE'; + +export interface HardwareResult { + ok: boolean; + capability: HardwareCapability; + /** Driver id + version for audit/reproducibility. */ + driver: string; + data?: T; + error?: string; + capturedAt: string; // ISO-8601 +} + +/** Lifecycle every driver implements. */ +export interface HardwareDriver { + readonly id: string; // e.g. "acme-nfc@1.2.0" + readonly capability: HardwareCapability; + info(): Promise; + health(): Promise; + /** Initialize/attach to the physical device. */ + connect(): Promise; + disconnect(): Promise; +} diff --git a/packages/backend/src/modules/health/health.module.ts b/packages/backend/src/modules/health/health.module.ts new file mode 100644 index 0000000..b0a23ed --- /dev/null +++ b/packages/backend/src/modules/health/health.module.ts @@ -0,0 +1,17 @@ +import { Controller, Get } from '@nestjs/common'; +import { Module } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Public } from '../../common/decorators/public.decorator'; + +@ApiTags('health') +@Controller('health') +class HealthController { + @Public() + @Get() + check() { + return { status: 'ok', service: 'rayverify-api', time: new Date().toISOString() }; + } +} + +@Module({ controllers: [HealthController] }) +export class HealthModule {} diff --git a/packages/backend/src/modules/identity/dto/identity.dto.ts b/packages/backend/src/modules/identity/dto/identity.dto.ts new file mode 100644 index 0000000..7930544 --- /dev/null +++ b/packages/backend/src/modules/identity/dto/identity.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsObject, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; + +class SimulateScoresDto { + @ApiPropertyOptional({ minimum: 0, maximum: 1 }) + @IsOptional() + confidence?: number; + @ApiPropertyOptional({ minimum: 0, maximum: 1 }) + @IsOptional() + liveness?: number; +} + +export class VerifyIdentityDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + caregiverId!: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Associate with a visit' }) + @IsOptional() + @IsUUID() + visitId?: string; + + @ApiPropertyOptional({ description: 'S3 key of the captured selfie/probe image' }) + @IsOptional() + @IsString() + probeS3Key?: string; + + @ApiPropertyOptional({ description: 'Dev/demo override for matcher scores' }) + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => SimulateScoresDto) + simulate?: SimulateScoresDto; +} diff --git a/packages/backend/src/modules/identity/identity.controller.ts b/packages/backend/src/modules/identity/identity.controller.ts new file mode 100644 index 0000000..b3263eb --- /dev/null +++ b/packages/backend/src/modules/identity/identity.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RequirePermissions } from '../../common/decorators/permissions.decorator'; +import { IdentityService } from './identity.service'; +import { VerifyIdentityDto } from './dto/identity.dto'; + +@ApiTags('identity') +@ApiBearerAuth('jwt') +@Controller('identity') +export class IdentityController { + constructor(private readonly identity: IdentityService) {} + + @Post('verify') + @RequirePermissions('identity:verify') + @ApiOperation({ + summary: 'Run selfie + liveness identity verification for a caregiver', + }) + verify(@Body() dto: VerifyIdentityDto) { + return this.identity.verify(dto); + } +} diff --git a/packages/backend/src/modules/identity/identity.module.ts b/packages/backend/src/modules/identity/identity.module.ts new file mode 100644 index 0000000..aa51eca --- /dev/null +++ b/packages/backend/src/modules/identity/identity.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { IdentityController } from './identity.controller'; +import { IdentityService } from './identity.service'; +import { IDENTITY_PROVIDER } from './providers/identity-provider.interface'; +import { StubIdentityProvider } from './providers/stub-identity.provider'; + +/** + * The identity provider is bound here. Swap StubIdentityProvider for a vendor + * (e.g. RekognitionIdentityProvider) or a hardware-backed provider without + * changing IdentityService. + */ +@Module({ + controllers: [IdentityController], + providers: [ + IdentityService, + { provide: IDENTITY_PROVIDER, useClass: StubIdentityProvider }, + ], + exports: [IdentityService], +}) +export class IdentityModule {} diff --git a/packages/backend/src/modules/identity/identity.service.ts b/packages/backend/src/modules/identity/identity.service.ts new file mode 100644 index 0000000..8b6dc29 --- /dev/null +++ b/packages/backend/src/modules/identity/identity.service.ts @@ -0,0 +1,106 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { IdentityMethod, Prisma, VerificationResult } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { + IDENTITY_PROVIDER, + IdentityProvider, +} from './providers/identity-provider.interface'; +import { VerifyIdentityDto } from './dto/identity.dto'; + +export interface IdentityVerificationOutcome { + id: string; + result: VerificationResult; + confidence: number; + liveness: number; + reasons: string[]; +} + +@Injectable() +export class IdentityService { + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + @Inject(IDENTITY_PROVIDER) private readonly provider: IdentityProvider, + ) {} + + /** + * Identity workflow: capture selfie → liveness → compare to enrolled face → + * confidence score → store append-only verification event. + */ + async verify(dto: VerifyIdentityDto): Promise { + const db = this.prisma.forRequest(); + + const caregiver = await db.caregiver.findUnique({ + where: { id: dto.caregiverId }, + include: { enrollments: { where: { isActive: true }, take: 1 } }, + }); + if (!caregiver) throw new NotFoundException('Caregiver not found'); + + const enrollment = caregiver.enrollments[0]; + const match = await this.provider.compare({ + probeS3Key: dto.probeS3Key, + referenceS3Key: enrollment?.referenceS3Key, + templateRef: enrollment?.templateRef, + simulate: dto.simulate, + }); + + const matchThreshold = this.config.get('identity.matchThreshold') ?? 0.82; + const livenessThreshold = this.config.get('identity.livenessThreshold') ?? 0.9; + const { result, reasons } = this.decide(match.confidence, match.liveness, matchThreshold, livenessThreshold, !!enrollment); + + const record = await db.identityVerification.create({ + data: { + organizationId: caregiver.organizationId, + visitId: dto.visitId ?? null, + caregiverId: caregiver.id, + method: IdentityMethod.SELFIE, + result, + confidenceScore: new Prisma.Decimal(match.confidence.toFixed(4)), + livenessScore: new Prisma.Decimal(match.liveness.toFixed(4)), + probeS3Key: dto.probeS3Key, + matcher: match.matcher, + reasons: reasons as unknown as Prisma.InputJsonValue, + }, + select: { id: true }, + }); + + return { id: record.id, result, confidence: match.confidence, liveness: match.liveness, reasons }; + } + + private decide( + confidence: number, + liveness: number, + matchThreshold: number, + livenessThreshold: number, + hasEnrollment: boolean, + ): { result: VerificationResult; reasons: string[] } { + const reasons: string[] = []; + if (!hasEnrollment) reasons.push('No active biometric enrollment on file'); + + const matchOk = confidence >= matchThreshold; + const livenessOk = liveness >= livenessThreshold; + const matchBorderline = confidence >= matchThreshold * 0.85; + const livenessBorderline = liveness >= livenessThreshold * 0.85; + + reasons.push( + `Face match ${(confidence * 100).toFixed(0)}% vs threshold ${(matchThreshold * 100).toFixed(0)}%`, + ); + reasons.push( + `Liveness ${(liveness * 100).toFixed(0)}% vs threshold ${(livenessThreshold * 100).toFixed(0)}%`, + ); + + let result: VerificationResult; + if (hasEnrollment && matchOk && livenessOk) { + result = VerificationResult.PASS; + } else if (hasEnrollment && matchBorderline && livenessBorderline) { + result = VerificationResult.REVIEW; + reasons.push('Borderline scores — routed for manual review'); + } else { + result = VerificationResult.FAIL; + if (!livenessOk) reasons.push('Possible presentation/spoof attack (low liveness)'); + if (!matchOk) reasons.push('Face does not match enrolled caregiver'); + } + return { result, reasons }; + } +} diff --git a/packages/backend/src/modules/identity/providers/identity-provider.interface.ts b/packages/backend/src/modules/identity/providers/identity-provider.interface.ts new file mode 100644 index 0000000..a123436 --- /dev/null +++ b/packages/backend/src/modules/identity/providers/identity-provider.interface.ts @@ -0,0 +1,30 @@ +/** + * Abstraction over the biometric matcher (selfie face-match + liveness). Lets us + * swap the stub for a vendor (AWS Rekognition, a NIST-tested SDK) or, later, the + * hardware face/fingerprint modules — without touching the verification flow. + */ +export interface IdentityCompareInput { + /** S3 key of the just-captured probe image. */ + probeS3Key?: string; + /** Enrolled reference image / template pointers. */ + referenceS3Key?: string | null; + templateRef?: string | null; + /** Optional dev override to force scores in tests/demos. */ + simulate?: { confidence?: number; liveness?: number }; +} + +export interface IdentityCompareResult { + /** 0..1 face-match confidence. */ + confidence: number; + /** 0..1 liveness (anti-spoofing) probability. */ + liveness: number; + /** Matcher identifier + version for reproducibility/audit. */ + matcher: string; +} + +export abstract class IdentityProvider { + abstract readonly name: string; + abstract compare(input: IdentityCompareInput): Promise; +} + +export const IDENTITY_PROVIDER = Symbol('IDENTITY_PROVIDER'); diff --git a/packages/backend/src/modules/identity/providers/stub-identity.provider.ts b/packages/backend/src/modules/identity/providers/stub-identity.provider.ts new file mode 100644 index 0000000..85ff4cd --- /dev/null +++ b/packages/backend/src/modules/identity/providers/stub-identity.provider.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { + IdentityCompareInput, + IdentityCompareResult, + IdentityProvider, +} from './identity-provider.interface'; + +/** + * Deterministic local matcher for development/CI. Returns high scores by default + * (happy path) but honors `simulate` overrides so flows like REVIEW/FAIL and the + * fraud IDENTITY_MISMATCH detector can be exercised end-to-end without a vendor. + * + * Replace with a real provider in production — see IdentityProvider. + */ +@Injectable() +export class StubIdentityProvider extends IdentityProvider { + readonly name = 'stub-matcher@1.0.0'; + + async compare(input: IdentityCompareInput): Promise { + return { + confidence: input.simulate?.confidence ?? 0.96, + liveness: input.simulate?.liveness ?? 0.98, + matcher: this.name, + }; + } +} diff --git a/packages/backend/src/modules/notifications/notifications.controller.ts b/packages/backend/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..232943e --- /dev/null +++ b/packages/backend/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Param, ParseUUIDPipe, Patch, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { paginate, PaginationQueryDto } from '../../common/dto/pagination.dto'; +import { NotificationsService } from './notifications.service'; + +@ApiTags('notifications') +@ApiBearerAuth('jwt') +@Controller('notifications') +export class NotificationsController { + constructor(private readonly notifications: NotificationsService) {} + + @Get() + @ApiOperation({ summary: 'List the current user’s notifications' }) + async list(@Query() query: PaginationQueryDto) { + const { data, total } = await this.notifications.listForCurrentUser({ skip: query.skip, take: query.take }); + return paginate(data, total, query); + } + + @Patch(':id/read') + @ApiOperation({ summary: 'Mark a notification read' }) + markRead(@Param('id', ParseUUIDPipe) id: string) { + return this.notifications.markRead(id); + } +} diff --git a/packages/backend/src/modules/notifications/notifications.module.ts b/packages/backend/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..07620bb --- /dev/null +++ b/packages/backend/src/modules/notifications/notifications.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; + +@Global() +@Module({ + controllers: [NotificationsController], + providers: [NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/packages/backend/src/modules/notifications/notifications.service.ts b/packages/backend/src/modules/notifications/notifications.service.ts new file mode 100644 index 0000000..1ff718d --- /dev/null +++ b/packages/backend/src/modules/notifications/notifications.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationChannel, Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { TenantContext } from '../../common/context/tenant-context'; + +@Injectable() +export class NotificationsService { + constructor(private readonly prisma: PrismaService) {} + + /** Emits a notification (used by the fraud pipeline for HIGH/CRITICAL alerts). */ + async emit(input: { + userId?: string; + title: string; + body?: string; + channel?: NotificationChannel; + data?: Record; + }) { + const ctx = TenantContext.require(); + const db = this.prisma.forRequest(); + return db.notification.create({ + data: { + organizationId: ctx.organizationId, + userId: input.userId, + channel: input.channel ?? NotificationChannel.IN_APP, + title: input.title, + body: input.body, + data: (input.data ?? {}) as Prisma.InputJsonValue, + }, + }); + } + + async listForCurrentUser(params: { skip: number; take: number }) { + const ctx = TenantContext.require(); + const db = this.prisma.forRequest(); + const where: Prisma.NotificationWhereInput = { userId: ctx.userId }; + const [data, total] = await Promise.all([ + db.notification.findMany({ where, orderBy: { createdAt: 'desc' }, skip: params.skip, take: params.take }), + db.notification.count({ where }), + ]); + return { data, total }; + } + + async markRead(id: string) { + const db = this.prisma.forRequest(); + return db.notification.update({ where: { id }, data: { status: 'READ', readAt: new Date() } }); + } +} diff --git a/packages/backend/src/modules/providers/providers.controller.ts b/packages/backend/src/modules/providers/providers.controller.ts new file mode 100644 index 0000000..8056d11 --- /dev/null +++ b/packages/backend/src/modules/providers/providers.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RequirePermissions } from '../../common/decorators/permissions.decorator'; +import { paginate, PaginationQueryDto } from '../../common/dto/pagination.dto'; +import { ProvidersService } from './providers.service'; + +@ApiTags('providers') +@ApiBearerAuth('jwt') +@Controller('providers') +export class ProvidersController { + constructor(private readonly providers: ProvidersService) {} + + @Get('risk-ranking') + @RequirePermissions('provider:read') + @ApiOperation({ summary: 'Provider risk ranking (highest risk first)' }) + async ranking(@Query() query: PaginationQueryDto) { + const { data, total } = await this.providers.ranking({ skip: query.skip, take: query.take }); + return paginate(data, total, query); + } + + @Get(':id/risk-profile') + @RequirePermissions('provider:read') + @ApiOperation({ summary: 'Provider risk profile with historical trend' }) + profile(@Param('id', ParseUUIDPipe) id: string) { + return this.providers.getProfile(id); + } + + @Post(':id/risk-profile/recompute') + @RequirePermissions('provider:score') + @ApiOperation({ summary: 'Recompute a provider risk profile from current signals' }) + recompute(@Param('id', ParseUUIDPipe) id: string) { + return this.providers.recompute(id); + } +} diff --git a/packages/backend/src/modules/providers/providers.module.ts b/packages/backend/src/modules/providers/providers.module.ts new file mode 100644 index 0000000..2764766 --- /dev/null +++ b/packages/backend/src/modules/providers/providers.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ProvidersController } from './providers.controller'; +import { ProvidersService } from './providers.service'; + +@Module({ + controllers: [ProvidersController], + providers: [ProvidersService], + exports: [ProvidersService], +}) +export class ProvidersModule {} diff --git a/packages/backend/src/modules/providers/providers.service.ts b/packages/backend/src/modules/providers/providers.service.ts new file mode 100644 index 0000000..f09b998 --- /dev/null +++ b/packages/backend/src/modules/providers/providers.service.ts @@ -0,0 +1,117 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { FraudEventType, Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { TenantContext } from '../../common/context/tenant-context'; +import { clampScore, scoreToRiskLevel } from '../../common/util/risk'; + +interface TrendPoint { + t: string; + score: number; +} + +@Injectable() +export class ProvidersService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Recomputes a provider's dynamic risk profile from aggregated signals: + * verification failures, GPS/billing/identity anomalies, and case history. + * Persists the snapshot and appends to the historical trend. + */ + async recompute(providerId: string) { + const db = this.prisma.forRequest(); + const orgId = TenantContext.require().organizationId; + + const provider = await db.provider.findUnique({ where: { id: providerId }, select: { id: true } }); + if (!provider) throw new NotFoundException('Provider not found'); + + const byTypes = (types: FraudEventType[]) => + db.fraudEvent.count({ where: { type: { in: types }, visit: { providerId } } }); + + const [ + verificationFailures, + gpsAnomalies, + billingAnomalies, + identityIssues, + openCases, + substantiatedCases, + ] = await Promise.all([ + db.visitVerification.count({ where: { result: 'FAIL', visit: { providerId } } }), + byTypes([FraudEventType.GPS_ANOMALY, FraudEventType.GEOFENCE_BREACH]), + byTypes([FraudEventType.UNUSUAL_BILLING, FraudEventType.EXCESSIVE_OVERTIME, FraudEventType.ABNORMAL_DURATION]), + byTypes([FraudEventType.IDENTITY_MISMATCH, FraudEventType.LIVENESS_FAILURE]), + db.fraudCase.count({ where: { providerId, status: { in: ['OPEN', 'IN_REVIEW', 'ESCALATED', 'PENDING_PAYMENT_HOLD'] } } }), + db.fraudCase.count({ where: { providerId, status: 'SUBSTANTIATED' } }), + ]); + + const currentScore = clampScore( + verificationFailures * 5 + + gpsAnomalies * 4 + + billingAnomalies * 6 + + identityIssues * 7 + + openCases * 3 + + substantiatedCases * 15, + ); + const riskLevel = scoreToRiskLevel(currentScore); + + const existing = await db.providerRiskProfile.findUnique({ where: { providerId } }); + const trend: TrendPoint[] = Array.isArray(existing?.trend) ? (existing!.trend as unknown as TrendPoint[]) : []; + trend.push({ t: new Date().toISOString(), score: currentScore }); + const trimmed = trend.slice(-90); // keep last 90 points + + return db.providerRiskProfile.upsert({ + where: { providerId }, + create: { + organizationId: orgId, + providerId, + currentScore, + riskLevel, + verificationFailures, + gpsAnomalies, + billingAnomalies, + identityIssues, + openCases, + substantiatedCases, + trend: trimmed as unknown as Prisma.InputJsonValue, + lastComputedAt: new Date(), + }, + update: { + currentScore, + riskLevel, + verificationFailures, + gpsAnomalies, + billingAnomalies, + identityIssues, + openCases, + substantiatedCases, + trend: trimmed as unknown as Prisma.InputJsonValue, + lastComputedAt: new Date(), + }, + }); + } + + /** Risk ranking for the investigator dashboard. */ + async ranking(params: { skip: number; take: number }) { + const db = this.prisma.forRequest(); + const [data, total] = await Promise.all([ + db.providerRiskProfile.findMany({ + orderBy: { currentScore: 'desc' }, + skip: params.skip, + take: params.take, + include: { provider: { select: { id: true, legalName: true, npi: true } } }, + }), + db.providerRiskProfile.count(), + ]); + return { data, total }; + } + + async getProfile(providerId: string) { + const db = this.prisma.forRequest(); + const profile = await db.providerRiskProfile.findUnique({ + where: { providerId }, + include: { provider: { select: { id: true, legalName: true, npi: true } } }, + }); + if (!profile) throw new NotFoundException('No risk profile — recompute first'); + return profile; + } +} diff --git a/packages/backend/src/modules/reports/reports.controller.ts b/packages/backend/src/modules/reports/reports.controller.ts new file mode 100644 index 0000000..378198b --- /dev/null +++ b/packages/backend/src/modules/reports/reports.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiProperty, ApiPropertyOptional, ApiTags } from '@nestjs/swagger'; +import { ReportFormat, ReportType } from '@prisma/client'; +import { IsEnum, IsObject, IsOptional } from 'class-validator'; +import { RequirePermissions } from '../../common/decorators/permissions.decorator'; +import { paginate, PaginationQueryDto } from '../../common/dto/pagination.dto'; +import { ReportsService } from './reports.service'; + +class RequestReportDto { + @ApiProperty({ enum: ReportType }) @IsEnum(ReportType) type!: ReportType; + @ApiPropertyOptional({ enum: ReportFormat }) @IsOptional() @IsEnum(ReportFormat) format?: ReportFormat; + @ApiPropertyOptional({ type: Object }) @IsOptional() @IsObject() parameters?: Record; +} + +class ListReportsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: ReportType }) @IsOptional() @IsEnum(ReportType) type?: ReportType; +} + +@ApiTags('reports') +@ApiBearerAuth('jwt') +@Controller('reports') +export class ReportsController { + constructor(private readonly reports: ReportsService) {} + + @Post() + @RequirePermissions('report:create') + @ApiOperation({ summary: 'Queue a report (PDF/XLSX) for generation' }) + request(@Body() dto: RequestReportDto) { + return this.reports.request(dto); + } + + @Get() + @RequirePermissions('report:read') + @ApiOperation({ summary: 'List reports' }) + async list(@Query() query: ListReportsQueryDto) { + const { data, total } = await this.reports.list({ type: query.type, skip: query.skip, take: query.take }); + return paginate(data, total, query); + } + + @Get(':id') + @RequirePermissions('report:read') + @ApiOperation({ summary: 'Get a report (status + download when READY)' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.reports.findOne(id); + } +} diff --git a/packages/backend/src/modules/reports/reports.module.ts b/packages/backend/src/modules/reports/reports.module.ts new file mode 100644 index 0000000..00595b5 --- /dev/null +++ b/packages/backend/src/modules/reports/reports.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ReportsController } from './reports.controller'; +import { ReportsService } from './reports.service'; + +@Module({ + controllers: [ReportsController], + providers: [ReportsService], + exports: [ReportsService], +}) +export class ReportsModule {} diff --git a/packages/backend/src/modules/reports/reports.service.ts b/packages/backend/src/modules/reports/reports.service.ts new file mode 100644 index 0000000..068ef8f --- /dev/null +++ b/packages/backend/src/modules/reports/reports.service.ts @@ -0,0 +1,53 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma, ReportFormat, ReportType } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { TenantContext } from '../../common/context/tenant-context'; + +export interface RequestReportInput { + type: ReportType; + format?: ReportFormat; + parameters?: Record; +} + +@Injectable() +export class ReportsService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Queues a report. A worker (BullMQ) renders it to PDF/XLSX, uploads to S3, + * and flips status to READY with a presigned download. Here we persist the + * request; generation is handled by the reporting worker. + */ + async request(input: RequestReportInput) { + const ctx = TenantContext.require(); + const db = this.prisma.forRequest(); + return db.report.create({ + data: { + organizationId: ctx.organizationId, + type: input.type, + format: input.format ?? ReportFormat.PDF, + parameters: (input.parameters ?? {}) as Prisma.InputJsonValue, + requestedById: ctx.userId, + // 7-day signed-URL validity window for generated artifacts. + expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000), + }, + }); + } + + async list(params: { type?: ReportType; skip: number; take: number }) { + const db = this.prisma.forRequest(); + const where: Prisma.ReportWhereInput = params.type ? { type: params.type } : {}; + const [data, total] = await Promise.all([ + db.report.findMany({ where, orderBy: { createdAt: 'desc' }, skip: params.skip, take: params.take }), + db.report.count({ where }), + ]); + return { data, total }; + } + + async findOne(id: string) { + const db = this.prisma.forRequest(); + const report = await db.report.findUnique({ where: { id } }); + if (!report) throw new NotFoundException('Report not found'); + return report; + } +} diff --git a/packages/backend/src/modules/visits/dto/visits.dto.ts b/packages/backend/src/modules/visits/dto/visits.dto.ts new file mode 100644 index 0000000..62adcfb --- /dev/null +++ b/packages/backend/src/modules/visits/dto/visits.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsDateString, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + Max, + Min, +} from 'class-validator'; + +export class CreateVisitDto { + @ApiProperty({ format: 'uuid' }) @IsUUID() providerId!: string; + @ApiProperty({ format: 'uuid' }) @IsUUID() caregiverId!: string; + @ApiProperty({ format: 'uuid' }) @IsUUID() patientId!: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() authorizationId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() serviceCode?: string; + @ApiProperty() @IsDateString() scheduledStart!: string; + @ApiPropertyOptional() @IsOptional() @IsDateString() scheduledEnd?: string; +} + +export class ClockEventDto { + @ApiProperty({ minimum: -90, maximum: 90 }) @IsNumber() @Min(-90) @Max(90) lat!: number; + @ApiProperty({ minimum: -180, maximum: 180 }) @IsNumber() @Min(-180) @Max(180) lng!: number; + @ApiPropertyOptional() @IsOptional() @IsNumber() accuracyMeters?: number; + @ApiPropertyOptional() @IsOptional() @IsDateString() capturedAt?: string; + @ApiPropertyOptional({ description: 'Client device id' }) @IsOptional() @IsString() deviceId?: string; +} + +export class ClockOutDto extends ClockEventDto { + @ApiPropertyOptional({ description: 'Billed units for this visit' }) + @IsOptional() + @IsInt() + @Type(() => Number) + billedUnits?: number; +} diff --git a/packages/backend/src/modules/visits/visits.controller.ts b/packages/backend/src/modules/visits/visits.controller.ts new file mode 100644 index 0000000..26fa293 --- /dev/null +++ b/packages/backend/src/modules/visits/visits.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiPropertyOptional, ApiTags } from '@nestjs/swagger'; +import { VisitStatus } from '@prisma/client'; +import { IsEnum, IsOptional } from 'class-validator'; +import { RequirePermissions } from '../../common/decorators/permissions.decorator'; +import { paginate, PaginationQueryDto } from '../../common/dto/pagination.dto'; +import { VisitsService } from './visits.service'; +import { ClockEventDto, ClockOutDto, CreateVisitDto } from './dto/visits.dto'; + +class ListVisitsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: VisitStatus }) + @IsOptional() + @IsEnum(VisitStatus) + status?: VisitStatus; +} + +@ApiTags('visits') +@ApiBearerAuth('jwt') +@Controller('visits') +export class VisitsController { + constructor(private readonly visits: VisitsService) {} + + @Post() + @RequirePermissions('visit:create') + @ApiOperation({ summary: 'Schedule/create a visit' }) + create(@Body() dto: CreateVisitDto) { + return this.visits.create(dto); + } + + @Get() + @RequirePermissions('visit:read') + @ApiOperation({ summary: 'List visits' }) + async list(@Query() query: ListVisitsQueryDto) { + const { data, total } = await this.visits.list({ + status: query.status, + skip: query.skip, + take: query.take, + }); + return paginate(data, total, query); + } + + @Get(':id') + @RequirePermissions('visit:read') + @ApiOperation({ summary: 'Get a visit with its full verification package' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.visits.findOne(id); + } + + @Post(':id/clock-in') + @RequirePermissions('visit:clock') + @ApiOperation({ summary: 'Record clock-in (GPS + device evidence)' }) + clockIn(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ClockEventDto) { + return this.visits.clockIn(id, dto); + } + + @Post(':id/clock-out') + @RequirePermissions('visit:clock') + @ApiOperation({ summary: 'Record clock-out' }) + clockOut(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ClockOutDto) { + return this.visits.clockOut(id, dto); + } + + @Post(':id/verify') + @RequirePermissions('visit:verify') + @ApiOperation({ summary: 'Run the verification chain and produce the rollup' }) + verify(@Param('id', ParseUUIDPipe) id: string) { + return this.visits.runVerificationChain(id); + } +} diff --git a/packages/backend/src/modules/visits/visits.module.ts b/packages/backend/src/modules/visits/visits.module.ts new file mode 100644 index 0000000..77ddd1c --- /dev/null +++ b/packages/backend/src/modules/visits/visits.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { FraudModule } from '../fraud/fraud.module'; +import { VisitsController } from './visits.controller'; +import { VisitsService } from './visits.service'; + +@Module({ + imports: [FraudModule], + controllers: [VisitsController], + providers: [VisitsService], + exports: [VisitsService], +}) +export class VisitsModule {} diff --git a/packages/backend/src/modules/visits/visits.service.ts b/packages/backend/src/modules/visits/visits.service.ts new file mode 100644 index 0000000..730f3a3 --- /dev/null +++ b/packages/backend/src/modules/visits/visits.service.ts @@ -0,0 +1,270 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + DeviceTrustLevel, + Prisma, + VerificationResult, + VisitStatus, +} from '@prisma/client'; +import { createHash } from 'node:crypto'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { TenantContext } from '../../common/context/tenant-context'; +import { haversineMeters } from '../../common/util/geo'; +import { FraudService } from '../fraud/fraud.service'; +import { CreateVisitDto, ClockEventDto, ClockOutDto } from './dto/visits.dto'; + +@Injectable() +export class VisitsService { + constructor( + private readonly prisma: PrismaService, + private readonly fraud: FraudService, + ) {} + + private orgId(): string { + return TenantContext.require().organizationId; + } + + async create(dto: CreateVisitDto) { + const db = this.prisma.forRequest(); + return db.visit.create({ + data: { + organizationId: this.orgId(), + providerId: dto.providerId, + caregiverId: dto.caregiverId, + patientId: dto.patientId, + authorizationId: dto.authorizationId, + serviceCode: dto.serviceCode, + scheduledStart: new Date(dto.scheduledStart), + scheduledEnd: dto.scheduledEnd ? new Date(dto.scheduledEnd) : null, + status: VisitStatus.SCHEDULED, + }, + }); + } + + /** Records clock-in: writes append-only GPS + device verification evidence. */ + async clockIn(visitId: string, dto: ClockEventDto) { + const db = this.prisma.forRequest(); + const visit = await db.visit.findUnique({ + where: { id: visitId }, + include: { authorization: true }, + }); + if (!visit) throw new NotFoundException('Visit not found'); + + const capturedAt = dto.capturedAt ? new Date(dto.capturedAt) : new Date(); + const gps = this.evaluateGeofence(dto, visit.authorization); + + await db.gpsVerification.create({ + data: { + organizationId: visit.organizationId, + visitId: visit.id, + latitude: new Prisma.Decimal(dto.lat), + longitude: new Prisma.Decimal(dto.lng), + accuracyMeters: dto.accuracyMeters != null ? new Prisma.Decimal(dto.accuracyMeters) : null, + distanceMeters: gps.distance != null ? new Prisma.Decimal(gps.distance.toFixed(2)) : null, + result: gps.result, + capturedAt, + eventType: 'CLOCK_IN', + rawPayload: { accuracyMeters: dto.accuracyMeters } as Prisma.InputJsonValue, + }, + }); + + let deviceUuid: string | undefined; + if (dto.deviceId) { + const device = await db.device.upsert({ + where: { organizationId_deviceId: { organizationId: visit.organizationId, deviceId: dto.deviceId } }, + create: { organizationId: visit.organizationId, deviceId: dto.deviceId, trustLevel: DeviceTrustLevel.UNKNOWN }, + update: { lastSeenAt: new Date() }, + }); + deviceUuid = device.id; + await db.deviceVerification.create({ + data: { + organizationId: visit.organizationId, + visitId: visit.id, + deviceId: device.id, + result: this.deviceResult(device.trustLevel, device.isEmulator || device.isRooted || device.isJailbroken), + trustLevel: device.trustLevel, + signals: { + isEmulator: device.isEmulator, + isRooted: device.isRooted, + isJailbroken: device.isJailbroken, + } as Prisma.InputJsonValue, + }, + }); + } + + return db.visit.update({ + where: { id: visit.id }, + data: { + status: VisitStatus.IN_PROGRESS, + clockInAt: capturedAt, + clockInLat: new Prisma.Decimal(dto.lat), + clockInLng: new Prisma.Decimal(dto.lng), + deviceId: deviceUuid, + }, + }); + } + + async clockOut(visitId: string, dto: ClockOutDto) { + const db = this.prisma.forRequest(); + const visit = await db.visit.findUnique({ where: { id: visitId } }); + if (!visit) throw new NotFoundException('Visit not found'); + + const capturedAt = dto.capturedAt ? new Date(dto.capturedAt) : new Date(); + const duration = visit.clockInAt + ? Math.round((capturedAt.getTime() - visit.clockInAt.getTime()) / 60000) + : null; + + await db.gpsVerification.create({ + data: { + organizationId: visit.organizationId, + visitId: visit.id, + latitude: new Prisma.Decimal(dto.lat), + longitude: new Prisma.Decimal(dto.lng), + result: VerificationResult.PASS, + capturedAt, + eventType: 'CLOCK_OUT', + }, + }); + + return db.visit.update({ + where: { id: visit.id }, + data: { + status: VisitStatus.COMPLETED, + clockOutAt: capturedAt, + durationMinutes: duration, + billedUnits: dto.billedUnits, + }, + }); + } + + /** + * Runs the full verification chain and produces the immutable rollup: + * identity → GPS → device → patient → fraud scoring → approval decision. + */ + async runVerificationChain(visitId: string) { + const db = this.prisma.forRequest(); + const visit = await db.visit.findUnique({ + where: { id: visitId }, + include: { + identityVerifications: { orderBy: { createdAt: 'desc' }, take: 1 }, + gpsVerifications: { orderBy: { capturedAt: 'desc' }, take: 1 }, + deviceVerifications: { orderBy: { createdAt: 'desc' }, take: 1 }, + }, + }); + if (!visit) throw new NotFoundException('Visit not found'); + + // Step results (missing evidence ⇒ REVIEW, never silently PASS). + const identity = visit.identityVerifications[0]?.result ?? VerificationResult.REVIEW; + const gps = visit.gpsVerifications[0]?.result ?? VerificationResult.REVIEW; + const device = visit.deviceVerifications[0]?.result ?? VerificationResult.REVIEW; + const patient = VerificationResult.PASS; // patient confirmation (stub for now) + + // Fraud scoring (Module 3). + const scoring = await this.fraud.scoreVisit(visit.id); + + const chain = { + identity, + gps, + device, + patient, + fraud: { score: scoring.composite.score, riskLevel: scoring.composite.riskLevel }, + }; + + const overall = this.fuseChainResult([identity, gps, device, patient], scoring.composite.score); + const evidenceHash = createHash('sha256') + .update(JSON.stringify({ visitId: visit.id, chain, factors: scoring.composite.factors })) + .digest('hex'); + + await db.visitVerification.upsert({ + where: { visitId: visit.id }, + create: { + organizationId: visit.organizationId, + visitId: visit.id, + result: overall, + riskScore: scoring.composite.score, + riskLevel: scoring.composite.riskLevel, + chain: chain as Prisma.InputJsonValue, + evidenceHash, + }, + update: { + result: overall, + riskScore: scoring.composite.score, + riskLevel: scoring.composite.riskLevel, + chain: chain as Prisma.InputJsonValue, + evidenceHash, + }, + }); + + const status = + overall === VerificationResult.PASS + ? VisitStatus.APPROVED + : overall === VerificationResult.FAIL + ? VisitStatus.REJECTED + : VisitStatus.FLAGGED; + + await db.visit.update({ + where: { id: visit.id }, + data: { verificationResult: overall, status }, + }); + + return { visitId: visit.id, result: overall, status, chain, evidenceHash, fraud: scoring.composite }; + } + + async findOne(visitId: string) { + const db = this.prisma.forRequest(); + const visit = await db.visit.findUnique({ + where: { id: visitId }, + include: { + visitVerification: true, + identityVerifications: { orderBy: { createdAt: 'desc' } }, + gpsVerifications: { orderBy: { capturedAt: 'desc' } }, + deviceVerifications: { orderBy: { createdAt: 'desc' } }, + fraudEvents: { orderBy: { detectedAt: 'desc' } }, + }, + }); + if (!visit) throw new NotFoundException('Visit not found'); + return visit; + } + + async list(params: { status?: VisitStatus; skip: number; take: number }) { + const db = this.prisma.forRequest(); + const where: Prisma.VisitWhereInput = params.status ? { status: params.status } : {}; + const [data, total] = await Promise.all([ + db.visit.findMany({ where, orderBy: { scheduledStart: 'desc' }, skip: params.skip, take: params.take }), + db.visit.count({ where }), + ]); + return { data, total }; + } + + // ---- helpers ---- + + private evaluateGeofence( + dto: ClockEventDto, + auth: { latitude: Prisma.Decimal | null; longitude: Prisma.Decimal | null; radiusMeters: number } | null, + ): { result: VerificationResult; distance: number | null } { + if (!auth?.latitude || !auth?.longitude) { + return { result: VerificationResult.REVIEW, distance: null }; + } + const distance = haversineMeters( + { lat: dto.lat, lng: dto.lng }, + { lat: Number(auth.latitude), lng: Number(auth.longitude) }, + ); + if (distance <= auth.radiusMeters) return { result: VerificationResult.PASS, distance }; + if (distance > auth.radiusMeters * 5) return { result: VerificationResult.FAIL, distance }; + return { result: VerificationResult.REVIEW, distance }; // FLAG + } + + private deviceResult(trust: DeviceTrustLevel, tampered: boolean): VerificationResult { + if (trust === DeviceTrustLevel.BLOCKED || tampered) return VerificationResult.FAIL; + if (trust === DeviceTrustLevel.SUSPICIOUS || trust === DeviceTrustLevel.UNKNOWN) { + return VerificationResult.REVIEW; + } + return VerificationResult.PASS; + } + + /** Any FAIL ⇒ FAIL. Critical fraud (>80) ⇒ FAIL. Any REVIEW or high (>60) ⇒ REVIEW. */ + private fuseChainResult(steps: VerificationResult[], fraudScore: number): VerificationResult { + if (steps.includes(VerificationResult.FAIL) || fraudScore > 80) return VerificationResult.FAIL; + if (steps.includes(VerificationResult.REVIEW) || fraudScore > 60) return VerificationResult.REVIEW; + return VerificationResult.PASS; + } +} diff --git a/packages/backend/test/fraud-engine.spec.ts b/packages/backend/test/fraud-engine.spec.ts new file mode 100644 index 0000000..ea93660 --- /dev/null +++ b/packages/backend/test/fraud-engine.spec.ts @@ -0,0 +1,107 @@ +import { FraudScoringService } from '../src/modules/fraud/fraud-scoring.service'; +import { ImpossibleTravelDetector } from '../src/modules/fraud/detectors/impossible-travel.detector'; +import { GpsAnomalyDetector } from '../src/modules/fraud/detectors/gps-anomaly.detector'; +import { AbnormalDurationDetector } from '../src/modules/fraud/detectors/abnormal-duration.detector'; +import { VisitFeatureContext } from '../src/modules/fraud/detectors/types'; +import { haversineMeters, speedKmh } from '../src/common/util/geo'; +import { scoreToRiskLevel } from '../src/common/util/risk'; + +const baseCtx = (overrides: Partial): VisitFeatureContext => ({ + visit: { + id: 'v1', + organizationId: 'o1', + caregiverId: 'c1', + patientId: 'p1', + providerId: 'pr1', + scheduledStart: new Date('2026-06-09T14:00:00Z'), + clockInAt: new Date('2026-06-09T14:02:00Z'), + clockInLat: 39.7817, + clockInLng: -89.6501, + }, + authorization: { latitude: 39.7817, longitude: -89.6501, radiusMeters: 150, authorizedUnits: null }, + identity: { result: 'PASS', confidenceScore: 0.97, livenessScore: 0.99 }, + device: null, + caregiverRecentVisits: [], + patientRecentVisits: [], + config: { impossibleTravelKmh: 900, duplicateWindowMin: 10 }, + ...overrides, +}); + +describe('geo utilities', () => { + it('computes haversine distance (~0 for same point)', () => { + expect(haversineMeters({ lat: 39.7817, lng: -89.6501 }, { lat: 39.7817, lng: -89.6501 })).toBeCloseTo(0, 1); + }); + it('flags impossible speeds', () => { + const d = haversineMeters({ lat: 40.7128, lng: -74.006 }, { lat: 34.0522, lng: -118.2437 }); + expect(speedKmh(d, 20 * 60 * 1000)).toBeGreaterThan(900); + }); +}); + +describe('risk banding', () => { + it('maps scores to bands', () => { + expect(scoreToRiskLevel(10)).toBe('LOW'); + expect(scoreToRiskLevel(45)).toBe('MODERATE'); + expect(scoreToRiskLevel(75)).toBe('HIGH'); + expect(scoreToRiskLevel(95)).toBe('CRITICAL'); + }); +}); + +describe('GpsAnomalyDetector', () => { + const det = new GpsAnomalyDetector(); + it('passes inside the radius', () => { + const r = det.detect(baseCtx({})); + expect(r.triggered).toBe(false); + }); + it('fails on a major discrepancy', () => { + const r = det.detect( + baseCtx({ visit: { ...baseCtx({}).visit, clockInLat: 39.9, clockInLng: -89.9 } }), + ); + expect(r.triggered).toBe(true); + expect(r.evidence.decision).toBe('FAIL'); + expect(r.severity).toBeGreaterThan(70); + }); +}); + +describe('ImpossibleTravelDetector', () => { + const det = new ImpossibleTravelDetector(); + it('triggers when implied speed exceeds threshold', () => { + const ctx = baseCtx({ + caregiverRecentVisits: [ + { id: 'v0', clockInAt: new Date('2026-06-09T13:50:00Z'), clockInLat: 34.0522, clockInLng: -118.2437, patientId: 'p9' }, + ], + }); + const r = det.detect(ctx); + expect(r.triggered).toBe(true); + expect(Number(r.evidence.impliedSpeedKmh)).toBeGreaterThan(900); + }); +}); + +describe('AbnormalDurationDetector', () => { + const det = new AbnormalDurationDetector(); + it('flags sub-2-minute visits via the hard floor', () => { + const ctx = baseCtx({ visit: { ...baseCtx({}).visit, durationMinutes: 1 } }); + const r = det.detect(ctx); + expect(r.triggered).toBe(true); + expect(r.evidence.rule).toBe('hard_floor_2min'); + }); +}); + +describe('FraudScoringService fusion', () => { + const scoring = new FraudScoringService(); + it('returns 0/LOW when nothing triggers', () => { + const out = scoring.fuse([]); + expect(out.score).toBe(0); + expect(out.riskLevel).toBe('LOW'); + }); + it('combines multiple signals into a higher composite with explainable factors', () => { + const det = [new GpsAnomalyDetector(), new AbnormalDurationDetector()]; + const ctx = baseCtx({ + visit: { ...baseCtx({}).visit, clockInLat: 39.9, clockInLng: -89.9, durationMinutes: 1 }, + }); + const results = det.map((d) => d.detect(ctx)); + const out = scoring.fuse(results); + expect(out.score).toBeGreaterThan(60); + expect(out.factors.length).toBe(2); + expect(out.factors[0].contribution).toBeGreaterThan(0); + }); +}); diff --git a/packages/backend/tsconfig.build.json b/packages/backend/tsconfig.build.json new file mode 100644 index 0000000..f4f2ef5 --- /dev/null +++ b/packages/backend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma/seed.ts"] +} diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json new file mode 100644 index 0000000..be10609 --- /dev/null +++ b/packages/backend/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + "paths": { + "@/*": ["src/*"], + "@rayverify/shared": ["../shared/src/index.ts"] + } + }, + "include": ["src/**/*", "prisma/seed.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/frontend/.env.example b/packages/frontend/.env.example new file mode 100644 index 0000000..12c6973 --- /dev/null +++ b/packages/frontend/.env.example @@ -0,0 +1,9 @@ +# RayVerify™ Investigator Dashboard — environment variables +# Copy to .env.local and fill in values before running locally. + +# Backend API base URL (no trailing slash) +NEXT_PUBLIC_API_BASE_URL=http://localhost:4000 + +# NextAuth / JWT (set a strong secret in production) +NEXTAUTH_SECRET=change-me-before-deploy +NEXTAUTH_URL=http://localhost:3000 diff --git a/packages/frontend/.eslintrc.json b/packages/frontend/.eslintrc.json new file mode 100644 index 0000000..47a038c --- /dev/null +++ b/packages/frontend/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["next/core-web-vitals"], + "rules": { + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/no-explicit-any": "warn" + } +} diff --git a/packages/frontend/Dockerfile b/packages/frontend/Dockerfile new file mode 100644 index 0000000..0fd6b13 --- /dev/null +++ b/packages/frontend/Dockerfile @@ -0,0 +1,46 @@ +# ============================================================================= +# RayVerify™ Investigator Dashboard — Multi-stage Dockerfile +# Base: node:20-alpine | Output: Next.js standalone +# ============================================================================= + +# ---- deps stage ------------------------------------------------------- +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --ignore-scripts + +# ---- builder stage ---------------------------------------------------- +FROM node:20-alpine AS builder +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +RUN npm run build + +# ---- runner stage (standalone) ---------------------------------------- +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/packages/frontend/README.md b/packages/frontend/README.md new file mode 100644 index 0000000..bf44b50 --- /dev/null +++ b/packages/frontend/README.md @@ -0,0 +1,122 @@ +# @rayverify/frontend + +Next.js 15 Investigator Dashboard for the RayVerify™ platform. + +## Stack + +| Layer | Technology | +|---|---| +| Framework | Next.js 15 (App Router, server components) | +| Language | TypeScript 5 (strict) | +| Styling | TailwindCSS 3 + CSS custom properties (shadcn theme) | +| UI Primitives | shadcn/ui pattern (Radix UI + CVA) | +| Data Fetching | TanStack Query v5 (QueryClientProvider in app/providers.tsx) | +| Charts | Recharts 2 | +| Icons | lucide-react | + +## Project structure + +``` +packages/frontend/ +├── app/ +│ ├── layout.tsx Root layout (font, providers, metadata) +│ ├── globals.css Tailwind + CSS variables (gov/security palette) +│ ├── providers.tsx TanStack QueryClientProvider +│ ├── login/page.tsx Login screen +│ └── (dashboard)/ Dashboard route group +│ ├── layout.tsx Sidebar + TopBar shell +│ ├── page.tsx Overview / KPIs + charts +│ ├── FraudTrendChart.tsx Line chart (client component) +│ ├── RiskDistributionChart.tsx Donut chart +│ ├── alerts/page.tsx Fraud alerts list +│ ├── cases/ +│ │ ├── page.tsx Case list +│ │ └── [id]/page.tsx Case detail (timeline, evidence, notes) +│ ├── providers/ +│ │ ├── page.tsx Provider risk rankings + detail +│ │ ├── ProviderTrendSparkline.tsx +│ │ └── ProviderRiskTrendChart.tsx +│ ├── visits/ +│ │ ├── page.tsx Visit list +│ │ └── [id]/page.tsx Visit verification chain +│ ├── audit/page.tsx Audit log viewer +│ └── reports/page.tsx Report generation + history +├── components/ +│ ├── ui/ shadcn-style primitives +│ │ ├── button.tsx +│ │ ├── card.tsx +│ │ ├── badge.tsx +│ │ ├── input.tsx +│ │ ├── select.tsx +│ │ ├── tabs.tsx +│ │ ├── dialog.tsx +│ │ ├── dropdown-menu.tsx +│ │ ├── separator.tsx +│ │ ├── skeleton.tsx +│ │ └── avatar.tsx +│ ├── RiskBadge.tsx Risk level badge (LOW/MODERATE/HIGH/CRITICAL) +│ ├── VerificationResultBadge.tsx PASS/REVIEW/FAIL badge +│ ├── StatCard.tsx KPI card with trend indicator +│ ├── PageHeader.tsx Page title + action slot +│ ├── Sidebar.tsx Navigation sidebar +│ ├── TopBar.tsx Org switcher + user menu +│ ├── DataTable.tsx Generic typed table +│ └── FraudTimeline.tsx Vertical fraud event timeline +├── lib/ +│ ├── types.ts TypeScript types mirroring Prisma schema +│ ├── mock.ts Typed mock data (runs without a backend) +│ ├── api.ts Typed fetch client for all API endpoints +│ ├── risk.ts Risk band & verification result display configs +│ └── utils.ts cn(), formatters, risk helpers +├── Dockerfile Multi-stage Next.js standalone image +├── .env.example Environment variables template +├── package.json +├── tsconfig.json +├── tailwind.config.ts +└── postcss.config.mjs +``` + +## Running locally + +```bash +# From repo root +npm install + +# From this package +cd packages/frontend +cp ../../.env.example .env.local # or just create .env.local with: +# NEXT_PUBLIC_API_BASE_URL=http://localhost:4000 + +npm run dev # http://localhost:3000 +npm run typecheck # tsc --noEmit (best-effort, needs npm install first) +npm run build # production build +``` + +## Page routes + +| Route | Description | +|---|---| +| `/login` | Investigator sign-in | +| `/` | Overview — KPIs, fraud trend, risk distribution, recent alerts | +| `/alerts` | Fraud alerts list with severity filters | +| `/cases` | Investigation case management | +| `/cases/[id]` | Case detail — timeline, evidence, notes, status actions | +| `/providers` | Provider risk ranking table + detail chart | +| `/visits` | Visit list with verification results | +| `/visits/[id]` | Visit verification chain (identity → GPS → device → patient → fraud) | +| `/audit` | Immutable audit log viewer | +| `/reports` | Report generation (PDF/Excel) + history | + +## Mock data + +All pages render with `lib/mock.ts` data and no backend dependency. When +`NEXT_PUBLIC_API_BASE_URL` is set and the backend is running, swap each page +to use TanStack Query hooks calling `lib/api.ts` instead of the mock imports. + +## Design principles + +- Government / security palette: deep navy primary, slate grays, precise semantic colours for risk bands. +- Data-dense, not flashy: compact padding, tabular numbers, monospace for IDs/hashes. +- Consistent `RiskBadge` and `VerificationResultBadge` across all pages. +- Accessible: semantic HTML, `sr-only` labels, focus rings. +- Risk bands: 0–30 LOW (green) · 31–60 MODERATE (amber) · 61–80 HIGH (orange) · 81–100 CRITICAL (red). diff --git a/packages/frontend/app/(dashboard)/FraudTrendChart.tsx b/packages/frontend/app/(dashboard)/FraudTrendChart.tsx new file mode 100644 index 0000000..6b4628c --- /dev/null +++ b/packages/frontend/app/(dashboard)/FraudTrendChart.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; +import { FraudTrendPoint } from "@/lib/types"; + +interface FraudTrendChartProps { + data: FraudTrendPoint[]; +} + +export function FraudTrendChart({ data }: FraudTrendChartProps) { + return ( + + + + + new Date(v).toLocaleDateString("en-US", { month: "short", day: "numeric" }) + } + interval={2} + /> + + + new Date(v).toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }) + } + /> + + + + + + + ); +} diff --git a/packages/frontend/app/(dashboard)/RiskDistributionChart.tsx b/packages/frontend/app/(dashboard)/RiskDistributionChart.tsx new file mode 100644 index 0000000..ce68c1c --- /dev/null +++ b/packages/frontend/app/(dashboard)/RiskDistributionChart.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts"; +import { RiskDistribution } from "@/lib/types"; +import { RISK_BAND } from "@/lib/risk"; +import { RiskLevel } from "@/lib/types"; + +interface RiskDistributionChartProps { + data: RiskDistribution[]; +} + +export function RiskDistributionChart({ data }: RiskDistributionChartProps) { + const chartData = data.map((d) => ({ + name: RISK_BAND[d.level as RiskLevel].label, + value: d.count, + pct: d.pct, + fill: RISK_BAND[d.level as RiskLevel].hex, + })); + + return ( + + + + {chartData.map((entry, index) => ( + + ))} + + [ + `${(value as number).toLocaleString()} (${(props?.payload?.pct as number | undefined)?.toFixed(1)}%)`, + "Visits", + ]} + /> + + + ); +} diff --git a/packages/frontend/app/(dashboard)/alerts/page.tsx b/packages/frontend/app/(dashboard)/alerts/page.tsx new file mode 100644 index 0000000..425048c --- /dev/null +++ b/packages/frontend/app/(dashboard)/alerts/page.tsx @@ -0,0 +1,132 @@ +import type { Metadata } from "next"; +import { PageHeader } from "@/components/PageHeader"; +import { RiskBadge } from "@/components/RiskBadge"; +import { DataTable, Column } from "@/components/DataTable"; +import { Card, CardContent } from "@/components/ui/card"; +import { MOCK_FRAUD_EVENTS } from "@/lib/mock"; +import { FraudEvent } from "@/lib/types"; +import { fraudTypeLabel, formatDateTime, formatRelative } from "@/lib/utils"; + +export const metadata: Metadata = { title: "Fraud Alerts" }; + +const STATUS_CLASSES: Record = { + OPEN: "bg-amber-50 text-amber-700 border-amber-200", + TRIAGED: "bg-blue-50 text-blue-700 border-blue-200", + LINKED_TO_CASE: "bg-purple-50 text-purple-700 border-purple-200", + DISMISSED: "bg-slate-50 text-slate-600 border-slate-200", + CONFIRMED: "bg-red-50 text-red-700 border-red-200", +}; + +const columns: Column[] = [ + { + key: "type", + header: "Alert Type", + render: (row) => ( +
+

{fraudTypeLabel(row.type)}

+ {row.detector && ( +

+ {row.detector} +

+ )} +
+ ), + }, + { + key: "risk", + header: "Severity / Risk", + render: (row) => ( +
+ + + {row.severity} + +
+ ), + }, + { + key: "status", + header: "Status", + render: (row) => ( + + {row.status.replace(/_/g, " ")} + + ), + }, + { + key: "explanation", + header: "Finding", + className: "max-w-xs", + render: (row) => ( +

+ {row.explanation ?? "—"} +

+ ), + }, + { + key: "visit", + header: "Visit", + render: (row) => ( + + {row.visitId ?? "—"} + + ), + }, + { + key: "detected", + header: "Detected", + render: (row) => ( +
+

{formatRelative(row.detectedAt)}

+

+ {formatDateTime(row.detectedAt)} +

+
+ ), + }, +]; + +export default function AlertsPage() { + return ( +
+ + + {/* Summary strip */} +
+ {[ + { label: "Total Open", value: MOCK_FRAUD_EVENTS.filter((e) => e.status === "OPEN").length, cls: "text-amber-700" }, + { label: "Confirmed", value: MOCK_FRAUD_EVENTS.filter((e) => e.status === "CONFIRMED").length, cls: "text-red-700" }, + { label: "Triaged", value: MOCK_FRAUD_EVENTS.filter((e) => e.status === "TRIAGED").length, cls: "text-blue-700" }, + { label: "Dismissed", value: MOCK_FRAUD_EVENTS.filter((e) => e.status === "DISMISSED").length, cls: "text-slate-500" }, + ].map((s) => ( +
+

+ {s.label} +

+

+ {s.value} +

+
+ ))} +
+ + + + r.id} + /> + + +
+ ); +} diff --git a/packages/frontend/app/(dashboard)/audit/page.tsx b/packages/frontend/app/(dashboard)/audit/page.tsx new file mode 100644 index 0000000..4c53a56 --- /dev/null +++ b/packages/frontend/app/(dashboard)/audit/page.tsx @@ -0,0 +1,155 @@ +import type { Metadata } from "next"; +import { PageHeader } from "@/components/PageHeader"; +import { DataTable, Column } from "@/components/DataTable"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { MOCK_AUDIT_LOGS } from "@/lib/mock"; +import { AuditLog } from "@/lib/types"; +import { formatDateTime, initials } from "@/lib/utils"; +import { Lock } from "lucide-react"; + +export const metadata: Metadata = { title: "Audit Log" }; + +const ACTION_CLASSES: Record = { + CREATE: "bg-green-50 text-green-700 border-green-200", + READ: "bg-slate-50 text-slate-600 border-slate-200", + UPDATE: "bg-blue-50 text-blue-700 border-blue-200", + DELETE: "bg-red-50 text-red-700 border-red-200", + LOGIN: "bg-indigo-50 text-indigo-700 border-indigo-200", + LOGOUT: "bg-indigo-50 text-indigo-600 border-indigo-200", + EXPORT: "bg-amber-50 text-amber-700 border-amber-200", + VERIFY: "bg-teal-50 text-teal-700 border-teal-200", + SCORE: "bg-purple-50 text-purple-700 border-purple-200", + CASE_ACTION: "bg-orange-50 text-orange-700 border-orange-200", + CONFIG_CHANGE: "bg-red-50 text-red-800 border-red-300", +}; + +const columns: Column[] = [ + { + key: "actor", + header: "Actor", + render: (row) => + row.actor ? ( +
+ + + {initials(row.actor.firstName, row.actor.lastName)} + + +
+

+ {row.actor.firstName} {row.actor.lastName} +

+

{row.actor.email}

+
+
+ ) : ( + System + ), + }, + { + key: "action", + header: "Action", + render: (row) => ( + + {row.action.replace(/_/g, " ")} + + ), + }, + { + key: "resource", + header: "Resource", + render: (row) => ( +
+

+ {row.resourceType.replace(/_/g, " ")} +

+ {row.resourceId && ( +

{row.resourceId}

+ )} +
+ ), + }, + { + key: "ip", + header: "IP Address", + render: (row) => ( + + {row.ipAddress ?? "—"} + + ), + }, + { + key: "metadata", + header: "Context", + className: "max-w-xs", + render: (row) => ( +

+ {Object.keys(row.metadata).length > 0 + ? JSON.stringify(row.metadata) + : "—"} +

+ ), + }, + { + key: "hash", + header: "Hash", + render: (row) => ( + + {row.hash ? row.hash.slice(0, 16) + "…" : "—"} + + ), + }, + { + key: "timestamp", + header: "Timestamp", + render: (row) => ( + + {formatDateTime(row.createdAt)} + + ), + }, +]; + +export default function AuditPage() { + return ( +
+ + + {/* Compliance notice */} +
+ +

+ This log is append-only and tamper-evident. Records are hashed using a + SHA-256 chain (prevHash → hash). Modifications are cryptographically + detectable. Retained per your organization's HIPAA data-retention + policy. +

+
+ + {/* Search bar */} +
+ +
+ + + + r.id} + /> + + +
+ ); +} diff --git a/packages/frontend/app/(dashboard)/cases/[id]/page.tsx b/packages/frontend/app/(dashboard)/cases/[id]/page.tsx new file mode 100644 index 0000000..5bd6f4b --- /dev/null +++ b/packages/frontend/app/(dashboard)/cases/[id]/page.tsx @@ -0,0 +1,256 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft, FileText, MapPin, Shield, User, Clock } from "lucide-react"; +import { PageHeader } from "@/components/PageHeader"; +import { RiskBadge } from "@/components/RiskBadge"; +import { FraudTimeline } from "@/components/FraudTimeline"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Separator } from "@/components/ui/separator"; +import { + MOCK_CASES, + MOCK_FRAUD_EVENTS, + MOCK_CASE_NOTES, + MOCK_CASE_EVIDENCE, +} from "@/lib/mock"; +import { + formatCents, + formatDateTime, + caseStatusLabel, +} from "@/lib/utils"; +import { PRIORITY_CONFIG } from "@/lib/risk"; + +export const metadata: Metadata = { title: "Case Detail" }; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function CaseDetailPage({ params }: PageProps) { + const { id } = await params; + const fraudCase = MOCK_CASES.find((c) => c.id === id); + if (!fraudCase) notFound(); + + const events = MOCK_FRAUD_EVENTS.filter((e) => e.caseId === id); + const notes = MOCK_CASE_NOTES.filter((n) => n.caseId === id); + const evidence = MOCK_CASE_EVIDENCE.filter((e) => e.caseId === id); + const priorityCfg = PRIORITY_CONFIG[fraudCase.priority]; + + const STATUS_TRANSITIONS: Record = { + OPEN: ["IN_REVIEW", "ESCALATED"], + IN_REVIEW: ["ESCALATED", "PENDING_PAYMENT_HOLD", "UNSUBSTANTIATED"], + ESCALATED: ["PENDING_PAYMENT_HOLD", "SUBSTANTIATED"], + PENDING_PAYMENT_HOLD: ["SUBSTANTIATED", "UNSUBSTANTIATED"], + }; + const availableTransitions = STATUS_TRANSITIONS[fraudCase.status] ?? []; + + return ( +
+ {/* Back nav */} +
+ + 0 ? ( +
+ {availableTransitions.map((t) => ( + + ))} +
+ ) : undefined + } + /> +
+ + {/* Metadata strip */} +
+ {[ + { label: "Status", value: caseStatusLabel(fraudCase.status) }, + { + label: "Priority", + value: ( + + {priorityCfg.label} + + ), + }, + { + label: "Risk", + value: , + }, + { + label: "Exposure", + value: fraudCase.exposureCents + ? formatCents(fraudCase.exposureCents) + : "—", + }, + { + label: "Assignee", + value: fraudCase.assignee + ? `${fraudCase.assignee.firstName} ${fraudCase.assignee.lastName}` + : "Unassigned", + }, + { + label: "Opened", + value: formatDateTime(fraudCase.openedAt), + }, + ].map((item) => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+ + {/* Provider */} + {fraudCase.provider && ( +
+ +
+ + {fraudCase.provider.legalName} + + + NPI: {fraudCase.provider.npi ?? "—"} · Medicaid ID:{" "} + {fraudCase.provider.medicaidId ?? "—"} + +
+ +
+ )} + + {/* Summary */} + {fraudCase.summary && ( + + + Summary + + +

+ {fraudCase.summary} +

+
+
+ )} + + {/* Tabs: Timeline, Evidence, Notes */} + + + + Fraud Events ({events.length}) + + + Evidence ({evidence.length}) + + Notes ({notes.length}) + + + + + + 0 ? events : MOCK_FRAUD_EVENTS.slice(0, 3)} /> + + + + + + + + {evidence.length === 0 ? ( +

+ No evidence attached yet. +

+ ) : ( +
    + {evidence.map((ev) => ( +
  • +
    + +
    +
    +

    + {ev.label} +

    +

    + Kind: {ev.kind} + {ev.contentHash && ( + + {ev.contentHash} + + )} +

    +
    +
  • + ))} +
+ )} +
+
+
+ + + + + {notes.length === 0 ? ( +

No notes yet.

+ ) : ( + notes.map((note) => ( +
+
+ + + {note.author?.firstName} {note.author?.lastName} + + {note.isInternal && ( + + Internal + + )} + + + {formatDateTime(note.createdAt)} + +
+

+ {note.body} +

+ +
+ )) + )} + + {/* Add note form */} +
+