Skip to content

Remove the v1 user routes and keep complete simulation and report results final - #4029

Draft
MaxGhenis wants to merge 3 commits into
masterfrom
max/legacy-user-route-authz-20261023
Draft

MaxGhenis wants to merge 3 commits into
masterfrom
max/legacy-user-route-authz-20261023

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4028

This comes from the 2026-10-22 security review of PolicyEngine's internet-facing services, which confirmed both gaps from the code (no requests were sent to production).

Summary

  • The v1 user routes are removed. POST, GET and PUT /{country_id}/user-profile and POST, GET /{country_id}/user-policy/{user_id} and PUT /{country_id}/user-policy read and changed user profiles and saved policies by a user_id, auth0_id or record id taken from the request, and checked no credential. App v2 does not call them (checked against policyengine-app-v2 main), and Remove the routes only the legacy site used, check health at /health, and stop serving Canada, Nigeria and Israel #4017 reports no production requests to them in the 30 days to 2026-10-19. Signed-in users reach their saved work through /v2/user-policies and /v2/me, which take the owner from the verified credential. The deletions follow Remove the routes only the legacy site used, check health at /health, and stop serving Canada, Nigeria and Israel #4017's (same files, same removed blocks; it also removes GET /{country_id}/policies, which stays here), so its rebase should mostly drop them. As in Remove the routes only the legacy site used, check health at /health, and stop serving Canada, Nigeria and Israel #4017, the v1 saved-policy service and copying modules stay until dual-write is deleted, and the Cloud SQL user_profiles and user_policies rows are kept.
  • A complete simulation or report result is final. PATCH /{country_id}/simulation and PATCH /{country_id}/report take no credential, and the records are shared: identical requests reuse one record. Once a record holds a complete result, an update that repeats it answers 200 and writes nothing, and one that would change it (another output, an error, a status, an error message) answers 409 and leaves the record and its runs as they were.
    • Simulations: a result is final when it came from the installed country model or a newer one. A result from an older model stays replaceable, because app v2 recalculates every simulation of a new household report, reused ones included, and stores the installed model's result, as it does today. An older revision (a rollback) cannot replace a newer model's result.
    • Reports: an update to an outdated report ID now goes to its current equivalent, the record GET already serves for that ID, and keeps the requested ID in the response. Before, GET served the current record while PATCH wrote the outdated row, so the current record stayed pending and was recalculated on every open.
    • Not final: a complete record with no output, stale SPM simulation output (the server resets it and runs it again), a simulation result from an older model, and a report while a server-started rerun is pending or running (no production code starts one today; the run service supports reruns and its tests exercise them).
    • Also rejected now: a completion whose output is null, including a JSON-encoded "null" (it used to store a complete record with no result), and a body id that is not an integer ("5", 5.0 and true used to reach the row).

Why not a compute-worker credential

The review suggested restricting these updates to the compute worker's credential. There is no such worker: the browser that ran the calculation stores its result, signed in or not (ResultPersister in app v2). Requiring a worker credential would break every report. This PR closes what can be closed without changing that.

What stays open

  • First write wins on pending records. IDs are sequential and GET by ID is anonymous, so a caller can watch for new records and complete each one before the browser that is calculating it; with this change that first result is the one kept, and the browser's write gets 409. Today any record can be rewritten at any time, so this is a smaller surface, but it is not closed. The same goes for a simulation whose result came from an older model, or whose SPM output is stale. Closing it means the server writes or verifies results itself: both kinds are computed server-side first (GET /{country_id}/household/{id}/policy/{id} and the economy routes, each cached), so the server can store its own result and stop accepting caller-supplied output. That needs an app-v2 change too, so it is a separate PR.
  • Old Cloud Run revisions. Every deploy leaves a tagged revision on the production service, nothing removes the tags, and run.app URLs bypass Cloud Armor (docs/migration/lb-cloud-armor-runbook.md). Until the tags on pre-fix revisions are removed, those revisions would still serve the removed routes and the old PATCH. That needs a production change, so it is queued for a maintainer (not verified live from here), and the deploy should remove stale tags in a separate PR.

Invariants

  1. Every legacy user record operation is owner-scoped. No HTTP route reads or writes a v1 user record (user_profiles, user_policies, user_policy_mirror_events) on a caller-chosen owner. With the v1 routes gone, no module an HTTP route can import touches those tables at all, and the remaining user-owned records are served only by /v2 routes that take the owner from the verified credential.
  2. A complete result is final. For every sequence of anonymous updates to a simulation or report, once the record holds a complete result from the installed model (or a newer one), every column of the record and of each of its runs stays as it was, except while a server-started report rerun is active.
  3. The calculation path still works. Pending → complete, failed → complete, repeating a complete result in either JSON encoding, replacing an older model's result, and completing an outdated report's current equivalent all answer 200.

Tests (tests/unit/routes/test_legacy_user_records_owner_scoped.py, tests/unit/routes/test_result_updates_are_final.py):

  • Invariant 1: the deployed ASGI application (Flask and native routes) serves no user-profile or user-policy path; an import-graph walk from every HTTP entry point (api, asgi, asgi_factory, every Flask and native route module; function-local and package-relative imports included) reaches neither the v1 user-record models (imported by name or used through v1_models) nor the saved-policy service, with checks that the walk catches each import form; and a Hypothesis property over 150 generated requests to the removed paths (any method, country, identifier, ownership fields in the query or body, no credential, a bearer token or a signed-out browser header) asserts 404 or 405, no seeded user record echoed, and the stored user records unchanged.
  • Invariant 2: Hypothesis properties over 200 update sequences each for simulations and reports (any updates, then a completion, then any updates or repeats of it; outputs that are objects, lists, strings, numbers or "null", raw or JSON-encoded) check every response code and stored state against a reference model, and that no column of the record or its runs changes after completion. Regression tests cover each change an anonymous caller could make to a complete record, the canonical US economy comparison, a newer model's result, and an outdated report ID that cannot reopen its complete current equivalent.
  • Invariant 3: regression tests for each path listed under it, plus the not-final cases.

Failed first: run against unchanged origin/master, 21 of the 28 new tests failed (the legacy routes were served and reachable; forged results, status changes, "null" completions and non-integer IDs were accepted; an outdated report ID wrote the outdated row). The 7 that passed are the walk's self-check and the paths that must work before and after: completion, failed → complete, stale SPM output, older-model results and the report rerun.

Reviews

Two independent reviews of the first head (89a49931): an Opus 5.5 agent and GPT-6.1 Sol. Both found no way, within one revision, to read or change a legacy user record or to change a complete current result, and both requested changes. This head addresses them:

  • Household recalculation after a model release got 409 and left reused simulations on the older model (both reviews): older-model results are now replaceable.
  • The outdated-cache-version exception let a revision with another bundle reopen current reports, and PATCH wrote the outdated row (Opus): outdated IDs now resolve to their current equivalent, and the exception is gone.
  • A JSON-encoded "null" completed a record with no result, which then stuck (both): rejected, and a complete record without output is not final.
  • A JSON-string output conflicted with its own retry (Sol): decoded values are compared as stored.
  • The candidate check could print a record into public CI logs on a regression (Opus): it reads IDs no record can have and never prints the body.
  • uv.lock had been written by an older uv (Opus, Sol): relocked with uv 0.13.4, which CI pins; the diff now only adds hypothesis 6.168.5 (released 2026-10-05).
  • The tag URLs of old revisions (Opus): see "What stays open".

Migration notes

  • Route groups covered: policy (the v1 saved-policy routes) and user_profile (the v1 profile routes); simulation and report (the result update rule). migration_registry.py keeps the user-profile and user-policy path segments, as Remove the routes only the legacy site used, check health at /health, and stop serving Canada, Nigeria and Israel #4017 does.
  • What stays on the Flask/v1 path: every other v1 route, including GET /{country_id}/policies.
  • User-visible API changes: the removed routes answer 404; PATCH /{country_id}/simulation and PATCH /{country_id}/report answer 409 for an update that would change a complete result, and 400 for a completion without output or a non-integer id; an update to an outdated report ID completes its current equivalent. The OpenAPI document and docs/canonical-spm.md describe these. The app-v2 contract registry drops the saved-policy workflow, and the generated contract documents are refreshed.
  • The Cloud Run candidate check now asserts that GET /us/user-profile and GET /us/user-policy/{id} answer 404 (reads only).
  • hypothesis joins the dev extra, and .hypothesis/ is ignored.

Environment variables

None added or changed.

Inline suppressions

None added.

Testing

  • ruff format --check ., ruff check ., uv run --frozen --extra dev mypy, python scripts/run_quality_guards.py, python -m scripts.rules guard --base-ref origin/master and uv lock --check (uv 0.13.4) pass.
  • Unit, contract and to-refactor suites: 3,230 passed (uv run --frozen pytest tests/unit tests/contract tests/to_refactor). The integration suites need disposable MySQL and PostgreSQL and run in CI.

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits October 23, 2026 06:48
POST, GET and PUT /{country_id}/user-profile and POST, GET and PUT
/{country_id}/user-policy read and changed user profiles and saved
policies by a user_id, auth0_id or record id taken from the request,
and checked no credential. App v2 does not call them, and #4017 reports
no production requests to them in the 30 days to 2026-10-19. Signed-in
users reach their saved work through the v2 routes, which take the
owner from the verified credential. Found by the 2026-10-22 security
review.

