feat(auth): gate existing endpoints with matrix_gate (router-level + per-endpoint)

Replaces the skipped 63ae0d2 commit: adds matrix_gate to deps.py and
applies it as a dependency on every authenticated FastAPI route, including
extracted routers (acks, ta1_acks, admin) and inline endpoints in api.py.

The PERMISSIONS matrix in auth/permissions.py controls which roles can
hit which (method, path) combinations; matrix_gate is fail-closed by
default (any endpoint not in the matrix returns 403).
This commit is contained in:
Nora
2026-06-22 15:40:00 -06:00
parent a933672411
commit 70280f70bb
2 changed files with 102 additions and 65 deletions
+66 -65
View File
@@ -29,12 +29,13 @@ from contextlib import asynccontextmanager
from time import monotonic
from typing import Any, AsyncIterator
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import ValidationError
from cyclone import __version__, db
from cyclone.auth.deps import matrix_gate
from cyclone.db import Batch, Claim, ClaimState, Remittance
from sqlalchemy import desc, or_
from sqlalchemy.exc import IntegrityError
@@ -283,9 +284,9 @@ app.add_middleware(SecurityHeadersMiddleware)
from cyclone.api_routers import acks, admin, health, ta1_acks # noqa: E402
app.include_router(health.router)
app.include_router(acks.router)
app.include_router(ta1_acks.router)
app.include_router(admin.router)
app.include_router(acks.router, dependencies=[Depends(matrix_gate)])
app.include_router(ta1_acks.router, dependencies=[Depends(matrix_gate)])
app.include_router(admin.router, dependencies=[Depends(matrix_gate)])
@app.exception_handler(HTTPException)
@@ -351,7 +352,7 @@ async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSON
)
@app.post("/api/parse-837")
@app.post("/api/parse-837", dependencies=[Depends(matrix_gate)])
async def parse_837(
request: Request,
file: UploadFile = File(...),
@@ -537,7 +538,7 @@ def _reconciliation_summary_for_batch(batch_id: str) -> dict:
}
@app.post("/api/parse-835")
@app.post("/api/parse-835", dependencies=[Depends(matrix_gate)])
async def parse_835_endpoint(
request: Request,
file: UploadFile = File(...),
@@ -688,7 +689,7 @@ def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
@app.post("/api/parse-999")
@app.post("/api/parse-999", dependencies=[Depends(matrix_gate)])
async def parse_999_endpoint(
request: Request,
file: UploadFile = File(...),
@@ -815,7 +816,7 @@ def _ta1_synthetic_source_batch_id(interchange_control_number: str) -> str:
return f"TA1-{(interchange_control_number or '').strip() or '000000001'}"
@app.post("/api/parse-ta1")
@app.post("/api/parse-ta1", dependencies=[Depends(matrix_gate)])
async def parse_ta1_endpoint(
file: UploadFile = File(...),
) -> Any:
@@ -911,7 +912,7 @@ def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
@app.post("/api/parse-277ca")
@app.post("/api/parse-277ca", dependencies=[Depends(matrix_gate)])
async def parse_277ca_endpoint(
request: Request,
file: UploadFile = File(...),
@@ -1031,7 +1032,7 @@ async def parse_277ca_endpoint(
})
@app.get("/api/277ca-acks")
@app.get("/api/277ca-acks", dependencies=[Depends(matrix_gate)])
def list_277ca_acks_endpoint(
limit: int = Query(100, ge=1, le=1000),
) -> Any:
@@ -1041,7 +1042,7 @@ def list_277ca_acks_endpoint(
return {"total": len(rows), "items": items}
@app.get("/api/277ca-acks/{ack_id}")
@app.get("/api/277ca-acks/{ack_id}", dependencies=[Depends(matrix_gate)])
def get_277ca_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted 277CA ACK row with its parsed detail."""
row = store.get_277ca_ack(ack_id)
@@ -1105,7 +1106,7 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
# --------------------------------------------------------------------------- #
@app.get("/api/inbox/lanes")
@app.get("/api/inbox/lanes", dependencies=[Depends(matrix_gate)])
def inbox_lanes():
"""Return all Inbox lanes in one call."""
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
@@ -1123,7 +1124,7 @@ def inbox_lanes():
}
@app.post("/api/inbox/candidates/{remit_id}/match")
@app.post("/api/inbox/candidates/{remit_id}/match", dependencies=[Depends(matrix_gate)])
def inbox_match_candidate(remit_id: str, body: dict):
"""Manually link a remit to a claim."""
claim_id = body.get("claim_id")
@@ -1152,7 +1153,7 @@ def inbox_match_candidate(remit_id: str, body: dict):
return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}
@app.post("/api/inbox/candidates/dismiss")
@app.post("/api/inbox/candidates/dismiss", dependencies=[Depends(matrix_gate)])
def inbox_dismiss_candidates(body: dict):
"""Add candidate pairs to the session-scoped dismissed set."""
pairs = body.get("pairs") or []
@@ -1178,7 +1179,7 @@ def inbox_dismiss_candidates(body: dict):
# (the timestamp is not bumped). Returns the count actually transitioned
# so the UI can show "3 of 5 were already acknowledged".
# --------------------------------------------------------------------------- #
@app.post("/api/inbox/payer-rejected/acknowledge")
@app.post("/api/inbox/payer-rejected/acknowledge", dependencies=[Depends(matrix_gate)])
def inbox_acknowledge_payer_rejected(body: dict):
"""Mark Payer-Rejected claims as acknowledged by the operator."""
claim_ids = body.get("claim_ids") or []
@@ -1238,7 +1239,7 @@ def inbox_acknowledge_payer_rejected(body: dict):
}
@app.post("/api/inbox/rejected/resubmit")
@app.post("/api/inbox/rejected/resubmit", dependencies=[Depends(matrix_gate)])
def inbox_resubmit_rejected(
request: Request,
body: dict,
@@ -1332,7 +1333,7 @@ def inbox_resubmit_rejected(
)
@app.post("/api/batches/{batch_id}/export-837")
@app.post("/api/batches/{batch_id}/export-837", dependencies=[Depends(matrix_gate)])
def export_batch_837(request: Request, batch_id: str, body: dict):
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids.
@@ -1541,7 +1542,7 @@ def export_batch_837(request: Request, batch_id: str, body: dict):
)
@app.get("/api/inbox/export.csv")
@app.get("/api/inbox/export.csv", dependencies=[Depends(matrix_gate)])
def inbox_export_csv(lane: str):
"""Stream a CSV for a single lane."""
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
@@ -1594,7 +1595,7 @@ def _batch_summary_claim_count(rec: BatchRecord) -> int:
return 0
@app.get("/api/batches")
@app.get("/api/batches", dependencies=[Depends(matrix_gate)])
def list_batches(
request: Request,
limit: int = Query(100, ge=1, le=1000),
@@ -1628,7 +1629,7 @@ def list_batches(
}
@app.get("/api/batches/{batch_id}")
@app.get("/api/batches/{batch_id}", dependencies=[Depends(matrix_gate)])
def get_batch(batch_id: str) -> Any:
rec = store.get(batch_id)
if rec is None:
@@ -1639,7 +1640,7 @@ def get_batch(batch_id: str) -> Any:
return json.loads(rec.result.model_dump_json())
@app.get("/api/claims")
@app.get("/api/claims", dependencies=[Depends(matrix_gate)])
def list_claims(
request: Request,
batch_id: str | None = Query(None),
@@ -1685,7 +1686,7 @@ def list_claims(
# --------------------------------------------------------------------------- #
@app.get("/api/claims/stream")
@app.get("/api/claims/stream", dependencies=[Depends(matrix_gate)])
async def claims_stream(
request: Request,
status: str | None = Query(None),
@@ -1732,7 +1733,7 @@ async def claims_stream(
return StreamingResponse(gen(), media_type="application/x-ndjson")
@app.get("/api/claims/{claim_id}")
@app.get("/api/claims/{claim_id}", dependencies=[Depends(matrix_gate)])
def get_claim_detail_endpoint(claim_id: str) -> dict:
"""Return one claim with full drawer context (SP4).
@@ -1757,7 +1758,7 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
return body
@app.get("/api/claims/{claim_id}/serialize-837")
@app.get("/api/claims/{claim_id}/serialize-837", dependencies=[Depends(matrix_gate)])
def serialize_claim_as_837(claim_id: str):
"""Return the claim as a regenerated X12 837P file (SP8).
@@ -1809,7 +1810,7 @@ def serialize_claim_as_837(claim_id: str):
)
@app.get("/api/claims/{claim_id}/line-reconciliation")
@app.get("/api/claims/{claim_id}/line-reconciliation", dependencies=[Depends(matrix_gate)])
def get_claim_line_reconciliation(claim_id: str) -> dict:
"""Per-line reconciliation view for the ClaimDrawer tab.
@@ -2006,7 +2007,7 @@ def _svc_to_dict(svc) -> dict:
}
@app.get("/api/reconciliation/unmatched")
@app.get("/api/reconciliation/unmatched", dependencies=[Depends(matrix_gate)])
def get_reconciliation_unmatched() -> dict:
"""Return unmatched Claims (left) and unmatched Remittances (right).
@@ -2023,7 +2024,7 @@ def get_reconciliation_unmatched() -> dict:
# --------------------------------------------------------------------------- #
@app.get("/api/batch-diff")
@app.get("/api/batch-diff", dependencies=[Depends(matrix_gate)])
def get_batch_diff(
a: str | None = Query(None),
b: str | None = Query(None),
@@ -2070,7 +2071,7 @@ def get_batch_diff(
return diff_batches_to_wire(a_rec, b_rec)
@app.post("/api/reconciliation/match")
@app.post("/api/reconciliation/match", dependencies=[Depends(matrix_gate)])
def post_reconciliation_match(body: dict) -> dict:
"""Manually pair a Claim with a Remittance (operator override).
@@ -2115,7 +2116,7 @@ def post_reconciliation_match(body: dict) -> dict:
)
@app.post("/api/reconciliation/unmatch")
@app.post("/api/reconciliation/unmatch", dependencies=[Depends(matrix_gate)])
def post_reconciliation_unmatch(body: dict) -> dict:
"""Remove the current match for a Claim; reset Claim to submitted.
@@ -2147,7 +2148,7 @@ def post_reconciliation_unmatch(body: dict) -> dict:
)
@app.get("/api/remittances")
@app.get("/api/remittances", dependencies=[Depends(matrix_gate)])
def list_remittances(
request: Request,
batch_id: str | None = Query(None),
@@ -2186,7 +2187,7 @@ def list_remittances(
}
@app.get("/api/remittances/stream")
@app.get("/api/remittances/stream", dependencies=[Depends(matrix_gate)])
async def remittances_stream(
request: Request,
payer: str | None = Query(None),
@@ -2225,7 +2226,7 @@ async def remittances_stream(
return StreamingResponse(gen(), media_type="application/x-ndjson")
@app.get("/api/remittances/{remittance_id}")
@app.get("/api/remittances/{remittance_id}", dependencies=[Depends(matrix_gate)])
def get_remittance(remittance_id: str) -> dict:
"""Return one remittance with its labeled CAS ``adjustments`` array.
@@ -2242,7 +2243,7 @@ def get_remittance(remittance_id: str) -> dict:
return body
@app.get("/api/providers")
@app.get("/api/providers", dependencies=[Depends(matrix_gate)])
def list_providers(
request: Request,
npi: str | None = Query(None),
@@ -2272,7 +2273,7 @@ def list_providers(
}
@app.get("/api/activity")
@app.get("/api/activity", dependencies=[Depends(matrix_gate)])
def list_activity(
request: Request,
kind: str | None = Query(None),
@@ -2299,7 +2300,7 @@ def list_activity(
}
@app.get("/api/activity/stream")
@app.get("/api/activity/stream", dependencies=[Depends(matrix_gate)])
async def activity_stream(
request: Request,
kind: str | None = Query(None),
@@ -2447,7 +2448,7 @@ def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
return result, service_type_code
@app.post("/api/eligibility/request")
@app.post("/api/eligibility/request", dependencies=[Depends(matrix_gate)])
def post_eligibility_request(body: dict) -> Any:
"""Build a 270 eligibility inquiry from a small JSON body.
@@ -2473,7 +2474,7 @@ def post_eligibility_request(body: dict) -> Any:
}
@app.post("/api/eligibility/parse-271")
@app.post("/api/eligibility/parse-271", dependencies=[Depends(matrix_gate)])
async def post_eligibility_parse_271(
file: UploadFile = File(...),
) -> Any:
@@ -2533,7 +2534,7 @@ async def post_eligibility_parse_271(
# ---------------------------------------------------------------------------
@app.get("/api/clearhouse")
@app.get("/api/clearhouse", dependencies=[Depends(matrix_gate)])
def get_clearhouse():
"""Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block)."""
ch = store.get_clearhouse()
@@ -2542,7 +2543,7 @@ def get_clearhouse():
return json.loads(ch.model_dump_json())
@app.post("/api/clearhouse/submit")
@app.post("/api/clearhouse/submit", dependencies=[Depends(matrix_gate)])
def submit_to_clearhouse(request: Request, body: dict):
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
@@ -2729,7 +2730,7 @@ def _serialize_claim_from_raw(claim_row, raw: dict, **kwargs) -> str:
)
@app.get("/api/config/providers")
@app.get("/api/config/providers", dependencies=[Depends(matrix_gate)])
def list_configured_providers(is_active: bool | None = Query(default=True)):
"""List the configured provider rows (3 NPIs for SP9)."""
return [json.loads(p.model_dump_json()) for p in store.list_providers(is_active=is_active)]
@@ -2740,7 +2741,7 @@ def list_configured_providers(is_active: bool | None = Query(default=True)):
# --------------------------------------------------------------------------- #
@app.get("/api/admin/audit-log")
@app.get("/api/admin/audit-log", dependencies=[Depends(matrix_gate)])
def list_audit_log_endpoint(
entity_type: str | None = Query(default=None),
entity_id: str | None = Query(default=None),
@@ -2782,7 +2783,7 @@ def list_audit_log_endpoint(
}
@app.get("/api/admin/audit-log/verify")
@app.get("/api/admin/audit-log/verify", dependencies=[Depends(matrix_gate)])
def verify_audit_log_endpoint() -> Any:
"""Walk the audit-log chain and verify every row's hash.
@@ -2822,7 +2823,7 @@ from cyclone import secrets as _secrets
_db_rotate_lock = _threading.Lock()
@app.post("/api/admin/db/rotate-key")
@app.post("/api/admin/db/rotate-key", dependencies=[Depends(matrix_gate)])
def rotate_db_key_endpoint(body: dict | None = None) -> Any:
"""Generate a fresh DB key, re-encrypt the DB, update the Keychain.
@@ -2982,7 +2983,7 @@ def _backup_or_503():
raise HTTPException(status_code=503, detail=str(exc))
@app.post("/api/admin/backup/create")
@app.post("/api/admin/backup/create", dependencies=[Depends(matrix_gate)])
def backup_create() -> Any:
"""Take an encrypted backup right now. Returns the new backup metadata."""
from cyclone import audit_log as _audit
@@ -3036,7 +3037,7 @@ def backup_create() -> Any:
}
@app.get("/api/admin/backup/list")
@app.get("/api/admin/backup/list", dependencies=[Depends(matrix_gate)])
def backup_list(
limit: int = Query(default=100, ge=1, le=1000),
status: str | None = Query(default=None),
@@ -3065,7 +3066,7 @@ def backup_list(
}
@app.get("/api/admin/backup/status")
@app.get("/api/admin/backup/status", dependencies=[Depends(matrix_gate)])
def backup_status() -> Any:
"""Snapshot of the backup subsystem (counts, disk usage, last run)."""
svc = _backup_or_503()
@@ -3079,7 +3080,7 @@ def backup_status() -> Any:
return snap
@app.post("/api/admin/backup/{backup_id}/verify")
@app.post("/api/admin/backup/{backup_id}/verify", dependencies=[Depends(matrix_gate)])
def backup_verify(backup_id: int) -> Any:
"""Decrypt + checksum-verify a backup against its sidecar."""
svc = _backup_or_503()
@@ -3098,7 +3099,7 @@ def backup_verify(backup_id: int) -> Any:
}
@app.post("/api/admin/backup/{backup_id}/restore/initiate")
@app.post("/api/admin/backup/{backup_id}/restore/initiate", dependencies=[Depends(matrix_gate)])
def backup_restore_initiate(backup_id: int) -> Any:
"""First step of the two-step restore. Returns a ``restore_token``."""
svc = _backup_or_503()
@@ -3126,7 +3127,7 @@ def backup_restore_initiate(backup_id: int) -> Any:
}
@app.post("/api/admin/backup/{backup_id}/restore/confirm")
@app.post("/api/admin/backup/{backup_id}/restore/confirm", dependencies=[Depends(matrix_gate)])
def backup_restore_confirm(
backup_id: int,
body: dict | None = None,
@@ -3178,7 +3179,7 @@ def backup_restore_confirm(
}
@app.post("/api/admin/backup/prune")
@app.post("/api/admin/backup/prune", dependencies=[Depends(matrix_gate)])
def backup_prune() -> Any:
"""Apply the retention policy now. Returns the deleted paths."""
from cyclone import audit_log as _audit
@@ -3206,7 +3207,7 @@ def backup_prune() -> Any:
# ---------------------------------------------------------------------------
@app.post("/api/admin/backup/scheduler/start")
@app.post("/api/admin/backup/scheduler/start", dependencies=[Depends(matrix_gate)])
async def backup_scheduler_start() -> Any:
"""Begin the backup scheduler loop."""
try:
@@ -3217,7 +3218,7 @@ async def backup_scheduler_start() -> Any:
return {"status": sched.status().as_dict()}
@app.post("/api/admin/backup/scheduler/stop")
@app.post("/api/admin/backup/scheduler/stop", dependencies=[Depends(matrix_gate)])
async def backup_scheduler_stop() -> Any:
"""Stop the backup scheduler loop."""
try:
@@ -3228,7 +3229,7 @@ async def backup_scheduler_stop() -> Any:
return {"status": sched.status().as_dict()}
@app.post("/api/admin/backup/scheduler/tick")
@app.post("/api/admin/backup/scheduler/tick", dependencies=[Depends(matrix_gate)])
async def backup_scheduler_tick() -> Any:
"""Run one backup tick now (create + prune + audit)."""
try:
@@ -3262,7 +3263,7 @@ def _scheduler_or_503():
raise HTTPException(status_code=503, detail=str(exc))
@app.post("/api/admin/scheduler/start")
@app.post("/api/admin/scheduler/start", dependencies=[Depends(matrix_gate)])
async def scheduler_start() -> Any:
"""Begin polling the MFT inbound path every poll_interval_seconds."""
sched = _scheduler_or_503()
@@ -3270,7 +3271,7 @@ async def scheduler_start() -> Any:
return {"status": sched.status().as_dict()}
@app.post("/api/admin/scheduler/stop")
@app.post("/api/admin/scheduler/stop", dependencies=[Depends(matrix_gate)])
async def scheduler_stop() -> Any:
"""Stop polling. Waits up to 30s for the current tick to finish."""
sched = _scheduler_or_503()
@@ -3278,7 +3279,7 @@ async def scheduler_stop() -> Any:
return {"status": sched.status().as_dict()}
@app.post("/api/admin/scheduler/tick")
@app.post("/api/admin/scheduler/tick", dependencies=[Depends(matrix_gate)])
async def scheduler_tick() -> Any:
"""Run a single poll cycle synchronously and return the result.
@@ -3291,14 +3292,14 @@ async def scheduler_tick() -> Any:
return {"ok": True, "tick": result.as_dict()}
@app.get("/api/admin/scheduler/status")
@app.get("/api/admin/scheduler/status", dependencies=[Depends(matrix_gate)])
def scheduler_status() -> Any:
"""Return the scheduler's runtime snapshot (running, counters, last tick)."""
sched = _scheduler_or_503()
return sched.status().as_dict()
@app.get("/api/admin/scheduler/processed-files")
@app.get("/api/admin/scheduler/processed-files", dependencies=[Depends(matrix_gate)])
def scheduler_processed_files(
limit: int = Query(default=100, ge=1, le=1000),
status: str | None = Query(default=None),
@@ -3346,7 +3347,7 @@ def scheduler_processed_files(
}
@app.get("/api/config/providers/{npi}")
@app.get("/api/config/providers/{npi}", dependencies=[Depends(matrix_gate)])
def get_configured_provider(npi: str):
p = store.get_provider(npi)
if p is None:
@@ -3420,12 +3421,12 @@ def get_configured_provider(npi: str):
return provider_dict
@app.get("/api/config/payers")
@app.get("/api/config/payers", dependencies=[Depends(matrix_gate)])
def list_configured_payers(is_active: bool | None = Query(default=True)):
return [json.loads(p.model_dump_json()) for p in store.list_payers(is_active=is_active)]
@app.get("/api/config/payers/{payer_id}/configs")
@app.get("/api/config/payers/{payer_id}/configs", dependencies=[Depends(matrix_gate)])
def list_payer_configs(payer_id: str):
"""List all (transaction_type, config_json) blocks for a payer."""
from cyclone import payers as payer_loader
@@ -3476,7 +3477,7 @@ def _clear_summary_cache() -> None:
# UI proves TTL-bounded staleness is unacceptable.
@app.get("/api/payers/{payer_id}/summary")
@app.get("/api/payers/{payer_id}/summary", dependencies=[Depends(matrix_gate)])
def get_payer_summary(payer_id: str):
"""Payer-level rollup for the drill-down panel.
@@ -3562,7 +3563,7 @@ def get_payer_summary(payer_id: str):
return payload
@app.post("/api/admin/reload-config")
@app.post("/api/admin/reload-config", dependencies=[Depends(matrix_gate)])
def reload_config():
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
from cyclone import payers as payer_loader
@@ -3583,8 +3584,8 @@ def reload_config():
from cyclone.auth.routes import router as auth_router
from cyclone.auth.admin import router as admin_users_router
app.include_router(auth_router)
app.include_router(admin_users_router)
app.include_router(auth_router, dependencies=[Depends(matrix_gate)])
app.include_router(admin_users_router, dependencies=[Depends(matrix_gate)])
__all__ = ["app"]
+36
View File
@@ -102,3 +102,39 @@ def require_role(*allowed: Role):
)
return user
return _dep
async def matrix_gate(
request: Request,
user: dict = Depends(get_current_user),
) -> dict:
"""App-wide gate: requires auth, then enforces the PERMISSIONS matrix.
Behavior:
* AUTH_DISABLED short-circuits (synthetic admin, no role check).
* No session cookie → 401 from get_current_user.
* Endpoint not in the matrix → 403 (fail-closed).
* User role not allowed for (method, path) → 403.
* Empty allowed-roles set (e.g. /api/healthz) → public, no role check.
Used as ``dependencies=[Depends(matrix_gate)]`` on every authenticated
route. Centralizing the gate here means the matrix is the source of
truth — no need to wire per-route ``require_role(...)`` calls.
"""
if AUTH_DISABLED:
return user
method = request.method
path = request.url.path
roles = allowed_roles(method, path)
if roles is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
user_role = user.get("role")
if user_role not in {r.value for r in roles}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user