feat(auth): gate existing endpoints with matrix_gate dependency
This commit is contained in:
+47
-46
@@ -26,12 +26,13 @@ from datetime import datetime, timezone
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
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 Claim, ClaimState, Remittance
|
from cyclone.db import Claim, ClaimState, Remittance
|
||||||
from cyclone.inbox_state import apply_999_rejections
|
from cyclone.inbox_state import apply_999_rejections
|
||||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||||
@@ -255,7 +256,7 @@ def health() -> dict[str, str]:
|
|||||||
return {"status": "ok", "version": __version__}
|
return {"status": "ok", "version": __version__}
|
||||||
|
|
||||||
|
|
||||||
@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(...),
|
||||||
@@ -448,7 +449,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(...),
|
||||||
@@ -592,7 +593,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(...),
|
||||||
@@ -718,7 +719,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:
|
||||||
@@ -797,7 +798,7 @@ async def parse_ta1_endpoint(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/ta1-acks")
|
@app.get("/api/ta1-acks", dependencies=[Depends(matrix_gate)])
|
||||||
def list_ta1_acks_endpoint(
|
def list_ta1_acks_endpoint(
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -815,7 +816,7 @@ def list_ta1_acks_endpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/ta1-acks/{ack_id}")
|
@app.get("/api/ta1-acks/{ack_id}", dependencies=[Depends(matrix_gate)])
|
||||||
def get_ta1_ack_endpoint(ack_id: int) -> dict:
|
def get_ta1_ack_endpoint(ack_id: int) -> dict:
|
||||||
"""Return one persisted TA1 ACK row with its parsed detail."""
|
"""Return one persisted TA1 ACK row with its parsed detail."""
|
||||||
row = store.get_ta1_ack(ack_id)
|
row = store.get_ta1_ack(ack_id)
|
||||||
@@ -861,7 +862,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(...),
|
||||||
@@ -980,7 +981,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:
|
||||||
@@ -990,7 +991,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)
|
||||||
@@ -1054,7 +1055,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())
|
||||||
@@ -1072,7 +1073,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")
|
||||||
@@ -1101,7 +1102,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 []
|
||||||
@@ -1115,7 +1116,7 @@ def inbox_dismiss_candidates(body: dict):
|
|||||||
return {"ok": True, "dismissed_count": len(pairs)}
|
return {"ok": True, "dismissed_count": len(pairs)}
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
@@ -1209,7 +1210,7 @@ def inbox_resubmit_rejected(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@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"}:
|
||||||
@@ -1262,7 +1263,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),
|
||||||
@@ -1296,7 +1297,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:
|
||||||
@@ -1307,7 +1308,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),
|
||||||
@@ -1418,7 +1419,7 @@ async def _tail_events(
|
|||||||
bus.unsubscribe(queue, kinds)
|
bus.unsubscribe(queue, kinds)
|
||||||
|
|
||||||
|
|
||||||
@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),
|
||||||
@@ -1465,7 +1466,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).
|
||||||
|
|
||||||
@@ -1490,7 +1491,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).
|
||||||
|
|
||||||
@@ -1542,7 +1543,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.
|
||||||
|
|
||||||
@@ -1739,7 +1740,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).
|
||||||
|
|
||||||
@@ -1756,7 +1757,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),
|
||||||
@@ -1803,7 +1804,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).
|
||||||
|
|
||||||
@@ -1848,7 +1849,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.
|
||||||
|
|
||||||
@@ -1880,7 +1881,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),
|
||||||
@@ -1919,7 +1920,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),
|
||||||
@@ -1958,7 +1959,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.
|
||||||
|
|
||||||
@@ -1975,7 +1976,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),
|
||||||
@@ -2005,7 +2006,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),
|
||||||
@@ -2037,7 +2038,7 @@ def list_activity(
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/dashboard/summary")
|
@app.get("/api/dashboard/summary", dependencies=[Depends(matrix_gate)])
|
||||||
def dashboard_summary(
|
def dashboard_summary(
|
||||||
months: int = Query(6, ge=1, le=24),
|
months: int = Query(6, ge=1, le=24),
|
||||||
top_providers: int = Query(4, ge=1, le=20),
|
top_providers: int = Query(4, ge=1, le=20),
|
||||||
@@ -2061,7 +2062,7 @@ def dashboard_summary(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@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),
|
||||||
@@ -2125,7 +2126,7 @@ def _ack_to_ui(row) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/acks")
|
@app.get("/api/acks", dependencies=[Depends(matrix_gate)])
|
||||||
def list_acks_endpoint(
|
def list_acks_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
@@ -2149,7 +2150,7 @@ def list_acks_endpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/acks/{ack_id}")
|
@app.get("/api/acks/{ack_id}", dependencies=[Depends(matrix_gate)])
|
||||||
def get_ack_endpoint(ack_id: int) -> dict:
|
def get_ack_endpoint(ack_id: int) -> dict:
|
||||||
"""Return one persisted ACK row with its parsed detail.
|
"""Return one persisted ACK row with its parsed detail.
|
||||||
|
|
||||||
@@ -2289,7 +2290,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.
|
||||||
|
|
||||||
@@ -2315,7 +2316,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:
|
||||||
@@ -2375,7 +2376,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()
|
||||||
@@ -2384,7 +2385,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(body: dict):
|
def submit_to_clearhouse(body: dict):
|
||||||
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
|
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
|
||||||
|
|
||||||
@@ -2484,7 +2485,7 @@ def _serialize_claim_from_raw(claim_row, raw: dict) -> 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)]
|
||||||
@@ -2495,7 +2496,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),
|
||||||
@@ -2537,7 +2538,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.
|
||||||
|
|
||||||
@@ -2556,7 +2557,7 @@ def verify_audit_log_endpoint() -> Any:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@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:
|
||||||
@@ -2564,12 +2565,12 @@ def get_configured_provider(npi: str):
|
|||||||
return json.loads(p.model_dump_json())
|
return json.loads(p.model_dump_json())
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
@@ -2586,7 +2587,7 @@ def list_payer_configs(payer_id: str):
|
|||||||
return configs
|
return configs
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
|
|||||||
@@ -102,3 +102,40 @@ 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 the single ``dependencies=[Depends(matrix_gate)]`` on every
|
||||||
|
authenticated route in ``cyclone.api``. 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
|
||||||
|
|||||||
@@ -10,26 +10,51 @@ requires the engine to be initialized before any DB access, whereas the
|
|||||||
old in-memory store did not. Adding the init here keeps existing test
|
old in-memory store did not. Adding the init here keeps existing test
|
||||||
modules (test_api.py, test_api_835.py, test_api_gets.py,
|
modules (test_api.py, test_api_835.py, test_api_gets.py,
|
||||||
test_api_parse_persists.py) working unchanged.
|
test_api_parse_persists.py) working unchanged.
|
||||||
|
|
||||||
|
Auth posture: ``CYCLONE_AUTH_DISABLED`` defaults to ``"1"`` here so the
|
||||||
|
existing test suite (700+ tests that don't log in) continues to pass
|
||||||
|
unmodified. Tests in ``test_auth_*`` modules explicitly re-enable auth
|
||||||
|
so they exercise the real ``get_current_user`` / ``matrix_gate`` paths.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Must be set before ``cyclone.api`` is imported so ``get_current_user``
|
||||||
|
# / ``matrix_gate`` see the env var. The module-level ``AUTH_DISABLED``
|
||||||
|
# flag in ``cyclone.auth.deps`` is flipped per-test below.
|
||||||
|
os.environ.setdefault("CYCLONE_AUTH_DISABLED", "1")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _auto_init_db(tmp_path, monkeypatch):
|
def _auto_init_db(tmp_path, monkeypatch, request):
|
||||||
"""Point CYCLONE_DB_URL at a per-test SQLite file and init the schema.
|
"""Point CYCLONE_DB_URL at a per-test SQLite file and init the schema.
|
||||||
|
|
||||||
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
|
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
|
||||||
does not invoke the FastAPI lifespan handler unless used as a context
|
does not invoke the FastAPI lifespan handler unless used as a context
|
||||||
manager. The bus is reset between tests so subscribers don't leak.
|
manager. The bus is reset between tests so subscribers don't leak.
|
||||||
|
|
||||||
|
Auth posture is set per-test from the test module's name: legacy
|
||||||
|
tests (any module not starting with ``test_auth``) run with
|
||||||
|
``AUTH_DISABLED=True`` so they hit the API without logging in;
|
||||||
|
``test_auth_*`` modules run with ``AUTH_DISABLED=False`` so they
|
||||||
|
exercise the real ``get_current_user`` / ``matrix_gate`` paths.
|
||||||
"""
|
"""
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||||
from cyclone import db
|
from cyclone import db
|
||||||
from cyclone.api import app
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import deps as _auth_deps
|
||||||
from cyclone.pubsub import EventBus
|
from cyclone.pubsub import EventBus
|
||||||
|
|
||||||
|
test_module = request.node.module.__name__
|
||||||
|
if test_module.startswith("test_auth"):
|
||||||
|
_auth_deps.AUTH_DISABLED = False
|
||||||
|
else:
|
||||||
|
_auth_deps.AUTH_DISABLED = True
|
||||||
|
|
||||||
db._reset_for_tests()
|
db._reset_for_tests()
|
||||||
db.init_db()
|
db.init_db()
|
||||||
app.state.event_bus = EventBus()
|
app.state.event_bus = EventBus()
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Spot-check that existing endpoints now require auth (when AUTH_DISABLED is not set).
|
||||||
|
|
||||||
|
The conftest in ``tests/conftest.py`` flips ``AUTH_DISABLED=True`` for
|
||||||
|
every test module that does NOT start with ``test_auth`` — this module
|
||||||
|
deliberately is NOT named ``test_auth_*`` so it inherits the default
|
||||||
|
disabled posture... wait, that's the opposite of what we want.
|
||||||
|
|
||||||
|
This module is named ``test_existing_endpoints_require_auth`` so the
|
||||||
|
conftest will treat it as a legacy test and set ``AUTH_DISABLED=True``,
|
||||||
|
bypassing the auth check entirely. To actually verify the gate, this
|
||||||
|
test's ``client`` fixture flips ``AUTH_DISABLED`` to ``False``
|
||||||
|
*just for this test*, then restores it afterwards. This way the
|
||||||
|
rest of the suite still sees the disabled posture and existing
|
||||||
|
tests keep passing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import deps as _auth_deps
|
||||||
|
from cyclone.auth.deps import AUTH_DISABLED
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
# Force AUTH_DISABLED=False for these tests so the dep actually checks the cookie.
|
||||||
|
original = AUTH_DISABLED
|
||||||
|
_auth_deps.AUTH_DISABLED = False
|
||||||
|
try:
|
||||||
|
yield TestClient(app)
|
||||||
|
finally:
|
||||||
|
_auth_deps.AUTH_DISABLED = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method,path", [
|
||||||
|
("GET", "/api/claims"),
|
||||||
|
("GET", "/api/remittances"),
|
||||||
|
("GET", "/api/providers"),
|
||||||
|
("GET", "/api/batches"),
|
||||||
|
("GET", "/api/dashboard/summary"),
|
||||||
|
("GET", "/api/activity"),
|
||||||
|
])
|
||||||
|
def test_existing_get_endpoints_require_auth(client, method, path):
|
||||||
|
resp = client.request(method, path)
|
||||||
|
assert resp.status_code == 401, f"{method} {path} returned {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_is_public(client):
|
||||||
|
"""``/api/health`` is the public healthcheck and must remain reachable."""
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
assert resp.status_code != 401, f"/api/health returned {resp.status_code}"
|
||||||
Reference in New Issue
Block a user