Skip to content

fix(mcs): send credentials to the measure engine and target the job's MCS - #393

Open
blakenan-bellese wants to merge 2 commits into
mainfrom
fix/mcs-auth-wiring
Open

fix(mcs): send credentials to the measure engine and target the job's MCS#393
blakenan-bellese wants to merge 2 commits into
mainfrom
fix/mcs-auth-wiring

Conversation

@blakenan-bellese

Copy link
Copy Markdown
Collaborator

Summary

  • Jobs against a remote MCS failed every patient with HTTP 401 "Authorization header missing Bearer token". evaluate_measure() had no auth parameter at all — a bare client.get(url) with no headers. MCS connections wired the URL through to jobs (Job.mcs_url, _get_mcs_url()) but never the credentials.
  • Three more call sites had the same defect: push_resources(), wipe_patient_data(), and resolve_evaluated_resource() all defaulted to settings.MEASURE_ENGINE_URL with no auth. So patient data went to the local engine while evaluation ran against the remote one. Fixing only the 401 would have produced clean 200s with every population at zero — a silent wrong answer, worse than the visible failure.
  • Adds _get_mcs_auth_headers() mirroring _get_cdr_auth_headers(), and routes all four interactions at the job's MCS with its credentials. No migrationJob.mcs_id already existed.

Related issue

Refs #391 (token refresh — the stored bearer token is still static and expires)
Refs #392 (the wipe is now destructive against a shared remote MCS)

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • Infrastructure / CI/CD
  • Chore (deps, tooling, lockfile)

Checklist

  • Tests added or updated (6 new, written failing-first)
  • docs/ updated (if architecture or API changed) — N/A, no architecture or API surface change
  • No new ADRs needed, OR I've added an entry to docs/decisions.mdADR-011 added (changed auth behavior)
  • Security implications considered — see below

Design notes

Credentials are read live, not snapshotted onto the job. Job snapshots mcs_url/mcs_name/mcs_id so job rendering never depends on current config state, but credentials are deliberately excluded and read from the live MCSConfig — same as the CDR path. Copying secrets onto every job row would multiply the blast radius of a DB disclosure and leave stale tokens across history. Trade-off: deleting an MCS config makes its jobs unrunnable, handled by raising a clear error rather than silently evaluating unauthenticated.

MCS resolution moved above the wipe in run_job. The wipe must clear the same server the job pushes to and evaluates against. Wiping a different server leaves the real target's prior-run patients in place, and those inflate the next evaluation's populations.

No mcs_auth_type column needed. mcs_id alone distinguishes "no MCS linked" (→ {}) from "config deleted" (→ raise), so unlike the CDR path this needed no migration.

Security implications

  • Credentials are Fernet-encrypted at rest (unchanged) and are not echoed in connection API responses (verified).
  • Auth headers are now sent to the configured MCS host. _validate_ssrf_url() already gates which hosts a connection may point at.
  • Existing sanitizers redact Authorization/Bearer in logged errors — confirmed in the captured 401s.
  • Behavior change worth reviewer attention: the job-start wipe now issues DELETE {Type}?_lastUpdated=gt1900-01-01 across 23 resource types against the job's MCS. On a dedicated local engine that is correct and unchanged in effect. On a shared remote MCS it deletes other users' patient data. Filed as bug: job start wipes ALL patient data on the target MCS — must be optional/scoped for shared remote servers #392 with a proposed scoped-wipe fix. Flagging explicitly because this PR is what makes that reachable.

Test plan

Automated

cd backend && ruff check app/ tests/ && ruff format --check app/ tests/
cd backend && python3 -m pytest tests/ --ignore=tests/integration -q       # 510 passed
./scripts/run-integration-tests.sh \
  --ignore=tests/integration/test_golden_measures.py \
  --ignore=tests/integration/test_connectathon_measures.py \
  --ignore=tests/integration/test_groups_dropdown.py \
  --ignore=tests/integration/test_full_jobs_pipeline.py \
  --ignore=tests/integration/test_factory_reset.py                          # 37 passed, 3 skipped

test_full_workflow.py was deliberately kept in (not ignored) since this touches the measure pipeline.

New tests, all written failing-first and confirmed RED before implementation:

Test Guards
test_run_job_targets_job_mcs_with_credentials wipe + push + evaluate all get the job's MCS URL and its headers
test_get_mcs_auth_headers_builds_from_linked_config bearer config → Authorization header
test_get_mcs_auth_headers_empty_when_no_mcs_linked local/legacy jobs stay unauthenticated
test_get_mcs_auth_headers_raises_when_config_deleted deleted config fails loudly, not silently
test_evaluate_measure_sends_auth_headers headers reach the MCS
test_evaluate_measure_without_auth_sends_no_authorization unauthenticated local HAPI unaffected
test_resolve_evaluated_resource_uses_given_base_and_auth snapshots read back from the evaluating server

Four pre-existing tests needed updating — all stale mock signatures against the intentionally-changed call shape (fake_resolve, mock_evaluate, mock_wipe.assert_awaited_once_with). Assertions were updated, not loosened.

Manual — against the real CMS connectathon server

Before After
401 responses 122 (every patient) 0
HTTP statuses from MCS all 401 146 × 200
Patients on the remote MCS 0 56
Encounter / Condition / Observation 0 / 0 / 0 131 / 131 / 28

Remaining failures on that server are server-side — HSEARCH800001: Hibernate Search was not initialized, returned as 200 with an OperationOutcome body, which evaluate_measure already classifies as a failure. Nothing client-side can fix that.

Local regression: a job against the seeded local measure engine completed 56/56, 0 failed with valid MeasureReports. Populations came back zero for the period I chose; direct $evaluate-measure against the same engine with Lenny out of the loop returns the same zeros, so that is a data/period/cohort property and not a change in behavior.

🤖 Generated with Claude Code

… MCS

Jobs against a remote MCS failed every patient with HTTP 401
"Authorization header missing Bearer token". Two independent breaks:

1. evaluate_measure() had no auth parameter at all — a bare client.get()
   with no headers. The orchestrator resolved the job's MCS *url* via
   _get_mcs_url() but there was no equivalent for credentials.

2. push_resources(), wipe_patient_data(), and resolve_evaluated_resource()
   all defaulted to settings.MEASURE_ENGINE_URL with no auth, so patient
   data went to the local engine while evaluation ran against the remote
   one. Fixing only the 401 would have produced clean 200s with every
   population at zero — a silent wrong answer.

Adds _get_mcs_auth_headers(), mirroring _get_cdr_auth_headers(): reads
credentials from the live MCSConfig via Job.mcs_id rather than duplicating
secrets onto the job row, returns {} when no MCS is linked, and raises when
the linked config was deleted instead of silently evaluating unauthenticated.
No migration — Job.mcs_id already existed.

MCS resolution moves above the wipe in run_job so the wipe targets the same
engine the job pushes to and evaluates against; wiping a different server
would leave the real target's prior-run data in place and contaminate
populations.

Verified against the CMS connectathon server: 401s 122 -> 0, all responses
200, and patient data now reaches the remote MCS (0 -> 56 Patients, 131
Encounters, 131 Conditions). Remaining failures there are server-side
(HSEARCH800001, Hibernate Search not initialized), not client-side.

Note: the wipe is now destructive against a shared remote MCS. Tracked
separately in #392.

Refs #391 (token refresh)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant