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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions migrations/20260529000000_regulatory_evidence_package.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
-- Regulatory Examination Support & Evidence Package
-- Stores generated evidence packages, policy version history, and system test reports.

-- ── Evidence packages ─────────────────────────────────────────────────────────
CREATE TABLE regulatory_evidence_packages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scope_label TEXT NOT NULL,
period_from TIMESTAMPTZ NOT NULL,
period_to TIMESTAMPTZ NOT NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
generated_by TEXT NOT NULL DEFAULT 'system',
-- SHA-256 of the canonical JSON payload
checksum_sha256 TEXT NOT NULL,
-- HMAC-SHA256 signature proving platform origin
signature_hmac_sha256 TEXT NOT NULL,
-- Source system counts (summary — full data lives in source tables)
aml_log_count BIGINT NOT NULL DEFAULT 0,
travel_rule_count BIGINT NOT NULL DEFAULT 0,
kyc_event_count BIGINT NOT NULL DEFAULT 0,
multisig_event_count BIGINT NOT NULL DEFAULT 0,
policy_snapshot_count BIGINT NOT NULL DEFAULT 0,
system_test_count BIGINT NOT NULL DEFAULT 0
);

CREATE INDEX idx_reg_evidence_scope ON regulatory_evidence_packages(scope_label);
CREATE INDEX idx_reg_evidence_period ON regulatory_evidence_packages(period_from, period_to);
CREATE INDEX idx_reg_evidence_gen_at ON regulatory_evidence_packages(generated_at DESC);

-- ── Policy version history ────────────────────────────────────────────────────
-- Stores the state of every compliance policy at each point in time.
-- Enables "What was our KYC threshold on January 1st?" queries.
CREATE TABLE regulatory_policy_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
policy_name TEXT NOT NULL, -- e.g. "kyc_threshold", "aml_ctr_threshold"
policy_version TEXT NOT NULL, -- e.g. "v1.2"
effective_from TIMESTAMPTZ NOT NULL,
effective_until TIMESTAMPTZ, -- NULL = currently active
-- Full policy state as JSON (thresholds, rules, limits, etc.)
policy_state JSONB NOT NULL,
changed_by TEXT NOT NULL,
change_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_reg_policy_name_time ON regulatory_policy_history(policy_name, effective_from DESC);
CREATE INDEX idx_reg_policy_active ON regulatory_policy_history(policy_name) WHERE effective_until IS NULL;

-- ── System test & health reports ──────────────────────────────────────────────
-- Stores AML stress tests, pen-test results, DR tests, etc.
-- Attached to evidence packages to prove controls were operating effectively.
CREATE TABLE regulatory_system_test_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
report_type TEXT NOT NULL, -- "aml_stress_test" | "pentest" | "security_scan" | "dr_test"
report_label TEXT NOT NULL,
executed_at TIMESTAMPTZ NOT NULL,
executed_by TEXT NOT NULL,
outcome TEXT NOT NULL CHECK (outcome IN ('pass', 'fail', 'partial')),
summary TEXT NOT NULL,
findings JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_reg_test_reports_type ON regulatory_system_test_reports(report_type);
CREATE INDEX idx_reg_test_reports_time ON regulatory_system_test_reports(executed_at DESC);
5 changes: 5 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ pub mod event_bus;
#[cfg(feature = "database")]
pub mod travel_rule;

// Regulatory Examination Support & Evidence Package
// Automated evidence collection, policy versioning, signed exports, system test reports
#[cfg(feature = "database")]
pub mod regulatory_evidence;

// Contract error enum for Soroban (only when not using database feature)
#[cfg(not(feature = "database"))]
#[contracterror]
Expand Down
21 changes: 21 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ mod defi;
mod banking;
mod capacity;

// Regulatory Examination Support & Evidence Package
mod regulatory_evidence;

// Imports
use std::sync::Arc;
use crate::config::AppConfig;
Expand Down Expand Up @@ -1905,6 +1908,23 @@ async fn main() -> anyhow::Result<()> {
Router::new()
};

// ── Regulatory Examination Support & Evidence Package ─────────────────────
let regulatory_evidence_routes = if let (Some(ref pool), Some(ref writer)) = (db_pool.as_ref(), audit_writer.as_ref()) {
let reg_repo = std::sync::Arc::new(regulatory_evidence::RegulatoryEvidenceRepository::new(pool.clone()));
let reg_service = std::sync::Arc::new(regulatory_evidence::RegulatoryEvidenceService::new(
reg_repo,
writer.clone(),
));
let reg_state = std::sync::Arc::new(regulatory_evidence::RegulatoryEvidenceState {
service: reg_service,
});
info!("📋 Regulatory evidence package routes enabled");
regulatory_evidence::regulatory_evidence_routes(reg_state)
} else {
info!("⏭️ Skipping regulatory evidence routes (no database)");
Router::new()
};

// ── Compliance Effectiveness Reporting (AML/KYC KPI Reports) ─────────────
let compliance_effectiveness_routes = if let Some(ref pool) = db_pool {
let ce_repo = std::sync::Arc::new(
Expand Down Expand Up @@ -2590,6 +2610,7 @@ async fn main() -> anyhow::Result<()> {
.merge(audit_routes)
.merge(auditor_portal_routes)
.merge(sar_routes)
.merge(regulatory_evidence_routes)
.merge(compliance_effectiveness_routes)
.merge(kyb_routes)
.merge(key_rotation_routes)
Expand Down
159 changes: 159 additions & 0 deletions src/regulatory_evidence/handlers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
use crate::regulatory_evidence::{
models::*,
service::{EvidenceError, RegulatoryEvidenceService},
};
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;

// ── State ─────────────────────────────────────────────────────────────────────

#[derive(Clone)]
pub struct RegulatoryEvidenceState {
pub service: Arc<RegulatoryEvidenceService>,
}

// ── Error helper ──────────────────────────────────────────────────────────────

fn err(e: EvidenceError) -> Response {
let status = e.status_code();
(status, Json(serde_json::json!({ "error": e.to_string() }))).into_response()
}

fn extract_ip(headers: &HeaderMap) -> String {
headers
.get("x-forwarded-for")
.or_else(|| headers.get("x-real-ip"))
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.unwrap_or("127.0.0.1")
.trim()
.to_string()
}

// ── Evidence packages ─────────────────────────────────────────────────────────

/// POST /api/v1/regulatory-evidence/packages
/// Generate a new evidence package for a given scope/period.
pub async fn generate_package(
State(state): State<Arc<RegulatoryEvidenceState>>,
headers: HeaderMap,
Json(body): Json<GenerateEvidencePackageRequest>,
) -> Response {
let ip = extract_ip(&headers);
match state.service.generate_package(&body, &ip).await {
Ok(pkg) => (StatusCode::CREATED, Json(serde_json::json!({ "data": pkg }))).into_response(),
Err(e) => err(e),
}
}

/// GET /api/v1/regulatory-evidence/packages
pub async fn list_packages(
State(state): State<Arc<RegulatoryEvidenceState>>,
Query(q): Query<EvidencePackageListQuery>,
) -> Response {
let limit = q.limit.unwrap_or(50).min(200);
let offset = q.offset.unwrap_or(0);
match state.service.list_packages(q.scope_label.as_deref(), limit, offset).await {
Ok(pkgs) => (StatusCode::OK, Json(serde_json::json!({ "data": pkgs }))).into_response(),
Err(e) => err(e),
}
}

/// GET /api/v1/regulatory-evidence/packages/:id
pub async fn get_package(
State(state): State<Arc<RegulatoryEvidenceState>>,
Path(id): Path<Uuid>,
) -> Response {
match state.service.get_package(id).await {
Ok(Some(pkg)) => (StatusCode::OK, Json(serde_json::json!({ "data": pkg }))).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Not found" }))).into_response(),
Err(e) => err(e),
}
}

// ── Policy history ────────────────────────────────────────────────────────────

/// POST /api/v1/regulatory-evidence/policies
pub async fn record_policy_snapshot(
State(state): State<Arc<RegulatoryEvidenceState>>,
Json(body): Json<CreatePolicySnapshotRequest>,
) -> Response {
match state.service.record_policy_snapshot(&body).await {
Ok(snap) => (StatusCode::CREATED, Json(serde_json::json!({ "data": snap }))).into_response(),
Err(e) => err(e),
}
}

/// GET /api/v1/regulatory-evidence/policies/point-in-time
pub async fn policy_at_point_in_time(
State(state): State<Arc<RegulatoryEvidenceState>>,
Query(q): Query<PolicyAtPointInTimeQuery>,
) -> Response {
match state.service.policy_at_point_in_time(&q).await {
Ok(Some(snap)) => (StatusCode::OK, Json(serde_json::json!({ "data": snap }))).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "No policy found at that point in time" }))).into_response(),
Err(e) => err(e),
}
}

/// GET /api/v1/regulatory-evidence/policies/:name/history
pub async fn list_policy_history(
State(state): State<Arc<RegulatoryEvidenceState>>,
Path(name): Path<String>,
) -> Response {
match state.service.list_policy_history(&name).await {
Ok(history) => (StatusCode::OK, Json(serde_json::json!({ "data": history }))).into_response(),
Err(e) => err(e),
}
}

/// GET /api/v1/regulatory-evidence/policies
pub async fn list_policy_names(
State(state): State<Arc<RegulatoryEvidenceState>>,
) -> Response {
match state.service.list_policy_names().await {
Ok(names) => (StatusCode::OK, Json(serde_json::json!({ "data": names }))).into_response(),
Err(e) => err(e),
}
}

// ── System test reports ───────────────────────────────────────────────────────

/// POST /api/v1/regulatory-evidence/test-reports
pub async fn record_test_report(
State(state): State<Arc<RegulatoryEvidenceState>>,
Json(body): Json<CreateSystemTestReportRequest>,
) -> Response {
match state.service.record_test_report(&body).await {
Ok(report) => (StatusCode::CREATED, Json(serde_json::json!({ "data": report }))).into_response(),
Err(e) => err(e),
}
}

/// GET /api/v1/regulatory-evidence/test-reports
#[derive(Deserialize)]
pub struct TestReportQuery {
pub report_type: Option<String>,
pub from: Option<DateTime<Utc>>,
pub to: Option<DateTime<Utc>>,
pub limit: Option<i64>,
}

pub async fn list_test_reports(
State(state): State<Arc<RegulatoryEvidenceState>>,
Query(q): Query<TestReportQuery>,
) -> Response {
let limit = q.limit.unwrap_or(50).min(200);
match state.service.list_test_reports(q.report_type.as_deref(), q.from, q.to, limit).await {
Ok(reports) => (StatusCode::OK, Json(serde_json::json!({ "data": reports }))).into_response(),
Err(e) => err(e),
}
}
19 changes: 19 additions & 0 deletions src/regulatory_evidence/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//! Regulatory Examination Support & Evidence Package (Issue #348-ext)
//!
//! Provides:
//! - Automated evidence collection from AML, Travel Rule, KYC, and Multisig sources
//! - Point-in-time policy history (e.g. "What was our KYC threshold on Jan 1?")
//! - Cryptographically signed (HMAC-SHA256) evidence package exports
//! - System health & test report attachment
//! - All generation requests logged to the Immutable Audit Trail

pub mod handlers;
pub mod models;
pub mod repository;
pub mod routes;
pub mod service;

pub use handlers::RegulatoryEvidenceState;
pub use repository::RegulatoryEvidenceRepository;
pub use routes::regulatory_evidence_routes;
pub use service::RegulatoryEvidenceService;
Loading