From 70280f70bb0f0d5d697f8a69a38982a470edc1d2 Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 22 Jun 2026 15:40:00 -0600 Subject: [PATCH] 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). --- backend/src/cyclone/api.py | 131 ++++++++++++++++--------------- backend/src/cyclone/auth/deps.py | 36 +++++++++ 2 files changed, 102 insertions(+), 65 deletions(-) diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 95ae900..fd83077 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -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"] diff --git a/backend/src/cyclone/auth/deps.py b/backend/src/cyclone/auth/deps.py index 4660381..59501fc 100644 --- a/backend/src/cyclone/auth/deps.py +++ b/backend/src/cyclone/auth/deps.py @@ -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