diff --git a/.github/scripts/deploy_cloud_run_candidate.sh b/.github/scripts/deploy_cloud_run_candidate.sh index 827403ce0..fee77af4f 100755 --- a/.github/scripts/deploy_cloud_run_candidate.sh +++ b/.github/scripts/deploy_cloud_run_candidate.sh @@ -44,8 +44,21 @@ env_vars=( "V2_SUPABASE_PROJECT_REF=${V2_SUPABASE_PROJECT_REF}" "V2_SUPABASE_ENVIRONMENT=${V2_SUPABASE_ENVIRONMENT}" "V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE=${V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE}" + "PUBLIC_API_AUTH_REQUIRED=${PUBLIC_API_AUTH_REQUIRED}" + "PUBLIC_API_AUTH_ISSUER=${PUBLIC_API_AUTH_ISSUER:-}" + "PUBLIC_API_AUTH_AUDIENCE=${PUBLIC_API_AUTH_AUDIENCE:-}" ) +# Optional: account deletion removes the Auth0 user only when the Management +# API client is configured. Validation requires all three values or none. +if [[ -n "${AUTH0_MANAGEMENT_DOMAIN:-}" ]]; then + env_vars+=( + "AUTH0_MANAGEMENT_DOMAIN=${AUTH0_MANAGEMENT_DOMAIN}" + "AUTH0_MANAGEMENT_CLIENT_ID=${AUTH0_MANAGEMENT_CLIENT_ID}" + "AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE=${AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE}" + ) +fi + if [[ -n "${OLD_SIMULATION_GATEWAY_URL:-}" ]]; then env_vars+=("OLD_SIMULATION_GATEWAY_URL=${OLD_SIMULATION_GATEWAY_URL}") fi diff --git a/.github/scripts/validate_cloud_run_deploy_env.sh b/.github/scripts/validate_cloud_run_deploy_env.sh index 1f1e32451..81c310c9f 100755 --- a/.github/scripts/validate_cloud_run_deploy_env.sh +++ b/.github/scripts/validate_cloud_run_deploy_env.sh @@ -56,7 +56,8 @@ cloud_run_require_env \ GATEWAY_AUTH_ISSUER \ GATEWAY_AUTH_AUDIENCE \ GATEWAY_AUTH_CLIENT_ID \ - GATEWAY_AUTH_CLIENT_SECRET_RESOURCE + GATEWAY_AUTH_CLIENT_SECRET_RESOURCE \ + PUBLIC_API_AUTH_REQUIRED for selector in \ ROUTE_IMPL_HEALTH \ @@ -110,6 +111,51 @@ case "${DB_WRITE_HOUSEHOLD}" in ;; esac +# Signed-in user routes fail closed without Auth0 settings. When required, +# the issuer and audience must be present; otherwise they are set together +# or not at all. +case "${PUBLIC_API_AUTH_REQUIRED}" in + 0|1) ;; + *) + printf '%s=%s is invalid; expected 0 or 1\n' \ + "PUBLIC_API_AUTH_REQUIRED" "${PUBLIC_API_AUTH_REQUIRED}" >&2 + exit 1 + ;; +esac +if [[ "${PUBLIC_API_AUTH_REQUIRED}" == "1" ]]; then + cloud_run_require_env PUBLIC_API_AUTH_ISSUER PUBLIC_API_AUTH_AUDIENCE +fi +if [[ -n "${PUBLIC_API_AUTH_ISSUER:-}" && -z "${PUBLIC_API_AUTH_AUDIENCE:-}" ]] \ + || [[ -z "${PUBLIC_API_AUTH_ISSUER:-}" && -n "${PUBLIC_API_AUTH_AUDIENCE:-}" ]]; then + echo "Set both PUBLIC_API_AUTH_ISSUER and PUBLIC_API_AUTH_AUDIENCE, or neither" >&2 + exit 1 +fi +if [[ -n "${PUBLIC_API_AUTH_ISSUER:-}" && "${PUBLIC_API_AUTH_ISSUER}" != https://* ]]; then + echo "PUBLIC_API_AUTH_ISSUER must be an absolute HTTPS URL" >&2 + exit 1 +fi + +auth0_management_count=0 +for name in \ + AUTH0_MANAGEMENT_DOMAIN \ + AUTH0_MANAGEMENT_CLIENT_ID \ + AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE; do + if [[ -n "${!name:-}" ]]; then + auth0_management_count=$((auth0_management_count + 1)) + fi +done +if (( auth0_management_count != 0 && auth0_management_count != 3 )); then + echo "Set AUTH0_MANAGEMENT_DOMAIN, AUTH0_MANAGEMENT_CLIENT_ID, and AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE together, or none" >&2 + exit 1 +fi +# The staging live test deletes its own account through DELETE /v2/me. With +# Management API deletion configured, that would delete the CI user from +# Auth0, so only production may configure it. +if (( auth0_management_count != 0 )) && [[ "${DEPLOYMENT_ENVIRONMENT}" != "production" ]]; then + echo "AUTH0_MANAGEMENT_* settings are production-only; unset them for ${DEPLOYMENT_ENVIRONMENT}" >&2 + exit 1 +fi + selected_url_env="$( simulation_entrypoint_url_env_name "${SIM_ENTRYPOINT}" )" diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 014c61797..15c135e5f 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -207,6 +207,9 @@ jobs: DB_WRITE_POLICY: ${{ vars.DB_WRITE_POLICY }} DB_READ_HOUSEHOLD: ${{ vars.DB_READ_HOUSEHOLD }} DB_WRITE_HOUSEHOLD: ${{ vars.DB_WRITE_HOUSEHOLD }} + PUBLIC_API_AUTH_REQUIRED: ${{ vars.PUBLIC_API_AUTH_REQUIRED }} + PUBLIC_API_AUTH_ISSUER: ${{ vars.PUBLIC_API_AUTH_ISSUER }} + PUBLIC_API_AUTH_AUDIENCE: ${{ vars.PUBLIC_API_AUTH_AUDIENCE }} CLOUD_RUN_SERVICE: policyengine-api-staging CLOUD_RUN_RUNTIME_CACHE_ENVIRONMENT: staging CLOUD_RUN_RUNTIME_CACHE_URL_SECRET: policyengine-api-staging-runtime-cache-url:latest @@ -251,7 +254,7 @@ jobs: run: >- env -u DEPLOYMENT_ENVIRONMENT -u ROUTE_IMPL_HEALTH -u ROUTE_IMPL_SPECIFICATION -u ROUTE_IMPL_METADATA -u ROUTE_IMPL_POLICY -u ROUTE_IMPL_HOUSEHOLD -u DB_READ_POLICY -u DB_WRITE_POLICY -u DB_READ_HOUSEHOLD -u DB_WRITE_HOUSEHOLD -u CLOUD_RUN_SERVICE -u CLOUD_RUN_RUNTIME_CACHE_ENVIRONMENT -u CLOUD_RUN_RUNTIME_CACHE_URL_SECRET -u CLOUD_RUN_RUNTIME_CACHE_CA_CERT_SECRET -u CLOUD_RUN_POLICYENGINE_DB_PASSWORD_SECRET -u CLOUD_RUN_MIN_INSTANCES -u CLOUD_RUN_SERVICE_MIN_INSTANCES -u CLOUD_RUN_MAX_INSTANCES - -u V2_SUPABASE_PROJECT_REF -u V2_SUPABASE_ENVIRONMENT -u V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE -u PRODUCTION_V2_SUPABASE_PROJECT_REF -u PRODUCTION_V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE -u PRODUCTION_POLICYENGINE_DB_INSTANCE_CONNECTION_NAME make test + -u V2_SUPABASE_PROJECT_REF -u V2_SUPABASE_ENVIRONMENT -u V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE -u PRODUCTION_V2_SUPABASE_PROJECT_REF -u PRODUCTION_V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE -u PRODUCTION_POLICYENGINE_DB_INSTANCE_CONNECTION_NAME -u PUBLIC_API_AUTH_REQUIRED -u PUBLIC_API_AUTH_ISSUER -u PUBLIC_API_AUTH_AUDIENCE make test env: RUNTIME_CACHE_MODE: local RUNTIME_CACHE_URL: redis://127.0.0.1:6379/0 @@ -326,6 +329,14 @@ jobs: API_BASE_URL: ${{ steps.candidate.outputs.url }} STAGING_API_TEST_PROBE_ID: cloud-run-${{ steps.cloud_run.outputs.revision_tag }} V2_MIGRATION_DATABASE_URL: ${{ secrets.V2_MIGRATION_DATABASE_URL }} + # Staging-only Auth0 CI application (password grant) and test user. + STAGING_AUTH0_TEST_DOMAIN: ${{ vars.STAGING_AUTH0_TEST_DOMAIN }} + STAGING_AUTH0_TEST_AUDIENCE: ${{ vars.STAGING_AUTH0_TEST_AUDIENCE }} + STAGING_AUTH0_TEST_CLIENT_ID: ${{ secrets.STAGING_AUTH0_TEST_CLIENT_ID }} + STAGING_AUTH0_TEST_CLIENT_SECRET: ${{ secrets.STAGING_AUTH0_TEST_CLIENT_SECRET }} + STAGING_AUTH0_TEST_USERNAME: ${{ secrets.STAGING_AUTH0_TEST_USERNAME }} + STAGING_AUTH0_TEST_PASSWORD: ${{ secrets.STAGING_AUTH0_TEST_PASSWORD }} + STAGING_AUTH0_TEST_REALM: ${{ vars.STAGING_AUTH0_TEST_REALM }} parallel-live-simulation-tests-staging-cloud-run: name: Run live simulations against the staging candidate @@ -491,6 +502,12 @@ jobs: DB_WRITE_POLICY: ${{ vars.DB_WRITE_POLICY }} DB_READ_HOUSEHOLD: ${{ vars.DB_READ_HOUSEHOLD }} DB_WRITE_HOUSEHOLD: ${{ vars.DB_WRITE_HOUSEHOLD }} + PUBLIC_API_AUTH_REQUIRED: ${{ vars.PUBLIC_API_AUTH_REQUIRED }} + PUBLIC_API_AUTH_ISSUER: ${{ vars.PUBLIC_API_AUTH_ISSUER }} + PUBLIC_API_AUTH_AUDIENCE: ${{ vars.PUBLIC_API_AUTH_AUDIENCE }} + AUTH0_MANAGEMENT_DOMAIN: ${{ vars.AUTH0_MANAGEMENT_DOMAIN }} + AUTH0_MANAGEMENT_CLIENT_ID: ${{ vars.AUTH0_MANAGEMENT_CLIENT_ID }} + AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE }} CLOUD_RUN_SERVICE: policyengine-api CLOUD_RUN_RUNTIME_CACHE_ENVIRONMENT: production CLOUD_RUN_RUNTIME_CACHE_URL_SECRET: policyengine-api-prod-runtime-cache-url:latest diff --git a/.github/workflows/seed-v2-database.yml b/.github/workflows/seed-v2-database.yml index 57af8aae5..6a9206846 100644 --- a/.github/workflows/seed-v2-database.yml +++ b/.github/workflows/seed-v2-database.yml @@ -46,6 +46,10 @@ jobs: run: uv run python scripts/qualify_v2_household_migration.py env: V2_MIGRATION_DATABASE_URL: ${{ secrets.V2_MIGRATION_DATABASE_URL }} + - name: Qualify the pending user-association reshape + run: uv run python scripts/qualify_v2_user_association_migration.py + env: + V2_MIGRATION_DATABASE_URL: ${{ secrets.V2_MIGRATION_DATABASE_URL }} - name: Upgrade and verify the v2 schema run: bash .github/scripts/migrate_v2_metadata_schema.sh env: diff --git a/.github/workflows/v2-integration-check.yml b/.github/workflows/v2-integration-check.yml index f30c17d6f..6d41bb5c1 100644 --- a/.github/workflows/v2-integration-check.yml +++ b/.github/workflows/v2-integration-check.yml @@ -75,6 +75,8 @@ jobs: run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_catalog_publication.py tests/integration/test_v2_metadata_routes.py - name: Test v2 policy persistence and immediate v1 mirroring run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_policy_persistence.py tests/integration/test_v1_policy_dual_write.py tests/integration/test_mysql_policy_dual_write.py tests/integration/test_v2_user_policy_mirroring.py tests/integration/test_v1_user_policy_dual_write.py + - name: Test signed-in identity provisioning and user associations + run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_user_identity_persistence.py - name: Test v2 household persistence and immediate v1 copying run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_household_persistence.py tests/integration/test_v1_household_dual_write.py tests/integration/test_mysql_household_spm_dual_write.py - name: Qualify production-scale v2 metadata publication diff --git a/AGENTS.md b/AGENTS.md index f47b70413..89a7d6ee7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. - `api.v2.routes-no-sessions-or-sql`: v2 route modules never open database sessions or construct SQL; they call one service method and map typed failures to HTTP. (doc-only, not checked: follow it yourself) - `api.v2.services-no-sql`: v2 services sequence work but never construct SQL expressions, call Session.exec, or define HTTP response models. (doc-only, not checked: follow it yourself) - `api.v2.session-modules-no-queries`: v2 database_session modules own session lifetime only and never build select, insert, update, delete or exec calls. (blocking: pytest `tests/unit/v2/test_data_crud_boundaries.py`) +- `api.v2.user-services-no-sql`: v2 user services (identity, accounts, legacy references and user associations) never build or run SQL themselves or import SQLAlchemy; their connectors do. (blocking: pytest `tests/unit/v2/test_data_crud_boundaries.py`) - `api.v2.validators-transformations-no-db`: v2 validators and transformations never import SQLAlchemy or SQLModel query or session APIs. (blocking: pytest `tests/unit/v2/test_data_crud_boundaries.py`) ### Runtime persistence and route boundaries @@ -120,7 +121,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. - `api.quality.alembic-revisions-unused-imports`: Alembic revision files are exempt from unused-import checks (F401), because autogenerated revisions import types they may not use. (advisory: ruff per-file ignore of F401 for `migrations/*/versions/*.py`, not blocking) - `api.quality.environment-variables-load`: Required environment variables and credentials load in a deployed-like environment. (blocking: CI job `test_env_vars`) -- `api.quality.mypy-v2`: Shared query models, FastAPI dependencies, and v2 routes and services pass mypy with untyped definitions disallowed. (blocking: mypy) +- `api.quality.mypy-v2`: Shared query models, FastAPI dependencies, v2 routes and services, and the user sign-in and account-deletion libraries pass mypy with untyped definitions disallowed. (blocking: mypy) - `api.quality.ruff-format`: All Python files are formatted with ruff format. (blocking: ruff format) - `api.quality.ruff-lint`: Changed Python files pass ruff check with the rules selected in pyproject.toml (ruff's defaults, made explicit); agents run it before committing, but CI does not yet. (advisory: ruff check (E4, E7, E9, F), not blocking) - `api.quality.tests-pass`: The unit, contract and to-refactor test suites pass. (blocking: CI job `test`) diff --git a/changelog.d/feat-stage-12a-user-login-associations.added.md b/changelog.d/feat-stage-12a-user-login-associations.added.md new file mode 100644 index 000000000..317ab31c3 --- /dev/null +++ b/changelog.d/feat-stage-12a-user-login-associations.added.md @@ -0,0 +1 @@ +Add Auth0 user login and signed-out saving for app v3: /v2/me (provisioning, profile, export, upgrade of a browser's unauthenticated user at sign-in, account deletion), standard and unauthenticated user types identified by a bearer token or the PolicyEngine-Unauthenticated-User header, owner-scoped v1-anchored user associations for policies, households, simulations, and reports, and the user_identities schema. diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index f29b8069b..d70331717 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -7,8 +7,8 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | Metric | Count | | --- | ---: | | route group count | 9 | -| workflow count | 13 | -| request count | 50 | +| workflow count | 16 | +| request count | 88 | | db entity count | 6 | | sim flow count | 3 | @@ -22,9 +22,9 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | `policy` | `policy`, `policies`, `user-policy`, `user-policies` | `policy` | `none` | | `household` | `household`, `households`, `user-household`, `user-households`, `calculate`, `calculate-full` | `household` | `household` | | `economy` | `economy` | `simulation` | `economy` | -| `simulation` | `simulation`, `simulations` | `simulation` | `economy` | -| `report` | `report` | `report` | `report` | -| `user_profile` | `user-profile` | `user` | `none` | +| `simulation` | `simulation`, `simulations`, `user-simulations` | `simulation` | `economy` | +| `report` | `report`, `user-reports` | `report` | `report` | +| `user_profile` | `user-profile`, `me` | `user` | `none` | ## App V2 Workflow Contracts @@ -33,151 +33,213 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi - Current contract: `api_v1_compatible` - Future owner: PR 10: Policy Migration -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/us/policy` | 201 | `policy` | `status`, `message`, `result.policy_id` | -| `GET` | `/us/policy/{policy_id}` | 200 | `policy` | `status`, `message`, `result` | -| `GET` | `/us/policies` | 200 | `policy` | `result` | +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/us/policy` | 201 | `policy` | none | `status`, `message`, `result.policy_id` | +| `GET` | `/us/policy/{policy_id}` | 200 | `policy` | none | `status`, `message`, `result` | +| `GET` | `/us/policies` | 200 | `policy` | none | `result` | ### `policy_resources_v2` - Current contract: `typed_v2_resources` - Future owner: PR 10: Policy Migration -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/v2/policies?country_id=us` | 201 | `policy` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.tax_benefit_model_id`, `result.item.parameter_values`, `result.item.created_at`, `result.item.updated_at` | -| `GET` | `/v2/policies/{policy_id}?country_id=us` | 200 | `policy` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.tax_benefit_model_id`, `result.item.parameter_values`, `result.item.created_at`, `result.item.updated_at` | -| `GET` | `/v2/policies?country_id=us` | 200 | `policy` | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/v2/policies?country_id=us` | 201 | `policy` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.tax_benefit_model_id`, `result.item.parameter_values`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/policies/{policy_id}?country_id=us` | 200 | `policy` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.tax_benefit_model_id`, `result.item.parameter_values`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/policies?country_id=us` | 200 | `policy` | none | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | ### `saved_policy_v1_compatibility` - Current contract: `api_v1_compatible` - Future owner: PR 10: Policy Migration -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/us/user-policy` | 201 | `policy` | `status`, `message`, `result.id`, `result.reform_id`, `result.reform_label`, `result.baseline_id`, `result.user_id` | -| `GET` | `/us/user-policy/{user_id}` | 200 | `policy` | `status`, `message`, `result` | -| `PUT` | `/us/user-policy` | 200 | `policy` | `status`, `message`, `result.id` | +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/us/user-policy` | 201 | `policy` | none | `status`, `message`, `result.id`, `result.reform_id`, `result.reform_label`, `result.baseline_id`, `result.user_id` | +| `GET` | `/us/user-policy/{user_id}` | 200 | `policy` | none | `status`, `message`, `result` | +| `PUT` | `/us/user-policy` | 200 | `policy` | none | `status`, `message`, `result.id` | ### `user_policy_associations_v2` - Current contract: `typed_v2_resources` -- Future owner: PR 10: Policy Migration - -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/v2/user-policies?country_id=us` | 201 | `policy` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.policy_id`, `result.item.name`, `result.item.description`, `result.item.created_at`, `result.item.updated_at` | -| `GET` | `/v2/user-policies/{association_id}?country_id=us` | 200 | `policy` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.policy_id`, `result.item.name`, `result.item.description`, `result.item.created_at`, `result.item.updated_at` | -| `GET` | `/v2/user-policies?country_id=us&user_id={user_id}` | 200 | `policy` | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | -| `PATCH` | `/v2/user-policies/{association_id}?country_id=us` | 200 | `policy` | `status`, `message`, `result.item.id`, `result.item.name`, `result.item.description`, `result.item.updated_at` | -| `DELETE` | `/v2/user-policies/{association_id}?country_id=us` | 204 | `policy` | | +- Future owner: PR 12A: User Login and v1-Anchored User Associations + +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/v2/user-policies?country_id=us` | 201 | `policy` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.policy_id`, `result.item.legacy_policy_id`, `result.item.name`, `result.item.description`, `result.item.baseline`, `result.item.provenance`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-policies/{association_id}?country_id=us` | 200 | `policy` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.policy_id`, `result.item.legacy_policy_id`, `result.item.name`, `result.item.description`, `result.item.baseline`, `result.item.provenance`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-policies?country_id=us` | 200 | `policy` | `Authorization` | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `PATCH` | `/v2/user-policies/{association_id}?country_id=us` | 200 | `policy` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.legacy_policy_id`, `result.item.policy_id`, `result.item.name`, `result.item.description`, `result.item.baseline`, `result.item.provenance`, `result.item.updated_at` | +| `DELETE` | `/v2/user-policies/{association_id}?country_id=us` | 204 | `policy` | `Authorization` | | +| `POST` | `/v2/user-policies?country_id=us` | 201 | `policy` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.policy_id`, `result.item.legacy_policy_id`, `result.item.name`, `result.item.description`, `result.item.baseline`, `result.item.provenance`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-policies/{association_id}?country_id=us` | 200 | `policy` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.policy_id`, `result.item.legacy_policy_id`, `result.item.name`, `result.item.description`, `result.item.baseline`, `result.item.provenance`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-policies?country_id=us` | 200 | `policy` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `PATCH` | `/v2/user-policies/{association_id}?country_id=us` | 200 | `policy` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.legacy_policy_id`, `result.item.policy_id`, `result.item.name`, `result.item.description`, `result.item.baseline`, `result.item.provenance`, `result.item.updated_at` | +| `DELETE` | `/v2/user-policies/{association_id}?country_id=us` | 204 | `policy` | `PolicyEngine-Unauthenticated-User` | | ### `household_save_read` - Current contract: `api_v1_compatible` - Future owner: PR 11: Household Migration -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/us/household` | 201 | `household` | `status`, `message`, `result.household_id` | -| `GET` | `/us/household/{household_id}` | 200 | `household` | `status`, `message`, `result` | +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/us/household` | 201 | `household` | none | `status`, `message`, `result.household_id` | +| `GET` | `/us/household/{household_id}` | 200 | `household` | none | `status`, `message`, `result` | ### `household_resources_v2` - Current contract: `typed_v2_resources` - Future owner: PR 11: Household Migration -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/v2/households?country_id=us` | 201 | `household` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.default_year`, `result.item.household_data`, `result.item.created_at`, `result.item.updated_at` | -| `GET` | `/v2/households/{household_id}?country_id=us` | 200 | `household` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.default_year`, `result.item.household_data`, `result.item.created_at`, `result.item.updated_at` | -| `GET` | `/v2/households?country_id=us` | 200 | `household` | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/v2/households?country_id=us` | 201 | `household` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.default_year`, `result.item.household_data`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/households/{household_id}?country_id=us` | 200 | `household` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.default_year`, `result.item.household_data`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/households?country_id=us` | 200 | `household` | none | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | ### `user_household_associations_v2` - Current contract: `typed_v2_resources` -- Future owner: PR 11: Household Migration +- Future owner: PR 12A: User Login and v1-Anchored User Associations + +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/v2/user-households?country_id=us` | 201 | `household` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.household_id`, `result.item.legacy_household_id`, `result.item.name`, `result.item.description`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-households/{association_id}?country_id=us` | 200 | `household` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.household_id`, `result.item.legacy_household_id`, `result.item.name`, `result.item.description`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-households?country_id=us` | 200 | `household` | `Authorization` | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `PATCH` | `/v2/user-households/{association_id}?country_id=us` | 200 | `household` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.legacy_household_id`, `result.item.household_id`, `result.item.name`, `result.item.description`, `result.item.updated_at` | +| `DELETE` | `/v2/user-households/{association_id}?country_id=us` | 204 | `household` | `Authorization` | | +| `POST` | `/v2/user-households?country_id=us` | 201 | `household` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.household_id`, `result.item.legacy_household_id`, `result.item.name`, `result.item.description`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-households/{association_id}?country_id=us` | 200 | `household` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.household_id`, `result.item.legacy_household_id`, `result.item.name`, `result.item.description`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-households?country_id=us` | 200 | `household` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `PATCH` | `/v2/user-households/{association_id}?country_id=us` | 200 | `household` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.legacy_household_id`, `result.item.household_id`, `result.item.name`, `result.item.description`, `result.item.updated_at` | +| `DELETE` | `/v2/user-households/{association_id}?country_id=us` | 204 | `household` | `PolicyEngine-Unauthenticated-User` | | + +### `user_simulation_associations_v2` + +- Current contract: `typed_v2_resources` +- Future owner: PR 12A: User Login and v1-Anchored User Associations + +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/v2/user-simulations?country_id=us` | 201 | `simulation` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.simulation_id`, `result.item.legacy_simulation_id`, `result.item.name`, `result.item.description`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-simulations/{association_id}?country_id=us` | 200 | `simulation` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.simulation_id`, `result.item.legacy_simulation_id`, `result.item.name`, `result.item.description`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-simulations?country_id=us` | 200 | `simulation` | `Authorization` | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `PATCH` | `/v2/user-simulations/{association_id}?country_id=us` | 200 | `simulation` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.legacy_simulation_id`, `result.item.name`, `result.item.description`, `result.item.updated_at` | +| `DELETE` | `/v2/user-simulations/{association_id}?country_id=us` | 204 | `simulation` | `Authorization` | | +| `POST` | `/v2/user-simulations?country_id=us` | 201 | `simulation` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.simulation_id`, `result.item.legacy_simulation_id`, `result.item.name`, `result.item.description`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-simulations/{association_id}?country_id=us` | 200 | `simulation` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.simulation_id`, `result.item.legacy_simulation_id`, `result.item.name`, `result.item.description`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-simulations?country_id=us` | 200 | `simulation` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `PATCH` | `/v2/user-simulations/{association_id}?country_id=us` | 200 | `simulation` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.legacy_simulation_id`, `result.item.name`, `result.item.description`, `result.item.updated_at` | +| `DELETE` | `/v2/user-simulations/{association_id}?country_id=us` | 204 | `simulation` | `PolicyEngine-Unauthenticated-User` | | + +### `user_report_associations_v2` + +- Current contract: `typed_v2_resources` +- Future owner: PR 12A: User Login and v1-Anchored User Associations + +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/v2/user-reports?country_id=us` | 201 | `report` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.report_id`, `result.item.legacy_report_id`, `result.item.name`, `result.item.description`, `result.item.user_policy_id`, `result.item.baseline_user_policy_id`, `result.item.source_note`, `result.item.provisions`, `result.item.validation`, `result.item.budget_window_years`, `result.item.last_run_at`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-reports/{association_id}?country_id=us` | 200 | `report` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.report_id`, `result.item.legacy_report_id`, `result.item.name`, `result.item.description`, `result.item.user_policy_id`, `result.item.baseline_user_policy_id`, `result.item.source_note`, `result.item.provisions`, `result.item.validation`, `result.item.budget_window_years`, `result.item.last_run_at`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-reports?country_id=us` | 200 | `report` | `Authorization` | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `PATCH` | `/v2/user-reports/{association_id}?country_id=us` | 200 | `report` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.legacy_report_id`, `result.item.name`, `result.item.description`, `result.item.user_policy_id`, `result.item.baseline_user_policy_id`, `result.item.source_note`, `result.item.provisions`, `result.item.validation`, `result.item.budget_window_years`, `result.item.last_run_at`, `result.item.updated_at` | +| `DELETE` | `/v2/user-reports/{association_id}?country_id=us` | 204 | `report` | `Authorization` | | +| `POST` | `/v2/user-reports?country_id=us` | 201 | `report` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.report_id`, `result.item.legacy_report_id`, `result.item.name`, `result.item.description`, `result.item.user_policy_id`, `result.item.baseline_user_policy_id`, `result.item.source_note`, `result.item.provisions`, `result.item.validation`, `result.item.budget_window_years`, `result.item.last_run_at`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-reports/{association_id}?country_id=us` | 200 | `report` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.report_id`, `result.item.legacy_report_id`, `result.item.name`, `result.item.description`, `result.item.user_policy_id`, `result.item.baseline_user_policy_id`, `result.item.source_note`, `result.item.provisions`, `result.item.validation`, `result.item.budget_window_years`, `result.item.last_run_at`, `result.item.created_at`, `result.item.updated_at` | +| `GET` | `/v2/user-reports?country_id=us` | 200 | `report` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `PATCH` | `/v2/user-reports/{association_id}?country_id=us` | 200 | `report` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.legacy_report_id`, `result.item.name`, `result.item.description`, `result.item.user_policy_id`, `result.item.baseline_user_policy_id`, `result.item.source_note`, `result.item.provisions`, `result.item.validation`, `result.item.budget_window_years`, `result.item.last_run_at`, `result.item.updated_at` | +| `DELETE` | `/v2/user-reports/{association_id}?country_id=us` | 204 | `report` | `PolicyEngine-Unauthenticated-User` | | + +### `user_account_v2` -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/v2/user-households?country_id=us` | 201 | `household` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.household_id`, `result.item.name`, `result.item.description`, `result.item.created_at`, `result.item.updated_at` | -| `GET` | `/v2/user-households/{association_id}?country_id=us` | 200 | `household` | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.user_id`, `result.item.household_id`, `result.item.name`, `result.item.description`, `result.item.created_at`, `result.item.updated_at` | -| `GET` | `/v2/user-households?country_id=us&user_id={user_id}` | 200 | `household` | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | -| `PATCH` | `/v2/user-households/{association_id}?country_id=us` | 200 | `household` | `status`, `message`, `result.item.id`, `result.item.household_id`, `result.item.name`, `result.item.description`, `result.item.updated_at` | -| `DELETE` | `/v2/user-households/{association_id}?country_id=us` | 204 | `household` | | +- Current contract: `typed_v2_resources` +- Future owner: PR 12A: User Login and v1-Anchored User Associations + +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `GET` | `/v2/me?country_id=us` | 200 | `user_profile` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.user_type`, `result.item.primary_country`, `result.item.first_name`, `result.item.last_name`, `result.item.email`, `result.item.created_at` | +| `GET` | `/v2/me?country_id=us` | 200 | `user_profile` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.user_type`, `result.item.primary_country`, `result.item.first_name`, `result.item.last_name`, `result.item.email`, `result.item.created_at` | +| `PATCH` | `/v2/me?country_id=us` | 200 | `user_profile` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.primary_country`, `result.item.first_name`, `result.item.last_name` | +| `GET` | `/v2/me/export?country_id=us` | 200 | `user_profile` | `Authorization` | `status`, `message`, `result.user`, `result.policies`, `result.households`, `result.simulations`, `result.reports` | +| `GET` | `/v2/me/export?country_id=us` | 200 | `user_profile` | `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.user`, `result.policies`, `result.households`, `result.simulations`, `result.reports` | +| `POST` | `/v2/me/upgrade?country_id=us` | 200 | `user_profile` | `Authorization`, `PolicyEngine-Unauthenticated-User` | `status`, `message`, `result.item.id`, `result.item.user_type`, `result.item.primary_country`, `result.outcome` | +| `DELETE` | `/v2/me?country_id=us` | 204 | `user_profile` | `Authorization` | | +| `DELETE` | `/v2/me?country_id=us` | 204 | `user_profile` | `PolicyEngine-Unauthenticated-User` | | ### `household_calculate` - Current contract: `api_v1_compatible` - Future owner: PR 13: Household Calculation Compute Cutover -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/us/calculate` | 200 | `household` | `status`, `message`, `result` | +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/us/calculate` | 200 | `household` | none | `status`, `message`, `result` | ### `region_selection` - Current contract: `api_v1_compatible` - Future owner: PR 9: v2 Metadata, Regions, Datasets, Parameters, and Variables -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `GET` | `/us/metadata` | 200 | `metadata` | `status`, `result.current_law_id`, `result.economy_options.region`, `result.economy_options.time_period` | -| `GET` | `/uk/metadata` | 200 | `metadata` | `status`, `result.current_law_id`, `result.economy_options.region`, `result.economy_options.time_period` | +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `GET` | `/us/metadata` | 200 | `metadata` | none | `status`, `result.current_law_id`, `result.economy_options.region`, `result.economy_options.time_period` | +| `GET` | `/uk/metadata` | 200 | `metadata` | none | `status`, `result.current_law_id`, `result.economy_options.region`, `result.economy_options.time_period` | ### `metadata_resources_v2_preview` - Current contract: `typed_v2_resources` - Future owner: Later metadata read cutover and v2 route-prefix removal -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `GET` | `/v2/tax-benefit-models?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | -| `GET` | `/v2/tax-benefit-model-versions?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | -| `GET` | `/v2/variables?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | -| `GET` | `/v2/parameters?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | -| `GET` | `/v2/parameters/children?country_id=us&parent_path=gov` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | -| `GET` | `/v2/parameter-values?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | -| `GET` | `/v2/datasets?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | -| `GET` | `/v2/regions?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | -| `GET` | `/v2/tax-benefit-models/{model_id}?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.item` | -| `GET` | `/v2/tax-benefit-model-versions/{version_id}?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.item` | -| `GET` | `/v2/variables/{variable_id}?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.item` | -| `GET` | `/v2/parameters/{parameter_id}?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.item` | -| `GET` | `/v2/parameter-values/{value_id}?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.item` | -| `GET` | `/v2/datasets/{dataset_id}?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.item` | -| `GET` | `/v2/regions/{region_id}?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.item` | -| `GET` | `/v2/regions/by-code/state/ca?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.item` | -| `GET` | `/v2/tax-benefit-models/by-country/us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.model`, `result.model_version` | -| `GET` | `/v2/economy-options?country_id=us` | 200 | `metadata` | `status`, `message`, `result.policyengine_version`, `result.current_law_id`, `result.region`, `result.time_period`, `result.datasets` | +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `GET` | `/v2/tax-benefit-models?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `GET` | `/v2/tax-benefit-model-versions?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `GET` | `/v2/variables?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `GET` | `/v2/parameters?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `GET` | `/v2/parameters/children?country_id=us&parent_path=gov` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `GET` | `/v2/parameter-values?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `GET` | `/v2/datasets?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `GET` | `/v2/regions?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | +| `GET` | `/v2/tax-benefit-models/{model_id}?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.item` | +| `GET` | `/v2/tax-benefit-model-versions/{version_id}?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.item` | +| `GET` | `/v2/variables/{variable_id}?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.item` | +| `GET` | `/v2/parameters/{parameter_id}?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.item` | +| `GET` | `/v2/parameter-values/{value_id}?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.item` | +| `GET` | `/v2/datasets/{dataset_id}?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.item` | +| `GET` | `/v2/regions/{region_id}?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.item` | +| `GET` | `/v2/regions/by-code/state/ca?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.item` | +| `GET` | `/v2/tax-benefit-models/by-country/us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.model`, `result.model_version` | +| `GET` | `/v2/economy-options?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.current_law_id`, `result.region`, `result.time_period`, `result.datasets` | ### `simulation_submit_poll` - Current contract: `api_v1_compatible` - Future owner: PR 13: Household Calculation Compute Cutover -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/us/simulation` | 201 | `simulation` | `status`, `message`, `result.id`, `result.status` | -| `GET` | `/us/simulation/{simulation_id}` | 200 | `simulation` | `status`, `message`, `result` | +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/us/simulation` | 201 | `simulation` | none | `status`, `message`, `result.id`, `result.status` | +| `GET` | `/us/simulation/{simulation_id}` | 200 | `simulation` | none | `status`, `message`, `result` | ### `report_create_poll` - Current contract: `api_v1_compatible` - Future owner: PR 14: Economy Simulation and Economic Impact Compute Cutover -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/us/report` | 201 | `report` | `status`, `message`, `result.id`, `result.status` | -| `GET` | `/us/report/{report_id}` | 200 | `report` | `status`, `message`, `result` | +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/us/report` | 201 | `report` | none | `status`, `message`, `result.id`, `result.status` | +| `GET` | `/us/report/{report_id}` | 200 | `report` | none | `status`, `message`, `result` | ### `budget_window_submit_poll` - Current contract: `api_v1_compatible` - Future owner: PR 15: Budget-Window and Remaining Simulation API Migration -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `GET` | `/us/economy/{policy_id}/over/{baseline_policy_id}/budget-window?region=us&start_year=2026&window_size=1` | 200 | `economy` | `status`, `result.kind`, `progress`, `completed_years`, `computing_years`, `queued_years`, `error` | +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `GET` | `/us/economy/{policy_id}/over/{baseline_policy_id}/budget-window?region=us&start_year=2026&window_size=1` | 200 | `economy` | none | `status`, `result.kind`, `progress`, `completed_years`, `computing_years`, `queued_years`, `error` | diff --git a/docs/engineering/skills/api-routes.md b/docs/engineering/skills/api-routes.md index 1b6a4b91a..c308ceac4 100644 --- a/docs/engineering/skills/api-routes.md +++ b/docs/engineering/skills/api-routes.md @@ -70,8 +70,9 @@ The Phase 10 policy schemas demonstrate the required composition: - `PolicyDetailQuery` for country-scoped policy detail; - `PolicyCollectionQuery` for exact model filtering and pagination; - `CountryQuery` for association create, detail, update, and delete; -- `UserPolicyCollectionQuery` for association user/policy filtering and - pagination. +- `UserPolicyCollectionQuery` for user association reference filtering and + pagination; the owner comes from the request credential (a bearer token or + the `PolicyEngine-Unauthenticated-User` header), never the query. ## Compatibility and Documentation diff --git a/docs/engineering/skills/testing.md b/docs/engineering/skills/testing.md index d64c1c126..51c36817d 100644 --- a/docs/engineering/skills/testing.md +++ b/docs/engineering/skills/testing.md @@ -298,3 +298,52 @@ uv run --frozen pytest \ tests/unit/test_cloud_run_deploy_scripts.py \ tests/unit/v2/test_metadata_deployment.py -q ``` + +## Stage 12A User Login and Associations + +Run the static type check, then the user route, service, authentication, +contract, and deployment tests: + +```bash +uv run --frozen --extra dev mypy +uv run --frozen pytest \ + tests/unit/libs/test_user_auth.py \ + tests/unit/libs/test_auth0_management.py \ + tests/unit/v2/test_user_route_auth.py \ + tests/unit/v2/test_me_routes.py \ + tests/unit/v2/test_user_policy_routes.py \ + tests/unit/v2/test_user_household_routes.py \ + tests/unit/v2/test_user_simulation_report_routes.py \ + tests/unit/v2/test_user_policy_service.py \ + tests/unit/v2/test_user_household_service.py \ + tests/unit/v2/test_user_account_service.py \ + tests/unit/v2/test_unauthenticated_user_service.py \ + tests/unit/v2/test_legacy_reference_service.py \ + tests/unit/v2/test_user_association_migration_qualification.py \ + tests/unit/v2/test_models.py \ + tests/unit/v2/test_alembic_v2.py \ + tests/unit/test_readiness.py \ + tests/unit/test_cloud_run_deploy_scripts.py \ + tests/unit/test_migration_contract_artifacts.py \ + tests/contract -q +``` + +Prove provisioning races (signed-in and signed-out), conversion and merge at +sign-in under concurrency, the row locks that keep a signed-out browser from +acting on its converted user, named conflict targets, and the reshape +lifecycle against the disposable Postgres database: + +```bash +V2_ALEMBIC_DISPOSABLE_TEST=1 \ +V2_MIGRATION_DATABASE_URL="postgresql+psycopg://.../policyengine_v2_alembic_test" \ +uv run --frozen pytest \ + tests/integration/test_alembic_v2_lifecycle.py \ + tests/integration/test_v2_user_identity_persistence.py -q +``` + +Route tests inject a principal (a `UserPrincipal` or an +`UnauthenticatedPrincipal`) through `NativeRouteDependencies(require_user=...)`; +omit it to exercise the fail-closed default authenticator. +`tests/integration/test_live_v2_policies.py` is a staging deploy gate that +signs in the staging Auth0 test user and saves work as fresh signed-out +browsers; it is not run locally. diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index e53974bf2..a12090988 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -1,10 +1,10 @@ { "metadata": { "db_entity_count": 6, - "request_count": 50, + "request_count": 88, "route_group_count": 9, "sim_flow_count": 3, - "workflow_count": 13 + "workflow_count": 16 }, "route_groups": [ { @@ -79,7 +79,8 @@ "name": "simulation", "path_segments": [ "simulation", - "simulations" + "simulations", + "user-simulations" ], "sim_flow": "economy" }, @@ -87,7 +88,8 @@ "db_entity": "report", "name": "report", "path_segments": [ - "report" + "report", + "user-reports" ], "sim_flow": "report" }, @@ -95,7 +97,8 @@ "db_entity": "user", "name": "user_profile", "path_segments": [ - "user-profile" + "user-profile", + "me" ], "sim_flow": null } @@ -109,6 +112,7 @@ "requests": [ { "expected_status": 201, + "headers": [], "method": "POST", "path": "/us/policy", "route_group": "policy", @@ -120,6 +124,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/us/policy/{policy_id}", "route_group": "policy", @@ -131,6 +136,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/us/policies", "route_group": "policy", @@ -147,6 +153,7 @@ "requests": [ { "expected_status": 201, + "headers": [], "method": "POST", "path": "/v2/policies?country_id=us", "route_group": "policy", @@ -163,6 +170,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/policies/{policy_id}?country_id=us", "route_group": "policy", @@ -179,6 +187,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/policies?country_id=us", "route_group": "policy", @@ -200,6 +209,7 @@ "requests": [ { "expected_status": 201, + "headers": [], "method": "POST", "path": "/us/user-policy", "route_group": "policy", @@ -215,6 +225,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/us/user-policy/{user_id}", "route_group": "policy", @@ -226,6 +237,7 @@ }, { "expected_status": 200, + "headers": [], "method": "PUT", "path": "/us/user-policy", "route_group": "policy", @@ -239,11 +251,14 @@ }, { "current_contract": "typed_v2_resources", - "future_owner_pr": "PR 10: Policy Migration", + "future_owner_pr": "PR 12A: User Login and v1-Anchored User Associations", "name": "user_policy_associations_v2", "requests": [ { "expected_status": 201, + "headers": [ + "Authorization" + ], "method": "POST", "path": "/v2/user-policies?country_id=us", "route_group": "policy", @@ -254,14 +269,20 @@ "result.item.country_id", "result.item.user_id", "result.item.policy_id", + "result.item.legacy_policy_id", "result.item.name", "result.item.description", + "result.item.baseline", + "result.item.provenance", "result.item.created_at", "result.item.updated_at" ] }, { "expected_status": 200, + "headers": [ + "Authorization" + ], "method": "GET", "path": "/v2/user-policies/{association_id}?country_id=us", "route_group": "policy", @@ -272,16 +293,22 @@ "result.item.country_id", "result.item.user_id", "result.item.policy_id", + "result.item.legacy_policy_id", "result.item.name", "result.item.description", + "result.item.baseline", + "result.item.provenance", "result.item.created_at", "result.item.updated_at" ] }, { "expected_status": 200, + "headers": [ + "Authorization" + ], "method": "GET", - "path": "/v2/user-policies?country_id=us&user_id={user_id}", + "path": "/v2/user-policies?country_id=us", "route_group": "policy", "stable_response_fields": [ "status", @@ -294,6 +321,9 @@ }, { "expected_status": 200, + "headers": [ + "Authorization" + ], "method": "PATCH", "path": "/v2/user-policies/{association_id}?country_id=us", "route_group": "policy", @@ -301,13 +331,116 @@ "status", "message", "result.item.id", + "result.item.legacy_policy_id", + "result.item.policy_id", "result.item.name", "result.item.description", + "result.item.baseline", + "result.item.provenance", "result.item.updated_at" ] }, { "expected_status": 204, + "headers": [ + "Authorization" + ], + "method": "DELETE", + "path": "/v2/user-policies/{association_id}?country_id=us", + "route_group": "policy", + "stable_response_fields": [] + }, + { + "expected_status": 201, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "POST", + "path": "/v2/user-policies?country_id=us", + "route_group": "policy", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + "result.item.policy_id", + "result.item.legacy_policy_id", + "result.item.name", + "result.item.description", + "result.item.baseline", + "result.item.provenance", + "result.item.created_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "GET", + "path": "/v2/user-policies/{association_id}?country_id=us", + "route_group": "policy", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + "result.item.policy_id", + "result.item.legacy_policy_id", + "result.item.name", + "result.item.description", + "result.item.baseline", + "result.item.provenance", + "result.item.created_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "GET", + "path": "/v2/user-policies?country_id=us", + "route_group": "policy", + "stable_response_fields": [ + "status", + "message", + "result.items", + "result.offset", + "result.limit", + "result.has_more" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "PATCH", + "path": "/v2/user-policies/{association_id}?country_id=us", + "route_group": "policy", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.legacy_policy_id", + "result.item.policy_id", + "result.item.name", + "result.item.description", + "result.item.baseline", + "result.item.provenance", + "result.item.updated_at" + ] + }, + { + "expected_status": 204, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], "method": "DELETE", "path": "/v2/user-policies/{association_id}?country_id=us", "route_group": "policy", @@ -322,6 +455,7 @@ "requests": [ { "expected_status": 201, + "headers": [], "method": "POST", "path": "/us/household", "route_group": "household", @@ -333,6 +467,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/us/household/{household_id}", "route_group": "household", @@ -351,6 +486,7 @@ "requests": [ { "expected_status": 201, + "headers": [], "method": "POST", "path": "/v2/households?country_id=us", "route_group": "household", @@ -367,6 +503,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/households/{household_id}?country_id=us", "route_group": "household", @@ -383,6 +520,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/households?country_id=us", "route_group": "household", @@ -399,11 +537,14 @@ }, { "current_contract": "typed_v2_resources", - "future_owner_pr": "PR 11: Household Migration", + "future_owner_pr": "PR 12A: User Login and v1-Anchored User Associations", "name": "user_household_associations_v2", "requests": [ { "expected_status": 201, + "headers": [ + "Authorization" + ], "method": "POST", "path": "/v2/user-households?country_id=us", "route_group": "household", @@ -414,6 +555,7 @@ "result.item.country_id", "result.item.user_id", "result.item.household_id", + "result.item.legacy_household_id", "result.item.name", "result.item.description", "result.item.created_at", @@ -422,6 +564,9 @@ }, { "expected_status": 200, + "headers": [ + "Authorization" + ], "method": "GET", "path": "/v2/user-households/{association_id}?country_id=us", "route_group": "household", @@ -432,6 +577,7 @@ "result.item.country_id", "result.item.user_id", "result.item.household_id", + "result.item.legacy_household_id", "result.item.name", "result.item.description", "result.item.created_at", @@ -440,8 +586,11 @@ }, { "expected_status": 200, + "headers": [ + "Authorization" + ], "method": "GET", - "path": "/v2/user-households?country_id=us&user_id={user_id}", + "path": "/v2/user-households?country_id=us", "route_group": "household", "stable_response_fields": [ "status", @@ -454,6 +603,9 @@ }, { "expected_status": 200, + "headers": [ + "Authorization" + ], "method": "PATCH", "path": "/v2/user-households/{association_id}?country_id=us", "route_group": "household", @@ -461,6 +613,7 @@ "status", "message", "result.item.id", + "result.item.legacy_household_id", "result.item.household_id", "result.item.name", "result.item.description", @@ -469,10 +622,653 @@ }, { "expected_status": 204, + "headers": [ + "Authorization" + ], "method": "DELETE", "path": "/v2/user-households/{association_id}?country_id=us", "route_group": "household", "stable_response_fields": [] + }, + { + "expected_status": 201, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "POST", + "path": "/v2/user-households?country_id=us", + "route_group": "household", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + "result.item.household_id", + "result.item.legacy_household_id", + "result.item.name", + "result.item.description", + "result.item.created_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "GET", + "path": "/v2/user-households/{association_id}?country_id=us", + "route_group": "household", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + "result.item.household_id", + "result.item.legacy_household_id", + "result.item.name", + "result.item.description", + "result.item.created_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "GET", + "path": "/v2/user-households?country_id=us", + "route_group": "household", + "stable_response_fields": [ + "status", + "message", + "result.items", + "result.offset", + "result.limit", + "result.has_more" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "PATCH", + "path": "/v2/user-households/{association_id}?country_id=us", + "route_group": "household", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.legacy_household_id", + "result.item.household_id", + "result.item.name", + "result.item.description", + "result.item.updated_at" + ] + }, + { + "expected_status": 204, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "DELETE", + "path": "/v2/user-households/{association_id}?country_id=us", + "route_group": "household", + "stable_response_fields": [] + } + ] + }, + { + "current_contract": "typed_v2_resources", + "future_owner_pr": "PR 12A: User Login and v1-Anchored User Associations", + "name": "user_simulation_associations_v2", + "requests": [ + { + "expected_status": 201, + "headers": [ + "Authorization" + ], + "method": "POST", + "path": "/v2/user-simulations?country_id=us", + "route_group": "simulation", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + "result.item.simulation_id", + "result.item.legacy_simulation_id", + "result.item.name", + "result.item.description", + "result.item.created_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "Authorization" + ], + "method": "GET", + "path": "/v2/user-simulations/{association_id}?country_id=us", + "route_group": "simulation", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + "result.item.simulation_id", + "result.item.legacy_simulation_id", + "result.item.name", + "result.item.description", + "result.item.created_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "Authorization" + ], + "method": "GET", + "path": "/v2/user-simulations?country_id=us", + "route_group": "simulation", + "stable_response_fields": [ + "status", + "message", + "result.items", + "result.offset", + "result.limit", + "result.has_more" + ] + }, + { + "expected_status": 200, + "headers": [ + "Authorization" + ], + "method": "PATCH", + "path": "/v2/user-simulations/{association_id}?country_id=us", + "route_group": "simulation", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.legacy_simulation_id", + "result.item.name", + "result.item.description", + "result.item.updated_at" + ] + }, + { + "expected_status": 204, + "headers": [ + "Authorization" + ], + "method": "DELETE", + "path": "/v2/user-simulations/{association_id}?country_id=us", + "route_group": "simulation", + "stable_response_fields": [] + }, + { + "expected_status": 201, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "POST", + "path": "/v2/user-simulations?country_id=us", + "route_group": "simulation", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + "result.item.simulation_id", + "result.item.legacy_simulation_id", + "result.item.name", + "result.item.description", + "result.item.created_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "GET", + "path": "/v2/user-simulations/{association_id}?country_id=us", + "route_group": "simulation", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + "result.item.simulation_id", + "result.item.legacy_simulation_id", + "result.item.name", + "result.item.description", + "result.item.created_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "GET", + "path": "/v2/user-simulations?country_id=us", + "route_group": "simulation", + "stable_response_fields": [ + "status", + "message", + "result.items", + "result.offset", + "result.limit", + "result.has_more" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "PATCH", + "path": "/v2/user-simulations/{association_id}?country_id=us", + "route_group": "simulation", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.legacy_simulation_id", + "result.item.name", + "result.item.description", + "result.item.updated_at" + ] + }, + { + "expected_status": 204, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "DELETE", + "path": "/v2/user-simulations/{association_id}?country_id=us", + "route_group": "simulation", + "stable_response_fields": [] + } + ] + }, + { + "current_contract": "typed_v2_resources", + "future_owner_pr": "PR 12A: User Login and v1-Anchored User Associations", + "name": "user_report_associations_v2", + "requests": [ + { + "expected_status": 201, + "headers": [ + "Authorization" + ], + "method": "POST", + "path": "/v2/user-reports?country_id=us", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + "result.item.report_id", + "result.item.legacy_report_id", + "result.item.name", + "result.item.description", + "result.item.user_policy_id", + "result.item.baseline_user_policy_id", + "result.item.source_note", + "result.item.provisions", + "result.item.validation", + "result.item.budget_window_years", + "result.item.last_run_at", + "result.item.created_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "Authorization" + ], + "method": "GET", + "path": "/v2/user-reports/{association_id}?country_id=us", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + "result.item.report_id", + "result.item.legacy_report_id", + "result.item.name", + "result.item.description", + "result.item.user_policy_id", + "result.item.baseline_user_policy_id", + "result.item.source_note", + "result.item.provisions", + "result.item.validation", + "result.item.budget_window_years", + "result.item.last_run_at", + "result.item.created_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "Authorization" + ], + "method": "GET", + "path": "/v2/user-reports?country_id=us", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.items", + "result.offset", + "result.limit", + "result.has_more" + ] + }, + { + "expected_status": 200, + "headers": [ + "Authorization" + ], + "method": "PATCH", + "path": "/v2/user-reports/{association_id}?country_id=us", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.legacy_report_id", + "result.item.name", + "result.item.description", + "result.item.user_policy_id", + "result.item.baseline_user_policy_id", + "result.item.source_note", + "result.item.provisions", + "result.item.validation", + "result.item.budget_window_years", + "result.item.last_run_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 204, + "headers": [ + "Authorization" + ], + "method": "DELETE", + "path": "/v2/user-reports/{association_id}?country_id=us", + "route_group": "report", + "stable_response_fields": [] + }, + { + "expected_status": 201, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "POST", + "path": "/v2/user-reports?country_id=us", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + "result.item.report_id", + "result.item.legacy_report_id", + "result.item.name", + "result.item.description", + "result.item.user_policy_id", + "result.item.baseline_user_policy_id", + "result.item.source_note", + "result.item.provisions", + "result.item.validation", + "result.item.budget_window_years", + "result.item.last_run_at", + "result.item.created_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "GET", + "path": "/v2/user-reports/{association_id}?country_id=us", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + "result.item.report_id", + "result.item.legacy_report_id", + "result.item.name", + "result.item.description", + "result.item.user_policy_id", + "result.item.baseline_user_policy_id", + "result.item.source_note", + "result.item.provisions", + "result.item.validation", + "result.item.budget_window_years", + "result.item.last_run_at", + "result.item.created_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "GET", + "path": "/v2/user-reports?country_id=us", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.items", + "result.offset", + "result.limit", + "result.has_more" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "PATCH", + "path": "/v2/user-reports/{association_id}?country_id=us", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.legacy_report_id", + "result.item.name", + "result.item.description", + "result.item.user_policy_id", + "result.item.baseline_user_policy_id", + "result.item.source_note", + "result.item.provisions", + "result.item.validation", + "result.item.budget_window_years", + "result.item.last_run_at", + "result.item.updated_at" + ] + }, + { + "expected_status": 204, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "DELETE", + "path": "/v2/user-reports/{association_id}?country_id=us", + "route_group": "report", + "stable_response_fields": [] + } + ] + }, + { + "current_contract": "typed_v2_resources", + "future_owner_pr": "PR 12A: User Login and v1-Anchored User Associations", + "name": "user_account_v2", + "requests": [ + { + "expected_status": 200, + "headers": [ + "Authorization" + ], + "method": "GET", + "path": "/v2/me?country_id=us", + "route_group": "user_profile", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.user_type", + "result.item.primary_country", + "result.item.first_name", + "result.item.last_name", + "result.item.email", + "result.item.created_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "GET", + "path": "/v2/me?country_id=us", + "route_group": "user_profile", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.user_type", + "result.item.primary_country", + "result.item.first_name", + "result.item.last_name", + "result.item.email", + "result.item.created_at" + ] + }, + { + "expected_status": 200, + "headers": [ + "Authorization" + ], + "method": "PATCH", + "path": "/v2/me?country_id=us", + "route_group": "user_profile", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.primary_country", + "result.item.first_name", + "result.item.last_name" + ] + }, + { + "expected_status": 200, + "headers": [ + "Authorization" + ], + "method": "GET", + "path": "/v2/me/export?country_id=us", + "route_group": "user_profile", + "stable_response_fields": [ + "status", + "message", + "result.user", + "result.policies", + "result.households", + "result.simulations", + "result.reports" + ] + }, + { + "expected_status": 200, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "GET", + "path": "/v2/me/export?country_id=us", + "route_group": "user_profile", + "stable_response_fields": [ + "status", + "message", + "result.user", + "result.policies", + "result.households", + "result.simulations", + "result.reports" + ] + }, + { + "expected_status": 200, + "headers": [ + "Authorization", + "PolicyEngine-Unauthenticated-User" + ], + "method": "POST", + "path": "/v2/me/upgrade?country_id=us", + "route_group": "user_profile", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.user_type", + "result.item.primary_country", + "result.outcome" + ] + }, + { + "expected_status": 204, + "headers": [ + "Authorization" + ], + "method": "DELETE", + "path": "/v2/me?country_id=us", + "route_group": "user_profile", + "stable_response_fields": [] + }, + { + "expected_status": 204, + "headers": [ + "PolicyEngine-Unauthenticated-User" + ], + "method": "DELETE", + "path": "/v2/me?country_id=us", + "route_group": "user_profile", + "stable_response_fields": [] } ] }, @@ -483,6 +1279,7 @@ "requests": [ { "expected_status": 200, + "headers": [], "method": "POST", "path": "/us/calculate", "route_group": "household", @@ -501,6 +1298,7 @@ "requests": [ { "expected_status": 200, + "headers": [], "method": "GET", "path": "/us/metadata", "route_group": "metadata", @@ -513,6 +1311,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/uk/metadata", "route_group": "metadata", @@ -532,6 +1331,7 @@ "requests": [ { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/tax-benefit-models?country_id=us", "route_group": "metadata", @@ -547,6 +1347,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/tax-benefit-model-versions?country_id=us", "route_group": "metadata", @@ -562,6 +1363,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/variables?country_id=us", "route_group": "metadata", @@ -577,6 +1379,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/parameters?country_id=us", "route_group": "metadata", @@ -592,6 +1395,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/parameters/children?country_id=us&parent_path=gov", "route_group": "metadata", @@ -607,6 +1411,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/parameter-values?country_id=us", "route_group": "metadata", @@ -622,6 +1427,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/datasets?country_id=us", "route_group": "metadata", @@ -637,6 +1443,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/regions?country_id=us", "route_group": "metadata", @@ -652,6 +1459,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/tax-benefit-models/{model_id}?country_id=us", "route_group": "metadata", @@ -664,6 +1472,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/tax-benefit-model-versions/{version_id}?country_id=us", "route_group": "metadata", @@ -676,6 +1485,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/variables/{variable_id}?country_id=us", "route_group": "metadata", @@ -688,6 +1498,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/parameters/{parameter_id}?country_id=us", "route_group": "metadata", @@ -700,6 +1511,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/parameter-values/{value_id}?country_id=us", "route_group": "metadata", @@ -712,6 +1524,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/datasets/{dataset_id}?country_id=us", "route_group": "metadata", @@ -724,6 +1537,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/regions/{region_id}?country_id=us", "route_group": "metadata", @@ -736,6 +1550,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/regions/by-code/state/ca?country_id=us", "route_group": "metadata", @@ -748,6 +1563,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/tax-benefit-models/by-country/us", "route_group": "metadata", @@ -761,6 +1577,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/economy-options?country_id=us", "route_group": "metadata", @@ -783,6 +1600,7 @@ "requests": [ { "expected_status": 201, + "headers": [], "method": "POST", "path": "/us/simulation", "route_group": "simulation", @@ -795,6 +1613,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/us/simulation/{simulation_id}", "route_group": "simulation", @@ -813,6 +1632,7 @@ "requests": [ { "expected_status": 201, + "headers": [], "method": "POST", "path": "/us/report", "route_group": "report", @@ -825,6 +1645,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/us/report/{report_id}", "route_group": "report", @@ -843,6 +1664,7 @@ "requests": [ { "expected_status": 200, + "headers": [], "method": "GET", "path": "/us/economy/{policy_id}/over/{baseline_policy_id}/budget-window?region=us&start_year=2026&window_size=1", "route_group": "economy", diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 8663517ab..070d535e7 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -8,11 +8,11 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce | Tier | enforced | ratcheted | advisory | doc-only | Total | | --- | ---: | ---: | ---: | ---: | ---: | -| mechanical | 39 | 1 | 2 | 12 | 54 | +| mechanical | 40 | 1 | 2 | 12 | 55 | | metric | 0 | 0 | 0 | 0 | 0 | | judgment | 0 | 0 | 0 | 0 | 0 | -**Blocking:** 40 of 54 rules. +**Blocking:** 41 of 55 rules. ## Gaps @@ -83,6 +83,7 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce | `api.v2.routes-no-sessions-or-sql` | v2 route modules never open database sessions or construct SQL; they call one service method and map typed failures to HTTP. | mechanical | doc-only | no | nothing | — | — | | `api.v2.services-no-sql` | v2 services sequence work but never construct SQL expressions, call Session.exec, or define HTTP response models. | mechanical | doc-only | no | nothing | — | — | | `api.v2.session-modules-no-queries` | v2 database_session modules own session lifetime only and never build select, insert, update, delete or exec calls. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | **unproven** | +| `api.v2.user-services-no-sql` | v2 user services (identity, accounts, legacy references and user associations) never build or run SQL themselves or import SQLAlchemy; their connectors do. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | yes | | `api.v2.validators-transformations-no-db` | v2 validators and transformations never import SQLAlchemy or SQLModel query or session APIs. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | yes | ```mermaid @@ -99,6 +100,7 @@ flowchart LR r_api_v2_routes_no_sessions_or_sql("api.v2.routes-no-sessions-or-sql (doc-only)") r_api_v2_services_no_sql("api.v2.services-no-sql (doc-only)") r_api_v2_session_modules_no_queries("api.v2.session-modules-no-queries (enforced)") + r_api_v2_user_services_no_sql("api.v2.user-services-no-sql (enforced)") r_api_v2_validators_transformations_no_db("api.v2.validators-transformations-no-db (enforced)") r_api_routes_shared_query_parameters -.-> gap_unenforced r_api_v2_connectors_mutations_no_reads --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py @@ -110,6 +112,7 @@ flowchart LR r_api_v2_routes_no_sessions_or_sql -.-> gap_unenforced r_api_v2_services_no_sql -.-> gap_unenforced r_api_v2_session_modules_no_queries --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py + r_api_v2_user_services_no_sql --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py r_api_v2_validators_transformations_no_db --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py ``` @@ -267,7 +270,7 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | --- | | `api.quality.alembic-revisions-unused-imports` | Alembic revision files are exempt from unused-import checks (F401), because autogenerated revisions import types they may not use. | mechanical | advisory | no | ruff per-file ignore of F401 for `migrations/*/versions/*.py` | — | — | | `api.quality.environment-variables-load` | Required environment variables and credentials load in a deployed-like environment. | mechanical | enforced | yes | CI job `test_env_vars` | `pr.yml:test_env_vars` | — | -| `api.quality.mypy-v2` | Shared query models, FastAPI dependencies, and v2 routes and services pass mypy with untyped definitions disallowed. | mechanical | enforced | yes | mypy | `pr.yml:typecheck-v2` | — | +| `api.quality.mypy-v2` | Shared query models, FastAPI dependencies, v2 routes and services, and the user sign-in and account-deletion libraries pass mypy with untyped definitions disallowed. | mechanical | enforced | yes | mypy | `pr.yml:typecheck-v2` | — | | `api.quality.ruff-format` | All Python files are formatted with ruff format. | mechanical | enforced | yes | ruff format | `pr.yml:lint` | — | | `api.quality.ruff-lint` | Changed Python files pass ruff check with the rules selected in pyproject.toml (ruff's defaults, made explicit); agents run it before committing, but CI does not yet. | mechanical | advisory | no | ruff check (E4, E7, E9, F) | — | — | | `api.quality.tests-pass` | The unit, contract and to-refactor test suites pass. | mechanical | enforced | yes | CI job `test` | `pr.yml:test` | — | diff --git a/docs/migration/armor/proposed/20261012-pol-api-lb-stage-12a-user-writes.yaml b/docs/migration/armor/proposed/20261012-pol-api-lb-stage-12a-user-writes.yaml new file mode 100644 index 000000000..142ed5cac --- /dev/null +++ b/docs/migration/armor/proposed/20261012-pol-api-lb-stage-12a-user-writes.yaml @@ -0,0 +1,106 @@ +# PROPOSED (not deployed): Stage 12A per-IP throttle for user-owned writes. +# +# This is the newest deployed snapshot +# (../20260830T141827Z-pol-api-lb-metadata-enforced.yaml) plus one new rule at +# priority 1200. It lives under proposed/ so it is never mistaken for the +# authoritative deployed state. An operator applies the rule with the +# runbook's preview-first procedure (docs/migration/lb-cloud-armor-runbook.md), +# then exports and commits a new timestamped snapshot: +# +# gcloud compute security-policies rules create 1200 \ +# --project=$PROJECT --security-policy=pol-api-lb \ +# --expression="request.method.matches('^(?:POST|PUT|PATCH|DELETE)$') && request.path.matches('^/v2/(?:me|user-)')" \ +# --action=throttle \ +# --rate-limit-threshold-count=120 --rate-limit-threshold-interval-sec=60 \ +# --conform-action=allow --exceed-action=deny-429 --enforce-on-key=IP \ +# --preview +# +# Design notes: +# - The rule covers signed-in and signed-out writes alike: requests with a +# bearer token and requests with the PolicyEngine-Unauthenticated-User +# header reach the same /v2/me* and /v2/user-* routes. It includes +# POST /v2/me/upgrade, which app v3 sends once per sign-in to move the +# browser's signed-out work into the account. +# - Anyone can mint unauthenticated users: the first write with a new UUID in +# the header creates one, with no sign-in. This per-IP write throttle is the +# only bound on how fast one IP can create them (and their rows); the +# 256 KiB body limit and the per-field bounds limit what each write stores. +# Reads never create a user. +# - Reads (GET) and CORS preflights (OPTIONS) are excluded: app v3 lists saved +# reforms and reports and reads GET /v2/me on its account page; NAT'd +# offices share IPs. +# - App v3 saves signed-out work one record at a time as the user creates it, +# so a busy browser sends a handful of writes per minute; 120 per minute per +# IP leaves room for offices and campuses behind shared NAT. A throttled +# write (429) fails that save, and the app reports it. +# - The earlier import throttle (priority 1150, POST /v2/me/imports) is gone: +# that route was removed before release. +# - Cloud Armor CEL regexes reject capture groups, so the plan's +# ^/v2/(me|user-) is written ^/v2/(?:me|user-). +# - The threshold is a starting point; confirm with >=24h of preview logs and +# the runbook's empirical threshold check before enforcing. +creationTimestamp: '2026-07-21T07:16:39.177-07:00' +description: Rate limiting for api.policyengine.org (lb-api). See docs/migration/lb-cloud-armor-runbook.md +fingerprint: dT_5QmeI6h8= +id: '200433714284642728' +kind: compute#securityPolicy +labelFingerprint: 42WmSpB8rSM= +name: pol-api-lb +rules: +- action: throttle + description: '' + kind: compute#securityPolicyRule + match: + expr: + expression: request.path.matches('^/[a-z]{2}/metadata$') + preview: false + priority: 1000 + rateLimitOptions: + conformAction: allow + enforceOnKey: IP + exceedAction: deny(429) + rateLimitThreshold: + count: 30 + intervalSec: 60 +- action: throttle + description: '' + kind: compute#securityPolicyRule + match: + expr: + expression: request.path.matches('^/[a-z]{2}/calculate(?:-full)?$') + preview: true + priority: 1100 + rateLimitOptions: + conformAction: allow + enforceOnKey: IP + exceedAction: deny(429) + rateLimitThreshold: + count: 75 + intervalSec: 60 +- action: throttle + description: 'Stage 12A: per-IP limit on user-owned writes' + kind: compute#securityPolicyRule + match: + expr: + expression: request.method.matches('^(?:POST|PUT|PATCH|DELETE)$') && request.path.matches('^/v2/(?:me|user-)') + preview: true + priority: 1200 + rateLimitOptions: + conformAction: allow + enforceOnKey: IP + exceedAction: deny(429) + rateLimitThreshold: + count: 120 + intervalSec: 60 +- action: allow + description: default rule + kind: compute#securityPolicyRule + match: + config: + srcIpRanges: + - '*' + versionedExpr: SRC_IPS_V1 + preview: false + priority: 2147483647 +selfLink: https://www.googleapis.com/compute/v1/projects/policyengine-api/global/securityPolicies/pol-api-lb +type: CLOUD_ARMOR diff --git a/docs/migration/armor/proposed/20261013-pol-api-lb-stage-12a-rollback-guard.yaml b/docs/migration/armor/proposed/20261013-pol-api-lb-stage-12a-rollback-guard.yaml new file mode 100644 index 000000000..530498e71 --- /dev/null +++ b/docs/migration/armor/proposed/20261013-pol-api-lb-stage-12a-rollback-guard.yaml @@ -0,0 +1,113 @@ +# PROPOSED (not deployed): Stage 12A application-rollback guard. +# +# Apply this rule BEFORE shifting production traffic back to a Public API +# revision older than Stage 12A (revision fbb7488ebe8a) once any user-owned +# record exists, signed in or not. Those revisions serve /v2/user-policies and +# /v2/user-households without authentication: anyone could list, read, create, +# rename or delete a user's saved work by user or record ID. This rule denies +# every user-owned route (/v2/me*, including /v2/me/upgrade, and /v2/user-*) +# at the edge instead. Saved work is unavailable while it is in place; v1 +# routes and the /v2 metadata, policy and household routes are unaffected. +# +# This is the newest deployed snapshot +# (../20260830T141827Z-pol-api-lb-metadata-enforced.yaml) plus one deny rule at +# priority 900, evaluated before every throttle. It lives under proposed/ so it +# is never mistaken for the authoritative deployed state, and it is independent +# of the proposed Stage 12A write throttle (priority 1200). +# +# Apply (enforced at once: a rollback guard that only logs protects nothing): +# +# gcloud compute security-policies rules create 900 \ +# --project=$PROJECT --security-policy=pol-api-lb \ +# --description="Stage 12A rollback guard: deny user-owned routes" \ +# --expression="request.path.matches('^/v2/(?:me|user-(?:policies|households|simulations|reports))(?:/.*)?$')" \ +# --action=deny-403 +# +# Cloud Armor only sees traffic that crosses the load balancer, and the +# service's *.run.app URLs (including revision tag URLs) bypass it. While the +# guard is in place, close that path too: +# +# gcloud run services update policyengine-api \ +# --project=$PROJECT --region=us-central1 \ +# --ingress=internal-and-cloud-load-balancing +# +# Then shift traffic to the previous revision, verify that +# `curl -i https://api.policyengine.org/v2/user-policies?country_id=us` returns +# 403, export and commit a timestamped snapshot (runbook "Snapshot after every +# policy change"). +# +# Remove the guard only after a Stage 12A-or-later revision serves 100% of +# traffic again: +# +# gcloud run services update policyengine-api \ +# --project=$PROJECT --region=us-central1 --ingress=all +# gcloud compute security-policies rules delete 900 \ +# --project=$PROJECT --security-policy=pol-api-lb +# +# Design notes: +# - deny-403, not a throttle, and no preview period: this is an emergency +# control, an explicit exception to the runbook's preview-first rule. +# - All methods are denied, including GET and OPTIONS: the previous revision's +# reads expose saved work as well as its writes. +# - The path list is explicit so /v2 metadata and resource routes stay open; +# Cloud Armor CEL regexes reject capture groups, so groups are (?:...). +# - Restoring ingress to "all" re-opens the run.app URLs that CI smoke tests +# and the staging promotion use; they are unavailable while the guard holds. +creationTimestamp: '2026-07-21T07:16:39.177-07:00' +description: Rate limiting for api.policyengine.org (lb-api). See docs/migration/lb-cloud-armor-runbook.md +fingerprint: dT_5QmeI6h8= +id: '200433714284642728' +kind: compute#securityPolicy +labelFingerprint: 42WmSpB8rSM= +name: pol-api-lb +rules: +- action: deny(403) + description: 'Stage 12A rollback guard: deny user-owned routes' + kind: compute#securityPolicyRule + match: + expr: + expression: request.path.matches('^/v2/(?:me|user-(?:policies|households|simulations|reports))(?:/.*)?$') + preview: false + priority: 900 +- action: throttle + description: '' + kind: compute#securityPolicyRule + match: + expr: + expression: request.path.matches('^/[a-z]{2}/metadata$') + preview: false + priority: 1000 + rateLimitOptions: + conformAction: allow + enforceOnKey: IP + exceedAction: deny(429) + rateLimitThreshold: + count: 30 + intervalSec: 60 +- action: throttle + description: '' + kind: compute#securityPolicyRule + match: + expr: + expression: request.path.matches('^/[a-z]{2}/calculate(?:-full)?$') + preview: true + priority: 1100 + rateLimitOptions: + conformAction: allow + enforceOnKey: IP + exceedAction: deny(429) + rateLimitThreshold: + count: 75 + intervalSec: 60 +- action: allow + description: default rule + kind: compute#securityPolicyRule + match: + config: + srcIpRanges: + - '*' + versionedExpr: SRC_IPS_V1 + preview: false + priority: 2147483647 +selfLink: https://www.googleapis.com/compute/v1/projects/policyengine-api/global/securityPolicies/pol-api-lb +type: CLOUD_ARMOR diff --git a/docs/migration/stage-10-v2-policies.md b/docs/migration/stage-10-v2-policies.md index 30baa763b..e5db09e48 100644 --- a/docs/migration/stage-10-v2-policies.md +++ b/docs/migration/stage-10-v2-policies.md @@ -93,6 +93,11 @@ Supabase connection. The routes are registered as preview resources; `ROUTE_IMPL_POLICY=fastapi_native` declares them operational for deployment readiness validation without moving v1 routes away from Flask. +Stage 12A update: `/v2/user-policies` now requires a signed-in user's bearer +token, takes the owner from the token (requests no longer carry `user_id`), +accepts `legacy_policy_id`, and returns 409 for rows maintained by this mirror. +See `docs/migration/stage-12a-user-login.md`. + Native user-policy requests use an existing `users.id` UUID. V1 saved-policy mirroring keeps the opaque Cloud SQL user identifier out of that foreign-key column: `legacy_user_mappings` maps the exact v1 string one-to-one to a v2 user diff --git a/docs/migration/stage-11-v2-households.md b/docs/migration/stage-11-v2-households.md index 54ba0b075..ceca93f4a 100644 --- a/docs/migration/stage-11-v2-households.md +++ b/docs/migration/stage-11-v2-households.md @@ -92,7 +92,10 @@ DB_WRITE_HOUSEHOLD=cloud_sql `ROUTE_IMPL_HOUSEHOLD` remains `flask_fallback` because the route group still contains v1 calculation paths assigned to a later migration stage. -`DB_READ_HOUSEHOLD` remains `cloud_sql`. Native `/v2/households` and +`DB_READ_HOUSEHOLD` remains `cloud_sql`. (Stage 12A update: +`/v2/user-households` now requires a signed-in user's bearer token, takes the +owner from the token, and accepts `legacy_household_id`; see +`docs/migration/stage-12a-user-login.md`.) Native `/v2/households` and `/v2/user-households` requests are registered independently and always use the server-side Supabase connection; they do not consult these v1 selectors. The readiness check therefore requires valid v2 runtime database settings even when diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md new file mode 100644 index 000000000..5a6c78dbc --- /dev/null +++ b/docs/migration/stage-12a-user-login.md @@ -0,0 +1,565 @@ +# Stage 12A: user accounts and v1-anchored user associations + +Stage 12A saves app v3 users' work in the Public API database, whether or not +they are signed in. A signed-in user authenticates with Auth0 social sign-on; +a signed-out browser identifies itself with a random UUID it keeps. The +Public API turns either credential into a PolicyEngine user ID and writes that +ID onto every saved policy, household, simulation, and report. Saved records +point at the v1 artifacts the app already creates. At sign-in, the browser's +signed-out work moves into the signed-in account. + +## Consumers + +Only app v3 (the flagship shell in `policyengine-app-v2`) consumes these +routes. The legacy builder stays browser-only, signed in or not. + +- **Signed out.** App v3 generates a random UUID once per browser + (`crypto.randomUUID()`), keeps it, and sends it as + `PolicyEngine-Unauthenticated-User: ` on every `/v2/me*` and + `/v2/user-*` request made while signed out. Its saved reforms and reports + are stored server-side under that browser's unauthenticated user. +- **Sign-in.** Right after sign-in, app v3 calls + `POST /v2/me/upgrade?country_id=` with both the bearer token and the + browser's header. The browser's signed-out work then belongs to the account + (see "Conversion and merge"). Afterwards app v3 sends only the bearer token. +- **Account.** App v3 calls `GET /v2/me` on its account page. +- **Saved work.** App v3 reads and writes `/v2/user-policies` (its reforms) + and `/v2/user-reports`. `/v2/user-households` and `/v2/user-simulations` + have no app consumer yet. + +## User types and credentials + +`users.user_type` is `standard` or `unauthenticated`. + +- A **standard** user is backed by one or more verified sign-in identities. + Every user that existed before this revision, including the Stage 10 + legacy-mapping users, is standard. +- An **unauthenticated** user belongs to one signed-out browser. Its `id` is + the browser's UUID, and every other user column (`first_name`, `last_name`, + `email`, `primary_country`) is NULL. Its timestamps work as usual. + +The router-level authenticator (`NativeRouteDependencies.require_user`) turns +a request into a principal before query, path, and body validation (only a +body that is not parseable JSON is rejected, with 422, by FastAPI first): + +| Request | Principal or response | +| --- | --- | +| `Authorization: Bearer ` | Validated as below; a valid user token is a `UserPrincipal`. The unauthenticated-user header is ignored. | +| No bearer token, `PolicyEngine-Unauthenticated-User: ` | `UnauthenticatedPrincipal(user_id)`. Needs no Auth0 configuration. | +| No bearer token and no header, or a header that is not a canonical UUID | 401 with `WWW-Authenticate: Bearer` | + +A canonical UUID is the lowercase, hyphenated form that +`crypto.randomUUID()` produces (`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`). +Uppercase, unhyphenated, braced, and `urn:uuid:` forms are rejected. + +Bearer tokens are RS256 Auth0 access tokens for the Public API audience. +`libs/user_auth.py` validates them against `{issuer}.well-known/jwks.json` and +checks `iss` and `aud`. The decoder is ported from the Simulation Entrypoint. +For a bearer token: + +- an invalid token: 401 with `WWW-Authenticate: Bearer error="invalid_token"` + (an invalid token never falls back to the header); +- a machine-to-machine (`…@clients`) token: 403; +- authentication not configured, or signing keys unreachable: 503. + +The type alias `AccountPrincipal = UserPrincipal | UnauthenticatedPrincipal` +flows from the routes through every service. + +An unauthenticated principal never acts on a standard user. When its UUID +names a standard user (one converted from this browser, or anyone else's), the +request gets 401 with `WWW-Authenticate: Bearer` and the message "This +browser's saved work now belongs to a signed-in account; sign in to continue". +After a conversion, app v3 should sign the user in, or generate a new UUID to +start a new signed-out session. + +CORS already allows every request header (`allow_headers=["*"]` in +`asgi_factory`), so browsers may send `PolicyEngine-Unauthenticated-User` +cross-origin; a unit test covers the preflight. OpenAPI publishes the header as +the `UnauthenticatedUser` API-key security scheme, an alternative to +`UserAccessToken`. + +## Provisioning strategies + +`services/v2/identity` selects an `AccountProvisioningStrategy` from the +principal's type (`account_provisioning_for`). Each strategy has +`find_in_session` (no writes), `resolve_in_session(..., country_id)` (may +provision), and `resolve_for_read_in_session`. `find_principal_user_in_session`, +`resolve_principal_user_in_session`, and +`resolve_principal_user_for_read_in_session` dispatch to it. + +- **`StandardAccountProvisioning`** maps `(issuer, subject)` to `users.id` + through `user_identities`. The first request of any kind, read or write, + inserts a standard user (with `primary_country` from `country_id`) and the + identity. A concurrent first request loses `UNIQUE(issuer, subject)`, rolls + back its savepoint, and re-reads, so exactly one user is created. + `last_seen_at` is written at most hourly. Email is stored only when + `email_verified` is true; it is never unique and never used for linking. No + legacy user is adopted implicitly. +- **`UnauthenticatedAccountProvisioning`** provisions only on a write. It + inserts `users(id=, user_type='unauthenticated')` with + `ON CONFLICT (id) DO NOTHING`, then re-reads the row `FOR SHARE`. Concurrent + first writes therefore create exactly one user. The share lock holds until + the write commits, so a conversion at sign-in waits for the write, and a + write waits for a conversion in progress and then sees its result. If the + user was deleted between the insert and the re-read (by its own account + deletion, or a merge at sign-in), the insert runs once more. A row that + turns out to be standard gets the 401 above. + +**Reads never provision an unauthenticated user.** An absent one behaves as a +user with no records: lists are empty, gets return 404, `GET /v2/me` returns +404, and `GET /v2/me/export` returns `user: null` with empty lists. Creates, +PATCH, and DELETE of association records are writes and provision it. PATCH +and DELETE on a record it does not own still return 404. + +## Conversion and merge + +`POST /v2/me/upgrade?country_id=us|uk` calls +`convert_unauthenticated_user_in_session(session, unauthenticated_user_id=..., +principal=..., country_id=...)` in one transaction. It first locks the +browser's `users` row `FOR UPDATE`, but only while that row is +unauthenticated: a standard user's row is never locked. + +| State | Outcome | What happens | +| --- | --- | --- | +| The identity has no user yet; the browser's unauthenticated user exists | `converted` | The `user_identities` row (issuer, subject, verified email) is inserted pointing at the browser's `users.id`; the user becomes `standard` with `primary_country = country_id`. The ID is unchanged, so every association stays owned. | +| The identity already belongs to standard user S; the unauthenticated user exists | `merged` | Every row of `user_policies`, `user_household_associations`, `user_simulation_associations`, and `user_report_associations` owned by the unauthenticated user moves to S with its ID, contents, and timestamps unchanged; then the unauthenticated user is deleted. | +| No unauthenticated user with that ID | `none` | The identity's user is resolved, or provisioned, as on any request. | +| The ID names a standard user (already converted, or anyone else's) | `none` | That user is never touched; the identity's user is resolved as on any request. | + +The association tables have no uniqueness involving the owner +(`client_ref` and its per-user constraints were removed), so a merge never +conflicts. Association IDs do not change, so a report's `user_policy_id` link +stays valid. `legacy_user_mappings` and `legacy_user_policy_mappings` belong +only to standard users and are untouched. + +Concurrency on Postgres: + +- Concurrent upgrades of one browser run one at a time behind the + `FOR UPDATE` lock. The first converts (or merges); the rest find a standard + user and return `none`. +- A first sign-in racing an upgrade, or two browsers upgrading into a new + account at once, are reconciled by `UNIQUE(issuer, subject)`: the identity + insert that loses merges into the winner's user instead. +- A signed-out write in progress holds its share lock, so the upgrade waits + for it and then moves its record too. A signed-out write that starts during + the upgrade waits and then gets 401 (converted) or starts a new + unauthenticated user (merged). +- Deleting an unauthenticated account matches only `user_type = + 'unauthenticated'`, so a deletion racing a conversion never deletes the + converted standard user. + +## Security notes + +- **The browser UUID is a bearer credential.** Whoever presents it acts as + that browser's unauthenticated user. Keep it out of URLs (it travels only in + the header) and out of logs. The Public API never logs it: unauthenticated + provisioning and deletion log only `user_type`, the upgrade logs only its + outcome, request logs carry the path without the query string and never the + headers, and `UnauthenticatedPrincipal` and `ResolvedUser` keep the ID out + of `repr`. Responses still include `user_id` and `id`, but only to the + credential's owner. +- **An unauthenticated principal never acts on a standard user**, including + the one its own browser converted into (see "User types and credentials"). +- **Anyone can mint unauthenticated users.** Any new UUID creates one on its + first write, without signing in. Only edge throttling bounds how fast one + client can create users and rows: the proposed per-IP write throttle + (`docs/migration/armor/proposed/20261012-pol-api-lb-stage-12a-user-writes.yaml`). + The request and field bounds below limit what each write stores. + +## What else changed + +- **Ownership.** Request bodies and queries never accept `user_id`; the owner + always comes from the credential. Another user's record returns 404. +- **v1 references.** Each association accepts a `legacy_*_id` (a country-scoped + v1 database ID). A batched, read-only SQLAlchemy connector checks it against + the v1 mapped models in a short transaction, closed before the v2 + transaction opens. A missing reference returns 422. + - `user-policies` and `user-households` also accept the v2 `policy_id` or + `household_id`. With only a legacy ID, the v2 reference is filled from + `legacy_policy_mappings` / `legacy_household_mappings` when a mapping + exists. + - Changing the legacy ID re-resolves the v2 reference, or clears it. + - `user-simulations` and `user-reports` accept only legacy IDs in 12A. +- **Stage 10 mirror rows.** A `user_policies` row with a + `legacy_user_policy_mappings` entry returns 409 on PATCH and DELETE. The + mirror projection now also records `legacy_policy_id`. +- **Request bounds.** Every `/v2/me*` and `/v2/user-*` write rejects a body + over 256 KiB with 413. FastAPI reads and parses the whole JSON body before + any dependency runs, so this check does not limit what is read or parsed; + Cloud Run's 32 MiB request limit does. The check is a dependency that runs + after authentication, so an oversized body gets 413 before any + body-validation error is reported and before any service or database work, + which bounds what can be stored. Caller fields are also bounded: + `description` and `source_note` at 10,000 characters, report `provisions` at + 64 KiB serialized, and the report `validation` snapshot at 500 calibration + matches and 200 scorecard programs, with bounded strings (64 to 1,024 + characters). `provenance` was already bounded (enumerated fields and a + 2,048-character `ref`). Every user-route float and JSON value also rejects + NaN and Infinity with 422: Python's JSON parser accepts them but Postgres + JSONB cannot store them. +- **Account editing.** `PATCH /v2/me` returns 403 ("Sign in to edit your + account details.") for an unauthenticated principal. +- **Account deletion.** For a signed-in user, when the Management API is + configured, `DELETE /v2/me` first reads the client secret from Secret + Manager and obtains a Management token. If either fails, it returns 503 with + nothing deleted. It then deletes the user's `legacy_user_mappings` rows + (RESTRICT FK), then the user (identities and associations cascade), then the + Auth0 user. A 401 from Auth0 drops the cached token and retries once with a + fresh one. If the Auth0 call still fails after the data is gone, the route + returns 502 and a retry is safe. The Management settings are read only for + this route. For an unauthenticated user, `DELETE /v2/me` deletes the user and + its associations, never builds the Auth0 client, and returns 204 even when + the user does not exist. +- **Removed before release: browser imports.** The earlier design kept + signed-out work in browser storage and copied it in at sign-in through + `POST /v2/me/imports`, idempotent on a per-user `client_ref`. That route, + its services, validators, types, response models, tests, and contract + entries, the `client_ref` field on every association input and response, + and the `client_ref` columns and their `UNIQUE(user_id, client_ref)` + constraints are all gone. A request to the old import path now gets the + v2 catch-all's 405. + +The wire contract (fields, statuses, nested report types) is the Stage 12A HTTP +contract shared with `policyengine-app-v2`. The generated route inventory is in +`docs/engineering/migration-contracts.md`. + +| Route | Signed in (bearer token) | Signed out (unauthenticated-user header) | +| --- | --- | --- | +| `GET /v2/me?country_id=` | 200; provisions on first call | 200, or 404 before the first saved record | +| `PATCH /v2/me` | Updates `first_name`, `last_name`, `primary_country` | 403 | +| `GET /v2/me/export` | The user and every association, all countries | The same; `user: null` and empty lists before the first saved record | +| `POST /v2/me/upgrade?country_id=` | Requires the header too (422 without it); `{"item": MeItem, "outcome": ...}` | 401 (a bearer token is required) | +| `DELETE /v2/me` | 204; deletes data, then the Auth0 user when configured | 204; deletes data, never calls Auth0 | +| `/v2/user-policies`, `/v2/user-households`, `/v2/user-simulations`, `/v2/user-reports` | POST (201), list, GET, PATCH, DELETE (204), owner-scoped | The same; reads never provision | + +`MeItem` is `{id, user_type, primary_country, first_name, last_name, email, +created_at}`; `user_type` is `standard` or `unauthenticated`, and +`primary_country` is null only for an unauthenticated user. The upgrade +response is +`{"status": "ok", "message": null, "result": {"item": , "outcome": "converted" | "merged" | "none"}}`. + +Status conventions follow the existing v2 routes. Schema, query, and header +validation failures return 422 (`Invalid API v2 request`). Contradictory +references, such as a body and query `country_id` mismatch, return 400. +Unknown v1 references, and a `user_policy_id` that is not the caller's, return +422 with a specific message. Mirror-owned rows return 409. Oversized bodies +return 413. + +## Schema (one autogenerated revision) + +Revision `fbb7488ebe8a` (`link signed-in users to v1-anchored associations`, +parent `91af4fc8d8c1`) was regenerated, under the same revision ID, with +`uv run alembic -c alembic-v2.ini revision --autogenerate --rev-id fbb7488ebe8a` +from the final SQLModel metadata. It has no post-generation corrections. + +- `users`: adds `user_type varchar(16)` NOT NULL with server default + `'standard'`, so every existing row and every insert by an earlier Public + API revision is standard. `primary_country` becomes nullable. Adds + `ck_users_user_type` (`user_type IN ('unauthenticated', 'standard')`) and + `ck_users_standard_primary_country` + (`user_type = 'unauthenticated' OR primary_country IS NOT NULL`); + `ck_users_primary_country` is kept. +- `user_identities` (new): `id`, `user_id` → `users.id` CASCADE, `issuer` and + `subject` text, `email varchar(320)` nullable, `created_at`, `last_seen_at`; + `uq_user_identities_issuer_subject`; `ix_user_identities_user_id`. +- `user_policies`: `policy_id` nullable (composite FK kept, `MATCH SIMPLE`); + adds `legacy_policy_id bigint`, `baseline` (CHECK `current-law` / + `current-policy`), and `provenance jsonb`. Adds + `ck_user_policies_policy_ref` and an index on + `(country_id, legacy_policy_id)`. +- `user_household_associations`: the same pattern with + `legacy_household_id`. +- `user_simulation_associations` and `user_report_associations` (dormant, so + reshaped): the foreign keys to v2 `simulations` and `reports` and the old + `(user_id, simulation_id|report_id)` uniques are dropped. Each gains + `country_id` (NOT NULL, CHECK), `name`, `description`, a legacy ID, a + reference CHECK, `UNIQUE(id, country_id)`, and listing indexes. Reports also + add `user_policy_id` → `user_policies.id` ON DELETE SET NULL (the saved + reform the report was run from), `baseline_user_policy_id` → + `user_policies.id` ON DELETE SET NULL (the saved reform it was compared + with, when not current law; the baseline's name is that reform's name, so no + label is stored), `source_note`, `provisions jsonb` (each provision may carry + optional `growth`, `growth_start`, `growth_base`, `growth_round_to` and + `growth_round` settings), `validation jsonb`, and `budget_window_years smallint` + (the years of the budget window run with the report, from its year; NULL for + none, otherwise 2 to 75 by `ck_user_report_associations_budget_window_years`, + matching the economy route's limit). +- The old `country` and `label` columns on those two tables are retired but + kept: `country` becomes nullable and nothing reads or writes either column. + The previous Public API revision still maps them, so dropping them would + break it during the deploy window or an application rollback (see + "Rollback"). + +The models drop the `user_associations` relationships from v2 simulations and +reports. `V2_ALEMBIC_HEAD_REVISION` is `fbb7488ebe8a`. + +### Reshape safety + +The reshape requires `user_simulation_associations` and +`user_report_associations` to be empty. Adding a NOT NULL `country_id` also +fails naturally when rows exist, following the Stage 11 precedent. The +read-only qualification command reports all four association row counts. It +stops when the revision is pending and a dormant table holds rows: + +```bash +V2_MIGRATION_DATABASE_URL=... V2_SUPABASE_PROJECT_REF=... V2_SUPABASE_ENVIRONMENT=... \ + uv run python -m policyengine_api.data.v2.user_association_migration_qualification +``` + +It uses SQLAlchemy in a Postgres read-only, rolled-back transaction and never +prints the URL. `seed-v2-database.yml` runs it, through +`scripts/qualify_v2_user_association_migration.py`, before every schema +upgrade. Before merging, an operator should run it read-only against staging +and production and confirm both dormant tables are empty. + +### Rollback + +Roll forward whenever possible. The schema lets the previous Public API +revision run (see "Schema compatibility" below), but that revision serves +`/v2/user-policies` and `/v2/user-households` without authentication: any +caller can list a user's saved items by `user_id`, read, rename or delete one +by record ID, and create rows for any user. Rolling the application back +would therefore hand users' saved work to anyone. + +- **Before any user-owned record exists, an application rollback is safe + without a database downgrade.** Signed-out saving needs no configuration, so + treat user-owned records as existing from the moment this revision serves + traffic in an environment. +- **Once any user-owned record exists, signed in or not, an application + rollback must apply the Stage 12A rollback guard first** + (`docs/migration/armor/proposed/20261013-pol-api-lb-stage-12a-rollback-guard.yaml`): + 1. Create the Cloud Armor rule that denies `/v2/me*` (including + `/v2/me/upgrade`) and `/v2/user-*` with 403 at the load balancer. + 2. Set the Cloud Run service's ingress to `internal-and-cloud-load-balancing`, + because `*.run.app` URLs bypass Cloud Armor. + 3. Shift traffic to the previous revision, and check that + `https://api.policyengine.org/v2/user-policies?country_id=us` returns 403. + 4. Remove the guard and restore ingress only after a Stage 12A-or-later + revision serves all traffic again. + + While the guard is in place, saved work is unavailable, signed in or not: + app v3 shows its account error and saves fail. v1 routes and the `/v2` + metadata, policy, and household routes are unaffected. Run.app URLs, which + CI smoke tests use, are closed too. Staging is not behind the load balancer + and holds only test accounts, so it needs no guard. +- **Nothing else in the previous revision exposes Stage 12A data.** Its native + routes were checked one by one: only `/v2/user-policies` and + `/v2/user-households` (all five operations each) read or write user + association rows. `/v2/me*`, `/v2/user-simulations`, and `/v2/user-reports` + do not exist there and fall through to its 404 handler. `/v2/policies` and + `/v2/households` serve shared v2 artifacts that Stage 12A does not change. + Its Flask v1 routes read and write only Cloud SQL. Its Stage 10 saved-policy + mirror reads and writes only legacy transition users (standard users found + through `legacy_user_mappings`); no signed-in or unauthenticated user is + ever one of them, because Stage 12A never adopts a legacy user. +- **Behind the guard, one more previous-revision gap does not matter.** Its + `/v2/user-policies` and `/v2/user-households` responses require a non-null + `policy_id` / `household_id`, so any response that includes a legacy-only + row (a v1 reference with no v2 copy) fails with 500. +- **A database downgrade is lossy.** It restores NOT NULL on `policy_id`, + `household_id`, `simulation_id`, `report_id`, `country`, and + `users.primary_country`, so it fails while legacy-only rows, any simulation + or report associations, or any unauthenticated user exist. Removing them + first deletes users' saved work. + +### Schema compatibility + +The revision is additive (expand-only) for everything the previous Public API +revision maps: it drops no table or column, and it adds no required column +without a server default to any table that revision writes. The only new +required columns are `country_id` on the simulation and report associations, +which that revision maps but never inserts into, and `users.user_type`, whose +server default makes every user the previous revision creates (its Stage 10 +transition users, which always have a primary country) standard, so both new +`users` checks pass. Relaxing `users.primary_country` to nullable affects only +unauthenticated users. The previous revision reads `users` only to confirm a +row exists (in its `/v2/user-policies` and `/v2/user-households` creates, +which the rollback guard denies) and through `legacy_user_mappings`; loading a +row with a NULL `primary_country` does not fail there. + +The lifecycle test `test_stage_12a_schema_serves_the_previous_revision` +exercises this rather than only comparing columns. It reflects the schema at +the parent revision with SQLAlchemy and upgrades to head. Then, through the +reflected parent tables, it does the following for every table the parent +revision maps: + +- inserts a row that supplies only the parent's required columns (plus the + nullable values that the parent's own check constraints demand); +- reads the row back with every parent column; +- rewrites every non-key column; +- deletes the row. + +Rows of the two dormant tables are inserted through head's tables, since only +new code writes them, and are then read, rewritten, and deleted through the +parent's. The test also deletes, through the parent's `users` table, a user who +owns rows in all four association tables and a `user_identities` row, and it +checks that every one of them cascades away. It fails if head drops a parent +column, or adds a required column without a server default to a table the +parent revision writes. + +## Settings + +No setting is new for signed-out saving: the unauthenticated-user header works +whether or not the Auth0 settings below are present. Without them, bearer +requests keep their 401/503 behavior, and signed-out requests still work. + +| Setting | Source | Meaning | +| --- | --- | --- | +| `PUBLIC_API_AUTH_REQUIRED` | GitHub variable (`staging`, `production`) | `1` requires issuer and audience; `0` allows them to be absent, so bearer requests answer 401 or 503 | +| `PUBLIC_API_AUTH_ISSUER` | GitHub variable | Auth0 issuer URL, normalized to a trailing `/` (the custom domain, for example `https://auth.policyengine.org/`) | +| `PUBLIC_API_AUTH_AUDIENCE` | GitHub variable | The Public API resource-server identifier | +| `AUTH0_MANAGEMENT_DOMAIN` | GitHub variable (optional, `production` only) | Canonical tenant domain (the Management API is not served on custom domains) | +| `AUTH0_MANAGEMENT_CLIENT_ID` | GitHub variable (optional, `production` only) | M2M client with `delete:users` | +| `AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE` | GitHub secret (optional, `production` only) | Secret Manager version holding the M2M client secret | + +> **Set the final issuer before the first sign-in.** `user_identities` is +> keyed by `(issuer, subject)`, so `PUBLIC_API_AUTH_ISSUER` must hold its +> final value, the custom domain (for example +> `https://auth.policyengine.org/`) rather than the tenant's `*.auth0.com` +> domain, before anyone signs in to that environment. Changing it later +> makes every existing user look new: their next sign-in provisions a new, +> empty account, and their old account and its saved work are orphaned, +> with no sign-in that can read, export, or delete them. Nothing re-keys +> identities automatically. + +`readiness.validate_policy_runtime_configuration` validates the +`PUBLIC_API_AUTH_*` and `AUTH0_MANAGEMENT_*` settings. Inconsistent values fail +readiness, which is the deploy gate. Request handling reads the +`PUBLIC_API_AUTH_*` settings only for a bearer token, so nothing in them +blocks a signed-out request. `validate_cloud_run_deploy_env.sh` requires +`PUBLIC_API_AUTH_REQUIRED` (`0` or `1`) and checks the issuer, audience, and +all-or-none Management settings, and it rejects Management settings outside +production. `deploy_cloud_run_candidate.sh` writes them to +the revision. The release test step unsets them. + +The staging deploy gate (`tests/integration/test_live_v2_policies.py`) also +needs the following in the GitHub `staging` environment: + +| Setting | Kind | +| --- | --- | +| `STAGING_AUTH0_TEST_DOMAIN` | variable; its tokens' `iss` must equal staging `PUBLIC_API_AUTH_ISSUER` | +| `STAGING_AUTH0_TEST_AUDIENCE` | variable; equals staging `PUBLIC_API_AUTH_AUDIENCE` | +| `STAGING_AUTH0_TEST_CLIENT_ID`, `STAGING_AUTH0_TEST_CLIENT_SECRET` | secrets; staging-only CI application with the password grant | +| `STAGING_AUTH0_TEST_USERNAME`, `STAGING_AUTH0_TEST_PASSWORD` | secrets; dedicated test user | +| `STAGING_AUTH0_TEST_REALM` | optional variable; database connection for the password-realm grant | + +The live test deletes the test user's PolicyEngine account at the start and the +end of each signed-in check. Its signed-out check saves work as fresh random +browser UUIDs, converts one into the test user's account and merges another +into it, and deletes everything it created, so it can run repeatedly. With +Management API deletion configured, deleting the account would also delete +the CI user from Auth0, so the staging deploy job never receives the +`AUTH0_MANAGEMENT_*` settings and the deploy validation rejects them outside +production. + +## Auth0 prerequisites (operator) + +Configure staging first, then production: + +1. **Custom domain** (for example `auth.policyengine.org`). Tokens then carry + that issuer. Finish this before setting `PUBLIC_API_AUTH_ISSUER`, which + must not change once a user has signed in (see "Settings"). +2. **Public API resource server** (RS256), whose identifier becomes + `PUBLIC_API_AUTH_AUDIENCE`. +3. **Social connections and SPA applications** for app v3. +4. **Staging-only CI application** with the password grant, plus a test user. +5. **Optional Management API M2M client** with `delete:users`, production + only. Store its client secret in Secret Manager, put the version's resource + name in the `production` GitHub secret + `AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE`, and grant the Cloud Run runtime + service account (the `GCP_CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT` GitHub secret) + `roles/secretmanager.secretAccessor` on that secret: + + ```bash + gcloud secrets add-iam-policy-binding \ + --project= \ + --member="serviceAccount:" \ + --role="roles/secretmanager.secretAccessor" + ``` + + Without the grant, every signed-in account deletion returns 503 and + deletes nothing. + +## Rollout + +1. Create the GitHub variables and secrets above. Every deploy now requires + `PUBLIC_API_AUTH_REQUIRED`. Staging deploys also need the test-user settings, + because the live test is a deploy gate. +2. Run the qualification command read-only against staging and production. +3. Merge. The release migration applies `fbb7488ebe8a` after the seed + workflow's qualification step passes. +4. Staging: `PUBLIC_API_AUTH_REQUIRED=1` with the staging issuer and audience. + The staging candidate must pass the signed-in and signed-out live tests + before promotion. +5. Production: signed-out saving works as soon as the revision serves + traffic. Set the production Auth0 values once production Auth0, including + its custom domain, is ready; the issuer cannot change after the first + sign-in (see "Settings"). The candidate smoke test checks that `/v2/me` and + `/v2/user-*` requests without a credential, or with a malformed + unauthenticated-user header, return 401. After promotion, also check that + `curl https://api.policyengine.org/v2/user-policies?country_id=us` + returns 401. +6. Apply the proposed Cloud Armor write throttle in preview + (`docs/migration/armor/proposed/20261012-pol-api-lb-stage-12a-user-writes.yaml`) + and enforce it only after the runbook's checks. It is the only bound on how + fast one client can create unauthenticated users. +7. Keep the rollback guard + (`docs/migration/armor/proposed/20261013-pol-api-lb-stage-12a-rollback-guard.yaml`) + ready. From the first user-owned record on, signed in or not, it is a + precondition for any application rollback to a pre-Stage 12A revision (see + "Rollback"). + +User-owned routes must never become less restrictive during a cutover. The +migration-contract guard rejects any `/v2/me` or `/v2/user-` contract that +requires neither `Authorization` nor `PolicyEngine-Unauthenticated-User`, and +the registry records each user-owned request once per credential that can make +it. + +## Verification + +```bash +uv run --frozen --extra dev mypy +uv run pytest tests/unit/v2 tests/unit/libs tests/unit/test_readiness.py \ + tests/unit/test_query_parameters.py tests/unit/test_cloud_run_deploy_scripts.py \ + tests/unit/test_migration_contract_artifacts.py tests/contract -q +V2_ALEMBIC_DISPOSABLE_TEST=1 \ +V2_MIGRATION_DATABASE_URL="postgresql+psycopg://.../policyengine_v2_alembic_test" \ + uv run pytest tests/integration/test_alembic_v2_lifecycle.py \ + tests/integration/test_v2_user_identity_persistence.py -q +python scripts/export_migration_contracts.py +python scripts/run_quality_guards.py +``` + +The disposable-Postgres suite proves the following: + +- concurrent first sign-ins create one user; +- concurrent first writes by one signed-out browser create one + unauthenticated user, with every other user column NULL; +- concurrent upgrades of one browser convert exactly once; +- two browsers upgrading into a new account at once share it: one converts, + the other merges; +- a merge moves all four association kinds with their IDs, keeps report links + valid, and deletes the unauthenticated user; +- a signed-out write waits for a conversion and then gets 401, or, after a + merge, starts a new unauthenticated user; +- a signed-out account deletion racing a conversion never deletes the + converted user; +- named `ON CONFLICT` targets work; +- `MATCH SIMPLE` composite keys accept NULL v2 references; +- account deletion cascades after removing legacy mappings; +- NaN and Infinity never reach JSONB; +- the parent revision's table usage works against head (see "Schema + compatibility"); +- the qualification transaction is read-only. + +## Deferred + +- A later autogenerated revision that drops the retired `country` and `label` + columns from `user_simulation_associations` and `user_report_associations`, + once no deployable Public API revision maps them. +- A retention policy for unauthenticated users whose browsers never return. + Nothing expires them today. +- An explicit claim flow for legacy-app users (`POST /v2/me/legacy-claims`). +- Accepting v2 `simulation_id` / `report_id` once the Simulation Entrypoint owns + those records. diff --git a/migrations/v2/versions/fbb7488ebe8a_link_signed_in_users_to_v1_anchored_.py b/migrations/v2/versions/fbb7488ebe8a_link_signed_in_users_to_v1_anchored_.py new file mode 100644 index 000000000..f3c09fe2f --- /dev/null +++ b/migrations/v2/versions/fbb7488ebe8a_link_signed_in_users_to_v1_anchored_.py @@ -0,0 +1,510 @@ +"""link signed-in users to v1-anchored associations + +Revision ID: fbb7488ebe8a +Revises: 91af4fc8d8c1 +Create Date: 2026-10-15 01:35:47.751988 +Generation: uv run alembic -c alembic-v2.ini revision --autogenerate +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.dialects import postgresql + +revision: str = "fbb7488ebe8a" +down_revision: Union[str, None] = "91af4fc8d8c1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user_identities", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("issuer", sa.Text(), nullable=False), + sa.Column("subject", sa.Text(), nullable=False), + sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=True), + sa.Column( + "last_seen_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("fk_user_identities_user_id_users"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user_identities")), + sa.UniqueConstraint( + "issuer", "subject", name="uq_user_identities_issuer_subject" + ), + ) + op.create_index( + "ix_user_identities_user_id", "user_identities", ["user_id"], unique=False + ) + op.add_column( + "user_household_associations", + sa.Column("legacy_household_id", sa.BigInteger(), nullable=True), + ) + op.alter_column( + "user_household_associations", + "household_id", + existing_type=sa.UUID(), + nullable=True, + ) + op.create_index( + "ix_user_households_country_legacy_household", + "user_household_associations", + ["country_id", "legacy_household_id"], + unique=False, + ) + op.create_check_constraint( + op.f("ck_user_household_associations_household_ref"), + "user_household_associations", + "household_id IS NOT NULL OR legacy_household_id IS NOT NULL", + ) + op.add_column( + "user_policies", sa.Column("legacy_policy_id", sa.BigInteger(), nullable=True) + ) + op.add_column( + "user_policies", + sa.Column( + "baseline", sqlmodel.sql.sqltypes.AutoString(length=16), nullable=True + ), + ) + op.add_column( + "user_policies", + sa.Column( + "provenance", + sa.JSON().with_variant( + postgresql.JSONB(astext_type=sa.Text()), "postgresql" + ), + nullable=True, + ), + ) + op.alter_column( + "user_policies", "policy_id", existing_type=sa.UUID(), nullable=True + ) + op.create_index( + "ix_user_policies_country_legacy_policy", + "user_policies", + ["country_id", "legacy_policy_id"], + unique=False, + ) + op.create_check_constraint( + op.f("ck_user_policies_baseline"), + "user_policies", + "baseline IS NULL OR baseline IN ('current-law', 'current-policy')", + ) + op.create_check_constraint( + op.f("ck_user_policies_policy_ref"), + "user_policies", + "policy_id IS NOT NULL OR legacy_policy_id IS NOT NULL", + ) + op.add_column( + "user_report_associations", + sa.Column("legacy_report_id", sa.BigInteger(), nullable=True), + ) + op.add_column( + "user_report_associations", + sa.Column( + "country_id", sqlmodel.sql.sqltypes.AutoString(length=2), nullable=False + ), + ) + op.add_column( + "user_report_associations", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + ) + op.add_column( + "user_report_associations", sa.Column("description", sa.Text(), nullable=True) + ) + op.add_column( + "user_report_associations", + sa.Column("user_policy_id", sa.Uuid(), nullable=True), + ) + op.add_column( + "user_report_associations", + sa.Column("baseline_user_policy_id", sa.Uuid(), nullable=True), + ) + op.add_column( + "user_report_associations", sa.Column("source_note", sa.Text(), nullable=True) + ) + op.add_column( + "user_report_associations", + sa.Column( + "provisions", + sa.JSON().with_variant( + postgresql.JSONB(astext_type=sa.Text()), "postgresql" + ), + nullable=True, + ), + ) + op.add_column( + "user_report_associations", + sa.Column( + "validation", + sa.JSON().with_variant( + postgresql.JSONB(astext_type=sa.Text()), "postgresql" + ), + nullable=True, + ), + ) + op.add_column( + "user_report_associations", + sa.Column("budget_window_years", sa.SmallInteger(), nullable=True), + ) + op.alter_column( + "user_report_associations", "report_id", existing_type=sa.UUID(), nullable=True + ) + op.alter_column( + "user_report_associations", + "country", + existing_type=sa.VARCHAR(length=16), + nullable=True, + ) + op.drop_constraint( + op.f("uq_user_report_associations_user_report"), + "user_report_associations", + type_="unique", + ) + op.create_index( + op.f("ix_user_report_associations_baseline_user_policy_id"), + "user_report_associations", + ["baseline_user_policy_id"], + unique=False, + ) + op.create_index( + op.f("ix_user_report_associations_user_policy_id"), + "user_report_associations", + ["user_policy_id"], + unique=False, + ) + op.create_index( + "ix_user_reports_country_legacy_report", + "user_report_associations", + ["country_id", "legacy_report_id"], + unique=False, + ) + op.create_index( + "ix_user_reports_country_user_created_id", + "user_report_associations", + ["country_id", "user_id", "created_at", "id"], + unique=False, + ) + op.create_unique_constraint( + "uq_user_report_associations_id_country", + "user_report_associations", + ["id", "country_id"], + ) + op.drop_constraint( + op.f("fk_user_report_associations_report_id_reports"), + "user_report_associations", + type_="foreignkey", + ) + op.create_foreign_key( + op.f("fk_user_report_associations_baseline_user_policy_id_user_policies"), + "user_report_associations", + "user_policies", + ["baseline_user_policy_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_user_report_associations_user_policy_id_user_policies"), + "user_report_associations", + "user_policies", + ["user_policy_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_check_constraint( + op.f("ck_user_report_associations_budget_window_years"), + "user_report_associations", + "budget_window_years IS NULL OR budget_window_years BETWEEN 2 AND 75", + ) + op.create_check_constraint( + op.f("ck_user_report_associations_country"), + "user_report_associations", + "country_id IN ('us', 'uk')", + ) + op.create_check_constraint( + op.f("ck_user_report_associations_report_ref"), + "user_report_associations", + "report_id IS NOT NULL OR legacy_report_id IS NOT NULL", + ) + op.add_column( + "user_simulation_associations", + sa.Column("legacy_simulation_id", sa.BigInteger(), nullable=True), + ) + op.add_column( + "user_simulation_associations", + sa.Column( + "country_id", sqlmodel.sql.sqltypes.AutoString(length=2), nullable=False + ), + ) + op.add_column( + "user_simulation_associations", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + ) + op.add_column( + "user_simulation_associations", + sa.Column("description", sa.Text(), nullable=True), + ) + op.alter_column( + "user_simulation_associations", + "simulation_id", + existing_type=sa.UUID(), + nullable=True, + ) + op.alter_column( + "user_simulation_associations", + "country", + existing_type=sa.VARCHAR(length=16), + nullable=True, + ) + op.drop_constraint( + op.f("uq_user_simulation_associations_user_simulation"), + "user_simulation_associations", + type_="unique", + ) + op.create_index( + "ix_user_simulations_country_legacy_simulation", + "user_simulation_associations", + ["country_id", "legacy_simulation_id"], + unique=False, + ) + op.create_index( + "ix_user_simulations_country_user_created_id", + "user_simulation_associations", + ["country_id", "user_id", "created_at", "id"], + unique=False, + ) + op.create_unique_constraint( + "uq_user_simulation_associations_id_country", + "user_simulation_associations", + ["id", "country_id"], + ) + op.drop_constraint( + op.f("fk_user_simulation_associations_simulation_id_simulations"), + "user_simulation_associations", + type_="foreignkey", + ) + op.create_check_constraint( + op.f("ck_user_simulation_associations_country"), + "user_simulation_associations", + "country_id IN ('us', 'uk')", + ) + op.create_check_constraint( + op.f("ck_user_simulation_associations_simulation_ref"), + "user_simulation_associations", + "simulation_id IS NOT NULL OR legacy_simulation_id IS NOT NULL", + ) + op.add_column( + "users", + sa.Column( + "user_type", + sqlmodel.sql.sqltypes.AutoString(length=16), + server_default="standard", + nullable=False, + ), + ) + op.alter_column( + "users", "primary_country", existing_type=sa.VARCHAR(length=2), nullable=True + ) + op.create_check_constraint( + op.f("ck_users_standard_primary_country"), + "users", + "user_type = 'unauthenticated' OR primary_country IS NOT NULL", + ) + op.create_check_constraint( + op.f("ck_users_user_type"), + "users", + "user_type IN ('unauthenticated', 'standard')", + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f("ck_users_user_type"), "users", type_="check") + op.drop_constraint( + op.f("ck_users_standard_primary_country"), "users", type_="check" + ) + op.alter_column( + "users", "primary_country", existing_type=sa.VARCHAR(length=2), nullable=False + ) + op.drop_column("users", "user_type") + op.drop_constraint( + op.f("ck_user_simulation_associations_simulation_ref"), + "user_simulation_associations", + type_="check", + ) + op.drop_constraint( + op.f("ck_user_simulation_associations_country"), + "user_simulation_associations", + type_="check", + ) + op.create_foreign_key( + op.f("fk_user_simulation_associations_simulation_id_simulations"), + "user_simulation_associations", + "simulations", + ["simulation_id"], + ["id"], + ondelete="CASCADE", + ) + op.drop_constraint( + "uq_user_simulation_associations_id_country", + "user_simulation_associations", + type_="unique", + ) + op.drop_index( + "ix_user_simulations_country_user_created_id", + table_name="user_simulation_associations", + ) + op.drop_index( + "ix_user_simulations_country_legacy_simulation", + table_name="user_simulation_associations", + ) + op.create_unique_constraint( + op.f("uq_user_simulation_associations_user_simulation"), + "user_simulation_associations", + ["user_id", "simulation_id"], + postgresql_nulls_not_distinct=False, + ) + op.alter_column( + "user_simulation_associations", + "country", + existing_type=sa.VARCHAR(length=16), + nullable=False, + ) + op.alter_column( + "user_simulation_associations", + "simulation_id", + existing_type=sa.UUID(), + nullable=False, + ) + op.drop_column("user_simulation_associations", "description") + op.drop_column("user_simulation_associations", "name") + op.drop_column("user_simulation_associations", "country_id") + op.drop_column("user_simulation_associations", "legacy_simulation_id") + op.drop_constraint( + op.f("ck_user_report_associations_report_ref"), + "user_report_associations", + type_="check", + ) + op.drop_constraint( + op.f("ck_user_report_associations_country"), + "user_report_associations", + type_="check", + ) + op.drop_constraint( + op.f("ck_user_report_associations_budget_window_years"), + "user_report_associations", + type_="check", + ) + op.drop_constraint( + op.f("fk_user_report_associations_user_policy_id_user_policies"), + "user_report_associations", + type_="foreignkey", + ) + op.drop_constraint( + op.f("fk_user_report_associations_baseline_user_policy_id_user_policies"), + "user_report_associations", + type_="foreignkey", + ) + op.create_foreign_key( + op.f("fk_user_report_associations_report_id_reports"), + "user_report_associations", + "reports", + ["report_id"], + ["id"], + ondelete="CASCADE", + ) + op.drop_constraint( + "uq_user_report_associations_id_country", + "user_report_associations", + type_="unique", + ) + op.drop_index( + "ix_user_reports_country_user_created_id", table_name="user_report_associations" + ) + op.drop_index( + "ix_user_reports_country_legacy_report", table_name="user_report_associations" + ) + op.drop_index( + op.f("ix_user_report_associations_user_policy_id"), + table_name="user_report_associations", + ) + op.drop_index( + op.f("ix_user_report_associations_baseline_user_policy_id"), + table_name="user_report_associations", + ) + op.create_unique_constraint( + op.f("uq_user_report_associations_user_report"), + "user_report_associations", + ["user_id", "report_id"], + postgresql_nulls_not_distinct=False, + ) + op.alter_column( + "user_report_associations", + "country", + existing_type=sa.VARCHAR(length=16), + nullable=False, + ) + op.alter_column( + "user_report_associations", "report_id", existing_type=sa.UUID(), nullable=False + ) + op.drop_column("user_report_associations", "budget_window_years") + op.drop_column("user_report_associations", "validation") + op.drop_column("user_report_associations", "provisions") + op.drop_column("user_report_associations", "source_note") + op.drop_column("user_report_associations", "baseline_user_policy_id") + op.drop_column("user_report_associations", "user_policy_id") + op.drop_column("user_report_associations", "description") + op.drop_column("user_report_associations", "name") + op.drop_column("user_report_associations", "country_id") + op.drop_column("user_report_associations", "legacy_report_id") + op.drop_constraint( + op.f("ck_user_policies_policy_ref"), "user_policies", type_="check" + ) + op.drop_constraint( + op.f("ck_user_policies_baseline"), "user_policies", type_="check" + ) + op.drop_index("ix_user_policies_country_legacy_policy", table_name="user_policies") + op.alter_column( + "user_policies", "policy_id", existing_type=sa.UUID(), nullable=False + ) + op.drop_column("user_policies", "provenance") + op.drop_column("user_policies", "baseline") + op.drop_column("user_policies", "legacy_policy_id") + op.drop_constraint( + op.f("ck_user_household_associations_household_ref"), + "user_household_associations", + type_="check", + ) + op.drop_index( + "ix_user_households_country_legacy_household", + table_name="user_household_associations", + ) + op.alter_column( + "user_household_associations", + "household_id", + existing_type=sa.UUID(), + nullable=False, + ) + op.drop_column("user_household_associations", "legacy_household_id") + op.drop_index("ix_user_identities_user_id", table_name="user_identities") + op.drop_table("user_identities") + # ### end Alembic commands ### diff --git a/policyengine_api/asgi_factory.py b/policyengine_api/asgi_factory.py index 408cd8aa5..69f752a34 100644 --- a/policyengine_api/asgi_factory.py +++ b/policyengine_api/asgi_factory.py @@ -19,6 +19,9 @@ build_specification_router, ) from policyengine_api.fastapi_routes.v2.errors import ( + V2AuthenticationRequiredError, + V2AuthenticationUnavailableError, + V2PermissionDeniedError, V2RequestTooLargeError, v2_error_response, ) @@ -164,6 +167,31 @@ async def oversized_v2_request( ) -> Response: return v2_error_response(413, str(error)) + @app.exception_handler(V2AuthenticationRequiredError) + async def unauthenticated_v2_request( + _request: Request, + error: V2AuthenticationRequiredError, + ) -> Response: + return v2_error_response( + 401, + str(error), + headers={"WWW-Authenticate": error.challenge}, + ) + + @app.exception_handler(V2PermissionDeniedError) + async def forbidden_v2_request( + _request: Request, + error: V2PermissionDeniedError, + ) -> Response: + return v2_error_response(403, str(error)) + + @app.exception_handler(V2AuthenticationUnavailableError) + async def unavailable_v2_authentication( + _request: Request, + error: V2AuthenticationUnavailableError, + ) -> Response: + return v2_error_response(503, str(error)) + @app.middleware("http") async def add_request_context_and_migration_logging(request, call_next): started_at = time.time() @@ -309,7 +337,11 @@ def finish_native_route( allow_origin_regex=".*", allow_methods=["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"], allow_headers=["*"], - expose_headers=[REQUEST_ID_HEADER, OBSERVABILITY_ID_HEADER], + expose_headers=[ + REQUEST_ID_HEADER, + OBSERVABILITY_ID_HEADER, + "WWW-Authenticate", + ], allow_credentials=False, max_age=600, ) diff --git a/policyengine_api/data/v2/migration_target.py b/policyengine_api/data/v2/migration_target.py index 4bddc292e..391c58910 100644 --- a/policyengine_api/data/v2/migration_target.py +++ b/policyengine_api/data/v2/migration_target.py @@ -21,7 +21,7 @@ ) V2_ALEMBIC_DISPOSABLE_TEST = "V2_ALEMBIC_DISPOSABLE_TEST" -V2_ALEMBIC_HEAD_REVISION = "91af4fc8d8c1" +V2_ALEMBIC_HEAD_REVISION = "fbb7488ebe8a" DISPOSABLE_DATABASE_NAME = "policyengine_v2_alembic_test" DISPOSABLE_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "postgres"}) MIGRATION_ROLE = "policyengine_v2_migrator" diff --git a/policyengine_api/data/v2/models/__init__.py b/policyengine_api/data/v2/models/__init__.py index 1b512f9e2..0fed305cd 100644 --- a/policyengine_api/data/v2/models/__init__.py +++ b/policyengine_api/data/v2/models/__init__.py @@ -30,7 +30,11 @@ TaxBenefitModelVersion, Variable, ) -from policyengine_api.data.v2.models.users import User # noqa: E402 +from policyengine_api.data.v2.models.users import ( # noqa: E402 + User, + UserIdentity, + UserType, +) from policyengine_api.data.v2.models.policies import ( # noqa: E402 Dynamic, Policy, @@ -140,9 +144,11 @@ "TaxBenefitModelVersion", "User", "UserHouseholdAssociation", + "UserIdentity", "UserPolicy", "UserReportAssociation", "UserSimulationAssociation", + "UserType", "V2_METADATA", "Variable", ] diff --git a/policyengine_api/data/v2/models/associations.py b/policyengine_api/data/v2/models/associations.py index 1f0c32de8..4b24bb4b0 100644 --- a/policyengine_api/data/v2/models/associations.py +++ b/policyengine_api/data/v2/models/associations.py @@ -1,7 +1,18 @@ -"""Canonical SQLModel tables linking v2 users to their domain records.""" +"""Canonical SQLModel tables linking v2 users to their saved records. + +Every association names its target through a nullable v2 reference, a +nullable country-scoped v1 (legacy) reference, or both; a database check +requires at least one. + +The simulation and report associations still carry the pre-Stage 12A +``country`` and ``label`` columns. Nothing reads or writes them; they stay +nullable only so the previous Public API revision, whose models still map +them, can load these rows during a deploy or rollback. A later revision drops +them once that revision can no longer be deployed. +""" from datetime import datetime -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional from uuid import UUID import sqlalchemy as sa @@ -10,14 +21,21 @@ from policyengine_api.data.v2.models.base import TimestampedModel from policyengine_api.data.v2.models.households import Household from policyengine_api.data.v2.models.policies import Policy -from policyengine_api.data.v2.models.simulations import Simulation from policyengine_api.data.v2.models.users import User +from policyengine_api.utils.budget_window import BUDGET_WINDOW_MAX_YEARS if TYPE_CHECKING: from policyengine_api.data.v2.models.policy_mappings import ( LegacyUserPolicyMapping, ) - from policyengine_api.data.v2.models.reports import Report + + +USER_POLICY_BASELINES = ("current-law", "current-policy") + + +def _jsonb_type() -> sa.types.TypeEngine[Any]: + # Typed JSON storage is one of the reviewed direct-SQLAlchemy exceptions. + return sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), "postgresql") class UserHouseholdAssociation(TimestampedModel, table=True): @@ -32,6 +50,10 @@ class UserHouseholdAssociation(TimestampedModel, table=True): "country_id IN ('us', 'uk')", name="ck_user_household_associations_country", ), + sa.CheckConstraint( + "household_id IS NOT NULL OR legacy_household_id IS NOT NULL", + name="ck_user_household_associations_household_ref", + ), sa.ForeignKeyConstraint( ["household_id", "country_id"], ["households.id", "households.country_id"], @@ -50,6 +72,11 @@ class UserHouseholdAssociation(TimestampedModel, table=True): "country_id", "household_id", ), + sa.Index( + "ix_user_households_country_legacy_household", + "country_id", + "legacy_household_id", + ), ) user_id: UUID = Field( @@ -57,13 +84,14 @@ class UserHouseholdAssociation(TimestampedModel, table=True): ondelete="CASCADE", index=True, ) - household_id: UUID = Field(index=True) + household_id: UUID | None = Field(default=None, index=True) + legacy_household_id: int | None = Field(default=None, sa_type=sa.BigInteger) country_id: str = Field(max_length=2) name: str | None = Field(default=None, max_length=255) description: str | None = Field(default=None, sa_type=sa.Text) user: User = Relationship(back_populates="household_associations") - household: Household = Relationship(back_populates="user_associations") + household: Household | None = Relationship(back_populates="user_associations") class UserPolicy(TimestampedModel, table=True): @@ -78,6 +106,14 @@ class UserPolicy(TimestampedModel, table=True): "country_id IN ('us', 'uk')", name="ck_user_policies_country", ), + sa.CheckConstraint( + "policy_id IS NOT NULL OR legacy_policy_id IS NOT NULL", + name="ck_user_policies_policy_ref", + ), + sa.CheckConstraint( + "baseline IS NULL OR baseline IN ('current-law', 'current-policy')", + name="ck_user_policies_baseline", + ), sa.ForeignKeyConstraint( ["policy_id", "country_id"], ["policies.id", "policies.country_id"], @@ -96,6 +132,11 @@ class UserPolicy(TimestampedModel, table=True): "country_id", "policy_id", ), + sa.Index( + "ix_user_policies_country_legacy_policy", + "country_id", + "legacy_policy_id", + ), ) user_id: UUID = Field( @@ -103,13 +144,16 @@ class UserPolicy(TimestampedModel, table=True): ondelete="CASCADE", index=True, ) - policy_id: UUID = Field(index=True) + policy_id: UUID | None = Field(default=None, index=True) + legacy_policy_id: int | None = Field(default=None, sa_type=sa.BigInteger) country_id: str = Field(max_length=2) name: str | None = Field(default=None, max_length=255) description: str | None = Field(default=None, sa_type=sa.Text) + baseline: str | None = Field(default=None, max_length=16) + provenance: dict[str, Any] | None = Field(default=None, sa_type=_jsonb_type()) user: User = Relationship(back_populates="policy_associations") - policy: Policy = Relationship(back_populates="user_associations") + policy: Policy | None = Relationship(back_populates="user_associations") legacy_mapping: Optional["LegacyUserPolicyMapping"] = Relationship( back_populates="association", cascade_delete=True, @@ -117,12 +161,38 @@ class UserPolicy(TimestampedModel, table=True): class UserSimulationAssociation(TimestampedModel, table=True): + """A user's saved v1 simulation; ``simulation_id`` is a logical reference. + + Simulations are owned by the Simulation Entrypoint, so the v2 reference + deliberately has no cross-service foreign key. + """ + __tablename__ = "user_simulation_associations" __table_args__ = ( sa.UniqueConstraint( + "id", + "country_id", + name="uq_user_simulation_associations_id_country", + ), + sa.CheckConstraint( + "country_id IN ('us', 'uk')", + name="ck_user_simulation_associations_country", + ), + sa.CheckConstraint( + "simulation_id IS NOT NULL OR legacy_simulation_id IS NOT NULL", + name="ck_user_simulation_associations_simulation_ref", + ), + sa.Index( + "ix_user_simulations_country_user_created_id", + "country_id", "user_id", - "simulation_id", - name="uq_user_simulation_associations_user_simulation", + "created_at", + "id", + ), + sa.Index( + "ix_user_simulations_country_legacy_simulation", + "country_id", + "legacy_simulation_id", ), ) @@ -131,25 +201,57 @@ class UserSimulationAssociation(TimestampedModel, table=True): ondelete="CASCADE", index=True, ) - simulation_id: UUID = Field( - foreign_key="simulations.id", - ondelete="CASCADE", - index=True, - ) - country: str = Field(max_length=16) + simulation_id: UUID | None = Field(default=None, index=True) + legacy_simulation_id: int | None = Field(default=None, sa_type=sa.BigInteger) + country_id: str = Field(max_length=2) + name: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, sa_type=sa.Text) + # Retired; see the module docstring. + country: str | None = Field(default=None, max_length=16) label: str | None = Field(default=None, max_length=255) user: User = Relationship(back_populates="simulation_associations") - simulation: Simulation = Relationship(back_populates="user_associations") class UserReportAssociation(TimestampedModel, table=True): + """A user's saved v1 report; ``report_id`` is a logical reference. + + ``legacy_report_id`` is the requested v1 report output identifier. The + optional ``user_policy_id`` links a v3 report to the same owner's saved + reform and is cleared when that reform is deleted. + """ + __tablename__ = "user_report_associations" __table_args__ = ( sa.UniqueConstraint( + "id", + "country_id", + name="uq_user_report_associations_id_country", + ), + sa.CheckConstraint( + "country_id IN ('us', 'uk')", + name="ck_user_report_associations_country", + ), + sa.CheckConstraint( + "report_id IS NOT NULL OR legacy_report_id IS NOT NULL", + name="ck_user_report_associations_report_ref", + ), + sa.CheckConstraint( + "budget_window_years IS NULL OR budget_window_years BETWEEN 2 AND " + f"{BUDGET_WINDOW_MAX_YEARS}", + name="ck_user_report_associations_budget_window_years", + ), + sa.Index( + "ix_user_reports_country_user_created_id", + "country_id", "user_id", - "report_id", - name="uq_user_report_associations_user_report", + "created_at", + "id", + ), + sa.Index( + "ix_user_reports_country_legacy_report", + "country_id", + "legacy_report_id", ), ) @@ -158,17 +260,36 @@ class UserReportAssociation(TimestampedModel, table=True): ondelete="CASCADE", index=True, ) - report_id: UUID = Field( - foreign_key="reports.id", - ondelete="CASCADE", + report_id: UUID | None = Field(default=None, index=True) + legacy_report_id: int | None = Field(default=None, sa_type=sa.BigInteger) + country_id: str = Field(max_length=2) + name: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, sa_type=sa.Text) + user_policy_id: UUID | None = Field( + default=None, + foreign_key="user_policies.id", + ondelete="SET NULL", index=True, ) - country: str = Field(max_length=16) - label: str | None = Field(default=None, max_length=255) + # The same owner's saved reform the report was compared with, when the + # comparison was not current law; its name is the baseline's label. + baseline_user_policy_id: UUID | None = Field( + default=None, + foreign_key="user_policies.id", + ondelete="SET NULL", + index=True, + ) + source_note: str | None = Field(default=None, sa_type=sa.Text) + provisions: list[Any] | None = Field(default=None, sa_type=_jsonb_type()) + validation: dict[str, Any] | None = Field(default=None, sa_type=_jsonb_type()) + # Years in the budget window run with the report, from the report's year. + budget_window_years: int | None = Field(default=None, sa_type=sa.SmallInteger) last_run_at: datetime | None = Field( default=None, sa_type=sa.DateTime(timezone=True), ) + # Retired; see the module docstring. + country: str | None = Field(default=None, max_length=16) + label: str | None = Field(default=None, max_length=255) user: User = Relationship(back_populates="report_associations") - report: "Report" = Relationship(back_populates="user_associations") diff --git a/policyengine_api/data/v2/models/reports.py b/policyengine_api/data/v2/models/reports.py index 59ac9c4f6..10171fedc 100644 --- a/policyengine_api/data/v2/models/reports.py +++ b/policyengine_api/data/v2/models/reports.py @@ -13,7 +13,6 @@ TimestampedModel, enum_type, ) -from policyengine_api.data.v2.models.associations import UserReportAssociation from policyengine_api.data.v2.models.households import Household from policyengine_api.data.v2.models.metadata import ( Dataset, @@ -133,10 +132,6 @@ class Report(TimestampedModel, table=True): back_populates="report", cascade_delete=True, ) - user_associations: list[UserReportAssociation] = Relationship( - back_populates="report", - cascade_delete=True, - ) class ReportRun(TimestampedModel, table=True): diff --git a/policyengine_api/data/v2/models/simulations.py b/policyengine_api/data/v2/models/simulations.py index d076ea4df..9509b5e96 100644 --- a/policyengine_api/data/v2/models/simulations.py +++ b/policyengine_api/data/v2/models/simulations.py @@ -18,7 +18,6 @@ from policyengine_api.data.v2.models.policies import Dynamic, Policy if TYPE_CHECKING: - from policyengine_api.data.v2.models.associations import UserSimulationAssociation from policyengine_api.data.v2.models.reports import Report @@ -136,7 +135,3 @@ class Simulation(TimestampedModel, table=True): back_populates="reform_simulation", sa_relationship_kwargs={"foreign_keys": "Report.reform_simulation_id"}, ) - user_associations: list["UserSimulationAssociation"] = Relationship( - back_populates="simulation", - cascade_delete=True, - ) diff --git a/policyengine_api/data/v2/models/users.py b/policyengine_api/data/v2/models/users.py index 87db70d6b..14a294f30 100644 --- a/policyengine_api/data/v2/models/users.py +++ b/policyengine_api/data/v2/models/users.py @@ -1,11 +1,20 @@ -"""Canonical SQLModel table for v2 users.""" +"""Canonical SQLModel tables for v2 users and their sign-in identities. +A user is either ``standard`` (backed by one or more verified sign-in +identities, as every Stage 10 legacy-mapping user is) or ``unauthenticated``. +An unauthenticated user's ``id`` is the random UUID a signed-out browser +generates and sends as its credential; every other user column stays NULL. +""" + +from datetime import datetime +from enum import StrEnum from typing import TYPE_CHECKING +from uuid import UUID import sqlalchemy as sa from sqlmodel import Field, Relationship -from policyengine_api.data.v2.models.base import IdentifiedModel +from policyengine_api.data.v2.models.base import IdentifiedModel, utc_now if TYPE_CHECKING: from policyengine_api.data.v2.models.associations import ( @@ -17,6 +26,11 @@ from policyengine_api.data.v2.models.reports import Report +class UserType(StrEnum): + UNAUTHENTICATED = "unauthenticated" + STANDARD = "standard" + + class User(IdentifiedModel, table=True): __tablename__ = "users" __table_args__ = ( @@ -25,14 +39,33 @@ class User(IdentifiedModel, table=True): "primary_country IN ('us', 'uk')", name="ck_users_primary_country", ), + sa.CheckConstraint( + "user_type IN ('unauthenticated', 'standard')", + name="ck_users_user_type", + ), + sa.CheckConstraint( + "user_type = 'unauthenticated' OR primary_country IS NOT NULL", + name="ck_users_standard_primary_country", + ), ) first_name: str | None = Field(default=None, max_length=255) last_name: str | None = Field(default=None, max_length=255) email: str | None = Field(default=None, max_length=320, index=True) - primary_country: str = Field(max_length=2) + primary_country: str | None = Field(default=None, max_length=2) + # The server default keeps inserts by earlier Public API revisions, which + # do not map this column, creating standard users. + user_type: str = Field( + default=UserType.STANDARD.value, + max_length=16, + sa_column_kwargs={"server_default": UserType.STANDARD.value}, + ) reports: list["Report"] = Relationship(back_populates="user") + identities: list["UserIdentity"] = Relationship( + back_populates="user", + cascade_delete=True, + ) household_associations: list["UserHouseholdAssociation"] = Relationship( back_populates="user", cascade_delete=True, @@ -49,3 +82,33 @@ class User(IdentifiedModel, table=True): back_populates="user", cascade_delete=True, ) + + +class UserIdentity(IdentifiedModel, table=True): + """Map one verified external sign-in subject to one v2 user. + + The issuer is stored in its normalized trailing-slash form. The email is + informational only: it is never unique and never used to link accounts. + """ + + __tablename__ = "user_identities" + __table_args__ = ( + sa.UniqueConstraint( + "issuer", + "subject", + name="uq_user_identities_issuer_subject", + ), + sa.Index("ix_user_identities_user_id", "user_id"), + ) + + user_id: UUID = Field(foreign_key="users.id", ondelete="CASCADE") + issuer: str = Field(sa_type=sa.Text) + subject: str = Field(sa_type=sa.Text) + email: str | None = Field(default=None, max_length=320) + last_seen_at: datetime = Field( + default_factory=utc_now, + sa_type=sa.DateTime(timezone=True), + sa_column_kwargs={"server_default": sa.func.now()}, + ) + + user: User = Relationship(back_populates="identities") diff --git a/policyengine_api/data/v2/user_association_migration_qualification.py b/policyengine_api/data/v2/user_association_migration_qualification.py new file mode 100644 index 000000000..b317730d5 --- /dev/null +++ b/policyengine_api/data/v2/user_association_migration_qualification.py @@ -0,0 +1,239 @@ +"""Read-only qualification for the Stage 12A user-association reshape. + +Revision ``fbb7488ebe8a`` reshapes the dormant ``user_simulation_associations`` +and ``user_report_associations`` tables (dropping their v2 foreign keys and +legacy columns and adding a required ``country_id``). Before it is applied, +those tables must be empty. This command reports the row counts of all four +v2 user-association tables through SQLAlchemy in a read-only, rolled-back +transaction and fails when the revision is pending and a dormant table still +holds rows. It never writes, and it never prints the database URL. + +Run against the configured target with ``V2_MIGRATION_DATABASE_URL``, +``V2_SUPABASE_PROJECT_REF``, and ``V2_SUPABASE_ENVIRONMENT`` set:: + + uv run python -m policyengine_api.data.v2.user_association_migration_qualification +""" + +from __future__ import annotations + +from collections.abc import Callable, Collection, Mapping +from dataclasses import dataclass +import json +import sys +from typing import Protocol + +from alembic.config import Config +from alembic.migration import MigrationContext +from alembic.script import ScriptDirectory +from sqlalchemy import Connection, Engine, func, inspect, select +from sqlalchemy.exc import NoInspectionAvailable +from sqlalchemy.pool import NullPool +from sqlmodel import create_engine + +from policyengine_api.data.v2.models import ( + UserHouseholdAssociation, + UserPolicy, + UserReportAssociation, + UserSimulationAssociation, +) +from policyengine_api.data.v2.settings import ( + V2ConfigurationError, + V2DatabaseSettings, + load_v2_migration_database_settings, +) + + +USER_ASSOCIATION_MIGRATION_REVISION = "fbb7488ebe8a" +V2_ALEMBIC_CONFIG = "alembic-v2.ini" + + +class ScalarExecutor(Protocol): + """Minimal database interface required by the row-count queries.""" + + def scalar(self, statement: object) -> object | None: + """Return the first column from the first result row.""" + + +@dataclass(frozen=True) +class UserAssociationCounts: + """Row counts of the four v2 user-association tables.""" + + user_policies: int + user_household_associations: int + user_simulation_associations: int + user_report_associations: int + + @property + def dormant_total(self) -> int: + return self.user_simulation_associations + self.user_report_associations + + def as_dict(self) -> dict[str, int]: + return { + "user_policies": self.user_policies, + "user_household_associations": self.user_household_associations, + "user_simulation_associations": self.user_simulation_associations, + "user_report_associations": self.user_report_associations, + } + + +@dataclass(frozen=True) +class UserAssociationQualification: + """Non-secret evidence for one configured Supabase target.""" + + environment: str + project_ref: str + counts: UserAssociationCounts + migration_pending: bool + + def as_dict(self) -> dict[str, object]: + return { + "outcome": "ok", + "revision": USER_ASSOCIATION_MIGRATION_REVISION, + "migration_pending": self.migration_pending, + "dormant_tables_empty": self.counts.dormant_total == 0, + "environment": self.environment, + "project_ref": self.project_ref, + "counts": self.counts.as_dict(), + } + + +class RetainedDormantAssociationError(RuntimeError): + """Raised when a dormant association table holds rows before the reshape.""" + + def __init__(self, counts: UserAssociationCounts) -> None: + self.counts = counts + super().__init__( + "the configured Supabase target contains user_simulation_associations=" + f"{counts.user_simulation_associations} and user_report_associations=" + f"{counts.user_report_associations} rows; the Stage 12A reshape " + "requires both to be empty. Nothing was modified; obtain a reviewed " + "data-preservation plan before retrying" + ) + + +def read_user_association_counts( + executor: ScalarExecutor, + existing_tables: Collection[str] | None = None, +) -> UserAssociationCounts: + """Count every row in the four user-association tables.""" + + def count(model: type, table_name: str) -> int: + if existing_tables is not None and table_name not in existing_tables: + return 0 + return int(executor.scalar(select(func.count()).select_from(model)) or 0) + + return UserAssociationCounts( + user_policies=count(UserPolicy, "user_policies"), + user_household_associations=count( + UserHouseholdAssociation, "user_household_associations" + ), + user_simulation_associations=count( + UserSimulationAssociation, "user_simulation_associations" + ), + user_report_associations=count( + UserReportAssociation, "user_report_associations" + ), + ) + + +def require_empty_dormant_associations( + counts: UserAssociationCounts, + *, + migration_pending: bool, +) -> None: + if migration_pending and counts.dormant_total: + raise RetainedDormantAssociationError(counts) + + +def user_association_migration_is_pending(connection: Connection) -> bool: + """Return whether the Stage 12A revision is in the upgrade path.""" + + current_heads = MigrationContext.configure(connection).get_current_heads() + revisions = ScriptDirectory.from_config( + Config(V2_ALEMBIC_CONFIG) + ).iterate_revisions("heads", current_heads) + return USER_ASSOCIATION_MIGRATION_REVISION in { + migration.revision for migration in revisions + } + + +def build_qualification_engine(settings: V2DatabaseSettings) -> Engine: + """Build an isolated connection pool for one qualification attempt.""" + + return create_engine(settings.connection.url, poolclass=NullPool) + + +def _existing_tables(connection: Connection) -> list[str] | None: + try: + return inspect(connection).get_table_names(schema="public") + except NoInspectionAvailable: + return None + + +def qualify_user_association_migration( + environ: Mapping[str, str] | None = None, + *, + engine_builder: Callable[[V2DatabaseSettings], Engine] = ( + build_qualification_engine + ), + pending_checker: Callable[[Connection], bool] = ( + user_association_migration_is_pending + ), +) -> UserAssociationQualification: + """Report counts and stop when a pending reshape would affect rows.""" + + settings = load_v2_migration_database_settings(environ) + engine = engine_builder(settings) + try: + with engine.connect() as connection: + # Postgres enforces the read-only session characteristic; it is + # applied before the transaction begins and reset on release. + connection.execution_options(postgresql_readonly=True) + transaction = connection.begin() + try: + migration_pending = pending_checker(connection) + counts = read_user_association_counts( + connection, _existing_tables(connection) + ) + require_empty_dormant_associations( + counts, migration_pending=migration_pending + ) + finally: + transaction.rollback() + finally: + engine.dispose() + return UserAssociationQualification( + environment=settings.target.environment, + project_ref=settings.target.project_ref, + counts=counts, + migration_pending=migration_pending, + ) + + +def _error_payload(error: Exception) -> dict[str, object]: + safe_errors = (V2ConfigurationError, RetainedDormantAssociationError) + message = ( + str(error) + if isinstance(error, safe_errors) + else "v2 user-association migration qualification failed unexpectedly" + ) + return { + "outcome": "error", + "error": {"type": type(error).__name__, "message": message}, + } + + +def main() -> int: + """Run qualification and return a shell-compatible status.""" + + try: + evidence = qualify_user_association_migration() + except Exception as error: # noqa: BLE001 - command must emit safe evidence + print(json.dumps(_error_payload(error), sort_keys=True), file=sys.stderr) + return 1 + print(json.dumps(evidence.as_dict(), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 41a288836..b8026afc0 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -10,7 +10,11 @@ from typing import TYPE_CHECKING, Protocol from uuid import UUID +from fastapi import Depends +from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer + from policyengine_api.json_types import JSONObject +from policyengine_api.libs.user_auth import UNAUTHENTICATED_USER_HEADER if TYPE_CHECKING: from policyengine_api.services.v2.metadata.types import ( @@ -39,18 +43,44 @@ NativeHouseholdCreation, NativeHouseholdCreationInput, ) + from policyengine_api.services.v2.associations.types import ( + LegacyReferenceChecker, + ) + from policyengine_api.services.v2.identity.types import ( + AccountPrincipal, + UserPrincipal, + ) + from policyengine_api.services.v2.user_accounts.types import ( + AccountDeletion, + AccountExport, + AccountRead, + AccountUpdateInput, + AccountUpgrade, + ) from policyengine_api.services.v2.user_policies.types import ( - UserPolicyCreationInput, + UserPolicyCreateFields, UserPolicyPage, UserPolicyRead, UserPolicyUpdateInput, ) from policyengine_api.services.v2.user_households.types import ( - UserHouseholdCreationInput, + UserHouseholdCreateFields, UserHouseholdPage, UserHouseholdRead, UserHouseholdUpdateInput, ) + from policyengine_api.services.v2.user_reports.types import ( + UserReportCreateFields, + UserReportPage, + UserReportRead, + UserReportUpdateInput, + ) + from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationCreateFields, + UserSimulationPage, + UserSimulationRead, + UserSimulationUpdateInput, + ) class MetadataReader(Protocol): @@ -258,16 +288,19 @@ def list_households( class V2UserPolicyResourceService(Protocol): - """Route-independent native association operations for one request.""" + """Route-independent user-policy operations for one request.""" def create_user_policy( self, - association_input: "UserPolicyCreationInput", + *, + principal: "AccountPrincipal", + association_input: "UserPolicyCreateFields", ) -> "UserPolicyRead": ... def get_user_policy( self, *, + principal: "AccountPrincipal", country_id: str, association_id: UUID, ) -> "UserPolicyRead": ... @@ -275,9 +308,10 @@ def get_user_policy( def list_user_policies( self, *, + principal: "AccountPrincipal", country_id: str, - user_id: UUID, policy_id: UUID | None = None, + legacy_policy_id: int | None = None, offset: int = 0, limit: int = 100, ) -> "UserPolicyPage": ... @@ -285,6 +319,7 @@ def list_user_policies( def patch_user_policy( self, *, + principal: "AccountPrincipal", country_id: str, association_id: UUID, association_input: "UserPolicyUpdateInput", @@ -293,22 +328,26 @@ def patch_user_policy( def delete_user_policy( self, *, + principal: "AccountPrincipal", country_id: str, association_id: UUID, ) -> None: ... class V2UserHouseholdResourceService(Protocol): - """Route-independent native user-household operations for one request.""" + """Route-independent user-household operations for one request.""" def create_user_household( self, - association_input: "UserHouseholdCreationInput", + *, + principal: "AccountPrincipal", + association_input: "UserHouseholdCreateFields", ) -> "UserHouseholdRead": ... def get_user_household( self, *, + principal: "AccountPrincipal", country_id: str, association_id: UUID, ) -> "UserHouseholdRead": ... @@ -316,9 +355,10 @@ def get_user_household( def list_user_households( self, *, + principal: "AccountPrincipal", country_id: str, - user_id: UUID, household_id: UUID | None = None, + legacy_household_id: int | None = None, offset: int = 0, limit: int = 100, ) -> "UserHouseholdPage": ... @@ -326,6 +366,7 @@ def list_user_households( def patch_user_household( self, *, + principal: "AccountPrincipal", country_id: str, association_id: UUID, association_input: "UserHouseholdUpdateInput", @@ -334,11 +375,134 @@ def patch_user_household( def delete_user_household( self, *, + principal: "AccountPrincipal", country_id: str, association_id: UUID, ) -> None: ... +class V2UserSimulationResourceService(Protocol): + """Route-independent user-simulation operations for one request.""" + + def create_user_simulation( + self, + *, + principal: "AccountPrincipal", + association_input: "UserSimulationCreateFields", + ) -> "UserSimulationRead": ... + + def get_user_simulation( + self, + *, + principal: "AccountPrincipal", + country_id: str, + association_id: UUID, + ) -> "UserSimulationRead": ... + + def list_user_simulations( + self, + *, + principal: "AccountPrincipal", + country_id: str, + legacy_simulation_id: int | None = None, + offset: int = 0, + limit: int = 100, + ) -> "UserSimulationPage": ... + + def patch_user_simulation( + self, + *, + principal: "AccountPrincipal", + country_id: str, + association_id: UUID, + association_input: "UserSimulationUpdateInput", + ) -> "UserSimulationRead": ... + + def delete_user_simulation( + self, + *, + principal: "AccountPrincipal", + country_id: str, + association_id: UUID, + ) -> None: ... + + +class V2UserReportResourceService(Protocol): + """Route-independent user-report operations for one request.""" + + def create_user_report( + self, + *, + principal: "AccountPrincipal", + association_input: "UserReportCreateFields", + ) -> "UserReportRead": ... + + def get_user_report( + self, + *, + principal: "AccountPrincipal", + country_id: str, + association_id: UUID, + ) -> "UserReportRead": ... + + def list_user_reports( + self, + *, + principal: "AccountPrincipal", + country_id: str, + legacy_report_id: int | None = None, + offset: int = 0, + limit: int = 100, + ) -> "UserReportPage": ... + + def patch_user_report( + self, + *, + principal: "AccountPrincipal", + country_id: str, + association_id: UUID, + association_input: "UserReportUpdateInput", + ) -> "UserReportRead": ... + + def delete_user_report( + self, + *, + principal: "AccountPrincipal", + country_id: str, + association_id: UUID, + ) -> None: ... + + +class V2AccountResourceService(Protocol): + """Route-independent account operations for one request.""" + + def get_account( + self, *, principal: "AccountPrincipal", country_id: str + ) -> "AccountRead": ... + + def update_account( + self, + *, + principal: "AccountPrincipal", + country_id: str, + account_input: "AccountUpdateInput", + ) -> "AccountRead": ... + + def export_account( + self, *, principal: "AccountPrincipal", country_id: str + ) -> "AccountExport": ... + + def upgrade_account( + self, + *, + principal: "UserPrincipal", + unauthenticated_user_id: UUID, + country_id: str, + ) -> "AccountUpgrade": ... + + def delete_account(self, *, principal: "AccountPrincipal") -> "AccountDeletion": ... + + class SimulationGatewayProbe(Protocol): """Minimal simulation-entrypoint health-check interface.""" @@ -412,6 +576,63 @@ def _default_v2_household_service_factory() -> V2HouseholdResourceService: ) +_user_bearer_scheme = HTTPBearer( + auto_error=False, + scheme_name="UserAccessToken", + description="Auth0 user access token issued for the Public API audience.", +) +_unauthenticated_user_scheme = APIKeyHeader( + name=UNAUTHENTICATED_USER_HEADER, + auto_error=False, + scheme_name="UnauthenticatedUser", + description=( + "Canonical UUID that a signed-out browser generates once and keeps. It " + "is a bearer credential for that browser's unauthenticated user; " + "ignored when a bearer access token is present." + ), +) + + +def _default_require_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_user_bearer_scheme), + unauthenticated_user: str | None = Depends(_unauthenticated_user_scheme), +) -> "AccountPrincipal": + """Authenticate the request's account; fails closed without a credential.""" + + from policyengine_api.fastapi_routes.v2.user_auth import ( + authenticate_account_request, + ) + + return authenticate_account_request( + None if credentials is None else f"Bearer {credentials.credentials}", + unauthenticated_user, + ) + + +def _build_v1_legacy_reference_service() -> "LegacyReferenceChecker": + from policyengine_api.data.orm import get_v1_session_factory + from policyengine_api.services.v2.legacy_refs.database_session import ( + LegacyReferenceDatabaseSession, + ) + from policyengine_api.services.v2.legacy_refs.services import ( + V1LegacyReferenceService, + ) + + return V1LegacyReferenceService( + LegacyReferenceDatabaseSession(get_v1_session_factory()) + ) + + +def _default_legacy_reference_checker() -> "LegacyReferenceChecker": + """Defer the v1 session factory until a request checks a v1 reference.""" + + from policyengine_api.services.v2.legacy_refs.services import ( + DeferredLegacyReferenceService, + ) + + return DeferredLegacyReferenceService(_build_v1_legacy_reference_service) + + def _default_v2_user_policy_service_factory() -> V2UserPolicyResourceService: from policyengine_api.data.v2.database import get_v2_session_factory from policyengine_api.services.v2.user_policies.database_session import ( @@ -421,7 +642,10 @@ def _default_v2_user_policy_service_factory() -> V2UserPolicyResourceService: V2UserPolicyService, ) - return V2UserPolicyService(UserPolicyDatabaseSession(get_v2_session_factory())) + return V2UserPolicyService( + UserPolicyDatabaseSession(get_v2_session_factory()), + _default_legacy_reference_checker(), + ) def _default_v2_user_household_service_factory() -> V2UserHouseholdResourceService: @@ -434,7 +658,54 @@ def _default_v2_user_household_service_factory() -> V2UserHouseholdResourceServi ) return V2UserHouseholdService( - UserHouseholdDatabaseSession(get_v2_session_factory()) + UserHouseholdDatabaseSession(get_v2_session_factory()), + _default_legacy_reference_checker(), + ) + + +def _default_v2_user_simulation_service_factory() -> V2UserSimulationResourceService: + from policyengine_api.data.v2.database import get_v2_session_factory + from policyengine_api.services.v2.user_simulations.database_session import ( + UserSimulationDatabaseSession, + ) + from policyengine_api.services.v2.user_simulations.services import ( + V2UserSimulationService, + ) + + return V2UserSimulationService( + UserSimulationDatabaseSession(get_v2_session_factory()), + _default_legacy_reference_checker(), + ) + + +def _default_v2_user_report_service_factory() -> V2UserReportResourceService: + from policyengine_api.data.v2.database import get_v2_session_factory + from policyengine_api.services.v2.user_reports.database_session import ( + UserReportDatabaseSession, + ) + from policyengine_api.services.v2.user_reports.services import ( + V2UserReportService, + ) + + return V2UserReportService( + UserReportDatabaseSession(get_v2_session_factory()), + _default_legacy_reference_checker(), + ) + + +def _default_v2_account_service_factory() -> V2AccountResourceService: + from policyengine_api.data.v2.database import get_v2_session_factory + from policyengine_api.libs.auth0_management import build_auth0_user_deleter + from policyengine_api.services.v2.user_accounts.database_session import ( + AccountDatabaseSession, + ) + from policyengine_api.services.v2.user_accounts.services import ( + V2AccountService, + ) + + return V2AccountService( + AccountDatabaseSession(get_v2_session_factory()), + auth0_user_deleter_factory=build_auth0_user_deleter, ) @@ -455,6 +726,18 @@ class NativeRouteDependencies: v2_user_household_service_factory: ( Callable[[], V2UserHouseholdResourceService] | None ) = None + v2_user_simulation_service_factory: ( + Callable[[], V2UserSimulationResourceService] | None + ) = None + v2_user_report_service_factory: Callable[[], V2UserReportResourceService] | None = ( + None + ) + v2_account_service_factory: Callable[[], V2AccountResourceService] | None = None + # User-owned routes attach this at router level. The default validates the + # Auth0 bearer token, or else accepts the unauthenticated-user header, and + # fails closed (401/403/503); tests replace it with a callable returning a + # fixed principal. + require_user: Callable[..., "AccountPrincipal"] = _default_require_user @classmethod def defaults(cls) -> "NativeRouteDependencies": @@ -471,4 +754,10 @@ def defaults(cls) -> "NativeRouteDependencies": v2_user_household_service_factory=( _default_v2_user_household_service_factory ), + v2_user_simulation_service_factory=( + _default_v2_user_simulation_service_factory + ), + v2_user_report_service_factory=_default_v2_user_report_service_factory, + v2_account_service_factory=_default_v2_account_service_factory, + require_user=_default_require_user, ) diff --git a/policyengine_api/fastapi_routes/v2/errors.py b/policyengine_api/fastapi_routes/v2/errors.py index 4a357c961..e96059514 100644 --- a/policyengine_api/fastapi_routes/v2/errors.py +++ b/policyengine_api/fastapi_routes/v2/errors.py @@ -1,15 +1,38 @@ """Shared error response contract for all native API v2 routes.""" +from collections.abc import Mapping from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, StringConstraints from starlette.responses import JSONResponse +BEARER_CHALLENGE = "Bearer" +INVALID_TOKEN_BEARER_CHALLENGE = 'Bearer error="invalid_token"' + + class V2RequestTooLargeError(ValueError): """Raised when an API v2 request exceeds its documented byte limit.""" +class V2AuthenticationRequiredError(Exception): + """Raised when a user route has no valid bearer access token (401).""" + + def __init__(self, message: str, *, invalid_token: bool = False) -> None: + super().__init__(message) + self.challenge = ( + INVALID_TOKEN_BEARER_CHALLENGE if invalid_token else BEARER_CHALLENGE + ) + + +class V2PermissionDeniedError(Exception): + """Raised when an authenticated principal may not use a route (403).""" + + +class V2AuthenticationUnavailableError(Exception): + """Raised when this deployment cannot authenticate users (503).""" + + class V2ErrorResponse(BaseModel): """Strict error response shared by every API v2 resource.""" @@ -19,11 +42,17 @@ class V2ErrorResponse(BaseModel): message: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] -def v2_error_response(status_code: int, message: str) -> JSONResponse: +def v2_error_response( + status_code: int, + message: str, + *, + headers: Mapping[str, str] | None = None, +) -> JSONResponse: """Serialize one API v2 error without resource-path dispatch.""" error = V2ErrorResponse(message=message) return JSONResponse( status_code=status_code, content=error.model_dump(mode="json"), + headers=dict(headers) if headers is not None else None, ) diff --git a/policyengine_api/fastapi_routes/v2/me/__init__.py b/policyengine_api/fastapi_routes/v2/me/__init__.py new file mode 100644 index 000000000..3b9e873ae --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/me/__init__.py @@ -0,0 +1 @@ +"""FastAPI adapters for the current user's account resource (/v2/me).""" diff --git a/policyengine_api/fastapi_routes/v2/me/request_models.py b/policyengine_api/fastapi_routes/v2/me/request_models.py new file mode 100644 index 000000000..b0f9bf15d --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/me/request_models.py @@ -0,0 +1,7 @@ +"""Strict HTTP request models for the account resource.""" + +from policyengine_api.services.v2.user_accounts.types import AccountUpdateInput + + +class AccountPatchRequest(AccountUpdateInput): + """Explicitly supplied account presentation fields.""" diff --git a/policyengine_api/fastapi_routes/v2/me/response_models.py b/policyengine_api/fastapi_routes/v2/me/response_models.py new file mode 100644 index 000000000..59920c256 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/me/response_models.py @@ -0,0 +1,107 @@ +"""Strict HTTP response models for the account resource.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Generic, Literal, TypeVar +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from policyengine_api.fastapi_routes.v2.user_households.response_models import ( + UserHouseholdItem, +) +from policyengine_api.fastapi_routes.v2.user_policies.response_models import ( + UserPolicyItem, +) +from policyengine_api.fastapi_routes.v2.user_reports.response_models import ( + UserReportItem, +) +from policyengine_api.fastapi_routes.v2.user_simulations.response_models import ( + UserSimulationItem, +) +from policyengine_api.query_parameters import CountryId +from policyengine_api.services.v2.user_accounts.types import ( + AccountExport, + AccountRead, + AccountUpgrade, +) + + +class StrictAccountAPIModel(BaseModel): + model_config = ConfigDict(extra="forbid", from_attributes=True) + + +class MeItem(StrictAccountAPIModel): + id: UUID + user_type: Literal["unauthenticated", "standard"] + # Null only for an unauthenticated user. + primary_country: CountryId | None + first_name: str | None + last_name: str | None + email: str | None + created_at: datetime + + @classmethod + def from_read(cls, item: AccountRead) -> "MeItem": + return cls.model_validate(item) + + +class MeDetailResult(StrictAccountAPIModel): + item: MeItem + + +class MeExportResult(StrictAccountAPIModel): + # Null when an unauthenticated user has not saved anything yet. + user: MeItem | None + policies: list[UserPolicyItem] + households: list[UserHouseholdItem] + simulations: list[UserSimulationItem] + reports: list[UserReportItem] + + @classmethod + def from_export(cls, export: AccountExport) -> "MeExportResult": + return cls( + user=None if export.user is None else MeItem.from_read(export.user), + policies=[UserPolicyItem.from_read(item) for item in export.policies], + households=[ + UserHouseholdItem.from_read(item) for item in export.households + ], + simulations=[ + UserSimulationItem.from_read(item) for item in export.simulations + ], + reports=[UserReportItem.from_read(item) for item in export.reports], + ) + + +class MeUpgradeResult(StrictAccountAPIModel): + item: MeItem + outcome: Literal["converted", "merged", "none"] + + @classmethod + def from_upgrade(cls, upgrade: AccountUpgrade) -> "MeUpgradeResult": + return cls( + item=MeItem.from_read(upgrade.account), + outcome=upgrade.outcome.value, + ) + + +ResultT = TypeVar("ResultT") + + +class AccountSuccessResponse(StrictAccountAPIModel, Generic[ResultT]): + status: Literal["ok"] = "ok" + message: None = None + result: ResultT + + +class MeDetailResponse(AccountSuccessResponse[MeDetailResult]): + pass + + +class MeExportResponse(AccountSuccessResponse[MeExportResult]): + pass + + +class MeUpgradeResponse(AccountSuccessResponse[MeUpgradeResult]): + pass diff --git a/policyengine_api/fastapi_routes/v2/me/routes.py b/policyengine_api/fastapi_routes/v2/me/routes.py new file mode 100644 index 000000000..f8814db0e --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/me/routes.py @@ -0,0 +1,228 @@ +"""Native FastAPI routes for the current user's account resource (/v2/me). + +A signed-in caller's standard user is provisioned on first use, with +``primary_country`` taken from ``country_id``. A signed-out browser's +unauthenticated user exists only after its first saved record, so reading +an absent one returns 404 and exporting it returns nothing. Every route +requires a credential; authentication is a router-level dependency. +""" + +from __future__ import annotations + +from collections.abc import Callable +from uuid import UUID + +from fastapi import APIRouter, Depends, Header +from starlette.responses import JSONResponse, Response + +from policyengine_api.fastapi_routes.dependencies import ( + NativeRouteDependencies, + V2AccountResourceService, +) +from policyengine_api.fastapi_routes.query_parameters import query_dependency +from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse +from policyengine_api.fastapi_routes.v2.me.request_models import ( + AccountPatchRequest, +) +from policyengine_api.fastapi_routes.v2.me.response_models import ( + MeDetailResponse, + MeDetailResult, + MeExportResponse, + MeExportResult, + MeItem, + MeUpgradeResponse, + MeUpgradeResult, +) +from policyengine_api.fastapi_routes.v2.user_auth import require_signed_in_principal +from policyengine_api.fastapi_routes.v2.user_resources import ( + USER_RESOURCE_ERROR_RESPONSES, + enforce_user_write_request_size, + user_resource_operation, +) +from policyengine_api.libs.user_auth import ( + CANONICAL_UUID_PATTERN, + UNAUTHENTICATED_USER_HEADER, +) +from policyengine_api.query_parameters import CountryQuery +from policyengine_api.services.v2.identity.types import ( + AccountPrincipal, + UserPrincipal, +) + + +def _service_factory( + dependencies: NativeRouteDependencies, +) -> Callable[[], V2AccountResourceService]: + if dependencies.v2_account_service_factory is not None: + return dependencies.v2_account_service_factory + from policyengine_api.fastapi_routes.dependencies import ( + _default_v2_account_service_factory, + ) + + return _default_v2_account_service_factory + + +def build_v2_me_router(dependencies: NativeRouteDependencies) -> APIRouter: + """Build account routes without opening a connection.""" + + require_user = dependencies.require_user + router = APIRouter( + prefix="/v2", + responses=USER_RESOURCE_ERROR_RESPONSES, + dependencies=[Depends(require_user)], + ) + country_query = query_dependency(CountryQuery) + service_factory = _service_factory(dependencies) + + def require_signed_in_user( + principal: AccountPrincipal = Depends(require_user), + ) -> UserPrincipal: + return require_signed_in_principal(principal) + + @router.get( + "/me", + response_model=MeDetailResponse, + summary="Read the current user", + description=( + "For a bearer access token, returns the PolicyEngine user, creating " + "it on the first call with primary_country set from country_id. For " + "the PolicyEngine-Unauthenticated-User header, returns the " + "browser's unauthenticated user, or 404 before its first saved " + "record; reading never creates one." + ), + ) + def get_me( + query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), + ) -> MeDetailResponse | JSONResponse: + def read() -> MeDetailResponse: + item = service_factory().get_account( + principal=principal, + country_id=query.country_id, + ) + return MeDetailResponse(result=MeDetailResult(item=MeItem.from_read(item))) + + return user_resource_operation(read) + + @router.patch( + "/me", + response_model=MeDetailResponse, + summary="Update the signed-in user's names or primary country", + description="Unauthenticated users cannot edit account details (403).", + ) + def patch_me( + body: AccountPatchRequest, + query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), + _size: None = Depends(enforce_user_write_request_size), + ) -> MeDetailResponse | JSONResponse: + def patch() -> MeDetailResponse: + item = service_factory().update_account( + principal=principal, + country_id=query.country_id, + account_input=body, + ) + return MeDetailResponse(result=MeDetailResult(item=MeItem.from_read(item))) + + return user_resource_operation(patch) + + @router.delete( + "/me", + response_model=None, + status_code=204, + response_class=Response, + responses={ + 502: { + "model": V2ErrorResponse, + "description": ( + "Account data was deleted but the Auth0 user could not be " + "removed; retrying is safe." + ), + }, + 503: { + "model": V2ErrorResponse, + "description": ( + "Auth0 Management credentials could not be obtained, or " + "persistence is unavailable; nothing was deleted." + ), + }, + }, + summary="Delete the current user and all of their saved records", + description=( + "For a signed-in user, obtains Auth0 Management credentials first " + "when they are configured, then deletes the user's legacy user " + "mappings, the user (identities and associations cascade), and the " + "Auth0 user. For an unauthenticated user, deletes the user and its " + "associations and never calls Auth0; 204 even when it does not " + "exist." + ), + ) + def delete_me( + query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), + ) -> Response | JSONResponse: + def delete() -> Response: + service_factory().delete_account(principal=principal) + return Response(status_code=204) + + return user_resource_operation(delete) + + @router.get( + "/me/export", + response_model=MeExportResponse, + summary="Export the current user and every saved record", + description=( + "Covers every country, not only country_id. user is null, and every " + "list empty, for an unauthenticated user with no saved records." + ), + ) + def export_me( + query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), + ) -> MeExportResponse | JSONResponse: + def export() -> MeExportResponse: + result = service_factory().export_account( + principal=principal, + country_id=query.country_id, + ) + return MeExportResponse(result=MeExportResult.from_export(result)) + + return user_resource_operation(export) + + @router.post( + "/me/upgrade", + response_model=MeUpgradeResponse, + summary="Move this browser's unauthenticated user into the signed-in account", + description=( + "Requires both a bearer access token and the " + "PolicyEngine-Unauthenticated-User header. When the signed-in " + "identity has no account yet, the unauthenticated user becomes it " + "in place, keeping its ID and records (converted). When the " + "identity already has an account, every record moves to that " + "account with its ID unchanged and the unauthenticated user is " + "deleted (merged). When the header names no unauthenticated user, " + "including a signed-in account's ID, nothing moves (none). The " + "response is the signed-in account." + ), + ) + def upgrade_me( + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = Depends(require_signed_in_user), + unauthenticated_user: str = Header( + alias=UNAUTHENTICATED_USER_HEADER, + pattern=CANONICAL_UUID_PATTERN, + description="The browser's unauthenticated user ID (a canonical UUID).", + ), + _size: None = Depends(enforce_user_write_request_size), + ) -> MeUpgradeResponse | JSONResponse: + def upgrade() -> MeUpgradeResponse: + result = service_factory().upgrade_account( + principal=principal, + unauthenticated_user_id=UUID(unauthenticated_user), + country_id=query.country_id, + ) + return MeUpgradeResponse(result=MeUpgradeResult.from_upgrade(result)) + + return user_resource_operation(upgrade) + + return router diff --git a/policyengine_api/fastapi_routes/v2/routes.py b/policyengine_api/fastapi_routes/v2/routes.py index fc2624a2d..627096c1f 100644 --- a/policyengine_api/fastapi_routes/v2/routes.py +++ b/policyengine_api/fastapi_routes/v2/routes.py @@ -19,6 +19,7 @@ from policyengine_api.fastapi_routes.v2.households.routes import ( build_v2_household_router, ) +from policyengine_api.fastapi_routes.v2.me.routes import build_v2_me_router from policyengine_api.fastapi_routes.v2.policies.routes import build_v2_policy_router from policyengine_api.fastapi_routes.v2.user_policies.routes import ( build_v2_user_policy_router, @@ -26,6 +27,12 @@ from policyengine_api.fastapi_routes.v2.user_households.routes import ( build_v2_user_household_router, ) +from policyengine_api.fastapi_routes.v2.user_reports.routes import ( + build_v2_user_report_router, +) +from policyengine_api.fastapi_routes.v2.user_simulations.routes import ( + build_v2_user_simulation_router, +) def build_v2_router( @@ -34,6 +41,9 @@ def build_v2_router( """Build isolated resource routes without loading v2 configuration.""" router = APIRouter() + router.include_router(build_v2_me_router(dependencies)) + router.include_router(build_v2_user_report_router(dependencies)) + router.include_router(build_v2_user_simulation_router(dependencies)) router.include_router(build_v2_user_household_router(dependencies)) router.include_router(build_v2_user_policy_router(dependencies)) router.include_router(build_v2_household_router(dependencies)) diff --git a/policyengine_api/fastapi_routes/v2/user_auth.py b/policyengine_api/fastapi_routes/v2/user_auth.py new file mode 100644 index 000000000..9a27f35d4 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_auth.py @@ -0,0 +1,109 @@ +"""HTTP adapter from request credentials to account principals. + +User-owned routes attach ``NativeRouteDependencies.require_user`` at router +level, so authentication runs before query and body validation. A request +authenticates as: + +- the signed-in user of an ``Authorization: Bearer`` access token, which + takes precedence over every other credential; +- otherwise, the unauthenticated user named by a canonical UUID in the + ``PolicyEngine-Unauthenticated-User`` header, which needs no Auth0 + configuration. + +Failures map to the shared API v2 error envelope: + +- no credential, an invalid token, or a malformed unauthenticated-user + header: 401 with ``WWW-Authenticate: Bearer``; +- a machine (``…@clients``) principal: 403; +- a bearer token while authentication is not configured, or while signing + keys are unreachable: 503. +""" + +from __future__ import annotations + +from policyengine_api.fastapi_routes.v2.errors import ( + V2AuthenticationRequiredError, + V2AuthenticationUnavailableError, + V2PermissionDeniedError, +) +from policyengine_api.libs.user_auth import ( + InvalidBearerTokenError, + InvalidUnauthenticatedUserError, + MissingBearerTokenError, + PrincipalKind, + UNAUTHENTICATED_USER_HEADER, + UserAuthConfigurationError, + UserAuthUnavailableError, + authenticate_bearer, + load_user_auth_settings, + unauthenticated_user_id, +) +from policyengine_api.services.v2.identity.types import ( + AccountPrincipal, + UnauthenticatedPrincipal, + UserPrincipal, +) + + +def authenticate_user_request(authorization: str | None) -> UserPrincipal: + """Return the signed-in user principal or raise a typed HTTP failure.""" + + try: + settings = load_user_auth_settings() + except UserAuthConfigurationError as error: + raise V2AuthenticationUnavailableError( + "User authentication is unavailable" + ) from error + try: + principal = authenticate_bearer(authorization, settings) + except MissingBearerTokenError as error: + raise V2AuthenticationRequiredError("Authentication is required") from error + except InvalidBearerTokenError as error: + raise V2AuthenticationRequiredError( + "The bearer access token is invalid", + invalid_token=True, + ) from error + except UserAuthUnavailableError as error: + raise V2AuthenticationUnavailableError( + "User authentication is unavailable" + ) from error + if principal.kind is PrincipalKind.CLIENT: + raise V2PermissionDeniedError( + "This resource requires a signed-in user; machine clients are not permitted" + ) + return UserPrincipal( + issuer=principal.issuer, + subject=principal.subject, + email=principal.email, + ) + + +def authenticate_account_request( + authorization: str | None, + unauthenticated_user: str | None, +) -> AccountPrincipal: + """Return the request's account principal or raise a typed HTTP failure. + + A bearer token is validated as for any signed-in request and the + unauthenticated-user header is then ignored. Without one, the header + selects the browser's unauthenticated user. + """ + + if authorization is None and unauthenticated_user is not None: + try: + return UnauthenticatedPrincipal( + user_id=unauthenticated_user_id(unauthenticated_user) + ) + except InvalidUnauthenticatedUserError as error: + raise V2AuthenticationRequiredError( + f"The {UNAUTHENTICATED_USER_HEADER} header must be a canonical UUID" + ) from error + return authenticate_user_request(authorization) + + +def require_signed_in_principal(principal: AccountPrincipal) -> UserPrincipal: + """Refuse an unauthenticated principal where only a signed-in user may act.""" + + if isinstance(principal, UserPrincipal): + return principal + raise V2AuthenticationRequiredError("Sign in with a bearer access token") diff --git a/policyengine_api/fastapi_routes/v2/user_households/request_models.py b/policyengine_api/fastapi_routes/v2/user_households/request_models.py index b5e21475a..e95df4eb5 100644 --- a/policyengine_api/fastapi_routes/v2/user_households/request_models.py +++ b/policyengine_api/fastapi_routes/v2/user_households/request_models.py @@ -1,13 +1,13 @@ -"""Strict HTTP request models for native v2 user-household associations.""" +"""Strict HTTP request models for v2 user-household associations.""" from policyengine_api.services.v2.user_households.types import ( - UserHouseholdCreationInput, + UserHouseholdCreateFields, UserHouseholdUpdateInput, ) -class UserHouseholdCreateRequest(UserHouseholdCreationInput): - """Association identity, immutable link fields, and presentation fields.""" +class UserHouseholdCreateRequest(UserHouseholdCreateFields): + """Association fields; the owner is taken from the request credential.""" class UserHouseholdPatchRequest(UserHouseholdUpdateInput): diff --git a/policyengine_api/fastapi_routes/v2/user_households/response_models.py b/policyengine_api/fastapi_routes/v2/user_households/response_models.py index d6ba2fd01..04d761658 100644 --- a/policyengine_api/fastapi_routes/v2/user_households/response_models.py +++ b/policyengine_api/fastapi_routes/v2/user_households/response_models.py @@ -1,4 +1,4 @@ -"""Strict HTTP response models for native v2 user-household associations.""" +"""Strict HTTP response models for v2 user-household associations.""" from __future__ import annotations @@ -8,8 +8,10 @@ from pydantic import BaseModel, ConfigDict -from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse -from policyengine_api.query_parameters import CountryId, ResourceId, UserId +from policyengine_api.fastapi_routes.v2.user_resources import ( + USER_RESOURCE_ERROR_RESPONSES, +) +from policyengine_api.query_parameters import CountryId from policyengine_api.services.v2.user_households.types import ( UserHouseholdPage, UserHouseholdRead, @@ -23,8 +25,9 @@ class StrictUserHouseholdAPIModel(BaseModel): class UserHouseholdItem(StrictUserHouseholdAPIModel): id: UUID country_id: CountryId - user_id: UserId - household_id: ResourceId + user_id: UUID + household_id: UUID | None + legacy_household_id: int | None name: str | None description: str | None created_at: datetime @@ -74,29 +77,6 @@ class UserHouseholdPageResponse(UserHouseholdSuccessResponse[UserHouseholdPageRe pass -USER_HOUSEHOLD_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { - 400: { - "model": V2ErrorResponse, - "description": "Association content or country selection is invalid.", - }, - 404: { - "model": V2ErrorResponse, - "description": "The selected user, household, or association does not exist.", - }, - 409: { - "model": V2ErrorResponse, - "description": "Association state conflicts with stored state.", - }, - 422: { - "model": V2ErrorResponse, - "description": "The request does not match the association schema.", - }, - 500: { - "model": V2ErrorResponse, - "description": "The association operation could not be completed.", - }, - 503: { - "model": V2ErrorResponse, - "description": "Supabase association persistence is unavailable.", - }, -} +USER_HOUSEHOLD_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = ( + USER_RESOURCE_ERROR_RESPONSES +) diff --git a/policyengine_api/fastapi_routes/v2/user_households/routes.py b/policyengine_api/fastapi_routes/v2/user_households/routes.py index 089255343..9a019f695 100644 --- a/policyengine_api/fastapi_routes/v2/user_households/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_households/routes.py @@ -1,16 +1,13 @@ -"""Native FastAPI routes for mutable v2 user-household associations.""" +"""Native FastAPI routes for v2 user-household associations.""" from __future__ import annotations from collections.abc import Callable -from typing import TypeVar from uuid import UUID from fastapi import APIRouter, Depends -from sqlalchemy.exc import IntegrityError, SQLAlchemyError from starlette.responses import JSONResponse, Response -from policyengine_api.data.v2.settings import V2ConfigurationError from policyengine_api.fastapi_routes.dependencies import ( NativeRouteDependencies, V2UserHouseholdResourceService, @@ -29,6 +26,10 @@ UserHouseholdPageResponse, UserHouseholdPageResult, ) +from policyengine_api.fastapi_routes.v2.user_resources import ( + enforce_user_write_request_size, + user_resource_operation, +) from policyengine_api.query_parameters import ( UserHouseholdCollectionQuery, UserHouseholdCreateQuery, @@ -36,12 +37,7 @@ UserHouseholdDetailQuery, UserHouseholdUpdateQuery, ) -from policyengine_api.services.v2.user_households.validators import ( - AssociationCountryConflictError, - AssociationHouseholdNotFoundError, - AssociationUserNotFoundError, - UserHouseholdNotFoundError, -) +from policyengine_api.services.v2.identity.types import AccountPrincipal def _service_factory( @@ -56,38 +52,24 @@ def _service_factory( return _default_v2_user_household_service_factory -OperationT = TypeVar("OperationT") - - -def _association_operation( - operation: Callable[[], OperationT], -) -> OperationT | JSONResponse: - try: - return operation() - except AssociationCountryConflictError as error: - return v2_error_response(400, str(error)) - except ( - AssociationHouseholdNotFoundError, - AssociationUserNotFoundError, - UserHouseholdNotFoundError, - ) as error: - return v2_error_response(404, str(error)) - except IntegrityError: - return v2_error_response( - 409, "User-household association conflicts with storage" - ) - except (V2ConfigurationError, SQLAlchemyError): - return v2_error_response(503, "V2 association persistence is unavailable") - except Exception: # noqa: BLE001 - return a secret-safe typed error - return v2_error_response(500, "V2 association operation failed") - - def build_v2_user_household_router( dependencies: NativeRouteDependencies, ) -> APIRouter: - """Build native association routes without opening a database connection.""" - - router = APIRouter(prefix="/v2", responses=USER_HOUSEHOLD_ERROR_RESPONSES) + """Build user association routes without opening a connection. + + Authentication is a router-level dependency, so a missing or invalid + credential is rejected before query or body validation. + """ + + require_user = dependencies.require_user + router = APIRouter( + prefix="/v2", + responses=USER_HOUSEHOLD_ERROR_RESPONSES, + dependencies=[ + Depends(require_user), + Depends(enforce_user_write_request_size), + ], + ) create_query = query_dependency(UserHouseholdCreateQuery) detail_query = query_dependency(UserHouseholdDetailQuery) collection_query = query_dependency(UserHouseholdCollectionQuery) @@ -99,40 +81,46 @@ def build_v2_user_household_router( "/user-households", response_model=UserHouseholdDetailResponse, status_code=201, - summary="Create a user-household association", + summary="Save a household for the current user", description=( - "Creates a saved association for an existing v2 user UUID. The UUID " - "identifies a database row; this operation does not prove that the " - "caller controls that user and performs no authentication or " - "authorization check." + "Requires a bearer access token or the PolicyEngine-Unauthenticated-User " + "header; the owner is taken from that credential. " + "Accepts legacy_household_id (a v1 household, checked in the v1 " + "database) or household_id (a v2 household)." ), ) def create_user_household( body: UserHouseholdCreateRequest, query: UserHouseholdCreateQuery = Depends(create_query), + principal: AccountPrincipal = Depends(require_user), ) -> UserHouseholdDetailResponse | JSONResponse: if body.country_id != query.country_id: return v2_error_response(400, "Body country_id must match query country_id") def create() -> UserHouseholdDetailResponse: - item = service_factory().create_user_household(body) + item = service_factory().create_user_household( + principal=principal, + association_input=body, + ) return UserHouseholdDetailResponse( result=UserHouseholdDetailResult(item=UserHouseholdItem.from_read(item)) ) - return _association_operation(create) + return user_resource_operation(create) @router.get( "/user-households/{association_id}", response_model=UserHouseholdDetailResponse, - summary="Read one user-household association", + summary="Read one of the current user's saved households", ) def get_user_household( association_id: UUID, query: UserHouseholdDetailQuery = Depends(detail_query), + principal: AccountPrincipal = Depends(require_user), ) -> UserHouseholdDetailResponse | JSONResponse: def read() -> UserHouseholdDetailResponse: item = service_factory().get_user_household( + principal=principal, country_id=query.country_id, association_id=association_id, ) @@ -140,25 +128,23 @@ def read() -> UserHouseholdDetailResponse: result=UserHouseholdDetailResult(item=UserHouseholdItem.from_read(item)) ) - return _association_operation(read) + return user_resource_operation(read) @router.get( "/user-households", response_model=UserHouseholdPageResponse, - summary="List user-household associations", - description=( - "Filters by a v2 user UUID. A match is not proof of caller control " - "and is not an authentication or authorization decision." - ), + summary="List the current user's saved households", ) def get_user_households( query: UserHouseholdCollectionQuery = Depends(collection_query), + principal: AccountPrincipal = Depends(require_user), ) -> UserHouseholdPageResponse | JSONResponse: def read() -> UserHouseholdPageResponse: page = service_factory().list_user_households( + principal=principal, country_id=query.country_id, - user_id=query.user_id, household_id=query.household_id, + legacy_household_id=query.legacy_household_id, offset=query.offset, limit=query.limit, ) @@ -166,20 +152,26 @@ def read() -> UserHouseholdPageResponse: result=UserHouseholdPageResult.from_page(page) ) - return _association_operation(read) + return user_resource_operation(read) @router.patch( "/user-households/{association_id}", response_model=UserHouseholdDetailResponse, - summary="Update or reassign a user-household association", + summary="Update or repoint one of the current user's saved households", + description=( + "Changing legacy_household_id repoints the row and re-resolves " + "household_id from the Stage 11 copy mapping, or clears it." + ), ) def patch_user_household_route( association_id: UUID, body: UserHouseholdPatchRequest, query: UserHouseholdUpdateQuery = Depends(update_query), + principal: AccountPrincipal = Depends(require_user), ) -> UserHouseholdDetailResponse | JSONResponse: def patch() -> UserHouseholdDetailResponse: item = service_factory().patch_user_household( + principal=principal, country_id=query.country_id, association_id=association_id, association_input=body, @@ -188,26 +180,28 @@ def patch() -> UserHouseholdDetailResponse: result=UserHouseholdDetailResult(item=UserHouseholdItem.from_read(item)) ) - return _association_operation(patch) + return user_resource_operation(patch) @router.delete( "/user-households/{association_id}", response_model=None, status_code=204, response_class=Response, - summary="Delete one user-household association", + summary="Delete one of the current user's saved households", ) def delete_user_household_route( association_id: UUID, query: UserHouseholdDeleteQuery = Depends(delete_query), + principal: AccountPrincipal = Depends(require_user), ) -> Response | JSONResponse: def delete() -> Response: service_factory().delete_user_household( + principal=principal, country_id=query.country_id, association_id=association_id, ) return Response(status_code=204) - return _association_operation(delete) + return user_resource_operation(delete) return router diff --git a/policyengine_api/fastapi_routes/v2/user_policies/request_models.py b/policyengine_api/fastapi_routes/v2/user_policies/request_models.py index 9292bc440..fa6451b1a 100644 --- a/policyengine_api/fastapi_routes/v2/user_policies/request_models.py +++ b/policyengine_api/fastapi_routes/v2/user_policies/request_models.py @@ -1,14 +1,14 @@ -"""Strict HTTP request models for native v2 user-policy associations.""" +"""Strict HTTP request models for v2 user-policy associations.""" from policyengine_api.services.v2.user_policies.types import ( - UserPolicyCreationInput, + UserPolicyCreateFields, UserPolicyUpdateInput, ) -class UserPolicyCreateRequest(UserPolicyCreationInput): - """Association identity, immutable link fields, and presentation fields.""" +class UserPolicyCreateRequest(UserPolicyCreateFields): + """Association fields; the owner is taken from the request credential.""" class UserPolicyPatchRequest(UserPolicyUpdateInput): - """Explicitly supplied mutable presentation fields.""" + """Explicitly supplied mutable link or presentation fields.""" diff --git a/policyengine_api/fastapi_routes/v2/user_policies/response_models.py b/policyengine_api/fastapi_routes/v2/user_policies/response_models.py index caef44df3..3ad5ea842 100644 --- a/policyengine_api/fastapi_routes/v2/user_policies/response_models.py +++ b/policyengine_api/fastapi_routes/v2/user_policies/response_models.py @@ -1,4 +1,4 @@ -"""Strict HTTP response models for native v2 user-policy associations.""" +"""Strict HTTP response models for v2 user-policy associations.""" from __future__ import annotations @@ -8,12 +8,15 @@ from pydantic import BaseModel, ConfigDict -from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse +from policyengine_api.fastapi_routes.v2.user_resources import ( + USER_RESOURCE_ERROR_RESPONSES, +) +from policyengine_api.query_parameters import CountryId from policyengine_api.services.v2.user_policies.types import ( + UserPolicyBaseline, UserPolicyPage, UserPolicyRead, ) -from policyengine_api.query_parameters import CountryId, ResourceId, UserId class StrictUserPolicyAPIModel(BaseModel): @@ -22,13 +25,30 @@ class StrictUserPolicyAPIModel(BaseModel): model_config = ConfigDict(extra="forbid", from_attributes=True) +class PolicyProvenanceItem(BaseModel): + """Stored v3 reform provenance as responses publish it. + + It mirrors the contract without the input bounds and ignores unknown keys, + so any stored row serializes. + """ + + model_config = ConfigDict(extra="ignore", from_attributes=True) + + source: str + ref: str | None = None + confidence: str | None = None + + class UserPolicyItem(StrictUserPolicyAPIModel): id: UUID country_id: CountryId - user_id: UserId - policy_id: ResourceId + user_id: UUID + policy_id: UUID | None + legacy_policy_id: int | None name: str | None description: str | None + baseline: UserPolicyBaseline | None + provenance: PolicyProvenanceItem | None created_at: datetime updated_at: datetime @@ -74,29 +94,6 @@ class UserPolicyPageResponse(UserPolicySuccessResponse[UserPolicyPageResult]): pass -USER_POLICY_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { - 400: { - "model": V2ErrorResponse, - "description": "Association content or country selection is invalid.", - }, - 404: { - "model": V2ErrorResponse, - "description": "The selected user, policy, or association does not exist.", - }, - 409: { - "model": V2ErrorResponse, - "description": "Association state conflicts with stored state.", - }, - 422: { - "model": V2ErrorResponse, - "description": "The request does not match the association schema.", - }, - 500: { - "model": V2ErrorResponse, - "description": "The association operation could not be completed.", - }, - 503: { - "model": V2ErrorResponse, - "description": "Supabase association persistence is unavailable.", - }, -} +USER_POLICY_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = ( + USER_RESOURCE_ERROR_RESPONSES +) diff --git a/policyengine_api/fastapi_routes/v2/user_policies/routes.py b/policyengine_api/fastapi_routes/v2/user_policies/routes.py index 7b4f78c77..7f604744b 100644 --- a/policyengine_api/fastapi_routes/v2/user_policies/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_policies/routes.py @@ -1,16 +1,19 @@ -"""Native FastAPI routes for mutable v2 user-policy associations.""" +"""Native FastAPI routes for v2 user-policy associations.""" from __future__ import annotations from collections.abc import Callable -from typing import TypeVar from uuid import UUID from fastapi import APIRouter, Depends -from sqlalchemy.exc import SQLAlchemyError from starlette.responses import JSONResponse, Response -from policyengine_api.data.v2.settings import V2ConfigurationError +from policyengine_api.fastapi_routes.dependencies import ( + NativeRouteDependencies, + V2UserPolicyResourceService, +) +from policyengine_api.fastapi_routes.query_parameters import query_dependency +from policyengine_api.fastapi_routes.v2.errors import v2_error_response from policyengine_api.fastapi_routes.v2.user_policies.request_models import ( UserPolicyCreateRequest, UserPolicyPatchRequest, @@ -23,22 +26,15 @@ UserPolicyPageResponse, UserPolicyPageResult, ) -from policyengine_api.fastapi_routes.v2.errors import v2_error_response -from policyengine_api.services.v2.user_policies.validators import ( - UserPolicyNotFoundError, - AssociationCountryConflictError, - AssociationPolicyNotFoundError, - AssociationUserNotFoundError, -) -from policyengine_api.fastapi_routes.dependencies import ( - NativeRouteDependencies, - V2UserPolicyResourceService, +from policyengine_api.fastapi_routes.v2.user_resources import ( + enforce_user_write_request_size, + user_resource_operation, ) -from policyengine_api.fastapi_routes.query_parameters import query_dependency from policyengine_api.query_parameters import ( CountryQuery, UserPolicyCollectionQuery, ) +from policyengine_api.services.v2.identity.types import AccountPrincipal def _service_factory( @@ -53,37 +49,24 @@ def _service_factory( return _default_v2_user_policy_service_factory -OperationT = TypeVar("OperationT") - - -def _association_operation( - operation: Callable[[], OperationT], -) -> OperationT | JSONResponse: - try: - return operation() - except AssociationCountryConflictError as error: - return v2_error_response(400, str(error)) - except ( - AssociationPolicyNotFoundError, - AssociationUserNotFoundError, - UserPolicyNotFoundError, - ) as error: - return v2_error_response(404, str(error)) - except (V2ConfigurationError, SQLAlchemyError): - return v2_error_response( - 503, - "V2 association persistence is unavailable", - ) - except Exception: # noqa: BLE001 - return a secret-safe typed error - return v2_error_response(500, "V2 association operation failed") - - def build_v2_user_policy_router( dependencies: NativeRouteDependencies, ) -> APIRouter: - """Build native association routes without opening a database connection.""" - - router = APIRouter(prefix="/v2", responses=USER_POLICY_ERROR_RESPONSES) + """Build user association routes without opening a connection. + + Authentication is a router-level dependency, so a missing or invalid + credential is rejected before query or body validation. + """ + + require_user = dependencies.require_user + router = APIRouter( + prefix="/v2", + responses=USER_POLICY_ERROR_RESPONSES, + dependencies=[ + Depends(require_user), + Depends(enforce_user_write_request_size), + ], + ) country_query = query_dependency(CountryQuery) collection_query = query_dependency(UserPolicyCollectionQuery) service_factory = _service_factory(dependencies) @@ -92,17 +75,19 @@ def build_v2_user_policy_router( "/user-policies", response_model=UserPolicyDetailResponse, status_code=201, - summary="Create a user-policy association", + summary="Save a policy for the current user", description=( - "Creates a saved association for an existing v2 user UUID. The " - "UUID identifies a database row; this operation does not prove " - "that the caller controls that user and performs no authentication " - "or authorization check." + "Requires a bearer access token or the PolicyEngine-Unauthenticated-User " + "header; the owner is taken from that credential. " + "Accepts legacy_policy_id (a v1 policy, checked in the v1 database) " + "or policy_id (a v2 policy). When only legacy_policy_id is supplied, " + "policy_id is filled from the Stage 10 mirror mapping when one exists." ), ) def create_user_policy( body: UserPolicyCreateRequest, query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), ) -> UserPolicyDetailResponse | JSONResponse: if body.country_id != query.country_id: return v2_error_response( @@ -111,24 +96,29 @@ def create_user_policy( ) def create() -> UserPolicyDetailResponse: - item = service_factory().create_user_policy(body) + item = service_factory().create_user_policy( + principal=principal, + association_input=body, + ) return UserPolicyDetailResponse( result=UserPolicyDetailResult(item=UserPolicyItem.from_read(item)) ) - return _association_operation(create) + return user_resource_operation(create) @router.get( "/user-policies/{association_id}", response_model=UserPolicyDetailResponse, - summary="Read one user-policy association", + summary="Read one of the current user's saved policies", ) def get_user_policy( association_id: UUID, query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), ) -> UserPolicyDetailResponse | JSONResponse: def read() -> UserPolicyDetailResponse: item = service_factory().get_user_policy( + principal=principal, country_id=query.country_id, association_id=association_id, ) @@ -136,44 +126,49 @@ def read() -> UserPolicyDetailResponse: result=UserPolicyDetailResult(item=UserPolicyItem.from_read(item)) ) - return _association_operation(read) + return user_resource_operation(read) @router.get( "/user-policies", response_model=UserPolicyPageResponse, - summary="List user-policy associations", - description=( - "Filters by a v2 user UUID. A match is not proof of caller control " - "and is not an authentication or authorization decision." - ), + summary="List the current user's saved policies", ) def get_user_policies( query: UserPolicyCollectionQuery = Depends(collection_query), + principal: AccountPrincipal = Depends(require_user), ) -> UserPolicyPageResponse | JSONResponse: def read() -> UserPolicyPageResponse: page = service_factory().list_user_policies( + principal=principal, country_id=query.country_id, - user_id=query.user_id, policy_id=query.policy_id, + legacy_policy_id=query.legacy_policy_id, offset=query.offset, limit=query.limit, ) return UserPolicyPageResponse(result=UserPolicyPageResult.from_page(page)) - return _association_operation(read) + return user_resource_operation(read) @router.patch( "/user-policies/{association_id}", response_model=UserPolicyDetailResponse, - summary="Update association presentation fields", + summary="Update or repoint one of the current user's saved policies", + description=( + "Changing legacy_policy_id repoints the row and re-resolves policy_id " + "from the Stage 10 mirror mapping, or clears it. Rows maintained by " + "the v1 saved-policy mirror return 409." + ), ) def patch_user_policy_route( association_id: UUID, body: UserPolicyPatchRequest, query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), ) -> UserPolicyDetailResponse | JSONResponse: def patch() -> UserPolicyDetailResponse: item = service_factory().patch_user_policy( + principal=principal, country_id=query.country_id, association_id=association_id, association_input=body, @@ -182,26 +177,28 @@ def patch() -> UserPolicyDetailResponse: result=UserPolicyDetailResult(item=UserPolicyItem.from_read(item)) ) - return _association_operation(patch) + return user_resource_operation(patch) @router.delete( "/user-policies/{association_id}", response_model=None, status_code=204, response_class=Response, - summary="Delete one user-policy association", + summary="Delete one of the current user's saved policies", ) def delete_user_policy_route( association_id: UUID, query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), ) -> Response | JSONResponse: def delete() -> Response: service_factory().delete_user_policy( + principal=principal, country_id=query.country_id, association_id=association_id, ) return Response(status_code=204) - return _association_operation(delete) + return user_resource_operation(delete) return router diff --git a/policyengine_api/fastapi_routes/v2/user_reports/__init__.py b/policyengine_api/fastapi_routes/v2/user_reports/__init__.py new file mode 100644 index 000000000..9a38959ea --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_reports/__init__.py @@ -0,0 +1 @@ +"""FastAPI adapters for v2 user-report associations.""" diff --git a/policyengine_api/fastapi_routes/v2/user_reports/request_models.py b/policyengine_api/fastapi_routes/v2/user_reports/request_models.py new file mode 100644 index 000000000..f9bcdb879 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_reports/request_models.py @@ -0,0 +1,14 @@ +"""Strict HTTP request models for v2 user-report associations.""" + +from policyengine_api.services.v2.user_reports.types import ( + UserReportCreateFields, + UserReportUpdateInput, +) + + +class UserReportCreateRequest(UserReportCreateFields): + """Association fields; the owner is taken from the request credential.""" + + +class UserReportPatchRequest(UserReportUpdateInput): + """Explicitly supplied mutable link or presentation fields.""" diff --git a/policyengine_api/fastapi_routes/v2/user_reports/response_models.py b/policyengine_api/fastapi_routes/v2/user_reports/response_models.py new file mode 100644 index 000000000..1c5923356 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_reports/response_models.py @@ -0,0 +1,156 @@ +"""Strict HTTP response models for v2 user-report associations.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Generic, Literal, TypeVar +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +from policyengine_api.query_parameters import CountryId +from policyengine_api.services.v2.user_reports.types import ( + UserReportPage, + UserReportRead, +) + + +class StrictUserReportAPIModel(BaseModel): + model_config = ConfigDict(extra="forbid", from_attributes=True) + + +class StoredSnapshotItem(BaseModel): + """A stored v3 report snapshot as responses publish it. + + These shapes mirror the contract but not the input bounds: they ignore + unknown keys and accept any stored length, count, or label, so a row saved + under older or looser input rules still serializes and one row can never + fail a list or an export. + """ + + model_config = ConfigDict(extra="ignore", from_attributes=True) + + +class ReportProvisionIntervalItem(StoredSnapshotItem): + # The contract makes this key optional rather than nullable. + parameter: str | None = Field(default=None, exclude_if=lambda value: value is None) + start_date: str + end_date: str + value: JsonValue + + +class ReportProvisionItem(StoredSnapshotItem): + path: str + breadcrumb: str + unit: str | None = None + baseline_value: JsonValue + value: JsonValue + intervals: list[ReportProvisionIntervalItem] | None = None + # Optional rather than nullable: absent unless the provision grows. + growth: str | None = Field(default=None, exclude_if=lambda value: value is None) + growth_start: int | None = Field( + default=None, exclude_if=lambda value: value is None + ) + growth_base: int | None = Field( + default=None, exclude_if=lambda value: value is None + ) + growth_round_to: float | None = Field( + default=None, exclude_if=lambda value: value is None + ) + growth_round: str | None = Field( + default=None, exclude_if=lambda value: value is None + ) + + +class ReportCalibrationWorstItem(StoredSnapshotItem): + name: str + source: str + geography: str + relative_error: float | None = None + + +class ReportCalibrationMatchItem(StoredSnapshotItem): + variable: str + depth: int + ring: str + target_count: int + mean_abs_relative_error: float + worst: ReportCalibrationWorstItem + + +class ReportCalibrationItem(StoredSnapshotItem): + release_id: str | None = None + geography: str + reached_count: int + matches: list[ReportCalibrationMatchItem] + + +class ReportScorecardItem(StoredSnapshotItem): + programs: list[str] + + +class ReportValidationItem(StoredSnapshotItem): + matched_at: str + map_model_version: str + calibration: ReportCalibrationItem | None = None + scorecard: ReportScorecardItem | None = None + + +class UserReportItem(StrictUserReportAPIModel): + id: UUID + country_id: CountryId + user_id: UUID + report_id: UUID | None + legacy_report_id: int | None + name: str | None + description: str | None + user_policy_id: UUID | None + baseline_user_policy_id: UUID | None + source_note: str | None + provisions: list[ReportProvisionItem] | None + validation: ReportValidationItem | None + budget_window_years: int | None + last_run_at: datetime | None + created_at: datetime + updated_at: datetime + + @classmethod + def from_read(cls, item: UserReportRead) -> "UserReportItem": + return cls.model_validate(item) + + +class UserReportDetailResult(StrictUserReportAPIModel): + item: UserReportItem + + +class UserReportPageResult(StrictUserReportAPIModel): + items: list[UserReportItem] + offset: int + limit: int + has_more: bool + + @classmethod + def from_page(cls, page: UserReportPage) -> "UserReportPageResult": + return cls( + items=[UserReportItem.from_read(item) for item in page.items], + offset=page.offset, + limit=page.limit, + has_more=page.has_more, + ) + + +ResultT = TypeVar("ResultT") + + +class UserReportSuccessResponse(StrictUserReportAPIModel, Generic[ResultT]): + status: Literal["ok"] = "ok" + message: None = None + result: ResultT + + +class UserReportDetailResponse(UserReportSuccessResponse[UserReportDetailResult]): + pass + + +class UserReportPageResponse(UserReportSuccessResponse[UserReportPageResult]): + pass diff --git a/policyengine_api/fastapi_routes/v2/user_reports/routes.py b/policyengine_api/fastapi_routes/v2/user_reports/routes.py new file mode 100644 index 000000000..01926d3f7 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_reports/routes.py @@ -0,0 +1,195 @@ +"""Native FastAPI routes for v2 user-report associations.""" + +from __future__ import annotations + +from collections.abc import Callable +from uuid import UUID + +from fastapi import APIRouter, Depends +from starlette.responses import JSONResponse, Response + +from policyengine_api.fastapi_routes.dependencies import ( + NativeRouteDependencies, + V2UserReportResourceService, +) +from policyengine_api.fastapi_routes.query_parameters import query_dependency +from policyengine_api.fastapi_routes.v2.errors import v2_error_response +from policyengine_api.fastapi_routes.v2.user_resources import ( + USER_RESOURCE_ERROR_RESPONSES, + enforce_user_write_request_size, + user_resource_operation, +) +from policyengine_api.fastapi_routes.v2.user_reports.request_models import ( + UserReportCreateRequest, + UserReportPatchRequest, +) +from policyengine_api.fastapi_routes.v2.user_reports.response_models import ( + UserReportDetailResponse, + UserReportDetailResult, + UserReportItem, + UserReportPageResponse, + UserReportPageResult, +) +from policyengine_api.query_parameters import ( + CountryQuery, + UserReportCollectionQuery, +) +from policyengine_api.services.v2.identity.types import AccountPrincipal + + +def _service_factory( + dependencies: NativeRouteDependencies, +) -> Callable[[], V2UserReportResourceService]: + if dependencies.v2_user_report_service_factory is not None: + return dependencies.v2_user_report_service_factory + from policyengine_api.fastapi_routes.dependencies import ( + _default_v2_user_report_service_factory, + ) + + return _default_v2_user_report_service_factory + + +def build_v2_user_report_router( + dependencies: NativeRouteDependencies, +) -> APIRouter: + """Build user association routes without opening a connection. + + Authentication is a router-level dependency, so a missing or invalid + credential is rejected before query or body validation. + """ + + require_user = dependencies.require_user + router = APIRouter( + prefix="/v2", + responses=USER_RESOURCE_ERROR_RESPONSES, + dependencies=[ + Depends(require_user), + Depends(enforce_user_write_request_size), + ], + ) + country_query = query_dependency(CountryQuery) + collection_query = query_dependency(UserReportCollectionQuery) + service_factory = _service_factory(dependencies) + + @router.post( + "/user-reports", + response_model=UserReportDetailResponse, + status_code=201, + summary="Save a v1 report for the current user", + description=( + "Requires a bearer access token or the PolicyEngine-Unauthenticated-User " + "header; the owner is taken from that credential. " + "Stage 12A accepts legacy_report_id only (the requested v1 report " + "output ID), checked in the v1 database. An optional user_policy_id " + "must name the same user's saved policy in the same country." + ), + ) + def create_user_report( + body: UserReportCreateRequest, + query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), + ) -> UserReportDetailResponse | JSONResponse: + if body.country_id != query.country_id: + return v2_error_response(400, "Body country_id must match query country_id") + + def create() -> UserReportDetailResponse: + item = service_factory().create_user_report( + principal=principal, + association_input=body, + ) + return UserReportDetailResponse( + result=UserReportDetailResult(item=UserReportItem.from_read(item)) + ) + + return user_resource_operation(create) + + @router.get( + "/user-reports/{association_id}", + response_model=UserReportDetailResponse, + summary="Read one of the current user's saved reports", + ) + def get_user_report( + association_id: UUID, + query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), + ) -> UserReportDetailResponse | JSONResponse: + def read() -> UserReportDetailResponse: + item = service_factory().get_user_report( + principal=principal, + country_id=query.country_id, + association_id=association_id, + ) + return UserReportDetailResponse( + result=UserReportDetailResult(item=UserReportItem.from_read(item)) + ) + + return user_resource_operation(read) + + @router.get( + "/user-reports", + response_model=UserReportPageResponse, + summary="List the current user's saved reports", + ) + def get_user_reports( + query: UserReportCollectionQuery = Depends(collection_query), + principal: AccountPrincipal = Depends(require_user), + ) -> UserReportPageResponse | JSONResponse: + def read() -> UserReportPageResponse: + page = service_factory().list_user_reports( + principal=principal, + country_id=query.country_id, + legacy_report_id=query.legacy_report_id, + offset=query.offset, + limit=query.limit, + ) + return UserReportPageResponse(result=UserReportPageResult.from_page(page)) + + return user_resource_operation(read) + + @router.patch( + "/user-reports/{association_id}", + response_model=UserReportDetailResponse, + summary="Update or repoint one of the current user's saved reports", + ) + def patch_user_report_route( + association_id: UUID, + body: UserReportPatchRequest, + query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), + ) -> UserReportDetailResponse | JSONResponse: + def patch() -> UserReportDetailResponse: + item = service_factory().patch_user_report( + principal=principal, + country_id=query.country_id, + association_id=association_id, + association_input=body, + ) + return UserReportDetailResponse( + result=UserReportDetailResult(item=UserReportItem.from_read(item)) + ) + + return user_resource_operation(patch) + + @router.delete( + "/user-reports/{association_id}", + response_model=None, + status_code=204, + response_class=Response, + summary="Delete one of the current user's saved reports", + ) + def delete_user_report_route( + association_id: UUID, + query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), + ) -> Response | JSONResponse: + def delete() -> Response: + service_factory().delete_user_report( + principal=principal, + country_id=query.country_id, + association_id=association_id, + ) + return Response(status_code=204) + + return user_resource_operation(delete) + + return router diff --git a/policyengine_api/fastapi_routes/v2/user_resources.py b/policyengine_api/fastapi_routes/v2/user_resources.py new file mode 100644 index 000000000..891d91085 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_resources.py @@ -0,0 +1,204 @@ +"""Shared HTTP conventions for user-owned API v2 resources.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +import logging +import traceback +from typing import Any, TypeVar + +from fastapi import Request +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from starlette.responses import JSONResponse + +from policyengine_api.data.v2.settings import V2ConfigurationError +from policyengine_api.fastapi_routes.v2.errors import ( + BEARER_CHALLENGE, + V2ErrorResponse, + V2RequestTooLargeError, + v2_error_response, +) +from policyengine_api.services.v2.associations.validators import ( + AssociationConflictError, + AssociationLinkNotFoundError, + AssociationNotFoundError, + AssociationReferenceConflictError, +) +from policyengine_api.services.v2.identity.validators import ( + StandardUserRequiresSignInError, +) +from policyengine_api.services.v2.legacy_refs.validators import ( + LegacyReferenceNotFoundError, +) +from policyengine_api.services.v2.user_accounts.validators import ( + AccountDeletionUnavailableError, + AccountEditRequiresSignInError, + AccountIdentityDeletionError, + AccountNotFoundError, +) +from policyengine_api.services.v2.user_households.validators import ( + AssociationHouseholdNotFoundError, +) +from policyengine_api.services.v2.user_policies.validators import ( + AssociationPolicyNotFoundError, +) + + +MAXIMUM_USER_WRITE_REQUEST_BYTES = 256 * 1024 + + +def request_size_limit( + maximum_bytes: int, *, label: str +) -> Callable[[Request], Awaitable[None]]: + """Build a dependency that bounds declared and actual request body bytes. + + It runs after the router-level authentication dependency, so an + unauthenticated request still gets 401 first. + """ + + async def enforce(request: Request) -> None: + content_length = request.headers.get("content-length") + if content_length is not None: + try: + declared_length = int(content_length) + except ValueError as error: + raise V2RequestTooLargeError( + "Request Content-Length is invalid" + ) from error + if declared_length > maximum_bytes: + raise V2RequestTooLargeError(f"Request body exceeds {label}") + if len(await request.body()) > maximum_bytes: + raise V2RequestTooLargeError(f"Request body exceeds {label}") + + return enforce + + +enforce_user_write_request_size = request_size_limit( + MAXIMUM_USER_WRITE_REQUEST_BYTES, label="256 KiB" +) + + +USER_RESOURCE_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { + 400: { + "model": V2ErrorResponse, + "description": "The supplied references or countries contradict each other.", + }, + 401: { + "model": V2ErrorResponse, + "description": ( + "A valid bearer access token, or a canonical UUID in the " + "PolicyEngine-Unauthenticated-User header, is required; that UUID " + "may not name a signed-in account. The response carries " + "WWW-Authenticate: Bearer." + ), + }, + 403: { + "model": V2ErrorResponse, + "description": ( + "Machine (client-credentials) principals are not permitted, and " + "unauthenticated users cannot edit account details." + ), + }, + 404: { + "model": V2ErrorResponse, + "description": "The record does not exist or belongs to another user.", + }, + 409: { + "model": V2ErrorResponse, + "description": ( + "The change conflicts with stored state, such as a record maintained " + "by the v1 saved-policy mirror." + ), + }, + 413: { + "model": V2ErrorResponse, + "description": "The request body exceeds 256 KiB.", + }, + 422: { + "model": V2ErrorResponse, + "description": ( + "The request does not match the schema, or a referenced v1 artifact " + "or linked record does not exist." + ), + }, + 500: { + "model": V2ErrorResponse, + "description": "The operation could not be completed.", + }, + 503: { + "model": V2ErrorResponse, + "description": "User authentication or persistence is unavailable.", + }, +} + +logger = logging.getLogger(__name__) + +OperationT = TypeVar("OperationT") + + +def _log_failure(event: str, error: Exception) -> None: + """Record a failure without its message. + + Exception messages can carry bound SQL parameters or token claims, so only + the type and the innermost frame locations are logged. Logging never + changes the response. + """ + + try: + frames = traceback.extract_tb(error.__traceback__)[-8:] + logger.error( + event, + extra={ + "policyengine_attributes": { + "error_type": type(error).__name__, + "error_frames": "; ".join( + f"{frame.filename.rsplit('/', 1)[-1]}:{frame.lineno} " + f"in {frame.name}" + for frame in frames + ), + } + }, + ) + except Exception: # noqa: BLE001 - observability must not alter the response + pass + + +def user_resource_operation( + operation: Callable[[], OperationT], +) -> OperationT | JSONResponse: + """Run one service call and map typed failures to secret-safe errors.""" + + try: + return operation() + except StandardUserRequiresSignInError as error: + return v2_error_response( + 401, str(error), headers={"WWW-Authenticate": BEARER_CHALLENGE} + ) + except AccountEditRequiresSignInError as error: + return v2_error_response(403, str(error)) + except AssociationReferenceConflictError as error: + return v2_error_response(400, str(error)) + except (LegacyReferenceNotFoundError, AssociationLinkNotFoundError) as error: + return v2_error_response(422, str(error)) + except ( + AssociationNotFoundError, + AssociationPolicyNotFoundError, + AssociationHouseholdNotFoundError, + AccountNotFoundError, + ) as error: + return v2_error_response(404, str(error)) + except AssociationConflictError as error: + return v2_error_response(409, str(error)) + except IntegrityError: + return v2_error_response(409, "The record conflicts with stored state") + except AccountIdentityDeletionError as error: + return v2_error_response(502, str(error)) + except AccountDeletionUnavailableError as error: + return v2_error_response(503, str(error)) + except (V2ConfigurationError, SQLAlchemyError) as error: + # Schema drift and rejected data surface here too, so keep a record. + _log_failure("v2_user_persistence_unavailable", error) + return v2_error_response(503, "V2 user persistence is unavailable") + except Exception as error: # noqa: BLE001 - return a secret-safe typed error + _log_failure("v2_user_operation_failed", error) + return v2_error_response(500, "V2 user operation failed") diff --git a/policyengine_api/fastapi_routes/v2/user_simulations/__init__.py b/policyengine_api/fastapi_routes/v2/user_simulations/__init__.py new file mode 100644 index 000000000..a23f353b6 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_simulations/__init__.py @@ -0,0 +1 @@ +"""FastAPI adapters for v2 user-simulation associations.""" diff --git a/policyengine_api/fastapi_routes/v2/user_simulations/request_models.py b/policyengine_api/fastapi_routes/v2/user_simulations/request_models.py new file mode 100644 index 000000000..ba3d8e73f --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_simulations/request_models.py @@ -0,0 +1,14 @@ +"""Strict HTTP request models for v2 user-simulation associations.""" + +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationCreateFields, + UserSimulationUpdateInput, +) + + +class UserSimulationCreateRequest(UserSimulationCreateFields): + """Association fields; the owner is taken from the request credential.""" + + +class UserSimulationPatchRequest(UserSimulationUpdateInput): + """Explicitly supplied mutable link or presentation fields.""" diff --git a/policyengine_api/fastapi_routes/v2/user_simulations/response_models.py b/policyengine_api/fastapi_routes/v2/user_simulations/response_models.py new file mode 100644 index 000000000..a7e662cd8 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_simulations/response_models.py @@ -0,0 +1,76 @@ +"""Strict HTTP response models for v2 user-simulation associations.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Generic, Literal, TypeVar +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from policyengine_api.query_parameters import CountryId +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationPage, + UserSimulationRead, +) + + +class StrictUserSimulationAPIModel(BaseModel): + model_config = ConfigDict(extra="forbid", from_attributes=True) + + +class UserSimulationItem(StrictUserSimulationAPIModel): + id: UUID + country_id: CountryId + user_id: UUID + simulation_id: UUID | None + legacy_simulation_id: int | None + name: str | None + description: str | None + created_at: datetime + updated_at: datetime + + @classmethod + def from_read(cls, item: UserSimulationRead) -> "UserSimulationItem": + return cls.model_validate(item) + + +class UserSimulationDetailResult(StrictUserSimulationAPIModel): + item: UserSimulationItem + + +class UserSimulationPageResult(StrictUserSimulationAPIModel): + items: list[UserSimulationItem] + offset: int + limit: int + has_more: bool + + @classmethod + def from_page(cls, page: UserSimulationPage) -> "UserSimulationPageResult": + return cls( + items=[UserSimulationItem.from_read(item) for item in page.items], + offset=page.offset, + limit=page.limit, + has_more=page.has_more, + ) + + +ResultT = TypeVar("ResultT") + + +class UserSimulationSuccessResponse(StrictUserSimulationAPIModel, Generic[ResultT]): + status: Literal["ok"] = "ok" + message: None = None + result: ResultT + + +class UserSimulationDetailResponse( + UserSimulationSuccessResponse[UserSimulationDetailResult] +): + pass + + +class UserSimulationPageResponse( + UserSimulationSuccessResponse[UserSimulationPageResult] +): + pass diff --git a/policyengine_api/fastapi_routes/v2/user_simulations/routes.py b/policyengine_api/fastapi_routes/v2/user_simulations/routes.py new file mode 100644 index 000000000..768d28796 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_simulations/routes.py @@ -0,0 +1,202 @@ +"""Native FastAPI routes for v2 user-simulation associations.""" + +from __future__ import annotations + +from collections.abc import Callable +from uuid import UUID + +from fastapi import APIRouter, Depends +from starlette.responses import JSONResponse, Response + +from policyengine_api.fastapi_routes.dependencies import ( + NativeRouteDependencies, + V2UserSimulationResourceService, +) +from policyengine_api.fastapi_routes.query_parameters import query_dependency +from policyengine_api.fastapi_routes.v2.errors import v2_error_response +from policyengine_api.fastapi_routes.v2.user_resources import ( + USER_RESOURCE_ERROR_RESPONSES, + enforce_user_write_request_size, + user_resource_operation, +) +from policyengine_api.fastapi_routes.v2.user_simulations.request_models import ( + UserSimulationCreateRequest, + UserSimulationPatchRequest, +) +from policyengine_api.fastapi_routes.v2.user_simulations.response_models import ( + UserSimulationDetailResponse, + UserSimulationDetailResult, + UserSimulationItem, + UserSimulationPageResponse, + UserSimulationPageResult, +) +from policyengine_api.query_parameters import ( + CountryQuery, + UserSimulationCollectionQuery, +) +from policyengine_api.services.v2.identity.types import AccountPrincipal + + +def _service_factory( + dependencies: NativeRouteDependencies, +) -> Callable[[], V2UserSimulationResourceService]: + if dependencies.v2_user_simulation_service_factory is not None: + return dependencies.v2_user_simulation_service_factory + from policyengine_api.fastapi_routes.dependencies import ( + _default_v2_user_simulation_service_factory, + ) + + return _default_v2_user_simulation_service_factory + + +def build_v2_user_simulation_router( + dependencies: NativeRouteDependencies, +) -> APIRouter: + """Build user association routes without opening a connection. + + Authentication is a router-level dependency, so a missing or invalid + credential is rejected before query or body validation. + """ + + require_user = dependencies.require_user + router = APIRouter( + prefix="/v2", + responses=USER_RESOURCE_ERROR_RESPONSES, + dependencies=[ + Depends(require_user), + Depends(enforce_user_write_request_size), + ], + ) + country_query = query_dependency(CountryQuery) + collection_query = query_dependency(UserSimulationCollectionQuery) + service_factory = _service_factory(dependencies) + + @router.post( + "/user-simulations", + response_model=UserSimulationDetailResponse, + status_code=201, + summary="Save a v1 simulation for the current user", + description=( + "Requires a bearer access token or the PolicyEngine-Unauthenticated-User " + "header; the owner is taken from that credential. " + "Stage 12A accepts legacy_simulation_id only, checked in the v1 " + "database." + ), + ) + def create_user_simulation( + body: UserSimulationCreateRequest, + query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), + ) -> UserSimulationDetailResponse | JSONResponse: + if body.country_id != query.country_id: + return v2_error_response(400, "Body country_id must match query country_id") + + def create() -> UserSimulationDetailResponse: + item = service_factory().create_user_simulation( + principal=principal, + association_input=body, + ) + return UserSimulationDetailResponse( + result=UserSimulationDetailResult( + item=UserSimulationItem.from_read(item) + ) + ) + + return user_resource_operation(create) + + @router.get( + "/user-simulations/{association_id}", + response_model=UserSimulationDetailResponse, + summary="Read one of the current user's saved simulations", + ) + def get_user_simulation( + association_id: UUID, + query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), + ) -> UserSimulationDetailResponse | JSONResponse: + def read() -> UserSimulationDetailResponse: + item = service_factory().get_user_simulation( + principal=principal, + country_id=query.country_id, + association_id=association_id, + ) + return UserSimulationDetailResponse( + result=UserSimulationDetailResult( + item=UserSimulationItem.from_read(item) + ) + ) + + return user_resource_operation(read) + + @router.get( + "/user-simulations", + response_model=UserSimulationPageResponse, + summary="List the current user's saved simulations", + ) + def get_user_simulations( + query: UserSimulationCollectionQuery = Depends(collection_query), + principal: AccountPrincipal = Depends(require_user), + ) -> UserSimulationPageResponse | JSONResponse: + def read() -> UserSimulationPageResponse: + page = service_factory().list_user_simulations( + principal=principal, + country_id=query.country_id, + legacy_simulation_id=query.legacy_simulation_id, + offset=query.offset, + limit=query.limit, + ) + return UserSimulationPageResponse( + result=UserSimulationPageResult.from_page(page) + ) + + return user_resource_operation(read) + + @router.patch( + "/user-simulations/{association_id}", + response_model=UserSimulationDetailResponse, + summary="Update or repoint one of the current user's saved simulations", + ) + def patch_user_simulation_route( + association_id: UUID, + body: UserSimulationPatchRequest, + query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), + ) -> UserSimulationDetailResponse | JSONResponse: + def patch() -> UserSimulationDetailResponse: + item = service_factory().patch_user_simulation( + principal=principal, + country_id=query.country_id, + association_id=association_id, + association_input=body, + ) + return UserSimulationDetailResponse( + result=UserSimulationDetailResult( + item=UserSimulationItem.from_read(item) + ) + ) + + return user_resource_operation(patch) + + @router.delete( + "/user-simulations/{association_id}", + response_model=None, + status_code=204, + response_class=Response, + summary="Delete one of the current user's saved simulations", + ) + def delete_user_simulation_route( + association_id: UUID, + query: CountryQuery = Depends(country_query), + principal: AccountPrincipal = Depends(require_user), + ) -> Response | JSONResponse: + def delete() -> Response: + service_factory().delete_user_simulation( + principal=principal, + country_id=query.country_id, + association_id=association_id, + ) + return Response(status_code=204) + + return user_resource_operation(delete) + + return router diff --git a/policyengine_api/libs/auth0_management.py b/policyengine_api/libs/auth0_management.py new file mode 100644 index 000000000..704a95dcd --- /dev/null +++ b/policyengine_api/libs/auth0_management.py @@ -0,0 +1,265 @@ +"""Optional Auth0 Management API access for account deletion. + +``DELETE /v2/me`` removes the Auth0 user only when a Management API +machine-to-machine client with the ``delete:users`` permission is configured. +The configuration is all-or-none: a domain, a client ID, and exactly one +client-secret source (a direct value or a Secret Manager resource, matching +the gateway-auth pattern). Without it, account deletion removes only Public +API data and logs that the Auth0 deletion was skipped. + +The domain must be the tenant's canonical Auth0 domain (for example +``policyengine.us.auth0.com``): the Management API audience is not served on +custom domains. This module never logs a subject, token, or secret. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from functools import lru_cache +import os +import re +import threading +import time +from urllib.parse import quote + +import httpx + + +AUTH0_MANAGEMENT_DOMAIN_ENV = "AUTH0_MANAGEMENT_DOMAIN" +AUTH0_MANAGEMENT_CLIENT_ID_ENV = "AUTH0_MANAGEMENT_CLIENT_ID" +AUTH0_MANAGEMENT_CLIENT_SECRET_ENV = "AUTH0_MANAGEMENT_CLIENT_SECRET" +AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE_ENV = "AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE" +AUTH0_MANAGEMENT_ENV_VARS = ( + AUTH0_MANAGEMENT_DOMAIN_ENV, + AUTH0_MANAGEMENT_CLIENT_ID_ENV, + AUTH0_MANAGEMENT_CLIENT_SECRET_ENV, + AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE_ENV, +) +DOMAIN_PATTERN = re.compile( + r"^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?" + r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$" +) +SECRET_RESOURCE_PATTERN = re.compile(r"^projects/[^/]+/secrets/[^/]+/versions/[^/]+$") +HTTP_TIMEOUT_SECONDS = 10.0 +TOKEN_REFRESH_MARGIN_SECONDS = 60 + + +class Auth0ManagementConfigurationError(RuntimeError): + """Raised when the optional Management API settings are incomplete.""" + + +class Auth0ManagementError(RuntimeError): + """Raised when the Management API rejects or fails a request.""" + + +@dataclass(frozen=True) +class Auth0ManagementSettings: + domain: str + client_id: str + client_secret: str = field(default="", repr=False) + client_secret_resource: str = "" + + +def load_auth0_management_settings( + environ: Mapping[str, str] | None = None, +) -> Auth0ManagementSettings | None: + """Return complete Management API settings, or ``None`` when absent.""" + + values = os.environ if environ is None else environ + domain = values.get(AUTH0_MANAGEMENT_DOMAIN_ENV, "").strip() + client_id = values.get(AUTH0_MANAGEMENT_CLIENT_ID_ENV, "").strip() + client_secret = values.get(AUTH0_MANAGEMENT_CLIENT_SECRET_ENV, "").strip() + secret_resource = values.get( + AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE_ENV, "" + ).strip() + if not any((domain, client_id, client_secret, secret_resource)): + return None + if client_secret and secret_resource: + raise Auth0ManagementConfigurationError( + f"Set exactly one of {AUTH0_MANAGEMENT_CLIENT_SECRET_ENV} or " + f"{AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE_ENV}" + ) + if not domain or not client_id or not (client_secret or secret_resource): + raise Auth0ManagementConfigurationError( + "Auth0 Management API deletion is partially configured: set " + f"{AUTH0_MANAGEMENT_DOMAIN_ENV}, {AUTH0_MANAGEMENT_CLIENT_ID_ENV}, and " + "exactly one client-secret source" + ) + if DOMAIN_PATTERN.fullmatch(domain) is None: + raise Auth0ManagementConfigurationError( + f"{AUTH0_MANAGEMENT_DOMAIN_ENV} must be a bare host name" + ) + if secret_resource and SECRET_RESOURCE_PATTERN.fullmatch(secret_resource) is None: + raise Auth0ManagementConfigurationError( + f"{AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE_ENV} is invalid" + ) + return Auth0ManagementSettings( + domain=domain, + client_id=client_id, + client_secret=client_secret, + client_secret_resource=secret_resource, + ) + + +@lru_cache(maxsize=None) +def _load_secret_from_secret_manager(resource_name: str) -> str: + from google.cloud import secretmanager + + client = secretmanager.SecretManagerServiceClient() + response = client.access_secret_version(request={"name": resource_name}) + return response.payload.data.decode("utf-8") + + +class Auth0ManagementClient: + """Delete Auth0 users through a cached client-credentials token.""" + + def __init__( + self, + settings: Auth0ManagementSettings, + *, + http_client: httpx.Client | None = None, + secret_loader: Callable[[str], str] = _load_secret_from_secret_manager, + ) -> None: + self._settings = settings + self._http_client = http_client + self._secret_loader = secret_loader + self._token: str | None = None + self._expires_at = 0.0 + self._lock = threading.Lock() + + @property + def _base_url(self) -> str: + return f"https://{self._settings.domain}" + + def _client_secret(self) -> str: + if self._settings.client_secret: + return self._settings.client_secret + try: + secret = self._secret_loader(self._settings.client_secret_resource).strip() + except Exception as error: + raise Auth0ManagementError( + "the Management API client secret could not be loaded" + ) from error + if not secret: + raise Auth0ManagementError("the Management API client secret is empty") + return secret + + def _request(self, method: str, url: str, **kwargs: object) -> httpx.Response: + try: + if self._http_client is not None: + return self._http_client.request(method, url, **kwargs) # type: ignore[arg-type] + with httpx.Client(timeout=HTTP_TIMEOUT_SECONDS) as client: + return client.request(method, url, **kwargs) # type: ignore[arg-type] + except httpx.HTTPError as error: + raise Auth0ManagementError( + f"Management API request failed: {type(error).__name__}" + ) from error + + def _access_token(self) -> str: + with self._lock: + now = time.time() + if ( + self._token is not None + and now < self._expires_at - TOKEN_REFRESH_MARGIN_SECONDS + ): + return self._token + response = self._request( + "POST", + f"{self._base_url}/oauth/token", + json={ + "grant_type": "client_credentials", + "client_id": self._settings.client_id, + "client_secret": self._client_secret(), + "audience": f"{self._base_url}/api/v2/", + }, + ) + if response.status_code != 200: + raise Auth0ManagementError( + f"Management API token request failed: HTTP {response.status_code}" + ) + payload = response.json() + token = payload.get("access_token") if isinstance(payload, dict) else None + expires_in = ( + payload.get("expires_in") if isinstance(payload, dict) else None + ) + if not isinstance(token, str) or not token: + raise Auth0ManagementError("Management API token response is invalid") + lifetime = int(expires_in) if isinstance(expires_in, int) else 0 + self._token = token + self._expires_at = now + max(lifetime, TOKEN_REFRESH_MARGIN_SECONDS * 2) + return token + + def _discard_token(self, token: str) -> None: + """Forget a rejected token unless another thread already replaced it.""" + + with self._lock: + if self._token == token: + self._token = None + self._expires_at = 0.0 + + def prepare_deletion(self) -> None: + """Load the client secret and obtain a token before any data changes. + + Callers run this before deleting their own records, so a credential or + Secret Manager failure stops the deletion with nothing removed. + """ + + self._access_token() + + def _delete_request(self, subject: str, token: str) -> httpx.Response: + return self._request( + "DELETE", + f"{self._base_url}/api/v2/users/{quote(subject, safe='')}", + headers={"Authorization": f"Bearer {token}"}, + ) + + def delete_user(self, subject: str) -> None: + """Delete one Auth0 user; an already-absent user counts as deleted. + + A 401 means the cached token was revoked or rotated, so it is dropped + and the request is retried once with a fresh token. + """ + + token = self._access_token() + response = self._delete_request(subject, token) + if response.status_code == 401: + self._discard_token(token) + response = self._delete_request(subject, self._access_token()) + if response.status_code in {200, 202, 204, 404}: + return + raise Auth0ManagementError( + f"Management API user deletion failed: HTTP {response.status_code}" + ) + + +_shared_clients: dict[Auth0ManagementSettings, Auth0ManagementClient] = {} +_shared_clients_lock = threading.Lock() + + +def _shared_client(settings: Auth0ManagementSettings) -> Auth0ManagementClient: + """Return one process-wide client per settings so its token is reused.""" + + with _shared_clients_lock: + client = _shared_clients.get(settings) + if client is None: + client = Auth0ManagementClient(settings) + _shared_clients[settings] = client + return client + + +def build_auth0_user_deleter( + environ: Mapping[str, str] | None = None, +) -> Auth0ManagementClient | None: + """Return the deleter for the configured tenant, or ``None``. + + Every call with the same settings returns one shared client, so the + Management API token is fetched once and reused until shortly before it + expires. Settings are read on each call, so callers invoke this only when + they are about to delete an account. + """ + + settings = load_auth0_management_settings(environ) + if settings is None: + return None + return _shared_client(settings) diff --git a/policyengine_api/libs/user_auth.py b/policyengine_api/libs/user_auth.py new file mode 100644 index 000000000..7648edf4f --- /dev/null +++ b/policyengine_api/libs/user_auth.py @@ -0,0 +1,288 @@ +"""Inbound authentication for user-owned Public API routes. + +The Public API accepts RS256 access tokens issued by the PolicyEngine Auth0 +tenant for the Public API resource server. The decoder is ported from the +Simulation Entrypoint (``policyengine_simulation_entry.auth``): the JWKS is +fetched from ``{issuer}.well-known/jwks.json`` and ``iss``/``aud`` are +verified. Machine-to-machine tokens (``…@clients`` subjects) are classified +as client principals so user routes can refuse them. + +A signed-out browser instead sends ``PolicyEngine-Unauthenticated-User``: a +random UUID it generates once and keeps. That UUID is a bearer credential for +the browser's unauthenticated user; it needs no Auth0 configuration. + +This module never logs a token, subject, email address, or browser UUID. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from enum import StrEnum +from functools import lru_cache +import logging +import os +import re +from typing import Any, Protocol +from urllib.parse import urlparse +from uuid import UUID + +import jwt + + +PUBLIC_API_AUTH_REQUIRED_ENV = "PUBLIC_API_AUTH_REQUIRED" +PUBLIC_API_AUTH_ISSUER_ENV = "PUBLIC_API_AUTH_ISSUER" +PUBLIC_API_AUTH_AUDIENCE_ENV = "PUBLIC_API_AUTH_AUDIENCE" +PUBLIC_API_AUTH_ENV_VARS = ( + PUBLIC_API_AUTH_REQUIRED_ENV, + PUBLIC_API_AUTH_ISSUER_ENV, + PUBLIC_API_AUTH_AUDIENCE_ENV, +) + +UNAUTHENTICATED_USER_HEADER = "PolicyEngine-Unauthenticated-User" +# The canonical lowercase, hyphenated form that crypto.randomUUID() produces. +CANONICAL_UUID_PATTERN = ( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" +) + +CLIENT_SUBJECT_SUFFIX = "@clients" +CLIENT_CREDENTIALS_GRANT = "client-credentials" +TOKEN_ALGORITHMS = ("RS256",) +REQUIRED_TOKEN_CLAIMS = ("exp", "iss", "aud", "sub") +MAXIMUM_EMAIL_LENGTH = 320 +JWKS_TIMEOUT_SECONDS = 5 + +logger = logging.getLogger(__name__) + + +class UserAuthConfigurationError(RuntimeError): + """Raised when the inbound user-authentication settings are unsafe.""" + + +class UserAuthError(Exception): + """Base class for a request that cannot be authenticated.""" + + +class MissingBearerTokenError(UserAuthError): + """Raised when a request carries no bearer access token.""" + + +class InvalidBearerTokenError(UserAuthError): + """Raised when a bearer token fails signature or claim validation.""" + + +class UserAuthUnavailableError(UserAuthError): + """Raised when authentication cannot be performed by this deployment.""" + + +class InvalidUnauthenticatedUserError(UserAuthError): + """Raised when the unauthenticated-user header is not a canonical UUID.""" + + +class PrincipalKind(StrEnum): + USER = "user" + CLIENT = "client" + + +@dataclass(frozen=True) +class UserAuthSettings: + """Validated inbound authentication settings.""" + + required: bool + issuer: str + audience: str + + @property + def configured(self) -> bool: + return bool(self.issuer and self.audience) + + +@dataclass(frozen=True) +class VerifiedPrincipal: + """Identity asserted by one validated access token.""" + + issuer: str + subject: str + kind: PrincipalKind + email: str | None = None + + +class TokenDecoder(Protocol): + def decode(self, token: str) -> Mapping[str, Any]: ... + + +def _normalized_issuer(value: str) -> str: + return f"{value.rstrip('/')}/" if value else "" + + +def _validate_issuer_url(value: str) -> None: + parsed = urlparse(value) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.params + or parsed.query + or parsed.fragment + ): + raise UserAuthConfigurationError( + f"{PUBLIC_API_AUTH_ISSUER_ENV} must be an absolute HTTPS URL without " + "credentials, parameters, a query string, or a fragment" + ) + + +def load_user_auth_settings( + environ: Mapping[str, str] | None = None, +) -> UserAuthSettings: + """Load and validate the inbound user-authentication configuration.""" + + values = os.environ if environ is None else environ + required_value = values.get(PUBLIC_API_AUTH_REQUIRED_ENV, "").strip() + if required_value not in {"", "0", "1"}: + raise UserAuthConfigurationError( + f"{PUBLIC_API_AUTH_REQUIRED_ENV} must be 0 or 1" + ) + required = required_value == "1" + issuer = _normalized_issuer(values.get(PUBLIC_API_AUTH_ISSUER_ENV, "").strip()) + audience = values.get(PUBLIC_API_AUTH_AUDIENCE_ENV, "").strip() + if bool(issuer) != bool(audience): + raise UserAuthConfigurationError( + f"Set both {PUBLIC_API_AUTH_ISSUER_ENV} and " + f"{PUBLIC_API_AUTH_AUDIENCE_ENV}, or neither" + ) + if required and not issuer: + raise UserAuthConfigurationError( + f"{PUBLIC_API_AUTH_REQUIRED_ENV}=1 requires " + f"{PUBLIC_API_AUTH_ISSUER_ENV} and {PUBLIC_API_AUTH_AUDIENCE_ENV}" + ) + if issuer: + _validate_issuer_url(issuer) + return UserAuthSettings(required=required, issuer=issuer, audience=audience) + + +class JWTDecoder: + """Validate Auth0 RS256 access tokens without database dependencies.""" + + def __init__( + self, + issuer: str, + audience: str, + *, + jwks_client: jwt.PyJWKClient | None = None, + ) -> None: + self.issuer = issuer + self.audience = audience + self.jwks_client = jwks_client or jwt.PyJWKClient( + f"{issuer}.well-known/jwks.json", + cache_keys=True, + timeout=JWKS_TIMEOUT_SECONDS, + ) + + def decode(self, token: str) -> Mapping[str, Any]: + signing_key = self.jwks_client.get_signing_key_from_jwt(token).key + return jwt.decode( + token, + signing_key, + algorithms=list(TOKEN_ALGORITHMS), + audience=self.audience, + issuer=self.issuer, + options={"require": list(REQUIRED_TOKEN_CLAIMS)}, + ) + + +@lru_cache(maxsize=8) +def _decoder(issuer: str, audience: str) -> JWTDecoder: + return JWTDecoder(issuer=issuer, audience=audience) + + +def reset_decoder_cache() -> None: + """Clear cached decoders for tests and signing-key or tenant rotations.""" + + _decoder.cache_clear() + + +def bearer_token(authorization: str | None) -> str: + """Return the credentials of an ``Authorization: Bearer`` header.""" + + if not authorization: + raise MissingBearerTokenError("a bearer access token is required") + scheme, _, credentials = authorization.strip().partition(" ") + credentials = credentials.strip() + if scheme.lower() != "bearer" or not credentials: + raise MissingBearerTokenError("a bearer access token is required") + return credentials + + +def unauthenticated_user_id(value: str) -> UUID: + """Return the browser UUID carried by the unauthenticated-user header.""" + + if re.fullmatch(CANONICAL_UUID_PATTERN, value) is None: + raise InvalidUnauthenticatedUserError( + f"{UNAUTHENTICATED_USER_HEADER} must be a canonical UUID" + ) + return UUID(value) + + +def _verified_email(claims: Mapping[str, Any]) -> str | None: + email = claims.get("email") + if claims.get("email_verified") is not True or not isinstance(email, str): + return None + email = email.strip() + if not email or len(email) > MAXIMUM_EMAIL_LENGTH: + return None + return email + + +def classify_principal(claims: Mapping[str, Any]) -> VerifiedPrincipal: + """Build a principal from already-validated token claims.""" + + subject = claims.get("sub") + issuer = claims.get("iss") + if not isinstance(subject, str) or not subject.strip(): + raise InvalidBearerTokenError("the access token has no subject") + if not isinstance(issuer, str) or not issuer: + raise InvalidBearerTokenError("the access token has no issuer") + is_client = ( + subject.endswith(CLIENT_SUBJECT_SUFFIX) + or claims.get("gty") == CLIENT_CREDENTIALS_GRANT + ) + return VerifiedPrincipal( + issuer=issuer, + subject=subject, + kind=PrincipalKind.CLIENT if is_client else PrincipalKind.USER, + email=None if is_client else _verified_email(claims), + ) + + +def authenticate_bearer( + authorization: str | None, + settings: UserAuthSettings, + *, + decoder: TokenDecoder | None = None, +) -> VerifiedPrincipal: + """Validate one Authorization header and classify its principal.""" + + token = bearer_token(authorization) + if not settings.configured: + raise UserAuthUnavailableError("user authentication is not configured") + selected_decoder = decoder or _decoder(settings.issuer, settings.audience) + try: + claims = selected_decoder.decode(token) + except jwt.PyJWKClientConnectionError as error: + logger.warning( + "public_api_user_auth_unavailable", + extra={"policyengine_attributes": {"error_type": type(error).__name__}}, + ) + raise UserAuthUnavailableError( + "the signing keys could not be retrieved" + ) from error + except (jwt.PyJWTError, ValueError) as error: + logger.info( + "public_api_user_auth_rejected", + extra={"policyengine_attributes": {"error_type": type(error).__name__}}, + ) + raise InvalidBearerTokenError("the bearer access token is invalid") from error + if claims.get("iss") != settings.issuer: + raise InvalidBearerTokenError("the bearer access token is invalid") + return classify_principal(claims) diff --git a/policyengine_api/migration_flags.py b/policyengine_api/migration_flags.py index d4496e6d5..442d76179 100644 --- a/policyengine_api/migration_flags.py +++ b/policyengine_api/migration_flags.py @@ -214,12 +214,20 @@ def get_migration_context( use_configured_db_sources: bool = True, db_write_source: str | None = None, db_read_source: str | None = None, + include_compute: bool = True, ) -> MigrationContext: - """Return current migration flag values for a request or route group.""" + """Return current migration flag values for a request or route group. + + ``include_compute=False`` describes a persistence-only request in a route + group that also computes, so it carries no simulation flow or compute + selection. + """ route_config = ROUTE_GROUP_CONFIG_BY_NAME.get(route_group) if db_entity is None and route_config is not None: db_entity = route_config.db_entity - if sim_flow is None and route_config is not None: + if not include_compute: + sim_flow = None + elif sim_flow is None and route_config is not None: sim_flow = route_config.sim_flow if use_configured_db_sources: @@ -256,6 +264,7 @@ def get_migration_log_context( use_configured_db_sources: bool = True, db_write_source: str | None = None, db_read_source: str | None = None, + include_compute: bool = True, ) -> dict: """Best-effort logging context; never raises on invalid flag settings.""" try: @@ -265,6 +274,7 @@ def get_migration_log_context( use_configured_db_sources=use_configured_db_sources, db_write_source=db_write_source, db_read_source=db_read_source, + include_compute=include_compute, ).to_log_dict() except ValueError as error: return { diff --git a/policyengine_api/migration_logging.py b/policyengine_api/migration_logging.py index e4432ace1..d5f1e7e8c 100644 --- a/policyengine_api/migration_logging.py +++ b/policyengine_api/migration_logging.py @@ -34,40 +34,46 @@ "variables", } ) -V2_POLICY_RESOURCE_SEGMENTS = frozenset({"policies", "user-policies"}) -V2_HOUSEHOLD_RESOURCE_SEGMENTS = frozenset({"households", "user-households"}) +# Native v2 resources served from Supabase. The signed-in user resources +# (/v2/me*, /v2/user-*) keep their data only in Supabase, whatever the route +# group's configured v1 database flags say. +V2_SUPABASE_RESOURCE_SEGMENTS = frozenset( + { + "policies", + "user-policies", + "households", + "user-households", + "user-simulations", + "user-reports", + "me", + } +) +# Signed-in user resources only store and read saved work. Their route groups +# (household, simulation, report, user_profile) also compute, but these +# requests never do, so they log no simulation flow or compute selection. +V2_USER_RESOURCE_SEGMENTS = frozenset( + {"user-policies", "user-households", "user-simulations", "user-reports", "me"} +) +V2_RESOURCE_METHODS = frozenset({"GET", "POST", "PATCH", "DELETE"}) -def _is_v2_metadata_resource_read(method: str, path: str) -> bool: - if method != "GET": - return False +def _v2_resource_segment(path: str) -> str | None: segments = [segment for segment in path.strip("/").split("/") if segment] - return ( - len(segments) >= 2 - and segments[0] == "v2" - and segments[1] in V2_METADATA_RESOURCE_SEGMENTS - ) + if len(segments) >= 2 and segments[0] == "v2": + return segments[1] + return None -def _is_v2_policy_resource(method: str, path: str) -> bool: - if method not in {"GET", "POST", "PATCH", "DELETE"}: - return False - segments = [segment for segment in path.strip("/").split("/") if segment] +def _is_v2_metadata_resource_read(method: str, path: str) -> bool: return ( - len(segments) >= 2 - and segments[0] == "v2" - and segments[1] in V2_POLICY_RESOURCE_SEGMENTS + method == "GET" and _v2_resource_segment(path) in V2_METADATA_RESOURCE_SEGMENTS ) -def _is_v2_household_resource(method: str, path: str) -> bool: - if method not in {"GET", "POST", "PATCH", "DELETE"}: - return False - segments = [segment for segment in path.strip("/").split("/") if segment] +def _is_v2_supabase_resource(method: str, path: str) -> bool: return ( - len(segments) >= 2 - and segments[0] == "v2" - and segments[1] in V2_HOUSEHOLD_RESOURCE_SEGMENTS + method in V2_RESOURCE_METHODS + and _v2_resource_segment(path) in V2_SUPABASE_RESOURCE_SEGMENTS ) @@ -187,26 +193,18 @@ def _migration_context( ) -> dict[str, str | None]: route_group = infer_route_group(path) is_v2_metadata_read = _is_v2_metadata_resource_read(method, path) - is_v2_policy_resource = _is_v2_policy_resource(method, path) - is_v2_household_resource = _is_v2_household_resource(method, path) - uses_explicit_v2_source = ( - is_v2_metadata_read or is_v2_policy_resource or is_v2_household_resource - ) + is_v2_supabase_resource = _is_v2_supabase_resource(method, path) return get_migration_log_context( route_group, route_impl=route_impl, - use_configured_db_sources=not uses_explicit_v2_source, + use_configured_db_sources=not (is_v2_metadata_read or is_v2_supabase_resource), db_write_source=( - "supabase" - if (is_v2_policy_resource or is_v2_household_resource) - and method in {"POST", "PATCH", "DELETE"} - else None + "supabase" if is_v2_supabase_resource and method != "GET" else None ), db_read_source=( "supabase" - if is_v2_metadata_read - or (is_v2_policy_resource or is_v2_household_resource) - and method == "GET" + if is_v2_metadata_read or (is_v2_supabase_resource and method == "GET") else None ), + include_compute=_v2_resource_segment(path) not in V2_USER_RESOURCE_SEGMENTS, ) diff --git a/policyengine_api/migration_registry.py b/policyengine_api/migration_registry.py index a99ae2131..878452e4e 100644 --- a/policyengine_api/migration_registry.py +++ b/policyengine_api/migration_registry.py @@ -68,19 +68,19 @@ class RouteGroupConfig: ), RouteGroupConfig( name="simulation", - path_segments=("simulation", "simulations"), + path_segments=("simulation", "simulations", "user-simulations"), db_entity="simulation", sim_flow="economy", ), RouteGroupConfig( name="report", - path_segments=("report",), + path_segments=("report", "user-reports"), db_entity="report", sim_flow="report", ), RouteGroupConfig( name="user_profile", - path_segments=("user-profile",), + path_segments=("user-profile", "me"), db_entity="user", ), ) diff --git a/policyengine_api/query_parameters.py b/policyengine_api/query_parameters.py index 3be792058..8075f11f0 100644 --- a/policyengine_api/query_parameters.py +++ b/policyengine_api/query_parameters.py @@ -81,6 +81,15 @@ def validate_legacy_user_id(value: str) -> str: UUID, Field(description="V2 user UUID; does not prove caller control"), ] +MAXIMUM_LEGACY_RESOURCE_ID = 2**63 - 1 +LegacyResourceId = Annotated[ + int, + Field( + ge=1, + le=MAXIMUM_LEGACY_RESOURCE_ID, + description="Exact country-scoped v1 database identifier", + ), +] LegacyUserId = Annotated[ str, Field( @@ -206,10 +215,10 @@ class PolicyCollectionQuery(CountryQuery, PaginationQuery): class UserPolicyCollectionQuery(CountryQuery, PaginationQuery): - """Query contract for one v2 user's policy associations.""" + """Query contract for the signed-in user's policy associations.""" - user_id: UserId policy_id: ResourceId | None = None + legacy_policy_id: LegacyResourceId | None = None class HouseholdCreateQuery(CountryQuery): @@ -235,10 +244,10 @@ class UserHouseholdDetailQuery(CountryQuery): class UserHouseholdCollectionQuery(CountryQuery, PaginationQuery): - """Query contract for one v2 user's household associations.""" + """Query contract for the signed-in user's household associations.""" - user_id: UserId household_id: ResourceId | None = None + legacy_household_id: LegacyResourceId | None = None class UserHouseholdUpdateQuery(CountryQuery): @@ -249,6 +258,18 @@ class UserHouseholdDeleteQuery(CountryQuery): """Query contract for country-scoped association deletion.""" +class UserSimulationCollectionQuery(CountryQuery, PaginationQuery): + """Query contract for the signed-in user's simulation associations.""" + + legacy_simulation_id: LegacyResourceId | None = None + + +class UserReportCollectionQuery(CountryQuery, PaginationQuery): + """Query contract for the signed-in user's report associations.""" + + legacy_report_id: LegacyResourceId | None = None + + QueryParametersT = TypeVar("QueryParametersT", bound=StrictQueryParameters) diff --git a/policyengine_api/readiness.py b/policyengine_api/readiness.py index dfcdf2325..106ca424a 100644 --- a/policyengine_api/readiness.py +++ b/policyengine_api/readiness.py @@ -15,11 +15,15 @@ def validate_policy_runtime_configuration() -> None: - """Validate v1 source selectors, v2 persistence and measurement settings.""" + """Validate v1 source selectors, v2 persistence, user auth and measurement.""" from policyengine_api.data.v2.settings import ( load_v2_runtime_database_settings, ) + from policyengine_api.libs.auth0_management import ( + load_auth0_management_settings, + ) + from policyengine_api.libs.user_auth import load_user_auth_settings from policyengine_api.migration_flags import ( get_v1_household_read_source, get_v1_household_write_source, @@ -33,6 +37,10 @@ def validate_policy_runtime_configuration() -> None: get_v1_household_write_source() get_v1_household_read_source() load_v2_runtime_database_settings() + # User-owned routes fail closed without these settings; an inconsistent + # or required-but-missing configuration must stop the deployment here. + load_user_auth_settings() + load_auth0_management_settings() # A bundle whose measurement configuration this build cannot serve would # reject every country request. Report it here, where the deployment is # gated, instead of only on the requests themselves. diff --git a/policyengine_api/services/v2/associations/__init__.py b/policyengine_api/services/v2/associations/__init__.py new file mode 100644 index 000000000..50ae87f07 --- /dev/null +++ b/policyengine_api/services/v2/associations/__init__.py @@ -0,0 +1 @@ +"""Shared vocabulary for user-owned v2 association resources.""" diff --git a/policyengine_api/services/v2/associations/services.py b/policyengine_api/services/v2/associations/services.py new file mode 100644 index 000000000..1e65411b7 --- /dev/null +++ b/policyengine_api/services/v2/associations/services.py @@ -0,0 +1,24 @@ +"""Operations shared by the four user-owned association services.""" + +from __future__ import annotations + +from policyengine_api.services.v2.associations.types import LegacyReferenceChecker +from policyengine_api.services.v2.associations.validators import ( + require_legacy_reference_checker, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind + + +def require_legacy_reference( + legacy_references: LegacyReferenceChecker | None, + kind: LegacyRecordKind, + *, + country_id: str, + legacy_id: int, +) -> None: + """Check one v1 reference before the caller opens its v2 transaction.""" + + require_legacy_reference_checker(legacy_references).require_existing( + country_id=country_id, + requested={kind: (legacy_id,)}, + ) diff --git a/policyengine_api/services/v2/associations/types.py b/policyengine_api/services/v2/associations/types.py new file mode 100644 index 000000000..af853112b --- /dev/null +++ b/policyengine_api/services/v2/associations/types.py @@ -0,0 +1,74 @@ +"""Framework-independent field types shared by user association resources.""" + +from __future__ import annotations + +from collections.abc import Collection, Mapping +from typing import TYPE_CHECKING, Annotated, Protocol, TypeAlias, Union + +from pydantic import BaseModel, ConfigDict, FiniteFloat, StringConstraints +from typing_extensions import TypeAliasType + +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind + + +ASSOCIATION_DESCRIPTION_MAX_LENGTH = 10_000 + +# A JSON value without NaN or Infinity. Python's JSON parser accepts both, but +# Postgres JSONB rejects them, so they must fail request validation instead. +# Type checkers get the plain recursive alias (as Pydantic's own JsonValue +# does); Pydantic needs TypeAliasType to build the recursive validator. +if TYPE_CHECKING: + FiniteJsonValue: TypeAlias = Union[ + list["FiniteJsonValue"], + dict[str, "FiniteJsonValue"], + str, + bool, + int, + float, + None, + ] +else: + FiniteJsonValue = TypeAliasType( + "FiniteJsonValue", + Union[ + list["FiniteJsonValue"], + dict[str, "FiniteJsonValue"], + str, + bool, + int, + FiniteFloat, + None, + ], + ) + +AssociationName = Annotated[str, StringConstraints(max_length=255)] +AssociationDescription = Annotated[ + str, StringConstraints(max_length=ASSOCIATION_DESCRIPTION_MAX_LENGTH) +] + + +class StrictAssociationModel(BaseModel): + """Reject fields outside a reviewed association contract. + + Float fields also reject NaN and Infinity, which Postgres cannot store. + """ + + model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False) + + +class LegacyReferenceChecker(Protocol): + """Read-only v1 existence checks performed before a v2 transaction.""" + + def existing_ids( + self, + *, + country_id: str, + requested: Mapping[LegacyRecordKind, Collection[int]], + ) -> dict[LegacyRecordKind, frozenset[int]]: ... + + def require_existing( + self, + *, + country_id: str, + requested: Mapping[LegacyRecordKind, Collection[int]], + ) -> None: ... diff --git a/policyengine_api/services/v2/associations/validators.py b/policyengine_api/services/v2/associations/validators.py new file mode 100644 index 000000000..869db875a --- /dev/null +++ b/policyengine_api/services/v2/associations/validators.py @@ -0,0 +1,49 @@ +"""Typed failures shared by user-owned association resources.""" + +from __future__ import annotations + +from uuid import UUID + +from policyengine_api.services.v2.associations.types import LegacyReferenceChecker + + +class AssociationNotFoundError(LookupError): + """The association is absent, in another country, or owned by another user.""" + + +class AssociationReferenceConflictError(ValueError): + """The supplied references or countries contradict each other.""" + + +class AssociationConflictError(RuntimeError): + """The requested change conflicts with stored association state.""" + + +class MirrorOwnedAssociationError(AssociationConflictError): + """The association is maintained by the Stage 10 v1 saved-policy mirror.""" + + +class AssociationLinkNotFoundError(LookupError): + """A linked association is absent or not owned by the same user and country.""" + + +class LegacyReferenceCheckUnavailableError(RuntimeError): + """The service was built without the read-only v1 reference checker.""" + + +def require_legacy_reference_checker( + legacy_references: LegacyReferenceChecker | None, +) -> LegacyReferenceChecker: + if legacy_references is None: + raise LegacyReferenceCheckUnavailableError( + "v1 reference checks are not configured" + ) + return legacy_references + + +def require_not_mirror_owned(mirror_owned: bool, *, association_id: UUID) -> None: + if mirror_owned: + raise MirrorOwnedAssociationError( + f"association {association_id} is maintained from v1 saved policies " + "and cannot be changed here" + ) diff --git a/policyengine_api/services/v2/identity/__init__.py b/policyengine_api/services/v2/identity/__init__.py new file mode 100644 index 000000000..b15d60727 --- /dev/null +++ b/policyengine_api/services/v2/identity/__init__.py @@ -0,0 +1 @@ +"""User identity resolution, provisioning strategies, and account conversion.""" diff --git a/policyengine_api/services/v2/identity/database_connectors/__init__.py b/policyengine_api/services/v2/identity/database_connectors/__init__.py new file mode 100644 index 000000000..1a56302b5 --- /dev/null +++ b/policyengine_api/services/v2/identity/database_connectors/__init__.py @@ -0,0 +1 @@ +"""SQL-facing connector functions for v2 user identities and accounts.""" diff --git a/policyengine_api/services/v2/identity/database_connectors/creates.py b/policyengine_api/services/v2/identity/database_connectors/creates.py new file mode 100644 index 000000000..7071a32b8 --- /dev/null +++ b/policyengine_api/services/v2/identity/database_connectors/creates.py @@ -0,0 +1,60 @@ +"""Database inserts used by v2 identity provisioning.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy.dialects.postgresql import insert +from sqlmodel import Session, col + +from policyengine_api.data.v2.models import User, UserIdentity, UserType + + +def create_provisioned_user(session: Session, *, primary_country: str) -> User: + user = User(primary_country=primary_country, user_type=UserType.STANDARD.value) + session.add(user) + session.flush() + return user + + +def create_unauthenticated_user(session: Session, *, user_id: UUID) -> bool: + """Insert an unauthenticated user unless a user with its ID already exists. + + Returns whether this call inserted the row. + """ + + return ( + session.execute( + insert(User) + .values(id=user_id, user_type=UserType.UNAUTHENTICATED.value) + .on_conflict_do_nothing(index_elements=[col(User.id)]) + .returning(col(User.id)) + ).scalar_one_or_none() + is not None + ) + + +def create_user_identity( + session: Session, + *, + user_id: UUID, + issuer: str, + subject: str, + email: str | None, + seen_at: datetime, +) -> UUID | None: + """Insert one identity, returning its user unless another request won.""" + + return session.execute( + insert(UserIdentity) + .values( + user_id=user_id, + issuer=issuer, + subject=subject, + email=email, + last_seen_at=seen_at, + ) + .on_conflict_do_nothing(constraint="uq_user_identities_issuer_subject") + .returning(col(UserIdentity.user_id)) + ).scalar_one_or_none() diff --git a/policyengine_api/services/v2/identity/database_connectors/deletes.py b/policyengine_api/services/v2/identity/database_connectors/deletes.py new file mode 100644 index 000000000..1355490cd --- /dev/null +++ b/policyengine_api/services/v2/identity/database_connectors/deletes.py @@ -0,0 +1,26 @@ +"""Database deletes used by v2 identity conversion and account deletion.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import delete +from sqlmodel import Session, col + +from policyengine_api.data.v2.models import User, UserType + + +def delete_unauthenticated_user(session: Session, *, user_id: UUID) -> None: + """Delete an unauthenticated user; its associations cascade. + + The statement matches only an unauthenticated row, so it never deletes a + standard user, including one converted by a concurrent transaction. + """ + + session.execute( + delete(User).where( + col(User.id) == user_id, + col(User.user_type) == UserType.UNAUTHENTICATED.value, + ) + ) + session.flush() diff --git a/policyengine_api/services/v2/identity/database_connectors/reads.py b/policyengine_api/services/v2/identity/database_connectors/reads.py new file mode 100644 index 000000000..b90d9949f --- /dev/null +++ b/policyengine_api/services/v2/identity/database_connectors/reads.py @@ -0,0 +1,66 @@ +"""Database selections used by v2 identity resolution.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import Session, select + +from policyengine_api.data.v2.models import User, UserIdentity, UserType + + +def read_user_identity( + session: Session, + *, + issuer: str, + subject: str, +) -> UserIdentity | None: + return session.exec( + select(UserIdentity).where( + UserIdentity.issuer == issuer, + UserIdentity.subject == subject, + ) + ).one_or_none() + + +def read_user( + session: Session, + user_id: UUID, + *, + for_share: bool = False, +) -> User | None: + """Read one user, optionally holding ``FOR SHARE`` until the transaction ends. + + The share lock lets concurrent writers of one unauthenticated user proceed + together but waits for a conversion holding ``FOR UPDATE``, then re-reads + the row as that conversion committed it. + """ + + statement = select(User).where(User.id == user_id) + if for_share: + statement = statement.with_for_update(read=True) + return session.exec( + statement.execution_options(populate_existing=True) + ).one_or_none() + + +def read_unauthenticated_user_for_update( + session: Session, + user_id: UUID, +) -> User | None: + """Lock and return an unauthenticated user. + + A standard user is neither returned nor locked. A row converted by a + concurrent transaction is re-checked once that transaction commits, so + it is no longer returned. + """ + + return session.exec( + select(User) + .where( + User.id == user_id, + User.user_type == UserType.UNAUTHENTICATED.value, + ) + .with_for_update() + .execution_options(populate_existing=True) + ).one_or_none() diff --git a/policyengine_api/services/v2/identity/database_connectors/updates.py b/policyengine_api/services/v2/identity/database_connectors/updates.py new file mode 100644 index 000000000..ab79814a7 --- /dev/null +++ b/policyengine_api/services/v2/identity/database_connectors/updates.py @@ -0,0 +1,77 @@ +"""Database updates used by v2 identity resolution and conversion.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import update +from sqlmodel import Session, col + +from policyengine_api.data.v2.models import ( + User, + UserHouseholdAssociation, + UserIdentity, + UserPolicy, + UserReportAssociation, + UserSimulationAssociation, + UserType, +) + + +USER_ASSOCIATION_TABLES = ( + UserPolicy, + UserHouseholdAssociation, + UserSimulationAssociation, + UserReportAssociation, +) + + +def update_user_identity_activity( + session: Session, + identity: UserIdentity, + *, + email: str | None, + seen_at: datetime, +) -> UserIdentity: + identity.email = email + identity.last_seen_at = seen_at + session.add(identity) + session.flush() + return identity + + +def update_user_to_standard( + session: Session, + user: User, + *, + primary_country: str, +) -> User: + user.user_type = UserType.STANDARD.value + user.primary_country = primary_country + session.add(user) + session.flush() + return user + + +def reassign_user_associations( + session: Session, + *, + from_user_id: UUID, + to_user_id: UUID, +) -> None: + """Move every association row from one owner to another. + + Association IDs, contents, and timestamps are unchanged, so links between + the moved rows (a report's ``user_policy_id`` and + ``baseline_user_policy_id``) stay valid. + """ + + for table in USER_ASSOCIATION_TABLES: + session.execute( + update(table) + .where(col(table.user_id) == from_user_id) + .values(user_id=to_user_id, updated_at=col(table.updated_at)) + .execution_options(synchronize_session=False) + ) + session.flush() diff --git a/policyengine_api/services/v2/identity/database_session.py b/policyengine_api/services/v2/identity/database_session.py new file mode 100644 index 000000000..b4e2603b6 --- /dev/null +++ b/policyengine_api/services/v2/identity/database_session.py @@ -0,0 +1,28 @@ +"""Transaction helpers for v2 identity provisioning.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from sqlalchemy.orm import SessionTransaction +from sqlmodel import Session + + +@contextmanager +def provisioning_savepoint(session: Session) -> Iterator[SessionTransaction]: + """Contain one provisioning attempt so a lost race can be discarded. + + The caller rolls the yielded savepoint back when another request created + the identity first; otherwise it is released when the block exits. + """ + + savepoint = session.begin_nested() + try: + yield savepoint + except BaseException: + if savepoint.is_active: + savepoint.rollback() + raise + if savepoint.is_active: + savepoint.commit() diff --git a/policyengine_api/services/v2/identity/services.py b/policyengine_api/services/v2/identity/services.py new file mode 100644 index 000000000..808e51e76 --- /dev/null +++ b/policyengine_api/services/v2/identity/services.py @@ -0,0 +1,354 @@ +"""Find, provision, and convert the v2 user a request acts as. + +Every user-owned route resolves its principal once per request inside its own +v2 transaction. A provisioning strategy, selected from the principal's type, +decides how: + +- ``StandardAccountProvisioning`` serves a verified sign-in identity. The + first request for an unknown ``(issuer, subject)`` provisions a standard + ``users`` row (with the request country as its primary country) and a + ``user_identities`` row, on reads as well as writes. Concurrent first + requests are reconciled by ``UNIQUE(issuer, subject)``: the loser rolls its + savepoint back and re-reads the winner's identity, so exactly one user is + created. There is no implicit adoption of legacy users and email is never + used for linking. +- ``UnauthenticatedAccountProvisioning`` serves a signed-out browser whose + credential is a UUID. Only writes provision its unauthenticated user, with + the browser's UUID as ``users.id``; a read for an absent user finds nothing. + A UUID that names a standard user is refused. + +:func:`convert_unauthenticated_user_in_session` moves a browser's +unauthenticated user into a signed-in account at sign-in. + +Only standard user UUIDs are logged. An unauthenticated user's UUID is its +credential, so its log records carry only the user type. +""" + +from __future__ import annotations + +from datetime import datetime +import logging +from uuid import UUID + +from sqlmodel import Session + +from policyengine_api.data.v2.models import UserType +from policyengine_api.data.v2.models.base import utc_now +from policyengine_api.services.v2.identity.database_connectors.creates import ( + create_provisioned_user, + create_unauthenticated_user, + create_user_identity, +) +from policyengine_api.services.v2.identity.database_connectors.deletes import ( + delete_unauthenticated_user, +) +from policyengine_api.services.v2.identity.database_connectors.reads import ( + read_unauthenticated_user_for_update, + read_user, + read_user_identity, +) +from policyengine_api.services.v2.identity.database_connectors.updates import ( + reassign_user_associations, + update_user_identity_activity, + update_user_to_standard, +) +from policyengine_api.services.v2.identity.database_session import ( + provisioning_savepoint, +) +from policyengine_api.services.v2.identity.transformations import ( + converted_primary_country, + identity_refresh_required, + refreshed_identity_email, + standard_resolved_user, + unauthenticated_resolved_user, +) +from policyengine_api.services.v2.identity.types import ( + AccountConversion, + AccountConversionOutcome, + AccountPrincipal, + AccountProvisioningStrategy, + ResolvedUser, + UnauthenticatedPrincipal, + UserPrincipal, +) +from policyengine_api.services.v2.identity.validators import ( + require_concurrent_identity, + require_provisioned_user, + require_unauthenticated_user, +) + + +logger = logging.getLogger(__name__) + +# A second attempt covers an unauthenticated user deleted (by its own account +# deletion, or by a merge at sign-in) between the insert and the re-read. +UNAUTHENTICATED_PROVISIONING_ATTEMPTS = 2 + + +class StandardAccountProvisioning: + """Provision the standard user of a verified sign-in identity on any request.""" + + def __init__(self, principal: UserPrincipal) -> None: + self._principal = principal + + def find_in_session(self, session: Session) -> ResolvedUser | None: + identity = read_user_identity( + session, + issuer=self._principal.issuer, + subject=self._principal.subject, + ) + if identity is None: + return None + return standard_resolved_user(identity.user_id, email=identity.email) + + def resolve_in_session( + self, + session: Session, + *, + country_id: str, + now: datetime | None = None, + ) -> ResolvedUser: + principal = self._principal + seen_at = now or utc_now() + identity = read_user_identity( + session, + issuer=principal.issuer, + subject=principal.subject, + ) + if identity is not None: + if identity_refresh_required(identity, email=principal.email, now=seen_at): + update_user_identity_activity( + session, + identity, + email=refreshed_identity_email(identity, principal.email), + seen_at=seen_at, + ) + return standard_resolved_user(identity.user_id, email=identity.email) + + with provisioning_savepoint(session) as savepoint: + user = create_provisioned_user(session, primary_country=country_id) + created_user_id = create_user_identity( + session, + user_id=user.id, + issuer=principal.issuer, + subject=principal.subject, + email=principal.email, + seen_at=seen_at, + ) + if created_user_id is None: + savepoint.rollback() + + if created_user_id is not None: + logger.info( + "provisioned_v2_user", + extra={ + "policyengine_attributes": { + "v2_user_id": str(created_user_id), + "user_type": UserType.STANDARD.value, + } + }, + ) + return standard_resolved_user(created_user_id, email=principal.email) + + concurrent = require_concurrent_identity( + read_user_identity( + session, + issuer=principal.issuer, + subject=principal.subject, + ) + ) + return standard_resolved_user(concurrent.user_id, email=concurrent.email) + + def resolve_for_read_in_session( + self, + session: Session, + *, + country_id: str, + ) -> ResolvedUser | None: + # A signed-in account exists from its first request, read or write. + return self.resolve_in_session(session, country_id=country_id) + + +class UnauthenticatedAccountProvisioning: + """Provision a signed-out browser's unauthenticated user on its first write.""" + + def __init__(self, principal: UnauthenticatedPrincipal) -> None: + self._principal = principal + + def find_in_session(self, session: Session) -> ResolvedUser | None: + user = require_unauthenticated_user(read_user(session, self._principal.user_id)) + return None if user is None else unauthenticated_resolved_user(user) + + def resolve_in_session( + self, + session: Session, + *, + country_id: str, + now: datetime | None = None, + ) -> ResolvedUser: + # An unauthenticated user has no primary country or sign-in activity. + del country_id, now + user_id = self._principal.user_id + created = False + user = None + for _attempt in range(UNAUTHENTICATED_PROVISIONING_ATTEMPTS): + created = create_unauthenticated_user(session, user_id=user_id) + # The share lock keeps a conversion or merge at sign-in from + # changing this user until the write commits, and waits for one + # already in progress. + user = require_unauthenticated_user( + read_user(session, user_id, for_share=True) + ) + if user is not None: + break + provisioned = require_provisioned_user(user) + if created: + logger.info( + "provisioned_v2_user", + extra={ + "policyengine_attributes": { + "user_type": UserType.UNAUTHENTICATED.value + } + }, + ) + return unauthenticated_resolved_user(provisioned) + + def resolve_for_read_in_session( + self, + session: Session, + *, + country_id: str, + ) -> ResolvedUser | None: + # Reads never provision: an absent user simply owns no records. + del country_id + return self.find_in_session(session) + + +def account_provisioning_for( + principal: AccountPrincipal, +) -> AccountProvisioningStrategy: + """Select the provisioning strategy for a principal's type.""" + + if isinstance(principal, UnauthenticatedPrincipal): + return UnauthenticatedAccountProvisioning(principal) + return StandardAccountProvisioning(principal) + + +def find_principal_user_in_session( + session: Session, + principal: AccountPrincipal, +) -> ResolvedUser | None: + """Return the existing user for a principal without provisioning one.""" + + return account_provisioning_for(principal).find_in_session(session) + + +def resolve_principal_user_in_session( + session: Session, + principal: AccountPrincipal, + *, + country_id: str, + now: datetime | None = None, +) -> ResolvedUser: + """Return the principal's v2 user, provisioning it on first use.""" + + return account_provisioning_for(principal).resolve_in_session( + session, country_id=country_id, now=now + ) + + +def resolve_principal_user_for_read_in_session( + session: Session, + principal: AccountPrincipal, + *, + country_id: str, +) -> ResolvedUser | None: + """Return the user a read-only operation acts as. + + A standard principal is provisioned as on any request. An unauthenticated + principal is not: ``None`` means it owns no records yet. + """ + + return account_provisioning_for(principal).resolve_for_read_in_session( + session, country_id=country_id + ) + + +def convert_unauthenticated_user_in_session( + session: Session, + *, + unauthenticated_user_id: UUID, + principal: UserPrincipal, + country_id: str, + now: datetime | None = None, +) -> AccountConversion: + """Move a browser's unauthenticated user into a signed-in account. + + The unauthenticated user's row is locked first (a standard user's row is + never locked): concurrent conversions of one browser run one at a time, + and each waits for that browser's writes in progress, which in turn wait + for it. + + - No user with the identity yet: the unauthenticated user becomes it in + place, keeping its ID and therefore every association it owns. + - The identity already has a standard user: every association moves to + that user unchanged, and the unauthenticated user is deleted. + - No unauthenticated user, or the ID names a standard user (one already + converted, or anyone else's): nothing is touched, and the identity's + user is resolved as on any request. + + A concurrent first request for the same identity is reconciled by + ``UNIQUE(issuer, subject)``: when it wins, this conversion merges into + its user instead. + """ + + seen_at = now or utc_now() + standard_provisioning = StandardAccountProvisioning(principal) + unauthenticated_user = read_unauthenticated_user_for_update( + session, unauthenticated_user_id + ) + if unauthenticated_user is None: + return AccountConversion( + owner=standard_provisioning.resolve_in_session( + session, country_id=country_id, now=seen_at + ), + outcome=AccountConversionOutcome.NONE, + ) + + converted = ( + standard_provisioning.find_in_session(session) is None + and create_user_identity( + session, + user_id=unauthenticated_user.id, + issuer=principal.issuer, + subject=principal.subject, + email=principal.email, + seen_at=seen_at, + ) + is not None + ) + if converted: + update_user_to_standard( + session, + unauthenticated_user, + primary_country=converted_primary_country( + unauthenticated_user, country_id=country_id + ), + ) + return AccountConversion( + owner=standard_resolved_user( + unauthenticated_user.id, email=principal.email + ), + outcome=AccountConversionOutcome.CONVERTED, + ) + + owner = standard_provisioning.resolve_in_session( + session, country_id=country_id, now=seen_at + ) + reassign_user_associations( + session, + from_user_id=unauthenticated_user.id, + to_user_id=owner.user_id, + ) + delete_unauthenticated_user(session, user_id=unauthenticated_user.id) + return AccountConversion(owner=owner, outcome=AccountConversionOutcome.MERGED) diff --git a/policyengine_api/services/v2/identity/transformations.py b/policyengine_api/services/v2/identity/transformations.py new file mode 100644 index 000000000..fc8b1de51 --- /dev/null +++ b/policyengine_api/services/v2/identity/transformations.py @@ -0,0 +1,49 @@ +"""Pure transformations for v2 identity resolution.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from uuid import UUID + +from policyengine_api.data.v2.models import User, UserIdentity, UserType +from policyengine_api.services.v2.identity.types import ResolvedUser + + +LAST_SEEN_REFRESH_INTERVAL = timedelta(hours=1) + + +def _aware(value: datetime) -> datetime: + return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) + + +def refreshed_identity_email(identity: UserIdentity, email: str | None) -> str | None: + """Keep a stored verified email when the current token carries none.""" + + return identity.email if email is None else email + + +def identity_refresh_required( + identity: UserIdentity, + *, + email: str | None, + now: datetime, +) -> bool: + """Write identity activity at most hourly, or when the email changes.""" + + if refreshed_identity_email(identity, email) != identity.email: + return True + return _aware(now) - _aware(identity.last_seen_at) >= LAST_SEEN_REFRESH_INTERVAL + + +def standard_resolved_user(user_id: UUID, *, email: str | None) -> ResolvedUser: + return ResolvedUser(user_id=user_id, email=email, user_type=UserType.STANDARD) + + +def unauthenticated_resolved_user(user: User) -> ResolvedUser: + return ResolvedUser(user_id=user.id, email=None, user_type=UserType.UNAUTHENTICATED) + + +def converted_primary_country(user: User, *, country_id: str) -> str: + """A converted user keeps any primary country, or takes the request's.""" + + return user.primary_country or country_id diff --git a/policyengine_api/services/v2/identity/types.py b/policyengine_api/services/v2/identity/types.py new file mode 100644 index 000000000..cd95481ef --- /dev/null +++ b/policyengine_api/services/v2/identity/types.py @@ -0,0 +1,94 @@ +"""Framework-independent data exchanged by v2 identity layers.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import StrEnum +from typing import TYPE_CHECKING, Annotated, Protocol, TypeAlias +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints + +from policyengine_api.data.v2.models import UserType + +if TYPE_CHECKING: + from sqlmodel import Session + + +IdentityText = Annotated[str, StringConstraints(min_length=1)] +IdentityEmail = Annotated[str, StringConstraints(min_length=1, max_length=320)] + + +class UserPrincipal(BaseModel): + """A verified external sign-in identity; never derived from request data.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + issuer: IdentityText + subject: IdentityText + email: IdentityEmail | None = None + + +class UnauthenticatedPrincipal(BaseModel): + """A signed-out browser, identified by the UUID it sends as its credential. + + The UUID is a bearer credential, so it is excluded from ``repr`` and must + never be logged. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + user_id: UUID = Field(repr=False) + + +AccountPrincipal: TypeAlias = UserPrincipal | UnauthenticatedPrincipal + + +@dataclass(frozen=True) +class ResolvedUser: + """The v2 user that one principal acts as.""" + + # An unauthenticated user's ID is its credential, so it stays out of repr. + user_id: UUID = field(repr=False) + email: str | None + user_type: UserType + + +class AccountProvisioningStrategy(Protocol): + """Find or provision the v2 user for one principal inside a transaction.""" + + def find_in_session(self, session: Session) -> ResolvedUser | None: + """Return the principal's existing user without writing anything.""" + + def resolve_in_session( + self, + session: Session, + *, + country_id: str, + now: datetime | None = None, + ) -> ResolvedUser: + """Return the principal's user, provisioning it when it is absent.""" + + def resolve_for_read_in_session( + self, + session: Session, + *, + country_id: str, + ) -> ResolvedUser | None: + """Return the user a read-only operation acts as, if one exists.""" + + +class AccountConversionOutcome(StrEnum): + # The unauthenticated user became the signed-in identity's user in place. + CONVERTED = "converted" + # Its records moved to the identity's existing user, and it was deleted. + MERGED = "merged" + # There was no unauthenticated user to convert. + NONE = "none" + + +@dataclass(frozen=True) +class AccountConversion: + owner: ResolvedUser + outcome: AccountConversionOutcome diff --git a/policyengine_api/services/v2/identity/validators.py b/policyengine_api/services/v2/identity/validators.py new file mode 100644 index 000000000..9a875a7ee --- /dev/null +++ b/policyengine_api/services/v2/identity/validators.py @@ -0,0 +1,48 @@ +"""Database-independent validation for v2 identity resolution.""" + +from __future__ import annotations + +from policyengine_api.data.v2.models import User, UserIdentity, UserType + + +class IdentityProvisioningError(RuntimeError): + """Raised when a concurrent provisioning conflict leaves no stored identity.""" + + +class StandardUserRequiresSignInError(PermissionError): + """Raised when an unauthenticated credential names a standard user. + + An unauthenticated principal never acts on a standard user, including one + converted from the same browser's unauthenticated user. + """ + + +def require_concurrent_identity(identity: UserIdentity | None) -> UserIdentity: + if identity is None: + raise IdentityProvisioningError( + "user identity provisioning conflict did not resolve to a stored row" + ) + return identity + + +def is_unauthenticated_user(user: User) -> bool: + return user.user_type == UserType.UNAUTHENTICATED + + +def require_unauthenticated_user(user: User | None) -> User | None: + """Pass through an absent or unauthenticated user; refuse a standard one.""" + + if user is not None and not is_unauthenticated_user(user): + raise StandardUserRequiresSignInError( + "This browser's saved work now belongs to a signed-in account; " + "sign in to continue" + ) + return user + + +def require_provisioned_user(user: User | None) -> User: + if user is None: + raise IdentityProvisioningError( + "unauthenticated user provisioning did not resolve to a stored row" + ) + return user diff --git a/policyengine_api/services/v2/legacy_refs/__init__.py b/policyengine_api/services/v2/legacy_refs/__init__.py new file mode 100644 index 000000000..67ef832d8 --- /dev/null +++ b/policyengine_api/services/v2/legacy_refs/__init__.py @@ -0,0 +1 @@ +"""Read-only existence checks for country-scoped v1 artifacts.""" diff --git a/policyengine_api/services/v2/legacy_refs/database_connectors/__init__.py b/policyengine_api/services/v2/legacy_refs/database_connectors/__init__.py new file mode 100644 index 000000000..601a881f3 --- /dev/null +++ b/policyengine_api/services/v2/legacy_refs/database_connectors/__init__.py @@ -0,0 +1 @@ +"""SQL-facing read connectors for v1 artifact existence.""" diff --git a/policyengine_api/services/v2/legacy_refs/database_connectors/reads.py b/policyengine_api/services/v2/legacy_refs/database_connectors/reads.py new file mode 100644 index 000000000..074b7357b --- /dev/null +++ b/policyengine_api/services/v2/legacy_refs/database_connectors/reads.py @@ -0,0 +1,56 @@ +"""Batched v1 artifact existence reads through the v1 mapped models.""" + +from __future__ import annotations + +from collections.abc import Collection + +from sqlalchemy import select +from sqlalchemy.orm import InstrumentedAttribute, Session + +from policyengine_api.data.v1_models import ( + Household, + Policy, + ReportOutput, + Simulation, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind + + +READ_BATCH_SIZE = 500 + + +def _identity_columns( + kind: LegacyRecordKind, +) -> tuple[InstrumentedAttribute[int], InstrumentedAttribute[str]]: + if kind is LegacyRecordKind.POLICY: + return Policy.id, Policy.country_id + if kind is LegacyRecordKind.HOUSEHOLD: + return Household.id, Household.country_id + if kind is LegacyRecordKind.SIMULATION: + return Simulation.id, Simulation.country_id + return ReportOutput.id, ReportOutput.country_id + + +def read_existing_legacy_ids( + session: Session, + *, + kind: LegacyRecordKind, + country_id: str, + legacy_ids: Collection[int], +) -> frozenset[int]: + """Return the requested IDs that exist for the country, in bounded batches.""" + + id_column, country_column = _identity_columns(kind) + requested = sorted(set(legacy_ids)) + existing: set[int] = set() + for start in range(0, len(requested), READ_BATCH_SIZE): + batch = requested[start : start + READ_BATCH_SIZE] + existing.update( + int(legacy_id) + for legacy_id in session.execute( + select(id_column) + .where(country_column == country_id, id_column.in_(batch)) + .distinct() + ).scalars() + ) + return frozenset(existing) diff --git a/policyengine_api/services/v2/legacy_refs/database_session.py b/policyengine_api/services/v2/legacy_refs/database_session.py new file mode 100644 index 000000000..ab4b66fc2 --- /dev/null +++ b/policyengine_api/services/v2/legacy_refs/database_session.py @@ -0,0 +1,24 @@ +"""Short read-only v1 Cloud SQL sessions for reference checks.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from sqlalchemy.orm import Session, sessionmaker + + +class LegacyReferenceDatabaseSession: + """Open one v1 transaction that is always rolled back and closed.""" + + def __init__(self, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + @contextmanager + def read(self) -> Iterator[Session]: + with self._session_factory() as session: + transaction = session.begin() + try: + yield session + finally: + transaction.rollback() diff --git a/policyengine_api/services/v2/legacy_refs/services.py b/policyengine_api/services/v2/legacy_refs/services.py new file mode 100644 index 000000000..48a86cd61 --- /dev/null +++ b/policyengine_api/services/v2/legacy_refs/services.py @@ -0,0 +1,103 @@ +"""Check v1 artifact references before a v2 association transaction opens. + +The read uses the v1 mapped models through the v1 session factory in a short +transaction that is rolled back and closed before the caller opens its v2 +transaction. It never writes, so it deliberately avoids +``resolve_report_output``. +""" + +from __future__ import annotations + +from collections.abc import Callable, Collection, Mapping + +from policyengine_api.services.v2.associations.types import LegacyReferenceChecker +from policyengine_api.services.v2.legacy_refs.database_connectors.reads import ( + read_existing_legacy_ids, +) +from policyengine_api.services.v2.legacy_refs.database_session import ( + LegacyReferenceDatabaseSession, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind +from policyengine_api.services.v2.legacy_refs.validators import ( + require_legacy_references, +) + + +class V1LegacyReferenceService: + """Batched, read-only v1 existence checks.""" + + def __init__(self, database_session: LegacyReferenceDatabaseSession) -> None: + self._database_session = database_session + + def existing_ids( + self, + *, + country_id: str, + requested: Mapping[LegacyRecordKind, Collection[int]], + ) -> dict[LegacyRecordKind, frozenset[int]]: + """Return existing IDs per kind using one short v1 transaction.""" + + selected = {kind: ids for kind, ids in requested.items() if ids} + if not selected: + return {kind: frozenset() for kind in requested} + with self._database_session.read() as session: + existing = { + kind: read_existing_legacy_ids( + session, + kind=kind, + country_id=country_id, + legacy_ids=ids, + ) + for kind, ids in selected.items() + } + return {kind: existing.get(kind, frozenset()) for kind in requested} + + def require_existing( + self, + *, + country_id: str, + requested: Mapping[LegacyRecordKind, Collection[int]], + ) -> None: + """Raise ``LegacyReferenceNotFoundError`` for any missing reference.""" + + existing = self.existing_ids(country_id=country_id, requested=requested) + for kind, ids in requested.items(): + require_legacy_references( + kind, + country_id=country_id, + requested=ids, + existing=existing[kind], + ) + + +class DeferredLegacyReferenceService: + """Build the v1 reference service only when a reference is checked. + + Reads, lists, deletes, and most account routes never check v1 + references, so they must not depend on the v1 database configuration. + """ + + def __init__(self, build: Callable[[], LegacyReferenceChecker]) -> None: + self._build = build + self._service: LegacyReferenceChecker | None = None + + def _resolved(self) -> LegacyReferenceChecker: + if self._service is None: + self._service = self._build() + return self._service + + def existing_ids( + self, + *, + country_id: str, + requested: Mapping[LegacyRecordKind, Collection[int]], + ) -> dict[LegacyRecordKind, frozenset[int]]: + return self._resolved().existing_ids(country_id=country_id, requested=requested) + + def require_existing( + self, + *, + country_id: str, + requested: Mapping[LegacyRecordKind, Collection[int]], + ) -> None: + self._resolved().require_existing(country_id=country_id, requested=requested) diff --git a/policyengine_api/services/v2/legacy_refs/types.py b/policyengine_api/services/v2/legacy_refs/types.py new file mode 100644 index 000000000..de70f498c --- /dev/null +++ b/policyengine_api/services/v2/legacy_refs/types.py @@ -0,0 +1,28 @@ +"""Framework-independent v1 artifact reference types.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Annotated + +from pydantic import Field, Strict + + +MAXIMUM_LEGACY_RECORD_ID = 2**63 - 1 + +LegacyRecordId = Annotated[ + int, + Strict(), + Field( + ge=1, + le=MAXIMUM_LEGACY_RECORD_ID, + description="Exact v1 database identifier (a JSON integer)", + ), +] + + +class LegacyRecordKind(StrEnum): + POLICY = "policy" + HOUSEHOLD = "household" + SIMULATION = "simulation" + REPORT = "report" diff --git a/policyengine_api/services/v2/legacy_refs/validators.py b/policyengine_api/services/v2/legacy_refs/validators.py new file mode 100644 index 000000000..c47c1da21 --- /dev/null +++ b/policyengine_api/services/v2/legacy_refs/validators.py @@ -0,0 +1,26 @@ +"""Database-independent validation of v1 artifact references.""" + +from __future__ import annotations + +from collections.abc import Collection + +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind + + +class LegacyReferenceNotFoundError(LookupError): + """Raised when a referenced v1 artifact does not exist in its country.""" + + +def require_legacy_references( + kind: LegacyRecordKind, + *, + country_id: str, + requested: Collection[int], + existing: Collection[int], +) -> None: + missing = sorted(set(requested) - set(existing)) + if missing: + rendered = ", ".join(str(legacy_id) for legacy_id in missing) + raise LegacyReferenceNotFoundError( + f"v1 {kind.value} {rendered} was not found for country {country_id}" + ) diff --git a/policyengine_api/services/v2/user_accounts/__init__.py b/policyengine_api/services/v2/user_accounts/__init__.py new file mode 100644 index 000000000..854a3590a --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/__init__.py @@ -0,0 +1 @@ +"""Account read, update, export, upgrade, and deletion services.""" diff --git a/policyengine_api/services/v2/user_accounts/database_connectors/__init__.py b/policyengine_api/services/v2/user_accounts/database_connectors/__init__.py new file mode 100644 index 000000000..f5151fbb8 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/database_connectors/__init__.py @@ -0,0 +1 @@ +"""SQL-only connector functions for signed-in v2 account records.""" diff --git a/policyengine_api/services/v2/user_accounts/database_connectors/deletes.py b/policyengine_api/services/v2/user_accounts/database_connectors/deletes.py new file mode 100644 index 000000000..9a5395145 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/database_connectors/deletes.py @@ -0,0 +1,26 @@ +"""Database deletes for signed-in v2 accounts. + +``legacy_user_mappings`` restricts user deletion, so its rows are removed +first. Deleting the user then cascades to identities and every association. +""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import delete +from sqlmodel import Session, col + +from policyengine_api.data.v2.models import LegacyUserMapping, User + + +def delete_account_legacy_user_mappings(session: Session, *, user_id: UUID) -> None: + session.execute( + delete(LegacyUserMapping).where(col(LegacyUserMapping.user_id) == user_id) + ) + session.flush() + + +def delete_account_user(session: Session, *, user_id: UUID) -> None: + session.execute(delete(User).where(col(User.id) == user_id)) + session.flush() diff --git a/policyengine_api/services/v2/user_accounts/database_connectors/reads.py b/policyengine_api/services/v2/user_accounts/database_connectors/reads.py new file mode 100644 index 000000000..bbb5f1860 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/database_connectors/reads.py @@ -0,0 +1,28 @@ +"""Database reads for signed-in v2 accounts.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import Session, col, select + +from policyengine_api.data.v2.models import User, UserIdentity + + +def read_account_user(session: Session, user_id: UUID) -> User | None: + return session.get(User, user_id) + + +def read_identity_subjects( + session: Session, + *, + user_id: UUID, + issuer: str, +) -> list[str]: + return list( + session.exec( + select(UserIdentity.subject) + .where(UserIdentity.user_id == user_id, UserIdentity.issuer == issuer) + .order_by(col(UserIdentity.created_at), col(UserIdentity.id)) + ).all() + ) diff --git a/policyengine_api/services/v2/user_accounts/database_connectors/updates.py b/policyengine_api/services/v2/user_accounts/database_connectors/updates.py new file mode 100644 index 000000000..fb2bcdee7 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/database_connectors/updates.py @@ -0,0 +1,23 @@ +"""Database updates for signed-in v2 accounts.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from sqlmodel import Session + +from policyengine_api.data.v2.models import User + + +def update_account_user( + session: Session, + user: User, + values: Mapping[str, Any], +) -> User: + for field_name, value in values.items(): + setattr(user, field_name, value) + session.add(user) + session.flush() + session.refresh(user) + return user diff --git a/policyengine_api/services/v2/user_accounts/database_session.py b/policyengine_api/services/v2/user_accounts/database_session.py new file mode 100644 index 000000000..f12a48cd7 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/database_session.py @@ -0,0 +1,24 @@ +"""Database session lifetime management for v2 account services.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from sqlalchemy.orm import sessionmaker +from sqlmodel import Session + + +class AccountDatabaseSession: + def __init__(self, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + @contextmanager + def read(self) -> Iterator[Session]: + with self._session_factory() as session: + yield session + + @contextmanager + def transaction(self) -> Iterator[Session]: + with self._session_factory.begin() as session: + yield session diff --git a/policyengine_api/services/v2/user_accounts/services.py b/policyengine_api/services/v2/user_accounts/services.py new file mode 100644 index 000000000..d4c8f8cf2 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/services.py @@ -0,0 +1,285 @@ +"""Account services for ``/v2/me``. + +Each operation finds or provisions the caller's v2 user through the +principal's provisioning strategy. A signed-in principal's standard user is +provisioned on first use; a signed-out browser's unauthenticated user exists +only after its first write, so reading, exporting, or deleting an absent one +finds nothing. Only signed-in principals edit account details. + +Upgrading moves a browser's unauthenticated user into the signed-in account +at sign-in. Deleting a standard account removes the user's legacy mappings, +then the user (identities and associations cascade), and finally the Auth0 +user when the Management API is configured; deleting an unauthenticated +account removes only its user and associations. Only standard user UUIDs are +logged. +""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from uuid import UUID + +from policyengine_api.data.v2.models import UserType +from policyengine_api.services.v2.identity.database_connectors.deletes import ( + delete_unauthenticated_user, +) +from policyengine_api.services.v2.identity.services import ( + convert_unauthenticated_user_in_session, + find_principal_user_in_session, + resolve_principal_user_for_read_in_session, + resolve_principal_user_in_session, +) +from policyengine_api.services.v2.identity.types import ( + AccountPrincipal, + UnauthenticatedPrincipal, + UserPrincipal, +) +from policyengine_api.services.v2.user_accounts.database_connectors.deletes import ( + delete_account_legacy_user_mappings, + delete_account_user, +) +from policyengine_api.services.v2.user_accounts.database_connectors.reads import ( + read_account_user, + read_identity_subjects, +) +from policyengine_api.services.v2.user_accounts.database_connectors.updates import ( + update_account_user, +) +from policyengine_api.services.v2.user_accounts.database_session import ( + AccountDatabaseSession, +) +from policyengine_api.services.v2.user_accounts.transformations import ( + account_read, + account_update_values, +) +from policyengine_api.services.v2.user_accounts.types import ( + AccountDeletion, + AccountExport, + AccountRead, + AccountUpdateInput, + AccountUpgrade, + Auth0UserDeleter, +) +from policyengine_api.services.v2.user_accounts.validators import ( + AccountDeletionUnavailableError, + AccountIdentityDeletionError, + require_account, + require_editable_account, + require_resolved_account, +) +from policyengine_api.services.v2.user_households.services import ( + read_all_user_households, +) +from policyengine_api.services.v2.user_policies.services import ( + read_all_user_policies, +) +from policyengine_api.services.v2.user_reports.services import ( + read_all_user_reports, +) +from policyengine_api.services.v2.user_simulations.services import ( + read_all_user_simulations, +) + + +logger = logging.getLogger(__name__) + + +class V2AccountService: + """Sequence account operations through explicit sessions.""" + + def __init__( + self, + database_session: AccountDatabaseSession, + *, + auth0_user_deleter_factory: Callable[[], Auth0UserDeleter | None] | None = None, + ) -> None: + self._database_session = database_session + self._auth0_user_deleter_factory = auth0_user_deleter_factory + + def _prepared_auth0_user_deleter(self) -> Auth0UserDeleter | None: + """Build and authenticate the Auth0 deleter before any data changes. + + Only account deletion needs the Management API, so its settings are + read here rather than for every /v2/me request. + """ + + if self._auth0_user_deleter_factory is None: + return None + try: + deleter = self._auth0_user_deleter_factory() + if deleter is not None: + deleter.prepare_deletion() + except Exception as error: + logger.warning( + "auth0_user_deletion_unavailable", + extra={"policyengine_attributes": {"error_type": type(error).__name__}}, + ) + raise AccountDeletionUnavailableError( + "Account deletion is temporarily unavailable; nothing was deleted" + ) from error + return deleter + + def get_account( + self, *, principal: AccountPrincipal, country_id: str + ) -> AccountRead: + with self._database_session.transaction() as session: + owner = require_resolved_account( + resolve_principal_user_for_read_in_session( + session, principal, country_id=country_id + ) + ) + user = require_account(read_account_user(session, owner.user_id)) + return account_read(user, email=owner.email) + + def update_account( + self, + *, + principal: AccountPrincipal, + country_id: str, + account_input: AccountUpdateInput, + ) -> AccountRead: + signed_in = require_editable_account(principal) + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, signed_in, country_id=country_id + ) + user = require_account(read_account_user(session, owner.user_id)) + return account_read( + update_account_user( + session, user, account_update_values(account_input) + ), + email=owner.email, + ) + + def export_account( + self, *, principal: AccountPrincipal, country_id: str + ) -> AccountExport: + with self._database_session.transaction() as session: + owner = resolve_principal_user_for_read_in_session( + session, principal, country_id=country_id + ) + if owner is None: + return AccountExport( + user=None, + policies=(), + households=(), + simulations=(), + reports=(), + ) + user = require_account(read_account_user(session, owner.user_id)) + return AccountExport( + user=account_read(user, email=owner.email), + policies=read_all_user_policies(session, user_id=owner.user_id), + households=read_all_user_households(session, user_id=owner.user_id), + simulations=read_all_user_simulations(session, user_id=owner.user_id), + reports=read_all_user_reports(session, user_id=owner.user_id), + ) + + def upgrade_account( + self, + *, + principal: UserPrincipal, + unauthenticated_user_id: UUID, + country_id: str, + ) -> AccountUpgrade: + with self._database_session.transaction() as session: + conversion = convert_unauthenticated_user_in_session( + session, + unauthenticated_user_id=unauthenticated_user_id, + principal=principal, + country_id=country_id, + ) + user = require_account(read_account_user(session, conversion.owner.user_id)) + upgrade = AccountUpgrade( + account=account_read(user, email=conversion.owner.email), + outcome=conversion.outcome, + ) + # A converted user's ID was a browser credential, so no ID is logged. + logger.info( + "upgraded_v2_user", + extra={"policyengine_attributes": {"outcome": upgrade.outcome.value}}, + ) + return upgrade + + def delete_account(self, *, principal: AccountPrincipal) -> AccountDeletion: + if isinstance(principal, UnauthenticatedPrincipal): + return self._delete_unauthenticated_account(principal) + return self._delete_standard_account(principal) + + def _delete_unauthenticated_account( + self, principal: UnauthenticatedPrincipal + ) -> AccountDeletion: + """Delete a browser's unauthenticated user; there is no Auth0 user.""" + + with self._database_session.transaction() as session: + owner = find_principal_user_in_session(session, principal) + if owner is not None: + delete_unauthenticated_user(session, user_id=owner.user_id) + if owner is None: + return AccountDeletion(user_id=None, auth0_subjects=()) + logger.info( + "deleted_v2_user_account", + extra={ + "policyengine_attributes": {"user_type": UserType.UNAUTHENTICATED.value} + }, + ) + return AccountDeletion(user_id=owner.user_id, auth0_subjects=()) + + def _delete_standard_account(self, principal: UserPrincipal) -> AccountDeletion: + auth0_user_deleter = self._prepared_auth0_user_deleter() + with self._database_session.transaction() as session: + owner = find_principal_user_in_session(session, principal) + if owner is None: + subjects: tuple[str, ...] = (principal.subject,) + else: + subjects = tuple( + read_identity_subjects( + session, user_id=owner.user_id, issuer=principal.issuer + ) + ) or (principal.subject,) + delete_account_legacy_user_mappings(session, user_id=owner.user_id) + delete_account_user(session, user_id=owner.user_id) + deleted_user_id = owner.user_id if owner is not None else None + if deleted_user_id is not None: + logger.info( + "deleted_v2_user_account", + extra={"policyengine_attributes": {"v2_user_id": str(deleted_user_id)}}, + ) + + if auth0_user_deleter is None: + logger.warning( + "auth0_user_deletion_skipped_not_configured", + extra={ + "policyengine_attributes": { + "v2_user_id": ( + str(deleted_user_id) + if deleted_user_id is not None + else None + ) + } + }, + ) + return AccountDeletion(user_id=deleted_user_id, auth0_subjects=()) + try: + for subject in subjects: + auth0_user_deleter.delete_user(subject) + except Exception as error: + logger.warning( + "auth0_user_deletion_failed", + extra={ + "policyengine_attributes": { + "v2_user_id": ( + str(deleted_user_id) + if deleted_user_id is not None + else None + ), + "error_type": type(error).__name__, + } + }, + ) + raise AccountIdentityDeletionError( + "Account data was deleted but the sign-in account could not be " + "removed; retry the request" + ) from error + return AccountDeletion(user_id=deleted_user_id, auth0_subjects=subjects) diff --git a/policyengine_api/services/v2/user_accounts/transformations.py b/policyengine_api/services/v2/user_accounts/transformations.py new file mode 100644 index 000000000..877ce1a18 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/transformations.py @@ -0,0 +1,27 @@ +"""Pure representation transformations for v2 account operations.""" + +from __future__ import annotations + +from typing import Any + +from policyengine_api.data.v2.models import User +from policyengine_api.services.v2.user_accounts.types import ( + AccountRead, + AccountUpdateInput, +) + + +def account_read(user: User, *, email: str | None) -> AccountRead: + return AccountRead( + id=user.id, + user_type=user.user_type, + primary_country=user.primary_country, + first_name=user.first_name, + last_name=user.last_name, + email=email if email is not None else user.email, + created_at=user.created_at, + ) + + +def account_update_values(account_input: AccountUpdateInput) -> dict[str, Any]: + return account_input.model_dump(exclude_unset=True) diff --git a/policyengine_api/services/v2/user_accounts/types.py b/policyengine_api/services/v2/user_accounts/types.py new file mode 100644 index 000000000..67da83764 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/types.py @@ -0,0 +1,89 @@ +"""Framework-independent data exchanged by v2 account layers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Annotated, Protocol +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, StringConstraints, model_validator + +from policyengine_api.query_parameters import CountryId +from policyengine_api.services.v2.identity.types import AccountConversionOutcome +from policyengine_api.services.v2.user_households.types import UserHouseholdRead +from policyengine_api.services.v2.user_policies.types import UserPolicyRead +from policyengine_api.services.v2.user_reports.types import UserReportRead +from policyengine_api.services.v2.user_simulations.types import UserSimulationRead + + +PersonName = Annotated[str, StringConstraints(max_length=255)] + + +class StrictAccountInput(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class AccountUpdateInput(StrictAccountInput): + first_name: PersonName | None = None + last_name: PersonName | None = None + primary_country: CountryId | None = None + + @model_validator(mode="after") + def require_valid_supplied_field(self) -> "AccountUpdateInput": + supplied = self.model_fields_set.intersection( + {"first_name", "last_name", "primary_country"} + ) + if not supplied: + raise ValueError( + "At least one of first_name, last_name, or primary_country must be " + "supplied" + ) + if "primary_country" in supplied and self.primary_country is None: + raise ValueError("primary_country must not be null") + return self + + +@dataclass(frozen=True) +class AccountRead: + id: UUID + user_type: str + # Null only for an unauthenticated user. + primary_country: str | None + first_name: str | None + last_name: str | None + email: str | None + created_at: datetime + + +@dataclass(frozen=True) +class AccountExport: + # Null when an unauthenticated principal has not saved anything yet. + user: AccountRead | None + policies: tuple[UserPolicyRead, ...] + households: tuple[UserHouseholdRead, ...] + simulations: tuple[UserSimulationRead, ...] + reports: tuple[UserReportRead, ...] + + +@dataclass(frozen=True) +class AccountUpgrade: + """The signed-in account after a browser's unauthenticated user moved in.""" + + account: AccountRead + outcome: AccountConversionOutcome + + +@dataclass(frozen=True) +class AccountDeletion: + user_id: UUID | None + auth0_subjects: tuple[str, ...] + + +class Auth0UserDeleter(Protocol): + """Removes users from Auth0 when an account is deleted.""" + + def prepare_deletion(self) -> None: + """Obtain working credentials; raise before any data is deleted.""" + + def delete_user(self, subject: str) -> None: ... diff --git a/policyengine_api/services/v2/user_accounts/validators.py b/policyengine_api/services/v2/user_accounts/validators.py new file mode 100644 index 000000000..e86f957f2 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/validators.py @@ -0,0 +1,46 @@ +"""Database-independent validation for v2 account operations.""" + +from __future__ import annotations + +from policyengine_api.data.v2.models import User +from policyengine_api.services.v2.identity.types import ( + AccountPrincipal, + ResolvedUser, + UserPrincipal, +) + + +class AccountNotFoundError(LookupError): + """Raised when the principal has no stored account.""" + + +class AccountEditRequiresSignInError(PermissionError): + """Raised when an unauthenticated principal edits account details.""" + + +class AccountIdentityDeletionError(RuntimeError): + """Raised when account data was removed but the Auth0 user was not.""" + + +class AccountDeletionUnavailableError(RuntimeError): + """Raised before any data is deleted when Auth0 deletion cannot run.""" + + +def require_resolved_account(owner: ResolvedUser | None) -> ResolvedUser: + """An unauthenticated principal has no account until its first write.""" + + if owner is None: + raise AccountNotFoundError("The account was not found") + return owner + + +def require_account(user: User | None) -> User: + if user is None: + raise AccountNotFoundError("The account was not found") + return user + + +def require_editable_account(principal: AccountPrincipal) -> UserPrincipal: + if not isinstance(principal, UserPrincipal): + raise AccountEditRequiresSignInError("Sign in to edit your account details.") + return principal diff --git a/policyengine_api/services/v2/user_households/database_connectors/creates.py b/policyengine_api/services/v2/user_households/database_connectors/creates.py index eb871996f..20cd7fa8c 100644 --- a/policyengine_api/services/v2/user_households/database_connectors/creates.py +++ b/policyengine_api/services/v2/user_households/database_connectors/creates.py @@ -1,5 +1,8 @@ """Database inserts for v2 user-household associations.""" +from __future__ import annotations + + from sqlmodel import Session from policyengine_api.data.v2.models import UserHouseholdAssociation diff --git a/policyengine_api/services/v2/user_households/database_connectors/reads.py b/policyengine_api/services/v2/user_households/database_connectors/reads.py index 02d9b8288..9945e74ac 100644 --- a/policyengine_api/services/v2/user_households/database_connectors/reads.py +++ b/policyengine_api/services/v2/user_households/database_connectors/reads.py @@ -2,15 +2,16 @@ from __future__ import annotations +from collections.abc import Collection from uuid import UUID from sqlmodel import Session, col, select -from policyengine_api.data.v2.models import Household, User, UserHouseholdAssociation - - -def read_user(session: Session, user_id: UUID) -> User | None: - return session.get(User, user_id) +from policyengine_api.data.v2.models import ( + Household, + LegacyHouseholdMapping, + UserHouseholdAssociation, +) def read_household_for_association( @@ -22,9 +23,10 @@ def read_household_for_association( ).one_or_none() -def read_user_household_row( +def read_owned_user_household_row( session: Session, *, + user_id: UUID, country_id: str, association_id: UUID, ) -> UserHouseholdAssociation | None: @@ -32,6 +34,7 @@ def read_user_household_row( select(UserHouseholdAssociation).where( UserHouseholdAssociation.id == association_id, UserHouseholdAssociation.country_id == country_id, + UserHouseholdAssociation.user_id == user_id, ) ).one_or_none() @@ -44,6 +47,7 @@ def read_user_household_rows( household_id: UUID | None, offset: int, limit: int, + legacy_household_id: int | None = None, ) -> list[UserHouseholdAssociation]: statement = select(UserHouseholdAssociation).where( UserHouseholdAssociation.country_id == country_id, @@ -53,6 +57,10 @@ def read_user_household_rows( statement = statement.where( UserHouseholdAssociation.household_id == household_id ) + if legacy_household_id is not None: + statement = statement.where( + UserHouseholdAssociation.legacy_household_id == legacy_household_id + ) return list( session.exec( statement.order_by( @@ -63,3 +71,45 @@ def read_user_household_rows( .limit(limit + 1) ).all() ) + + +def read_all_user_household_rows( + session: Session, *, user_id: UUID +) -> list[UserHouseholdAssociation]: + return list( + session.exec( + select(UserHouseholdAssociation) + .where(UserHouseholdAssociation.user_id == user_id) + .order_by( + col(UserHouseholdAssociation.created_at), + col(UserHouseholdAssociation.id), + ) + ).all() + ) + + +def read_copied_household_ids( + session: Session, + *, + country_id: str, + legacy_household_ids: Collection[int], +) -> dict[int, UUID]: + """Return v2 household UUIDs for v1 households that Stage 11 copied.""" + + if not legacy_household_ids: + return {} + rows = session.exec( + select( + LegacyHouseholdMapping.legacy_household_id, + LegacyHouseholdMapping.household_id, + ).where( + LegacyHouseholdMapping.country_id == country_id, + col(LegacyHouseholdMapping.legacy_household_id).in_( + sorted(set(legacy_household_ids)) + ), + ) + ).all() + return { + int(legacy_household_id): household_id + for legacy_household_id, household_id in rows + } diff --git a/policyengine_api/services/v2/user_households/database_connectors/updates.py b/policyengine_api/services/v2/user_households/database_connectors/updates.py index 2e7c6a030..3608cf61b 100644 --- a/policyengine_api/services/v2/user_households/database_connectors/updates.py +++ b/policyengine_api/services/v2/user_households/database_connectors/updates.py @@ -1,5 +1,9 @@ """Database updates for mutable v2 user-household association fields.""" +from __future__ import annotations + +from uuid import UUID + from sqlmodel import Session from policyengine_api.data.v2.models import UserHouseholdAssociation @@ -13,9 +17,14 @@ def update_user_household( session: Session, association: UserHouseholdAssociation, association_input: UserHouseholdUpdateInput, + *, + repointed_household_id: UUID | None = None, + repoint: bool = False, ) -> UserHouseholdAssociation: for field_name, value in association_input.model_dump(exclude_unset=True).items(): setattr(association, field_name, value) + if repoint: + association.household_id = repointed_household_id association.updated_at = utc_now() session.add(association) session.flush() diff --git a/policyengine_api/services/v2/user_households/services.py b/policyengine_api/services/v2/user_households/services.py index 0853e3114..a89f5c0cf 100644 --- a/policyengine_api/services/v2/user_households/services.py +++ b/policyengine_api/services/v2/user_households/services.py @@ -1,4 +1,10 @@ -"""Application services for native v2 user-household associations.""" +"""Application services for native v2 user-household associations. + +The owner always comes from the verified session principal: a signed-in user +or a signed-out browser. Reads never provision a browser's unauthenticated +user; one that does not exist yet owns no records. v1 references are checked +in a short read-only v1 transaction before the v2 transaction opens. +""" from __future__ import annotations @@ -6,6 +12,17 @@ from sqlmodel import Session +from policyengine_api.data.v2.models import UserHouseholdAssociation +from policyengine_api.services.v2.associations.services import ( + require_legacy_reference, +) +from policyengine_api.services.v2.associations.types import LegacyReferenceChecker +from policyengine_api.services.v2.identity.services import ( + resolve_principal_user_for_read_in_session, + resolve_principal_user_in_session, +) +from policyengine_api.services.v2.identity.types import AccountPrincipal +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind from policyengine_api.services.v2.user_households.database_connectors.creates import ( create_user_household, ) @@ -13,9 +30,10 @@ delete_user_household, ) from policyengine_api.services.v2.user_households.database_connectors.reads import ( + read_all_user_household_rows, + read_copied_household_ids, read_household_for_association, - read_user, - read_user_household_row, + read_owned_user_household_row, read_user_household_rows, ) from policyengine_api.services.v2.user_households.database_connectors.updates import ( @@ -25,159 +43,249 @@ UserHouseholdDatabaseSession, ) from policyengine_api.services.v2.user_households.transformations import ( + owned_user_household_input, user_household_page, user_household_read, ) from policyengine_api.services.v2.user_households.types import ( - UserHouseholdCreationInput, + UserHouseholdCreateFields, UserHouseholdPage, UserHouseholdRead, UserHouseholdUpdateInput, ) from policyengine_api.services.v2.user_households.validators import ( require_user_household, - validate_association_creation, + resolve_association_household_id, validate_association_household, ) -def read_complete_user_household( +def read_owned_user_household( session: Session, *, + user_id: UUID | None, country_id: str, association_id: UUID, -) -> UserHouseholdRead: - association = require_user_household( - read_user_household_row( +) -> UserHouseholdAssociation: + """``user_id`` is None for an unauthenticated user that owns nothing yet.""" + + return require_user_household( + None + if user_id is None + else read_owned_user_household_row( session, + user_id=user_id, country_id=country_id, association_id=association_id, ), association_id=association_id, ) - return user_household_read(association) -def read_user_household_page( +def resolve_user_household_reference( session: Session, *, country_id: str, - user_id: UUID, household_id: UUID | None, - offset: int, - limit: int, -) -> UserHouseholdPage: - rows = read_user_household_rows( - session, - country_id=country_id, - user_id=user_id, - household_id=household_id, - offset=offset, - limit=limit, + legacy_household_id: int | None, +) -> UUID | None: + """Validate an explicit v2 household and combine it with the v1 mapping.""" + + if household_id is not None: + validate_association_household( + country_id=country_id, + household_id=household_id, + household=read_household_for_association(session, household_id), + ) + mapped_household_id = ( + read_copied_household_ids( + session, + country_id=country_id, + legacy_household_ids=(legacy_household_id,), + ).get(legacy_household_id) + if legacy_household_id is not None + else None + ) + return resolve_association_household_id( + requested_household_id=household_id, + mapped_household_id=mapped_household_id, + ) + + +def read_all_user_households( + session: Session, *, user_id: UUID +) -> tuple[UserHouseholdRead, ...]: + """Return every household association owned by a user, across countries.""" + + return tuple( + user_household_read(row) + for row in read_all_user_household_rows(session, user_id=user_id) ) - return user_household_page(rows, offset=offset, limit=limit) class V2UserHouseholdService: - """Sequence association operations through explicit database sessions.""" + """Sequence owner-scoped association operations through explicit sessions.""" - def __init__(self, database_session: UserHouseholdDatabaseSession) -> None: + def __init__( + self, + database_session: UserHouseholdDatabaseSession, + legacy_references: LegacyReferenceChecker | None = None, + ) -> None: self._database_session = database_session + self._legacy_references = legacy_references + + def _require_legacy_household( + self, *, country_id: str, legacy_household_id: int + ) -> None: + require_legacy_reference( + self._legacy_references, + LegacyRecordKind.HOUSEHOLD, + country_id=country_id, + legacy_id=legacy_household_id, + ) def create_user_household( self, - association_input: UserHouseholdCreationInput, + *, + principal: AccountPrincipal, + association_input: UserHouseholdCreateFields, ) -> UserHouseholdRead: + if association_input.legacy_household_id is not None: + self._require_legacy_household( + country_id=association_input.country_id, + legacy_household_id=association_input.legacy_household_id, + ) with self._database_session.transaction() as session: - household = read_household_for_association( + owner = resolve_principal_user_in_session( session, - association_input.household_id, + principal, + country_id=association_input.country_id, ) - validate_association_creation( - association_input, - user=read_user(session, association_input.user_id), - household=household, + household_id = resolve_user_household_reference( + session, + country_id=association_input.country_id, + household_id=association_input.household_id, + legacy_household_id=association_input.legacy_household_id, ) return user_household_read( - create_user_household(session, association_input) + create_user_household( + session, + owned_user_household_input( + association_input, + user_id=owner.user_id, + household_id=household_id, + ), + ) ) def get_user_household( self, *, + principal: AccountPrincipal, country_id: str, association_id: UUID, ) -> UserHouseholdRead: - with self._database_session.read() as session: - return read_complete_user_household( - session, - country_id=country_id, - association_id=association_id, + with self._database_session.transaction() as session: + owner = resolve_principal_user_for_read_in_session( + session, principal, country_id=country_id + ) + return user_household_read( + read_owned_user_household( + session, + user_id=None if owner is None else owner.user_id, + country_id=country_id, + association_id=association_id, + ) ) def list_user_households( self, *, + principal: AccountPrincipal, country_id: str, - user_id: UUID, household_id: UUID | None = None, + legacy_household_id: int | None = None, offset: int = 0, limit: int = 100, ) -> UserHouseholdPage: - with self._database_session.read() as session: - return read_user_household_page( + with self._database_session.transaction() as session: + owner = resolve_principal_user_for_read_in_session( + session, principal, country_id=country_id + ) + if owner is None: + return user_household_page([], offset=offset, limit=limit) + rows = read_user_household_rows( session, country_id=country_id, - user_id=user_id, + user_id=owner.user_id, household_id=household_id, + legacy_household_id=legacy_household_id, offset=offset, limit=limit, ) + return user_household_page(rows, offset=offset, limit=limit) def patch_user_household( self, *, + principal: AccountPrincipal, country_id: str, association_id: UUID, association_input: UserHouseholdUpdateInput, ) -> UserHouseholdRead: + repoint = "legacy_household_id" in association_input.model_fields_set + if repoint: + assert association_input.legacy_household_id is not None + self._require_legacy_household( + country_id=country_id, + legacy_household_id=association_input.legacy_household_id, + ) with self._database_session.transaction() as session: - association = require_user_household( - read_user_household_row( + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + association = read_owned_user_household( + session, + user_id=owner.user_id, + country_id=country_id, + association_id=association_id, + ) + repointed_household_id = ( + read_copied_household_ids( session, country_id=country_id, - association_id=association_id, - ), - association_id=association_id, + legacy_household_ids=(association_input.legacy_household_id,), + ).get(association_input.legacy_household_id) + if repoint and association_input.legacy_household_id is not None + else None ) - if "household_id" in association_input.model_fields_set: - assert association_input.household_id is not None - replacement = read_household_for_association( + return user_household_read( + update_user_household( session, - association_input.household_id, - ) - validate_association_household( - country_id=association.country_id, - household_id=association_input.household_id, - household=replacement, + association, + association_input, + repointed_household_id=repointed_household_id, + repoint=repoint, ) - return user_household_read( - update_user_household(session, association, association_input) ) def delete_user_household( self, *, + principal: AccountPrincipal, country_id: str, association_id: UUID, ) -> None: with self._database_session.transaction() as session: - association = require_user_household( - read_user_household_row( + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + delete_user_household( + session, + read_owned_user_household( session, + user_id=owner.user_id, country_id=country_id, association_id=association_id, ), - association_id=association_id, ) - delete_user_household(session, association) diff --git a/policyengine_api/services/v2/user_households/transformations.py b/policyengine_api/services/v2/user_households/transformations.py index 1c0203573..945b0abe1 100644 --- a/policyengine_api/services/v2/user_households/transformations.py +++ b/policyengine_api/services/v2/user_households/transformations.py @@ -1,7 +1,13 @@ """Pure representation transformations for v2 user-household operations.""" +from __future__ import annotations + +from uuid import UUID + from policyengine_api.data.v2.models import UserHouseholdAssociation from policyengine_api.services.v2.user_households.types import ( + UserHouseholdCreateFields, + UserHouseholdCreationInput, UserHouseholdPage, UserHouseholdRead, ) @@ -15,6 +21,7 @@ def user_household_read( country_id=association.country_id, user_id=association.user_id, household_id=association.household_id, + legacy_household_id=association.legacy_household_id, name=association.name, description=association.description, created_at=association.created_at, @@ -34,3 +41,16 @@ def user_household_page( limit=limit, has_more=len(rows) > limit, ) + + +def owned_user_household_input( + fields: UserHouseholdCreateFields, + *, + user_id: UUID, + household_id: UUID | None, +) -> UserHouseholdCreationInput: + return UserHouseholdCreationInput( + **fields.model_dump(exclude={"household_id"}), + user_id=user_id, + household_id=household_id, + ) diff --git a/policyengine_api/services/v2/user_households/types.py b/policyengine_api/services/v2/user_households/types.py index 3765dc1ac..cbebeb943 100644 --- a/policyengine_api/services/v2/user_households/types.py +++ b/policyengine_api/services/v2/user_households/types.py @@ -4,44 +4,76 @@ from dataclasses import dataclass from datetime import datetime -from typing import Annotated from uuid import UUID -from pydantic import BaseModel, ConfigDict, StringConstraints, model_validator +from pydantic import model_validator from policyengine_api.query_parameters import CountryId, ResourceId, UserId +from policyengine_api.services.v2.associations.types import ( + AssociationDescription, + AssociationName, + StrictAssociationModel, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId -AssociationName = Annotated[str, StringConstraints(max_length=255)] +class StrictUserHouseholdInput(StrictAssociationModel): + """Reject fields outside the reviewed user-household contract.""" -class StrictUserHouseholdInput(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) +class UserHouseholdCreateFields(StrictUserHouseholdInput): + """Caller-supplied association fields; the owner comes from the session.""" + + country_id: CountryId + household_id: ResourceId | None = None + legacy_household_id: LegacyRecordId | None = None + name: AssociationName | None = None + description: AssociationDescription | None = None + + @model_validator(mode="after") + def require_household_reference(self) -> "UserHouseholdCreateFields": + if self.household_id is None and self.legacy_household_id is None: + raise ValueError( + "At least one of legacy_household_id or household_id is required" + ) + return self class UserHouseholdCreationInput(StrictUserHouseholdInput): + """Complete persisted association values, including the resolved owner.""" + country_id: CountryId user_id: UserId - household_id: ResourceId + household_id: ResourceId | None = None + legacy_household_id: LegacyRecordId | None = None name: AssociationName | None = None description: str | None = None + @model_validator(mode="after") + def require_household_reference(self) -> "UserHouseholdCreationInput": + if self.household_id is None and self.legacy_household_id is None: + raise ValueError( + "At least one of legacy_household_id or household_id is required" + ) + return self + class UserHouseholdUpdateInput(StrictUserHouseholdInput): - household_id: ResourceId | None = None + legacy_household_id: LegacyRecordId | None = None name: AssociationName | None = None - description: str | None = None + description: AssociationDescription | None = None @model_validator(mode="after") def require_valid_supplied_field(self) -> "UserHouseholdUpdateInput": - mutable_fields = {"household_id", "name", "description"} + mutable_fields = {"legacy_household_id", "name", "description"} supplied = self.model_fields_set.intersection(mutable_fields) if not supplied: raise ValueError( - "At least one of household_id, name, or description must be supplied" + "At least one of legacy_household_id, name, or description must be " + "supplied" ) - if "household_id" in supplied and self.household_id is None: - raise ValueError("household_id must not be null") + if "legacy_household_id" in supplied and self.legacy_household_id is None: + raise ValueError("legacy_household_id must not be null") return self @@ -50,7 +82,8 @@ class UserHouseholdRead: id: UUID country_id: str user_id: UUID - household_id: UUID + household_id: UUID | None + legacy_household_id: int | None name: str | None description: str | None created_at: datetime diff --git a/policyengine_api/services/v2/user_households/validators.py b/policyengine_api/services/v2/user_households/validators.py index f33200064..d6874dc99 100644 --- a/policyengine_api/services/v2/user_households/validators.py +++ b/policyengine_api/services/v2/user_households/validators.py @@ -4,14 +4,15 @@ from uuid import UUID -from policyengine_api.data.v2.models import Household, User, UserHouseholdAssociation -from policyengine_api.services.v2.user_households.types import ( - UserHouseholdCreationInput, +from policyengine_api.data.v2.models import Household, UserHouseholdAssociation +from policyengine_api.services.v2.associations.validators import ( + AssociationNotFoundError, + AssociationReferenceConflictError, ) -class UserHouseholdNotFoundError(LookupError): - """Raised when an association is absent from the selected country.""" +class UserHouseholdNotFoundError(AssociationNotFoundError): + """Raised when an association is absent for the caller and country.""" class AssociationHouseholdNotFoundError(LookupError): @@ -22,7 +23,7 @@ class AssociationUserNotFoundError(LookupError): """Raised when an association references an unknown v2 user UUID.""" -class AssociationCountryConflictError(ValueError): +class AssociationCountryConflictError(AssociationReferenceConflictError): """Raised when an association and household have different countries.""" @@ -38,23 +39,6 @@ def require_user_household( return association -def validate_association_creation( - association_input: UserHouseholdCreationInput, - *, - user: User | None, - household: Household | None, -) -> None: - if user is None: - raise AssociationUserNotFoundError( - f"user {association_input.user_id} was not found" - ) - validate_association_household( - country_id=association_input.country_id, - household_id=association_input.household_id, - household=household, - ) - - def validate_association_household( *, country_id: str, @@ -69,3 +53,25 @@ def validate_association_household( raise AssociationCountryConflictError( "Association country_id must match the referenced household" ) + + +def resolve_association_household_id( + *, + requested_household_id: UUID | None, + mapped_household_id: UUID | None, +) -> UUID | None: + """Combine an explicit v2 reference with the v1 mapping, if both exist.""" + + if ( + requested_household_id is not None + and mapped_household_id is not None + and requested_household_id != mapped_household_id + ): + raise AssociationReferenceConflictError( + "household_id does not match the household copied from legacy_household_id" + ) + return ( + requested_household_id + if requested_household_id is not None + else mapped_household_id + ) diff --git a/policyengine_api/services/v2/user_policies/database_connectors/reads.py b/policyengine_api/services/v2/user_policies/database_connectors/reads.py index 66a12624f..b22b51a94 100644 --- a/policyengine_api/services/v2/user_policies/database_connectors/reads.py +++ b/policyengine_api/services/v2/user_policies/database_connectors/reads.py @@ -2,11 +2,13 @@ from __future__ import annotations +from collections.abc import Collection from uuid import UUID from sqlmodel import Session, col, select from policyengine_api.data.v2.models import ( + LegacyPolicyMapping, LegacyUserMapping, LegacyUserPolicyMapping, Policy, @@ -61,13 +63,18 @@ def read_mapped_user_policy( ).one_or_none() -def read_user_policy_row( - session: Session, *, country_id: str, association_id: UUID +def read_owned_user_policy_row( + session: Session, + *, + user_id: UUID, + country_id: str, + association_id: UUID, ) -> UserPolicy | None: return session.exec( select(UserPolicy).where( UserPolicy.id == association_id, UserPolicy.country_id == country_id, + UserPolicy.user_id == user_id, ) ).one_or_none() @@ -80,6 +87,7 @@ def read_user_policy_rows( policy_id: UUID | None, offset: int, limit: int, + legacy_policy_id: int | None = None, ) -> list[UserPolicy]: statement = select(UserPolicy).where( UserPolicy.country_id == country_id, @@ -87,6 +95,8 @@ def read_user_policy_rows( ) if policy_id is not None: statement = statement.where(UserPolicy.policy_id == policy_id) + if legacy_policy_id is not None: + statement = statement.where(UserPolicy.legacy_policy_id == legacy_policy_id) return list( session.exec( statement.order_by(col(UserPolicy.created_at), col(UserPolicy.id)) @@ -94,3 +104,47 @@ def read_user_policy_rows( .limit(limit + 1) ).all() ) + + +def read_all_user_policy_rows(session: Session, *, user_id: UUID) -> list[UserPolicy]: + return list( + session.exec( + select(UserPolicy) + .where(UserPolicy.user_id == user_id) + .order_by(col(UserPolicy.created_at), col(UserPolicy.id)) + ).all() + ) + + +def read_user_policy_mirror_exists(session: Session, *, association_id: UUID) -> bool: + return ( + session.exec( + select(LegacyUserPolicyMapping.id).where( + LegacyUserPolicyMapping.user_policy_id == association_id + ) + ).first() + is not None + ) + + +def read_mirrored_policy_ids( + session: Session, + *, + country_id: str, + legacy_policy_ids: Collection[int], +) -> dict[int, UUID]: + """Return v2 policy UUIDs for v1 policies that the Stage 10 mirror copied.""" + + if not legacy_policy_ids: + return {} + rows = session.exec( + select( + LegacyPolicyMapping.legacy_policy_id, LegacyPolicyMapping.policy_id + ).where( + LegacyPolicyMapping.country_id == country_id, + col(LegacyPolicyMapping.legacy_policy_id).in_( + sorted(set(legacy_policy_ids)) + ), + ) + ).all() + return {int(legacy_policy_id): policy_id for legacy_policy_id, policy_id in rows} diff --git a/policyengine_api/services/v2/user_policies/database_connectors/updates.py b/policyengine_api/services/v2/user_policies/database_connectors/updates.py index c58fd4d12..c16b715bc 100644 --- a/policyengine_api/services/v2/user_policies/database_connectors/updates.py +++ b/policyengine_api/services/v2/user_policies/database_connectors/updates.py @@ -2,6 +2,8 @@ from __future__ import annotations +from uuid import UUID + from sqlmodel import Session from policyengine_api.data.v2.models import LegacyUserPolicyMapping, UserPolicy @@ -13,9 +15,14 @@ def update_user_policy( session: Session, association: UserPolicy, association_input: UserPolicyUpdateInput, + *, + repointed_policy_id: UUID | None = None, + repoint: bool = False, ) -> UserPolicy: for field_name, value in association_input.model_dump(exclude_unset=True).items(): setattr(association, field_name, value) + if repoint: + association.policy_id = repointed_policy_id association.updated_at = utc_now() session.add(association) session.flush() diff --git a/policyengine_api/services/v2/user_policies/services.py b/policyengine_api/services/v2/user_policies/services.py index ac71912d4..e41972c1d 100644 --- a/policyengine_api/services/v2/user_policies/services.py +++ b/policyengine_api/services/v2/user_policies/services.py @@ -6,7 +6,20 @@ from sqlmodel import Session -from policyengine_api.data.v2.models import LegacyUserPolicyMapping +from policyengine_api.data.v2.models import LegacyUserPolicyMapping, UserPolicy +from policyengine_api.services.v2.associations.services import ( + require_legacy_reference, +) +from policyengine_api.services.v2.associations.types import LegacyReferenceChecker +from policyengine_api.services.v2.associations.validators import ( + require_not_mirror_owned, +) +from policyengine_api.services.v2.identity.services import ( + resolve_principal_user_for_read_in_session, + resolve_principal_user_in_session, +) +from policyengine_api.services.v2.identity.types import AccountPrincipal +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind from policyengine_api.services.v2.policies.services import ( mirror_legacy_policy_in_session, ) @@ -22,12 +35,15 @@ delete_user_policy, ) from policyengine_api.services.v2.user_policies.database_connectors.reads import ( + read_all_user_policy_rows, read_legacy_user_mapping, read_legacy_user_policy_mapping, read_mapped_user_policy, + read_mirrored_policy_ids, + read_owned_user_policy_row, read_policy_for_association, read_user, - read_user_policy_row, + read_user_policy_mirror_exists, read_user_policy_rows, ) from policyengine_api.services.v2.user_policies.database_connectors.updates import ( @@ -41,6 +57,7 @@ USER_POLICY_FINGERPRINT_VERSION, fingerprint_legacy_user_policy, legacy_name_requires_update, + owned_user_policy_input, project_legacy_user_policy, user_policy_page, user_policy_read, @@ -49,6 +66,7 @@ LegacyUserPolicyMappingAction, LegacyUserPolicyPersistenceResult, LegacyUserPolicySnapshot, + UserPolicyCreateFields, UserPolicyCreationInput, UserPolicyPage, UserPolicyRead, @@ -56,8 +74,10 @@ ) from policyengine_api.services.v2.user_policies.validators import ( LegacyUserPolicyIntegrityError, + require_mirrored_policy_id, require_user_policy, - validate_association_creation, + resolve_association_policy_id, + validate_association_policy, validate_existing_legacy_user_mapping, validate_existing_legacy_user_policy_mapping, validate_legacy_user_mapping_conflict, @@ -65,20 +85,6 @@ ) -def read_complete_user_policy( - session: Session, *, country_id: str, association_id: UUID -) -> UserPolicyRead: - association = require_user_policy( - read_user_policy_row( - session, - country_id=country_id, - association_id=association_id, - ), - association_id=association_id, - ) - return user_policy_read(association) - - def read_user_policy_page( session: Session, *, @@ -87,18 +93,83 @@ def read_user_policy_page( policy_id: UUID | None, offset: int, limit: int, + legacy_policy_id: int | None = None, ) -> UserPolicyPage: rows = read_user_policy_rows( session, country_id=country_id, user_id=user_id, policy_id=policy_id, + legacy_policy_id=legacy_policy_id, offset=offset, limit=limit, ) return user_policy_page(rows, offset=offset, limit=limit) +def read_owned_user_policy( + session: Session, + *, + user_id: UUID | None, + country_id: str, + association_id: UUID, +) -> UserPolicy: + """``user_id`` is None for an unauthenticated user that owns nothing yet.""" + + return require_user_policy( + None + if user_id is None + else read_owned_user_policy_row( + session, + user_id=user_id, + country_id=country_id, + association_id=association_id, + ), + association_id=association_id, + ) + + +def resolve_user_policy_reference( + session: Session, + *, + country_id: str, + policy_id: UUID | None, + legacy_policy_id: int | None, +) -> UUID | None: + """Validate an explicit v2 policy and combine it with the v1 mapping.""" + + if policy_id is not None: + validate_association_policy( + country_id=country_id, + policy_id=policy_id, + policy=read_policy_for_association(session, policy_id), + ) + mapped_policy_id = ( + read_mirrored_policy_ids( + session, + country_id=country_id, + legacy_policy_ids=(legacy_policy_id,), + ).get(legacy_policy_id) + if legacy_policy_id is not None + else None + ) + return resolve_association_policy_id( + requested_policy_id=policy_id, + mapped_policy_id=mapped_policy_id, + ) + + +def read_all_user_policies( + session: Session, *, user_id: UUID +) -> tuple[UserPolicyRead, ...]: + """Return every association owned by a user, across countries.""" + + return tuple( + user_policy_read(row) + for row in read_all_user_policy_rows(session, user_id=user_id) + ) + + def resolve_legacy_user_id( session: Session, *, @@ -199,6 +270,7 @@ def persist_legacy_user_policy_mapping( source_revision: int, changed_fields: frozenset[str], ) -> LegacyUserPolicyPersistenceResult: + policy_id = require_mirrored_policy_id(projection) existing = read_legacy_user_policy_mapping( session, country_id=country_id, @@ -213,7 +285,7 @@ def persist_legacy_user_policy_mapping( reform_label=reform_label, fingerprint=fingerprint, user_id=projection.user_id, - policy_id=projection.policy_id, + policy_id=policy_id, changed_fields=changed_fields, source_revision=source_revision, ) @@ -231,7 +303,7 @@ def persist_legacy_user_policy_mapping( if mapping_id is not None: return LegacyUserPolicyPersistenceResult( association_id=association.id, - policy_id=projection.policy_id, + policy_id=policy_id, association_created=True, association_updated=False, mapping_created=True, @@ -255,7 +327,7 @@ def persist_legacy_user_policy_mapping( reform_label=reform_label, fingerprint=fingerprint, user_id=projection.user_id, - policy_id=projection.policy_id, + policy_id=policy_id, changed_fields=changed_fields, source_revision=source_revision, ) @@ -298,48 +370,107 @@ def mirror_legacy_user_policy_in_session( class V2UserPolicyService: - """Sequence association operations through explicit database sessions.""" + """Sequence owner-scoped association operations through explicit sessions. + + The owner always comes from the verified session principal: a signed-in + user or a signed-out browser. Reads never provision a browser's + unauthenticated user; one that does not exist yet owns no records. v1 + references are checked in a short read-only v1 transaction before the v2 + transaction opens. + """ - def __init__(self, database_session: UserPolicyDatabaseSession) -> None: + def __init__( + self, + database_session: UserPolicyDatabaseSession, + legacy_references: LegacyReferenceChecker | None = None, + ) -> None: self._database_session = database_session + self._legacy_references = legacy_references + + def _require_legacy_policy(self, *, country_id: str, legacy_policy_id: int) -> None: + require_legacy_reference( + self._legacy_references, + LegacyRecordKind.POLICY, + country_id=country_id, + legacy_id=legacy_policy_id, + ) def create_user_policy( - self, association_input: UserPolicyCreationInput + self, + *, + principal: AccountPrincipal, + association_input: UserPolicyCreateFields, ) -> UserPolicyRead: + if association_input.legacy_policy_id is not None: + self._require_legacy_policy( + country_id=association_input.country_id, + legacy_policy_id=association_input.legacy_policy_id, + ) with self._database_session.transaction() as session: - policy = read_policy_for_association(session, association_input.policy_id) - validate_association_creation( - association_input, - user=read_user(session, association_input.user_id), - policy=policy, + owner = resolve_principal_user_in_session( + session, + principal, + country_id=association_input.country_id, + ) + policy_id = resolve_user_policy_reference( + session, + country_id=association_input.country_id, + policy_id=association_input.policy_id, + legacy_policy_id=association_input.legacy_policy_id, + ) + return user_policy_read( + create_user_policy( + session, + owned_user_policy_input( + association_input, + user_id=owner.user_id, + policy_id=policy_id, + ), + ) ) - return user_policy_read(create_user_policy(session, association_input)) def get_user_policy( - self, *, country_id: str, association_id: UUID + self, + *, + principal: AccountPrincipal, + country_id: str, + association_id: UUID, ) -> UserPolicyRead: - with self._database_session.read() as session: - return read_complete_user_policy( - session, - country_id=country_id, - association_id=association_id, + with self._database_session.transaction() as session: + owner = resolve_principal_user_for_read_in_session( + session, principal, country_id=country_id + ) + return user_policy_read( + read_owned_user_policy( + session, + user_id=None if owner is None else owner.user_id, + country_id=country_id, + association_id=association_id, + ) ) def list_user_policies( self, *, + principal: AccountPrincipal, country_id: str, - user_id: UUID, policy_id: UUID | None = None, + legacy_policy_id: int | None = None, offset: int = 0, limit: int = 100, ) -> UserPolicyPage: - with self._database_session.read() as session: + with self._database_session.transaction() as session: + owner = resolve_principal_user_for_read_in_session( + session, principal, country_id=country_id + ) + if owner is None: + return user_policy_page([], offset=offset, limit=limit) return read_user_policy_page( session, country_id=country_id, - user_id=user_id, + user_id=owner.user_id, policy_id=policy_id, + legacy_policy_id=legacy_policy_id, offset=offset, limit=limit, ) @@ -347,33 +478,72 @@ def list_user_policies( def patch_user_policy( self, *, + principal: AccountPrincipal, country_id: str, association_id: UUID, association_input: UserPolicyUpdateInput, ) -> UserPolicyRead: + repoint = "legacy_policy_id" in association_input.model_fields_set + if repoint: + assert association_input.legacy_policy_id is not None + self._require_legacy_policy( + country_id=country_id, + legacy_policy_id=association_input.legacy_policy_id, + ) with self._database_session.transaction() as session: - association = require_user_policy( - read_user_policy_row( + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + association = read_owned_user_policy( + session, + user_id=owner.user_id, + country_id=country_id, + association_id=association_id, + ) + require_not_mirror_owned( + read_user_policy_mirror_exists(session, association_id=association.id), + association_id=association.id, + ) + repointed_policy_id = ( + read_mirrored_policy_ids( session, country_id=country_id, - association_id=association_id, - ), - association_id=association_id, + legacy_policy_ids=(association_input.legacy_policy_id,), + ).get(association_input.legacy_policy_id) + if repoint and association_input.legacy_policy_id is not None + else None ) return user_policy_read( - update_user_policy(session, association, association_input) + update_user_policy( + session, + association, + association_input, + repointed_policy_id=repointed_policy_id, + repoint=repoint, + ) ) - def delete_user_policy(self, *, country_id: str, association_id: UUID) -> None: + def delete_user_policy( + self, + *, + principal: AccountPrincipal, + country_id: str, + association_id: UUID, + ) -> None: with self._database_session.transaction() as session: - association = require_user_policy( - read_user_policy_row( - session, - country_id=country_id, - association_id=association_id, - ), + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + association = read_owned_user_policy( + session, + user_id=owner.user_id, + country_id=country_id, association_id=association_id, ) + require_not_mirror_owned( + read_user_policy_mirror_exists(session, association_id=association.id), + association_id=association.id, + ) delete_user_policy(session, association) def mirror_legacy_user_policy( diff --git a/policyengine_api/services/v2/user_policies/transformations.py b/policyengine_api/services/v2/user_policies/transformations.py index 615059f07..65ee43052 100644 --- a/policyengine_api/services/v2/user_policies/transformations.py +++ b/policyengine_api/services/v2/user_policies/transformations.py @@ -9,6 +9,7 @@ from policyengine_api.data.v2.models import UserPolicy from policyengine_api.services.v2.user_policies.types import ( LegacyUserPolicySnapshot, + UserPolicyCreateFields, UserPolicyCreationInput, UserPolicyPage, UserPolicyRead, @@ -24,8 +25,11 @@ def user_policy_read(association: UserPolicy) -> UserPolicyRead: country_id=association.country_id, user_id=association.user_id, policy_id=association.policy_id, + legacy_policy_id=association.legacy_policy_id, name=association.name, description=association.description, + baseline=association.baseline, + provenance=association.provenance, created_at=association.created_at, updated_at=association.updated_at, ) @@ -71,11 +75,27 @@ def project_legacy_user_policy( country_id=snapshot.country_id, user_id=user_id, policy_id=policy_id, + legacy_policy_id=snapshot.reform_id, name=snapshot.reform_label, description=None, ) +def owned_user_policy_input( + fields: UserPolicyCreateFields, + *, + user_id: UUID, + policy_id: UUID | None, +) -> UserPolicyCreationInput: + """Attach the session owner and the resolved v2 policy reference.""" + + return UserPolicyCreationInput( + **fields.model_dump(exclude={"policy_id"}), + user_id=user_id, + policy_id=policy_id, + ) + + def legacy_name_requires_update( association: UserPolicy, *, diff --git a/policyengine_api/services/v2/user_policies/types.py b/policyengine_api/services/v2/user_policies/types.py index 67f70cc6a..23dc7180d 100644 --- a/policyengine_api/services/v2/user_policies/types.py +++ b/policyengine_api/services/v2/user_policies/types.py @@ -5,10 +5,10 @@ from dataclasses import dataclass from datetime import datetime from enum import StrEnum -from typing import Annotated +from typing import Annotated, Any, Literal from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator +from pydantic import Field, StringConstraints, model_validator from policyengine_api.query_parameters import ( CountryId, @@ -16,30 +16,90 @@ ResourceId, UserId, ) +from policyengine_api.services.v2.associations.types import ( + AssociationDescription, + AssociationName, + StrictAssociationModel, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId + + +UserPolicyBaseline = Literal["current-law", "current-policy"] -class StrictAssociationInput(BaseModel): +class StrictAssociationInput(StrictAssociationModel): """Reject fields outside the reviewed association contract.""" - model_config = ConfigDict(extra="forbid", frozen=True) + +class PolicyProvenance(StrictAssociationInput): + """Where a v3 reform came from.""" + + source: Literal["manual", "chat", "bill", "tool"] + ref: Annotated[str, StringConstraints(max_length=2048)] | None = None + confidence: Literal["exact", "approximated", "partial"] | None = None + + +class UserPolicyCreateFields(StrictAssociationInput): + """Caller-supplied association fields; the owner comes from the session.""" + + country_id: CountryId + policy_id: ResourceId | None = None + legacy_policy_id: LegacyRecordId | None = None + name: AssociationName | None = None + description: AssociationDescription | None = None + baseline: UserPolicyBaseline | None = None + provenance: PolicyProvenance | None = None + + @model_validator(mode="after") + def require_policy_reference(self) -> "UserPolicyCreateFields": + if self.policy_id is None and self.legacy_policy_id is None: + raise ValueError( + "At least one of legacy_policy_id or policy_id is required" + ) + return self class UserPolicyCreationInput(StrictAssociationInput): + """Complete persisted association values, including the resolved owner.""" + country_id: CountryId user_id: UserId - policy_id: ResourceId - name: Annotated[str, StringConstraints(max_length=255)] | None = None + policy_id: ResourceId | None = None + # The Stage 10 mirror projects any committed v1 reform identifier. + legacy_policy_id: Annotated[int, Field(ge=0)] | None = None + name: AssociationName | None = None description: str | None = None + baseline: UserPolicyBaseline | None = None + provenance: PolicyProvenance | None = None + + @model_validator(mode="after") + def require_policy_reference(self) -> "UserPolicyCreationInput": + if self.policy_id is None and self.legacy_policy_id is None: + raise ValueError( + "At least one of legacy_policy_id or policy_id is required" + ) + return self class UserPolicyUpdateInput(StrictAssociationInput): - name: Annotated[str, StringConstraints(max_length=255)] | None = None - description: str | None = None + legacy_policy_id: LegacyRecordId | None = None + name: AssociationName | None = None + description: AssociationDescription | None = None + baseline: UserPolicyBaseline | None = None + provenance: PolicyProvenance | None = None @model_validator(mode="after") def require_supplied_field(self) -> "UserPolicyUpdateInput": - if not self.model_fields_set.intersection({"name", "description"}): - raise ValueError("At least one of name or description must be supplied") + supplied = self.model_fields_set.intersection( + {"legacy_policy_id", "name", "description", "baseline", "provenance"} + ) + if not supplied: + raise ValueError( + "At least one of legacy_policy_id, name, description, baseline, " + "or provenance must be supplied" + ) + if "legacy_policy_id" in supplied and self.legacy_policy_id is None: + raise ValueError("legacy_policy_id must not be null") return self @@ -75,9 +135,12 @@ class UserPolicyRead: id: UUID country_id: str user_id: UUID - policy_id: UUID + policy_id: UUID | None + legacy_policy_id: int | None name: str | None description: str | None + baseline: str | None + provenance: dict[str, Any] | None created_at: datetime updated_at: datetime diff --git a/policyengine_api/services/v2/user_policies/validators.py b/policyengine_api/services/v2/user_policies/validators.py index 1d895ecba..a460713b7 100644 --- a/policyengine_api/services/v2/user_policies/validators.py +++ b/policyengine_api/services/v2/user_policies/validators.py @@ -12,6 +12,10 @@ User, UserPolicy, ) +from policyengine_api.services.v2.associations.validators import ( + AssociationNotFoundError, + AssociationReferenceConflictError, +) from policyengine_api.services.v2.user_policies.types import ( LegacyUserPolicyMappingAction, ) @@ -24,8 +28,8 @@ ) -class UserPolicyNotFoundError(LookupError): - """Raised when an association is absent from the selected country.""" +class UserPolicyNotFoundError(AssociationNotFoundError): + """Raised when an association is absent for the caller and country.""" class AssociationPolicyNotFoundError(LookupError): @@ -36,7 +40,7 @@ class AssociationUserNotFoundError(LookupError): """Raised when an association references an unknown v2 user UUID.""" -class AssociationCountryConflictError(ValueError): +class AssociationCountryConflictError(AssociationReferenceConflictError): """Raised when an association and its referenced policy differ by country.""" @@ -54,26 +58,38 @@ def require_user_policy( return association -def validate_association_creation( - association_input: "UserPolicyCreationInput", +def validate_association_policy( *, - user: User | None, + country_id: str, + policy_id: UUID, policy: Policy | None, ) -> None: - if user is None: - raise AssociationUserNotFoundError( - f"user {association_input.user_id} was not found" - ) if policy is None: - raise AssociationPolicyNotFoundError( - f"policy {association_input.policy_id} was not found" - ) - if policy.country_id != association_input.country_id: + raise AssociationPolicyNotFoundError(f"policy {policy_id} was not found") + if policy.country_id != country_id: raise AssociationCountryConflictError( "Association country_id must match the referenced policy" ) +def resolve_association_policy_id( + *, + requested_policy_id: UUID | None, + mapped_policy_id: UUID | None, +) -> UUID | None: + """Combine an explicit v2 reference with the v1 mapping, if both exist.""" + + if ( + requested_policy_id is not None + and mapped_policy_id is not None + and requested_policy_id != mapped_policy_id + ): + raise AssociationReferenceConflictError( + "policy_id does not match the policy mirrored from legacy_policy_id" + ) + return requested_policy_id if requested_policy_id is not None else mapped_policy_id + + def validate_existing_legacy_user_mapping( mapping: LegacyUserMapping, *, user: User | None ) -> UUID: @@ -153,3 +169,13 @@ def validate_existing_legacy_user_policy_mapping( "legacy user-policy revision has an unapplied predecessor" ) return LegacyUserPolicyMappingAction.UPDATE, association + + +def require_mirrored_policy_id(projection: "UserPolicyCreationInput") -> UUID: + """The Stage 10 mirror always links a saved policy to its v2 policy.""" + + if projection.policy_id is None: + raise LegacyUserPolicyIntegrityError( + "mirrored saved policy has no v2 policy reference" + ) + return projection.policy_id diff --git a/policyengine_api/services/v2/user_reports/__init__.py b/policyengine_api/services/v2/user_reports/__init__.py new file mode 100644 index 000000000..1875d7c1a --- /dev/null +++ b/policyengine_api/services/v2/user_reports/__init__.py @@ -0,0 +1 @@ +"""Native v2 user-report association services.""" diff --git a/policyengine_api/services/v2/user_reports/database_connectors/__init__.py b/policyengine_api/services/v2/user_reports/database_connectors/__init__.py new file mode 100644 index 000000000..807c44a85 --- /dev/null +++ b/policyengine_api/services/v2/user_reports/database_connectors/__init__.py @@ -0,0 +1 @@ +"""SQL-only connector functions for v2 user-report persistence.""" diff --git a/policyengine_api/services/v2/user_reports/database_connectors/creates.py b/policyengine_api/services/v2/user_reports/database_connectors/creates.py new file mode 100644 index 000000000..d303307e6 --- /dev/null +++ b/policyengine_api/services/v2/user_reports/database_connectors/creates.py @@ -0,0 +1,20 @@ +"""Database inserts for v2 user-report associations.""" + +from __future__ import annotations + + +from sqlmodel import Session + +from policyengine_api.data.v2.models import UserReportAssociation +from policyengine_api.services.v2.user_reports.types import UserReportCreationInput + + +def create_user_report( + session: Session, + association_input: UserReportCreationInput, +) -> UserReportAssociation: + association = UserReportAssociation(**association_input.model_dump()) + session.add(association) + session.flush() + session.refresh(association) + return association diff --git a/policyengine_api/services/v2/user_reports/database_connectors/deletes.py b/policyengine_api/services/v2/user_reports/database_connectors/deletes.py new file mode 100644 index 000000000..dfd6a3cce --- /dev/null +++ b/policyengine_api/services/v2/user_reports/database_connectors/deletes.py @@ -0,0 +1,15 @@ +"""Database deletes for v2 user-report associations.""" + +from __future__ import annotations + +from sqlmodel import Session + +from policyengine_api.data.v2.models import UserReportAssociation + + +def delete_user_report( + session: Session, + association: UserReportAssociation, +) -> None: + session.delete(association) + session.flush() diff --git a/policyengine_api/services/v2/user_reports/database_connectors/reads.py b/policyengine_api/services/v2/user_reports/database_connectors/reads.py new file mode 100644 index 000000000..5e82ce935 --- /dev/null +++ b/policyengine_api/services/v2/user_reports/database_connectors/reads.py @@ -0,0 +1,88 @@ +"""Database reads for v2 user-report associations.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import Session, col, select + +from policyengine_api.data.v2.models import UserPolicy, UserReportAssociation + + +def read_owned_user_report_row( + session: Session, + *, + user_id: UUID, + country_id: str, + association_id: UUID, +) -> UserReportAssociation | None: + return session.exec( + select(UserReportAssociation).where( + UserReportAssociation.id == association_id, + UserReportAssociation.country_id == country_id, + UserReportAssociation.user_id == user_id, + ) + ).one_or_none() + + +def read_user_report_rows( + session: Session, + *, + country_id: str, + user_id: UUID, + legacy_report_id: int | None, + offset: int, + limit: int, +) -> list[UserReportAssociation]: + statement = select(UserReportAssociation).where( + UserReportAssociation.country_id == country_id, + UserReportAssociation.user_id == user_id, + ) + if legacy_report_id is not None: + statement = statement.where( + UserReportAssociation.legacy_report_id == legacy_report_id + ) + return list( + session.exec( + statement.order_by( + col(UserReportAssociation.created_at), + col(UserReportAssociation.id), + ) + .offset(offset) + .limit(limit + 1) + ).all() + ) + + +def read_all_user_report_rows( + session: Session, *, user_id: UUID +) -> list[UserReportAssociation]: + return list( + session.exec( + select(UserReportAssociation) + .where(UserReportAssociation.user_id == user_id) + .order_by( + col(UserReportAssociation.created_at), + col(UserReportAssociation.id), + ) + ).all() + ) + + +def read_owned_user_policy_exists( + session: Session, + *, + user_id: UUID, + country_id: str, + user_policy_id: UUID, +) -> bool: + return ( + session.exec( + select(UserPolicy.id).where( + UserPolicy.id == user_policy_id, + UserPolicy.user_id == user_id, + UserPolicy.country_id == country_id, + ) + ).one_or_none() + is not None + ) diff --git a/policyengine_api/services/v2/user_reports/database_connectors/updates.py b/policyengine_api/services/v2/user_reports/database_connectors/updates.py new file mode 100644 index 000000000..c9dcd199f --- /dev/null +++ b/policyengine_api/services/v2/user_reports/database_connectors/updates.py @@ -0,0 +1,25 @@ +"""Database updates for mutable v2 user-report association fields.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from sqlmodel import Session + +from policyengine_api.data.v2.models import UserReportAssociation +from policyengine_api.data.v2.models.base import utc_now + + +def update_user_report( + session: Session, + association: UserReportAssociation, + values: Mapping[str, Any], +) -> UserReportAssociation: + for field_name, value in values.items(): + setattr(association, field_name, value) + association.updated_at = utc_now() + session.add(association) + session.flush() + session.refresh(association) + return association diff --git a/policyengine_api/services/v2/user_reports/database_session.py b/policyengine_api/services/v2/user_reports/database_session.py new file mode 100644 index 000000000..af411d46e --- /dev/null +++ b/policyengine_api/services/v2/user_reports/database_session.py @@ -0,0 +1,24 @@ +"""Database session lifetime management for v2 user-report services.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from sqlalchemy.orm import sessionmaker +from sqlmodel import Session + + +class UserReportDatabaseSession: + def __init__(self, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + @contextmanager + def read(self) -> Iterator[Session]: + with self._session_factory() as session: + yield session + + @contextmanager + def transaction(self) -> Iterator[Session]: + with self._session_factory.begin() as session: + yield session diff --git a/policyengine_api/services/v2/user_reports/services.py b/policyengine_api/services/v2/user_reports/services.py new file mode 100644 index 000000000..4e60be509 --- /dev/null +++ b/policyengine_api/services/v2/user_reports/services.py @@ -0,0 +1,274 @@ +"""Application services for native v2 user-report associations. + +Stage 12A anchors saved reports on the requested v1 report output ID. The +owner always comes from the verified session principal (a signed-in user or a +signed-out browser), and v1 references are checked in a short read-only v1 +transaction before the v2 transaction opens. Reads never provision a +browser's unauthenticated user; one that does not exist yet owns no records. +An optional ``user_policy_id`` (the saved reform the report was run from) and +an optional ``baseline_user_policy_id`` (the saved reform it was compared with) +must each name the same owner's saved policy in the same country. +""" + +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import Session + +from policyengine_api.data.v2.models import UserReportAssociation +from policyengine_api.services.v2.associations.services import ( + require_legacy_reference, +) +from policyengine_api.services.v2.associations.types import LegacyReferenceChecker +from policyengine_api.services.v2.identity.services import ( + resolve_principal_user_for_read_in_session, + resolve_principal_user_in_session, +) +from policyengine_api.services.v2.identity.types import AccountPrincipal +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind +from policyengine_api.services.v2.user_reports.database_connectors.creates import ( + create_user_report, +) +from policyengine_api.services.v2.user_reports.database_connectors.deletes import ( + delete_user_report, +) +from policyengine_api.services.v2.user_reports.database_connectors.reads import ( + read_all_user_report_rows, + read_owned_user_policy_exists, + read_owned_user_report_row, + read_user_report_rows, +) +from policyengine_api.services.v2.user_reports.database_connectors.updates import ( + update_user_report, +) +from policyengine_api.services.v2.user_reports.database_session import ( + UserReportDatabaseSession, +) +from policyengine_api.services.v2.user_reports.transformations import ( + owned_user_report_input, + user_report_page, + user_report_read, + user_report_update_values, +) +from policyengine_api.services.v2.user_reports.types import ( + UserReportCreateFields, + UserReportPage, + UserReportRead, + UserReportUpdateInput, +) +from policyengine_api.services.v2.user_reports.validators import ( + require_owned_user_policy, + require_user_report, +) + + +def read_owned_user_report( + session: Session, + *, + user_id: UUID | None, + country_id: str, + association_id: UUID, +) -> UserReportAssociation: + """``user_id`` is None for an unauthenticated user that owns nothing yet.""" + + return require_user_report( + None + if user_id is None + else read_owned_user_report_row( + session, + user_id=user_id, + country_id=country_id, + association_id=association_id, + ), + association_id=association_id, + ) + + +def validate_linked_user_policy( + session: Session, + *, + user_id: UUID, + country_id: str, + user_policy_id: UUID | None, +) -> None: + if user_policy_id is None: + return + require_owned_user_policy( + read_owned_user_policy_exists( + session, + user_id=user_id, + country_id=country_id, + user_policy_id=user_policy_id, + ), + user_policy_id=user_policy_id, + ) + + +def read_all_user_reports( + session: Session, *, user_id: UUID +) -> tuple[UserReportRead, ...]: + """Return every report association owned by a user, across countries.""" + + return tuple( + user_report_read(row) + for row in read_all_user_report_rows(session, user_id=user_id) + ) + + +class V2UserReportService: + """Sequence owner-scoped association operations through explicit sessions.""" + + def __init__( + self, + database_session: UserReportDatabaseSession, + legacy_references: LegacyReferenceChecker | None = None, + ) -> None: + self._database_session = database_session + self._legacy_references = legacy_references + + def _require_legacy_report(self, *, country_id: str, legacy_report_id: int) -> None: + require_legacy_reference( + self._legacy_references, + LegacyRecordKind.REPORT, + country_id=country_id, + legacy_id=legacy_report_id, + ) + + def create_user_report( + self, + *, + principal: AccountPrincipal, + association_input: UserReportCreateFields, + ) -> UserReportRead: + self._require_legacy_report( + country_id=association_input.country_id, + legacy_report_id=association_input.legacy_report_id, + ) + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, + principal, + country_id=association_input.country_id, + ) + for linked_user_policy_id in ( + association_input.user_policy_id, + association_input.baseline_user_policy_id, + ): + validate_linked_user_policy( + session, + user_id=owner.user_id, + country_id=association_input.country_id, + user_policy_id=linked_user_policy_id, + ) + return user_report_read( + create_user_report( + session, + owned_user_report_input(association_input, user_id=owner.user_id), + ) + ) + + def get_user_report( + self, + *, + principal: AccountPrincipal, + country_id: str, + association_id: UUID, + ) -> UserReportRead: + with self._database_session.transaction() as session: + owner = resolve_principal_user_for_read_in_session( + session, principal, country_id=country_id + ) + return user_report_read( + read_owned_user_report( + session, + user_id=None if owner is None else owner.user_id, + country_id=country_id, + association_id=association_id, + ) + ) + + def list_user_reports( + self, + *, + principal: AccountPrincipal, + country_id: str, + legacy_report_id: int | None = None, + offset: int = 0, + limit: int = 100, + ) -> UserReportPage: + with self._database_session.transaction() as session: + owner = resolve_principal_user_for_read_in_session( + session, principal, country_id=country_id + ) + if owner is None: + return user_report_page([], offset=offset, limit=limit) + rows = read_user_report_rows( + session, + country_id=country_id, + user_id=owner.user_id, + legacy_report_id=legacy_report_id, + offset=offset, + limit=limit, + ) + return user_report_page(rows, offset=offset, limit=limit) + + def patch_user_report( + self, + *, + principal: AccountPrincipal, + country_id: str, + association_id: UUID, + association_input: UserReportUpdateInput, + ) -> UserReportRead: + if association_input.legacy_report_id is not None: + self._require_legacy_report( + country_id=country_id, + legacy_report_id=association_input.legacy_report_id, + ) + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + association = read_owned_user_report( + session, + user_id=owner.user_id, + country_id=country_id, + association_id=association_id, + ) + for link_field in ("user_policy_id", "baseline_user_policy_id"): + if link_field in association_input.model_fields_set: + validate_linked_user_policy( + session, + user_id=owner.user_id, + country_id=country_id, + user_policy_id=getattr(association_input, link_field), + ) + return user_report_read( + update_user_report( + session, + association, + user_report_update_values(association_input), + ) + ) + + def delete_user_report( + self, + *, + principal: AccountPrincipal, + country_id: str, + association_id: UUID, + ) -> None: + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + delete_user_report( + session, + read_owned_user_report( + session, + user_id=owner.user_id, + country_id=country_id, + association_id=association_id, + ), + ) diff --git a/policyengine_api/services/v2/user_reports/transformations.py b/policyengine_api/services/v2/user_reports/transformations.py new file mode 100644 index 000000000..976e2bd10 --- /dev/null +++ b/policyengine_api/services/v2/user_reports/transformations.py @@ -0,0 +1,81 @@ +"""Pure representation transformations for v2 user-report operations.""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from policyengine_api.data.v2.models import UserReportAssociation +from policyengine_api.services.v2.user_reports.types import ( + UserReportCreateFields, + UserReportCreationInput, + UserReportPage, + UserReportRead, + UserReportUpdateInput, + stored_provisions, + stored_validation, +) + + +def user_report_read(association: UserReportAssociation) -> UserReportRead: + return UserReportRead( + id=association.id, + country_id=association.country_id, + user_id=association.user_id, + report_id=association.report_id, + legacy_report_id=association.legacy_report_id, + name=association.name, + description=association.description, + user_policy_id=association.user_policy_id, + baseline_user_policy_id=association.baseline_user_policy_id, + source_note=association.source_note, + provisions=association.provisions, + validation=association.validation, + budget_window_years=association.budget_window_years, + last_run_at=association.last_run_at, + created_at=association.created_at, + updated_at=association.updated_at, + ) + + +def user_report_page( + rows: list[UserReportAssociation], + *, + offset: int, + limit: int, +) -> UserReportPage: + return UserReportPage( + items=tuple(user_report_read(row) for row in rows[:limit]), + offset=offset, + limit=limit, + has_more=len(rows) > limit, + ) + + +def owned_user_report_input( + fields: UserReportCreateFields, + *, + user_id: UUID, +) -> UserReportCreationInput: + return UserReportCreationInput( + **fields.model_dump(exclude={"provisions", "validation"}), + provisions=stored_provisions(fields.provisions), + validation=stored_validation(fields.validation), + user_id=user_id, + ) + + +def user_report_update_values( + association_input: UserReportUpdateInput, +) -> dict[str, Any]: + """Return explicitly supplied fields with JSON documents normalized.""" + + values = association_input.model_dump( + exclude_unset=True, + exclude={"provisions", "validation"}, + ) + if "provisions" in association_input.model_fields_set: + values["provisions"] = stored_provisions(association_input.provisions) + if "validation" in association_input.model_fields_set: + values["validation"] = stored_validation(association_input.validation) + return values diff --git a/policyengine_api/services/v2/user_reports/types.py b/policyengine_api/services/v2/user_reports/types.py new file mode 100644 index 000000000..9e15186de --- /dev/null +++ b/policyengine_api/services/v2/user_reports/types.py @@ -0,0 +1,232 @@ +"""Framework-independent data exchanged by v2 user-report layers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import json +from typing import Annotated, Literal +from uuid import UUID + +from pydantic import ( + AwareDatetime, + Field, + JsonValue, + StringConstraints, + model_validator, +) + +from policyengine_api.query_parameters import CountryId, ResourceId, UserId +from policyengine_api.services.v2.associations.types import ( + AssociationDescription, + AssociationName, + FiniteJsonValue, + StrictAssociationModel, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId +from policyengine_api.utils.budget_window import BUDGET_WINDOW_MAX_YEARS + + +MAXIMUM_PROVISIONS_BYTES = 64 * 1024 +REPORT_SOURCE_NOTE_MAX_LENGTH = 10_000 +MAXIMUM_CALIBRATION_MATCHES = 500 +MAXIMUM_SCORECARD_PROGRAMS = 200 +IsoDate = Annotated[str, StringConstraints(pattern=r"^\d{4}-\d{2}-\d{2}$")] +ReportSourceNote = Annotated[ + str, StringConstraints(max_length=REPORT_SOURCE_NOTE_MAX_LENGTH) +] +# A window of one year is the report itself, so it is stored as no window. +BudgetWindowYears = Annotated[int, Field(ge=2, le=BUDGET_WINDOW_MAX_YEARS)] +ShortText = Annotated[str, StringConstraints(max_length=64)] +LabelText = Annotated[str, StringConstraints(max_length=255)] +LongLabelText = Annotated[str, StringConstraints(max_length=1024)] + + +class StrictUserReportModel(StrictAssociationModel): + """Reject fields outside the reviewed user-report contract.""" + + +class ReportProvisionInterval(StrictUserReportModel): + # The contract makes this key optional rather than nullable. + parameter: str | None = Field(default=None, exclude_if=lambda value: value is None) + start_date: IsoDate + end_date: IsoDate + value: FiniteJsonValue + + +class ReportProvision(StrictUserReportModel): + path: str + breadcrumb: str + unit: str | None = None + baseline_value: FiniteJsonValue + value: FiniteJsonValue + intervals: list[ReportProvisionInterval] | None = None + # How a value set from one year on moves after it: 'fixed', + # 'current_law', or an index's parameter path. The rest say when it + # moves and how grown values round. Absent for a hand-set schedule. + growth: LongLabelText | None = None + growth_start: int | None = None + growth_base: int | None = None + growth_round_to: float | None = None + growth_round: Literal["nearest", "down", "up"] | None = None + + +class ReportCalibrationWorst(StrictUserReportModel): + name: LongLabelText + source: LongLabelText + geography: ShortText + relative_error: float | None = None + + +class ReportCalibrationMatch(StrictUserReportModel): + variable: LabelText + depth: int + ring: Literal["primary", "mechanism", "downstream"] + target_count: int + mean_abs_relative_error: float + worst: ReportCalibrationWorst + + +class ReportCalibration(StrictUserReportModel): + release_id: LabelText | None = None + geography: ShortText + reached_count: int + matches: Annotated[ + list[ReportCalibrationMatch], + Field(max_length=MAXIMUM_CALIBRATION_MATCHES), + ] + + +class ReportScorecard(StrictUserReportModel): + programs: Annotated[list[LabelText], Field(max_length=MAXIMUM_SCORECARD_PROGRAMS)] + + +class ReportValidation(StrictUserReportModel): + matched_at: ShortText + map_model_version: LabelText + calibration: ReportCalibration | None = None + scorecard: ReportScorecard | None = None + + +StoredJSONObject = dict[str, JsonValue] + + +def stored_provisions( + provisions: list[ReportProvision] | None, +) -> list[StoredJSONObject] | None: + """Serialize provisions exactly as supplied, enforcing the 64 KiB cap.""" + + if provisions is None: + return None + stored = [ + provision.model_dump(mode="json", exclude_unset=True) + for provision in provisions + ] + encoded = json.dumps(stored, separators=(",", ":"), ensure_ascii=False) + if len(encoded.encode("utf-8")) > MAXIMUM_PROVISIONS_BYTES: + raise ValueError("provisions must not exceed 64 KiB when serialized") + return stored + + +def stored_validation( + validation: ReportValidation | None, +) -> StoredJSONObject | None: + if validation is None: + return None + return validation.model_dump(mode="json", exclude_unset=True) + + +class _ReportPresentationFields(StrictUserReportModel): + name: AssociationName | None = None + description: AssociationDescription | None = None + source_note: ReportSourceNote | None = None + provisions: list[ReportProvision] | None = None + validation: ReportValidation | None = None + budget_window_years: BudgetWindowYears | None = None + last_run_at: AwareDatetime | None = None + + @model_validator(mode="after") + def bound_provisions(self) -> "_ReportPresentationFields": + stored_provisions(self.provisions) + return self + + +class UserReportCreateFields(_ReportPresentationFields): + """Caller-supplied fields; Stage 12A accepts only v1 report references.""" + + country_id: CountryId + legacy_report_id: LegacyRecordId + user_policy_id: ResourceId | None = None + baseline_user_policy_id: ResourceId | None = None + + +class UserReportCreationInput(StrictUserReportModel): + """Complete persisted association values, including the resolved owner.""" + + country_id: CountryId + user_id: UserId + legacy_report_id: LegacyRecordId + name: AssociationName | None = None + description: str | None = None + user_policy_id: ResourceId | None = None + baseline_user_policy_id: ResourceId | None = None + source_note: str | None = None + provisions: list[StoredJSONObject] | None = None + validation: StoredJSONObject | None = None + budget_window_years: int | None = None + last_run_at: AwareDatetime | None = None + + +class UserReportUpdateInput(_ReportPresentationFields): + legacy_report_id: LegacyRecordId | None = None + user_policy_id: ResourceId | None = None + baseline_user_policy_id: ResourceId | None = None + + @model_validator(mode="after") + def require_valid_supplied_field(self) -> "UserReportUpdateInput": + mutable_fields = { + "legacy_report_id", + "name", + "description", + "user_policy_id", + "baseline_user_policy_id", + "source_note", + "provisions", + "validation", + "budget_window_years", + "last_run_at", + } + supplied = self.model_fields_set.intersection(mutable_fields) + if not supplied: + raise ValueError("At least one mutable user-report field must be supplied") + if "legacy_report_id" in supplied and self.legacy_report_id is None: + raise ValueError("legacy_report_id must not be null") + return self + + +@dataclass(frozen=True) +class UserReportRead: + id: UUID + country_id: str + user_id: UUID + report_id: UUID | None + legacy_report_id: int | None + name: str | None + description: str | None + user_policy_id: UUID | None + baseline_user_policy_id: UUID | None + source_note: str | None + provisions: list[StoredJSONObject] | None + validation: StoredJSONObject | None + budget_window_years: int | None + last_run_at: datetime | None + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True) +class UserReportPage: + items: tuple[UserReportRead, ...] + offset: int + limit: int + has_more: bool diff --git a/policyengine_api/services/v2/user_reports/validators.py b/policyengine_api/services/v2/user_reports/validators.py new file mode 100644 index 000000000..0f540e5cd --- /dev/null +++ b/policyengine_api/services/v2/user_reports/validators.py @@ -0,0 +1,35 @@ +"""Database-independent validation for v2 user-report operations.""" + +from __future__ import annotations + +from uuid import UUID + +from policyengine_api.data.v2.models import UserReportAssociation +from policyengine_api.services.v2.associations.validators import ( + AssociationLinkNotFoundError, + AssociationNotFoundError, +) + + +class UserReportNotFoundError(AssociationNotFoundError): + """Raised when an association is absent for the caller and country.""" + + +def require_user_report( + association: UserReportAssociation | None, + *, + association_id: UUID, +) -> UserReportAssociation: + if association is None: + raise UserReportNotFoundError( + f"user-report association {association_id} was not found" + ) + return association + + +def require_owned_user_policy(owned: bool, *, user_policy_id: UUID) -> None: + if not owned: + raise AssociationLinkNotFoundError( + f"user-policy association {user_policy_id} was not found for this " + "user and country" + ) diff --git a/policyengine_api/services/v2/user_simulations/__init__.py b/policyengine_api/services/v2/user_simulations/__init__.py new file mode 100644 index 000000000..cfc2c3ac0 --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/__init__.py @@ -0,0 +1 @@ +"""Native v2 user-simulation association services.""" diff --git a/policyengine_api/services/v2/user_simulations/database_connectors/__init__.py b/policyengine_api/services/v2/user_simulations/database_connectors/__init__.py new file mode 100644 index 000000000..5852a7f5f --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/database_connectors/__init__.py @@ -0,0 +1 @@ +"""SQL-only connector functions for v2 user-simulation persistence.""" diff --git a/policyengine_api/services/v2/user_simulations/database_connectors/creates.py b/policyengine_api/services/v2/user_simulations/database_connectors/creates.py new file mode 100644 index 000000000..991ec151e --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/database_connectors/creates.py @@ -0,0 +1,22 @@ +"""Database inserts for v2 user-simulation associations.""" + +from __future__ import annotations + + +from sqlmodel import Session + +from policyengine_api.data.v2.models import UserSimulationAssociation +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationCreationInput, +) + + +def create_user_simulation( + session: Session, + association_input: UserSimulationCreationInput, +) -> UserSimulationAssociation: + association = UserSimulationAssociation(**association_input.model_dump()) + session.add(association) + session.flush() + session.refresh(association) + return association diff --git a/policyengine_api/services/v2/user_simulations/database_connectors/deletes.py b/policyengine_api/services/v2/user_simulations/database_connectors/deletes.py new file mode 100644 index 000000000..f2bc8e718 --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/database_connectors/deletes.py @@ -0,0 +1,15 @@ +"""Database deletes for v2 user-simulation associations.""" + +from __future__ import annotations + +from sqlmodel import Session + +from policyengine_api.data.v2.models import UserSimulationAssociation + + +def delete_user_simulation( + session: Session, + association: UserSimulationAssociation, +) -> None: + session.delete(association) + session.flush() diff --git a/policyengine_api/services/v2/user_simulations/database_connectors/reads.py b/policyengine_api/services/v2/user_simulations/database_connectors/reads.py new file mode 100644 index 000000000..b23d6ade1 --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/database_connectors/reads.py @@ -0,0 +1,69 @@ +"""Database reads for v2 user-simulation associations.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import Session, col, select + +from policyengine_api.data.v2.models import UserSimulationAssociation + + +def read_owned_user_simulation_row( + session: Session, + *, + user_id: UUID, + country_id: str, + association_id: UUID, +) -> UserSimulationAssociation | None: + return session.exec( + select(UserSimulationAssociation).where( + UserSimulationAssociation.id == association_id, + UserSimulationAssociation.country_id == country_id, + UserSimulationAssociation.user_id == user_id, + ) + ).one_or_none() + + +def read_user_simulation_rows( + session: Session, + *, + country_id: str, + user_id: UUID, + legacy_simulation_id: int | None, + offset: int, + limit: int, +) -> list[UserSimulationAssociation]: + statement = select(UserSimulationAssociation).where( + UserSimulationAssociation.country_id == country_id, + UserSimulationAssociation.user_id == user_id, + ) + if legacy_simulation_id is not None: + statement = statement.where( + UserSimulationAssociation.legacy_simulation_id == legacy_simulation_id + ) + return list( + session.exec( + statement.order_by( + col(UserSimulationAssociation.created_at), + col(UserSimulationAssociation.id), + ) + .offset(offset) + .limit(limit + 1) + ).all() + ) + + +def read_all_user_simulation_rows( + session: Session, *, user_id: UUID +) -> list[UserSimulationAssociation]: + return list( + session.exec( + select(UserSimulationAssociation) + .where(UserSimulationAssociation.user_id == user_id) + .order_by( + col(UserSimulationAssociation.created_at), + col(UserSimulationAssociation.id), + ) + ).all() + ) diff --git a/policyengine_api/services/v2/user_simulations/database_connectors/updates.py b/policyengine_api/services/v2/user_simulations/database_connectors/updates.py new file mode 100644 index 000000000..0d4aae48d --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/database_connectors/updates.py @@ -0,0 +1,25 @@ +"""Database updates for mutable v2 user-simulation association fields.""" + +from __future__ import annotations + +from sqlmodel import Session + +from policyengine_api.data.v2.models import UserSimulationAssociation +from policyengine_api.data.v2.models.base import utc_now +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationUpdateInput, +) + + +def update_user_simulation( + session: Session, + association: UserSimulationAssociation, + association_input: UserSimulationUpdateInput, +) -> UserSimulationAssociation: + for field_name, value in association_input.model_dump(exclude_unset=True).items(): + setattr(association, field_name, value) + association.updated_at = utc_now() + session.add(association) + session.flush() + session.refresh(association) + return association diff --git a/policyengine_api/services/v2/user_simulations/database_session.py b/policyengine_api/services/v2/user_simulations/database_session.py new file mode 100644 index 000000000..fa6b74c23 --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/database_session.py @@ -0,0 +1,24 @@ +"""Database session lifetime management for v2 user-simulation services.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from sqlalchemy.orm import sessionmaker +from sqlmodel import Session + + +class UserSimulationDatabaseSession: + def __init__(self, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + @contextmanager + def read(self) -> Iterator[Session]: + with self._session_factory() as session: + yield session + + @contextmanager + def transaction(self) -> Iterator[Session]: + with self._session_factory.begin() as session: + yield session diff --git a/policyengine_api/services/v2/user_simulations/services.py b/policyengine_api/services/v2/user_simulations/services.py new file mode 100644 index 000000000..b3d38607d --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/services.py @@ -0,0 +1,234 @@ +"""Application services for native v2 user-simulation associations. + +Stage 12A anchors saved simulations on v1 simulation IDs. The owner always +comes from the verified session principal (a signed-in user or a signed-out +browser), and v1 references are checked in a short read-only v1 transaction +before the v2 transaction opens. Reads never provision a browser's +unauthenticated user; one that does not exist yet owns no records. +""" + +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import Session + +from policyengine_api.data.v2.models import UserSimulationAssociation +from policyengine_api.services.v2.associations.services import ( + require_legacy_reference, +) +from policyengine_api.services.v2.associations.types import LegacyReferenceChecker +from policyengine_api.services.v2.identity.services import ( + resolve_principal_user_for_read_in_session, + resolve_principal_user_in_session, +) +from policyengine_api.services.v2.identity.types import AccountPrincipal +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind +from policyengine_api.services.v2.user_simulations.database_connectors.creates import ( + create_user_simulation, +) +from policyengine_api.services.v2.user_simulations.database_connectors.deletes import ( + delete_user_simulation, +) +from policyengine_api.services.v2.user_simulations.database_connectors.reads import ( + read_all_user_simulation_rows, + read_owned_user_simulation_row, + read_user_simulation_rows, +) +from policyengine_api.services.v2.user_simulations.database_connectors.updates import ( + update_user_simulation, +) +from policyengine_api.services.v2.user_simulations.database_session import ( + UserSimulationDatabaseSession, +) +from policyengine_api.services.v2.user_simulations.transformations import ( + owned_user_simulation_input, + user_simulation_page, + user_simulation_read, +) +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationCreateFields, + UserSimulationPage, + UserSimulationRead, + UserSimulationUpdateInput, +) +from policyengine_api.services.v2.user_simulations.validators import ( + require_user_simulation, +) + + +def read_owned_user_simulation( + session: Session, + *, + user_id: UUID | None, + country_id: str, + association_id: UUID, +) -> UserSimulationAssociation: + """``user_id`` is None for an unauthenticated user that owns nothing yet.""" + + return require_user_simulation( + None + if user_id is None + else read_owned_user_simulation_row( + session, + user_id=user_id, + country_id=country_id, + association_id=association_id, + ), + association_id=association_id, + ) + + +def read_all_user_simulations( + session: Session, *, user_id: UUID +) -> tuple[UserSimulationRead, ...]: + """Return every simulation association owned by a user, across countries.""" + + return tuple( + user_simulation_read(row) + for row in read_all_user_simulation_rows(session, user_id=user_id) + ) + + +class V2UserSimulationService: + """Sequence owner-scoped association operations through explicit sessions.""" + + def __init__( + self, + database_session: UserSimulationDatabaseSession, + legacy_references: LegacyReferenceChecker | None = None, + ) -> None: + self._database_session = database_session + self._legacy_references = legacy_references + + def _require_legacy_simulation( + self, *, country_id: str, legacy_simulation_id: int + ) -> None: + require_legacy_reference( + self._legacy_references, + LegacyRecordKind.SIMULATION, + country_id=country_id, + legacy_id=legacy_simulation_id, + ) + + def create_user_simulation( + self, + *, + principal: AccountPrincipal, + association_input: UserSimulationCreateFields, + ) -> UserSimulationRead: + self._require_legacy_simulation( + country_id=association_input.country_id, + legacy_simulation_id=association_input.legacy_simulation_id, + ) + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, + principal, + country_id=association_input.country_id, + ) + return user_simulation_read( + create_user_simulation( + session, + owned_user_simulation_input( + association_input, + user_id=owner.user_id, + ), + ) + ) + + def get_user_simulation( + self, + *, + principal: AccountPrincipal, + country_id: str, + association_id: UUID, + ) -> UserSimulationRead: + with self._database_session.transaction() as session: + owner = resolve_principal_user_for_read_in_session( + session, principal, country_id=country_id + ) + return user_simulation_read( + read_owned_user_simulation( + session, + user_id=None if owner is None else owner.user_id, + country_id=country_id, + association_id=association_id, + ) + ) + + def list_user_simulations( + self, + *, + principal: AccountPrincipal, + country_id: str, + legacy_simulation_id: int | None = None, + offset: int = 0, + limit: int = 100, + ) -> UserSimulationPage: + with self._database_session.transaction() as session: + owner = resolve_principal_user_for_read_in_session( + session, principal, country_id=country_id + ) + if owner is None: + return user_simulation_page([], offset=offset, limit=limit) + rows = read_user_simulation_rows( + session, + country_id=country_id, + user_id=owner.user_id, + legacy_simulation_id=legacy_simulation_id, + offset=offset, + limit=limit, + ) + return user_simulation_page(rows, offset=offset, limit=limit) + + def patch_user_simulation( + self, + *, + principal: AccountPrincipal, + country_id: str, + association_id: UUID, + association_input: UserSimulationUpdateInput, + ) -> UserSimulationRead: + if association_input.legacy_simulation_id is not None: + self._require_legacy_simulation( + country_id=country_id, + legacy_simulation_id=association_input.legacy_simulation_id, + ) + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + return user_simulation_read( + update_user_simulation( + session, + read_owned_user_simulation( + session, + user_id=owner.user_id, + country_id=country_id, + association_id=association_id, + ), + association_input, + ) + ) + + def delete_user_simulation( + self, + *, + principal: AccountPrincipal, + country_id: str, + association_id: UUID, + ) -> None: + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + delete_user_simulation( + session, + read_owned_user_simulation( + session, + user_id=owner.user_id, + country_id=country_id, + association_id=association_id, + ), + ) diff --git a/policyengine_api/services/v2/user_simulations/transformations.py b/policyengine_api/services/v2/user_simulations/transformations.py new file mode 100644 index 000000000..adc207023 --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/transformations.py @@ -0,0 +1,51 @@ +"""Pure representation transformations for v2 user-simulation operations.""" + +from __future__ import annotations + +from uuid import UUID + +from policyengine_api.data.v2.models import UserSimulationAssociation +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationCreateFields, + UserSimulationCreationInput, + UserSimulationPage, + UserSimulationRead, +) + + +def user_simulation_read( + association: UserSimulationAssociation, +) -> UserSimulationRead: + return UserSimulationRead( + id=association.id, + country_id=association.country_id, + user_id=association.user_id, + simulation_id=association.simulation_id, + legacy_simulation_id=association.legacy_simulation_id, + name=association.name, + description=association.description, + created_at=association.created_at, + updated_at=association.updated_at, + ) + + +def user_simulation_page( + rows: list[UserSimulationAssociation], + *, + offset: int, + limit: int, +) -> UserSimulationPage: + return UserSimulationPage( + items=tuple(user_simulation_read(row) for row in rows[:limit]), + offset=offset, + limit=limit, + has_more=len(rows) > limit, + ) + + +def owned_user_simulation_input( + fields: UserSimulationCreateFields, + *, + user_id: UUID, +) -> UserSimulationCreationInput: + return UserSimulationCreationInput(**fields.model_dump(), user_id=user_id) diff --git a/policyengine_api/services/v2/user_simulations/types.py b/policyengine_api/services/v2/user_simulations/types.py new file mode 100644 index 000000000..ef406d18c --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/types.py @@ -0,0 +1,79 @@ +"""Framework-independent data exchanged by v2 user-simulation layers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from pydantic import model_validator + +from policyengine_api.query_parameters import CountryId, UserId +from policyengine_api.services.v2.associations.types import ( + AssociationDescription, + AssociationName, + StrictAssociationModel, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId + + +class StrictUserSimulationInput(StrictAssociationModel): + """Reject fields outside the reviewed user-simulation contract.""" + + +class UserSimulationCreateFields(StrictUserSimulationInput): + """Caller-supplied fields; Stage 12A accepts only v1 simulation references.""" + + country_id: CountryId + legacy_simulation_id: LegacyRecordId + name: AssociationName | None = None + description: AssociationDescription | None = None + + +class UserSimulationCreationInput(StrictUserSimulationInput): + country_id: CountryId + user_id: UserId + legacy_simulation_id: LegacyRecordId + name: AssociationName | None = None + description: str | None = None + + +class UserSimulationUpdateInput(StrictUserSimulationInput): + legacy_simulation_id: LegacyRecordId | None = None + name: AssociationName | None = None + description: AssociationDescription | None = None + + @model_validator(mode="after") + def require_valid_supplied_field(self) -> "UserSimulationUpdateInput": + supplied = self.model_fields_set.intersection( + {"legacy_simulation_id", "name", "description"} + ) + if not supplied: + raise ValueError( + "At least one of legacy_simulation_id, name, or description must " + "be supplied" + ) + if "legacy_simulation_id" in supplied and self.legacy_simulation_id is None: + raise ValueError("legacy_simulation_id must not be null") + return self + + +@dataclass(frozen=True) +class UserSimulationRead: + id: UUID + country_id: str + user_id: UUID + simulation_id: UUID | None + legacy_simulation_id: int | None + name: str | None + description: str | None + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True) +class UserSimulationPage: + items: tuple[UserSimulationRead, ...] + offset: int + limit: int + has_more: bool diff --git a/policyengine_api/services/v2/user_simulations/validators.py b/policyengine_api/services/v2/user_simulations/validators.py new file mode 100644 index 000000000..29a8aae9b --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/validators.py @@ -0,0 +1,26 @@ +"""Database-independent validation for v2 user-simulation operations.""" + +from __future__ import annotations + +from uuid import UUID + +from policyengine_api.data.v2.models import UserSimulationAssociation +from policyengine_api.services.v2.associations.validators import ( + AssociationNotFoundError, +) + + +class UserSimulationNotFoundError(AssociationNotFoundError): + """Raised when an association is absent for the caller and country.""" + + +def require_user_simulation( + association: UserSimulationAssociation | None, + *, + association_id: UUID, +) -> UserSimulationAssociation: + if association is None: + raise UserSimulationNotFoundError( + f"user-simulation association {association_id} was not found" + ) + return association diff --git a/pyproject.toml b/pyproject.toml index aa9d3b425..7d3630da1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ dependencies = [ "werkzeug", "sqlmodel>=0.0.39,<0.1", "psycopg[binary]>=3.3,<4", + "pyjwt[crypto]>=2.10,<3", ] [project.optional-dependencies] @@ -95,6 +96,8 @@ files = [ "policyengine_api/fastapi_routes/dependencies.py", "policyengine_api/fastapi_routes/v2", "policyengine_api/services/v2", + "policyengine_api/libs/user_auth.py", + "policyengine_api/libs/auth0_management.py", ] explicit_package_bases = true follow_imports = "silent" diff --git a/rules/fixtures/api.v2.user-services-no-sql/bad_services.py b/rules/fixtures/api.v2.user-services-no-sql/bad_services.py new file mode 100644 index 000000000..3ca5e0b0e --- /dev/null +++ b/rules/fixtures/api.v2.user-services-no-sql/bad_services.py @@ -0,0 +1,9 @@ +"""Proof fixture: a user service that queries rows itself. The rule must reject it.""" + +from sqlmodel import Session, select + +from policyengine_api.data.v2.models import UserPolicy + + +def list_user_policies(session: Session, user_id: str) -> list[UserPolicy]: + return list(session.exec(select(UserPolicy).where(UserPolicy.user_id == user_id))) diff --git a/rules/fixtures/api.v2.user-services-no-sql/good_services.py b/rules/fixtures/api.v2.user-services-no-sql/good_services.py new file mode 100644 index 000000000..d060ac947 --- /dev/null +++ b/rules/fixtures/api.v2.user-services-no-sql/good_services.py @@ -0,0 +1,7 @@ +"""Proof fixture: a user service that leaves queries to its connector. The rule must accept it.""" + +from policyengine_api.services.v2.user_policies.database_connectors import reads + + +def list_user_policies(session, user_id: str): + return reads.list_user_policies(session, user_id=user_id) diff --git a/rules/registry.toml b/rules/registry.toml index e53acc471..5c9846e73 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -189,6 +189,27 @@ autofix = false [rule.enforcer] kind = "none" +[[rule]] +id = "api.v2.user-services-no-sql" +category = "v2-layers" +statement = "v2 user services (identity, accounts, legacy references and user associations) never build or run SQL themselves or import SQLAlchemy; their connectors do." +rationale = "docs/engineering/skills/v2-code-organization.md#servicespy" +scope = ["policyengine_api/services/v2/identity/services.py", "policyengine_api/services/v2/legacy_refs/services.py", "policyengine_api/services/v2/user_*/services.py"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pytest" +nodes = ["tests/unit/v2/test_data_crud_boundaries.py::test_user_services_construct_no_sql"] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "make test" +[rule.proof] +bad_fixture = "rules/fixtures/api.v2.user-services-no-sql/bad_services.py" +good_fixture = "rules/fixtures/api.v2.user-services-no-sql/good_services.py" +test = "tests/unit/v2/test_data_crud_boundaries.py::test_rule_proof_user_services_no_sql" + [[rule]] id = "api.v2.connectors-never-commit" category = "v2-layers" @@ -699,9 +720,9 @@ codes = ["F401"] [[rule]] id = "api.quality.mypy-v2" category = "quality" -statement = "Shared query models, FastAPI dependencies, and v2 routes and services pass mypy with untyped definitions disallowed." +statement = "Shared query models, FastAPI dependencies, v2 routes and services, and the user sign-in and account-deletion libraries pass mypy with untyped definitions disallowed." rationale = "docs/engineering/skills/v2-code-organization.md" -scope = ["policyengine_api/fastapi_routes/v2/**/*.py", "policyengine_api/services/v2/**/*.py"] +scope = ["policyengine_api/fastapi_routes/v2/**/*.py", "policyengine_api/services/v2/**/*.py", "policyengine_api/libs/user_auth.py", "policyengine_api/libs/auth0_management.py"] tier = "mechanical" status = "enforced" autofix = false @@ -713,6 +734,8 @@ paths = [ "policyengine_api/fastapi_routes/dependencies.py", "policyengine_api/fastapi_routes/v2", "policyengine_api/services/v2", + "policyengine_api/libs/user_auth.py", + "policyengine_api/libs/auth0_management.py", ] [[rule.ci]] workflow = ".github/workflows/pr.yml" diff --git a/scripts/export_migration_contracts.py b/scripts/export_migration_contracts.py index 218fe3f4c..db306c4dc 100644 --- a/scripts/export_migration_contracts.py +++ b/scripts/export_migration_contracts.py @@ -38,6 +38,7 @@ def build_payload() -> dict[str, Any]: for request in workflow.requests: row = asdict(request) row["stable_response_fields"] = list(row["stable_response_fields"]) + row["headers"] = list(row["headers"]) requests.append(row) request_count += len(requests) workflows.append( @@ -119,18 +120,22 @@ def render_markdown(payload: dict[str, Any]) -> str: f"- Current contract: `{workflow['current_contract']}`", f"- Future owner: {workflow['future_owner_pr']}", "", - "| Method | Path | Status | Route group | Stable response fields |", - "| --- | --- | ---: | --- | --- |", + "| Method | Path | Status | Route group | Required headers | " + "Stable response fields |", + "| --- | --- | ---: | --- | --- | --- |", ] ) for request in workflow["requests"]: fields = ", ".join( f"`{field}`" for field in request["stable_response_fields"] ) + headers = ( + ", ".join(f"`{header}`" for header in request["headers"]) or "none" + ) lines.append( f"| `{request['method']}` | `{request['path']}` | " f"{request['expected_status']} | `{request['route_group']}` | " - f"{fields} |" + f"{headers} | {fields} |" ) lines.append("") diff --git a/scripts/guards/migration_contracts.py b/scripts/guards/migration_contracts.py index 04d3c0918..7663863cf 100644 --- a/scripts/guards/migration_contracts.py +++ b/scripts/guards/migration_contracts.py @@ -10,6 +10,13 @@ REPO_ROOT = Path(__file__).resolve().parents[2] ALLOWED_CURRENT_CONTRACTS = frozenset({"api_v1_compatible", "typed_v2_resources"}) +# User-owned routes must never become less restrictive during a cutover: each +# contract requires a signed-in user's bearer token or a signed-out browser's +# unauthenticated-user UUID. +USER_OWNED_PATH_PREFIXES = ("/v2/me", "/v2/user-") +AUTHORIZATION_HEADER = "Authorization" +UNAUTHENTICATED_USER_HEADER = "PolicyEngine-Unauthenticated-User" +USER_CREDENTIAL_HEADERS = frozenset({AUTHORIZATION_HEADER, UNAUTHENTICATED_USER_HEADER}) def _check_unique_values( @@ -77,8 +84,12 @@ def _check_workflows(payload: dict[str, Any]) -> list[str]: violations.append(f"{workflow['name']}: at least one request is required") for request in workflow["requests"]: + headers = request.get("headers", []) context = f"{workflow['name']} {request['method']} {request['path']}" - request_keys.append(f"{request['method']} {request['path']}") + # One route may be recorded once per credential that can call it. + request_keys.append( + " ".join((request["method"], request["path"], *sorted(headers))) + ) if request["route_group"] not in declared_route_groups: violations.append( f"{context}: unknown route_group {request['route_group']!r}" @@ -90,6 +101,13 @@ def _check_workflows(payload: dict[str, Any]) -> list[str]: violations.append(f"{context}: stable_response_fields is required") if not request["path"].startswith("/"): violations.append(f"{context}: path must start with /") + if request["path"].startswith( + USER_OWNED_PATH_PREFIXES + ) and USER_CREDENTIAL_HEADERS.isdisjoint(headers): + violations.append( + f"{context}: user-owned routes must require the " + f"{AUTHORIZATION_HEADER} or {UNAUTHENTICATED_USER_HEADER} header" + ) if request["expected_status"] not in {200, 201, 202, 204}: violations.append( f"{context}: unexpected status {request['expected_status']}" diff --git a/scripts/qualify_v2_user_association_migration.py b/scripts/qualify_v2_user_association_migration.py new file mode 100644 index 000000000..3caeb1f93 --- /dev/null +++ b/scripts/qualify_v2_user_association_migration.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""Report v2 user-association row counts before the Stage 12A reshape.""" + +from policyengine_api.data.v2.user_association_migration_qualification import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/contract/registry.py b/tests/contract/registry.py index b9766126d..1cd8f1baf 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -3,6 +3,14 @@ from dataclasses import dataclass +AUTHORIZATION_HEADER = "Authorization" +UNAUTHENTICATED_USER_HEADER = "PolicyEngine-Unauthenticated-User" +USER_AUTH_HEADERS = (AUTHORIZATION_HEADER,) +UNAUTHENTICATED_USER_HEADERS = (UNAUTHENTICATED_USER_HEADER,) +# Each user-owned request is recorded once per credential that can make it. +ACCOUNT_CREDENTIAL_HEADERS = (USER_AUTH_HEADERS, UNAUTHENTICATED_USER_HEADERS) + + @dataclass(frozen=True) class ContractRequest: method: str @@ -10,6 +18,113 @@ class ContractRequest: expected_status: int stable_response_fields: tuple[str, ...] route_group: str + # Request headers the route requires, by name. User-owned routes require + # "Authorization: Bearer " from a signed-in user, + # or "PolicyEngine-Unauthenticated-User: " from a + # signed-out browser. + headers: tuple[str, ...] = () + + +def _association_item_fields(*names: str) -> tuple[str, ...]: + return ( + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.user_id", + *(f"result.item.{name}" for name in names), + "result.item.created_at", + "result.item.updated_at", + ) + + +PAGE_FIELDS = ( + "status", + "message", + "result.items", + "result.offset", + "result.limit", + "result.has_more", +) + + +def _association_requests( + resource: str, + *, + route_group: str, + item_fields: tuple[str, ...], + patch_fields: tuple[str, ...], + headers: tuple[str, ...], +) -> tuple[ContractRequest, ...]: + detail_path = f"/v2/{resource}/{{association_id}}?country_id=us" + return ( + ContractRequest( + method="POST", + path=f"/v2/{resource}?country_id=us", + expected_status=201, + stable_response_fields=item_fields, + route_group=route_group, + headers=headers, + ), + ContractRequest( + method="GET", + path=detail_path, + expected_status=200, + stable_response_fields=item_fields, + route_group=route_group, + headers=headers, + ), + ContractRequest( + method="GET", + path=f"/v2/{resource}?country_id=us", + expected_status=200, + stable_response_fields=PAGE_FIELDS, + route_group=route_group, + headers=headers, + ), + ContractRequest( + method="PATCH", + path=detail_path, + expected_status=200, + stable_response_fields=( + "status", + "message", + "result.item.id", + *(f"result.item.{name}" for name in patch_fields), + "result.item.updated_at", + ), + route_group=route_group, + headers=headers, + ), + ContractRequest( + method="DELETE", + path=detail_path, + expected_status=204, + stable_response_fields=(), + route_group=route_group, + headers=headers, + ), + ) + + +def _user_association_requests( + resource: str, + *, + route_group: str, + item_fields: tuple[str, ...], + patch_fields: tuple[str, ...], +) -> tuple[ContractRequest, ...]: + return tuple( + request + for headers in ACCOUNT_CREDENTIAL_HEADERS + for request in _association_requests( + resource, + route_group=route_group, + item_fields=item_fields, + patch_fields=patch_fields, + headers=headers, + ) + ) @dataclass(frozen=True) @@ -145,78 +260,25 @@ class WorkflowContract: WorkflowContract( name="user_policy_associations_v2", current_contract="typed_v2_resources", - future_owner_pr="PR 10: Policy Migration", - requests=( - ContractRequest( - method="POST", - path="/v2/user-policies?country_id=us", - expected_status=201, - stable_response_fields=( - "status", - "message", - "result.item.id", - "result.item.country_id", - "result.item.user_id", - "result.item.policy_id", - "result.item.name", - "result.item.description", - "result.item.created_at", - "result.item.updated_at", - ), - route_group="policy", - ), - ContractRequest( - method="GET", - path=("/v2/user-policies/{association_id}?country_id=us"), - expected_status=200, - stable_response_fields=( - "status", - "message", - "result.item.id", - "result.item.country_id", - "result.item.user_id", - "result.item.policy_id", - "result.item.name", - "result.item.description", - "result.item.created_at", - "result.item.updated_at", - ), - route_group="policy", - ), - ContractRequest( - method="GET", - path="/v2/user-policies?country_id=us&user_id={user_id}", - expected_status=200, - stable_response_fields=( - "status", - "message", - "result.items", - "result.offset", - "result.limit", - "result.has_more", - ), - route_group="policy", - ), - ContractRequest( - method="PATCH", - path=("/v2/user-policies/{association_id}?country_id=us"), - expected_status=200, - stable_response_fields=( - "status", - "message", - "result.item.id", - "result.item.name", - "result.item.description", - "result.item.updated_at", - ), - route_group="policy", - ), - ContractRequest( - method="DELETE", - path=("/v2/user-policies/{association_id}?country_id=us"), - expected_status=204, - stable_response_fields=(), - route_group="policy", + future_owner_pr="PR 12A: User Login and v1-Anchored User Associations", + requests=_user_association_requests( + "user-policies", + route_group="policy", + item_fields=_association_item_fields( + "policy_id", + "legacy_policy_id", + "name", + "description", + "baseline", + "provenance", + ), + patch_fields=( + "legacy_policy_id", + "policy_id", + "name", + "description", + "baseline", + "provenance", ), ), ), @@ -297,79 +359,159 @@ class WorkflowContract: WorkflowContract( name="user_household_associations_v2", current_contract="typed_v2_resources", - future_owner_pr="PR 11: Household Migration", + future_owner_pr="PR 12A: User Login and v1-Anchored User Associations", + requests=_user_association_requests( + "user-households", + route_group="household", + item_fields=_association_item_fields( + "household_id", + "legacy_household_id", + "name", + "description", + ), + patch_fields=( + "legacy_household_id", + "household_id", + "name", + "description", + ), + ), + ), + WorkflowContract( + name="user_simulation_associations_v2", + current_contract="typed_v2_resources", + future_owner_pr="PR 12A: User Login and v1-Anchored User Associations", + requests=_user_association_requests( + "user-simulations", + route_group="simulation", + item_fields=_association_item_fields( + "simulation_id", + "legacy_simulation_id", + "name", + "description", + ), + patch_fields=("legacy_simulation_id", "name", "description"), + ), + ), + WorkflowContract( + name="user_report_associations_v2", + current_contract="typed_v2_resources", + future_owner_pr="PR 12A: User Login and v1-Anchored User Associations", + requests=_user_association_requests( + "user-reports", + route_group="report", + item_fields=_association_item_fields( + "report_id", + "legacy_report_id", + "name", + "description", + "user_policy_id", + "baseline_user_policy_id", + "source_note", + "provisions", + "validation", + "budget_window_years", + "last_run_at", + ), + patch_fields=( + "legacy_report_id", + "name", + "description", + "user_policy_id", + "baseline_user_policy_id", + "source_note", + "provisions", + "validation", + "budget_window_years", + "last_run_at", + ), + ), + ), + WorkflowContract( + name="user_account_v2", + current_contract="typed_v2_resources", + future_owner_pr="PR 12A: User Login and v1-Anchored User Associations", requests=( - ContractRequest( - method="POST", - path="/v2/user-households?country_id=us", - expected_status=201, - stable_response_fields=( - "status", - "message", - "result.item.id", - "result.item.country_id", - "result.item.user_id", - "result.item.household_id", - "result.item.name", - "result.item.description", - "result.item.created_at", - "result.item.updated_at", - ), - route_group="household", + *( + ContractRequest( + method="GET", + path="/v2/me?country_id=us", + expected_status=200, + stable_response_fields=( + "status", + "message", + "result.item.id", + "result.item.user_type", + "result.item.primary_country", + "result.item.first_name", + "result.item.last_name", + "result.item.email", + "result.item.created_at", + ), + route_group="user_profile", + headers=headers, + ) + for headers in ACCOUNT_CREDENTIAL_HEADERS ), ContractRequest( - method="GET", - path="/v2/user-households/{association_id}?country_id=us", + method="PATCH", + path="/v2/me?country_id=us", expected_status=200, stable_response_fields=( "status", "message", "result.item.id", - "result.item.country_id", - "result.item.user_id", - "result.item.household_id", - "result.item.name", - "result.item.description", - "result.item.created_at", - "result.item.updated_at", + "result.item.primary_country", + "result.item.first_name", + "result.item.last_name", ), - route_group="household", + route_group="user_profile", + headers=USER_AUTH_HEADERS, ), - ContractRequest( - method="GET", - path="/v2/user-households?country_id=us&user_id={user_id}", - expected_status=200, - stable_response_fields=( - "status", - "message", - "result.items", - "result.offset", - "result.limit", - "result.has_more", - ), - route_group="household", + *( + ContractRequest( + method="GET", + path="/v2/me/export?country_id=us", + expected_status=200, + stable_response_fields=( + "status", + "message", + "result.user", + "result.policies", + "result.households", + "result.simulations", + "result.reports", + ), + route_group="user_profile", + headers=headers, + ) + for headers in ACCOUNT_CREDENTIAL_HEADERS ), ContractRequest( - method="PATCH", - path="/v2/user-households/{association_id}?country_id=us", + method="POST", + path="/v2/me/upgrade?country_id=us", expected_status=200, stable_response_fields=( "status", "message", "result.item.id", - "result.item.household_id", - "result.item.name", - "result.item.description", - "result.item.updated_at", + "result.item.user_type", + "result.item.primary_country", + "result.outcome", ), - route_group="household", + route_group="user_profile", + headers=(AUTHORIZATION_HEADER, UNAUTHENTICATED_USER_HEADER), ), - ContractRequest( - method="DELETE", - path="/v2/user-households/{association_id}?country_id=us", - expected_status=204, - stable_response_fields=(), - route_group="household", + *( + ContractRequest( + method="DELETE", + path="/v2/me?country_id=us", + expected_status=204, + stable_response_fields=(), + route_group="user_profile", + headers=headers, + ) + for headers in ACCOUNT_CREDENTIAL_HEADERS ), ), ), diff --git a/tests/contract/test_app_v2_workflow_contracts.py b/tests/contract/test_app_v2_workflow_contracts.py index 9eba4dbfe..5978f0ac6 100644 --- a/tests/contract/test_app_v2_workflow_contracts.py +++ b/tests/contract/test_app_v2_workflow_contracts.py @@ -15,6 +15,9 @@ def test_app_v2_workflow_contract_registry_is_complete(): "household_save_read", "household_resources_v2", "user_household_associations_v2", + "user_simulation_associations_v2", + "user_report_associations_v2", + "user_account_v2", "household_calculate", "region_selection", "metadata_resources_v2_preview", @@ -33,6 +36,9 @@ def test_app_v2_workflow_contract_registry_is_complete(): "user_policy_associations_v2", "household_resources_v2", "user_household_associations_v2", + "user_simulation_associations_v2", + "user_report_associations_v2", + "user_account_v2", } else "api_v1_compatible" ) @@ -46,6 +52,15 @@ def test_app_v2_workflow_contract_registry_is_complete(): assert request.expected_status in {200, 201, 202, 204} assert request.stable_response_fields or request.expected_status == 204 assert request.route_group in ROUTE_GROUP_CONFIG_BY_NAME + if request.path.startswith(("/v2/me", "/v2/user-")): + assert request.headers in { + ("Authorization",), + ("PolicyEngine-Unauthenticated-User",), + ("Authorization", "PolicyEngine-Unauthenticated-User"), + } + assert "user_id" not in request.path + else: + assert request.headers == () assert all( not request.path.startswith("/v2/") @@ -59,3 +74,40 @@ def test_app_v2_workflow_contract_registry_is_complete(): if workflow.current_contract == "typed_v2_resources" for request in workflow.requests } + + +def test_user_routes_record_signed_in_and_signed_out_contracts() -> None: + user_requests = { + (request.method, request.path.split("?", 1)[0], request.headers) + for request in APP_V2_ROUTE_CONTRACTS + if request.path.startswith(("/v2/me", "/v2/user-")) + } + signed_out = { + (method, path) + for method, path, headers in user_requests + if headers == ("PolicyEngine-Unauthenticated-User",) + } + + for resource in ("policies", "households", "simulations", "reports"): + collection = f"/v2/user-{resource}" + detail = f"{collection}/{{association_id}}" + assert { + ("POST", collection), + ("GET", collection), + ("GET", detail), + ("PATCH", detail), + ("DELETE", detail), + } <= signed_out + assert {("GET", "/v2/me"), ("GET", "/v2/me/export"), ("DELETE", "/v2/me")} <= ( + signed_out + ) + # Signed-out browsers cannot edit account details. + assert ("PATCH", "/v2/me") not in signed_out + assert ( + "POST", + "/v2/me/upgrade", + ("Authorization", "PolicyEngine-Unauthenticated-User"), + ) in user_requests + assert not any( + "/v2/me/imports" in path for _method, path, _headers in user_requests + ) diff --git a/tests/integration/test_alembic_v2_lifecycle.py b/tests/integration/test_alembic_v2_lifecycle.py index 7c6628a48..987adddf0 100644 --- a/tests/integration/test_alembic_v2_lifecycle.py +++ b/tests/integration/test_alembic_v2_lifecycle.py @@ -1,5 +1,8 @@ """Exercise the isolated v2 Alembic lifecycle against disposable Postgres.""" +from collections.abc import Iterator +from datetime import datetime, timedelta, timezone +import itertools import os from uuid import UUID, uuid4 @@ -27,6 +30,7 @@ STAGE_11_PREVIOUS_REVISION = "af34023a728f" STAGE_12_PREVIOUS_REVISION = "724b1b11a33e" STAGE_12_COMPARISON_PREVIOUS_REVISION = "439303be14fe" +STAGE_12A_PREVIOUS_REVISION = "91af4fc8d8c1" V2_TABLE_NAMES = frozenset(table.name for table in V2_METADATA.tables.values()) @@ -97,6 +101,99 @@ def _assert_head(engine) -> None: } <= set(inspect(engine).get_table_names(schema="public")) +def _columns(engine, table_name: str) -> dict[str, dict]: + return { + column["name"]: column + for column in inspect(engine).get_columns(table_name, schema="public") + } + + +def _assert_pre_stage_12a_association_shape(engine) -> None: + assert "user_identities" not in inspect(engine).get_table_names(schema="public") + for table_name, reference in ( + ("user_simulation_associations", "simulation"), + ("user_report_associations", "report"), + ): + columns = _columns(engine, table_name) + assert {"country", "label"} <= columns.keys() + assert { + "country_id", + "name", + f"legacy_{reference}_id", + }.isdisjoint(columns) + assert not columns[f"{reference}_id"]["nullable"] + assert { + foreign_key["referred_table"] + for foreign_key in inspect(engine).get_foreign_keys(table_name) + } == {"users", f"{reference}s"} + for table_name, reference in ( + ("user_policies", "policy"), + ("user_household_associations", "household"), + ): + columns = _columns(engine, table_name) + assert f"legacy_{reference}_id" not in columns + assert not columns[f"{reference}_id"]["nullable"] + user_columns = _columns(engine, "users") + assert "user_type" not in user_columns + assert not user_columns["primary_country"]["nullable"] + + +def _assert_stage_12a_association_shape(engine) -> None: + identity_constraints = { + constraint["name"]: constraint["column_names"] + for constraint in inspect(engine).get_unique_constraints("user_identities") + } + assert identity_constraints["uq_user_identities_issuer_subject"] == [ + "issuer", + "subject", + ] + for table_name, reference in ( + ("user_policies", "policy"), + ("user_household_associations", "household"), + ("user_simulation_associations", "simulation"), + ("user_report_associations", "report"), + ): + columns = _columns(engine, table_name) + assert columns[f"{reference}_id"]["nullable"] + assert columns[f"legacy_{reference}_id"]["nullable"] + assert not columns["country_id"]["nullable"] + assert "client_ref" not in columns + # No uniqueness involves the owner, so a merge can move any row. + assert [ + constraint["column_names"] + for constraint in inspect(engine).get_unique_constraints(table_name) + ] == [["id", "country_id"]] + for table_name in ("user_simulation_associations", "user_report_associations"): + referred_tables = { + foreign_key["referred_table"] + for foreign_key in inspect(engine).get_foreign_keys(table_name) + } + assert {"simulations", "reports"}.isdisjoint(referred_tables) + report_policy_key = next( + foreign_key + for foreign_key in inspect(engine).get_foreign_keys("user_report_associations") + if foreign_key["referred_table"] == "user_policies" + ) + assert report_policy_key["options"]["ondelete"] == "SET NULL" + for table_name in ("user_simulation_associations", "user_report_associations"): + columns = _columns(engine, table_name) + assert columns["country"]["nullable"] + assert columns["label"]["nullable"] + user_columns = _columns(engine, "users") + assert not user_columns["user_type"]["nullable"] + assert "standard" in user_columns["user_type"]["default"] + assert user_columns["primary_country"]["nullable"] + user_checks = { + constraint["name"] + for constraint in inspect(engine).get_check_constraints("users") + } + assert { + "ck_users_primary_country", + "ck_users_user_type", + "ck_users_standard_primary_country", + } <= user_checks + + def test_empty_upgrade_check_base_downgrade_and_reupgrade() -> None: database_url = _disposable_url() config = _config() @@ -112,6 +209,17 @@ def test_empty_upgrade_check_base_downgrade_and_reupgrade() -> None: command.check(config) _assert_head(engine) + command.downgrade(config, STAGE_12A_PREVIOUS_REVISION) + with engine.connect() as connection: + context = MigrationContext.configure(connection) + assert context.get_current_revision() == STAGE_12A_PREVIOUS_REVISION + _assert_pre_stage_12a_association_shape(engine) + + command.upgrade(config, "head") + command.check(config) + _assert_head(engine) + _assert_stage_12a_association_shape(engine) + command.downgrade(config, STAGE_12_COMPARISON_PREVIOUS_REVISION) with engine.connect() as connection: context = MigrationContext.configure(connection) @@ -276,6 +384,284 @@ def test_empty_upgrade_check_base_downgrade_and_reupgrade() -> None: engine.dispose() +# Dormant at the parent revision: its models map these tables, and deleting a +# user cascades through them, but no parent-revision code inserts into them. +# They are the only tables Stage 12A may give a new required column. +PARENT_REVISION_READ_ONLY_TABLES = frozenset( + {"user_simulation_associations", "user_report_associations"} +) +USER_ASSOCIATION_TABLES = ( + "user_policies", + "user_household_associations", + "user_simulation_associations", + "user_report_associations", +) +COUNTRY_COLUMN_NAMES = frozenset({"country", "country_id", "primary_country"}) +# Nullable references that a parent-revision check constraint requires. +REFERENCES_REQUIRED_BY_CHECKS = frozenset({("simulations", "household_id")}) +# Required values that a parent-revision check constraint restricts. +CHECKED_VALUES = {("datasets", "year"): 2026} + + +def _is_required_without_default(column: sa.Column) -> bool: + return not column.nullable and column.server_default is None + + +def _new_required_columns(parent_table: sa.Table, head_table: sa.Table) -> set[str]: + return { + column.name + for column in head_table.columns + if column.name not in parent_table.columns + and _is_required_without_default(column) + } + + +def _test_value(table: sa.Table, column: sa.Column, sequence: Iterator[int]): + if (table.name, column.name) in CHECKED_VALUES: + return CHECKED_VALUES[(table.name, column.name)] + if column.name in COUNTRY_COLUMN_NAMES: + return "us" + column_type = column.type + if isinstance(column_type, sa.Enum): + return column_type.enums[0] + if isinstance(column_type, sa.Uuid): + return uuid4() + if isinstance(column_type, sa.Boolean): + return False + if isinstance(column_type, sa.Integer): + return 1 + if isinstance(column_type, sa.Float): + return 0.0 + if isinstance(column_type, sa.DateTime): + # Later than every server-default created_at, as retention checks need. + return datetime.now(timezone.utc) + timedelta(days=1) + if isinstance(column_type, sa.JSON): + return {} + if isinstance(column_type, sa.String): + length = column_type.length or 255 + if length == 64: + return uuid4().hex + uuid4().hex + return f"value-{next(sequence)}"[:length] + raise AssertionError(f"no test value for {table.name}.{column.name}") + + +def _row_values( + table: sa.Table, + stored_rows: dict[str, dict], + sequence: Iterator[int], + *, + head_table: sa.Table | None = None, +) -> dict: + """Supply only what the parent revision requires, plus check-bound values. + + ``head_table`` adds the columns head newly requires; only rows of the + parent's read-only tables, which new code writes, may need them. + """ + + values = { + column.name: _test_value(table, column, sequence) + for column in table.columns + if _is_required_without_default(column) + or (table.name, column.name) in REFERENCES_REQUIRED_BY_CHECKS + } + if head_table is not None: + for column_name in _new_required_columns(table, head_table): + values[column_name] = _test_value( + head_table, head_table.columns[column_name], sequence + ) + for foreign_key in table.foreign_key_constraints: + if not any(element.parent.name in values for element in foreign_key.elements): + continue + referenced_row = stored_rows[foreign_key.referred_table.name] + for element in foreign_key.elements: + values[element.parent.name] = referenced_row[element.column.name] + return values + + +def _primary_key_clause(table: sa.Table, row: dict): + return sa.and_( + *(column == row[column.name] for column in table.primary_key.columns) + ) + + +def _insert_and_read_back( + connection, + table: sa.Table, + values: dict, + *, + insert_table: sa.Table | None = None, +) -> dict: + """Insert a row, then read it back through the parent's reflected table. + + Rows of the parent's read-only tables are inserted through head's table, + as only new code writes them. + """ + + target = insert_table if insert_table is not None else table + connection.execute(target.insert().values(values)) + # Selecting the reflected table reads every column the parent maps. + return dict( + connection.execute(sa.select(table).where(_primary_key_clause(table, values))) + .mappings() + .one() + ) + + +def _rewrite_mapped_columns(connection, table: sa.Table, row: dict) -> None: + """Update every non-key column the parent maps, as an ORM flush can.""" + + key_columns = {column.name for column in table.primary_key.columns} | { + element.parent.name + for foreign_key in table.foreign_key_constraints + for element in foreign_key.elements + } + result = connection.execute( + table.update() + .where(_primary_key_clause(table, row)) + .values( + { + # sa.null() keeps SQL NULL; a bare None becomes JSON null. + name: sa.null() if value is None else value + for name, value in row.items() + if name not in key_columns + } + ) + ) + assert result.rowcount == 1, table.name + + +def _assert_user_deletion_cascades( + connection, + parent: sa.MetaData, + head: sa.MetaData, + stored_rows: dict[str, dict], + sequence: Iterator[int], +) -> None: + """Delete, through the parent's users table, a user who owns Stage 12A rows.""" + + users = parent.tables["users"] + owner = _insert_and_read_back( + connection, users, _row_values(users, stored_rows, sequence) + ) + owner_rows = {**stored_rows, "users": owner} + for table_name in USER_ASSOCIATION_TABLES: + table = parent.tables[table_name] + head_table = ( + head.tables[table_name] + if table_name in PARENT_REVISION_READ_ONLY_TABLES + else None + ) + _insert_and_read_back( + connection, + table, + _row_values(table, owner_rows, sequence, head_table=head_table), + insert_table=head_table, + ) + identities = head.tables["user_identities"] + connection.execute( + identities.insert().values( + id=uuid4(), + user_id=owner["id"], + issuer="https://auth.example.test/", + subject="google-oauth2|previous-revision", + ) + ) + + result = connection.execute(users.delete().where(users.c.id == owner["id"])) + + assert result.rowcount == 1 + for table_name in (*USER_ASSOCIATION_TABLES, "user_identities"): + table = head.tables[table_name] + remaining = connection.execute( + sa.select(sa.func.count()) + .select_from(table) + .where(table.c.user_id == owner["id"]) + ).scalar_one() + assert remaining == 0, table_name + + +def test_stage_12a_schema_serves_the_previous_revision() -> None: + """Run the previous Public API revision's table usage against head. + + The parent revision's schema is reflected and then used, after upgrading, + to insert a row into every table it maps (supplying only what it + requires), read the row back with every mapped column, rewrite those + columns, delete it, and delete a user who owns Stage 12A rows so the + cascades run. Head may not give any table the parent revision writes a + new required column without a server default. + """ + + database_url = _disposable_url() + config = _config() + engine = create_engine(database_url) + + try: + command.downgrade(config, STAGE_12A_PREVIOUS_REVISION) + parent = sa.MetaData() + parent.reflect(engine) + command.upgrade(config, "head") + head = sa.MetaData() + head.reflect(engine) + parent_tables = [ + table for table in parent.sorted_tables if table.name != "alembic_version" + ] + + dropped_columns = sorted( + f"{table.name}.{column.name}" + for table in parent_tables + for column in table.columns + if table.name not in head.tables + or column.name not in head.tables[table.name].columns + ) + assert dropped_columns == [] + tables_with_new_required_columns = { + table.name: sorted(_new_required_columns(table, head.tables[table.name])) + for table in parent_tables + if _new_required_columns(table, head.tables[table.name]) + } + assert set(tables_with_new_required_columns) <= ( + PARENT_REVISION_READ_ONLY_TABLES + ), tables_with_new_required_columns + + sequence = itertools.count() + with engine.connect() as connection: + transaction = connection.begin() + try: + stored_rows: dict[str, dict] = {} + for table in parent_tables: + head_table = ( + head.tables[table.name] + if table.name in PARENT_REVISION_READ_ONLY_TABLES + else None + ) + stored_rows[table.name] = _insert_and_read_back( + connection, + table, + _row_values( + table, stored_rows, sequence, head_table=head_table + ), + insert_table=head_table, + ) + _rewrite_mapped_columns(connection, table, stored_rows[table.name]) + + _assert_user_deletion_cascades( + connection, parent, head, stored_rows, sequence + ) + + for table in reversed(parent_tables): + result = connection.execute( + table.delete().where( + _primary_key_clause(table, stored_rows[table.name]) + ) + ) + assert result.rowcount == 1, table.name + finally: + transaction.rollback() + finally: + command.upgrade(config, "head") + engine.dispose() + + def test_upgrade_to_head_validates_the_resulting_schema_against_metadata() -> None: database_url = _disposable_url() config = _config() @@ -500,3 +886,62 @@ def test_baseline_region_default_enforces_same_model_dataset() -> None: ) command.upgrade(config, "head") engine.dispose() + + +def test_stage_12a_reshape_refuses_retained_dormant_association_rows() -> None: + """A retained pre-12A report association stops the reshape unchanged.""" + + database_url = _disposable_url() + config = _config() + engine = create_engine(database_url) + user_id = uuid4() + model_id = uuid4() + report_id = uuid4() + + def reflected(name: str) -> sa.Table: + return sa.Table(name, sa.MetaData(), autoload_with=engine) + + try: + command.downgrade(config, STAGE_12A_PREVIOUS_REVISION) + with engine.begin() as connection: + connection.execute( + sa.insert(reflected("users")).values(id=user_id, primary_country="us") + ) + connection.execute( + sa.insert(reflected("tax_benefit_models")).values( + id=model_id, name=f"stage12a-{model_id.hex[:8]}" + ) + ) + connection.execute( + sa.insert(reflected("reports")).values( + id=report_id, + label="Stage 12A retained report", + country="us", + tax_benefit_model_id=model_id, + inputs={}, + ) + ) + connection.execute( + sa.insert(reflected("user_report_associations")).values( + id=uuid4(), user_id=user_id, report_id=report_id, country="us" + ) + ) + + with pytest.raises(sa.exc.IntegrityError): + command.upgrade(config, "head") + + with engine.connect() as connection: + context = MigrationContext.configure(connection) + assert context.get_current_revision() == STAGE_12A_PREVIOUS_REVISION + _assert_pre_stage_12a_association_shape(engine) + finally: + with engine.begin() as connection: + for table_name, row_id in ( + ("users", user_id), + ("reports", report_id), + ("tax_benefit_models", model_id), + ): + table = reflected(table_name) + connection.execute(sa.delete(table).where(table.c.id == row_id)) + command.upgrade(config, "head") + engine.dispose() diff --git a/tests/integration/test_cloud_run_candidate.py b/tests/integration/test_cloud_run_candidate.py index 81fb8df8f..beb497efc 100644 --- a/tests/integration/test_cloud_run_candidate.py +++ b/tests/integration/test_cloud_run_candidate.py @@ -66,3 +66,21 @@ def test_cloud_run_candidate_stage6_read_route_contracts(api_client): "Country zz not found. Available countries are: uk, us, ca, ng, il" ), } + + +def test_cloud_run_candidate_user_routes_fail_closed_without_a_token(api_client): + """User-owned routes must never become readable without a credential.""" + + for path in ("/v2/me", "/v2/user-policies", "/v2/user-reports"): + for headers in ({}, {"PolicyEngine-Unauthenticated-User": "not-a-uuid"}): + response = api_client.get( + path, + params={ + "country_id": "us", + "user_id": "00000000-0000-0000-0000-000000000000", + }, + headers=headers, + ) + assert response.status_code == 401, (path, response.text) + assert response.headers["www-authenticate"].startswith("Bearer") + assert response.json()["status"] == "error" diff --git a/tests/integration/test_live_v2_policies.py b/tests/integration/test_live_v2_policies.py index e1584c17a..ea9370777 100644 --- a/tests/integration/test_live_v2_policies.py +++ b/tests/integration/test_live_v2_policies.py @@ -1,27 +1,96 @@ -"""Write/read lifecycle checks for deployed native v2 policy resources.""" +"""Live staging checks for native v2 policies and user-owned resources. + +The staging deployment runs this module against the tagged Cloud Run +candidate. Signed-in routes use a dedicated staging Auth0 test user whose +access token is obtained through the staging-only CI application's password +grant. Signed-out checks send a fresh random UUID per run in the +``PolicyEngine-Unauthenticated-User`` header and need no credential. Every +credential comes from the GitHub ``staging`` environment: + +- ``STAGING_AUTH0_TEST_DOMAIN``: Auth0 domain issuing tokens whose ``iss`` + matches the staging ``PUBLIC_API_AUTH_ISSUER`` (variable); +- ``STAGING_AUTH0_TEST_AUDIENCE``: the Public API resource server identifier, + equal to ``PUBLIC_API_AUTH_AUDIENCE`` (variable); +- ``STAGING_AUTH0_TEST_CLIENT_ID`` / ``STAGING_AUTH0_TEST_CLIENT_SECRET``: the + CI application with the password grant enabled (secrets); +- ``STAGING_AUTH0_TEST_USERNAME`` / ``STAGING_AUTH0_TEST_PASSWORD``: the test + user (secrets); +- ``STAGING_AUTH0_TEST_REALM`` (optional variable): a database connection name + for the password-realm grant when the tenant has no default directory. + +Missing settings fail the run instead of skipping it: the signed-in routes are +a staging deployment gate. The module never prints a token or credential. It +starts and ends by deleting the test user's PolicyEngine account, so staging +must not configure the Auth0 Management API deletion for this user's tenant +(``AUTH0_MANAGEMENT_*``), or the CI user would be removed from Auth0. +""" from __future__ import annotations import os from uuid import uuid4 -from sqlalchemy import create_engine, text -from sqlalchemy.pool import NullPool +import httpx +import pytest + +AUTH0_ENVIRONMENT = ( + "STAGING_AUTH0_TEST_DOMAIN", + "STAGING_AUTH0_TEST_AUDIENCE", + "STAGING_AUTH0_TEST_CLIENT_ID", + "STAGING_AUTH0_TEST_CLIENT_SECRET", + "STAGING_AUTH0_TEST_USERNAME", + "STAGING_AUTH0_TEST_PASSWORD", +) +UNKNOWN_LEGACY_ID = 9_007_199_254_740_000 +COUNTRY_ID = "us" +UNAUTHENTICATED_USER_HEADER = "PolicyEngine-Unauthenticated-User" -def _v2_database_url() -> str: - value = os.environ.get("V2_MIGRATION_DATABASE_URL") - if not value: + +def _staging_user_token() -> str: + values = {name: os.environ.get(name, "").strip() for name in AUTH0_ENVIRONMENT} + missing = [name for name, value in values.items() if not value] + if missing: raise RuntimeError( - "V2_MIGRATION_DATABASE_URL is required for the live v2 probe" + "The live signed-in route checks require staging Auth0 test " + f"settings: {', '.join(missing)}" ) - return value + domain = values["STAGING_AUTH0_TEST_DOMAIN"].removeprefix("https://").rstrip("/") + form = { + "grant_type": "password", + "username": values["STAGING_AUTH0_TEST_USERNAME"], + "password": values["STAGING_AUTH0_TEST_PASSWORD"], + "audience": values["STAGING_AUTH0_TEST_AUDIENCE"], + "client_id": values["STAGING_AUTH0_TEST_CLIENT_ID"], + "client_secret": values["STAGING_AUTH0_TEST_CLIENT_SECRET"], + "scope": "openid", + } + realm = os.environ.get("STAGING_AUTH0_TEST_REALM", "").strip() + if realm: + form["grant_type"] = "http://auth0.com/oauth/grant-type/password-realm" + form["realm"] = realm + response = httpx.post(f"https://{domain}/oauth/token", data=form, timeout=30.0) + if response.status_code != 200: + raise RuntimeError( + f"Staging Auth0 password grant failed: HTTP {response.status_code}" + ) + token = response.json().get("access_token") + if not isinstance(token, str) or not token: + raise RuntimeError("Staging Auth0 password grant returned no access token") + return token + + +@pytest.fixture +def user_headers(api_base_url: str) -> dict[str, str]: + # Depending on api_base_url skips local runs before any token request. + del api_base_url + return {"Authorization": f"Bearer {_staging_user_token()}"} -def _one_catalog_item(api_client, resource: str, *, country_id: str) -> dict: +def _one_catalog_item(api_client, resource: str) -> dict: response = api_client.get( f"/v2/{resource}", - params={"country_id": country_id, "limit": 1}, + params={"country_id": COUNTRY_ID, "limit": 1}, ) assert response.status_code == 200, response.text[:500] items = response.json()["result"]["items"] @@ -29,160 +98,400 @@ def _one_catalog_item(api_client, resource: str, *, country_id: str) -> dict: return items[0] -def test_live_native_policy_and_user_policy_lifecycle( +def _v1_id(api_client, path: str, body: dict, field: str) -> int: + response = api_client.post(f"/{COUNTRY_ID}/{path}", json=body) + assert response.status_code in {200, 201}, response.text[:500] + value = response.json()["result"][field] + assert isinstance(value, int) + return value + + +def _create_v1_artifacts(api_client) -> dict[str, int]: + """Create (or reuse deduplicated) v1 artifacts without running compute.""" + + policy_id = _v1_id(api_client, "policy", {"data": {}}, "policy_id") + household_id = _v1_id( + api_client, + "household", + {"label": "Stage 12A live probe", "data": {}}, + "household_id", + ) + simulation_id = _v1_id( + api_client, + "simulation", + { + "population_id": COUNTRY_ID, + "population_type": "geography", + "policy_id": policy_id, + }, + "id", + ) + report_id = _v1_id( + api_client, + "report", + {"simulation_1_id": simulation_id, "simulation_2_id": None, "year": "2026"}, + "id", + ) + return { + "policy": policy_id, + "household": household_id, + "simulation": simulation_id, + "report": report_id, + } + + +def _delete_account(api_client, headers: dict[str, str]) -> None: + response = api_client.delete( + "/v2/me", params={"country_id": COUNTRY_ID}, headers=headers + ) + assert response.status_code == 204, response.text[:500] + + +def _browser_headers() -> dict[str, str]: + """A fresh signed-out browser; its UUID never leaves the request headers.""" + + return {UNAUTHENTICATED_USER_HEADER: str(uuid4())} + + +def _save_policy(api_client, headers: dict[str, str], legacy_policy_id: int) -> dict: + response = api_client.post( + "/v2/user-policies", + params={"country_id": COUNTRY_ID}, + headers=headers, + json={ + "country_id": COUNTRY_ID, + "legacy_policy_id": legacy_policy_id, + "name": "Stage 12A signed-out reform", + }, + ) + assert response.status_code == 201, response.text[:500] + return response.json()["result"]["item"] + + +def _policy_ids(api_client, headers: dict[str, str]) -> set[str]: + response = api_client.get( + "/v2/user-policies", + params={"country_id": COUNTRY_ID, "limit": 1000}, + headers=headers, + ) + assert response.status_code == 200, response.text[:500] + return {item["id"] for item in response.json()["result"]["items"]} + + +def test_live_user_routes_reject_unauthenticated_requests(api_client) -> None: + for method, path in ( + ("GET", "/v2/me"), + ("GET", "/v2/me/export"), + ("POST", "/v2/me/upgrade"), + ("GET", "/v2/user-policies"), + ("GET", "/v2/user-households"), + ("GET", "/v2/user-simulations"), + ("GET", "/v2/user-reports"), + ): + for headers in ({}, {UNAUTHENTICATED_USER_HEADER: "not-a-uuid"}): + response = api_client.request( + method, + path, + params={"country_id": COUNTRY_ID, "user_id": str(uuid4())}, + headers=headers, + json={} if method == "POST" else None, + ) + assert response.status_code == 401, (path, response.text[:500]) + assert response.headers["www-authenticate"].startswith("Bearer") + assert response.json()["status"] == "error" + + +def test_live_native_policy_resources_and_v2_policy_save( api_client, integration_probe_id: str, + user_headers: dict[str, str], ) -> None: - """Exercise every Phase 10 native route against the staging database.""" + model = _one_catalog_item(api_client, "tax-benefit-models") + parameter = _one_catalog_item(api_client, "parameters") + policy_body = { + "country_id": COUNTRY_ID, + "tax_benefit_model_id": model["id"], + "parameter_values": [ + { + "parameter_id": parameter["id"], + "value": f"stage12a-native-{integration_probe_id}", + "start_date": "2026-01-01T00:00:00Z", + } + ], + } + created = api_client.post( + "/v2/policies", params={"country_id": COUNTRY_ID}, json=policy_body + ) + assert created.status_code == 201, created.text[:500] + created_item = created.json()["result"]["item"] + policy_id = created_item["id"] + assert created_item["parameter_values"][0]["parameter_name"] == parameter["name"] - country_id = "us" - model = _one_catalog_item( - api_client, - "tax-benefit-models", - country_id=country_id, + repeated = api_client.post( + "/v2/policies", params={"country_id": COUNTRY_ID}, json=policy_body ) - parameter = _one_catalog_item( - api_client, - "parameters", - country_id=country_id, + assert repeated.status_code == 200, repeated.text[:500] + assert repeated.json()["result"]["item"]["id"] == policy_id + detail = api_client.get( + f"/v2/policies/{policy_id}", params={"country_id": COUNTRY_ID} ) - user_id = uuid4() - engine = create_engine(_v2_database_url(), poolclass=NullPool) + assert detail.status_code == 200, detail.text[:500] + assert detail.json()["result"]["item"] == created_item + collection = api_client.get( + "/v2/policies", + params={ + "country_id": COUNTRY_ID, + "tax_benefit_model_id": model["id"], + "limit": 1, + }, + ) + assert collection.status_code == 200, collection.text[:500] + assert set(collection.json()["result"]) == {"items", "offset", "limit", "has_more"} + + saved = api_client.post( + "/v2/user-policies", + params={"country_id": COUNTRY_ID}, + headers=user_headers, + json={"country_id": COUNTRY_ID, "policy_id": policy_id, "name": "v2 save"}, + ) + assert saved.status_code == 201, saved.text[:500] + saved_item = saved.json()["result"]["item"] + assert saved_item["policy_id"] == policy_id + assert saved_item["legacy_policy_id"] is None + deleted = api_client.delete( + f"/v2/user-policies/{saved_item['id']}", + params={"country_id": COUNTRY_ID}, + headers=user_headers, + ) + assert deleted.status_code == 204, deleted.text[:500] + + +def test_live_signed_in_user_lifecycle( + api_client, + integration_probe_id: str, + user_headers: dict[str, str], +) -> None: + params = {"country_id": COUNTRY_ID} + # Start from a clean account in case an earlier run stopped midway. + _delete_account(api_client, user_headers) try: - with engine.begin() as connection: - connection.execute( - text( - "INSERT INTO users (id, primary_country) VALUES (:id, :country_id)" - ), - {"id": user_id, "country_id": country_id}, + me = api_client.get("/v2/me", params=params, headers=user_headers) + assert me.status_code == 200, me.text[:500] + me_item = me.json()["result"]["item"] + user_id = me_item["id"] + assert me_item["primary_country"] == COUNTRY_ID + + patched_me = api_client.patch( + "/v2/me", + params=params, + headers=user_headers, + json={"first_name": "Stage 12A", "last_name": None}, + ) + assert patched_me.status_code == 200, patched_me.text[:500] + assert patched_me.json()["result"]["item"]["first_name"] == "Stage 12A" + + legacy = _create_v1_artifacts(api_client) + resources = { + "user-policies": ("legacy_policy_id", legacy["policy"]), + "user-households": ("legacy_household_id", legacy["household"]), + "user-simulations": ("legacy_simulation_id", legacy["simulation"]), + "user-reports": ("legacy_report_id", legacy["report"]), + } + for resource, (legacy_field, legacy_id) in resources.items(): + created = api_client.post( + f"/v2/{resource}", + params=params, + headers=user_headers, + json={ + "country_id": COUNTRY_ID, + legacy_field: legacy_id, + "name": f"Stage 12A {resource} {integration_probe_id}", + }, ) - existing_policy_count = connection.scalar( - text( - "SELECT COUNT(*) FROM policies " - "WHERE country_id = :country_id " - "AND tax_benefit_model_id = :model_id" - ), - {"country_id": country_id, "model_id": model["id"]}, + assert created.status_code == 201, (resource, created.text[:500]) + item = created.json()["result"]["item"] + assert item["user_id"] == user_id + assert item[legacy_field] == legacy_id + + missing = api_client.post( + f"/v2/{resource}", + params=params, + headers=user_headers, + json={"country_id": COUNTRY_ID, legacy_field: UNKNOWN_LEGACY_ID}, ) + assert missing.status_code == 422, (resource, missing.text[:500]) - policy_body = { - "country_id": country_id, - "tax_benefit_model_id": model["id"], - "parameter_values": [ - { - "parameter_id": parameter["id"], - "value": f"phase10-native-{integration_probe_id}", - "start_date": "2026-01-01T00:00:00Z", - } - ], - } - created = api_client.post( - "/v2/policies", - params={"country_id": country_id}, - json=policy_body, + detail_path = f"/v2/{resource}/{item['id']}" + detail = api_client.get(detail_path, params=params, headers=user_headers) + assert detail.status_code == 200, (resource, detail.text[:500]) + assert detail.json()["result"]["item"] == item + listed = api_client.get( + f"/v2/{resource}", + params={**params, legacy_field: legacy_id}, + headers=user_headers, + ) + assert listed.status_code == 200, (resource, listed.text[:500]) + assert item["id"] in {row["id"] for row in listed.json()["result"]["items"]} + patched = api_client.patch( + detail_path, + params=params, + headers=user_headers, + json={"description": "Renamed by the live probe"}, + ) + assert patched.status_code == 200, (resource, patched.text[:500]) + assert patched.json()["result"]["item"]["description"] == ( + "Renamed by the live probe" + ) + deleted = api_client.delete( + detail_path, params=params, headers=user_headers + ) + assert deleted.status_code == 204, (resource, deleted.text[:500]) + absent = api_client.get(detail_path, params=params, headers=user_headers) + assert absent.status_code == 404, (resource, absent.text[:500]) + + policy = api_client.post( + "/v2/user-policies", + params=params, + headers=user_headers, + json={ + "country_id": COUNTRY_ID, + "legacy_policy_id": legacy["policy"], + "baseline": "current-law", + "provenance": {"source": "manual"}, + }, ) - assert created.status_code == 201, created.text[:500] - created_item = created.json()["result"]["item"] - policy_id = created_item["id"] - assert created_item["country_id"] == country_id - assert created_item["tax_benefit_model_id"] == model["id"] - assert created_item["parameter_values"][0]["parameter_id"] == parameter["id"] - assert ( - created_item["parameter_values"][0]["parameter_name"] == parameter["name"] + assert policy.status_code == 201, policy.text[:500] + policy_id = policy.json()["result"]["item"]["id"] + report = api_client.post( + "/v2/user-reports", + params=params, + headers=user_headers, + json={ + "country_id": COUNTRY_ID, + "legacy_report_id": legacy["report"], + "user_policy_id": policy_id, + }, ) - assert created_item["created_at"] - assert created_item["updated_at"] + assert report.status_code == 201, report.text[:500] + report_id = report.json()["result"]["item"]["id"] - repeated = api_client.post( - "/v2/policies", - params={"country_id": country_id}, - json=policy_body, + exported = api_client.get("/v2/me/export", params=params, headers=user_headers) + assert exported.status_code == 200, exported.text[:500] + export = exported.json()["result"] + assert export["user"]["id"] == user_id + assert export["user"]["user_type"] == "standard" + exported_report = next( + row for row in export["reports"] if row["id"] == report_id ) - assert repeated.status_code == 200, repeated.text[:500] - assert repeated.json()["result"]["item"]["id"] == policy_id + assert exported_report["user_policy_id"] == policy_id + assert [row["id"] for row in export["policies"]] == [policy_id] + + _delete_account(api_client, user_headers) + reprovisioned = api_client.get("/v2/me", params=params, headers=user_headers) + assert reprovisioned.status_code == 200, reprovisioned.text[:500] + assert reprovisioned.json()["result"]["item"]["id"] != user_id + empty_export = api_client.get( + "/v2/me/export", params=params, headers=user_headers + ) + assert empty_export.status_code == 200, empty_export.text[:500] + assert empty_export.json()["result"]["policies"] == [] + finally: + _delete_account(api_client, user_headers) + +def test_live_unauthenticated_users_round_trip_and_upgrade( + api_client, + user_headers: dict[str, str], +) -> None: + """Exercise signed-out saving, conversion, and merge at sign-in. + + Every run uses fresh browser UUIDs and starts and ends by deleting the + test user's account, so it leaves nothing behind and can run repeatedly. + """ + + params = {"country_id": COUNTRY_ID} + legacy_policy_id = _v1_id(api_client, "policy", {"data": {}}, "policy_id") + converted_browser = _browser_headers() + merged_browser = _browser_headers() + signed_out_only_browser = _browser_headers() + # The upgrade must convert, so no account may exist for the test user yet. + _delete_account(api_client, user_headers) + try: + # Reads never create an unauthenticated user. + absent = api_client.get("/v2/me", params=params, headers=converted_browser) + assert absent.status_code == 404, absent.text[:500] + assert _policy_ids(api_client, converted_browser) == set() + + saved = _save_policy(api_client, converted_browser, legacy_policy_id) + assert saved["user_id"] == converted_browser[UNAUTHENTICATED_USER_HEADER] detail = api_client.get( - f"/v2/policies/{policy_id}", - params={"country_id": country_id}, + f"/v2/user-policies/{saved['id']}", + params=params, + headers=converted_browser, ) assert detail.status_code == 200, detail.text[:500] - assert detail.json()["result"]["item"] == created_item - - collection = api_client.get( - "/v2/policies", - params={ - "country_id": country_id, - "tax_benefit_model_id": model["id"], - "offset": existing_policy_count, - "limit": 1, - }, + assert detail.json()["result"]["item"] == saved + me = api_client.get("/v2/me", params=params, headers=converted_browser) + assert me.status_code == 200, me.text[:500] + assert me.json()["result"]["item"]["user_type"] == "unauthenticated" + assert me.json()["result"]["item"]["primary_country"] is None + edited = api_client.patch( + "/v2/me", + params=params, + headers=converted_browser, + json={"first_name": "Signed out"}, ) - assert collection.status_code == 200, collection.text[:500] - assert policy_id in { - item["id"] for item in collection.json()["result"]["items"] - } + assert edited.status_code == 403, edited.text[:500] - association_body = { - "country_id": country_id, - "user_id": str(user_id), - "policy_id": policy_id, - "name": f"Phase 10 native {integration_probe_id}", - "description": "Synthetic staging lifecycle record", - } - association = api_client.post( - "/v2/user-policies", - params={"country_id": country_id}, - json=association_body, - ) - assert association.status_code == 201, association.text[:500] - association_item = association.json()["result"]["item"] - association_id = association_item["id"] + # A signed-out browser's work is invisible to the signed-in user... + other_work = _save_policy(api_client, signed_out_only_browser, legacy_policy_id) + assert other_work["id"] not in _policy_ids(api_client, user_headers) - association_detail = api_client.get( - f"/v2/user-policies/{association_id}", - params={"country_id": country_id}, + # ...until sign-in converts this browser's user in place. The bearer + # GET above provisioned the account, so delete it again first. + _delete_account(api_client, user_headers) + converted = api_client.post( + "/v2/me/upgrade", + params=params, + headers={**user_headers, **converted_browser}, ) - assert association_detail.status_code == 200, association_detail.text[:500] - assert association_detail.json()["result"]["item"] == association_item + assert converted.status_code == 200, converted.text[:500] + converted_result = converted.json()["result"] + assert converted_result["outcome"] == "converted" + account_id = converted_result["item"]["id"] + assert account_id == converted_browser[UNAUTHENTICATED_USER_HEADER] + assert converted_result["item"]["user_type"] == "standard" + assert _policy_ids(api_client, user_headers) == {saved["id"]} + refused = api_client.get("/v2/me", params=params, headers=converted_browser) + assert refused.status_code == 401, refused.text[:500] - association_collection = api_client.get( - "/v2/user-policies", - params={ - "country_id": country_id, - "user_id": str(user_id), - "policy_id": policy_id, - }, - ) - assert association_collection.status_code == 200 - assert [ - item["id"] for item in association_collection.json()["result"]["items"] - ] == [association_id] - - updated_name = f"Phase 10 renamed {integration_probe_id}" - patched = api_client.patch( - f"/v2/user-policies/{association_id}", - params={"country_id": country_id}, - json={"name": updated_name}, - ) - assert patched.status_code == 200, patched.text[:500] - patched_item = patched.json()["result"]["item"] - assert patched_item["name"] == updated_name - assert patched_item["description"] == association_body["description"] - - deleted = api_client.delete( - f"/v2/user-policies/{association_id}", - params={"country_id": country_id}, + # A second browser's work merges into the existing account unchanged. + merged_work = _save_policy(api_client, merged_browser, legacy_policy_id) + merged = api_client.post( + "/v2/me/upgrade", + params=params, + headers={**user_headers, **merged_browser}, ) - assert deleted.status_code == 204, deleted.text[:500] - absent = api_client.get( - f"/v2/user-policies/{association_id}", - params={"country_id": country_id}, + assert merged.status_code == 200, merged.text[:500] + assert merged.json()["result"]["outcome"] == "merged" + assert merged.json()["result"]["item"]["id"] == account_id + assert _policy_ids(api_client, user_headers) == {saved["id"], merged_work["id"]} + gone = api_client.get("/v2/me", params=params, headers=merged_browser) + assert gone.status_code == 404, gone.text[:500] + + repeated = api_client.post( + "/v2/me/upgrade", + params=params, + headers={**user_headers, **merged_browser}, ) - assert absent.status_code == 404, absent.text[:500] + assert repeated.status_code == 200, repeated.text[:500] + assert repeated.json()["result"]["outcome"] == "none" + + # A signed-out browser can delete its own work, and nothing else. + _delete_account(api_client, signed_out_only_browser) + assert _policy_ids(api_client, signed_out_only_browser) == set() + assert _policy_ids(api_client, user_headers) == {saved["id"], merged_work["id"]} finally: - with engine.begin() as connection: - connection.execute( - text("DELETE FROM users WHERE id = :id"), - {"id": user_id}, - ) - engine.dispose() + _delete_account(api_client, signed_out_only_browser) + _delete_account(api_client, merged_browser) + _delete_account(api_client, user_headers) diff --git a/tests/integration/test_v2_household_persistence.py b/tests/integration/test_v2_household_persistence.py index da1ccbe08..6edabd3ca 100644 --- a/tests/integration/test_v2_household_persistence.py +++ b/tests/integration/test_v2_household_persistence.py @@ -48,10 +48,13 @@ from policyengine_api.services.v2.user_households.services import ( V2UserHouseholdService, ) +from policyengine_api.services.v2.identity.types import UserPrincipal +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind from policyengine_api.services.v2.user_households.types import ( - UserHouseholdCreationInput, + UserHouseholdCreateFields, UserHouseholdUpdateInput, ) +from tests.unit.v2.user_service_support import FakeLegacyReferences def _disposable_url() -> str: @@ -260,9 +263,19 @@ def test_legacy_mappings_are_many_to_one_and_conflicts_roll_back() -> None: def test_content_edit_creates_replacement_and_reassigns_named_association() -> None: engine = create_engine(_disposable_url()) sessions = sessionmaker(engine, class_=Session, expire_on_commit=False) - association_service = V2UserHouseholdService(UserHouseholdDatabaseSession(sessions)) - user_id = uuid4() + replacement_legacy_id = int(uuid4().int % 1_000_000_000) + 1 + association_service = V2UserHouseholdService( + UserHouseholdDatabaseSession(sessions), + FakeLegacyReferences( + {("us", LegacyRecordKind.HOUSEHOLD): {replacement_legacy_id}} + ), + ) + principal = UserPrincipal( + issuer=f"https://stage11-{uuid4().hex[:12]}.example.test/", + subject="google-oauth2|household-editor", + ) household_ids: set[UUID] = set() + user_ids: set[UUID] = set() try: with sessions.begin() as session: original = create_normalized_household( @@ -274,29 +287,40 @@ def test_content_edit_creates_replacement_and_reassigns_named_association() -> N _normalized_input(f"after-edit-{uuid4()}"), ) household_ids.update({original.household_id, replacement.household_id}) - session.add(User(id=user_id, primary_country="us")) + session.add( + LegacyHouseholdMapping( + country_id="us", + legacy_household_id=replacement_legacy_id, + household_id=replacement.household_id, + source_api_version="1", + fingerprint_version=1, + fingerprint_sha256="e" * 64, + ) + ) first = association_service.create_user_household( - UserHouseholdCreationInput( + principal=principal, + association_input=UserHouseholdCreateFields( country_id="us", - user_id=user_id, household_id=original.household_id, name="Application display label", - ) + ), ) + user_ids.add(first.user_id) duplicate = association_service.create_user_household( - UserHouseholdCreationInput( + principal=principal, + association_input=UserHouseholdCreateFields( country_id="us", - user_id=user_id, household_id=original.household_id, name="Second saved entry", - ) + ), ) reassigned = association_service.patch_user_household( + principal=principal, country_id="us", association_id=first.id, association_input=UserHouseholdUpdateInput( - household_id=replacement.household_id, + legacy_household_id=replacement_legacy_id, name="Edited application display label", ), ) @@ -305,6 +329,7 @@ def test_content_edit_creates_replacement_and_reassigns_named_association() -> N assert first.name == "Application display label" assert reassigned.id == first.id assert reassigned.household_id == replacement.household_id + assert reassigned.legacy_household_id == replacement_legacy_id assert reassigned.name == "Edited application display label" assert {"name", "description", "label"}.isdisjoint( Household.__table__.columns.keys() @@ -316,10 +341,12 @@ def test_content_edit_creates_replacement_and_reassigns_named_association() -> N delete(Household).where(Household.id == original.household_id) ) - association_service.delete_user_household( - country_id="us", - association_id=first.id, - ) + for association_id in (first.id, duplicate.id): + association_service.delete_user_household( + principal=principal, + country_id="us", + association_id=association_id, + ) with sessions() as session: assert session.get(Household, original.household_id) is not None assert session.get(Household, replacement.household_id) is not None @@ -328,6 +355,6 @@ def test_content_edit_creates_replacement_and_reassigns_named_association() -> N _cleanup( engine, household_ids=household_ids, - user_ids={user_id}, + user_ids=user_ids, ) engine.dispose() diff --git a/tests/integration/test_v2_user_identity_persistence.py b/tests/integration/test_v2_user_identity_persistence.py new file mode 100644 index 000000000..315107909 --- /dev/null +++ b/tests/integration/test_v2_user_identity_persistence.py @@ -0,0 +1,736 @@ +"""PostgreSQL tests for identity provisioning, unauthenticated users, and upgrades. + +These prove the behaviors SQLite cannot: named ``ON CONFLICT`` targets, +concurrent first sign-in, concurrent first writes by one signed-out browser, +row locks that serialize conversion at sign-in with that browser's writes, +``MATCH SIMPLE`` composite keys with NULL references, and cascading account +deletion. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +import os +from threading import Barrier +import time +from uuid import UUID, uuid4 + +import pytest +from pydantic import ValidationError +from sqlalchemy import create_engine, delete, func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import sessionmaker +from sqlmodel import Session + +from policyengine_api.data.v2.migration_target import ( + V2_ALEMBIC_DISPOSABLE_TEST, + load_v2_alembic_settings, +) +from policyengine_api.data.v2.models import ( + LegacyUserMapping, + User, + UserHouseholdAssociation, + UserIdentity, + UserPolicy, + UserReportAssociation, + UserSimulationAssociation, +) +from policyengine_api.data.v2.settings import ( + V2_MIGRATION_DATABASE_URL, + V2_SUPABASE_ENVIRONMENT, + V2_SUPABASE_PROJECT_REF, +) +from policyengine_api.data.v2 import user_association_migration_qualification +from policyengine_api.services.v2.identity.services import ( + convert_unauthenticated_user_in_session, + resolve_principal_user_in_session, +) +from policyengine_api.services.v2.identity.types import ( + AccountConversionOutcome, + UnauthenticatedPrincipal, + UserPrincipal, +) +from policyengine_api.services.v2.identity.validators import ( + StandardUserRequiresSignInError, +) +from policyengine_api.services.v2.user_accounts.database_session import ( + AccountDatabaseSession, +) +from policyengine_api.services.v2.user_accounts.services import V2AccountService +from policyengine_api.services.v2.user_accounts.types import AccountUpdateInput +from policyengine_api.services.v2.user_households.database_session import ( + UserHouseholdDatabaseSession, +) +from policyengine_api.services.v2.user_households.services import ( + V2UserHouseholdService, +) +from policyengine_api.services.v2.user_households.types import ( + UserHouseholdCreateFields, +) +from policyengine_api.services.v2.user_policies.database_session import ( + UserPolicyDatabaseSession, +) +from policyengine_api.services.v2.user_policies.services import V2UserPolicyService +from policyengine_api.services.v2.user_policies.types import UserPolicyCreateFields +from policyengine_api.services.v2.user_reports.database_session import ( + UserReportDatabaseSession, +) +from policyengine_api.services.v2.user_reports.services import V2UserReportService +from policyengine_api.services.v2.user_reports.types import UserReportCreateFields +from policyengine_api.services.v2.user_simulations.database_session import ( + UserSimulationDatabaseSession, +) +from policyengine_api.services.v2.user_simulations.services import ( + V2UserSimulationService, +) +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationCreateFields, +) +from tests.unit.v2.user_service_support import default_legacy_references + + +# Long enough for a write that is not blocked by a held row lock to finish. +LOCK_WAIT_SECONDS = 1.0 +ASSOCIATION_MODELS = ( + UserPolicy, + UserHouseholdAssociation, + UserSimulationAssociation, + UserReportAssociation, +) + + +def _disposable_url() -> str: + database_url = os.environ.get(V2_MIGRATION_DATABASE_URL, "") + if not database_url: + pytest.skip(f"{V2_MIGRATION_DATABASE_URL} is not set") + settings = load_v2_alembic_settings( + { + V2_MIGRATION_DATABASE_URL: database_url, + V2_ALEMBIC_DISPOSABLE_TEST: os.environ.get(V2_ALEMBIC_DISPOSABLE_TEST, ""), + } + ) + if not settings.disposable_test: + pytest.fail("identity persistence tests require disposable-test mode") + return settings.url.render_as_string(hide_password=False) + + +@pytest.fixture +def sessions() -> Iterator[sessionmaker[Session]]: + engine = create_engine(_disposable_url(), pool_size=10, max_overflow=10) + factory = sessionmaker(engine, class_=Session, expire_on_commit=False) + issuer = f"https://stage12a-{uuid4().hex[:12]}.example.test/" + factory.kw["info"] = {"issuer": issuer, "browser_user_ids": []} + try: + yield factory + finally: + with engine.begin() as connection: + owned = select(UserIdentity.user_id).where(UserIdentity.issuer == issuer) + user_ids = [ + *connection.execute(owned).scalars(), + *factory.kw["info"]["browser_user_ids"], + ] + if user_ids: + connection.execute( + delete(LegacyUserMapping).where( + LegacyUserMapping.user_id.in_(user_ids) + ) + ) + connection.execute(delete(User).where(User.id.in_(user_ids))) + engine.dispose() + + +def _principal(sessions, subject: str) -> UserPrincipal: + return UserPrincipal(issuer=sessions.kw["info"]["issuer"], subject=subject) + + +def _browser(sessions) -> UnauthenticatedPrincipal: + """A fresh signed-out browser whose user the fixture removes afterwards.""" + + user_id = uuid4() + sessions.kw["info"]["browser_user_ids"].append(user_id) + return UnauthenticatedPrincipal(user_id=user_id) + + +def _count(sessions, statement) -> int: + with sessions() as session: + return int(session.execute(statement).scalar_one()) + + +@dataclass(frozen=True) +class Services: + accounts: V2AccountService + policies: V2UserPolicyService + households: V2UserHouseholdService + simulations: V2UserSimulationService + reports: V2UserReportService + + +def _services(sessions) -> Services: + references = default_legacy_references() + return Services( + accounts=V2AccountService(AccountDatabaseSession(sessions)), + policies=V2UserPolicyService(UserPolicyDatabaseSession(sessions), references), + households=V2UserHouseholdService( + UserHouseholdDatabaseSession(sessions), references + ), + simulations=V2UserSimulationService( + UserSimulationDatabaseSession(sessions), references + ), + reports=V2UserReportService(UserReportDatabaseSession(sessions), references), + ) + + +def _save_one_of_each(services: Services, principal) -> dict[type, UUID]: + policy = services.policies.create_user_policy( + principal=principal, + association_input=UserPolicyCreateFields(country_id="us", legacy_policy_id=1), + ) + household = services.households.create_user_household( + principal=principal, + association_input=UserHouseholdCreateFields( + country_id="us", legacy_household_id=10 + ), + ) + simulation = services.simulations.create_user_simulation( + principal=principal, + association_input=UserSimulationCreateFields( + country_id="us", legacy_simulation_id=613 + ), + ) + report = services.reports.create_user_report( + principal=principal, + association_input=UserReportCreateFields( + country_id="us", + legacy_report_id=615, + user_policy_id=policy.id, + baseline_user_policy_id=policy.id, + ), + ) + return { + UserPolicy: policy.id, + UserHouseholdAssociation: household.id, + UserSimulationAssociation: simulation.id, + UserReportAssociation: report.id, + } + + +def _owners(sessions, records: dict[type, UUID]) -> set[UUID | None]: + with sessions() as session: + return { + None if row is None else row.user_id + for row in ( + session.get(model, record_id) for model, record_id in records.items() + ) + } + + +def _save_policy(services: Services, principal) -> UUID: + return services.policies.create_user_policy( + principal=principal, + association_input=UserPolicyCreateFields(country_id="us", legacy_policy_id=1), + ).id + + +def test_concurrent_first_requests_provision_exactly_one_user(sessions) -> None: + principal = _principal(sessions, "google-oauth2|race") + workers = 8 + barrier = Barrier(workers) + + def first_request(_index: int): + barrier.wait() + with sessions.begin() as session: + return resolve_principal_user_in_session( + session, principal, country_id="us" + ).user_id + + with ThreadPoolExecutor(max_workers=workers) as executor: + user_ids = set(executor.map(first_request, range(workers))) + + assert len(user_ids) == 1 + issuer = principal.issuer + assert ( + _count( + sessions, + select(func.count()) + .select_from(UserIdentity) + .where(UserIdentity.issuer == issuer), + ) + == 1 + ) + assert ( + _count( + sessions, + select(func.count()).select_from(User).where(User.id.in_(user_ids)), + ) + == 1 + ) + + +def test_concurrent_first_writes_by_one_browser_create_exactly_one_user( + sessions, +) -> None: + browser = _browser(sessions) + services = _services(sessions) + workers = 8 + barrier = Barrier(workers) + + def first_write(_index: int) -> UUID: + barrier.wait() + return services.policies.create_user_policy( + principal=browser, + association_input=UserPolicyCreateFields( + country_id="us", legacy_policy_id=1 + ), + ).user_id + + with ThreadPoolExecutor(max_workers=workers) as executor: + owners = set(executor.map(first_write, range(workers))) + + assert owners == {browser.user_id} + with sessions() as session: + user = session.get(User, browser.user_id) + policy_count = session.execute( + select(func.count()) + .select_from(UserPolicy) + .where(UserPolicy.user_id == browser.user_id) + ).scalar_one() + assert user is not None + assert (user.user_type, user.primary_country, user.email) == ( + "unauthenticated", + None, + None, + ) + assert policy_count == workers + + +def test_concurrent_upgrades_of_one_browser_convert_exactly_once(sessions) -> None: + browser = _browser(sessions) + alice = _principal(sessions, "google-oauth2|converted-once") + services = _services(sessions) + records = _save_one_of_each(services, browser) + workers = 6 + barrier = Barrier(workers) + + def upgrade(_index: int): + barrier.wait() + return services.accounts.upgrade_account( + principal=alice, + unauthenticated_user_id=browser.user_id, + country_id="us", + ) + + with ThreadPoolExecutor(max_workers=workers) as executor: + upgrades = list(executor.map(upgrade, range(workers))) + + assert sorted(upgrade.outcome.value for upgrade in upgrades) == [ + "converted", + *["none"] * (workers - 1), + ] + assert {upgrade.account.id for upgrade in upgrades} == {browser.user_id} + assert _owners(sessions, records) == {browser.user_id} + with sessions() as session: + identities = session.execute( + select(UserIdentity.user_id).where(UserIdentity.issuer == alice.issuer) + ).all() + user = session.get(User, browser.user_id) + assert [row.user_id for row in identities] == [browser.user_id] + assert user is not None and user.user_type == "standard" + + +def test_concurrent_first_upgrades_from_two_browsers_share_one_account( + sessions, +) -> None: + browsers = [_browser(sessions), _browser(sessions)] + alice = _principal(sessions, "google-oauth2|two-browsers") + services = _services(sessions) + records = [_save_one_of_each(services, browser) for browser in browsers] + barrier = Barrier(len(browsers)) + + def upgrade(browser: UnauthenticatedPrincipal): + barrier.wait() + return services.accounts.upgrade_account( + principal=alice, + unauthenticated_user_id=browser.user_id, + country_id="us", + ) + + with ThreadPoolExecutor(max_workers=len(browsers)) as executor: + upgrades = list(executor.map(upgrade, browsers)) + + # The first sign-in converts one browser in place; the other loses the + # UNIQUE(issuer, subject) race and merges into it. + assert sorted(upgrade.outcome.value for upgrade in upgrades) == [ + "converted", + "merged", + ] + (account_id,) = {upgrade.account.id for upgrade in upgrades} + assert account_id in {browser.user_id for browser in browsers} + assert all(_owners(sessions, saved) == {account_id} for saved in records) + remaining = _count( + sessions, + select(func.count()) + .select_from(User) + .where(User.id.in_([browser.user_id for browser in browsers])), + ) + assert remaining == 1 + + +def test_merge_moves_every_association_and_keeps_links_and_constraints( + sessions, +) -> None: + browser = _browser(sessions) + alice = _principal(sessions, "google-oauth2|merged") + services = _services(sessions) + alice_id = services.accounts.get_account(principal=alice, country_id="us").id + alice_records = _save_one_of_each(services, alice) + browser_records = _save_one_of_each(services, browser) + + upgrade = services.accounts.upgrade_account( + principal=alice, unauthenticated_user_id=browser.user_id, country_id="us" + ) + + assert upgrade.outcome is AccountConversionOutcome.MERGED + assert upgrade.account.id == alice_id + assert _owners(sessions, browser_records) == {alice_id} + assert _owners(sessions, alice_records) == {alice_id} + with sessions() as session: + assert session.get(User, browser.user_id) is None + report = session.get( + UserReportAssociation, browser_records[UserReportAssociation] + ) + assert report is not None + assert report.user_policy_id == browser_records[UserPolicy] + assert report.baseline_user_policy_id == browser_records[UserPolicy] + exported = services.accounts.export_account(principal=alice, country_id="us") + assert len(exported.policies) == len(exported.reports) == 2 + + # The moved report's link still follows its policy's ON DELETE SET NULL. + services.policies.delete_user_policy( + principal=alice, + country_id="us", + association_id=browser_records[UserPolicy], + ) + linked = services.reports.get_user_report( + principal=alice, + country_id="us", + association_id=browser_records[UserReportAssociation], + ) + assert linked.user_policy_id is None + assert linked.baseline_user_policy_id is None + + +def _hold_a_conversion(sessions, *, browser, principal) -> Session: + """Convert or merge ``browser`` in an open transaction holding its lock.""" + + session = sessions() + session.begin() + convert_unauthenticated_user_in_session( + session, + unauthenticated_user_id=browser.user_id, + principal=principal, + country_id="us", + ) + return session + + +def test_a_browser_write_waits_for_its_conversion_and_is_then_refused( + sessions, +) -> None: + browser = _browser(sessions) + alice = _principal(sessions, "google-oauth2|write-during-conversion") + services = _services(sessions) + _save_policy(services, browser) + converting = _hold_a_conversion(sessions, browser=browser, principal=alice) + + with ThreadPoolExecutor(max_workers=1) as executor: + write = executor.submit(_save_policy, services, browser) + time.sleep(LOCK_WAIT_SECONDS) + assert not write.done() + converting.commit() + converting.close() + with pytest.raises(StandardUserRequiresSignInError): + write.result(timeout=30) + + with sessions() as session: + policy_count = session.execute( + select(func.count()) + .select_from(UserPolicy) + .where(UserPolicy.user_id == browser.user_id) + ).scalar_one() + assert policy_count == 1 + + +def test_a_browser_write_after_its_merge_starts_a_new_unauthenticated_user( + sessions, +) -> None: + browser = _browser(sessions) + alice = _principal(sessions, "google-oauth2|write-during-merge") + services = _services(sessions) + alice_id = services.accounts.get_account(principal=alice, country_id="us").id + merged_policy_id = _save_policy(services, browser) + merging = _hold_a_conversion(sessions, browser=browser, principal=alice) + + with ThreadPoolExecutor(max_workers=1) as executor: + write = executor.submit(_save_policy, services, browser) + time.sleep(LOCK_WAIT_SECONDS) + assert not write.done() + merging.commit() + merging.close() + new_policy_id = write.result(timeout=30) + + with sessions() as session: + merged_policy = session.get(UserPolicy, merged_policy_id) + new_policy = session.get(UserPolicy, new_policy_id) + browser_user = session.get(User, browser.user_id) + assert merged_policy is not None and merged_policy.user_id == alice_id + assert new_policy is not None and new_policy.user_id == browser.user_id + assert browser_user is not None and browser_user.user_type == "unauthenticated" + + +def test_a_browser_account_deletion_never_deletes_its_converted_user( + sessions, +) -> None: + browser = _browser(sessions) + alice = _principal(sessions, "google-oauth2|delete-during-conversion") + services = _services(sessions) + policy_id = _save_policy(services, browser) + converting = _hold_a_conversion(sessions, browser=browser, principal=alice) + + with ThreadPoolExecutor(max_workers=1) as executor: + deletion = executor.submit(services.accounts.delete_account, principal=browser) + time.sleep(LOCK_WAIT_SECONDS) + assert not deletion.done() + converting.commit() + converting.close() + deletion.result(timeout=30) + + with sessions() as session: + user = session.get(User, browser.user_id) + policy = session.get(UserPolicy, policy_id) + assert user is not None and user.user_type == "standard" + assert policy is not None and policy.user_id == browser.user_id + + +def test_upgrade_never_locks_a_standard_user_named_by_the_header(sessions) -> None: + alice = _principal(sessions, "google-oauth2|names-someone-else") + bob = _principal(sessions, "google-oauth2|named-by-the-header") + services = _services(sessions) + bob_id = services.accounts.get_account(principal=bob, country_id="us").id + upgrading = sessions() + upgrading.begin() + try: + conversion = convert_unauthenticated_user_in_session( + upgrading, + unauthenticated_user_id=bob_id, + principal=alice, + country_id="us", + ) + assert conversion.outcome is AccountConversionOutcome.NONE + # Bob's own account update would wait here if his row were locked. + with ThreadPoolExecutor(max_workers=1) as executor: + update = executor.submit( + services.accounts.update_account, + principal=bob, + country_id="us", + account_input=AccountUpdateInput(first_name="Bob"), + ) + assert update.result(timeout=10 * LOCK_WAIT_SECONDS).first_name == "Bob" + finally: + upgrading.rollback() + upgrading.close() + + +def test_unauthenticated_users_are_written_only_with_their_id(sessions) -> None: + browser = _browser(sessions) + alice = _principal(sessions, "google-oauth2|not-a-browser") + services = _services(sessions) + alice_id = services.accounts.get_account(principal=alice, country_id="us").id + + page = services.policies.list_user_policies(principal=browser, country_id="us") + with pytest.raises(StandardUserRequiresSignInError): + services.policies.list_user_policies( + principal=UnauthenticatedPrincipal(user_id=alice_id), country_id="us" + ) + _save_policy(services, browser) + + assert page.items == () + with sessions() as session: + user = session.get(User, browser.user_id) + assert user is not None + assert ( + user.user_type, + user.primary_country, + user.first_name, + user.last_name, + user.email, + ) == ("unauthenticated", None, None, None, None) + with pytest.raises(IntegrityError): + with sessions.begin() as session: + session.add(User(user_type="standard")) + + +def test_non_finite_numbers_never_reach_jsonb(sessions) -> None: + principal = _principal(sessions, "google-oauth2|non-finite") + services = _services(sessions) + provision = { + "path": "gov.irs.credits.ctc.amount", + "breadcrumb": "Child Tax Credit amount", + "baseline_value": 2000, + "value": 3000, + } + + stored = services.reports.create_user_report( + principal=principal, + association_input=UserReportCreateFields.model_validate( + {"country_id": "us", "legacy_report_id": 615, "provisions": [provision]} + ), + ) + with pytest.raises(ValidationError): + UserReportCreateFields.model_validate( + { + "country_id": "us", + "legacy_report_id": 616, + "provisions": [{**provision, "value": float("inf")}], + } + ) + + with sessions() as session: + row = session.get(UserReportAssociation, stored.id) + assert row is not None and row.provisions == [provision] + + +def test_report_budget_windows_persist_within_the_api_window_limit(sessions) -> None: + principal = _principal(sessions, "google-oauth2|budget-window") + services = _services(sessions) + + stored = services.reports.create_user_report( + principal=principal, + association_input=UserReportCreateFields( + country_id="us", legacy_report_id=615, budget_window_years=10 + ), + ) + with sessions() as session: + row = session.get(UserReportAssociation, stored.id) + assert row is not None and row.budget_window_years == 10 + + for out_of_range_years in (1, 76): + with pytest.raises(IntegrityError): + with sessions.begin() as session: + session.add( + UserReportAssociation( + user_id=stored.user_id, + country_id="us", + legacy_report_id=615, + budget_window_years=out_of_range_years, + ) + ) + + +def test_reference_checks_and_match_simple_composite_keys(sessions) -> None: + principal = _principal(sessions, "google-oauth2|constraints") + with sessions.begin() as session: + owner = resolve_principal_user_in_session( + session, principal, country_id="us" + ).user_id + + with sessions.begin() as session: + session.add_all( + [ + UserPolicy(user_id=owner, country_id="us", legacy_policy_id=1), + UserPolicy(user_id=owner, country_id="us", legacy_policy_id=2), + # One owner may save the same v1 policy more than once. + UserPolicy(user_id=owner, country_id="us", legacy_policy_id=2), + ] + ) + + for invalid in ( + UserPolicy(user_id=owner, country_id="us"), + UserPolicy(user_id=owner, country_id="us", legacy_policy_id=1, baseline="x"), + UserPolicy(user_id=owner, country_id="ca", legacy_policy_id=1), + UserPolicy(user_id=owner, country_id="us", policy_id=uuid4()), + ): + with pytest.raises(IntegrityError): + with sessions.begin() as session: + session.add(invalid) + + +def test_account_deletion_cascades_after_removing_legacy_mappings(sessions) -> None: + alice = _principal(sessions, "google-oauth2|alice") + bob = _principal(sessions, "google-oauth2|bob") + deleted_subjects: list[str] = [] + + class RecordingDeleter: + def prepare_deletion(self) -> None: + return None + + def delete_user(self, subject: str) -> None: + deleted_subjects.append(subject) + + services = _services(sessions) + accounts = V2AccountService( + AccountDatabaseSession(sessions), + auth0_user_deleter_factory=RecordingDeleter, + ) + alice_id = accounts.get_account(principal=alice, country_id="us").id + bob_id = accounts.get_account(principal=bob, country_id="us").id + _save_one_of_each(services, alice) + with sessions.begin() as session: + session.add( + LegacyUserMapping(legacy_user_id=f"legacy-{uuid4().hex}", user_id=alice_id) + ) + + accounts.delete_account(principal=alice) + + assert deleted_subjects == [alice.subject] + with sessions() as session: + assert session.get(User, alice_id) is None + assert session.get(User, bob_id) is not None + for model in (LegacyUserMapping, *ASSOCIATION_MODELS): + assert ( + session.execute( + select(func.count()) + .select_from(model) + .where(model.user_id == alice_id) + ).scalar_one() + == 0 + ) + + +def test_qualification_reads_counts_in_a_read_only_transaction(sessions) -> None: + disposable_url = _disposable_url() + # The settings loader requires a persistent-looking target; the engine + # builder substitutes the disposable database, which is all that is read. + environ = { + V2_MIGRATION_DATABASE_URL: ( + "postgresql+psycopg://migration:unused@db.abcdefghijklmnopqrst." + "supabase.co/postgres?sslmode=require" + ), + V2_SUPABASE_PROJECT_REF: "abcdefghijklmnopqrst", + V2_SUPABASE_ENVIRONMENT: "test", + } + observed_read_only: list[object] = [] + + def pending_checker(connection) -> bool: + observed_read_only.append(connection.connection.dbapi_connection.read_only) + return user_association_migration_qualification.user_association_migration_is_pending( + connection + ) + + evidence = ( + user_association_migration_qualification.qualify_user_association_migration( + environ, + engine_builder=lambda _settings: create_engine(disposable_url), + pending_checker=pending_checker, + ) + ) + + assert observed_read_only == [True] + assert evidence.migration_pending is False + assert set(evidence.as_dict()["counts"]) == { + "user_policies", + "user_household_associations", + "user_simulation_associations", + "user_report_associations", + } diff --git a/tests/unit/libs/test_auth0_management.py b/tests/unit/libs/test_auth0_management.py new file mode 100644 index 000000000..9bf779756 --- /dev/null +++ b/tests/unit/libs/test_auth0_management.py @@ -0,0 +1,239 @@ +"""Unit tests for :mod:`policyengine_api.libs.auth0_management`.""" + +from __future__ import annotations + +import httpx +import pytest + +from policyengine_api.libs.auth0_management import ( + Auth0ManagementClient, + Auth0ManagementConfigurationError, + Auth0ManagementError, + Auth0ManagementSettings, + build_auth0_user_deleter, + load_auth0_management_settings, +) + + +DOMAIN = "policyengine.us.auth0.com" +SECRET_RESOURCE = "projects/p/secrets/auth0-management/versions/latest" + + +def _client(handler, **settings) -> tuple[Auth0ManagementClient, list[httpx.Request]]: + requests: list[httpx.Request] = [] + + def record(request: httpx.Request) -> httpx.Response: + requests.append(request) + return handler(request) + + values = {"domain": DOMAIN, "client_id": "m2m", "client_secret": "raw-secret"} + values.update(settings) + return ( + Auth0ManagementClient( + Auth0ManagementSettings(**values), + http_client=httpx.Client(transport=httpx.MockTransport(record)), + secret_loader=lambda resource: "loaded-secret", + ), + requests, + ) + + +def _token_then(status: int): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/oauth/token": + return httpx.Response( + 200, json={"access_token": "mgmt", "expires_in": 3600} + ) + return httpx.Response(status) + + return handler + + +def test_settings_are_optional_and_all_or_none() -> None: + assert load_auth0_management_settings({}) is None + assert build_auth0_user_deleter({}) is None + with pytest.raises(Auth0ManagementConfigurationError, match="partially"): + load_auth0_management_settings({"AUTH0_MANAGEMENT_DOMAIN": DOMAIN}) + with pytest.raises(Auth0ManagementConfigurationError, match="exactly one"): + load_auth0_management_settings( + { + "AUTH0_MANAGEMENT_DOMAIN": DOMAIN, + "AUTH0_MANAGEMENT_CLIENT_ID": "m2m", + "AUTH0_MANAGEMENT_CLIENT_SECRET": "raw", + "AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE": SECRET_RESOURCE, + } + ) + with pytest.raises(Auth0ManagementConfigurationError, match="bare host"): + load_auth0_management_settings( + { + "AUTH0_MANAGEMENT_DOMAIN": f"https://{DOMAIN}/", + "AUTH0_MANAGEMENT_CLIENT_ID": "m2m", + "AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE": SECRET_RESOURCE, + } + ) + settings = load_auth0_management_settings( + { + "AUTH0_MANAGEMENT_DOMAIN": DOMAIN, + "AUTH0_MANAGEMENT_CLIENT_ID": "m2m", + "AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE": SECRET_RESOURCE, + } + ) + assert settings is not None + assert "raw" not in repr(settings) + + +@pytest.mark.parametrize("status", [204, 404]) +def test_delete_user_uses_an_encoded_subject_and_tolerates_absent_users( + status: int, +) -> None: + client, requests = _client(_token_then(status)) + + client.delete_user("google-oauth2|1234") + + token_request, delete_request = requests + assert token_request.url == f"https://{DOMAIN}/oauth/token" + assert b'"audience":"https://policyengine.us.auth0.com/api/v2/"' in ( + token_request.content.replace(b" ", b"") + ) + assert delete_request.method == "DELETE" + assert delete_request.url.raw_path == b"/api/v2/users/google-oauth2%7C1234" + assert delete_request.headers["authorization"] == "Bearer mgmt" + + +def test_token_is_cached_and_secret_resources_are_loaded() -> None: + client, requests = _client( + _token_then(204), client_secret="", client_secret_resource=SECRET_RESOURCE + ) + + client.delete_user("auth0|1") + client.delete_user("auth0|2") + + assert [request.url.raw_path for request in requests] == [ + b"/oauth/token", + b"/api/v2/users/auth0%7C1", + b"/api/v2/users/auth0%7C2", + ] + assert b"loaded-secret" in requests[0].content + + +@pytest.mark.parametrize("status", [401, 403, 500]) +def test_failed_deletions_raise_without_leaking_subjects(status: int) -> None: + client, _requests = _client(_token_then(status)) + + with pytest.raises(Auth0ManagementError) as error: + client.delete_user("google-oauth2|secret-subject") + + assert "secret-subject" not in str(error.value) + + +def test_deleters_share_one_client_and_its_token(monkeypatch) -> None: + from policyengine_api.libs import auth0_management + + requests: list[httpx.Request] = [] + + def record(request: httpx.Request) -> httpx.Response: + requests.append(request) + return _token_then(204)(request) + + mock_client = httpx.Client + monkeypatch.setattr( + auth0_management.httpx, + "Client", + lambda **kwargs: mock_client(transport=httpx.MockTransport(record), **kwargs), + ) + monkeypatch.setattr(auth0_management, "_shared_clients", {}) + environ = { + "AUTH0_MANAGEMENT_DOMAIN": DOMAIN, + "AUTH0_MANAGEMENT_CLIENT_ID": "m2m", + "AUTH0_MANAGEMENT_CLIENT_SECRET": "raw-secret", + } + + first = build_auth0_user_deleter(environ) + second = build_auth0_user_deleter(environ) + assert first is not None and second is not None + first.delete_user("auth0|1") + second.delete_user("auth0|2") + + assert first is second + assert [request.url.raw_path for request in requests] == [ + b"/oauth/token", + b"/api/v2/users/auth0%7C1", + b"/api/v2/users/auth0%7C2", + ] + + +def _tokens_then(delete_statuses: list[int]): + """Issue numbered tokens and answer deletions with the given statuses.""" + + issued_tokens: list[str] = [] + remaining_statuses = list(delete_statuses) + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/oauth/token": + issued_tokens.append(f"mgmt-{len(issued_tokens) + 1}") + return httpx.Response( + 200, json={"access_token": issued_tokens[-1], "expires_in": 86400} + ) + return httpx.Response(remaining_statuses.pop(0)) + + return handler + + +def test_a_rejected_token_is_dropped_and_the_deletion_retried_once() -> None: + client, requests = _client(_tokens_then([401, 204])) + + client.delete_user("auth0|1") + + assert [request.url.raw_path for request in requests] == [ + b"/oauth/token", + b"/api/v2/users/auth0%7C1", + b"/oauth/token", + b"/api/v2/users/auth0%7C1", + ] + assert requests[1].headers["authorization"] == "Bearer mgmt-1" + assert requests[3].headers["authorization"] == "Bearer mgmt-2" + + +def test_a_second_rejection_fails_without_further_retries() -> None: + client, requests = _client(_tokens_then([401, 401])) + + with pytest.raises(Auth0ManagementError, match="HTTP 401"): + client.delete_user("auth0|1") + + assert len(requests) == 4 + + +def test_prepare_deletion_loads_the_secret_and_fetches_a_token() -> None: + client, requests = _client( + _tokens_then([204]), client_secret="", client_secret_resource=SECRET_RESOURCE + ) + + client.prepare_deletion() + client.delete_user("auth0|1") + + assert [request.url.raw_path for request in requests] == [ + b"/oauth/token", + b"/api/v2/users/auth0%7C1", + ] + + +def test_prepare_deletion_fails_when_the_secret_cannot_be_read() -> None: + def unreadable(_resource: str) -> str: + raise PermissionError("secretmanager.versions.access denied") + + requests: list[httpx.Request] = [] + client = Auth0ManagementClient( + Auth0ManagementSettings( + domain=DOMAIN, client_id="m2m", client_secret_resource=SECRET_RESOURCE + ), + http_client=httpx.Client( + transport=httpx.MockTransport( + lambda request: requests.append(request) or httpx.Response(500) + ) + ), + secret_loader=unreadable, + ) + + with pytest.raises(Auth0ManagementError, match="could not be loaded"): + client.prepare_deletion() + assert requests == [] diff --git a/tests/unit/libs/test_user_auth.py b/tests/unit/libs/test_user_auth.py new file mode 100644 index 000000000..6b573775e --- /dev/null +++ b/tests/unit/libs/test_user_auth.py @@ -0,0 +1,228 @@ +"""Unit tests for :mod:`policyengine_api.libs.user_auth` with real RS256 tokens.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from uuid import UUID + +from cryptography.hazmat.primitives.asymmetric import rsa +import jwt +import pytest + +from policyengine_api.libs.user_auth import ( + InvalidBearerTokenError, + InvalidUnauthenticatedUserError, + JWTDecoder, + MissingBearerTokenError, + PrincipalKind, + UserAuthConfigurationError, + UserAuthSettings, + UserAuthUnavailableError, + _normalized_issuer, + authenticate_bearer, + load_user_auth_settings, + unauthenticated_user_id, +) + + +ISSUER = "https://auth.example.test/" +AUDIENCE = "https://api.policyengine.org/" +SIGNING_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +OTHER_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +class StaticJWKClient: + """Stand-in for PyJWKClient that serves one public key without network.""" + + def __init__(self, private_key=SIGNING_KEY, error: Exception | None = None): + self._public_key = private_key.public_key() + self._error = error + + def get_signing_key_from_jwt(self, _token: str): + if self._error is not None: + raise self._error + return SimpleNamespace(key=self._public_key) + + +def _token(*, key=SIGNING_KEY, algorithm: str = "RS256", **claims) -> str: + now = datetime.now(timezone.utc) + payload = { + "iss": ISSUER, + "aud": [AUDIENCE, f"{ISSUER}userinfo"], + "sub": "google-oauth2|1234", + "iat": now, + "exp": now + timedelta(minutes=10), + } + payload.update(claims) + payload = {name: value for name, value in payload.items() if value is not None} + return jwt.encode(payload, key, algorithm=algorithm) + + +def _settings(issuer: str = ISSUER) -> UserAuthSettings: + return load_user_auth_settings( + { + "PUBLIC_API_AUTH_REQUIRED": "1", + "PUBLIC_API_AUTH_ISSUER": issuer, + "PUBLIC_API_AUTH_AUDIENCE": AUDIENCE, + } + ) + + +def _authenticate(token: str, *, settings: UserAuthSettings | None = None, **client): + selected = settings or _settings() + decoder = JWTDecoder( + selected.issuer, + selected.audience, + jwks_client=StaticJWKClient(**client), + ) + return authenticate_bearer(f"Bearer {token}", selected, decoder=decoder) + + +def test_valid_user_token_yields_a_user_principal() -> None: + principal = _authenticate(_token()) + + assert principal.kind is PrincipalKind.USER + assert principal.issuer == ISSUER + assert principal.subject == "google-oauth2|1234" + assert principal.email is None + + +def test_issuer_configured_without_a_trailing_slash_is_normalized() -> None: + settings = _settings("https://auth.example.test") + + assert settings.issuer == ISSUER + assert _normalized_issuer("https://auth.example.test//") == ISSUER + assert _authenticate(_token(), settings=settings).issuer == ISSUER + + +@pytest.mark.parametrize( + "token", + [ + _token(exp=datetime.now(timezone.utc) - timedelta(minutes=1)), + _token(aud="https://other-api.example.test/"), + _token(iss="https://evil.example.test/"), + _token(iss=ISSUER.rstrip("/")), + _token(key=OTHER_KEY), + _token(sub=None), + _token(exp=None), + jwt.encode({"iss": ISSUER, "sub": "x"}, "h" * 32, algorithm="HS256"), + "not-a-jwt", + ], + ids=[ + "expired", + "wrong-audience", + "wrong-issuer", + "issuer-without-slash", + "wrong-key", + "missing-subject", + "missing-expiry", + "hs256", + "malformed", + ], +) +def test_invalid_tokens_are_rejected(token: str) -> None: + with pytest.raises(InvalidBearerTokenError): + _authenticate(token) + + +def test_client_credentials_tokens_are_client_principals() -> None: + by_subject = _authenticate(_token(sub="abc123@clients")) + by_grant = _authenticate(_token(sub="abc123", gty="client-credentials")) + + assert by_subject.kind is PrincipalKind.CLIENT + assert by_grant.kind is PrincipalKind.CLIENT + + +def test_email_is_kept_only_when_verified() -> None: + verified = _authenticate(_token(email="ada@example.com", email_verified=True)) + unverified = _authenticate(_token(email="ada@example.com", email_verified=False)) + string_flag = _authenticate(_token(email="ada@example.com", email_verified="true")) + + assert verified.email == "ada@example.com" + assert unverified.email is None + assert string_flag.email is None + + +@pytest.mark.parametrize("header", [None, "", "Bearer", "Bearer ", "Basic abc"]) +def test_missing_bearer_tokens_are_reported_before_configuration(header) -> None: + with pytest.raises(MissingBearerTokenError): + authenticate_bearer( + header, UserAuthSettings(required=False, issuer="", audience="") + ) + + +def test_unconfigured_or_unreachable_key_sets_are_unavailable() -> None: + with pytest.raises(UserAuthUnavailableError): + authenticate_bearer( + "Bearer token", UserAuthSettings(required=False, issuer="", audience="") + ) + with pytest.raises(UserAuthUnavailableError): + _authenticate(_token(), error=jwt.PyJWKClientConnectionError("down")) + + +@pytest.mark.parametrize( + ("environ", "message"), + [ + ({"PUBLIC_API_AUTH_REQUIRED": "yes"}, "must be 0 or 1"), + ({"PUBLIC_API_AUTH_REQUIRED": "1"}, "requires"), + ({"PUBLIC_API_AUTH_ISSUER": ISSUER}, "Set both"), + ({"PUBLIC_API_AUTH_AUDIENCE": AUDIENCE}, "Set both"), + ( + { + "PUBLIC_API_AUTH_ISSUER": "http://auth.example.test/", + "PUBLIC_API_AUTH_AUDIENCE": AUDIENCE, + }, + "HTTPS", + ), + ( + { + "PUBLIC_API_AUTH_ISSUER": "https://auth.example.test/?x=1", + "PUBLIC_API_AUTH_AUDIENCE": AUDIENCE, + }, + "HTTPS", + ), + ], +) +def test_settings_fail_closed_when_inconsistent(environ, message: str) -> None: + with pytest.raises(UserAuthConfigurationError, match=message): + load_user_auth_settings(environ) + + +def test_settings_allow_an_explicitly_disabled_deployment() -> None: + settings = load_user_auth_settings({"PUBLIC_API_AUTH_REQUIRED": "0"}) + + assert settings == UserAuthSettings(required=False, issuer="", audience="") + assert not settings.configured + assert issubclass(UserAuthConfigurationError, RuntimeError) + + +def test_decoder_uses_the_issuer_jwks_endpoint() -> None: + decoder = JWTDecoder(ISSUER, AUDIENCE) + + assert decoder.jwks_client.uri == f"{ISSUER}.well-known/jwks.json" + + +def test_unauthenticated_user_ids_are_canonical_uuids() -> None: + browser_user_id = "8f14e45f-ceea-467a-9575-0b2c5c1c2f2a" + + assert unauthenticated_user_id(browser_user_id) == UUID(browser_user_id) + + +@pytest.mark.parametrize( + "value", + [ + "", + "not-a-uuid", + "8F14E45F-CEEA-467A-9575-0B2C5C1C2F2A", + "8f14e45fceea467a95750b2c5c1c2f2a", + "{8f14e45f-ceea-467a-9575-0b2c5c1c2f2a}", + "urn:uuid:8f14e45f-ceea-467a-9575-0b2c5c1c2f2a", + "8f14e45f-ceea-467a-9575-0b2c5c1c2f2a\n", + ], +) +def test_non_canonical_unauthenticated_user_ids_are_rejected(value: str) -> None: + with pytest.raises(InvalidUnauthenticatedUserError) as raised: + unauthenticated_user_id(value) + + assert value.strip() not in str(raised.value) or not value.strip() diff --git a/tests/unit/routes/test_migration_context_logging.py b/tests/unit/routes/test_migration_context_logging.py index a449138f0..1cc1cbc3b 100644 --- a/tests/unit/routes/test_migration_context_logging.py +++ b/tests/unit/routes/test_migration_context_logging.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch from fastapi.testclient import TestClient +import pytest from flask import Flask, Response from policyengine_api.asgi_factory import NativeRouteDependencies, create_asgi_app @@ -486,6 +487,78 @@ def test_v2_household_resources_log_actual_supabase_read_and_write_sources( } +@pytest.mark.parametrize( + ("method", "path", "route_group", "db_read", "db_write"), + [ + ("GET", "/v2/me", "user_profile", "supabase", None), + ("PATCH", "/v2/me", "user_profile", None, "supabase"), + ("DELETE", "/v2/me", "user_profile", None, "supabase"), + ("GET", "/v2/me/export", "user_profile", "supabase", None), + ("POST", "/v2/me/upgrade", "user_profile", None, "supabase"), + ("GET", "/v2/user-simulations", "simulation", "supabase", None), + ("POST", "/v2/user-households", "household", None, "supabase"), + ("POST", "/v2/user-simulations", "simulation", None, "supabase"), + ( + "DELETE", + "/v2/user-simulations/00000000-0000-0000-0000-000000000001", + "simulation", + None, + "supabase", + ), + ( + "GET", + "/v2/user-reports/00000000-0000-0000-0000-000000000001", + "report", + "supabase", + None, + ), + ( + "PATCH", + "/v2/user-reports/00000000-0000-0000-0000-000000000001", + "report", + None, + "supabase", + ), + ], +) +def test_signed_in_user_resources_log_supabase_sources( + monkeypatch, method, path, route_group, db_read, db_write +): + for name in ( + "DB_READ_SIMULATION", + "DB_WRITE_SIMULATION", + "DB_READ_REPORT", + "DB_WRITE_REPORT", + "DB_READ_USER", + "DB_WRITE_USER", + ): + monkeypatch.setenv(name, "invalid-for-native-v2") + + with patch("policyengine_api.migration_logging.logger") as mock_logger: + log_migration_request( + request_id="request", + method=method, + path=path, + status_code=200, + started_at=None, + country_id="us", + route_impl=RouteImplementation.FASTAPI_NATIVE, + ) + + context = mock_logger.log_struct.call_args.args[0]["migration"] + assert "migration_flag_error" not in context + assert context == { + **context, + "route_group": route_group, + "route_impl": "fastapi_native", + "db_read": db_read, + "db_write": db_write, + # Saved-work CRUD never computes, whatever its route group does. + "sim_flow": None, + "sim_compute": None, + } + + def test_native_route_failure_logs_country_and_actual_implementation(): dependencies = NativeRouteDependencies( readiness_probe=lambda: True, @@ -591,3 +664,21 @@ def test_asgi_shell_does_not_add_retired_backend_header_to_fallback(): assert response.status_code == 200 assert "X-PolicyEngine-Backend" not in response.headers + + +def test_compute_routes_in_the_same_groups_keep_their_compute_labels(): + with patch("policyengine_api.migration_logging.logger") as mock_logger: + log_migration_request( + request_id="request", + method="POST", + path="/us/simulation", + status_code=200, + started_at=None, + country_id="us", + route_impl=RouteImplementation.FLASK_FALLBACK, + ) + + context = mock_logger.log_struct.call_args.args[0]["migration"] + assert context["route_group"] == "simulation" + assert context["sim_flow"] == "economy" + assert context["sim_compute"] is not None diff --git a/tests/unit/test_alembic_workflows.py b/tests/unit/test_alembic_workflows.py index 81801d5c4..eecf5d957 100644 --- a/tests/unit/test_alembic_workflows.py +++ b/tests/unit/test_alembic_workflows.py @@ -221,6 +221,7 @@ def test_reusable_v2_integration_check_uses_disposable_databases_and_coverage(): assert "test_mysql_policy_dual_write.py" in workflow assert "test_v2_user_policy_mirroring.py" in workflow assert "test_v1_user_policy_dual_write.py" in workflow + assert "test_v2_user_identity_persistence.py" in workflow assert "test_v2_household_persistence.py" in workflow assert "test_v1_household_dual_write.py" in workflow assert "test_mysql_household_spm_dual_write.py" in workflow @@ -229,7 +230,7 @@ def test_reusable_v2_integration_check_uses_disposable_databases_and_coverage(): assert "test_runtime_cache_redis.py" in workflow assert "uv sync --frozen" in workflow assert workflow.count("coverage run --branch") == 1 - assert workflow.count("coverage run -a --branch") == 5 + assert workflow.count("coverage run -a --branch") == 6 assert "coverage xml -i -o coverage-v2.xml" in workflow assert "codecov/codecov-action@v5" in workflow assert "files: coverage-v2.xml" in workflow diff --git a/tests/unit/test_cloud_run_deploy_scripts.py b/tests/unit/test_cloud_run_deploy_scripts.py index 2153ec036..f23b754bb 100644 --- a/tests/unit/test_cloud_run_deploy_scripts.py +++ b/tests/unit/test_cloud_run_deploy_scripts.py @@ -73,6 +73,14 @@ def _gateway_auth_env() -> dict[str, str]: } +def _public_api_auth_env() -> dict[str, str]: + return { + "PUBLIC_API_AUTH_REQUIRED": "1", + "PUBLIC_API_AUTH_ISSUER": "https://auth.example.test/", + "PUBLIC_API_AUTH_AUDIENCE": "https://api.policyengine.org/", + } + + def _v2_target_env() -> dict[str, str]: return { "V2_SUPABASE_PROJECT_REF": TEST_V2_PROJECT_REF, @@ -126,6 +134,7 @@ def _required_runtime_env() -> dict[str, str]: **_observability_env(), **_v2_target_env(), **_gateway_auth_env(), + **_public_api_auth_env(), } @@ -748,6 +757,7 @@ def test_validate_cloud_run_deploy_env_accepts_direct_mode_from_environment(): ), **_v2_target_env(), **_gateway_auth_env(), + **_public_api_auth_env(), **_observability_env(), ), ) @@ -905,6 +915,7 @@ def test_validate_cloud_run_deploy_env_requires_only_selected_url( ), **_v2_target_env(), **_gateway_auth_env(), + **_public_api_auth_env(), **_observability_env(), ) missing_result = _run_script( @@ -2514,3 +2525,154 @@ def test_push_workflow_never_uses_latest_or_tag_alias_for_cloud_run_traffic(): assert "--to-latest" not in workflow.lower() assert "--to-revisions" in promotion_script assert '"${target_revision}=100"' in promotion_script + + +def test_deploy_cloud_run_candidate_passes_user_auth_settings(): + result = _run_script( + ".github/scripts/deploy_cloud_run_candidate.sh", + _script_env( + **_required_runtime_env(), + CLOUD_RUN_IMAGE_URI="us-central1-docker.pkg.dev/project/repo/api:sha", + CLOUD_RUN_TAG="stage3-test", + ), + ) + + assert result.returncode == 0, result.stderr + assert "PUBLIC_API_AUTH_REQUIRED=1" in result.stdout + assert "PUBLIC_API_AUTH_ISSUER=https://auth.example.test/" in result.stdout + assert "PUBLIC_API_AUTH_AUDIENCE=https://api.policyengine.org/" in result.stdout + assert "AUTH0_MANAGEMENT_DOMAIN" not in result.stdout + + with_management = _run_script( + ".github/scripts/deploy_cloud_run_candidate.sh", + _script_env( + **_required_runtime_env(), + CLOUD_RUN_IMAGE_URI="us-central1-docker.pkg.dev/project/repo/api:sha", + CLOUD_RUN_TAG="stage3-test", + AUTH0_MANAGEMENT_DOMAIN="policyengine.us.auth0.com", + AUTH0_MANAGEMENT_CLIENT_ID="management-client", + AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE=( + "projects/p/secrets/auth0-management/versions/latest" + ), + ), + ) + assert with_management.returncode == 0, with_management.stderr + assert "AUTH0_MANAGEMENT_DOMAIN=policyengine.us.auth0.com" in with_management.stdout + assert ( + "AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE=" + "projects/p/secrets/auth0-management/versions/latest" in with_management.stdout + ) + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"PUBLIC_API_AUTH_REQUIRED": None}, "PUBLIC_API_AUTH_REQUIRED"), + ({"PUBLIC_API_AUTH_REQUIRED": "true"}, "expected 0 or 1"), + ({"PUBLIC_API_AUTH_ISSUER": None}, "PUBLIC_API_AUTH_ISSUER"), + ({"PUBLIC_API_AUTH_AUDIENCE": None}, "PUBLIC_API_AUTH_AUDIENCE"), + ( + {"PUBLIC_API_AUTH_REQUIRED": "0", "PUBLIC_API_AUTH_AUDIENCE": None}, + "or neither", + ), + ({"PUBLIC_API_AUTH_ISSUER": "http://auth.example.test/"}, "HTTPS"), + ({"AUTH0_MANAGEMENT_DOMAIN": "policyengine.us.auth0.com"}, "or none"), + ], +) +def test_validate_cloud_run_deploy_env_rejects_unsafe_user_auth_settings( + changes, message +): + env = _script_env(**_required_runtime_env()) + for name, value in changes.items(): + if value is None: + env.pop(name) + else: + env[name] = value + + result = _run_script(".github/scripts/validate_cloud_run_deploy_env.sh", env) + + assert result.returncode == 1 + assert message in result.stderr + + +def test_validate_cloud_run_deploy_env_keeps_auth0_management_out_of_staging(): + management = { + "AUTH0_MANAGEMENT_DOMAIN": "policyengine.us.auth0.com", + "AUTH0_MANAGEMENT_CLIENT_ID": "management-client", + "AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE": ( + "projects/p/secrets/auth0-management/versions/latest" + ), + } + staging = _script_env(**_staging_runtime_env(), **management) + production = _script_env(**_required_runtime_env(), **management) + + staging_result = _run_script( + ".github/scripts/validate_cloud_run_deploy_env.sh", staging + ) + production_result = _run_script( + ".github/scripts/validate_cloud_run_deploy_env.sh", production + ) + + assert staging_result.returncode == 1 + assert "production-only" in staging_result.stderr + assert production_result.returncode == 0, production_result.stderr + + +def test_validate_cloud_run_deploy_env_accepts_disabled_user_auth(): + env = _script_env(**_required_runtime_env()) + env["PUBLIC_API_AUTH_REQUIRED"] = "0" + env.pop("PUBLIC_API_AUTH_ISSUER") + env.pop("PUBLIC_API_AUTH_AUDIENCE") + + result = _run_script(".github/scripts/validate_cloud_run_deploy_env.sh", env) + + assert result.returncode == 0, result.stderr + + +def test_push_workflow_wires_user_auth_settings_and_staging_test_user(): + workflow = _push_workflow() + staging = _workflow_job_block(workflow, "deploy-cloud-run-staging") + production = _workflow_job_block(workflow, "deploy-cloud-run-candidate") + + for job in (staging, production): + for line in ( + "PUBLIC_API_AUTH_REQUIRED: ${{ vars.PUBLIC_API_AUTH_REQUIRED }}", + "PUBLIC_API_AUTH_ISSUER: ${{ vars.PUBLIC_API_AUTH_ISSUER }}", + "PUBLIC_API_AUTH_AUDIENCE: ${{ vars.PUBLIC_API_AUTH_AUDIENCE }}", + ): + assert line in job + # Management API deletion is production-only: the staging live test + # deletes its own account, which would delete the CI user from Auth0. + for line in ( + "AUTH0_MANAGEMENT_DOMAIN: ${{ vars.AUTH0_MANAGEMENT_DOMAIN }}", + "AUTH0_MANAGEMENT_CLIENT_ID: ${{ vars.AUTH0_MANAGEMENT_CLIENT_ID }}", + "AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE: " + "${{ secrets.AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE }}", + ): + assert line in production + assert "AUTH0_MANAGEMENT" not in staging + + test_step_start = staging.index("- name: Run release tests") + test_step = staging[test_step_start : staging.index("- name: GCP", test_step_start)] + for name in ( + "PUBLIC_API_AUTH_REQUIRED", + "PUBLIC_API_AUTH_ISSUER", + "PUBLIC_API_AUTH_AUDIENCE", + ): + assert f"-u {name}" in test_step + + integration_start = staging.index("- name: Run staging integration tests") + integration = staging[integration_start:] + assert "tests/integration/test_live_v2_policies.py" in integration + for name in ( + "STAGING_AUTH0_TEST_DOMAIN: ${{ vars.STAGING_AUTH0_TEST_DOMAIN }}", + "STAGING_AUTH0_TEST_AUDIENCE: ${{ vars.STAGING_AUTH0_TEST_AUDIENCE }}", + "STAGING_AUTH0_TEST_CLIENT_ID: ${{ secrets.STAGING_AUTH0_TEST_CLIENT_ID }}", + "STAGING_AUTH0_TEST_CLIENT_SECRET: " + "${{ secrets.STAGING_AUTH0_TEST_CLIENT_SECRET }}", + "STAGING_AUTH0_TEST_USERNAME: ${{ secrets.STAGING_AUTH0_TEST_USERNAME }}", + "STAGING_AUTH0_TEST_PASSWORD: ${{ secrets.STAGING_AUTH0_TEST_PASSWORD }}", + "STAGING_AUTH0_TEST_REALM: ${{ vars.STAGING_AUTH0_TEST_REALM }}", + ): + assert name in integration + assert "STAGING_AUTH0_TEST" not in production diff --git a/tests/unit/test_migration_contract_artifacts.py b/tests/unit/test_migration_contract_artifacts.py index a0a623b75..148af7f27 100644 --- a/tests/unit/test_migration_contract_artifacts.py +++ b/tests/unit/test_migration_contract_artifacts.py @@ -1,5 +1,8 @@ +from collections import Counter import json +import pytest + from scripts import export_migration_contracts from scripts.guards import migration_contracts @@ -10,8 +13,8 @@ def test_migration_contract_payload_summarizes_route_contracts(): assert payload["version"] == 1 assert payload["metadata"] == { "route_group_count": 9, - "workflow_count": 13, - "request_count": 50, + "workflow_count": 16, + "request_count": 88, "db_entity_count": 6, "sim_flow_count": 3, } @@ -23,6 +26,9 @@ def test_migration_contract_payload_summarizes_route_contracts(): "household_save_read", "household_resources_v2", "user_household_associations_v2", + "user_simulation_associations_v2", + "user_report_associations_v2", + "user_account_v2", "household_calculate", "region_selection", "metadata_resources_v2_preview", @@ -71,3 +77,70 @@ def test_migration_contract_quality_guard_rejects_duplicate_path_segments( "duplicate route path segment" in violation for violation in migration_contracts.check() ) + + +def _payload_with_user_report_headers(headers: list[str]) -> dict: + payload = export_migration_contracts.build_payload() + workflows = [ + { + **workflow, + "requests": [ + {**request, "headers": headers} for request in workflow["requests"] + ], + } + if workflow["name"] == "user_report_associations_v2" + else workflow + for workflow in payload["workflows"] + ] + return {**payload, "workflows": workflows} + + +@pytest.mark.parametrize("headers", [[], ["Content-Type"]]) +def test_migration_contract_quality_guard_requires_a_user_route_credential( + monkeypatch, headers: list[str] +): + payload = _payload_with_user_report_headers(headers) + monkeypatch.setattr(export_migration_contracts, "build_payload", lambda: payload) + + violations = migration_contracts.check() + + assert any( + "user-owned routes must require the Authorization or " + "PolicyEngine-Unauthenticated-User header" in violation + for violation in violations + ) + + +def test_migration_contract_quality_guard_keys_requests_by_credential( + monkeypatch, +): + payload = _payload_with_user_report_headers(["Authorization"]) + monkeypatch.setattr(export_migration_contracts, "build_payload", lambda: payload) + + violations = migration_contracts.check() + + # The signed-out variants now duplicate the signed-in ones exactly. + assert any( + "duplicate contract request: 'POST /v2/user-reports?country_id=us " + "Authorization'" in violation + for violation in violations + ) + assert not any("must require" in violation for violation in violations) + + +def test_user_contracts_require_a_signed_in_or_signed_out_credential(): + payload = export_migration_contracts.build_payload() + user_requests = [ + request + for workflow in payload["workflows"] + for request in workflow["requests"] + if request["path"].startswith(("/v2/me", "/v2/user-")) + ] + headers_by_request = Counter(tuple(request["headers"]) for request in user_requests) + + assert headers_by_request == { + ("Authorization",): 24, + ("PolicyEngine-Unauthenticated-User",): 23, + ("Authorization", "PolicyEngine-Unauthenticated-User"): 1, + } + assert all("user_id=" not in request["path"] for request in user_requests) diff --git a/tests/unit/test_migration_flags.py b/tests/unit/test_migration_flags.py index 8be835697..bcd90902b 100644 --- a/tests/unit/test_migration_flags.py +++ b/tests/unit/test_migration_flags.py @@ -179,6 +179,11 @@ def test_explicit_migration_context_rejects_invalid_database_sources( "/v2/user-policies/00000000-0000-0000-0000-000000000001", "policy", ), + ("/v2/user-households", "household"), + ("/v2/user-simulations", "simulation"), + ("/v2/user-reports/00000000-0000-0000-0000-000000000001", "report"), + ("/v2/me", "user_profile"), + ("/v2/me/upgrade", "user_profile"), ("/us/policy/1", "policy"), ("/us/policies", "policy"), ("/us/household/1", "household"), diff --git a/tests/unit/test_query_parameters.py b/tests/unit/test_query_parameters.py index e7a64dca0..2bd16698b 100644 --- a/tests/unit/test_query_parameters.py +++ b/tests/unit/test_query_parameters.py @@ -27,6 +27,8 @@ UserHouseholdDetailQuery, UserHouseholdUpdateQuery, UserPolicyCollectionQuery, + UserReportCollectionQuery, + UserSimulationCollectionQuery, parse_multidict_query, parse_query_items, ) @@ -117,22 +119,21 @@ def test_duplicate_scalar_is_rejected_and_explicit_list_is_preserved() -> None: def test_multidict_adapter_preserves_the_canonical_contract() -> None: policy_id = uuid4() - user_id = uuid4() parsed = parse_multidict_query( UserPolicyCollectionQuery, MultiDict( [ ("country_id", "UK"), - ("user_id", str(user_id)), ("policy_id", str(policy_id)), + ("legacy_policy_id", "95762"), ("offset", "2"), ] ), ) assert parsed.country_id == "uk" - assert parsed.user_id == user_id assert parsed.policy_id == policy_id + assert parsed.legacy_policy_id == 95762 assert parsed.offset == 2 @@ -232,7 +233,6 @@ def test_uuid_filter_is_materialized_as_uuid() -> None: def test_household_query_contracts_compose_canonical_fields() -> None: household_id = uuid4() - user_id = uuid4() collection = parse_query_items( HouseholdCollectionQuery, @@ -247,8 +247,8 @@ def test_household_query_contracts_compose_canonical_fields() -> None: UserHouseholdCollectionQuery, [ ("country_id", "US"), - ("user_id", str(user_id)), ("household_id", str(household_id)), + ("legacy_household_id", "123"), ], ) @@ -259,8 +259,8 @@ def test_household_query_contracts_compose_canonical_fields() -> None: "default_year": 2026, } assert associations.country_id == "us" - assert associations.user_id == user_id assert associations.household_id == household_id + assert associations.legacy_household_id == 123 assert associations.offset == 0 assert associations.limit == 100 @@ -288,17 +288,33 @@ def test_household_default_year_is_bounded(default_year: str) -> None: assert raised.value.errors()[0]["loc"] == ("default_year",) -def test_user_household_collection_requires_a_uuid_user() -> None: - with pytest.raises(ValidationError) as missing: - parse_query_items(UserHouseholdCollectionQuery, [("country_id", "us")]) - assert missing.value.errors()[0]["loc"] == ("user_id",) +@pytest.mark.parametrize( + ("model_type", "field_name"), + [ + (UserPolicyCollectionQuery, "legacy_policy_id"), + (UserHouseholdCollectionQuery, "legacy_household_id"), + (UserSimulationCollectionQuery, "legacy_simulation_id"), + (UserReportCollectionQuery, "legacy_report_id"), + ], +) +def test_user_association_collections_take_the_owner_from_the_session( + model_type, field_name: str +) -> None: + parsed = parse_query_items(model_type, [("country_id", "us")]) + assert getattr(parsed, field_name) is None + assert (parsed.offset, parsed.limit) == (0, 100) - with pytest.raises(ValidationError) as malformed: + with pytest.raises(ValidationError) as user_supplied: parse_query_items( - UserHouseholdCollectionQuery, - [("country_id", "us"), ("user_id", "not-a-uuid")], + model_type, + [("country_id", "us"), ("user_id", str(uuid4()))], ) - assert malformed.value.errors()[0]["loc"] == ("user_id",) + assert user_supplied.value.errors()[0]["loc"] == ("user_id",) + + for invalid in ("0", "-1", "not-an-id", str(2**63)): + with pytest.raises(ValidationError) as malformed: + parse_query_items(model_type, [("country_id", "us"), (field_name, invalid)]) + assert malformed.value.errors()[0]["loc"] == (field_name,) @pytest.mark.parametrize( diff --git a/tests/unit/test_readiness.py b/tests/unit/test_readiness.py index b0fe57bad..7656953ff 100644 --- a/tests/unit/test_readiness.py +++ b/tests/unit/test_readiness.py @@ -155,3 +155,47 @@ def test_readiness_accepts_a_certified_measurement_configuration(monkeypatch): } } assert _ready_with_bundle(monkeypatch, bundle, implements_spm=True) is True + + +@pytest.mark.parametrize( + "environment", + [ + {"PUBLIC_API_AUTH_REQUIRED": "1"}, + {"PUBLIC_API_AUTH_REQUIRED": "true"}, + {"PUBLIC_API_AUTH_ISSUER": "https://auth.example.test/"}, + {"AUTH0_MANAGEMENT_DOMAIN": "policyengine.us.auth0.com"}, + ], +) +def test_inconsistent_user_auth_settings_fail_readiness(monkeypatch, environment): + for name in ( + "PUBLIC_API_AUTH_REQUIRED", + "PUBLIC_API_AUTH_ISSUER", + "PUBLIC_API_AUTH_AUDIENCE", + "AUTH0_MANAGEMENT_DOMAIN", + "AUTH0_MANAGEMENT_CLIENT_ID", + "AUTH0_MANAGEMENT_CLIENT_SECRET", + "AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE", + ): + monkeypatch.delenv(name, raising=False) + for name, value in environment.items(): + monkeypatch.setenv(name, value) + + with patch( + "policyengine_api.data.v2.settings.load_v2_runtime_database_settings", + return_value=object(), + ): + readiness.mark_ready() + assert readiness.is_ready() is False + + +def test_required_user_auth_settings_pass_readiness(monkeypatch): + monkeypatch.setenv("PUBLIC_API_AUTH_REQUIRED", "1") + monkeypatch.setenv("PUBLIC_API_AUTH_ISSUER", "https://auth.example.test") + monkeypatch.setenv("PUBLIC_API_AUTH_AUDIENCE", "https://api.policyengine.org/") + + with patch( + "policyengine_api.data.v2.settings.load_v2_runtime_database_settings", + return_value=object(), + ): + readiness.mark_ready() + assert readiness.is_ready() is True diff --git a/tests/unit/v2/test_alembic_v2.py b/tests/unit/v2/test_alembic_v2.py index 0bc4eb7bb..a6e166ab5 100644 --- a/tests/unit/v2/test_alembic_v2.py +++ b/tests/unit/v2/test_alembic_v2.py @@ -46,6 +46,7 @@ "legacy_user_policy_mappings", "stage12_evaluation_reports", "stage12_evaluation_simulations", + "user_identities", } ) POOLER_URL = ( @@ -234,6 +235,7 @@ def test_v2_revision_chain_has_generated_policy_and_user_identity_changes() -> N assert script.get_heads() == [V2_ALEMBIC_HEAD_REVISION] assert [revision.revision for revision in script.walk_revisions()] == [ V2_ALEMBIC_HEAD_REVISION, + "91af4fc8d8c1", "60d6518b6a98", "439303be14fe", "724b1b11a33e", @@ -487,6 +489,82 @@ def test_stage_12_observability_revision_is_generated_and_reversible() -> None: assert "op.bulk_insert(" not in revision +def test_stage_12a_user_identity_and_association_revision_is_generated() -> None: + revision = ( + REPO + / "migrations/v2/versions/fbb7488ebe8a_link_signed_in_users_to_v1_anchored_.py" + ).read_text(encoding="utf-8") + + assert V2_ALEMBIC_HEAD_REVISION == "fbb7488ebe8a" + assert ( + "Generation: uv run alembic -c alembic-v2.ini revision --autogenerate" + in revision + ) + assert 'down_revision: Union[str, None] = "91af4fc8d8c1"' in revision + # One generated revision covers both the identity table and the reshape. + assert revision.count("op.create_table(") == 1 + assert 'op.create_table(\n "user_identities"' in revision + assert '"uq_user_identities_issuer_subject"' in revision + assert 'op.drop_table("user_identities")' in revision + for table_name, reference in ( + ("user_policies", "policy"), + ("user_household_associations", "household"), + ("user_simulation_associations", "simulation"), + ("user_report_associations", "report"), + ): + assert f'"legacy_{reference}_id"' in revision + # The browser-local import keys were removed before release. + assert "client_ref" not in revision + for check_name in ( + "ck_user_policies_policy_ref", + "ck_user_policies_baseline", + "ck_user_household_associations_household_ref", + "ck_user_simulation_associations_simulation_ref", + "ck_user_simulation_associations_country", + "ck_user_report_associations_report_ref", + "ck_user_report_associations_country", + "ck_user_report_associations_budget_window_years", + "ck_users_user_type", + "ck_users_standard_primary_country", + ): + assert check_name in revision + # Cross-service foreign keys to v2 simulations and reports are removed. + assert "fk_user_simulation_associations_simulation_id_simulations" in revision + assert "fk_user_report_associations_report_id_reports" in revision + assert "fk_user_report_associations_user_policy_id_user_policies" in revision + assert ( + "fk_user_report_associations_baseline_user_policy_id_user_policies" in revision + ) + assert 'ondelete="SET NULL"' in revision + assert "Post-generation" not in revision + assert "op.execute(" not in revision + assert "op.bulk_insert(" not in revision + # The previous Public API revision still maps the retired country and label + # columns, so the upgrade relaxes them instead of dropping anything. + upgrade = revision[ + revision.index("def upgrade()") : revision.index("def downgrade()") + ] + assert "op.drop_column(" not in upgrade + assert "op.drop_table(" not in upgrade + for table_name in ("user_simulation_associations", "user_report_associations"): + assert ( + f'"{table_name}",\n "country",\n' + " existing_type=sa.VARCHAR(length=16),\n" + " nullable=True," + ) in upgrade + # Users gain a type whose server default keeps the previous revision's + # inserts standard, and only unauthenticated users omit a country. + assert ( + '"user_type",\n' + " sqlmodel.sql.sqltypes.AutoString(length=16),\n" + ' server_default="standard",\n' + " nullable=False," + ) in upgrade + assert ( + '"users", "primary_country", existing_type=sa.VARCHAR(length=2), nullable=True' + ) in upgrade + + def test_alembic_rejects_unknown_missing_and_divergent_history(tmp_path: Path) -> None: original = REPO / "migrations/v2" missing = tmp_path / "missing" diff --git a/tests/unit/v2/test_data_crud_boundaries.py b/tests/unit/v2/test_data_crud_boundaries.py index f7d03f0f6..5f0dafc94 100644 --- a/tests/unit/v2/test_data_crud_boundaries.py +++ b/tests/unit/v2/test_data_crud_boundaries.py @@ -48,6 +48,15 @@ def _has_database_query_dependency(path: Path) -> bool: ) +def _constructs_sql(path: Path) -> bool: + """Whether a module builds or runs SQL itself instead of calling a connector.""" + calls = _called_names(path) + imports = _imported_modules(path) + return not calls.isdisjoint( + {"select", "insert", "update", "delete", "exec", "text"} + ) or any(module.startswith("sqlalchemy") for module in imports) + + def _discover(*patterns: str) -> list[str]: """Modules under SERVICES_ROOT matching any pattern, as sorted relative paths. @@ -124,3 +133,26 @@ def test_database_session_modules_do_not_construct_queries( ) -> None: calls = _called_names(SERVICES_ROOT / relative_path) assert calls.isdisjoint({"select", "insert", "update", "delete", "exec"}) + + +@pytest.mark.parametrize( + "relative_path", + ( + "identity/services.py", + "legacy_refs/services.py", + "user_accounts/services.py", + "user_households/services.py", + "user_policies/services.py", + "user_reports/services.py", + "user_simulations/services.py", + ), +) +def test_user_services_construct_no_sql(relative_path: str) -> None: + assert not _constructs_sql(SERVICES_ROOT / relative_path) + + +def test_rule_proof_user_services_no_sql() -> None: + """Proof for rule api.v2.user-services-no-sql (rules/registry.toml).""" + fixtures = PROJECT_ROOT / "rules/fixtures/api.v2.user-services-no-sql" + assert _constructs_sql(fixtures / "bad_services.py") + assert not _constructs_sql(fixtures / "good_services.py") diff --git a/tests/unit/v2/test_import_side_effects.py b/tests/unit/v2/test_import_side_effects.py index 5de6c3efb..aed196b7a 100644 --- a/tests/unit/v2/test_import_side_effects.py +++ b/tests/unit/v2/test_import_side_effects.py @@ -70,10 +70,11 @@ def reject_ddl(*args, **kwargs): import sys after = set(pathlib.Path.cwd().iterdir()) assert before == after -assert len(V2_METADATA.tables) == 38 +assert len(V2_METADATA.tables) == 39 assert "policyengine_api.data.v2.catalog.initialization" not in sys.modules assert "policyengine_api.data.v2.policy_migration_qualification" not in sys.modules assert "policyengine_api.data.v2.household_migration_qualification" not in sys.modules +assert "policyengine_api.data.v2.user_association_migration_qualification" not in sys.modules """ result = subprocess.run( diff --git a/tests/unit/v2/test_legacy_reference_service.py b/tests/unit/v2/test_legacy_reference_service.py new file mode 100644 index 000000000..d46cdbb60 --- /dev/null +++ b/tests/unit/v2/test_legacy_reference_service.py @@ -0,0 +1,195 @@ +"""Batched, read-only v1 existence checks through the v1 mapped models.""" + +from __future__ import annotations + +import pytest +import sqlalchemy as sa +from sqlalchemy.orm import Session, sessionmaker + +from policyengine_api.data.v1_models import ( + Household, + Policy, + ReportOutput, + Simulation, +) +from policyengine_api.services.v2.legacy_refs.database_connectors import reads +from policyengine_api.services.v2.legacy_refs.database_session import ( + LegacyReferenceDatabaseSession, +) +from policyengine_api.services.v2.legacy_refs.services import ( + V1LegacyReferenceService, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind +from policyengine_api.services.v2.legacy_refs.validators import ( + LegacyReferenceNotFoundError, +) + + +@pytest.fixture +def v1_sessions(): + engine = sa.create_engine("sqlite://") + # SQLite cannot autoincrement inside v1's composite policy key, so the + # physical test tables come from detached copies without that option. + sqlite_metadata = sa.MetaData() + for model in (Policy, Household, Simulation, ReportOutput): + copy = model.__table__.to_metadata(sqlite_metadata) + copy.c.id.autoincrement = False + sqlite_metadata.create_all(engine) + sessions = sessionmaker(engine, class_=Session, expire_on_commit=False) + with sessions.begin() as session: + session.add_all( + [ + Policy( + id=1, + country_id="us", + api_version="1", + policy_json={}, + policy_hash="a", + ), + Policy( + id=2, + country_id="uk", + api_version="1", + policy_json={}, + policy_hash="b", + ), + Household( + id=10, + country_id="us", + api_version="1", + household_json={}, + household_hash="h", + ), + Simulation( + id=613, + country_id="us", + api_version="1", + population_id="us", + population_type="geography", + policy_id=1, + ), + ReportOutput( + id=615, + country_id="us", + simulation_1_id=613, + api_version="1", + ), + ] + ) + yield sessions + engine.dispose() + + +def test_existing_ids_are_country_scoped_and_batched(v1_sessions, monkeypatch) -> None: + monkeypatch.setattr(reads, "READ_BATCH_SIZE", 1) + statements: list[str] = [] + engine = v1_sessions.kw["bind"] + + def record(_conn, _cursor, statement, _parameters, _context, _many) -> None: + statements.append(statement) + + sa.event.listen(engine, "before_cursor_execute", record) + service = V1LegacyReferenceService(LegacyReferenceDatabaseSession(v1_sessions)) + + existing = service.existing_ids( + country_id="us", + requested={ + LegacyRecordKind.POLICY: [1, 2, 3], + LegacyRecordKind.HOUSEHOLD: [10], + LegacyRecordKind.SIMULATION: [613, 613], + LegacyRecordKind.REPORT: [], + }, + ) + + assert existing == { + LegacyRecordKind.POLICY: frozenset({1}), + LegacyRecordKind.HOUSEHOLD: frozenset({10}), + LegacyRecordKind.SIMULATION: frozenset({613}), + LegacyRecordKind.REPORT: frozenset(), + } + selects = [statement for statement in statements if "SELECT" in statement] + assert len(selects) == 5 + assert all("country_id" in statement for statement in selects) + assert not any( + keyword in statement + for statement in statements + for keyword in ("INSERT", "UPDATE", "DELETE") + ) + + +def test_missing_references_raise_the_422_error(v1_sessions) -> None: + service = V1LegacyReferenceService(LegacyReferenceDatabaseSession(v1_sessions)) + + service.require_existing( + country_id="us", requested={LegacyRecordKind.REPORT: [615]} + ) + with pytest.raises(LegacyReferenceNotFoundError, match="v1 report 616"): + service.require_existing( + country_id="us", requested={LegacyRecordKind.REPORT: [615, 616]} + ) + with pytest.raises(LegacyReferenceNotFoundError, match="country uk"): + service.require_existing( + country_id="uk", requested={LegacyRecordKind.POLICY: [1]} + ) + + +def test_empty_requests_open_no_v1_session() -> None: + def reject_session(): + raise AssertionError("no v1 session should be opened") + + service = V1LegacyReferenceService( + LegacyReferenceDatabaseSession(reject_session) # type: ignore[arg-type] + ) + + assert service.existing_ids( + country_id="us", requested={LegacyRecordKind.POLICY: []} + ) == {LegacyRecordKind.POLICY: frozenset()} + + +def test_deferred_service_builds_the_v1_service_once_on_first_check() -> None: + from policyengine_api.services.v2.legacy_refs.services import ( + DeferredLegacyReferenceService, + ) + + builds: list[int] = [] + + class Built: + def existing_ids(self, *, country_id, requested): + return {kind: frozenset(ids) for kind, ids in requested.items()} + + def require_existing(self, *, country_id, requested): + return None + + def build(): + builds.append(1) + return Built() + + deferred = DeferredLegacyReferenceService(build) + assert builds == [] + + deferred.require_existing( + country_id="us", requested={LegacyRecordKind.POLICY: (1,)} + ) + assert deferred.existing_ids( + country_id="us", requested={LegacyRecordKind.POLICY: (2,)} + ) == {LegacyRecordKind.POLICY: frozenset({2})} + assert builds == [1] + + +def test_default_reference_checker_does_not_touch_the_v1_database( + monkeypatch, +) -> None: + from policyengine_api.data import orm + from policyengine_api.fastapi_routes import dependencies + + def unavailable(): + raise RuntimeError("v1 database is not configured") + + monkeypatch.setattr(orm, "get_v1_session_factory", unavailable) + + checker = dependencies._default_legacy_reference_checker() + + with pytest.raises(RuntimeError, match="not configured"): + checker.require_existing( + country_id="us", requested={LegacyRecordKind.POLICY: (1,)} + ) diff --git a/tests/unit/v2/test_me_routes.py b/tests/unit/v2/test_me_routes.py new file mode 100644 index 000000000..b903b9329 --- /dev/null +++ b/tests/unit/v2/test_me_routes.py @@ -0,0 +1,443 @@ +"""Native FastAPI contract tests for the account resource.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +from policyengine_api.services.v2.identity.types import AccountConversionOutcome +from policyengine_api.services.v2.identity.validators import ( + StandardUserRequiresSignInError, +) +from policyengine_api.services.v2.user_accounts.types import ( + AccountDeletion, + AccountExport, + AccountRead, + AccountUpgrade, +) +from policyengine_api.services.v2.user_accounts.validators import ( + AccountDeletionUnavailableError, + AccountEditRequiresSignInError, + AccountIdentityDeletionError, + AccountNotFoundError, +) +from policyengine_api.services.v2.user_policies.types import UserPolicyRead +from policyengine_api.services.v2.user_reports.types import UserReportRead +from tests.unit.v2.user_route_support import ( + BROWSER_USER_ID, + CREATED_AT, + OWNER_ID, + PRINCIPAL, + UNAUTHENTICATED_PRINCIPAL, + RecordingService, + signed_out_browser, + user_route_client, +) + + +RECORD_ID = UUID("00000000-0000-0000-0000-000000000065") +UNAUTHENTICATED_HEADERS = {"PolicyEngine-Unauthenticated-User": str(BROWSER_USER_ID)} + + +def _account(**changes) -> AccountRead: + values = { + "id": OWNER_ID, + "user_type": "standard", + "primary_country": "us", + "first_name": None, + "last_name": None, + "email": None, + "created_at": CREATED_AT, + } + values.update(changes) + return AccountRead(**values) + + +def _result(name: str, kwargs: dict): + if name == "export_account": + return AccountExport( + user=_account(), + policies=(), + households=(), + simulations=(), + reports=(), + ) + if name == "upgrade_account": + return AccountUpgrade( + account=_account(), outcome=AccountConversionOutcome.CONVERTED + ) + if name == "delete_account": + return AccountDeletion(user_id=OWNER_ID, auth0_subjects=()) + if name == "update_account": + return _account(first_name="Ada") + return _account() + + +def _client(service: RecordingService | None = None, **client_options): + service = service or RecordingService(_result) + client, _flask_calls = user_route_client( + v2_account_service_factory=lambda: service, **client_options + ) + return client, service + + +def test_get_me_provisions_with_the_query_country() -> None: + client, service = _client() + + response = client.get("/v2/me?country_id=UK") + + assert response.status_code == 200 + assert response.json() == { + "status": "ok", + "message": None, + "result": { + "item": { + "id": str(OWNER_ID), + "user_type": "standard", + "primary_country": "us", + "first_name": None, + "last_name": None, + "email": None, + "created_at": "2026-01-01T00:00:00Z", + } + }, + } + assert service.calls == [ + ("get_account", {"principal": PRINCIPAL, "country_id": "uk"}) + ] + + +def test_patch_me_accepts_names_and_country_only() -> None: + client, service = _client() + + accepted = client.patch( + "/v2/me?country_id=us", + json={"first_name": "Ada", "last_name": None, "primary_country": "uk"}, + ) + rejected = [ + client.patch("/v2/me?country_id=us", json=body) + for body in ( + {}, + {"email": "ada@example.com"}, + {"primary_country": None}, + {"primary_country": "ca"}, + {"id": str(OWNER_ID)}, + ) + ] + + assert accepted.status_code == 200 + assert accepted.json()["result"]["item"]["first_name"] == "Ada" + assert service.calls[0][1]["account_input"].primary_country == "uk" + assert [response.status_code for response in rejected] == [422] * 5 + assert len(service.calls) == 1 + + +def test_export_and_delete_me() -> None: + client, service = _client() + + exported = client.get("/v2/me/export?country_id=us") + deleted = client.delete("/v2/me?country_id=us") + + assert exported.status_code == 200 + assert exported.json()["result"] == { + "user": exported.json()["result"]["user"], + "policies": [], + "households": [], + "simulations": [], + "reports": [], + } + assert deleted.status_code == 204 + assert deleted.content == b"" + assert service.calls[1] == ("delete_account", {"principal": PRINCIPAL}) + + +def test_failed_auth0_deletion_reports_a_retryable_upstream_error() -> None: + service = RecordingService(_result) + service.error = AccountIdentityDeletionError("retry the request") + client, _service = _client(service) + + response = client.delete("/v2/me?country_id=us") + + assert response.status_code == 502 + assert response.json() == {"status": "error", "message": "retry the request"} + + +def test_unavailable_auth0_deletion_reports_that_nothing_was_deleted() -> None: + service = RecordingService(_result) + service.error = AccountDeletionUnavailableError( + "Account deletion is temporarily unavailable; nothing was deleted" + ) + client, _service = _client(service) + + response = client.delete("/v2/me?country_id=us") + + assert response.status_code == 503 + assert "nothing was deleted" in response.json()["message"] + + +def test_export_serializes_stored_rows_outside_current_input_bounds() -> None: + long_text = "x" * 20_000 + odd_policy = UserPolicyRead( + id=RECORD_ID, + country_id="us", + user_id=OWNER_ID, + policy_id=None, + legacy_policy_id=1, + name=None, + description=long_text, + baseline="current-law", + provenance={"source": "a-source-added-later", "ref": long_text, "extra": 1}, + created_at=CREATED_AT, + updated_at=CREATED_AT, + ) + odd_report = UserReportRead( + id=RECORD_ID, + country_id="us", + user_id=OWNER_ID, + report_id=None, + legacy_report_id=615, + name=None, + description=None, + user_policy_id=None, + baseline_user_policy_id=None, + source_note=long_text, + provisions=[ + { + "path": long_text, + "breadcrumb": "b", + "baseline_value": 1, + "value": 2, + "retired_key": True, + } + ], + validation={"matched_at": long_text, "map_model_version": "1"}, + budget_window_years=None, + last_run_at=None, + created_at=CREATED_AT, + updated_at=CREATED_AT, + ) + + def result(name: str, kwargs: dict): + if name == "export_account": + return AccountExport( + user=_account(), + policies=(odd_policy,), + households=(), + simulations=(), + reports=(odd_report,), + ) + return _result(name, kwargs) + + client, _service = _client(RecordingService(result)) + + exported = client.get("/v2/me/export?country_id=us") + + assert exported.status_code == 200 + body = exported.json()["result"] + assert body["policies"][0]["provenance"] == { + "source": "a-source-added-later", + "ref": long_text, + "confidence": None, + } + assert body["reports"][0]["provisions"][0]["path"] == long_text + assert "retired_key" not in body["reports"][0]["provisions"][0] + + +def test_unauthenticated_principals_reach_every_account_operation() -> None: + client, service = _client(require_user=signed_out_browser) + + read = client.get("/v2/me?country_id=us") + exported = client.get("/v2/me/export?country_id=us") + deleted = client.delete("/v2/me?country_id=us") + + assert [read.status_code, exported.status_code, deleted.status_code] == [ + 200, + 200, + 204, + ] + assert [(name, kwargs["principal"]) for name, kwargs in service.calls] == [ + ("get_account", UNAUTHENTICATED_PRINCIPAL), + ("export_account", UNAUTHENTICATED_PRINCIPAL), + ("delete_account", UNAUTHENTICATED_PRINCIPAL), + ] + + +def test_unauthenticated_account_items_have_no_primary_country() -> None: + def result(name: str, kwargs: dict): + return _account( + id=BROWSER_USER_ID, user_type="unauthenticated", primary_country=None + ) + + client, _service = _client( + RecordingService(result), require_user=signed_out_browser + ) + + response = client.get("/v2/me?country_id=us") + + assert response.status_code == 200 + item = response.json()["result"]["item"] + assert (item["id"], item["user_type"], item["primary_country"]) == ( + str(BROWSER_USER_ID), + "unauthenticated", + None, + ) + + +def test_absent_unauthenticated_users_read_as_not_found_and_export_empty() -> None: + def result(name: str, kwargs: dict): + if name == "get_account": + raise AccountNotFoundError("The account was not found") + return AccountExport( + user=None, policies=(), households=(), simulations=(), reports=() + ) + + client, _service = _client( + RecordingService(result), require_user=signed_out_browser + ) + + read = client.get("/v2/me?country_id=us") + exported = client.get("/v2/me/export?country_id=us") + + assert read.status_code == 404 + assert str(BROWSER_USER_ID) not in read.text + assert exported.status_code == 200 + assert exported.json()["result"] == { + "user": None, + "policies": [], + "households": [], + "simulations": [], + "reports": [], + } + + +def test_unauthenticated_account_edits_are_forbidden() -> None: + service = RecordingService(_result) + service.error = AccountEditRequiresSignInError( + "Sign in to edit your account details." + ) + client, _service = _client(service, require_user=signed_out_browser) + + response = client.patch("/v2/me?country_id=us", json={"first_name": "Ada"}) + + assert response.status_code == 403 + assert response.json() == { + "status": "error", + "message": "Sign in to edit your account details.", + } + + +def test_a_credential_naming_a_signed_in_account_asks_the_browser_to_sign_in() -> None: + service = RecordingService(_result) + service.error = StandardUserRequiresSignInError("sign in to continue") + client, _service = _client(service, require_user=signed_out_browser) + + response = client.get("/v2/me?country_id=us") + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert response.json() == {"status": "error", "message": "sign in to continue"} + + +@pytest.mark.parametrize( + ("outcome", "expected"), + [ + (AccountConversionOutcome.CONVERTED, "converted"), + (AccountConversionOutcome.MERGED, "merged"), + (AccountConversionOutcome.NONE, "none"), + ], +) +def test_upgrade_returns_the_signed_in_account_and_outcome( + outcome: AccountConversionOutcome, expected: str +) -> None: + def result(name: str, kwargs: dict): + return AccountUpgrade(account=_account(), outcome=outcome) + + client, service = _client(RecordingService(result)) + + response = client.post( + "/v2/me/upgrade?country_id=UK", headers=UNAUTHENTICATED_HEADERS + ) + + assert response.status_code == 200 + assert response.json() == { + "status": "ok", + "message": None, + "result": { + "item": { + "id": str(OWNER_ID), + "user_type": "standard", + "primary_country": "us", + "first_name": None, + "last_name": None, + "email": None, + "created_at": "2026-01-01T00:00:00Z", + }, + "outcome": expected, + }, + } + assert service.calls == [ + ( + "upgrade_account", + { + "principal": PRINCIPAL, + "unauthenticated_user_id": BROWSER_USER_ID, + "country_id": "uk", + }, + ) + ] + + +@pytest.mark.parametrize( + "headers", + [ + {}, + {"PolicyEngine-Unauthenticated-User": "not-a-uuid"}, + {"PolicyEngine-Unauthenticated-User": str(BROWSER_USER_ID).upper()}, + {"PolicyEngine-Unauthenticated-User": BROWSER_USER_ID.hex}, + {"PolicyEngine-Unauthenticated-User": f"{{{BROWSER_USER_ID}}}"}, + ], +) +def test_upgrade_requires_a_canonical_unauthenticated_user_header( + headers: dict[str, str], +) -> None: + client, service = _client() + + response = client.post("/v2/me/upgrade?country_id=us", headers=headers) + + assert response.status_code == 422 + assert response.json() == { + "status": "error", + "message": "Invalid API v2 request", + } + assert service.calls == [] + + +def test_upgrade_requires_a_bearer_access_token() -> None: + client, service = _client(require_user=signed_out_browser) + + response = client.post( + "/v2/me/upgrade?country_id=us", headers=UNAUTHENTICATED_HEADERS + ) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert service.calls == [] + + +def test_upgrade_authentication_precedes_query_validation() -> None: + client, service = _client(require_user=signed_out_browser) + + response = client.post("/v2/me/upgrade?country_id=zz") + + assert response.status_code == 401 + assert service.calls == [] + + +def test_import_route_is_removed() -> None: + client, service = _client() + + response = client.post("/v2/me/imports?country_id=us", json={"policies": []}) + + assert response.status_code == 405 + assert service.calls == [] diff --git a/tests/unit/v2/test_metadata_deployment.py b/tests/unit/v2/test_metadata_deployment.py index c4619c341..175440c2e 100644 --- a/tests/unit/v2/test_metadata_deployment.py +++ b/tests/unit/v2/test_metadata_deployment.py @@ -92,6 +92,20 @@ def test_household_data_qualification_precedes_schema_upgrade() -> None: assert isolation < policy_qualification < household_qualification < upgrade +def test_user_association_qualification_precedes_schema_upgrade() -> None: + workflow = _read(".github/workflows/seed-v2-database.yml") + + household_qualification = workflow.index( + "scripts/qualify_v2_household_migration.py" + ) + user_association_qualification = workflow.index( + "scripts/qualify_v2_user_association_migration.py" + ) + upgrade = workflow.index("migrate_v2_metadata_schema.sh") + + assert household_qualification < user_association_qualification < upgrade + + def test_seeding_success_is_required_before_candidate_creation() -> None: workflow = _read(".github/workflows/push.yml") staging_seed = _job(workflow, "seed-v2-staging-database") diff --git a/tests/unit/v2/test_metadata_routes.py b/tests/unit/v2/test_metadata_routes.py index 480d4a0c4..fe29ef66e 100644 --- a/tests/unit/v2/test_metadata_routes.py +++ b/tests/unit/v2/test_metadata_routes.py @@ -546,6 +546,13 @@ def test_openapi_references_explicit_resource_response_schemas() -> None: "/v2/user-households/{association_id}", "/v2/user-policies", "/v2/user-policies/{association_id}", + "/v2/user-simulations", + "/v2/user-simulations/{association_id}", + "/v2/user-reports", + "/v2/user-reports/{association_id}", + "/v2/me", + "/v2/me/export", + "/v2/me/upgrade", } assert set(schema["paths"]) == metadata_paths | native_paths assert "V2ErrorResponse" in schema["components"]["schemas"] diff --git a/tests/unit/v2/test_model_persistence.py b/tests/unit/v2/test_model_persistence.py index 2a4cc854e..fbbc17078 100644 --- a/tests/unit/v2/test_model_persistence.py +++ b/tests/unit/v2/test_model_persistence.py @@ -126,6 +126,38 @@ def test_user_primary_country_can_change_between_us_and_uk_only() -> None: engine.dispose() +def test_only_unauthenticated_users_may_omit_a_primary_country() -> None: + engine = create_engine("sqlite://") + V2_METADATA.create_all(engine) + + with Session(engine) as session: + session.add(User(user_type="unauthenticated")) + session.commit() + for invalid in ( + User(), + User(user_type="standard"), + User(user_type="guest", primary_country="us"), + ): + with Session(engine) as session: + session.add(invalid) + with pytest.raises(IntegrityError): + session.commit() + with engine.begin() as connection: + # Writers that predate user_type rely on its server default. + connection.execute( + sa.text( + "INSERT INTO users (id, created_at, primary_country) " + "VALUES ('00000000000000000000000000000001', CURRENT_TIMESTAMP, 'us')" + ) + ) + stored_types = connection.execute( + sa.text("SELECT user_type FROM users ORDER BY user_type") + ).scalars() + assert list(stored_types) == ["standard", "unauthenticated"] + + engine.dispose() + + def test_canonical_parameter_values_are_unique_by_parameter_and_start_date() -> None: engine = create_engine("sqlite://") V2_METADATA.create_all(engine) diff --git a/tests/unit/v2/test_models.py b/tests/unit/v2/test_models.py index bf9b3e2f4..70ab22faf 100644 --- a/tests/unit/v2/test_models.py +++ b/tests/unit/v2/test_models.py @@ -21,9 +21,11 @@ TaxBenefitModelVersion, User, UserHouseholdAssociation, + UserIdentity, UserPolicy, UserReportAssociation, UserSimulationAssociation, + UserType, V2_METADATA, ) @@ -48,6 +50,7 @@ def test_domain_models_are_grouped_into_topic_scoped_modules() -> None: expected_modules = { User: "users", + UserIdentity: "users", Policy: "policies", Dynamic: "policies", Household: "households", @@ -338,19 +341,41 @@ def test_report_definition_and_run_columns_are_separated() -> None: assert report_run.c.idempotency_key.type.as_uuid -def test_user_primary_country_is_required_and_limited_to_supported_values() -> None: +def test_standard_users_require_a_supported_primary_country() -> None: users = V2_METADATA.tables["users"] - assert not users.c.primary_country.nullable + # Nullable only for unauthenticated users; a check requires it otherwise. + assert users.c.primary_country.nullable assert users.c.first_name.nullable assert users.c.last_name.nullable assert users.c.email.nullable assert users.c.primary_country.type.length == 2 - assert "ck_users_primary_country" in { - constraint.name for constraint in users.constraints + checks = { + constraint.name: str(constraint.sqltext) + for constraint in users.constraints + if isinstance(constraint, sa.CheckConstraint) + } + assert checks == { + "ck_users_primary_country": "primary_country IN ('us', 'uk')", + "ck_users_user_type": "user_type IN ('unauthenticated', 'standard')", + "ck_users_standard_primary_country": ( + "user_type = 'unauthenticated' OR primary_country IS NOT NULL" + ), } +def test_user_type_defaults_to_standard_for_every_writer() -> None: + users = V2_METADATA.tables["users"] + + assert not users.c.user_type.nullable + assert users.c.user_type.type.length == 16 + # The server default covers inserts by Public API revisions that predate + # the column; the Python default covers this revision's ORM inserts. + assert users.c.user_type.server_default.arg == "standard" + assert User().user_type == "standard" + assert [member.value for member in UserType] == ["unauthenticated", "standard"] + + def test_regions_have_one_same_model_version_default_logical_dataset() -> None: regions = V2_METADATA.tables["regions"] datasets = V2_METADATA.tables["datasets"] @@ -520,3 +545,94 @@ def test_complete_metadata_compiles_for_postgres_without_mutation() -> None: assert len(statements) == len(V2_METADATA.tables) assert all("CREATE TABLE" in statement for statement in statements) + + +def test_user_identity_maps_one_issuer_subject_to_one_user() -> None: + identities = V2_METADATA.tables["user_identities"] + + unique_columns = { + constraint.name: tuple(column.name for column in constraint.columns) + for constraint in identities.constraints + if isinstance(constraint, sa.UniqueConstraint) + } + assert unique_columns == { + "uq_user_identities_issuer_subject": ("issuer", "subject") + } + assert isinstance(identities.c.issuer.type, sa.Text) + assert isinstance(identities.c.subject.type, sa.Text) + assert identities.c.email.nullable + assert identities.c.email.type.length == 320 + assert not identities.c.last_seen_at.nullable + user_foreign_key = next(iter(identities.c.user_id.foreign_keys)) + assert user_foreign_key.target_fullname == "users.id" + assert user_foreign_key.ondelete == "CASCADE" + assert "ix_user_identities_user_id" in {index.name for index in identities.indexes} + assert sa.inspect(User).relationships["identities"].back_populates == "user" + + +def test_associations_are_v1_anchored_without_client_refs() -> None: + for table_name, reference in ( + ("user_policies", "policy"), + ("user_household_associations", "household"), + ("user_simulation_associations", "simulation"), + ("user_report_associations", "report"), + ): + table = V2_METADATA.tables[table_name] + assert table.c[f"{reference}_id"].nullable + assert table.c[f"legacy_{reference}_id"].nullable + assert isinstance(table.c[f"legacy_{reference}_id"].type, sa.BigInteger) + assert "client_ref" not in table.c + assert not table.c.country_id.nullable + assert table.c.country_id.type.length == 2 + constraints = {constraint.name: constraint for constraint in table.constraints} + reference_check = constraints[f"ck_{table_name}_{reference}_ref"] + assert str(reference_check.sqltext) == ( + f"{reference}_id IS NOT NULL OR legacy_{reference}_id IS NOT NULL" + ) + # No uniqueness involves the owner, so a merge at sign-in can move + # any association to another user unchanged. + assert { + tuple(column.name for column in constraint.columns) + for constraint in table.constraints + if isinstance(constraint, sa.UniqueConstraint) + } == {("id", "country_id")} + + +def test_simulation_and_report_associations_have_no_cross_service_keys() -> None: + simulations = V2_METADATA.tables["user_simulation_associations"] + reports = V2_METADATA.tables["user_report_associations"] + + # The retired columns stay, nullable, for the previous Public API revision. + for table in (simulations, reports): + assert table.c.country.nullable + assert table.c.label.nullable + assert { + foreign_key.target_fullname for foreign_key in simulations.foreign_keys + } == {"users.id"} + assert {foreign_key.target_fullname for foreign_key in reports.foreign_keys} == { + "users.id", + "user_policies.id", + } + user_policy_key = next(iter(reports.c.user_policy_id.foreign_keys)) + assert user_policy_key.ondelete == "SET NULL" + for column_name in ("provisions", "validation"): + postgres_type = reports.c[column_name].type.dialect_impl(postgresql.dialect()) + assert isinstance(postgres_type, postgresql.JSONB) + assert set(sa.inspect(UserSimulationAssociation).relationships.keys()) == {"user"} + assert set(sa.inspect(UserReportAssociation).relationships.keys()) == {"user"} + assert "user_associations" not in sa.inspect(Simulation).relationships.keys() + + +def test_user_policy_records_v3_reform_fields() -> None: + policies = V2_METADATA.tables["user_policies"] + + assert policies.c.baseline.type.length == 16 + baseline_check = next( + constraint + for constraint in policies.constraints + if constraint.name == "ck_user_policies_baseline" + ) + assert "current-law" in str(baseline_check.sqltext) + assert "current-policy" in str(baseline_check.sqltext) + postgres_type = policies.c.provenance.type.dialect_impl(postgresql.dialect()) + assert isinstance(postgres_type, postgresql.JSONB) diff --git a/tests/unit/v2/test_unauthenticated_user_service.py b/tests/unit/v2/test_unauthenticated_user_service.py new file mode 100644 index 000000000..5f163a9d3 --- /dev/null +++ b/tests/unit/v2/test_unauthenticated_user_service.py @@ -0,0 +1,635 @@ +"""Service tests for unauthenticated users and their conversion at sign-in.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +import logging +from uuid import UUID + +import pytest +from sqlmodel import select + +from policyengine_api.data.v2.models import ( + User, + UserHouseholdAssociation, + UserIdentity, + UserPolicy, + UserReportAssociation, + UserSimulationAssociation, +) +from policyengine_api.services.v2.identity import services as identity_services +from policyengine_api.services.v2.identity.services import ( + StandardAccountProvisioning, + UnauthenticatedAccountProvisioning, + account_provisioning_for, + find_principal_user_in_session, + resolve_principal_user_for_read_in_session, + resolve_principal_user_in_session, +) +from policyengine_api.services.v2.identity.types import ( + AccountConversionOutcome, + UnauthenticatedPrincipal, +) +from policyengine_api.services.v2.identity.validators import ( + StandardUserRequiresSignInError, +) +from policyengine_api.services.v2.user_accounts import services as account_services +from policyengine_api.services.v2.user_accounts.database_session import ( + AccountDatabaseSession, +) +from policyengine_api.services.v2.user_accounts.services import V2AccountService +from policyengine_api.services.v2.user_accounts.types import AccountUpdateInput +from policyengine_api.services.v2.user_accounts.validators import ( + AccountEditRequiresSignInError, + AccountNotFoundError, +) +from policyengine_api.services.v2.user_households.database_session import ( + UserHouseholdDatabaseSession, +) +from policyengine_api.services.v2.user_households.services import ( + V2UserHouseholdService, +) +from policyengine_api.services.v2.user_households.types import ( + UserHouseholdCreateFields, +) +from policyengine_api.services.v2.user_households.validators import ( + UserHouseholdNotFoundError, +) +from policyengine_api.services.v2.user_policies.database_session import ( + UserPolicyDatabaseSession, +) +from policyengine_api.services.v2.user_policies.services import V2UserPolicyService +from policyengine_api.services.v2.user_policies.types import ( + UserPolicyCreateFields, + UserPolicyUpdateInput, +) +from policyengine_api.services.v2.user_policies.validators import ( + UserPolicyNotFoundError, +) +from policyengine_api.services.v2.user_reports.database_session import ( + UserReportDatabaseSession, +) +from policyengine_api.services.v2.user_reports.services import V2UserReportService +from policyengine_api.services.v2.user_reports.types import UserReportCreateFields +from policyengine_api.services.v2.user_reports.validators import ( + UserReportNotFoundError, +) +from policyengine_api.services.v2.user_simulations.database_session import ( + UserSimulationDatabaseSession, +) +from policyengine_api.services.v2.user_simulations.services import ( + V2UserSimulationService, +) +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationCreateFields, +) +from policyengine_api.services.v2.user_simulations.validators import ( + UserSimulationNotFoundError, +) +from tests.unit.v2.user_service_support import ( + ALICE, + BOB, + OTHER_SIGNED_OUT_BROWSER, + SIGNED_OUT_BROWSER, + V2Store, + default_legacy_references, + v2_store, +) + + +BROWSER_USER_ID = SIGNED_OUT_BROWSER.user_id + + +@dataclass(frozen=True) +class Services: + store: V2Store + accounts: V2AccountService + policies: V2UserPolicyService + households: V2UserHouseholdService + simulations: V2UserSimulationService + reports: V2UserReportService + + +def _never_build_the_auth0_deleter(): + raise AssertionError("unauthenticated users have no Auth0 user") + + +@pytest.fixture +def services() -> Iterator[Services]: + with v2_store() as store: + references = default_legacy_references() + yield Services( + store=store, + accounts=V2AccountService( + AccountDatabaseSession(store.sessions), + auth0_user_deleter_factory=_never_build_the_auth0_deleter, + ), + policies=V2UserPolicyService( + UserPolicyDatabaseSession(store.sessions), references + ), + households=V2UserHouseholdService( + UserHouseholdDatabaseSession(store.sessions), references + ), + simulations=V2UserSimulationService( + UserSimulationDatabaseSession(store.sessions), references + ), + reports=V2UserReportService( + UserReportDatabaseSession(store.sessions), references + ), + ) + + +@dataclass(frozen=True) +class SavedRecords: + policy_id: UUID + household_id: UUID + simulation_id: UUID + report_id: UUID + + +def _save_one_of_each(services: Services, principal) -> SavedRecords: + policy = services.policies.create_user_policy( + principal=principal, + association_input=UserPolicyCreateFields(country_id="us", legacy_policy_id=1), + ) + household = services.households.create_user_household( + principal=principal, + association_input=UserHouseholdCreateFields( + country_id="us", legacy_household_id=10 + ), + ) + simulation = services.simulations.create_user_simulation( + principal=principal, + association_input=UserSimulationCreateFields( + country_id="us", legacy_simulation_id=613 + ), + ) + report = services.reports.create_user_report( + principal=principal, + association_input=UserReportCreateFields( + country_id="us", legacy_report_id=615, user_policy_id=policy.id + ), + ) + return SavedRecords( + policy_id=policy.id, + household_id=household.id, + simulation_id=simulation.id, + report_id=report.id, + ) + + +def _owners(services: Services, records: SavedRecords) -> dict[str, UUID | None]: + with services.store.sessions() as session: + rows = { + "policy": session.get(UserPolicy, records.policy_id), + "household": session.get(UserHouseholdAssociation, records.household_id), + "simulation": session.get(UserSimulationAssociation, records.simulation_id), + "report": session.get(UserReportAssociation, records.report_id), + } + return {kind: None if row is None else row.user_id for kind, row in rows.items()} + + +def _users(services: Services) -> list[User]: + with services.store.sessions() as session: + return list(session.exec(select(User)).all()) + + +@contextmanager +def _recorded_logs(*loggers: logging.Logger) -> Iterator[list[logging.LogRecord]]: + """Record INFO logs; Alembic tests may have disabled these loggers.""" + + records: list[logging.LogRecord] = [] + + class RecordingHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = RecordingHandler(level=logging.INFO) + previous = [(logger.disabled, logger.level) for logger in loggers] + for logger in loggers: + logger.disabled = False + logger.setLevel(logging.INFO) + logger.addHandler(handler) + try: + yield records + finally: + for logger, (was_disabled, level) in zip(loggers, previous, strict=True): + logger.removeHandler(handler) + logger.disabled = was_disabled + logger.setLevel(level) + + +def _logged_text(records: list[logging.LogRecord]) -> str: + return " ".join( + f"{record.getMessage()} {getattr(record, 'policyengine_attributes', {})}" + for record in records + ) + + +def test_the_selector_picks_a_strategy_from_the_principal_type() -> None: + assert isinstance(account_provisioning_for(ALICE), StandardAccountProvisioning) + assert isinstance( + account_provisioning_for(SIGNED_OUT_BROWSER), + UnauthenticatedAccountProvisioning, + ) + + +def test_the_browser_uuid_stays_out_of_principal_and_owner_reprs( + services: Services, +) -> None: + with services.store.sessions.begin() as session: + owner = resolve_principal_user_in_session( + session, SIGNED_OUT_BROWSER, country_id="us" + ) + + assert str(BROWSER_USER_ID) not in repr(SIGNED_OUT_BROWSER) + assert str(BROWSER_USER_ID) not in repr(owner) + + +def test_writes_provision_an_unauthenticated_user_with_only_its_id( + services: Services, +) -> None: + with services.store.sessions.begin() as session: + assert find_principal_user_in_session(session, SIGNED_OUT_BROWSER) is None + first = resolve_principal_user_in_session( + session, SIGNED_OUT_BROWSER, country_id="uk" + ) + with services.store.sessions.begin() as session: + second = resolve_principal_user_in_session( + session, SIGNED_OUT_BROWSER, country_id="us" + ) + found = find_principal_user_in_session(session, SIGNED_OUT_BROWSER) + + assert first == second == found + assert (first.user_id, first.email, first.user_type) == ( + BROWSER_USER_ID, + None, + "unauthenticated", + ) + (user,) = _users(services) + assert (user.id, user.user_type) == (BROWSER_USER_ID, "unauthenticated") + assert ( + user.primary_country, + user.first_name, + user.last_name, + user.email, + ) == (None, None, None, None) + assert user.created_at is not None + + +def test_provisioning_retries_when_the_user_vanishes_before_the_re_read( + services: Services, monkeypatch: pytest.MonkeyPatch +) -> None: + original = identity_services.read_user + reads = {"count": 0} + + def read_after_a_concurrent_deletion(session, user_id, **options): + reads["count"] += 1 + if reads["count"] == 1: + return None + return original(session, user_id, **options) + + monkeypatch.setattr( + identity_services, "read_user", read_after_a_concurrent_deletion + ) + with services.store.sessions.begin() as session: + owner = resolve_principal_user_in_session( + session, SIGNED_OUT_BROWSER, country_id="us" + ) + + assert owner.user_id == BROWSER_USER_ID + assert reads["count"] == 2 + + +def test_standard_users_are_never_found_or_provisioned_through_the_header( + services: Services, +) -> None: + alice = services.accounts.get_account(principal=ALICE, country_id="us") + impostor = UnauthenticatedPrincipal(user_id=alice.id) + + with services.store.sessions.begin() as session: + with pytest.raises(StandardUserRequiresSignInError): + find_principal_user_in_session(session, impostor) + with pytest.raises(StandardUserRequiresSignInError): + resolve_principal_user_in_session(session, impostor, country_id="us") + with pytest.raises(StandardUserRequiresSignInError): + resolve_principal_user_for_read_in_session( + session, impostor, country_id="us" + ) + for operation in ( + lambda: services.policies.list_user_policies( + principal=impostor, country_id="us" + ), + lambda: services.policies.create_user_policy( + principal=impostor, + association_input=UserPolicyCreateFields( + country_id="us", legacy_policy_id=1 + ), + ), + lambda: services.accounts.export_account(principal=impostor, country_id="us"), + lambda: services.accounts.delete_account(principal=impostor), + ): + with pytest.raises(StandardUserRequiresSignInError): + operation() + + (user,) = _users(services) + assert (user.id, user.user_type) == (alice.id, "standard") + with services.store.sessions() as session: + assert session.exec(select(UserPolicy)).all() == [] + + +def test_reads_never_provision_an_unauthenticated_user(services: Services) -> None: + missing_id = UUID("00000000-0000-0000-0000-000000000123") + pages = [ + services.policies.list_user_policies( + principal=SIGNED_OUT_BROWSER, country_id="us" + ), + services.households.list_user_households( + principal=SIGNED_OUT_BROWSER, country_id="us" + ), + services.simulations.list_user_simulations( + principal=SIGNED_OUT_BROWSER, country_id="us" + ), + services.reports.list_user_reports( + principal=SIGNED_OUT_BROWSER, country_id="us" + ), + ] + for read, error in ( + (services.policies.get_user_policy, UserPolicyNotFoundError), + (services.households.get_user_household, UserHouseholdNotFoundError), + (services.simulations.get_user_simulation, UserSimulationNotFoundError), + (services.reports.get_user_report, UserReportNotFoundError), + ): + with pytest.raises(error): + read( + principal=SIGNED_OUT_BROWSER, + country_id="us", + association_id=missing_id, + ) + with pytest.raises(AccountNotFoundError) as not_found: + services.accounts.get_account(principal=SIGNED_OUT_BROWSER, country_id="us") + exported = services.accounts.export_account( + principal=SIGNED_OUT_BROWSER, country_id="us" + ) + + assert all(page.items == () and not page.has_more for page in pages) + assert str(BROWSER_USER_ID) not in str(not_found.value) + assert exported.user is None + assert ( + exported.policies, + exported.households, + exported.simulations, + exported.reports, + ) == ((), (), (), ()) + assert _users(services) == [] + + +def test_unauthenticated_users_own_their_writes_and_read_them_back( + services: Services, +) -> None: + records = _save_one_of_each(services, SIGNED_OUT_BROWSER) + + account = services.accounts.get_account( + principal=SIGNED_OUT_BROWSER, country_id="us" + ) + exported = services.accounts.export_account( + principal=SIGNED_OUT_BROWSER, country_id="us" + ) + listed = services.policies.list_user_policies( + principal=SIGNED_OUT_BROWSER, country_id="us" + ) + renamed = services.policies.patch_user_policy( + principal=SIGNED_OUT_BROWSER, + country_id="us", + association_id=records.policy_id, + association_input=UserPolicyUpdateInput(name="Renamed"), + ) + + assert (account.id, account.user_type, account.primary_country) == ( + BROWSER_USER_ID, + "unauthenticated", + None, + ) + assert exported.user == account + assert [item.id for item in exported.reports] == [records.report_id] + assert exported.reports[0].user_policy_id == records.policy_id + assert [item.id for item in listed.items] == [records.policy_id] + assert renamed.name == "Renamed" + assert set(_owners(services, records).values()) == {BROWSER_USER_ID} + with pytest.raises(UserPolicyNotFoundError): + services.policies.get_user_policy( + principal=OTHER_SIGNED_OUT_BROWSER, + country_id="us", + association_id=records.policy_id, + ) + with pytest.raises(UserPolicyNotFoundError): + services.policies.get_user_policy( + principal=ALICE, country_id="us", association_id=records.policy_id + ) + + +def test_unauthenticated_users_cannot_edit_account_details( + services: Services, +) -> None: + for principal_state in ("absent", "present"): + if principal_state == "present": + _save_one_of_each(services, SIGNED_OUT_BROWSER) + with pytest.raises(AccountEditRequiresSignInError): + services.accounts.update_account( + principal=SIGNED_OUT_BROWSER, + country_id="us", + account_input=AccountUpdateInput(first_name="Ada"), + ) + + (user,) = _users(services) + assert user.first_name is None + + +def test_deleting_an_unauthenticated_account_removes_only_its_data( + services: Services, +) -> None: + absent = services.accounts.delete_account(principal=SIGNED_OUT_BROWSER) + records = _save_one_of_each(services, SIGNED_OUT_BROWSER) + other_records = _save_one_of_each(services, OTHER_SIGNED_OUT_BROWSER) + + deleted = services.accounts.delete_account(principal=SIGNED_OUT_BROWSER) + + assert (absent.user_id, absent.auth0_subjects) == (None, ()) + assert (deleted.user_id, deleted.auth0_subjects) == (BROWSER_USER_ID, ()) + assert set(_owners(services, records).values()) == {None} + assert set(_owners(services, other_records).values()) == { + OTHER_SIGNED_OUT_BROWSER.user_id + } + assert [user.id for user in _users(services)] == [OTHER_SIGNED_OUT_BROWSER.user_id] + + +def test_upgrade_converts_an_unauthenticated_user_in_place( + services: Services, +) -> None: + records = _save_one_of_each(services, SIGNED_OUT_BROWSER) + + upgrade = services.accounts.upgrade_account( + principal=ALICE, unauthenticated_user_id=BROWSER_USER_ID, country_id="uk" + ) + + assert upgrade.outcome is AccountConversionOutcome.CONVERTED + assert ( + upgrade.account.id, + upgrade.account.user_type, + upgrade.account.primary_country, + ) == (BROWSER_USER_ID, "standard", "uk") + assert set(_owners(services, records).values()) == {BROWSER_USER_ID} + with services.store.sessions() as session: + identity = session.exec(select(UserIdentity)).one() + report = session.get(UserReportAssociation, records.report_id) + assert (identity.user_id, identity.subject) == (BROWSER_USER_ID, ALICE.subject) + assert report is not None and report.user_policy_id == records.policy_id + signed_in = services.accounts.get_account(principal=ALICE, country_id="us") + assert signed_in.id == BROWSER_USER_ID + with pytest.raises(StandardUserRequiresSignInError): + services.policies.list_user_policies( + principal=SIGNED_OUT_BROWSER, country_id="us" + ) + + again = services.accounts.upgrade_account( + principal=ALICE, unauthenticated_user_id=BROWSER_USER_ID, country_id="us" + ) + assert again.outcome is AccountConversionOutcome.NONE + assert again.account.id == BROWSER_USER_ID + assert again.account.primary_country == "uk" + + +def test_upgrade_merges_every_association_into_an_existing_account( + services: Services, +) -> None: + alice = services.accounts.get_account(principal=ALICE, country_id="us") + alice_records = _save_one_of_each(services, ALICE) + records = _save_one_of_each(services, SIGNED_OUT_BROWSER) + with services.store.sessions() as session: + updated_before = { + row.id: row.updated_at for row in session.exec(select(UserPolicy)).all() + } + + upgrade = services.accounts.upgrade_account( + principal=ALICE, unauthenticated_user_id=BROWSER_USER_ID, country_id="us" + ) + + assert upgrade.outcome is AccountConversionOutcome.MERGED + assert (upgrade.account.id, upgrade.account.user_type) == (alice.id, "standard") + assert set(_owners(services, records).values()) == {alice.id} + assert set(_owners(services, alice_records).values()) == {alice.id} + assert [user.id for user in _users(services)] == [alice.id] + with services.store.sessions() as session: + report = session.get(UserReportAssociation, records.report_id) + updated_after = { + row.id: row.updated_at for row in session.exec(select(UserPolicy)).all() + } + assert report is not None and report.user_policy_id == records.policy_id + assert updated_after == updated_before + linked = services.reports.get_user_report( + principal=ALICE, country_id="us", association_id=records.report_id + ) + assert linked.user_policy_id == records.policy_id + with pytest.raises(AccountNotFoundError): + services.accounts.get_account(principal=SIGNED_OUT_BROWSER, country_id="us") + + +def test_upgrade_without_an_unauthenticated_user_resolves_the_account( + services: Services, +) -> None: + upgrade = services.accounts.upgrade_account( + principal=ALICE, unauthenticated_user_id=BROWSER_USER_ID, country_id="uk" + ) + + assert upgrade.outcome is AccountConversionOutcome.NONE + assert upgrade.account.id != BROWSER_USER_ID + assert (upgrade.account.user_type, upgrade.account.primary_country) == ( + "standard", + "uk", + ) + assert [user.id for user in _users(services)] == [upgrade.account.id] + + +def test_upgrade_never_touches_a_standard_user_named_by_the_header( + services: Services, +) -> None: + bob = services.accounts.get_account(principal=BOB, country_id="us") + bob_records = _save_one_of_each(services, BOB) + + upgrade = services.accounts.upgrade_account( + principal=ALICE, unauthenticated_user_id=bob.id, country_id="us" + ) + + assert upgrade.outcome is AccountConversionOutcome.NONE + assert upgrade.account.id != bob.id + assert set(_owners(services, bob_records).values()) == {bob.id} + with services.store.sessions() as session: + identities = { + identity.subject: identity.user_id + for identity in session.exec(select(UserIdentity)).all() + } + assert identities == {BOB.subject: bob.id, ALICE.subject: upgrade.account.id} + + +def test_upgrade_merges_when_a_concurrent_first_sign_in_wins_the_identity( + services: Services, monkeypatch: pytest.MonkeyPatch +) -> None: + records = _save_one_of_each(services, SIGNED_OUT_BROWSER) + original = identity_services.create_user_identity + winner: dict[str, UUID] = {} + + def lose_to_a_concurrent_first_request(session, **identity): + # SQLite has one in-memory connection, so the winning request's rows + # are written through this session; the insert below then conflicts. + user = User(primary_country="us") + session.add(user) + session.flush() + session.add( + UserIdentity( + user_id=user.id, + issuer=identity["issuer"], + subject=identity["subject"], + ) + ) + session.flush() + winner["user_id"] = user.id + return original(session, **identity) + + monkeypatch.setattr( + identity_services, + "create_user_identity", + lose_to_a_concurrent_first_request, + ) + + upgrade = services.accounts.upgrade_account( + principal=ALICE, unauthenticated_user_id=BROWSER_USER_ID, country_id="us" + ) + + assert upgrade.outcome is AccountConversionOutcome.MERGED + assert upgrade.account.id == winner["user_id"] + assert set(_owners(services, records).values()) == {winner["user_id"]} + assert [user.id for user in _users(services)] == [winner["user_id"]] + + +def test_unauthenticated_user_ids_are_never_logged(services: Services) -> None: + with _recorded_logs(identity_services.logger, account_services.logger) as records: + _save_one_of_each(services, SIGNED_OUT_BROWSER) + services.accounts.delete_account(principal=SIGNED_OUT_BROWSER) + _save_one_of_each(services, SIGNED_OUT_BROWSER) + services.accounts.upgrade_account( + principal=ALICE, unauthenticated_user_id=BROWSER_USER_ID, country_id="us" + ) + + events = [ + (record.getMessage(), record.policyengine_attributes) for record in records + ] + assert events == [ + ("provisioned_v2_user", {"user_type": "unauthenticated"}), + ("deleted_v2_user_account", {"user_type": "unauthenticated"}), + ("provisioned_v2_user", {"user_type": "unauthenticated"}), + ("upgraded_v2_user", {"outcome": "converted"}), + ] + assert str(BROWSER_USER_ID) not in _logged_text(records) + assert BROWSER_USER_ID.hex not in _logged_text(records) diff --git a/tests/unit/v2/test_user_account_service.py b/tests/unit/v2/test_user_account_service.py new file mode 100644 index 000000000..c6ca0b811 --- /dev/null +++ b/tests/unit/v2/test_user_account_service.py @@ -0,0 +1,501 @@ +"""Service tests for signed-in identity provisioning, saves, and deletion.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from sqlmodel import select + +from policyengine_api.data.v2.models import ( + LegacyUserMapping, + User, + UserIdentity, + UserPolicy, + UserSimulationAssociation, +) +from policyengine_api.services.v2.associations.validators import ( + AssociationLinkNotFoundError, +) +from policyengine_api.services.v2.identity import services as identity_services +from policyengine_api.services.v2.identity.services import ( + resolve_principal_user_in_session, +) +from policyengine_api.services.v2.identity.types import UserPrincipal +from policyengine_api.services.v2.legacy_refs.validators import ( + LegacyReferenceNotFoundError, +) +from policyengine_api.services.v2.user_accounts.database_session import ( + AccountDatabaseSession, +) +from policyengine_api.services.v2.user_accounts.services import V2AccountService +from policyengine_api.services.v2.user_accounts.types import AccountUpdateInput +from policyengine_api.services.v2.user_accounts.validators import ( + AccountDeletionUnavailableError, + AccountIdentityDeletionError, +) +from policyengine_api.services.v2.user_policies.database_session import ( + UserPolicyDatabaseSession, +) +from policyengine_api.services.v2.user_policies.services import V2UserPolicyService +from policyengine_api.services.v2.user_policies.types import UserPolicyCreateFields +from policyengine_api.services.v2.user_reports.database_session import ( + UserReportDatabaseSession, +) +from policyengine_api.services.v2.user_reports.services import V2UserReportService +from policyengine_api.services.v2.user_reports.types import ( + UserReportCreateFields, + UserReportUpdateInput, +) +from policyengine_api.services.v2.user_simulations.database_session import ( + UserSimulationDatabaseSession, +) +from policyengine_api.services.v2.user_simulations.services import ( + V2UserSimulationService, +) +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationCreateFields, + UserSimulationUpdateInput, +) +from policyengine_api.services.v2.user_simulations.validators import ( + UserSimulationNotFoundError, +) +from tests.unit.v2.user_service_support import ( + ALICE, + BOB, + ISSUER, + default_legacy_references, + v2_store, +) + + +@pytest.fixture +def store(): + with v2_store() as selected: + yield selected + + +class RecordingAuth0Deleter: + """Record Auth0 calls; optionally fail preparation or deletion.""" + + def __init__( + self, + *, + prepare_error: Exception | None = None, + delete_error: Exception | None = None, + on_prepare=None, + ) -> None: + self.prepare_error = prepare_error + self.delete_error = delete_error + self.on_prepare = on_prepare + self.prepared = 0 + self.deleted_subjects: list[str] = [] + + def prepare_deletion(self) -> None: + self.prepared += 1 + if self.on_prepare is not None: + self.on_prepare() + if self.prepare_error is not None: + raise self.prepare_error + + def delete_user(self, subject: str) -> None: + if self.delete_error is not None: + raise self.delete_error + self.deleted_subjects.append(subject) + + +def _services(selected, *, deleter=None, deleter_factory=None): + references = default_legacy_references() + if deleter_factory is None and deleter is not None: + + def deleter_factory(): + return deleter + + return ( + V2AccountService( + AccountDatabaseSession(selected.sessions), + auth0_user_deleter_factory=deleter_factory, + ), + V2UserPolicyService(UserPolicyDatabaseSession(selected.sessions), references), + V2UserSimulationService( + UserSimulationDatabaseSession(selected.sessions), references + ), + V2UserReportService(UserReportDatabaseSession(selected.sessions), references), + ) + + +def test_first_call_provisions_once_and_later_calls_reuse_the_user(store) -> None: + accounts, *_ = _services(store) + + first = accounts.get_account(principal=ALICE, country_id="uk") + second = accounts.get_account(principal=ALICE, country_id="us") + other = accounts.get_account(principal=BOB, country_id="us") + + assert first.id == second.id != other.id + assert first.primary_country == second.primary_country == "uk" + with store.sessions() as session: + assert len(session.exec(select(User)).all()) == 2 + assert len(session.exec(select(UserIdentity)).all()) == 2 + + +def test_concurrent_first_calls_converge_on_one_user(store, monkeypatch) -> None: + original = identity_services.read_user_identity + reads = {"count": 0} + + def racing_read(session, **identity): + reads["count"] += 1 + if reads["count"] == 1: + with store.sessions.begin() as winner: + user = User(primary_country="us") + winner.add(user) + winner.flush() + winner.add(UserIdentity(user_id=user.id, **identity)) + return None + return original(session, **identity) + + monkeypatch.setattr(identity_services, "read_user_identity", racing_read) + accounts, *_ = _services(store) + + resolved = accounts.get_account(principal=ALICE, country_id="us") + + with store.sessions() as session: + users = session.exec(select(User)).all() + identities = session.exec(select(UserIdentity)).all() + assert [user.id for user in users] == [resolved.id] + assert [identity.user_id for identity in identities] == [resolved.id] + + +def test_identity_activity_is_written_at_most_hourly(store) -> None: + now = datetime(2026, 5, 1, 12, tzinfo=timezone.utc) + verified = UserPrincipal(issuer=ISSUER, subject=ALICE.subject, email="a@x.test") + + def resolve(principal, at): + with store.sessions.begin() as session: + resolve_principal_user_in_session( + session, principal, country_id="us", now=at + ) + with store.sessions() as session: + identity = session.exec(select(UserIdentity)).one() + return identity.last_seen_at.replace(tzinfo=timezone.utc), identity.email + + assert resolve(ALICE, now) == (now, None) + assert resolve(ALICE, now + timedelta(minutes=59)) == (now, None) + assert resolve(ALICE, now + timedelta(minutes=61)) == ( + now + timedelta(minutes=61), + None, + ) + assert resolve(verified, now + timedelta(minutes=62)) == ( + now + timedelta(minutes=62), + "a@x.test", + ) + assert resolve(ALICE, now + timedelta(minutes=63)) == ( + now + timedelta(minutes=62), + "a@x.test", + ) + + +def test_account_updates_names_and_country(store) -> None: + accounts, *_ = _services(store) + + updated = accounts.update_account( + principal=ALICE, + country_id="us", + account_input=AccountUpdateInput(first_name="Ada", primary_country="uk"), + ) + + assert (updated.first_name, updated.last_name, updated.primary_country) == ( + "Ada", + None, + "uk", + ) + + +def test_simulation_saves_are_owner_scoped_and_v1_checked(store) -> None: + _accounts, _policies, simulations, _reports = _services(store) + created = simulations.create_user_simulation( + principal=ALICE, + association_input=UserSimulationCreateFields( + country_id="us", legacy_simulation_id=613 + ), + ) + + with pytest.raises(LegacyReferenceNotFoundError): + simulations.create_user_simulation( + principal=ALICE, + association_input=UserSimulationCreateFields( + country_id="us", legacy_simulation_id=999 + ), + ) + with pytest.raises(UserSimulationNotFoundError): + simulations.patch_user_simulation( + principal=BOB, + country_id="us", + association_id=created.id, + association_input=UserSimulationUpdateInput(name="stolen"), + ) + repointed = simulations.patch_user_simulation( + principal=ALICE, + country_id="us", + association_id=created.id, + association_input=UserSimulationUpdateInput(legacy_simulation_id=614), + ) + assert (repointed.simulation_id, repointed.legacy_simulation_id) == (None, 614) + assert [ + item.id + for item in simulations.list_user_simulations( + principal=ALICE, country_id="us", legacy_simulation_id=614 + ).items + ] == [created.id] + + +def test_report_policy_links_require_the_same_owner_and_survive_deletion( + store, +) -> None: + _accounts, policies, _simulations, reports = _services(store) + alice_policy = policies.create_user_policy( + principal=ALICE, + association_input=UserPolicyCreateFields(country_id="us", legacy_policy_id=1), + ) + bob_policy = policies.create_user_policy( + principal=BOB, + association_input=UserPolicyCreateFields(country_id="us", legacy_policy_id=2), + ) + + report = reports.create_user_report( + principal=ALICE, + association_input=UserReportCreateFields.model_validate( + { + "country_id": "us", + "legacy_report_id": 615, + "user_policy_id": str(alice_policy.id), + "provisions": [ + { + "path": "gov.x", + "breadcrumb": "X", + "baseline_value": 1, + "value": 2, + } + ], + } + ), + ) + with pytest.raises(AssociationLinkNotFoundError): + reports.create_user_report( + principal=ALICE, + association_input=UserReportCreateFields( + country_id="us", legacy_report_id=615, user_policy_id=bob_policy.id + ), + ) + with pytest.raises(AssociationLinkNotFoundError): + reports.patch_user_report( + principal=ALICE, + country_id="us", + association_id=report.id, + association_input=UserReportUpdateInput(user_policy_id=bob_policy.id), + ) + + with pytest.raises(AssociationLinkNotFoundError): + reports.create_user_report( + principal=ALICE, + association_input=UserReportCreateFields( + country_id="us", + legacy_report_id=615, + baseline_user_policy_id=bob_policy.id, + ), + ) + with pytest.raises(AssociationLinkNotFoundError): + reports.patch_user_report( + principal=ALICE, + country_id="us", + association_id=report.id, + association_input=UserReportUpdateInput( + baseline_user_policy_id=bob_policy.id + ), + ) + compared = reports.patch_user_report( + principal=ALICE, + country_id="us", + association_id=report.id, + association_input=UserReportUpdateInput( + baseline_user_policy_id=alice_policy.id + ), + ) + assert compared.baseline_user_policy_id == alice_policy.id + + assert report.user_policy_id == alice_policy.id + assert report.provisions == [ + {"path": "gov.x", "breadcrumb": "X", "baseline_value": 1, "value": 2} + ] + policies.delete_user_policy( + principal=ALICE, country_id="us", association_id=alice_policy.id + ) + refreshed = reports.get_user_report( + principal=ALICE, country_id="us", association_id=report.id + ) + assert refreshed.user_policy_id is None + + +def test_export_covers_every_country(store) -> None: + accounts, policies, *_ = _services(store) + policies.create_user_policy( + principal=ALICE, + association_input=UserPolicyCreateFields(country_id="us", legacy_policy_id=1), + ) + policies.create_user_policy( + principal=ALICE, + association_input=UserPolicyCreateFields(country_id="uk", legacy_policy_id=7), + ) + policies.create_user_policy( + principal=BOB, + association_input=UserPolicyCreateFields(country_id="us", legacy_policy_id=2), + ) + + exported = accounts.export_account(principal=ALICE, country_id="us") + + assert sorted(item.country_id for item in exported.policies) == ["uk", "us"] + assert exported.households == exported.simulations == exported.reports == () + + +def test_delete_removes_legacy_mappings_identities_and_associations(store) -> None: + deleter = RecordingAuth0Deleter() + accounts, policies, simulations, _reports = _services(store, deleter=deleter) + account = accounts.get_account(principal=ALICE, country_id="us") + policies.create_user_policy( + principal=ALICE, + association_input=UserPolicyCreateFields(country_id="us", legacy_policy_id=1), + ) + simulations.create_user_simulation( + principal=ALICE, + association_input=UserSimulationCreateFields( + country_id="us", legacy_simulation_id=613 + ), + ) + accounts.get_account(principal=BOB, country_id="us") + with store.sessions.begin() as session: + session.add( + LegacyUserMapping(legacy_user_id="auth0|legacy", user_id=account.id) + ) + session.add( + UserIdentity( + user_id=account.id, issuer=ISSUER, subject="google-oauth2|alice-linked" + ) + ) + + deletion = accounts.delete_account(principal=ALICE) + + assert deletion.user_id == account.id + assert deleter.prepared == 1 + assert deleter.deleted_subjects == [ALICE.subject, "google-oauth2|alice-linked"] + with store.sessions() as session: + assert session.get(User, account.id) is None + assert session.exec(select(LegacyUserMapping)).all() == [] + assert { + identity.subject for identity in session.exec(select(UserIdentity)) + } == {BOB.subject} + assert session.exec(select(UserPolicy)).all() == [] + assert session.exec(select(UserSimulationAssociation)).all() == [] + + +def test_delete_without_management_api_or_with_failures(store) -> None: + accounts, *_ = _services(store) + accounts.get_account(principal=ALICE, country_id="us") + + assert accounts.delete_account(principal=ALICE).auth0_subjects == () + assert accounts.delete_account(principal=ALICE).user_id is None + + failing_deleter = RecordingAuth0Deleter( + delete_error=RuntimeError("management api down") + ) + failing, *_ = _services(store, deleter=failing_deleter) + failing.get_account(principal=BOB, country_id="us") + with pytest.raises(AccountIdentityDeletionError): + failing.delete_account(principal=BOB) + with store.sessions() as session: + assert session.exec(select(User)).all() == [] + + +def test_reads_lists_and_account_routes_never_build_the_v1_checker(store) -> None: + from policyengine_api.services.v2.legacy_refs.services import ( + DeferredLegacyReferenceService, + ) + + def unavailable(): + raise AssertionError("the v1 reference service must not be built") + + references = DeferredLegacyReferenceService(unavailable) + accounts = V2AccountService(AccountDatabaseSession(store.sessions)) + simulations = V2UserSimulationService( + UserSimulationDatabaseSession(store.sessions), references + ) + + accounts.get_account(principal=ALICE, country_id="us") + accounts.export_account(principal=ALICE, country_id="us") + page = simulations.list_user_simulations(principal=ALICE, country_id="us") + + assert page.items == () + with pytest.raises(AssertionError, match="must not be built"): + simulations.create_user_simulation( + principal=ALICE, + association_input=UserSimulationCreateFields( + country_id="us", legacy_simulation_id=613 + ), + ) + + +@pytest.mark.parametrize("failure", ["settings", "credentials"]) +def test_auth0_credential_failures_stop_deletion_before_any_data_changes( + store, failure +) -> None: + def unusable_settings(): + raise RuntimeError("AUTH0_MANAGEMENT_DOMAIN must be a bare host name") + + if failure == "settings": + accounts, *_ = _services(store, deleter_factory=unusable_settings) + else: + accounts, *_ = _services( + store, + deleter=RecordingAuth0Deleter( + prepare_error=RuntimeError("secret access denied") + ), + ) + account = accounts.get_account(principal=ALICE, country_id="us") + + with pytest.raises(AccountDeletionUnavailableError, match="nothing was deleted"): + accounts.delete_account(principal=ALICE) + + with store.sessions() as session: + assert session.get(User, account.id) is not None + assert session.exec(select(UserIdentity)).all() != [] + + +def test_auth0_credentials_are_prepared_before_data_is_deleted(store) -> None: + accounts_for_lookup, *_ = _services(store) + account = accounts_for_lookup.get_account(principal=ALICE, country_id="us") + user_present_at_prepare: list[bool] = [] + + def record_user_presence() -> None: + with store.sessions() as session: + user_present_at_prepare.append(session.get(User, account.id) is not None) + + deleter = RecordingAuth0Deleter(on_prepare=record_user_presence) + accounts, *_ = _services(store, deleter=deleter) + + accounts.delete_account(principal=ALICE) + + assert user_present_at_prepare == [True] + assert deleter.deleted_subjects == [ALICE.subject] + + +def test_only_account_deletion_builds_the_auth0_deleter(store) -> None: + def unexpected_build(): + raise AssertionError("the Auth0 deleter must be built only for deletion") + + accounts, *_ = _services(store, deleter_factory=unexpected_build) + + accounts.get_account(principal=ALICE, country_id="us") + accounts.update_account( + principal=ALICE, + country_id="us", + account_input=AccountUpdateInput(first_name="Ada"), + ) + accounts.export_account(principal=ALICE, country_id="us") diff --git a/tests/unit/v2/test_user_association_migration_qualification.py b/tests/unit/v2/test_user_association_migration_qualification.py new file mode 100644 index 000000000..73d244069 --- /dev/null +++ b/tests/unit/v2/test_user_association_migration_qualification.py @@ -0,0 +1,143 @@ +"""Tests for read-only Stage 12A user-association qualification.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +import json + +import pytest + +from policyengine_api.data.v2 import ( + user_association_migration_qualification as qualification, +) +from alembic.config import Config +from alembic.script import ScriptDirectory + +from policyengine_api.constants import REPO +from policyengine_api.data.v2.settings import ( + V2_MIGRATION_DATABASE_URL, + V2_SUPABASE_ENVIRONMENT, + V2_SUPABASE_PROJECT_REF, +) + + +ENVIRONMENT = { + V2_MIGRATION_DATABASE_URL: ( + "postgresql+psycopg://migration:test-password@db." + "abcdefghijklmnopqrst.supabase.co/postgres?sslmode=require" + ), + V2_SUPABASE_PROJECT_REF: "abcdefghijklmnopqrst", + V2_SUPABASE_ENVIRONMENT: "staging", +} + + +class FakeTransaction: + def __init__(self) -> None: + self.rolled_back = False + + def rollback(self) -> None: + self.rolled_back = True + + +class FakeConnection: + def __init__(self, counts: tuple[int, int, int, int]) -> None: + self._counts = iter(counts) + self.transaction = FakeTransaction() + self.options: list[dict[str, object]] = [] + self.statements: list[str] = [] + + def execution_options(self, **options: object) -> "FakeConnection": + self.options.append(options) + return self + + def begin(self) -> FakeTransaction: + return self.transaction + + def scalar(self, statement: object) -> int: + self.statements.append(str(statement)) + return next(self._counts) + + +class FakeEngine: + def __init__(self, connection: FakeConnection) -> None: + self.connection = connection + self.disposed = False + + @contextmanager + def connect(self) -> Iterator[FakeConnection]: + yield self.connection + + def dispose(self) -> None: + self.disposed = True + + +def _qualify(counts, *, pending: bool): + connection = FakeConnection(counts) + engine = FakeEngine(connection) + evidence = qualification.qualify_user_association_migration( + ENVIRONMENT, + engine_builder=lambda _settings: engine, + pending_checker=lambda _connection: pending, + ) + return evidence, connection, engine + + +def test_counts_are_reported_from_a_read_only_rolled_back_transaction() -> None: + evidence, connection, engine = _qualify((3, 2, 0, 0), pending=True) + + assert evidence.as_dict() == { + "outcome": "ok", + "revision": "fbb7488ebe8a", + "migration_pending": True, + "dormant_tables_empty": True, + "environment": "staging", + "project_ref": "abcdefghijklmnopqrst", + "counts": { + "user_policies": 3, + "user_household_associations": 2, + "user_simulation_associations": 0, + "user_report_associations": 0, + }, + } + assert connection.options == [{"postgresql_readonly": True}] + assert connection.transaction.rolled_back + assert engine.disposed + assert len(connection.statements) == 4 + assert all("count(*)" in statement for statement in connection.statements) + + +def test_retained_dormant_rows_stop_a_pending_reshape() -> None: + with pytest.raises(qualification.RetainedDormantAssociationError) as error: + _qualify((0, 0, 1, 2), pending=True) + + assert error.value.counts.dormant_total == 3 + + +def test_applied_reshape_reports_rows_without_failing() -> None: + evidence, connection, _engine = _qualify((0, 0, 5, 7), pending=False) + + assert evidence.as_dict()["dormant_tables_empty"] is False + assert evidence.as_dict()["migration_pending"] is False + assert connection.transaction.rolled_back + + +def test_revision_constant_names_a_revision_in_the_v2_chain() -> None: + script = ScriptDirectory.from_config(Config(str(REPO / "alembic-v2.ini"))) + + revision = script.get_revision(qualification.USER_ASSOCIATION_MIGRATION_REVISION) + + assert revision is not None + assert revision.down_revision == "91af4fc8d8c1" + + +def test_main_redacts_unexpected_errors(monkeypatch, capsys) -> None: + def fail(): + raise RuntimeError("postgresql://user:secret@host/db") + + monkeypatch.setattr(qualification, "qualify_user_association_migration", fail) + + assert qualification.main() == 1 + payload = json.loads(capsys.readouterr().err) + assert payload["outcome"] == "error" + assert "secret" not in json.dumps(payload) diff --git a/tests/unit/v2/test_user_household_routes.py b/tests/unit/v2/test_user_household_routes.py index 4be95201f..3f82601c0 100644 --- a/tests/unit/v2/test_user_household_routes.py +++ b/tests/unit/v2/test_user_household_routes.py @@ -1,21 +1,16 @@ -"""Native FastAPI contract tests for v2 user-household associations.""" +"""Native FastAPI contract tests for signed-in v2 user-household associations.""" from __future__ import annotations -from datetime import datetime, timedelta, timezone -from uuid import UUID, uuid4 +from datetime import timedelta +from uuid import UUID -from fastapi.testclient import TestClient -from flask import Flask, jsonify import pytest -from sqlalchemy.exc import IntegrityError, OperationalError, TimeoutError +from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from policyengine_api.asgi_factory import create_asgi_app from policyengine_api.data.v2.settings import V2ConfigurationError -from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies -from policyengine_api.migration_flags import ( - RouteImplementation, - RouteImplementationSettings, +from policyengine_api.services.v2.legacy_refs.validators import ( + LegacyReferenceNotFoundError, ) from policyengine_api.services.v2.user_households.types import ( UserHouseholdPage, @@ -24,385 +19,182 @@ from policyengine_api.services.v2.user_households.validators import ( AssociationCountryConflictError, AssociationHouseholdNotFoundError, - AssociationUserNotFoundError, UserHouseholdNotFoundError, ) +from tests.unit.v2.user_route_support import ( + CREATED_AT, + OWNER_ID, + PRINCIPAL, + RecordingService, + user_route_client, +) -ASSOCIATION_ID = UUID("00000000-0000-0000-0000-000000000060") -HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-000000000010") -REPLACEMENT_ID = UUID("00000000-0000-0000-0000-000000000011") -USER_ID = UUID("00000000-0000-0000-0000-000000000070") -CREATED_AT = datetime(2026, 1, 1, tzinfo=timezone.utc) - - -def _association_read( - *, - association_id: UUID = ASSOCIATION_ID, - household_id: UUID = HOUSEHOLD_ID, - name: str | None = "Saved household", - description: str | None = "Personal note", -) -> UserHouseholdRead: - return UserHouseholdRead( - id=association_id, - country_id="us", - user_id=USER_ID, - household_id=household_id, - name=name, - description=description, - created_at=CREATED_AT, - updated_at=CREATED_AT + timedelta(seconds=1), - ) - - -class FakeUserHouseholdService: - def __init__(self) -> None: - self.error: Exception | None = None - self.calls: list[tuple[str, object]] = [] - self.next_item = _association_read() +ASSOCIATION_ID = UUID("00000000-0000-0000-0000-000000000061") +HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-000000000011") - def _raise(self) -> None: - if self.error is not None: - raise self.error - def create_user_household(self, association_input) -> UserHouseholdRead: - self.calls.append(("create_user_household", association_input)) - self._raise() - return self.next_item +def _association_read(**changes) -> UserHouseholdRead: + values = { + "id": ASSOCIATION_ID, + "country_id": "us", + "user_id": OWNER_ID, + "household_id": HOUSEHOLD_ID, + "legacy_household_id": 123, + "name": "Family", + "description": None, + "created_at": CREATED_AT, + "updated_at": CREATED_AT + timedelta(seconds=1), + } + values.update(changes) + return UserHouseholdRead(**values) - def get_user_household(self, **identity) -> UserHouseholdRead: - self.calls.append(("get_user_household", identity)) - self._raise() - return self.next_item - def list_user_households(self, **filters) -> UserHouseholdPage: - self.calls.append(("list_user_households", filters)) - self._raise() +def _result(name: str, kwargs: dict): + if name == "list_user_households": return UserHouseholdPage( - items=(self.next_item,), - offset=filters["offset"], - limit=filters["limit"], - has_more=False, + items=(_association_read(),), + offset=kwargs["offset"], + limit=kwargs["limit"], + has_more=True, ) + if name == "delete_user_household": + return None + return _association_read() - def patch_user_household(self, **changes) -> UserHouseholdRead: - self.calls.append(("patch_user_household", changes)) - self._raise() - patch = changes["association_input"] - return _association_read( - household_id=( - patch.household_id - if "household_id" in patch.model_fields_set - else HOUSEHOLD_ID - ), - name=patch.name if "name" in patch.model_fields_set else "Saved household", - description=( - patch.description - if "description" in patch.model_fields_set - else "Personal note" - ), - ) - def delete_user_household(self, **identity) -> None: - self.calls.append(("delete_user_household", identity)) - self._raise() - - -def _client( - service: FakeUserHouseholdService, -) -> tuple[TestClient, dict[str, int]]: - flask_calls = {"count": 0} - flask_app = Flask(__name__) - - @flask_app.route("/", methods=["GET", "POST", "PATCH", "DELETE"]) - def fallback(resource: str): - flask_calls["count"] += 1 - return jsonify({"source": "flask", "resource": resource}) - - dependencies = NativeRouteDependencies( - readiness_probe=lambda: True, - gateway_client_factory=lambda: None, - metadata_reader_factory=lambda: None, - specification_provider=lambda: {}, - v2_user_household_service_factory=lambda: service, - ) - settings = RouteImplementationSettings( - health=RouteImplementation.FLASK_FALLBACK, - specification=RouteImplementation.FLASK_FALLBACK, - metadata=RouteImplementation.FLASK_FALLBACK, - ) - return ( - TestClient( - create_asgi_app( - flask_app, - dependencies=dependencies, - route_settings=settings, - ), - raise_server_exceptions=False, - ), - flask_calls, +def _client(service: RecordingService | None = None): + service = service or RecordingService(_result) + client, flask_calls = user_route_client( + v2_user_household_service_factory=lambda: service ) + return client, service, flask_calls -def _body(**changes) -> dict[str, object]: - body: dict[str, object] = { - "country_id": "us", - "user_id": str(USER_ID), - "household_id": str(HOUSEHOLD_ID), - "name": "Saved household", - "description": "Personal note", - } - body.update(changes) - return body +def test_create_returns_complete_contract_owned_by_the_session_user() -> None: + client, service, _flask_calls = _client() + response = client.post( + "/v2/user-households?country_id=us", + json={ + "country_id": "us", + "legacy_household_id": 123, + "name": "Family", + }, + ) -def test_create_returns_complete_distinct_association_contract() -> None: - service = FakeUserHouseholdService() - client, flask_calls = _client(service) - - first = client.post("/v2/user-households?country_id=US", json=_body()) - service.next_item = _association_read(association_id=uuid4()) - second = client.post("/v2/user-households?country_id=us", json=_body()) - - assert first.status_code == second.status_code == 201 - assert first.json()["result"]["item"] == { + assert response.status_code == 201 + assert response.json()["result"]["item"] == { "id": str(ASSOCIATION_ID), "country_id": "us", - "user_id": str(USER_ID), + "user_id": str(OWNER_ID), "household_id": str(HOUSEHOLD_ID), - "name": "Saved household", - "description": "Personal note", + "legacy_household_id": 123, + "name": "Family", + "description": None, "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:01Z", } - assert first.json()["result"]["item"]["id"] != second.json()["result"]["item"]["id"] - assert [call[0] for call in service.calls] == [ - "create_user_household", - "create_user_household", - ] - assert flask_calls["count"] == 0 + name, kwargs = service.calls[0] + assert name == "create_user_household" + assert kwargs["principal"] == PRINCIPAL + assert kwargs["association_input"].legacy_household_id == 123 -def test_create_rejects_country_mismatch_and_invalid_identifiers() -> None: - service = FakeUserHouseholdService() - client, _flask_calls = _client(service) +@pytest.mark.parametrize( + "body", + [ + {"country_id": "us", "legacy_household_id": 1, "user_id": str(OWNER_ID)}, + {"country_id": "us", "name": "No reference"}, + {"country_id": "us", "legacy_household_id": -4}, + {"country_id": "us", "household_id": "not-a-uuid"}, + ], +) +def test_create_rejects_user_ids_and_invalid_references(body: dict) -> None: + client, service, _flask_calls = _client() - mismatch = client.post("/v2/user-households?country_id=uk", json=_body()) - invalid_user = client.post( - "/v2/user-households?country_id=us", - json=_body(user_id="not-a-uuid"), - ) - long_name = client.post( - "/v2/user-households?country_id=us", - json=_body(name="x" * 256), - ) + response = client.post("/v2/user-households?country_id=us", json=body) - assert mismatch.status_code == 400 - assert invalid_user.status_code == 422 - assert long_name.status_code == 422 + assert response.status_code == 422 assert service.calls == [] -def test_detail_and_list_pass_country_user_household_and_pagination() -> None: - service = FakeUserHouseholdService() - client, _flask_calls = _client(service) +def test_create_rejects_country_mismatch() -> None: + client, service, _flask_calls = _client() - detail = client.get(f"/v2/user-households/{ASSOCIATION_ID}?country_id=US") - page = client.get( - f"/v2/user-households?country_id=us&user_id={USER_ID}" - f"&household_id={HOUSEHOLD_ID}&offset=2&limit=3" + response = client.post( + "/v2/user-households?country_id=uk", + json={"country_id": "us", "household_id": str(HOUSEHOLD_ID)}, ) - assert detail.status_code == 200 - assert page.status_code == 200 - assert service.calls == [ - ( - "get_user_household", - {"country_id": "us", "association_id": ASSOCIATION_ID}, - ), - ( - "list_user_households", - { - "country_id": "us", - "user_id": USER_ID, - "household_id": HOUSEHOLD_ID, - "offset": 2, - "limit": 3, - }, - ), - ] - - -def test_collection_rejects_missing_unknown_duplicate_and_invalid_queries() -> None: - service = FakeUserHouseholdService() - client, _flask_calls = _client(service) - - responses = [ - client.get("/v2/user-households?country_id=us"), - client.get(f"/v2/user-households?country_id=us&user_id={USER_ID}&search=home"), - client.get( - f"/v2/user-households?country_id=us&country_id=uk&user_id={USER_ID}" - ), - client.get(f"/v2/user-households?country_id=us&user_id={USER_ID}&limit=501"), - ] - - assert [response.status_code for response in responses] == [422] * 4 + assert response.status_code == 400 assert service.calls == [] -def test_patch_supports_null_clearing_and_household_reassignment() -> None: - service = FakeUserHouseholdService() - client, flask_calls = _client(service) +def test_list_and_patch_use_the_session_user_and_legacy_fields() -> None: + client, service, _flask_calls = _client() - cleared = client.patch( + page = client.get( + "/v2/user-households?country_id=us&legacy_household_id=123&limit=1" + ) + patched = client.patch( f"/v2/user-households/{ASSOCIATION_ID}?country_id=us", - json={"name": None, "description": None}, + json={"legacy_household_id": 124}, ) reassigned = client.patch( f"/v2/user-households/{ASSOCIATION_ID}?country_id=us", - json={"household_id": str(REPLACEMENT_ID)}, + json={"household_id": str(HOUSEHOLD_ID)}, ) + user_filter = client.get(f"/v2/user-households?country_id=us&user_id={OWNER_ID}") - assert cleared.status_code == 200 - assert cleared.json()["result"]["item"]["name"] is None - assert reassigned.status_code == 200 - assert reassigned.json()["result"]["item"]["household_id"] == str(REPLACEMENT_ID) - assert [call[0] for call in service.calls] == [ - "patch_user_household", - "patch_user_household", - ] - assert flask_calls["count"] == 0 - - -def test_patch_rejects_empty_null_household_and_immutable_fields() -> None: - service = FakeUserHouseholdService() - client, _flask_calls = _client(service) - url = f"/v2/user-households/{ASSOCIATION_ID}?country_id=us" - - responses = [ - client.patch(url, json={}), - client.patch(url, json={"household_id": None}), - client.patch(url, json={"user_id": str(USER_ID)}), - client.patch(url, json={"country_id": "uk"}), - ] - - assert [response.status_code for response in responses] == [422] * 4 - assert service.calls == [] - - -def test_delete_returns_no_content_and_uses_country_scoped_identity() -> None: - service = FakeUserHouseholdService() - client, flask_calls = _client(service) - - response = client.delete(f"/v2/user-households/{ASSOCIATION_ID}?country_id=us") - - assert response.status_code == 204 - assert response.content == b"" - assert service.calls == [ - ( - "delete_user_household", - {"country_id": "us", "association_id": ASSOCIATION_ID}, - ) - ] - assert flask_calls["count"] == 0 + assert page.status_code == 200 + assert page.json()["result"]["has_more"] is True + assert patched.status_code == 200 + assert reassigned.status_code == 422 + assert user_filter.status_code == 422 + assert service.calls[0] == ( + "list_user_households", + { + "principal": PRINCIPAL, + "country_id": "us", + "household_id": None, + "legacy_household_id": 123, + "offset": 0, + "limit": 1, + }, + ) + assert service.calls[1][1]["association_input"].legacy_household_id == 124 + assert len(service.calls) == 2 @pytest.mark.parametrize( - ("error", "status", "message"), + ("error", "status"), [ - (AssociationCountryConflictError("different country"), 400, "country"), - (AssociationHouseholdNotFoundError("household not found"), 404, "not found"), - (AssociationUserNotFoundError("user not found"), 404, "not found"), - (UserHouseholdNotFoundError("association not found"), 404, "not found"), - ( - IntegrityError("statement secret", {}, Exception("credential")), - 409, - "conflicts", - ), - (V2ConfigurationError("postgresql://secret"), 503, "unavailable"), - ( - OperationalError("statement secret", {}, Exception("credential")), - 503, - "unavailable", - ), - (TimeoutError("pool timeout"), 503, "unavailable"), - (RuntimeError("credential and caller data"), 500, "operation failed"), + (AssociationCountryConflictError("country"), 400), + (AssociationHouseholdNotFoundError("household was not found"), 404), + (UserHouseholdNotFoundError("association was not found"), 404), + (IntegrityError("secret", {}, Exception("credential")), 409), + (LegacyReferenceNotFoundError("v1 household 9 was not found"), 422), + (V2ConfigurationError("postgresql://secret"), 503), + (SQLAlchemyError("secret"), 503), + (RuntimeError("credential"), 500), ], ) -def test_association_failures_map_to_secret_safe_typed_errors( - error: Exception, - status: int, - message: str, +def test_failures_map_to_secret_safe_typed_errors( + error: Exception, status: int ) -> None: - service = FakeUserHouseholdService() + service = RecordingService(_result) service.error = error - client, _flask_calls = _client(service) + client, _service, _flask_calls = _client(service) - response = client.get(f"/v2/user-households/{ASSOCIATION_ID}?country_id=us") + response = client.delete(f"/v2/user-households/{ASSOCIATION_ID}?country_id=us") assert response.status_code == status assert response.json()["status"] == "error" - assert message in response.json()["message"].lower() assert "secret" not in response.text assert "credential" not in response.text -def test_openapi_publishes_complete_no_auth_association_contracts() -> None: - service = FakeUserHouseholdService() - client, _flask_calls = _client(service) - schema = client.get("/v2/openapi.json").json() - - assert { - path for path in schema["paths"] if path.startswith("/v2/user-households") - } == { - "/v2/user-households", - "/v2/user-households/{association_id}", - } - collection = schema["paths"]["/v2/user-households"]["get"] - parameters = {item["name"]: item for item in collection["parameters"]} - assert parameters["country_id"]["required"] is True - assert parameters["user_id"]["required"] is True - assert "does not prove caller control" in parameters["user_id"]["description"] - assert parameters["household_id"]["schema"]["anyOf"][0]["format"] == "uuid" - assert parameters["limit"]["schema"]["maximum"] == 500 - assert set(schema["paths"]["/v2/user-households"]) == {"get", "post"} - assert set(schema["paths"]["/v2/user-households/{association_id}"]) == { - "get", - "patch", - "delete", - } - for path, method in ( - ("/v2/user-households", "post"), - ("/v2/user-households", "get"), - ("/v2/user-households/{association_id}", "get"), - ("/v2/user-households/{association_id}", "patch"), - ("/v2/user-households/{association_id}", "delete"), - ): - operation = schema["paths"][path][method] - assert "security" not in operation - assert {"400", "404", "409", "422", "500", "503"} <= set(operation["responses"]) - item = schema["components"]["schemas"]["UserHouseholdItem"] - assert set(item["required"]) == { - "id", - "country_id", - "user_id", - "household_id", - "name", - "description", - "created_at", - "updated_at", - } - patch = schema["paths"]["/v2/user-households/{association_id}"]["patch"] - patch_ref = patch["requestBody"]["content"]["application/json"]["schema"]["$ref"] - patch_schema = schema["components"]["schemas"][patch_ref.rsplit("/", 1)[-1]] - assert patch_schema["additionalProperties"] is False - assert set(patch_schema["properties"]) == { - "household_id", - "name", - "description", - } - - def test_patch_preflight_and_native_routes_avoid_cloud_sql_and_flask( monkeypatch, ) -> None: @@ -413,20 +205,53 @@ def reject_cloud_sql(): "policyengine_api.data.orm.get_v1_session_factory", reject_cloud_sql, ) - service = FakeUserHouseholdService() - client, flask_calls = _client(service) + client, service, flask_calls = _client() preflight = client.options( f"/v2/user-households/{ASSOCIATION_ID}", headers={ "Origin": "https://app.example", "Access-Control-Request-Method": "PATCH", - "Access-Control-Request-Headers": "content-type", + "Access-Control-Request-Headers": "authorization,content-type", }, ) detail = client.get(f"/v2/user-households/{ASSOCIATION_ID}?country_id=us") assert preflight.status_code == 200 assert "PATCH" in preflight.headers["access-control-allow-methods"] + assert "authorization" in preflight.headers["access-control-allow-headers"] assert detail.status_code == 200 assert service.calls[0][0] == "get_user_household" assert flask_calls["count"] == 0 + + +def test_openapi_publishes_signed_in_household_contract() -> None: + client, _service, _flask_calls = _client() + schema = client.get("/v2/openapi.json").json() + + collection = schema["paths"]["/v2/user-households"]["get"] + assert {item["name"] for item in collection["parameters"]} == { + "country_id", + "offset", + "limit", + "household_id", + "legacy_household_id", + } + patch = schema["paths"]["/v2/user-households/{association_id}"]["patch"] + patch_ref = patch["requestBody"]["content"]["application/json"]["schema"]["$ref"] + patch_schema = schema["components"]["schemas"][patch_ref.rsplit("/", 1)[-1]] + assert set(patch_schema["properties"]) == { + "legacy_household_id", + "name", + "description", + } + assert set(schema["components"]["schemas"]["UserHouseholdItem"]["required"]) == { + "id", + "country_id", + "user_id", + "household_id", + "legacy_household_id", + "name", + "description", + "created_at", + "updated_at", + } diff --git a/tests/unit/v2/test_user_household_service.py b/tests/unit/v2/test_user_household_service.py index bd6c59d78..3dec5a04a 100644 --- a/tests/unit/v2/test_user_household_service.py +++ b/tests/unit/v2/test_user_household_service.py @@ -1,19 +1,13 @@ -"""Service tests for native v2 user-household associations.""" +"""Service tests for v2 user-household associations.""" from __future__ import annotations -from uuid import UUID, uuid4 - import pytest -import sqlalchemy as sa -from sqlalchemy.orm import sessionmaker -from sqlmodel import Session, create_engine, select - -from policyengine_api.data.v2.models import ( - Household, - User, - UserHouseholdAssociation, - V2_METADATA, +from sqlmodel import select + +from policyengine_api.data.v2.models import Household, UserHouseholdAssociation +from policyengine_api.services.v2.legacy_refs.validators import ( + LegacyReferenceNotFoundError, ) from policyengine_api.services.v2.user_households.database_session import ( UserHouseholdDatabaseSession, @@ -22,216 +16,128 @@ V2UserHouseholdService, ) from policyengine_api.services.v2.user_households.types import ( - UserHouseholdCreationInput, + UserHouseholdCreateFields, UserHouseholdUpdateInput, ) from policyengine_api.services.v2.user_households.validators import ( - AssociationCountryConflictError, AssociationHouseholdNotFoundError, - AssociationUserNotFoundError, UserHouseholdNotFoundError, ) - - -USER_ID = UUID("00000000-0000-0000-0000-000000000070") -OTHER_USER_ID = UUID("00000000-0000-0000-0000-000000000071") +from tests.unit.v2.user_service_support import ( + ALICE, + BOB, + MAPPED_LEGACY_HOUSEHOLD_ID, + default_legacy_references, + v2_store, +) @pytest.fixture -def association_store(): - engine = create_engine("sqlite://") - - @sa.event.listens_for(engine, "connect") - def enable_foreign_keys(dbapi_connection, _connection_record) -> None: - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() - - V2_METADATA.create_all(engine) - sessions = sessionmaker(engine, class_=Session, expire_on_commit=False) - with sessions.begin() as session: - first = Household( - country_id="us", - default_year=2026, - household_data={"people": [], "household": []}, - canonicalization_version=1, - content_hash="a" * 64, - ) - replacement = Household( - country_id="us", - default_year=2027, - household_data={"people": [], "household": []}, - canonicalization_version=1, - content_hash="b" * 64, +def store(): + with v2_store() as selected: + service = V2UserHouseholdService( + UserHouseholdDatabaseSession(selected.sessions), + default_legacy_references(), ) - uk = Household( - country_id="uk", - default_year=2026, - household_data={"people": [], "household": [], "benunit": []}, - canonicalization_version=1, - content_hash="c" * 64, - ) - session.add_all( - [ - first, - replacement, - uk, - User(id=USER_ID, primary_country="us"), - User(id=OTHER_USER_ID, primary_country="us"), - ] - ) - session.flush() - identities = (first.id, replacement.id, uk.id) + yield service, selected - yield ( - V2UserHouseholdService(UserHouseholdDatabaseSession(sessions)), - sessions, - identities, - ) - engine.dispose() - -def _command(household_id, **changes) -> UserHouseholdCreationInput: - values = { - "country_id": "us", - "user_id": USER_ID, - "household_id": household_id, - "name": "Saved household", - "description": "Personal note", - } +def _fields(**changes) -> UserHouseholdCreateFields: + values: dict[str, object] = {"country_id": "us", "legacy_household_id": 10} values.update(changes) - return UserHouseholdCreationInput.model_validate(values) + return UserHouseholdCreateFields.model_validate(values) -def test_create_allows_distinct_duplicate_links_for_an_existing_user( - association_store, -) -> None: - service, sessions, (household_id, _replacement_id, _uk_id) = association_store +def test_legacy_households_resolve_copied_v2_households(store) -> None: + service, selected = store - first = service.create_user_household(_command(household_id)) - second = service.create_user_household(_command(household_id, name="Second save")) + unmapped = service.create_user_household( + principal=ALICE, association_input=_fields(name="Unmapped") + ) + mapped = service.create_user_household( + principal=ALICE, + association_input=_fields(legacy_household_id=MAPPED_LEGACY_HOUSEHOLD_ID), + ) + v2_only = service.create_user_household( + principal=ALICE, + association_input=UserHouseholdCreateFields( + country_id="us", household_id=selected.household_id + ), + ) - assert first.id != second.id - assert first.user_id == USER_ID - assert second.household_id == household_id - with sessions() as session: - assert len(session.exec(select(UserHouseholdAssociation)).all()) == 2 + assert (unmapped.household_id, unmapped.legacy_household_id) == (None, 10) + assert mapped.household_id == selected.household_id + assert v2_only.legacy_household_id is None + assert unmapped.user_id == mapped.user_id == v2_only.user_id -def test_create_rejects_missing_dependencies_and_country_conflict( - association_store, -) -> None: - service, _sessions, (household_id, _replacement_id, _uk_id) = association_store +def test_missing_references_are_rejected(store) -> None: + service, selected = store + with pytest.raises(LegacyReferenceNotFoundError): + service.create_user_household( + principal=ALICE, association_input=_fields(legacy_household_id=404) + ) with pytest.raises(AssociationHouseholdNotFoundError): - service.create_user_household(_command(uuid4())) - with pytest.raises(AssociationUserNotFoundError): - service.create_user_household(_command(household_id, user_id=uuid4())) - with pytest.raises(AssociationCountryConflictError): - service.create_user_household(_command(household_id, country_id="uk")) - - -def test_detail_list_filter_and_pagination_are_country_scoped( - association_store, -) -> None: - service, _sessions, (household_id, replacement_id, _uk_id) = association_store - first = service.create_user_household(_command(household_id, name="First")) - service.create_user_household(_command(replacement_id, name="Second")) - service.create_user_household( - _command(household_id, user_id=OTHER_USER_ID, name="Other") - ) + service.create_user_household( + principal=ALICE, + association_input=UserHouseholdCreateFields( + country_id="us", household_id=selected.policy_id + ), + ) - detail = service.get_user_household(country_id="us", association_id=first.id) - filtered = service.list_user_households( - country_id="us", - user_id=USER_ID, - household_id=household_id, - limit=1, - ) - second_page = service.list_user_households( - country_id="us", - user_id=USER_ID, - offset=1, - limit=1, + +def test_ownership_repointing_and_deletion(store) -> None: + service, selected = store + created = service.create_user_household( + principal=ALICE, association_input=_fields(name="Family") ) - assert detail.id == first.id - assert [item.name for item in filtered.items] == ["First"] - assert filtered.has_more is False - assert [item.name for item in second_page.items] == ["Second"] with pytest.raises(UserHouseholdNotFoundError): - service.get_user_household(country_id="uk", association_id=first.id) - - -def test_patch_clears_presentation_data_and_reassigns_without_changing_identity( - association_store, -) -> None: - service, sessions, (household_id, replacement_id, _uk_id) = association_store - created = service.create_user_household(_command(household_id)) + service.get_user_household( + principal=BOB, country_id="us", association_id=created.id + ) + with pytest.raises(UserHouseholdNotFoundError): + service.delete_user_household( + principal=BOB, country_id="us", association_id=created.id + ) + assert service.list_user_households(principal=BOB, country_id="us").items == () - cleared = service.patch_user_household( + repointed = service.patch_user_household( + principal=ALICE, country_id="us", association_id=created.id, - association_input=UserHouseholdUpdateInput(name=None, description=None), + association_input=UserHouseholdUpdateInput( + legacy_household_id=MAPPED_LEGACY_HOUSEHOLD_ID + ), ) - reassigned = service.patch_user_household( + cleared = service.patch_user_household( + principal=ALICE, country_id="us", association_id=created.id, - association_input=UserHouseholdUpdateInput(household_id=replacement_id), + association_input=UserHouseholdUpdateInput(legacy_household_id=11, name=None), + ) + assert repointed.household_id == selected.household_id + assert (cleared.household_id, cleared.legacy_household_id, cleared.name) == ( + None, + 11, + None, ) - assert cleared.name is None - assert cleared.description is None - assert reassigned.id == created.id - assert reassigned.user_id == created.user_id - assert reassigned.country_id == created.country_id - assert reassigned.household_id == replacement_id - with sessions() as session: - assert session.get(Household, household_id) is not None - assert session.get(Household, replacement_id) is not None - - -def test_failed_reassignment_leaves_association_unchanged(association_store) -> None: - service, sessions, (household_id, _replacement_id, uk_id) = association_store - created = service.create_user_household(_command(household_id)) - - with pytest.raises(AssociationHouseholdNotFoundError): - service.patch_user_household( - country_id="us", - association_id=created.id, - association_input=UserHouseholdUpdateInput(household_id=uuid4()), - ) - with pytest.raises(AssociationCountryConflictError): - service.patch_user_household( - country_id="us", - association_id=created.id, - association_input=UserHouseholdUpdateInput(household_id=uk_id), - ) - - with sessions() as session: - stored = session.get(UserHouseholdAssociation, created.id) - assert stored is not None - assert stored.household_id == household_id - - -def test_delete_removes_only_association(association_store) -> None: - service, sessions, (household_id, _replacement_id, _uk_id) = association_store - created = service.create_user_household(_command(household_id)) - - service.delete_user_household(country_id="us", association_id=created.id) - - with sessions() as session: - assert session.get(UserHouseholdAssociation, created.id) is None - assert session.get(Household, household_id) is not None - assert session.get(User, USER_ID) is not None + service.delete_user_household( + principal=ALICE, country_id="us", association_id=created.id + ) + with selected.sessions() as session: + assert session.exec(select(UserHouseholdAssociation)).all() == [] + assert session.get(Household, selected.household_id) is not None -def test_patch_rejects_empty_null_household_and_immutable_fields() -> None: +def test_patch_input_rejects_reassignment_and_empty_changes() -> None: with pytest.raises(ValueError): UserHouseholdUpdateInput() with pytest.raises(ValueError): - UserHouseholdUpdateInput(household_id=None) - with pytest.raises(ValueError): - UserHouseholdUpdateInput.model_validate({"user_id": str(USER_ID)}) + UserHouseholdUpdateInput.model_validate( + {"household_id": "00000000-0000-0000-0000-000000000001"} + ) with pytest.raises(ValueError): - UserHouseholdUpdateInput.model_validate({"country_id": "us"}) + UserHouseholdUpdateInput(legacy_household_id=None) diff --git a/tests/unit/v2/test_user_policy_routes.py b/tests/unit/v2/test_user_policy_routes.py index 922a99e08..a4c6094fa 100644 --- a/tests/unit/v2/test_user_policy_routes.py +++ b/tests/unit/v2/test_user_policy_routes.py @@ -1,17 +1,21 @@ -"""Native FastAPI contract tests for v2 user-policy associations.""" +"""Native FastAPI contract tests for signed-in v2 user-policy associations.""" from __future__ import annotations -from datetime import datetime, timedelta, timezone -from uuid import UUID, uuid4 +from datetime import timedelta +from uuid import UUID -from fastapi.testclient import TestClient -from flask import Flask, jsonify import pytest -from sqlalchemy.exc import OperationalError, SQLAlchemyError, TimeoutError +from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError -from policyengine_api.asgi_factory import create_asgi_app from policyengine_api.data.v2.settings import V2ConfigurationError +from policyengine_api.services.v2.associations.validators import ( + AssociationReferenceConflictError, + MirrorOwnedAssociationError, +) +from policyengine_api.services.v2.legacy_refs.validators import ( + LegacyReferenceNotFoundError, +) from policyengine_api.services.v2.user_policies.types import ( UserPolicyPage, UserPolicyRead, @@ -19,145 +23,76 @@ from policyengine_api.services.v2.user_policies.validators import ( AssociationCountryConflictError, AssociationPolicyNotFoundError, - AssociationUserNotFoundError, UserPolicyNotFoundError, ) -from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies -from policyengine_api.migration_flags import ( - RouteImplementation, - RouteImplementationSettings, +from tests.unit.v2.user_route_support import ( + CREATED_AT, + OWNER_ID, + PRINCIPAL, + RecordingService, + user_route_client, ) ASSOCIATION_ID = UUID("00000000-0000-0000-0000-000000000060") POLICY_ID = UUID("00000000-0000-0000-0000-000000000010") -USER_ID = UUID("00000000-0000-0000-0000-000000000070") -CREATED_AT = datetime(2026, 1, 1, tzinfo=timezone.utc) - - -def _association_read( - *, - association_id: UUID = ASSOCIATION_ID, - country_id: str = "us", - user_id: UUID = USER_ID, - policy_id: UUID = POLICY_ID, - name: str | None = "Saved reform", - description: str | None = "Personal note", -) -> UserPolicyRead: - return UserPolicyRead( - id=association_id, - country_id=country_id, - user_id=user_id, - policy_id=policy_id, - name=name, - description=description, - created_at=CREATED_AT, - updated_at=CREATED_AT + timedelta(seconds=1), - ) - +PROVENANCE = {"source": "chat", "ref": "thread-1", "confidence": "exact"} -class FakeUserPolicyService: - def __init__(self) -> None: - self.error: Exception | None = None - self.calls: list[tuple[str, object]] = [] - self.next_item = _association_read() - def _raise(self) -> None: - if self.error is not None: - raise self.error - - def create_user_policy(self, association_input) -> UserPolicyRead: - self.calls.append(("create_user_policy", association_input)) - self._raise() - return self.next_item +def _association_read(**changes) -> UserPolicyRead: + values = { + "id": ASSOCIATION_ID, + "country_id": "us", + "user_id": OWNER_ID, + "policy_id": None, + "legacy_policy_id": 95762, + "name": "Saved reform", + "description": "Personal note", + "baseline": "current-law", + "provenance": PROVENANCE, + "created_at": CREATED_AT, + "updated_at": CREATED_AT + timedelta(seconds=1), + } + values.update(changes) + return UserPolicyRead(**values) - def get_user_policy(self, **identity) -> UserPolicyRead: - self.calls.append(("get_user_policy", identity)) - self._raise() - return self.next_item - def list_user_policies(self, **filters) -> UserPolicyPage: - self.calls.append(("list_user_policies", filters)) - self._raise() +def _result(name: str, kwargs: dict): + if name == "list_user_policies": return UserPolicyPage( - items=(self.next_item,), - offset=filters["offset"], - limit=filters["limit"], + items=(_association_read(),), + offset=kwargs["offset"], + limit=kwargs["limit"], has_more=False, ) + if name == "delete_user_policy": + return None + return _association_read() - def patch_user_policy(self, **changes) -> UserPolicyRead: - self.calls.append(("patch_user_policy", changes)) - self._raise() - association_input = changes["association_input"] - name = ( - association_input.name - if "name" in association_input.model_fields_set - else "Saved reform" - ) - description = ( - association_input.description - if "description" in association_input.model_fields_set - else "Personal note" - ) - return _association_read(name=name, description=description) - - def delete_user_policy(self, **identity) -> None: - self.calls.append(("delete_user_policy", identity)) - self._raise() - - -def _client( - service: FakeUserPolicyService, -) -> tuple[TestClient, dict[str, int]]: - flask_calls = {"count": 0} - flask_app = Flask(__name__) - - @flask_app.route("/", methods=["GET", "POST", "PATCH", "DELETE"]) - def fallback(resource: str): - flask_calls["count"] += 1 - return jsonify({"source": "flask", "resource": resource}) - - dependencies = NativeRouteDependencies( - readiness_probe=lambda: True, - gateway_client_factory=lambda: None, - metadata_reader_factory=lambda: None, - specification_provider=lambda: {}, - v2_user_policy_service_factory=lambda: service, - ) - settings = RouteImplementationSettings( - health=RouteImplementation.FLASK_FALLBACK, - specification=RouteImplementation.FLASK_FALLBACK, - metadata=RouteImplementation.FLASK_FALLBACK, - ) - return ( - TestClient( - create_asgi_app( - flask_app, - dependencies=dependencies, - route_settings=settings, - ), - raise_server_exceptions=False, - ), - flask_calls, + +def _client(service: RecordingService | None = None): + service = service or RecordingService(_result) + client, flask_calls = user_route_client( + v2_user_policy_service_factory=lambda: service ) + return client, service, flask_calls def _body(**changes) -> dict[str, object]: body: dict[str, object] = { "country_id": "us", - "user_id": str(USER_ID), - "policy_id": str(POLICY_ID), + "legacy_policy_id": 95762, "name": "Saved reform", "description": "Personal note", + "baseline": "current-law", + "provenance": PROVENANCE, } body.update(changes) return body -def test_create_returns_complete_distinct_association_contract() -> None: - service = FakeUserPolicyService() - client, flask_calls = _client(service) +def test_create_returns_complete_contract_owned_by_the_session_user() -> None: + client, service, flask_calls = _client() response = client.post("/v2/user-policies?country_id=US", json=_body()) @@ -169,69 +104,88 @@ def test_create_returns_complete_distinct_association_contract() -> None: "item": { "id": str(ASSOCIATION_ID), "country_id": "us", - "user_id": str(USER_ID), - "policy_id": str(POLICY_ID), + "user_id": str(OWNER_ID), + "policy_id": None, + "legacy_policy_id": 95762, "name": "Saved reform", "description": "Personal note", + "baseline": "current-law", + "provenance": PROVENANCE, "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:01Z", } }, } - command = service.calls[0][1] - assert command.user_id == USER_ID - assert command.policy_id == POLICY_ID + name, kwargs = service.calls[0] + assert name == "create_user_policy" + assert kwargs["principal"] == PRINCIPAL + fields = kwargs["association_input"] + assert fields.legacy_policy_id == 95762 + assert fields.policy_id is None + assert fields.provenance.source == "chat" + assert not hasattr(fields, "user_id") assert flask_calls["count"] == 0 -def test_repeated_create_calls_service_twice_without_link_deduplication() -> None: - service = FakeUserPolicyService() - client, _flask_calls = _client(service) +def test_create_accepts_a_v2_policy_reference() -> None: + client, service, _flask_calls = _client() - first = client.post("/v2/user-policies?country_id=us", json=_body()) - service.next_item = _association_read(association_id=uuid4()) - second = client.post("/v2/user-policies?country_id=us", json=_body()) + response = client.post( + "/v2/user-policies?country_id=us", + json={"country_id": "us", "policy_id": str(POLICY_ID)}, + ) - assert first.status_code == second.status_code == 201 - assert first.json()["result"]["item"]["id"] != second.json()["result"]["item"]["id"] - assert [call[0] for call in service.calls] == [ - "create_user_policy", - "create_user_policy", - ] + assert response.status_code == 201 + fields = service.calls[0][1]["association_input"] + assert (fields.policy_id, fields.legacy_policy_id) == (POLICY_ID, None) -def test_create_rejects_country_mismatch_and_invalid_fields() -> None: - service = FakeUserPolicyService() - client, _flask_calls = _client(service) +@pytest.mark.parametrize( + "body", + [ + _body(user_id=str(OWNER_ID)), + {"country_id": "us", "name": "No reference"}, + _body(legacy_policy_id="95762"), + _body(legacy_policy_id=0), + _body(legacy_policy_id=True), + _body(baseline="baseline"), + _body(provenance={"source": "email"}), + _body(provenance={"source": "chat", "extra": 1}), + _body(client_ref="rf-1"), + _body(name="x" * 256), + ], +) +def test_create_rejects_user_ids_missing_references_and_invalid_fields( + body: dict, +) -> None: + client, service, _flask_calls = _client() - mismatch = client.post("/v2/user-policies?country_id=uk", json=_body()) - invalid_user = client.post( - "/v2/user-policies?country_id=us", - json=_body(user_id="not-a-uuid"), - ) - long_name = client.post( - "/v2/user-policies?country_id=us", - json=_body(name="x" * 256), - ) + response = client.post("/v2/user-policies?country_id=us", json=body) - assert mismatch.status_code == 400 - assert invalid_user.status_code == 422 - assert invalid_user.json() == { + assert response.status_code == 422 + assert response.json() == { "status": "error", "message": "Invalid API v2 request", } - assert long_name.status_code == 422 assert service.calls == [] -def test_detail_and_list_use_country_user_policy_and_pagination_filters() -> None: - service = FakeUserPolicyService() - client, _flask_calls = _client(service) +def test_create_rejects_country_mismatch() -> None: + client, service, _flask_calls = _client() + + response = client.post("/v2/user-policies?country_id=uk", json=_body()) + + assert response.status_code == 400 + assert service.calls == [] + + +def test_detail_and_list_are_scoped_to_the_session_user() -> None: + client, service, _flask_calls = _client() detail = client.get(f"/v2/user-policies/{ASSOCIATION_ID}?country_id=US") page = client.get( - f"/v2/user-policies?country_id=us&user_id={USER_ID}" - f"&policy_id={POLICY_ID}&offset=2&limit=3" + f"/v2/user-policies?country_id=us&policy_id={POLICY_ID}" + "&legacy_policy_id=95762&offset=2&limit=3" ) assert detail.status_code == 200 @@ -239,14 +193,19 @@ def test_detail_and_list_use_country_user_policy_and_pagination_filters() -> Non assert service.calls == [ ( "get_user_policy", - {"country_id": "us", "association_id": ASSOCIATION_ID}, + { + "principal": PRINCIPAL, + "country_id": "us", + "association_id": ASSOCIATION_ID, + }, ), ( "list_user_policies", { + "principal": PRINCIPAL, "country_id": "us", - "user_id": USER_ID, "policy_id": POLICY_ID, + "legacy_policy_id": 95762, "offset": 2, "limit": 3, }, @@ -260,50 +219,55 @@ def test_detail_and_list_use_country_user_policy_and_pagination_filters() -> Non } -def test_collection_rejects_missing_unknown_duplicate_and_invalid_queries() -> None: - service = FakeUserPolicyService() - client, _flask_calls = _client(service) +def test_collection_rejects_user_ids_unknown_duplicate_and_invalid_queries() -> None: + client, service, _flask_calls = _client() responses = [ - client.get("/v2/user-policies?country_id=us"), - client.get(f"/v2/user-policies?country_id=us&user_id={USER_ID}&search=reform"), - client.get(f"/v2/user-policies?country_id=us&country_id=uk&user_id={USER_ID}"), - client.get(f"/v2/user-policies?country_id=us&user_id={USER_ID}&limit=501"), + client.get(f"/v2/user-policies?country_id=us&user_id={OWNER_ID}"), + client.get("/v2/user-policies"), + client.get("/v2/user-policies?country_id=us&search=reform"), + client.get("/v2/user-policies?country_id=us&country_id=uk"), + client.get("/v2/user-policies?country_id=us&limit=501"), + client.get("/v2/user-policies?country_id=us&legacy_policy_id=0"), ] - assert [response.status_code for response in responses] == [422] * 4 + assert [response.status_code for response in responses] == [422] * 6 assert all(response.json()["status"] == "error" for response in responses) assert service.calls == [] -def test_patch_supports_explicit_null_and_rejects_identity_or_empty_changes() -> None: - service = FakeUserPolicyService() - client, flask_calls = _client(service) +def test_patch_repoints_legacy_reference_and_rejects_invalid_changes() -> None: + client, service, flask_calls = _client() - cleared = client.patch( - f"/v2/user-policies/{ASSOCIATION_ID}?country_id=us", - json={"name": None}, - ) - identity = client.patch( - f"/v2/user-policies/{ASSOCIATION_ID}?country_id=us", - json={"policy_id": str(uuid4())}, - ) - empty = client.patch( + repointed = client.patch( f"/v2/user-policies/{ASSOCIATION_ID}?country_id=us", - json={}, + json={"legacy_policy_id": 101, "name": None}, ) + invalid = [ + client.patch( + f"/v2/user-policies/{ASSOCIATION_ID}?country_id=us", + json=body, + ) + for body in ( + {"policy_id": str(POLICY_ID)}, + {"user_id": str(OWNER_ID)}, + {"client_ref": "rf-2"}, + {"legacy_policy_id": None}, + {}, + ) + ] - assert cleared.status_code == 200 - assert cleared.json()["result"]["item"]["name"] is None - assert identity.status_code == 422 - assert empty.status_code == 422 + assert repointed.status_code == 200 + association_input = service.calls[0][1]["association_input"] + assert association_input.legacy_policy_id == 101 + assert association_input.model_fields_set == {"legacy_policy_id", "name"} + assert [response.status_code for response in invalid] == [422] * 5 assert [call[0] for call in service.calls] == ["patch_user_policy"] assert flask_calls["count"] == 0 -def test_delete_returns_no_content_and_uses_country_scoped_identity() -> None: - service = FakeUserPolicyService() - client, flask_calls = _client(service) +def test_delete_returns_no_content_for_the_session_user() -> None: + client, service, _flask_calls = _client() response = client.delete(f"/v2/user-policies/{ASSOCIATION_ID}?country_id=us") @@ -312,40 +276,52 @@ def test_delete_returns_no_content_and_uses_country_scoped_identity() -> None: assert service.calls == [ ( "delete_user_policy", - {"country_id": "us", "association_id": ASSOCIATION_ID}, + { + "principal": PRINCIPAL, + "country_id": "us", + "association_id": ASSOCIATION_ID, + }, ) ] - assert flask_calls["count"] == 0 @pytest.mark.parametrize( ("error", "status", "message"), [ (AssociationCountryConflictError("different country"), 400, "country"), + (AssociationReferenceConflictError("policy_id mismatch"), 400, "mismatch"), (AssociationPolicyNotFoundError("policy was not found"), 404, "not found"), - (AssociationUserNotFoundError("user was not found"), 404, "not found"), (UserPolicyNotFoundError("association was not found"), 404, "not found"), + (MirrorOwnedAssociationError("maintained from v1"), 409, "maintained"), + ( + IntegrityError("statement secret", {}, Exception("credential")), + 409, + "conflicts", + ), + (LegacyReferenceNotFoundError("v1 policy 9 was not found"), 422, "v1 policy"), (V2ConfigurationError("postgresql://secret"), 503, "unavailable"), ( OperationalError("statement secret", {}, Exception("credential")), 503, "unavailable", ), - (TimeoutError("pool timeout"), 503, "unavailable"), (SQLAlchemyError("statement secret"), 503, "unavailable"), (RuntimeError("credential and caller data"), 500, "operation failed"), ], ) -def test_association_failures_map_to_secret_safe_typed_errors( +def test_failures_map_to_secret_safe_typed_errors( error: Exception, status: int, message: str, ) -> None: - service = FakeUserPolicyService() + service = RecordingService(_result) service.error = error - client, _flask_calls = _client(service) + client, _service, _flask_calls = _client(service) - response = client.get(f"/v2/user-policies/{ASSOCIATION_ID}?country_id=us") + response = client.patch( + f"/v2/user-policies/{ASSOCIATION_ID}?country_id=us", + json={"name": "Renamed"}, + ) assert response.status_code == status assert response.json()["status"] == "error" @@ -354,31 +330,21 @@ def test_association_failures_map_to_secret_safe_typed_errors( assert "credential" not in response.text -def test_openapi_publishes_complete_no_auth_association_contracts() -> None: - service = FakeUserPolicyService() - client, _flask_calls = _client(service) +def test_openapi_publishes_signed_in_association_contracts() -> None: + client, _service, _flask_calls = _client() schema = client.get("/v2/openapi.json").json() - assert { - path for path in schema["paths"] if path.startswith("/v2/user-policies") - } == { - "/v2/user-policies", - "/v2/user-policies/{association_id}", - } collection = schema["paths"]["/v2/user-policies"]["get"] parameters = {item["name"]: item for item in collection["parameters"]} - assert parameters["country_id"]["required"] is True - assert parameters["user_id"]["required"] is True - assert "does not prove caller control" in parameters["user_id"]["description"] - assert parameters["user_id"]["schema"]["format"] == "uuid" - assert parameters["limit"]["schema"]["maximum"] == 500 - assert set(schema["paths"]["/v2/user-policies"]) == {"get", "post"} - assert set(schema["paths"]["/v2/user-policies/{association_id}"]) == { - "get", - "patch", - "delete", + assert set(parameters) == { + "country_id", + "offset", + "limit", + "policy_id", + "legacy_policy_id", } - + assert parameters["legacy_policy_id"]["schema"]["anyOf"][0]["minimum"] == 1 + assert parameters["limit"]["schema"]["maximum"] == 500 for path, method in ( ("/v2/user-policies", "post"), ("/v2/user-policies", "get"), @@ -387,8 +353,9 @@ def test_openapi_publishes_complete_no_auth_association_contracts() -> None: ("/v2/user-policies/{association_id}", "delete"), ): operation = schema["paths"][path][method] - assert "security" not in operation - assert {"400", "404", "409", "422", "500", "503"} <= set(operation["responses"]) + assert {"400", "401", "403", "404", "409", "422", "500", "503"} <= set( + operation["responses"] + ) item_schema = schema["components"]["schemas"]["UserPolicyItem"] assert set(item_schema["required"]) == { @@ -396,16 +363,31 @@ def test_openapi_publishes_complete_no_auth_association_contracts() -> None: "country_id", "user_id", "policy_id", + "legacy_policy_id", "name", "description", + "baseline", + "provenance", "created_at", "updated_at", } + create_ref = schema["paths"]["/v2/user-policies"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"]["$ref"] + create_schema = schema["components"]["schemas"][create_ref.rsplit("/", 1)[-1]] + assert create_schema["additionalProperties"] is False + assert "user_id" not in create_schema["properties"] patch = schema["paths"]["/v2/user-policies/{association_id}"]["patch"] patch_ref = patch["requestBody"]["content"]["application/json"]["schema"]["$ref"] patch_schema = schema["components"]["schemas"][patch_ref.rsplit("/", 1)[-1]] assert patch_schema["additionalProperties"] is False - assert set(patch_schema["properties"]) == {"name", "description"} + assert set(patch_schema["properties"]) == { + "legacy_policy_id", + "name", + "description", + "baseline", + "provenance", + } def test_native_association_routes_do_not_use_cloud_sql_or_flask( @@ -418,8 +400,7 @@ def reject_cloud_sql(): "policyengine_api.data.orm.get_v1_session_factory", reject_cloud_sql, ) - service = FakeUserPolicyService() - client, flask_calls = _client(service) + client, service, flask_calls = _client() response = client.get(f"/v2/user-policies/{ASSOCIATION_ID}?country_id=us") diff --git a/tests/unit/v2/test_user_policy_service.py b/tests/unit/v2/test_user_policy_service.py index 3444bf9ae..be1a98464 100644 --- a/tests/unit/v2/test_user_policy_service.py +++ b/tests/unit/v2/test_user_policy_service.py @@ -1,234 +1,337 @@ -"""Service tests for native v2 user-policy associations.""" +"""Service tests for v2 user-policy associations.""" from __future__ import annotations -from datetime import datetime, timezone from uuid import UUID import pytest -import sqlalchemy as sa -from sqlalchemy.orm import sessionmaker -from sqlmodel import Session, create_engine, select +from sqlmodel import select from policyengine_api.data.v2.models import ( LegacyUserPolicyMapping, - Parameter, - ParameterValue, Policy, - TaxBenefitModel, - TaxBenefitModelVersion, User, + UserIdentity, UserPolicy, - V2_METADATA, +) +from policyengine_api.services.v2.associations.validators import ( + AssociationReferenceConflictError, + MirrorOwnedAssociationError, +) +from policyengine_api.services.v2.legacy_refs.validators import ( + LegacyReferenceNotFoundError, ) from policyengine_api.services.v2.user_policies.database_session import ( UserPolicyDatabaseSession, ) from policyengine_api.services.v2.user_policies.services import V2UserPolicyService +from policyengine_api.services.v2.user_policies.transformations import ( + project_legacy_user_policy, +) from policyengine_api.services.v2.user_policies.types import ( - UserPolicyCreationInput, + LegacyUserPolicySnapshot, + UserPolicyCreateFields, UserPolicyUpdateInput, ) from policyengine_api.services.v2.user_policies.validators import ( AssociationCountryConflictError, AssociationPolicyNotFoundError, - AssociationUserNotFoundError, UserPolicyNotFoundError, ) - - -USER_ID = UUID("00000000-0000-0000-0000-000000000070") -OTHER_USER_ID = UUID("00000000-0000-0000-0000-000000000071") +from tests.unit.v2.user_service_support import ( + ALICE, + BOB, + MAPPED_LEGACY_POLICY_ID, + default_legacy_references, + v2_store, +) @pytest.fixture -def association_store(): - engine = create_engine("sqlite://") - - @sa.event.listens_for(engine, "connect") - def enable_foreign_keys(dbapi_connection, _connection_record) -> None: - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() - - V2_METADATA.create_all(engine) - sessions = sessionmaker(engine, class_=Session, expire_on_commit=False) - with sessions.begin() as session: - model = TaxBenefitModel(name="policyengine-us") - version = TaxBenefitModelVersion( - model=model, - version="5.2.0", - current_law_id=1, - metadata_time_periods=[2026], - ) - parameter = Parameter( - name="gov.example.rate", - tax_benefit_model_version=version, +def store(): + with v2_store() as selected: + legacy_references = default_legacy_references() + service = V2UserPolicyService( + UserPolicyDatabaseSession(selected.sessions), + legacy_references, ) - policy = Policy( - country_id="us", - tax_benefit_model=model, - tax_benefit_model_version=version, - canonicalization_version=1, - content_hash="a" * 64, - ) - value = ParameterValue( - parameter=parameter, - policy=policy, - value_json=0.2, - start_date=datetime(2026, 1, 1, tzinfo=timezone.utc), - ) - session.add_all( - [ - value, - User(id=USER_ID, primary_country="us"), - User(id=OTHER_USER_ID, primary_country="us"), - ] - ) - session.flush() - identity = (policy.id, value.id) + yield service, selected, legacy_references - yield V2UserPolicyService(UserPolicyDatabaseSession(sessions)), sessions, identity - engine.dispose() - -def _command(policy_id, **changes) -> UserPolicyCreationInput: - values = { +def _fields(**changes) -> UserPolicyCreateFields: + values: dict[str, object] = { "country_id": "us", - "user_id": USER_ID, - "policy_id": policy_id, + "legacy_policy_id": 1, "name": "Saved reform", "description": "Personal note", } values.update(changes) - return UserPolicyCreationInput.model_validate(values) + return UserPolicyCreateFields.model_validate(values) + +def test_first_save_provisions_the_owner_and_stores_v3_fields(store) -> None: + service, selected, legacy_references = store -def test_create_allows_distinct_duplicate_links_for_an_existing_user( - association_store, -) -> None: - service, sessions, (policy_id, _value_id) = association_store + created = service.create_user_policy( + principal=ALICE, + association_input=_fields( + baseline="current-policy", + provenance={"source": "bill", "ref": "HR-1"}, + ), + ) - first = service.create_user_policy(_command(policy_id)) - second = service.create_user_policy(_command(policy_id, name="Second save")) + assert created.policy_id is None + assert created.legacy_policy_id == 1 + assert created.baseline == "current-policy" + assert created.provenance == {"source": "bill", "ref": "HR-1", "confidence": None} + with selected.sessions() as session: + identity = session.exec(select(UserIdentity)).one() + user = session.get(User, identity.user_id) + assert (identity.issuer, identity.subject) == (ALICE.issuer, ALICE.subject) + assert created.user_id == identity.user_id + assert user is not None and user.primary_country == "us" + assert legacy_references.calls == [("us", {"policy": [1]})] + + +def test_legacy_reference_resolves_the_mirrored_v2_policy(store) -> None: + service, selected, _legacy_references = store + + mapped = service.create_user_policy( + principal=ALICE, + association_input=_fields(legacy_policy_id=MAPPED_LEGACY_POLICY_ID), + ) + explicit = service.create_user_policy( + principal=ALICE, + association_input=_fields( + legacy_policy_id=MAPPED_LEGACY_POLICY_ID, + policy_id=selected.policy_id, + ), + ) + v2_only = service.create_user_policy( + principal=ALICE, + association_input=UserPolicyCreateFields( + country_id="us", policy_id=selected.policy_id + ), + ) - assert first.id != second.id - assert first.user_id == USER_ID - assert second.policy_id == policy_id - with sessions() as session: - assert len(session.exec(select(UserPolicy)).all()) == 2 + assert mapped.policy_id == explicit.policy_id == v2_only.policy_id + assert mapped.policy_id == selected.policy_id + assert v2_only.legacy_policy_id is None -def test_create_rejects_missing_policy_and_country_conflict( - association_store, -) -> None: - service, _sessions, (policy_id, _value_id) = association_store +def test_create_rejects_bad_references_before_writing(store) -> None: + service, selected, _legacy_references = store + with pytest.raises(LegacyReferenceNotFoundError, match="v1 policy 404"): + service.create_user_policy( + principal=ALICE, association_input=_fields(legacy_policy_id=404) + ) + with pytest.raises(LegacyReferenceNotFoundError): + service.create_user_policy( + principal=ALICE, + association_input=_fields(country_id="uk", legacy_policy_id=1), + ) with pytest.raises(AssociationPolicyNotFoundError): - service.create_user_policy(_command("00000000-0000-0000-0000-000000000099")) - with pytest.raises(AssociationUserNotFoundError): service.create_user_policy( - _command( - policy_id, - user_id="00000000-0000-0000-0000-000000000099", - ) + principal=ALICE, + association_input=_fields( + legacy_policy_id=None, + policy_id=UUID("00000000-0000-0000-0000-000000000099"), + ), ) with pytest.raises(AssociationCountryConflictError): - service.create_user_policy(_command(policy_id, country_id="uk")) + service.create_user_policy( + principal=ALICE, + association_input=_fields( + legacy_policy_id=None, policy_id=selected.uk_policy_id + ), + ) + with pytest.raises(AssociationReferenceConflictError): + service.create_user_policy( + principal=ALICE, + association_input=_fields( + legacy_policy_id=MAPPED_LEGACY_POLICY_ID, + policy_id=selected.uk_policy_id, + country_id="us", + ), + ) + with selected.sessions() as session: + assert session.exec(select(UserPolicy)).all() == [] + assert session.exec(select(User)).all() == [] -def test_detail_list_filter_and_pagination_are_country_scoped( - association_store, -) -> None: - service, _sessions, (policy_id, _value_id) = association_store - first = service.create_user_policy(_command(policy_id, name="First")) - service.create_user_policy(_command(policy_id, name="Second")) - service.create_user_policy(_command(policy_id, user_id=OTHER_USER_ID, name="Other")) - detail = service.get_user_policy( - country_id="us", - association_id=first.id, +def test_other_users_rows_are_not_found_and_lists_are_owner_scoped(store) -> None: + service, _selected, _legacy_references = store + alice_row = service.create_user_policy( + principal=ALICE, association_input=_fields(name="Alice") ) - page = service.list_user_policies( - country_id="us", - user_id=USER_ID, - policy_id=policy_id, - limit=1, - ) - second_page = service.list_user_policies( - country_id="us", - user_id=USER_ID, - offset=1, - limit=1, + service.create_user_policy(principal=BOB, association_input=_fields(name="Bob")) + service.create_user_policy( + principal=ALICE, association_input=_fields(name="Alice 2", legacy_policy_id=2) ) - assert detail.id == first.id - assert [item.name for item in page.items] == ["First"] + for operation in ( + lambda: service.get_user_policy( + principal=BOB, country_id="us", association_id=alice_row.id + ), + lambda: service.patch_user_policy( + principal=BOB, + country_id="us", + association_id=alice_row.id, + association_input=UserPolicyUpdateInput(name="stolen"), + ), + lambda: service.delete_user_policy( + principal=BOB, country_id="us", association_id=alice_row.id + ), + lambda: service.get_user_policy( + principal=ALICE, country_id="uk", association_id=alice_row.id + ), + ): + with pytest.raises(UserPolicyNotFoundError): + operation() + + page = service.list_user_policies(principal=ALICE, country_id="us", limit=1) + filtered = service.list_user_policies( + principal=ALICE, country_id="us", legacy_policy_id=2 + ) + assert [item.name for item in page.items] == ["Alice"] assert page.has_more is True - assert [item.name for item in second_page.items] == ["Second"] - with pytest.raises(UserPolicyNotFoundError): - service.get_user_policy(country_id="uk", association_id=first.id) + assert [item.name for item in filtered.items] == ["Alice 2"] + assert service.get_user_policy( + principal=ALICE, country_id="us", association_id=alice_row.id + ).name == ("Alice") -def test_patch_changes_only_supplied_fields_and_supports_null_clearing( - association_store, -) -> None: - service, _sessions, (policy_id, _value_id) = association_store - created = service.create_user_policy(_command(policy_id)) +def test_repointing_re_resolves_or_clears_the_v2_reference(store) -> None: + service, selected, legacy_references = store + created = service.create_user_policy(principal=ALICE, association_input=_fields()) - renamed = service.patch_user_policy( + mapped = service.patch_user_policy( + principal=ALICE, country_id="us", association_id=created.id, - association_input=UserPolicyUpdateInput(name="Renamed"), + association_input=UserPolicyUpdateInput( + legacy_policy_id=MAPPED_LEGACY_POLICY_ID + ), ) cleared = service.patch_user_policy( + principal=ALICE, + country_id="us", + association_id=created.id, + association_input=UserPolicyUpdateInput(legacy_policy_id=3, description=None), + ) + renamed = service.patch_user_policy( + principal=ALICE, country_id="us", association_id=created.id, - association_input=UserPolicyUpdateInput(description=None), + association_input=UserPolicyUpdateInput(name="Renamed"), ) - assert renamed.name == "Renamed" - assert renamed.description == "Personal note" - assert cleared.name == "Renamed" + assert (mapped.legacy_policy_id, mapped.policy_id) == ( + MAPPED_LEGACY_POLICY_ID, + selected.policy_id, + ) + assert (cleared.legacy_policy_id, cleared.policy_id) == (3, None) assert cleared.description is None - assert cleared.updated_at >= created.updated_at - assert (cleared.country_id, cleared.user_id, cleared.policy_id) == ( - "us", - USER_ID, - policy_id, + assert (renamed.legacy_policy_id, renamed.policy_id, renamed.name) == ( + 3, + None, + "Renamed", ) + with pytest.raises(LegacyReferenceNotFoundError): + service.patch_user_policy( + principal=ALICE, + country_id="us", + association_id=created.id, + association_input=UserPolicyUpdateInput(legacy_policy_id=404), + ) + assert legacy_references.calls[-1] == ("us", {"policy": [404]}) + + +def test_rows_owned_by_the_stage_10_mirror_conflict(store) -> None: + service, selected, _legacy_references = store + created = service.create_user_policy(principal=ALICE, association_input=_fields()) + with selected.sessions.begin() as session: + session.add( + LegacyUserPolicyMapping( + country_id="us", + legacy_user_policy_id=42, + user_policy_id=created.id, + fingerprint_version=1, + fingerprint_sha256="b" * 64, + ) + ) - -def test_delete_removes_mapping_but_preserves_policy_and_parameter_value( - association_store, -) -> None: - service, sessions, (policy_id, value_id) = association_store - created = service.create_user_policy(_command(policy_id)) - with sessions.begin() as session: - mapping = LegacyUserPolicyMapping( + with pytest.raises(MirrorOwnedAssociationError): + service.patch_user_policy( + principal=ALICE, country_id="us", - legacy_user_policy_id=42, - user_policy_id=created.id, - fingerprint_version=1, - fingerprint_sha256="b" * 64, + association_id=created.id, + association_input=UserPolicyUpdateInput(name="Renamed"), + ) + with pytest.raises(MirrorOwnedAssociationError): + service.delete_user_policy( + principal=ALICE, country_id="us", association_id=created.id ) - session.add(mapping) - session.flush() - mapping_id = mapping.id + assert service.get_user_policy( + principal=ALICE, country_id="us", association_id=created.id + ).name == ("Saved reform") - service.delete_user_policy(country_id="us", association_id=created.id) - with sessions() as session: +def test_delete_removes_only_the_association(store) -> None: + service, selected, _legacy_references = store + created = service.create_user_policy( + principal=ALICE, + association_input=_fields(legacy_policy_id=MAPPED_LEGACY_POLICY_ID), + ) + + service.delete_user_policy( + principal=ALICE, country_id="us", association_id=created.id + ) + + with selected.sessions() as session: assert session.get(UserPolicy, created.id) is None - assert session.get(LegacyUserPolicyMapping, mapping_id) is None - assert session.get(Policy, policy_id) is not None - assert session.get(ParameterValue, value_id) is not None + assert session.get(Policy, selected.policy_id) is not None + assert session.get(User, created.user_id) is not None + + +def test_stage_10_projection_records_the_v1_reform_id() -> None: + snapshot = LegacyUserPolicySnapshot.model_validate( + { + "country_id": "us", + "legacy_user_policy_id": 10, + "reform_id": 95762, + "reform_label": "Reform", + "baseline_id": 2, + "baseline_label": None, + "user_id": "auth0|legacy", + "year": "2026", + "geography": "us", + "dataset": None, + "number_of_provisions": 1, + "api_version": "1.0.0", + "added_date": 1, + "updated_date": 2, + } + ) + policy_id = UUID("00000000-0000-0000-0000-000000000010") + user_id = UUID("00000000-0000-0000-0000-000000000070") + + projection = project_legacy_user_policy( + snapshot, user_id=user_id, policy_id=policy_id + ) + + assert projection.legacy_policy_id == 95762 + assert projection.policy_id == policy_id -def test_patch_command_rejects_empty_and_immutable_fields() -> None: +def test_patch_input_requires_a_supported_field() -> None: with pytest.raises(ValueError): UserPolicyUpdateInput() with pytest.raises(ValueError): UserPolicyUpdateInput.model_validate( {"policy_id": "00000000-0000-0000-0000-000000000001"} ) + with pytest.raises(ValueError): + UserPolicyUpdateInput(legacy_policy_id=None) diff --git a/tests/unit/v2/test_user_route_auth.py b/tests/unit/v2/test_user_route_auth.py new file mode 100644 index 000000000..3601f944b --- /dev/null +++ b/tests/unit/v2/test_user_route_auth.py @@ -0,0 +1,489 @@ +"""Router-level authentication for every signed-in user route.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any +from uuid import UUID + +import pytest + +from policyengine_api.libs import user_auth +from policyengine_api.services.v2.identity.types import ( + UnauthenticatedPrincipal, + UserPrincipal, +) +from policyengine_api.services.v2.user_accounts.types import AccountExport +from policyengine_api.services.v2.user_policies.types import UserPolicyPage +from tests.unit.v2.user_route_support import RecordingService, user_route_client + + +ISSUER = "https://auth.example.test/" +AUDIENCE = "https://api.policyengine.org/" +ASSOCIATION_ID = "00000000-0000-0000-0000-000000000001" +BROWSER_USER_ID = UUID("0d9e8f7a-6b5c-4d3e-8f2a-1b0c9d8e7f6a") +UNAUTHENTICATED_HEADER = "PolicyEngine-Unauthenticated-User" + + +class FakeDecoder: + def __init__(self, claims: Mapping[str, Any] | Exception) -> None: + self.claims = claims + self.tokens: list[str] = [] + + def decode(self, token: str) -> Mapping[str, Any]: + self.tokens.append(token) + if isinstance(self.claims, Exception): + raise self.claims + return self.claims + + +@pytest.fixture +def configured_auth(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(user_auth.PUBLIC_API_AUTH_REQUIRED_ENV, "1") + monkeypatch.setenv(user_auth.PUBLIC_API_AUTH_ISSUER_ENV, ISSUER.rstrip("/")) + monkeypatch.setenv(user_auth.PUBLIC_API_AUTH_AUDIENCE_ENV, AUDIENCE) + + def install(claims: Mapping[str, Any] | Exception) -> FakeDecoder: + decoder = FakeDecoder(claims) + monkeypatch.setattr(user_auth, "_decoder", lambda issuer, audience: decoder) + return decoder + + return install + + +def _services() -> tuple[dict[str, RecordingService], dict[str, Any]]: + services = { + name: RecordingService() + for name in ("account", "policy", "household", "simulation", "report") + } + factories = { + "v2_account_service_factory": lambda: services["account"], + "v2_user_policy_service_factory": lambda: services["policy"], + "v2_user_household_service_factory": lambda: services["household"], + "v2_user_simulation_service_factory": lambda: services["simulation"], + "v2_user_report_service_factory": lambda: services["report"], + } + return services, factories + + +def _user_operations() -> list[tuple[str, str]]: + """Every signed-in operation, read from the built application's OpenAPI.""" + + _services_by_name, factories = _services() + client, _flask_calls = user_route_client(require_user=None, **factories) + paths = client.get("/v2/openapi.json").json()["paths"] + operations = sorted( + (method.upper(), path.replace("{association_id}", ASSOCIATION_ID)) + for path, methods in paths.items() + if path.startswith(("/v2/me", "/v2/user-")) + for method in methods + if method in {"get", "post", "patch", "delete"} + ) + # 5 account operations plus 5 for each of the 4 association resources. + assert len(operations) >= 25 + return operations + + +@pytest.mark.parametrize(("method", "path"), _user_operations()) +def test_every_user_route_fails_closed_without_a_token(method: str, path: str) -> None: + services, factories = _services() + client, flask_calls = user_route_client(require_user=None, **factories) + + response = client.request( + method, + f"{path}?country_id=us", + json={} if method in {"POST", "PATCH"} else None, + headers={"Origin": "https://app.policyengine.org"}, + ) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert response.json() == { + "status": "error", + "message": "Authentication is required", + } + assert "www-authenticate" in { + value.strip().lower() + for value in response.headers["access-control-expose-headers"].split(",") + } + assert all(not service.calls for service in services.values()) + assert flask_calls["count"] == 0 + + +@pytest.mark.parametrize( + ("method", "path", "body"), + [ + ("GET", "/v2/user-policies?country_id=zz&bogus=1&limit=0", None), + ("GET", "/v2/me", None), + ("POST", "/v2/user-reports?country_id=us", {"user_id": "spoofed"}), + ("PATCH", "/v2/user-households/not-a-uuid?country_id=us", {}), + ("POST", "/v2/user-policies?country_id=uk", {"country_id": "us"}), + ], +) +def test_authentication_precedes_query_body_and_path_validation( + method: str, path: str, body: dict | None +) -> None: + _services_by_name, factories = _services() + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.request(method, path, json=body) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + + +def test_invalid_tokens_are_rejected_with_an_invalid_token_challenge( + configured_auth, +) -> None: + import jwt + + decoder = configured_auth(jwt.ExpiredSignatureError("expired")) + _services_by_name, factories = _services() + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.get( + "/v2/me?country_id=us", + headers={"Authorization": "Bearer expired-token"}, + ) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == 'Bearer error="invalid_token"' + assert "expired-token" not in response.text + assert decoder.tokens == ["expired-token"] + + +def test_machine_client_principals_are_forbidden(configured_auth) -> None: + configured_auth( + {"iss": ISSUER, "sub": "abc123@clients", "gty": "client-credentials"} + ) + services, factories = _services() + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.get( + "/v2/user-policies?country_id=us", + headers={"Authorization": "Bearer machine-token"}, + ) + + assert response.status_code == 403 + assert response.json()["status"] == "error" + assert not services["policy"].calls + + +def test_valid_user_tokens_reach_the_service_as_principals(configured_auth) -> None: + configured_auth( + { + "iss": ISSUER, + "sub": "google-oauth2|42", + "email": "ada@example.com", + "email_verified": True, + } + ) + services, factories = _services() + services["policy"].next_result = None + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.delete( + "/v2/user-policies/00000000-0000-0000-0000-000000000001?country_id=us", + headers={"Authorization": "Bearer user-token"}, + ) + + assert response.status_code == 204 + assert services["policy"].calls[0][1]["principal"] == UserPrincipal( + issuer=ISSUER, + subject="google-oauth2|42", + email="ada@example.com", + ) + + +def test_unconfigured_authentication_is_unavailable_not_open( + monkeypatch: pytest.MonkeyPatch, +) -> None: + for name in user_auth.PUBLIC_API_AUTH_ENV_VARS: + monkeypatch.delenv(name, raising=False) + services, factories = _services() + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.get( + "/v2/me?country_id=us", + headers={"Authorization": "Bearer user-token"}, + ) + + assert response.status_code == 503 + assert not services["account"].calls + + +def test_default_dependencies_publish_the_bearer_security_scheme() -> None: + _services_by_name, factories = _services() + client, _flask_calls = user_route_client(require_user=None, **factories) + + schema = client.get("/v2/openapi.json").json() + + security_schemes = schema["components"]["securitySchemes"] + assert "UserAccessToken" in security_schemes + assert security_schemes["UnauthenticatedUser"] == { + "type": "apiKey", + "in": "header", + "name": UNAUTHENTICATED_HEADER, + "description": security_schemes["UnauthenticatedUser"]["description"], + } + for path in ("/v2/me", "/v2/user-policies", "/v2/user-reports"): + # Separate requirement objects are alternatives in OpenAPI. + assert schema["paths"][path]["get"]["security"] == [ + {"UserAccessToken": []}, + {"UnauthenticatedUser": []}, + ] + assert "security" not in schema["paths"]["/v2/policies"]["get"] + upgrade_header = next( + parameter + for parameter in schema["paths"]["/v2/me/upgrade"]["post"]["parameters"] + if parameter["in"] == "header" + ) + assert upgrade_header["name"] == UNAUTHENTICATED_HEADER + assert upgrade_header["required"] is True + + +def _recorded_failure_logs(operation) -> tuple[object, list]: + import logging + + from policyengine_api.fastapi_routes.v2 import user_resources + + class RecordingHandler(logging.Handler): + def __init__(self) -> None: + super().__init__(level=logging.ERROR) + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + # Alembic tests run logging.config.fileConfig, which disables loggers that + # already exist, so re-enable this one and record on it directly. + handler = RecordingHandler() + was_disabled = user_resources.logger.disabled + user_resources.logger.disabled = False + user_resources.logger.addHandler(handler) + try: + response = user_resources.user_resource_operation(operation) + finally: + user_resources.logger.removeHandler(handler) + user_resources.logger.disabled = was_disabled + return response, handler.records + + +SECRET_SUBJECT = "|".join(("google-oauth2", "secret-subject")) + + +def test_unexpected_failures_are_logged_without_their_message() -> None: + def fail(): + raise RuntimeError(f"{SECRET_SUBJECT} leaked into a message") + + response, records = _recorded_failure_logs(fail) + + assert response.status_code == 500 + (record,) = records + assert record.getMessage() == "v2_user_operation_failed" + attributes = record.policyengine_attributes + assert attributes["error_type"] == "RuntimeError" + assert "in fail" in attributes["error_frames"] + assert "secret-subject" not in str(attributes) + assert record.exc_info is None + + +def test_persistence_failures_are_logged_without_their_message() -> None: + from sqlalchemy.exc import DataError + + def fail_in_database(): + raise DataError( + "INSERT ...", {"subject": SECRET_SUBJECT}, Exception("bad value") + ) + + response, records = _recorded_failure_logs(fail_in_database) + + assert response.status_code == 503 + (record,) = records + assert record.getMessage() == "v2_user_persistence_unavailable" + attributes = record.policyengine_attributes + assert attributes["error_type"] == "DataError" + assert "in fail_in_database" in attributes["error_frames"] + assert "secret-subject" not in str(attributes) + + +def _without_auth_settings(monkeypatch: pytest.MonkeyPatch) -> None: + for name in user_auth.PUBLIC_API_AUTH_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +@pytest.mark.parametrize("auth_configured", [False, True]) +def test_the_unauthenticated_user_header_authenticates_a_signed_out_browser( + monkeypatch: pytest.MonkeyPatch, configured_auth, auth_configured: bool +) -> None: + if not auth_configured: + _without_auth_settings(monkeypatch) + services, factories = _services() + services["policy"].next_result = UserPolicyPage( + items=(), offset=0, limit=100, has_more=False + ) + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.get( + "/v2/user-policies?country_id=us", + headers={UNAUTHENTICATED_HEADER: str(BROWSER_USER_ID)}, + ) + + assert response.status_code == 200 + assert services["policy"].calls[0][1]["principal"] == UnauthenticatedPrincipal( + user_id=BROWSER_USER_ID + ) + + +@pytest.mark.parametrize( + "header_value", + [ + "not-a-uuid", + str(BROWSER_USER_ID).upper(), + BROWSER_USER_ID.hex, + f"urn:uuid:{BROWSER_USER_ID}", + f"{BROWSER_USER_ID} ", + ], +) +def test_malformed_unauthenticated_user_headers_are_unauthenticated( + header_value: str, +) -> None: + services, factories = _services() + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.post( + "/v2/user-reports?country_id=zz", + headers={UNAUTHENTICATED_HEADER: header_value}, + json={"bogus": True}, + ) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert response.json() == { + "status": "error", + "message": f"The {UNAUTHENTICATED_HEADER} header must be a canonical UUID", + } + assert all(not service.calls for service in services.values()) + + +def test_a_bearer_token_takes_precedence_over_the_unauthenticated_header( + configured_auth, +) -> None: + configured_auth({"iss": ISSUER, "sub": "google-oauth2|42"}) + services, factories = _services() + services["account"].next_result = AccountExport( + user=None, policies=(), households=(), simulations=(), reports=() + ) + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.get( + "/v2/me/export?country_id=us", + headers={ + "Authorization": "Bearer user-token", + UNAUTHENTICATED_HEADER: str(BROWSER_USER_ID), + }, + ) + + assert response.status_code == 200 + assert services["account"].calls[0][1]["principal"] == UserPrincipal( + issuer=ISSUER, subject="google-oauth2|42" + ) + + +def test_an_invalid_bearer_token_never_falls_back_to_the_header( + configured_auth, +) -> None: + import jwt + + configured_auth(jwt.InvalidSignatureError("bad signature")) + services, factories = _services() + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.get( + "/v2/user-policies?country_id=us", + headers={ + "Authorization": "Bearer forged-token", + UNAUTHENTICATED_HEADER: str(BROWSER_USER_ID), + }, + ) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == 'Bearer error="invalid_token"' + assert not services["policy"].calls + + +def test_machine_client_principals_are_forbidden_beside_the_header( + configured_auth, +) -> None: + configured_auth( + {"iss": ISSUER, "sub": "abc123@clients", "gty": "client-credentials"} + ) + services, factories = _services() + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.get( + "/v2/user-policies?country_id=us", + headers={ + "Authorization": "Bearer machine-token", + UNAUTHENTICATED_HEADER: str(BROWSER_USER_ID), + }, + ) + + assert response.status_code == 403 + assert not services["policy"].calls + + +def test_a_bearer_token_without_auth_settings_is_unavailable_beside_the_header( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _without_auth_settings(monkeypatch) + services, factories = _services() + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.get( + "/v2/me?country_id=us", + headers={ + "Authorization": "Bearer user-token", + UNAUTHENTICATED_HEADER: str(BROWSER_USER_ID), + }, + ) + + assert response.status_code == 503 + assert not services["account"].calls + + +def test_upgrade_with_only_the_unauthenticated_header_requires_sign_in() -> None: + services, factories = _services() + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.post( + "/v2/me/upgrade?country_id=us", + headers={UNAUTHENTICATED_HEADER: str(BROWSER_USER_ID)}, + ) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert not services["account"].calls + + +def test_browsers_may_send_the_unauthenticated_header_cross_origin() -> None: + _services_by_name, factories = _services() + client, _flask_calls = user_route_client(require_user=None, **factories) + + response = client.options( + "/v2/user-policies?country_id=us", + headers={ + "Origin": "https://app.policyengine.org", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": ( + f"content-type,{UNAUTHENTICATED_HEADER.lower()}" + ), + }, + ) + + assert response.status_code == 200 + allowed = { + value.strip().lower() + for value in response.headers["access-control-allow-headers"].split(",") + } + assert UNAUTHENTICATED_HEADER.lower() in allowed diff --git a/tests/unit/v2/test_user_simulation_report_routes.py b/tests/unit/v2/test_user_simulation_report_routes.py new file mode 100644 index 000000000..dce6a2fec --- /dev/null +++ b/tests/unit/v2/test_user_simulation_report_routes.py @@ -0,0 +1,460 @@ +"""Native FastAPI contract tests for signed-in simulation and report saves.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from uuid import UUID + +import pytest + +from policyengine_api.services.v2.associations.validators import ( + AssociationLinkNotFoundError, +) +from policyengine_api.services.v2.legacy_refs.validators import ( + LegacyReferenceNotFoundError, +) +from policyengine_api.services.v2.user_reports.types import ( + UserReportPage, + UserReportRead, +) +from policyengine_api.services.v2.user_reports.validators import ( + UserReportNotFoundError, +) +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationPage, + UserSimulationRead, +) +from tests.unit.v2.user_route_support import ( + CREATED_AT, + OWNER_ID, + PRINCIPAL, + RecordingService, + user_route_client, +) + + +SIMULATION_ASSOCIATION_ID = UUID("00000000-0000-0000-0000-000000000062") +REPORT_ASSOCIATION_ID = UUID("00000000-0000-0000-0000-000000000063") +USER_POLICY_ID = UUID("00000000-0000-0000-0000-000000000064") +BASELINE_USER_POLICY_ID = UUID("00000000-0000-0000-0000-000000000065") +LAST_RUN_AT = datetime(2026, 2, 1, 12, tzinfo=timezone.utc) +PROVISIONS = [ + { + "path": "gov.irs.credits.ctc.amount", + "breadcrumb": "Child Tax Credit amount", + "unit": "currency-USD", + "baseline_value": 2000, + "value": 3000, + "intervals": [ + {"start_date": "2026-01-01", "end_date": "2026-12-31", "value": 3000} + ], + } +] +# A value set from one year on that then grows with an index, rounded down. +GROWING_PROVISION = { + "path": "gov.irs.credits.ctc.refundable.individual_max", + "breadcrumb": "Refundable CTC maximum", + "unit": "currency-USD", + "baseline_value": 1700, + "value": 2000, + "growth": "gov.irs.uprating", + "growth_start": 2027, + "growth_base": 2026, + "growth_round_to": 100, + "growth_round": "down", +} +VALIDATION = { + "matched_at": "2026-02-01T12:00:00Z", + "map_model_version": "1", + "calibration": { + "release_id": None, + "geography": "us", + "reached_count": 1, + "matches": [ + { + "variable": "ctc", + "depth": 1, + "ring": "primary", + "target_count": 3, + "mean_abs_relative_error": 0.1, + "worst": { + "name": "ctc_total", + "source": "irs", + "geography": "us", + "relative_error": None, + }, + } + ], + }, + "scorecard": {"programs": ["ctc"]}, +} + + +def _simulation_read() -> UserSimulationRead: + return UserSimulationRead( + id=SIMULATION_ASSOCIATION_ID, + country_id="us", + user_id=OWNER_ID, + simulation_id=None, + legacy_simulation_id=613, + name="Baseline", + description=None, + created_at=CREATED_AT, + updated_at=CREATED_AT + timedelta(seconds=1), + ) + + +def _report_read() -> UserReportRead: + return UserReportRead( + id=REPORT_ASSOCIATION_ID, + country_id="us", + user_id=OWNER_ID, + report_id=None, + legacy_report_id=615, + name="CTC report", + description=None, + user_policy_id=USER_POLICY_ID, + baseline_user_policy_id=BASELINE_USER_POLICY_ID, + source_note="from chat", + provisions=PROVISIONS, + validation=VALIDATION, + budget_window_years=10, + last_run_at=LAST_RUN_AT, + created_at=CREATED_AT, + updated_at=CREATED_AT + timedelta(seconds=1), + ) + + +def _simulation_result(name: str, kwargs: dict): + if name == "list_user_simulations": + return UserSimulationPage( + items=(_simulation_read(),), + offset=kwargs["offset"], + limit=kwargs["limit"], + has_more=False, + ) + return None if name.startswith("delete") else _simulation_read() + + +def _report_result(name: str, kwargs: dict): + if name == "list_user_reports": + return UserReportPage( + items=(_report_read(),), + offset=kwargs["offset"], + limit=kwargs["limit"], + has_more=False, + ) + return None if name.startswith("delete") else _report_read() + + +def _clients(): + simulations = RecordingService(_simulation_result) + reports = RecordingService(_report_result) + client, _flask_calls = user_route_client( + v2_user_simulation_service_factory=lambda: simulations, + v2_user_report_service_factory=lambda: reports, + ) + return client, simulations, reports + + +def test_simulation_routes_require_v1_references_and_scope_to_the_user() -> None: + client, simulations, _reports = _clients() + + created = client.post( + "/v2/user-simulations?country_id=us", + json={"country_id": "us", "legacy_simulation_id": 613}, + ) + v2_reference = client.post( + "/v2/user-simulations?country_id=us", + json={"country_id": "us", "simulation_id": str(SIMULATION_ASSOCIATION_ID)}, + ) + listed = client.get("/v2/user-simulations?country_id=us&legacy_simulation_id=613") + patched = client.patch( + f"/v2/user-simulations/{SIMULATION_ASSOCIATION_ID}?country_id=us", + json={"legacy_simulation_id": 614, "description": "Repointed"}, + ) + deleted = client.delete( + f"/v2/user-simulations/{SIMULATION_ASSOCIATION_ID}?country_id=us" + ) + + assert created.status_code == 201 + assert created.json()["result"]["item"] == { + "id": str(SIMULATION_ASSOCIATION_ID), + "country_id": "us", + "user_id": str(OWNER_ID), + "simulation_id": None, + "legacy_simulation_id": 613, + "name": "Baseline", + "description": None, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:01Z", + } + assert v2_reference.status_code == 422 + assert listed.status_code == 200 + assert patched.status_code == 200 + assert deleted.status_code == 204 + assert [call[0] for call in simulations.calls] == [ + "create_user_simulation", + "list_user_simulations", + "patch_user_simulation", + "delete_user_simulation", + ] + assert all(call[1]["principal"] == PRINCIPAL for call in simulations.calls) + assert simulations.calls[1][1]["legacy_simulation_id"] == 613 + + +def test_report_routes_round_trip_v3_fields() -> None: + client, _simulations, reports = _clients() + + created = client.post( + "/v2/user-reports?country_id=us", + json={ + "country_id": "us", + "legacy_report_id": 615, + "name": "CTC report", + "user_policy_id": str(USER_POLICY_ID), + "baseline_user_policy_id": str(BASELINE_USER_POLICY_ID), + "source_note": "from chat", + "provisions": PROVISIONS + [GROWING_PROVISION], + "validation": VALIDATION, + "budget_window_years": 10, + "last_run_at": "2026-02-01T12:00:00Z", + }, + ) + + assert created.status_code == 201 + item = created.json()["result"]["item"] + assert item["legacy_report_id"] == 615 + assert item["report_id"] is None + assert item["user_policy_id"] == str(USER_POLICY_ID) + assert item["baseline_user_policy_id"] == str(BASELINE_USER_POLICY_ID) + assert item["provisions"] == PROVISIONS + assert item["validation"] == VALIDATION + assert item["budget_window_years"] == 10 + assert item["last_run_at"] == "2026-02-01T12:00:00Z" + fields = reports.calls[0][1]["association_input"] + assert fields.provisions[0].intervals[0].value == 3000 + assert fields.baseline_user_policy_id == BASELINE_USER_POLICY_ID + assert fields.provisions[1].growth == "gov.irs.uprating" + assert fields.provisions[1].growth_round == "down" + assert fields.validation.calibration.matches[0].ring == "primary" + + +@pytest.mark.parametrize( + "changes", + [ + {"report_id": str(REPORT_ASSOCIATION_ID)}, + {"legacy_report_id": None}, + {"user_id": str(OWNER_ID)}, + {"last_run_at": "2026-02-01T12:00:00"}, + {"provisions": [{"path": "x", "breadcrumb": "y"}]}, + {"provisions": [{**PROVISIONS[0], "extra": True}]}, + { + "provisions": [ + {**PROVISIONS[0], "intervals": [{"start_date": "2026", "value": 1}]} + ] + }, + {"validation": {**VALIDATION, "scorecard": {"programs": "ctc"}}}, + {"provisions": [{**PROVISIONS[0], "value": "x" * (64 * 1024)}]}, + {"provisions": [{**GROWING_PROVISION, "growth_round": "sideways"}]}, + {"provisions": [{**GROWING_PROVISION, "growth_start": "next year"}]}, + {"provisions": [{**GROWING_PROVISION, "growth_round_to": "ten"}]}, + {"baseline_user_policy_id": "not-a-uuid"}, + {"budget_window_years": 1}, + {"budget_window_years": 76}, + {"budget_window_years": "ten"}, + ], +) +def test_report_create_rejects_invalid_v3_payloads(changes: dict) -> None: + client, _simulations, reports = _clients() + body = {"country_id": "us", "legacy_report_id": 615, **changes} + + response = client.post("/v2/user-reports?country_id=us", json=body) + + assert response.status_code == 422 + assert reports.calls == [] + + +@pytest.mark.parametrize( + ("error", "status"), + [ + (AssociationLinkNotFoundError("user-policy association was not found"), 422), + (LegacyReferenceNotFoundError("v1 report 615 was not found"), 422), + (UserReportNotFoundError("association was not found"), 404), + ], +) +def test_report_failures_map_to_contract_statuses( + error: Exception, status: int +) -> None: + client, _simulations, reports = _clients() + reports.error = error + + response = client.patch( + f"/v2/user-reports/{REPORT_ASSOCIATION_ID}?country_id=us", + json={"user_policy_id": str(USER_POLICY_ID)}, + ) + + assert response.status_code == status + assert response.json()["status"] == "error" + + +def test_report_patch_accepts_only_contract_fields() -> None: + client, _simulations, reports = _clients() + + accepted = client.patch( + f"/v2/user-reports/{REPORT_ASSOCIATION_ID}?country_id=us", + json={ + "legacy_report_id": 616, + "user_policy_id": None, + "baseline_user_policy_id": None, + "provisions": None, + "budget_window_years": None, + "last_run_at": "2026-03-01T00:00:00+00:00", + }, + ) + rejected = client.patch( + f"/v2/user-reports/{REPORT_ASSOCIATION_ID}?country_id=us", + json={"client_ref": "sur-2"}, + ) + + assert accepted.status_code == 200 + assert rejected.status_code == 422 + association_input = reports.calls[0][1]["association_input"] + assert association_input.model_fields_set == { + "legacy_report_id", + "user_policy_id", + "baseline_user_policy_id", + "provisions", + "budget_window_years", + "last_run_at", + } + + +def test_report_snapshots_echo_stored_keys_with_contract_nulls() -> None: + sparse_provision = { + "path": "gov.irs.credits.ctc.amount", + "breadcrumb": "Child Tax Credit amount", + "baseline_value": 2000, + "value": 3000, + "intervals": [ + {"start_date": "2026-01-01", "end_date": "2026-12-31", "value": 3000} + ], + } + sparse_validation = {"matched_at": "2026-02-01T12:00:00Z", "map_model_version": "1"} + reports = RecordingService( + lambda _name, _kwargs: replace( + _report_read(), + provisions=[sparse_provision], + validation=sparse_validation, + ) + ) + client, _flask_calls = user_route_client( + v2_user_report_service_factory=lambda: reports + ) + + item = client.get(f"/v2/user-reports/{REPORT_ASSOCIATION_ID}?country_id=us").json()[ + "result" + ]["item"] + + # Nullable contract keys are present; the optional interval parameter + # stays absent rather than null. + assert item["provisions"] == [{**sparse_provision, "unit": None}] + assert "parameter" not in item["provisions"][0]["intervals"][0] + assert item["validation"] == { + **sparse_validation, + "calibration": None, + "scorecard": None, + } + + +def test_report_provisions_echo_growth_only_when_stored() -> None: + reports = RecordingService( + lambda _name, _kwargs: replace( + _report_read(), provisions=[*PROVISIONS, GROWING_PROVISION] + ) + ) + client, _flask_calls = user_route_client( + v2_user_report_service_factory=lambda: reports + ) + + item = client.get(f"/v2/user-reports/{REPORT_ASSOCIATION_ID}?country_id=us").json()[ + "result" + ]["item"] + + fixed_provision, growing_provision = item["provisions"] + assert growing_provision == {**GROWING_PROVISION, "intervals": None} + # The growth keys are optional, not nullable: a fixed value has none. + assert not any(key.startswith("growth") for key in fixed_provision) + + +def test_openapi_types_report_and_policy_snapshots() -> None: + client, _simulations, _reports = _clients() + + schemas = client.get("/v2/openapi.json").json()["components"]["schemas"] + + def property_refs(schema_name: str, field_name: str) -> set[str]: + field = schemas[schema_name]["properties"][field_name] + options = field.get("anyOf", [field]) + refs = set() + for option in options: + target = option.get("items", option) + if "$ref" in target: + refs.add(target["$ref"].rsplit("/", 1)[-1]) + return refs + + report_item = next(name for name in schemas if name.startswith("UserReportItem")) + policy_item = next(name for name in schemas if name.startswith("UserPolicyItem")) + assert { + name.split("-")[0] for name in property_refs(report_item, "provisions") + } == {"ReportProvisionItem"} + assert { + name.split("-")[0] for name in property_refs(report_item, "validation") + } == {"ReportValidationItem"} + assert { + name.split("-")[0] for name in property_refs(policy_item, "provenance") + } == {"PolicyProvenanceItem"} + + +def test_stored_snapshots_outside_input_bounds_still_serialize() -> None: + long_label = "x" * 5_000 + match = { + "variable": long_label, + "depth": 0, + "ring": "a-ring-added-later", + "target_count": 1, + "mean_abs_relative_error": 0.1, + "worst": {"name": long_label, "source": "irs", "geography": "us"}, + } + oversized_validation = { + "matched_at": long_label, + "map_model_version": "1", + "retired_key": "kept in storage, dropped from responses", + "calibration": { + "geography": long_label, + "reached_count": 600, + "matches": [match] * 600, + }, + "scorecard": {"programs": ["snap"] * 500}, + } + odd_read = replace(_report_read(), validation=oversized_validation) + reports = RecordingService( + lambda _name, kwargs: UserReportPage( + items=(odd_read, _report_read()), + offset=kwargs["offset"], + limit=kwargs["limit"], + has_more=False, + ) + ) + client, _flask_calls = user_route_client( + v2_user_report_service_factory=lambda: reports + ) + + response = client.get("/v2/user-reports?country_id=us") + + assert response.status_code == 200 + first, second = response.json()["result"]["items"] + assert len(first["validation"]["calibration"]["matches"]) == 600 + assert first["validation"]["calibration"]["geography"] == long_label + assert "retired_key" not in first["validation"] + assert second["validation"] == VALIDATION diff --git a/tests/unit/v2/test_user_write_limits.py b/tests/unit/v2/test_user_write_limits.py new file mode 100644 index 000000000..1012f63ed --- /dev/null +++ b/tests/unit/v2/test_user_write_limits.py @@ -0,0 +1,310 @@ +"""Request-size and field bounds on user-owned writes.""" + +from __future__ import annotations + +import json +from typing import Any +from uuid import UUID + +from pydantic import ValidationError +import pytest + +from policyengine_api.fastapi_routes.v2.user_resources import ( + MAXIMUM_USER_WRITE_REQUEST_BYTES, +) +from policyengine_api.services.v2.associations.types import ( + ASSOCIATION_DESCRIPTION_MAX_LENGTH, +) +from policyengine_api.services.v2.user_households.types import ( + UserHouseholdCreateFields, + UserHouseholdUpdateInput, +) +from policyengine_api.services.v2.user_policies.types import ( + UserPolicyCreateFields, + UserPolicyUpdateInput, +) +from policyengine_api.services.v2.user_reports.types import ( + MAXIMUM_CALIBRATION_MATCHES, + MAXIMUM_SCORECARD_PROGRAMS, + REPORT_SOURCE_NOTE_MAX_LENGTH, + ReportValidation, + UserReportCreateFields, + UserReportUpdateInput, +) +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationCreateFields, + UserSimulationRead, + UserSimulationUpdateInput, +) +from tests.unit.v2.user_route_support import ( + CREATED_AT, + OWNER_ID, + RecordingService, + user_route_client, +) + + +UUID_PATH_VALUE = "00000000-0000-0000-0000-000000000001" + + +def _services() -> tuple[dict[str, RecordingService], dict[str, Any]]: + services = { + name: RecordingService() + for name in ("account", "policy", "household", "simulation", "report") + } + factories = { + "v2_account_service_factory": lambda: services["account"], + "v2_user_policy_service_factory": lambda: services["policy"], + "v2_user_household_service_factory": lambda: services["household"], + "v2_user_simulation_service_factory": lambda: services["simulation"], + "v2_user_report_service_factory": lambda: services["report"], + } + return services, factories + + +def _user_write_operations() -> list[tuple[str, str]]: + _services_by_name, factories = _services() + client, _flask_calls = user_route_client(**factories) + paths = client.get("/v2/openapi.json").json()["paths"] + operations = [ + (method.upper(), path.replace("{association_id}", UUID_PATH_VALUE)) + for path, methods in paths.items() + if path.startswith(("/v2/me", "/v2/user-")) + for method in methods + if method in {"post", "patch"} + ] + assert len(operations) >= 10 + return sorted(operations) + + +def _padded_body(size: int) -> bytes: + body = json.dumps({"description": "x" * size}).encode() + assert len(body) > size + return body + + +@pytest.mark.parametrize(("method", "path"), _user_write_operations()) +def test_every_user_write_rejects_oversized_bodies_before_the_service( + method: str, path: str +) -> None: + services, factories = _services() + client, _flask_calls = user_route_client(**factories) + + response = client.request( + method, + f"{path}?country_id=us", + content=_padded_body(MAXIMUM_USER_WRITE_REQUEST_BYTES), + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 413 + assert response.json()["status"] == "error" + assert all(not service.calls for service in services.values()) + + +def test_single_writes_accept_bodies_under_the_limit() -> None: + services, factories = _services() + description = "x" * ASSOCIATION_DESCRIPTION_MAX_LENGTH + services["simulation"].next_result = UserSimulationRead( + id=UUID(UUID_PATH_VALUE), + country_id="us", + user_id=OWNER_ID, + simulation_id=None, + legacy_simulation_id=613, + name=None, + description=description, + created_at=CREATED_AT, + updated_at=CREATED_AT, + ) + client, _flask_calls = user_route_client(**factories) + + response = client.post( + "/v2/user-simulations?country_id=us", + content=json.dumps( + { + "country_id": "us", + "legacy_simulation_id": 613, + "description": description, + } + ), + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 201 + assert response.json()["result"]["item"]["description"] == description + + +@pytest.mark.parametrize( + ("model", "fields"), + [ + (UserPolicyCreateFields, {"country_id": "us", "legacy_policy_id": 1}), + (UserPolicyUpdateInput, {}), + (UserHouseholdCreateFields, {"country_id": "us", "legacy_household_id": 1}), + (UserHouseholdUpdateInput, {}), + ( + UserSimulationCreateFields, + {"country_id": "us", "legacy_simulation_id": 1}, + ), + (UserSimulationUpdateInput, {}), + (UserReportCreateFields, {"country_id": "us", "legacy_report_id": 1}), + (UserReportUpdateInput, {}), + ], +) +def test_descriptions_are_bounded_on_every_caller_input(model, fields) -> None: + model.model_validate( + {**fields, "description": "x" * ASSOCIATION_DESCRIPTION_MAX_LENGTH} + ) + with pytest.raises(ValidationError): + model.model_validate( + {**fields, "description": "x" * (ASSOCIATION_DESCRIPTION_MAX_LENGTH + 1)} + ) + + +@pytest.mark.parametrize("model", [UserReportCreateFields, UserReportUpdateInput]) +def test_report_source_notes_are_bounded(model) -> None: + fields = {"country_id": "us", "legacy_report_id": 1} + allowed = set(model.model_fields) + base = {name: value for name, value in fields.items() if name in allowed} + model.model_validate({**base, "source_note": "x" * REPORT_SOURCE_NOTE_MAX_LENGTH}) + with pytest.raises(ValidationError): + model.model_validate( + {**base, "source_note": "x" * (REPORT_SOURCE_NOTE_MAX_LENGTH + 1)} + ) + + +def _validation(*, matches: int = 1, programs: int = 1, **overrides: Any) -> dict: + match = { + "variable": "income_tax", + "depth": 0, + "ring": "primary", + "target_count": 2, + "mean_abs_relative_error": 0.1, + "worst": {"name": "irs", "source": "soi", "geography": "US"}, + } + return { + "matched_at": "2026-10-12T00:00:00Z", + "map_model_version": "1", + "calibration": { + "geography": "US", + "reached_count": matches, + "matches": [match] * matches, + }, + "scorecard": {"programs": ["snap"] * programs}, + **overrides, + } + + +def test_report_validation_bounds_list_lengths_and_strings() -> None: + ReportValidation.model_validate( + _validation( + matches=MAXIMUM_CALIBRATION_MATCHES, programs=MAXIMUM_SCORECARD_PROGRAMS + ) + ) + for too_large in ( + _validation(matches=MAXIMUM_CALIBRATION_MATCHES + 1), + _validation(programs=MAXIMUM_SCORECARD_PROGRAMS + 1), + _validation(matched_at="x" * 65), + _validation(map_model_version="x" * 256), + ): + with pytest.raises(ValidationError): + ReportValidation.model_validate(too_large) + + +NON_FINITE_NUMBERS = [float("nan"), float("inf"), float("-inf")] + + +def _provision(**overrides: Any) -> dict: + return { + "path": "gov.irs.credits.ctc.amount", + "breadcrumb": "Child Tax Credit amount", + "baseline_value": 2000, + "value": 3000, + **overrides, + } + + +@pytest.mark.parametrize("number", NON_FINITE_NUMBERS) +@pytest.mark.parametrize( + "provision", + [ + lambda number: _provision(baseline_value=number), + lambda number: _provision(value={"nested": [1, number]}), + lambda number: _provision( + intervals=[ + {"start_date": "2026-01-01", "end_date": "2026-12-31", "value": number} + ] + ), + ], +) +def test_report_provisions_reject_non_finite_numbers(provision, number) -> None: + fields = {"country_id": "us", "legacy_report_id": 1} + with pytest.raises(ValidationError): + UserReportCreateFields.model_validate( + {**fields, "provisions": [provision(number)]} + ) + + +@pytest.mark.parametrize("number", NON_FINITE_NUMBERS) +def test_report_validation_rejects_non_finite_numbers(number) -> None: + worst = {"name": "irs", "source": "soi", "geography": "US"} + match = { + "variable": "income_tax", + "depth": 0, + "ring": "primary", + "target_count": 2, + "mean_abs_relative_error": 0.1, + "worst": worst, + } + for snapshot in ( + _validation( + calibration={ + "geography": "US", + "reached_count": 1, + "matches": [{**match, "mean_abs_relative_error": number}], + } + ), + _validation( + calibration={ + "geography": "US", + "reached_count": 1, + "matches": [{**match, "worst": {**worst, "relative_error": number}}], + } + ), + ): + with pytest.raises(ValidationError): + ReportValidation.model_validate(snapshot) + + +def test_finite_json_values_keep_their_types() -> None: + provision = UserReportCreateFields.model_validate( + { + "country_id": "us", + "legacy_report_id": 1, + "provisions": [ + _provision(value={"rate": 0.25, "flag": True, "count": 3, "label": "x"}) + ], + } + ).provisions[0] + + assert provision.value == {"rate": 0.25, "flag": True, "count": 3, "label": "x"} + assert isinstance(provision.value["flag"], bool) + assert isinstance(provision.value["count"], int) + + +@pytest.mark.parametrize("token", ["NaN", "Infinity", "-Infinity"]) +def test_single_writes_with_non_finite_json_numbers_are_rejected(token) -> None: + services, factories = _services() + client, _flask_calls = user_route_client(**factories) + + response = client.post( + "/v2/user-reports?country_id=us", + content=( + '{"country_id": "us", "legacy_report_id": 615, "provisions": ' + '[{"path": "a", "breadcrumb": "b", "baseline_value": ' + f'{token}, "value": 1}}]}}' + ), + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 422 + assert not services["report"].calls diff --git a/tests/unit/v2/user_route_support.py b/tests/unit/v2/user_route_support.py new file mode 100644 index 000000000..2620c3e48 --- /dev/null +++ b/tests/unit/v2/user_route_support.py @@ -0,0 +1,108 @@ +"""Shared helpers for signed-in user route tests.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from fastapi.testclient import TestClient +from flask import Flask, jsonify + +from policyengine_api.asgi_factory import create_asgi_app +from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +from policyengine_api.migration_flags import ( + RouteImplementation, + RouteImplementationSettings, +) +from policyengine_api.services.v2.identity.types import ( + AccountPrincipal, + UnauthenticatedPrincipal, + UserPrincipal, +) + + +ISSUER = "https://auth.example.test/" +PRINCIPAL = UserPrincipal(issuer=ISSUER, subject="google-oauth2|1234") +BROWSER_USER_ID = UUID("6f1c2a4e-8b3d-4c5e-9a7f-1e2d3c4b5a69") +UNAUTHENTICATED_PRINCIPAL = UnauthenticatedPrincipal(user_id=BROWSER_USER_ID) +OWNER_ID = UUID("00000000-0000-0000-0000-000000000070") +CREATED_AT = datetime(2026, 1, 1, tzinfo=timezone.utc) + + +def signed_in_user() -> UserPrincipal: + return PRINCIPAL + + +def signed_out_browser() -> UnauthenticatedPrincipal: + return UNAUTHENTICATED_PRINCIPAL + + +def user_route_client( + *, + require_user: Callable[..., AccountPrincipal] | None = signed_in_user, + **service_factories: Any, +) -> tuple[TestClient, dict[str, int]]: + """Build the ASGI app with fake services and an injected principal. + + Passing ``require_user=None`` keeps the fail-closed default authenticator. + """ + + flask_calls = {"count": 0} + flask_app = Flask(__name__) + + @flask_app.route("/", methods=["GET", "POST", "PATCH", "DELETE"]) + def fallback(resource: str): + flask_calls["count"] += 1 + return jsonify({"source": "flask", "resource": resource}) + + overrides: dict[str, Any] = dict(service_factories) + if require_user is not None: + overrides["require_user"] = require_user + dependencies = NativeRouteDependencies( + readiness_probe=lambda: True, + gateway_client_factory=lambda: None, + metadata_reader_factory=lambda: None, + specification_provider=lambda: {}, + **overrides, + ) + settings = RouteImplementationSettings( + health=RouteImplementation.FLASK_FALLBACK, + specification=RouteImplementation.FLASK_FALLBACK, + metadata=RouteImplementation.FLASK_FALLBACK, + ) + return ( + TestClient( + create_asgi_app( + flask_app, + dependencies=dependencies, + route_settings=settings, + ), + raise_server_exceptions=False, + ), + flask_calls, + ) + + +class RecordingService: + """Record calls; return ``next_result`` or raise ``error`` for any method.""" + + def __init__(self, next_result: Any = None) -> None: + self.error: Exception | None = None + self.calls: list[tuple[str, dict[str, Any]]] = [] + self.next_result = next_result + + def __getattr__(self, name: str) -> Callable[..., Any]: + if name.startswith("_"): + raise AttributeError(name) + + def method(**kwargs: Any) -> Any: + self.calls.append((name, kwargs)) + if self.error is not None: + raise self.error + if callable(self.next_result): + return self.next_result(name, kwargs) + return self.next_result + + return method diff --git a/tests/unit/v2/user_service_support.py b/tests/unit/v2/user_service_support.py new file mode 100644 index 000000000..220644970 --- /dev/null +++ b/tests/unit/v2/user_service_support.py @@ -0,0 +1,198 @@ +"""SQLite-backed helpers for signed-in user service tests.""" + +from __future__ import annotations + +from collections.abc import Collection, Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timezone +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy.orm import sessionmaker +from sqlmodel import Session, create_engine + +from policyengine_api.data.v2.models import ( + Household, + LegacyHouseholdMapping, + LegacyPolicyMapping, + Parameter, + ParameterValue, + Policy, + TaxBenefitModel, + TaxBenefitModelVersion, + V2_METADATA, +) +from policyengine_api.services.v2.identity.types import ( + UnauthenticatedPrincipal, + UserPrincipal, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind +from policyengine_api.services.v2.legacy_refs.validators import ( + require_legacy_references, +) + + +ISSUER = "https://auth.example.test/" +ALICE = UserPrincipal(issuer=ISSUER, subject="google-oauth2|alice") +BOB = UserPrincipal(issuer=ISSUER, subject="google-oauth2|bob") +SIGNED_OUT_BROWSER = UnauthenticatedPrincipal( + user_id=UUID("3d6f0a8e-5b2c-4e1f-9d7a-2c4b6e8f0a13") +) +OTHER_SIGNED_OUT_BROWSER = UnauthenticatedPrincipal( + user_id=UUID("9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d") +) +MAPPED_LEGACY_POLICY_ID = 501 +MAPPED_LEGACY_HOUSEHOLD_ID = 601 + + +class FakeLegacyReferences: + """In-memory v1 existence checks keyed by country and record kind.""" + + def __init__( + self, + existing: Mapping[tuple[str, LegacyRecordKind], Collection[int]], + ) -> None: + self._existing = {key: frozenset(ids) for key, ids in existing.items()} + self.calls: list[tuple[str, dict[LegacyRecordKind, list[int]]]] = [] + + def existing_ids( + self, + *, + country_id: str, + requested: Mapping[LegacyRecordKind, Collection[int]], + ) -> dict[LegacyRecordKind, frozenset[int]]: + self.calls.append( + (country_id, {kind: sorted(ids) for kind, ids in requested.items()}) + ) + return { + kind: frozenset(ids) & self._existing.get((country_id, kind), frozenset()) + for kind, ids in requested.items() + } + + def require_existing( + self, + *, + country_id: str, + requested: Mapping[LegacyRecordKind, Collection[int]], + ) -> None: + existing = self.existing_ids(country_id=country_id, requested=requested) + for kind, ids in requested.items(): + require_legacy_references( + kind, + country_id=country_id, + requested=ids, + existing=existing[kind], + ) + + +def default_legacy_references() -> FakeLegacyReferences: + return FakeLegacyReferences( + { + ("us", LegacyRecordKind.POLICY): {1, 2, 3, MAPPED_LEGACY_POLICY_ID}, + ("us", LegacyRecordKind.HOUSEHOLD): {10, 11, MAPPED_LEGACY_HOUSEHOLD_ID}, + ("us", LegacyRecordKind.SIMULATION): {613, 614}, + ("us", LegacyRecordKind.REPORT): {615, 616}, + ("uk", LegacyRecordKind.POLICY): {7}, + } + ) + + +@dataclass(frozen=True) +class V2Store: + sessions: sessionmaker[Session] + policy_id: UUID + uk_policy_id: UUID + household_id: UUID + + +@contextmanager +def v2_store() -> Iterator[V2Store]: + """Create an isolated SQLite v2 schema with mirrored v1 records.""" + + engine = create_engine("sqlite://") + + @sa.event.listens_for(engine, "connect") + def enable_foreign_keys(dbapi_connection, _connection_record) -> None: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + # SQLAlchemy's documented pysqlite recipe: let SQLAlchemy own BEGIN so + # provisioning savepoints behave as they do on Postgres. + dbapi_connection.isolation_level = None + + @sa.event.listens_for(engine, "begin") + def begin_transaction(connection) -> None: + connection.exec_driver_sql("BEGIN") + + V2_METADATA.create_all(engine) + sessions = sessionmaker(engine, class_=Session, expire_on_commit=False) + with sessions.begin() as session: + model = TaxBenefitModel(name="policyengine-us") + version = TaxBenefitModelVersion( + model=model, + version="5.2.0", + current_law_id=1, + metadata_time_periods=[2026], + ) + parameter = Parameter( + name="gov.example.rate", tax_benefit_model_version=version + ) + policy = Policy( + country_id="us", + tax_benefit_model=model, + tax_benefit_model_version=version, + canonicalization_version=1, + content_hash="a" * 64, + ) + uk_policy = Policy( + country_id="uk", + tax_benefit_model=model, + tax_benefit_model_version=version, + canonicalization_version=1, + content_hash="b" * 64, + ) + value = ParameterValue( + parameter=parameter, + policy=policy, + value_json=0.2, + start_date=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + household = Household( + country_id="us", + default_year=2026, + household_data={"people": [], "household": []}, + canonicalization_version=1, + content_hash="c" * 64, + ) + session.add_all([value, uk_policy, household]) + session.flush() + session.add_all( + [ + LegacyPolicyMapping( + country_id="us", + legacy_policy_id=MAPPED_LEGACY_POLICY_ID, + policy_id=policy.id, + source_policy_hash="hash", + ), + LegacyHouseholdMapping( + country_id="us", + legacy_household_id=MAPPED_LEGACY_HOUSEHOLD_ID, + household_id=household.id, + source_api_version="1", + fingerprint_version=1, + fingerprint_sha256="d" * 64, + ), + ] + ) + session.flush() + store = V2Store( + sessions=sessions, + policy_id=policy.id, + uk_policy_id=uk_policy.id, + household_id=household.id, + ) + try: + yield store + finally: + engine.dispose() diff --git a/uv.lock b/uv.lock index 021ae47d3..e78947167 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -2798,6 +2798,7 @@ dependencies = [ { name = "policyengine-observability", extra = ["flask", "google", "httpx", "otlp-grpc"] }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pymysql" }, { name = "python-dotenv" }, { name = "redis" }, @@ -2851,6 +2852,7 @@ requires-dist = [ { name = "policyengine-observability", extras = ["flask", "google", "httpx", "otlp-grpc"], specifier = ">=3.0.2,<4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3,<4" }, { name = "pydantic" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10,<3" }, { name = "pymysql" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-snapshot", marker = "extra == 'dev'" }, @@ -3475,6 +3477,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymysql" version = "1.1.2"