diff --git a/document/development/2026-07-18/dev-logs/bugs/tenant-identity-global-unique-indexes.md b/document/development/2026-07-18/dev-logs/bugs/tenant-identity-global-unique-indexes.md new file mode 100644 index 0000000..cb89bee --- /dev/null +++ b/document/development/2026-07-18/dev-logs/bugs/tenant-identity-global-unique-indexes.md @@ -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 锁,须在用户确认维护窗口后应用。 diff --git a/server/alembic/versions/20260718_0029_tenant_identity_lookup_indexes.py b/server/alembic/versions/20260718_0029_tenant_identity_lookup_indexes.py new file mode 100644 index 0000000..168a85b --- /dev/null +++ b/server/alembic/versions/20260718_0029_tenant_identity_lookup_indexes.py @@ -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) diff --git a/server/src/app/db/migration_preflight.py b/server/src/app/db/migration_preflight.py index cd4769a..95d5202 100644 --- a/server/src/app/db/migration_preflight.py +++ b/server/src/app/db/migration_preflight.py @@ -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() ) diff --git a/server/tests/test_alembic_migrations.py b/server/tests/test_alembic_migrations.py index 36d38b2..bcec5cb 100644 --- a/server/tests/test_alembic_migrations.py +++ b/server/tests/test_alembic_migrations.py @@ -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() diff --git a/server/tests/test_migration_preflight.py b/server/tests/test_migration_preflight.py index da279aa..863157e 100644 --- a/server/tests/test_migration_preflight.py +++ b/server/tests/test_migration_preflight.py @@ -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"}, ), ],