The deletions match #4017's: the user-profile blueprint and v1 user
service go, the saved-policy routes and their helpers leave
policy_routes.py, the app-v2 contract registry drops the saved-policy
workflow and the generated contract documents are refreshed. As there,
the v1 saved-policy service and copying modules stay until dual-write
is deleted. GET /{country_id}/policies stays; #4017 removes it with the
legacy site.

New tests state the invariant that every legacy user record operation
is owner-scoped: the deployed application serves neither path, no
module an HTTP route can import touches the v1 user-record tables, and
a Hypothesis property over generated requests to the removed paths
(any method, country, identifier, ownership fields and credentials)
gets 404 or 405 and leaves every stored user record unchanged. The
Cloud Run candidate check asserts the removed paths answer 404.
hypothesis joins the dev extra.

Tests (failed first on origin/master: the routes were registered and
reachable from the route modules, and requests to them reached the
handlers instead of answering 404): the new invariant tests, then the
route boundary, contract and service-boundary tests.

Fixes #4028

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PATCH /{country_id}/simulation and PATCH /{country_id}/report take no
credential, and their records are shared: identical requests reuse one
record. Any caller could replace a complete result with another output
or an error, and every viewer of that record saw it. Found by the
2026-10-22 security review.

There is no compute worker to restrict these updates to: the browser
that ran a calculation stores its result. So the rule is on the record.
Once it holds a complete result, an update that repeats it (a retried
write, or a second browser finishing the same calculation, in either
JSON encoding) answers 200 and writes nothing, and one that would
change its status, output or error message answers 409 and leaves the
record and its runs as they were. A completed US economy output is
compared in its canonical SPM form. The services check this after
locking the record and before any write, so the no-op path does not
restamp the version or touch the runs.

Three results stay open, because the server decided they run again:
stale SPM simulation output, a report under an outdated cache version
(reads resolve it to its current equivalent) and either record while a
server-started rerun is pending or running. Marking a complete report
running now answers 409 rather than 400; the old check still applies
to a failed report.

Tests (failed first on origin/master: a forged output and a status
change replaced complete results with 200): regression tests for each
anonymous change, the canonical economy comparison, the calculation
path and the three open cases, and Hypothesis properties over update
sequences for both records, checked step by step against a reference
model. The OpenAPI document gains both 409 responses.

Fixes #4028

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.07692% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.82%. Comparing base (881a009) to head (b9f0b1f).

Files with missing lines Patch % Lines
policyengine_api/services/report_output_service.py 95.65% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master    #4029       +/-   ##
===========================================
+ Coverage   51.11%   87.82%   +36.71%     
===========================================
  Files         242      257       +15     
  Lines       14441    15710     +1269     
  Branches     2307     2561      +254     
===========================================
+ Hits         7382    13798     +6416     
+ Misses       6436     1258     -5178     
- Partials      623      654       +31     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…lity

Two independent reviews of the first head found that the finality rule
broke one app-v2 flow and left three gaps.

App v2 recalculates every simulation of a new household report,
reused complete ones included, and stores the installed model's result.
After a model release that result differs, so it got 409 and the
shared simulation stayed on the older model. A simulation result is now
final only when it came from the installed country model or a newer
one (PEP 440 comparison of api_version; an unreadable version counts as
older when it differs), so recalculation replaces older results as
before, and an older revision cannot replace a newer one. The
speculative simulation-rerun exception goes: nothing starts one.

An update to an outdated report ID now goes to its current equivalent,
the record GET already serves for that ID, and keeps the requested ID
in the response. Before, PATCH wrote the outdated row and the current
record stayed pending; and the outdated-cache-version exception let a
revision with another bundle reopen current reports. That exception is
gone.

A completion whose output decodes to null, such as "null", stored a
complete record with no result, which then refused the real one: it is
rejected, and a complete record without output is not final. A body id
that is not an integer is rejected. Stored outputs are compared as
stored, so a JSON-string output repeats as itself.

The Cloud Run candidate check reads IDs no record can have and never
prints the body, since CI logs are public. uv.lock is relocked with uv
0.13.4, CI's pin, and now only adds hypothesis 6.168.5. The OpenAPI
document and docs/canonical-spm.md describe the rules; the report 409
response has its schema.

Tests: regressions for newer models, outdated report IDs, null
completions, non-integer IDs and JSON-string outputs, which failed first
on origin/master, and for older models, which must pass on both; the properties now compare
every column of the record and its runs after completion, over a wider
output domain; the import walk handles package-relative imports and
models used through v1_models.

Fixes #4028

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis force-pushed the max/legacy-user-route-authz-20261023 branch from 84eff40 to b9f0b1f Compare October 23, 2026 11:36

This branch was successfully deployed

1 active deployment
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.

Remove the v1 user routes and keep complete simulation and report results final

1 participant