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:
+66
-65
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user