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 time import monotonic
from typing import Any, AsyncIterator 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.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response, StreamingResponse from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import ValidationError from pydantic import ValidationError
from cyclone import __version__, db from cyclone import __version__, db
from cyclone.auth.deps import matrix_gate
from cyclone.db import Batch, Claim, ClaimState, Remittance from cyclone.db import Batch, Claim, ClaimState, Remittance
from sqlalchemy import desc, or_ from sqlalchemy import desc, or_
from sqlalchemy.exc import IntegrityError 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 from cyclone.api_routers import acks, admin, health, ta1_acks # noqa: E402
app.include_router(health.router) app.include_router(health.router)
app.include_router(acks.router) app.include_router(acks.router, dependencies=[Depends(matrix_gate)])
app.include_router(ta1_acks.router) app.include_router(ta1_acks.router, dependencies=[Depends(matrix_gate)])
app.include_router(admin.router) app.include_router(admin.router, dependencies=[Depends(matrix_gate)])
@app.exception_handler(HTTPException) @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( async def parse_837(
request: Request, request: Request,
file: UploadFile = File(...), 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( async def parse_835_endpoint(
request: Request, request: Request,
file: UploadFile = File(...), 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'}" 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( async def parse_999_endpoint(
request: Request, request: Request,
file: UploadFile = File(...), 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'}" 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( async def parse_ta1_endpoint(
file: UploadFile = File(...), file: UploadFile = File(...),
) -> Any: ) -> 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'}" 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( async def parse_277ca_endpoint(
request: Request, request: Request,
file: UploadFile = File(...), 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( def list_277ca_acks_endpoint(
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
) -> Any: ) -> Any:
@@ -1041,7 +1042,7 @@ def list_277ca_acks_endpoint(
return {"total": len(rows), "items": items} 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: def get_277ca_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted 277CA ACK row with its parsed detail.""" """Return one persisted 277CA ACK row with its parsed detail."""
row = store.get_277ca_ack(ack_id) 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(): def inbox_lanes():
"""Return all Inbox lanes in one call.""" """Return all Inbox lanes in one call."""
dismissed_pairs = getattr(app.state, "dismissed_pairs", set()) 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): def inbox_match_candidate(remit_id: str, body: dict):
"""Manually link a remit to a claim.""" """Manually link a remit to a claim."""
claim_id = body.get("claim_id") 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} 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): def inbox_dismiss_candidates(body: dict):
"""Add candidate pairs to the session-scoped dismissed set.""" """Add candidate pairs to the session-scoped dismissed set."""
pairs = body.get("pairs") or [] 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 # (the timestamp is not bumped). Returns the count actually transitioned
# so the UI can show "3 of 5 were already acknowledged". # 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): def inbox_acknowledge_payer_rejected(body: dict):
"""Mark Payer-Rejected claims as acknowledged by the operator.""" """Mark Payer-Rejected claims as acknowledged by the operator."""
claim_ids = body.get("claim_ids") or [] 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( def inbox_resubmit_rejected(
request: Request, request: Request,
body: dict, 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): def export_batch_837(request: Request, batch_id: str, body: dict):
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids. """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): def inbox_export_csv(lane: str):
"""Stream a CSV for a single lane.""" """Stream a CSV for a single lane."""
if lane not in {"rejected", "candidates", "unmatched", "done_today"}: if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
@@ -1594,7 +1595,7 @@ def _batch_summary_claim_count(rec: BatchRecord) -> int:
return 0 return 0
@app.get("/api/batches") @app.get("/api/batches", dependencies=[Depends(matrix_gate)])
def list_batches( def list_batches(
request: Request, request: Request,
limit: int = Query(100, ge=1, le=1000), 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: def get_batch(batch_id: str) -> Any:
rec = store.get(batch_id) rec = store.get(batch_id)
if rec is None: if rec is None:
@@ -1639,7 +1640,7 @@ def get_batch(batch_id: str) -> Any:
return json.loads(rec.result.model_dump_json()) return json.loads(rec.result.model_dump_json())
@app.get("/api/claims") @app.get("/api/claims", dependencies=[Depends(matrix_gate)])
def list_claims( def list_claims(
request: Request, request: Request,
batch_id: str | None = Query(None), 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( async def claims_stream(
request: Request, request: Request,
status: str | None = Query(None), status: str | None = Query(None),
@@ -1732,7 +1733,7 @@ async def claims_stream(
return StreamingResponse(gen(), media_type="application/x-ndjson") 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: def get_claim_detail_endpoint(claim_id: str) -> dict:
"""Return one claim with full drawer context (SP4). """Return one claim with full drawer context (SP4).
@@ -1757,7 +1758,7 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
return body 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): def serialize_claim_as_837(claim_id: str):
"""Return the claim as a regenerated X12 837P file (SP8). """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: def get_claim_line_reconciliation(claim_id: str) -> dict:
"""Per-line reconciliation view for the ClaimDrawer tab. """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: def get_reconciliation_unmatched() -> dict:
"""Return unmatched Claims (left) and unmatched Remittances (right). """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( def get_batch_diff(
a: str | None = Query(None), a: str | None = Query(None),
b: 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) 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: def post_reconciliation_match(body: dict) -> dict:
"""Manually pair a Claim with a Remittance (operator override). """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: def post_reconciliation_unmatch(body: dict) -> dict:
"""Remove the current match for a Claim; reset Claim to submitted. """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( def list_remittances(
request: Request, request: Request,
batch_id: str | None = Query(None), 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( async def remittances_stream(
request: Request, request: Request,
payer: str | None = Query(None), payer: str | None = Query(None),
@@ -2225,7 +2226,7 @@ async def remittances_stream(
return StreamingResponse(gen(), media_type="application/x-ndjson") 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: def get_remittance(remittance_id: str) -> dict:
"""Return one remittance with its labeled CAS ``adjustments`` array. """Return one remittance with its labeled CAS ``adjustments`` array.
@@ -2242,7 +2243,7 @@ def get_remittance(remittance_id: str) -> dict:
return body return body
@app.get("/api/providers") @app.get("/api/providers", dependencies=[Depends(matrix_gate)])
def list_providers( def list_providers(
request: Request, request: Request,
npi: str | None = Query(None), 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( def list_activity(
request: Request, request: Request,
kind: str | None = Query(None), 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( async def activity_stream(
request: Request, request: Request,
kind: str | None = Query(None), kind: str | None = Query(None),
@@ -2447,7 +2448,7 @@ def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
return result, service_type_code 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: def post_eligibility_request(body: dict) -> Any:
"""Build a 270 eligibility inquiry from a small JSON body. """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( async def post_eligibility_parse_271(
file: UploadFile = File(...), file: UploadFile = File(...),
) -> Any: ) -> 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(): def get_clearhouse():
"""Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block).""" """Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block)."""
ch = store.get_clearhouse() ch = store.get_clearhouse()
@@ -2542,7 +2543,7 @@ def get_clearhouse():
return json.loads(ch.model_dump_json()) 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): def submit_to_clearhouse(request: Request, body: dict):
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub. """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)): def list_configured_providers(is_active: bool | None = Query(default=True)):
"""List the configured provider rows (3 NPIs for SP9).""" """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)] 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( def list_audit_log_endpoint(
entity_type: str | None = Query(default=None), entity_type: str | None = Query(default=None),
entity_id: 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: def verify_audit_log_endpoint() -> Any:
"""Walk the audit-log chain and verify every row's hash. """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() _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: def rotate_db_key_endpoint(body: dict | None = None) -> Any:
"""Generate a fresh DB key, re-encrypt the DB, update the Keychain. """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)) 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: def backup_create() -> Any:
"""Take an encrypted backup right now. Returns the new backup metadata.""" """Take an encrypted backup right now. Returns the new backup metadata."""
from cyclone import audit_log as _audit 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( def backup_list(
limit: int = Query(default=100, ge=1, le=1000), limit: int = Query(default=100, ge=1, le=1000),
status: str | None = Query(default=None), 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: def backup_status() -> Any:
"""Snapshot of the backup subsystem (counts, disk usage, last run).""" """Snapshot of the backup subsystem (counts, disk usage, last run)."""
svc = _backup_or_503() svc = _backup_or_503()
@@ -3079,7 +3080,7 @@ def backup_status() -> Any:
return snap 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: def backup_verify(backup_id: int) -> Any:
"""Decrypt + checksum-verify a backup against its sidecar.""" """Decrypt + checksum-verify a backup against its sidecar."""
svc = _backup_or_503() 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: def backup_restore_initiate(backup_id: int) -> Any:
"""First step of the two-step restore. Returns a ``restore_token``.""" """First step of the two-step restore. Returns a ``restore_token``."""
svc = _backup_or_503() 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( def backup_restore_confirm(
backup_id: int, backup_id: int,
body: dict | None = None, 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: def backup_prune() -> Any:
"""Apply the retention policy now. Returns the deleted paths.""" """Apply the retention policy now. Returns the deleted paths."""
from cyclone import audit_log as _audit 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: async def backup_scheduler_start() -> Any:
"""Begin the backup scheduler loop.""" """Begin the backup scheduler loop."""
try: try:
@@ -3217,7 +3218,7 @@ async def backup_scheduler_start() -> Any:
return {"status": sched.status().as_dict()} 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: async def backup_scheduler_stop() -> Any:
"""Stop the backup scheduler loop.""" """Stop the backup scheduler loop."""
try: try:
@@ -3228,7 +3229,7 @@ async def backup_scheduler_stop() -> Any:
return {"status": sched.status().as_dict()} 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: async def backup_scheduler_tick() -> Any:
"""Run one backup tick now (create + prune + audit).""" """Run one backup tick now (create + prune + audit)."""
try: try:
@@ -3262,7 +3263,7 @@ def _scheduler_or_503():
raise HTTPException(status_code=503, detail=str(exc)) 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: async def scheduler_start() -> Any:
"""Begin polling the MFT inbound path every poll_interval_seconds.""" """Begin polling the MFT inbound path every poll_interval_seconds."""
sched = _scheduler_or_503() sched = _scheduler_or_503()
@@ -3270,7 +3271,7 @@ async def scheduler_start() -> Any:
return {"status": sched.status().as_dict()} 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: async def scheduler_stop() -> Any:
"""Stop polling. Waits up to 30s for the current tick to finish.""" """Stop polling. Waits up to 30s for the current tick to finish."""
sched = _scheduler_or_503() sched = _scheduler_or_503()
@@ -3278,7 +3279,7 @@ async def scheduler_stop() -> Any:
return {"status": sched.status().as_dict()} 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: async def scheduler_tick() -> Any:
"""Run a single poll cycle synchronously and return the result. """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()} 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: def scheduler_status() -> Any:
"""Return the scheduler's runtime snapshot (running, counters, last tick).""" """Return the scheduler's runtime snapshot (running, counters, last tick)."""
sched = _scheduler_or_503() sched = _scheduler_or_503()
return sched.status().as_dict() 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( def scheduler_processed_files(
limit: int = Query(default=100, ge=1, le=1000), limit: int = Query(default=100, ge=1, le=1000),
status: str | None = Query(default=None), 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): def get_configured_provider(npi: str):
p = store.get_provider(npi) p = store.get_provider(npi)
if p is None: if p is None:
@@ -3420,12 +3421,12 @@ def get_configured_provider(npi: str):
return provider_dict 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)): 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)] 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): def list_payer_configs(payer_id: str):
"""List all (transaction_type, config_json) blocks for a payer.""" """List all (transaction_type, config_json) blocks for a payer."""
from cyclone import payers as payer_loader from cyclone import payers as payer_loader
@@ -3476,7 +3477,7 @@ def _clear_summary_cache() -> None:
# UI proves TTL-bounded staleness is unacceptable. # 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): def get_payer_summary(payer_id: str):
"""Payer-level rollup for the drill-down panel. """Payer-level rollup for the drill-down panel.
@@ -3562,7 +3563,7 @@ def get_payer_summary(payer_id: str):
return payload return payload
@app.post("/api/admin/reload-config") @app.post("/api/admin/reload-config", dependencies=[Depends(matrix_gate)])
def reload_config(): def reload_config():
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts.""" """Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
from cyclone import payers as payer_loader 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.routes import router as auth_router
from cyclone.auth.admin import router as admin_users_router from cyclone.auth.admin import router as admin_users_router
app.include_router(auth_router) app.include_router(auth_router, dependencies=[Depends(matrix_gate)])
app.include_router(admin_users_router) app.include_router(admin_users_router, dependencies=[Depends(matrix_gate)])
__all__ = ["app"] __all__ = ["app"]
+36
View File
@@ -102,3 +102,39 @@ def require_role(*allowed: Role):
) )
return user return user
return _dep 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