"""establish trusted tenant identity foundation Revision ID: 20260717_0025 Revises: 20260717_0024 Create Date: 2026-07-17 10:20:00 """ from __future__ import annotations from collections.abc import Sequence import sqlalchemy as sa from alembic import op revision: str = "20260717_0025" down_revision: str | None = "20260717_0024" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None _DEFAULT_TENANT = "default" _TENANT_SOURCE_TABLES = ( "auth_sessions", "expense_cases", "commercial_billing_periods", "tenant_subscriptions", "financial_connector_configs", "agent_asset_release_observations", ) def _require_postgresql() -> None: dialect_name = op.get_bind().dialect.name if dialect_name != "postgresql": raise RuntimeError( "20260717_0025 only supports PostgreSQL; " f"refusing to mutate {dialect_name} without transactional constraint DDL" ) def _inspector() -> sa.Inspector: return sa.inspect(op.get_bind()) def _has_table(table_name: str) -> bool: return _inspector().has_table(table_name) def _has_column(table_name: str, column_name: str) -> bool: if not _has_table(table_name): return False return column_name in { str(item["name"]) for item in _inspector().get_columns(table_name) } def _constraint_exists(table_name: str, constraint_name: str) -> bool: inspector = _inspector() names = { str(item.get("name") or "") for item in ( *inspector.get_unique_constraints(table_name), *inspector.get_foreign_keys(table_name), *inspector.get_check_constraints(table_name), ) } return constraint_name in names def _index_exists(table_name: str, index_name: str) -> bool: return index_name in { str(item.get("name") or "") for item in _inspector().get_indexes(table_name) } def _drop_unique_for_columns(table_name: str, columns: tuple[str, ...]) -> None: if not _has_table(table_name): return for item in _inspector().get_unique_constraints(table_name): if tuple(item.get("column_names") or ()) != columns: continue name = str(item.get("name") or "") if name: op.drop_constraint(name, table_name, type_="unique") def _drop_foreign_keys_for_columns( table_name: str, columns: tuple[str, ...], ) -> None: if not _has_table(table_name): return for item in _inspector().get_foreign_keys(table_name): if tuple(item.get("constrained_columns") or ()) != columns: continue name = str(item.get("name") or "") if name: op.drop_constraint(name, table_name, type_="foreignkey") def _ensure_unique( table_name: str, name: str, columns: tuple[str, ...], ) -> None: if not _constraint_exists(table_name, name): op.create_unique_constraint(name, table_name, list(columns)) def _ensure_index( table_name: str, name: str, columns: tuple[str, ...], ) -> None: if not _index_exists(table_name, name): op.create_index(name, table_name, list(columns)) def _ensure_tenant_column(table_name: str) -> None: if not _has_table(table_name): return if not _has_column(table_name, "tenant_id"): op.add_column( table_name, sa.Column("tenant_id", sa.String(length=64), nullable=True), ) if table_name == "expense_claims" and _has_table("expense_case_links"): op.execute( sa.text( "UPDATE expense_claims AS claim SET tenant_id = COALESCE((" "SELECT link.tenant_id FROM expense_case_links AS link " "WHERE link.resource_type = 'expense_claim' " "AND link.resource_id = claim.id ORDER BY link.created_at ASC LIMIT 1" "), :default_tenant) WHERE claim.tenant_id IS NULL" ).bindparams(default_tenant=_DEFAULT_TENANT) ) else: op.execute( sa.text( f"UPDATE {table_name} SET tenant_id = :default_tenant " "WHERE tenant_id IS NULL" ).bindparams(default_tenant=_DEFAULT_TENANT) ) op.alter_column( table_name, "tenant_id", existing_type=sa.String(length=64), nullable=False, server_default=None, ) def _create_tenant_registry() -> None: if not _has_table("tenants"): op.create_table( "tenants", sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("tenant_code", sa.String(length=64), nullable=False), sa.Column("name", sa.String(length=160), nullable=False), sa.Column("status", sa.String(length=20), nullable=False), sa.Column( "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, ), sa.Column( "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, ), sa.CheckConstraint( "status IN ('active', 'suspended', 'disabled')", name="ck_tenants_status", ), sa.CheckConstraint( "length(trim(tenant_id)) > 0 AND length(trim(tenant_code)) > 0 " "AND length(trim(name)) > 0", name="ck_tenants_identity", ), sa.PrimaryKeyConstraint("tenant_id"), sa.UniqueConstraint("tenant_code", name="uq_tenants_tenant_code"), ) op.create_index("ix_tenants_status", "tenants", ["status"]) op.execute( sa.text( "INSERT INTO tenants (tenant_id, tenant_code, name, status) VALUES " "('default', 'default', '默认企业', 'active'), " "('platform', 'platform', '平台管理域', 'active') " "ON CONFLICT DO NOTHING" ) ) for table_name in _TENANT_SOURCE_TABLES: if not _has_column(table_name, "tenant_id"): continue op.execute( sa.text( f"INSERT INTO tenants (tenant_id, tenant_code, name, status) " f"SELECT DISTINCT trim(tenant_id), trim(tenant_id), trim(tenant_id), " f"'active' FROM {table_name} " "WHERE tenant_id IS NOT NULL AND length(trim(tenant_id)) > 0 " "ON CONFLICT DO NOTHING" ) ) def _tenantize_organizations() -> None: table = "organization_units" if not _has_table(table): return _ensure_tenant_column(table) _drop_unique_for_columns(table, ("unit_code",)) _drop_foreign_keys_for_columns(table, ("parent_id",)) _ensure_unique(table, "uq_organization_units_tenant_id", ("tenant_id", "id")) _ensure_unique( table, "uq_organization_units_tenant_code", ("tenant_id", "unit_code"), ) if not _constraint_exists(table, "fk_organization_units_tenant"): op.create_foreign_key( "fk_organization_units_tenant", table, "tenants", ["tenant_id"], ["tenant_id"], ondelete="RESTRICT", ) if not _constraint_exists(table, "fk_organization_units_tenant_parent"): op.create_foreign_key( "fk_organization_units_tenant_parent", table, table, ["tenant_id", "parent_id"], ["tenant_id", "id"], ondelete="RESTRICT", ) _ensure_index( table, "ix_organization_units_tenant_name", ("tenant_id", "name"), ) def _tenantize_employees() -> None: table = "employees" if not _has_table(table): return _ensure_tenant_column(table) _drop_unique_for_columns(table, ("employee_no",)) _drop_unique_for_columns(table, ("email",)) _drop_foreign_keys_for_columns(table, ("organization_unit_id",)) _drop_foreign_keys_for_columns(table, ("manager_id",)) _ensure_unique(table, "uq_employees_tenant_id", ("tenant_id", "id")) _ensure_unique( table, "uq_employees_tenant_employee_no", ("tenant_id", "employee_no"), ) _ensure_unique(table, "uq_employees_tenant_email", ("tenant_id", "email")) if not _constraint_exists(table, "fk_employees_tenant"): op.create_foreign_key( "fk_employees_tenant", table, "tenants", ["tenant_id"], ["tenant_id"], ondelete="RESTRICT", ) if _has_table("organization_units") and not _constraint_exists( table, "fk_employees_tenant_organization_unit", ): op.create_foreign_key( "fk_employees_tenant_organization_unit", table, "organization_units", ["tenant_id", "organization_unit_id"], ["tenant_id", "id"], ondelete="RESTRICT", ) if not _constraint_exists(table, "fk_employees_tenant_manager"): op.create_foreign_key( "fk_employees_tenant_manager", table, table, ["tenant_id", "manager_id"], ["tenant_id", "id"], ondelete="RESTRICT", ) _ensure_index(table, "ix_employees_tenant_status", ("tenant_id", "employment_status")) _ensure_index(table, "ix_employees_tenant_name", ("tenant_id", "name")) def _tenantize_financial_records() -> None: if _has_table("expense_claims"): table = "expense_claims" _ensure_tenant_column(table) _drop_unique_for_columns(table, ("claim_no",)) _drop_foreign_keys_for_columns(table, ("employee_id",)) _drop_foreign_keys_for_columns(table, ("department_id",)) _ensure_unique(table, "uq_expense_claims_tenant_id", ("tenant_id", "id")) _ensure_unique( table, "uq_expense_claims_tenant_claim_no", ("tenant_id", "claim_no"), ) if not _constraint_exists(table, "fk_expense_claims_tenant"): op.create_foreign_key( "fk_expense_claims_tenant", table, "tenants", ["tenant_id"], ["tenant_id"], ondelete="RESTRICT", ) if _has_table("employees") and not _constraint_exists( table, "fk_expense_claims_tenant_employee", ): op.create_foreign_key( "fk_expense_claims_tenant_employee", table, "employees", ["tenant_id", "employee_id"], ["tenant_id", "id"], ondelete="RESTRICT", ) if _has_table("organization_units") and not _constraint_exists( table, "fk_expense_claims_tenant_department", ): op.create_foreign_key( "fk_expense_claims_tenant_department", table, "organization_units", ["tenant_id", "department_id"], ["tenant_id", "id"], ondelete="RESTRICT", ) _ensure_index(table, "ix_expense_claims_tenant_status", ("tenant_id", "status")) _ensure_index( table, "ix_expense_claims_tenant_occurred", ("tenant_id", "occurred_at"), ) for table, number_column, constraint_name, dimension_column, index_name in ( ( "accounts_receivable", "receivable_no", "uq_accounts_receivable_tenant_no", "customer_id", "ix_accounts_receivable_tenant_customer", ), ( "accounts_payable", "payable_no", "uq_accounts_payable_tenant_no", "vendor_id", "ix_accounts_payable_tenant_vendor", ), ): if not _has_table(table): continue _ensure_tenant_column(table) _drop_unique_for_columns(table, (number_column,)) _ensure_unique(table, constraint_name, ("tenant_id", number_column)) tenant_fk = f"fk_{table}_tenant" if not _constraint_exists(table, tenant_fk): op.create_foreign_key( tenant_fk, table, "tenants", ["tenant_id"], ["tenant_id"], ondelete="RESTRICT", ) _ensure_index(table, index_name, ("tenant_id", dimension_column)) def _ensure_memberships() -> None: if not _has_table("employees"): return if not _has_table("tenant_memberships"): op.create_table( "tenant_memberships", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("employee_id", sa.String(length=36), nullable=False), sa.Column("status", sa.String(length=20), nullable=False), sa.Column("is_primary", sa.Boolean(), nullable=False), sa.Column( "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, ), sa.Column( "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, ), sa.CheckConstraint( "status IN ('active', 'inactive')", name="ck_tenant_memberships_status", ), sa.ForeignKeyConstraint( ["tenant_id"], ["tenants.tenant_id"], name="fk_tenant_memberships_tenant", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "employee_id"], ["employees.tenant_id", "employees.id"], name="fk_tenant_memberships_tenant_employee", ondelete="CASCADE", ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint( "tenant_id", "employee_id", name="uq_tenant_memberships_tenant_employee", ), ) op.create_index( "ix_tenant_memberships_employee_active", "tenant_memberships", ["employee_id", "status"], ) op.create_index( "ix_tenant_memberships_tenant_active", "tenant_memberships", ["tenant_id", "status"], ) op.execute( sa.text( "INSERT INTO tenant_memberships " "(id, tenant_id, employee_id, status, is_primary) " "SELECT gen_random_uuid()::text, employee.tenant_id, employee.id, " "CASE WHEN employee.employment_status = '停用' THEN 'inactive' " "ELSE 'active' END, true FROM employees AS employee " "ON CONFLICT (tenant_id, employee_id) DO NOTHING" ) ) def _bind_auth_sessions() -> None: if not _has_table("auth_sessions"): return if not _constraint_exists("auth_sessions", "fk_auth_sessions_tenant"): op.create_foreign_key( "fk_auth_sessions_tenant", "auth_sessions", "tenants", ["tenant_id"], ["tenant_id"], ondelete="RESTRICT", ) def upgrade() -> None: _require_postgresql() _create_tenant_registry() _tenantize_organizations() _tenantize_employees() _tenantize_financial_records() _ensure_memberships() _bind_auth_sessions() def _require_safe_downgrade() -> None: for table_name in ( "organization_units", "employees", "expense_claims", "accounts_receivable", "accounts_payable", ): if not _has_column(table_name, "tenant_id"): continue count = int( op.get_bind().scalar( sa.text( f"SELECT COUNT(*) FROM {table_name} " "WHERE tenant_id <> :default_tenant" ).bindparams(default_tenant=_DEFAULT_TENANT) ) or 0 ) if count: raise RuntimeError( "cannot downgrade tenant identity foundation: " f"{table_name} contains non-default tenant data ({count})" ) def _restore_legacy_financial_constraints() -> None: if _has_table("expense_claims"): table = "expense_claims" for name in ( "fk_expense_claims_tenant_department", "fk_expense_claims_tenant_employee", "fk_expense_claims_tenant", "uq_expense_claims_tenant_claim_no", "uq_expense_claims_tenant_id", ): if _constraint_exists(table, name): op.drop_constraint(name, table) for name in ( "ix_expense_claims_tenant_occurred", "ix_expense_claims_tenant_status", ): if _index_exists(table, name): op.drop_index(name, table_name=table) _ensure_unique(table, "uq_expense_claims_claim_no", ("claim_no",)) if _has_table("employees"): op.create_foreign_key( "fk_expense_claims_employee_id", table, "employees", ["employee_id"], ["id"], ) if _has_table("organization_units"): op.create_foreign_key( "fk_expense_claims_department_id", table, "organization_units", ["department_id"], ["id"], ) op.drop_column(table, "tenant_id") for table, constraint_name, number_column, index_name in ( ( "accounts_receivable", "uq_accounts_receivable_tenant_no", "receivable_no", "ix_accounts_receivable_tenant_customer", ), ( "accounts_payable", "uq_accounts_payable_tenant_no", "payable_no", "ix_accounts_payable_tenant_vendor", ), ): if not _has_column(table, "tenant_id"): continue tenant_fk = f"fk_{table}_tenant" if _constraint_exists(table, tenant_fk): op.drop_constraint(tenant_fk, table, type_="foreignkey") if _constraint_exists(table, constraint_name): op.drop_constraint(constraint_name, table, type_="unique") if _index_exists(table, index_name): op.drop_index(index_name, table_name=table) _ensure_unique(table, f"uq_{table}_{number_column}", (number_column,)) op.drop_column(table, "tenant_id") def _restore_legacy_employee_constraints() -> None: if _has_table("employees"): table = "employees" for name in ( "fk_employees_tenant_manager", "fk_employees_tenant_organization_unit", "fk_employees_tenant", "uq_employees_tenant_email", "uq_employees_tenant_employee_no", "uq_employees_tenant_id", ): if _constraint_exists(table, name): op.drop_constraint(name, table) for name in ("ix_employees_tenant_name", "ix_employees_tenant_status"): if _index_exists(table, name): op.drop_index(name, table_name=table) _ensure_unique(table, "uq_employees_employee_no", ("employee_no",)) _ensure_unique(table, "uq_employees_email", ("email",)) if _has_table("organization_units"): op.create_foreign_key( "fk_employees_organization_unit_id", table, "organization_units", ["organization_unit_id"], ["id"], ) op.create_foreign_key( "fk_employees_manager_id", table, table, ["manager_id"], ["id"], ) op.drop_column(table, "tenant_id") if _has_table("organization_units"): table = "organization_units" for name in ( "fk_organization_units_tenant_parent", "fk_organization_units_tenant", "uq_organization_units_tenant_code", "uq_organization_units_tenant_id", ): if _constraint_exists(table, name): op.drop_constraint(name, table) if _index_exists(table, "ix_organization_units_tenant_name"): op.drop_index("ix_organization_units_tenant_name", table_name=table) _ensure_unique(table, "uq_organization_units_unit_code", ("unit_code",)) op.create_foreign_key( "fk_organization_units_parent_id", table, table, ["parent_id"], ["id"], ) op.drop_column(table, "tenant_id") def downgrade() -> None: _require_postgresql() _require_safe_downgrade() if _has_table("auth_sessions") and _constraint_exists( "auth_sessions", "fk_auth_sessions_tenant", ): op.drop_constraint( "fk_auth_sessions_tenant", "auth_sessions", type_="foreignkey", ) if _has_table("tenant_memberships"): op.drop_table("tenant_memberships") _restore_legacy_financial_constraints() _restore_legacy_employee_constraints() if _has_table("tenants"): if _index_exists("tenants", "ix_tenants_status"): op.drop_index("ix_tenants_status", table_name="tenants") op.drop_table("tenants")