Decouple scopes from tenancy schema
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled

This commit is contained in:
2026-07-10 17:33:43 +02:00
parent 635d25c74c
commit 79af252e88
27 changed files with 529 additions and 75 deletions

View File

@@ -11,9 +11,9 @@ from govoplan_core.security.permissions import TENANT_SCOPES
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.settings import settings
from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account
from govoplan_access.backend.db.models import Role, SystemRoleAssignment, User, UserRoleAssignment
from govoplan_access.backend.db.models import Role, SystemRoleAssignment, Tenant, User, UserRoleAssignment
from govoplan_access.backend.security.api_keys import CreatedApiKey, create_api_key
from govoplan_tenancy.backend.db.models import Tenant
from govoplan_core.tenancy.scope import create_scope_tables
DEFAULT_SCOPES = sorted(TENANT_SCOPES)
@@ -38,6 +38,7 @@ def create_all_tables() -> None:
build_platform_registry(enabled_modules)
engine = get_database().engine
create_scope_tables(engine)
Base.metadata.create_all(bind=engine)

View File

@@ -31,6 +31,8 @@ REVISION_FILE_FOLDERS = "4e5f6a7b8c9d"
REVISION_NAMESPACE_PLATFORM_TABLES = "2e3f4a5b6c7d"
REVISION_CORE_CHANGE_SEQUENCE = "3f4a5b6c7d8e"
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
LEGACY_SCOPE_TABLE = "tenancy_tenants"
CORE_SCOPE_TABLE = "core_scopes"
_NAMESPACE_TABLE_RENAMES = (
("tenants", "tenancy_tenants"),
@@ -126,12 +128,13 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
"access_users": {"account_id", "settings", "mail_profile_policy"},
"access_groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
"access_roles": {"system_template_id", "system_required"},
"tenancy_tenants": {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"},
"campaigns": {"settings", "mail_profile_policy"},
"mail_server_profiles": {"scope_type", "scope_id"},
"core_system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
}
_SCOPE_TABLE_COLUMNS = {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"}
_CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
"file_folders": {"uq_file_folders_active_user_path", "uq_file_folders_active_group_path"},
"campaign_versions": {
@@ -254,6 +257,11 @@ def _has_indexes(inspector, table_name: str, required: set[str]) -> bool:
def _has_create_all_schema_through_hierarchical_settings(inspector, tables: set[str]) -> bool:
if not _CREATE_ALL_THROUGH_HIERARCHICAL_TABLES.issubset(tables):
return False
if not any(
table_name in tables and _has_columns(inspector, table_name, _SCOPE_TABLE_COLUMNS)
for table_name in (CORE_SCOPE_TABLE, LEGACY_SCOPE_TABLE)
):
return False
for table_name, required_columns in _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS.items():
if table_name not in tables or not _has_columns(inspector, table_name, required_columns):
return False
@@ -295,6 +303,53 @@ def _rename_table(connection, old_name: str, new_name: str) -> None:
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}"))
def _reconcile_scope_table_names(connection, tables: set[str]) -> bool:
if LEGACY_SCOPE_TABLE not in tables:
return False
changed = False
if CORE_SCOPE_TABLE in tables:
if _row_count(connection, CORE_SCOPE_TABLE) == 0:
_drop_table(connection, CORE_SCOPE_TABLE)
tables.remove(CORE_SCOPE_TABLE)
changed = True
elif _row_count(connection, LEGACY_SCOPE_TABLE) == 0:
_drop_table(connection, LEGACY_SCOPE_TABLE)
tables.remove(LEGACY_SCOPE_TABLE)
changed = True
else:
raise RuntimeError(f"Cannot reconcile non-empty {LEGACY_SCOPE_TABLE} over non-empty {CORE_SCOPE_TABLE}")
if LEGACY_SCOPE_TABLE in tables and CORE_SCOPE_TABLE not in tables:
_rename_table(connection, LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE)
tables.remove(LEGACY_SCOPE_TABLE)
tables.add(CORE_SCOPE_TABLE)
changed = True
return changed
def reconcile_scope_table_retirement_drift(database_url: str | None = None) -> bool:
"""Repair databases that are stamped after the scope-table rename.
The real Alembic migration performs the same rename. This helper covers
development databases that were manually stamped or partially created via
create_all(), where the version marker can be newer than the table name.
It runs after Alembic upgrades so older migrations can still use the
historical table name while they execute.
"""
url = database_url or settings.database_url
changed = False
engine = create_engine(url)
try:
with engine.begin() as connection:
tables = set(inspect(connection).get_table_names())
changed = _reconcile_scope_table_names(connection, tables)
finally:
engine.dispose()
return changed
def reconcile_namespace_table_drift(database_url: str | None = None) -> bool:
"""Repair dev databases stamped past the namespace-table migration.
@@ -466,6 +521,8 @@ def migrate_database(
),
"heads",
)
if reconcile_legacy_schema:
reconcile_scope_table_retirement_drift(url)
current = database_revision(url)
return MigrationResult(
previous_revision=previous,