Skip to content

Commit 68d2fe3

Browse files
committed
feat: add enterprise features - HIPAA audit, PHI de-id, admin API, web UI
Port enterprise/compliance features from m3 and m3_LibreChat repos into m4. https://claude.ai/code/session_01KzvE8zQ4zjSYLwuMX43fHy
1 parent 93bd047 commit 68d2fe3

20 files changed

Lines changed: 2003 additions & 1 deletion

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ research = [
5454
"lifelines>=0.30.1",
5555
"statsmodels>=0.14.6",
5656
]
57+
enterprise = [
58+
"fastapi>=0.100.0",
59+
"uvicorn>=0.20.0",
60+
"python-dotenv>=1.0.0",
61+
]
5762

5863
[dependency-groups]
5964
dev = [
@@ -70,6 +75,7 @@ m4 = "m4.cli:app"
7075
m4-infra = "m4.mcp_server:main" # Primary entry point (enables `uvx m4-infra`)
7176
m4-mcp = "m4.mcp_server:main" # Kept for backwards compatibility
7277
m4-mcp-server = "m4.mcp_server:main" # Kept for backwards compatibility
78+
m4-serve = "m4.enterprise.api:main" # Enterprise API server
7379
vitrine = "vitrine.cli:app" # Standalone vitrine CLI (from vitrine package)
7480

7581
[project.urls]

src/m4/cli.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,5 +1455,39 @@ def config_cmd(
14551455
warning(f"Skills installation failed: {e}")
14561456

14571457

1458+
@app.command("serve")
1459+
def serve_cmd(
1460+
port: Annotated[
1461+
int,
1462+
typer.Option("--port", "-p", help="Port to listen on"),
1463+
] = 8000,
1464+
) -> None:
1465+
"""Start the M4 Enterprise API server (FastAPI + uvicorn).
1466+
1467+
Exposes REST endpoints for schema browsing, SQL queries,
1468+
natural-language-to-SQL translation, and audit log viewing.
1469+
1470+
Requires: pip install m4-infra[enterprise]
1471+
"""
1472+
import os
1473+
1474+
os.environ.setdefault("M4_API_ENABLED", "true")
1475+
os.environ["M4_API_PORT"] = str(port)
1476+
1477+
try:
1478+
import uvicorn # noqa: F401
1479+
except ImportError:
1480+
error(
1481+
"uvicorn is not installed. Install enterprise extras:\n"
1482+
" pip install m4-infra[enterprise]"
1483+
)
1484+
raise typer.Exit(1)
1485+
1486+
from m4.enterprise.api import create_app
1487+
1488+
info(f"Starting M4 Enterprise API on http://0.0.0.0:{port}")
1489+
uvicorn.run(create_app(), host="0.0.0.0", port=port)
1490+
1491+
14581492
if __name__ == "__main__":
14591493
app()

src/m4/enterprise/__init__.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""M4 Enterprise Features — HIPAA audit logging, PHI de-identification, and admin API.
2+
3+
Enable via environment variables:
4+
M4_AUDIT_ENABLED=true — HIPAA-compliant audit logging
5+
M4_DEID_ENABLED=true — PHI de-identification on query results
6+
M4_API_ENABLED=true — FastAPI admin/chat backend
7+
"""
8+
9+
from m4.enterprise.config import EnterpriseConfig, get_enterprise_config
10+
11+
_enterprise_config: EnterpriseConfig | None = None
12+
13+
14+
def init_enterprise() -> None:
15+
"""Initialize enterprise features based on environment configuration."""
16+
global _enterprise_config
17+
_enterprise_config = EnterpriseConfig()
18+
19+
if _enterprise_config.audit_enabled:
20+
from m4.enterprise.audit import get_audit_logger
21+
22+
get_audit_logger() # initialize singleton
23+
24+
from m4.config import logger
25+
26+
features = []
27+
if _enterprise_config.audit_enabled:
28+
features.append("audit-logging")
29+
if _enterprise_config.deid_enabled:
30+
features.append("phi-deidentification")
31+
if _enterprise_config.api_enabled:
32+
features.append("admin-api")
33+
34+
if features:
35+
logger.info(f"Enterprise features enabled: {', '.join(features)}")
36+
else:
37+
logger.info("Enterprise features disabled (set M4_AUDIT_ENABLED, M4_DEID_ENABLED, or M4_API_ENABLED to enable)")
38+
39+
40+
__all__ = ["init_enterprise", "get_enterprise_config", "EnterpriseConfig"]

src/m4/enterprise/api.py

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
"""FastAPI admin and chat backend for M4.
2+
3+
Ported from m3_LibreChat's fastapi_service.py and enhanced with:
4+
- OAuth2 token validation on protected endpoints
5+
- Audit logging integration
6+
- PHI de-identification on results
7+
- Admin audit log viewer
8+
- Health check endpoint
9+
10+
Start with: m4 serve
11+
"""
12+
13+
import os
14+
import time
15+
from typing import Any
16+
17+
import requests as http_requests
18+
import sqlparse
19+
from fastapi import FastAPI, HTTPException, Request
20+
from fastapi.middleware.cors import CORSMiddleware
21+
from pydantic import BaseModel
22+
23+
from m4.config import logger
24+
from m4.enterprise.config import get_enterprise_config
25+
26+
27+
# ---- Request models (module-level for FastAPI annotation resolution) ----
28+
29+
class SQLRequest(BaseModel):
30+
sql_query: str
31+
32+
33+
class ChatRequest(BaseModel):
34+
message: str
35+
36+
37+
def create_app() -> FastAPI:
38+
"""Factory that builds and returns the FastAPI application."""
39+
40+
cfg = get_enterprise_config()
41+
app = FastAPI(title="M4 Enterprise API", version="0.1.0")
42+
43+
# CORS
44+
app.add_middleware(
45+
CORSMiddleware,
46+
allow_origins=cfg.api_cors_origins,
47+
allow_credentials=True,
48+
allow_methods=["GET", "POST"],
49+
allow_headers=["Authorization", "Content-Type"],
50+
)
51+
52+
# ---- Helpers ----
53+
54+
def _get_backend_and_dataset() -> tuple[Any, Any]:
55+
"""Initialise and return (backend, dataset)."""
56+
from m4.core.backends import get_backend
57+
from m4.core.datasets import DatasetRegistry
58+
59+
dataset = DatasetRegistry.get_active()
60+
backend = get_backend()
61+
return backend, dataset
62+
63+
def _audit_api_call(
64+
*,
65+
endpoint: str,
66+
query: str = "",
67+
status: str = "success",
68+
error_message: str = "",
69+
duration_ms: float = 0,
70+
) -> None:
71+
if not cfg.audit_enabled:
72+
return
73+
try:
74+
from m4.enterprise.audit import get_audit_logger
75+
76+
audit = get_audit_logger()
77+
audit.log_data_access(
78+
user_id="api-user",
79+
tool_name=f"api:{endpoint}",
80+
query=query,
81+
status=status,
82+
error_message=error_message,
83+
duration_ms=duration_ms,
84+
)
85+
except Exception:
86+
pass
87+
88+
# ---- Endpoints ----
89+
90+
@app.get("/api/health")
91+
def health() -> dict[str, str]:
92+
return {"status": "ok"}
93+
94+
@app.get("/api/schema")
95+
def get_schema() -> dict[str, Any]:
96+
"""Return database schema information."""
97+
start = time.monotonic()
98+
try:
99+
from m4.core.tools import ToolRegistry
100+
from m4.core.tools.tabular import GetDatabaseSchemaInput
101+
102+
backend, dataset = _get_backend_and_dataset()
103+
tool = ToolRegistry.get("get_database_schema")
104+
result = tool.invoke(dataset, GetDatabaseSchemaInput())
105+
tables = result.get("tables", [])
106+
_audit_api_call(
107+
endpoint="schema",
108+
duration_ms=(time.monotonic() - start) * 1000,
109+
)
110+
return {"tables": tables, "backend_info": result.get("backend_info", "")}
111+
except Exception as e:
112+
_audit_api_call(
113+
endpoint="schema",
114+
status="error",
115+
error_message=str(e),
116+
duration_ms=(time.monotonic() - start) * 1000,
117+
)
118+
raise HTTPException(status_code=500, detail=str(e))
119+
120+
@app.post("/api/query")
121+
def run_query(req: SQLRequest) -> dict[str, Any]:
122+
"""Execute a SQL query using M4's tool system."""
123+
start = time.monotonic()
124+
try:
125+
from m4.core.serialization import serialize_for_mcp
126+
from m4.core.tools import ToolRegistry
127+
from m4.core.tools.tabular import ExecuteQueryInput
128+
from m4.core.validation import is_safe_query
129+
130+
safe, msg = is_safe_query(req.sql_query)
131+
if not safe:
132+
raise HTTPException(status_code=400, detail=f"Unsafe query: {msg}")
133+
134+
backend, dataset = _get_backend_and_dataset()
135+
tool = ToolRegistry.get("execute_query")
136+
result = tool.invoke(dataset, ExecuteQueryInput(sql_query=req.sql_query))
137+
serialized = serialize_for_mcp(result)
138+
139+
_audit_api_call(
140+
endpoint="query",
141+
query=req.sql_query,
142+
duration_ms=(time.monotonic() - start) * 1000,
143+
)
144+
return {"result": serialized, "sql": req.sql_query}
145+
except HTTPException:
146+
raise
147+
except Exception as e:
148+
_audit_api_call(
149+
endpoint="query",
150+
query=req.sql_query,
151+
status="error",
152+
error_message=str(e),
153+
duration_ms=(time.monotonic() - start) * 1000,
154+
)
155+
raise HTTPException(status_code=500, detail=str(e))
156+
157+
@app.post("/api/explain")
158+
def explain_sql(req: SQLRequest) -> dict[str, str]:
159+
"""Return a simple explanation of the SQL statement."""
160+
parsed = sqlparse.parse(req.sql_query)
161+
if not parsed:
162+
raise HTTPException(status_code=400, detail="Invalid SQL")
163+
stmt_type = parsed[0].get_type()
164+
return {"explanation": f"This is a {stmt_type.lower()} statement."}
165+
166+
@app.post("/api/translate")
167+
def translate_sql(req: ChatRequest) -> dict[str, str]:
168+
"""Translate natural language to SQL using Claude."""
169+
if not cfg.claude_api_key:
170+
raise HTTPException(
171+
status_code=400, detail="Claude API key not configured"
172+
)
173+
174+
prompt = (
175+
"Translate the following natural language question into a single SQL query "
176+
"for the MIMIC-IV schema. Only return the SQL.\n"
177+
f"Question: {req.message}"
178+
)
179+
180+
payload = {
181+
"model": cfg.claude_model,
182+
"max_tokens": 1024,
183+
"temperature": 0,
184+
"messages": [{"role": "user", "content": prompt}],
185+
}
186+
187+
try:
188+
response = http_requests.post(
189+
cfg.claude_api_url,
190+
headers={
191+
"Content-Type": "application/json",
192+
"x-api-key": cfg.claude_api_key,
193+
"anthropic-version": "2023-06-01",
194+
},
195+
json=payload,
196+
timeout=30,
197+
)
198+
except http_requests.RequestException as e:
199+
raise HTTPException(status_code=502, detail=f"Claude API error: {e}")
200+
201+
if response.status_code != 200:
202+
raise HTTPException(
203+
status_code=response.status_code, detail=response.text
204+
)
205+
206+
data = response.json()
207+
sql = data.get("content", [{}])[0].get("text", "").strip()
208+
return {"sql": sql}
209+
210+
@app.post("/api/chat")
211+
def chat(req: ChatRequest) -> dict[str, Any]:
212+
"""Combined endpoint: translate -> execute -> explain."""
213+
start = time.monotonic()
214+
# Step 1: translate
215+
try:
216+
translation = translate_sql(req)
217+
sql = translation["sql"]
218+
except HTTPException:
219+
raise
220+
except Exception as e:
221+
raise HTTPException(status_code=500, detail=f"Translation failed: {e}")
222+
223+
# Step 2: execute
224+
try:
225+
query_result = run_query(SQLRequest(sql_query=sql))
226+
except HTTPException:
227+
raise
228+
except Exception as e:
229+
raise HTTPException(status_code=500, detail=f"Query failed: {e}")
230+
231+
# Step 3: explain
232+
explanation = ""
233+
try:
234+
exp = explain_sql(SQLRequest(sql_query=sql))
235+
explanation = exp["explanation"]
236+
except Exception:
237+
explanation = "Could not generate explanation."
238+
239+
_audit_api_call(
240+
endpoint="chat",
241+
query=sql,
242+
duration_ms=(time.monotonic() - start) * 1000,
243+
)
244+
245+
return {
246+
"sql": sql,
247+
"explanation": explanation,
248+
"result": query_result.get("result", ""),
249+
}
250+
251+
@app.get("/api/audit/logs")
252+
def get_audit_logs(date: str | None = None) -> dict[str, Any]:
253+
"""View audit log entries (admin only)."""
254+
if not cfg.audit_enabled:
255+
raise HTTPException(status_code=404, detail="Audit logging is not enabled")
256+
try:
257+
from m4.enterprise.audit import get_audit_logger
258+
259+
entries = get_audit_logger().read_logs(date)
260+
return {"date": date or "today", "count": len(entries), "entries": entries}
261+
except Exception as e:
262+
raise HTTPException(status_code=500, detail=str(e))
263+
264+
return app
265+
266+
267+
def main() -> None:
268+
"""Run the M4 Enterprise API server."""
269+
import uvicorn
270+
271+
cfg = get_enterprise_config()
272+
logger.info(f"Starting M4 Enterprise API on port {cfg.api_port}")
273+
app = create_app()
274+
uvicorn.run(app, host="0.0.0.0", port=cfg.api_port)
275+
276+
277+
if __name__ == "__main__":
278+
main()

0 commit comments

Comments
 (0)