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

@@ -5,7 +5,7 @@ from typing import Any
from sqlalchemy.orm import Session
from govoplan_access.auth import ApiPrincipal
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.change_sequence import record_change
from govoplan_core.core.events import current_event_trace, normalize_trace_id
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
"""Core auth dependency facade.
Routers depend on this module instead of a concrete access-provider package.
The current implementation delegates to the access module; replacing this
facade is the remaining step toward a fully provider-neutral auth kernel.
"""
from govoplan_access.auth import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
__all__ = [
"ApiPrincipal",
"get_api_principal",
"has_scope",
"require_any_scope",
"require_scope",
]

View File

@@ -25,6 +25,9 @@ CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
CAPABILITY_AUDIT_SINK = "audit.sink"
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
ACCESS_CAPABILITY_NAMES = frozenset(
{
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
@@ -38,6 +41,15 @@ ACCESS_CAPABILITY_NAMES = frozenset(
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_TENANCY_TENANT_RESOLVER,
CAPABILITY_AUDIT_SINK,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
}
)
AUTH_CAPABILITY_NAMES = frozenset(
{
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
}
)

View File

@@ -17,7 +17,7 @@ from govoplan_core.server.registry import parse_enabled_modules
MODULE_SETTINGS_KEY = "module_management"
INSTALL_PLAN_KEY = "install_plan"
REQUIRED_PLATFORM_MODULES = ("tenancy", "access")
REQUIRED_PLATFORM_MODULES = ("access",)
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
INSTALL_PLAN_ACTIONS = ("install", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")

View File

@@ -215,6 +215,8 @@ class ModuleManifest:
version: str
dependencies: tuple[str, ...] = ()
optional_dependencies: tuple[str, ...] = ()
required_capabilities: tuple[str, ...] = ()
optional_capabilities: tuple[str, ...] = ()
permissions: tuple[PermissionDefinition, ...] = ()
role_templates: tuple[RoleTemplate, ...] = ()
route_factory: RouteFactory | None = None

View File

@@ -19,6 +19,7 @@ class OrganizationUnitRef:
tenant_id: str
slug: str
name: str
unit_type_id: str | None = None
parent_id: str | None = None
description: str | None = None
status: OrganizationStatus = "active"
@@ -31,6 +32,7 @@ class OrganizationFunctionRef:
organization_unit_id: str
slug: str
name: str
function_type_id: str | None = None
description: str | None = None
delegable: bool = False
act_in_place_allowed: bool = False
@@ -41,10 +43,10 @@ class OrganizationFunctionRef:
class OrganizationFunctionAssignmentRef:
id: str
tenant_id: str
account_id: str
identity_id: str
function_id: str
organization_unit_id: str
identity_id: str | None = None
account_id: str | None = None
applies_to_subunits: bool = False
source: FunctionAssignmentSource = "direct"
delegated_from_assignment_id: str | None = None
@@ -76,6 +78,14 @@ class OrganizationDirectory(Protocol):
def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
...
def function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
def function_assignments_for_account(
self,
account_id: str,

View File

@@ -152,12 +152,16 @@ class PlatformRegistry:
def validate(self) -> RegistrySnapshot:
ordered = tuple(self._topologically_sorted())
available_capabilities = set(self._capability_factories)
seen_permissions: dict[str, PermissionDefinition] = {}
for manifest in ordered:
_validate_manifest_shape(manifest)
for dependency in manifest.dependencies:
if dependency not in self._manifests:
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
for capability in manifest.required_capabilities:
if capability not in available_capabilities:
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
for dependency in manifest.optional_dependencies:
if dependency == manifest.id:
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
@@ -230,10 +234,16 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
if overlap:
joined = ", ".join(sorted(overlap))
raise RegistryError(f"Module {manifest.id!r} lists dependencies as both required and optional: {joined}")
capability_overlap = set(manifest.required_capabilities) & set(manifest.optional_capabilities)
if capability_overlap:
joined = ", ".join(sorted(capability_overlap))
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
if manifest.migration_spec is not None:
if manifest.migration_spec.module_id != manifest.id:
@@ -275,6 +285,14 @@ def _validate_dependency_list(module_id: str, field_name: str, dependencies: tup
raise RegistryError(f"Module {module_id!r} has invalid dependency id {dependency!r}")
def _validate_capability_list(module_id: str, field_name: str, capabilities: tuple[str, ...]) -> None:
if len(capabilities) != len(set(capabilities)):
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
for capability in capabilities:
if not capability.strip() or any(part == "" for part in capability.split(".")):
raise RegistryError(f"Module {module_id!r} has invalid capability name {capability!r}")
def _validate_nav_item(module_id: str, item: NavItem) -> None:
if not item.path.startswith("/"):
raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}")

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,

View File

@@ -23,7 +23,7 @@ from govoplan_access.backend.db.models import Group, User
from govoplan_audit.backend.db.models import AuditLog
from govoplan_core.admin.models import SystemSettings
from govoplan_core.settings import settings
from govoplan_tenancy.backend.db.models import Tenant
from govoplan_core.tenancy.scope import Tenant
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
RETENTION_DAY_KEYS = (

View File

@@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from sqlalchemy.engine import make_url
from govoplan_access.auth import ApiPrincipal, require_scope
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables
from govoplan_core.db.session import get_database

View File

@@ -1,15 +1,19 @@
from __future__ import annotations
from collections.abc import Callable, Iterable, Sequence
import importlib
from govoplan_access.backend.manifest import get_manifest as get_access_manifest
from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
ManifestFactory = Callable[[], ModuleManifest]
_BUILTIN_MANIFESTS = {
"access": "govoplan_access.backend.manifest:get_manifest",
"tenancy": "govoplan_tenancy.backend.manifest:get_manifest",
}
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
if isinstance(value, str):
@@ -32,8 +36,20 @@ def available_module_manifests(
ignore_load_errors=ignore_load_errors,
)
}
manifests.setdefault("tenancy", get_tenancy_manifest())
manifests.setdefault("access", get_access_manifest())
loading_all = enabled_modules is None
builtin_ids = set(_BUILTIN_MANIFESTS) if loading_all else set(parse_enabled_modules(enabled_modules))
for module_id in sorted(builtin_ids & set(_BUILTIN_MANIFESTS)):
if module_id in manifests:
continue
try:
manifests[module_id] = _load_builtin_manifest(module_id)
except ModuleNotFoundError as exc:
module_root = _BUILTIN_MANIFESTS[module_id].split(":", 1)[0].split(".", 1)[0]
if exc.name != module_root or (not ignore_load_errors and not loading_all):
raise
except Exception:
if not ignore_load_errors:
raise
for factory in manifest_factories:
manifest = factory()
manifests[manifest.id] = manifest
@@ -42,15 +58,8 @@ def available_module_manifests(
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry:
requested = parse_enabled_modules(enabled_modules)
if "tenancy" not in requested:
requested.insert(0, "tenancy")
if "access" not in requested:
tenancy_index = requested.index("tenancy")
requested.insert(tenancy_index + 1, "access")
elif requested.index("access") < requested.index("tenancy"):
requested.remove("access")
tenancy_index = requested.index("tenancy")
requested.insert(tenancy_index + 1, "access")
requested.insert(0, "access")
available = available_module_manifests(manifest_factories, enabled_modules=requested)
registry = PlatformRegistry()
@@ -61,3 +70,14 @@ def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_fa
registry.register(manifest)
registry.validate()
return registry
def _load_builtin_manifest(module_id: str) -> ModuleManifest:
target = _BUILTIN_MANIFESTS[module_id]
module_name, function_name = target.split(":", 1)
module = importlib.import_module(module_name)
factory = getattr(module, function_name)
manifest = factory()
if not isinstance(manifest, ModuleManifest):
raise RegistryError(f"Built-in module manifest factory returned invalid value: {module_id}")
return manifest

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, JSON, MetaData, String, Text
from sqlalchemy.types import DateTime
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Mapped, mapped_column, registry
from govoplan_core.db.base import NAMING_CONVENTION, utcnow
scope_registry = registry(metadata=MetaData(naming_convention=NAMING_CONVENTION))
def new_uuid() -> str:
return str(uuid.uuid4())
@scope_registry.mapped
class Tenant:
"""Core-owned scope row used when the tenancy module is not installed.
The table was historically named ``tenancy_tenants``. It is now owned by
core as ``core_scopes`` so access, auth and policy code can use a stable
scope identifier without importing or requiring the tenancy module.
"""
__tablename__ = "core_scopes"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
allow_api_keys: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
def create_scope_tables(engine: Engine) -> None:
scope_registry.metadata.create_all(bind=engine, checkfirst=True)
__all__ = ["Tenant", "create_scope_tables", "new_uuid", "scope_registry"]

View File

@@ -8,7 +8,7 @@ from govoplan_core.admin.common import AdminValidationError
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.core.runtime import get_registry
from govoplan_access.backend.db.models import ApiKey, Group, User
from govoplan_tenancy.backend.db.models import Tenant
from govoplan_core.tenancy.scope import Tenant
@dataclass(frozen=True)