fix(migrations): repair tenant identity lookup indexes

This commit is contained in:
caoxiaozhu
2026-07-20 10:28:31 +08:00
parent 07241b4648
commit 372e35d62a
5 changed files with 517 additions and 5 deletions

View File

@@ -0,0 +1,14 @@
# 员工与组织残留全局唯一索引阻断多租户复用
日期2026-07-18
文档路径document/development/2026-07-18/dev-logs/bugs/tenant-identity-global-unique-indexes.md
## 修复记录
- 15:54记录数据库迁移修复员工编号、员工邮箱和组织编码仍保留单列全局唯一索引第二个企业无法使用相同企业内部标识。
- Git 提交检查:`git fetch --all --prune` 成功upstream `origin/main` 无新提交;本地 ahead 19 条,最新为 `07241b46 fix(docker): manage local postgres in default compose``787bc3a4 feat(platform): close AI expense value loop``242d68c3 feat(approval): add task workflow and waiver decisions`,另有 16 条。
- 原因历史迁移已增加正确的租户复合唯一约束但三个旧单列唯一索引未被替换PostgreSQL 仍按全局范围拒绝重复值。
- 修改:新增 Alembic `20260718_0029`,在 PostgreSQL 中校验复合唯一约束和现有索引形态后,将三个同名单列唯一索引替换为普通查询索引;降级前检查跨租户重复并在无法安全恢复全局唯一时拒绝降级。
- 操作:更新迁移前置检查和迁移测试;只读核验当前数据库仍位于 `20260717_0028`,未执行 live DDL、数据写入或主容器重启。
- 验证:容器内迁移测试 `167 passed, 1 skipped`Ruff 通过Alembic HEAD 为 `20260718_0029`;跳过项仅因未配置专用一次性 PostgreSQL 迁移测试库,未使用当前业务库替代。
- 影响:代码层已具备租户内唯一、跨租户可复用的索引迁移;正式生效会短暂获取索引 DDL 锁,须在用户确认维护窗口后应用。

View File

