Repository navigation
Conversation
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
…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
force-pushed
the
max/legacy-user-route-authz-20261023
branch
from
October 23, 2026 11:36
84eff40 to
b9f0b1f
Compare
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
POST,GETandPUT /{country_id}/user-profileandPOST,GET /{country_id}/user-policy/{user_id}andPUT /{country_id}/user-policyread and changed user profiles and saved policies by auser_id,auth0_idor recordidtaken from the request, and checked no credential. App v2 does not call them (checked againstpolicyengine-app-v2main), 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-policiesand/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 removesGET /{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 SQLuser_profilesanduser_policiesrows are kept.PATCH /{country_id}/simulationandPATCH /{country_id}/reporttake 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.GETalready serves for that ID, and keeps the requested ID in the response. Before,GETserved the current record whilePATCHwrote the outdated row, so the current record stayed pending and was recalculated on every open."null"(it used to store a complete record with no result), and a bodyidthat is not an integer ("5",5.0andtrueused 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 (
ResultPersisterin app v2). Requiring a worker credential would break every report. This PR closes what can be closed without changing that.What stays open
GETby 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.run.appURLs 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 oldPATCH. 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
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/v2routes that take the owner from the verified credential.Tests (
tests/unit/routes/test_legacy_user_records_owner_scoped.py,tests/unit/routes/test_result_updates_are_final.py):user-profileoruser-policypath; 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 throughv1_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."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.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:PATCHwrote the outdated row (Opus): outdated IDs now resolve to their current equivalent, and the exception is gone."null"completed a record with no result, which then stuck (both): rejected, and a complete record without output is not final.uv.lockhad been written by an older uv (Opus, Sol): relocked with uv 0.13.4, which CI pins; the diff now only addshypothesis6.168.5 (released 2026-10-05).Migration notes
policy(the v1 saved-policy routes) anduser_profile(the v1 profile routes);simulationandreport(the result update rule).migration_registry.pykeeps theuser-profileanduser-policypath segments, as Remove the routes only the legacy site used, check health at /health, and stop serving Canada, Nigeria and Israel #4017 does.GET /{country_id}/policies.PATCH /{country_id}/simulationandPATCH /{country_id}/reportanswer 409 for an update that would change a complete result, and 400 for a completion without output or a non-integerid; an update to an outdated report ID completes its current equivalent. The OpenAPI document anddocs/canonical-spm.mddescribe these. The app-v2 contract registry drops the saved-policy workflow, and the generated contract documents are refreshed.GET /us/user-profileandGET /us/user-policy/{id}answer 404 (reads only).hypothesisjoins 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/masteranduv lock --check(uv 0.13.4) pass.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