feat(auth): gate existing endpoints with matrix_gate dependency

This commit is contained in:
Nora
2026-06-22 14:46:56 -06:00
parent 23c1bb6b34
commit 63ae0d2905
4 changed files with 180 additions and 47 deletions
+47 -46
View File
@@ -26,12 +26,13 @@ from datetime import datetime, timezone
from contextlib import asynccontextmanager
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 Claim, ClaimState, Remittance
from cyclone.inbox_state import apply_999_rejections
from cyclone.inbox_state_277ca import apply_277ca_rejections
@@ -255,7 +256,7 @@ def health() -> dict[str, str]:
return {"status": "ok", "version": __version__}
@app.post("/api/parse-837")
@app.post("/api/parse-837", dependencies=[Depends(matrix_gate)])
async def parse_837(
request: Request,
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(
request: Request,
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'}"
@app.post("/api/parse-999")
@app.post("/api/parse-999", dependencies=[Depends(matrix_gate)])
async def parse_999_endpoint(
request: Request,
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'}"
@app.post("/api/parse-ta1")
@app.post("/api/parse-ta1", dependencies=[Depends(matrix_gate)])
async def parse_ta1_endpoint(
file: UploadFile = File(...),
) -> 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(
limit: int = Query(100, ge=1, le=1000),
) -> 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:
"""Return one persisted TA1 ACK row with its parsed detail."""
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'}"
@app.post("/api/parse-277ca")
@app.post("/api/parse-277ca", dependencies=[Depends(matrix_gate)])
async def parse_277ca_endpoint(
request: Request,
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(
limit: int = Query(100, ge=1, le=1000),
) -> Any:
@@ -990,7 +991,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)
@@ -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():
"""Return all Inbox lanes in one call."""
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):
"""Manually link a remit to a claim."""
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}
@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 []
@@ -1115,7 +1116,7 @@ def inbox_dismiss_candidates(body: dict):
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(
request: Request,
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):
"""Stream a CSV for a single lane."""
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
@@ -1262,7 +1263,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),
@@ -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:
rec = store.get(batch_id)
if rec is None:
@@ -1307,7 +1308,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),
@@ -1418,7 +1419,7 @@ async def _tail_events(
bus.unsubscribe(queue, kinds)
@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),
@@ -1465,7 +1466,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).
@@ -1490,7 +1491,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).
@@ -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:
"""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:
"""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(
a: 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)
@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).
@@ -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:
"""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(
request: Request,
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(
request: Request,
payer: str | None = Query(None),
@@ -1958,7 +1959,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.
@@ -1975,7 +1976,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),
@@ -2005,7 +2006,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),
@@ -2037,7 +2038,7 @@ def list_activity(
# --------------------------------------------------------------------------- #
@app.get("/api/dashboard/summary")
@app.get("/api/dashboard/summary", dependencies=[Depends(matrix_gate)])
def dashboard_summary(
months: int = Query(6, ge=1, le=24),
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(
request: Request,
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(
request: Request,
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:
"""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
@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.
@@ -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(
file: UploadFile = File(...),
) -> 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():
"""Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block)."""
ch = store.get_clearhouse()
@@ -2384,7 +2385,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(body: dict):
"""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)):
"""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)]
@@ -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(
entity_type: 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:
"""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):
p = store.get_provider(npi)
if p is None:
@@ -2564,12 +2565,12 @@ def get_configured_provider(npi: str):
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)):
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
@@ -2586,7 +2587,7 @@ def list_payer_configs(payer_id: str):
return configs
@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
+37
View File
@@ -102,3 +102,40 @@ 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 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
+26 -1
View File
@@ -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
modules (test_api.py, test_api_835.py, test_api_gets.py,
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
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
@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.
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
does not invoke the FastAPI lifespan handler unless used as a context
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")
from cyclone import db
from cyclone.api import app
from cyclone.auth import deps as _auth_deps
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.init_db()
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}"