@@ -0,0 +1,249 @@
"""repair tenant identity lookup indexes
Revision ID: 20260718_0029
Revises: 20260717_0028
Create Date: 2026-07-18
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260718_0029"
down_revision: str | None = "20260717_0028"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
class _TargetIndex:
__slots__ = (
"table_name",
"column_name",
"index_name",
"tenant_unique_constraint",
)
def __init__(
self,
*,
table_name: str,
column_name: str,
index_name: str,
tenant_unique_constraint: str,
) -> None:
self.table_name = table_name
self.column_name = column_name
self.index_name = index_name
self.tenant_unique_constraint = tenant_unique_constraint
_TARGET_INDEXES = (
_TargetIndex(
table_name="organization_units",
column_name="unit_code",
index_name="ix_organization_units_unit_code",
tenant_unique_constraint="uq_organization_units_tenant_code",
),
_TargetIndex(
table_name="employees",
column_name="employee_no",
index_name="ix_employees_employee_no",
tenant_unique_constraint="uq_employees_tenant_employee_no",
),
_TargetIndex(
table_name="employees",
column_name="email",
index_name="ix_employees_email",
tenant_unique_constraint="uq_employees_tenant_email",
),
)
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260718_0029 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional index DDL"
)
def _inspector() -> sa.Inspector:
return sa.inspect(op.get_bind())
def _assert_tenant_unique_constraint(target: _TargetIndex) -> None:
constraints = {
str(item.get("name") or ""): tuple(item.get("column_names") or ())
for item in _inspector().get_unique_constraints(target.table_name)
}
expected_columns = ("tenant_id", target.column_name)
actual_columns = constraints.get(target.tenant_unique_constraint)
if actual_columns != expected_columns:
raise RuntimeError(
"cannot repair tenant identity lookup indexes: "
f"{target.tenant_unique_constraint} must cover {expected_columns}, "
f"found {actual_columns!r}"
)
def _validated_present_targets() -> tuple[_TargetIndex, ...]:
inspector = _inspector()
present_targets: list[_TargetIndex] = []
for target in _TARGET_INDEXES:
if not inspector.has_table(target.table_name):
continue
columns = {
str(item.get("name") or ""): item
for item in inspector.get_columns(target.table_name)
}
required_columns = {"tenant_id", target.column_name}
missing_columns = required_columns - columns.keys()
if missing_columns:
raise RuntimeError(
"cannot repair tenant identity lookup indexes: "
f"{target.table_name} is missing columns "
f"{', '.join(sorted(missing_columns))}"
)
if bool(columns["tenant_id"].get("nullable", True)):
raise RuntimeError(
"cannot repair tenant identity lookup indexes: "
f"{target.table_name}.tenant_id must be non-nullable"
)
_assert_tenant_unique_constraint(target)
present_targets.append(target)
return tuple(present_targets)
def _index_uniqueness(target: _TargetIndex) -> bool | None:
indexes = [
item
for item in _inspector().get_indexes(target.table_name)
if str(item.get("name") or "") == target.index_name
]
if not indexes:
return None
if len(indexes) != 1:
raise RuntimeError(
"cannot repair tenant identity lookup indexes: "
f"found multiple indexes named {target.index_name}"
)
index = indexes[0]
actual_columns = tuple(index.get("column_names") or ())
if actual_columns != (target.column_name,):
raise RuntimeError(
"cannot repair tenant identity lookup indexes: "
f"{target.index_name} must cover only {target.column_name}, "
f"found {actual_columns!r}"
)
if index.get("duplicates_constraint"):
raise RuntimeError(
"cannot repair tenant identity lookup indexes: "
f"{target.index_name} backs a constraint and is not safe to replace"
)
dialect_options = dict(index.get("dialect_options") or {})
predicate = dialect_options.get("postgresql_where")
index_method = dialect_options.get("postgresql_using")
operator_classes = dialect_options.get("postgresql_ops") or {}
included_columns = (
index.get("include_columns")
or dialect_options.get("postgresql_include")
or ()
)
column_sorting = index.get("column_sorting") or {}
if (
predicate is not None
or included_columns
or index_method not in (None, "btree")
or operator_classes
or column_sorting
):
raise RuntimeError(
"cannot repair tenant identity lookup indexes: "
f"{target.index_name} is not a plain single-column btree index"
)
return bool(index.get("unique", False))
def _replace_index(target: _TargetIndex, *, unique: bool) -> None:
current_uniqueness = _index_uniqueness(target)
if current_uniqueness == unique:
return
if current_uniqueness is not None:
op.drop_index(target.index_name, table_name=target.table_name)
op.create_index(
target.index_name,
target.table_name,
[target.column_name],
unique=unique,
)
if _index_uniqueness(target) != unique:
raise RuntimeError(
"tenant identity lookup index replacement did not reach the expected state: "
f"{target.index_name} unique={unique}"
)
def _cross_tenant_duplicate_count(target: _TargetIndex) -> int:
table = sa.table(
target.table_name,
sa.column("tenant_id"),
sa.column(target.column_name),
)
value_column = table.c[target.column_name]
duplicate_values = (
sa.select(value_column)
.where(value_column.is_not(None))
.group_by(value_column)
.having(sa.func.count(sa.distinct(table.c.tenant_id)) > 1)
.subquery()
)
return int(
op.get_bind().scalar(
sa.select(sa.func.count()).select_from(duplicate_values)
)
or 0
)
def upgrade() -> None:
_require_postgresql()
targets = _validated_present_targets()
# 先校验所有旧索引形态,防止修到一半才发现同名索引承载了其他用途。
for target in targets:
_index_uniqueness(target)
for target in targets:
_replace_index(target, unique=False)
for target in targets:
_assert_tenant_unique_constraint(target)
def downgrade() -> None:
_require_postgresql()
targets = _validated_present_targets()
# downgrade 会恢复旧的全局唯一索引;必须在任何 DDL 前一次性排除跨租户重复。
for target in targets:
_index_uniqueness(target)
violations = {
f"{target.table_name}.{target.column_name}": duplicate_count
for target in targets
if (duplicate_count := _cross_tenant_duplicate_count(target)) > 0
}
if violations:
details = ", ".join(
f"{name}={count}" for name, count in sorted(violations.items())
)
raise RuntimeError(
"cannot downgrade tenant identity lookup indexes: "
f"cross-tenant duplicate values exist ({details})"
)
for target in targets:
_replace_index(target, unique=True)
for target in targets:
_assert_tenant_unique_constraint(target)

View File

@@ -320,7 +320,10 @@ MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0027"]
| frozenset({"tenant_finance_report_configs", "tenant_finance_report_runs"})
)
if MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"] != MIGRATION_OWNED_TABLES:
MIGRATION_OWNED_TABLES_BY_REVISION["20260718_0029"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"]
)
if MIGRATION_OWNED_TABLES_BY_REVISION["20260718_0029"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
@@ -410,6 +413,7 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
"20260717_0026",
"20260717_0027",
"20260717_0028",
"20260718_0029",
}
else frozenset()
)

View File

@@ -46,9 +46,29 @@ from app.models.risk_observation import RiskObservation
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
HEAD_REVISION = "20260717_0028"
HEAD_REVISION = "20260718_0029"
SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
TENANT_IDENTITY_LOOKUP_INDEXES = (
(
"organization_units",
"unit_code",
"ix_organization_units_unit_code",
"uq_organization_units_tenant_code",
),
(
"employees",
"employee_no",
"ix_employees_employee_no",
"uq_employees_tenant_employee_no",
),
(
"employees",
"email",
"ix_employees_email",
"uq_employees_tenant_email",
),
)
class _UnsupportedDialectOperationGuard:
@@ -185,6 +205,30 @@ def _assert_indexes(
assert indexes.get(index_name) == expected_columns
def _assert_tenant_identity_lookup_indexes(
engine: Engine,
*,
unique: bool,
) -> None:
inspector = inspect(engine)
for table_name, column_name, index_name, constraint_name in (
TENANT_IDENTITY_LOOKUP_INDEXES
):
indexes = {
str(item["name"]): item
for item in inspector.get_indexes(table_name, schema="public")
}
index = indexes[index_name]
assert tuple(index["column_names"]) == (column_name,)
assert bool(index.get("unique", False)) is unique
_assert_unique_constraint(
engine,
table_name,
constraint_name,
("tenant_id", column_name),
)
def _assert_postgresql_index_predicate(
engine: Engine,
table_name: str,
@@ -1652,6 +1696,8 @@ def _assert_base_schema(engine: Engine) -> None:
("20260717_0027_knowledge_tenant_security.py", "downgrade"),
("20260717_0028_hermes_ontology_tenant_security.py", "upgrade"),
("20260717_0028_hermes_ontology_tenant_security.py", "downgrade"),
("20260718_0029_tenant_identity_lookup_indexes.py", "upgrade"),
("20260718_0029_tenant_identity_lookup_indexes.py", "downgrade"),
],
)
def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
@@ -1668,6 +1714,116 @@ def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
assert operation_guard.mutation_calls == []
@pytest.mark.parametrize(
("direction", "initial_unique", "expected_unique"),
[
("upgrade", True, False),
("downgrade", False, True),
],
)
def test_tenant_identity_lookup_index_migration_replaces_legacy_index_uniqueness(
monkeypatch: pytest.MonkeyPatch,
direction: str,
initial_unique: bool,
expected_unique: bool,
) -> None:
migration = _load_migration_module(
"20260718_0029_tenant_identity_lookup_indexes.py"
)
targets = migration._TARGET_INDEXES
index_states = {
target.index_name: initial_unique
for target in targets
}
mutation_calls: list[tuple[str, str, str, tuple[str, ...] | None, bool | None]] = []
class _IndexOperation:
bind = SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
def get_bind(self) -> SimpleNamespace:
return self.bind
def drop_index(self, index_name: str, *, table_name: str) -> None:
mutation_calls.append(("drop", table_name, index_name, None, None))
index_states.pop(index_name)
def create_index(
self,
index_name: str,
table_name: str,
columns: list[str],
*,
unique: bool,
) -> None:
mutation_calls.append(
("create", table_name, index_name, tuple(columns), unique)
)
index_states[index_name] = unique
migration.op = _IndexOperation()
monkeypatch.setattr(migration, "_validated_present_targets", lambda: targets)
monkeypatch.setattr(
migration,
"_index_uniqueness",
lambda target: index_states.get(target.index_name),
)
monkeypatch.setattr(
migration,
"_assert_tenant_unique_constraint",
lambda _target: None,
)
monkeypatch.setattr(
migration,
"_cross_tenant_duplicate_count",
lambda _target: 0,
)
getattr(migration, direction)()
assert index_states == {
target.index_name: expected_unique
for target in targets
}
assert len(mutation_calls) == len(targets) * 2
assert all(
call[4] is expected_unique
for call in mutation_calls
if call[0] == "create"
)
def test_tenant_identity_lookup_index_migration_refuses_lossy_downgrade(
monkeypatch: pytest.MonkeyPatch,
) -> None:
migration = _load_migration_module(
"20260718_0029_tenant_identity_lookup_indexes.py"
)
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
migration.op = operation_guard
monkeypatch.setattr(
migration,
"_validated_present_targets",
lambda: migration._TARGET_INDEXES,
)
monkeypatch.setattr(migration, "_index_uniqueness", lambda _target: False)
monkeypatch.setattr(
migration,
"_cross_tenant_duplicate_count",
lambda _target: 1,
)
with pytest.raises(RuntimeError, match="cross-tenant duplicate values exist") as error:
migration.downgrade()
for expected_dimension in (
"organization_units.unit_code",
"employees.employee_no",
"employees.email",
):
assert expected_dimension in str(error.value)
assert operation_guard.mutation_calls == []
def test_risk_disposition_snapshot_migration_refuses_lossy_downgrade() -> None:
migration = _load_migration_module("20260716_0012_risk_disposition_response_snapshot.py")
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
@@ -1933,6 +2089,76 @@ def test_risk_waiver_model_declares_decision_metadata_constraints() -> None:
assert "reject_waiver" in str(event_action_constraint.sqltext)
def _recreate_legacy_global_tenant_identity_indexes(engine: Engine) -> None:
with engine.begin() as connection:
for table_name, column_name, index_name, _constraint_name in (
TENANT_IDENTITY_LOOKUP_INDEXES
):
connection.execute(text(f'DROP INDEX IF EXISTS "{index_name}"'))
connection.execute(
text(
f'CREATE UNIQUE INDEX "{index_name}" '
f'ON "{table_name}" ("{column_name}")'
)
)
def _create_cross_tenant_identity_duplicate_probe(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO organization_units (
id, tenant_id, unit_code, name, unit_type
) VALUES
(
'tenant-index-org-default', 'default',
'TENANT-INDEX-DUP', '租户索引默认组织', 'department'
),
(
'tenant-index-org-platform', 'platform',
'TENANT-INDEX-DUP', '租户索引平台组织', 'department'
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO employees (
id, tenant_id, employee_no, name, email, position, grade,
employment_status, sync_state, compliance_score, spotlight,
organization_unit_id
) VALUES
(
'tenant-index-employee-default', 'default',
'TENANT-INDEX-DUP', '租户索引默认员工',
'tenant-index-dup@example.com', '员工', 'P3',
'在职', '已同步', 100, false,
'tenant-index-org-default'
),
(
'tenant-index-employee-platform', 'platform',
'TENANT-INDEX-DUP', '租户索引平台员工',
'tenant-index-dup@example.com', '员工', 'P3',
'在职', '已同步', 100, false,
'tenant-index-org-platform'
)
"""
)
)
def _delete_cross_tenant_identity_duplicate_probe(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
text("DELETE FROM employees WHERE id LIKE 'tenant-index-employee-%'")
)
connection.execute(
text("DELETE FROM organization_units WHERE id LIKE 'tenant-index-org-%'")
)
@pytest.mark.parametrize(
"database_url",
[
@@ -1973,10 +2199,29 @@ def test_alembic_migration_cycle_on_disposable_postgres(
_upgrade_revision(migration_database_url, "20260716_0019")
_create_legacy_connector_payload_probe(engine)
_upgrade_revision(migration_database_url, "20260717_0028")
create_legacy_schema(engine)
_recreate_legacy_global_tenant_identity_indexes(engine)
_upgrade_head(migration_database_url)
_assert_and_delete_legacy_connector_payload_probe(engine)
_assert_head_schema(engine)
_assert_tenant_identity_lookup_indexes(engine, unique=False)
assert validate_migration_state(engine).revision == HEAD_REVISION
_create_cross_tenant_identity_duplicate_probe(engine)
with pytest.raises(
RuntimeError,
match="cross-tenant duplicate values exist",
) as tenant_index_error:
_downgrade_revision(migration_database_url, "20260717_0028")
for expected_dimension in (
"organization_units.unit_code",
"employees.employee_no",
"employees.email",
):
assert expected_dimension in str(tenant_index_error.value)
assert validate_migration_state(engine).revision == HEAD_REVISION
_assert_tenant_identity_lookup_indexes(engine, unique=False)
_delete_cross_tenant_identity_duplicate_probe(engine)
_assert_savings_runtime_invariants(engine)
_assert_commercial_runtime_invariants(engine)
_assert_financial_connector_runtime_invariants(engine)
@@ -2016,7 +2261,6 @@ def test_alembic_migration_cycle_on_disposable_postgres(
"WHERE id = 'downgrade-refusal-baseline'"
)
)
create_legacy_schema(engine)
assert "expense_claims" in _table_names(engine)
_upgrade_head(migration_database_url)
@@ -2059,6 +2303,7 @@ def test_alembic_migration_cycle_on_disposable_postgres(
_upgrade_head(migration_database_url)
_assert_head_schema(engine)
_assert_tenant_identity_lookup_indexes(engine, unique=False)
_assert_legacy_sentinel(engine)
finally:
engine.dispose()

View File

@@ -214,8 +214,8 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
- {"knowledge_onlyoffice_sessions"},
),
(
"20260717_0028",
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"]
"20260718_0029",
MIGRATION_OWNED_TABLES_BY_REVISION["20260718_0029"]
- {"tenant_finance_report_runs"},
),
],