From 2bf255c4b64642b7a12c1f9dda4078728e7de962 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:27:01 +0400 Subject: [PATCH 01/35] Add user identities and v1-anchored association schema Add the user_identities table and reshape the four user association tables so each row can reference a v1 artifact, a v2 record, or both, with a per-user client_ref for idempotent imports. Simulation and report associations lose their cross-service foreign keys to v2 simulations and reports. One autogenerated v2 revision (fbb7488ebe8a) covers both parts. Co-Authored-By: Claude Opus 5.5 --- ...8a_link_signed_in_users_to_v1_anchored_.py | 501 ++++++++++++++++++ policyengine_api/data/v2/migration_target.py | 2 +- policyengine_api/data/v2/models/__init__.py | 3 +- .../data/v2/models/associations.py | 181 ++++++- policyengine_api/data/v2/models/reports.py | 5 - .../data/v2/models/simulations.py | 5 - policyengine_api/data/v2/models/users.py | 40 +- .../integration/test_alembic_v2_lifecycle.py | 147 +++++ tests/unit/v2/test_alembic_v2.py | 47 ++ tests/unit/v2/test_import_side_effects.py | 2 +- tests/unit/v2/test_models.py | 94 ++++ 11 files changed, 987 insertions(+), 40 deletions(-) create mode 100644 migrations/v2/versions/fbb7488ebe8a_link_signed_in_users_to_v1_anchored_.py 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..5d7b947d9 --- /dev/null +++ b/migrations/v2/versions/fbb7488ebe8a_link_signed_in_users_to_v1_anchored_.py @@ -0,0 +1,501 @@ +"""link signed-in users to v1-anchored associations + +Revision ID: fbb7488ebe8a +Revises: 91af4fc8d8c1 +Create Date: 2026-10-12 23:23:23.599084 +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.add_column( + "user_household_associations", + sa.Column( + "client_ref", sqlmodel.sql.sqltypes.AutoString(length=64), 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_unique_constraint( + "uq_user_household_associations_user_client_ref", + "user_household_associations", + ["user_id", "client_ref"], + ) + 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.add_column( + "user_policies", + sa.Column( + "client_ref", sqlmodel.sql.sqltypes.AutoString(length=64), 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_unique_constraint( + "uq_user_policies_user_client_ref", "user_policies", ["user_id", "client_ref"] + ) + 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("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( + "client_ref", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True + ), + ) + op.alter_column( + "user_report_associations", "report_id", existing_type=sa.UUID(), 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_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.create_unique_constraint( + "uq_user_report_associations_user_client_ref", + "user_report_associations", + ["user_id", "client_ref"], + ) + 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_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_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.drop_column("user_report_associations", "label") + op.drop_column("user_report_associations", "country") + 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.add_column( + "user_simulation_associations", + sa.Column( + "client_ref", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True + ), + ) + op.alter_column( + "user_simulation_associations", + "simulation_id", + existing_type=sa.UUID(), + 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.create_unique_constraint( + "uq_user_simulation_associations_user_client_ref", + "user_simulation_associations", + ["user_id", "client_ref"], + ) + 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.drop_column("user_simulation_associations", "label") + op.drop_column("user_simulation_associations", "country") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "user_simulation_associations", + sa.Column( + "country", sa.VARCHAR(length=16), autoincrement=False, nullable=False + ), + ) + op.add_column( + "user_simulation_associations", + sa.Column("label", sa.VARCHAR(length=255), autoincrement=False, nullable=True), + ) + 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_user_client_ref", + "user_simulation_associations", + type_="unique", + ) + 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", + "simulation_id", + existing_type=sa.UUID(), + nullable=False, + ) + op.drop_column("user_simulation_associations", "client_ref") + 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.add_column( + "user_report_associations", + sa.Column( + "country", sa.VARCHAR(length=16), autoincrement=False, nullable=False + ), + ) + op.add_column( + "user_report_associations", + sa.Column("label", sa.VARCHAR(length=255), autoincrement=False, nullable=True), + ) + 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("fk_user_report_associations_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_user_client_ref", + "user_report_associations", + type_="unique", + ) + 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.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", "report_id", existing_type=sa.UUID(), nullable=False + ) + op.drop_column("user_report_associations", "client_ref") + 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", "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_constraint( + "uq_user_policies_user_client_ref", "user_policies", type_="unique" + ) + 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", "client_ref") + 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_constraint( + "uq_user_household_associations_user_client_ref", + "user_household_associations", + type_="unique", + ) + 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", "client_ref") + 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/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..95fce9fbd 100644 --- a/policyengine_api/data/v2/models/__init__.py +++ b/policyengine_api/data/v2/models/__init__.py @@ -30,7 +30,7 @@ TaxBenefitModelVersion, Variable, ) -from policyengine_api.data.v2.models.users import User # noqa: E402 +from policyengine_api.data.v2.models.users import User, UserIdentity # noqa: E402 from policyengine_api.data.v2.models.policies import ( # noqa: E402 Dynamic, Policy, @@ -140,6 +140,7 @@ "TaxBenefitModelVersion", "User", "UserHouseholdAssociation", + "UserIdentity", "UserPolicy", "UserReportAssociation", "UserSimulationAssociation", diff --git a/policyengine_api/data/v2/models/associations.py b/policyengine_api/data/v2/models/associations.py index 1f0c32de8..2f7ea88ec 100644 --- a/policyengine_api/data/v2/models/associations.py +++ b/policyengine_api/data/v2/models/associations.py @@ -1,7 +1,13 @@ -"""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. ``client_ref`` carries the browser-local identifier of +an imported record so repeated imports are idempotent per user. +""" 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 +16,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 if TYPE_CHECKING: from policyengine_api.data.v2.models.policy_mappings import ( LegacyUserPolicyMapping, ) - from policyengine_api.data.v2.models.reports import Report + + +ASSOCIATION_CLIENT_REF_MAX_LENGTH = 64 +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): @@ -28,10 +41,19 @@ class UserHouseholdAssociation(TimestampedModel, table=True): "country_id", name="uq_user_household_associations_id_country", ), + sa.UniqueConstraint( + "user_id", + "client_ref", + name="uq_user_household_associations_user_client_ref", + ), sa.CheckConstraint( "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,18 @@ 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) + client_ref: str | None = Field( + default=None, + max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH, + ) 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): @@ -74,10 +106,23 @@ class UserPolicy(TimestampedModel, table=True): "country_id", name="uq_user_policies_id_country", ), + sa.UniqueConstraint( + "user_id", + "client_ref", + name="uq_user_policies_user_client_ref", + ), sa.CheckConstraint( "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 +141,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 +153,20 @@ 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()) + client_ref: str | None = Field( + default=None, + max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH, + ) 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 +174,43 @@ 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.UniqueConstraint( + "user_id", + "client_ref", + name="uq_user_simulation_associations_user_client_ref", + ), + 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 +219,58 @@ class UserSimulationAssociation(TimestampedModel, table=True): ondelete="CASCADE", index=True, ) - simulation_id: UUID = Field( - foreign_key="simulations.id", - ondelete="CASCADE", - index=True, + 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) + client_ref: str | None = Field( + default=None, + max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH, ) - country: str = Field(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.UniqueConstraint( + "user_id", + "client_ref", + name="uq_user_report_associations_user_client_ref", + ), + 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.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 +279,27 @@ 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) + 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()) last_run_at: datetime | None = Field( default=None, sa_type=sa.DateTime(timezone=True), ) + client_ref: str | None = Field( + default=None, + max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH, + ) 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..54c1be3bc 100644 --- a/policyengine_api/data/v2/models/users.py +++ b/policyengine_api/data/v2/models/users.py @@ -1,11 +1,13 @@ -"""Canonical SQLModel table for v2 users.""" +"""Canonical SQLModel tables for v2 users and their sign-in identities.""" +from datetime import datetime 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 ( @@ -33,6 +35,10 @@ class User(IdentifiedModel, table=True): primary_country: str = Field(max_length=2) 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 +55,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/tests/integration/test_alembic_v2_lifecycle.py b/tests/integration/test_alembic_v2_lifecycle.py index 7c6628a48..4be61a99f 100644 --- a/tests/integration/test_alembic_v2_lifecycle.py +++ b/tests/integration/test_alembic_v2_lifecycle.py @@ -27,6 +27,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 +98,82 @@ 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", + "client_ref", + }.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", "client_ref"}.isdisjoint(columns) + assert not columns[f"{reference}_id"]["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"] + unique_constraints = { + constraint["name"]: constraint["column_names"] + for constraint in inspect(engine).get_unique_constraints(table_name) + } + assert unique_constraints[f"uq_{table_name}_user_client_ref"] == [ + "user_id", + "client_ref", + ] + 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" + + def test_empty_upgrade_check_base_downgrade_and_reupgrade() -> None: database_url = _disposable_url() config = _config() @@ -112,6 +189,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) @@ -500,3 +588,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/unit/v2/test_alembic_v2.py b/tests/unit/v2/test_alembic_v2.py index 0bc4eb7bb..8c587d520 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,51 @@ 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'"uq_{table_name}_user_client_ref"' in revision + assert f'"legacy_{reference}_id"' 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", + ): + 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 'ondelete="SET NULL"' in revision + assert "Post-generation" not in revision + assert "op.execute(" not in revision + assert "op.bulk_insert(" not in revision + + 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_import_side_effects.py b/tests/unit/v2/test_import_side_effects.py index 5de6c3efb..015317b3b 100644 --- a/tests/unit/v2/test_import_side_effects.py +++ b/tests/unit/v2/test_import_side_effects.py @@ -70,7 +70,7 @@ 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 diff --git a/tests/unit/v2/test_models.py b/tests/unit/v2/test_models.py index bf9b3e2f4..43cde2cb9 100644 --- a/tests/unit/v2/test_models.py +++ b/tests/unit/v2/test_models.py @@ -21,6 +21,7 @@ TaxBenefitModelVersion, User, UserHouseholdAssociation, + UserIdentity, UserPolicy, UserReportAssociation, UserSimulationAssociation, @@ -48,6 +49,7 @@ def test_domain_models_are_grouped_into_topic_scoped_modules() -> None: expected_modules = { User: "users", + UserIdentity: "users", Policy: "policies", Dynamic: "policies", Household: "households", @@ -520,3 +522,95 @@ 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_and_import_idempotent() -> 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 table.c.client_ref.type.length == 64 + 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" + ) + client_ref_unique = constraints[f"uq_{table_name}_user_client_ref"] + assert [column.name for column in client_ref_unique.columns] == [ + "user_id", + "client_ref", + ] + assert ("id", "country_id") in { + tuple(column.name for column in constraint.columns) + for constraint in table.constraints + if isinstance(constraint, sa.UniqueConstraint) + } + + +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"] + + assert {"country", "label"}.isdisjoint(simulations.c.keys()) + assert {"country", "label"}.isdisjoint(reports.c.keys()) + 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) From 6868fc9b601cbf323e2c0d34b1e5a98315e36cef Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:58:11 +0400 Subject: [PATCH 02/35] Add Auth0 bearer authentication for user-owned routes Port the Simulation Entrypoint JWTDecoder (PyJWT PyJWKClient, RS256, iss/aud) into libs/user_auth.py with issuer normalization and client principal classification, plus an optional Auth0 Management API client for account deletion. Validate PUBLIC_API_AUTH_* and AUTH0_MANAGEMENT_* settings in the readiness gate, map authentication failures to the v2 error envelope with WWW-Authenticate, and expose that header via CORS. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/asgi_factory.py | 34 ++- policyengine_api/fastapi_routes/v2/errors.py | 31 ++- policyengine_api/libs/auth0_management.py | 216 +++++++++++++++ policyengine_api/libs/user_auth.py | 262 +++++++++++++++++++ policyengine_api/readiness.py | 10 +- pyproject.toml | 3 + tests/unit/libs/test_auth0_management.py | 126 +++++++++ tests/unit/libs/test_user_auth.py | 200 ++++++++++++++ tests/unit/test_readiness.py | 44 ++++ uv.lock | 18 +- 10 files changed, 940 insertions(+), 4 deletions(-) create mode 100644 policyengine_api/libs/auth0_management.py create mode 100644 policyengine_api/libs/user_auth.py create mode 100644 tests/unit/libs/test_auth0_management.py create mode 100644 tests/unit/libs/test_user_auth.py 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/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/libs/auth0_management.py b/policyengine_api/libs/auth0_management.py new file mode 100644 index 000000000..9c4bf0c01 --- /dev/null +++ b/policyengine_api/libs/auth0_management.py @@ -0,0 +1,216 @@ +"""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 delete_user(self, subject: str) -> None: + """Delete one Auth0 user; an already-absent user counts as deleted.""" + + response = self._request( + "DELETE", + f"{self._base_url}/api/v2/users/{quote(subject, safe='')}", + headers={"Authorization": f"Bearer {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}" + ) + + +def build_auth0_user_deleter( + environ: Mapping[str, str] | None = None, +) -> Callable[[str], None] | None: + """Return a deleter bound to the configured tenant, or ``None``.""" + + settings = load_auth0_management_settings(environ) + if settings is None: + return None + return Auth0ManagementClient(settings).delete_user diff --git a/policyengine_api/libs/user_auth.py b/policyengine_api/libs/user_auth.py new file mode 100644 index 000000000..a4f4ae459 --- /dev/null +++ b/policyengine_api/libs/user_auth.py @@ -0,0 +1,262 @@ +"""Inbound Auth0 bearer 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. + +This module never logs a token, subject, or email address. +""" + +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 +from typing import Any, Protocol +from urllib.parse import urlparse + +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, +) + +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 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 _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={"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={"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/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/pyproject.toml b/pyproject.toml index 54988ccad..d340510ba 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] @@ -90,6 +91,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/tests/unit/libs/test_auth0_management.py b/tests/unit/libs/test_auth0_management.py new file mode 100644 index 000000000..a230e0ac7 --- /dev/null +++ b/tests/unit/libs/test_auth0_management.py @@ -0,0 +1,126 @@ +"""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) diff --git a/tests/unit/libs/test_user_auth.py b/tests/unit/libs/test_user_auth.py new file mode 100644 index 000000000..646e594d3 --- /dev/null +++ b/tests/unit/libs/test_user_auth.py @@ -0,0 +1,200 @@ +"""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 cryptography.hazmat.primitives.asymmetric import rsa +import jwt +import pytest + +from policyengine_api.libs.user_auth import ( + InvalidBearerTokenError, + JWTDecoder, + MissingBearerTokenError, + PrincipalKind, + UserAuthConfigurationError, + UserAuthSettings, + UserAuthUnavailableError, + _normalized_issuer, + authenticate_bearer, + load_user_auth_settings, +) + + +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" 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/uv.lock b/uv.lock index 355ae95a9..003f3b009 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" From 98e27cc6da265fde59076d153a046969db24669d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:58:17 +0400 Subject: [PATCH 03/35] Lock user associations to the signed-in user and add v1-anchored saves Resolve (and on first use provision) the caller's v2 user from the bearer token through user_identities, with savepoint race recovery on UNIQUE(issuer, subject). Remove user_id from association requests and queries, scope every read and write to the owner (404 otherwise), and accept legacy v1 references checked through a batched, read-only v1 connector (422 when missing). Repointing re-resolves or clears the v2 reference, and rows owned by the Stage 10 mirror return 409. Add /v2/user-simulations, /v2/user-reports (with the v3 report fields), and /v2/me (GET, PATCH, DELETE, export, idempotent imports). The Stage 10 projection now records legacy_policy_id. require_user is a fail-closed NativeRouteDependencies field attached at router level. Co-Authored-By: Claude Opus 5.5 --- .../fastapi_routes/dependencies.py | 284 +++++++++- .../fastapi_routes/v2/me/__init__.py | 1 + .../fastapi_routes/v2/me/request_models.py | 17 + .../fastapi_routes/v2/me/response_models.py | 112 ++++ .../fastapi_routes/v2/me/routes.py | 211 ++++++++ policyengine_api/fastapi_routes/v2/routes.py | 10 + .../fastapi_routes/v2/user_auth.py | 61 +++ .../v2/user_households/request_models.py | 8 +- .../v2/user_households/response_models.py | 43 +- .../v2/user_households/routes.py | 103 ++-- .../v2/user_policies/request_models.py | 10 +- .../v2/user_policies/response_models.py | 46 +- .../fastapi_routes/v2/user_policies/routes.py | 116 ++-- .../v2/user_reports/__init__.py | 1 + .../v2/user_reports/request_models.py | 14 + .../v2/user_reports/response_models.py | 78 +++ .../fastapi_routes/v2/user_reports/routes.py | 190 +++++++ .../fastapi_routes/v2/user_resources.py | 111 ++++ .../v2/user_simulations/__init__.py | 1 + .../v2/user_simulations/request_models.py | 14 + .../v2/user_simulations/response_models.py | 77 +++ .../v2/user_simulations/routes.py | 197 +++++++ policyengine_api/query_parameters.py | 29 +- .../services/v2/associations/__init__.py | 1 + .../services/v2/associations/types.py | 44 ++ .../services/v2/associations/validators.py | 48 ++ .../services/v2/identity/__init__.py | 1 + .../identity/database_connectors/__init__.py | 1 + .../identity/database_connectors/creates.py | 43 ++ .../v2/identity/database_connectors/reads.py | 21 + .../identity/database_connectors/updates.py | 23 + .../services/v2/identity/database_session.py | 28 + .../services/v2/identity/services.py | 131 +++++ .../services/v2/identity/transformations.py | 33 ++ .../services/v2/identity/types.py | 32 ++ .../services/v2/identity/validators.py | 17 + .../services/v2/legacy_refs/__init__.py | 1 + .../database_connectors/__init__.py | 1 + .../legacy_refs/database_connectors/reads.py | 56 ++ .../v2/legacy_refs/database_session.py | 24 + .../services/v2/legacy_refs/services.py | 69 +++ .../services/v2/legacy_refs/types.py | 28 + .../services/v2/legacy_refs/validators.py | 26 + .../services/v2/user_accounts/__init__.py | 1 + .../database_connectors/__init__.py | 1 + .../database_connectors/deletes.py | 26 + .../database_connectors/reads.py | 28 + .../database_connectors/updates.py | 23 + .../v2/user_accounts/database_session.py | 24 + .../services/v2/user_accounts/services.py | 297 +++++++++++ .../v2/user_accounts/transformations.py | 26 + .../services/v2/user_accounts/types.py | 117 +++++ .../services/v2/user_accounts/validators.py | 84 +++ .../database_connectors/creates.py | 23 +- .../database_connectors/reads.py | 85 ++- .../database_connectors/updates.py | 9 + .../services/v2/user_households/services.py | 274 +++++++--- .../v2/user_households/transformations.py | 39 ++ .../services/v2/user_households/types.py | 68 ++- .../services/v2/user_households/validators.py | 52 +- .../database_connectors/creates.py | 13 + .../database_connectors/reads.py | 79 +++ .../database_connectors/updates.py | 7 + .../services/v2/user_policies/services.py | 297 +++++++++-- .../v2/user_policies/transformations.py | 39 ++ .../services/v2/user_policies/types.py | 92 +++- .../services/v2/user_policies/validators.py | 54 +- .../services/v2/user_reports/__init__.py | 1 + .../database_connectors/__init__.py | 1 + .../database_connectors/creates.py | 38 ++ .../database_connectors/deletes.py | 15 + .../user_reports/database_connectors/reads.py | 122 +++++ .../database_connectors/updates.py | 25 + .../v2/user_reports/database_session.py | 24 + .../services/v2/user_reports/services.py | 327 ++++++++++++ .../v2/user_reports/transformations.py | 102 ++++ .../services/v2/user_reports/types.py | 209 ++++++++ .../services/v2/user_reports/validators.py | 47 ++ .../services/v2/user_simulations/__init__.py | 1 + .../database_connectors/__init__.py | 1 + .../database_connectors/creates.py | 40 ++ .../database_connectors/deletes.py | 15 + .../database_connectors/reads.py | 83 +++ .../database_connectors/updates.py | 25 + .../v2/user_simulations/database_session.py | 24 + .../services/v2/user_simulations/services.py | 269 ++++++++++ .../v2/user_simulations/transformations.py | 68 +++ .../services/v2/user_simulations/types.py | 90 ++++ .../v2/user_simulations/validators.py | 38 ++ tests/unit/test_query_parameters.py | 44 +- tests/unit/v2/test_data_crud_boundaries.py | 56 ++ .../unit/v2/test_legacy_reference_service.py | 146 +++++ tests/unit/v2/test_me_routes.py | 219 ++++++++ tests/unit/v2/test_metadata_routes.py | 7 + tests/unit/v2/test_user_account_service.py | 457 ++++++++++++++++ tests/unit/v2/test_user_household_routes.py | 497 ++++++------------ tests/unit/v2/test_user_household_service.py | 288 ++++------ tests/unit/v2/test_user_policy_routes.py | 425 ++++++++------- tests/unit/v2/test_user_policy_service.py | 427 +++++++++------ tests/unit/v2/test_user_route_auth.py | 216 ++++++++ .../v2/test_user_simulation_report_routes.py | 302 +++++++++++ tests/unit/v2/user_route_support.py | 98 ++++ tests/unit/v2/user_service_support.py | 189 +++++++ 103 files changed, 7791 insertions(+), 1265 deletions(-) create mode 100644 policyengine_api/fastapi_routes/v2/me/__init__.py create mode 100644 policyengine_api/fastapi_routes/v2/me/request_models.py create mode 100644 policyengine_api/fastapi_routes/v2/me/response_models.py create mode 100644 policyengine_api/fastapi_routes/v2/me/routes.py create mode 100644 policyengine_api/fastapi_routes/v2/user_auth.py create mode 100644 policyengine_api/fastapi_routes/v2/user_reports/__init__.py create mode 100644 policyengine_api/fastapi_routes/v2/user_reports/request_models.py create mode 100644 policyengine_api/fastapi_routes/v2/user_reports/response_models.py create mode 100644 policyengine_api/fastapi_routes/v2/user_reports/routes.py create mode 100644 policyengine_api/fastapi_routes/v2/user_resources.py create mode 100644 policyengine_api/fastapi_routes/v2/user_simulations/__init__.py create mode 100644 policyengine_api/fastapi_routes/v2/user_simulations/request_models.py create mode 100644 policyengine_api/fastapi_routes/v2/user_simulations/response_models.py create mode 100644 policyengine_api/fastapi_routes/v2/user_simulations/routes.py create mode 100644 policyengine_api/services/v2/associations/__init__.py create mode 100644 policyengine_api/services/v2/associations/types.py create mode 100644 policyengine_api/services/v2/associations/validators.py create mode 100644 policyengine_api/services/v2/identity/__init__.py create mode 100644 policyengine_api/services/v2/identity/database_connectors/__init__.py create mode 100644 policyengine_api/services/v2/identity/database_connectors/creates.py create mode 100644 policyengine_api/services/v2/identity/database_connectors/reads.py create mode 100644 policyengine_api/services/v2/identity/database_connectors/updates.py create mode 100644 policyengine_api/services/v2/identity/database_session.py create mode 100644 policyengine_api/services/v2/identity/services.py create mode 100644 policyengine_api/services/v2/identity/transformations.py create mode 100644 policyengine_api/services/v2/identity/types.py create mode 100644 policyengine_api/services/v2/identity/validators.py create mode 100644 policyengine_api/services/v2/legacy_refs/__init__.py create mode 100644 policyengine_api/services/v2/legacy_refs/database_connectors/__init__.py create mode 100644 policyengine_api/services/v2/legacy_refs/database_connectors/reads.py create mode 100644 policyengine_api/services/v2/legacy_refs/database_session.py create mode 100644 policyengine_api/services/v2/legacy_refs/services.py create mode 100644 policyengine_api/services/v2/legacy_refs/types.py create mode 100644 policyengine_api/services/v2/legacy_refs/validators.py create mode 100644 policyengine_api/services/v2/user_accounts/__init__.py create mode 100644 policyengine_api/services/v2/user_accounts/database_connectors/__init__.py create mode 100644 policyengine_api/services/v2/user_accounts/database_connectors/deletes.py create mode 100644 policyengine_api/services/v2/user_accounts/database_connectors/reads.py create mode 100644 policyengine_api/services/v2/user_accounts/database_connectors/updates.py create mode 100644 policyengine_api/services/v2/user_accounts/database_session.py create mode 100644 policyengine_api/services/v2/user_accounts/services.py create mode 100644 policyengine_api/services/v2/user_accounts/transformations.py create mode 100644 policyengine_api/services/v2/user_accounts/types.py create mode 100644 policyengine_api/services/v2/user_accounts/validators.py create mode 100644 policyengine_api/services/v2/user_reports/__init__.py create mode 100644 policyengine_api/services/v2/user_reports/database_connectors/__init__.py create mode 100644 policyengine_api/services/v2/user_reports/database_connectors/creates.py create mode 100644 policyengine_api/services/v2/user_reports/database_connectors/deletes.py create mode 100644 policyengine_api/services/v2/user_reports/database_connectors/reads.py create mode 100644 policyengine_api/services/v2/user_reports/database_connectors/updates.py create mode 100644 policyengine_api/services/v2/user_reports/database_session.py create mode 100644 policyengine_api/services/v2/user_reports/services.py create mode 100644 policyengine_api/services/v2/user_reports/transformations.py create mode 100644 policyengine_api/services/v2/user_reports/types.py create mode 100644 policyengine_api/services/v2/user_reports/validators.py create mode 100644 policyengine_api/services/v2/user_simulations/__init__.py create mode 100644 policyengine_api/services/v2/user_simulations/database_connectors/__init__.py create mode 100644 policyengine_api/services/v2/user_simulations/database_connectors/creates.py create mode 100644 policyengine_api/services/v2/user_simulations/database_connectors/deletes.py create mode 100644 policyengine_api/services/v2/user_simulations/database_connectors/reads.py create mode 100644 policyengine_api/services/v2/user_simulations/database_connectors/updates.py create mode 100644 policyengine_api/services/v2/user_simulations/database_session.py create mode 100644 policyengine_api/services/v2/user_simulations/services.py create mode 100644 policyengine_api/services/v2/user_simulations/transformations.py create mode 100644 policyengine_api/services/v2/user_simulations/types.py create mode 100644 policyengine_api/services/v2/user_simulations/validators.py create mode 100644 tests/unit/v2/test_legacy_reference_service.py create mode 100644 tests/unit/v2/test_me_routes.py create mode 100644 tests/unit/v2/test_user_account_service.py create mode 100644 tests/unit/v2/test_user_route_auth.py create mode 100644 tests/unit/v2/test_user_simulation_report_routes.py create mode 100644 tests/unit/v2/user_route_support.py create mode 100644 tests/unit/v2/user_service_support.py diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 41a288836..42c971d3d 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -10,6 +10,9 @@ from typing import TYPE_CHECKING, Protocol from uuid import UUID +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + from policyengine_api.json_types import JSONObject if TYPE_CHECKING: @@ -39,18 +42,42 @@ NativeHouseholdCreation, NativeHouseholdCreationInput, ) + from policyengine_api.services.v2.associations.types import ( + LegacyReferenceChecker, + ) + from policyengine_api.services.v2.identity.types import UserPrincipal + from policyengine_api.services.v2.user_accounts.types import ( + AccountDeletion, + AccountExport, + AccountImportInput, + AccountImportResult, + AccountRead, + AccountUpdateInput, + ) 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 +285,19 @@ def list_households( class V2UserPolicyResourceService(Protocol): - """Route-independent native association operations for one request.""" + """Route-independent signed-in user-policy operations for one request.""" def create_user_policy( self, - association_input: "UserPolicyCreationInput", + *, + principal: "UserPrincipal", + association_input: "UserPolicyCreateFields", ) -> "UserPolicyRead": ... def get_user_policy( self, *, + principal: "UserPrincipal", country_id: str, association_id: UUID, ) -> "UserPolicyRead": ... @@ -275,9 +305,10 @@ def get_user_policy( def list_user_policies( self, *, + principal: "UserPrincipal", 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 +316,7 @@ def list_user_policies( def patch_user_policy( self, *, + principal: "UserPrincipal", country_id: str, association_id: UUID, association_input: "UserPolicyUpdateInput", @@ -293,22 +325,26 @@ def patch_user_policy( def delete_user_policy( self, *, + principal: "UserPrincipal", country_id: str, association_id: UUID, ) -> None: ... class V2UserHouseholdResourceService(Protocol): - """Route-independent native user-household operations for one request.""" + """Route-independent signed-in user-household operations for one request.""" def create_user_household( self, - association_input: "UserHouseholdCreationInput", + *, + principal: "UserPrincipal", + association_input: "UserHouseholdCreateFields", ) -> "UserHouseholdRead": ... def get_user_household( self, *, + principal: "UserPrincipal", country_id: str, association_id: UUID, ) -> "UserHouseholdRead": ... @@ -316,9 +352,10 @@ def get_user_household( def list_user_households( self, *, + principal: "UserPrincipal", 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 +363,7 @@ def list_user_households( def patch_user_household( self, *, + principal: "UserPrincipal", country_id: str, association_id: UUID, association_input: "UserHouseholdUpdateInput", @@ -334,11 +372,134 @@ def patch_user_household( def delete_user_household( self, *, + principal: "UserPrincipal", + country_id: str, + association_id: UUID, + ) -> None: ... + + +class V2UserSimulationResourceService(Protocol): + """Route-independent signed-in user-simulation operations for one request.""" + + def create_user_simulation( + self, + *, + principal: "UserPrincipal", + association_input: "UserSimulationCreateFields", + ) -> "UserSimulationRead": ... + + def get_user_simulation( + self, + *, + principal: "UserPrincipal", + country_id: str, + association_id: UUID, + ) -> "UserSimulationRead": ... + + def list_user_simulations( + self, + *, + principal: "UserPrincipal", + country_id: str, + legacy_simulation_id: int | None = None, + offset: int = 0, + limit: int = 100, + ) -> "UserSimulationPage": ... + + def patch_user_simulation( + self, + *, + principal: "UserPrincipal", + country_id: str, + association_id: UUID, + association_input: "UserSimulationUpdateInput", + ) -> "UserSimulationRead": ... + + def delete_user_simulation( + self, + *, + principal: "UserPrincipal", + country_id: str, + association_id: UUID, + ) -> None: ... + + +class V2UserReportResourceService(Protocol): + """Route-independent signed-in user-report operations for one request.""" + + def create_user_report( + self, + *, + principal: "UserPrincipal", + association_input: "UserReportCreateFields", + ) -> "UserReportRead": ... + + def get_user_report( + self, + *, + principal: "UserPrincipal", + country_id: str, + association_id: UUID, + ) -> "UserReportRead": ... + + def list_user_reports( + self, + *, + principal: "UserPrincipal", + country_id: str, + legacy_report_id: int | None = None, + offset: int = 0, + limit: int = 100, + ) -> "UserReportPage": ... + + def patch_user_report( + self, + *, + principal: "UserPrincipal", + country_id: str, + association_id: UUID, + association_input: "UserReportUpdateInput", + ) -> "UserReportRead": ... + + def delete_user_report( + self, + *, + principal: "UserPrincipal", country_id: str, association_id: UUID, ) -> None: ... +class V2AccountResourceService(Protocol): + """Route-independent signed-in account operations for one request.""" + + def get_account( + self, *, principal: "UserPrincipal", country_id: str + ) -> "AccountRead": ... + + def update_account( + self, + *, + principal: "UserPrincipal", + country_id: str, + account_input: "AccountUpdateInput", + ) -> "AccountRead": ... + + def export_account( + self, *, principal: "UserPrincipal", country_id: str + ) -> "AccountExport": ... + + def import_records( + self, + *, + principal: "UserPrincipal", + country_id: str, + import_input: "AccountImportInput", + ) -> "AccountImportResult": ... + + def delete_account(self, *, principal: "UserPrincipal") -> "AccountDeletion": ... + + class SimulationGatewayProbe(Protocol): """Minimal simulation-entrypoint health-check interface.""" @@ -412,6 +573,41 @@ 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.", +) + + +def _default_require_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_user_bearer_scheme), +) -> "UserPrincipal": + """Authenticate the signed-in user; fails closed without valid settings.""" + + from policyengine_api.fastapi_routes.v2.user_auth import ( + authenticate_user_request, + ) + + return authenticate_user_request( + None if credentials is None else f"Bearer {credentials.credentials}" + ) + + +def _default_legacy_reference_checker() -> "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_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 +617,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 +633,55 @@ 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()), + legacy_references=_default_legacy_reference_checker(), + auth0_user_deleter=build_auth0_user_deleter(), ) @@ -455,6 +702,17 @@ 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 and fails closed (401/403/503); tests replace it with + # a callable returning a fixed principal. + require_user: Callable[..., "UserPrincipal"] = _default_require_user @classmethod def defaults(cls) -> "NativeRouteDependencies": @@ -471,4 +729,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/me/__init__.py b/policyengine_api/fastapi_routes/v2/me/__init__.py new file mode 100644 index 000000000..546b66441 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/me/__init__.py @@ -0,0 +1 @@ +"""FastAPI adapters for the signed-in 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..107db1ccf --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/me/request_models.py @@ -0,0 +1,17 @@ +"""Strict HTTP request models for the signed-in account resource.""" + +from policyengine_api.services.v2.user_accounts.types import ( + AccountImportInput, + AccountUpdateInput, +) + + +MAXIMUM_IMPORT_REQUEST_BYTES = 1_048_576 + + +class AccountPatchRequest(AccountUpdateInput): + """Explicitly supplied account presentation fields.""" + + +class AccountImportRequest(AccountImportInput): + """Browser-local records offered for idempotent import (at most 500).""" 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..25e86e9e6 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/me/response_models.py @@ -0,0 +1,112 @@ +"""Strict HTTP response models for the signed-in 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, + AccountImportResult, + AccountRead, +) + + +class StrictAccountAPIModel(BaseModel): + model_config = ConfigDict(extra="forbid", from_attributes=True) + + +class MeItem(StrictAccountAPIModel): + id: UUID + primary_country: CountryId + 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): + user: MeItem + policies: list[UserPolicyItem] + households: list[UserHouseholdItem] + simulations: list[UserSimulationItem] + reports: list[UserReportItem] + + @classmethod + def from_export(cls, export: AccountExport) -> "MeExportResult": + return cls( + user=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 MeImportSkip(StrictAccountAPIModel): + client_ref: str | None + reason: Literal["invalid", "unknown_legacy_reference"] + + +class MeImportResult(StrictAccountAPIModel): + id_map: dict[str, UUID] + skipped: list[MeImportSkip] + + @classmethod + def from_result(cls, result: AccountImportResult) -> "MeImportResult": + return cls( + id_map=dict(result.id_map), + skipped=[ + MeImportSkip(client_ref=skip.client_ref, reason=skip.reason.value) + for skip in result.skipped + ], + ) + + +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 MeImportResponse(AccountSuccessResponse[MeImportResult]): + 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..e6c5ddfa8 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/me/routes.py @@ -0,0 +1,211 @@ +"""Native FastAPI routes for the signed-in account resource (/v2/me). + +``GET /v2/me`` provisions the caller's PolicyEngine user on first use, with +``primary_country`` taken from ``country_id``. Every route requires a user +bearer token; authentication is a router-level dependency. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from fastapi import APIRouter, Depends, Request +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, + V2RequestTooLargeError, +) +from policyengine_api.fastapi_routes.v2.me.request_models import ( + MAXIMUM_IMPORT_REQUEST_BYTES, + AccountImportRequest, + AccountPatchRequest, +) +from policyengine_api.fastapi_routes.v2.me.response_models import ( + MeDetailResponse, + MeDetailResult, + MeExportResponse, + MeExportResult, + MeImportResponse, + MeImportResult, + MeItem, +) +from policyengine_api.fastapi_routes.v2.user_resources import ( + USER_RESOURCE_ERROR_RESPONSES, + user_resource_operation, +) +from policyengine_api.query_parameters import CountryQuery +from policyengine_api.services.v2.identity.types import UserPrincipal + + +async def enforce_import_request_size(request: Request) -> None: + """Bound both declared and actual request bytes before service creation.""" + + 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( + "Import request Content-Length is invalid" + ) from error + if declared_length > MAXIMUM_IMPORT_REQUEST_BYTES: + raise V2RequestTooLargeError("Import request body exceeds 1 MiB") + if len(await request.body()) > MAXIMUM_IMPORT_REQUEST_BYTES: + raise V2RequestTooLargeError("Import request body exceeds 1 MiB") + + +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 signed-in 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) + + @router.get( + "/me", + response_model=MeDetailResponse, + summary="Read (and on first use provision) the signed-in user", + description=( + "Returns the PolicyEngine user for the bearer token, creating it on " + "the first call with primary_country set from country_id." + ), + ) + def get_me( + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = 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", + ) + def patch_me( + body: AccountPatchRequest, + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = Depends(require_user), + ) -> 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." + ), + } + }, + summary="Delete the signed-in user and all of their saved records", + description=( + "Deletes the user's legacy user mappings, then the user (identities " + "and associations cascade), then the Auth0 user when the Management " + "API is configured." + ), + ) + def delete_me( + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = 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 signed-in user and every saved record", + description="Covers every country, not only country_id.", + ) + def export_me( + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = 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/imports", + response_model=MeImportResponse, + responses={ + 413: { + "model": V2ErrorResponse, + "description": "The import body exceeds 1 MiB.", + } + }, + summary="Import browser-local saved records into the signed-in account", + description=( + "At most 500 items and 1 MiB. Idempotent on (user, client_ref): an " + "existing client_ref returns its stored ID unchanged. Items with an " + "unknown v1 reference or an invalid shape are skipped and reported." + ), + ) + def import_records( + body: AccountImportRequest, + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = Depends(require_user), + _size: None = Depends(enforce_import_request_size), + ) -> MeImportResponse | JSONResponse: + def import_() -> MeImportResponse: + result = service_factory().import_records( + principal=principal, + country_id=query.country_id, + import_input=body, + ) + return MeImportResponse(result=MeImportResult.from_result(result)) + + return user_resource_operation(import_) + + 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..988e4a9dc --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_auth.py @@ -0,0 +1,61 @@ +"""HTTP adapter from bearer access tokens to verified user principals. + +User-owned routes attach ``NativeRouteDependencies.require_user`` at router +level, so authentication runs before query and body validation. Failures map +to the shared API v2 error envelope: + +- no bearer token, or an invalid token: 401 with ``WWW-Authenticate: Bearer``; +- a machine (``…@clients``) principal: 403; +- authentication not configured, or signing keys 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, + MissingBearerTokenError, + PrincipalKind, + UserAuthConfigurationError, + UserAuthUnavailableError, + authenticate_bearer, + load_user_auth_settings, +) +from policyengine_api.services.v2.identity.types import 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, + ) 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..829a04764 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 signed-in 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 bearer token.""" 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..9dba3b987 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 signed-in 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,10 +25,12 @@ 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 + client_ref: str | None created_at: datetime updated_at: datetime @@ -74,29 +78,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..235ea8319 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 signed-in 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,7 @@ UserHouseholdPageResponse, UserHouseholdPageResult, ) +from policyengine_api.fastapi_routes.v2.user_resources import user_resource_operation from policyengine_api.query_parameters import ( UserHouseholdCollectionQuery, UserHouseholdCreateQuery, @@ -36,12 +34,7 @@ UserHouseholdDetailQuery, UserHouseholdUpdateQuery, ) -from policyengine_api.services.v2.user_households.validators import ( - AssociationCountryConflictError, - AssociationHouseholdNotFoundError, - AssociationUserNotFoundError, - UserHouseholdNotFoundError, -) +from policyengine_api.services.v2.identity.types import UserPrincipal def _service_factory( @@ -56,38 +49,21 @@ 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.""" + """Build signed-in association routes without opening a connection. - router = APIRouter(prefix="/v2", responses=USER_HOUSEHOLD_ERROR_RESPONSES) + Authentication is a router-level dependency, so a missing or invalid + token 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)], + ) create_query = query_dependency(UserHouseholdCreateQuery) detail_query = query_dependency(UserHouseholdDetailQuery) collection_query = query_dependency(UserHouseholdCollectionQuery) @@ -99,40 +75,45 @@ 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 signed-in 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 user bearer token; the owner is taken from the token. " + "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: UserPrincipal = 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 signed-in user's saved households", ) def get_user_household( association_id: UUID, query: UserHouseholdDetailQuery = Depends(detail_query), + principal: UserPrincipal = 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 +121,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 signed-in user's saved households", ) def get_user_households( query: UserHouseholdCollectionQuery = Depends(collection_query), + principal: UserPrincipal = 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 +145,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 signed-in 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: UserPrincipal = 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 +173,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 signed-in user's saved households", ) def delete_user_household_route( association_id: UUID, query: UserHouseholdDeleteQuery = Depends(delete_query), + principal: UserPrincipal = 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..f1ac41ee4 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 signed-in 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 bearer token.""" 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..3aae30575 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 signed-in 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): @@ -25,10 +28,14 @@ class StrictUserPolicyAPIModel(BaseModel): 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: dict[str, Any] | None + client_ref: str | None created_at: datetime updated_at: datetime @@ -74,29 +81,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..d44cc80dc 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 signed-in 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,12 @@ 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.query_parameters import query_dependency +from policyengine_api.fastapi_routes.v2.user_resources import user_resource_operation from policyengine_api.query_parameters import ( CountryQuery, UserPolicyCollectionQuery, ) +from policyengine_api.services.v2.identity.types import UserPrincipal def _service_factory( @@ -53,37 +46,21 @@ 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.""" + """Build signed-in association routes without opening a connection. + + Authentication is a router-level dependency, so a missing or invalid + token is rejected before query or body validation. + """ - router = APIRouter(prefix="/v2", responses=USER_POLICY_ERROR_RESPONSES) + require_user = dependencies.require_user + router = APIRouter( + prefix="/v2", + responses=USER_POLICY_ERROR_RESPONSES, + dependencies=[Depends(require_user)], + ) country_query = query_dependency(CountryQuery) collection_query = query_dependency(UserPolicyCollectionQuery) service_factory = _service_factory(dependencies) @@ -92,17 +69,18 @@ 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 signed-in 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 user bearer token; the owner is taken from the token. " + "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: UserPrincipal = Depends(require_user), ) -> UserPolicyDetailResponse | JSONResponse: if body.country_id != query.country_id: return v2_error_response( @@ -111,24 +89,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 signed-in user's saved policies", ) def get_user_policy( association_id: UUID, query: CountryQuery = Depends(country_query), + principal: UserPrincipal = 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 +119,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 signed-in user's saved policies", ) def get_user_policies( query: UserPolicyCollectionQuery = Depends(collection_query), + principal: UserPrincipal = 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 signed-in 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: UserPrincipal = 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 +170,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 signed-in user's saved policies", ) def delete_user_policy_route( association_id: UUID, query: CountryQuery = Depends(country_query), + principal: UserPrincipal = 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..e26d8c074 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_reports/__init__.py @@ -0,0 +1 @@ +"""FastAPI adapters for signed-in 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..5cccf9c26 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_reports/request_models.py @@ -0,0 +1,14 @@ +"""Strict HTTP request models for signed-in 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 bearer token.""" + + +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..6b504329c --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_reports/response_models.py @@ -0,0 +1,78 @@ +"""Strict HTTP response models for signed-in v2 user-report associations.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, 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_reports.types import ( + UserReportPage, + UserReportRead, +) + + +class StrictUserReportAPIModel(BaseModel): + model_config = ConfigDict(extra="forbid", from_attributes=True) + + +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 + source_note: str | None + provisions: list[Any] | None + validation: dict[str, Any] | None + last_run_at: datetime | None + client_ref: str | 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..f7e5f0930 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_reports/routes.py @@ -0,0 +1,190 @@ +"""Native FastAPI routes for signed-in 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, + 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 UserPrincipal + + +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 signed-in association routes without opening a connection. + + Authentication is a router-level dependency, so a missing or invalid + token 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)], + ) + 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 signed-in user", + description=( + "Requires a user bearer token; the owner is taken from the token. " + "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: UserPrincipal = 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 signed-in user's saved reports", + ) + def get_user_report( + association_id: UUID, + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = 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 signed-in user's saved reports", + ) + def get_user_reports( + query: UserReportCollectionQuery = Depends(collection_query), + principal: UserPrincipal = 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 signed-in user's saved reports", + ) + def patch_user_report_route( + association_id: UUID, + body: UserReportPatchRequest, + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = 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 signed-in user's saved reports", + ) + def delete_user_report_route( + association_id: UUID, + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = 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..869770e87 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_resources.py @@ -0,0 +1,111 @@ +"""Shared HTTP conventions for signed-in user-owned API v2 resources.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, TypeVar + +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 ( + V2ErrorResponse, + v2_error_response, +) +from policyengine_api.services.v2.associations.validators import ( + AssociationConflictError, + AssociationLinkNotFoundError, + AssociationNotFoundError, + AssociationReferenceConflictError, +) +from policyengine_api.services.v2.legacy_refs.validators import ( + LegacyReferenceNotFoundError, +) +from policyengine_api.services.v2.user_accounts.validators import ( + AccountIdentityDeletionError, + AccountNotFoundError, +) +from policyengine_api.services.v2.user_households.validators import ( + AssociationHouseholdNotFoundError, +) +from policyengine_api.services.v2.user_policies.validators import ( + AssociationPolicyNotFoundError, +) + + +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 is required; the response carries " + "WWW-Authenticate: Bearer." + ), + }, + 403: { + "model": V2ErrorResponse, + "description": "Machine (client-credentials) principals are not permitted.", + }, + 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 or a duplicate client_ref." + ), + }, + 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.", + }, +} + +OperationT = TypeVar("OperationT") + + +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 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 (V2ConfigurationError, SQLAlchemyError): + return v2_error_response(503, "V2 user persistence is unavailable") + except Exception: # noqa: BLE001 - return a secret-safe typed 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..2a9c5f9e4 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_simulations/__init__.py @@ -0,0 +1 @@ +"""FastAPI adapters for signed-in 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..66fb05043 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_simulations/request_models.py @@ -0,0 +1,14 @@ +"""Strict HTTP request models for signed-in 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 bearer token.""" + + +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..4291a6a07 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_simulations/response_models.py @@ -0,0 +1,77 @@ +"""Strict HTTP response models for signed-in 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 + client_ref: 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..a44400ecb --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/user_simulations/routes.py @@ -0,0 +1,197 @@ +"""Native FastAPI routes for signed-in 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, + 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 UserPrincipal + + +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 signed-in association routes without opening a connection. + + Authentication is a router-level dependency, so a missing or invalid + token 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)], + ) + 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 signed-in user", + description=( + "Requires a user bearer token; the owner is taken from the token. " + "Stage 12A accepts legacy_simulation_id only, checked in the v1 " + "database." + ), + ) + def create_user_simulation( + body: UserSimulationCreateRequest, + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = 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 signed-in user's saved simulations", + ) + def get_user_simulation( + association_id: UUID, + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = 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 signed-in user's saved simulations", + ) + def get_user_simulations( + query: UserSimulationCollectionQuery = Depends(collection_query), + principal: UserPrincipal = 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 signed-in user's saved simulations", + ) + def patch_user_simulation_route( + association_id: UUID, + body: UserSimulationPatchRequest, + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = 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 signed-in user's saved simulations", + ) + def delete_user_simulation_route( + association_id: UUID, + query: CountryQuery = Depends(country_query), + principal: UserPrincipal = 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/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/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/types.py b/policyengine_api/services/v2/associations/types.py new file mode 100644 index 000000000..096717f9c --- /dev/null +++ b/policyengine_api/services/v2/associations/types.py @@ -0,0 +1,44 @@ +"""Framework-independent field types shared by user association resources.""" + +from __future__ import annotations + +from collections.abc import Collection, Mapping +from typing import Annotated, Protocol + +from pydantic import BaseModel, ConfigDict, StringConstraints + +from policyengine_api.data.v2.models.associations import ( + ASSOCIATION_CLIENT_REF_MAX_LENGTH, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind + + +AssociationName = Annotated[str, StringConstraints(max_length=255)] +AssociationClientRef = Annotated[ + str, + StringConstraints(min_length=1, max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH), +] + + +class StrictAssociationModel(BaseModel): + """Reject fields outside a reviewed association contract.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +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..423d0163f --- /dev/null +++ b/policyengine_api/services/v2/associations/validators.py @@ -0,0 +1,48 @@ +"""Typed failures shared by user-owned association resources.""" + +from __future__ import annotations + +from uuid import UUID + + +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 AssociationClientRefConflictError(AssociationConflictError): + """The caller already has an association with the supplied client_ref.""" + + +class AssociationLinkNotFoundError(LookupError): + """A linked association is absent or not owned by the same user and country.""" + + +def require_unused_client_ref( + existing_id: UUID | None, + *, + client_ref: str, +) -> None: + if existing_id is not None: + raise AssociationClientRefConflictError( + f"an association with client_ref {client_ref!r} already exists" + ) + + +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..035c25044 --- /dev/null +++ b/policyengine_api/services/v2/identity/__init__.py @@ -0,0 +1 @@ +"""Signed-in user identity resolution and account services.""" 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..a9ac5164f --- /dev/null +++ b/policyengine_api/services/v2/identity/database_connectors/creates.py @@ -0,0 +1,43 @@ +"""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 + + +def create_provisioned_user(session: Session, *, primary_country: str) -> User: + user = User(primary_country=primary_country) + session.add(user) + session.flush() + return user + + +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/reads.py b/policyengine_api/services/v2/identity/database_connectors/reads.py new file mode 100644 index 000000000..01e23b3e4 --- /dev/null +++ b/policyengine_api/services/v2/identity/database_connectors/reads.py @@ -0,0 +1,21 @@ +"""Database selections used by v2 identity resolution.""" + +from __future__ import annotations + +from sqlmodel import Session, select + +from policyengine_api.data.v2.models import UserIdentity + + +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() 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..5cc7daa86 --- /dev/null +++ b/policyengine_api/services/v2/identity/database_connectors/updates.py @@ -0,0 +1,23 @@ +"""Database updates used by v2 identity resolution.""" + +from __future__ import annotations + +from datetime import datetime + +from sqlmodel import Session + +from policyengine_api.data.v2.models import UserIdentity + + +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 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..9b5173a5d --- /dev/null +++ b/policyengine_api/services/v2/identity/services.py @@ -0,0 +1,131 @@ +"""Resolve and provision the v2 user for a verified sign-in identity. + +Every user-owned route runs :func:`resolve_principal_user_in_session` once per +request inside its own v2 transaction. The first request for an unknown +``(issuer, subject)`` provisions a ``users`` row (with the request country as +its primary country) and a ``user_identities`` row. 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. Only the v2 user UUID is logged. +""" + +from __future__ import annotations + +from datetime import datetime +import logging + +from sqlmodel import Session + +from policyengine_api.data.v2.models.base import utc_now +from policyengine_api.services.v2.identity.database_connectors.creates import ( + create_provisioned_user, + create_user_identity, +) +from policyengine_api.services.v2.identity.database_connectors.reads import ( + read_user_identity, +) +from policyengine_api.services.v2.identity.database_connectors.updates import ( + update_user_identity_activity, +) +from policyengine_api.services.v2.identity.database_session import ( + provisioning_savepoint, +) +from policyengine_api.services.v2.identity.transformations import ( + identity_refresh_required, + refreshed_identity_email, +) +from policyengine_api.services.v2.identity.types import ResolvedUser, UserPrincipal +from policyengine_api.services.v2.identity.validators import ( + require_concurrent_identity, +) + + +logger = logging.getLogger(__name__) + + +def find_principal_user_in_session( + session: Session, + principal: UserPrincipal, +) -> ResolvedUser | None: + """Return the existing user for a principal without provisioning one.""" + + identity = read_user_identity( + session, + issuer=principal.issuer, + subject=principal.subject, + ) + if identity is None: + return None + return ResolvedUser( + user_id=identity.user_id, + email=identity.email, + provisioned=False, + ) + + +def resolve_principal_user_in_session( + session: Session, + principal: UserPrincipal, + *, + country_id: str, + now: datetime | None = None, +) -> ResolvedUser: + """Return the principal's v2 user, provisioning it on first use.""" + + 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 ResolvedUser( + user_id=identity.user_id, + email=identity.email, + provisioned=False, + ) + + 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={"v2_user_id": str(created_user_id)}, + ) + return ResolvedUser( + user_id=created_user_id, + email=principal.email, + provisioned=True, + ) + + concurrent = require_concurrent_identity( + read_user_identity( + session, + issuer=principal.issuer, + subject=principal.subject, + ) + ) + return ResolvedUser( + user_id=concurrent.user_id, + email=concurrent.email, + provisioned=False, + ) diff --git a/policyengine_api/services/v2/identity/transformations.py b/policyengine_api/services/v2/identity/transformations.py new file mode 100644 index 000000000..8928da6be --- /dev/null +++ b/policyengine_api/services/v2/identity/transformations.py @@ -0,0 +1,33 @@ +"""Pure transformations for v2 identity resolution.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from policyengine_api.data.v2.models import UserIdentity + + +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 diff --git a/policyengine_api/services/v2/identity/types.py b/policyengine_api/services/v2/identity/types.py new file mode 100644 index 000000000..b10afee06 --- /dev/null +++ b/policyengine_api/services/v2/identity/types.py @@ -0,0 +1,32 @@ +"""Framework-independent data exchanged by v2 identity layers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, StringConstraints + + +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 + + +@dataclass(frozen=True) +class ResolvedUser: + """The v2 user that owns one verified sign-in identity.""" + + user_id: UUID + email: str | None + provisioned: bool diff --git a/policyengine_api/services/v2/identity/validators.py b/policyengine_api/services/v2/identity/validators.py new file mode 100644 index 000000000..dcb139e4c --- /dev/null +++ b/policyengine_api/services/v2/identity/validators.py @@ -0,0 +1,17 @@ +"""Database-independent validation for v2 identity resolution.""" + +from __future__ import annotations + +from policyengine_api.data.v2.models import UserIdentity + + +class IdentityProvisioningError(RuntimeError): + """Raised when a concurrent provisioning conflict leaves no stored identity.""" + + +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 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..b44e9056b --- /dev/null +++ b/policyengine_api/services/v2/legacy_refs/services.py @@ -0,0 +1,69 @@ +"""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 Collection, Mapping + +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], + ) 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..8eb8e0031 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/__init__.py @@ -0,0 +1 @@ +"""Signed-in account read, update, export, import, 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..ac59cd4b1 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/services.py @@ -0,0 +1,297 @@ +"""Signed-in account services for ``/v2/me``. + +Reads, updates, exports, and imports resolve (and on first use provision) the +caller's v2 user from the verified session principal. Imports are idempotent +on ``(user_id, client_ref)``: unknown v1 references and invalid items are +skipped and reported rather than failing the request. Deletion removes the +user's legacy mappings, then the user (identities and associations cascade), +and finally the Auth0 user when the Management API is configured. Only v2 user +UUIDs are logged. +""" + +from __future__ import annotations + +from collections.abc import Callable +import logging + +from policyengine_api.services.v2.associations.types import LegacyReferenceChecker +from policyengine_api.services.v2.identity.services import ( + find_principal_user_in_session, + resolve_principal_user_in_session, +) +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_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, + AccountImportInput, + AccountImportResult, + AccountRead, + AccountUpdateInput, +) +from policyengine_api.services.v2.user_accounts.validators import ( + AccountIdentityDeletionError, + partition_import_items, + partition_known_legacy_items, + require_account, +) +from policyengine_api.services.v2.user_households.services import ( + import_user_households_in_session, + read_all_user_households, +) +from policyengine_api.services.v2.user_households.types import UserHouseholdImportItem +from policyengine_api.services.v2.user_policies.services import ( + import_user_policies_in_session, + read_all_user_policies, +) +from policyengine_api.services.v2.user_policies.types import UserPolicyImportItem +from policyengine_api.services.v2.user_reports.services import ( + import_user_reports_in_session, + read_all_user_reports, +) +from policyengine_api.services.v2.user_reports.types import UserReportImportItem +from policyengine_api.services.v2.user_simulations.services import ( + import_user_simulations_in_session, + read_all_user_simulations, +) +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationImportItem, +) + + +logger = logging.getLogger(__name__) + + +class V2AccountService: + """Sequence signed-in account operations through explicit sessions.""" + + def __init__( + self, + database_session: AccountDatabaseSession, + *, + legacy_references: LegacyReferenceChecker | None = None, + auth0_user_deleter: Callable[[str], None] | None = None, + ) -> None: + self._database_session = database_session + self._legacy_references = legacy_references + self._auth0_user_deleter = auth0_user_deleter + + def get_account(self, *, principal: UserPrincipal, country_id: str) -> AccountRead: + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + user = require_account( + read_account_user(session, owner.user_id), user_id=owner.user_id + ) + return account_read(user, email=owner.email) + + def update_account( + self, + *, + principal: UserPrincipal, + country_id: str, + account_input: AccountUpdateInput, + ) -> AccountRead: + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + user = require_account( + read_account_user(session, owner.user_id), user_id=owner.user_id + ) + return account_read( + update_account_user( + session, user, account_update_values(account_input) + ), + email=owner.email, + ) + + def export_account( + self, *, principal: UserPrincipal, country_id: str + ) -> AccountExport: + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + user = require_account( + read_account_user(session, owner.user_id), user_id=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 import_records( + self, + *, + principal: UserPrincipal, + country_id: str, + import_input: AccountImportInput, + ) -> AccountImportResult: + policies, policy_skips = partition_import_items( + import_input.policies, UserPolicyImportItem + ) + households, household_skips = partition_import_items( + import_input.households, UserHouseholdImportItem + ) + simulations, simulation_skips = partition_import_items( + import_input.simulations, UserSimulationImportItem + ) + reports, report_skips = partition_import_items( + import_input.reports, UserReportImportItem + ) + + if self._legacy_references is None: + raise RuntimeError("v1 reference checks are not configured") + existing = self._legacy_references.existing_ids( + country_id=country_id, + requested={ + LegacyRecordKind.POLICY: [item.legacy_policy_id for item in policies], + LegacyRecordKind.HOUSEHOLD: [ + item.legacy_household_id for item in households + ], + LegacyRecordKind.SIMULATION: [ + item.legacy_simulation_id for item in simulations + ], + LegacyRecordKind.REPORT: [item.legacy_report_id for item in reports], + }, + ) + known_policies, unknown_policies = partition_known_legacy_items( + policies, + legacy_ids=[item.legacy_policy_id for item in policies], + client_refs=[item.client_ref for item in policies], + existing=existing[LegacyRecordKind.POLICY], + ) + known_households, unknown_households = partition_known_legacy_items( + households, + legacy_ids=[item.legacy_household_id for item in households], + client_refs=[item.client_ref for item in households], + existing=existing[LegacyRecordKind.HOUSEHOLD], + ) + known_simulations, unknown_simulations = partition_known_legacy_items( + simulations, + legacy_ids=[item.legacy_simulation_id for item in simulations], + client_refs=[item.client_ref for item in simulations], + existing=existing[LegacyRecordKind.SIMULATION], + ) + known_reports, unknown_reports = partition_known_legacy_items( + reports, + legacy_ids=[item.legacy_report_id for item in reports], + client_refs=[item.client_ref for item in reports], + existing=existing[LegacyRecordKind.REPORT], + ) + + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + policy_ids = import_user_policies_in_session( + session, + user_id=owner.user_id, + country_id=country_id, + items=known_policies, + ) + household_ids = import_user_households_in_session( + session, + user_id=owner.user_id, + country_id=country_id, + items=known_households, + ) + simulation_ids = import_user_simulations_in_session( + session, + user_id=owner.user_id, + country_id=country_id, + items=known_simulations, + ) + report_ids = import_user_reports_in_session( + session, + user_id=owner.user_id, + country_id=country_id, + items=known_reports, + imported_policy_ids=policy_ids, + ) + + return AccountImportResult( + id_map={**policy_ids, **household_ids, **simulation_ids, **report_ids}, + skipped=( + *policy_skips, + *unknown_policies, + *household_skips, + *unknown_households, + *simulation_skips, + *unknown_simulations, + *report_skips, + *unknown_reports, + ), + ) + + def delete_account(self, *, principal: UserPrincipal) -> AccountDeletion: + 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={"v2_user_id": str(deleted_user_id)}, + ) + + if self._auth0_user_deleter is None: + logger.warning( + "auth0_user_deletion_skipped_not_configured", + extra={ + "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: + self._auth0_user_deleter(subject) + except Exception as error: + logger.warning( + "auth0_user_deletion_failed", + extra={ + "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..c96e6e585 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/transformations.py @@ -0,0 +1,26 @@ +"""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, + 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..e5aaaca71 --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/types.py @@ -0,0 +1,117 @@ +"""Framework-independent data exchanged by v2 account layers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +from typing import Annotated +from uuid import UUID + +from pydantic import ( + BaseModel, + ConfigDict, + JsonValue, + StringConstraints, + model_validator, +) + +from policyengine_api.query_parameters import CountryId +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 + + +MAXIMUM_IMPORT_ITEMS = 500 +PersonName = Annotated[str, StringConstraints(max_length=255)] +ImportedRecord = dict[str, JsonValue] + + +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 + + +class AccountImportInput(StrictAccountInput): + """Browser-local records; each item is validated individually.""" + + policies: list[ImportedRecord] = [] + households: list[ImportedRecord] = [] + simulations: list[ImportedRecord] = [] + reports: list[ImportedRecord] = [] + + @model_validator(mode="after") + def bound_item_count(self) -> "AccountImportInput": + total = ( + len(self.policies) + + len(self.households) + + len(self.simulations) + + len(self.reports) + ) + if total > MAXIMUM_IMPORT_ITEMS: + raise ValueError( + f"An import may contain at most {MAXIMUM_IMPORT_ITEMS} items" + ) + return self + + +class ImportSkipReason(StrEnum): + INVALID = "invalid" + UNKNOWN_LEGACY_REFERENCE = "unknown_legacy_reference" + + +@dataclass(frozen=True) +class ImportSkip: + client_ref: str | None + reason: ImportSkipReason + + +@dataclass(frozen=True) +class AccountImportResult: + id_map: dict[str, UUID] + skipped: tuple[ImportSkip, ...] + + +@dataclass(frozen=True) +class AccountRead: + id: UUID + primary_country: str + first_name: str | None + last_name: str | None + email: str | None + created_at: datetime + + +@dataclass(frozen=True) +class AccountExport: + user: AccountRead + policies: tuple[UserPolicyRead, ...] + households: tuple[UserHouseholdRead, ...] + simulations: tuple[UserSimulationRead, ...] + reports: tuple[UserReportRead, ...] + + +@dataclass(frozen=True) +class AccountDeletion: + user_id: UUID | None + auth0_subjects: tuple[str, ...] 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..e4bb71e6e --- /dev/null +++ b/policyengine_api/services/v2/user_accounts/validators.py @@ -0,0 +1,84 @@ +"""Database-independent validation for v2 account operations.""" + +from __future__ import annotations + +from collections.abc import Collection, Sequence +from typing import TypeVar +from uuid import UUID + +from pydantic import BaseModel, ValidationError + +from policyengine_api.data.v2.models import User +from policyengine_api.services.v2.user_accounts.types import ( + ImportedRecord, + ImportSkip, + ImportSkipReason, +) + + +class AccountNotFoundError(LookupError): + """Raised when the resolved account row is missing.""" + + +class AccountIdentityDeletionError(RuntimeError): + """Raised when account data was removed but the Auth0 user was not.""" + + +ImportItemT = TypeVar("ImportItemT", bound=BaseModel) +LegacyItemT = TypeVar("LegacyItemT") + + +def require_account(user: User | None, *, user_id: UUID) -> User: + if user is None: + raise AccountNotFoundError(f"user {user_id} was not found") + return user + + +def _client_ref(record: ImportedRecord) -> str | None: + value = record.get("client_ref") + return value if isinstance(value, str) else None + + +def partition_import_items( + records: Sequence[ImportedRecord], + item_type: type[ImportItemT], +) -> tuple[list[ImportItemT], list[ImportSkip]]: + """Validate each record independently; invalid records are skipped.""" + + valid: list[ImportItemT] = [] + skipped: list[ImportSkip] = [] + for record in records: + try: + valid.append(item_type.model_validate(record)) + except ValidationError: + skipped.append( + ImportSkip( + client_ref=_client_ref(record), + reason=ImportSkipReason.INVALID, + ) + ) + return valid, skipped + + +def partition_known_legacy_items( + items: Sequence[LegacyItemT], + *, + legacy_ids: Sequence[int], + client_refs: Sequence[str], + existing: Collection[int], +) -> tuple[list[LegacyItemT], list[ImportSkip]]: + """Skip items whose v1 reference does not exist in the import country.""" + + known: list[LegacyItemT] = [] + skipped: list[ImportSkip] = [] + for item, legacy_id, client_ref in zip(items, legacy_ids, client_refs, strict=True): + if legacy_id in existing: + known.append(item) + else: + skipped.append( + ImportSkip( + client_ref=client_ref, + reason=ImportSkipReason.UNKNOWN_LEGACY_REFERENCE, + ) + ) + return known, skipped 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..7284ce54f 100644 --- a/policyengine_api/services/v2/user_households/database_connectors/creates.py +++ b/policyengine_api/services/v2/user_households/database_connectors/creates.py @@ -1,6 +1,11 @@ """Database inserts for v2 user-household associations.""" -from sqlmodel import Session +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.dialects.postgresql import insert +from sqlmodel import Session, col from policyengine_api.data.v2.models import UserHouseholdAssociation from policyengine_api.services.v2.user_households.types import ( @@ -17,3 +22,19 @@ def create_user_household( session.flush() session.refresh(association) return association + + +def import_user_household( + session: Session, + association_input: UserHouseholdCreationInput, +) -> UUID | None: + """Insert one imported association unless its client_ref already exists.""" + + return session.execute( + insert(UserHouseholdAssociation) + .values(**association_input.model_dump()) + .on_conflict_do_nothing( + constraint="uq_user_household_associations_user_client_ref" + ) + .returning(col(UserHouseholdAssociation.id)) + ).scalar_one_or_none() 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..bd66d3fb6 100644 --- a/policyengine_api/services/v2/user_households/database_connectors/reads.py +++ b/policyengine_api/services/v2/user_households/database_connectors/reads.py @@ -2,11 +2,17 @@ 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 +from policyengine_api.data.v2.models import ( + Household, + LegacyHouseholdMapping, + User, + UserHouseholdAssociation, +) def read_user(session: Session, user_id: UUID) -> User | None: @@ -36,6 +42,22 @@ def read_user_household_row( ).one_or_none() +def read_owned_user_household_row( + session: Session, + *, + user_id: UUID, + country_id: str, + association_id: UUID, +) -> UserHouseholdAssociation | None: + return session.exec( + select(UserHouseholdAssociation).where( + UserHouseholdAssociation.id == association_id, + UserHouseholdAssociation.country_id == country_id, + UserHouseholdAssociation.user_id == user_id, + ) + ).one_or_none() + + def read_user_household_rows( session: Session, *, @@ -44,6 +66,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 +76,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 +90,59 @@ 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_user_household_id_by_client_ref( + session: Session, + *, + user_id: UUID, + client_ref: str, +) -> UUID | None: + return session.exec( + select(UserHouseholdAssociation.id).where( + UserHouseholdAssociation.user_id == user_id, + UserHouseholdAssociation.client_ref == client_ref, + ) + ).one_or_none() + + +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..370d3f333 100644 --- a/policyengine_api/services/v2/user_households/services.py +++ b/policyengine_api/services/v2/user_households/services.py @@ -1,21 +1,39 @@ -"""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. v1 references are +checked in a short read-only v1 transaction before the v2 transaction opens. +""" from __future__ import annotations +from collections.abc import Sequence from uuid import UUID from sqlmodel import Session +from policyengine_api.data.v2.models import UserHouseholdAssociation +from policyengine_api.services.v2.associations.types import LegacyReferenceChecker +from policyengine_api.services.v2.associations.validators import ( + require_unused_client_ref, +) +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.types import LegacyRecordKind from policyengine_api.services.v2.user_households.database_connectors.creates import ( create_user_household, + import_user_household, ) from policyengine_api.services.v2.user_households.database_connectors.deletes import ( 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_id_by_client_ref, read_user_household_rows, ) from policyengine_api.services.v2.user_households.database_connectors.updates import ( @@ -25,159 +43,295 @@ UserHouseholdDatabaseSession, ) from policyengine_api.services.v2.user_households.transformations import ( + imported_user_household_input, + owned_user_household_input, user_household_page, user_household_read, ) from policyengine_api.services.v2.user_households.types import ( - UserHouseholdCreationInput, + UserHouseholdCreateFields, + UserHouseholdImportItem, 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( +class UserHouseholdImportIntegrityError(RuntimeError): + """Raised when an imported client_ref resolves to no stored association.""" + + +def read_owned_user_household( session: Session, *, + user_id: UUID, country_id: str, association_id: UUID, -) -> UserHouseholdRead: - association = require_user_household( - read_user_household_row( +) -> UserHouseholdAssociation: + return require_user_household( + 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( + 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) + ) + + +def import_user_households_in_session( + session: Session, + *, + user_id: UUID, + country_id: str, + items: Sequence[UserHouseholdImportItem], +) -> dict[str, UUID]: + """Insert imported associations idempotently on ``(user_id, client_ref)``.""" + + copied = read_copied_household_ids( session, country_id=country_id, - user_id=user_id, - household_id=household_id, - offset=offset, - limit=limit, + legacy_household_ids={item.legacy_household_id for item in items}, ) - return user_household_page(rows, offset=offset, limit=limit) + imported: dict[str, UUID] = {} + for item in items: + association_id = import_user_household( + session, + imported_user_household_input( + item, + country_id=country_id, + user_id=user_id, + household_id=copied.get(item.legacy_household_id), + ), + ) or read_user_household_id_by_client_ref( + session, + user_id=user_id, + client_ref=item.client_ref, + ) + if association_id is None: + raise UserHouseholdImportIntegrityError( + "imported user-household client_ref did not resolve to a stored row" + ) + imported[item.client_ref] = association_id + return imported 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: + if self._legacy_references is None: + raise RuntimeError("v1 reference checks are not configured") + self._legacy_references.require_existing( + country_id=country_id, + requested={LegacyRecordKind.HOUSEHOLD: (legacy_household_id,)}, + ) def create_user_household( self, - association_input: UserHouseholdCreationInput, + *, + principal: UserPrincipal, + 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, + if association_input.client_ref is not None: + require_unused_client_ref( + read_user_household_id_by_client_ref( + session, + user_id=owner.user_id, + client_ref=association_input.client_ref, + ), + client_ref=association_input.client_ref, + ) + 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: UserPrincipal, 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_in_session( + session, principal, country_id=country_id + ) + return user_household_read( + read_owned_user_household( + session, + user_id=owner.user_id, + country_id=country_id, + association_id=association_id, + ) ) def list_user_households( self, *, + principal: UserPrincipal, 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_in_session( + session, principal, country_id=country_id + ) + 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: UserPrincipal, 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: UserPrincipal, 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..16d66e0fd 100644 --- a/policyengine_api/services/v2/user_households/transformations.py +++ b/policyengine_api/services/v2/user_households/transformations.py @@ -1,7 +1,14 @@ """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, + UserHouseholdImportItem, UserHouseholdPage, UserHouseholdRead, ) @@ -15,8 +22,10 @@ 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, + client_ref=association.client_ref, created_at=association.created_at, updated_at=association.updated_at, ) @@ -34,3 +43,33 @@ 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, + ) + + +def imported_user_household_input( + item: UserHouseholdImportItem, + *, + country_id: str, + user_id: UUID, + household_id: UUID | None, +) -> UserHouseholdCreationInput: + return UserHouseholdCreationInput.model_validate( + { + **item.model_dump(), + "country_id": country_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..c1b035a0c 100644 --- a/policyengine_api/services/v2/user_households/types.py +++ b/policyengine_api/services/v2/user_households/types.py @@ -4,55 +4,99 @@ 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 BaseModel, ConfigDict, model_validator from policyengine_api.query_parameters import CountryId, ResourceId, UserId - - -AssociationName = Annotated[str, StringConstraints(max_length=255)] +from policyengine_api.services.v2.associations.types import ( + AssociationClientRef, + AssociationName, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId 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: str | None = None + client_ref: AssociationClientRef | 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 + client_ref: AssociationClientRef | 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 @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 +class UserHouseholdImportItem(StrictUserHouseholdInput): + """One browser-local saved household offered for idempotent import.""" + + client_ref: AssociationClientRef + legacy_household_id: LegacyRecordId + name: AssociationName | None = None + description: str | None = None + + @dataclass(frozen=True) 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 + client_ref: str | None created_at: datetime updated_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/creates.py b/policyengine_api/services/v2/user_policies/database_connectors/creates.py index e8d48701f..bc2bac916 100644 --- a/policyengine_api/services/v2/user_policies/database_connectors/creates.py +++ b/policyengine_api/services/v2/user_policies/database_connectors/creates.py @@ -26,6 +26,19 @@ def create_user_policy( return association +def import_user_policy( + session: Session, association_input: UserPolicyCreationInput +) -> UUID | None: + """Insert one imported association unless its client_ref already exists.""" + + return session.execute( + insert(UserPolicy) + .values(**association_input.model_dump()) + .on_conflict_do_nothing(constraint="uq_user_policies_user_client_ref") + .returning(col(UserPolicy.id)) + ).scalar_one_or_none() + + def create_transition_user(session: Session, *, primary_country: str) -> User: user = User(primary_country=primary_country) session.add(user) 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..bd62fb835 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, @@ -72,6 +74,22 @@ def read_user_policy_row( ).one_or_none() +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() + + def read_user_policy_rows( session: Session, *, @@ -80,6 +98,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 +106,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 +115,61 @@ 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_id_by_client_ref( + session: Session, + *, + user_id: UUID, + client_ref: str, +) -> UUID | None: + return session.exec( + select(UserPolicy.id).where( + UserPolicy.user_id == user_id, + UserPolicy.client_ref == client_ref, + ) + ).one_or_none() + + +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..8b6ef9bc8 100644 --- a/policyengine_api/services/v2/user_policies/services.py +++ b/policyengine_api/services/v2/user_policies/services.py @@ -2,11 +2,22 @@ from __future__ import annotations +from collections.abc import Sequence from uuid import UUID 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.types import LegacyReferenceChecker +from policyengine_api.services.v2.associations.validators import ( + require_not_mirror_owned, + require_unused_client_ref, +) +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.types import LegacyRecordKind from policyengine_api.services.v2.policies.services import ( mirror_legacy_policy_in_session, ) @@ -16,17 +27,23 @@ create_legacy_user_policy_mapping, create_transition_user, create_user_policy, + import_user_policy, ) from policyengine_api.services.v2.user_policies.database_connectors.deletes import ( delete_transition_user, 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_id_by_client_ref, + read_user_policy_mirror_exists, read_user_policy_row, read_user_policy_rows, ) @@ -40,7 +57,9 @@ from policyengine_api.services.v2.user_policies.transformations import ( USER_POLICY_FINGERPRINT_VERSION, fingerprint_legacy_user_policy, + imported_user_policy_input, legacy_name_requires_update, + owned_user_policy_input, project_legacy_user_policy, user_policy_page, user_policy_read, @@ -49,15 +68,19 @@ LegacyUserPolicyMappingAction, LegacyUserPolicyPersistenceResult, LegacyUserPolicySnapshot, + UserPolicyCreateFields, UserPolicyCreationInput, + UserPolicyImportItem, UserPolicyPage, UserPolicyRead, UserPolicyUpdateInput, ) 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, @@ -87,18 +110,116 @@ 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, + country_id: str, + association_id: UUID, +) -> UserPolicy: + return require_user_policy( + 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 import_user_policies_in_session( + session: Session, + *, + user_id: UUID, + country_id: str, + items: Sequence[UserPolicyImportItem], +) -> dict[str, UUID]: + """Insert imported associations idempotently on ``(user_id, client_ref)``.""" + + mirrored = read_mirrored_policy_ids( + session, + country_id=country_id, + legacy_policy_ids={item.legacy_policy_id for item in items}, + ) + imported: dict[str, UUID] = {} + for item in items: + association_id = import_user_policy( + session, + imported_user_policy_input( + item, + country_id=country_id, + user_id=user_id, + policy_id=mirrored.get(item.legacy_policy_id), + ), + ) or read_user_policy_id_by_client_ref( + session, + user_id=user_id, + client_ref=item.client_ref, + ) + if association_id is None: + raise LegacyUserPolicyIntegrityError( + "imported user-policy client_ref did not resolve to a stored row" + ) + imported[item.client_ref] = association_id + return imported + + def resolve_legacy_user_id( session: Session, *, @@ -199,6 +320,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 +335,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 +353,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 +377,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 +420,112 @@ 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. 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: + if self._legacy_references is None: + raise RuntimeError("v1 reference checks are not configured") + self._legacy_references.require_existing( + country_id=country_id, + requested={LegacyRecordKind.POLICY: (legacy_policy_id,)}, + ) def create_user_policy( - self, association_input: UserPolicyCreationInput + self, + *, + principal: UserPrincipal, + 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, + ) + if association_input.client_ref is not None: + require_unused_client_ref( + read_user_policy_id_by_client_ref( + session, + user_id=owner.user_id, + client_ref=association_input.client_ref, + ), + client_ref=association_input.client_ref, + ) + 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: UserPrincipal, + 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_in_session( + session, principal, country_id=country_id + ) + return user_policy_read( + read_owned_user_policy( + session, + user_id=owner.user_id, + country_id=country_id, + association_id=association_id, + ) ) def list_user_policies( self, *, + principal: UserPrincipal, 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_in_session( + session, principal, country_id=country_id + ) 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 +533,72 @@ def list_user_policies( def patch_user_policy( self, *, + principal: UserPrincipal, 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: UserPrincipal, + 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..9de7b2150 100644 --- a/policyengine_api/services/v2/user_policies/transformations.py +++ b/policyengine_api/services/v2/user_policies/transformations.py @@ -9,7 +9,9 @@ from policyengine_api.data.v2.models import UserPolicy from policyengine_api.services.v2.user_policies.types import ( LegacyUserPolicySnapshot, + UserPolicyCreateFields, UserPolicyCreationInput, + UserPolicyImportItem, UserPolicyPage, UserPolicyRead, ) @@ -24,8 +26,12 @@ 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, + client_ref=association.client_ref, created_at=association.created_at, updated_at=association.updated_at, ) @@ -71,11 +77,44 @@ 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 imported_user_policy_input( + item: UserPolicyImportItem, + *, + country_id: str, + user_id: UUID, + policy_id: UUID | None, +) -> UserPolicyCreationInput: + return UserPolicyCreationInput.model_validate( + { + **item.model_dump(), + "country_id": country_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..0d5876dab 100644 --- a/policyengine_api/services/v2/user_policies/types.py +++ b/policyengine_api/services/v2/user_policies/types.py @@ -5,7 +5,7 @@ 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 @@ -16,6 +16,14 @@ ResourceId, UserId, ) +from policyengine_api.services.v2.associations.types import ( + AssociationClientRef, + AssociationName, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId + + +UserPolicyBaseline = Literal["current-law", "current-policy"] class StrictAssociationInput(BaseModel): @@ -24,25 +32,91 @@ class StrictAssociationInput(BaseModel): 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: str | None = None + baseline: UserPolicyBaseline | None = None + provenance: PolicyProvenance | None = None + client_ref: AssociationClientRef | 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 + client_ref: AssociationClientRef | 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 + legacy_policy_id: LegacyRecordId | None = None + name: AssociationName | None = None description: str | 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 +class UserPolicyImportItem(StrictAssociationInput): + """One browser-local saved policy offered for idempotent import.""" + + client_ref: AssociationClientRef + legacy_policy_id: LegacyRecordId + name: AssociationName | None = None + description: str | None = None + baseline: UserPolicyBaseline | None = None + provenance: PolicyProvenance | None = None + + class LegacyUserPolicySnapshot(StrictAssociationInput): """Detached complete committed v1 saved-policy row.""" @@ -75,9 +149,13 @@ 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 + client_ref: str | 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..c780a5501 --- /dev/null +++ b/policyengine_api/services/v2/user_reports/database_connectors/creates.py @@ -0,0 +1,38 @@ +"""Database inserts for v2 user-report associations.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.dialects.postgresql import insert +from sqlmodel import Session, col + +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 + + +def import_user_report( + session: Session, + association_input: UserReportCreationInput, +) -> UUID | None: + """Insert one imported association unless its client_ref already exists.""" + + return session.execute( + insert(UserReportAssociation) + .values(**association_input.model_dump()) + .on_conflict_do_nothing( + constraint="uq_user_report_associations_user_client_ref" + ) + .returning(col(UserReportAssociation.id)) + ).scalar_one_or_none() 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..7587b4db9 --- /dev/null +++ b/policyengine_api/services/v2/user_reports/database_connectors/reads.py @@ -0,0 +1,122 @@ +"""Database reads for v2 user-report associations.""" + +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 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_user_report_id_by_client_ref( + session: Session, + *, + user_id: UUID, + client_ref: str, +) -> UUID | None: + return session.exec( + select(UserReportAssociation.id).where( + UserReportAssociation.user_id == user_id, + UserReportAssociation.client_ref == client_ref, + ) + ).one_or_none() + + +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 + ) + + +def read_owned_user_policy_ids_by_client_ref( + session: Session, + *, + user_id: UUID, + country_id: str, + client_refs: Collection[str], +) -> dict[str, UUID]: + if not client_refs: + return {} + rows = session.exec( + select(UserPolicy.client_ref, UserPolicy.id).where( + UserPolicy.user_id == user_id, + UserPolicy.country_id == country_id, + col(UserPolicy.client_ref).in_(sorted(set(client_refs))), + ) + ).all() + return {client_ref: policy_id for client_ref, policy_id in rows if client_ref} 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..9136ee476 --- /dev/null +++ b/policyengine_api/services/v2/user_reports/services.py @@ -0,0 +1,327 @@ +"""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, and v1 references are +checked in a short read-only v1 transaction before the v2 transaction opens. +An optional ``user_policy_id`` must name the same owner's saved policy in the +same country. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from uuid import UUID + +from sqlmodel import Session + +from policyengine_api.data.v2.models import UserReportAssociation +from policyengine_api.services.v2.associations.types import LegacyReferenceChecker +from policyengine_api.services.v2.associations.validators import ( + require_unused_client_ref, +) +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.types import LegacyRecordKind +from policyengine_api.services.v2.user_reports.database_connectors.creates import ( + create_user_report, + import_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_policy_ids_by_client_ref, + read_owned_user_report_row, + read_user_report_id_by_client_ref, + 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 ( + imported_user_report_input, + owned_user_report_input, + user_report_page, + user_report_read, + user_report_update_values, +) +from policyengine_api.services.v2.user_reports.types import ( + UserReportCreateFields, + UserReportImportItem, + UserReportPage, + UserReportRead, + UserReportUpdateInput, +) +from policyengine_api.services.v2.user_reports.validators import ( + require_imported_user_report, + require_owned_user_policy, + require_user_report, +) + + +def read_owned_user_report( + session: Session, + *, + user_id: UUID, + country_id: str, + association_id: UUID, +) -> UserReportAssociation: + return require_user_report( + 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) + ) + + +def import_user_reports_in_session( + session: Session, + *, + user_id: UUID, + country_id: str, + items: Sequence[UserReportImportItem], + imported_policy_ids: Mapping[str, UUID], +) -> dict[str, UUID]: + """Insert imported reports idempotently on ``(user_id, client_ref)``. + + ``user_policy_client_ref`` resolves against policies imported by the same + request first and then against the owner's stored policies in the same + country; an unresolved reference leaves ``user_policy_id`` empty. + """ + + stored_policy_ids = read_owned_user_policy_ids_by_client_ref( + session, + user_id=user_id, + country_id=country_id, + client_refs={ + item.user_policy_client_ref + for item in items + if item.user_policy_client_ref is not None + and item.user_policy_client_ref not in imported_policy_ids + }, + ) + policy_ids = {**stored_policy_ids, **imported_policy_ids} + imported: dict[str, UUID] = {} + for item in items: + imported[item.client_ref] = require_imported_user_report( + import_user_report( + session, + imported_user_report_input( + item, + country_id=country_id, + user_id=user_id, + user_policy_id=( + policy_ids.get(item.user_policy_client_ref) + if item.user_policy_client_ref is not None + else None + ), + ), + ) + or read_user_report_id_by_client_ref( + session, + user_id=user_id, + client_ref=item.client_ref, + ) + ) + return imported + + +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: + if self._legacy_references is None: + raise RuntimeError("v1 reference checks are not configured") + self._legacy_references.require_existing( + country_id=country_id, + requested={LegacyRecordKind.REPORT: (legacy_report_id,)}, + ) + + def create_user_report( + self, + *, + principal: UserPrincipal, + 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, + ) + if association_input.client_ref is not None: + require_unused_client_ref( + read_user_report_id_by_client_ref( + session, + user_id=owner.user_id, + client_ref=association_input.client_ref, + ), + client_ref=association_input.client_ref, + ) + validate_linked_user_policy( + session, + user_id=owner.user_id, + country_id=association_input.country_id, + user_policy_id=association_input.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: UserPrincipal, + country_id: str, + association_id: UUID, + ) -> UserReportRead: + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + return user_report_read( + read_owned_user_report( + session, + user_id=owner.user_id, + country_id=country_id, + association_id=association_id, + ) + ) + + def list_user_reports( + self, + *, + principal: UserPrincipal, + 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_in_session( + session, principal, country_id=country_id + ) + 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: UserPrincipal, + 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, + ) + if "user_policy_id" in association_input.model_fields_set: + validate_linked_user_policy( + session, + user_id=owner.user_id, + country_id=country_id, + user_policy_id=association_input.user_policy_id, + ) + return user_report_read( + update_user_report( + session, + association, + user_report_update_values(association_input), + ) + ) + + def delete_user_report( + self, + *, + principal: UserPrincipal, + 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..3c02d06e2 --- /dev/null +++ b/policyengine_api/services/v2/user_reports/transformations.py @@ -0,0 +1,102 @@ +"""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, + UserReportImportItem, + 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, + source_note=association.source_note, + provisions=association.provisions, + validation=association.validation, + last_run_at=association.last_run_at, + client_ref=association.client_ref, + 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 imported_user_report_input( + item: UserReportImportItem, + *, + country_id: str, + user_id: UUID, + user_policy_id: UUID | None, +) -> UserReportCreationInput: + return UserReportCreationInput.model_validate( + { + **item.model_dump( + exclude={"provisions", "validation", "user_policy_client_ref"} + ), + "provisions": stored_provisions(item.provisions), + "validation": stored_validation(item.validation), + "country_id": country_id, + "user_id": user_id, + "user_policy_id": user_policy_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..010bb8c5c --- /dev/null +++ b/policyengine_api/services/v2/user_reports/types.py @@ -0,0 +1,209 @@ +"""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, Any, Literal +from uuid import UUID + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + JsonValue, + StringConstraints, + model_validator, +) + +from policyengine_api.query_parameters import CountryId, ResourceId, UserId +from policyengine_api.services.v2.associations.types import ( + AssociationClientRef, + AssociationName, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId + + +MAXIMUM_PROVISIONS_BYTES = 64 * 1024 +IsoDate = Annotated[str, StringConstraints(pattern=r"^\d{4}-\d{2}-\d{2}$")] + + +class StrictUserReportModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class ReportProvisionInterval(StrictUserReportModel): + parameter: str | None = None + start_date: IsoDate + end_date: IsoDate + value: JsonValue + + +class ReportProvision(StrictUserReportModel): + path: str + breadcrumb: str + unit: str | None = None + baseline_value: JsonValue + value: JsonValue + intervals: list[ReportProvisionInterval] | None = None + + +class ReportCalibrationWorst(StrictUserReportModel): + name: str + source: str + geography: str + relative_error: float | None = None + + +class ReportCalibrationMatch(StrictUserReportModel): + variable: str + depth: int + ring: Literal["primary", "mechanism", "downstream"] + target_count: int + mean_abs_relative_error: float + worst: ReportCalibrationWorst + + +class ReportCalibration(StrictUserReportModel): + release_id: str | None = None + geography: str + reached_count: int + matches: list[ReportCalibrationMatch] + + +class ReportScorecard(StrictUserReportModel): + programs: list[str] + + +class ReportValidation(StrictUserReportModel): + matched_at: str + map_model_version: str + 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: str | None = None + source_note: str | None = None + provisions: list[ReportProvision] | None = None + validation: ReportValidation | 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 + client_ref: AssociationClientRef | 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 + source_note: str | None = None + provisions: list[StoredJSONObject] | None = None + validation: StoredJSONObject | None = None + last_run_at: AwareDatetime | None = None + client_ref: AssociationClientRef | None = None + + +class UserReportUpdateInput(_ReportPresentationFields): + legacy_report_id: LegacyRecordId | None = None + 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", + "source_note", + "provisions", + "validation", + "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 + + +class UserReportImportItem(_ReportPresentationFields): + """One browser-local saved report offered for idempotent import.""" + + client_ref: AssociationClientRef + legacy_report_id: LegacyRecordId + user_policy_client_ref: AssociationClientRef | None = None + + +@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 + source_note: str | None + provisions: list[Any] | None + validation: dict[str, Any] | None + last_run_at: datetime | None + client_ref: str | 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..f4d07859a --- /dev/null +++ b/policyengine_api/services/v2/user_reports/validators.py @@ -0,0 +1,47 @@ +"""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.""" + + +class UserReportImportIntegrityError(RuntimeError): + """Raised when an imported client_ref resolves to no stored association.""" + + +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" + ) + + +def require_imported_user_report(association_id: UUID | None) -> UUID: + if association_id is None: + raise UserReportImportIntegrityError( + "imported user-report client_ref did not resolve to a stored row" + ) + return association_id 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..806975d99 --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/database_connectors/creates.py @@ -0,0 +1,40 @@ +"""Database inserts for v2 user-simulation associations.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.dialects.postgresql import insert +from sqlmodel import Session, col + +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 + + +def import_user_simulation( + session: Session, + association_input: UserSimulationCreationInput, +) -> UUID | None: + """Insert one imported association unless its client_ref already exists.""" + + return session.execute( + insert(UserSimulationAssociation) + .values(**association_input.model_dump()) + .on_conflict_do_nothing( + constraint="uq_user_simulation_associations_user_client_ref" + ) + .returning(col(UserSimulationAssociation.id)) + ).scalar_one_or_none() 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..33541076e --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/database_connectors/reads.py @@ -0,0 +1,83 @@ +"""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() + ) + + +def read_user_simulation_id_by_client_ref( + session: Session, + *, + user_id: UUID, + client_ref: str, +) -> UUID | None: + return session.exec( + select(UserSimulationAssociation.id).where( + UserSimulationAssociation.user_id == user_id, + UserSimulationAssociation.client_ref == client_ref, + ) + ).one_or_none() 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..587391b4f --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/services.py @@ -0,0 +1,269 @@ +"""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, and v1 references are checked in a +short read-only v1 transaction before the v2 transaction opens. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from uuid import UUID + +from sqlmodel import Session + +from policyengine_api.data.v2.models import UserSimulationAssociation +from policyengine_api.services.v2.associations.types import LegacyReferenceChecker +from policyengine_api.services.v2.associations.validators import ( + require_unused_client_ref, +) +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.types import LegacyRecordKind +from policyengine_api.services.v2.user_simulations.database_connectors.creates import ( + create_user_simulation, + import_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_id_by_client_ref, + 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 ( + imported_user_simulation_input, + owned_user_simulation_input, + user_simulation_page, + user_simulation_read, +) +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationCreateFields, + UserSimulationImportItem, + UserSimulationPage, + UserSimulationRead, + UserSimulationUpdateInput, +) +from policyengine_api.services.v2.user_simulations.validators import ( + require_imported_user_simulation, + require_user_simulation, +) + + +def read_owned_user_simulation( + session: Session, + *, + user_id: UUID, + country_id: str, + association_id: UUID, +) -> UserSimulationAssociation: + return require_user_simulation( + 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) + ) + + +def import_user_simulations_in_session( + session: Session, + *, + user_id: UUID, + country_id: str, + items: Sequence[UserSimulationImportItem], +) -> dict[str, UUID]: + """Insert imported associations idempotently on ``(user_id, client_ref)``.""" + + imported: dict[str, UUID] = {} + for item in items: + imported[item.client_ref] = require_imported_user_simulation( + import_user_simulation( + session, + imported_user_simulation_input( + item, + country_id=country_id, + user_id=user_id, + ), + ) + or read_user_simulation_id_by_client_ref( + session, + user_id=user_id, + client_ref=item.client_ref, + ) + ) + return imported + + +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: + if self._legacy_references is None: + raise RuntimeError("v1 reference checks are not configured") + self._legacy_references.require_existing( + country_id=country_id, + requested={LegacyRecordKind.SIMULATION: (legacy_simulation_id,)}, + ) + + def create_user_simulation( + self, + *, + principal: UserPrincipal, + 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, + ) + if association_input.client_ref is not None: + require_unused_client_ref( + read_user_simulation_id_by_client_ref( + session, + user_id=owner.user_id, + client_ref=association_input.client_ref, + ), + client_ref=association_input.client_ref, + ) + 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: UserPrincipal, + country_id: str, + association_id: UUID, + ) -> UserSimulationRead: + with self._database_session.transaction() as session: + owner = resolve_principal_user_in_session( + session, principal, country_id=country_id + ) + return user_simulation_read( + read_owned_user_simulation( + session, + user_id=owner.user_id, + country_id=country_id, + association_id=association_id, + ) + ) + + def list_user_simulations( + self, + *, + principal: UserPrincipal, + 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_in_session( + session, principal, country_id=country_id + ) + 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: UserPrincipal, + 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: UserPrincipal, + 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..a88d20996 --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/transformations.py @@ -0,0 +1,68 @@ +"""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, + UserSimulationImportItem, + 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, + client_ref=association.client_ref, + 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) + + +def imported_user_simulation_input( + item: UserSimulationImportItem, + *, + country_id: str, + user_id: UUID, +) -> UserSimulationCreationInput: + return UserSimulationCreationInput.model_validate( + { + **item.model_dump(), + "country_id": country_id, + "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..35bfcd9f0 --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/types.py @@ -0,0 +1,90 @@ +"""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 BaseModel, ConfigDict, model_validator + +from policyengine_api.query_parameters import CountryId, UserId +from policyengine_api.services.v2.associations.types import ( + AssociationClientRef, + AssociationName, +) +from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId + + +class StrictUserSimulationInput(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +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: str | None = None + client_ref: AssociationClientRef | None = None + + +class UserSimulationCreationInput(StrictUserSimulationInput): + country_id: CountryId + user_id: UserId + legacy_simulation_id: LegacyRecordId + name: AssociationName | None = None + description: str | None = None + client_ref: AssociationClientRef | None = None + + +class UserSimulationUpdateInput(StrictUserSimulationInput): + legacy_simulation_id: LegacyRecordId | None = None + name: AssociationName | None = None + description: str | 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 + + +class UserSimulationImportItem(StrictUserSimulationInput): + """One browser-local saved simulation offered for idempotent import.""" + + client_ref: AssociationClientRef + legacy_simulation_id: LegacyRecordId + name: AssociationName | None = None + description: str | None = None + + +@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 + client_ref: 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..8bd851717 --- /dev/null +++ b/policyengine_api/services/v2/user_simulations/validators.py @@ -0,0 +1,38 @@ +"""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.""" + + +class UserSimulationImportIntegrityError(RuntimeError): + """Raised when an imported client_ref resolves to no stored association.""" + + +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 + + +def require_imported_user_simulation(association_id: UUID | None) -> UUID: + if association_id is None: + raise UserSimulationImportIntegrityError( + "imported user-simulation client_ref did not resolve to a stored row" + ) + return association_id 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/v2/test_data_crud_boundaries.py b/tests/unit/v2/test_data_crud_boundaries.py index 44b49ff3c..d3e2e1736 100644 --- a/tests/unit/v2/test_data_crud_boundaries.py +++ b/tests/unit/v2/test_data_crud_boundaries.py @@ -47,6 +47,19 @@ def _imported_modules(path: Path) -> set[str]: "comparison_runs/database_connectors/updates.py", "user_policies/database_connectors/updates.py", "user_policies/database_connectors/deletes.py", + "user_households/database_connectors/creates.py", + "user_households/database_connectors/updates.py", + "user_households/database_connectors/deletes.py", + "user_simulations/database_connectors/creates.py", + "user_simulations/database_connectors/updates.py", + "user_simulations/database_connectors/deletes.py", + "user_reports/database_connectors/creates.py", + "user_reports/database_connectors/updates.py", + "user_reports/database_connectors/deletes.py", + "identity/database_connectors/creates.py", + "identity/database_connectors/updates.py", + "user_accounts/database_connectors/updates.py", + "user_accounts/database_connectors/deletes.py", ), ) def test_mutation_connectors_contain_no_database_reads(relative_path: str) -> None: @@ -67,6 +80,12 @@ def test_mutation_connectors_contain_no_database_reads(relative_path: str) -> No "metadata/database_connectors/reads_parameters.py", "metadata/database_connectors/reads_regions.py", "metadata/database_connectors/reads_variables.py", + "user_households/database_connectors/reads.py", + "user_simulations/database_connectors/reads.py", + "user_reports/database_connectors/reads.py", + "identity/database_connectors/reads.py", + "user_accounts/database_connectors/reads.py", + "legacy_refs/database_connectors/reads.py", ), ) def test_read_connectors_contain_no_database_mutations(relative_path: str) -> None: @@ -85,6 +104,18 @@ def test_read_connectors_contain_no_database_mutations(relative_path: str) -> No "comparison_runs/transformations.py", "user_policies/validators.py", "user_policies/transformations.py", + "user_households/validators.py", + "user_households/transformations.py", + "user_simulations/validators.py", + "user_simulations/transformations.py", + "user_reports/validators.py", + "user_reports/transformations.py", + "identity/validators.py", + "identity/transformations.py", + "user_accounts/validators.py", + "user_accounts/transformations.py", + "legacy_refs/validators.py", + "associations/validators.py", ), ) def test_validation_and_transformation_modules_have_no_database_query_dependency( @@ -107,6 +138,12 @@ def test_validation_and_transformation_modules_have_no_database_query_dependency "comparison_runs/database_session.py", "metadata/database_session.py", "user_policies/database_session.py", + "user_households/database_session.py", + "user_simulations/database_session.py", + "user_reports/database_session.py", + "user_accounts/database_session.py", + "identity/database_session.py", + "legacy_refs/database_session.py", ), ) def test_database_session_modules_do_not_construct_queries( @@ -114,3 +151,22 @@ 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: + calls = _called_names(SERVICES_ROOT / relative_path) + imports = _imported_modules(SERVICES_ROOT / relative_path) + assert calls.isdisjoint({"select", "insert", "update", "delete", "exec", "text"}) + assert not any(module.startswith("sqlalchemy") for module in imports) 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..8311d37e6 --- /dev/null +++ b/tests/unit/v2/test_legacy_reference_service.py @@ -0,0 +1,146 @@ +"""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()} diff --git a/tests/unit/v2/test_me_routes.py b/tests/unit/v2/test_me_routes.py new file mode 100644 index 000000000..4a8fa2dfe --- /dev/null +++ b/tests/unit/v2/test_me_routes.py @@ -0,0 +1,219 @@ +"""Native FastAPI contract tests for the signed-in account resource.""" + +from __future__ import annotations + +import json +from uuid import UUID + +import pytest + +from policyengine_api.services.v2.user_accounts.types import ( + AccountDeletion, + AccountExport, + AccountImportResult, + AccountRead, + ImportSkip, + ImportSkipReason, +) +from policyengine_api.services.v2.user_accounts.validators import ( + AccountIdentityDeletionError, +) +from tests.unit.v2.user_route_support import ( + CREATED_AT, + OWNER_ID, + PRINCIPAL, + RecordingService, + user_route_client, +) + + +IMPORTED_ID = UUID("00000000-0000-0000-0000-000000000065") + + +def _account(**changes) -> AccountRead: + values = { + "id": OWNER_ID, + "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 == "import_records": + return AccountImportResult( + id_map={"sup-1": IMPORTED_ID}, + skipped=( + ImportSkip( + client_ref="sus-9", + reason=ImportSkipReason.UNKNOWN_LEGACY_REFERENCE, + ), + ImportSkip(client_ref=None, reason=ImportSkipReason.INVALID), + ), + ) + 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): + service = service or RecordingService(_result) + client, _flask_calls = user_route_client(v2_account_service_factory=lambda: service) + 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), + "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_import_returns_id_map_and_skipped_items() -> None: + client, service = _client() + + response = client.post( + "/v2/me/imports?country_id=us", + json={ + "policies": [{"client_ref": "sup-1", "legacy_policy_id": 1}], + "simulations": [{"client_ref": "sus-9", "legacy_simulation_id": 99}], + "reports": [{"legacy_report_id": "invalid"}], + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "status": "ok", + "message": None, + "result": { + "id_map": {"sup-1": str(IMPORTED_ID)}, + "skipped": [ + {"client_ref": "sus-9", "reason": "unknown_legacy_reference"}, + {"client_ref": None, "reason": "invalid"}, + ], + }, + } + import_input = service.calls[0][1]["import_input"] + assert import_input.households == [] + assert len(import_input.reports) == 1 + + +@pytest.mark.parametrize( + "body", + [ + {"policies": [{"client_ref": f"sup-{index}"} for index in range(501)]}, + {"unknown": []}, + {"policies": ["not-an-object"]}, + ], +) +def test_import_rejects_oversized_counts_and_unknown_sections(body: dict) -> None: + client, service = _client() + + response = client.post("/v2/me/imports?country_id=us", json=body) + + assert response.status_code == 422 + assert service.calls == [] + + +def test_import_rejects_bodies_over_one_mebibyte() -> None: + client, service = _client() + body = json.dumps( + {"policies": [{"client_ref": "sup-1", "description": "x" * 1_048_576}]} + ) + + response = client.post( + "/v2/me/imports?country_id=us", + content=body, + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 413 + assert response.json()["status"] == "error" + assert service.calls == [] diff --git a/tests/unit/v2/test_metadata_routes.py b/tests/unit/v2/test_metadata_routes.py index 480d4a0c4..1727e5009 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/imports", } assert set(schema["paths"]) == metadata_paths | native_paths assert "V2ErrorResponse" in schema["components"]["schemas"] 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..e67219c11 --- /dev/null +++ b/tests/unit/v2/test_user_account_service.py @@ -0,0 +1,457 @@ +"""Service tests for identity provisioning, saves, imports, 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, + UserHouseholdAssociation, + UserIdentity, + UserPolicy, + UserReportAssociation, + 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 ( + AccountImportInput, + AccountUpdateInput, +) +from policyengine_api.services.v2.user_accounts.validators import ( + 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, + MAPPED_LEGACY_POLICY_ID, + default_legacy_references, + v2_store, +) + + +@pytest.fixture +def store(): + with v2_store() as selected: + yield selected + + +def _services(selected, *, deleter=None): + references = default_legacy_references() + return ( + V2AccountService( + AccountDatabaseSession(selected.sessions), + legacy_references=references, + auth0_user_deleter=deleter, + ), + 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, client_ref="sus-1" + ), + ) + + 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), + ) + + 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 _import_input(**sections) -> AccountImportInput: + return AccountImportInput.model_validate(sections) + + +def test_import_is_idempotent_and_skips_unknown_or_invalid_items(store) -> None: + accounts, *_ = _services(store) + request = _import_input( + policies=[ + {"client_ref": "rf-1", "legacy_policy_id": MAPPED_LEGACY_POLICY_ID}, + {"client_ref": "sup-404", "legacy_policy_id": 404}, + {"client_ref": "sup-bad", "legacy_policy_id": "1"}, + ], + households=[{"client_ref": "suh-1", "legacy_household_id": 10}], + simulations=[{"client_ref": "sus-1", "legacy_simulation_id": 613}], + reports=[ + { + "client_ref": "lfr-1", + "legacy_report_id": 615, + "user_policy_client_ref": "rf-1", + "source_note": "imported", + }, + {"legacy_report_id": 615}, + ], + ) + + first = accounts.import_records( + principal=ALICE, country_id="us", import_input=request + ) + second = accounts.import_records( + principal=ALICE, country_id="us", import_input=request + ) + + assert set(first.id_map) == {"rf-1", "suh-1", "sus-1", "lfr-1"} + assert first.id_map == second.id_map + assert [(skip.client_ref, skip.reason.value) for skip in first.skipped] == [ + ("sup-bad", "invalid"), + ("sup-404", "unknown_legacy_reference"), + (None, "invalid"), + ] + with store.sessions() as session: + policy = session.get(UserPolicy, first.id_map["rf-1"]) + report = session.get(UserReportAssociation, first.id_map["lfr-1"]) + assert policy is not None and policy.policy_id == store.policy_id + assert report is not None and report.user_policy_id == policy.id + assert len(session.exec(select(UserPolicy)).all()) == 1 + assert len(session.exec(select(UserHouseholdAssociation)).all()) == 1 + assert len(session.exec(select(UserSimulationAssociation)).all()) == 1 + + +def test_import_keeps_existing_rows_and_links_earlier_imported_policies( + store, +) -> None: + accounts, *_ = _services(store) + accounts.import_records( + principal=ALICE, + country_id="us", + import_input=_import_input( + policies=[{"client_ref": "rf-1", "legacy_policy_id": 1, "name": "First"}] + ), + ) + + result = accounts.import_records( + principal=ALICE, + country_id="us", + import_input=_import_input( + policies=[{"client_ref": "rf-1", "legacy_policy_id": 2, "name": "Second"}], + reports=[ + { + "client_ref": "lfr-2", + "legacy_report_id": 616, + "user_policy_client_ref": "rf-1", + }, + { + "client_ref": "lfr-3", + "legacy_report_id": 616, + "user_policy_client_ref": "rf-unknown", + }, + ], + ), + ) + + with store.sessions() as session: + policy = session.get(UserPolicy, result.id_map["rf-1"]) + linked = session.get(UserReportAssociation, result.id_map["lfr-2"]) + unlinked = session.get(UserReportAssociation, result.id_map["lfr-3"]) + assert (policy.name, policy.legacy_policy_id) == ("First", 1) + assert linked.user_policy_id == policy.id + assert unlinked.user_policy_id is None + + +def test_import_input_bounds_the_item_count() -> None: + with pytest.raises(ValueError, match="at most 500"): + _import_input( + policies=[{"client_ref": str(index)} for index in range(250)], + reports=[{"client_ref": str(index)} for index in range(251)], + ) + + +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: + deleted_subjects: list[str] = [] + accounts, policies, simulations, _reports = _services( + store, deleter=deleted_subjects.append + ) + 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 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 + + def failing_deleter(_subject: str) -> None: + raise 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() == [] diff --git a/tests/unit/v2/test_user_household_routes.py b/tests/unit/v2/test_user_household_routes.py index 4be95201f..d1dffb33f 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,185 @@ 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, + "client_ref": "suh-1", + "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", + "client_ref": "suh-1", + }, + ) -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, + "client_ref": "suh-1", "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 +208,54 @@ 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", + "client_ref", + "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..3a4d6d972 100644 --- a/tests/unit/v2/test_user_household_service.py +++ b/tests/unit/v2/test_user_household_service.py @@ -1,19 +1,16 @@ -"""Service tests for native v2 user-household associations.""" +"""Service tests for signed-in 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.associations.validators import ( + AssociationClientRefConflictError, +) +from policyengine_api.services.v2.legacy_refs.validators import ( + LegacyReferenceNotFoundError, ) from policyengine_api.services.v2.user_households.database_session import ( UserHouseholdDatabaseSession, @@ -22,216 +19,135 @@ 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, +def store(): + with v2_store() as selected: + service = V2UserHouseholdService( + UserHouseholdDatabaseSession(selected.sessions), + default_legacy_references(), ) - replacement = Household( - country_id="us", - default_year=2027, - household_data={"people": [], "household": []}, - canonicalization_version=1, - content_hash="b" * 64, - ) - 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 ( - V2UserHouseholdService(UserHouseholdDatabaseSession(sessions)), - sessions, - identities, - ) - engine.dispose() + yield service, selected -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 - first = service.create_user_household(_command(household_id)) - second = service.create_user_household(_command(household_id, name="Second save")) +def test_legacy_households_resolve_copied_v2_households(store) -> None: + service, selected = store - 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 + 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 (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 - 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")) +def test_missing_references_and_duplicate_client_refs_are_rejected(store) -> None: + service, selected = store service.create_user_household( - _command(household_id, user_id=OTHER_USER_ID, name="Other") + principal=ALICE, association_input=_fields(client_ref="suh-1") ) - 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, - ) + 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( + principal=ALICE, + association_input=UserHouseholdCreateFields( + country_id="us", household_id=selected.policy_id + ), + ) + with pytest.raises(AssociationClientRefConflictError): + service.create_user_household( + principal=ALICE, association_input=_fields(client_ref="suh-1") + ) - 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_ownership_repointing_and_deletion(store) -> None: + service, selected = store + created = service.create_user_household( + principal=ALICE, association_input=_fields(name="Family") + ) -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)) + with pytest.raises(UserHouseholdNotFoundError): + 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..2641e9572 100644 --- a/tests/unit/v2/test_user_policy_routes.py +++ b/tests/unit/v2/test_user_policy_routes.py @@ -1,17 +1,22 @@ -"""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 ( + AssociationClientRefConflictError, + 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 +24,78 @@ 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, + "client_ref": "rf-1", + "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, + "client_ref": "rf-1", } 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 +107,89 @@ 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, + "client_ref": "rf-1", "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="x" * 65), + _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 +197,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 +223,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 +280,53 @@ 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"), + (AssociationClientRefConflictError("client_ref exists"), 409, "client_ref"), + ( + 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 +335,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 +358,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 +368,32 @@ def test_openapi_publishes_complete_no_auth_association_contracts() -> None: "country_id", "user_id", "policy_id", + "legacy_policy_id", "name", "description", + "baseline", + "provenance", + "client_ref", "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 +406,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..def9d9460 100644 --- a/tests/unit/v2/test_user_policy_service.py +++ b/tests/unit/v2/test_user_policy_service.py @@ -1,234 +1,357 @@ -"""Service tests for native v2 user-policy associations.""" +"""Service tests for signed-in 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 ( + AssociationClientRefConflictError, + 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], +def store(): + with v2_store() as selected: + legacy_references = default_legacy_references() + service = V2UserPolicyService( + UserPolicyDatabaseSession(selected.sessions), + legacy_references, ) - 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, - ) - 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 V2UserPolicyService(UserPolicyDatabaseSession(sessions)), sessions, identity - engine.dispose() + yield service, selected, legacy_references -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_create_allows_distinct_duplicate_links_for_an_existing_user( - association_store, -) -> None: - service, sessions, (policy_id, _value_id) = association_store +def test_first_save_provisions_the_owner_and_stores_v3_fields(store) -> None: + service, selected, legacy_references = store - first = service.create_user_policy(_command(policy_id)) - second = service.create_user_policy(_command(policy_id, name="Second save")) + created = service.create_user_policy( + principal=ALICE, + association_input=_fields( + baseline="current-policy", + provenance={"source": "bill", "ref": "HR-1"}, + client_ref="rf-1", + ), + ) + + 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} + assert created.client_ref == "rf-1" + 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_duplicate_client_ref_conflicts_on_single_create(store) -> None: + service, _selected, _legacy_references = store + service.create_user_policy( + principal=ALICE, association_input=_fields(client_ref="sup-1") ) - page = service.list_user_policies( - country_id="us", - user_id=USER_ID, - policy_id=policy_id, - limit=1, + + with pytest.raises(AssociationClientRefConflictError): + service.create_user_policy( + principal=ALICE, association_input=_fields(client_ref="sup-1") + ) + other_user = service.create_user_policy( + principal=BOB, association_input=_fields(client_ref="sup-1") ) - second_page = service.list_user_policies( - country_id="us", - user_id=USER_ID, - offset=1, - limit=1, + assert other_user.client_ref == "sup-1" + + +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") + ) + 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(description=None), + 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(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 + ) -def test_patch_command_rejects_empty_and_immutable_fields() -> None: + assert projection.legacy_policy_id == 95762 + assert projection.policy_id == policy_id + assert projection.client_ref is 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..055a89b2a --- /dev/null +++ b/tests/unit/v2/test_user_route_auth.py @@ -0,0 +1,216 @@ +"""Router-level authentication for every signed-in user route.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import pytest + +from policyengine_api.libs import user_auth +from policyengine_api.services.v2.identity.types import UserPrincipal +from tests.unit.v2.user_route_support import RecordingService, user_route_client + + +ISSUER = "https://auth.example.test/" +AUDIENCE = "https://api.policyengine.org/" +USER_ROUTE_REQUESTS = ( + ("GET", "/v2/me?country_id=us", None), + ("PATCH", "/v2/me?country_id=us", {"first_name": "Ada"}), + ("DELETE", "/v2/me?country_id=us", None), + ("GET", "/v2/me/export?country_id=us", None), + ("POST", "/v2/me/imports?country_id=us", {}), + ("GET", "/v2/user-policies?country_id=us", None), + ("POST", "/v2/user-policies?country_id=us", {"country_id": "us"}), + ("GET", "/v2/user-households?country_id=us", None), + ("GET", "/v2/user-simulations?country_id=us", None), + ("GET", "/v2/user-reports?country_id=us", None), + ( + "DELETE", + "/v2/user-reports/00000000-0000-0000-0000-000000000001?country_id=us", + None, + ), +) + + +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 + + +@pytest.mark.parametrize(("method", "path", "body"), USER_ROUTE_REQUESTS) +def test_every_user_route_fails_closed_without_a_token( + method: str, path: str, body: dict | None +) -> None: + services, factories = _services() + client, flask_calls = user_route_client(require_user=None, **factories) + + response = client.request( + method, + path, + json=body, + 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() + + assert "UserAccessToken" in schema["components"]["securitySchemes"] + for path in ("/v2/me", "/v2/user-policies", "/v2/user-reports"): + assert {"UserAccessToken": []} in schema["paths"][path]["get"]["security"] + assert "security" not in schema["paths"]["/v2/policies"]["get"] 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..f2be0d43b --- /dev/null +++ b/tests/unit/v2/test_user_simulation_report_routes.py @@ -0,0 +1,302 @@ +"""Native FastAPI contract tests for signed-in simulation and report saves.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from uuid import UUID + +import pytest + +from policyengine_api.services.v2.associations.validators import ( + AssociationClientRefConflictError, + 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") +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} + ], + } +] +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, + client_ref="sus-1", + 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, + source_note="from chat", + provisions=PROVISIONS, + validation=VALIDATION, + last_run_at=LAST_RUN_AT, + client_ref="sur-1", + 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, "client_ref": "sus-1"}, + ) + 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, + "client_ref": "sus-1", + "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), + "source_note": "from chat", + "provisions": PROVISIONS, + "validation": VALIDATION, + "last_run_at": "2026-02-01T12:00:00Z", + "client_ref": "sur-1", + }, + ) + + 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["provisions"] == PROVISIONS + assert item["validation"] == VALIDATION + 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.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)}]}, + ], +) +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), + (AssociationClientRefConflictError("client_ref exists"), 409), + ], +) +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, + "provisions": 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", + "provisions", + "last_run_at", + } diff --git a/tests/unit/v2/user_route_support.py b/tests/unit/v2/user_route_support.py new file mode 100644 index 000000000..eb9737aec --- /dev/null +++ b/tests/unit/v2/user_route_support.py @@ -0,0 +1,98 @@ +"""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 UserPrincipal + + +ISSUER = "https://auth.example.test/" +PRINCIPAL = UserPrincipal(issuer=ISSUER, subject="google-oauth2|1234") +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 user_route_client( + *, + require_user: Callable[..., UserPrincipal] | 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..01d75e9fc --- /dev/null +++ b/tests/unit/v2/user_service_support.py @@ -0,0 +1,189 @@ +"""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 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") +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() From 7b0cf1de2a1e0509540d0c4edba45091121df464 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:01:11 +0400 Subject: [PATCH 04/35] Register signed-in user route contracts and Postgres identity tests Add user-simulations, user-reports, and me path segments to the migration registry; give ContractRequest a required-headers field; record authenticated variants of the user-policy and user-household contracts and new simulation, report, and account contracts; guard that user-owned routes always require Authorization; and regenerate the contract artifacts. Add disposable-Postgres tests for concurrent first sign-in, concurrent idempotent imports, MATCH SIMPLE keys, and account deletion. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/v2-integration-check.yml | 2 + docs/engineering/migration-contracts.md | 205 +++++---- docs/generated/migration_contracts.json | 405 +++++++++++++++++- policyengine_api/migration_registry.py | 6 +- scripts/export_migration_contracts.py | 11 +- scripts/guards/migration_contracts.py | 10 + tests/contract/registry.py | 318 +++++++++----- .../test_app_v2_workflow_contracts.py | 11 + .../test_v2_user_identity_persistence.py | 272 ++++++++++++ tests/unit/test_alembic_workflows.py | 3 +- .../unit/test_migration_contract_artifacts.py | 50 ++- tests/unit/test_migration_flags.py | 5 + 12 files changed, 1088 insertions(+), 210 deletions(-) create mode 100644 tests/integration/test_v2_user_identity_persistence.py 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/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index f29b8069b..21679b6da 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 | 65 | | 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,190 @@ 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 +- Future owner: PR 12A: User Login and v1-Anchored User Associations -| 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` | | +| 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.client_ref`, `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.client_ref`, `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` | | ### `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.client_ref`, `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.client_ref`, `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` | | + +### `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.client_ref`, `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.client_ref`, `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` | | + +### `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.source_note`, `result.item.provisions`, `result.item.validation`, `result.item.last_run_at`, `result.item.client_ref`, `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.source_note`, `result.item.provisions`, `result.item.validation`, `result.item.last_run_at`, `result.item.client_ref`, `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.source_note`, `result.item.provisions`, `result.item.validation`, `result.item.last_run_at`, `result.item.updated_at` | +| `DELETE` | `/v2/user-reports/{association_id}?country_id=us` | 204 | `report` | `Authorization` | | + +### `user_account_v2` + +- Current contract: `typed_v2_resources` +- Future owner: PR 12A: User Login and v1-Anchored User Associations -| 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` | | +| 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.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` | +| `POST` | `/v2/me/imports?country_id=us` | 200 | `user_profile` | `Authorization` | `status`, `message`, `result.id_map`, `result.skipped` | +| `DELETE` | `/v2/me?country_id=us` | 204 | `user_profile` | `Authorization` | | ### `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/generated/migration_contracts.json b/docs/generated/migration_contracts.json index e53974bf2..b1082c1c1 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": 65, "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,21 @@ "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.client_ref", "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 +294,23 @@ "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.client_ref", "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 +323,9 @@ }, { "expected_status": 200, + "headers": [ + "Authorization" + ], "method": "PATCH", "path": "/v2/user-policies/{association_id}?country_id=us", "route_group": "policy", @@ -301,13 +333,20 @@ "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", @@ -322,6 +361,7 @@ "requests": [ { "expected_status": 201, + "headers": [], "method": "POST", "path": "/us/household", "route_group": "household", @@ -333,6 +373,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/us/household/{household_id}", "route_group": "household", @@ -351,6 +392,7 @@ "requests": [ { "expected_status": 201, + "headers": [], "method": "POST", "path": "/v2/households?country_id=us", "route_group": "household", @@ -367,6 +409,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/households/{household_id}?country_id=us", "route_group": "household", @@ -383,6 +426,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/households?country_id=us", "route_group": "household", @@ -399,11 +443,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,14 +461,19 @@ "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.client_ref", "result.item.created_at", "result.item.updated_at" ] }, { "expected_status": 200, + "headers": [ + "Authorization" + ], "method": "GET", "path": "/v2/user-households/{association_id}?country_id=us", "route_group": "household", @@ -432,16 +484,21 @@ "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.client_ref", "result.item.created_at", "result.item.updated_at" ] }, { "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 +511,9 @@ }, { "expected_status": 200, + "headers": [ + "Authorization" + ], "method": "PATCH", "path": "/v2/user-households/{association_id}?country_id=us", "route_group": "household", @@ -461,6 +521,7 @@ "status", "message", "result.item.id", + "result.item.legacy_household_id", "result.item.household_id", "result.item.name", "result.item.description", @@ -469,6 +530,9 @@ }, { "expected_status": 204, + "headers": [ + "Authorization" + ], "method": "DELETE", "path": "/v2/user-households/{association_id}?country_id=us", "route_group": "household", @@ -476,6 +540,303 @@ } ] }, + { + "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.client_ref", + "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.client_ref", + "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": [] + } + ] + }, + { + "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.source_note", + "result.item.provisions", + "result.item.validation", + "result.item.last_run_at", + "result.item.client_ref", + "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.source_note", + "result.item.provisions", + "result.item.validation", + "result.item.last_run_at", + "result.item.client_ref", + "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.source_note", + "result.item.provisions", + "result.item.validation", + "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": [] + } + ] + }, + { + "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.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": [ + "Authorization" + ], + "method": "POST", + "path": "/v2/me/imports?country_id=us", + "route_group": "user_profile", + "stable_response_fields": [ + "status", + "message", + "result.id_map", + "result.skipped" + ] + }, + { + "expected_status": 204, + "headers": [ + "Authorization" + ], + "method": "DELETE", + "path": "/v2/me?country_id=us", + "route_group": "user_profile", + "stable_response_fields": [] + } + ] + }, { "current_contract": "api_v1_compatible", "future_owner_pr": "PR 13: Household Calculation Compute Cutover", @@ -483,6 +844,7 @@ "requests": [ { "expected_status": 200, + "headers": [], "method": "POST", "path": "/us/calculate", "route_group": "household", @@ -501,6 +863,7 @@ "requests": [ { "expected_status": 200, + "headers": [], "method": "GET", "path": "/us/metadata", "route_group": "metadata", @@ -513,6 +876,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/uk/metadata", "route_group": "metadata", @@ -532,6 +896,7 @@ "requests": [ { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/tax-benefit-models?country_id=us", "route_group": "metadata", @@ -547,6 +912,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/tax-benefit-model-versions?country_id=us", "route_group": "metadata", @@ -562,6 +928,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/variables?country_id=us", "route_group": "metadata", @@ -577,6 +944,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/parameters?country_id=us", "route_group": "metadata", @@ -592,6 +960,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/parameters/children?country_id=us&parent_path=gov", "route_group": "metadata", @@ -607,6 +976,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/parameter-values?country_id=us", "route_group": "metadata", @@ -622,6 +992,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/datasets?country_id=us", "route_group": "metadata", @@ -637,6 +1008,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/regions?country_id=us", "route_group": "metadata", @@ -652,6 +1024,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/tax-benefit-models/{model_id}?country_id=us", "route_group": "metadata", @@ -664,6 +1037,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/tax-benefit-model-versions/{version_id}?country_id=us", "route_group": "metadata", @@ -676,6 +1050,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/variables/{variable_id}?country_id=us", "route_group": "metadata", @@ -688,6 +1063,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/parameters/{parameter_id}?country_id=us", "route_group": "metadata", @@ -700,6 +1076,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/parameter-values/{value_id}?country_id=us", "route_group": "metadata", @@ -712,6 +1089,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/datasets/{dataset_id}?country_id=us", "route_group": "metadata", @@ -724,6 +1102,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/regions/{region_id}?country_id=us", "route_group": "metadata", @@ -736,6 +1115,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/regions/by-code/state/ca?country_id=us", "route_group": "metadata", @@ -748,6 +1128,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/tax-benefit-models/by-country/us", "route_group": "metadata", @@ -761,6 +1142,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/v2/economy-options?country_id=us", "route_group": "metadata", @@ -783,6 +1165,7 @@ "requests": [ { "expected_status": 201, + "headers": [], "method": "POST", "path": "/us/simulation", "route_group": "simulation", @@ -795,6 +1178,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/us/simulation/{simulation_id}", "route_group": "simulation", @@ -813,6 +1197,7 @@ "requests": [ { "expected_status": 201, + "headers": [], "method": "POST", "path": "/us/report", "route_group": "report", @@ -825,6 +1210,7 @@ }, { "expected_status": 200, + "headers": [], "method": "GET", "path": "/us/report/{report_id}", "route_group": "report", @@ -843,6 +1229,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/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/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..701902b82 100644 --- a/scripts/guards/migration_contracts.py +++ b/scripts/guards/migration_contracts.py @@ -10,6 +10,9 @@ REPO_ROOT = Path(__file__).resolve().parents[2] ALLOWED_CURRENT_CONTRACTS = frozenset({"api_v1_compatible", "typed_v2_resources"}) +# Signed-in user routes must never become less restrictive during a cutover. +USER_OWNED_PATH_PREFIXES = ("/v2/me", "/v2/user-") +AUTHORIZATION_HEADER = "Authorization" def _check_unique_values( @@ -90,6 +93,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 ( + AUTHORIZATION_HEADER not in request.get("headers", []) + ): + violations.append( + f"{context}: user-owned routes must require the " + f"{AUTHORIZATION_HEADER} header" + ) if request["expected_status"] not in {200, 201, 202, 204}: violations.append( f"{context}: unexpected status {request['expected_status']}" diff --git a/tests/contract/registry.py b/tests/contract/registry.py index b9766126d..45305530f 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -3,6 +3,10 @@ from dataclasses import dataclass +AUTHORIZATION_HEADER = "Authorization" +USER_AUTH_HEADERS = (AUTHORIZATION_HEADER,) + + @dataclass(frozen=True) class ContractRequest: method: str @@ -10,6 +14,91 @@ class ContractRequest: expected_status: int stable_response_fields: tuple[str, ...] route_group: str + # Request headers the route requires, by name. Signed-in user routes + # require "Authorization: Bearer ". + 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.client_ref", + "result.item.created_at", + "result.item.updated_at", + ) + + +PAGE_FIELDS = ( + "status", + "message", + "result.items", + "result.offset", + "result.limit", + "result.has_more", +) + + +def _signed_in_association_requests( + resource: str, + *, + route_group: str, + item_fields: tuple[str, ...], + patch_fields: 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=USER_AUTH_HEADERS, + ), + ContractRequest( + method="GET", + path=detail_path, + expected_status=200, + stable_response_fields=item_fields, + route_group=route_group, + headers=USER_AUTH_HEADERS, + ), + ContractRequest( + method="GET", + path=f"/v2/{resource}?country_id=us", + expected_status=200, + stable_response_fields=PAGE_FIELDS, + route_group=route_group, + headers=USER_AUTH_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=USER_AUTH_HEADERS, + ), + ContractRequest( + method="DELETE", + path=detail_path, + expected_status=204, + stable_response_fields=(), + route_group=route_group, + headers=USER_AUTH_HEADERS, + ), + ) @dataclass(frozen=True) @@ -145,78 +234,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=_signed_in_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 +333,143 @@ 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=_signed_in_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=_signed_in_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=_signed_in_association_requests( + "user-reports", + route_group="report", + item_fields=_association_item_fields( + "report_id", + "legacy_report_id", + "name", + "description", + "user_policy_id", + "source_note", + "provisions", + "validation", + "last_run_at", + ), + patch_fields=( + "legacy_report_id", + "name", + "description", + "user_policy_id", + "source_note", + "provisions", + "validation", + "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, + method="GET", + 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.primary_country", + "result.item.first_name", + "result.item.last_name", + "result.item.email", "result.item.created_at", - "result.item.updated_at", ), - route_group="household", + route_group="user_profile", + headers=USER_AUTH_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}", + path="/v2/me/export?country_id=us", expected_status=200, stable_response_fields=( "status", "message", - "result.items", - "result.offset", - "result.limit", - "result.has_more", + "result.user", + "result.policies", + "result.households", + "result.simulations", + "result.reports", ), - route_group="household", + route_group="user_profile", + headers=USER_AUTH_HEADERS, ), ContractRequest( - method="PATCH", - path="/v2/user-households/{association_id}?country_id=us", + method="POST", + path="/v2/me/imports?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.id_map", + "result.skipped", ), - route_group="household", + route_group="user_profile", + headers=USER_AUTH_HEADERS, ), ContractRequest( method="DELETE", - path="/v2/user-households/{association_id}?country_id=us", + path="/v2/me?country_id=us", expected_status=204, stable_response_fields=(), - route_group="household", + route_group="user_profile", + headers=USER_AUTH_HEADERS, ), ), ), diff --git a/tests/contract/test_app_v2_workflow_contracts.py b/tests/contract/test_app_v2_workflow_contracts.py index 9eba4dbfe..73a00e022 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,11 @@ 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 == ("Authorization",) + assert "user_id" not in request.path + else: + assert request.headers == () assert all( not request.path.startswith("/v2/") 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..aeff6afc4 --- /dev/null +++ b/tests/integration/test_v2_user_identity_persistence.py @@ -0,0 +1,272 @@ +"""PostgreSQL tests for signed-in identity provisioning and user associations. + +These prove the behaviors SQLite cannot: named ``ON CONFLICT`` targets, +concurrent first sign-in, concurrent idempotent imports, ``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 +import os +from threading import Barrier +from uuid import uuid4 + +import pytest +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, + UserIdentity, + UserPolicy, + UserReportAssociation, +) +from policyengine_api.data.v2.settings import V2_MIGRATION_DATABASE_URL +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.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 AccountImportInput +from tests.unit.v2.user_service_support import default_legacy_references + + +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} + try: + yield factory + finally: + with engine.begin() as connection: + owned = select(UserIdentity.user_id).where(UserIdentity.issuer == issuer) + user_ids = list(connection.execute(owned).scalars()) + 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 _count(sessions, statement) -> int: + with sessions() as session: + return int(session.execute(statement).scalar_one()) + + +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_imports_are_idempotent_on_client_ref(sessions) -> None: + principal = _principal(sessions, "google-oauth2|importer") + service = V2AccountService( + AccountDatabaseSession(sessions), + legacy_references=default_legacy_references(), + ) + service.get_account(principal=principal, country_id="us") + request = AccountImportInput.model_validate( + { + "policies": [ + {"client_ref": f"sup-{index}", "legacy_policy_id": 1} + for index in range(5) + ], + "simulations": [{"client_ref": "sus-1", "legacy_simulation_id": 613}], + "reports": [ + { + "client_ref": "sur-1", + "legacy_report_id": 615, + "user_policy_client_ref": "sup-0", + } + ], + } + ) + workers = 4 + barrier = Barrier(workers) + + def import_records(_index: int): + barrier.wait() + return service.import_records( + principal=principal, country_id="us", import_input=request + ).id_map + + with ThreadPoolExecutor(max_workers=workers) as executor: + id_maps = list(executor.map(import_records, range(workers))) + + assert all(id_map == id_maps[0] for id_map in id_maps) + assert len(id_maps[0]) == 7 + with sessions() as session: + owner = session.execute( + select(UserIdentity.user_id).where( + UserIdentity.issuer == principal.issuer + ) + ).scalar_one() + assert ( + session.execute( + select(func.count()) + .select_from(UserPolicy) + .where(UserPolicy.user_id == owner) + ).scalar_one() + == 5 + ) + report = session.get(UserReportAssociation, id_maps[0]["sur-1"]) + assert report is not None + assert report.user_policy_id == id_maps[0]["sup-0"] + + +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), + ] + ) + + 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) + + with sessions.begin() as session: + session.add( + UserPolicy( + user_id=owner, country_id="us", legacy_policy_id=1, client_ref="rf-1" + ) + ) + with pytest.raises(IntegrityError): + with sessions.begin() as session: + session.add( + UserPolicy( + user_id=owner, + country_id="us", + legacy_policy_id=2, + client_ref="rf-1", + ) + ) + + +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] = [] + service = V2AccountService( + AccountDatabaseSession(sessions), + legacy_references=default_legacy_references(), + auth0_user_deleter=deleted_subjects.append, + ) + alice_id = service.get_account(principal=alice, country_id="us").id + bob_id = service.get_account(principal=bob, country_id="us").id + service.import_records( + principal=alice, + country_id="us", + import_input=AccountImportInput.model_validate( + { + "policies": [{"client_ref": "rf-1", "legacy_policy_id": 1}], + "reports": [ + { + "client_ref": "lfr-1", + "legacy_report_id": 615, + "user_policy_client_ref": "rf-1", + } + ], + } + ), + ) + with sessions.begin() as session: + session.add( + LegacyUserMapping(legacy_user_id=f"legacy-{uuid4().hex}", user_id=alice_id) + ) + + service.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, UserPolicy, UserReportAssociation): + assert ( + session.execute( + select(func.count()) + .select_from(model) + .where(model.user_id == alice_id) + ).scalar_one() + == 0 + ) diff --git a/tests/unit/test_alembic_workflows.py b/tests/unit/test_alembic_workflows.py index 1e2114154..3d62ec748 100644 --- a/tests/unit/test_alembic_workflows.py +++ b/tests/unit/test_alembic_workflows.py @@ -203,6 +203,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 @@ -211,7 +212,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_migration_contract_artifacts.py b/tests/unit/test_migration_contract_artifacts.py index a0a623b75..1f7ffa831 100644 --- a/tests/unit/test_migration_contract_artifacts.py +++ b/tests/unit/test_migration_contract_artifacts.py @@ -10,8 +10,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": 65, "db_entity_count": 6, "sim_flow_count": 3, } @@ -23,6 +23,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 +74,46 @@ def test_migration_contract_quality_guard_rejects_duplicate_path_segments( "duplicate route path segment" in violation for violation in migration_contracts.check() ) + + +def test_migration_contract_quality_guard_requires_user_route_authorization( + monkeypatch, +): + payload = export_migration_contracts.build_payload() + workflows = [ + { + **workflow, + "requests": [ + {**request, "headers": []} for request in workflow["requests"] + ], + } + if workflow["name"] == "user_report_associations_v2" + else workflow + for workflow in payload["workflows"] + ] + monkeypatch.setattr( + export_migration_contracts, + "build_payload", + lambda: {**payload, "workflows": workflows}, + ) + + violations = migration_contracts.check() + + assert any( + "user-owned routes must require the Authorization header" in violation + for violation in violations + ) + + +def test_signed_in_user_contracts_require_authorization_headers(): + 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-")) + ] + + assert len(user_requests) == 25 + assert all(request["headers"] == ["Authorization"] for request in user_requests) + 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..6bc667925 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/imports", "user_profile"), ("/us/policy/1", "policy"), ("/us/policies", "policy"), ("/us/household/1", "household"), From e90905401684648f0efcbcc40ebaa92e518138e0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:03:45 +0400 Subject: [PATCH 05/35] Add read-only qualification for the user-association reshape Report the four v2 user-association row counts through SQLAlchemy in a read-only, rolled-back transaction, and stop when the Stage 12A revision is pending while user_simulation_associations or user_report_associations hold rows. The seed workflow runs it before the schema upgrade. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/seed-v2-database.yml | 4 + ...ser_association_migration_qualification.py | 239 ++++++++++++++++++ .../qualify_v2_user_association_migration.py | 8 + .../test_v2_user_identity_persistence.py | 49 +++- tests/unit/v2/test_import_side_effects.py | 1 + tests/unit/v2/test_metadata_deployment.py | 14 + ...ser_association_migration_qualification.py | 143 +++++++++++ 7 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 policyengine_api/data/v2/user_association_migration_qualification.py create mode 100644 scripts/qualify_v2_user_association_migration.py create mode 100644 tests/unit/v2/test_user_association_migration_qualification.py 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/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/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/integration/test_v2_user_identity_persistence.py b/tests/integration/test_v2_user_identity_persistence.py index aeff6afc4..d1c844d68 100644 --- a/tests/integration/test_v2_user_identity_persistence.py +++ b/tests/integration/test_v2_user_identity_persistence.py @@ -30,7 +30,12 @@ UserPolicy, UserReportAssociation, ) -from policyengine_api.data.v2.settings import V2_MIGRATION_DATABASE_URL +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 ( resolve_principal_user_in_session, ) @@ -163,9 +168,7 @@ def import_records(_index: int): assert len(id_maps[0]) == 7 with sessions() as session: owner = session.execute( - select(UserIdentity.user_id).where( - UserIdentity.issuer == principal.issuer - ) + select(UserIdentity.user_id).where(UserIdentity.issuer == principal.issuer) ).scalar_one() assert ( session.execute( @@ -270,3 +273,41 @@ def test_account_deletion_cascades_after_removing_legacy_mappings(sessions) -> N ).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/v2/test_import_side_effects.py b/tests/unit/v2/test_import_side_effects.py index 015317b3b..aed196b7a 100644 --- a/tests/unit/v2/test_import_side_effects.py +++ b/tests/unit/v2/test_import_side_effects.py @@ -74,6 +74,7 @@ def reject_ddl(*args, **kwargs): 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_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_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) From 04fa436fdd833bd4a323f1a8bc149a60dcdad7eb Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:07:50 +0400 Subject: [PATCH 06/35] Wire user auth settings into deploys and gate staging on signed-in routes Pass PUBLIC_API_AUTH_* and the optional AUTH0_MANAGEMENT_* settings through the staging and production deploy jobs and the Cloud Run candidate script, validate them before deploying, and keep them out of the release test environment. Rewrite the live staging probe to sign in a dedicated staging Auth0 test user and exercise /v2/me, all four association resources, imports, export, and account deletion without direct database access. Both candidate smoke suites now assert that anonymous user-route requests return 401. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/deploy_cloud_run_candidate.sh | 13 + .../scripts/validate_cloud_run_deploy_env.sh | 41 +- .github/workflows/push.yml | 22 +- tests/integration/test_cloud_run_candidate.py | 16 + tests/integration/test_live_v2_policies.py | 503 +++++++++++++----- tests/unit/test_cloud_run_deploy_scripts.py | 135 +++++ 6 files changed, 585 insertions(+), 145 deletions(-) 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..ae02bc812 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,44 @@ 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 + selected_url_env="$( simulation_entrypoint_url_env_name "${SIM_ENTRYPOINT}" )" diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 9213f4fbd..ed9ed8d14 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -189,6 +189,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-staging CLOUD_RUN_RUNTIME_CACHE_ENVIRONMENT: staging CLOUD_RUN_RUNTIME_CACHE_URL_SECRET: policyengine-api-staging-runtime-cache-url:latest @@ -233,7 +239,8 @@ 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 -u AUTH0_MANAGEMENT_DOMAIN -u AUTH0_MANAGEMENT_CLIENT_ID -u AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE make test env: RUNTIME_CACHE_MODE: local RUNTIME_CACHE_URL: redis://127.0.0.1:6379/0 @@ -308,6 +315,13 @@ 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 }} parallel-live-simulation-tests-staging-cloud-run: name: Run live simulations against the staging candidate @@ -473,6 +487,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/tests/integration/test_cloud_run_candidate.py b/tests/integration/test_cloud_run_candidate.py index 81fb8df8f..fbf173a5a 100644 --- a/tests/integration/test_cloud_run_candidate.py +++ b/tests/integration/test_cloud_run_candidate.py @@ -66,3 +66,19 @@ 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): + """Signed-in user routes must never become readable anonymously.""" + + for path in ("/v2/me", "/v2/user-policies", "/v2/user-reports"): + response = api_client.get( + path, + params={ + "country_id": "us", + "user_id": "00000000-0000-0000-0000-000000000000", + }, + ) + 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..d8b98ab4f 100644 --- a/tests/integration/test_live_v2_policies.py +++ b/tests/integration/test_live_v2_policies.py @@ -1,27 +1,93 @@ -"""Write/read lifecycle checks for deployed native v2 policy resources.""" +"""Live staging checks for native v2 policies and signed-in user 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. 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" -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 +95,311 @@ 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 test_live_user_routes_reject_unauthenticated_requests(api_client) -> None: + for method, path in ( + ("GET", "/v2/me"), + ("GET", "/v2/me/export"), + ("POST", "/v2/me/imports"), + ("GET", "/v2/user-policies"), + ("GET", "/v2/user-households"), + ("GET", "/v2/user-simulations"), + ("GET", "/v2/user-reports"), + ): + response = api_client.request( + method, + path, + params={"country_id": COUNTRY_ID, "user_id": str(uuid4())}, + 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(): + client_ref = f"{resource}-{integration_probe_id}" + 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}", + "client_ref": client_ref, + }, ) - 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 + assert item["client_ref"] == client_ref + + duplicate = api_client.post( + f"/v2/{resource}", + params=params, + headers=user_headers, + json={ + "country_id": COUNTRY_ID, + legacy_field: legacy_id, + "client_ref": client_ref, + }, ) + assert duplicate.status_code == 409, (resource, duplicate.text[:500]) + 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": [ + 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]) + + import_body = { + "policies": [ + { + "client_ref": f"rf-{integration_probe_id}", + "legacy_policy_id": legacy["policy"], + "baseline": "current-law", + "provenance": {"source": "manual"}, + }, + { + "client_ref": f"sup-missing-{integration_probe_id}", + "legacy_policy_id": UNKNOWN_LEGACY_ID, + }, + ], + "households": [ + { + "client_ref": f"suh-{integration_probe_id}", + "legacy_household_id": legacy["household"], + } + ], + "simulations": [ + { + "client_ref": f"sus-{integration_probe_id}", + "legacy_simulation_id": legacy["simulation"], + } + ], + "reports": [ { - "parameter_id": parameter["id"], - "value": f"phase10-native-{integration_probe_id}", - "start_date": "2026-01-01T00:00:00Z", + "client_ref": f"lfr-{integration_probe_id}", + "legacy_report_id": legacy["report"], + "user_policy_client_ref": f"rf-{integration_probe_id}", } ], } - created = api_client.post( - "/v2/policies", - params={"country_id": country_id}, - json=policy_body, + imported = api_client.post( + "/v2/me/imports", params=params, headers=user_headers, json=import_body ) - 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 created_item["created_at"] - assert created_item["updated_at"] - + assert imported.status_code == 200, imported.text[:500] + result = imported.json()["result"] + assert len(result["id_map"]) == 4 + assert result["skipped"] == [ + { + "client_ref": f"sup-missing-{integration_probe_id}", + "reason": "unknown_legacy_reference", + } + ] repeated = api_client.post( - "/v2/policies", - params={"country_id": country_id}, - json=policy_body, + "/v2/me/imports", params=params, headers=user_headers, json=import_body ) assert repeated.status_code == 200, repeated.text[:500] - assert repeated.json()["result"]["item"]["id"] == policy_id + assert repeated.json()["result"]["id_map"] == result["id_map"] - detail = api_client.get( - f"/v2/policies/{policy_id}", - params={"country_id": country_id}, + 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 + report = next( + row + for row in export["reports"] + if row["client_ref"] == f"lfr-{integration_probe_id}" ) - 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 collection.status_code == 200, collection.text[:500] - assert policy_id in { - item["id"] for item in collection.json()["result"]["items"] - } - - 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 ( + report["user_policy_id"] == result["id_map"][f"rf-{integration_probe_id}"] ) - assert association.status_code == 201, association.text[:500] - association_item = association.json()["result"]["item"] - association_id = association_item["id"] + assert {len(export[name]) for name in ("policies", "households")} == {1} - association_detail = api_client.get( - f"/v2/user-policies/{association_id}", - params={"country_id": country_id}, - ) - assert association_detail.status_code == 200, association_detail.text[:500] - assert association_detail.json()["result"]["item"] == association_item - - association_collection = api_client.get( - "/v2/user-policies", - params={ - "country_id": country_id, - "user_id": str(user_id), - "policy_id": 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 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}, - ) - assert deleted.status_code == 204, deleted.text[:500] - absent = api_client.get( - f"/v2/user-policies/{association_id}", - params={"country_id": country_id}, - ) - assert absent.status_code == 404, absent.text[:500] + assert empty_export.status_code == 200, empty_export.text[:500] + assert empty_export.json()["result"]["policies"] == [] finally: - with engine.begin() as connection: - connection.execute( - text("DELETE FROM users WHERE id = :id"), - {"id": user_id}, - ) - engine.dispose() + _delete_account(api_client, user_headers) diff --git a/tests/unit/test_cloud_run_deploy_scripts.py b/tests/unit/test_cloud_run_deploy_scripts.py index a22289af8..1bbfc3ff6 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( @@ -2506,3 +2517,127 @@ 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_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 }}", + "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 job + + 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", + "AUTH0_MANAGEMENT_DOMAIN", + "AUTH0_MANAGEMENT_CLIENT_ID", + "AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE", + ): + 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 }}", + ): + assert name in integration + assert "STAGING_AUTH0_TEST" not in production From d92bffc2d96ac11eda30e2a849fc5f329398e9e1 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:09:32 +0400 Subject: [PATCH 07/35] Document Stage 12A user login and propose Cloud Armor throttles Add the Stage 12A migration guide (routes, provisioning, schema revision, settings, GitHub variables and secrets, Auth0 prerequisites, rollout, verification), cross-reference it from the Stage 10 and 11 guides, add Stage 12A test commands to the testing skill, record a changelog fragment, and add a doc-only, preview-first Cloud Armor proposal for signed-in user writes and imports. Co-Authored-By: Claude Opus 5.5 --- ...stage-12a-user-login-associations.added.md | 1 + docs/engineering/skills/testing.md | 45 ++++ ...1012-pol-api-lb-stage-12a-user-writes.yaml | 114 +++++++++ docs/migration/stage-10-v2-policies.md | 5 + docs/migration/stage-11-v2-households.md | 5 +- docs/migration/stage-12a-user-login.md | 224 ++++++++++++++++++ 6 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 changelog.d/feat-stage-12a-user-login-associations.added.md create mode 100644 docs/migration/armor/proposed/20261012-pol-api-lb-stage-12a-user-writes.yaml create mode 100644 docs/migration/stage-12a-user-login.md 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..06997df0a --- /dev/null +++ b/changelog.d/feat-stage-12a-user-login-associations.added.md @@ -0,0 +1 @@ +Add Auth0 user login for app v3: /v2/me (provisioning, profile, export, idempotent imports, account deletion), owner-scoped v1-anchored user associations for policies, households, simulations, and reports, and the user_identities schema. diff --git a/docs/engineering/skills/testing.md b/docs/engineering/skills/testing.md index d64c1c126..f5437ea66 100644 --- a/docs/engineering/skills/testing.md +++ b/docs/engineering/skills/testing.md @@ -298,3 +298,48 @@ 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 signed-in 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_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, idempotent imports, 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 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; it is not +run locally. 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..f479d6553 --- /dev/null +++ b/docs/migration/armor/proposed/20261012-pol-api-lb-stage-12a-user-writes.yaml @@ -0,0 +1,114 @@ +# PROPOSED (not deployed): Stage 12A per-IP throttles for signed-in user writes. +# +# This is the newest deployed snapshot +# (../20260830T141827Z-pol-api-lb-metadata-enforced.yaml) plus two new rules at +# priorities 1150 and 1200. It lives under proposed/ so it is never mistaken for +# the authoritative deployed state. An operator applies the two rules 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 1150 \ +# --project=$PROJECT --security-policy=pol-api-lb \ +# --expression="request.method == 'POST' && request.path.matches('^/v2/me/imports$')" \ +# --action=throttle \ +# --rate-limit-threshold-count=20 --rate-limit-threshold-interval-sec=600 \ +# --conform-action=allow --exceed-action=deny-429 --enforce-on-key=IP \ +# --preview +# +# 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: +# - Reads (GET) and CORS preflights (OPTIONS) are excluded: the app reads +# /v2/me on every page load and lists saved items, and NAT'd offices share IPs. +# - Imports run once per first sign-in (at most 500 items, 1 MiB), so they get a +# tighter, longer-window rule evaluated first (lower priority number). +# - Cloud Armor CEL regexes reject capture groups, so the plan's +# ^/v2/(me|user-) is written ^/v2/(?:me|user-). +# - Thresholds are starting points; 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 signed-in localStorage imports' + kind: compute#securityPolicyRule + match: + expr: + expression: request.method == 'POST' && request.path.matches('^/v2/me/imports$') + preview: true + priority: 1150 + rateLimitOptions: + conformAction: allow + enforceOnKey: IP + exceedAction: deny(429) + rateLimitThreshold: + count: 20 + intervalSec: 600 +- action: throttle + description: 'Stage 12A: per-IP limit on signed-in user 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/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..8d640eefe --- /dev/null +++ b/docs/migration/stage-12a-user-login.md @@ -0,0 +1,224 @@ +# Stage 12A: user login and v1-anchored user associations + +Stage 12A lets app v3 users sign in with Auth0 social sign-on and saves their +work server-side. The Public API turns the Auth0 identity 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. +Signed-out behavior is unchanged. + +## What changed + +- **Inbound authentication.** `/v2/me*` and `/v2/user-*` require + `Authorization: Bearer ` for the Public API audience. + `libs/user_auth.py` validates RS256 tokens against + `{issuer}.well-known/jwks.json` and checks `iss` and `aud`. The decoder is + ported from the Simulation Entrypoint. Responses: + - no token or an invalid token: 401 with `WWW-Authenticate: Bearer`; + - a machine-to-machine (`…@clients`) token: 403; + - authentication not configured, or signing keys unreachable: 503. + + Authentication is a router-level dependency + (`NativeRouteDependencies.require_user`, fail-closed by default). It runs + before query, path, and body validation. Only a body that is not parseable + JSON is rejected (422) by FastAPI first. +- **Identity and provisioning.** The new `user_identities` table maps + `(issuer, subject)` to `users.id`. The first authenticated request inserts the + 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. Logs carry + only the v2 user UUID. +- **Ownership.** Request bodies and queries no longer accept `user_id`; the + owner always comes from the token. 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`. +- **Imports.** `POST /v2/me/imports` takes up to 500 browser-local items in a + 1 MiB body. It is idempotent on `UNIQUE(user_id, client_ref)`: an existing + `client_ref` returns its stored ID unchanged. Items with an unknown v1 + reference or an invalid shape are skipped and reported. +- **Account deletion.** `DELETE /v2/me` deletes the user's + `legacy_user_mappings` rows (RESTRICT FK), then the user (identities and + associations cascade), then the Auth0 user through the Management API when + configured. If the Auth0 call fails after the data is gone, the route returns + 502 and a retry is safe. + +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 | Behavior | +| --- | --- | +| `GET /v2/me?country_id=` | Returns the user; provisions on first call | +| `PATCH /v2/me` | Updates `first_name`, `last_name`, `primary_country` | +| `GET /v2/me/export` | Returns the user and every association, all countries | +| `POST /v2/me/imports` | Idempotent bulk import; returns `id_map` and `skipped` | +| `DELETE /v2/me` | 204; deletes data, then the Auth0 user when configured | +| `/v2/user-policies`, `/v2/user-households`, `/v2/user-simulations`, `/v2/user-reports` | POST (201), list, GET, PATCH, DELETE (204), owner-scoped | + +Status conventions follow the existing v2 routes. Schema and query 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. Single-create `client_ref` clashes and mirror-owned rows return 409. + +## Schema (one autogenerated revision) + +Revision `fbb7488ebe8a` (`link signed-in users to v1-anchored associations`, +parent `91af4fc8d8c1`) was produced with +`uv run alembic -c alembic-v2.ini revision --autogenerate` from the final +SQLModel metadata. It has no post-generation corrections. + +- `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`), `provenance jsonb`, and `client_ref varchar(64)`. Adds + `ck_user_policies_policy_ref`, `uq_user_policies_user_client_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; `country` is + replaced by `country_id` (NOT NULL, CHECK) and `label` by `name` plus + `description`. Each gains a legacy ID, `client_ref`, a reference CHECK, + `UNIQUE(id, country_id)`, `UNIQUE(user_id, client_ref)`, and listing indexes. + Reports also add `user_policy_id` → `user_policies.id` ON DELETE SET NULL, + `source_note`, `provisions jsonb`, and `validation jsonb`. + +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. + +Downgrading one revision restores the previous shape. It fails naturally if +associations hold rows that the old shape cannot represent: legacy-only +references, or rows in the reshaped tables. + +## Settings + +| Setting | Source | Meaning | +| --- | --- | --- | +| `PUBLIC_API_AUTH_REQUIRED` | GitHub variable (`staging`, `production`) | `1` requires issuer and audience; `0` allows them to be absent, so user routes 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) | Canonical tenant domain (the Management API is not served on custom domains) | +| `AUTH0_MANAGEMENT_CLIENT_ID` | GitHub variable (optional) | M2M client with `delete:users` | +| `AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE` | GitHub secret (optional) | Secret Manager version holding the M2M client secret | + +`readiness.validate_policy_runtime_configuration` validates the +`PUBLIC_API_AUTH_*` and `AUTH0_MANAGEMENT_*` settings. Inconsistent values fail +readiness, which is the deploy gate. `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. `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. Leave the `AUTH0_MANAGEMENT_*` settings unset in staging, or the CI user +would also be removed from Auth0. + +## Auth0 prerequisites (operator) + +Configure staging first, then production: + +1. **Custom domain** (for example `auth.policyengine.org`). Tokens then carry + that issuer. +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`. + +## 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 live test before promotion. +5. Production: set the production values once production Auth0 is ready. The + candidate smoke test checks that anonymous `/v2/me` and `/v2/user-*` + requests 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 throttles in preview + (`docs/migration/armor/proposed/20261012-pol-api-lb-stage-12a-user-writes.yaml`) + and enforce them only after the runbook's checks. + +User-owned routes must never become less restrictive during a cutover. The +migration-contract guard rejects any `/v2/me` or `/v2/user-` contract that does +not require `Authorization`. + +## 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 imports are idempotent; +- named `ON CONFLICT` targets work; +- `MATCH SIMPLE` composite keys accept NULL v2 references; +- account deletion cascades after removing legacy mappings; +- the qualification transaction is read-only. + +## Deferred + +- 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. From 292665a0f5caf99611b75ecc73986a94dac4e833 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:09:39 +0400 Subject: [PATCH 08/35] Describe owner-from-token association queries in the routes skill Co-Authored-By: Claude Opus 5.5 --- docs/engineering/skills/api-routes.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/engineering/skills/api-routes.md b/docs/engineering/skills/api-routes.md index 1b6a4b91a..ef5c2a78c 100644 --- a/docs/engineering/skills/api-routes.md +++ b/docs/engineering/skills/api-routes.md @@ -70,8 +70,8 @@ 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 signed-in association reference filtering + and pagination; the owner comes from the bearer token, never the query. ## Compatibility and Documentation From 89be2b02505b74cf2478a07e8e22e5dba072f36d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:12:25 +0400 Subject: [PATCH 09/35] Keep release-test unsets inline-short and update household reassignment test Fold the new auth unsets into the existing release-test command line so the workflow keeps its three-line inline limit, and move the Stage 11 household reassignment integration test to the signed-in contract, repointing through a mapped legacy household. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/push.yml | 3 +- .../test_v2_household_persistence.py | 59 ++++++++++++++----- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index ed9ed8d14..540149bc9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -239,8 +239,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 - -u PUBLIC_API_AUTH_REQUIRED -u PUBLIC_API_AUTH_ISSUER -u PUBLIC_API_AUTH_AUDIENCE -u AUTH0_MANAGEMENT_DOMAIN -u AUTH0_MANAGEMENT_CLIENT_ID -u AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE 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 -u AUTH0_MANAGEMENT_DOMAIN -u AUTH0_MANAGEMENT_CLIENT_ID -u AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE make test env: RUNTIME_CACHE_MODE: local RUNTIME_CACHE_URL: redis://127.0.0.1:6379/0 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() From 5864f9daa3c9d05bbf0dce6f8e033342a7c89bb6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:12:59 +0400 Subject: [PATCH 10/35] Pass the optional staging Auth0 realm to the live signed-in probe Co-Authored-By: Claude Opus 5.5 --- .github/workflows/push.yml | 1 + tests/unit/test_cloud_run_deploy_scripts.py | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 540149bc9..c4fe11ff8 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -321,6 +321,7 @@ jobs: 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 diff --git a/tests/unit/test_cloud_run_deploy_scripts.py b/tests/unit/test_cloud_run_deploy_scripts.py index 1bbfc3ff6..6d0d9b9d5 100644 --- a/tests/unit/test_cloud_run_deploy_scripts.py +++ b/tests/unit/test_cloud_run_deploy_scripts.py @@ -2638,6 +2638,7 @@ def test_push_workflow_wires_user_auth_settings_and_staging_test_user(): "${{ 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 From 41c4af76c36da01be413febb8e4ef9650217b0ea Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:51:46 +0400 Subject: [PATCH 11/35] Keep retired association columns so the previous revision still loads rows The Stage 12A revision dropped country and label from the dormant simulation and report association tables, but the previous Public API revision still maps them: deleting a user through its models raised UndefinedColumn during the deploy window or after an application rollback. Keep both columns as retired, nullable, unused fields and regenerate fbb7488ebe8a (same revision ID) so the upgrade drops nothing. Verified against disposable Postgres: master's delete_transition_user now deletes a user who owns Stage 12A rows, and fails with the previous generated revision. A lifecycle test now requires every parent-revision column to survive and no nullable column to become required. The Stage 12A doc records the exact rollback rules and the follow-up revision that drops the retired columns. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-12a-user-login.md | 45 ++++++++++++--- ...8a_link_signed_in_users_to_v1_anchored_.py | 50 ++++++++--------- .../data/v2/models/associations.py | 12 ++++ .../integration/test_alembic_v2_lifecycle.py | 55 +++++++++++++++++++ tests/unit/v2/test_alembic_v2.py | 13 +++++ tests/unit/v2/test_models.py | 6 +- 6 files changed, 145 insertions(+), 36 deletions(-) diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index 8d640eefe..2da430428 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -92,12 +92,17 @@ SQLModel metadata. It has no post-generation corrections. `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; `country` is - replaced by `country_id` (NOT NULL, CHECK) and `label` by `name` plus - `description`. Each gains a legacy ID, `client_ref`, a reference CHECK, - `UNIQUE(id, country_id)`, `UNIQUE(user_id, client_ref)`, and listing indexes. - Reports also add `user_policy_id` → `user_policies.id` ON DELETE SET NULL, - `source_note`, `provisions jsonb`, and `validation jsonb`. + `(user_id, simulation_id|report_id)` uniques are dropped. Each gains + `country_id` (NOT NULL, CHECK), `name`, `description`, a legacy ID, + `client_ref`, a reference CHECK, `UNIQUE(id, country_id)`, + `UNIQUE(user_id, client_ref)`, and listing indexes. Reports also add + `user_policy_id` → `user_policies.id` ON DELETE SET NULL, `source_note`, + `provisions jsonb`, and `validation jsonb`. +- 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`. @@ -121,9 +126,28 @@ prints the URL. `seed-v2-database.yml` runs it, through upgrade. Before merging, an operator should run it read-only against staging and production and confirm both dormant tables are empty. -Downgrading one revision restores the previous shape. It fails naturally if -associations hold rows that the old shape cannot represent: legacy-only -references, or rows in the reshaped tables. +### Rollback + +The revision is additive for everything the previous Public API revision maps: +it drops no table or column and makes no nullable column required. The +lifecycle test `test_stage_12a_revision_keeps_the_previous_revision_loadable` +enforces this. + +- **Rolling back the application is safe without a database downgrade.** The + previous revision serves its routes against this schema, and deleting a user + through its models still cascades through the simulation and report + associations. +- **One exception.** The previous revision's unauthenticated + `/v2/user-policies` and `/v2/user-households` responses require a non-null + `policy_id` / `household_id`. Once a signed-in user owns a legacy-only row (a + v1 reference with no v2 copy), any of those responses that includes the row + fails with 500 under the previous revision. Nothing else is affected, and the + current production app does not call those routes. +- **A database downgrade is lossy.** It restores NOT NULL on `policy_id`, + `household_id`, `simulation_id`, `report_id`, and `country`, so it fails + while legacy-only rows or any simulation or report associations exist. + Removing them first deletes users' saved work. Prefer an application + rollback. ## Settings @@ -219,6 +243,9 @@ The disposable-Postgres suite proves the following: ## 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. - 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 index 5d7b947d9..5747b0079 100644 --- 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 @@ -2,7 +2,7 @@ Revision ID: fbb7488ebe8a Revises: 91af4fc8d8c1 -Create Date: 2026-10-12 23:23:23.599084 +Create Date: 2026-10-13 15:49:22.180908 Generation: uv run alembic -c alembic-v2.ini revision --autogenerate """ @@ -186,6 +186,12 @@ def upgrade() -> None: 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", @@ -242,8 +248,6 @@ def upgrade() -> None: "user_report_associations", "report_id IS NOT NULL OR legacy_report_id IS NOT NULL", ) - op.drop_column("user_report_associations", "label") - op.drop_column("user_report_associations", "country") op.add_column( "user_simulation_associations", sa.Column("legacy_simulation_id", sa.BigInteger(), nullable=True), @@ -274,6 +278,12 @@ def upgrade() -> None: 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", @@ -316,23 +326,11 @@ def upgrade() -> None: "user_simulation_associations", "simulation_id IS NOT NULL OR legacy_simulation_id IS NOT NULL", ) - op.drop_column("user_simulation_associations", "label") - op.drop_column("user_simulation_associations", "country") # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.add_column( - "user_simulation_associations", - sa.Column( - "country", sa.VARCHAR(length=16), autoincrement=False, nullable=False - ), - ) - op.add_column( - "user_simulation_associations", - sa.Column("label", sa.VARCHAR(length=255), autoincrement=False, nullable=True), - ) op.drop_constraint( op.f("ck_user_simulation_associations_simulation_ref"), "user_simulation_associations", @@ -375,6 +373,12 @@ def downgrade() -> None: ["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", @@ -386,16 +390,6 @@ def downgrade() -> None: 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.add_column( - "user_report_associations", - sa.Column( - "country", sa.VARCHAR(length=16), autoincrement=False, nullable=False - ), - ) - op.add_column( - "user_report_associations", - sa.Column("label", sa.VARCHAR(length=255), autoincrement=False, nullable=True), - ) op.drop_constraint( op.f("ck_user_report_associations_report_ref"), "user_report_associations", @@ -445,6 +439,12 @@ def downgrade() -> None: ["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 ) diff --git a/policyengine_api/data/v2/models/associations.py b/policyengine_api/data/v2/models/associations.py index 2f7ea88ec..395c800cb 100644 --- a/policyengine_api/data/v2/models/associations.py +++ b/policyengine_api/data/v2/models/associations.py @@ -4,6 +4,12 @@ nullable country-scoped v1 (legacy) reference, or both; a database check requires at least one. ``client_ref`` carries the browser-local identifier of an imported record so repeated imports are idempotent per user. + +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 @@ -228,6 +234,9 @@ class UserSimulationAssociation(TimestampedModel, table=True): default=None, max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH, ) + # 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") @@ -301,5 +310,8 @@ class UserReportAssociation(TimestampedModel, table=True): default=None, max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH, ) + # 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") diff --git a/tests/integration/test_alembic_v2_lifecycle.py b/tests/integration/test_alembic_v2_lifecycle.py index 4be61a99f..3e15081ef 100644 --- a/tests/integration/test_alembic_v2_lifecycle.py +++ b/tests/integration/test_alembic_v2_lifecycle.py @@ -172,6 +172,22 @@ def _assert_stage_12a_association_shape(engine) -> None: 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"] + + +def _column_nullability(engine) -> dict[str, dict[str, bool]]: + inspector = inspect(engine) + return { + table_name: { + column["name"]: column["nullable"] + for column in inspector.get_columns(table_name, schema="public") + } + for table_name in inspector.get_table_names(schema="public") + if table_name != "alembic_version" + } def test_empty_upgrade_check_base_downgrade_and_reupgrade() -> None: @@ -364,6 +380,45 @@ def test_empty_upgrade_check_base_downgrade_and_reupgrade() -> None: engine.dispose() +def test_stage_12a_revision_keeps_the_previous_revision_loadable() -> None: + """The previous Public API revision still maps every parent-revision column. + + During a deploy or an application rollback that revision reads rows the + Stage 12A schema holds, so the revision may add columns and relax + constraints but must not drop a table or column or make a nullable column + required. + """ + + database_url = _disposable_url() + config = _config() + engine = create_engine(database_url) + + try: + command.downgrade(config, STAGE_12A_PREVIOUS_REVISION) + previous = _column_nullability(engine) + + command.upgrade(config, "head") + current = _column_nullability(engine) + + missing = sorted( + f"{table_name}.{column_name}" + for table_name, columns in previous.items() + for column_name in columns + if column_name not in current.get(table_name, {}) + ) + newly_required = sorted( + f"{table_name}.{column_name}" + for table_name, columns in previous.items() + for column_name, nullable in columns.items() + if nullable and current.get(table_name, {}).get(column_name) is False + ) + assert missing == [] + assert newly_required == [] + 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() diff --git a/tests/unit/v2/test_alembic_v2.py b/tests/unit/v2/test_alembic_v2.py index 8c587d520..366b00b1c 100644 --- a/tests/unit/v2/test_alembic_v2.py +++ b/tests/unit/v2/test_alembic_v2.py @@ -532,6 +532,19 @@ def test_stage_12a_user_identity_and_association_revision_is_generated() -> None 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 def test_alembic_rejects_unknown_missing_and_divergent_history(tmp_path: Path) -> None: diff --git a/tests/unit/v2/test_models.py b/tests/unit/v2/test_models.py index 43cde2cb9..0ff3f3548 100644 --- a/tests/unit/v2/test_models.py +++ b/tests/unit/v2/test_models.py @@ -582,8 +582,10 @@ 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"] - assert {"country", "label"}.isdisjoint(simulations.c.keys()) - assert {"country", "label"}.isdisjoint(reports.c.keys()) + # 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"} From 29c5ea83fbe745c336bf5014990b6a0a589a78a4 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:57:51 +0400 Subject: [PATCH 12/35] Scope account imports to one country through shared association helpers An import whose policy client_ref already existed fell back to a lookup that ignored country, and the report import let that in-request map override its country-filtered lookup, so a US report could link to the same user's UK saved policy. Imports now resolve existing client_refs only in the import country, skip a client_ref the user holds in another country with the new client_ref_conflict reason, and resolve report policy links with one country-filtered lookup that also sees policies inserted earlier in the same transaction. The four association services now share the insert-or-reuse import, the client_ref reads, the single-create clash check, and the v1 reference gate, replacing four copies of each and their per-resource import integrity errors. The dead, owner-unscoped read_complete_user_policy, read_user_policy_row, read_user_household_row, and household read_user helpers are removed. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-12a-user-login.md | 9 +- .../fastapi_routes/v2/me/response_models.py | 2 +- .../fastapi_routes/v2/me/routes.py | 6 +- .../database_connectors/__init__.py | 1 + .../database_connectors/creates.py | 30 ++++++ .../associations/database_connectors/reads.py | 39 ++++++++ .../services/v2/associations/services.py | 97 +++++++++++++++++++ .../services/v2/associations/types.py | 26 ++++- .../services/v2/associations/validators.py | 16 +++ .../services/v2/user_accounts/services.py | 37 ++++--- .../services/v2/user_accounts/types.py | 2 + .../services/v2/user_accounts/validators.py | 9 ++ .../database_connectors/creates.py | 20 +--- .../database_connectors/reads.py | 33 ------- .../services/v2/user_households/services.py | 79 +++++++-------- .../database_connectors/creates.py | 13 --- .../database_connectors/reads.py | 25 ----- .../services/v2/user_policies/services.py | 90 +++++++---------- .../database_connectors/creates.py | 20 +--- .../user_reports/database_connectors/reads.py | 34 ------- .../services/v2/user_reports/services.py | 86 ++++++++-------- .../services/v2/user_reports/validators.py | 12 --- .../database_connectors/creates.py | 20 +--- .../database_connectors/reads.py | 14 --- .../services/v2/user_simulations/services.py | 64 ++++++------ .../v2/user_simulations/validators.py | 12 --- .../test_v2_user_identity_persistence.py | 42 ++++++++ tests/unit/v2/test_user_account_service.py | 38 ++++++++ 28 files changed, 486 insertions(+), 390 deletions(-) create mode 100644 policyengine_api/services/v2/associations/database_connectors/__init__.py create mode 100644 policyengine_api/services/v2/associations/database_connectors/creates.py create mode 100644 policyengine_api/services/v2/associations/database_connectors/reads.py create mode 100644 policyengine_api/services/v2/associations/services.py diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index 2da430428..a81c384ec 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -46,8 +46,12 @@ Signed-out behavior is unchanged. mirror projection now also records `legacy_policy_id`. - **Imports.** `POST /v2/me/imports` takes up to 500 browser-local items in a 1 MiB body. It is idempotent on `UNIQUE(user_id, client_ref)`: an existing - `client_ref` returns its stored ID unchanged. Items with an unknown v1 - reference or an invalid shape are skipped and reported. + `client_ref` in the import country returns its stored ID unchanged. Items + with an unknown v1 reference or an invalid shape are skipped and reported; + so is a `client_ref` the user already holds in another country + (`client_ref_conflict`), which is never linked across countries. A report's + `user_policy_client_ref` resolves only against the user's policies in the + import country. - **Account deletion.** `DELETE /v2/me` deletes the user's `legacy_user_mappings` rows (RESTRICT FK), then the user (identities and associations cascade), then the Auth0 user through the Management API when @@ -236,6 +240,7 @@ The disposable-Postgres suite proves the following: - concurrent first sign-ins create one user; - concurrent imports are idempotent; +- imports never reuse or link a `client_ref` from another country; - named `ON CONFLICT` targets work; - `MATCH SIMPLE` composite keys accept NULL v2 references; - account deletion cascades after removing legacy mappings; diff --git a/policyengine_api/fastapi_routes/v2/me/response_models.py b/policyengine_api/fastapi_routes/v2/me/response_models.py index 25e86e9e6..b76c5e081 100644 --- a/policyengine_api/fastapi_routes/v2/me/response_models.py +++ b/policyengine_api/fastapi_routes/v2/me/response_models.py @@ -73,7 +73,7 @@ def from_export(cls, export: AccountExport) -> "MeExportResult": class MeImportSkip(StrictAccountAPIModel): client_ref: str | None - reason: Literal["invalid", "unknown_legacy_reference"] + reason: Literal["invalid", "unknown_legacy_reference", "client_ref_conflict"] class MeImportResult(StrictAccountAPIModel): diff --git a/policyengine_api/fastapi_routes/v2/me/routes.py b/policyengine_api/fastapi_routes/v2/me/routes.py index e6c5ddfa8..6024303a1 100644 --- a/policyengine_api/fastapi_routes/v2/me/routes.py +++ b/policyengine_api/fastapi_routes/v2/me/routes.py @@ -188,8 +188,10 @@ def export() -> MeExportResponse: summary="Import browser-local saved records into the signed-in account", description=( "At most 500 items and 1 MiB. Idempotent on (user, client_ref): an " - "existing client_ref returns its stored ID unchanged. Items with an " - "unknown v1 reference or an invalid shape are skipped and reported." + "existing client_ref in the same country returns its stored ID " + "unchanged. Items with an unknown v1 reference, an invalid shape, or " + "a client_ref the user already holds in another country are skipped " + "and reported." ), ) def import_records( diff --git a/policyengine_api/services/v2/associations/database_connectors/__init__.py b/policyengine_api/services/v2/associations/database_connectors/__init__.py new file mode 100644 index 000000000..b0018a3dd --- /dev/null +++ b/policyengine_api/services/v2/associations/database_connectors/__init__.py @@ -0,0 +1 @@ +"""SQL shared by every user-owned association table.""" diff --git a/policyengine_api/services/v2/associations/database_connectors/creates.py b/policyengine_api/services/v2/associations/database_connectors/creates.py new file mode 100644 index 000000000..c0a8382da --- /dev/null +++ b/policyengine_api/services/v2/associations/database_connectors/creates.py @@ -0,0 +1,30 @@ +"""Database inserts shared by every user-owned association table.""" + +from __future__ import annotations + +from collections.abc import Mapping +from uuid import UUID + +from sqlalchemy.dialects.postgresql import insert +from sqlmodel import Session, col + +from policyengine_api.services.v2.associations.types import AssociationTable + + +def import_association( + session: Session, + table: type[AssociationTable], + values: Mapping[str, object], +) -> UUID | None: + """Insert one imported association unless its owner already uses its client_ref. + + Returns the new ID, or ``None`` when ``UNIQUE(user_id, client_ref)`` already + holds a row. + """ + + return session.execute( + insert(table) + .values(**values) + .on_conflict_do_nothing(constraint=f"uq_{table.__tablename__}_user_client_ref") + .returning(col(table.id)) + ).scalar_one_or_none() diff --git a/policyengine_api/services/v2/associations/database_connectors/reads.py b/policyengine_api/services/v2/associations/database_connectors/reads.py new file mode 100644 index 000000000..12e07c72a --- /dev/null +++ b/policyengine_api/services/v2/associations/database_connectors/reads.py @@ -0,0 +1,39 @@ +"""Database reads shared by every user-owned association table.""" + +from __future__ import annotations + +from collections.abc import Collection +from uuid import UUID + +from sqlmodel import Session, col, select + +from policyengine_api.services.v2.associations.types import AssociationTable + + +def read_association_ids_by_client_ref( + session: Session, + table: type[AssociationTable], + *, + user_id: UUID, + client_refs: Collection[str], + country_id: str | None, +) -> dict[str, UUID]: + """Return the owner's association IDs keyed by client_ref. + + ``country_id`` restricts the match to one country; ``None`` matches every + country, because ``client_ref`` is unique per user across countries. + """ + + if not client_refs: + return {} + statement = select(table.client_ref, table.id).where( + table.user_id == user_id, + col(table.client_ref).in_(sorted(set(client_refs))), + ) + if country_id is not None: + statement = statement.where(table.country_id == country_id) + return { + client_ref: association_id + for client_ref, association_id in session.exec(statement).all() + if client_ref is not None + } diff --git a/policyengine_api/services/v2/associations/services.py b/policyengine_api/services/v2/associations/services.py new file mode 100644 index 000000000..dc5f5eb14 --- /dev/null +++ b/policyengine_api/services/v2/associations/services.py @@ -0,0 +1,97 @@ +"""Operations shared by the four user-owned association services.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from uuid import UUID + +from sqlmodel import Session + +from policyengine_api.services.v2.associations.database_connectors.creates import ( + import_association, +) +from policyengine_api.services.v2.associations.database_connectors.reads import ( + read_association_ids_by_client_ref, +) +from policyengine_api.services.v2.associations.types import ( + AssociationImport, + AssociationTable, + LegacyReferenceChecker, +) +from policyengine_api.services.v2.associations.validators import ( + require_legacy_reference_checker, + require_unused_client_ref, +) +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,)}, + ) + + +def require_unused_client_ref_in_session( + session: Session, + table: type[AssociationTable], + *, + user_id: UUID, + client_ref: str | None, +) -> None: + """Reject a single create whose client_ref the owner already uses anywhere.""" + + if client_ref is None: + return + require_unused_client_ref( + read_association_ids_by_client_ref( + session, + table, + user_id=user_id, + client_refs=(client_ref,), + country_id=None, + ).get(client_ref), + client_ref=client_ref, + ) + + +def import_associations_in_session( + session: Session, + table: type[AssociationTable], + *, + user_id: UUID, + country_id: str, + rows: Sequence[tuple[str, Mapping[str, object]]], +) -> AssociationImport: + """Insert imported rows idempotently on ``(user_id, client_ref)``. + + An existing client_ref in the import country returns its stored ID + unchanged. One the owner already uses in another country is reported as a + conflict instead of being linked across countries. + """ + + ids: dict[str, UUID] = {} + conflicts: list[str] = [] + for client_ref, values in rows: + association_id = import_association( + session, table, values + ) or read_association_ids_by_client_ref( + session, + table, + user_id=user_id, + client_refs=(client_ref,), + country_id=country_id, + ).get(client_ref) + if association_id is None: + conflicts.append(client_ref) + else: + ids[client_ref] = association_id + return AssociationImport(ids=ids, conflicting_client_refs=tuple(conflicts)) diff --git a/policyengine_api/services/v2/associations/types.py b/policyengine_api/services/v2/associations/types.py index 096717f9c..fff5f020c 100644 --- a/policyengine_api/services/v2/associations/types.py +++ b/policyengine_api/services/v2/associations/types.py @@ -3,16 +3,28 @@ from __future__ import annotations from collections.abc import Collection, Mapping -from typing import Annotated, Protocol +from dataclasses import dataclass +from typing import Annotated, Protocol, TypeAlias +from uuid import UUID from pydantic import BaseModel, ConfigDict, StringConstraints from policyengine_api.data.v2.models.associations import ( ASSOCIATION_CLIENT_REF_MAX_LENGTH, + UserHouseholdAssociation, + UserPolicy, + UserReportAssociation, + UserSimulationAssociation, ) from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind +AssociationTable: TypeAlias = ( + UserPolicy + | UserHouseholdAssociation + | UserSimulationAssociation + | UserReportAssociation +) AssociationName = Annotated[str, StringConstraints(max_length=255)] AssociationClientRef = Annotated[ str, @@ -42,3 +54,15 @@ def require_existing( country_id: str, requested: Mapping[LegacyRecordKind, Collection[int]], ) -> None: ... + + +@dataclass(frozen=True) +class AssociationImport: + """Imported association IDs by client_ref, plus client_refs held elsewhere. + + ``conflicting_client_refs`` are client_refs the owner already uses in + another country; they are not linked across countries. + """ + + ids: dict[str, UUID] + conflicting_client_refs: tuple[str, ...] diff --git a/policyengine_api/services/v2/associations/validators.py b/policyengine_api/services/v2/associations/validators.py index 423d0163f..afed0ae8b 100644 --- a/policyengine_api/services/v2/associations/validators.py +++ b/policyengine_api/services/v2/associations/validators.py @@ -4,6 +4,8 @@ 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.""" @@ -29,6 +31,20 @@ 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_unused_client_ref( existing_id: UUID | None, *, diff --git a/policyengine_api/services/v2/user_accounts/services.py b/policyengine_api/services/v2/user_accounts/services.py index ac59cd4b1..62e5a853e 100644 --- a/policyengine_api/services/v2/user_accounts/services.py +++ b/policyengine_api/services/v2/user_accounts/services.py @@ -2,8 +2,9 @@ Reads, updates, exports, and imports resolve (and on first use provision) the caller's v2 user from the verified session principal. Imports are idempotent -on ``(user_id, client_ref)``: unknown v1 references and invalid items are -skipped and reported rather than failing the request. Deletion removes the +on ``(user_id, client_ref)``: unknown v1 references, invalid items, and +client_refs the user already holds in another country are skipped and reported +rather than failing the request. Deletion removes the user's legacy mappings, then the user (identities and associations cascade), and finally the Auth0 user when the Management API is configured. Only v2 user UUIDs are logged. @@ -15,6 +16,9 @@ import logging 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.identity.services import ( find_principal_user_in_session, resolve_principal_user_in_session, @@ -49,6 +53,7 @@ ) from policyengine_api.services.v2.user_accounts.validators import ( AccountIdentityDeletionError, + client_ref_conflict_skips, partition_import_items, partition_known_legacy_items, require_account, @@ -163,9 +168,9 @@ def import_records( import_input.reports, UserReportImportItem ) - if self._legacy_references is None: - raise RuntimeError("v1 reference checks are not configured") - existing = self._legacy_references.existing_ids( + existing = require_legacy_reference_checker( + self._legacy_references + ).existing_ids( country_id=country_id, requested={ LegacyRecordKind.POLICY: [item.legacy_policy_id for item in policies], @@ -207,43 +212,53 @@ def import_records( owner = resolve_principal_user_in_session( session, principal, country_id=country_id ) - policy_ids = import_user_policies_in_session( + # Policies go first so reports can link to policies imported by the + # same request. + policy_import = import_user_policies_in_session( session, user_id=owner.user_id, country_id=country_id, items=known_policies, ) - household_ids = import_user_households_in_session( + household_import = import_user_households_in_session( session, user_id=owner.user_id, country_id=country_id, items=known_households, ) - simulation_ids = import_user_simulations_in_session( + simulation_import = import_user_simulations_in_session( session, user_id=owner.user_id, country_id=country_id, items=known_simulations, ) - report_ids = import_user_reports_in_session( + report_import = import_user_reports_in_session( session, user_id=owner.user_id, country_id=country_id, items=known_reports, - imported_policy_ids=policy_ids, ) return AccountImportResult( - id_map={**policy_ids, **household_ids, **simulation_ids, **report_ids}, + id_map={ + **policy_import.ids, + **household_import.ids, + **simulation_import.ids, + **report_import.ids, + }, skipped=( *policy_skips, *unknown_policies, + *client_ref_conflict_skips(policy_import.conflicting_client_refs), *household_skips, *unknown_households, + *client_ref_conflict_skips(household_import.conflicting_client_refs), *simulation_skips, *unknown_simulations, + *client_ref_conflict_skips(simulation_import.conflicting_client_refs), *report_skips, *unknown_reports, + *client_ref_conflict_skips(report_import.conflicting_client_refs), ), ) diff --git a/policyengine_api/services/v2/user_accounts/types.py b/policyengine_api/services/v2/user_accounts/types.py index e5aaaca71..0d3de7119 100644 --- a/policyengine_api/services/v2/user_accounts/types.py +++ b/policyengine_api/services/v2/user_accounts/types.py @@ -78,6 +78,8 @@ def bound_item_count(self) -> "AccountImportInput": class ImportSkipReason(StrEnum): INVALID = "invalid" UNKNOWN_LEGACY_REFERENCE = "unknown_legacy_reference" + # The owner already uses the client_ref in another country. + CLIENT_REF_CONFLICT = "client_ref_conflict" @dataclass(frozen=True) diff --git a/policyengine_api/services/v2/user_accounts/validators.py b/policyengine_api/services/v2/user_accounts/validators.py index e4bb71e6e..0bd5a7e42 100644 --- a/policyengine_api/services/v2/user_accounts/validators.py +++ b/policyengine_api/services/v2/user_accounts/validators.py @@ -60,6 +60,15 @@ def partition_import_items( return valid, skipped +def client_ref_conflict_skips(client_refs: Sequence[str]) -> list[ImportSkip]: + """Report client_refs the owner already uses in another country.""" + + return [ + ImportSkip(client_ref=client_ref, reason=ImportSkipReason.CLIENT_REF_CONFLICT) + for client_ref in client_refs + ] + + def partition_known_legacy_items( items: Sequence[LegacyItemT], *, 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 7284ce54f..20cd7fa8c 100644 --- a/policyengine_api/services/v2/user_households/database_connectors/creates.py +++ b/policyengine_api/services/v2/user_households/database_connectors/creates.py @@ -2,10 +2,8 @@ from __future__ import annotations -from uuid import UUID -from sqlalchemy.dialects.postgresql import insert -from sqlmodel import Session, col +from sqlmodel import Session from policyengine_api.data.v2.models import UserHouseholdAssociation from policyengine_api.services.v2.user_households.types import ( @@ -22,19 +20,3 @@ def create_user_household( session.flush() session.refresh(association) return association - - -def import_user_household( - session: Session, - association_input: UserHouseholdCreationInput, -) -> UUID | None: - """Insert one imported association unless its client_ref already exists.""" - - return session.execute( - insert(UserHouseholdAssociation) - .values(**association_input.model_dump()) - .on_conflict_do_nothing( - constraint="uq_user_household_associations_user_client_ref" - ) - .returning(col(UserHouseholdAssociation.id)) - ).scalar_one_or_none() 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 bd66d3fb6..9945e74ac 100644 --- a/policyengine_api/services/v2/user_households/database_connectors/reads.py +++ b/policyengine_api/services/v2/user_households/database_connectors/reads.py @@ -10,15 +10,10 @@ from policyengine_api.data.v2.models import ( Household, LegacyHouseholdMapping, - User, UserHouseholdAssociation, ) -def read_user(session: Session, user_id: UUID) -> User | None: - return session.get(User, user_id) - - def read_household_for_association( session: Session, household_id: UUID, @@ -28,20 +23,6 @@ def read_household_for_association( ).one_or_none() -def read_user_household_row( - session: Session, - *, - country_id: str, - association_id: UUID, -) -> UserHouseholdAssociation | None: - return session.exec( - select(UserHouseholdAssociation).where( - UserHouseholdAssociation.id == association_id, - UserHouseholdAssociation.country_id == country_id, - ) - ).one_or_none() - - def read_owned_user_household_row( session: Session, *, @@ -107,20 +88,6 @@ def read_all_user_household_rows( ) -def read_user_household_id_by_client_ref( - session: Session, - *, - user_id: UUID, - client_ref: str, -) -> UUID | None: - return session.exec( - select(UserHouseholdAssociation.id).where( - UserHouseholdAssociation.user_id == user_id, - UserHouseholdAssociation.client_ref == client_ref, - ) - ).one_or_none() - - def read_copied_household_ids( session: Session, *, diff --git a/policyengine_api/services/v2/user_households/services.py b/policyengine_api/services/v2/user_households/services.py index 370d3f333..d13633506 100644 --- a/policyengine_api/services/v2/user_households/services.py +++ b/policyengine_api/services/v2/user_households/services.py @@ -12,9 +12,14 @@ from sqlmodel import Session from policyengine_api.data.v2.models import UserHouseholdAssociation -from policyengine_api.services.v2.associations.types import LegacyReferenceChecker -from policyengine_api.services.v2.associations.validators import ( - require_unused_client_ref, +from policyengine_api.services.v2.associations.services import ( + import_associations_in_session, + require_legacy_reference, + require_unused_client_ref_in_session, +) +from policyengine_api.services.v2.associations.types import ( + AssociationImport, + LegacyReferenceChecker, ) from policyengine_api.services.v2.identity.services import ( resolve_principal_user_in_session, @@ -23,7 +28,6 @@ from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind from policyengine_api.services.v2.user_households.database_connectors.creates import ( create_user_household, - import_user_household, ) from policyengine_api.services.v2.user_households.database_connectors.deletes import ( delete_user_household, @@ -33,7 +37,6 @@ read_copied_household_ids, read_household_for_association, read_owned_user_household_row, - read_user_household_id_by_client_ref, read_user_household_rows, ) from policyengine_api.services.v2.user_households.database_connectors.updates import ( @@ -62,10 +65,6 @@ ) -class UserHouseholdImportIntegrityError(RuntimeError): - """Raised when an imported client_ref resolves to no stored association.""" - - def read_owned_user_household( session: Session, *, @@ -131,7 +130,7 @@ def import_user_households_in_session( user_id: UUID, country_id: str, items: Sequence[UserHouseholdImportItem], -) -> dict[str, UUID]: +) -> AssociationImport: """Insert imported associations idempotently on ``(user_id, client_ref)``.""" copied = read_copied_household_ids( @@ -139,27 +138,24 @@ def import_user_households_in_session( country_id=country_id, legacy_household_ids={item.legacy_household_id for item in items}, ) - imported: dict[str, UUID] = {} - for item in items: - association_id = import_user_household( - session, - imported_user_household_input( - item, - country_id=country_id, - user_id=user_id, - household_id=copied.get(item.legacy_household_id), - ), - ) or read_user_household_id_by_client_ref( - session, - user_id=user_id, - client_ref=item.client_ref, - ) - if association_id is None: - raise UserHouseholdImportIntegrityError( - "imported user-household client_ref did not resolve to a stored row" + return import_associations_in_session( + session, + UserHouseholdAssociation, + user_id=user_id, + country_id=country_id, + rows=[ + ( + item.client_ref, + imported_user_household_input( + item, + country_id=country_id, + user_id=user_id, + household_id=copied.get(item.legacy_household_id), + ).model_dump(), ) - imported[item.client_ref] = association_id - return imported + for item in items + ], + ) class V2UserHouseholdService: @@ -176,11 +172,11 @@ def __init__( def _require_legacy_household( self, *, country_id: str, legacy_household_id: int ) -> None: - if self._legacy_references is None: - raise RuntimeError("v1 reference checks are not configured") - self._legacy_references.require_existing( + require_legacy_reference( + self._legacy_references, + LegacyRecordKind.HOUSEHOLD, country_id=country_id, - requested={LegacyRecordKind.HOUSEHOLD: (legacy_household_id,)}, + legacy_id=legacy_household_id, ) def create_user_household( @@ -200,15 +196,12 @@ def create_user_household( principal, country_id=association_input.country_id, ) - if association_input.client_ref is not None: - require_unused_client_ref( - read_user_household_id_by_client_ref( - session, - user_id=owner.user_id, - client_ref=association_input.client_ref, - ), - client_ref=association_input.client_ref, - ) + require_unused_client_ref_in_session( + session, + UserHouseholdAssociation, + user_id=owner.user_id, + client_ref=association_input.client_ref, + ) household_id = resolve_user_household_reference( session, country_id=association_input.country_id, diff --git a/policyengine_api/services/v2/user_policies/database_connectors/creates.py b/policyengine_api/services/v2/user_policies/database_connectors/creates.py index bc2bac916..e8d48701f 100644 --- a/policyengine_api/services/v2/user_policies/database_connectors/creates.py +++ b/policyengine_api/services/v2/user_policies/database_connectors/creates.py @@ -26,19 +26,6 @@ def create_user_policy( return association -def import_user_policy( - session: Session, association_input: UserPolicyCreationInput -) -> UUID | None: - """Insert one imported association unless its client_ref already exists.""" - - return session.execute( - insert(UserPolicy) - .values(**association_input.model_dump()) - .on_conflict_do_nothing(constraint="uq_user_policies_user_client_ref") - .returning(col(UserPolicy.id)) - ).scalar_one_or_none() - - def create_transition_user(session: Session, *, primary_country: str) -> User: user = User(primary_country=primary_country) session.add(user) 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 bd62fb835..b22b51a94 100644 --- a/policyengine_api/services/v2/user_policies/database_connectors/reads.py +++ b/policyengine_api/services/v2/user_policies/database_connectors/reads.py @@ -63,17 +63,6 @@ def read_mapped_user_policy( ).one_or_none() -def read_user_policy_row( - session: Session, *, country_id: str, association_id: UUID -) -> UserPolicy | None: - return session.exec( - select(UserPolicy).where( - UserPolicy.id == association_id, - UserPolicy.country_id == country_id, - ) - ).one_or_none() - - def read_owned_user_policy_row( session: Session, *, @@ -127,20 +116,6 @@ def read_all_user_policy_rows(session: Session, *, user_id: UUID) -> list[UserPo ) -def read_user_policy_id_by_client_ref( - session: Session, - *, - user_id: UUID, - client_ref: str, -) -> UUID | None: - return session.exec( - select(UserPolicy.id).where( - UserPolicy.user_id == user_id, - UserPolicy.client_ref == client_ref, - ) - ).one_or_none() - - def read_user_policy_mirror_exists(session: Session, *, association_id: UUID) -> bool: return ( session.exec( diff --git a/policyengine_api/services/v2/user_policies/services.py b/policyengine_api/services/v2/user_policies/services.py index 8b6ef9bc8..9290a6cad 100644 --- a/policyengine_api/services/v2/user_policies/services.py +++ b/policyengine_api/services/v2/user_policies/services.py @@ -8,10 +8,17 @@ from sqlmodel import Session from policyengine_api.data.v2.models import LegacyUserPolicyMapping, UserPolicy -from policyengine_api.services.v2.associations.types import LegacyReferenceChecker +from policyengine_api.services.v2.associations.services import ( + import_associations_in_session, + require_legacy_reference, + require_unused_client_ref_in_session, +) +from policyengine_api.services.v2.associations.types import ( + AssociationImport, + LegacyReferenceChecker, +) from policyengine_api.services.v2.associations.validators import ( require_not_mirror_owned, - require_unused_client_ref, ) from policyengine_api.services.v2.identity.services import ( resolve_principal_user_in_session, @@ -27,7 +34,6 @@ create_legacy_user_policy_mapping, create_transition_user, create_user_policy, - import_user_policy, ) from policyengine_api.services.v2.user_policies.database_connectors.deletes import ( delete_transition_user, @@ -42,9 +48,7 @@ read_owned_user_policy_row, read_policy_for_association, read_user, - read_user_policy_id_by_client_ref, read_user_policy_mirror_exists, - read_user_policy_row, read_user_policy_rows, ) from policyengine_api.services.v2.user_policies.database_connectors.updates import ( @@ -88,20 +92,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, *, @@ -189,7 +179,7 @@ def import_user_policies_in_session( user_id: UUID, country_id: str, items: Sequence[UserPolicyImportItem], -) -> dict[str, UUID]: +) -> AssociationImport: """Insert imported associations idempotently on ``(user_id, client_ref)``.""" mirrored = read_mirrored_policy_ids( @@ -197,27 +187,24 @@ def import_user_policies_in_session( country_id=country_id, legacy_policy_ids={item.legacy_policy_id for item in items}, ) - imported: dict[str, UUID] = {} - for item in items: - association_id = import_user_policy( - session, - imported_user_policy_input( - item, - country_id=country_id, - user_id=user_id, - policy_id=mirrored.get(item.legacy_policy_id), - ), - ) or read_user_policy_id_by_client_ref( - session, - user_id=user_id, - client_ref=item.client_ref, - ) - if association_id is None: - raise LegacyUserPolicyIntegrityError( - "imported user-policy client_ref did not resolve to a stored row" + return import_associations_in_session( + session, + UserPolicy, + user_id=user_id, + country_id=country_id, + rows=[ + ( + item.client_ref, + imported_user_policy_input( + item, + country_id=country_id, + user_id=user_id, + policy_id=mirrored.get(item.legacy_policy_id), + ).model_dump(), ) - imported[item.client_ref] = association_id - return imported + for item in items + ], + ) def resolve_legacy_user_id( @@ -436,11 +423,11 @@ def __init__( self._legacy_references = legacy_references def _require_legacy_policy(self, *, country_id: str, legacy_policy_id: int) -> None: - if self._legacy_references is None: - raise RuntimeError("v1 reference checks are not configured") - self._legacy_references.require_existing( + require_legacy_reference( + self._legacy_references, + LegacyRecordKind.POLICY, country_id=country_id, - requested={LegacyRecordKind.POLICY: (legacy_policy_id,)}, + legacy_id=legacy_policy_id, ) def create_user_policy( @@ -460,15 +447,12 @@ def create_user_policy( principal, country_id=association_input.country_id, ) - if association_input.client_ref is not None: - require_unused_client_ref( - read_user_policy_id_by_client_ref( - session, - user_id=owner.user_id, - client_ref=association_input.client_ref, - ), - client_ref=association_input.client_ref, - ) + require_unused_client_ref_in_session( + session, + UserPolicy, + user_id=owner.user_id, + client_ref=association_input.client_ref, + ) policy_id = resolve_user_policy_reference( session, country_id=association_input.country_id, diff --git a/policyengine_api/services/v2/user_reports/database_connectors/creates.py b/policyengine_api/services/v2/user_reports/database_connectors/creates.py index c780a5501..d303307e6 100644 --- a/policyengine_api/services/v2/user_reports/database_connectors/creates.py +++ b/policyengine_api/services/v2/user_reports/database_connectors/creates.py @@ -2,10 +2,8 @@ from __future__ import annotations -from uuid import UUID -from sqlalchemy.dialects.postgresql import insert -from sqlmodel import Session, col +from sqlmodel import Session from policyengine_api.data.v2.models import UserReportAssociation from policyengine_api.services.v2.user_reports.types import UserReportCreationInput @@ -20,19 +18,3 @@ def create_user_report( session.flush() session.refresh(association) return association - - -def import_user_report( - session: Session, - association_input: UserReportCreationInput, -) -> UUID | None: - """Insert one imported association unless its client_ref already exists.""" - - return session.execute( - insert(UserReportAssociation) - .values(**association_input.model_dump()) - .on_conflict_do_nothing( - constraint="uq_user_report_associations_user_client_ref" - ) - .returning(col(UserReportAssociation.id)) - ).scalar_one_or_none() diff --git a/policyengine_api/services/v2/user_reports/database_connectors/reads.py b/policyengine_api/services/v2/user_reports/database_connectors/reads.py index 7587b4db9..5e82ce935 100644 --- a/policyengine_api/services/v2/user_reports/database_connectors/reads.py +++ b/policyengine_api/services/v2/user_reports/database_connectors/reads.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Collection from uuid import UUID from sqlmodel import Session, col, select @@ -70,20 +69,6 @@ def read_all_user_report_rows( ) -def read_user_report_id_by_client_ref( - session: Session, - *, - user_id: UUID, - client_ref: str, -) -> UUID | None: - return session.exec( - select(UserReportAssociation.id).where( - UserReportAssociation.user_id == user_id, - UserReportAssociation.client_ref == client_ref, - ) - ).one_or_none() - - def read_owned_user_policy_exists( session: Session, *, @@ -101,22 +86,3 @@ def read_owned_user_policy_exists( ).one_or_none() is not None ) - - -def read_owned_user_policy_ids_by_client_ref( - session: Session, - *, - user_id: UUID, - country_id: str, - client_refs: Collection[str], -) -> dict[str, UUID]: - if not client_refs: - return {} - rows = session.exec( - select(UserPolicy.client_ref, UserPolicy.id).where( - UserPolicy.user_id == user_id, - UserPolicy.country_id == country_id, - col(UserPolicy.client_ref).in_(sorted(set(client_refs))), - ) - ).all() - return {client_ref: policy_id for client_ref, policy_id in rows if client_ref} diff --git a/policyengine_api/services/v2/user_reports/services.py b/policyengine_api/services/v2/user_reports/services.py index 9136ee476..bf57226c6 100644 --- a/policyengine_api/services/v2/user_reports/services.py +++ b/policyengine_api/services/v2/user_reports/services.py @@ -9,15 +9,23 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from uuid import UUID from sqlmodel import Session -from policyengine_api.data.v2.models import UserReportAssociation -from policyengine_api.services.v2.associations.types import LegacyReferenceChecker -from policyengine_api.services.v2.associations.validators import ( - require_unused_client_ref, +from policyengine_api.data.v2.models import UserPolicy, UserReportAssociation +from policyengine_api.services.v2.associations.database_connectors.reads import ( + read_association_ids_by_client_ref, +) +from policyengine_api.services.v2.associations.services import ( + import_associations_in_session, + require_legacy_reference, + require_unused_client_ref_in_session, +) +from policyengine_api.services.v2.associations.types import ( + AssociationImport, + LegacyReferenceChecker, ) from policyengine_api.services.v2.identity.services import ( resolve_principal_user_in_session, @@ -26,7 +34,6 @@ from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind from policyengine_api.services.v2.user_reports.database_connectors.creates import ( create_user_report, - import_user_report, ) from policyengine_api.services.v2.user_reports.database_connectors.deletes import ( delete_user_report, @@ -34,9 +41,7 @@ from policyengine_api.services.v2.user_reports.database_connectors.reads import ( read_all_user_report_rows, read_owned_user_policy_exists, - read_owned_user_policy_ids_by_client_ref, read_owned_user_report_row, - read_user_report_id_by_client_ref, read_user_report_rows, ) from policyengine_api.services.v2.user_reports.database_connectors.updates import ( @@ -60,7 +65,6 @@ UserReportUpdateInput, ) from policyengine_api.services.v2.user_reports.validators import ( - require_imported_user_report, require_owned_user_policy, require_user_report, ) @@ -121,32 +125,33 @@ def import_user_reports_in_session( user_id: UUID, country_id: str, items: Sequence[UserReportImportItem], - imported_policy_ids: Mapping[str, UUID], -) -> dict[str, UUID]: +) -> AssociationImport: """Insert imported reports idempotently on ``(user_id, client_ref)``. - ``user_policy_client_ref`` resolves against policies imported by the same - request first and then against the owner's stored policies in the same - country; an unresolved reference leaves ``user_policy_id`` empty. + ``user_policy_client_ref`` resolves against the owner's saved policies in + the import country only, including policies inserted earlier in the same + transaction. An unresolved reference leaves ``user_policy_id`` empty. """ - stored_policy_ids = read_owned_user_policy_ids_by_client_ref( + policy_ids = read_association_ids_by_client_ref( session, + UserPolicy, user_id=user_id, - country_id=country_id, client_refs={ item.user_policy_client_ref for item in items if item.user_policy_client_ref is not None - and item.user_policy_client_ref not in imported_policy_ids }, + country_id=country_id, ) - policy_ids = {**stored_policy_ids, **imported_policy_ids} - imported: dict[str, UUID] = {} - for item in items: - imported[item.client_ref] = require_imported_user_report( - import_user_report( - session, + return import_associations_in_session( + session, + UserReportAssociation, + user_id=user_id, + country_id=country_id, + rows=[ + ( + item.client_ref, imported_user_report_input( item, country_id=country_id, @@ -156,15 +161,11 @@ def import_user_reports_in_session( if item.user_policy_client_ref is not None else None ), - ), + ).model_dump(), ) - or read_user_report_id_by_client_ref( - session, - user_id=user_id, - client_ref=item.client_ref, - ) - ) - return imported + for item in items + ], + ) class V2UserReportService: @@ -179,11 +180,11 @@ def __init__( self._legacy_references = legacy_references def _require_legacy_report(self, *, country_id: str, legacy_report_id: int) -> None: - if self._legacy_references is None: - raise RuntimeError("v1 reference checks are not configured") - self._legacy_references.require_existing( + require_legacy_reference( + self._legacy_references, + LegacyRecordKind.REPORT, country_id=country_id, - requested={LegacyRecordKind.REPORT: (legacy_report_id,)}, + legacy_id=legacy_report_id, ) def create_user_report( @@ -202,15 +203,12 @@ def create_user_report( principal, country_id=association_input.country_id, ) - if association_input.client_ref is not None: - require_unused_client_ref( - read_user_report_id_by_client_ref( - session, - user_id=owner.user_id, - client_ref=association_input.client_ref, - ), - client_ref=association_input.client_ref, - ) + require_unused_client_ref_in_session( + session, + UserReportAssociation, + user_id=owner.user_id, + client_ref=association_input.client_ref, + ) validate_linked_user_policy( session, user_id=owner.user_id, diff --git a/policyengine_api/services/v2/user_reports/validators.py b/policyengine_api/services/v2/user_reports/validators.py index f4d07859a..0f540e5cd 100644 --- a/policyengine_api/services/v2/user_reports/validators.py +++ b/policyengine_api/services/v2/user_reports/validators.py @@ -15,10 +15,6 @@ class UserReportNotFoundError(AssociationNotFoundError): """Raised when an association is absent for the caller and country.""" -class UserReportImportIntegrityError(RuntimeError): - """Raised when an imported client_ref resolves to no stored association.""" - - def require_user_report( association: UserReportAssociation | None, *, @@ -37,11 +33,3 @@ def require_owned_user_policy(owned: bool, *, user_policy_id: UUID) -> None: f"user-policy association {user_policy_id} was not found for this " "user and country" ) - - -def require_imported_user_report(association_id: UUID | None) -> UUID: - if association_id is None: - raise UserReportImportIntegrityError( - "imported user-report client_ref did not resolve to a stored row" - ) - return association_id diff --git a/policyengine_api/services/v2/user_simulations/database_connectors/creates.py b/policyengine_api/services/v2/user_simulations/database_connectors/creates.py index 806975d99..991ec151e 100644 --- a/policyengine_api/services/v2/user_simulations/database_connectors/creates.py +++ b/policyengine_api/services/v2/user_simulations/database_connectors/creates.py @@ -2,10 +2,8 @@ from __future__ import annotations -from uuid import UUID -from sqlalchemy.dialects.postgresql import insert -from sqlmodel import Session, col +from sqlmodel import Session from policyengine_api.data.v2.models import UserSimulationAssociation from policyengine_api.services.v2.user_simulations.types import ( @@ -22,19 +20,3 @@ def create_user_simulation( session.flush() session.refresh(association) return association - - -def import_user_simulation( - session: Session, - association_input: UserSimulationCreationInput, -) -> UUID | None: - """Insert one imported association unless its client_ref already exists.""" - - return session.execute( - insert(UserSimulationAssociation) - .values(**association_input.model_dump()) - .on_conflict_do_nothing( - constraint="uq_user_simulation_associations_user_client_ref" - ) - .returning(col(UserSimulationAssociation.id)) - ).scalar_one_or_none() diff --git a/policyengine_api/services/v2/user_simulations/database_connectors/reads.py b/policyengine_api/services/v2/user_simulations/database_connectors/reads.py index 33541076e..b23d6ade1 100644 --- a/policyengine_api/services/v2/user_simulations/database_connectors/reads.py +++ b/policyengine_api/services/v2/user_simulations/database_connectors/reads.py @@ -67,17 +67,3 @@ def read_all_user_simulation_rows( ) ).all() ) - - -def read_user_simulation_id_by_client_ref( - session: Session, - *, - user_id: UUID, - client_ref: str, -) -> UUID | None: - return session.exec( - select(UserSimulationAssociation.id).where( - UserSimulationAssociation.user_id == user_id, - UserSimulationAssociation.client_ref == client_ref, - ) - ).one_or_none() diff --git a/policyengine_api/services/v2/user_simulations/services.py b/policyengine_api/services/v2/user_simulations/services.py index 587391b4f..f4669f8f6 100644 --- a/policyengine_api/services/v2/user_simulations/services.py +++ b/policyengine_api/services/v2/user_simulations/services.py @@ -13,9 +13,14 @@ from sqlmodel import Session from policyengine_api.data.v2.models import UserSimulationAssociation -from policyengine_api.services.v2.associations.types import LegacyReferenceChecker -from policyengine_api.services.v2.associations.validators import ( - require_unused_client_ref, +from policyengine_api.services.v2.associations.services import ( + import_associations_in_session, + require_legacy_reference, + require_unused_client_ref_in_session, +) +from policyengine_api.services.v2.associations.types import ( + AssociationImport, + LegacyReferenceChecker, ) from policyengine_api.services.v2.identity.services import ( resolve_principal_user_in_session, @@ -24,7 +29,6 @@ from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind from policyengine_api.services.v2.user_simulations.database_connectors.creates import ( create_user_simulation, - import_user_simulation, ) from policyengine_api.services.v2.user_simulations.database_connectors.deletes import ( delete_user_simulation, @@ -32,7 +36,6 @@ from policyengine_api.services.v2.user_simulations.database_connectors.reads import ( read_all_user_simulation_rows, read_owned_user_simulation_row, - read_user_simulation_id_by_client_ref, read_user_simulation_rows, ) from policyengine_api.services.v2.user_simulations.database_connectors.updates import ( @@ -55,7 +58,6 @@ UserSimulationUpdateInput, ) from policyengine_api.services.v2.user_simulations.validators import ( - require_imported_user_simulation, require_user_simulation, ) @@ -95,27 +97,26 @@ def import_user_simulations_in_session( user_id: UUID, country_id: str, items: Sequence[UserSimulationImportItem], -) -> dict[str, UUID]: +) -> AssociationImport: """Insert imported associations idempotently on ``(user_id, client_ref)``.""" - imported: dict[str, UUID] = {} - for item in items: - imported[item.client_ref] = require_imported_user_simulation( - import_user_simulation( - session, + return import_associations_in_session( + session, + UserSimulationAssociation, + user_id=user_id, + country_id=country_id, + rows=[ + ( + item.client_ref, imported_user_simulation_input( item, country_id=country_id, user_id=user_id, - ), + ).model_dump(), ) - or read_user_simulation_id_by_client_ref( - session, - user_id=user_id, - client_ref=item.client_ref, - ) - ) - return imported + for item in items + ], + ) class V2UserSimulationService: @@ -132,11 +133,11 @@ def __init__( def _require_legacy_simulation( self, *, country_id: str, legacy_simulation_id: int ) -> None: - if self._legacy_references is None: - raise RuntimeError("v1 reference checks are not configured") - self._legacy_references.require_existing( + require_legacy_reference( + self._legacy_references, + LegacyRecordKind.SIMULATION, country_id=country_id, - requested={LegacyRecordKind.SIMULATION: (legacy_simulation_id,)}, + legacy_id=legacy_simulation_id, ) def create_user_simulation( @@ -155,15 +156,12 @@ def create_user_simulation( principal, country_id=association_input.country_id, ) - if association_input.client_ref is not None: - require_unused_client_ref( - read_user_simulation_id_by_client_ref( - session, - user_id=owner.user_id, - client_ref=association_input.client_ref, - ), - client_ref=association_input.client_ref, - ) + require_unused_client_ref_in_session( + session, + UserSimulationAssociation, + user_id=owner.user_id, + client_ref=association_input.client_ref, + ) return user_simulation_read( create_user_simulation( session, diff --git a/policyengine_api/services/v2/user_simulations/validators.py b/policyengine_api/services/v2/user_simulations/validators.py index 8bd851717..29a8aae9b 100644 --- a/policyengine_api/services/v2/user_simulations/validators.py +++ b/policyengine_api/services/v2/user_simulations/validators.py @@ -14,10 +14,6 @@ class UserSimulationNotFoundError(AssociationNotFoundError): """Raised when an association is absent for the caller and country.""" -class UserSimulationImportIntegrityError(RuntimeError): - """Raised when an imported client_ref resolves to no stored association.""" - - def require_user_simulation( association: UserSimulationAssociation | None, *, @@ -28,11 +24,3 @@ def require_user_simulation( f"user-simulation association {association_id} was not found" ) return association - - -def require_imported_user_simulation(association_id: UUID | None) -> UUID: - if association_id is None: - raise UserSimulationImportIntegrityError( - "imported user-simulation client_ref did not resolve to a stored row" - ) - return association_id diff --git a/tests/integration/test_v2_user_identity_persistence.py b/tests/integration/test_v2_user_identity_persistence.py index d1c844d68..401deb613 100644 --- a/tests/integration/test_v2_user_identity_persistence.py +++ b/tests/integration/test_v2_user_identity_persistence.py @@ -183,6 +183,48 @@ def import_records(_index: int): assert report.user_policy_id == id_maps[0]["sup-0"] +def test_imports_never_reuse_a_client_ref_from_another_country(sessions) -> None: + principal = _principal(sessions, "google-oauth2|two-countries") + service = V2AccountService( + AccountDatabaseSession(sessions), + legacy_references=default_legacy_references(), + ) + uk = service.import_records( + principal=principal, + country_id="uk", + import_input=AccountImportInput.model_validate( + {"policies": [{"client_ref": "rf-shared", "legacy_policy_id": 7}]} + ), + ) + + us = service.import_records( + principal=principal, + country_id="us", + import_input=AccountImportInput.model_validate( + { + "policies": [{"client_ref": "rf-shared", "legacy_policy_id": 1}], + "reports": [ + { + "client_ref": "sur-us", + "legacy_report_id": 615, + "user_policy_client_ref": "rf-shared", + } + ], + } + ), + ) + + assert set(us.id_map) == {"sur-us"} + assert [(skip.client_ref, skip.reason.value) for skip in us.skipped] == [ + ("rf-shared", "client_ref_conflict") + ] + with sessions() as session: + report = session.get(UserReportAssociation, us.id_map["sur-us"]) + uk_policy = session.get(UserPolicy, uk.id_map["rf-shared"]) + assert report is not None and report.user_policy_id is None + assert uk_policy is not None and uk_policy.country_id == "uk" + + def test_reference_checks_and_match_simple_composite_keys(sessions) -> None: principal = _principal(sessions, "google-oauth2|constraints") with sessions.begin() as session: diff --git a/tests/unit/v2/test_user_account_service.py b/tests/unit/v2/test_user_account_service.py index e67219c11..b37de7424 100644 --- a/tests/unit/v2/test_user_account_service.py +++ b/tests/unit/v2/test_user_account_service.py @@ -369,6 +369,44 @@ def test_import_keeps_existing_rows_and_links_earlier_imported_policies( assert unlinked.user_policy_id is None +def test_import_never_reuses_or_links_records_from_another_country(store) -> None: + accounts, *_ = _services(store) + uk = accounts.import_records( + principal=ALICE, + country_id="uk", + import_input=_import_input( + policies=[{"client_ref": "rf-shared", "legacy_policy_id": 7}] + ), + ) + + us = accounts.import_records( + principal=ALICE, + country_id="us", + import_input=_import_input( + policies=[{"client_ref": "rf-shared", "legacy_policy_id": 1}], + reports=[ + { + "client_ref": "lfr-us", + "legacy_report_id": 615, + "user_policy_client_ref": "rf-shared", + } + ], + ), + ) + + assert set(us.id_map) == {"lfr-us"} + assert [(skip.client_ref, skip.reason.value) for skip in us.skipped] == [ + ("rf-shared", "client_ref_conflict") + ] + with store.sessions() as session: + report = session.get(UserReportAssociation, us.id_map["lfr-us"]) + uk_policy = session.get(UserPolicy, uk.id_map["rf-shared"]) + policies = session.exec(select(UserPolicy)).all() + assert report is not None and report.user_policy_id is None + assert uk_policy is not None and uk_policy.country_id == "uk" + assert [policy.country_id for policy in policies] == ["uk"] + + def test_import_input_bounds_the_item_count() -> None: with pytest.raises(ValueError, match="at most 500"): _import_input( From a496e725486fc747c5b7057015017b5d43275dad Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:01:23 +0400 Subject: [PATCH 13/35] Bound single user-route writes and their free-text fields Only POST /v2/me/imports had a body limit, so any signed-in user could persist multi-megabyte rows through single creates and patches. Every /v2/me* and /v2/user-* write now rejects a body over 256 KiB with 413 (imports keep 1 MiB) through one shared guard that runs after authentication. Caller inputs share field bounds on both paths: description and source_note at 10,000 characters, and the report validation snapshot at 500 calibration matches, 200 scorecard programs, and bounded strings. Persistence models stay unbounded so the Stage 10 mirror projection is unaffected. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-12a-user-login.md | 10 + .../fastapi_routes/v2/me/request_models.py | 3 - .../fastapi_routes/v2/me/routes.py | 36 +-- .../v2/user_households/routes.py | 10 +- .../fastapi_routes/v2/user_policies/routes.py | 10 +- .../fastapi_routes/v2/user_reports/routes.py | 6 +- .../fastapi_routes/v2/user_resources.py | 48 +++- .../v2/user_simulations/routes.py | 6 +- .../services/v2/associations/types.py | 5 + .../services/v2/user_households/types.py | 7 +- .../services/v2/user_policies/types.py | 7 +- .../services/v2/user_reports/types.py | 38 ++- .../services/v2/user_simulations/types.py | 7 +- tests/unit/v2/test_user_write_limits.py | 230 ++++++++++++++++++ 14 files changed, 362 insertions(+), 61 deletions(-) create mode 100644 tests/unit/v2/test_user_write_limits.py diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index a81c384ec..62d83d73b 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -52,6 +52,15 @@ Signed-out behavior is unchanged. (`client_ref_conflict`), which is never linked across countries. A report's `user_policy_client_ref` resolves only against the user's policies in the import country. +- **Request bounds.** Every `/v2/me*` and `/v2/user-*` write rejects a body + over 256 KiB with 413 (imports allow 1 MiB). The check runs after + authentication and before body validation or any service work. Caller + fields are also bounded on single writes and imports alike: `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`). - **Account deletion.** `DELETE /v2/me` deletes the user's `legacy_user_mappings` rows (RESTRICT FK), then the user (identities and associations cascade), then the Auth0 user through the Management API when @@ -76,6 +85,7 @@ 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. Single-create `client_ref` clashes and mirror-owned rows return 409. +Oversized bodies return 413. ## Schema (one autogenerated revision) diff --git a/policyengine_api/fastapi_routes/v2/me/request_models.py b/policyengine_api/fastapi_routes/v2/me/request_models.py index 107db1ccf..253e48fa5 100644 --- a/policyengine_api/fastapi_routes/v2/me/request_models.py +++ b/policyengine_api/fastapi_routes/v2/me/request_models.py @@ -6,9 +6,6 @@ ) -MAXIMUM_IMPORT_REQUEST_BYTES = 1_048_576 - - class AccountPatchRequest(AccountUpdateInput): """Explicitly supplied account presentation fields.""" diff --git a/policyengine_api/fastapi_routes/v2/me/routes.py b/policyengine_api/fastapi_routes/v2/me/routes.py index 6024303a1..0c351b5df 100644 --- a/policyengine_api/fastapi_routes/v2/me/routes.py +++ b/policyengine_api/fastapi_routes/v2/me/routes.py @@ -9,7 +9,7 @@ from collections.abc import Callable -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends from starlette.responses import JSONResponse, Response from policyengine_api.fastapi_routes.dependencies import ( @@ -17,12 +17,8 @@ V2AccountResourceService, ) from policyengine_api.fastapi_routes.query_parameters import query_dependency -from policyengine_api.fastapi_routes.v2.errors import ( - V2ErrorResponse, - V2RequestTooLargeError, -) +from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse from policyengine_api.fastapi_routes.v2.me.request_models import ( - MAXIMUM_IMPORT_REQUEST_BYTES, AccountImportRequest, AccountPatchRequest, ) @@ -37,29 +33,14 @@ ) from policyengine_api.fastapi_routes.v2.user_resources import ( USER_RESOURCE_ERROR_RESPONSES, + enforce_user_import_request_size, + enforce_user_write_request_size, user_resource_operation, ) from policyengine_api.query_parameters import CountryQuery from policyengine_api.services.v2.identity.types import UserPrincipal -async def enforce_import_request_size(request: Request) -> None: - """Bound both declared and actual request bytes before service creation.""" - - 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( - "Import request Content-Length is invalid" - ) from error - if declared_length > MAXIMUM_IMPORT_REQUEST_BYTES: - raise V2RequestTooLargeError("Import request body exceeds 1 MiB") - if len(await request.body()) > MAXIMUM_IMPORT_REQUEST_BYTES: - raise V2RequestTooLargeError("Import request body exceeds 1 MiB") - - def _service_factory( dependencies: NativeRouteDependencies, ) -> Callable[[], V2AccountResourceService]: @@ -115,6 +96,7 @@ def patch_me( body: AccountPatchRequest, query: CountryQuery = Depends(country_query), principal: UserPrincipal = Depends(require_user), + _size: None = Depends(enforce_user_write_request_size), ) -> MeDetailResponse | JSONResponse: def patch() -> MeDetailResponse: item = service_factory().update_account( @@ -179,12 +161,6 @@ def export() -> MeExportResponse: @router.post( "/me/imports", response_model=MeImportResponse, - responses={ - 413: { - "model": V2ErrorResponse, - "description": "The import body exceeds 1 MiB.", - } - }, summary="Import browser-local saved records into the signed-in account", description=( "At most 500 items and 1 MiB. Idempotent on (user, client_ref): an " @@ -198,7 +174,7 @@ def import_records( body: AccountImportRequest, query: CountryQuery = Depends(country_query), principal: UserPrincipal = Depends(require_user), - _size: None = Depends(enforce_import_request_size), + _size: None = Depends(enforce_user_import_request_size), ) -> MeImportResponse | JSONResponse: def import_() -> MeImportResponse: result = service_factory().import_records( diff --git a/policyengine_api/fastapi_routes/v2/user_households/routes.py b/policyengine_api/fastapi_routes/v2/user_households/routes.py index 235ea8319..875bb1b90 100644 --- a/policyengine_api/fastapi_routes/v2/user_households/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_households/routes.py @@ -26,7 +26,10 @@ UserHouseholdPageResponse, UserHouseholdPageResult, ) -from policyengine_api.fastapi_routes.v2.user_resources import user_resource_operation +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, @@ -62,7 +65,10 @@ def build_v2_user_household_router( router = APIRouter( prefix="/v2", responses=USER_HOUSEHOLD_ERROR_RESPONSES, - dependencies=[Depends(require_user)], + dependencies=[ + Depends(require_user), + Depends(enforce_user_write_request_size), + ], ) create_query = query_dependency(UserHouseholdCreateQuery) detail_query = query_dependency(UserHouseholdDetailQuery) diff --git a/policyengine_api/fastapi_routes/v2/user_policies/routes.py b/policyengine_api/fastapi_routes/v2/user_policies/routes.py index d44cc80dc..78c8e0d7b 100644 --- a/policyengine_api/fastapi_routes/v2/user_policies/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_policies/routes.py @@ -26,7 +26,10 @@ UserPolicyPageResponse, UserPolicyPageResult, ) -from policyengine_api.fastapi_routes.v2.user_resources import user_resource_operation +from policyengine_api.fastapi_routes.v2.user_resources import ( + enforce_user_write_request_size, + user_resource_operation, +) from policyengine_api.query_parameters import ( CountryQuery, UserPolicyCollectionQuery, @@ -59,7 +62,10 @@ def build_v2_user_policy_router( router = APIRouter( prefix="/v2", responses=USER_POLICY_ERROR_RESPONSES, - dependencies=[Depends(require_user)], + dependencies=[ + Depends(require_user), + Depends(enforce_user_write_request_size), + ], ) country_query = query_dependency(CountryQuery) collection_query = query_dependency(UserPolicyCollectionQuery) diff --git a/policyengine_api/fastapi_routes/v2/user_reports/routes.py b/policyengine_api/fastapi_routes/v2/user_reports/routes.py index f7e5f0930..f97b6f04a 100644 --- a/policyengine_api/fastapi_routes/v2/user_reports/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_reports/routes.py @@ -16,6 +16,7 @@ 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 ( @@ -61,7 +62,10 @@ def build_v2_user_report_router( router = APIRouter( prefix="/v2", responses=USER_RESOURCE_ERROR_RESPONSES, - dependencies=[Depends(require_user)], + dependencies=[ + Depends(require_user), + Depends(enforce_user_write_request_size), + ], ) country_query = query_dependency(CountryQuery) collection_query = query_dependency(UserReportCollectionQuery) diff --git a/policyengine_api/fastapi_routes/v2/user_resources.py b/policyengine_api/fastapi_routes/v2/user_resources.py index 869770e87..ff46f91eb 100644 --- a/policyengine_api/fastapi_routes/v2/user_resources.py +++ b/policyengine_api/fastapi_routes/v2/user_resources.py @@ -2,15 +2,17 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable 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 ( V2ErrorResponse, + V2RequestTooLargeError, v2_error_response, ) from policyengine_api.services.v2.associations.validators import ( @@ -34,6 +36,44 @@ ) +MAXIMUM_USER_WRITE_REQUEST_BYTES = 256 * 1024 +MAXIMUM_USER_IMPORT_REQUEST_BYTES = 1024 * 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" +) +enforce_user_import_request_size = request_size_limit( + MAXIMUM_USER_IMPORT_REQUEST_BYTES, label="1 MiB" +) + + USER_RESOURCE_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { 400: { "model": V2ErrorResponse, @@ -61,6 +101,12 @@ "by the v1 saved-policy mirror or a duplicate client_ref." ), }, + 413: { + "model": V2ErrorResponse, + "description": ( + "The request body exceeds 256 KiB (1 MiB for POST /v2/me/imports)." + ), + }, 422: { "model": V2ErrorResponse, "description": ( diff --git a/policyengine_api/fastapi_routes/v2/user_simulations/routes.py b/policyengine_api/fastapi_routes/v2/user_simulations/routes.py index a44400ecb..967e10ba7 100644 --- a/policyengine_api/fastapi_routes/v2/user_simulations/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_simulations/routes.py @@ -16,6 +16,7 @@ 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 ( @@ -61,7 +62,10 @@ def build_v2_user_simulation_router( router = APIRouter( prefix="/v2", responses=USER_RESOURCE_ERROR_RESPONSES, - dependencies=[Depends(require_user)], + dependencies=[ + Depends(require_user), + Depends(enforce_user_write_request_size), + ], ) country_query = query_dependency(CountryQuery) collection_query = query_dependency(UserSimulationCollectionQuery) diff --git a/policyengine_api/services/v2/associations/types.py b/policyengine_api/services/v2/associations/types.py index fff5f020c..f6d28ea9a 100644 --- a/policyengine_api/services/v2/associations/types.py +++ b/policyengine_api/services/v2/associations/types.py @@ -25,7 +25,12 @@ | UserSimulationAssociation | UserReportAssociation ) +ASSOCIATION_DESCRIPTION_MAX_LENGTH = 10_000 + AssociationName = Annotated[str, StringConstraints(max_length=255)] +AssociationDescription = Annotated[ + str, StringConstraints(max_length=ASSOCIATION_DESCRIPTION_MAX_LENGTH) +] AssociationClientRef = Annotated[ str, StringConstraints(min_length=1, max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH), diff --git a/policyengine_api/services/v2/user_households/types.py b/policyengine_api/services/v2/user_households/types.py index c1b035a0c..0db1f71db 100644 --- a/policyengine_api/services/v2/user_households/types.py +++ b/policyengine_api/services/v2/user_households/types.py @@ -11,6 +11,7 @@ from policyengine_api.query_parameters import CountryId, ResourceId, UserId from policyengine_api.services.v2.associations.types import ( AssociationClientRef, + AssociationDescription, AssociationName, ) from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId @@ -27,7 +28,7 @@ class UserHouseholdCreateFields(StrictUserHouseholdInput): household_id: ResourceId | None = None legacy_household_id: LegacyRecordId | None = None name: AssociationName | None = None - description: str | None = None + description: AssociationDescription | None = None client_ref: AssociationClientRef | None = None @model_validator(mode="after") @@ -62,7 +63,7 @@ def require_household_reference(self) -> "UserHouseholdCreationInput": class UserHouseholdUpdateInput(StrictUserHouseholdInput): 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": @@ -84,7 +85,7 @@ class UserHouseholdImportItem(StrictUserHouseholdInput): client_ref: AssociationClientRef legacy_household_id: LegacyRecordId name: AssociationName | None = None - description: str | None = None + description: AssociationDescription | None = None @dataclass(frozen=True) diff --git a/policyengine_api/services/v2/user_policies/types.py b/policyengine_api/services/v2/user_policies/types.py index 0d5876dab..3e994d65a 100644 --- a/policyengine_api/services/v2/user_policies/types.py +++ b/policyengine_api/services/v2/user_policies/types.py @@ -18,6 +18,7 @@ ) from policyengine_api.services.v2.associations.types import ( AssociationClientRef, + AssociationDescription, AssociationName, ) from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId @@ -47,7 +48,7 @@ class UserPolicyCreateFields(StrictAssociationInput): policy_id: ResourceId | None = None legacy_policy_id: LegacyRecordId | None = None name: AssociationName | None = None - description: str | None = None + description: AssociationDescription | None = None baseline: UserPolicyBaseline | None = None provenance: PolicyProvenance | None = None client_ref: AssociationClientRef | None = None @@ -87,7 +88,7 @@ def require_policy_reference(self) -> "UserPolicyCreationInput": class UserPolicyUpdateInput(StrictAssociationInput): legacy_policy_id: LegacyRecordId | None = None name: AssociationName | None = None - description: str | None = None + description: AssociationDescription | None = None baseline: UserPolicyBaseline | None = None provenance: PolicyProvenance | None = None @@ -112,7 +113,7 @@ class UserPolicyImportItem(StrictAssociationInput): client_ref: AssociationClientRef legacy_policy_id: LegacyRecordId name: AssociationName | None = None - description: str | None = None + description: AssociationDescription | None = None baseline: UserPolicyBaseline | None = None provenance: PolicyProvenance | None = None diff --git a/policyengine_api/services/v2/user_reports/types.py b/policyengine_api/services/v2/user_reports/types.py index 010bb8c5c..56daab349 100644 --- a/policyengine_api/services/v2/user_reports/types.py +++ b/policyengine_api/services/v2/user_reports/types.py @@ -12,6 +12,7 @@ AwareDatetime, BaseModel, ConfigDict, + Field, JsonValue, StringConstraints, model_validator, @@ -20,13 +21,23 @@ from policyengine_api.query_parameters import CountryId, ResourceId, UserId from policyengine_api.services.v2.associations.types import ( AssociationClientRef, + AssociationDescription, AssociationName, ) from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId 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) +] +ShortText = Annotated[str, StringConstraints(max_length=64)] +LabelText = Annotated[str, StringConstraints(max_length=255)] +LongLabelText = Annotated[str, StringConstraints(max_length=1024)] class StrictUserReportModel(BaseModel): @@ -50,14 +61,14 @@ class ReportProvision(StrictUserReportModel): class ReportCalibrationWorst(StrictUserReportModel): - name: str - source: str - geography: str + name: LongLabelText + source: LongLabelText + geography: ShortText relative_error: float | None = None class ReportCalibrationMatch(StrictUserReportModel): - variable: str + variable: LabelText depth: int ring: Literal["primary", "mechanism", "downstream"] target_count: int @@ -66,19 +77,22 @@ class ReportCalibrationMatch(StrictUserReportModel): class ReportCalibration(StrictUserReportModel): - release_id: str | None = None - geography: str + release_id: LabelText | None = None + geography: ShortText reached_count: int - matches: list[ReportCalibrationMatch] + matches: Annotated[ + list[ReportCalibrationMatch], + Field(max_length=MAXIMUM_CALIBRATION_MATCHES), + ] class ReportScorecard(StrictUserReportModel): - programs: list[str] + programs: Annotated[list[LabelText], Field(max_length=MAXIMUM_SCORECARD_PROGRAMS)] class ReportValidation(StrictUserReportModel): - matched_at: str - map_model_version: str + matched_at: ShortText + map_model_version: LabelText calibration: ReportCalibration | None = None scorecard: ReportScorecard | None = None @@ -113,8 +127,8 @@ def stored_validation( class _ReportPresentationFields(StrictUserReportModel): name: AssociationName | None = None - description: str | None = None - source_note: str | None = None + description: AssociationDescription | None = None + source_note: ReportSourceNote | None = None provisions: list[ReportProvision] | None = None validation: ReportValidation | None = None last_run_at: AwareDatetime | None = None diff --git a/policyengine_api/services/v2/user_simulations/types.py b/policyengine_api/services/v2/user_simulations/types.py index 35bfcd9f0..91b773edd 100644 --- a/policyengine_api/services/v2/user_simulations/types.py +++ b/policyengine_api/services/v2/user_simulations/types.py @@ -11,6 +11,7 @@ from policyengine_api.query_parameters import CountryId, UserId from policyengine_api.services.v2.associations.types import ( AssociationClientRef, + AssociationDescription, AssociationName, ) from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId @@ -26,7 +27,7 @@ class UserSimulationCreateFields(StrictUserSimulationInput): country_id: CountryId legacy_simulation_id: LegacyRecordId name: AssociationName | None = None - description: str | None = None + description: AssociationDescription | None = None client_ref: AssociationClientRef | None = None @@ -42,7 +43,7 @@ class UserSimulationCreationInput(StrictUserSimulationInput): class UserSimulationUpdateInput(StrictUserSimulationInput): legacy_simulation_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) -> "UserSimulationUpdateInput": @@ -65,7 +66,7 @@ class UserSimulationImportItem(StrictUserSimulationInput): client_ref: AssociationClientRef legacy_simulation_id: LegacyRecordId name: AssociationName | None = None - description: str | None = None + description: AssociationDescription | None = None @dataclass(frozen=True) 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..9210a78cd --- /dev/null +++ b/tests/unit/v2/test_user_write_limits.py @@ -0,0 +1,230 @@ +"""Request-size and field bounds on signed-in user 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_IMPORT_REQUEST_BYTES, + 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, + UserHouseholdImportItem, + UserHouseholdUpdateInput, +) +from policyengine_api.services.v2.user_policies.types import ( + UserPolicyCreateFields, + UserPolicyImportItem, + UserPolicyUpdateInput, +) +from policyengine_api.services.v2.user_reports.types import ( + MAXIMUM_CALIBRATION_MATCHES, + MAXIMUM_SCORECARD_PROGRAMS, + REPORT_SOURCE_NOTE_MAX_LENGTH, + ReportValidation, + UserReportCreateFields, + UserReportImportItem, + UserReportUpdateInput, +) +from policyengine_api.services.v2.user_simulations.types import ( + UserSimulationCreateFields, + UserSimulationImportItem, + 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) + limit = ( + MAXIMUM_USER_IMPORT_REQUEST_BYTES + if path == "/v2/me/imports" + else MAXIMUM_USER_WRITE_REQUEST_BYTES + ) + + response = client.request( + method, + f"{path}?country_id=us", + content=_padded_body(limit), + 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, + client_ref=None, + 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, {}), + (UserPolicyImportItem, {"client_ref": "sup-1", "legacy_policy_id": 1}), + (UserHouseholdCreateFields, {"country_id": "us", "legacy_household_id": 1}), + (UserHouseholdUpdateInput, {}), + (UserHouseholdImportItem, {"client_ref": "suh-1", "legacy_household_id": 1}), + ( + UserSimulationCreateFields, + {"country_id": "us", "legacy_simulation_id": 1}, + ), + (UserSimulationUpdateInput, {}), + ( + UserSimulationImportItem, + {"client_ref": "sus-1", "legacy_simulation_id": 1}, + ), + (UserReportCreateFields, {"country_id": "us", "legacy_report_id": 1}), + (UserReportUpdateInput, {}), + (UserReportImportItem, {"client_ref": "sur-1", "legacy_report_id": 1}), + ], +) +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, UserReportImportItem] +) +def test_report_source_notes_are_bounded(model) -> None: + fields = {"country_id": "us", "legacy_report_id": 1, "client_ref": "sur-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) From ed55f783e21d8fe31223a37ab9fd59fb2e8558ae Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:04:14 +0400 Subject: [PATCH 14/35] Type report and policy snapshots in user-route responses UserReportItem published provisions and validation, and UserPolicyItem published provenance, as untyped JSON, so the OpenAPI lost the shared contract's shapes. Responses now use the same ReportProvision, ReportValidation, and PolicyProvenance models that validate requests. Nullable contract keys a client omitted come back as null, and the optional interval parameter is omitted rather than null, as the contract specifies. Co-Authored-By: Claude Opus 5.5 --- .../v2/user_policies/response_models.py | 3 +- .../v2/user_reports/response_models.py | 8 ++- .../services/v2/user_reports/types.py | 9 +-- .../v2/test_user_simulation_report_routes.py | 66 +++++++++++++++++++ 4 files changed, 78 insertions(+), 8 deletions(-) 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 3aae30575..7f29a1bd1 100644 --- a/policyengine_api/fastapi_routes/v2/user_policies/response_models.py +++ b/policyengine_api/fastapi_routes/v2/user_policies/response_models.py @@ -13,6 +13,7 @@ ) from policyengine_api.query_parameters import CountryId from policyengine_api.services.v2.user_policies.types import ( + PolicyProvenance, UserPolicyBaseline, UserPolicyPage, UserPolicyRead, @@ -34,7 +35,7 @@ class UserPolicyItem(StrictUserPolicyAPIModel): name: str | None description: str | None baseline: UserPolicyBaseline | None - provenance: dict[str, Any] | None + provenance: PolicyProvenance | None client_ref: str | None created_at: datetime updated_at: datetime diff --git a/policyengine_api/fastapi_routes/v2/user_reports/response_models.py b/policyengine_api/fastapi_routes/v2/user_reports/response_models.py index 6b504329c..d0ed16420 100644 --- a/policyengine_api/fastapi_routes/v2/user_reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/user_reports/response_models.py @@ -3,13 +3,15 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Generic, Literal, TypeVar +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_reports.types import ( + ReportProvision, + ReportValidation, UserReportPage, UserReportRead, ) @@ -29,8 +31,8 @@ class UserReportItem(StrictUserReportAPIModel): description: str | None user_policy_id: UUID | None source_note: str | None - provisions: list[Any] | None - validation: dict[str, Any] | None + provisions: list[ReportProvision] | None + validation: ReportValidation | None last_run_at: datetime | None client_ref: str | None created_at: datetime diff --git a/policyengine_api/services/v2/user_reports/types.py b/policyengine_api/services/v2/user_reports/types.py index 56daab349..95cd02ab1 100644 --- a/policyengine_api/services/v2/user_reports/types.py +++ b/policyengine_api/services/v2/user_reports/types.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime import json -from typing import Annotated, Any, Literal +from typing import Annotated, Literal from uuid import UUID from pydantic import ( @@ -45,7 +45,8 @@ class StrictUserReportModel(BaseModel): class ReportProvisionInterval(StrictUserReportModel): - parameter: str | None = None + # 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: JsonValue @@ -207,8 +208,8 @@ class UserReportRead: description: str | None user_policy_id: UUID | None source_note: str | None - provisions: list[Any] | None - validation: dict[str, Any] | None + provisions: list[StoredJSONObject] | None + validation: StoredJSONObject | None last_run_at: datetime | None client_ref: str | None created_at: datetime diff --git a/tests/unit/v2/test_user_simulation_report_routes.py b/tests/unit/v2/test_user_simulation_report_routes.py index f2be0d43b..bc0718272 100644 --- a/tests/unit/v2/test_user_simulation_report_routes.py +++ b/tests/unit/v2/test_user_simulation_report_routes.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import replace from datetime import datetime, timedelta, timezone from uuid import UUID @@ -300,3 +301,68 @@ def test_report_patch_accepts_only_contract_fields() -> None: "provisions", "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_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") + } == {"ReportProvision"} + assert { + name.split("-")[0] for name in property_refs(report_item, "validation") + } == {"ReportValidation"} + assert { + name.split("-")[0] for name in property_refs(policy_item, "provenance") + } == {"PolicyProvenance"} From 8b66fb04fd49eabc879654040b0af63ce963932b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:04:51 +0400 Subject: [PATCH 15/35] Log every signed-in user route as Supabase traffic Request logging only treated policies, households, and their user routes as native Supabase resources, so /v2/user-simulations, /v2/user-reports, and /v2/me* were logged with the configured v1 database sources of the simulation, report, and user route groups. One segment set now covers every native v2 Supabase resource, with reads and writes labeled by method. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/migration_logging.py | 65 ++++++++--------- .../routes/test_migration_context_logging.py | 69 +++++++++++++++++++ 2 files changed, 97 insertions(+), 37 deletions(-) diff --git a/policyengine_api/migration_logging.py b/policyengine_api/migration_logging.py index e4432ace1..90528f589 100644 --- a/policyengine_api/migration_logging.py +++ b/policyengine_api/migration_logging.py @@ -34,40 +34,40 @@ "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", + } +) +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 +187,17 @@ 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 ), ) diff --git a/tests/unit/routes/test_migration_context_logging.py b/tests/unit/routes/test_migration_context_logging.py index a449138f0..d7b33eaf6 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,74 @@ 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/imports", "user_profile", None, "supabase"), + ("GET", "/v2/user-simulations", "simulation", "supabase", None), + ("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, + } + + def test_native_route_failure_logs_country_and_actual_implementation(): dependencies = NativeRouteDependencies( readiness_probe=lambda: True, From ee8f47d35f68e965115f5a04a1767bc912310493 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:08:08 +0400 Subject: [PATCH 16/35] Defer v1 reference checks, log unexpected user-route failures, share the Auth0 client - Every user-route service factory built the v1 Cloud SQL session factory up front, so a v1 misconfiguration failed reads, lists, and /v2/me too. The default checker now builds the v1 reference service only when a request actually checks a v1 reference. - user_resource_operation turned unexpected exceptions into 500s without any log. It now logs the error type and the innermost frame locations, never the message, which can carry SQL parameters or token claims. - Each account deletion built a new Auth0 Management client and fetched a new token. Deleters now share one client per settings, so the token is reused until shortly before it expires. - The user-route log fields were passed as plain logging extras, which the structured log sink drops; they now go through policyengine_attributes so the user UUID and error type reach the logs. Co-Authored-By: Claude Opus 5.5 --- .../fastapi_routes/dependencies.py | 12 ++++- .../fastapi_routes/v2/user_resources.py | 34 ++++++++++++- policyengine_api/libs/auth0_management.py | 23 ++++++++- policyengine_api/libs/user_auth.py | 4 +- .../services/v2/identity/services.py | 2 +- .../services/v2/legacy_refs/services.py | 36 +++++++++++++- .../services/v2/user_accounts/services.py | 24 ++++++--- tests/unit/libs/test_auth0_management.py | 36 ++++++++++++++ .../unit/v2/test_legacy_reference_service.py | 49 +++++++++++++++++++ tests/unit/v2/test_user_account_service.py | 30 ++++++++++++ tests/unit/v2/test_user_route_auth.py | 24 +++++++++ 11 files changed, 258 insertions(+), 16 deletions(-) diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 42c971d3d..8591795e9 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -594,7 +594,7 @@ def _default_require_user( ) -def _default_legacy_reference_checker() -> "LegacyReferenceChecker": +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, @@ -608,6 +608,16 @@ def _default_legacy_reference_checker() -> "LegacyReferenceChecker": ) +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 ( diff --git a/policyengine_api/fastapi_routes/v2/user_resources.py b/policyengine_api/fastapi_routes/v2/user_resources.py index ff46f91eb..08bdfa5d5 100644 --- a/policyengine_api/fastapi_routes/v2/user_resources.py +++ b/policyengine_api/fastapi_routes/v2/user_resources.py @@ -3,6 +3,8 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +import logging +import traceback from typing import Any, TypeVar from fastapi import Request @@ -124,9 +126,38 @@ async def enforce(request: Request) -> None: }, } +logger = logging.getLogger(__name__) + OperationT = TypeVar("OperationT") +def _log_unexpected_failure(error: Exception) -> None: + """Record an unexpected 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( + "v2_user_operation_failed", + 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: @@ -153,5 +184,6 @@ def user_resource_operation( return v2_error_response(502, str(error)) except (V2ConfigurationError, SQLAlchemyError): return v2_error_response(503, "V2 user persistence is unavailable") - except Exception: # noqa: BLE001 - return a secret-safe typed error + except Exception as error: # noqa: BLE001 - return a secret-safe typed error + _log_unexpected_failure(error) return v2_error_response(500, "V2 user operation failed") diff --git a/policyengine_api/libs/auth0_management.py b/policyengine_api/libs/auth0_management.py index 9c4bf0c01..838d988bb 100644 --- a/policyengine_api/libs/auth0_management.py +++ b/policyengine_api/libs/auth0_management.py @@ -205,12 +205,31 @@ def delete_user(self, subject: str) -> None: ) +_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, ) -> Callable[[str], None] | None: - """Return a deleter bound to the configured tenant, or ``None``.""" + """Return a deleter bound to the configured tenant, or ``None``. + + Every call with the same settings shares one client, so the Management + API token is fetched once and reused until shortly before it expires. + """ settings = load_auth0_management_settings(environ) if settings is None: return None - return Auth0ManagementClient(settings).delete_user + return _shared_client(settings).delete_user diff --git a/policyengine_api/libs/user_auth.py b/policyengine_api/libs/user_auth.py index a4f4ae459..c6d66927e 100644 --- a/policyengine_api/libs/user_auth.py +++ b/policyengine_api/libs/user_auth.py @@ -246,7 +246,7 @@ def authenticate_bearer( except jwt.PyJWKClientConnectionError as error: logger.warning( "public_api_user_auth_unavailable", - extra={"error_type": type(error).__name__}, + extra={"policyengine_attributes": {"error_type": type(error).__name__}}, ) raise UserAuthUnavailableError( "the signing keys could not be retrieved" @@ -254,7 +254,7 @@ def authenticate_bearer( except (jwt.PyJWTError, ValueError) as error: logger.info( "public_api_user_auth_rejected", - extra={"error_type": type(error).__name__}, + extra={"policyengine_attributes": {"error_type": type(error).__name__}}, ) raise InvalidBearerTokenError("the bearer access token is invalid") from error if claims.get("iss") != settings.issuer: diff --git a/policyengine_api/services/v2/identity/services.py b/policyengine_api/services/v2/identity/services.py index 9b5173a5d..be14b3c6b 100644 --- a/policyengine_api/services/v2/identity/services.py +++ b/policyengine_api/services/v2/identity/services.py @@ -109,7 +109,7 @@ def resolve_principal_user_in_session( if created_user_id is not None: logger.info( "provisioned_v2_user", - extra={"v2_user_id": str(created_user_id)}, + extra={"policyengine_attributes": {"v2_user_id": str(created_user_id)}}, ) return ResolvedUser( user_id=created_user_id, diff --git a/policyengine_api/services/v2/legacy_refs/services.py b/policyengine_api/services/v2/legacy_refs/services.py index b44e9056b..48a86cd61 100644 --- a/policyengine_api/services/v2/legacy_refs/services.py +++ b/policyengine_api/services/v2/legacy_refs/services.py @@ -8,8 +8,9 @@ from __future__ import annotations -from collections.abc import Collection, Mapping +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, ) @@ -67,3 +68,36 @@ def require_existing( 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/user_accounts/services.py b/policyengine_api/services/v2/user_accounts/services.py index 62e5a853e..7306aa33f 100644 --- a/policyengine_api/services/v2/user_accounts/services.py +++ b/policyengine_api/services/v2/user_accounts/services.py @@ -279,16 +279,20 @@ def delete_account(self, *, principal: UserPrincipal) -> AccountDeletion: if deleted_user_id is not None: logger.info( "deleted_v2_user_account", - extra={"v2_user_id": str(deleted_user_id)}, + extra={"policyengine_attributes": {"v2_user_id": str(deleted_user_id)}}, ) if self._auth0_user_deleter is None: logger.warning( "auth0_user_deletion_skipped_not_configured", extra={ - "v2_user_id": ( - str(deleted_user_id) if deleted_user_id is not None else None - ) + "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=()) @@ -299,10 +303,14 @@ def delete_account(self, *, principal: UserPrincipal) -> AccountDeletion: logger.warning( "auth0_user_deletion_failed", extra={ - "v2_user_id": ( - str(deleted_user_id) if deleted_user_id is not None else None - ), - "error_type": type(error).__name__, + "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( diff --git a/tests/unit/libs/test_auth0_management.py b/tests/unit/libs/test_auth0_management.py index a230e0ac7..f5357406a 100644 --- a/tests/unit/libs/test_auth0_management.py +++ b/tests/unit/libs/test_auth0_management.py @@ -124,3 +124,39 @@ def test_failed_deletions_raise_without_leaking_subjects(status: int) -> None: 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("auth0|1") + second("auth0|2") + + assert first.__self__ is second.__self__ # type: ignore[attr-defined] + assert [request.url.raw_path for request in requests] == [ + b"/oauth/token", + b"/api/v2/users/auth0%7C1", + b"/api/v2/users/auth0%7C2", + ] diff --git a/tests/unit/v2/test_legacy_reference_service.py b/tests/unit/v2/test_legacy_reference_service.py index 8311d37e6..d46cdbb60 100644 --- a/tests/unit/v2/test_legacy_reference_service.py +++ b/tests/unit/v2/test_legacy_reference_service.py @@ -144,3 +144,52 @@ def reject_session(): 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_user_account_service.py b/tests/unit/v2/test_user_account_service.py index b37de7424..c39de1d4e 100644 --- a/tests/unit/v2/test_user_account_service.py +++ b/tests/unit/v2/test_user_account_service.py @@ -493,3 +493,33 @@ def failing_deleter(_subject: str) -> None: 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), legacy_references=references + ) + 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 + ), + ) diff --git a/tests/unit/v2/test_user_route_auth.py b/tests/unit/v2/test_user_route_auth.py index 055a89b2a..d2747b410 100644 --- a/tests/unit/v2/test_user_route_auth.py +++ b/tests/unit/v2/test_user_route_auth.py @@ -214,3 +214,27 @@ def test_default_dependencies_publish_the_bearer_security_scheme() -> None: for path in ("/v2/me", "/v2/user-policies", "/v2/user-reports"): assert {"UserAccessToken": []} in schema["paths"][path]["get"]["security"] assert "security" not in schema["paths"]["/v2/policies"]["get"] + + +def test_unexpected_failures_are_logged_without_their_message(caplog) -> None: + from policyengine_api.fastapi_routes.v2.user_resources import ( + user_resource_operation, + ) + + subject = "|".join(("google-oauth2", "secret-subject")) + + def fail(): + raise RuntimeError(f"{subject} leaked into a message") + + with caplog.at_level("ERROR"): + response = user_resource_operation(fail) + + assert response.status_code == 500 + record = next( + record for record in caplog.records if record.msg == "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 caplog.text + assert "secret-subject" not in str(attributes) From c656ea15bb80c92536af6053bdc6e467a2329098 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:09:04 +0400 Subject: [PATCH 17/35] Keep Auth0 Management deletion out of staging deploys The staging live test deletes its own account through DELETE /v2/me, so Management API settings in staging would also delete the CI user from Auth0, and only documentation prevented it. The staging deploy job no longer receives AUTH0_MANAGEMENT_*, and the deploy validation rejects them for any environment other than production. Co-Authored-By: Claude Opus 5.5 --- .../scripts/validate_cloud_run_deploy_env.sh | 7 ++++ .github/workflows/push.yml | 5 +-- docs/migration/stage-12a-user-login.md | 15 ++++--- tests/unit/test_cloud_run_deploy_scripts.py | 40 +++++++++++++++---- 4 files changed, 50 insertions(+), 17 deletions(-) diff --git a/.github/scripts/validate_cloud_run_deploy_env.sh b/.github/scripts/validate_cloud_run_deploy_env.sh index ae02bc812..81c310c9f 100755 --- a/.github/scripts/validate_cloud_run_deploy_env.sh +++ b/.github/scripts/validate_cloud_run_deploy_env.sh @@ -148,6 +148,13 @@ 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 c4fe11ff8..37028a19f 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -192,9 +192,6 @@ jobs: 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-staging CLOUD_RUN_RUNTIME_CACHE_ENVIRONMENT: staging CLOUD_RUN_RUNTIME_CACHE_URL_SECRET: policyengine-api-staging-runtime-cache-url:latest @@ -239,7 +236,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 -u PUBLIC_API_AUTH_REQUIRED -u PUBLIC_API_AUTH_ISSUER -u PUBLIC_API_AUTH_AUDIENCE -u AUTH0_MANAGEMENT_DOMAIN -u AUTH0_MANAGEMENT_CLIENT_ID -u AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE 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 diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index 62d83d73b..1bd827764 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -170,15 +170,16 @@ enforces this. | `PUBLIC_API_AUTH_REQUIRED` | GitHub variable (`staging`, `production`) | `1` requires issuer and audience; `0` allows them to be absent, so user routes 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) | Canonical tenant domain (the Management API is not served on custom domains) | -| `AUTH0_MANAGEMENT_CLIENT_ID` | GitHub variable (optional) | M2M client with `delete:users` | -| `AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE` | GitHub secret (optional) | Secret Manager version holding the M2M client secret | +| `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 | `readiness.validate_policy_runtime_configuration` validates the `PUBLIC_API_AUTH_*` and `AUTH0_MANAGEMENT_*` settings. Inconsistent values fail readiness, which is the deploy gate. `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. `deploy_cloud_run_candidate.sh` writes them to +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 @@ -193,8 +194,10 @@ needs the following in the GitHub `staging` environment: | `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. Leave the `AUTH0_MANAGEMENT_*` settings unset in staging, or the CI user -would also be removed from Auth0. +end. With Management API deletion configured, that 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) diff --git a/tests/unit/test_cloud_run_deploy_scripts.py b/tests/unit/test_cloud_run_deploy_scripts.py index 6d0d9b9d5..d7bf54ce1 100644 --- a/tests/unit/test_cloud_run_deploy_scripts.py +++ b/tests/unit/test_cloud_run_deploy_scripts.py @@ -2587,6 +2587,29 @@ def test_validate_cloud_run_deploy_env_rejects_unsafe_user_auth_settings( 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" @@ -2608,12 +2631,18 @@ def test_push_workflow_wires_user_auth_settings_and_staging_test_user(): "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 }}", ): 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)] @@ -2621,9 +2650,6 @@ def test_push_workflow_wires_user_auth_settings_and_staging_test_user(): "PUBLIC_API_AUTH_REQUIRED", "PUBLIC_API_AUTH_ISSUER", "PUBLIC_API_AUTH_AUDIENCE", - "AUTH0_MANAGEMENT_DOMAIN", - "AUTH0_MANAGEMENT_CLIENT_ID", - "AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE", ): assert f"-u {name}" in test_step From 76baf1a9ce54c044dde945b8fdedc4c0d706e939 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:09:37 +0400 Subject: [PATCH 18/35] Cover every signed-in operation with the fail-closed 401 test The no-token test listed 11 of the 25 /v2/me* and /v2/user-* operations by hand. It now enumerates every operation from the built application's OpenAPI, so new user routes are covered automatically. Co-Authored-By: Claude Opus 5.5 --- tests/unit/v2/test_user_route_auth.py | 46 +++++++++++++-------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/unit/v2/test_user_route_auth.py b/tests/unit/v2/test_user_route_auth.py index d2747b410..579fe9ad5 100644 --- a/tests/unit/v2/test_user_route_auth.py +++ b/tests/unit/v2/test_user_route_auth.py @@ -14,23 +14,7 @@ ISSUER = "https://auth.example.test/" AUDIENCE = "https://api.policyengine.org/" -USER_ROUTE_REQUESTS = ( - ("GET", "/v2/me?country_id=us", None), - ("PATCH", "/v2/me?country_id=us", {"first_name": "Ada"}), - ("DELETE", "/v2/me?country_id=us", None), - ("GET", "/v2/me/export?country_id=us", None), - ("POST", "/v2/me/imports?country_id=us", {}), - ("GET", "/v2/user-policies?country_id=us", None), - ("POST", "/v2/user-policies?country_id=us", {"country_id": "us"}), - ("GET", "/v2/user-households?country_id=us", None), - ("GET", "/v2/user-simulations?country_id=us", None), - ("GET", "/v2/user-reports?country_id=us", None), - ( - "DELETE", - "/v2/user-reports/00000000-0000-0000-0000-000000000001?country_id=us", - None, - ), -) +ASSOCIATION_ID = "00000000-0000-0000-0000-000000000001" class FakeDecoder: @@ -74,17 +58,33 @@ def _services() -> tuple[dict[str, RecordingService], dict[str, Any]]: return services, factories -@pytest.mark.parametrize(("method", "path", "body"), USER_ROUTE_REQUESTS) -def test_every_user_route_fails_closed_without_a_token( - method: str, path: str, body: dict | None -) -> None: +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, - path, - json=body, + f"{path}?country_id=us", + json={} if method in {"POST", "PATCH"} else None, headers={"Origin": "https://app.policyengine.org"}, ) From e0839f3a4be4f5af26381d00af1a86e89d760ca6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:15:52 +0400 Subject: [PATCH 19/35] Keep the failure-logging test independent of Alembic's logging config Alembic tests run logging.config.fileConfig, which disables loggers that already exist, so the new failure-logging test passed alone but failed in the full suite. It now re-enables and records on the module logger directly. Co-Authored-By: Claude Opus 5.5 --- tests/unit/v2/test_user_route_auth.py | 40 ++++++++++++++++++++------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/tests/unit/v2/test_user_route_auth.py b/tests/unit/v2/test_user_route_auth.py index 579fe9ad5..4b56b85bd 100644 --- a/tests/unit/v2/test_user_route_auth.py +++ b/tests/unit/v2/test_user_route_auth.py @@ -216,25 +216,45 @@ def test_default_dependencies_publish_the_bearer_security_scheme() -> None: assert "security" not in schema["paths"]["/v2/policies"]["get"] -def test_unexpected_failures_are_logged_without_their_message(caplog) -> None: - from policyengine_api.fastapi_routes.v2.user_resources import ( - user_resource_operation, - ) +def test_unexpected_failures_are_logged_without_their_message() -> None: + 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) subject = "|".join(("google-oauth2", "secret-subject")) def fail(): raise RuntimeError(f"{subject} leaked into a message") - with caplog.at_level("ERROR"): - response = user_resource_operation(fail) + # 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(fail) + finally: + user_resources.logger.removeHandler(handler) + user_resources.logger.disabled = was_disabled assert response.status_code == 500 - record = next( - record for record in caplog.records if record.msg == "v2_user_operation_failed" - ) + (record,) = [ + record + for record in handler.records + if 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 caplog.text + assert "secret-subject" not in record.getMessage() assert "secret-subject" not in str(attributes) + assert record.exc_info is None From 361a709cb582feb75e817a4df5679fb208c34c74 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:02:54 +0400 Subject: [PATCH 20/35] Reject NaN and Infinity in user-route floats and JSON values Python's JSON parser and Pydantic's float and JsonValue types accept NaN and Infinity, but Postgres JSONB rejects them, so one such value in a report became a 503 and failed a whole import, including its valid items. Every user-route input base now sets allow_inf_nan=False, and report provision values use a finite-only recursive JSON value type. A single write containing one returns 422 and an import item containing one is skipped as invalid. Tests cover the types, the route, SQLite imports, and Postgres JSONB storage. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-12a-user-login.md | 5 +- .../services/v2/associations/types.py | 40 ++++++- .../services/v2/user_households/types.py | 7 +- .../services/v2/user_policies/types.py | 7 +- .../services/v2/user_reports/types.py | 14 +-- .../services/v2/user_simulations/types.py | 7 +- .../test_v2_user_identity_persistence.py | 44 ++++++++ tests/unit/v2/test_user_account_service.py | 30 ++++++ tests/unit/v2/test_user_write_limits.py | 100 ++++++++++++++++++ 9 files changed, 232 insertions(+), 22 deletions(-) diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index 1bd827764..4c5c4fcdd 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -60,7 +60,10 @@ Signed-out behavior is unchanged. 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`). + `ref`). Every user-route float and JSON value also rejects NaN and Infinity: + Python's JSON parser accepts them but Postgres JSONB cannot store them. A + single write containing one returns 422; an import item containing one is + skipped as `invalid`. - **Account deletion.** `DELETE /v2/me` deletes the user's `legacy_user_mappings` rows (RESTRICT FK), then the user (identities and associations cascade), then the Auth0 user through the Management API when diff --git a/policyengine_api/services/v2/associations/types.py b/policyengine_api/services/v2/associations/types.py index f6d28ea9a..37068cea4 100644 --- a/policyengine_api/services/v2/associations/types.py +++ b/policyengine_api/services/v2/associations/types.py @@ -4,10 +4,11 @@ from collections.abc import Collection, Mapping from dataclasses import dataclass -from typing import Annotated, Protocol, TypeAlias +from typing import TYPE_CHECKING, Annotated, Protocol, TypeAlias, Union from uuid import UUID -from pydantic import BaseModel, ConfigDict, StringConstraints +from pydantic import BaseModel, ConfigDict, FiniteFloat, StringConstraints +from typing_extensions import TypeAliasType from policyengine_api.data.v2.models.associations import ( ASSOCIATION_CLIENT_REF_MAX_LENGTH, @@ -27,6 +28,34 @@ ) 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) @@ -38,9 +67,12 @@ class StrictAssociationModel(BaseModel): - """Reject fields outside a reviewed association contract.""" + """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) + model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False) class LegacyReferenceChecker(Protocol): diff --git a/policyengine_api/services/v2/user_households/types.py b/policyengine_api/services/v2/user_households/types.py index 0db1f71db..549ed805d 100644 --- a/policyengine_api/services/v2/user_households/types.py +++ b/policyengine_api/services/v2/user_households/types.py @@ -6,19 +6,20 @@ from datetime import datetime from uuid import UUID -from pydantic import BaseModel, ConfigDict, model_validator +from pydantic import model_validator from policyengine_api.query_parameters import CountryId, ResourceId, UserId from policyengine_api.services.v2.associations.types import ( AssociationClientRef, AssociationDescription, AssociationName, + StrictAssociationModel, ) from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId -class StrictUserHouseholdInput(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) +class StrictUserHouseholdInput(StrictAssociationModel): + """Reject fields outside the reviewed user-household contract.""" class UserHouseholdCreateFields(StrictUserHouseholdInput): diff --git a/policyengine_api/services/v2/user_policies/types.py b/policyengine_api/services/v2/user_policies/types.py index 3e994d65a..0d025dd13 100644 --- a/policyengine_api/services/v2/user_policies/types.py +++ b/policyengine_api/services/v2/user_policies/types.py @@ -8,7 +8,7 @@ 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, @@ -20,6 +20,7 @@ AssociationClientRef, AssociationDescription, AssociationName, + StrictAssociationModel, ) from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId @@ -27,11 +28,9 @@ 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.""" diff --git a/policyengine_api/services/v2/user_reports/types.py b/policyengine_api/services/v2/user_reports/types.py index 95cd02ab1..d2047f2bd 100644 --- a/policyengine_api/services/v2/user_reports/types.py +++ b/policyengine_api/services/v2/user_reports/types.py @@ -10,8 +10,6 @@ from pydantic import ( AwareDatetime, - BaseModel, - ConfigDict, Field, JsonValue, StringConstraints, @@ -23,6 +21,8 @@ AssociationClientRef, AssociationDescription, AssociationName, + FiniteJsonValue, + StrictAssociationModel, ) from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId @@ -40,8 +40,8 @@ LongLabelText = Annotated[str, StringConstraints(max_length=1024)] -class StrictUserReportModel(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) +class StrictUserReportModel(StrictAssociationModel): + """Reject fields outside the reviewed user-report contract.""" class ReportProvisionInterval(StrictUserReportModel): @@ -49,15 +49,15 @@ class ReportProvisionInterval(StrictUserReportModel): parameter: str | None = Field(default=None, exclude_if=lambda value: value is None) start_date: IsoDate end_date: IsoDate - value: JsonValue + value: FiniteJsonValue class ReportProvision(StrictUserReportModel): path: str breadcrumb: str unit: str | None = None - baseline_value: JsonValue - value: JsonValue + baseline_value: FiniteJsonValue + value: FiniteJsonValue intervals: list[ReportProvisionInterval] | None = None diff --git a/policyengine_api/services/v2/user_simulations/types.py b/policyengine_api/services/v2/user_simulations/types.py index 91b773edd..3a44d7c7e 100644 --- a/policyengine_api/services/v2/user_simulations/types.py +++ b/policyengine_api/services/v2/user_simulations/types.py @@ -6,19 +6,20 @@ from datetime import datetime from uuid import UUID -from pydantic import BaseModel, ConfigDict, model_validator +from pydantic import model_validator from policyengine_api.query_parameters import CountryId, UserId from policyengine_api.services.v2.associations.types import ( AssociationClientRef, AssociationDescription, AssociationName, + StrictAssociationModel, ) from policyengine_api.services.v2.legacy_refs.types import LegacyRecordId -class StrictUserSimulationInput(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) +class StrictUserSimulationInput(StrictAssociationModel): + """Reject fields outside the reviewed user-simulation contract.""" class UserSimulationCreateFields(StrictUserSimulationInput): diff --git a/tests/integration/test_v2_user_identity_persistence.py b/tests/integration/test_v2_user_identity_persistence.py index 401deb613..307b95483 100644 --- a/tests/integration/test_v2_user_identity_persistence.py +++ b/tests/integration/test_v2_user_identity_persistence.py @@ -225,6 +225,50 @@ def test_imports_never_reuse_a_client_ref_from_another_country(sessions) -> None assert uk_policy is not None and uk_policy.country_id == "uk" +def test_non_finite_numbers_never_reach_jsonb(sessions) -> None: + principal = _principal(sessions, "google-oauth2|non-finite") + service = V2AccountService( + AccountDatabaseSession(sessions), + legacy_references=default_legacy_references(), + ) + provision = { + "path": "gov.irs.credits.ctc.amount", + "breadcrumb": "Child Tax Credit amount", + "baseline_value": 2000, + "value": 3000, + } + + result = service.import_records( + principal=principal, + country_id="us", + import_input=AccountImportInput.model_validate( + { + "policies": [{"client_ref": "rf-finite", "legacy_policy_id": 1}], + "reports": [ + { + "client_ref": "sur-finite", + "legacy_report_id": 615, + "provisions": [provision], + }, + { + "client_ref": "sur-infinite", + "legacy_report_id": 616, + "provisions": [{**provision, "value": float("inf")}], + }, + ], + } + ), + ) + + assert set(result.id_map) == {"rf-finite", "sur-finite"} + assert [(skip.client_ref, skip.reason.value) for skip in result.skipped] == [ + ("sur-infinite", "invalid") + ] + with sessions() as session: + stored = session.get(UserReportAssociation, result.id_map["sur-finite"]) + assert stored is not None and stored.provisions == [provision] + + def test_reference_checks_and_match_simple_composite_keys(sessions) -> None: principal = _principal(sessions, "google-oauth2|constraints") with sessions.begin() as session: diff --git a/tests/unit/v2/test_user_account_service.py b/tests/unit/v2/test_user_account_service.py index c39de1d4e..636170cc9 100644 --- a/tests/unit/v2/test_user_account_service.py +++ b/tests/unit/v2/test_user_account_service.py @@ -407,6 +407,36 @@ def test_import_never_reuses_or_links_records_from_another_country(store) -> Non assert [policy.country_id for policy in policies] == ["uk"] +def test_import_skips_items_with_non_finite_numbers_as_invalid(store) -> None: + accounts, *_ = _services(store) + provision = { + "path": "gov.irs.credits.ctc.amount", + "breadcrumb": "Child Tax Credit amount", + "baseline_value": float("nan"), + "value": 3000, + } + + result = accounts.import_records( + principal=ALICE, + country_id="us", + import_input=_import_input( + policies=[{"client_ref": "rf-finite", "legacy_policy_id": 1}], + reports=[ + { + "client_ref": "lfr-nan", + "legacy_report_id": 615, + "provisions": [provision], + } + ], + ), + ) + + assert set(result.id_map) == {"rf-finite"} + assert [(skip.client_ref, skip.reason.value) for skip in result.skipped] == [ + ("lfr-nan", "invalid") + ] + + def test_import_input_bounds_the_item_count() -> None: with pytest.raises(ValueError, match="at most 500"): _import_input( diff --git a/tests/unit/v2/test_user_write_limits.py b/tests/unit/v2/test_user_write_limits.py index 9210a78cd..8719d226b 100644 --- a/tests/unit/v2/test_user_write_limits.py +++ b/tests/unit/v2/test_user_write_limits.py @@ -228,3 +228,103 @@ def test_report_validation_bounds_list_lengths_and_strings() -> None: ): 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 From 0b72a24e92b326662a4f0d3a79634e503243a8dd Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:04:56 +0400 Subject: [PATCH 21/35] Log persistence failures and publish stored snapshots through lenient shapes user_resource_operation mapped every SQLAlchemyError to 503 without a log line, hiding schema drift such as the original UndefinedColumn and rejected data. It now logs the error type and frame locations, never the message, as it does for unexpected 500s. Responses reused the strict input models for report provisions and validation and for policy provenance, so any later tightening of the input bounds would make existing rows fail to serialize and break a whole list or /v2/me/export. Responses now use read shapes that mirror the contract without the input bounds and ignore unknown keys. Co-Authored-By: Claude Opus 5.5 --- .../v2/user_policies/response_models.py | 17 ++++- .../v2/user_reports/response_models.py | 71 +++++++++++++++++-- .../fastapi_routes/v2/user_resources.py | 12 ++-- tests/unit/v2/test_me_routes.py | 70 ++++++++++++++++++ tests/unit/v2/test_user_route_auth.py | 47 ++++++++---- .../v2/test_user_simulation_report_routes.py | 50 ++++++++++++- 6 files changed, 239 insertions(+), 28 deletions(-) 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 7f29a1bd1..1f9e92040 100644 --- a/policyengine_api/fastapi_routes/v2/user_policies/response_models.py +++ b/policyengine_api/fastapi_routes/v2/user_policies/response_models.py @@ -13,7 +13,6 @@ ) from policyengine_api.query_parameters import CountryId from policyengine_api.services.v2.user_policies.types import ( - PolicyProvenance, UserPolicyBaseline, UserPolicyPage, UserPolicyRead, @@ -26,6 +25,20 @@ 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 @@ -35,7 +48,7 @@ class UserPolicyItem(StrictUserPolicyAPIModel): name: str | None description: str | None baseline: UserPolicyBaseline | None - provenance: PolicyProvenance | None + provenance: PolicyProvenanceItem | None client_ref: str | None created_at: datetime updated_at: datetime diff --git a/policyengine_api/fastapi_routes/v2/user_reports/response_models.py b/policyengine_api/fastapi_routes/v2/user_reports/response_models.py index d0ed16420..b9e93ecb5 100644 --- a/policyengine_api/fastapi_routes/v2/user_reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/user_reports/response_models.py @@ -6,12 +6,10 @@ from typing import Generic, Literal, TypeVar from uuid import UUID -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field, JsonValue from policyengine_api.query_parameters import CountryId from policyengine_api.services.v2.user_reports.types import ( - ReportProvision, - ReportValidation, UserReportPage, UserReportRead, ) @@ -21,6 +19,69 @@ 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 + + +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 @@ -31,8 +92,8 @@ class UserReportItem(StrictUserReportAPIModel): description: str | None user_policy_id: UUID | None source_note: str | None - provisions: list[ReportProvision] | None - validation: ReportValidation | None + provisions: list[ReportProvisionItem] | None + validation: ReportValidationItem | None last_run_at: datetime | None client_ref: str | None created_at: datetime diff --git a/policyengine_api/fastapi_routes/v2/user_resources.py b/policyengine_api/fastapi_routes/v2/user_resources.py index 08bdfa5d5..166d6f694 100644 --- a/policyengine_api/fastapi_routes/v2/user_resources.py +++ b/policyengine_api/fastapi_routes/v2/user_resources.py @@ -131,8 +131,8 @@ async def enforce(request: Request) -> None: OperationT = TypeVar("OperationT") -def _log_unexpected_failure(error: Exception) -> None: - """Record an unexpected failure without its message. +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 @@ -142,7 +142,7 @@ def _log_unexpected_failure(error: Exception) -> None: try: frames = traceback.extract_tb(error.__traceback__)[-8:] logger.error( - "v2_user_operation_failed", + event, extra={ "policyengine_attributes": { "error_type": type(error).__name__, @@ -182,8 +182,10 @@ def user_resource_operation( return v2_error_response(409, "The record conflicts with stored state") except AccountIdentityDeletionError as error: return v2_error_response(502, str(error)) - except (V2ConfigurationError, SQLAlchemyError): + 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_unexpected_failure(error) + _log_failure("v2_user_operation_failed", error) return v2_error_response(500, "V2 user operation failed") diff --git a/tests/unit/v2/test_me_routes.py b/tests/unit/v2/test_me_routes.py index 4a8fa2dfe..22f4f9312 100644 --- a/tests/unit/v2/test_me_routes.py +++ b/tests/unit/v2/test_me_routes.py @@ -18,6 +18,8 @@ from policyengine_api.services.v2.user_accounts.validators import ( AccountIdentityDeletionError, ) +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 ( CREATED_AT, OWNER_ID, @@ -217,3 +219,71 @@ def test_import_rejects_bodies_over_one_mebibyte() -> None: assert response.status_code == 413 assert response.json()["status"] == "error" assert service.calls == [] + + +def test_export_serializes_stored_rows_outside_current_input_bounds() -> None: + long_text = "x" * 20_000 + odd_policy = UserPolicyRead( + id=IMPORTED_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}, + client_ref="rf-1", + created_at=CREATED_AT, + updated_at=CREATED_AT, + ) + odd_report = UserReportRead( + id=IMPORTED_ID, + country_id="us", + user_id=OWNER_ID, + report_id=None, + legacy_report_id=615, + name=None, + description=None, + 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"}, + last_run_at=None, + client_ref="lfr-1", + 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] diff --git a/tests/unit/v2/test_user_route_auth.py b/tests/unit/v2/test_user_route_auth.py index 4b56b85bd..85ec6056a 100644 --- a/tests/unit/v2/test_user_route_auth.py +++ b/tests/unit/v2/test_user_route_auth.py @@ -216,7 +216,7 @@ def test_default_dependencies_publish_the_bearer_security_scheme() -> None: assert "security" not in schema["paths"]["/v2/policies"]["get"] -def test_unexpected_failures_are_logged_without_their_message() -> None: +def _recorded_failure_logs(operation) -> tuple[object, list]: import logging from policyengine_api.fastapi_routes.v2 import user_resources @@ -229,11 +229,6 @@ def __init__(self) -> None: def emit(self, record: logging.LogRecord) -> None: self.records.append(record) - subject = "|".join(("google-oauth2", "secret-subject")) - - def fail(): - raise RuntimeError(f"{subject} leaked into a message") - # Alembic tests run logging.config.fileConfig, which disables loggers that # already exist, so re-enable this one and record on it directly. handler = RecordingHandler() @@ -241,20 +236,46 @@ def fail(): user_resources.logger.disabled = False user_resources.logger.addHandler(handler) try: - response = user_resources.user_resource_operation(fail) + 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,) = [ - record - for record in handler.records - if record.getMessage() == "v2_user_operation_failed" - ] + (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 record.getMessage() 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) diff --git a/tests/unit/v2/test_user_simulation_report_routes.py b/tests/unit/v2/test_user_simulation_report_routes.py index bc0718272..65dc02954 100644 --- a/tests/unit/v2/test_user_simulation_report_routes.py +++ b/tests/unit/v2/test_user_simulation_report_routes.py @@ -359,10 +359,54 @@ def property_refs(schema_name: str, field_name: str) -> set[str]: policy_item = next(name for name in schemas if name.startswith("UserPolicyItem")) assert { name.split("-")[0] for name in property_refs(report_item, "provisions") - } == {"ReportProvision"} + } == {"ReportProvisionItem"} assert { name.split("-")[0] for name in property_refs(report_item, "validation") - } == {"ReportValidation"} + } == {"ReportValidationItem"} assert { name.split("-")[0] for name in property_refs(policy_item, "provenance") - } == {"PolicyProvenance"} + } == {"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 From 91d46694b2af820e86a012f0898ccc02a3a4e706 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:06:47 +0400 Subject: [PATCH 22/35] Obtain Auth0 credentials before deleting an account, and retry a rejected token - DELETE /v2/me now reads the Management client secret and obtains a token before deleting any PolicyEngine data. A configuration, Secret Manager, or credential failure returns 503 with nothing deleted, instead of a 502 after the data is gone. - A 401 from Auth0 drops the cached token and retries once with a fresh one, so a revoked or rotated token no longer fails every deletion for up to its 24-hour lifetime. - The Management settings are read only for account deletion, not for every /v2/me request. - The Stage 12A doc lists the runtime service account's Secret Manager grant as a prerequisite. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-12a-user-login.md | 29 ++++- .../fastapi_routes/dependencies.py | 2 +- .../fastapi_routes/v2/me/routes.py | 15 ++- .../fastapi_routes/v2/user_resources.py | 3 + policyengine_api/libs/auth0_management.py | 48 ++++++-- .../services/v2/user_accounts/services.py | 34 ++++- .../services/v2/user_accounts/types.py | 11 +- .../services/v2/user_accounts/validators.py | 4 + .../test_v2_user_identity_persistence.py | 10 +- tests/unit/libs/test_auth0_management.py | 83 ++++++++++++- tests/unit/v2/test_me_routes.py | 14 +++ tests/unit/v2/test_user_account_service.py | 116 ++++++++++++++++-- 12 files changed, 330 insertions(+), 39 deletions(-) diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index 4c5c4fcdd..0cfd33f1c 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -64,11 +64,14 @@ Signed-out behavior is unchanged. Python's JSON parser accepts them but Postgres JSONB cannot store them. A single write containing one returns 422; an import item containing one is skipped as `invalid`. -- **Account deletion.** `DELETE /v2/me` deletes the user's - `legacy_user_mappings` rows (RESTRICT FK), then the user (identities and - associations cascade), then the Auth0 user through the Management API when - configured. If the Auth0 call fails after the data is gone, the route returns - 502 and a retry is safe. +- **Account deletion.** 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. 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 @@ -212,7 +215,21 @@ Configure staging first, then production: `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`. +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 account deletion returns 503 and deletes nothing. ## Rollout diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 8591795e9..00efcb230 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -691,7 +691,7 @@ def _default_v2_account_service_factory() -> V2AccountResourceService: return V2AccountService( AccountDatabaseSession(get_v2_session_factory()), legacy_references=_default_legacy_reference_checker(), - auth0_user_deleter=build_auth0_user_deleter(), + auth0_user_deleter_factory=build_auth0_user_deleter, ) diff --git a/policyengine_api/fastapi_routes/v2/me/routes.py b/policyengine_api/fastapi_routes/v2/me/routes.py index 0c351b5df..622d61a30 100644 --- a/policyengine_api/fastapi_routes/v2/me/routes.py +++ b/policyengine_api/fastapi_routes/v2/me/routes.py @@ -120,13 +120,20 @@ def patch() -> MeDetailResponse: "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 signed-in user and all of their saved records", description=( - "Deletes the user's legacy user mappings, then the user (identities " - "and associations cascade), then the Auth0 user when the Management " - "API is configured." + "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." ), ) def delete_me( diff --git a/policyengine_api/fastapi_routes/v2/user_resources.py b/policyengine_api/fastapi_routes/v2/user_resources.py index 166d6f694..77dbb03c2 100644 --- a/policyengine_api/fastapi_routes/v2/user_resources.py +++ b/policyengine_api/fastapi_routes/v2/user_resources.py @@ -27,6 +27,7 @@ LegacyReferenceNotFoundError, ) from policyengine_api.services.v2.user_accounts.validators import ( + AccountDeletionUnavailableError, AccountIdentityDeletionError, AccountNotFoundError, ) @@ -182,6 +183,8 @@ def user_resource_operation( 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) diff --git a/policyengine_api/libs/auth0_management.py b/policyengine_api/libs/auth0_management.py index 838d988bb..704a95dcd 100644 --- a/policyengine_api/libs/auth0_management.py +++ b/policyengine_api/libs/auth0_management.py @@ -190,14 +190,42 @@ def _access_token(self) -> str: self._expires_at = now + max(lifetime, TOKEN_REFRESH_MARGIN_SECONDS * 2) return token - def delete_user(self, subject: str) -> None: - """Delete one Auth0 user; an already-absent user counts as deleted.""" + 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. + """ - response = self._request( + 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 {self._access_token()}"}, + 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( @@ -222,14 +250,16 @@ def _shared_client(settings: Auth0ManagementSettings) -> Auth0ManagementClient: def build_auth0_user_deleter( environ: Mapping[str, str] | None = None, -) -> Callable[[str], None] | None: - """Return a deleter bound to the configured tenant, or ``None``. +) -> Auth0ManagementClient | None: + """Return the deleter for the configured tenant, or ``None``. - Every call with the same settings shares one client, so the Management - API token is fetched once and reused until shortly before it expires. + 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).delete_user + return _shared_client(settings) diff --git a/policyengine_api/services/v2/user_accounts/services.py b/policyengine_api/services/v2/user_accounts/services.py index 7306aa33f..8ffaa73a7 100644 --- a/policyengine_api/services/v2/user_accounts/services.py +++ b/policyengine_api/services/v2/user_accounts/services.py @@ -47,11 +47,13 @@ AccountDeletion, AccountExport, AccountImportInput, + Auth0UserDeleter, AccountImportResult, AccountRead, AccountUpdateInput, ) from policyengine_api.services.v2.user_accounts.validators import ( + AccountDeletionUnavailableError, AccountIdentityDeletionError, client_ref_conflict_skips, partition_import_items, @@ -93,11 +95,34 @@ def __init__( database_session: AccountDatabaseSession, *, legacy_references: LegacyReferenceChecker | None = None, - auth0_user_deleter: Callable[[str], None] | None = None, + auth0_user_deleter_factory: Callable[[], Auth0UserDeleter | None] | None = None, ) -> None: self._database_session = database_session self._legacy_references = legacy_references - self._auth0_user_deleter = auth0_user_deleter + 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: UserPrincipal, country_id: str) -> AccountRead: with self._database_session.transaction() as session: @@ -263,6 +288,7 @@ def import_records( ) def delete_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: @@ -282,7 +308,7 @@ def delete_account(self, *, principal: UserPrincipal) -> AccountDeletion: extra={"policyengine_attributes": {"v2_user_id": str(deleted_user_id)}}, ) - if self._auth0_user_deleter is None: + if auth0_user_deleter is None: logger.warning( "auth0_user_deletion_skipped_not_configured", extra={ @@ -298,7 +324,7 @@ def delete_account(self, *, principal: UserPrincipal) -> AccountDeletion: return AccountDeletion(user_id=deleted_user_id, auth0_subjects=()) try: for subject in subjects: - self._auth0_user_deleter(subject) + auth0_user_deleter.delete_user(subject) except Exception as error: logger.warning( "auth0_user_deletion_failed", diff --git a/policyengine_api/services/v2/user_accounts/types.py b/policyengine_api/services/v2/user_accounts/types.py index 0d3de7119..64c56baf4 100644 --- a/policyengine_api/services/v2/user_accounts/types.py +++ b/policyengine_api/services/v2/user_accounts/types.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime from enum import StrEnum -from typing import Annotated +from typing import Annotated, Protocol from uuid import UUID from pydantic import ( @@ -117,3 +117,12 @@ class AccountExport: 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 index 0bd5a7e42..a71829996 100644 --- a/policyengine_api/services/v2/user_accounts/validators.py +++ b/policyengine_api/services/v2/user_accounts/validators.py @@ -24,6 +24,10 @@ 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.""" + + ImportItemT = TypeVar("ImportItemT", bound=BaseModel) LegacyItemT = TypeVar("LegacyItemT") diff --git a/tests/integration/test_v2_user_identity_persistence.py b/tests/integration/test_v2_user_identity_persistence.py index 307b95483..082e594d0 100644 --- a/tests/integration/test_v2_user_identity_persistence.py +++ b/tests/integration/test_v2_user_identity_persistence.py @@ -316,10 +316,18 @@ def test_account_deletion_cascades_after_removing_legacy_mappings(sessions) -> N 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) + service = V2AccountService( AccountDatabaseSession(sessions), legacy_references=default_legacy_references(), - auth0_user_deleter=deleted_subjects.append, + auth0_user_deleter_factory=RecordingDeleter, ) alice_id = service.get_account(principal=alice, country_id="us").id bob_id = service.get_account(principal=bob, country_id="us").id diff --git a/tests/unit/libs/test_auth0_management.py b/tests/unit/libs/test_auth0_management.py index f5357406a..9bf779756 100644 --- a/tests/unit/libs/test_auth0_management.py +++ b/tests/unit/libs/test_auth0_management.py @@ -151,12 +151,89 @@ def record(request: httpx.Request) -> httpx.Response: first = build_auth0_user_deleter(environ) second = build_auth0_user_deleter(environ) assert first is not None and second is not None - first("auth0|1") - second("auth0|2") + first.delete_user("auth0|1") + second.delete_user("auth0|2") - assert first.__self__ is second.__self__ # type: ignore[attr-defined] + 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/v2/test_me_routes.py b/tests/unit/v2/test_me_routes.py index 22f4f9312..368657110 100644 --- a/tests/unit/v2/test_me_routes.py +++ b/tests/unit/v2/test_me_routes.py @@ -16,6 +16,7 @@ ImportSkipReason, ) from policyengine_api.services.v2.user_accounts.validators import ( + AccountDeletionUnavailableError, AccountIdentityDeletionError, ) from policyengine_api.services.v2.user_policies.types import UserPolicyRead @@ -158,6 +159,19 @@ def test_failed_auth0_deletion_reports_a_retryable_upstream_error() -> None: 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_import_returns_id_map_and_skipped_items() -> None: client, service = _client() diff --git a/tests/unit/v2/test_user_account_service.py b/tests/unit/v2/test_user_account_service.py index 636170cc9..465bc024e 100644 --- a/tests/unit/v2/test_user_account_service.py +++ b/tests/unit/v2/test_user_account_service.py @@ -36,6 +36,7 @@ AccountUpdateInput, ) from policyengine_api.services.v2.user_accounts.validators import ( + AccountDeletionUnavailableError, AccountIdentityDeletionError, ) from policyengine_api.services.v2.user_policies.database_session import ( @@ -80,13 +81,47 @@ def store(): yield selected -def _services(selected, *, deleter=None): +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), legacy_references=references, - auth0_user_deleter=deleter, + auth0_user_deleter_factory=deleter_factory, ), V2UserPolicyService(UserPolicyDatabaseSession(selected.sessions), references), V2UserSimulationService( @@ -467,10 +502,8 @@ def test_export_covers_every_country(store) -> None: def test_delete_removes_legacy_mappings_identities_and_associations(store) -> None: - deleted_subjects: list[str] = [] - accounts, policies, simulations, _reports = _services( - store, deleter=deleted_subjects.append - ) + 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, @@ -496,7 +529,8 @@ def test_delete_removes_legacy_mappings_identities_and_associations(store) -> No deletion = accounts.delete_account(principal=ALICE) assert deletion.user_id == account.id - assert deleted_subjects == [ALICE.subject, "google-oauth2|alice-linked"] + 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() == [] @@ -514,9 +548,9 @@ def test_delete_without_management_api_or_with_failures(store) -> None: assert accounts.delete_account(principal=ALICE).auth0_subjects == () assert accounts.delete_account(principal=ALICE).user_id is None - def failing_deleter(_subject: str) -> None: - raise RuntimeError("management api down") - + 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): @@ -553,3 +587,65 @@ def unavailable(): 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") + accounts.import_records( + principal=ALICE, country_id="us", import_input=_import_input() + ) From 4edf9bf106204780a82a1073d7e158ce6d005b9d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:07:21 +0400 Subject: [PATCH 23/35] Keep compute labels off saved-work requests and describe the size check accurately User-simulation, user-report, user-household, and /v2/me requests only store and read saved work, but logged the simulation flow and compute selection of their route groups (economy, report, household, and old_gateway). The migration context now takes include_compute, and request logging turns it off for the signed-in user resources while compute routes in the same groups keep their labels. The Stage 12A doc said the size check runs before body validation. FastAPI parses the whole body before any dependency, so the doc now says what the check bounds and what it does not. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-12a-user-login.md | 8 +++++-- policyengine_api/migration_flags.py | 14 ++++++++++-- policyengine_api/migration_logging.py | 7 ++++++ .../routes/test_migration_context_logging.py | 22 +++++++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index 0cfd33f1c..5e7fbceb1 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -53,8 +53,12 @@ Signed-out behavior is unchanged. `user_policy_client_ref` resolves only against the user's policies in the import country. - **Request bounds.** Every `/v2/me*` and `/v2/user-*` write rejects a body - over 256 KiB with 413 (imports allow 1 MiB). The check runs after - authentication and before body validation or any service work. Caller + over 256 KiB with 413 (imports allow 1 MiB). 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 on single writes and imports alike: `description` and `source_note` at 10,000 characters, report `provisions` at 64 KiB serialized, and the report `validation` snapshot at 500 calibration matches 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 90528f589..d5f1e7e8c 100644 --- a/policyengine_api/migration_logging.py +++ b/policyengine_api/migration_logging.py @@ -48,6 +48,12 @@ "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"}) @@ -200,4 +206,5 @@ def _migration_context( 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/tests/unit/routes/test_migration_context_logging.py b/tests/unit/routes/test_migration_context_logging.py index d7b33eaf6..802d6eeaa 100644 --- a/tests/unit/routes/test_migration_context_logging.py +++ b/tests/unit/routes/test_migration_context_logging.py @@ -496,6 +496,7 @@ def test_v2_household_resources_log_actual_supabase_read_and_write_sources( ("GET", "/v2/me/export", "user_profile", "supabase", None), ("POST", "/v2/me/imports", "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", @@ -552,6 +553,9 @@ def test_signed_in_user_resources_log_supabase_sources( "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, } @@ -660,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 From 9c00c1883400c5c7f451cd9b736cf73297830dbf Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:08:37 +0400 Subject: [PATCH 24/35] Require an edge guard before any application rollback with signed-in users The previous Public API revision serves /v2/user-policies and /v2/user-households without authentication. The reviewer reproduced an anonymous PATCH and DELETE against the migrated schema, so the documented "rolling back the application is safe" would have handed signed-in users' saved work to anyone with a record ID. Add a ready-to-apply Cloud Armor proposal that denies /v2/me* and /v2/user-* with 403 at priority 900, with the ingress change that closes the *.run.app bypass, and rewrite the Stage 12A rollback guidance: roll forward when possible; once any signed-in user exists, apply the guard before shifting traffic back, accepting that saved work is unavailable while it holds; a database downgrade stays lossy. The doc also records that no other previous-revision route exposes Stage 12A data. Co-Authored-By: Claude Opus 5.5 --- ...3-pol-api-lb-stage-12a-rollback-guard.yaml | 112 ++++++++++++++++++ docs/migration/stage-12a-user-login.md | 68 ++++++++--- 2 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 docs/migration/armor/proposed/20261013-pol-api-lb-stage-12a-rollback-guard.yaml 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..e1356e270 --- /dev/null +++ b/docs/migration/armor/proposed/20261013-pol-api-lb-stage-12a-rollback-guard.yaml @@ -0,0 +1,112 @@ +# 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 signed-in user +# exists. Those revisions serve /v2/user-policies and /v2/user-households +# without authentication: anyone could list, read, create, rename or delete a +# signed-in user's saved work by user or record ID. This rule denies every +# signed-in user route at the edge instead. Saved work is unavailable while it +# is in place; signed-out flows and v1 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 throttles (priorities 1150 and 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 signed-in user 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 signed-in user 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-12a-user-login.md b/docs/migration/stage-12a-user-login.md index 5e7fbceb1..9bf0941cc 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -152,26 +152,58 @@ and production and confirm both dormant tables are empty. ### Rollback -The revision is additive for everything the previous Public API revision maps: -it drops no table or column and makes no nullable column required. The -lifecycle test `test_stage_12a_revision_keeps_the_previous_revision_loadable` -enforces this. - -- **Rolling back the application is safe without a database downgrade.** The - previous revision serves its routes against this schema, and deleting a user - through its models still cascades through the simulation and report - associations. -- **One exception.** The previous revision's unauthenticated +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 signed-in users' saved work to anyone. + +- **Before any signed-in user exists, an application rollback is safe without + a database downgrade.** Treat signed-in users as existing from the moment an + environment has `PUBLIC_API_AUTH_ISSUER` configured, because from then on + anyone with an Auth0 account can provision one. +- **Once any signed-in user exists, 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*` 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: app v3 shows its + account error and signed-in saves fail. Signed-out flows, 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 writes only legacy transition users, and no signed-in 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`. Once a signed-in user owns a legacy-only row (a - v1 reference with no v2 copy), any of those responses that includes the row - fails with 500 under the previous revision. Nothing else is affected, and the - current production app does not call those routes. + `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`, and `country`, so it fails while legacy-only rows or any simulation or report associations exist. - Removing them first deletes users' saved work. Prefer an application - rollback. + Removing them first deletes users' saved work. + +### Schema compatibility + +The revision is additive for everything the previous Public API revision maps: +it drops no table or column and makes no nullable column required. The +lifecycle test `test_stage_12a_revision_keeps_the_previous_revision_loadable` +compares the column sets and nullability at the parent revision and at head. ## Settings @@ -253,6 +285,10 @@ Configure staging first, then production: 6. Apply the proposed Cloud Armor throttles in preview (`docs/migration/armor/proposed/20261012-pol-api-lb-stage-12a-user-writes.yaml`) and enforce them only after the runbook's checks. +7. Keep the rollback guard + (`docs/migration/armor/proposed/20261013-pol-api-lb-stage-12a-rollback-guard.yaml`) + ready. From the first signed-in user on, 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 does From 57553edae3dc5f57a46a2520bc75b34c4d355379 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:13:19 +0400 Subject: [PATCH 25/35] Exercise the previous revision's table usage against the Stage 12A schema The compatibility test only compared column sets and nullability. It now reflects the parent revision's schema, upgrades to head, and through the reflected parent tables inserts a row into every table the parent maps using only the parent's required columns, reads it back with every parent column, rewrites every non-key column, and deletes it. It also deletes, through the parent's users table, a user who owns rows in all four association tables and a user_identities row, and checks that the cascades run. It fails if head drops a parent column or adds a required column without a server default to a table the parent revision writes; only the two dormant association tables, which the parent never inserts into, may gain one. Run against the pre-fix revision that dropped country and label, the test fails. The Stage 12A doc now describes what the test checks. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-12a-user-login.md | 30 +- .../integration/test_alembic_v2_lifecycle.py | 298 ++++++++++++++++-- 2 files changed, 291 insertions(+), 37 deletions(-) diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index 9bf0941cc..dc03080cd 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -201,9 +201,30 @@ would therefore hand signed-in users' saved work to anyone. ### Schema compatibility The revision is additive for everything the previous Public API revision maps: -it drops no table or column and makes no nullable column required. The -lifecycle test `test_stage_12a_revision_keeps_the_previous_revision_loadable` -compares the column sets and nullability at the parent revision and at head. +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. + +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 @@ -317,6 +338,9 @@ The disposable-Postgres suite proves the following: - 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, and an import skips the item holding one; +- the parent revision's table usage works against head (see "Schema + compatibility"); - the qualification transaction is read-only. ## Deferred diff --git a/tests/integration/test_alembic_v2_lifecycle.py b/tests/integration/test_alembic_v2_lifecycle.py index 3e15081ef..07fb20c46 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 @@ -178,18 +181,6 @@ def _assert_stage_12a_association_shape(engine) -> None: assert columns["label"]["nullable"] -def _column_nullability(engine) -> dict[str, dict[str, bool]]: - inspector = inspect(engine) - return { - table_name: { - column["name"]: column["nullable"] - for column in inspector.get_columns(table_name, schema="public") - } - for table_name in inspector.get_table_names(schema="public") - if table_name != "alembic_version" - } - - def test_empty_upgrade_check_base_downgrade_and_reupgrade() -> None: database_url = _disposable_url() config = _config() @@ -380,13 +371,211 @@ def test_empty_upgrade_check_base_downgrade_and_reupgrade() -> None: engine.dispose() -def test_stage_12a_revision_keeps_the_previous_revision_loadable() -> None: - """The previous Public API revision still maps every parent-revision column. +# 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) + } + - During a deploy or an application rollback that revision reads rows the - Stage 12A schema holds, so the revision may add columns and relax - constraints but must not drop a table or column or make a nullable column - required. +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() @@ -395,25 +584,66 @@ def test_stage_12a_revision_keeps_the_previous_revision_loadable() -> None: try: command.downgrade(config, STAGE_12A_PREVIOUS_REVISION) - previous = _column_nullability(engine) - + parent = sa.MetaData() + parent.reflect(engine) command.upgrade(config, "head") - current = _column_nullability(engine) + head = sa.MetaData() + head.reflect(engine) + parent_tables = [ + table for table in parent.sorted_tables if table.name != "alembic_version" + ] - missing = sorted( - f"{table_name}.{column_name}" - for table_name, columns in previous.items() - for column_name in columns - if column_name not in current.get(table_name, {}) + 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 ) - newly_required = sorted( - f"{table_name}.{column_name}" - for table_name, columns in previous.items() - for column_name, nullable in columns.items() - if nullable and current.get(table_name, {}).get(column_name) is False - ) - assert missing == [] - assert newly_required == [] + 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() From fd6b9b97b245368ab358c4cd4d6ec2f3f8c783de Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:07:56 +0400 Subject: [PATCH 26/35] Report account_created on /v2/me so app v3 imports only for new accounts App v3 now calls GET /v2/me at the sign-in callback and offers to import the browser's signed-out reforms and reports only when that sign-in created the account. MeItem gains account_created, true only in the response to the request that inserted the users and user_identities rows. Identity resolution already returns that fact as ResolvedUser.provisioned, so it is threaded into AccountRead without another query. PATCH /v2/me and the export's user item follow the same rule. Concurrent first requests that lose the insert race and read the winner's rows back report false; a Postgres test confirms exactly one of eight concurrent first requests reports true. The Stage 12A doc now names app v3 as the only consumer, describes the sign-in check and the one-time import for new accounts, and notes that /v2/user-households and /v2/user-simulations have no app consumer yet. The contract registry and generated artifacts include the new field. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/migration-contracts.md | 4 +-- docs/generated/migration_contracts.json | 6 ++-- docs/migration/stage-12a-user-login.md | 34 +++++++++++++++---- .../fastapi_routes/v2/me/response_models.py | 10 +++++- .../fastapi_routes/v2/me/routes.py | 8 ++++- .../services/v2/identity/types.py | 7 +++- .../services/v2/user_accounts/services.py | 6 ++-- .../v2/user_accounts/transformations.py | 6 ++-- .../services/v2/user_accounts/types.py | 2 ++ tests/contract/registry.py | 2 ++ .../test_v2_user_identity_persistence.py | 23 +++++++++++++ tests/unit/v2/test_me_routes.py | 21 ++++++++++++ tests/unit/v2/test_user_account_service.py | 24 +++++++++++++ 13 files changed, 134 insertions(+), 19 deletions(-) diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index 21679b6da..f6451e690 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -141,8 +141,8 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | 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.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?country_id=us` | 200 | `user_profile` | `Authorization` | `status`, `message`, `result.item.id`, `result.item.primary_country`, `result.item.first_name`, `result.item.last_name`, `result.item.email`, `result.item.created_at`, `result.item.account_created` | +| `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`, `result.item.account_created` | | `GET` | `/v2/me/export?country_id=us` | 200 | `user_profile` | `Authorization` | `status`, `message`, `result.user`, `result.policies`, `result.households`, `result.simulations`, `result.reports` | | `POST` | `/v2/me/imports?country_id=us` | 200 | `user_profile` | `Authorization` | `status`, `message`, `result.id_map`, `result.skipped` | | `DELETE` | `/v2/me?country_id=us` | 204 | `user_profile` | `Authorization` | | diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index b1082c1c1..7f3f5562f 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -772,7 +772,8 @@ "result.item.first_name", "result.item.last_name", "result.item.email", - "result.item.created_at" + "result.item.created_at", + "result.item.account_created" ] }, { @@ -789,7 +790,8 @@ "result.item.id", "result.item.primary_country", "result.item.first_name", - "result.item.last_name" + "result.item.last_name", + "result.item.account_created" ] }, { diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index dc03080cd..c1ceebda3 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -6,6 +6,21 @@ 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. Signed-out behavior is unchanged. +## Consumers + +Only app v3 (the flagship shell in `policyengine-app-v2`) consumes these +routes. The legacy builder stays browser-only, signed in or not. + +- **Sign-in.** App v3 calls `GET /v2/me` at the sign-in callback, and again on + the account page. The `account_created` field in the sign-in response tells + it whether this sign-in just created the PolicyEngine account. +- **Import.** Only when `account_created` is true does app v3 offer to import + this browser's signed-out v3 reforms and reports through + `POST /v2/me/imports`. The import therefore runs once, for new accounts. +- **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. + ## What changed - **Inbound authentication.** `/v2/me*` and `/v2/user-*` require @@ -25,10 +40,14 @@ Signed-out behavior is unchanged. `(issuer, subject)` to `users.id`. The first authenticated request inserts the 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. Logs carry - only the v2 user UUID. + re-reads, so exactly one user is created. The `/v2/me` item's + `account_created` is true only in the response to the request that inserted + the rows; every other response, including the concurrent requests that lost + the race, has false. `PATCH /v2/me` and the export's `user` item follow the + same rule, since either can be the provisioning request. `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. Logs carry only the v2 user UUID. - **Ownership.** Request bodies and queries no longer accept `user_id`; the owner always comes from the token. Another user's record returns 404. - **v1 references.** Each association accepts a `legacy_*_id` (a country-scoped @@ -83,8 +102,8 @@ contract shared with `policyengine-app-v2`. The generated route inventory is in | Route | Behavior | | --- | --- | -| `GET /v2/me?country_id=` | Returns the user; provisions on first call | -| `PATCH /v2/me` | Updates `first_name`, `last_name`, `primary_country` | +| `GET /v2/me?country_id=` | Returns the user; provisions on first call, and only that response has `account_created: true` | +| `PATCH /v2/me` | Updates `first_name`, `last_name`, `primary_country`; reports `account_created` by the same rule | | `GET /v2/me/export` | Returns the user and every association, all countries | | `POST /v2/me/imports` | Idempotent bulk import; returns `id_map` and `skipped` | | `DELETE /v2/me` | 204; deletes data, then the Auth0 user when configured | @@ -332,7 +351,8 @@ python scripts/run_quality_guards.py The disposable-Postgres suite proves the following: -- concurrent first sign-ins create one user; +- concurrent first sign-ins create one user, and exactly one of them reports + `account_created: true`; - concurrent imports are idempotent; - imports never reuse or link a `client_ref` from another country; - named `ON CONFLICT` targets work; diff --git a/policyengine_api/fastapi_routes/v2/me/response_models.py b/policyengine_api/fastapi_routes/v2/me/response_models.py index b76c5e081..549b85b85 100644 --- a/policyengine_api/fastapi_routes/v2/me/response_models.py +++ b/policyengine_api/fastapi_routes/v2/me/response_models.py @@ -6,7 +6,7 @@ from typing import Generic, Literal, TypeVar from uuid import UUID -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from policyengine_api.fastapi_routes.v2.user_households.response_models import ( UserHouseholdItem, @@ -39,6 +39,14 @@ class MeItem(StrictAccountAPIModel): last_name: str | None email: str | None created_at: datetime + account_created: bool = Field( + description=( + "True only in the response to the request that created this " + "PolicyEngine account on first sign-in; false in every other " + "response, including concurrent first requests that did not create " + "it." + ) + ) @classmethod def from_read(cls, item: AccountRead) -> "MeItem": diff --git a/policyengine_api/fastapi_routes/v2/me/routes.py b/policyengine_api/fastapi_routes/v2/me/routes.py index 622d61a30..f549d842c 100644 --- a/policyengine_api/fastapi_routes/v2/me/routes.py +++ b/policyengine_api/fastapi_routes/v2/me/routes.py @@ -71,7 +71,9 @@ def build_v2_me_router(dependencies: NativeRouteDependencies) -> APIRouter: summary="Read (and on first use provision) the signed-in user", description=( "Returns the PolicyEngine user for the bearer token, creating it on " - "the first call with primary_country set from country_id." + "the first call with primary_country set from country_id. " + "account_created is true only in the response to the request that " + "created the account." ), ) def get_me( @@ -91,6 +93,10 @@ def read() -> MeDetailResponse: "/me", response_model=MeDetailResponse, summary="Update the signed-in user's names or primary country", + description=( + "Provisions the user on first use like GET /v2/me, and reports " + "account_created by the same rule." + ), ) def patch_me( body: AccountPatchRequest, diff --git a/policyengine_api/services/v2/identity/types.py b/policyengine_api/services/v2/identity/types.py index b10afee06..e11c1eaea 100644 --- a/policyengine_api/services/v2/identity/types.py +++ b/policyengine_api/services/v2/identity/types.py @@ -25,7 +25,12 @@ class UserPrincipal(BaseModel): @dataclass(frozen=True) class ResolvedUser: - """The v2 user that owns one verified sign-in identity.""" + """The v2 user that owns one verified sign-in identity. + + ``provisioned`` is true only when this resolution inserted the user and + identity rows. A concurrent request that lost the insert race and read the + winner's rows back resolves with ``provisioned`` false. + """ user_id: UUID email: str | None diff --git a/policyengine_api/services/v2/user_accounts/services.py b/policyengine_api/services/v2/user_accounts/services.py index 8ffaa73a7..d11f59620 100644 --- a/policyengine_api/services/v2/user_accounts/services.py +++ b/policyengine_api/services/v2/user_accounts/services.py @@ -132,7 +132,7 @@ def get_account(self, *, principal: UserPrincipal, country_id: str) -> AccountRe user = require_account( read_account_user(session, owner.user_id), user_id=owner.user_id ) - return account_read(user, email=owner.email) + return account_read(user, owner=owner) def update_account( self, @@ -152,7 +152,7 @@ def update_account( update_account_user( session, user, account_update_values(account_input) ), - email=owner.email, + owner=owner, ) def export_account( @@ -166,7 +166,7 @@ def export_account( read_account_user(session, owner.user_id), user_id=owner.user_id ) return AccountExport( - user=account_read(user, email=owner.email), + user=account_read(user, owner=owner), 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), diff --git a/policyengine_api/services/v2/user_accounts/transformations.py b/policyengine_api/services/v2/user_accounts/transformations.py index c96e6e585..e6dec8544 100644 --- a/policyengine_api/services/v2/user_accounts/transformations.py +++ b/policyengine_api/services/v2/user_accounts/transformations.py @@ -5,20 +5,22 @@ from typing import Any from policyengine_api.data.v2.models import User +from policyengine_api.services.v2.identity.types import ResolvedUser from policyengine_api.services.v2.user_accounts.types import ( AccountRead, AccountUpdateInput, ) -def account_read(user: User, *, email: str | None) -> AccountRead: +def account_read(user: User, *, owner: ResolvedUser) -> AccountRead: return AccountRead( id=user.id, 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, + email=owner.email if owner.email is not None else user.email, created_at=user.created_at, + account_created=owner.provisioned, ) diff --git a/policyengine_api/services/v2/user_accounts/types.py b/policyengine_api/services/v2/user_accounts/types.py index 64c56baf4..9d6cbb986 100644 --- a/policyengine_api/services/v2/user_accounts/types.py +++ b/policyengine_api/services/v2/user_accounts/types.py @@ -102,6 +102,8 @@ class AccountRead: last_name: str | None email: str | None created_at: datetime + # True only in the response to the request that provisioned the account. + account_created: bool @dataclass(frozen=True) diff --git a/tests/contract/registry.py b/tests/contract/registry.py index 45305530f..fe5d2b772 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -415,6 +415,7 @@ class WorkflowContract: "result.item.last_name", "result.item.email", "result.item.created_at", + "result.item.account_created", ), route_group="user_profile", headers=USER_AUTH_HEADERS, @@ -430,6 +431,7 @@ class WorkflowContract: "result.item.primary_country", "result.item.first_name", "result.item.last_name", + "result.item.account_created", ), route_group="user_profile", headers=USER_AUTH_HEADERS, diff --git a/tests/integration/test_v2_user_identity_persistence.py b/tests/integration/test_v2_user_identity_persistence.py index 082e594d0..b022aa939 100644 --- a/tests/integration/test_v2_user_identity_persistence.py +++ b/tests/integration/test_v2_user_identity_persistence.py @@ -129,6 +129,29 @@ def first_request(_index: int): ) +def test_exactly_one_concurrent_first_request_reports_account_created( + sessions, +) -> None: + principal = _principal(sessions, "google-oauth2|first-sign-in-race") + service = V2AccountService(AccountDatabaseSession(sessions)) + workers = 8 + barrier = Barrier(workers) + + def first_sign_in(_index: int): + barrier.wait() + return service.get_account(principal=principal, country_id="us") + + with ThreadPoolExecutor(max_workers=workers) as executor: + accounts = list(executor.map(first_sign_in, range(workers))) + + assert len({account.id for account in accounts}) == 1 + assert [account.account_created for account in accounts].count(True) == 1 + assert ( + service.get_account(principal=principal, country_id="us").account_created + is False + ) + + def test_concurrent_imports_are_idempotent_on_client_ref(sessions) -> None: principal = _principal(sessions, "google-oauth2|importer") service = V2AccountService( diff --git a/tests/unit/v2/test_me_routes.py b/tests/unit/v2/test_me_routes.py index 368657110..6c130b0b7 100644 --- a/tests/unit/v2/test_me_routes.py +++ b/tests/unit/v2/test_me_routes.py @@ -41,6 +41,7 @@ def _account(**changes) -> AccountRead: "last_name": None, "email": None, "created_at": CREATED_AT, + "account_created": False, } values.update(changes) return AccountRead(**values) @@ -96,6 +97,7 @@ def test_get_me_provisions_with_the_query_country() -> None: "last_name": None, "email": None, "created_at": "2026-01-01T00:00:00Z", + "account_created": False, } }, } @@ -104,6 +106,25 @@ def test_get_me_provisions_with_the_query_country() -> None: ] +@pytest.mark.parametrize( + ("method", "operation", "body"), + [ + ("GET", "get_account", None), + ("PATCH", "update_account", {"first_name": "Ada"}), + ], +) +def test_me_reports_account_created_from_the_service(method, operation, body) -> None: + service = RecordingService( + lambda name, _kwargs: _account(account_created=name == operation) + ) + client, _service = _client(service) + + response = client.request(method, "/v2/me?country_id=us", json=body) + + assert response.status_code == 200 + assert response.json()["result"]["item"]["account_created"] is True + + def test_patch_me_accepts_names_and_country_only() -> None: client, service = _client() diff --git a/tests/unit/v2/test_user_account_service.py b/tests/unit/v2/test_user_account_service.py index 465bc024e..afd17d48f 100644 --- a/tests/unit/v2/test_user_account_service.py +++ b/tests/unit/v2/test_user_account_service.py @@ -140,11 +140,35 @@ def test_first_call_provisions_once_and_later_calls_reuse_the_user(store) -> Non assert first.id == second.id != other.id assert first.primary_country == second.primary_country == "uk" + assert (first.account_created, second.account_created) == (True, False) + assert other.account_created is True with store.sessions() as session: assert len(session.exec(select(User)).all()) == 2 assert len(session.exec(select(UserIdentity)).all()) == 2 +def test_account_created_follows_whichever_account_route_provisions(store) -> None: + accounts, *_ = _services(store) + + patched_first = accounts.update_account( + principal=ALICE, + country_id="us", + account_input=AccountUpdateInput(first_name="Ada"), + ) + patched_again = accounts.update_account( + principal=ALICE, + country_id="us", + account_input=AccountUpdateInput(last_name="Lovelace"), + ) + read_after_patch = accounts.get_account(principal=ALICE, country_id="us") + exported_first = accounts.export_account(principal=BOB, country_id="us") + + assert patched_first.account_created is True + assert patched_again.account_created is False + assert read_after_patch.account_created is False + assert exported_first.user.account_created is True + + def test_concurrent_first_calls_converge_on_one_user(store, monkeypatch) -> None: original = identity_services.read_user_identity reads = {"count": 0} From 7bd6814548f5b08ded1132b78ea286255ab27e3c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:18:57 +0400 Subject: [PATCH 27/35] Drop account_created from /v2/me now that app v3 imports at every sign-in This reverts fd6b9b97. App v3 no longer limits the import to the sign-in that created the account: every sign-in moves the browser's signed-out v3 reforms and reports into the account, removes each item whose client_ref comes back in id_map, and keeps the rest for the next sign-in. Nothing reads account_created any more, so MeItem, PATCH /v2/me, the export's user item, the contract registry and the generated artifacts drop it, along with its tests. The Stage 12A doc's Consumers section now describes the import at every sign-in and the account page's GET /v2/me. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/migration-contracts.md | 4 +-- docs/generated/migration_contracts.json | 6 ++-- docs/migration/stage-12a-user-login.md | 32 ++++++++----------- .../fastapi_routes/v2/me/response_models.py | 10 +----- .../fastapi_routes/v2/me/routes.py | 8 +---- .../services/v2/identity/types.py | 7 +--- .../services/v2/user_accounts/services.py | 6 ++-- .../v2/user_accounts/transformations.py | 6 ++-- .../services/v2/user_accounts/types.py | 2 -- tests/contract/registry.py | 2 -- .../test_v2_user_identity_persistence.py | 23 ------------- tests/unit/v2/test_me_routes.py | 21 ------------ tests/unit/v2/test_user_account_service.py | 24 -------------- 13 files changed, 26 insertions(+), 125 deletions(-) diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index f6451e690..21679b6da 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -141,8 +141,8 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | 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.primary_country`, `result.item.first_name`, `result.item.last_name`, `result.item.email`, `result.item.created_at`, `result.item.account_created` | -| `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`, `result.item.account_created` | +| `GET` | `/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`, `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` | | `POST` | `/v2/me/imports?country_id=us` | 200 | `user_profile` | `Authorization` | `status`, `message`, `result.id_map`, `result.skipped` | | `DELETE` | `/v2/me?country_id=us` | 204 | `user_profile` | `Authorization` | | diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index 7f3f5562f..b1082c1c1 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -772,8 +772,7 @@ "result.item.first_name", "result.item.last_name", "result.item.email", - "result.item.created_at", - "result.item.account_created" + "result.item.created_at" ] }, { @@ -790,8 +789,7 @@ "result.item.id", "result.item.primary_country", "result.item.first_name", - "result.item.last_name", - "result.item.account_created" + "result.item.last_name" ] }, { diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index c1ceebda3..4eefc3ddf 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -11,12 +11,13 @@ Signed-out behavior is unchanged. Only app v3 (the flagship shell in `policyengine-app-v2`) consumes these routes. The legacy builder stays browser-only, signed in or not. -- **Sign-in.** App v3 calls `GET /v2/me` at the sign-in callback, and again on - the account page. The `account_created` field in the sign-in response tells - it whether this sign-in just created the PolicyEngine account. -- **Import.** Only when `account_created` is true does app v3 offer to import - this browser's signed-out v3 reforms and reports through - `POST /v2/me/imports`. The import therefore runs once, for new accounts. +- **Import.** At every sign-in, app v3 moves the v3 reforms and reports saved + in that browser while signed out into the account through + `POST /v2/me/imports`. It removes from the browser each item whose + `client_ref` comes back in `id_map`, and keeps the rest for the next + sign-in. Because imports are idempotent on `client_ref`, an item sent again + returns its existing ID. +- **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. @@ -40,14 +41,10 @@ routes. The legacy builder stays browser-only, signed in or not. `(issuer, subject)` to `users.id`. The first authenticated request inserts the 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. The `/v2/me` item's - `account_created` is true only in the response to the request that inserted - the rows; every other response, including the concurrent requests that lost - the race, has false. `PATCH /v2/me` and the export's `user` item follow the - same rule, since either can be the provisioning request. `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. Logs carry only the v2 user UUID. + 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. Logs carry + only the v2 user UUID. - **Ownership.** Request bodies and queries no longer accept `user_id`; the owner always comes from the token. Another user's record returns 404. - **v1 references.** Each association accepts a `legacy_*_id` (a country-scoped @@ -102,8 +99,8 @@ contract shared with `policyengine-app-v2`. The generated route inventory is in | Route | Behavior | | --- | --- | -| `GET /v2/me?country_id=` | Returns the user; provisions on first call, and only that response has `account_created: true` | -| `PATCH /v2/me` | Updates `first_name`, `last_name`, `primary_country`; reports `account_created` by the same rule | +| `GET /v2/me?country_id=` | Returns the user; provisions on first call | +| `PATCH /v2/me` | Updates `first_name`, `last_name`, `primary_country` | | `GET /v2/me/export` | Returns the user and every association, all countries | | `POST /v2/me/imports` | Idempotent bulk import; returns `id_map` and `skipped` | | `DELETE /v2/me` | 204; deletes data, then the Auth0 user when configured | @@ -351,8 +348,7 @@ python scripts/run_quality_guards.py The disposable-Postgres suite proves the following: -- concurrent first sign-ins create one user, and exactly one of them reports - `account_created: true`; +- concurrent first sign-ins create one user; - concurrent imports are idempotent; - imports never reuse or link a `client_ref` from another country; - named `ON CONFLICT` targets work; diff --git a/policyengine_api/fastapi_routes/v2/me/response_models.py b/policyengine_api/fastapi_routes/v2/me/response_models.py index 549b85b85..b76c5e081 100644 --- a/policyengine_api/fastapi_routes/v2/me/response_models.py +++ b/policyengine_api/fastapi_routes/v2/me/response_models.py @@ -6,7 +6,7 @@ from typing import Generic, Literal, TypeVar from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict from policyengine_api.fastapi_routes.v2.user_households.response_models import ( UserHouseholdItem, @@ -39,14 +39,6 @@ class MeItem(StrictAccountAPIModel): last_name: str | None email: str | None created_at: datetime - account_created: bool = Field( - description=( - "True only in the response to the request that created this " - "PolicyEngine account on first sign-in; false in every other " - "response, including concurrent first requests that did not create " - "it." - ) - ) @classmethod def from_read(cls, item: AccountRead) -> "MeItem": diff --git a/policyengine_api/fastapi_routes/v2/me/routes.py b/policyengine_api/fastapi_routes/v2/me/routes.py index f549d842c..622d61a30 100644 --- a/policyengine_api/fastapi_routes/v2/me/routes.py +++ b/policyengine_api/fastapi_routes/v2/me/routes.py @@ -71,9 +71,7 @@ def build_v2_me_router(dependencies: NativeRouteDependencies) -> APIRouter: summary="Read (and on first use provision) the signed-in user", description=( "Returns the PolicyEngine user for the bearer token, creating it on " - "the first call with primary_country set from country_id. " - "account_created is true only in the response to the request that " - "created the account." + "the first call with primary_country set from country_id." ), ) def get_me( @@ -93,10 +91,6 @@ def read() -> MeDetailResponse: "/me", response_model=MeDetailResponse, summary="Update the signed-in user's names or primary country", - description=( - "Provisions the user on first use like GET /v2/me, and reports " - "account_created by the same rule." - ), ) def patch_me( body: AccountPatchRequest, diff --git a/policyengine_api/services/v2/identity/types.py b/policyengine_api/services/v2/identity/types.py index e11c1eaea..b10afee06 100644 --- a/policyengine_api/services/v2/identity/types.py +++ b/policyengine_api/services/v2/identity/types.py @@ -25,12 +25,7 @@ class UserPrincipal(BaseModel): @dataclass(frozen=True) class ResolvedUser: - """The v2 user that owns one verified sign-in identity. - - ``provisioned`` is true only when this resolution inserted the user and - identity rows. A concurrent request that lost the insert race and read the - winner's rows back resolves with ``provisioned`` false. - """ + """The v2 user that owns one verified sign-in identity.""" user_id: UUID email: str | None diff --git a/policyengine_api/services/v2/user_accounts/services.py b/policyengine_api/services/v2/user_accounts/services.py index d11f59620..8ffaa73a7 100644 --- a/policyengine_api/services/v2/user_accounts/services.py +++ b/policyengine_api/services/v2/user_accounts/services.py @@ -132,7 +132,7 @@ def get_account(self, *, principal: UserPrincipal, country_id: str) -> AccountRe user = require_account( read_account_user(session, owner.user_id), user_id=owner.user_id ) - return account_read(user, owner=owner) + return account_read(user, email=owner.email) def update_account( self, @@ -152,7 +152,7 @@ def update_account( update_account_user( session, user, account_update_values(account_input) ), - owner=owner, + email=owner.email, ) def export_account( @@ -166,7 +166,7 @@ def export_account( read_account_user(session, owner.user_id), user_id=owner.user_id ) return AccountExport( - user=account_read(user, owner=owner), + 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), diff --git a/policyengine_api/services/v2/user_accounts/transformations.py b/policyengine_api/services/v2/user_accounts/transformations.py index e6dec8544..c96e6e585 100644 --- a/policyengine_api/services/v2/user_accounts/transformations.py +++ b/policyengine_api/services/v2/user_accounts/transformations.py @@ -5,22 +5,20 @@ from typing import Any from policyengine_api.data.v2.models import User -from policyengine_api.services.v2.identity.types import ResolvedUser from policyengine_api.services.v2.user_accounts.types import ( AccountRead, AccountUpdateInput, ) -def account_read(user: User, *, owner: ResolvedUser) -> AccountRead: +def account_read(user: User, *, email: str | None) -> AccountRead: return AccountRead( id=user.id, primary_country=user.primary_country, first_name=user.first_name, last_name=user.last_name, - email=owner.email if owner.email is not None else user.email, + email=email if email is not None else user.email, created_at=user.created_at, - account_created=owner.provisioned, ) diff --git a/policyengine_api/services/v2/user_accounts/types.py b/policyengine_api/services/v2/user_accounts/types.py index 9d6cbb986..64c56baf4 100644 --- a/policyengine_api/services/v2/user_accounts/types.py +++ b/policyengine_api/services/v2/user_accounts/types.py @@ -102,8 +102,6 @@ class AccountRead: last_name: str | None email: str | None created_at: datetime - # True only in the response to the request that provisioned the account. - account_created: bool @dataclass(frozen=True) diff --git a/tests/contract/registry.py b/tests/contract/registry.py index fe5d2b772..45305530f 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -415,7 +415,6 @@ class WorkflowContract: "result.item.last_name", "result.item.email", "result.item.created_at", - "result.item.account_created", ), route_group="user_profile", headers=USER_AUTH_HEADERS, @@ -431,7 +430,6 @@ class WorkflowContract: "result.item.primary_country", "result.item.first_name", "result.item.last_name", - "result.item.account_created", ), route_group="user_profile", headers=USER_AUTH_HEADERS, diff --git a/tests/integration/test_v2_user_identity_persistence.py b/tests/integration/test_v2_user_identity_persistence.py index b022aa939..082e594d0 100644 --- a/tests/integration/test_v2_user_identity_persistence.py +++ b/tests/integration/test_v2_user_identity_persistence.py @@ -129,29 +129,6 @@ def first_request(_index: int): ) -def test_exactly_one_concurrent_first_request_reports_account_created( - sessions, -) -> None: - principal = _principal(sessions, "google-oauth2|first-sign-in-race") - service = V2AccountService(AccountDatabaseSession(sessions)) - workers = 8 - barrier = Barrier(workers) - - def first_sign_in(_index: int): - barrier.wait() - return service.get_account(principal=principal, country_id="us") - - with ThreadPoolExecutor(max_workers=workers) as executor: - accounts = list(executor.map(first_sign_in, range(workers))) - - assert len({account.id for account in accounts}) == 1 - assert [account.account_created for account in accounts].count(True) == 1 - assert ( - service.get_account(principal=principal, country_id="us").account_created - is False - ) - - def test_concurrent_imports_are_idempotent_on_client_ref(sessions) -> None: principal = _principal(sessions, "google-oauth2|importer") service = V2AccountService( diff --git a/tests/unit/v2/test_me_routes.py b/tests/unit/v2/test_me_routes.py index 6c130b0b7..368657110 100644 --- a/tests/unit/v2/test_me_routes.py +++ b/tests/unit/v2/test_me_routes.py @@ -41,7 +41,6 @@ def _account(**changes) -> AccountRead: "last_name": None, "email": None, "created_at": CREATED_AT, - "account_created": False, } values.update(changes) return AccountRead(**values) @@ -97,7 +96,6 @@ def test_get_me_provisions_with_the_query_country() -> None: "last_name": None, "email": None, "created_at": "2026-01-01T00:00:00Z", - "account_created": False, } }, } @@ -106,25 +104,6 @@ def test_get_me_provisions_with_the_query_country() -> None: ] -@pytest.mark.parametrize( - ("method", "operation", "body"), - [ - ("GET", "get_account", None), - ("PATCH", "update_account", {"first_name": "Ada"}), - ], -) -def test_me_reports_account_created_from_the_service(method, operation, body) -> None: - service = RecordingService( - lambda name, _kwargs: _account(account_created=name == operation) - ) - client, _service = _client(service) - - response = client.request(method, "/v2/me?country_id=us", json=body) - - assert response.status_code == 200 - assert response.json()["result"]["item"]["account_created"] is True - - def test_patch_me_accepts_names_and_country_only() -> None: client, service = _client() diff --git a/tests/unit/v2/test_user_account_service.py b/tests/unit/v2/test_user_account_service.py index afd17d48f..465bc024e 100644 --- a/tests/unit/v2/test_user_account_service.py +++ b/tests/unit/v2/test_user_account_service.py @@ -140,35 +140,11 @@ def test_first_call_provisions_once_and_later_calls_reuse_the_user(store) -> Non assert first.id == second.id != other.id assert first.primary_country == second.primary_country == "uk" - assert (first.account_created, second.account_created) == (True, False) - assert other.account_created is True with store.sessions() as session: assert len(session.exec(select(User)).all()) == 2 assert len(session.exec(select(UserIdentity)).all()) == 2 -def test_account_created_follows_whichever_account_route_provisions(store) -> None: - accounts, *_ = _services(store) - - patched_first = accounts.update_account( - principal=ALICE, - country_id="us", - account_input=AccountUpdateInput(first_name="Ada"), - ) - patched_again = accounts.update_account( - principal=ALICE, - country_id="us", - account_input=AccountUpdateInput(last_name="Lovelace"), - ) - read_after_patch = accounts.get_account(principal=ALICE, country_id="us") - exported_first = accounts.export_account(principal=BOB, country_id="us") - - assert patched_first.account_created is True - assert patched_again.account_created is False - assert read_after_patch.account_created is False - assert exported_first.user.account_created is True - - def test_concurrent_first_calls_converge_on_one_user(store, monkeypatch) -> None: original = identity_services.read_user_identity reads = {"count": 0} From bb02660af90832e050db86e3002bd9e25a2df68b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:32:42 +0400 Subject: [PATCH 28/35] Remove the unused ResolvedUser.provisioned flag Nothing has read provisioned since 7bd68145 dropped account_created from /v2/me, so ResolvedUser carries only the user ID and email again. The concurrency tests already check that concurrent first sign-ins create exactly one users row and one user_identities row. Co-Authored-By: Claude Opus 5.5 --- .../services/v2/identity/services.py | 24 ++++--------------- .../services/v2/identity/types.py | 1 - 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/policyengine_api/services/v2/identity/services.py b/policyengine_api/services/v2/identity/services.py index be14b3c6b..2a87869a3 100644 --- a/policyengine_api/services/v2/identity/services.py +++ b/policyengine_api/services/v2/identity/services.py @@ -57,11 +57,7 @@ def find_principal_user_in_session( ) if identity is None: return None - return ResolvedUser( - user_id=identity.user_id, - email=identity.email, - provisioned=False, - ) + return ResolvedUser(user_id=identity.user_id, email=identity.email) def resolve_principal_user_in_session( @@ -87,11 +83,7 @@ def resolve_principal_user_in_session( email=refreshed_identity_email(identity, principal.email), seen_at=seen_at, ) - return ResolvedUser( - user_id=identity.user_id, - email=identity.email, - provisioned=False, - ) + return ResolvedUser(user_id=identity.user_id, email=identity.email) with provisioning_savepoint(session) as savepoint: user = create_provisioned_user(session, primary_country=country_id) @@ -111,11 +103,7 @@ def resolve_principal_user_in_session( "provisioned_v2_user", extra={"policyengine_attributes": {"v2_user_id": str(created_user_id)}}, ) - return ResolvedUser( - user_id=created_user_id, - email=principal.email, - provisioned=True, - ) + return ResolvedUser(user_id=created_user_id, email=principal.email) concurrent = require_concurrent_identity( read_user_identity( @@ -124,8 +112,4 @@ def resolve_principal_user_in_session( subject=principal.subject, ) ) - return ResolvedUser( - user_id=concurrent.user_id, - email=concurrent.email, - provisioned=False, - ) + return ResolvedUser(user_id=concurrent.user_id, email=concurrent.email) diff --git a/policyengine_api/services/v2/identity/types.py b/policyengine_api/services/v2/identity/types.py index b10afee06..c11f2f408 100644 --- a/policyengine_api/services/v2/identity/types.py +++ b/policyengine_api/services/v2/identity/types.py @@ -29,4 +29,3 @@ class ResolvedUser: user_id: UUID email: str | None - provisioned: bool From 466b8039967abf15b15fa1b3213284740ce7f605 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:32:52 +0400 Subject: [PATCH 29/35] Store no import item whose client_ref repeats in the request partition_import_items validated each record on its own, so a request with two policies sharing a client_ref, one valid and one invalid, returned that client_ref in both id_map and skipped. App v3 removes every browser item whose client_ref comes back in id_map, so it deleted an item that was never stored. The import now counts client_refs across the whole request first. Every record whose client_ref appears more than once is skipped with the existing invalid reason and none of them is stored, so the contract keeps its reason values. The count spans policies, households, simulations and reports because id_map is keyed by client_ref alone; a policy and a report sharing one would otherwise put it in both lists too. App v3's reform (rf-) and report (lfr-) IDs never collide. Other items in the request are imported as before. Service tests cover a repeated valid and invalid pair, two valid items, the items stored beside a repeated client_ref (including a report whose policy link names it), and a client_ref shared across kinds. The route description and the Stage 12A doc's import section state the rule. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-12a-user-login.md | 9 ++ .../fastapi_routes/v2/me/routes.py | 3 +- .../services/v2/user_accounts/services.py | 26 ++-- .../services/v2/user_accounts/validators.py | 40 +++++- tests/unit/v2/test_user_account_service.py | 117 ++++++++++++++++++ 5 files changed, 184 insertions(+), 11 deletions(-) diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index 4eefc3ddf..b1e5bc1ad 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -68,6 +68,15 @@ routes. The legacy builder stays browser-only, signed in or not. (`client_ref_conflict`), which is never linked across countries. A report's `user_policy_client_ref` resolves only against the user's policies in the import country. + + A `client_ref` may appear only once in a request, across policies, + households, simulations, and reports, because `id_map` is keyed by + `client_ref` alone. When one appears more than once, none of its items + is stored and each occurrence is reported in `skipped` as `invalid`; the + request's other items are imported as usual. `id_map` therefore never + holds a `client_ref` that is also in `skipped`, which matters because app + v3 removes from the browser every item whose `client_ref` comes back in + `id_map`. - **Request bounds.** Every `/v2/me*` and `/v2/user-*` write rejects a body over 256 KiB with 413 (imports allow 1 MiB). FastAPI reads and parses the whole JSON body before any dependency runs, so this check does not limit diff --git a/policyengine_api/fastapi_routes/v2/me/routes.py b/policyengine_api/fastapi_routes/v2/me/routes.py index 622d61a30..e934c060a 100644 --- a/policyengine_api/fastapi_routes/v2/me/routes.py +++ b/policyengine_api/fastapi_routes/v2/me/routes.py @@ -174,7 +174,8 @@ def export() -> MeExportResponse: "existing client_ref in the same country returns its stored ID " "unchanged. Items with an unknown v1 reference, an invalid shape, or " "a client_ref the user already holds in another country are skipped " - "and reported." + "and reported. A client_ref that appears more than once in the " + "request stores none of its items; each is skipped as invalid." ), ) def import_records( diff --git a/policyengine_api/services/v2/user_accounts/services.py b/policyengine_api/services/v2/user_accounts/services.py index 8ffaa73a7..887441335 100644 --- a/policyengine_api/services/v2/user_accounts/services.py +++ b/policyengine_api/services/v2/user_accounts/services.py @@ -2,9 +2,10 @@ Reads, updates, exports, and imports resolve (and on first use provision) the caller's v2 user from the verified session principal. Imports are idempotent -on ``(user_id, client_ref)``: unknown v1 references, invalid items, and -client_refs the user already holds in another country are skipped and reported -rather than failing the request. Deletion removes the +on ``(user_id, client_ref)``: unknown v1 references, invalid items, items +whose client_ref appears more than once in the request, and client_refs the +user already holds in another country are skipped and reported rather than +failing the request. Deletion removes the user's legacy mappings, then the user (identities and associations cascade), and finally the Auth0 user when the Management API is configured. Only v2 user UUIDs are logged. @@ -56,6 +57,7 @@ AccountDeletionUnavailableError, AccountIdentityDeletionError, client_ref_conflict_skips, + duplicated_client_refs, partition_import_items, partition_known_legacy_items, require_account, @@ -180,17 +182,27 @@ def import_records( country_id: str, import_input: AccountImportInput, ) -> AccountImportResult: + # A client_ref sent more than once stores none of its records, so + # id_map never returns a client_ref that is also skipped. + duplicates = duplicated_client_refs( + ( + import_input.policies, + import_input.households, + import_input.simulations, + import_input.reports, + ) + ) policies, policy_skips = partition_import_items( - import_input.policies, UserPolicyImportItem + import_input.policies, UserPolicyImportItem, duplicates=duplicates ) households, household_skips = partition_import_items( - import_input.households, UserHouseholdImportItem + import_input.households, UserHouseholdImportItem, duplicates=duplicates ) simulations, simulation_skips = partition_import_items( - import_input.simulations, UserSimulationImportItem + import_input.simulations, UserSimulationImportItem, duplicates=duplicates ) reports, report_skips = partition_import_items( - import_input.reports, UserReportImportItem + import_input.reports, UserReportImportItem, duplicates=duplicates ) existing = require_legacy_reference_checker( diff --git a/policyengine_api/services/v2/user_accounts/validators.py b/policyengine_api/services/v2/user_accounts/validators.py index a71829996..268d182f7 100644 --- a/policyengine_api/services/v2/user_accounts/validators.py +++ b/policyengine_api/services/v2/user_accounts/validators.py @@ -2,7 +2,8 @@ from __future__ import annotations -from collections.abc import Collection, Sequence +from collections import Counter +from collections.abc import Collection, Iterable, Sequence from typing import TypeVar from uuid import UUID @@ -43,21 +44,54 @@ def _client_ref(record: ImportedRecord) -> str | None: return value if isinstance(value, str) else None +def duplicated_client_refs( + record_groups: Iterable[Sequence[ImportedRecord]], +) -> frozenset[str]: + """Return the client_refs carried by more than one record of one request. + + ``id_map`` is keyed by client_ref alone, so it cannot tell records that + share one apart, and a client removes its local item once the client_ref + comes back in ``id_map``. Counting across every kind keeps a stored record + and a skipped one from sharing a client_ref in one response. + """ + + occurrences = Counter( + _client_ref(record) for records in record_groups for record in records + ) + return frozenset( + client_ref + for client_ref, count in occurrences.items() + if client_ref is not None and count > 1 + ) + + def partition_import_items( records: Sequence[ImportedRecord], item_type: type[ImportItemT], + *, + duplicates: Collection[str], ) -> tuple[list[ImportItemT], list[ImportSkip]]: - """Validate each record independently; invalid records are skipped.""" + """Validate each record independently; invalid records are skipped. + + Every record whose client_ref is in ``duplicates`` is skipped as invalid + without validation, so none of the records sharing it is stored. + """ valid: list[ImportItemT] = [] skipped: list[ImportSkip] = [] for record in records: + client_ref = _client_ref(record) + if client_ref is not None and client_ref in duplicates: + skipped.append( + ImportSkip(client_ref=client_ref, reason=ImportSkipReason.INVALID) + ) + continue try: valid.append(item_type.model_validate(record)) except ValidationError: skipped.append( ImportSkip( - client_ref=_client_ref(record), + client_ref=client_ref, reason=ImportSkipReason.INVALID, ) ) diff --git a/tests/unit/v2/test_user_account_service.py b/tests/unit/v2/test_user_account_service.py index 465bc024e..5f557027d 100644 --- a/tests/unit/v2/test_user_account_service.py +++ b/tests/unit/v2/test_user_account_service.py @@ -472,6 +472,123 @@ def test_import_skips_items_with_non_finite_numbers_as_invalid(store) -> None: ] +def test_import_stores_neither_a_valid_nor_an_invalid_repeated_client_ref( + store, +) -> None: + accounts, *_ = _services(store) + + result = accounts.import_records( + principal=ALICE, + country_id="us", + import_input=_import_input( + policies=[ + {"client_ref": "rf-repeated", "legacy_policy_id": 1}, + {"client_ref": "rf-repeated"}, + ] + ), + ) + + assert result.id_map == {} + assert [(skip.client_ref, skip.reason.value) for skip in result.skipped] == [ + ("rf-repeated", "invalid"), + ("rf-repeated", "invalid"), + ] + with store.sessions() as session: + assert session.exec(select(UserPolicy)).all() == [] + + +def test_import_stores_neither_of_two_valid_items_sharing_a_client_ref( + store, +) -> None: + accounts, *_ = _services(store) + + result = accounts.import_records( + principal=ALICE, + country_id="us", + import_input=_import_input( + policies=[ + {"client_ref": "rf-repeated", "legacy_policy_id": 1, "name": "A"}, + {"client_ref": "rf-repeated", "legacy_policy_id": 2, "name": "B"}, + ] + ), + ) + + assert result.id_map == {} + assert [(skip.client_ref, skip.reason.value) for skip in result.skipped] == [ + ("rf-repeated", "invalid"), + ("rf-repeated", "invalid"), + ] + with store.sessions() as session: + assert session.exec(select(UserPolicy)).all() == [] + + +def test_import_stores_items_beside_a_repeated_client_ref(store) -> None: + accounts, *_ = _services(store) + + result = accounts.import_records( + principal=ALICE, + country_id="us", + import_input=_import_input( + policies=[ + {"client_ref": "rf-repeated", "legacy_policy_id": 1}, + {"client_ref": "rf-kept", "legacy_policy_id": 2}, + {"client_ref": "rf-repeated", "legacy_policy_id": "1"}, + ], + households=[{"client_ref": "suh-kept", "legacy_household_id": 10}], + reports=[ + { + "client_ref": "lfr-linked", + "legacy_report_id": 615, + "user_policy_client_ref": "rf-kept", + }, + { + "client_ref": "lfr-unlinked", + "legacy_report_id": 616, + "user_policy_client_ref": "rf-repeated", + }, + ], + ), + ) + + assert set(result.id_map) == {"rf-kept", "suh-kept", "lfr-linked", "lfr-unlinked"} + assert [(skip.client_ref, skip.reason.value) for skip in result.skipped] == [ + ("rf-repeated", "invalid"), + ("rf-repeated", "invalid"), + ] + assert not set(result.id_map) & {skip.client_ref for skip in result.skipped} + with store.sessions() as session: + policies = session.exec(select(UserPolicy)).all() + linked = session.get(UserReportAssociation, result.id_map["lfr-linked"]) + unlinked = session.get(UserReportAssociation, result.id_map["lfr-unlinked"]) + households = session.exec(select(UserHouseholdAssociation)).all() + assert [policy.client_ref for policy in policies] == ["rf-kept"] + assert linked.user_policy_id == policies[0].id + assert unlinked.user_policy_id is None + assert [household.client_ref for household in households] == ["suh-kept"] + + +def test_import_treats_a_client_ref_shared_across_kinds_as_repeated(store) -> None: + accounts, *_ = _services(store) + + result = accounts.import_records( + principal=ALICE, + country_id="us", + import_input=_import_input( + policies=[{"client_ref": "shared-ref", "legacy_policy_id": 1}], + reports=[{"client_ref": "shared-ref", "legacy_report_id": 615}], + ), + ) + + assert result.id_map == {} + assert [(skip.client_ref, skip.reason.value) for skip in result.skipped] == [ + ("shared-ref", "invalid"), + ("shared-ref", "invalid"), + ] + with store.sessions() as session: + assert session.exec(select(UserPolicy)).all() == [] + assert session.exec(select(UserReportAssociation)).all() == [] + + def test_import_input_bounds_the_item_count() -> None: with pytest.raises(ValueError, match="at most 500"): _import_input( From 76c294c395ca00ffd54d952301142605fbb3411f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:33:00 +0400 Subject: [PATCH 30/35] Size the proposed import throttle for imports at every sign-in The Cloud Armor proposal still assumed that app v3 reads /v2/me on every page load and imports once, at the first sign-in. App v3 now imports at every sign-in: it moves the browser's signed-out reforms and reports with POST /v2/me/imports, chunked per country at up to 500 items and about 950 KB, after one GET /v2/me with the sign-in country, and a failed import offers a manual "Try again". A sign-in with browser work therefore sends one unthrottled GET and usually one import per country with items, at most two for typical browsers. The import rule (priority 1150) rises from 20 to 60 requests per 600 s per IP: room for about 30 such sign-ins per IP every ten minutes behind shared NAT, retries included, while still bounding the heaviest write path. The rule shapes and expressions are unchanged. The Stage 12A doc's Consumers section now also lists the GET /v2/me sent before an import. Co-Authored-By: Claude Opus 5.5 --- ...1012-pol-api-lb-stage-12a-user-writes.yaml | 27 ++++++++++++++----- docs/migration/stage-12a-user-login.md | 5 +++- 2 files changed, 25 insertions(+), 7 deletions(-) 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 index f479d6553..3a98dd38d 100644 --- 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 @@ -11,7 +11,7 @@ # --project=$PROJECT --security-policy=pol-api-lb \ # --expression="request.method == 'POST' && request.path.matches('^/v2/me/imports$')" \ # --action=throttle \ -# --rate-limit-threshold-count=20 --rate-limit-threshold-interval-sec=600 \ +# --rate-limit-threshold-count=60 --rate-limit-threshold-interval-sec=600 \ # --conform-action=allow --exceed-action=deny-429 --enforce-on-key=IP \ # --preview # @@ -24,10 +24,25 @@ # --preview # # Design notes: -# - Reads (GET) and CORS preflights (OPTIONS) are excluded: the app reads -# /v2/me on every page load and lists saved items, and NAT'd offices share IPs. -# - Imports run once per first sign-in (at most 500 items, 1 MiB), so they get a -# tighter, longer-window rule evaluated first (lower priority number). +# - Reads (GET) and CORS preflights (OPTIONS) are excluded: app v3 lists saved +# reforms and reports, and reads GET /v2/me on its account page and once +# before each sign-in import; NAT'd offices share IPs. +# - App v3 imports at every sign-in, not only the first. It moves the +# browser's signed-out reforms and reports into the account with +# POST /v2/me/imports, removes each item whose client_ref comes back in +# id_map, and keeps the rest for the next sign-in. Requests are chunked per +# country at <=500 items and about 950 KB. A sign-in with browser work sends +# one GET /v2/me (not throttled) and usually one import per country with +# items: at most 2 for typical browsers, more only for very large ones. A +# sign-in with nothing to move sends no import. A failed import offers a +# manual "Try again", which sends the imports again. +# - Imports are the heaviest write (up to 500 items, 1 MiB each), so they get +# a tighter, longer-window rule evaluated first (lower priority number): +# 60 requests per 600 s per IP. At 2 imports per sign-in that is about 30 +# sign-ins with browser work per IP every 10 minutes, room for offices and +# campuses behind shared NAT and for retries, while still bounding the +# import path. A throttled import (429) fails the run; its items stay in the +# browser for "Try again" or the next sign-in. # - Cloud Armor CEL regexes reject capture groups, so the plan's # ^/v2/(me|user-) is written ^/v2/(?:me|user-). # - Thresholds are starting points; confirm with >=24h of preview logs and the @@ -83,7 +98,7 @@ rules: enforceOnKey: IP exceedAction: deny(429) rateLimitThreshold: - count: 20 + count: 60 intervalSec: 600 - action: throttle description: 'Stage 12A: per-IP limit on signed-in user writes' diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index b1e5bc1ad..e869d8503 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -17,7 +17,10 @@ routes. The legacy builder stays browser-only, signed in or not. `client_ref` comes back in `id_map`, and keeps the rest for the next sign-in. Because imports are idempotent on `client_ref`, an item sent again returns its existing ID. -- **Account.** App v3 calls `GET /v2/me` on its account page. +- **Account.** App v3 calls `GET /v2/me` on its account page. Before an + import that has items to move, it also calls `GET /v2/me` once with the + sign-in country, so a first sign-in records that country as the + account's primary country. - **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. From 4f45745c7bba3e483b1c727a361e3d8fccd51acf Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:33:08 +0400 Subject: [PATCH 31/35] Warn that the auth issuer must be final before the first sign-in user_identities is keyed by (issuer, subject), so changing PUBLIC_API_AUTH_ISSUER after users exist makes every one of them look new: the next sign-in provisions an empty account and orphans the old one and its saved work. The Stage 12A doc's Settings section now says so, and the Auth0 prerequisites and production rollout steps require the custom domain to be in place before the issuer is set. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-12a-user-login.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index e869d8503..3f9ff3e97 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -265,6 +265,16 @@ parent revision writes. | `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. `validate_cloud_run_deploy_env.sh` requires @@ -295,7 +305,8 @@ production. Configure staging first, then production: 1. **Custom domain** (for example `auth.policyengine.org`). Tokens then carry - that issuer. + 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. @@ -326,11 +337,12 @@ Configure staging first, then production: 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 live test before promotion. -5. Production: set the production values once production Auth0 is ready. The - candidate smoke test checks that anonymous `/v2/me` and `/v2/user-*` - requests return 401. After promotion, also check that - `curl https://api.policyengine.org/v2/user-policies?country_id=us` returns - 401. +5. Production: set the production 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 anonymous + `/v2/me` and `/v2/user-*` requests 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 throttles in preview (`docs/migration/armor/proposed/20261012-pol-api-lb-stage-12a-user-writes.yaml`) and enforce them only after the runbook's checks. From a5f13a2c344352d42076fb46e02be207cafa451f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:23:28 +0400 Subject: [PATCH 32/35] Save signed-out work under unauthenticated users instead of importing it App v3 kept signed-out work in browser storage and copied it into the account at sign-in through POST /v2/me/imports. All saved work now lives in the database instead, signed in or not. users gains user_type: "standard" (Auth0-backed; every existing row, through a 'standard' server default) or "unauthenticated" (a signed-out browser, whose users.id is the random UUID it keeps; every other user column stays NULL). primary_country becomes nullable, and ck_users_user_type and ck_users_standard_primary_country keep it required for standard users. The unmerged revision fbb7488ebe8a is regenerated from the updated models under the same ID with no corrections, and stays expand-only for the previous revision. The router-level authenticator now returns an AccountPrincipal: a UserPrincipal for a bearer token, validated as before and taking precedence, or else an UnauthenticatedPrincipal for a canonical UUID in the PolicyEngine-Unauthenticated-User header, which needs no Auth0 configuration. No credential, or a malformed header, still gets 401 before any validation. Provisioning is a strategy selected from the principal type. StandardAccountProvisioning is the existing identity logic, unchanged. UnauthenticatedAccountProvisioning inserts the user ON CONFLICT DO NOTHING and re-reads it FOR SHARE, only on writes: reads by an absent unauthenticated user find no records (empty lists, 404s, an export with a null user). A UUID naming a standard user gets 401, so an unauthenticated principal never acts on a standard user. PATCH /v2/me is 403 for it, and DELETE /v2/me deletes only its data, without Auth0. POST /v2/me/upgrade takes both credentials. Under a FOR UPDATE lock on the unauthenticated user, it converts that user in place when the identity has no user yet, merges every association into the identity's existing user (IDs unchanged) and deletes it otherwise, and touches nothing when no unauthenticated user has that ID. The response is {"item": MeItem, "outcome": "converted" | "merged" | "none"}. The import route and everything only it used are removed, including client_ref on every association model, input, response, and table, and its per-user unique constraints. An unauthenticated user's ID is a credential, so it is never logged: its log records carry only user_type, and the upgrade logs only its outcome. Co-Authored-By: Claude Opus 5.5 --- ...8a_link_signed_in_users_to_v1_anchored_.py | 96 +-- policyengine_api/data/v2/models/__init__.py | 7 +- .../data/v2/models/associations.py | 40 +- policyengine_api/data/v2/models/users.py | 31 +- .../fastapi_routes/dependencies.py | 105 +-- .../fastapi_routes/v2/me/__init__.py | 2 +- .../fastapi_routes/v2/me/request_models.py | 11 +- .../fastapi_routes/v2/me/response_models.py | 35 +- .../fastapi_routes/v2/me/routes.py | 117 ++-- .../fastapi_routes/v2/user_auth.py | 60 +- .../v2/user_households/request_models.py | 4 +- .../v2/user_households/response_models.py | 3 +- .../v2/user_households/routes.py | 31 +- .../v2/user_policies/request_models.py | 4 +- .../v2/user_policies/response_models.py | 3 +- .../fastapi_routes/v2/user_policies/routes.py | 31 +- .../v2/user_reports/__init__.py | 2 +- .../v2/user_reports/request_models.py | 4 +- .../v2/user_reports/response_models.py | 3 +- .../fastapi_routes/v2/user_reports/routes.py | 31 +- .../fastapi_routes/v2/user_resources.py | 32 +- .../v2/user_simulations/__init__.py | 2 +- .../v2/user_simulations/request_models.py | 4 +- .../v2/user_simulations/response_models.py | 3 +- .../v2/user_simulations/routes.py | 31 +- policyengine_api/libs/user_auth.py | 30 +- .../database_connectors/__init__.py | 1 - .../database_connectors/creates.py | 30 - .../associations/database_connectors/reads.py | 39 -- .../services/v2/associations/services.py | 75 +-- .../services/v2/associations/types.py | 31 - .../services/v2/associations/validators.py | 15 - .../services/v2/identity/__init__.py | 2 +- .../identity/database_connectors/creates.py | 21 +- .../identity/database_connectors/deletes.py | 26 + .../v2/identity/database_connectors/reads.py | 47 +- .../identity/database_connectors/updates.py | 59 +- .../services/v2/identity/services.py | 345 ++++++++-- .../services/v2/identity/transformations.py | 18 +- .../services/v2/identity/types.py | 73 +- .../services/v2/identity/validators.py | 33 +- .../services/v2/user_accounts/__init__.py | 2 +- .../services/v2/user_accounts/services.py | 253 +++---- .../v2/user_accounts/transformations.py | 1 + .../services/v2/user_accounts/types.py | 69 +- .../services/v2/user_accounts/validators.py | 127 +--- .../services/v2/user_households/services.py | 85 +-- .../v2/user_households/transformations.py | 19 - .../services/v2/user_households/types.py | 13 - .../services/v2/user_policies/services.py | 85 +-- .../v2/user_policies/transformations.py | 19 - .../services/v2/user_policies/types.py | 15 - .../services/v2/user_reports/services.py | 105 +-- .../v2/user_reports/transformations.py | 23 - .../services/v2/user_reports/types.py | 12 - .../services/v2/user_simulations/services.py | 79 +-- .../v2/user_simulations/transformations.py | 17 - .../services/v2/user_simulations/types.py | 13 - .../integration/test_alembic_v2_lifecycle.py | 31 +- .../test_v2_user_identity_persistence.py | 597 +++++++++++----- tests/unit/libs/test_user_auth.py | 28 + .../routes/test_migration_context_logging.py | 2 +- tests/unit/test_migration_flags.py | 2 +- tests/unit/v2/test_alembic_v2.py | 16 +- tests/unit/v2/test_data_crud_boundaries.py | 1 + tests/unit/v2/test_me_routes.py | 308 ++++++--- tests/unit/v2/test_metadata_routes.py | 2 +- tests/unit/v2/test_model_persistence.py | 32 + tests/unit/v2/test_models.py | 46 +- .../v2/test_unauthenticated_user_service.py | 635 ++++++++++++++++++ tests/unit/v2/test_user_account_service.py | 303 +-------- tests/unit/v2/test_user_household_routes.py | 4 - tests/unit/v2/test_user_household_service.py | 14 +- tests/unit/v2/test_user_policy_routes.py | 8 +- tests/unit/v2/test_user_policy_service.py | 22 +- tests/unit/v2/test_user_route_auth.py | 214 +++++- .../v2/test_user_simulation_report_routes.py | 8 +- tests/unit/v2/test_user_write_limits.py | 28 +- tests/unit/v2/user_route_support.py | 14 +- tests/unit/v2/user_service_support.py | 11 +- 80 files changed, 2864 insertions(+), 1936 deletions(-) delete mode 100644 policyengine_api/services/v2/associations/database_connectors/__init__.py delete mode 100644 policyengine_api/services/v2/associations/database_connectors/creates.py delete mode 100644 policyengine_api/services/v2/associations/database_connectors/reads.py create mode 100644 policyengine_api/services/v2/identity/database_connectors/deletes.py create mode 100644 tests/unit/v2/test_unauthenticated_user_service.py 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 index 5747b0079..306705f6a 100644 --- 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 @@ -2,7 +2,7 @@ Revision ID: fbb7488ebe8a Revises: 91af4fc8d8c1 -Create Date: 2026-10-13 15:49:22.180908 +Create Date: 2026-10-14 17:52:33.514142 Generation: uv run alembic -c alembic-v2.ini revision --autogenerate """ @@ -58,12 +58,6 @@ def upgrade() -> None: "user_household_associations", sa.Column("legacy_household_id", sa.BigInteger(), nullable=True), ) - op.add_column( - "user_household_associations", - sa.Column( - "client_ref", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True - ), - ) op.alter_column( "user_household_associations", "household_id", @@ -76,11 +70,6 @@ def upgrade() -> None: ["country_id", "legacy_household_id"], unique=False, ) - op.create_unique_constraint( - "uq_user_household_associations_user_client_ref", - "user_household_associations", - ["user_id", "client_ref"], - ) op.create_check_constraint( op.f("ck_user_household_associations_household_ref"), "user_household_associations", @@ -105,12 +94,6 @@ def upgrade() -> None: nullable=True, ), ) - op.add_column( - "user_policies", - sa.Column( - "client_ref", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True - ), - ) op.alter_column( "user_policies", "policy_id", existing_type=sa.UUID(), nullable=True ) @@ -120,9 +103,6 @@ def upgrade() -> None: ["country_id", "legacy_policy_id"], unique=False, ) - op.create_unique_constraint( - "uq_user_policies_user_client_ref", "user_policies", ["user_id", "client_ref"] - ) op.create_check_constraint( op.f("ck_user_policies_baseline"), "user_policies", @@ -177,12 +157,6 @@ def upgrade() -> None: nullable=True, ), ) - op.add_column( - "user_report_associations", - sa.Column( - "client_ref", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True - ), - ) op.alter_column( "user_report_associations", "report_id", existing_type=sa.UUID(), nullable=True ) @@ -220,11 +194,6 @@ def upgrade() -> None: "user_report_associations", ["id", "country_id"], ) - op.create_unique_constraint( - "uq_user_report_associations_user_client_ref", - "user_report_associations", - ["user_id", "client_ref"], - ) op.drop_constraint( op.f("fk_user_report_associations_report_id_reports"), "user_report_associations", @@ -266,12 +235,6 @@ def upgrade() -> None: "user_simulation_associations", sa.Column("description", sa.Text(), nullable=True), ) - op.add_column( - "user_simulation_associations", - sa.Column( - "client_ref", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True - ), - ) op.alter_column( "user_simulation_associations", "simulation_id", @@ -306,11 +269,6 @@ def upgrade() -> None: "user_simulation_associations", ["id", "country_id"], ) - op.create_unique_constraint( - "uq_user_simulation_associations_user_client_ref", - "user_simulation_associations", - ["user_id", "client_ref"], - ) op.drop_constraint( op.f("fk_user_simulation_associations_simulation_id_simulations"), "user_simulation_associations", @@ -326,11 +284,41 @@ def upgrade() -> None: "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", @@ -349,11 +337,6 @@ def downgrade() -> None: ["id"], ondelete="CASCADE", ) - op.drop_constraint( - "uq_user_simulation_associations_user_client_ref", - "user_simulation_associations", - type_="unique", - ) op.drop_constraint( "uq_user_simulation_associations_id_country", "user_simulation_associations", @@ -385,7 +368,6 @@ def downgrade() -> None: existing_type=sa.UUID(), nullable=False, ) - op.drop_column("user_simulation_associations", "client_ref") op.drop_column("user_simulation_associations", "description") op.drop_column("user_simulation_associations", "name") op.drop_column("user_simulation_associations", "country_id") @@ -413,11 +395,6 @@ def downgrade() -> None: ["id"], ondelete="CASCADE", ) - op.drop_constraint( - "uq_user_report_associations_user_client_ref", - "user_report_associations", - type_="unique", - ) op.drop_constraint( "uq_user_report_associations_id_country", "user_report_associations", @@ -448,7 +425,6 @@ def downgrade() -> None: op.alter_column( "user_report_associations", "report_id", existing_type=sa.UUID(), nullable=False ) - op.drop_column("user_report_associations", "client_ref") op.drop_column("user_report_associations", "validation") op.drop_column("user_report_associations", "provisions") op.drop_column("user_report_associations", "source_note") @@ -463,14 +439,10 @@ def downgrade() -> None: op.drop_constraint( op.f("ck_user_policies_baseline"), "user_policies", type_="check" ) - op.drop_constraint( - "uq_user_policies_user_client_ref", "user_policies", type_="unique" - ) 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", "client_ref") op.drop_column("user_policies", "provenance") op.drop_column("user_policies", "baseline") op.drop_column("user_policies", "legacy_policy_id") @@ -479,11 +451,6 @@ def downgrade() -> None: "user_household_associations", type_="check", ) - op.drop_constraint( - "uq_user_household_associations_user_client_ref", - "user_household_associations", - type_="unique", - ) op.drop_index( "ix_user_households_country_legacy_household", table_name="user_household_associations", @@ -494,7 +461,6 @@ def downgrade() -> None: existing_type=sa.UUID(), nullable=False, ) - op.drop_column("user_household_associations", "client_ref") 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") diff --git a/policyengine_api/data/v2/models/__init__.py b/policyengine_api/data/v2/models/__init__.py index 95fce9fbd..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, UserIdentity # 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, @@ -144,6 +148,7 @@ "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 395c800cb..a94b5f9b7 100644 --- a/policyengine_api/data/v2/models/associations.py +++ b/policyengine_api/data/v2/models/associations.py @@ -2,8 +2,7 @@ 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. ``client_ref`` carries the browser-local identifier of -an imported record so repeated imports are idempotent per user. +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 @@ -30,7 +29,6 @@ ) -ASSOCIATION_CLIENT_REF_MAX_LENGTH = 64 USER_POLICY_BASELINES = ("current-law", "current-policy") @@ -47,11 +45,6 @@ class UserHouseholdAssociation(TimestampedModel, table=True): "country_id", name="uq_user_household_associations_id_country", ), - sa.UniqueConstraint( - "user_id", - "client_ref", - name="uq_user_household_associations_user_client_ref", - ), sa.CheckConstraint( "country_id IN ('us', 'uk')", name="ck_user_household_associations_country", @@ -95,10 +88,6 @@ class UserHouseholdAssociation(TimestampedModel, table=True): 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) - client_ref: str | None = Field( - default=None, - max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH, - ) user: User = Relationship(back_populates="household_associations") household: Household | None = Relationship(back_populates="user_associations") @@ -112,11 +101,6 @@ class UserPolicy(TimestampedModel, table=True): "country_id", name="uq_user_policies_id_country", ), - sa.UniqueConstraint( - "user_id", - "client_ref", - name="uq_user_policies_user_client_ref", - ), sa.CheckConstraint( "country_id IN ('us', 'uk')", name="ck_user_policies_country", @@ -166,10 +150,6 @@ class UserPolicy(TimestampedModel, table=True): 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()) - client_ref: str | None = Field( - default=None, - max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH, - ) user: User = Relationship(back_populates="policy_associations") policy: Policy | None = Relationship(back_populates="user_associations") @@ -193,11 +173,6 @@ class UserSimulationAssociation(TimestampedModel, table=True): "country_id", name="uq_user_simulation_associations_id_country", ), - sa.UniqueConstraint( - "user_id", - "client_ref", - name="uq_user_simulation_associations_user_client_ref", - ), sa.CheckConstraint( "country_id IN ('us', 'uk')", name="ck_user_simulation_associations_country", @@ -230,10 +205,6 @@ class UserSimulationAssociation(TimestampedModel, table=True): 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) - client_ref: str | None = Field( - default=None, - max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH, - ) # Retired; see the module docstring. country: str | None = Field(default=None, max_length=16) label: str | None = Field(default=None, max_length=255) @@ -256,11 +227,6 @@ class UserReportAssociation(TimestampedModel, table=True): "country_id", name="uq_user_report_associations_id_country", ), - sa.UniqueConstraint( - "user_id", - "client_ref", - name="uq_user_report_associations_user_client_ref", - ), sa.CheckConstraint( "country_id IN ('us', 'uk')", name="ck_user_report_associations_country", @@ -306,10 +272,6 @@ class UserReportAssociation(TimestampedModel, table=True): default=None, sa_type=sa.DateTime(timezone=True), ) - client_ref: str | None = Field( - default=None, - max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH, - ) # Retired; see the module docstring. country: str | None = Field(default=None, max_length=16) label: str | None = Field(default=None, max_length=255) diff --git a/policyengine_api/data/v2/models/users.py b/policyengine_api/data/v2/models/users.py index 54c1be3bc..14a294f30 100644 --- a/policyengine_api/data/v2/models/users.py +++ b/policyengine_api/data/v2/models/users.py @@ -1,6 +1,13 @@ -"""Canonical SQLModel tables for v2 users and their sign-in identities.""" +"""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 @@ -19,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__ = ( @@ -27,12 +39,27 @@ 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( diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 00efcb230..b8026afc0 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -11,9 +11,10 @@ from uuid import UUID from fastapi import Depends -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +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 ( @@ -45,14 +46,16 @@ from policyengine_api.services.v2.associations.types import ( LegacyReferenceChecker, ) - from policyengine_api.services.v2.identity.types import UserPrincipal + from policyengine_api.services.v2.identity.types import ( + AccountPrincipal, + UserPrincipal, + ) from policyengine_api.services.v2.user_accounts.types import ( AccountDeletion, AccountExport, - AccountImportInput, - AccountImportResult, AccountRead, AccountUpdateInput, + AccountUpgrade, ) from policyengine_api.services.v2.user_policies.types import ( UserPolicyCreateFields, @@ -285,19 +288,19 @@ def list_households( class V2UserPolicyResourceService(Protocol): - """Route-independent signed-in user-policy operations for one request.""" + """Route-independent user-policy operations for one request.""" def create_user_policy( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", association_input: "UserPolicyCreateFields", ) -> "UserPolicyRead": ... def get_user_policy( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, association_id: UUID, ) -> "UserPolicyRead": ... @@ -305,7 +308,7 @@ def get_user_policy( def list_user_policies( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, policy_id: UUID | None = None, legacy_policy_id: int | None = None, @@ -316,7 +319,7 @@ def list_user_policies( def patch_user_policy( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, association_id: UUID, association_input: "UserPolicyUpdateInput", @@ -325,26 +328,26 @@ def patch_user_policy( def delete_user_policy( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, association_id: UUID, ) -> None: ... class V2UserHouseholdResourceService(Protocol): - """Route-independent signed-in user-household operations for one request.""" + """Route-independent user-household operations for one request.""" def create_user_household( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", association_input: "UserHouseholdCreateFields", ) -> "UserHouseholdRead": ... def get_user_household( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, association_id: UUID, ) -> "UserHouseholdRead": ... @@ -352,7 +355,7 @@ def get_user_household( def list_user_households( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, household_id: UUID | None = None, legacy_household_id: int | None = None, @@ -363,7 +366,7 @@ def list_user_households( def patch_user_household( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, association_id: UUID, association_input: "UserHouseholdUpdateInput", @@ -372,26 +375,26 @@ def patch_user_household( def delete_user_household( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, association_id: UUID, ) -> None: ... class V2UserSimulationResourceService(Protocol): - """Route-independent signed-in user-simulation operations for one request.""" + """Route-independent user-simulation operations for one request.""" def create_user_simulation( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", association_input: "UserSimulationCreateFields", ) -> "UserSimulationRead": ... def get_user_simulation( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, association_id: UUID, ) -> "UserSimulationRead": ... @@ -399,7 +402,7 @@ def get_user_simulation( def list_user_simulations( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, legacy_simulation_id: int | None = None, offset: int = 0, @@ -409,7 +412,7 @@ def list_user_simulations( def patch_user_simulation( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, association_id: UUID, association_input: "UserSimulationUpdateInput", @@ -418,26 +421,26 @@ def patch_user_simulation( def delete_user_simulation( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, association_id: UUID, ) -> None: ... class V2UserReportResourceService(Protocol): - """Route-independent signed-in user-report operations for one request.""" + """Route-independent user-report operations for one request.""" def create_user_report( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", association_input: "UserReportCreateFields", ) -> "UserReportRead": ... def get_user_report( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, association_id: UUID, ) -> "UserReportRead": ... @@ -445,7 +448,7 @@ def get_user_report( def list_user_reports( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, legacy_report_id: int | None = None, offset: int = 0, @@ -455,7 +458,7 @@ def list_user_reports( def patch_user_report( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, association_id: UUID, association_input: "UserReportUpdateInput", @@ -464,40 +467,40 @@ def patch_user_report( def delete_user_report( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, association_id: UUID, ) -> None: ... class V2AccountResourceService(Protocol): - """Route-independent signed-in account operations for one request.""" + """Route-independent account operations for one request.""" def get_account( - self, *, principal: "UserPrincipal", country_id: str + self, *, principal: "AccountPrincipal", country_id: str ) -> "AccountRead": ... def update_account( self, *, - principal: "UserPrincipal", + principal: "AccountPrincipal", country_id: str, account_input: "AccountUpdateInput", ) -> "AccountRead": ... def export_account( - self, *, principal: "UserPrincipal", country_id: str + self, *, principal: "AccountPrincipal", country_id: str ) -> "AccountExport": ... - def import_records( + def upgrade_account( self, *, principal: "UserPrincipal", + unauthenticated_user_id: UUID, country_id: str, - import_input: "AccountImportInput", - ) -> "AccountImportResult": ... + ) -> "AccountUpgrade": ... - def delete_account(self, *, principal: "UserPrincipal") -> "AccountDeletion": ... + def delete_account(self, *, principal: "AccountPrincipal") -> "AccountDeletion": ... class SimulationGatewayProbe(Protocol): @@ -578,19 +581,31 @@ def _default_v2_household_service_factory() -> V2HouseholdResourceService: 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), -) -> "UserPrincipal": - """Authenticate the signed-in user; fails closed without valid settings.""" + 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_user_request, + authenticate_account_request, ) - return authenticate_user_request( - None if credentials is None else f"Bearer {credentials.credentials}" + return authenticate_account_request( + None if credentials is None else f"Bearer {credentials.credentials}", + unauthenticated_user, ) @@ -690,7 +705,6 @@ def _default_v2_account_service_factory() -> V2AccountResourceService: return V2AccountService( AccountDatabaseSession(get_v2_session_factory()), - legacy_references=_default_legacy_reference_checker(), auth0_user_deleter_factory=build_auth0_user_deleter, ) @@ -720,9 +734,10 @@ class NativeRouteDependencies: ) v2_account_service_factory: Callable[[], V2AccountResourceService] | None = None # User-owned routes attach this at router level. The default validates the - # Auth0 bearer token and fails closed (401/403/503); tests replace it with - # a callable returning a fixed principal. - require_user: Callable[..., "UserPrincipal"] = _default_require_user + # 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": diff --git a/policyengine_api/fastapi_routes/v2/me/__init__.py b/policyengine_api/fastapi_routes/v2/me/__init__.py index 546b66441..3b9e873ae 100644 --- a/policyengine_api/fastapi_routes/v2/me/__init__.py +++ b/policyengine_api/fastapi_routes/v2/me/__init__.py @@ -1 +1 @@ -"""FastAPI adapters for the signed-in account resource (/v2/me).""" +"""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 index 253e48fa5..b0f9bf15d 100644 --- a/policyengine_api/fastapi_routes/v2/me/request_models.py +++ b/policyengine_api/fastapi_routes/v2/me/request_models.py @@ -1,14 +1,7 @@ -"""Strict HTTP request models for the signed-in account resource.""" +"""Strict HTTP request models for the account resource.""" -from policyengine_api.services.v2.user_accounts.types import ( - AccountImportInput, - AccountUpdateInput, -) +from policyengine_api.services.v2.user_accounts.types import AccountUpdateInput class AccountPatchRequest(AccountUpdateInput): """Explicitly supplied account presentation fields.""" - - -class AccountImportRequest(AccountImportInput): - """Browser-local records offered for idempotent import (at most 500).""" diff --git a/policyengine_api/fastapi_routes/v2/me/response_models.py b/policyengine_api/fastapi_routes/v2/me/response_models.py index b76c5e081..59920c256 100644 --- a/policyengine_api/fastapi_routes/v2/me/response_models.py +++ b/policyengine_api/fastapi_routes/v2/me/response_models.py @@ -1,4 +1,4 @@ -"""Strict HTTP response models for the signed-in account resource.""" +"""Strict HTTP response models for the account resource.""" from __future__ import annotations @@ -23,8 +23,8 @@ from policyengine_api.query_parameters import CountryId from policyengine_api.services.v2.user_accounts.types import ( AccountExport, - AccountImportResult, AccountRead, + AccountUpgrade, ) @@ -34,7 +34,9 @@ class StrictAccountAPIModel(BaseModel): class MeItem(StrictAccountAPIModel): id: UUID - primary_country: CountryId + 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 @@ -50,7 +52,8 @@ class MeDetailResult(StrictAccountAPIModel): class MeExportResult(StrictAccountAPIModel): - user: MeItem + # Null when an unauthenticated user has not saved anything yet. + user: MeItem | None policies: list[UserPolicyItem] households: list[UserHouseholdItem] simulations: list[UserSimulationItem] @@ -59,7 +62,7 @@ class MeExportResult(StrictAccountAPIModel): @classmethod def from_export(cls, export: AccountExport) -> "MeExportResult": return cls( - user=MeItem.from_read(export.user), + 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 @@ -71,23 +74,15 @@ def from_export(cls, export: AccountExport) -> "MeExportResult": ) -class MeImportSkip(StrictAccountAPIModel): - client_ref: str | None - reason: Literal["invalid", "unknown_legacy_reference", "client_ref_conflict"] - - -class MeImportResult(StrictAccountAPIModel): - id_map: dict[str, UUID] - skipped: list[MeImportSkip] +class MeUpgradeResult(StrictAccountAPIModel): + item: MeItem + outcome: Literal["converted", "merged", "none"] @classmethod - def from_result(cls, result: AccountImportResult) -> "MeImportResult": + def from_upgrade(cls, upgrade: AccountUpgrade) -> "MeUpgradeResult": return cls( - id_map=dict(result.id_map), - skipped=[ - MeImportSkip(client_ref=skip.client_ref, reason=skip.reason.value) - for skip in result.skipped - ], + item=MeItem.from_read(upgrade.account), + outcome=upgrade.outcome.value, ) @@ -108,5 +103,5 @@ class MeExportResponse(AccountSuccessResponse[MeExportResult]): pass -class MeImportResponse(AccountSuccessResponse[MeImportResult]): +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 index e934c060a..f8814db0e 100644 --- a/policyengine_api/fastapi_routes/v2/me/routes.py +++ b/policyengine_api/fastapi_routes/v2/me/routes.py @@ -1,15 +1,18 @@ -"""Native FastAPI routes for the signed-in account resource (/v2/me). +"""Native FastAPI routes for the current user's account resource (/v2/me). -``GET /v2/me`` provisions the caller's PolicyEngine user on first use, with -``primary_country`` taken from ``country_id``. Every route requires a user -bearer token; authentication is a router-level dependency. +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 +from fastapi import APIRouter, Depends, Header from starlette.responses import JSONResponse, Response from policyengine_api.fastapi_routes.dependencies import ( @@ -19,7 +22,6 @@ 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 ( - AccountImportRequest, AccountPatchRequest, ) from policyengine_api.fastapi_routes.v2.me.response_models import ( @@ -27,18 +29,25 @@ MeDetailResult, MeExportResponse, MeExportResult, - MeImportResponse, - MeImportResult, 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_import_request_size, 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 UserPrincipal +from policyengine_api.services.v2.identity.types import ( + AccountPrincipal, + UserPrincipal, +) def _service_factory( @@ -54,7 +63,7 @@ def _service_factory( def build_v2_me_router(dependencies: NativeRouteDependencies) -> APIRouter: - """Build signed-in account routes without opening a connection.""" + """Build account routes without opening a connection.""" require_user = dependencies.require_user router = APIRouter( @@ -65,18 +74,26 @@ def build_v2_me_router(dependencies: NativeRouteDependencies) -> APIRouter: 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 (and on first use provision) the signed-in user", + summary="Read the current user", description=( - "Returns the PolicyEngine user for the bearer token, creating it on " - "the first call with primary_country set from country_id." + "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: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> MeDetailResponse | JSONResponse: def read() -> MeDetailResponse: item = service_factory().get_account( @@ -91,11 +108,12 @@ def read() -> MeDetailResponse: "/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: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), _size: None = Depends(enforce_user_write_request_size), ) -> MeDetailResponse | JSONResponse: def patch() -> MeDetailResponse: @@ -129,16 +147,19 @@ def patch() -> MeDetailResponse: ), }, }, - summary="Delete the signed-in user and all of their saved records", + summary="Delete the current user and all of their saved records", description=( - "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 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: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> Response | JSONResponse: def delete() -> Response: service_factory().delete_account(principal=principal) @@ -149,12 +170,15 @@ def delete() -> Response: @router.get( "/me/export", response_model=MeExportResponse, - summary="Export the signed-in user and every saved record", - description="Covers every country, not only country_id.", + 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: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> MeExportResponse | JSONResponse: def export() -> MeExportResponse: result = service_factory().export_account( @@ -166,32 +190,39 @@ def export() -> MeExportResponse: return user_resource_operation(export) @router.post( - "/me/imports", - response_model=MeImportResponse, - summary="Import browser-local saved records into the signed-in account", + "/me/upgrade", + response_model=MeUpgradeResponse, + summary="Move this browser's unauthenticated user into the signed-in account", description=( - "At most 500 items and 1 MiB. Idempotent on (user, client_ref): an " - "existing client_ref in the same country returns its stored ID " - "unchanged. Items with an unknown v1 reference, an invalid shape, or " - "a client_ref the user already holds in another country are skipped " - "and reported. A client_ref that appears more than once in the " - "request stores none of its items; each is skipped as invalid." + "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 import_records( - body: AccountImportRequest, + def upgrade_me( query: CountryQuery = Depends(country_query), - principal: UserPrincipal = Depends(require_user), - _size: None = Depends(enforce_user_import_request_size), - ) -> MeImportResponse | JSONResponse: - def import_() -> MeImportResponse: - result = service_factory().import_records( + 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, - import_input=body, ) - return MeImportResponse(result=MeImportResult.from_result(result)) + return MeUpgradeResponse(result=MeUpgradeResult.from_upgrade(result)) - return user_resource_operation(import_) + return user_resource_operation(upgrade) return router diff --git a/policyengine_api/fastapi_routes/v2/user_auth.py b/policyengine_api/fastapi_routes/v2/user_auth.py index 988e4a9dc..9a27f35d4 100644 --- a/policyengine_api/fastapi_routes/v2/user_auth.py +++ b/policyengine_api/fastapi_routes/v2/user_auth.py @@ -1,12 +1,22 @@ -"""HTTP adapter from bearer access tokens to verified user principals. +"""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. Failures map -to the shared API v2 error envelope: +level, so authentication runs before query and body validation. A request +authenticates as: -- no bearer token, or an invalid token: 401 with ``WWW-Authenticate: Bearer``; +- 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; -- authentication not configured, or signing keys unreachable: 503. +- a bearer token while authentication is not configured, or while signing + keys are unreachable: 503. """ from __future__ import annotations @@ -18,14 +28,21 @@ ) 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, ) -from policyengine_api.services.v2.identity.types import UserPrincipal def authenticate_user_request(authorization: str | None) -> UserPrincipal: @@ -59,3 +76,34 @@ def authenticate_user_request(authorization: str | None) -> UserPrincipal: 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 829a04764..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,4 +1,4 @@ -"""Strict HTTP request models for signed-in v2 user-household associations.""" +"""Strict HTTP request models for v2 user-household associations.""" from policyengine_api.services.v2.user_households.types import ( UserHouseholdCreateFields, @@ -7,7 +7,7 @@ class UserHouseholdCreateRequest(UserHouseholdCreateFields): - """Association fields; the owner is taken from the bearer token.""" + """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 9dba3b987..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 signed-in v2 user-household associations.""" +"""Strict HTTP response models for v2 user-household associations.""" from __future__ import annotations @@ -30,7 +30,6 @@ class UserHouseholdItem(StrictUserHouseholdAPIModel): legacy_household_id: int | None name: str | None description: str | None - client_ref: str | None created_at: datetime updated_at: datetime diff --git a/policyengine_api/fastapi_routes/v2/user_households/routes.py b/policyengine_api/fastapi_routes/v2/user_households/routes.py index 875bb1b90..9a019f695 100644 --- a/policyengine_api/fastapi_routes/v2/user_households/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_households/routes.py @@ -1,4 +1,4 @@ -"""Native FastAPI routes for signed-in v2 user-household associations.""" +"""Native FastAPI routes for v2 user-household associations.""" from __future__ import annotations @@ -37,7 +37,7 @@ UserHouseholdDetailQuery, UserHouseholdUpdateQuery, ) -from policyengine_api.services.v2.identity.types import UserPrincipal +from policyengine_api.services.v2.identity.types import AccountPrincipal def _service_factory( @@ -55,10 +55,10 @@ def _service_factory( def build_v2_user_household_router( dependencies: NativeRouteDependencies, ) -> APIRouter: - """Build signed-in association routes without opening a connection. + """Build user association routes without opening a connection. Authentication is a router-level dependency, so a missing or invalid - token is rejected before query or body validation. + credential is rejected before query or body validation. """ require_user = dependencies.require_user @@ -81,9 +81,10 @@ def build_v2_user_household_router( "/user-households", response_model=UserHouseholdDetailResponse, status_code=201, - summary="Save a household for the signed-in user", + summary="Save a household for the current user", description=( - "Requires a user bearer token; the owner is taken from the token. " + "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)." ), @@ -91,7 +92,7 @@ def build_v2_user_household_router( def create_user_household( body: UserHouseholdCreateRequest, query: UserHouseholdCreateQuery = Depends(create_query), - principal: UserPrincipal = Depends(require_user), + 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") @@ -110,12 +111,12 @@ def create() -> UserHouseholdDetailResponse: @router.get( "/user-households/{association_id}", response_model=UserHouseholdDetailResponse, - summary="Read one of the signed-in user's saved households", + summary="Read one of the current user's saved households", ) def get_user_household( association_id: UUID, query: UserHouseholdDetailQuery = Depends(detail_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserHouseholdDetailResponse | JSONResponse: def read() -> UserHouseholdDetailResponse: item = service_factory().get_user_household( @@ -132,11 +133,11 @@ def read() -> UserHouseholdDetailResponse: @router.get( "/user-households", response_model=UserHouseholdPageResponse, - summary="List the signed-in user's saved households", + summary="List the current user's saved households", ) def get_user_households( query: UserHouseholdCollectionQuery = Depends(collection_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserHouseholdPageResponse | JSONResponse: def read() -> UserHouseholdPageResponse: page = service_factory().list_user_households( @@ -156,7 +157,7 @@ def read() -> UserHouseholdPageResponse: @router.patch( "/user-households/{association_id}", response_model=UserHouseholdDetailResponse, - summary="Update or repoint one of the signed-in user's saved households", + 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." @@ -166,7 +167,7 @@ def patch_user_household_route( association_id: UUID, body: UserHouseholdPatchRequest, query: UserHouseholdUpdateQuery = Depends(update_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserHouseholdDetailResponse | JSONResponse: def patch() -> UserHouseholdDetailResponse: item = service_factory().patch_user_household( @@ -186,12 +187,12 @@ def patch() -> UserHouseholdDetailResponse: response_model=None, status_code=204, response_class=Response, - summary="Delete one of the signed-in user's saved households", + summary="Delete one of the current user's saved households", ) def delete_user_household_route( association_id: UUID, query: UserHouseholdDeleteQuery = Depends(delete_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> Response | JSONResponse: def delete() -> Response: service_factory().delete_user_household( 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 f1ac41ee4..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,4 +1,4 @@ -"""Strict HTTP request models for signed-in v2 user-policy associations.""" +"""Strict HTTP request models for v2 user-policy associations.""" from policyengine_api.services.v2.user_policies.types import ( UserPolicyCreateFields, @@ -7,7 +7,7 @@ class UserPolicyCreateRequest(UserPolicyCreateFields): - """Association fields; the owner is taken from the bearer token.""" + """Association fields; the owner is taken from the request credential.""" class UserPolicyPatchRequest(UserPolicyUpdateInput): 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 1f9e92040..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 signed-in v2 user-policy associations.""" +"""Strict HTTP response models for v2 user-policy associations.""" from __future__ import annotations @@ -49,7 +49,6 @@ class UserPolicyItem(StrictUserPolicyAPIModel): description: str | None baseline: UserPolicyBaseline | None provenance: PolicyProvenanceItem | None - client_ref: str | None created_at: datetime updated_at: datetime diff --git a/policyengine_api/fastapi_routes/v2/user_policies/routes.py b/policyengine_api/fastapi_routes/v2/user_policies/routes.py index 78c8e0d7b..7f604744b 100644 --- a/policyengine_api/fastapi_routes/v2/user_policies/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_policies/routes.py @@ -1,4 +1,4 @@ -"""Native FastAPI routes for signed-in v2 user-policy associations.""" +"""Native FastAPI routes for v2 user-policy associations.""" from __future__ import annotations @@ -34,7 +34,7 @@ CountryQuery, UserPolicyCollectionQuery, ) -from policyengine_api.services.v2.identity.types import UserPrincipal +from policyengine_api.services.v2.identity.types import AccountPrincipal def _service_factory( @@ -52,10 +52,10 @@ def _service_factory( def build_v2_user_policy_router( dependencies: NativeRouteDependencies, ) -> APIRouter: - """Build signed-in association routes without opening a connection. + """Build user association routes without opening a connection. Authentication is a router-level dependency, so a missing or invalid - token is rejected before query or body validation. + credential is rejected before query or body validation. """ require_user = dependencies.require_user @@ -75,9 +75,10 @@ def build_v2_user_policy_router( "/user-policies", response_model=UserPolicyDetailResponse, status_code=201, - summary="Save a policy for the signed-in user", + summary="Save a policy for the current user", description=( - "Requires a user bearer token; the owner is taken from the token. " + "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." @@ -86,7 +87,7 @@ def build_v2_user_policy_router( def create_user_policy( body: UserPolicyCreateRequest, query: CountryQuery = Depends(country_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserPolicyDetailResponse | JSONResponse: if body.country_id != query.country_id: return v2_error_response( @@ -108,12 +109,12 @@ def create() -> UserPolicyDetailResponse: @router.get( "/user-policies/{association_id}", response_model=UserPolicyDetailResponse, - summary="Read one of the signed-in user's saved policies", + summary="Read one of the current user's saved policies", ) def get_user_policy( association_id: UUID, query: CountryQuery = Depends(country_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserPolicyDetailResponse | JSONResponse: def read() -> UserPolicyDetailResponse: item = service_factory().get_user_policy( @@ -130,11 +131,11 @@ def read() -> UserPolicyDetailResponse: @router.get( "/user-policies", response_model=UserPolicyPageResponse, - summary="List the signed-in user's saved policies", + summary="List the current user's saved policies", ) def get_user_policies( query: UserPolicyCollectionQuery = Depends(collection_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserPolicyPageResponse | JSONResponse: def read() -> UserPolicyPageResponse: page = service_factory().list_user_policies( @@ -152,7 +153,7 @@ def read() -> UserPolicyPageResponse: @router.patch( "/user-policies/{association_id}", response_model=UserPolicyDetailResponse, - summary="Update or repoint one of the signed-in user's saved policies", + 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 " @@ -163,7 +164,7 @@ def patch_user_policy_route( association_id: UUID, body: UserPolicyPatchRequest, query: CountryQuery = Depends(country_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserPolicyDetailResponse | JSONResponse: def patch() -> UserPolicyDetailResponse: item = service_factory().patch_user_policy( @@ -183,12 +184,12 @@ def patch() -> UserPolicyDetailResponse: response_model=None, status_code=204, response_class=Response, - summary="Delete one of the signed-in user's saved policies", + summary="Delete one of the current user's saved policies", ) def delete_user_policy_route( association_id: UUID, query: CountryQuery = Depends(country_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> Response | JSONResponse: def delete() -> Response: service_factory().delete_user_policy( diff --git a/policyengine_api/fastapi_routes/v2/user_reports/__init__.py b/policyengine_api/fastapi_routes/v2/user_reports/__init__.py index e26d8c074..9a38959ea 100644 --- a/policyengine_api/fastapi_routes/v2/user_reports/__init__.py +++ b/policyengine_api/fastapi_routes/v2/user_reports/__init__.py @@ -1 +1 @@ -"""FastAPI adapters for signed-in v2 user-report associations.""" +"""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 index 5cccf9c26..f9bcdb879 100644 --- a/policyengine_api/fastapi_routes/v2/user_reports/request_models.py +++ b/policyengine_api/fastapi_routes/v2/user_reports/request_models.py @@ -1,4 +1,4 @@ -"""Strict HTTP request models for signed-in v2 user-report associations.""" +"""Strict HTTP request models for v2 user-report associations.""" from policyengine_api.services.v2.user_reports.types import ( UserReportCreateFields, @@ -7,7 +7,7 @@ class UserReportCreateRequest(UserReportCreateFields): - """Association fields; the owner is taken from the bearer token.""" + """Association fields; the owner is taken from the request credential.""" class UserReportPatchRequest(UserReportUpdateInput): diff --git a/policyengine_api/fastapi_routes/v2/user_reports/response_models.py b/policyengine_api/fastapi_routes/v2/user_reports/response_models.py index b9e93ecb5..be28fb430 100644 --- a/policyengine_api/fastapi_routes/v2/user_reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/user_reports/response_models.py @@ -1,4 +1,4 @@ -"""Strict HTTP response models for signed-in v2 user-report associations.""" +"""Strict HTTP response models for v2 user-report associations.""" from __future__ import annotations @@ -95,7 +95,6 @@ class UserReportItem(StrictUserReportAPIModel): provisions: list[ReportProvisionItem] | None validation: ReportValidationItem | None last_run_at: datetime | None - client_ref: str | None created_at: datetime updated_at: datetime diff --git a/policyengine_api/fastapi_routes/v2/user_reports/routes.py b/policyengine_api/fastapi_routes/v2/user_reports/routes.py index f97b6f04a..01926d3f7 100644 --- a/policyengine_api/fastapi_routes/v2/user_reports/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_reports/routes.py @@ -1,4 +1,4 @@ -"""Native FastAPI routes for signed-in v2 user-report associations.""" +"""Native FastAPI routes for v2 user-report associations.""" from __future__ import annotations @@ -34,7 +34,7 @@ CountryQuery, UserReportCollectionQuery, ) -from policyengine_api.services.v2.identity.types import UserPrincipal +from policyengine_api.services.v2.identity.types import AccountPrincipal def _service_factory( @@ -52,10 +52,10 @@ def _service_factory( def build_v2_user_report_router( dependencies: NativeRouteDependencies, ) -> APIRouter: - """Build signed-in association routes without opening a connection. + """Build user association routes without opening a connection. Authentication is a router-level dependency, so a missing or invalid - token is rejected before query or body validation. + credential is rejected before query or body validation. """ require_user = dependencies.require_user @@ -75,9 +75,10 @@ def build_v2_user_report_router( "/user-reports", response_model=UserReportDetailResponse, status_code=201, - summary="Save a v1 report for the signed-in user", + summary="Save a v1 report for the current user", description=( - "Requires a user bearer token; the owner is taken from the token. " + "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." @@ -86,7 +87,7 @@ def build_v2_user_report_router( def create_user_report( body: UserReportCreateRequest, query: CountryQuery = Depends(country_query), - principal: UserPrincipal = Depends(require_user), + 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") @@ -105,12 +106,12 @@ def create() -> UserReportDetailResponse: @router.get( "/user-reports/{association_id}", response_model=UserReportDetailResponse, - summary="Read one of the signed-in user's saved reports", + summary="Read one of the current user's saved reports", ) def get_user_report( association_id: UUID, query: CountryQuery = Depends(country_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserReportDetailResponse | JSONResponse: def read() -> UserReportDetailResponse: item = service_factory().get_user_report( @@ -127,11 +128,11 @@ def read() -> UserReportDetailResponse: @router.get( "/user-reports", response_model=UserReportPageResponse, - summary="List the signed-in user's saved reports", + summary="List the current user's saved reports", ) def get_user_reports( query: UserReportCollectionQuery = Depends(collection_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserReportPageResponse | JSONResponse: def read() -> UserReportPageResponse: page = service_factory().list_user_reports( @@ -148,13 +149,13 @@ def read() -> UserReportPageResponse: @router.patch( "/user-reports/{association_id}", response_model=UserReportDetailResponse, - summary="Update or repoint one of the signed-in user's saved reports", + 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: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserReportDetailResponse | JSONResponse: def patch() -> UserReportDetailResponse: item = service_factory().patch_user_report( @@ -174,12 +175,12 @@ def patch() -> UserReportDetailResponse: response_model=None, status_code=204, response_class=Response, - summary="Delete one of the signed-in user's saved reports", + summary="Delete one of the current user's saved reports", ) def delete_user_report_route( association_id: UUID, query: CountryQuery = Depends(country_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> Response | JSONResponse: def delete() -> Response: service_factory().delete_user_report( diff --git a/policyengine_api/fastapi_routes/v2/user_resources.py b/policyengine_api/fastapi_routes/v2/user_resources.py index 77dbb03c2..891d91085 100644 --- a/policyengine_api/fastapi_routes/v2/user_resources.py +++ b/policyengine_api/fastapi_routes/v2/user_resources.py @@ -1,4 +1,4 @@ -"""Shared HTTP conventions for signed-in user-owned API v2 resources.""" +"""Shared HTTP conventions for user-owned API v2 resources.""" from __future__ import annotations @@ -13,6 +13,7 @@ from policyengine_api.data.v2.settings import V2ConfigurationError from policyengine_api.fastapi_routes.v2.errors import ( + BEARER_CHALLENGE, V2ErrorResponse, V2RequestTooLargeError, v2_error_response, @@ -23,11 +24,15 @@ 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, ) @@ -40,7 +45,6 @@ MAXIMUM_USER_WRITE_REQUEST_BYTES = 256 * 1024 -MAXIMUM_USER_IMPORT_REQUEST_BYTES = 1024 * 1024 def request_size_limit( @@ -72,9 +76,6 @@ async def enforce(request: Request) -> None: enforce_user_write_request_size = request_size_limit( MAXIMUM_USER_WRITE_REQUEST_BYTES, label="256 KiB" ) -enforce_user_import_request_size = request_size_limit( - MAXIMUM_USER_IMPORT_REQUEST_BYTES, label="1 MiB" -) USER_RESOURCE_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { @@ -85,13 +86,18 @@ async def enforce(request: Request) -> None: 401: { "model": V2ErrorResponse, "description": ( - "A valid bearer access token is required; the response carries " + "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.", + "description": ( + "Machine (client-credentials) principals are not permitted, and " + "unauthenticated users cannot edit account details." + ), }, 404: { "model": V2ErrorResponse, @@ -101,14 +107,12 @@ async def enforce(request: Request) -> None: "model": V2ErrorResponse, "description": ( "The change conflicts with stored state, such as a record maintained " - "by the v1 saved-policy mirror or a duplicate client_ref." + "by the v1 saved-policy mirror." ), }, 413: { "model": V2ErrorResponse, - "description": ( - "The request body exceeds 256 KiB (1 MiB for POST /v2/me/imports)." - ), + "description": "The request body exceeds 256 KiB.", }, 422: { "model": V2ErrorResponse, @@ -166,6 +170,12 @@ def user_resource_operation( 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: diff --git a/policyengine_api/fastapi_routes/v2/user_simulations/__init__.py b/policyengine_api/fastapi_routes/v2/user_simulations/__init__.py index 2a9c5f9e4..a23f353b6 100644 --- a/policyengine_api/fastapi_routes/v2/user_simulations/__init__.py +++ b/policyengine_api/fastapi_routes/v2/user_simulations/__init__.py @@ -1 +1 @@ -"""FastAPI adapters for signed-in v2 user-simulation associations.""" +"""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 index 66fb05043..ba3d8e73f 100644 --- a/policyengine_api/fastapi_routes/v2/user_simulations/request_models.py +++ b/policyengine_api/fastapi_routes/v2/user_simulations/request_models.py @@ -1,4 +1,4 @@ -"""Strict HTTP request models for signed-in v2 user-simulation associations.""" +"""Strict HTTP request models for v2 user-simulation associations.""" from policyengine_api.services.v2.user_simulations.types import ( UserSimulationCreateFields, @@ -7,7 +7,7 @@ class UserSimulationCreateRequest(UserSimulationCreateFields): - """Association fields; the owner is taken from the bearer token.""" + """Association fields; the owner is taken from the request credential.""" class UserSimulationPatchRequest(UserSimulationUpdateInput): diff --git a/policyengine_api/fastapi_routes/v2/user_simulations/response_models.py b/policyengine_api/fastapi_routes/v2/user_simulations/response_models.py index 4291a6a07..a7e662cd8 100644 --- a/policyengine_api/fastapi_routes/v2/user_simulations/response_models.py +++ b/policyengine_api/fastapi_routes/v2/user_simulations/response_models.py @@ -1,4 +1,4 @@ -"""Strict HTTP response models for signed-in v2 user-simulation associations.""" +"""Strict HTTP response models for v2 user-simulation associations.""" from __future__ import annotations @@ -27,7 +27,6 @@ class UserSimulationItem(StrictUserSimulationAPIModel): legacy_simulation_id: int | None name: str | None description: str | None - client_ref: str | None created_at: datetime updated_at: datetime diff --git a/policyengine_api/fastapi_routes/v2/user_simulations/routes.py b/policyengine_api/fastapi_routes/v2/user_simulations/routes.py index 967e10ba7..768d28796 100644 --- a/policyengine_api/fastapi_routes/v2/user_simulations/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_simulations/routes.py @@ -1,4 +1,4 @@ -"""Native FastAPI routes for signed-in v2 user-simulation associations.""" +"""Native FastAPI routes for v2 user-simulation associations.""" from __future__ import annotations @@ -34,7 +34,7 @@ CountryQuery, UserSimulationCollectionQuery, ) -from policyengine_api.services.v2.identity.types import UserPrincipal +from policyengine_api.services.v2.identity.types import AccountPrincipal def _service_factory( @@ -52,10 +52,10 @@ def _service_factory( def build_v2_user_simulation_router( dependencies: NativeRouteDependencies, ) -> APIRouter: - """Build signed-in association routes without opening a connection. + """Build user association routes without opening a connection. Authentication is a router-level dependency, so a missing or invalid - token is rejected before query or body validation. + credential is rejected before query or body validation. """ require_user = dependencies.require_user @@ -75,9 +75,10 @@ def build_v2_user_simulation_router( "/user-simulations", response_model=UserSimulationDetailResponse, status_code=201, - summary="Save a v1 simulation for the signed-in user", + summary="Save a v1 simulation for the current user", description=( - "Requires a user bearer token; the owner is taken from the token. " + "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." ), @@ -85,7 +86,7 @@ def build_v2_user_simulation_router( def create_user_simulation( body: UserSimulationCreateRequest, query: CountryQuery = Depends(country_query), - principal: UserPrincipal = Depends(require_user), + 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") @@ -106,12 +107,12 @@ def create() -> UserSimulationDetailResponse: @router.get( "/user-simulations/{association_id}", response_model=UserSimulationDetailResponse, - summary="Read one of the signed-in user's saved simulations", + summary="Read one of the current user's saved simulations", ) def get_user_simulation( association_id: UUID, query: CountryQuery = Depends(country_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserSimulationDetailResponse | JSONResponse: def read() -> UserSimulationDetailResponse: item = service_factory().get_user_simulation( @@ -130,11 +131,11 @@ def read() -> UserSimulationDetailResponse: @router.get( "/user-simulations", response_model=UserSimulationPageResponse, - summary="List the signed-in user's saved simulations", + summary="List the current user's saved simulations", ) def get_user_simulations( query: UserSimulationCollectionQuery = Depends(collection_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserSimulationPageResponse | JSONResponse: def read() -> UserSimulationPageResponse: page = service_factory().list_user_simulations( @@ -153,13 +154,13 @@ def read() -> UserSimulationPageResponse: @router.patch( "/user-simulations/{association_id}", response_model=UserSimulationDetailResponse, - summary="Update or repoint one of the signed-in user's saved simulations", + 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: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> UserSimulationDetailResponse | JSONResponse: def patch() -> UserSimulationDetailResponse: item = service_factory().patch_user_simulation( @@ -181,12 +182,12 @@ def patch() -> UserSimulationDetailResponse: response_model=None, status_code=204, response_class=Response, - summary="Delete one of the signed-in user's saved simulations", + summary="Delete one of the current user's saved simulations", ) def delete_user_simulation_route( association_id: UUID, query: CountryQuery = Depends(country_query), - principal: UserPrincipal = Depends(require_user), + principal: AccountPrincipal = Depends(require_user), ) -> Response | JSONResponse: def delete() -> Response: service_factory().delete_user_simulation( diff --git a/policyengine_api/libs/user_auth.py b/policyengine_api/libs/user_auth.py index c6d66927e..7648edf4f 100644 --- a/policyengine_api/libs/user_auth.py +++ b/policyengine_api/libs/user_auth.py @@ -1,4 +1,4 @@ -"""Inbound Auth0 bearer authentication for user-owned Public API routes. +"""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 @@ -7,7 +7,11 @@ verified. Machine-to-machine tokens (``…@clients`` subjects) are classified as client principals so user routes can refuse them. -This module never logs a token, subject, or email address. +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 @@ -18,8 +22,10 @@ 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 @@ -33,6 +39,12 @@ 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",) @@ -63,6 +75,10 @@ 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" @@ -198,6 +214,16 @@ def bearer_token(authorization: str | None) -> str: 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): diff --git a/policyengine_api/services/v2/associations/database_connectors/__init__.py b/policyengine_api/services/v2/associations/database_connectors/__init__.py deleted file mode 100644 index b0018a3dd..000000000 --- a/policyengine_api/services/v2/associations/database_connectors/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""SQL shared by every user-owned association table.""" diff --git a/policyengine_api/services/v2/associations/database_connectors/creates.py b/policyengine_api/services/v2/associations/database_connectors/creates.py deleted file mode 100644 index c0a8382da..000000000 --- a/policyengine_api/services/v2/associations/database_connectors/creates.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Database inserts shared by every user-owned association table.""" - -from __future__ import annotations - -from collections.abc import Mapping -from uuid import UUID - -from sqlalchemy.dialects.postgresql import insert -from sqlmodel import Session, col - -from policyengine_api.services.v2.associations.types import AssociationTable - - -def import_association( - session: Session, - table: type[AssociationTable], - values: Mapping[str, object], -) -> UUID | None: - """Insert one imported association unless its owner already uses its client_ref. - - Returns the new ID, or ``None`` when ``UNIQUE(user_id, client_ref)`` already - holds a row. - """ - - return session.execute( - insert(table) - .values(**values) - .on_conflict_do_nothing(constraint=f"uq_{table.__tablename__}_user_client_ref") - .returning(col(table.id)) - ).scalar_one_or_none() diff --git a/policyengine_api/services/v2/associations/database_connectors/reads.py b/policyengine_api/services/v2/associations/database_connectors/reads.py deleted file mode 100644 index 12e07c72a..000000000 --- a/policyengine_api/services/v2/associations/database_connectors/reads.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Database reads shared by every user-owned association table.""" - -from __future__ import annotations - -from collections.abc import Collection -from uuid import UUID - -from sqlmodel import Session, col, select - -from policyengine_api.services.v2.associations.types import AssociationTable - - -def read_association_ids_by_client_ref( - session: Session, - table: type[AssociationTable], - *, - user_id: UUID, - client_refs: Collection[str], - country_id: str | None, -) -> dict[str, UUID]: - """Return the owner's association IDs keyed by client_ref. - - ``country_id`` restricts the match to one country; ``None`` matches every - country, because ``client_ref`` is unique per user across countries. - """ - - if not client_refs: - return {} - statement = select(table.client_ref, table.id).where( - table.user_id == user_id, - col(table.client_ref).in_(sorted(set(client_refs))), - ) - if country_id is not None: - statement = statement.where(table.country_id == country_id) - return { - client_ref: association_id - for client_ref, association_id in session.exec(statement).all() - if client_ref is not None - } diff --git a/policyengine_api/services/v2/associations/services.py b/policyengine_api/services/v2/associations/services.py index dc5f5eb14..1e65411b7 100644 --- a/policyengine_api/services/v2/associations/services.py +++ b/policyengine_api/services/v2/associations/services.py @@ -2,25 +2,9 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence -from uuid import UUID - -from sqlmodel import Session - -from policyengine_api.services.v2.associations.database_connectors.creates import ( - import_association, -) -from policyengine_api.services.v2.associations.database_connectors.reads import ( - read_association_ids_by_client_ref, -) -from policyengine_api.services.v2.associations.types import ( - AssociationImport, - AssociationTable, - LegacyReferenceChecker, -) +from policyengine_api.services.v2.associations.types import LegacyReferenceChecker from policyengine_api.services.v2.associations.validators import ( require_legacy_reference_checker, - require_unused_client_ref, ) from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind @@ -38,60 +22,3 @@ def require_legacy_reference( country_id=country_id, requested={kind: (legacy_id,)}, ) - - -def require_unused_client_ref_in_session( - session: Session, - table: type[AssociationTable], - *, - user_id: UUID, - client_ref: str | None, -) -> None: - """Reject a single create whose client_ref the owner already uses anywhere.""" - - if client_ref is None: - return - require_unused_client_ref( - read_association_ids_by_client_ref( - session, - table, - user_id=user_id, - client_refs=(client_ref,), - country_id=None, - ).get(client_ref), - client_ref=client_ref, - ) - - -def import_associations_in_session( - session: Session, - table: type[AssociationTable], - *, - user_id: UUID, - country_id: str, - rows: Sequence[tuple[str, Mapping[str, object]]], -) -> AssociationImport: - """Insert imported rows idempotently on ``(user_id, client_ref)``. - - An existing client_ref in the import country returns its stored ID - unchanged. One the owner already uses in another country is reported as a - conflict instead of being linked across countries. - """ - - ids: dict[str, UUID] = {} - conflicts: list[str] = [] - for client_ref, values in rows: - association_id = import_association( - session, table, values - ) or read_association_ids_by_client_ref( - session, - table, - user_id=user_id, - client_refs=(client_ref,), - country_id=country_id, - ).get(client_ref) - if association_id is None: - conflicts.append(client_ref) - else: - ids[client_ref] = association_id - return AssociationImport(ids=ids, conflicting_client_refs=tuple(conflicts)) diff --git a/policyengine_api/services/v2/associations/types.py b/policyengine_api/services/v2/associations/types.py index 37068cea4..af853112b 100644 --- a/policyengine_api/services/v2/associations/types.py +++ b/policyengine_api/services/v2/associations/types.py @@ -3,29 +3,14 @@ from __future__ import annotations from collections.abc import Collection, Mapping -from dataclasses import dataclass from typing import TYPE_CHECKING, Annotated, Protocol, TypeAlias, Union -from uuid import UUID from pydantic import BaseModel, ConfigDict, FiniteFloat, StringConstraints from typing_extensions import TypeAliasType -from policyengine_api.data.v2.models.associations import ( - ASSOCIATION_CLIENT_REF_MAX_LENGTH, - UserHouseholdAssociation, - UserPolicy, - UserReportAssociation, - UserSimulationAssociation, -) from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind -AssociationTable: TypeAlias = ( - UserPolicy - | UserHouseholdAssociation - | UserSimulationAssociation - | UserReportAssociation -) ASSOCIATION_DESCRIPTION_MAX_LENGTH = 10_000 # A JSON value without NaN or Infinity. Python's JSON parser accepts both, but @@ -60,10 +45,6 @@ AssociationDescription = Annotated[ str, StringConstraints(max_length=ASSOCIATION_DESCRIPTION_MAX_LENGTH) ] -AssociationClientRef = Annotated[ - str, - StringConstraints(min_length=1, max_length=ASSOCIATION_CLIENT_REF_MAX_LENGTH), -] class StrictAssociationModel(BaseModel): @@ -91,15 +72,3 @@ def require_existing( country_id: str, requested: Mapping[LegacyRecordKind, Collection[int]], ) -> None: ... - - -@dataclass(frozen=True) -class AssociationImport: - """Imported association IDs by client_ref, plus client_refs held elsewhere. - - ``conflicting_client_refs`` are client_refs the owner already uses in - another country; they are not linked across countries. - """ - - ids: dict[str, UUID] - conflicting_client_refs: tuple[str, ...] diff --git a/policyengine_api/services/v2/associations/validators.py b/policyengine_api/services/v2/associations/validators.py index afed0ae8b..869db875a 100644 --- a/policyengine_api/services/v2/associations/validators.py +++ b/policyengine_api/services/v2/associations/validators.py @@ -23,10 +23,6 @@ class MirrorOwnedAssociationError(AssociationConflictError): """The association is maintained by the Stage 10 v1 saved-policy mirror.""" -class AssociationClientRefConflictError(AssociationConflictError): - """The caller already has an association with the supplied client_ref.""" - - class AssociationLinkNotFoundError(LookupError): """A linked association is absent or not owned by the same user and country.""" @@ -45,17 +41,6 @@ def require_legacy_reference_checker( return legacy_references -def require_unused_client_ref( - existing_id: UUID | None, - *, - client_ref: str, -) -> None: - if existing_id is not None: - raise AssociationClientRefConflictError( - f"an association with client_ref {client_ref!r} already exists" - ) - - def require_not_mirror_owned(mirror_owned: bool, *, association_id: UUID) -> None: if mirror_owned: raise MirrorOwnedAssociationError( diff --git a/policyengine_api/services/v2/identity/__init__.py b/policyengine_api/services/v2/identity/__init__.py index 035c25044..b15d60727 100644 --- a/policyengine_api/services/v2/identity/__init__.py +++ b/policyengine_api/services/v2/identity/__init__.py @@ -1 +1 @@ -"""Signed-in user identity resolution and account services.""" +"""User identity resolution, provisioning strategies, and account conversion.""" diff --git a/policyengine_api/services/v2/identity/database_connectors/creates.py b/policyengine_api/services/v2/identity/database_connectors/creates.py index a9ac5164f..7071a32b8 100644 --- a/policyengine_api/services/v2/identity/database_connectors/creates.py +++ b/policyengine_api/services/v2/identity/database_connectors/creates.py @@ -8,16 +8,33 @@ from sqlalchemy.dialects.postgresql import insert from sqlmodel import Session, col -from policyengine_api.data.v2.models import User, UserIdentity +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 = 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, *, 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 index 01e23b3e4..b90d9949f 100644 --- a/policyengine_api/services/v2/identity/database_connectors/reads.py +++ b/policyengine_api/services/v2/identity/database_connectors/reads.py @@ -2,9 +2,11 @@ from __future__ import annotations +from uuid import UUID + from sqlmodel import Session, select -from policyengine_api.data.v2.models import UserIdentity +from policyengine_api.data.v2.models import User, UserIdentity, UserType def read_user_identity( @@ -19,3 +21,46 @@ def read_user_identity( 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 index 5cc7daa86..a71c533cc 100644 --- a/policyengine_api/services/v2/identity/database_connectors/updates.py +++ b/policyengine_api/services/v2/identity/database_connectors/updates.py @@ -1,12 +1,30 @@ -"""Database updates used by v2 identity resolution.""" +"""Database updates used by v2 identity resolution and conversion.""" from __future__ import annotations from datetime import datetime +from uuid import UUID -from sqlmodel import Session +from sqlalchemy import update +from sqlmodel import Session, col -from policyengine_api.data.v2.models import UserIdentity +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( @@ -21,3 +39,38 @@ def update_user_identity_activity( 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``) 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/services.py b/policyengine_api/services/v2/identity/services.py index 2a87869a3..808e51e76 100644 --- a/policyengine_api/services/v2/identity/services.py +++ b/policyengine_api/services/v2/identity/services.py @@ -1,115 +1,354 @@ -"""Resolve and provision the v2 user for a verified sign-in identity. - -Every user-owned route runs :func:`resolve_principal_user_in_session` once per -request inside its own v2 transaction. The first request for an unknown -``(issuer, subject)`` provisions a ``users`` row (with the request country as -its primary country) and a ``user_identities`` row. 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. Only the v2 user UUID is logged. +"""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.types import ResolvedUser, 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: UserPrincipal, + principal: AccountPrincipal, ) -> ResolvedUser | None: """Return the existing user for a principal without provisioning one.""" - identity = read_user_identity( - session, - issuer=principal.issuer, - subject=principal.subject, - ) - if identity is None: - return None - return ResolvedUser(user_id=identity.user_id, email=identity.email) + return account_provisioning_for(principal).find_in_session(session) def resolve_principal_user_in_session( session: Session, - principal: UserPrincipal, + 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() - identity = read_user_identity( - session, - issuer=principal.issuer, - subject=principal.subject, + standard_provisioning = StandardAccountProvisioning(principal) + unauthenticated_user = read_unauthenticated_user_for_update( + session, unauthenticated_user_id ) - 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 ResolvedUser(user_id=identity.user_id, email=identity.email) + if unauthenticated_user is None: + return AccountConversion( + owner=standard_provisioning.resolve_in_session( + session, country_id=country_id, now=seen_at + ), + outcome=AccountConversionOutcome.NONE, + ) - with provisioning_savepoint(session) as savepoint: - user = create_provisioned_user(session, primary_country=country_id) - created_user_id = create_user_identity( + converted = ( + standard_provisioning.find_in_session(session) is None + and create_user_identity( session, - user_id=user.id, + user_id=unauthenticated_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)}}, - ) - return ResolvedUser(user_id=created_user_id, email=principal.email) - - concurrent = require_concurrent_identity( - read_user_identity( + is not None + ) + if converted: + update_user_to_standard( session, - issuer=principal.issuer, - subject=principal.subject, + 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, ) - return ResolvedUser(user_id=concurrent.user_id, email=concurrent.email) + 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 index 8928da6be..fc8b1de51 100644 --- a/policyengine_api/services/v2/identity/transformations.py +++ b/policyengine_api/services/v2/identity/transformations.py @@ -3,8 +3,10 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone +from uuid import UUID -from policyengine_api.data.v2.models import UserIdentity +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) @@ -31,3 +33,17 @@ def identity_refresh_required( 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 index c11f2f408..cd95481ef 100644 --- a/policyengine_api/services/v2/identity/types.py +++ b/policyengine_api/services/v2/identity/types.py @@ -2,11 +2,18 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Annotated +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, StringConstraints +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)] @@ -23,9 +30,65 @@ class UserPrincipal(BaseModel): 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 owns one verified sign-in identity.""" + """The v2 user that one principal acts as.""" - user_id: UUID + # 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 index dcb139e4c..9a875a7ee 100644 --- a/policyengine_api/services/v2/identity/validators.py +++ b/policyengine_api/services/v2/identity/validators.py @@ -2,16 +2,47 @@ from __future__ import annotations -from policyengine_api.data.v2.models import UserIdentity +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/user_accounts/__init__.py b/policyengine_api/services/v2/user_accounts/__init__.py index 8eb8e0031..854a3590a 100644 --- a/policyengine_api/services/v2/user_accounts/__init__.py +++ b/policyengine_api/services/v2/user_accounts/__init__.py @@ -1 +1 @@ -"""Signed-in account read, update, export, import, and deletion services.""" +"""Account read, update, export, upgrade, and deletion services.""" diff --git a/policyengine_api/services/v2/user_accounts/services.py b/policyengine_api/services/v2/user_accounts/services.py index 887441335..d4c8f8cf2 100644 --- a/policyengine_api/services/v2/user_accounts/services.py +++ b/policyengine_api/services/v2/user_accounts/services.py @@ -1,31 +1,40 @@ -"""Signed-in account services for ``/v2/me``. +"""Account services for ``/v2/me``. -Reads, updates, exports, and imports resolve (and on first use provision) the -caller's v2 user from the verified session principal. Imports are idempotent -on ``(user_id, client_ref)``: unknown v1 references, invalid items, items -whose client_ref appears more than once in the request, and client_refs the -user already holds in another country are skipped and reported rather than -failing the request. Deletion removes the -user's legacy mappings, then the user (identities and associations cascade), -and finally the Auth0 user when the Management API is configured. Only v2 user -UUIDs are logged. +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.services.v2.associations.types import LegacyReferenceChecker -from policyengine_api.services.v2.associations.validators import ( - require_legacy_reference_checker, +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 UserPrincipal -from policyengine_api.services.v2.legacy_refs.types import LegacyRecordKind +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, @@ -47,60 +56,45 @@ from policyengine_api.services.v2.user_accounts.types import ( AccountDeletion, AccountExport, - AccountImportInput, - Auth0UserDeleter, - AccountImportResult, AccountRead, AccountUpdateInput, + AccountUpgrade, + Auth0UserDeleter, ) from policyengine_api.services.v2.user_accounts.validators import ( AccountDeletionUnavailableError, AccountIdentityDeletionError, - client_ref_conflict_skips, - duplicated_client_refs, - partition_import_items, - partition_known_legacy_items, require_account, + require_editable_account, + require_resolved_account, ) from policyengine_api.services.v2.user_households.services import ( - import_user_households_in_session, read_all_user_households, ) -from policyengine_api.services.v2.user_households.types import UserHouseholdImportItem from policyengine_api.services.v2.user_policies.services import ( - import_user_policies_in_session, read_all_user_policies, ) -from policyengine_api.services.v2.user_policies.types import UserPolicyImportItem from policyengine_api.services.v2.user_reports.services import ( - import_user_reports_in_session, read_all_user_reports, ) -from policyengine_api.services.v2.user_reports.types import UserReportImportItem from policyengine_api.services.v2.user_simulations.services import ( - import_user_simulations_in_session, read_all_user_simulations, ) -from policyengine_api.services.v2.user_simulations.types import ( - UserSimulationImportItem, -) logger = logging.getLogger(__name__) class V2AccountService: - """Sequence signed-in account operations through explicit sessions.""" + """Sequence account operations through explicit sessions.""" def __init__( self, database_session: AccountDatabaseSession, *, - legacy_references: LegacyReferenceChecker | None = None, auth0_user_deleter_factory: Callable[[], Auth0UserDeleter | None] | None = None, ) -> None: self._database_session = database_session - self._legacy_references = legacy_references self._auth0_user_deleter_factory = auth0_user_deleter_factory def _prepared_auth0_user_deleter(self) -> Auth0UserDeleter | None: @@ -126,30 +120,31 @@ def _prepared_auth0_user_deleter(self) -> Auth0UserDeleter | None: ) from error return deleter - def get_account(self, *, principal: UserPrincipal, country_id: str) -> AccountRead: + def get_account( + self, *, principal: AccountPrincipal, country_id: str + ) -> AccountRead: with self._database_session.transaction() as session: - owner = resolve_principal_user_in_session( - session, principal, country_id=country_id - ) - user = require_account( - read_account_user(session, owner.user_id), user_id=owner.user_id + 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: UserPrincipal, + 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, principal, country_id=country_id - ) - user = require_account( - read_account_user(session, owner.user_id), user_id=owner.user_id + 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) @@ -158,15 +153,21 @@ def update_account( ) def export_account( - self, *, principal: UserPrincipal, country_id: str + self, *, principal: AccountPrincipal, country_id: str ) -> AccountExport: with self._database_session.transaction() as session: - owner = resolve_principal_user_in_session( + owner = resolve_principal_user_for_read_in_session( session, principal, country_id=country_id ) - user = require_account( - read_account_user(session, owner.user_id), user_id=owner.user_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), @@ -175,131 +176,57 @@ def export_account( reports=read_all_user_reports(session, user_id=owner.user_id), ) - def import_records( + def upgrade_account( self, *, principal: UserPrincipal, + unauthenticated_user_id: UUID, country_id: str, - import_input: AccountImportInput, - ) -> AccountImportResult: - # A client_ref sent more than once stores none of its records, so - # id_map never returns a client_ref that is also skipped. - duplicates = duplicated_client_refs( - ( - import_input.policies, - import_input.households, - import_input.simulations, - import_input.reports, - ) - ) - policies, policy_skips = partition_import_items( - import_input.policies, UserPolicyImportItem, duplicates=duplicates - ) - households, household_skips = partition_import_items( - import_input.households, UserHouseholdImportItem, duplicates=duplicates - ) - simulations, simulation_skips = partition_import_items( - import_input.simulations, UserSimulationImportItem, duplicates=duplicates - ) - reports, report_skips = partition_import_items( - import_input.reports, UserReportImportItem, duplicates=duplicates - ) - - existing = require_legacy_reference_checker( - self._legacy_references - ).existing_ids( - country_id=country_id, - requested={ - LegacyRecordKind.POLICY: [item.legacy_policy_id for item in policies], - LegacyRecordKind.HOUSEHOLD: [ - item.legacy_household_id for item in households - ], - LegacyRecordKind.SIMULATION: [ - item.legacy_simulation_id for item in simulations - ], - LegacyRecordKind.REPORT: [item.legacy_report_id for item in reports], - }, - ) - known_policies, unknown_policies = partition_known_legacy_items( - policies, - legacy_ids=[item.legacy_policy_id for item in policies], - client_refs=[item.client_ref for item in policies], - existing=existing[LegacyRecordKind.POLICY], - ) - known_households, unknown_households = partition_known_legacy_items( - households, - legacy_ids=[item.legacy_household_id for item in households], - client_refs=[item.client_ref for item in households], - existing=existing[LegacyRecordKind.HOUSEHOLD], - ) - known_simulations, unknown_simulations = partition_known_legacy_items( - simulations, - legacy_ids=[item.legacy_simulation_id for item in simulations], - client_refs=[item.client_ref for item in simulations], - existing=existing[LegacyRecordKind.SIMULATION], - ) - known_reports, unknown_reports = partition_known_legacy_items( - reports, - legacy_ids=[item.legacy_report_id for item in reports], - client_refs=[item.client_ref for item in reports], - existing=existing[LegacyRecordKind.REPORT], - ) - + ) -> AccountUpgrade: with self._database_session.transaction() as session: - owner = resolve_principal_user_in_session( - session, principal, country_id=country_id - ) - # Policies go first so reports can link to policies imported by the - # same request. - policy_import = import_user_policies_in_session( - session, - user_id=owner.user_id, - country_id=country_id, - items=known_policies, - ) - household_import = import_user_households_in_session( - session, - user_id=owner.user_id, - country_id=country_id, - items=known_households, - ) - simulation_import = import_user_simulations_in_session( + conversion = convert_unauthenticated_user_in_session( session, - user_id=owner.user_id, + unauthenticated_user_id=unauthenticated_user_id, + principal=principal, country_id=country_id, - items=known_simulations, ) - report_import = import_user_reports_in_session( - session, - user_id=owner.user_id, - country_id=country_id, - items=known_reports, + 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.""" - return AccountImportResult( - id_map={ - **policy_import.ids, - **household_import.ids, - **simulation_import.ids, - **report_import.ids, + 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} }, - skipped=( - *policy_skips, - *unknown_policies, - *client_ref_conflict_skips(policy_import.conflicting_client_refs), - *household_skips, - *unknown_households, - *client_ref_conflict_skips(household_import.conflicting_client_refs), - *simulation_skips, - *unknown_simulations, - *client_ref_conflict_skips(simulation_import.conflicting_client_refs), - *report_skips, - *unknown_reports, - *client_ref_conflict_skips(report_import.conflicting_client_refs), - ), ) + return AccountDeletion(user_id=owner.user_id, auth0_subjects=()) - def delete_account(self, *, principal: UserPrincipal) -> AccountDeletion: + 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) diff --git a/policyengine_api/services/v2/user_accounts/transformations.py b/policyengine_api/services/v2/user_accounts/transformations.py index c96e6e585..877ce1a18 100644 --- a/policyengine_api/services/v2/user_accounts/transformations.py +++ b/policyengine_api/services/v2/user_accounts/transformations.py @@ -14,6 +14,7 @@ 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, diff --git a/policyengine_api/services/v2/user_accounts/types.py b/policyengine_api/services/v2/user_accounts/types.py index 64c56baf4..67da83764 100644 --- a/policyengine_api/services/v2/user_accounts/types.py +++ b/policyengine_api/services/v2/user_accounts/types.py @@ -4,28 +4,20 @@ from dataclasses import dataclass from datetime import datetime -from enum import StrEnum from typing import Annotated, Protocol from uuid import UUID -from pydantic import ( - BaseModel, - ConfigDict, - JsonValue, - StringConstraints, - model_validator, -) +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 -MAXIMUM_IMPORT_ITEMS = 500 PersonName = Annotated[str, StringConstraints(max_length=255)] -ImportedRecord = dict[str, JsonValue] class StrictAccountInput(BaseModel): @@ -52,52 +44,12 @@ def require_valid_supplied_field(self) -> "AccountUpdateInput": return self -class AccountImportInput(StrictAccountInput): - """Browser-local records; each item is validated individually.""" - - policies: list[ImportedRecord] = [] - households: list[ImportedRecord] = [] - simulations: list[ImportedRecord] = [] - reports: list[ImportedRecord] = [] - - @model_validator(mode="after") - def bound_item_count(self) -> "AccountImportInput": - total = ( - len(self.policies) - + len(self.households) - + len(self.simulations) - + len(self.reports) - ) - if total > MAXIMUM_IMPORT_ITEMS: - raise ValueError( - f"An import may contain at most {MAXIMUM_IMPORT_ITEMS} items" - ) - return self - - -class ImportSkipReason(StrEnum): - INVALID = "invalid" - UNKNOWN_LEGACY_REFERENCE = "unknown_legacy_reference" - # The owner already uses the client_ref in another country. - CLIENT_REF_CONFLICT = "client_ref_conflict" - - -@dataclass(frozen=True) -class ImportSkip: - client_ref: str | None - reason: ImportSkipReason - - -@dataclass(frozen=True) -class AccountImportResult: - id_map: dict[str, UUID] - skipped: tuple[ImportSkip, ...] - - @dataclass(frozen=True) class AccountRead: id: UUID - primary_country: str + user_type: str + # Null only for an unauthenticated user. + primary_country: str | None first_name: str | None last_name: str | None email: str | None @@ -106,13 +58,22 @@ class AccountRead: @dataclass(frozen=True) class AccountExport: - user: AccountRead + # 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 diff --git a/policyengine_api/services/v2/user_accounts/validators.py b/policyengine_api/services/v2/user_accounts/validators.py index 268d182f7..e86f957f2 100644 --- a/policyengine_api/services/v2/user_accounts/validators.py +++ b/policyengine_api/services/v2/user_accounts/validators.py @@ -2,23 +2,20 @@ from __future__ import annotations -from collections import Counter -from collections.abc import Collection, Iterable, Sequence -from typing import TypeVar -from uuid import UUID - -from pydantic import BaseModel, ValidationError - from policyengine_api.data.v2.models import User -from policyengine_api.services.v2.user_accounts.types import ( - ImportedRecord, - ImportSkip, - ImportSkipReason, +from policyengine_api.services.v2.identity.types import ( + AccountPrincipal, + ResolvedUser, + UserPrincipal, ) class AccountNotFoundError(LookupError): - """Raised when the resolved account row is missing.""" + """Raised when the principal has no stored account.""" + + +class AccountEditRequiresSignInError(PermissionError): + """Raised when an unauthenticated principal edits account details.""" class AccountIdentityDeletionError(RuntimeError): @@ -29,103 +26,21 @@ class AccountDeletionUnavailableError(RuntimeError): """Raised before any data is deleted when Auth0 deletion cannot run.""" -ImportItemT = TypeVar("ImportItemT", bound=BaseModel) -LegacyItemT = TypeVar("LegacyItemT") +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_id: UUID) -> User: +def require_account(user: User | None) -> User: if user is None: - raise AccountNotFoundError(f"user {user_id} was not found") + raise AccountNotFoundError("The account was not found") return user -def _client_ref(record: ImportedRecord) -> str | None: - value = record.get("client_ref") - return value if isinstance(value, str) else None - - -def duplicated_client_refs( - record_groups: Iterable[Sequence[ImportedRecord]], -) -> frozenset[str]: - """Return the client_refs carried by more than one record of one request. - - ``id_map`` is keyed by client_ref alone, so it cannot tell records that - share one apart, and a client removes its local item once the client_ref - comes back in ``id_map``. Counting across every kind keeps a stored record - and a skipped one from sharing a client_ref in one response. - """ - - occurrences = Counter( - _client_ref(record) for records in record_groups for record in records - ) - return frozenset( - client_ref - for client_ref, count in occurrences.items() - if client_ref is not None and count > 1 - ) - - -def partition_import_items( - records: Sequence[ImportedRecord], - item_type: type[ImportItemT], - *, - duplicates: Collection[str], -) -> tuple[list[ImportItemT], list[ImportSkip]]: - """Validate each record independently; invalid records are skipped. - - Every record whose client_ref is in ``duplicates`` is skipped as invalid - without validation, so none of the records sharing it is stored. - """ - - valid: list[ImportItemT] = [] - skipped: list[ImportSkip] = [] - for record in records: - client_ref = _client_ref(record) - if client_ref is not None and client_ref in duplicates: - skipped.append( - ImportSkip(client_ref=client_ref, reason=ImportSkipReason.INVALID) - ) - continue - try: - valid.append(item_type.model_validate(record)) - except ValidationError: - skipped.append( - ImportSkip( - client_ref=client_ref, - reason=ImportSkipReason.INVALID, - ) - ) - return valid, skipped - - -def client_ref_conflict_skips(client_refs: Sequence[str]) -> list[ImportSkip]: - """Report client_refs the owner already uses in another country.""" - - return [ - ImportSkip(client_ref=client_ref, reason=ImportSkipReason.CLIENT_REF_CONFLICT) - for client_ref in client_refs - ] - - -def partition_known_legacy_items( - items: Sequence[LegacyItemT], - *, - legacy_ids: Sequence[int], - client_refs: Sequence[str], - existing: Collection[int], -) -> tuple[list[LegacyItemT], list[ImportSkip]]: - """Skip items whose v1 reference does not exist in the import country.""" - - known: list[LegacyItemT] = [] - skipped: list[ImportSkip] = [] - for item, legacy_id, client_ref in zip(items, legacy_ids, client_refs, strict=True): - if legacy_id in existing: - known.append(item) - else: - skipped.append( - ImportSkip( - client_ref=client_ref, - reason=ImportSkipReason.UNKNOWN_LEGACY_REFERENCE, - ) - ) - return known, skipped +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/services.py b/policyengine_api/services/v2/user_households/services.py index d13633506..a89f5c0cf 100644 --- a/policyengine_api/services/v2/user_households/services.py +++ b/policyengine_api/services/v2/user_households/services.py @@ -1,30 +1,27 @@ """Application services for native v2 user-household associations. -The owner always comes from the verified session principal. v1 references are -checked in a short read-only v1 transaction before the v2 transaction opens. +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 -from collections.abc import Sequence from uuid import UUID from sqlmodel import Session from policyengine_api.data.v2.models import UserHouseholdAssociation from policyengine_api.services.v2.associations.services import ( - import_associations_in_session, require_legacy_reference, - require_unused_client_ref_in_session, -) -from policyengine_api.services.v2.associations.types import ( - AssociationImport, - LegacyReferenceChecker, ) +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 UserPrincipal +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, @@ -46,14 +43,12 @@ UserHouseholdDatabaseSession, ) from policyengine_api.services.v2.user_households.transformations import ( - imported_user_household_input, owned_user_household_input, user_household_page, user_household_read, ) from policyengine_api.services.v2.user_households.types import ( UserHouseholdCreateFields, - UserHouseholdImportItem, UserHouseholdPage, UserHouseholdRead, UserHouseholdUpdateInput, @@ -68,12 +63,16 @@ def read_owned_user_household( session: Session, *, - user_id: UUID, + user_id: UUID | None, country_id: str, association_id: UUID, ) -> UserHouseholdAssociation: + """``user_id`` is None for an unauthenticated user that owns nothing yet.""" + return require_user_household( - read_owned_user_household_row( + None + if user_id is None + else read_owned_user_household_row( session, user_id=user_id, country_id=country_id, @@ -124,40 +123,6 @@ def read_all_user_households( ) -def import_user_households_in_session( - session: Session, - *, - user_id: UUID, - country_id: str, - items: Sequence[UserHouseholdImportItem], -) -> AssociationImport: - """Insert imported associations idempotently on ``(user_id, client_ref)``.""" - - copied = read_copied_household_ids( - session, - country_id=country_id, - legacy_household_ids={item.legacy_household_id for item in items}, - ) - return import_associations_in_session( - session, - UserHouseholdAssociation, - user_id=user_id, - country_id=country_id, - rows=[ - ( - item.client_ref, - imported_user_household_input( - item, - country_id=country_id, - user_id=user_id, - household_id=copied.get(item.legacy_household_id), - ).model_dump(), - ) - for item in items - ], - ) - - class V2UserHouseholdService: """Sequence owner-scoped association operations through explicit sessions.""" @@ -182,7 +147,7 @@ def _require_legacy_household( def create_user_household( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, association_input: UserHouseholdCreateFields, ) -> UserHouseholdRead: if association_input.legacy_household_id is not None: @@ -196,12 +161,6 @@ def create_user_household( principal, country_id=association_input.country_id, ) - require_unused_client_ref_in_session( - session, - UserHouseholdAssociation, - user_id=owner.user_id, - client_ref=association_input.client_ref, - ) household_id = resolve_user_household_reference( session, country_id=association_input.country_id, @@ -222,18 +181,18 @@ def create_user_household( def get_user_household( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, association_id: UUID, ) -> UserHouseholdRead: with self._database_session.transaction() as session: - owner = resolve_principal_user_in_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=owner.user_id, + user_id=None if owner is None else owner.user_id, country_id=country_id, association_id=association_id, ) @@ -242,7 +201,7 @@ def get_user_household( def list_user_households( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, household_id: UUID | None = None, legacy_household_id: int | None = None, @@ -250,9 +209,11 @@ def list_user_households( limit: int = 100, ) -> UserHouseholdPage: with self._database_session.transaction() as session: - owner = resolve_principal_user_in_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, @@ -267,7 +228,7 @@ def list_user_households( def patch_user_household( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, association_id: UUID, association_input: UserHouseholdUpdateInput, @@ -311,7 +272,7 @@ def patch_user_household( def delete_user_household( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, association_id: UUID, ) -> None: diff --git a/policyengine_api/services/v2/user_households/transformations.py b/policyengine_api/services/v2/user_households/transformations.py index 16d66e0fd..945b0abe1 100644 --- a/policyengine_api/services/v2/user_households/transformations.py +++ b/policyengine_api/services/v2/user_households/transformations.py @@ -8,7 +8,6 @@ from policyengine_api.services.v2.user_households.types import ( UserHouseholdCreateFields, UserHouseholdCreationInput, - UserHouseholdImportItem, UserHouseholdPage, UserHouseholdRead, ) @@ -25,7 +24,6 @@ def user_household_read( legacy_household_id=association.legacy_household_id, name=association.name, description=association.description, - client_ref=association.client_ref, created_at=association.created_at, updated_at=association.updated_at, ) @@ -56,20 +54,3 @@ def owned_user_household_input( user_id=user_id, household_id=household_id, ) - - -def imported_user_household_input( - item: UserHouseholdImportItem, - *, - country_id: str, - user_id: UUID, - household_id: UUID | None, -) -> UserHouseholdCreationInput: - return UserHouseholdCreationInput.model_validate( - { - **item.model_dump(), - "country_id": country_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 549ed805d..cbebeb943 100644 --- a/policyengine_api/services/v2/user_households/types.py +++ b/policyengine_api/services/v2/user_households/types.py @@ -10,7 +10,6 @@ from policyengine_api.query_parameters import CountryId, ResourceId, UserId from policyengine_api.services.v2.associations.types import ( - AssociationClientRef, AssociationDescription, AssociationName, StrictAssociationModel, @@ -30,7 +29,6 @@ class UserHouseholdCreateFields(StrictUserHouseholdInput): legacy_household_id: LegacyRecordId | None = None name: AssociationName | None = None description: AssociationDescription | None = None - client_ref: AssociationClientRef | None = None @model_validator(mode="after") def require_household_reference(self) -> "UserHouseholdCreateFields": @@ -50,7 +48,6 @@ class UserHouseholdCreationInput(StrictUserHouseholdInput): legacy_household_id: LegacyRecordId | None = None name: AssociationName | None = None description: str | None = None - client_ref: AssociationClientRef | None = None @model_validator(mode="after") def require_household_reference(self) -> "UserHouseholdCreationInput": @@ -80,15 +77,6 @@ def require_valid_supplied_field(self) -> "UserHouseholdUpdateInput": return self -class UserHouseholdImportItem(StrictUserHouseholdInput): - """One browser-local saved household offered for idempotent import.""" - - client_ref: AssociationClientRef - legacy_household_id: LegacyRecordId - name: AssociationName | None = None - description: AssociationDescription | None = None - - @dataclass(frozen=True) class UserHouseholdRead: id: UUID @@ -98,7 +86,6 @@ class UserHouseholdRead: legacy_household_id: int | None name: str | None description: str | None - client_ref: str | None created_at: datetime updated_at: datetime diff --git a/policyengine_api/services/v2/user_policies/services.py b/policyengine_api/services/v2/user_policies/services.py index 9290a6cad..e41972c1d 100644 --- a/policyengine_api/services/v2/user_policies/services.py +++ b/policyengine_api/services/v2/user_policies/services.py @@ -2,28 +2,23 @@ from __future__ import annotations -from collections.abc import Sequence from uuid import UUID from sqlmodel import Session from policyengine_api.data.v2.models import LegacyUserPolicyMapping, UserPolicy from policyengine_api.services.v2.associations.services import ( - import_associations_in_session, require_legacy_reference, - require_unused_client_ref_in_session, -) -from policyengine_api.services.v2.associations.types import ( - AssociationImport, - LegacyReferenceChecker, ) +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 UserPrincipal +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, @@ -61,7 +56,6 @@ from policyengine_api.services.v2.user_policies.transformations import ( USER_POLICY_FINGERPRINT_VERSION, fingerprint_legacy_user_policy, - imported_user_policy_input, legacy_name_requires_update, owned_user_policy_input, project_legacy_user_policy, @@ -74,7 +68,6 @@ LegacyUserPolicySnapshot, UserPolicyCreateFields, UserPolicyCreationInput, - UserPolicyImportItem, UserPolicyPage, UserPolicyRead, UserPolicyUpdateInput, @@ -117,12 +110,16 @@ def read_user_policy_page( def read_owned_user_policy( session: Session, *, - user_id: UUID, + 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( - read_owned_user_policy_row( + None + if user_id is None + else read_owned_user_policy_row( session, user_id=user_id, country_id=country_id, @@ -173,40 +170,6 @@ def read_all_user_policies( ) -def import_user_policies_in_session( - session: Session, - *, - user_id: UUID, - country_id: str, - items: Sequence[UserPolicyImportItem], -) -> AssociationImport: - """Insert imported associations idempotently on ``(user_id, client_ref)``.""" - - mirrored = read_mirrored_policy_ids( - session, - country_id=country_id, - legacy_policy_ids={item.legacy_policy_id for item in items}, - ) - return import_associations_in_session( - session, - UserPolicy, - user_id=user_id, - country_id=country_id, - rows=[ - ( - item.client_ref, - imported_user_policy_input( - item, - country_id=country_id, - user_id=user_id, - policy_id=mirrored.get(item.legacy_policy_id), - ).model_dump(), - ) - for item in items - ], - ) - - def resolve_legacy_user_id( session: Session, *, @@ -409,8 +372,10 @@ def mirror_legacy_user_policy_in_session( class V2UserPolicyService: """Sequence owner-scoped association operations through explicit sessions. - The owner always comes from the verified session principal. v1 references - are checked in a short read-only v1 transaction before the v2 + 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. """ @@ -433,7 +398,7 @@ def _require_legacy_policy(self, *, country_id: str, legacy_policy_id: int) -> N def create_user_policy( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, association_input: UserPolicyCreateFields, ) -> UserPolicyRead: if association_input.legacy_policy_id is not None: @@ -447,12 +412,6 @@ def create_user_policy( principal, country_id=association_input.country_id, ) - require_unused_client_ref_in_session( - session, - UserPolicy, - user_id=owner.user_id, - client_ref=association_input.client_ref, - ) policy_id = resolve_user_policy_reference( session, country_id=association_input.country_id, @@ -473,18 +432,18 @@ def create_user_policy( def get_user_policy( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, association_id: UUID, ) -> UserPolicyRead: with self._database_session.transaction() as session: - owner = resolve_principal_user_in_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=owner.user_id, + user_id=None if owner is None else owner.user_id, country_id=country_id, association_id=association_id, ) @@ -493,7 +452,7 @@ def get_user_policy( def list_user_policies( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, policy_id: UUID | None = None, legacy_policy_id: int | None = None, @@ -501,9 +460,11 @@ def list_user_policies( limit: int = 100, ) -> UserPolicyPage: with self._database_session.transaction() as session: - owner = resolve_principal_user_in_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, @@ -517,7 +478,7 @@ def list_user_policies( def patch_user_policy( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, association_id: UUID, association_input: UserPolicyUpdateInput, @@ -565,7 +526,7 @@ def patch_user_policy( def delete_user_policy( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, association_id: UUID, ) -> None: diff --git a/policyengine_api/services/v2/user_policies/transformations.py b/policyengine_api/services/v2/user_policies/transformations.py index 9de7b2150..65ee43052 100644 --- a/policyengine_api/services/v2/user_policies/transformations.py +++ b/policyengine_api/services/v2/user_policies/transformations.py @@ -11,7 +11,6 @@ LegacyUserPolicySnapshot, UserPolicyCreateFields, UserPolicyCreationInput, - UserPolicyImportItem, UserPolicyPage, UserPolicyRead, ) @@ -31,7 +30,6 @@ def user_policy_read(association: UserPolicy) -> UserPolicyRead: description=association.description, baseline=association.baseline, provenance=association.provenance, - client_ref=association.client_ref, created_at=association.created_at, updated_at=association.updated_at, ) @@ -98,23 +96,6 @@ def owned_user_policy_input( ) -def imported_user_policy_input( - item: UserPolicyImportItem, - *, - country_id: str, - user_id: UUID, - policy_id: UUID | None, -) -> UserPolicyCreationInput: - return UserPolicyCreationInput.model_validate( - { - **item.model_dump(), - "country_id": country_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 0d025dd13..23dc7180d 100644 --- a/policyengine_api/services/v2/user_policies/types.py +++ b/policyengine_api/services/v2/user_policies/types.py @@ -17,7 +17,6 @@ UserId, ) from policyengine_api.services.v2.associations.types import ( - AssociationClientRef, AssociationDescription, AssociationName, StrictAssociationModel, @@ -50,7 +49,6 @@ class UserPolicyCreateFields(StrictAssociationInput): description: AssociationDescription | None = None baseline: UserPolicyBaseline | None = None provenance: PolicyProvenance | None = None - client_ref: AssociationClientRef | None = None @model_validator(mode="after") def require_policy_reference(self) -> "UserPolicyCreateFields": @@ -73,7 +71,6 @@ class UserPolicyCreationInput(StrictAssociationInput): description: str | None = None baseline: UserPolicyBaseline | None = None provenance: PolicyProvenance | None = None - client_ref: AssociationClientRef | None = None @model_validator(mode="after") def require_policy_reference(self) -> "UserPolicyCreationInput": @@ -106,17 +103,6 @@ def require_supplied_field(self) -> "UserPolicyUpdateInput": return self -class UserPolicyImportItem(StrictAssociationInput): - """One browser-local saved policy offered for idempotent import.""" - - client_ref: AssociationClientRef - legacy_policy_id: LegacyRecordId - name: AssociationName | None = None - description: AssociationDescription | None = None - baseline: UserPolicyBaseline | None = None - provenance: PolicyProvenance | None = None - - class LegacyUserPolicySnapshot(StrictAssociationInput): """Detached complete committed v1 saved-policy row.""" @@ -155,7 +141,6 @@ class UserPolicyRead: description: str | None baseline: str | None provenance: dict[str, Any] | None - client_ref: str | None created_at: datetime updated_at: datetime diff --git a/policyengine_api/services/v2/user_reports/services.py b/policyengine_api/services/v2/user_reports/services.py index bf57226c6..24a7f8c87 100644 --- a/policyengine_api/services/v2/user_reports/services.py +++ b/policyengine_api/services/v2/user_reports/services.py @@ -1,36 +1,30 @@ """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, and v1 references are -checked in a short read-only v1 transaction before the v2 transaction opens. +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`` must name the same owner's saved policy in the same country. """ from __future__ import annotations -from collections.abc import Sequence from uuid import UUID from sqlmodel import Session -from policyengine_api.data.v2.models import UserPolicy, UserReportAssociation -from policyengine_api.services.v2.associations.database_connectors.reads import ( - read_association_ids_by_client_ref, -) +from policyengine_api.data.v2.models import UserReportAssociation from policyengine_api.services.v2.associations.services import ( - import_associations_in_session, require_legacy_reference, - require_unused_client_ref_in_session, -) -from policyengine_api.services.v2.associations.types import ( - AssociationImport, - LegacyReferenceChecker, ) +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 UserPrincipal +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, @@ -51,7 +45,6 @@ UserReportDatabaseSession, ) from policyengine_api.services.v2.user_reports.transformations import ( - imported_user_report_input, owned_user_report_input, user_report_page, user_report_read, @@ -59,7 +52,6 @@ ) from policyengine_api.services.v2.user_reports.types import ( UserReportCreateFields, - UserReportImportItem, UserReportPage, UserReportRead, UserReportUpdateInput, @@ -73,12 +65,16 @@ def read_owned_user_report( session: Session, *, - user_id: UUID, + 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( - read_owned_user_report_row( + None + if user_id is None + else read_owned_user_report_row( session, user_id=user_id, country_id=country_id, @@ -119,55 +115,6 @@ def read_all_user_reports( ) -def import_user_reports_in_session( - session: Session, - *, - user_id: UUID, - country_id: str, - items: Sequence[UserReportImportItem], -) -> AssociationImport: - """Insert imported reports idempotently on ``(user_id, client_ref)``. - - ``user_policy_client_ref`` resolves against the owner's saved policies in - the import country only, including policies inserted earlier in the same - transaction. An unresolved reference leaves ``user_policy_id`` empty. - """ - - policy_ids = read_association_ids_by_client_ref( - session, - UserPolicy, - user_id=user_id, - client_refs={ - item.user_policy_client_ref - for item in items - if item.user_policy_client_ref is not None - }, - country_id=country_id, - ) - return import_associations_in_session( - session, - UserReportAssociation, - user_id=user_id, - country_id=country_id, - rows=[ - ( - item.client_ref, - imported_user_report_input( - item, - country_id=country_id, - user_id=user_id, - user_policy_id=( - policy_ids.get(item.user_policy_client_ref) - if item.user_policy_client_ref is not None - else None - ), - ).model_dump(), - ) - for item in items - ], - ) - - class V2UserReportService: """Sequence owner-scoped association operations through explicit sessions.""" @@ -190,7 +137,7 @@ def _require_legacy_report(self, *, country_id: str, legacy_report_id: int) -> N def create_user_report( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, association_input: UserReportCreateFields, ) -> UserReportRead: self._require_legacy_report( @@ -203,12 +150,6 @@ def create_user_report( principal, country_id=association_input.country_id, ) - require_unused_client_ref_in_session( - session, - UserReportAssociation, - user_id=owner.user_id, - client_ref=association_input.client_ref, - ) validate_linked_user_policy( session, user_id=owner.user_id, @@ -225,18 +166,18 @@ def create_user_report( def get_user_report( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, association_id: UUID, ) -> UserReportRead: with self._database_session.transaction() as session: - owner = resolve_principal_user_in_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=owner.user_id, + user_id=None if owner is None else owner.user_id, country_id=country_id, association_id=association_id, ) @@ -245,16 +186,18 @@ def get_user_report( def list_user_reports( self, *, - principal: UserPrincipal, + 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_in_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, @@ -268,7 +211,7 @@ def list_user_reports( def patch_user_report( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, association_id: UUID, association_input: UserReportUpdateInput, @@ -306,7 +249,7 @@ def patch_user_report( def delete_user_report( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, association_id: UUID, ) -> None: diff --git a/policyengine_api/services/v2/user_reports/transformations.py b/policyengine_api/services/v2/user_reports/transformations.py index 3c02d06e2..377f584c3 100644 --- a/policyengine_api/services/v2/user_reports/transformations.py +++ b/policyengine_api/services/v2/user_reports/transformations.py @@ -9,7 +9,6 @@ from policyengine_api.services.v2.user_reports.types import ( UserReportCreateFields, UserReportCreationInput, - UserReportImportItem, UserReportPage, UserReportRead, UserReportUpdateInput, @@ -32,7 +31,6 @@ def user_report_read(association: UserReportAssociation) -> UserReportRead: provisions=association.provisions, validation=association.validation, last_run_at=association.last_run_at, - client_ref=association.client_ref, created_at=association.created_at, updated_at=association.updated_at, ) @@ -65,27 +63,6 @@ def owned_user_report_input( ) -def imported_user_report_input( - item: UserReportImportItem, - *, - country_id: str, - user_id: UUID, - user_policy_id: UUID | None, -) -> UserReportCreationInput: - return UserReportCreationInput.model_validate( - { - **item.model_dump( - exclude={"provisions", "validation", "user_policy_client_ref"} - ), - "provisions": stored_provisions(item.provisions), - "validation": stored_validation(item.validation), - "country_id": country_id, - "user_id": user_id, - "user_policy_id": user_policy_id, - } - ) - - def user_report_update_values( association_input: UserReportUpdateInput, ) -> dict[str, Any]: diff --git a/policyengine_api/services/v2/user_reports/types.py b/policyengine_api/services/v2/user_reports/types.py index d2047f2bd..1f46f251f 100644 --- a/policyengine_api/services/v2/user_reports/types.py +++ b/policyengine_api/services/v2/user_reports/types.py @@ -18,7 +18,6 @@ from policyengine_api.query_parameters import CountryId, ResourceId, UserId from policyengine_api.services.v2.associations.types import ( - AssociationClientRef, AssociationDescription, AssociationName, FiniteJsonValue, @@ -146,7 +145,6 @@ class UserReportCreateFields(_ReportPresentationFields): country_id: CountryId legacy_report_id: LegacyRecordId user_policy_id: ResourceId | None = None - client_ref: AssociationClientRef | None = None class UserReportCreationInput(StrictUserReportModel): @@ -162,7 +160,6 @@ class UserReportCreationInput(StrictUserReportModel): provisions: list[StoredJSONObject] | None = None validation: StoredJSONObject | None = None last_run_at: AwareDatetime | None = None - client_ref: AssociationClientRef | None = None class UserReportUpdateInput(_ReportPresentationFields): @@ -189,14 +186,6 @@ def require_valid_supplied_field(self) -> "UserReportUpdateInput": return self -class UserReportImportItem(_ReportPresentationFields): - """One browser-local saved report offered for idempotent import.""" - - client_ref: AssociationClientRef - legacy_report_id: LegacyRecordId - user_policy_client_ref: AssociationClientRef | None = None - - @dataclass(frozen=True) class UserReportRead: id: UUID @@ -211,7 +200,6 @@ class UserReportRead: provisions: list[StoredJSONObject] | None validation: StoredJSONObject | None last_run_at: datetime | None - client_ref: str | None created_at: datetime updated_at: datetime diff --git a/policyengine_api/services/v2/user_simulations/services.py b/policyengine_api/services/v2/user_simulations/services.py index f4669f8f6..b3d38607d 100644 --- a/policyengine_api/services/v2/user_simulations/services.py +++ b/policyengine_api/services/v2/user_simulations/services.py @@ -1,31 +1,28 @@ """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, and v1 references are checked in a -short read-only v1 transaction before the v2 transaction opens. +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 collections.abc import Sequence from uuid import UUID from sqlmodel import Session from policyengine_api.data.v2.models import UserSimulationAssociation from policyengine_api.services.v2.associations.services import ( - import_associations_in_session, require_legacy_reference, - require_unused_client_ref_in_session, -) -from policyengine_api.services.v2.associations.types import ( - AssociationImport, - LegacyReferenceChecker, ) +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 UserPrincipal +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, @@ -45,14 +42,12 @@ UserSimulationDatabaseSession, ) from policyengine_api.services.v2.user_simulations.transformations import ( - imported_user_simulation_input, owned_user_simulation_input, user_simulation_page, user_simulation_read, ) from policyengine_api.services.v2.user_simulations.types import ( UserSimulationCreateFields, - UserSimulationImportItem, UserSimulationPage, UserSimulationRead, UserSimulationUpdateInput, @@ -65,12 +60,16 @@ def read_owned_user_simulation( session: Session, *, - user_id: UUID, + 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( - read_owned_user_simulation_row( + None + if user_id is None + else read_owned_user_simulation_row( session, user_id=user_id, country_id=country_id, @@ -91,34 +90,6 @@ def read_all_user_simulations( ) -def import_user_simulations_in_session( - session: Session, - *, - user_id: UUID, - country_id: str, - items: Sequence[UserSimulationImportItem], -) -> AssociationImport: - """Insert imported associations idempotently on ``(user_id, client_ref)``.""" - - return import_associations_in_session( - session, - UserSimulationAssociation, - user_id=user_id, - country_id=country_id, - rows=[ - ( - item.client_ref, - imported_user_simulation_input( - item, - country_id=country_id, - user_id=user_id, - ).model_dump(), - ) - for item in items - ], - ) - - class V2UserSimulationService: """Sequence owner-scoped association operations through explicit sessions.""" @@ -143,7 +114,7 @@ def _require_legacy_simulation( def create_user_simulation( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, association_input: UserSimulationCreateFields, ) -> UserSimulationRead: self._require_legacy_simulation( @@ -156,12 +127,6 @@ def create_user_simulation( principal, country_id=association_input.country_id, ) - require_unused_client_ref_in_session( - session, - UserSimulationAssociation, - user_id=owner.user_id, - client_ref=association_input.client_ref, - ) return user_simulation_read( create_user_simulation( session, @@ -175,18 +140,18 @@ def create_user_simulation( def get_user_simulation( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, association_id: UUID, ) -> UserSimulationRead: with self._database_session.transaction() as session: - owner = resolve_principal_user_in_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=owner.user_id, + user_id=None if owner is None else owner.user_id, country_id=country_id, association_id=association_id, ) @@ -195,16 +160,18 @@ def get_user_simulation( def list_user_simulations( self, *, - principal: UserPrincipal, + 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_in_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, @@ -218,7 +185,7 @@ def list_user_simulations( def patch_user_simulation( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, association_id: UUID, association_input: UserSimulationUpdateInput, @@ -248,7 +215,7 @@ def patch_user_simulation( def delete_user_simulation( self, *, - principal: UserPrincipal, + principal: AccountPrincipal, country_id: str, association_id: UUID, ) -> None: diff --git a/policyengine_api/services/v2/user_simulations/transformations.py b/policyengine_api/services/v2/user_simulations/transformations.py index a88d20996..adc207023 100644 --- a/policyengine_api/services/v2/user_simulations/transformations.py +++ b/policyengine_api/services/v2/user_simulations/transformations.py @@ -8,7 +8,6 @@ from policyengine_api.services.v2.user_simulations.types import ( UserSimulationCreateFields, UserSimulationCreationInput, - UserSimulationImportItem, UserSimulationPage, UserSimulationRead, ) @@ -25,7 +24,6 @@ def user_simulation_read( legacy_simulation_id=association.legacy_simulation_id, name=association.name, description=association.description, - client_ref=association.client_ref, created_at=association.created_at, updated_at=association.updated_at, ) @@ -51,18 +49,3 @@ def owned_user_simulation_input( user_id: UUID, ) -> UserSimulationCreationInput: return UserSimulationCreationInput(**fields.model_dump(), user_id=user_id) - - -def imported_user_simulation_input( - item: UserSimulationImportItem, - *, - country_id: str, - user_id: UUID, -) -> UserSimulationCreationInput: - return UserSimulationCreationInput.model_validate( - { - **item.model_dump(), - "country_id": country_id, - "user_id": user_id, - } - ) diff --git a/policyengine_api/services/v2/user_simulations/types.py b/policyengine_api/services/v2/user_simulations/types.py index 3a44d7c7e..ef406d18c 100644 --- a/policyengine_api/services/v2/user_simulations/types.py +++ b/policyengine_api/services/v2/user_simulations/types.py @@ -10,7 +10,6 @@ from policyengine_api.query_parameters import CountryId, UserId from policyengine_api.services.v2.associations.types import ( - AssociationClientRef, AssociationDescription, AssociationName, StrictAssociationModel, @@ -29,7 +28,6 @@ class UserSimulationCreateFields(StrictUserSimulationInput): legacy_simulation_id: LegacyRecordId name: AssociationName | None = None description: AssociationDescription | None = None - client_ref: AssociationClientRef | None = None class UserSimulationCreationInput(StrictUserSimulationInput): @@ -38,7 +36,6 @@ class UserSimulationCreationInput(StrictUserSimulationInput): legacy_simulation_id: LegacyRecordId name: AssociationName | None = None description: str | None = None - client_ref: AssociationClientRef | None = None class UserSimulationUpdateInput(StrictUserSimulationInput): @@ -61,15 +58,6 @@ def require_valid_supplied_field(self) -> "UserSimulationUpdateInput": return self -class UserSimulationImportItem(StrictUserSimulationInput): - """One browser-local saved simulation offered for idempotent import.""" - - client_ref: AssociationClientRef - legacy_simulation_id: LegacyRecordId - name: AssociationName | None = None - description: AssociationDescription | None = None - - @dataclass(frozen=True) class UserSimulationRead: id: UUID @@ -79,7 +67,6 @@ class UserSimulationRead: legacy_simulation_id: int | None name: str | None description: str | None - client_ref: str | None created_at: datetime updated_at: datetime diff --git a/tests/integration/test_alembic_v2_lifecycle.py b/tests/integration/test_alembic_v2_lifecycle.py index 07fb20c46..987adddf0 100644 --- a/tests/integration/test_alembic_v2_lifecycle.py +++ b/tests/integration/test_alembic_v2_lifecycle.py @@ -120,7 +120,6 @@ def _assert_pre_stage_12a_association_shape(engine) -> None: "country_id", "name", f"legacy_{reference}_id", - "client_ref", }.isdisjoint(columns) assert not columns[f"{reference}_id"]["nullable"] assert { @@ -132,8 +131,11 @@ def _assert_pre_stage_12a_association_shape(engine) -> None: ("user_household_associations", "household"), ): columns = _columns(engine, table_name) - assert {f"legacy_{reference}_id", "client_ref"}.isdisjoint(columns) + 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: @@ -155,14 +157,12 @@ def _assert_stage_12a_association_shape(engine) -> None: assert columns[f"{reference}_id"]["nullable"] assert columns[f"legacy_{reference}_id"]["nullable"] assert not columns["country_id"]["nullable"] - unique_constraints = { - constraint["name"]: constraint["column_names"] + 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) - } - assert unique_constraints[f"uq_{table_name}_user_client_ref"] == [ - "user_id", - "client_ref", - ] + ] == [["id", "country_id"]] for table_name in ("user_simulation_associations", "user_report_associations"): referred_tables = { foreign_key["referred_table"] @@ -179,6 +179,19 @@ def _assert_stage_12a_association_shape(engine) -> None: 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: diff --git a/tests/integration/test_v2_user_identity_persistence.py b/tests/integration/test_v2_user_identity_persistence.py index 082e594d0..7ba1271dc 100644 --- a/tests/integration/test_v2_user_identity_persistence.py +++ b/tests/integration/test_v2_user_identity_persistence.py @@ -1,19 +1,24 @@ -"""PostgreSQL tests for signed-in identity provisioning and user associations. +"""PostgreSQL tests for identity provisioning, unauthenticated users, and upgrades. These prove the behaviors SQLite cannot: named ``ON CONFLICT`` targets, -concurrent first sign-in, concurrent idempotent imports, ``MATCH SIMPLE`` -composite keys with NULL references, and cascading account deletion. +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 -from uuid import uuid4 +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 @@ -26,9 +31,11 @@ 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, @@ -37,17 +44,63 @@ ) 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 UserPrincipal +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 AccountImportInput +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: @@ -68,13 +121,16 @@ 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} + 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 = list(connection.execute(owned).scalars()) + user_ids = [ + *connection.execute(owned).scalars(), + *factory.kw["info"]["browser_user_ids"], + ] if user_ids: connection.execute( delete(LegacyUserMapping).where( @@ -89,11 +145,91 @@ 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 + ), + ) + 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 @@ -129,108 +265,310 @@ def first_request(_index: int): ) -def test_concurrent_imports_are_idempotent_on_client_ref(sessions) -> None: - principal = _principal(sessions, "google-oauth2|importer") - service = V2AccountService( - AccountDatabaseSession(sessions), - legacy_references=default_legacy_references(), - ) - service.get_account(principal=principal, country_id="us") - request = AccountImportInput.model_validate( - { - "policies": [ - {"client_ref": f"sup-{index}", "legacy_policy_id": 1} - for index in range(5) - ], - "simulations": [{"client_ref": "sus-1", "legacy_simulation_id": 613}], - "reports": [ - { - "client_ref": "sur-1", - "legacy_report_id": 615, - "user_policy_client_ref": "sup-0", - } - ], - } - ) - workers = 4 +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 import_records(_index: int): + def first_write(_index: int) -> UUID: barrier.wait() - return service.import_records( - principal=principal, country_id="us", import_input=request - ).id_map + 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: - id_maps = list(executor.map(import_records, range(workers))) + owners = set(executor.map(first_write, range(workers))) - assert all(id_map == id_maps[0] for id_map in id_maps) - assert len(id_maps[0]) == 7 + assert owners == {browser.user_id} with sessions() as session: - owner = session.execute( - select(UserIdentity.user_id).where(UserIdentity.issuer == principal.issuer) + 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 ( - session.execute( - select(func.count()) - .select_from(UserPolicy) - .where(UserPolicy.user_id == owner) - ).scalar_one() - == 5 + 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", ) - report = session.get(UserReportAssociation, id_maps[0]["sur-1"]) - assert report is not None - assert report.user_policy_id == id_maps[0]["sup-0"] + with ThreadPoolExecutor(max_workers=workers) as executor: + upgrades = list(executor.map(upgrade, range(workers))) -def test_imports_never_reuse_a_client_ref_from_another_country(sessions) -> None: - principal = _principal(sessions, "google-oauth2|two-countries") - service = V2AccountService( - AccountDatabaseSession(sessions), - legacy_references=default_legacy_references(), + 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])), ) - uk = service.import_records( - principal=principal, - country_id="uk", - import_input=AccountImportInput.model_validate( - {"policies": [{"client_ref": "rf-shared", "legacy_policy_id": 7}]} - ), + 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] + 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 + + +def _hold_a_conversion(sessions, *, browser, principal) -> Session: + """Convert or merge ``browser`` in an open transaction holding its lock.""" - us = service.import_records( + session = sessions() + session.begin() + convert_unauthenticated_user_in_session( + session, + unauthenticated_user_id=browser.user_id, principal=principal, country_id="us", - import_input=AccountImportInput.model_validate( - { - "policies": [{"client_ref": "rf-shared", "legacy_policy_id": 1}], - "reports": [ - { - "client_ref": "sur-us", - "legacy_report_id": 615, - "user_policy_client_ref": "rf-shared", - } - ], - } - ), ) + 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) - assert set(us.id_map) == {"sur-us"} - assert [(skip.client_ref, skip.reason.value) for skip in us.skipped] == [ - ("rf-shared", "client_ref_conflict") - ] with sessions() as session: - report = session.get(UserReportAssociation, us.id_map["sur-us"]) - uk_policy = session.get(UserPolicy, uk.id_map["rf-shared"]) - assert report is not None and report.user_policy_id is None - assert uk_policy is not None and uk_policy.country_id == "uk" + 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") - service = V2AccountService( - AccountDatabaseSession(sessions), - legacy_references=default_legacy_references(), - ) + services = _services(sessions) provision = { "path": "gov.irs.credits.ctc.amount", "breadcrumb": "Child Tax Credit amount", @@ -238,35 +576,24 @@ def test_non_finite_numbers_never_reach_jsonb(sessions) -> None: "value": 3000, } - result = service.import_records( + stored = services.reports.create_user_report( principal=principal, - country_id="us", - import_input=AccountImportInput.model_validate( - { - "policies": [{"client_ref": "rf-finite", "legacy_policy_id": 1}], - "reports": [ - { - "client_ref": "sur-finite", - "legacy_report_id": 615, - "provisions": [provision], - }, - { - "client_ref": "sur-infinite", - "legacy_report_id": 616, - "provisions": [{**provision, "value": float("inf")}], - }, - ], - } + 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")}], + } + ) - assert set(result.id_map) == {"rf-finite", "sur-finite"} - assert [(skip.client_ref, skip.reason.value) for skip in result.skipped] == [ - ("sur-infinite", "invalid") - ] with sessions() as session: - stored = session.get(UserReportAssociation, result.id_map["sur-finite"]) - assert stored is not None and stored.provisions == [provision] + row = session.get(UserReportAssociation, stored.id) + assert row is not None and row.provisions == [provision] def test_reference_checks_and_match_simple_composite_keys(sessions) -> None: @@ -281,6 +608,8 @@ def test_reference_checks_and_match_simple_composite_keys(sessions) -> None: [ 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), ] ) @@ -294,23 +623,6 @@ def test_reference_checks_and_match_simple_composite_keys(sessions) -> None: with sessions.begin() as session: session.add(invalid) - with sessions.begin() as session: - session.add( - UserPolicy( - user_id=owner, country_id="us", legacy_policy_id=1, client_ref="rf-1" - ) - ) - with pytest.raises(IntegrityError): - with sessions.begin() as session: - session.add( - UserPolicy( - user_id=owner, - country_id="us", - legacy_policy_id=2, - client_ref="rf-1", - ) - ) - def test_account_deletion_cascades_after_removing_legacy_mappings(sessions) -> None: alice = _principal(sessions, "google-oauth2|alice") @@ -324,41 +636,26 @@ def prepare_deletion(self) -> None: def delete_user(self, subject: str) -> None: deleted_subjects.append(subject) - service = V2AccountService( + services = _services(sessions) + accounts = V2AccountService( AccountDatabaseSession(sessions), - legacy_references=default_legacy_references(), auth0_user_deleter_factory=RecordingDeleter, ) - alice_id = service.get_account(principal=alice, country_id="us").id - bob_id = service.get_account(principal=bob, country_id="us").id - service.import_records( - principal=alice, - country_id="us", - import_input=AccountImportInput.model_validate( - { - "policies": [{"client_ref": "rf-1", "legacy_policy_id": 1}], - "reports": [ - { - "client_ref": "lfr-1", - "legacy_report_id": 615, - "user_policy_client_ref": "rf-1", - } - ], - } - ), - ) + 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) ) - service.delete_account(principal=alice) + 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, UserPolicy, UserReportAssociation): + for model in (LegacyUserMapping, *ASSOCIATION_MODELS): assert ( session.execute( select(func.count()) diff --git a/tests/unit/libs/test_user_auth.py b/tests/unit/libs/test_user_auth.py index 646e594d3..6b573775e 100644 --- a/tests/unit/libs/test_user_auth.py +++ b/tests/unit/libs/test_user_auth.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone from types import SimpleNamespace +from uuid import UUID from cryptography.hazmat.primitives.asymmetric import rsa import jwt @@ -11,6 +12,7 @@ from policyengine_api.libs.user_auth import ( InvalidBearerTokenError, + InvalidUnauthenticatedUserError, JWTDecoder, MissingBearerTokenError, PrincipalKind, @@ -20,6 +22,7 @@ _normalized_issuer, authenticate_bearer, load_user_auth_settings, + unauthenticated_user_id, ) @@ -198,3 +201,28 @@ 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 802d6eeaa..1cc1cbc3b 100644 --- a/tests/unit/routes/test_migration_context_logging.py +++ b/tests/unit/routes/test_migration_context_logging.py @@ -494,7 +494,7 @@ def test_v2_household_resources_log_actual_supabase_read_and_write_sources( ("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/imports", "user_profile", None, "supabase"), + ("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"), diff --git a/tests/unit/test_migration_flags.py b/tests/unit/test_migration_flags.py index 6bc667925..bcd90902b 100644 --- a/tests/unit/test_migration_flags.py +++ b/tests/unit/test_migration_flags.py @@ -183,7 +183,7 @@ def test_explicit_migration_context_rejects_invalid_database_sources( ("/v2/user-simulations", "simulation"), ("/v2/user-reports/00000000-0000-0000-0000-000000000001", "report"), ("/v2/me", "user_profile"), - ("/v2/me/imports", "user_profile"), + ("/v2/me/upgrade", "user_profile"), ("/us/policy/1", "policy"), ("/us/policies", "policy"), ("/us/household/1", "household"), diff --git a/tests/unit/v2/test_alembic_v2.py b/tests/unit/v2/test_alembic_v2.py index 366b00b1c..67179d56a 100644 --- a/tests/unit/v2/test_alembic_v2.py +++ b/tests/unit/v2/test_alembic_v2.py @@ -512,8 +512,9 @@ def test_stage_12a_user_identity_and_association_revision_is_generated() -> None ("user_simulation_associations", "simulation"), ("user_report_associations", "report"), ): - assert f'"uq_{table_name}_user_client_ref"' in revision 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", @@ -522,6 +523,8 @@ def test_stage_12a_user_identity_and_association_revision_is_generated() -> None "ck_user_simulation_associations_country", "ck_user_report_associations_report_ref", "ck_user_report_associations_country", + "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. @@ -545,6 +548,17 @@ def test_stage_12a_user_identity_and_association_revision_is_generated() -> None " 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: diff --git a/tests/unit/v2/test_data_crud_boundaries.py b/tests/unit/v2/test_data_crud_boundaries.py index d3e2e1736..5c0da0e1d 100644 --- a/tests/unit/v2/test_data_crud_boundaries.py +++ b/tests/unit/v2/test_data_crud_boundaries.py @@ -58,6 +58,7 @@ def _imported_modules(path: Path) -> set[str]: "user_reports/database_connectors/deletes.py", "identity/database_connectors/creates.py", "identity/database_connectors/updates.py", + "identity/database_connectors/deletes.py", "user_accounts/database_connectors/updates.py", "user_accounts/database_connectors/deletes.py", ), diff --git a/tests/unit/v2/test_me_routes.py b/tests/unit/v2/test_me_routes.py index 368657110..a71064253 100644 --- a/tests/unit/v2/test_me_routes.py +++ b/tests/unit/v2/test_me_routes.py @@ -1,41 +1,49 @@ -"""Native FastAPI contract tests for the signed-in account resource.""" +"""Native FastAPI contract tests for the account resource.""" from __future__ import annotations -import json 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, - AccountImportResult, AccountRead, - ImportSkip, - ImportSkipReason, + 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, ) -IMPORTED_ID = UUID("00000000-0000-0000-0000-000000000065") +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, @@ -55,16 +63,9 @@ def _result(name: str, kwargs: dict): simulations=(), reports=(), ) - if name == "import_records": - return AccountImportResult( - id_map={"sup-1": IMPORTED_ID}, - skipped=( - ImportSkip( - client_ref="sus-9", - reason=ImportSkipReason.UNKNOWN_LEGACY_REFERENCE, - ), - ImportSkip(client_ref=None, reason=ImportSkipReason.INVALID), - ), + if name == "upgrade_account": + return AccountUpgrade( + account=_account(), outcome=AccountConversionOutcome.CONVERTED ) if name == "delete_account": return AccountDeletion(user_id=OWNER_ID, auth0_subjects=()) @@ -73,9 +74,11 @@ def _result(name: str, kwargs: dict): return _account() -def _client(service: RecordingService | None = None): +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, _flask_calls = user_route_client( + v2_account_service_factory=lambda: service, **client_options + ) return client, service @@ -91,6 +94,7 @@ def test_get_me_provisions_with_the_query_country() -> None: "result": { "item": { "id": str(OWNER_ID), + "user_type": "standard", "primary_country": "us", "first_name": None, "last_name": None, @@ -172,73 +176,10 @@ def test_unavailable_auth0_deletion_reports_that_nothing_was_deleted() -> None: assert "nothing was deleted" in response.json()["message"] -def test_import_returns_id_map_and_skipped_items() -> None: - client, service = _client() - - response = client.post( - "/v2/me/imports?country_id=us", - json={ - "policies": [{"client_ref": "sup-1", "legacy_policy_id": 1}], - "simulations": [{"client_ref": "sus-9", "legacy_simulation_id": 99}], - "reports": [{"legacy_report_id": "invalid"}], - }, - ) - - assert response.status_code == 200 - assert response.json() == { - "status": "ok", - "message": None, - "result": { - "id_map": {"sup-1": str(IMPORTED_ID)}, - "skipped": [ - {"client_ref": "sus-9", "reason": "unknown_legacy_reference"}, - {"client_ref": None, "reason": "invalid"}, - ], - }, - } - import_input = service.calls[0][1]["import_input"] - assert import_input.households == [] - assert len(import_input.reports) == 1 - - -@pytest.mark.parametrize( - "body", - [ - {"policies": [{"client_ref": f"sup-{index}"} for index in range(501)]}, - {"unknown": []}, - {"policies": ["not-an-object"]}, - ], -) -def test_import_rejects_oversized_counts_and_unknown_sections(body: dict) -> None: - client, service = _client() - - response = client.post("/v2/me/imports?country_id=us", json=body) - - assert response.status_code == 422 - assert service.calls == [] - - -def test_import_rejects_bodies_over_one_mebibyte() -> None: - client, service = _client() - body = json.dumps( - {"policies": [{"client_ref": "sup-1", "description": "x" * 1_048_576}]} - ) - - response = client.post( - "/v2/me/imports?country_id=us", - content=body, - headers={"content-type": "application/json"}, - ) - - assert response.status_code == 413 - assert response.json()["status"] == "error" - assert service.calls == [] - - def test_export_serializes_stored_rows_outside_current_input_bounds() -> None: long_text = "x" * 20_000 odd_policy = UserPolicyRead( - id=IMPORTED_ID, + id=RECORD_ID, country_id="us", user_id=OWNER_ID, policy_id=None, @@ -247,12 +188,11 @@ def test_export_serializes_stored_rows_outside_current_input_bounds() -> None: description=long_text, baseline="current-law", provenance={"source": "a-source-added-later", "ref": long_text, "extra": 1}, - client_ref="rf-1", created_at=CREATED_AT, updated_at=CREATED_AT, ) odd_report = UserReportRead( - id=IMPORTED_ID, + id=RECORD_ID, country_id="us", user_id=OWNER_ID, report_id=None, @@ -272,7 +212,6 @@ def test_export_serializes_stored_rows_outside_current_input_bounds() -> None: ], validation={"matched_at": long_text, "map_model_version": "1"}, last_run_at=None, - client_ref="lfr-1", created_at=CREATED_AT, updated_at=CREATED_AT, ) @@ -301,3 +240,202 @@ def result(name: str, kwargs: dict): } 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_routes.py b/tests/unit/v2/test_metadata_routes.py index 1727e5009..fe29ef66e 100644 --- a/tests/unit/v2/test_metadata_routes.py +++ b/tests/unit/v2/test_metadata_routes.py @@ -552,7 +552,7 @@ def test_openapi_references_explicit_resource_response_schemas() -> None: "/v2/user-reports/{association_id}", "/v2/me", "/v2/me/export", - "/v2/me/imports", + "/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 0ff3f3548..70ab22faf 100644 --- a/tests/unit/v2/test_models.py +++ b/tests/unit/v2/test_models.py @@ -25,6 +25,7 @@ UserPolicy, UserReportAssociation, UserSimulationAssociation, + UserType, V2_METADATA, ) @@ -340,17 +341,39 @@ 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: @@ -547,7 +570,7 @@ def test_user_identity_maps_one_issuer_subject_to_one_user() -> None: assert sa.inspect(User).relationships["identities"].back_populates == "user" -def test_associations_are_v1_anchored_and_import_idempotent() -> None: +def test_associations_are_v1_anchored_without_client_refs() -> None: for table_name, reference in ( ("user_policies", "policy"), ("user_household_associations", "household"), @@ -558,7 +581,7 @@ def test_associations_are_v1_anchored_and_import_idempotent() -> None: 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 table.c.client_ref.type.length == 64 + 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} @@ -566,16 +589,13 @@ def test_associations_are_v1_anchored_and_import_idempotent() -> None: assert str(reference_check.sqltext) == ( f"{reference}_id IS NOT NULL OR legacy_{reference}_id IS NOT NULL" ) - client_ref_unique = constraints[f"uq_{table_name}_user_client_ref"] - assert [column.name for column in client_ref_unique.columns] == [ - "user_id", - "client_ref", - ] - assert ("id", "country_id") in { + # 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: 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 index 5f557027d..4d83790b5 100644 --- a/tests/unit/v2/test_user_account_service.py +++ b/tests/unit/v2/test_user_account_service.py @@ -1,4 +1,4 @@ -"""Service tests for identity provisioning, saves, imports, and deletion.""" +"""Service tests for signed-in identity provisioning, saves, and deletion.""" from __future__ import annotations @@ -10,10 +10,8 @@ from policyengine_api.data.v2.models import ( LegacyUserMapping, User, - UserHouseholdAssociation, UserIdentity, UserPolicy, - UserReportAssociation, UserSimulationAssociation, ) from policyengine_api.services.v2.associations.validators import ( @@ -31,10 +29,7 @@ AccountDatabaseSession, ) from policyengine_api.services.v2.user_accounts.services import V2AccountService -from policyengine_api.services.v2.user_accounts.types import ( - AccountImportInput, - AccountUpdateInput, -) +from policyengine_api.services.v2.user_accounts.types import AccountUpdateInput from policyengine_api.services.v2.user_accounts.validators import ( AccountDeletionUnavailableError, AccountIdentityDeletionError, @@ -69,7 +64,6 @@ ALICE, BOB, ISSUER, - MAPPED_LEGACY_POLICY_ID, default_legacy_references, v2_store, ) @@ -120,7 +114,6 @@ def deleter_factory(): return ( V2AccountService( AccountDatabaseSession(selected.sessions), - legacy_references=references, auth0_user_deleter_factory=deleter_factory, ), V2UserPolicyService(UserPolicyDatabaseSession(selected.sessions), references), @@ -222,7 +215,7 @@ def test_simulation_saves_are_owner_scoped_and_v1_checked(store) -> None: created = simulations.create_user_simulation( principal=ALICE, association_input=UserSimulationCreateFields( - country_id="us", legacy_simulation_id=613, client_ref="sus-1" + country_id="us", legacy_simulation_id=613 ), ) @@ -314,289 +307,6 @@ def test_report_policy_links_require_the_same_owner_and_survive_deletion( assert refreshed.user_policy_id is None -def _import_input(**sections) -> AccountImportInput: - return AccountImportInput.model_validate(sections) - - -def test_import_is_idempotent_and_skips_unknown_or_invalid_items(store) -> None: - accounts, *_ = _services(store) - request = _import_input( - policies=[ - {"client_ref": "rf-1", "legacy_policy_id": MAPPED_LEGACY_POLICY_ID}, - {"client_ref": "sup-404", "legacy_policy_id": 404}, - {"client_ref": "sup-bad", "legacy_policy_id": "1"}, - ], - households=[{"client_ref": "suh-1", "legacy_household_id": 10}], - simulations=[{"client_ref": "sus-1", "legacy_simulation_id": 613}], - reports=[ - { - "client_ref": "lfr-1", - "legacy_report_id": 615, - "user_policy_client_ref": "rf-1", - "source_note": "imported", - }, - {"legacy_report_id": 615}, - ], - ) - - first = accounts.import_records( - principal=ALICE, country_id="us", import_input=request - ) - second = accounts.import_records( - principal=ALICE, country_id="us", import_input=request - ) - - assert set(first.id_map) == {"rf-1", "suh-1", "sus-1", "lfr-1"} - assert first.id_map == second.id_map - assert [(skip.client_ref, skip.reason.value) for skip in first.skipped] == [ - ("sup-bad", "invalid"), - ("sup-404", "unknown_legacy_reference"), - (None, "invalid"), - ] - with store.sessions() as session: - policy = session.get(UserPolicy, first.id_map["rf-1"]) - report = session.get(UserReportAssociation, first.id_map["lfr-1"]) - assert policy is not None and policy.policy_id == store.policy_id - assert report is not None and report.user_policy_id == policy.id - assert len(session.exec(select(UserPolicy)).all()) == 1 - assert len(session.exec(select(UserHouseholdAssociation)).all()) == 1 - assert len(session.exec(select(UserSimulationAssociation)).all()) == 1 - - -def test_import_keeps_existing_rows_and_links_earlier_imported_policies( - store, -) -> None: - accounts, *_ = _services(store) - accounts.import_records( - principal=ALICE, - country_id="us", - import_input=_import_input( - policies=[{"client_ref": "rf-1", "legacy_policy_id": 1, "name": "First"}] - ), - ) - - result = accounts.import_records( - principal=ALICE, - country_id="us", - import_input=_import_input( - policies=[{"client_ref": "rf-1", "legacy_policy_id": 2, "name": "Second"}], - reports=[ - { - "client_ref": "lfr-2", - "legacy_report_id": 616, - "user_policy_client_ref": "rf-1", - }, - { - "client_ref": "lfr-3", - "legacy_report_id": 616, - "user_policy_client_ref": "rf-unknown", - }, - ], - ), - ) - - with store.sessions() as session: - policy = session.get(UserPolicy, result.id_map["rf-1"]) - linked = session.get(UserReportAssociation, result.id_map["lfr-2"]) - unlinked = session.get(UserReportAssociation, result.id_map["lfr-3"]) - assert (policy.name, policy.legacy_policy_id) == ("First", 1) - assert linked.user_policy_id == policy.id - assert unlinked.user_policy_id is None - - -def test_import_never_reuses_or_links_records_from_another_country(store) -> None: - accounts, *_ = _services(store) - uk = accounts.import_records( - principal=ALICE, - country_id="uk", - import_input=_import_input( - policies=[{"client_ref": "rf-shared", "legacy_policy_id": 7}] - ), - ) - - us = accounts.import_records( - principal=ALICE, - country_id="us", - import_input=_import_input( - policies=[{"client_ref": "rf-shared", "legacy_policy_id": 1}], - reports=[ - { - "client_ref": "lfr-us", - "legacy_report_id": 615, - "user_policy_client_ref": "rf-shared", - } - ], - ), - ) - - assert set(us.id_map) == {"lfr-us"} - assert [(skip.client_ref, skip.reason.value) for skip in us.skipped] == [ - ("rf-shared", "client_ref_conflict") - ] - with store.sessions() as session: - report = session.get(UserReportAssociation, us.id_map["lfr-us"]) - uk_policy = session.get(UserPolicy, uk.id_map["rf-shared"]) - policies = session.exec(select(UserPolicy)).all() - assert report is not None and report.user_policy_id is None - assert uk_policy is not None and uk_policy.country_id == "uk" - assert [policy.country_id for policy in policies] == ["uk"] - - -def test_import_skips_items_with_non_finite_numbers_as_invalid(store) -> None: - accounts, *_ = _services(store) - provision = { - "path": "gov.irs.credits.ctc.amount", - "breadcrumb": "Child Tax Credit amount", - "baseline_value": float("nan"), - "value": 3000, - } - - result = accounts.import_records( - principal=ALICE, - country_id="us", - import_input=_import_input( - policies=[{"client_ref": "rf-finite", "legacy_policy_id": 1}], - reports=[ - { - "client_ref": "lfr-nan", - "legacy_report_id": 615, - "provisions": [provision], - } - ], - ), - ) - - assert set(result.id_map) == {"rf-finite"} - assert [(skip.client_ref, skip.reason.value) for skip in result.skipped] == [ - ("lfr-nan", "invalid") - ] - - -def test_import_stores_neither_a_valid_nor_an_invalid_repeated_client_ref( - store, -) -> None: - accounts, *_ = _services(store) - - result = accounts.import_records( - principal=ALICE, - country_id="us", - import_input=_import_input( - policies=[ - {"client_ref": "rf-repeated", "legacy_policy_id": 1}, - {"client_ref": "rf-repeated"}, - ] - ), - ) - - assert result.id_map == {} - assert [(skip.client_ref, skip.reason.value) for skip in result.skipped] == [ - ("rf-repeated", "invalid"), - ("rf-repeated", "invalid"), - ] - with store.sessions() as session: - assert session.exec(select(UserPolicy)).all() == [] - - -def test_import_stores_neither_of_two_valid_items_sharing_a_client_ref( - store, -) -> None: - accounts, *_ = _services(store) - - result = accounts.import_records( - principal=ALICE, - country_id="us", - import_input=_import_input( - policies=[ - {"client_ref": "rf-repeated", "legacy_policy_id": 1, "name": "A"}, - {"client_ref": "rf-repeated", "legacy_policy_id": 2, "name": "B"}, - ] - ), - ) - - assert result.id_map == {} - assert [(skip.client_ref, skip.reason.value) for skip in result.skipped] == [ - ("rf-repeated", "invalid"), - ("rf-repeated", "invalid"), - ] - with store.sessions() as session: - assert session.exec(select(UserPolicy)).all() == [] - - -def test_import_stores_items_beside_a_repeated_client_ref(store) -> None: - accounts, *_ = _services(store) - - result = accounts.import_records( - principal=ALICE, - country_id="us", - import_input=_import_input( - policies=[ - {"client_ref": "rf-repeated", "legacy_policy_id": 1}, - {"client_ref": "rf-kept", "legacy_policy_id": 2}, - {"client_ref": "rf-repeated", "legacy_policy_id": "1"}, - ], - households=[{"client_ref": "suh-kept", "legacy_household_id": 10}], - reports=[ - { - "client_ref": "lfr-linked", - "legacy_report_id": 615, - "user_policy_client_ref": "rf-kept", - }, - { - "client_ref": "lfr-unlinked", - "legacy_report_id": 616, - "user_policy_client_ref": "rf-repeated", - }, - ], - ), - ) - - assert set(result.id_map) == {"rf-kept", "suh-kept", "lfr-linked", "lfr-unlinked"} - assert [(skip.client_ref, skip.reason.value) for skip in result.skipped] == [ - ("rf-repeated", "invalid"), - ("rf-repeated", "invalid"), - ] - assert not set(result.id_map) & {skip.client_ref for skip in result.skipped} - with store.sessions() as session: - policies = session.exec(select(UserPolicy)).all() - linked = session.get(UserReportAssociation, result.id_map["lfr-linked"]) - unlinked = session.get(UserReportAssociation, result.id_map["lfr-unlinked"]) - households = session.exec(select(UserHouseholdAssociation)).all() - assert [policy.client_ref for policy in policies] == ["rf-kept"] - assert linked.user_policy_id == policies[0].id - assert unlinked.user_policy_id is None - assert [household.client_ref for household in households] == ["suh-kept"] - - -def test_import_treats_a_client_ref_shared_across_kinds_as_repeated(store) -> None: - accounts, *_ = _services(store) - - result = accounts.import_records( - principal=ALICE, - country_id="us", - import_input=_import_input( - policies=[{"client_ref": "shared-ref", "legacy_policy_id": 1}], - reports=[{"client_ref": "shared-ref", "legacy_report_id": 615}], - ), - ) - - assert result.id_map == {} - assert [(skip.client_ref, skip.reason.value) for skip in result.skipped] == [ - ("shared-ref", "invalid"), - ("shared-ref", "invalid"), - ] - with store.sessions() as session: - assert session.exec(select(UserPolicy)).all() == [] - assert session.exec(select(UserReportAssociation)).all() == [] - - -def test_import_input_bounds_the_item_count() -> None: - with pytest.raises(ValueError, match="at most 500"): - _import_input( - policies=[{"client_ref": str(index)} for index in range(250)], - reports=[{"client_ref": str(index)} for index in range(251)], - ) - - def test_export_covers_every_country(store) -> None: accounts, policies, *_ = _services(store) policies.create_user_policy( @@ -685,9 +395,7 @@ def unavailable(): raise AssertionError("the v1 reference service must not be built") references = DeferredLegacyReferenceService(unavailable) - accounts = V2AccountService( - AccountDatabaseSession(store.sessions), legacy_references=references - ) + accounts = V2AccountService(AccountDatabaseSession(store.sessions)) simulations = V2UserSimulationService( UserSimulationDatabaseSession(store.sessions), references ) @@ -763,6 +471,3 @@ def unexpected_build(): account_input=AccountUpdateInput(first_name="Ada"), ) accounts.export_account(principal=ALICE, country_id="us") - accounts.import_records( - principal=ALICE, country_id="us", import_input=_import_input() - ) diff --git a/tests/unit/v2/test_user_household_routes.py b/tests/unit/v2/test_user_household_routes.py index d1dffb33f..3f82601c0 100644 --- a/tests/unit/v2/test_user_household_routes.py +++ b/tests/unit/v2/test_user_household_routes.py @@ -43,7 +43,6 @@ def _association_read(**changes) -> UserHouseholdRead: "legacy_household_id": 123, "name": "Family", "description": None, - "client_ref": "suh-1", "created_at": CREATED_AT, "updated_at": CREATED_AT + timedelta(seconds=1), } @@ -81,7 +80,6 @@ def test_create_returns_complete_contract_owned_by_the_session_user() -> None: "country_id": "us", "legacy_household_id": 123, "name": "Family", - "client_ref": "suh-1", }, ) @@ -94,7 +92,6 @@ def test_create_returns_complete_contract_owned_by_the_session_user() -> None: "legacy_household_id": 123, "name": "Family", "description": None, - "client_ref": "suh-1", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:01Z", } @@ -255,7 +252,6 @@ def test_openapi_publishes_signed_in_household_contract() -> None: "legacy_household_id", "name", "description", - "client_ref", "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 3a4d6d972..3dec5a04a 100644 --- a/tests/unit/v2/test_user_household_service.py +++ b/tests/unit/v2/test_user_household_service.py @@ -1,4 +1,4 @@ -"""Service tests for signed-in v2 user-household associations.""" +"""Service tests for v2 user-household associations.""" from __future__ import annotations @@ -6,9 +6,6 @@ from sqlmodel import select from policyengine_api.data.v2.models import Household, UserHouseholdAssociation -from policyengine_api.services.v2.associations.validators import ( - AssociationClientRefConflictError, -) from policyengine_api.services.v2.legacy_refs.validators import ( LegacyReferenceNotFoundError, ) @@ -74,11 +71,8 @@ def test_legacy_households_resolve_copied_v2_households(store) -> None: assert unmapped.user_id == mapped.user_id == v2_only.user_id -def test_missing_references_and_duplicate_client_refs_are_rejected(store) -> None: +def test_missing_references_are_rejected(store) -> None: service, selected = store - service.create_user_household( - principal=ALICE, association_input=_fields(client_ref="suh-1") - ) with pytest.raises(LegacyReferenceNotFoundError): service.create_user_household( @@ -91,10 +85,6 @@ def test_missing_references_and_duplicate_client_refs_are_rejected(store) -> Non country_id="us", household_id=selected.policy_id ), ) - with pytest.raises(AssociationClientRefConflictError): - service.create_user_household( - principal=ALICE, association_input=_fields(client_ref="suh-1") - ) def test_ownership_repointing_and_deletion(store) -> None: diff --git a/tests/unit/v2/test_user_policy_routes.py b/tests/unit/v2/test_user_policy_routes.py index 2641e9572..a4c6094fa 100644 --- a/tests/unit/v2/test_user_policy_routes.py +++ b/tests/unit/v2/test_user_policy_routes.py @@ -10,7 +10,6 @@ from policyengine_api.data.v2.settings import V2ConfigurationError from policyengine_api.services.v2.associations.validators import ( - AssociationClientRefConflictError, AssociationReferenceConflictError, MirrorOwnedAssociationError, ) @@ -51,7 +50,6 @@ def _association_read(**changes) -> UserPolicyRead: "description": "Personal note", "baseline": "current-law", "provenance": PROVENANCE, - "client_ref": "rf-1", "created_at": CREATED_AT, "updated_at": CREATED_AT + timedelta(seconds=1), } @@ -88,7 +86,6 @@ def _body(**changes) -> dict[str, object]: "description": "Personal note", "baseline": "current-law", "provenance": PROVENANCE, - "client_ref": "rf-1", } body.update(changes) return body @@ -114,7 +111,6 @@ def test_create_returns_complete_contract_owned_by_the_session_user() -> None: "description": "Personal note", "baseline": "current-law", "provenance": PROVENANCE, - "client_ref": "rf-1", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:01Z", } @@ -155,7 +151,7 @@ def test_create_accepts_a_v2_policy_reference() -> None: _body(baseline="baseline"), _body(provenance={"source": "email"}), _body(provenance={"source": "chat", "extra": 1}), - _body(client_ref="x" * 65), + _body(client_ref="rf-1"), _body(name="x" * 256), ], ) @@ -297,7 +293,6 @@ def test_delete_returns_no_content_for_the_session_user() -> None: (AssociationPolicyNotFoundError("policy was not found"), 404, "not found"), (UserPolicyNotFoundError("association was not found"), 404, "not found"), (MirrorOwnedAssociationError("maintained from v1"), 409, "maintained"), - (AssociationClientRefConflictError("client_ref exists"), 409, "client_ref"), ( IntegrityError("statement secret", {}, Exception("credential")), 409, @@ -373,7 +368,6 @@ def test_openapi_publishes_signed_in_association_contracts() -> None: "description", "baseline", "provenance", - "client_ref", "created_at", "updated_at", } diff --git a/tests/unit/v2/test_user_policy_service.py b/tests/unit/v2/test_user_policy_service.py index def9d9460..be1a98464 100644 --- a/tests/unit/v2/test_user_policy_service.py +++ b/tests/unit/v2/test_user_policy_service.py @@ -1,4 +1,4 @@ -"""Service tests for signed-in v2 user-policy associations.""" +"""Service tests for v2 user-policy associations.""" from __future__ import annotations @@ -15,7 +15,6 @@ UserPolicy, ) from policyengine_api.services.v2.associations.validators import ( - AssociationClientRefConflictError, AssociationReferenceConflictError, MirrorOwnedAssociationError, ) @@ -78,7 +77,6 @@ def test_first_save_provisions_the_owner_and_stores_v3_fields(store) -> None: association_input=_fields( baseline="current-policy", provenance={"source": "bill", "ref": "HR-1"}, - client_ref="rf-1", ), ) @@ -86,7 +84,6 @@ def test_first_save_provisions_the_owner_and_stores_v3_fields(store) -> None: assert created.legacy_policy_id == 1 assert created.baseline == "current-policy" assert created.provenance == {"source": "bill", "ref": "HR-1", "confidence": None} - assert created.client_ref == "rf-1" with selected.sessions() as session: identity = session.exec(select(UserIdentity)).one() user = session.get(User, identity.user_id) @@ -164,22 +161,6 @@ def test_create_rejects_bad_references_before_writing(store) -> None: assert session.exec(select(User)).all() == [] -def test_duplicate_client_ref_conflicts_on_single_create(store) -> None: - service, _selected, _legacy_references = store - service.create_user_policy( - principal=ALICE, association_input=_fields(client_ref="sup-1") - ) - - with pytest.raises(AssociationClientRefConflictError): - service.create_user_policy( - principal=ALICE, association_input=_fields(client_ref="sup-1") - ) - other_user = service.create_user_policy( - principal=BOB, association_input=_fields(client_ref="sup-1") - ) - assert other_user.client_ref == "sup-1" - - 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( @@ -343,7 +324,6 @@ def test_stage_10_projection_records_the_v1_reform_id() -> None: assert projection.legacy_policy_id == 95762 assert projection.policy_id == policy_id - assert projection.client_ref is None def test_patch_input_requires_a_supported_field() -> None: diff --git a/tests/unit/v2/test_user_route_auth.py b/tests/unit/v2/test_user_route_auth.py index 85ec6056a..3601f944b 100644 --- a/tests/unit/v2/test_user_route_auth.py +++ b/tests/unit/v2/test_user_route_auth.py @@ -4,17 +4,25 @@ 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 UserPrincipal +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: @@ -210,10 +218,28 @@ def test_default_dependencies_publish_the_bearer_security_scheme() -> None: schema = client.get("/v2/openapi.json").json() - assert "UserAccessToken" in schema["components"]["securitySchemes"] + 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"): - assert {"UserAccessToken": []} in schema["paths"][path]["get"]["security"] + # 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]: @@ -279,3 +305,185 @@ def fail_in_database(): 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 index 65dc02954..eb0b4799b 100644 --- a/tests/unit/v2/test_user_simulation_report_routes.py +++ b/tests/unit/v2/test_user_simulation_report_routes.py @@ -9,7 +9,6 @@ import pytest from policyengine_api.services.v2.associations.validators import ( - AssociationClientRefConflictError, AssociationLinkNotFoundError, ) from policyengine_api.services.v2.legacy_refs.validators import ( @@ -87,7 +86,6 @@ def _simulation_read() -> UserSimulationRead: legacy_simulation_id=613, name="Baseline", description=None, - client_ref="sus-1", created_at=CREATED_AT, updated_at=CREATED_AT + timedelta(seconds=1), ) @@ -107,7 +105,6 @@ def _report_read() -> UserReportRead: provisions=PROVISIONS, validation=VALIDATION, last_run_at=LAST_RUN_AT, - client_ref="sur-1", created_at=CREATED_AT, updated_at=CREATED_AT + timedelta(seconds=1), ) @@ -150,7 +147,7 @@ def test_simulation_routes_require_v1_references_and_scope_to_the_user() -> None created = client.post( "/v2/user-simulations?country_id=us", - json={"country_id": "us", "legacy_simulation_id": 613, "client_ref": "sus-1"}, + json={"country_id": "us", "legacy_simulation_id": 613}, ) v2_reference = client.post( "/v2/user-simulations?country_id=us", @@ -174,7 +171,6 @@ def test_simulation_routes_require_v1_references_and_scope_to_the_user() -> None "legacy_simulation_id": 613, "name": "Baseline", "description": None, - "client_ref": "sus-1", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:01Z", } @@ -206,7 +202,6 @@ def test_report_routes_round_trip_v3_fields() -> None: "provisions": PROVISIONS, "validation": VALIDATION, "last_run_at": "2026-02-01T12:00:00Z", - "client_ref": "sur-1", }, ) @@ -257,7 +252,6 @@ def test_report_create_rejects_invalid_v3_payloads(changes: dict) -> None: (AssociationLinkNotFoundError("user-policy association was not found"), 422), (LegacyReferenceNotFoundError("v1 report 615 was not found"), 422), (UserReportNotFoundError("association was not found"), 404), - (AssociationClientRefConflictError("client_ref exists"), 409), ], ) def test_report_failures_map_to_contract_statuses( diff --git a/tests/unit/v2/test_user_write_limits.py b/tests/unit/v2/test_user_write_limits.py index 8719d226b..1012f63ed 100644 --- a/tests/unit/v2/test_user_write_limits.py +++ b/tests/unit/v2/test_user_write_limits.py @@ -1,4 +1,4 @@ -"""Request-size and field bounds on signed-in user writes.""" +"""Request-size and field bounds on user-owned writes.""" from __future__ import annotations @@ -10,7 +10,6 @@ import pytest from policyengine_api.fastapi_routes.v2.user_resources import ( - MAXIMUM_USER_IMPORT_REQUEST_BYTES, MAXIMUM_USER_WRITE_REQUEST_BYTES, ) from policyengine_api.services.v2.associations.types import ( @@ -18,12 +17,10 @@ ) from policyengine_api.services.v2.user_households.types import ( UserHouseholdCreateFields, - UserHouseholdImportItem, UserHouseholdUpdateInput, ) from policyengine_api.services.v2.user_policies.types import ( UserPolicyCreateFields, - UserPolicyImportItem, UserPolicyUpdateInput, ) from policyengine_api.services.v2.user_reports.types import ( @@ -32,12 +29,10 @@ REPORT_SOURCE_NOTE_MAX_LENGTH, ReportValidation, UserReportCreateFields, - UserReportImportItem, UserReportUpdateInput, ) from policyengine_api.services.v2.user_simulations.types import ( UserSimulationCreateFields, - UserSimulationImportItem, UserSimulationRead, UserSimulationUpdateInput, ) @@ -94,16 +89,11 @@ def test_every_user_write_rejects_oversized_bodies_before_the_service( ) -> None: services, factories = _services() client, _flask_calls = user_route_client(**factories) - limit = ( - MAXIMUM_USER_IMPORT_REQUEST_BYTES - if path == "/v2/me/imports" - else MAXIMUM_USER_WRITE_REQUEST_BYTES - ) response = client.request( method, f"{path}?country_id=us", - content=_padded_body(limit), + content=_padded_body(MAXIMUM_USER_WRITE_REQUEST_BYTES), headers={"Content-Type": "application/json"}, ) @@ -123,7 +113,6 @@ def test_single_writes_accept_bodies_under_the_limit() -> None: legacy_simulation_id=613, name=None, description=description, - client_ref=None, created_at=CREATED_AT, updated_at=CREATED_AT, ) @@ -150,22 +139,15 @@ def test_single_writes_accept_bodies_under_the_limit() -> None: [ (UserPolicyCreateFields, {"country_id": "us", "legacy_policy_id": 1}), (UserPolicyUpdateInput, {}), - (UserPolicyImportItem, {"client_ref": "sup-1", "legacy_policy_id": 1}), (UserHouseholdCreateFields, {"country_id": "us", "legacy_household_id": 1}), (UserHouseholdUpdateInput, {}), - (UserHouseholdImportItem, {"client_ref": "suh-1", "legacy_household_id": 1}), ( UserSimulationCreateFields, {"country_id": "us", "legacy_simulation_id": 1}, ), (UserSimulationUpdateInput, {}), - ( - UserSimulationImportItem, - {"client_ref": "sus-1", "legacy_simulation_id": 1}, - ), (UserReportCreateFields, {"country_id": "us", "legacy_report_id": 1}), (UserReportUpdateInput, {}), - (UserReportImportItem, {"client_ref": "sur-1", "legacy_report_id": 1}), ], ) def test_descriptions_are_bounded_on_every_caller_input(model, fields) -> None: @@ -178,11 +160,9 @@ def test_descriptions_are_bounded_on_every_caller_input(model, fields) -> None: ) -@pytest.mark.parametrize( - "model", [UserReportCreateFields, UserReportUpdateInput, UserReportImportItem] -) +@pytest.mark.parametrize("model", [UserReportCreateFields, UserReportUpdateInput]) def test_report_source_notes_are_bounded(model) -> None: - fields = {"country_id": "us", "legacy_report_id": 1, "client_ref": "sur-1"} + 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}) diff --git a/tests/unit/v2/user_route_support.py b/tests/unit/v2/user_route_support.py index eb9737aec..2620c3e48 100644 --- a/tests/unit/v2/user_route_support.py +++ b/tests/unit/v2/user_route_support.py @@ -16,11 +16,17 @@ RouteImplementation, RouteImplementationSettings, ) -from policyengine_api.services.v2.identity.types import UserPrincipal +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) @@ -29,9 +35,13 @@ def signed_in_user() -> UserPrincipal: return PRINCIPAL +def signed_out_browser() -> UnauthenticatedPrincipal: + return UNAUTHENTICATED_PRINCIPAL + + def user_route_client( *, - require_user: Callable[..., UserPrincipal] | None = signed_in_user, + 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. diff --git a/tests/unit/v2/user_service_support.py b/tests/unit/v2/user_service_support.py index 01d75e9fc..220644970 100644 --- a/tests/unit/v2/user_service_support.py +++ b/tests/unit/v2/user_service_support.py @@ -23,7 +23,10 @@ TaxBenefitModelVersion, V2_METADATA, ) -from policyengine_api.services.v2.identity.types import UserPrincipal +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, @@ -33,6 +36,12 @@ 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 From 799655a59591a84a9dd3e44ea6cce175467bed1c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:23:37 +0400 Subject: [PATCH 33/35] Record signed-out user contracts and accept either credential in the guard Every /v2/user-* operation, and GET /v2/me, GET /v2/me/export and DELETE /v2/me, is now recorded once with Authorization and once with the PolicyEngine-Unauthenticated-User header. PATCH /v2/me stays signed-in only, POST /v2/me/upgrade requires both headers, and the POST /v2/me/imports contract and every client_ref response field are gone. GET /v2/me also anchors result.item.user_type. The migration-contracts guard now requires each user-owned contract to carry Authorization or the unauthenticated-user header, and keys duplicate detection by method, path and headers so one route may be recorded per credential. The generated contract JSON and Markdown are regenerated. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/migration-contracts.md | 45 +- docs/generated/migration_contracts.json | 449 +++++++++++++++++- scripts/guards/migration_contracts.py | 20 +- tests/contract/registry.py | 148 +++--- .../test_app_v2_workflow_contracts.py | 43 +- .../unit/test_migration_contract_artifacts.py | 53 ++- 6 files changed, 659 insertions(+), 99 deletions(-) diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index 21679b6da..0d3fad81c 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -8,7 +8,7 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | --- | ---: | | route group count | 9 | | workflow count | 16 | -| request count | 65 | +| request count | 88 | | db entity count | 6 | | sim flow count | 3 | @@ -68,11 +68,16 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | 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.client_ref`, `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.client_ref`, `result.item.created_at`, `result.item.updated_at` | +| `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` @@ -102,11 +107,16 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | 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.client_ref`, `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.client_ref`, `result.item.created_at`, `result.item.updated_at` | +| `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` @@ -115,11 +125,16 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | 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.client_ref`, `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.client_ref`, `result.item.created_at`, `result.item.updated_at` | +| `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` @@ -128,11 +143,16 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | 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.source_note`, `result.item.provisions`, `result.item.validation`, `result.item.last_run_at`, `result.item.client_ref`, `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.source_note`, `result.item.provisions`, `result.item.validation`, `result.item.last_run_at`, `result.item.client_ref`, `result.item.created_at`, `result.item.updated_at` | +| `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.source_note`, `result.item.provisions`, `result.item.validation`, `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.source_note`, `result.item.provisions`, `result.item.validation`, `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.source_note`, `result.item.provisions`, `result.item.validation`, `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.source_note`, `result.item.provisions`, `result.item.validation`, `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.source_note`, `result.item.provisions`, `result.item.validation`, `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.source_note`, `result.item.provisions`, `result.item.validation`, `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` @@ -141,11 +161,14 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | 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.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` | `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` | -| `POST` | `/v2/me/imports?country_id=us` | 200 | `user_profile` | `Authorization` | `status`, `message`, `result.id_map`, `result.skipped` | +| `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` diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index b1082c1c1..c54e6c392 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -1,7 +1,7 @@ { "metadata": { "db_entity_count": 6, - "request_count": 65, + "request_count": 88, "route_group_count": 9, "sim_flow_count": 3, "workflow_count": 16 @@ -274,7 +274,6 @@ "result.item.description", "result.item.baseline", "result.item.provenance", - "result.item.client_ref", "result.item.created_at", "result.item.updated_at" ] @@ -299,7 +298,6 @@ "result.item.description", "result.item.baseline", "result.item.provenance", - "result.item.client_ref", "result.item.created_at", "result.item.updated_at" ] @@ -351,6 +349,102 @@ "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", + "stable_response_fields": [] } ] }, @@ -464,7 +558,6 @@ "result.item.legacy_household_id", "result.item.name", "result.item.description", - "result.item.client_ref", "result.item.created_at", "result.item.updated_at" ] @@ -487,7 +580,6 @@ "result.item.legacy_household_id", "result.item.name", "result.item.description", - "result.item.client_ref", "result.item.created_at", "result.item.updated_at" ] @@ -537,6 +629,96 @@ "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": [] } ] }, @@ -563,7 +745,6 @@ "result.item.legacy_simulation_id", "result.item.name", "result.item.description", - "result.item.client_ref", "result.item.created_at", "result.item.updated_at" ] @@ -586,7 +767,6 @@ "result.item.legacy_simulation_id", "result.item.name", "result.item.description", - "result.item.client_ref", "result.item.created_at", "result.item.updated_at" ] @@ -635,6 +815,95 @@ "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": [] } ] }, @@ -666,7 +935,6 @@ "result.item.provisions", "result.item.validation", "result.item.last_run_at", - "result.item.client_ref", "result.item.created_at", "result.item.updated_at" ] @@ -694,7 +962,6 @@ "result.item.provisions", "result.item.validation", "result.item.last_run_at", - "result.item.client_ref", "result.item.created_at", "result.item.updated_at" ] @@ -748,6 +1015,110 @@ "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.source_note", + "result.item.provisions", + "result.item.validation", + "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.source_note", + "result.item.provisions", + "result.item.validation", + "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.source_note", + "result.item.provisions", + "result.item.validation", + "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": [] } ] }, @@ -768,6 +1139,27 @@ "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", @@ -813,16 +1205,37 @@ { "expected_status": 200, "headers": [ - "Authorization" + "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/imports?country_id=us", + "path": "/v2/me/upgrade?country_id=us", "route_group": "user_profile", "stable_response_fields": [ "status", "message", - "result.id_map", - "result.skipped" + "result.item.id", + "result.item.user_type", + "result.item.primary_country", + "result.outcome" ] }, { @@ -834,6 +1247,16 @@ "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": [] } ] }, diff --git a/scripts/guards/migration_contracts.py b/scripts/guards/migration_contracts.py index 701902b82..7663863cf 100644 --- a/scripts/guards/migration_contracts.py +++ b/scripts/guards/migration_contracts.py @@ -10,9 +10,13 @@ REPO_ROOT = Path(__file__).resolve().parents[2] ALLOWED_CURRENT_CONTRACTS = frozenset({"api_v1_compatible", "typed_v2_resources"}) -# Signed-in user routes must never become less restrictive during a cutover. +# 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( @@ -80,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}" @@ -93,12 +101,12 @@ 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 ( - AUTHORIZATION_HEADER not in request.get("headers", []) - ): + 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} header" + f"{AUTHORIZATION_HEADER} or {UNAUTHENTICATED_USER_HEADER} header" ) if request["expected_status"] not in {200, 201, 202, 204}: violations.append( diff --git a/tests/contract/registry.py b/tests/contract/registry.py index 45305530f..572dde19c 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -4,7 +4,11 @@ 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) @@ -14,8 +18,10 @@ class ContractRequest: expected_status: int stable_response_fields: tuple[str, ...] route_group: str - # Request headers the route requires, by name. Signed-in user routes - # require "Authorization: Bearer ". + # 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, ...] = () @@ -27,7 +33,6 @@ def _association_item_fields(*names: str) -> tuple[str, ...]: "result.item.country_id", "result.item.user_id", *(f"result.item.{name}" for name in names), - "result.item.client_ref", "result.item.created_at", "result.item.updated_at", ) @@ -43,12 +48,13 @@ def _association_item_fields(*names: str) -> tuple[str, ...]: ) -def _signed_in_association_requests( +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 ( @@ -58,7 +64,7 @@ def _signed_in_association_requests( expected_status=201, stable_response_fields=item_fields, route_group=route_group, - headers=USER_AUTH_HEADERS, + headers=headers, ), ContractRequest( method="GET", @@ -66,7 +72,7 @@ def _signed_in_association_requests( expected_status=200, stable_response_fields=item_fields, route_group=route_group, - headers=USER_AUTH_HEADERS, + headers=headers, ), ContractRequest( method="GET", @@ -74,7 +80,7 @@ def _signed_in_association_requests( expected_status=200, stable_response_fields=PAGE_FIELDS, route_group=route_group, - headers=USER_AUTH_HEADERS, + headers=headers, ), ContractRequest( method="PATCH", @@ -88,7 +94,7 @@ def _signed_in_association_requests( "result.item.updated_at", ), route_group=route_group, - headers=USER_AUTH_HEADERS, + headers=headers, ), ContractRequest( method="DELETE", @@ -96,11 +102,31 @@ def _signed_in_association_requests( expected_status=204, stable_response_fields=(), route_group=route_group, - headers=USER_AUTH_HEADERS, + 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) class WorkflowContract: name: str @@ -235,7 +261,7 @@ class WorkflowContract: name="user_policy_associations_v2", current_contract="typed_v2_resources", future_owner_pr="PR 12A: User Login and v1-Anchored User Associations", - requests=_signed_in_association_requests( + requests=_user_association_requests( "user-policies", route_group="policy", item_fields=_association_item_fields( @@ -334,7 +360,7 @@ class WorkflowContract: name="user_household_associations_v2", current_contract="typed_v2_resources", future_owner_pr="PR 12A: User Login and v1-Anchored User Associations", - requests=_signed_in_association_requests( + requests=_user_association_requests( "user-households", route_group="household", item_fields=_association_item_fields( @@ -355,7 +381,7 @@ class WorkflowContract: name="user_simulation_associations_v2", current_contract="typed_v2_resources", future_owner_pr="PR 12A: User Login and v1-Anchored User Associations", - requests=_signed_in_association_requests( + requests=_user_association_requests( "user-simulations", route_group="simulation", item_fields=_association_item_fields( @@ -371,7 +397,7 @@ class WorkflowContract: name="user_report_associations_v2", current_contract="typed_v2_resources", future_owner_pr="PR 12A: User Login and v1-Anchored User Associations", - requests=_signed_in_association_requests( + requests=_user_association_requests( "user-reports", route_group="report", item_fields=_association_item_fields( @@ -402,22 +428,26 @@ class WorkflowContract: current_contract="typed_v2_resources", future_owner_pr="PR 12A: User Login and v1-Anchored User Associations", requests=( - ContractRequest( - method="GET", - path="/v2/me?country_id=us", - expected_status=200, - stable_response_fields=( - "status", - "message", - "result.item.id", - "result.item.primary_country", - "result.item.first_name", - "result.item.last_name", - "result.item.email", - "result.item.created_at", - ), - route_group="user_profile", - headers=USER_AUTH_HEADERS, + *( + 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="PATCH", @@ -434,42 +464,50 @@ class WorkflowContract: route_group="user_profile", headers=USER_AUTH_HEADERS, ), - 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=USER_AUTH_HEADERS, + *( + 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="POST", - path="/v2/me/imports?country_id=us", + path="/v2/me/upgrade?country_id=us", expected_status=200, stable_response_fields=( "status", "message", - "result.id_map", - "result.skipped", + "result.item.id", + "result.item.user_type", + "result.item.primary_country", + "result.outcome", ), route_group="user_profile", - headers=USER_AUTH_HEADERS, + headers=(AUTHORIZATION_HEADER, UNAUTHENTICATED_USER_HEADER), ), - ContractRequest( - method="DELETE", - path="/v2/me?country_id=us", - expected_status=204, - stable_response_fields=(), - route_group="user_profile", - headers=USER_AUTH_HEADERS, + *( + 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 73a00e022..5978f0ac6 100644 --- a/tests/contract/test_app_v2_workflow_contracts.py +++ b/tests/contract/test_app_v2_workflow_contracts.py @@ -53,7 +53,11 @@ def test_app_v2_workflow_contract_registry_is_complete(): 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 == ("Authorization",) + assert request.headers in { + ("Authorization",), + ("PolicyEngine-Unauthenticated-User",), + ("Authorization", "PolicyEngine-Unauthenticated-User"), + } assert "user_id" not in request.path else: assert request.headers == () @@ -70,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/unit/test_migration_contract_artifacts.py b/tests/unit/test_migration_contract_artifacts.py index 1f7ffa831..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 @@ -11,7 +14,7 @@ def test_migration_contract_payload_summarizes_route_contracts(): assert payload["metadata"] == { "route_group_count": 9, "workflow_count": 16, - "request_count": 65, + "request_count": 88, "db_entity_count": 6, "sim_flow_count": 3, } @@ -76,36 +79,56 @@ def test_migration_contract_quality_guard_rejects_duplicate_path_segments( ) -def test_migration_contract_quality_guard_requires_user_route_authorization( - monkeypatch, -): +def _payload_with_user_report_headers(headers: list[str]) -> dict: payload = export_migration_contracts.build_payload() workflows = [ { **workflow, "requests": [ - {**request, "headers": []} for request in workflow["requests"] + {**request, "headers": headers} for request in workflow["requests"] ], } if workflow["name"] == "user_report_associations_v2" else workflow for workflow in payload["workflows"] ] - monkeypatch.setattr( - export_migration_contracts, - "build_payload", - lambda: {**payload, "workflows": 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( - "user-owned routes must require the Authorization header" in violation + "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_signed_in_user_contracts_require_authorization_headers(): +def test_user_contracts_require_a_signed_in_or_signed_out_credential(): payload = export_migration_contracts.build_payload() user_requests = [ request @@ -113,7 +136,11 @@ def test_signed_in_user_contracts_require_authorization_headers(): 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 len(user_requests) == 25 - assert all(request["headers"] == ["Authorization"] 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) From 9aace0b2e8f1455bd4e2511a4658ee499a43863b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:23:37 +0400 Subject: [PATCH 34/35] Exercise signed-out saving and upgrades in the staging and candidate checks The staging live test no longer imports or sends client_refs. A new check saves work as fresh random browser UUIDs: reads before the first write find nothing, a signed-out write reads back, PATCH /v2/me is 403, the signed-in user cannot see another browser's work, one browser is converted into the test user's account and a second merged into it, and a signed-out DELETE /v2/me removes only its own work. It starts and ends by deleting the test user's account, so it can run repeatedly. The candidate smoke test also checks that a malformed PolicyEngine-Unauthenticated-User header gets 401. Co-Authored-By: Claude Opus 5.5 --- tests/integration/test_cloud_run_candidate.py | 24 +- tests/integration/test_live_v2_policies.py | 258 ++++++++++++------ 2 files changed, 188 insertions(+), 94 deletions(-) diff --git a/tests/integration/test_cloud_run_candidate.py b/tests/integration/test_cloud_run_candidate.py index fbf173a5a..beb497efc 100644 --- a/tests/integration/test_cloud_run_candidate.py +++ b/tests/integration/test_cloud_run_candidate.py @@ -69,16 +69,18 @@ def test_cloud_run_candidate_stage6_read_route_contracts(api_client): def test_cloud_run_candidate_user_routes_fail_closed_without_a_token(api_client): - """Signed-in user routes must never become readable anonymously.""" + """User-owned routes must never become readable without a credential.""" for path in ("/v2/me", "/v2/user-policies", "/v2/user-reports"): - response = api_client.get( - path, - params={ - "country_id": "us", - "user_id": "00000000-0000-0000-0000-000000000000", - }, - ) - assert response.status_code == 401, (path, response.text) - assert response.headers["www-authenticate"].startswith("Bearer") - assert response.json()["status"] == "error" + 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 d8b98ab4f..ea9370777 100644 --- a/tests/integration/test_live_v2_policies.py +++ b/tests/integration/test_live_v2_policies.py @@ -1,9 +1,11 @@ -"""Live staging checks for native v2 policies and signed-in user 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. Every credential comes from the GitHub ``staging`` environment: +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); @@ -42,6 +44,7 @@ ) UNKNOWN_LEGACY_ID = 9_007_199_254_740_000 COUNTRY_ID = "us" +UNAUTHENTICATED_USER_HEADER = "PolicyEngine-Unauthenticated-User" def _staging_user_token() -> str: @@ -144,25 +147,58 @@ def _delete_account(api_client, headers: dict[str, str]) -> None: 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/imports"), + ("POST", "/v2/me/upgrade"), ("GET", "/v2/user-policies"), ("GET", "/v2/user-households"), ("GET", "/v2/user-simulations"), ("GET", "/v2/user-reports"), ): - response = api_client.request( - method, - path, - params={"country_id": COUNTRY_ID, "user_id": str(uuid4())}, - 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" + 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( @@ -262,7 +298,6 @@ def test_live_signed_in_user_lifecycle( "user-reports": ("legacy_report_id", legacy["report"]), } for resource, (legacy_field, legacy_id) in resources.items(): - client_ref = f"{resource}-{integration_probe_id}" created = api_client.post( f"/v2/{resource}", params=params, @@ -270,27 +305,14 @@ def test_live_signed_in_user_lifecycle( json={ "country_id": COUNTRY_ID, legacy_field: legacy_id, - "name": f"Stage 12A {resource}", - "client_ref": client_ref, + "name": f"Stage 12A {resource} {integration_probe_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 - assert item["client_ref"] == client_ref - duplicate = api_client.post( - f"/v2/{resource}", - params=params, - headers=user_headers, - json={ - "country_id": COUNTRY_ID, - legacy_field: legacy_id, - "client_ref": client_ref, - }, - ) - assert duplicate.status_code == 409, (resource, duplicate.text[:500]) missing = api_client.post( f"/v2/{resource}", params=params, @@ -327,70 +349,42 @@ def test_live_signed_in_user_lifecycle( absent = api_client.get(detail_path, params=params, headers=user_headers) assert absent.status_code == 404, (resource, absent.text[:500]) - import_body = { - "policies": [ - { - "client_ref": f"rf-{integration_probe_id}", - "legacy_policy_id": legacy["policy"], - "baseline": "current-law", - "provenance": {"source": "manual"}, - }, - { - "client_ref": f"sup-missing-{integration_probe_id}", - "legacy_policy_id": UNKNOWN_LEGACY_ID, - }, - ], - "households": [ - { - "client_ref": f"suh-{integration_probe_id}", - "legacy_household_id": legacy["household"], - } - ], - "simulations": [ - { - "client_ref": f"sus-{integration_probe_id}", - "legacy_simulation_id": legacy["simulation"], - } - ], - "reports": [ - { - "client_ref": f"lfr-{integration_probe_id}", - "legacy_report_id": legacy["report"], - "user_policy_client_ref": f"rf-{integration_probe_id}", - } - ], - } - imported = api_client.post( - "/v2/me/imports", params=params, headers=user_headers, json=import_body + 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 imported.status_code == 200, imported.text[:500] - result = imported.json()["result"] - assert len(result["id_map"]) == 4 - assert result["skipped"] == [ - { - "client_ref": f"sup-missing-{integration_probe_id}", - "reason": "unknown_legacy_reference", - } - ] - repeated = api_client.post( - "/v2/me/imports", params=params, headers=user_headers, json=import_body + 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 repeated.status_code == 200, repeated.text[:500] - assert repeated.json()["result"]["id_map"] == result["id_map"] + assert report.status_code == 201, report.text[:500] + report_id = report.json()["result"]["item"]["id"] 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 - report = next( - row - for row in export["reports"] - if row["client_ref"] == f"lfr-{integration_probe_id}" - ) - assert ( - report["user_policy_id"] == result["id_map"][f"rf-{integration_probe_id}"] + assert export["user"]["user_type"] == "standard" + exported_report = next( + row for row in export["reports"] if row["id"] == report_id ) - assert {len(export[name]) for name in ("policies", "households")} == {1} + 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) @@ -403,3 +397,101 @@ def test_live_signed_in_user_lifecycle( 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/user-policies/{saved['id']}", + params=params, + headers=converted_browser, + ) + assert detail.status_code == 200, detail.text[:500] + 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 edited.status_code == 403, edited.text[:500] + + # 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) + + # ...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 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] + + # 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 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 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: + _delete_account(api_client, signed_out_only_browser) + _delete_account(api_client, merged_browser) + _delete_account(api_client, user_headers) From 60c650dd2cb79d7e294271e82dedac61cde5c75f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:23:50 +0400 Subject: [PATCH 35/35] Document unauthenticated users, upgrades, and the single write throttle The Stage 12A doc now describes the design that replaced browser imports: app v3 sends PolicyEngine-Unauthenticated-User while signed out and calls POST /v2/me/upgrade at sign-in; the standard and unauthenticated user types; the two provisioning strategies and read-without-provisioning; conversion, merge and their concurrency; the security notes (the UUID is a bearer credential kept out of URLs and logs, an unauthenticated principal never acts on a standard user, and only edge throttling bounds anonymous user creation); the regenerated revision; and why a rollback now needs the guard from the first user-owned record, signed in or not. The import sections are removed. The proposed Cloud Armor user-writes policy drops the import throttle (priority 1150); its per-IP write throttle covers signed-out writes and POST /v2/me/upgrade and is the only bound on minting unauthenticated users. The rollback guard still denies every /v2/me* and /v2/user-* path. The testing and API-route skills and the changelog fragment follow. Co-Authored-By: Claude Opus 5.5 --- ...stage-12a-user-login-associations.added.md | 2 +- docs/engineering/skills/api-routes.md | 5 +- docs/engineering/skills/testing.md | 20 +- ...1012-pol-api-lb-stage-12a-user-writes.yaml | 73 +-- ...3-pol-api-lb-stage-12a-rollback-guard.yaml | 19 +- docs/migration/stage-12a-user-login.md | 470 ++++++++++++------ 6 files changed, 368 insertions(+), 221 deletions(-) diff --git a/changelog.d/feat-stage-12a-user-login-associations.added.md b/changelog.d/feat-stage-12a-user-login-associations.added.md index 06997df0a..317ab31c3 100644 --- a/changelog.d/feat-stage-12a-user-login-associations.added.md +++ b/changelog.d/feat-stage-12a-user-login-associations.added.md @@ -1 +1 @@ -Add Auth0 user login for app v3: /v2/me (provisioning, profile, export, idempotent imports, account deletion), owner-scoped v1-anchored user associations for policies, households, simulations, and reports, and the user_identities schema. +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/skills/api-routes.md b/docs/engineering/skills/api-routes.md index ef5c2a78c..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 signed-in association reference filtering - and pagination; the owner comes from the bearer token, never the query. +- `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 f5437ea66..51c36817d 100644 --- a/docs/engineering/skills/testing.md +++ b/docs/engineering/skills/testing.md @@ -301,7 +301,7 @@ uv run --frozen pytest \ ## Stage 12A User Login and Associations -Run the static type check, then the signed-in route, service, authentication, +Run the static type check, then the user route, service, authentication, contract, and deployment tests: ```bash @@ -317,6 +317,7 @@ uv run --frozen pytest \ 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 \ @@ -327,8 +328,10 @@ uv run --frozen pytest \ tests/contract -q ``` -Prove provisioning races, idempotent imports, named conflict targets, and the -reshape lifecycle against the disposable Postgres database: +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 \ @@ -338,8 +341,9 @@ uv run --frozen pytest \ tests/integration/test_v2_user_identity_persistence.py -q ``` -Route tests inject a principal 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; it is not -run locally. +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/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 index 3a98dd38d..142ed5cac 100644 --- 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 @@ -1,20 +1,12 @@ -# PROPOSED (not deployed): Stage 12A per-IP throttles for signed-in user writes. +# 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 two new rules at -# priorities 1150 and 1200. It lives under proposed/ so it is never mistaken for -# the authoritative deployed state. An operator applies the two rules with the +# (../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 1150 \ -# --project=$PROJECT --security-policy=pol-api-lb \ -# --expression="request.method == 'POST' && request.path.matches('^/v2/me/imports$')" \ -# --action=throttle \ -# --rate-limit-threshold-count=60 --rate-limit-threshold-interval-sec=600 \ -# --conform-action=allow --exceed-action=deny-429 --enforce-on-key=IP \ -# --preview -# # 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-)')" \ @@ -24,29 +16,29 @@ # --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 and once -# before each sign-in import; NAT'd offices share IPs. -# - App v3 imports at every sign-in, not only the first. It moves the -# browser's signed-out reforms and reports into the account with -# POST /v2/me/imports, removes each item whose client_ref comes back in -# id_map, and keeps the rest for the next sign-in. Requests are chunked per -# country at <=500 items and about 950 KB. A sign-in with browser work sends -# one GET /v2/me (not throttled) and usually one import per country with -# items: at most 2 for typical browsers, more only for very large ones. A -# sign-in with nothing to move sends no import. A failed import offers a -# manual "Try again", which sends the imports again. -# - Imports are the heaviest write (up to 500 items, 1 MiB each), so they get -# a tighter, longer-window rule evaluated first (lower priority number): -# 60 requests per 600 s per IP. At 2 imports per sign-in that is about 30 -# sign-ins with browser work per IP every 10 minutes, room for offices and -# campuses behind shared NAT and for retries, while still bounding the -# import path. A throttled import (429) fails the run; its items stay in the -# browser for "Try again" or the next sign-in. +# 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-). -# - Thresholds are starting points; confirm with >=24h of preview logs and the -# runbook's empirical threshold check before enforcing. +# - 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= @@ -86,22 +78,7 @@ rules: count: 75 intervalSec: 60 - action: throttle - description: 'Stage 12A: per-IP limit on signed-in localStorage imports' - kind: compute#securityPolicyRule - match: - expr: - expression: request.method == 'POST' && request.path.matches('^/v2/me/imports$') - preview: true - priority: 1150 - rateLimitOptions: - conformAction: allow - enforceOnKey: IP - exceedAction: deny(429) - rateLimitThreshold: - count: 60 - intervalSec: 600 -- action: throttle - description: 'Stage 12A: per-IP limit on signed-in user writes' + description: 'Stage 12A: per-IP limit on user-owned writes' kind: compute#securityPolicyRule match: expr: 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 index e1356e270..530498e71 100644 --- 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 @@ -1,24 +1,25 @@ # 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 signed-in user -# exists. Those revisions serve /v2/user-policies and /v2/user-households -# without authentication: anyone could list, read, create, rename or delete a -# signed-in user's saved work by user or record ID. This rule denies every -# signed-in user route at the edge instead. Saved work is unavailable while it -# is in place; signed-out flows and v1 routes are unaffected. +# 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 throttles (priorities 1150 and 1200). +# 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 signed-in user routes" \ +# --description="Stage 12A rollback guard: deny user-owned routes" \ # --expression="request.path.matches('^/v2/(?:me|user-(?:policies|households|simulations|reports))(?:/.*)?$')" \ # --action=deny-403 # @@ -61,7 +62,7 @@ labelFingerprint: 42WmSpB8rSM= name: pol-api-lb rules: - action: deny(403) - description: 'Stage 12A rollback guard: deny signed-in user routes' + description: 'Stage 12A rollback guard: deny user-owned routes' kind: compute#securityPolicyRule match: expr: diff --git a/docs/migration/stage-12a-user-login.md b/docs/migration/stage-12a-user-login.md index 3f9ff3e97..b36414fc2 100644 --- a/docs/migration/stage-12a-user-login.md +++ b/docs/migration/stage-12a-user-login.md @@ -1,59 +1,179 @@ -# Stage 12A: user login and v1-anchored user associations +# Stage 12A: user accounts and v1-anchored user associations -Stage 12A lets app v3 users sign in with Auth0 social sign-on and saves their -work server-side. The Public API turns the Auth0 identity 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. -Signed-out behavior is unchanged. +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. -- **Import.** At every sign-in, app v3 moves the v3 reforms and reports saved - in that browser while signed out into the account through - `POST /v2/me/imports`. It removes from the browser each item whose - `client_ref` comes back in `id_map`, and keeps the rest for the next - sign-in. Because imports are idempotent on `client_ref`, an item sent again - returns its existing ID. -- **Account.** App v3 calls `GET /v2/me` on its account page. Before an - import that has items to move, it also calls `GET /v2/me` once with the - sign-in country, so a first sign-in records that country as the - account's primary country. +- **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. -## What changed - -- **Inbound authentication.** `/v2/me*` and `/v2/user-*` require - `Authorization: Bearer ` for the Public API audience. - `libs/user_auth.py` validates RS256 tokens against - `{issuer}.well-known/jwks.json` and checks `iss` and `aud`. The decoder is - ported from the Simulation Entrypoint. Responses: - - no token or an invalid token: 401 with `WWW-Authenticate: Bearer`; - - a machine-to-machine (`…@clients`) token: 403; - - authentication not configured, or signing keys unreachable: 503. - - Authentication is a router-level dependency - (`NativeRouteDependencies.require_user`, fail-closed by default). It runs - before query, path, and body validation. Only a body that is not parseable - JSON is rejected (422) by FastAPI first. -- **Identity and provisioning.** The new `user_identities` table maps - `(issuer, subject)` to `users.id`. The first authenticated request inserts the - 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. Logs carry - only the v2 user UUID. -- **Ownership.** Request bodies and queries no longer accept `user_id`; the - owner always comes from the token. Another user's record returns 404. +## 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. + 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 @@ -63,92 +183,99 @@ routes. The legacy builder stays browser-only, signed in or not. - **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`. -- **Imports.** `POST /v2/me/imports` takes up to 500 browser-local items in a - 1 MiB body. It is idempotent on `UNIQUE(user_id, client_ref)`: an existing - `client_ref` in the import country returns its stored ID unchanged. Items - with an unknown v1 reference or an invalid shape are skipped and reported; - so is a `client_ref` the user already holds in another country - (`client_ref_conflict`), which is never linked across countries. A report's - `user_policy_client_ref` resolves only against the user's policies in the - import country. - - A `client_ref` may appear only once in a request, across policies, - households, simulations, and reports, because `id_map` is keyed by - `client_ref` alone. When one appears more than once, none of its items - is stored and each occurrence is reported in `skipped` as `invalid`; the - request's other items are imported as usual. `id_map` therefore never - holds a `client_ref` that is also in `skipped`, which matters because app - v3 removes from the browser every item whose `client_ref` comes back in - `id_map`. - **Request bounds.** Every `/v2/me*` and `/v2/user-*` write rejects a body - over 256 KiB with 413 (imports allow 1 MiB). 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 on single writes and imports alike: `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: - Python's JSON parser accepts them but Postgres JSONB cannot store them. A - single write containing one returns 422; an import item containing one is - skipped as `invalid`. -- **Account deletion.** 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. + 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 | Behavior | -| --- | --- | -| `GET /v2/me?country_id=` | Returns the user; provisions on first call | -| `PATCH /v2/me` | Updates `first_name`, `last_name`, `primary_country` | -| `GET /v2/me/export` | Returns the user and every association, all countries | -| `POST /v2/me/imports` | Idempotent bulk import; returns `id_map` and `skipped` | -| `DELETE /v2/me` | 204; deletes data, then the Auth0 user when configured | -| `/v2/user-policies`, `/v2/user-households`, `/v2/user-simulations`, `/v2/user-reports` | POST (201), list, GET, PATCH, DELETE (204), owner-scoped | - -Status conventions follow the existing v2 routes. Schema and query 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. Single-create `client_ref` clashes and mirror-owned rows return 409. -Oversized bodies return 413. +| 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 produced with -`uv run alembic -c alembic-v2.ini revision --autogenerate` from the final -SQLModel metadata. It has no post-generation corrections. - +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`), `provenance jsonb`, and `client_ref varchar(64)`. Adds - `ck_user_policies_policy_ref`, `uq_user_policies_user_client_ref`, and an - index on `(country_id, legacy_policy_id)`. + `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, - `client_ref`, a reference CHECK, `UNIQUE(id, country_id)`, - `UNIQUE(user_id, client_ref)`, and listing indexes. Reports also add - `user_policy_id` → `user_policies.id` ON DELETE SET NULL, `source_note`, + `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, `source_note`, `provisions jsonb`, and `validation jsonb`. - The old `country` and `label` columns on those two tables are retired but kept: `country` becomes nullable and nothing reads or writes either column. @@ -185,17 +312,17 @@ 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 signed-in users' saved work to anyone. - -- **Before any signed-in user exists, an application rollback is safe without - a database downgrade.** Treat signed-in users as existing from the moment an - environment has `PUBLIC_API_AUTH_ISSUER` configured, because from then on - anyone with an Auth0 account can provision one. -- **Once any signed-in user exists, an application rollback must apply the - Stage 12A rollback guard first** +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*` and `/v2/user-*` with - 403 at the load balancer. + 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 @@ -203,11 +330,11 @@ would therefore hand signed-in users' saved work to anyone. 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: app v3 shows its - account error and signed-in saves fail. Signed-out flows, 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. + 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 @@ -215,24 +342,33 @@ would therefore hand signed-in users' saved work to anyone. 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 writes only legacy transition users, and no signed-in user is ever - one of them, because Stage 12A never adopts a legacy user. + 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`, and `country`, so it fails - while legacy-only rows or any simulation or report associations exist. - Removing them first deletes users' saved work. + `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 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. +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 @@ -256,9 +392,13 @@ 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 user routes answer 401 or 503 | +| `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) | @@ -277,7 +417,9 @@ parent revision writes. `readiness.validate_policy_runtime_configuration` validates the `PUBLIC_API_AUTH_*` and `AUTH0_MANAGEMENT_*` settings. Inconsistent values fail -readiness, which is the deploy gate. `validate_cloud_run_deploy_env.sh` requires +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 @@ -295,8 +437,11 @@ needs the following in the GitHub `staging` environment: | `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. With Management API deletion configured, that would also delete the CI -user from Auth0, so the staging deploy job never receives 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. @@ -325,7 +470,8 @@ Configure staging first, then production: --role="roles/secretmanager.secretAccessor" ``` - Without the grant, every account deletion returns 503 and deletes nothing. + Without the grant, every signed-in account deletion returns 503 and + deletes nothing. ## Rollout @@ -336,24 +482,31 @@ Configure staging first, then 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 live test before promotion. -5. Production: set the production values once production Auth0, including + 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 anonymous - `/v2/me` and `/v2/user-*` requests return 401. After promotion, also check - that `curl https://api.policyengine.org/v2/user-policies?country_id=us` + 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 throttles in preview +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 them only after the runbook's checks. + 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 signed-in user on, it is a precondition for any - application rollback to a pre-Stage 12A revision (see "Rollback"). + 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 does -not require `Authorization`. +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 @@ -373,12 +526,21 @@ python scripts/run_quality_guards.py The disposable-Postgres suite proves the following: - concurrent first sign-ins create one user; -- concurrent imports are idempotent; -- imports never reuse or link a `client_ref` from another country; +- 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, and an import skips the item holding one; +- NaN and Infinity never reach JSONB; - the parent revision's table usage works against head (see "Schema compatibility"); - the qualification transaction is read-only. @@ -388,6 +550,8 @@ The disposable-Postgres suite proves the following: - 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.