Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/seed-v2-database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ jobs:
run: bash .github/scripts/migrate_v2_metadata_schema.sh
env:
V2_MIGRATION_DATABASE_URL: ${{ secrets.V2_MIGRATION_DATABASE_URL }}
- name: Grant declared v2 table privileges
run: uv run python scripts/reconcile_v2_privileges.py apply
env:
V2_MIGRATION_DATABASE_URL: ${{ secrets.V2_MIGRATION_DATABASE_URL }}
- name: Verify declared v2 table privileges
run: uv run python scripts/reconcile_v2_privileges.py verify
env:
V2_MIGRATION_DATABASE_URL: ${{ secrets.V2_MIGRATION_DATABASE_URL }}
- name: Seed and validate the v2 metadata catalog
run: uv run python scripts/publish_v2_metadata_catalog.py
env:
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/v2-integration-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ jobs:
ALEMBIC_DATABASE_URL: mysql+pymysql://root:policyengine_test@127.0.0.1:3306/policyengine_alembic_test
V2_MIGRATION_DATABASE_URL: postgresql+psycopg://postgres:policyengine_v2_test@127.0.0.1:5432/policyengine_v2_alembic_test
V2_ALEMBIC_DISPOSABLE_TEST: "1"
# Names the Stage 12 role in the disposable database: policyengine_stage12_test.
DEPLOYMENT_ENVIRONMENT: test
RUNTIME_CACHE_TEST_URL: redis://127.0.0.1:6379/0
steps:
- name: Checkout repo
Expand All @@ -67,6 +69,10 @@ jobs:
run: uv run alembic -c alembic-v1.ini upgrade head
- name: Prepare the disposable v2 schema
run: uv run alembic -c alembic-v2.ini upgrade head
- name: Create the managed v2 roles and grant their declared privileges
run: uv run python scripts/reconcile_v2_privileges.py apply --create-missing-roles
- name: Verify the managed v2 roles hold their declared privileges
run: uv run python scripts/reconcile_v2_privileges.py verify
- name: Verify the installed PolicyEngine.py catalog interface
run: uv run coverage run --branch -m pytest -q tests/integration/test_v2_catalog_installed.py
env:
Expand All @@ -77,6 +83,8 @@ jobs:
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 comparison-run persistence and privilege reconciliation as the managed roles
run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_stage12_comparison_persistence.py tests/integration/test_v2_privilege_reconciliation.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
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`.

### Migration and schema

- Checked: `api.migration.alembic-v1-qualified`, `api.migration.alembic-v2-qualified`, `api.migration.bundle-supported-by-simulation-api`, `api.migration.contracts-consistent`, `api.migration.cross-database-integration`
- Checked: `api.migration.alembic-v1-qualified`, `api.migration.alembic-v2-qualified`, `api.migration.bundle-supported-by-simulation-api`, `api.migration.contracts-consistent`, `api.migration.cross-database-integration`, `api.v2.deployed-privileges-match`, `api.v2.runtime-privileges-declared`, `api.v2.runtime-privileges-tested-as-runtime-role`
- `api.migration.alembic-autogenerated-only`: Alembic revisions are autogenerated against an explicitly selected v1 or v2 configuration; never hand-written DDL or data operations. (doc-only, not checked: follow it yourself)

### Code quality and types
Expand Down
1 change: 1 addition & 0 deletions changelog.d/v2-table-privileges.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Declare the v2 database roles' table privileges in the repository (`policyengine_api/data/v2/privileges.py`), grant them after every v2 schema upgrade and check them before each deploy with `scripts/reconcile_v2_privileges.py`, and run the cross-database integration tests as those roles instead of the database superuser.
1 change: 1 addition & 0 deletions changelog.d/v2-table-privileges.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The first deploy grants the Public API's production runtime role the privileges its v2 code needs and production lacked: INSERT on `policies`, `households` and `parameter_values`, and INSERT and UPDATE on `legacy_policy_mappings` and `legacy_household_mappings`. It also records in the repository the SELECT grants applied by hand to fix failed reform saves (#3977).
62 changes: 62 additions & 0 deletions docs/engineering/skills/alembic-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,68 @@ population tables. Review every autogenerated table change, and compare the
live schema directly with the complete SQLModel metadata during the migration
lifecycle.

## Table privileges

The table privileges of the managed v2 database roles are declared in
`policyengine_api/data/v2/privileges.py`, not in Alembic revisions: revisions
stay generated-only, the simulation API's role name differs per environment,
and a revision runs once per database, so it can't repair drift.

| Role | Used by |
| --- | --- |
| `policyengine_v2_runtime` | The Public API on Cloud Run |
| `policyengine_v2_catalog_writer` | `scripts/publish_v2_metadata_catalog.py` |
| `policyengine_stage12_<environment>` | The simulation API and its Modal worker, on the temporary comparison-run tables |

Every v2 table has an entry for every role, so "no access" is a decision, not
an omission. The migration role owns every table and isn't listed. Three rules
check the declarations:

- `api.v2.runtime-privileges-declared`: a unit test reads each connector
module and fails when it needs a privilege its role isn't declared to hold.
- `api.v2.runtime-privileges-tested-as-runtime-role`: the cross-database
integration tests run the v2 user services as `policyengine_v2_runtime` and
the comparison-run services as `policyengine_stage12_test`, holding only
their declared privileges.
- `api.v2.deployed-privileges-match`: every deploy runs
`scripts/reconcile_v2_privileges.py apply` right after the schema upgrade,
then `verify`, before the catalog is seeded.

`apply` grants each declared privilege a role lacks. It never revokes: it
reports privileges the file doesn't declare, and the projects' default
privileges, in the job summary. It grants nothing when a declared role or
table is missing. The roles themselves are provisioned outside the
repository; `--create-missing-roles` is accepted only for the disposable test
database.

The declarations include write privileges for code paths production doesn't
turn on: the dual-write copies into `legacy_policy_mappings` and
`legacy_household_mappings`, and the writes behind the native `/v2/policies`
and `/v2/households` routes. Narrow them when those tables are removed.

When a pull request adds or changes a v2 table, or a connector's use of one:

1. Update the table's entry in `privileges.py` for every role.
2. Run `make test`, which includes `tests/unit/v2/test_privilege_manifest.py`.
3. Run the integration tests as the managed roles against disposable
PostgreSQL:

```bash
export V2_MIGRATION_DATABASE_URL="postgresql+psycopg://.../policyengine_v2_alembic_test" \
V2_ALEMBIC_DISPOSABLE_TEST=1 DEPLOYMENT_ENVIRONMENT=test
uv run alembic -c alembic-v2.ini upgrade head
uv run python scripts/reconcile_v2_privileges.py apply --create-missing-roles
uv run python scripts/reconcile_v2_privileges.py verify
uv run pytest -q tests/integration/test_v2_user_identity_persistence.py \
tests/integration/test_v2_stage12_comparison_persistence.py \
tests/integration/test_v2_privilege_reconciliation.py
```

Never grant privileges by hand in staging or production; they reach those
databases only through the deploy step, after review. The unit test reads
model names in connector modules, so it misses raw `text()` SQL and SQL issued
outside `database_connectors/`; the integration tests catch those.

## V2 application-data migrations

Alembic is also the sole authority for intentional migrations of retained v2
Expand Down
9 changes: 9 additions & 0 deletions docs/engineering/skills/v2-code-organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,15 @@ request-level or business validation, or convert rows into public response
types. Connector modules do not call one another to sequence a workflow; the
service makes that ordering explicit.

The module name also decides which table privileges the connector needs
(rule `api.v2.runtime-privileges-declared`): `reads` modules need SELECT;
`creates`, `updates` and `deletes` modules need INSERT, UPDATE or DELETE, plus
SELECT; and a function that locks rows with `with_for_update` needs UPDATE on
the tables it locks. A connector that starts using a table, or a new kind of
operation on one, needs that privilege declared in
`policyengine_api/data/v2/privileges.py` in the same pull request; see
[Table privileges](alembic-migrations.md#table-privileges).

Do not use `repository` as a generic synonym for SQL access. Reserve that term
for an intentional Repository-pattern abstraction with a stable interface that
hides interchangeable persistence implementations. API v2 currently uses
Expand Down
32 changes: 26 additions & 6 deletions docs/generated/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 47 | 0 | 1 | 12 | 60 |
| mechanical | 50 | 0 | 1 | 12 | 63 |
| metric | 0 | 0 | 0 | 0 | 0 |
| judgment | 0 | 0 | 0 | 1 | 1 |

**Blocking:** 47 of 61 rules.
**Blocking:** 50 of 64 rules.

## Gaps

Expand Down Expand Up @@ -232,6 +232,9 @@ flowchart LR
| `api.migration.bundle-supported-by-simulation-api` | A PolicyEngine bundle version change is supported by the simulation API before it merges. | mechanical | enforced | yes | CI job `ensure-policyengine-bundle-supported-by-simulation-api` | `pr.yml:ensure-policyengine-bundle-supported-by-simulation-api` | — |
| `api.migration.contracts-consistent` | Migration route groups and workflow contracts are internally consistent, and their generated docs are current. | mechanical | enforced | yes | quality guard `migration-contracts` | `pr.yml:quality-guards` | **unproven** |
| `api.migration.cross-database-integration` | Cross-database integration tests pass against real MySQL and Postgres. | mechanical | enforced | yes | CI job `v2-integration-check` | `pr.yml:v2-integration-check` | — |
| `api.v2.deployed-privileges-match` | Every v2 deploy grants the managed roles each privilege policyengine_api/data/v2/privileges.py declares right after the schema upgrade, reports privileges it doesn't declare without revoking them, and stops before the catalog is seeded if a declared privilege is still missing. | mechanical | enforced | yes | CI job `seed`, `catalog-runtime-integration` | `seed-v2-database.yml:seed`, `v2-integration-check.yml:catalog-runtime-integration` | yes |
| `api.v2.runtime-privileges-declared` | Every v2 table has an entry for each managed database role in policyengine_api/data/v2/privileges.py, and every privilege a v2 connector module needs (from its reads, creates, updates or deletes module name and its row locks) is declared for the role that runs it. | mechanical | enforced | yes | pytest `tests/unit/v2/test_privilege_manifest.py` | `pr.yml:test` | yes |
| `api.v2.runtime-privileges-tested-as-runtime-role` | The cross-database integration tests run the v2 user services as policyengine_v2_runtime and the comparison-run services as policyengine_stage12_<environment>, holding only their declared privileges, so a statement those privileges don't allow fails CI. | mechanical | enforced | yes | CI job `v2-integration-check`, `catalog-runtime-integration` | `pr.yml:v2-integration-check`, `v2-integration-check.yml:catalog-runtime-integration` | — |

```mermaid
flowchart LR
Expand All @@ -252,6 +255,15 @@ flowchart LR
r_api_migration_cross_database_integration("api.migration.cross-database-integration (enforced)")
e_CI_job_v2_integration_check["CI job v2-integration-check"]
j_CI_pr_yml_v2_integration_check["CI pr.yml:v2-integration-check"]
r_api_v2_deployed_privileges_match("api.v2.deployed-privileges-match (enforced)")
e_CI_job_seed__catalog_runtime_integration["CI job seed, catalog-runtime-integration"]
j_CI_seed_v2_database_yml_seed["CI seed-v2-database.yml:seed"]
j_CI_v2_integration_check_yml_catalog_runtime_integration["CI v2-integration-check.yml:catalog-runtime-integration"]
r_api_v2_runtime_privileges_declared("api.v2.runtime-privileges-declared (enforced)")
e_pytest_tests_unit_v2_test_privilege_manifest_py["pytest tests/unit/v2/test_privilege_manifest.py"]
j_CI_pr_yml_test["CI pr.yml:test"]
r_api_v2_runtime_privileges_tested_as_runtime_role("api.v2.runtime-privileges-tested-as-runtime-role (enforced)")
e_CI_job_v2_integration_check__catalog_runtime_integration["CI job v2-integration-check, catalog-runtime-integration"]
r_api_migration_alembic_autogenerated_only -.-> gap_unenforced
r_api_migration_alembic_v1_qualified --> e_CI_job_alembic_v1_check
e_CI_job_alembic_v1_check --> j_CI_pr_yml_alembic_v1_check
Expand All @@ -263,6 +275,14 @@ flowchart LR
e_quality_guard_migration_contracts --> j_CI_pr_yml_quality_guards
r_api_migration_cross_database_integration --> e_CI_job_v2_integration_check
e_CI_job_v2_integration_check --> j_CI_pr_yml_v2_integration_check
r_api_v2_deployed_privileges_match --> e_CI_job_seed__catalog_runtime_integration
e_CI_job_seed__catalog_runtime_integration --> j_CI_seed_v2_database_yml_seed
e_CI_job_seed__catalog_runtime_integration --> j_CI_v2_integration_check_yml_catalog_runtime_integration
r_api_v2_runtime_privileges_declared --> e_pytest_tests_unit_v2_test_privilege_manifest_py
e_pytest_tests_unit_v2_test_privilege_manifest_py --> j_CI_pr_yml_test
r_api_v2_runtime_privileges_tested_as_runtime_role --> e_CI_job_v2_integration_check__catalog_runtime_integration
e_CI_job_v2_integration_check__catalog_runtime_integration --> j_CI_pr_yml_v2_integration_check
e_CI_job_v2_integration_check__catalog_runtime_integration --> j_CI_v2_integration_check_yml_catalog_runtime_integration
```

## Code quality and types
Expand All @@ -271,7 +291,7 @@ flowchart LR
| --- | --- | --- | --- | --- | --- | --- | --- |
| `api.quality.alembic-revisions-unused-imports` | Ruff doesn't lint Alembic revision files (lint.exclude in pyproject.toml), because they must stay exactly as Alembic generates them. | mechanical | advisory | no | nothing | — | — |
| `api.quality.ci-cd-scripts-ruff-exemptions` | CI/CD scripts under .github may print their results and fetch fixed URLs (T201, S310); every other ruff rule applies to them. | mechanical | enforced | yes | ruff per-file ignore of S310, T201 for `.github/**` | `pr.yml:lint` | — |
| `api.quality.command-line-modules-ruff-exemptions` | Application modules that also run as command-line programs (if __name__ == "__main__") may print their output (T201); every other ruff rule applies to them. | mechanical | enforced | yes | ruff per-file ignore of T201 for `policyengine_api/{data/v2/catalog/initialization,data/v2/household_migration_qualification,data/v2/policy_migration_qualification,data/v2/user_association_migration_qualification,worker_spm_release}.py` | `pr.yml:lint` | — |
| `api.quality.command-line-modules-ruff-exemptions` | Application modules that also run as command-line programs (if __name__ == "__main__") may print their output (T201); every other ruff rule applies to them. | mechanical | enforced | yes | ruff per-file ignore of T201 for `policyengine_api/{data/v2/catalog/initialization,data/v2/household_migration_qualification,data/v2/policy_migration_qualification,data/v2/privilege_reconciliation,data/v2/user_association_migration_qualification,worker_spm_release}.py` | `pr.yml:lint` | — |
| `api.quality.command-line-scripts-ruff-exemptions` | Command-line scripts under scripts/ may print their output, run other programs, fetch fixed URLs, stamp today's date and pick random samples (T201, S603, S607, S310, DTZ011, S311); every other ruff rule applies to them. | mechanical | enforced | yes | ruff per-file ignore of DTZ011, S310, S311, S603, S607, T201 for `scripts/**` | `pr.yml:lint` | — |
| `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, 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` | — |
Expand All @@ -289,7 +309,7 @@ flowchart LR
e_ruff_per_file_ignore_of_S310__T201_for__github___["ruff per-file ignore of S310, T201 for .github/**"]
j_CI_pr_yml_lint["CI pr.yml:lint"]
r_api_quality_command_line_modules_ruff_exemptions("api.quality.command-line-modules-ruff-exemptions (enforced)")
e_ruff_per_file_ignore_of_T201_for_policyengine_api__data_v2_catalog_initialization_data_v2_household_migration_qualification_data_v2_policy_migration_qualification_data_v2_user_association_migration_qualification_worker_spm_release__py["ruff per-file ignore of T201 for policyengine_api/{data/v2/catalog/initialization,data/v2/household_migration_qualification,data/v2/policy_migration_qualification,data/v2/user_association_migration_qualification,worker_spm_release}.py"]
e_ruff_per_file_ignore_of_T201_for_policyengine_api__data_v2_catalog_initialization_data_v2_household_migration_qualification_data_v2_policy_migration_qualification_data_v2_privilege_reconciliation_data_v2_user_association_migration_qualification_worker_spm_release__py["ruff per-file ignore of T201 for policyengine_api/{data/v2/catalog/initialization,data/v2/household_migration_qualification,data/v2/policy_migration_qualification,data/v2/privilege_reconciliation,data/v2/user_association_migration_qualification,worker_spm_release}.py"]
r_api_quality_command_line_scripts_ruff_exemptions("api.quality.command-line-scripts-ruff-exemptions (enforced)")
e_ruff_per_file_ignore_of_DTZ011__S310__S311__S603__S607__T201_for_scripts___["ruff per-file ignore of DTZ011, S310, S311, S603, S607, T201 for scripts/**"]
r_api_quality_environment_variables_load("api.quality.environment-variables-load (enforced)")
Expand All @@ -312,8 +332,8 @@ flowchart LR
r_api_quality_alembic_revisions_unused_imports -.-> gap_unenforced
r_api_quality_ci_cd_scripts_ruff_exemptions --> e_ruff_per_file_ignore_of_S310__T201_for__github___
e_ruff_per_file_ignore_of_S310__T201_for__github___ --> j_CI_pr_yml_lint
r_api_quality_command_line_modules_ruff_exemptions --> e_ruff_per_file_ignore_of_T201_for_policyengine_api__data_v2_catalog_initialization_data_v2_household_migration_qualification_data_v2_policy_migration_qualification_data_v2_user_association_migration_qualification_worker_spm_release__py
e_ruff_per_file_ignore_of_T201_for_policyengine_api__data_v2_catalog_initialization_data_v2_household_migration_qualification_data_v2_policy_migration_qualification_data_v2_user_association_migration_qualification_worker_spm_release__py --> j_CI_pr_yml_lint
r_api_quality_command_line_modules_ruff_exemptions --> e_ruff_per_file_ignore_of_T201_for_policyengine_api__data_v2_catalog_initialization_data_v2_household_migration_qualification_data_v2_policy_migration_qualification_data_v2_privilege_reconciliation_data_v2_user_association_migration_qualification_worker_spm_release__py
e_ruff_per_file_ignore_of_T201_for_policyengine_api__data_v2_catalog_initialization_data_v2_household_migration_qualification_data_v2_policy_migration_qualification_data_v2_privilege_reconciliation_data_v2_user_association_migration_qualification_worker_spm_release__py --> j_CI_pr_yml_lint
r_api_quality_command_line_scripts_ruff_exemptions --> e_ruff_per_file_ignore_of_DTZ011__S310__S311__S603__S607__T201_for_scripts___
e_ruff_per_file_ignore_of_DTZ011__S310__S311__S603__S607__T201_for_scripts___ --> j_CI_pr_yml_lint
r_api_quality_environment_variables_load --> e_CI_job_test_env_vars
Expand Down
Loading
Loading