diff --git a/pyproject.toml b/pyproject.toml index eb0964c..b803adc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.12" authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.6", + "govoplan-core>=0.1.7", ] [tool.setuptools.packages.find] diff --git a/src/govoplan_identity/backend/manifest.py b/src/govoplan_identity/backend/manifest.py index 6920537..01926b8 100644 --- a/src/govoplan_identity/backend/manifest.py +++ b/src/govoplan_identity/backend/manifest.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY from govoplan_core.core.module_guards import persistent_table_uninstall_guard @@ -58,7 +60,12 @@ manifest = ModuleManifest( permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, route_factory=_route_factory, - migration_spec=MigrationSpec(module_id="identity", metadata=Base.metadata), + migration_spec=MigrationSpec( + module_id="identity", + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + migration_after=("access",), + ), uninstall_guard_providers=( persistent_table_uninstall_guard( identity_models.Identity, diff --git a/src/govoplan_identity/backend/migrations/__init__.py b/src/govoplan_identity/backend/migrations/__init__.py new file mode 100644 index 0000000..58bb885 --- /dev/null +++ b/src/govoplan_identity/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Identity module migrations.""" diff --git a/src/govoplan_identity/backend/migrations/versions/5c6d7e8f9a10_identity_directory.py b/src/govoplan_identity/backend/migrations/versions/5c6d7e8f9a10_identity_directory.py new file mode 100644 index 0000000..7436862 --- /dev/null +++ b/src/govoplan_identity/backend/migrations/versions/5c6d7e8f9a10_identity_directory.py @@ -0,0 +1,141 @@ +"""identity directory + +Revision ID: 5c6d7e8f9a10 +Revises: 4a5b6c7d8e9f +Create Date: 2026-07-11 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "5c6d7e8f9a10" +down_revision = "4a5b6c7d8e9f" +branch_labels = None +depends_on = None + + +def _tables() -> set[str]: + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def _indexes(table_name: str) -> set[str]: + if table_name not in _tables(): + return set() + return {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table_name)} + + +def _create_index_if_missing(name: str, table_name: str, columns: list[str], *, unique: bool = False, **kwargs) -> None: + if table_name in _tables() and name not in _indexes(table_name): + op.create_index(name, table_name, columns, unique=unique, **kwargs) + + +def _drop_index_if_exists(name: str, table_name: str) -> None: + if table_name in _tables() and name in _indexes(table_name): + op.drop_index(name, table_name=table_name) + + +def upgrade() -> None: + tables = _tables() + if "identity_identities" not in tables: + op.create_table( + "identity_identities", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("display_name", sa.String(length=255), nullable=True), + sa.Column("external_subject", sa.String(length=255), nullable=True), + sa.Column("source", sa.String(length=50), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("settings", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_identity_identities")), + ) + _create_index_if_missing(op.f("ix_identity_identities_external_subject"), "identity_identities", ["external_subject"]) + + tables = _tables() + if "identity_account_links" not in tables: + op.create_table( + "identity_account_links", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("identity_id", sa.String(length=36), nullable=False), + sa.Column("account_id", sa.String(length=36), nullable=False), + sa.Column("is_primary", sa.Boolean(), nullable=False), + sa.Column("source", sa.String(length=50), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["identity_id"], + ["identity_identities.id"], + name=op.f("fk_identity_account_links_identity_id_identity_identities"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_identity_account_links")), + sa.UniqueConstraint("identity_id", "account_id", name="uq_identity_module_account_links_identity_account"), + ) + _create_index_if_missing(op.f("ix_identity_account_links_account_id"), "identity_account_links", ["account_id"]) + _create_index_if_missing(op.f("ix_identity_account_links_identity_id"), "identity_account_links", ["identity_id"]) + _create_index_if_missing( + "uq_identity_module_account_links_primary_account", + "identity_account_links", + ["account_id"], + unique=True, + sqlite_where=sa.text("is_primary = 1"), + postgresql_where=sa.text("is_primary IS TRUE"), + ) + _create_index_if_missing( + "uq_identity_module_account_links_primary_identity", + "identity_account_links", + ["identity_id"], + unique=True, + sqlite_where=sa.text("is_primary = 1"), + postgresql_where=sa.text("is_primary IS TRUE"), + ) + + tables = _tables() + if {"access_identities", "access_identity_account_links"}.issubset(tables): + op.execute(""" + INSERT INTO identity_identities + (id, display_name, external_subject, source, is_active, settings, created_at, updated_at) + SELECT id, display_name, external_subject, source, is_active, settings, created_at, updated_at + FROM access_identities source_identity + WHERE NOT EXISTS ( + SELECT 1 + FROM identity_identities target_identity + WHERE target_identity.id = source_identity.id + ) + """) + op.execute(""" + INSERT INTO identity_account_links + (id, identity_id, account_id, is_primary, source, created_at, updated_at) + SELECT id, identity_id, account_id, is_primary, source, created_at, updated_at + FROM access_identity_account_links source_link + WHERE EXISTS ( + SELECT 1 + FROM identity_identities target_identity + WHERE target_identity.id = source_link.identity_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM identity_account_links target_link + WHERE target_link.id = source_link.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM identity_account_links target_link + WHERE target_link.identity_id = source_link.identity_id + AND target_link.account_id = source_link.account_id + ) + """) + + +def downgrade() -> None: + _drop_index_if_exists("uq_identity_module_account_links_primary_identity", "identity_account_links") + _drop_index_if_exists("uq_identity_module_account_links_primary_account", "identity_account_links") + _drop_index_if_exists(op.f("ix_identity_account_links_identity_id"), "identity_account_links") + _drop_index_if_exists(op.f("ix_identity_account_links_account_id"), "identity_account_links") + if "identity_account_links" in _tables(): + op.drop_table("identity_account_links") + _drop_index_if_exists(op.f("ix_identity_identities_external_subject"), "identity_identities") + if "identity_identities" in _tables(): + op.drop_table("identity_identities") diff --git a/src/govoplan_identity/backend/migrations/versions/__init__.py b/src/govoplan_identity/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..a5398f3 --- /dev/null +++ b/src/govoplan_identity/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Identity module migration versions."""