diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 16e33f5..2e09bdc 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -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 diff --git a/backend/src/cyclone/auth/deps.py b/backend/src/cyclone/auth/deps.py index 4660381..67f78ce 100644 --- a/backend/src/cyclone/auth/deps.py +++ b/backend/src/cyclone/auth/deps.py @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0ee2b75..148a69c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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() diff --git a/backend/tests/test_existing_endpoints_require_auth.py b/backend/tests/test_existing_endpoints_require_auth.py new file mode 100644 index 0000000..cadd03d --- /dev/null +++ b/backend/tests/test_existing_endpoints_require_auth.py @@ -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}"