Decouple scopes from tenancy schema
This commit is contained in:
@@ -13,6 +13,7 @@ from govoplan_core.db.base import Base
|
||||
from govoplan_core.server.default_config import get_server_config
|
||||
from govoplan_core.server.registry import build_platform_registry
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_core.tenancy.scope import scope_registry
|
||||
|
||||
config = context.config
|
||||
database_url = config.attributes.get("database_url") or settings.database_url
|
||||
@@ -30,7 +31,7 @@ def _target_metadata():
|
||||
enabled_modules,
|
||||
manifest_factories=manifest_factories,
|
||||
)
|
||||
plan = migration_metadata_plan(registry, extra_metadata=(Base.metadata,))
|
||||
plan = migration_metadata_plan(registry, extra_metadata=(scope_registry.metadata, Base.metadata))
|
||||
return tuple(dict.fromkeys(plan.metadata))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""rename tenancy scope table to core scopes
|
||||
|
||||
Revision ID: 4f2a9c8e7b6d
|
||||
Revises: 3f4a5b6c7d8e
|
||||
Create Date: 2026-07-10 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "4f2a9c8e7b6d"
|
||||
down_revision = "3f4a5b6c7d8e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
LEGACY_SCOPE_TABLE = "tenancy_tenants"
|
||||
CORE_SCOPE_TABLE = "core_scopes"
|
||||
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
|
||||
CORE_SLUG_INDEX = "ix_core_scopes_slug"
|
||||
|
||||
|
||||
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
||||
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
|
||||
|
||||
|
||||
def _scope_tables(bind: sa.Connection) -> set[str]:
|
||||
return set(sa.inspect(bind).get_table_names())
|
||||
|
||||
|
||||
def _drop_table_if_empty(bind: sa.Connection, table_name: str) -> bool:
|
||||
if _row_count(bind, table_name) != 0:
|
||||
return False
|
||||
op.drop_table(table_name)
|
||||
return True
|
||||
|
||||
|
||||
def _ensure_slug_index(bind: sa.Connection, table_name: str, index_name: str, old_index_name: str) -> None:
|
||||
indexes = {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
if old_index_name in indexes:
|
||||
op.drop_index(old_index_name, table_name=table_name)
|
||||
indexes.remove(old_index_name)
|
||||
if index_name not in indexes:
|
||||
op.create_index(op.f(index_name), table_name, ["slug"], unique=True)
|
||||
|
||||
|
||||
def _rename_scope_table(old_name: str, new_name: str) -> None:
|
||||
bind = op.get_bind()
|
||||
tables = _scope_tables(bind)
|
||||
if old_name not in tables:
|
||||
if new_name in tables:
|
||||
_ensure_slug_index(bind, new_name, CORE_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else LEGACY_SLUG_INDEX, LEGACY_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else CORE_SLUG_INDEX)
|
||||
return
|
||||
|
||||
if new_name in tables:
|
||||
if _drop_table_if_empty(bind, new_name):
|
||||
tables.remove(new_name)
|
||||
elif _drop_table_if_empty(bind, old_name):
|
||||
_ensure_slug_index(bind, new_name, CORE_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else LEGACY_SLUG_INDEX, LEGACY_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else CORE_SLUG_INDEX)
|
||||
return
|
||||
else:
|
||||
raise RuntimeError(f"Cannot reconcile non-empty {old_name} over non-empty {new_name}")
|
||||
|
||||
if old_name in tables and new_name not in tables:
|
||||
op.rename_table(old_name, new_name)
|
||||
_ensure_slug_index(bind, new_name, CORE_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else LEGACY_SLUG_INDEX, LEGACY_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else CORE_SLUG_INDEX)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_rename_scope_table(LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_rename_scope_table(CORE_SCOPE_TABLE, LEGACY_SCOPE_TABLE)
|
||||
@@ -47,8 +47,8 @@ helpers, and secret helpers. The extracted access implementation lives in
|
||||
`govoplan-access`; live ORM table definitions have been split across their
|
||||
platform owners using module-prefixed table names. The old core route,
|
||||
admin-service, and access-security re-export modules have been removed.
|
||||
Callers must use module-owned imports, the public `govoplan_access.auth` request
|
||||
dependency API, or kernel capabilities.
|
||||
Callers must use module-owned imports, the public `govoplan_core.auth` request
|
||||
dependency facade, or kernel capabilities.
|
||||
The remaining platform compatibility surfaces are temporary until the matching
|
||||
platform modules are fully self-contained:
|
||||
|
||||
@@ -114,25 +114,27 @@ Known access-related capability names are defined in
|
||||
- `security.secretProvider`
|
||||
- `audit.sink`
|
||||
|
||||
`govoplan-access` currently registers `access.principalResolver`,
|
||||
`govoplan-access` currently registers `auth.principalResolver`,
|
||||
`auth.permissionEvaluator`, `access.principalResolver`,
|
||||
`access.permissionEvaluator`, `access.directory`, `access.tenantProvisioner`,
|
||||
`access.administration`, and `access.governanceMaterializer`.
|
||||
`govoplan-tenancy` registers `tenancy.tenantResolver`. The minimal
|
||||
authenticated platform set is now `tenancy` plus `access`; the registry
|
||||
inserts `tenancy` before `access` when only feature modules are requested.
|
||||
authenticated platform set is now `access`; tenancy is optional and adds tenant
|
||||
administration plus tenant resolver behavior when installed.
|
||||
Feature modules should prefer these capabilities over direct reads of
|
||||
access/tenant ORM models when they need labels, group membership, default
|
||||
access provisioning, counts, audit actor labels, or tenant metadata.
|
||||
|
||||
FastAPI route dependencies for authenticated endpoints are access-owned and
|
||||
published from `govoplan_access.auth`. Routers may import that public API for
|
||||
FastAPI route dependencies for authenticated endpoints are imported from the
|
||||
core `govoplan_core.auth` facade. Routers may import that public API for
|
||||
`ApiPrincipal`, `get_api_principal`, `has_scope`, `require_scope`, and
|
||||
`require_any_scope`; they must not import access ORM models or
|
||||
`govoplan_access.backend.*` implementation internals.
|
||||
|
||||
Current live table ownership:
|
||||
|
||||
- `govoplan-tenancy`: `tenancy_tenants`
|
||||
- core scope table: `core_scopes` (used by access/core baseline; managed by
|
||||
`govoplan-tenancy` behavior when the tenancy module is installed)
|
||||
- `govoplan-access`: `access_accounts`, `access_users`, `access_groups`,
|
||||
`access_roles`, `access_system_role_assignments`,
|
||||
`access_user_group_memberships`, `access_user_role_assignments`,
|
||||
@@ -580,7 +582,27 @@ Examples:
|
||||
|
||||
## Cross-Module Integration
|
||||
|
||||
A module can declare required dependencies and optional dependencies. Optional behavior should be enabled by module presence and permissions, not by importing another module's WebUI internals.
|
||||
A module can declare required module dependencies, optional module
|
||||
dependencies, required capabilities, and optional capabilities. Required module
|
||||
dependencies are reserved for unavoidable startup ownership, such as a module
|
||||
that cannot import or mount without another module package. Most runtime
|
||||
relationships should be expressed as capabilities instead.
|
||||
|
||||
Auth/principal access is a capability contract, not a reason to hard-depend on
|
||||
the `govoplan-access` repository. Current routers import `govoplan_core.auth`;
|
||||
that facade delegates to access today and is the migration point for a future
|
||||
provider-neutral auth kernel. Feature manifests should require
|
||||
`auth.principalResolver` and `auth.permissionEvaluator`, while access remains
|
||||
the default installed provider.
|
||||
|
||||
Tenancy is optional. Existing scoped data still uses `tenant_id` as a scope
|
||||
identifier, backed by the core-owned `core_scopes` table. Access and
|
||||
organizations must not import the tenancy package or declare a hard dependency
|
||||
on it. Tenancy-specific administration and tenant resolver behavior live behind
|
||||
the tenancy module and its capabilities.
|
||||
|
||||
Optional behavior should be enabled by module presence, capabilities, and
|
||||
permissions, not by importing another module's WebUI internals.
|
||||
|
||||
Rules:
|
||||
|
||||
@@ -599,7 +621,7 @@ The repository includes `scripts/check_dependency_boundaries.py`. It enforces th
|
||||
- access source may not import files/mail/campaign internals
|
||||
- feature modules may not import access implementation internals
|
||||
- feature modules may not add new direct imports of sibling feature modules
|
||||
- FastAPI routers may import the published `govoplan_access.auth` dependency API
|
||||
- FastAPI routers import the core `govoplan_core.auth` dependency facade
|
||||
- the transitional allowlist is expected to stay empty
|
||||
|
||||
Any future exception is extraction debt and must be temporary, documented in the
|
||||
|
||||
@@ -36,7 +36,6 @@ PREFIXES = {
|
||||
}
|
||||
FEATURE_OWNERS = ("files", "mail", "campaign")
|
||||
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard")
|
||||
PUBLIC_ACCESS_IMPORTS = ("govoplan_access.auth",)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -89,10 +88,6 @@ def _allowed(owner: str, path: Path, imported_owner: str) -> bool:
|
||||
return (owner, _relative(owner, path), imported_owner) in ALLOWLIST_KEYS
|
||||
|
||||
|
||||
def _is_public_access_import(imported: str) -> bool:
|
||||
return any(imported == item or imported.startswith(f"{item}.") for item in PUBLIC_ACCESS_IMPORTS)
|
||||
|
||||
|
||||
def _violations() -> list[Violation]:
|
||||
violations: list[Violation] = []
|
||||
for owner, root in REPOS.items():
|
||||
@@ -105,8 +100,6 @@ def _violations() -> list[Violation]:
|
||||
imported_owner = _import_owner(imported)
|
||||
if imported_owner is None or imported_owner == owner:
|
||||
continue
|
||||
if imported_owner == "access" and _is_public_access_import(imported):
|
||||
continue
|
||||
if owner == "core" and imported_owner in FEATURE_OWNERS:
|
||||
if not _allowed(owner, path, imported_owner):
|
||||
violations.append(Violation(owner, path, lineno, imported, imported_owner))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
50
src/govoplan_core/tenancy/scope.py
Normal file
50
src/govoplan_core/tenancy/scope.py
Normal 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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -73,6 +73,7 @@ from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.server.app import create_app
|
||||
from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_core.tenancy.scope import create_scope_tables
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Function,
|
||||
@@ -604,6 +605,7 @@ class AccessContractTests(unittest.TestCase):
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-directory-"))
|
||||
try:
|
||||
with temporary_database(f"sqlite:///{root / 'directory.db'}") as database:
|
||||
create_scope_tables(database.engine)
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.session() as session:
|
||||
tenant = Tenant(id="tenant-directory", slug="directory", name="Directory Tenant", settings={})
|
||||
@@ -716,6 +718,7 @@ class AccessContractTests(unittest.TestCase):
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-semantic-api-"))
|
||||
try:
|
||||
with temporary_database(f"sqlite:///{root / 'semantic.db'}") as database:
|
||||
create_scope_tables(database.engine)
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
tenant_id = "tenant-semantic"
|
||||
account_id = "account-semantic"
|
||||
@@ -906,6 +909,7 @@ class AccessContractTests(unittest.TestCase):
|
||||
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
|
||||
app = create_app(config)
|
||||
|
||||
create_scope_tables(database.engine)
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.session() as session:
|
||||
tenant = Tenant(id=tenant_id, slug="capability", name="Capability Tenant", settings={})
|
||||
|
||||
@@ -136,7 +136,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
job_indexes = {index["name"] for index in inspector.get_indexes("campaign_jobs")}
|
||||
attempt_columns = {column["name"] for column in inspector.get_columns("send_attempts")}
|
||||
tables = set(inspector.get_table_names())
|
||||
tenant_columns = {column["name"] for column in inspector.get_columns("tenancy_tenants")}
|
||||
tenant_columns = {column["name"] for column in inspector.get_columns("core_scopes")}
|
||||
user_columns = {column["name"] for column in inspector.get_columns("access_users")}
|
||||
session_columns = {column["name"] for column in inspector.get_columns("access_auth_sessions")}
|
||||
group_columns = {column["name"] for column in inspector.get_columns("access_groups")}
|
||||
@@ -186,6 +186,8 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
self.assertIn("claim_token", attempt_columns)
|
||||
self.assertIn("access_accounts", tables)
|
||||
self.assertIn("access_system_role_assignments", tables)
|
||||
self.assertIn("core_scopes", tables)
|
||||
self.assertNotIn("tenancy_tenants", tables)
|
||||
self.assertIn("core_system_settings", tables)
|
||||
self.assertIn("admin_governance_templates", tables)
|
||||
self.assertIn("admin_governance_template_assignments", tables)
|
||||
@@ -234,6 +236,34 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_module_migrations_apply_after_core_scope_table_rename(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="msm-late-module-migration-test-") as directory:
|
||||
database = Path(directory) / "late-modules.db"
|
||||
url = f"sqlite:///{database}"
|
||||
command.upgrade(alembic_config(database_url=url, enabled_modules=()), "heads")
|
||||
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
self.assertIn("core_scopes", tables)
|
||||
self.assertNotIn("tenancy_tenants", tables)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
migrate_database(database_url=url)
|
||||
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
mail_policy_fks = inspector.get_foreign_keys("mail_profile_policies")
|
||||
referred_tables = {fk["referred_table"] for fk in mail_policy_fks}
|
||||
self.assertIn("core_scopes", referred_tables)
|
||||
self.assertNotIn("tenancy_tenants", referred_tables)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_repairs_current_schema_stamped_at_file_folders(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="msm-current-schema-marker-test-") as directory:
|
||||
database = Path(directory) / "current-schema-marker.db"
|
||||
@@ -355,6 +385,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
account = connection.execute(text(
|
||||
"SELECT id, normalized_email, password_hash FROM access_accounts"
|
||||
)).mappings().one()
|
||||
@@ -389,8 +420,14 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
"SELECT slug, is_builtin FROM access_roles WHERE tenant_id IS NULL AND slug IN ('system_owner', 'system_admin', 'system_auditor')"
|
||||
)).mappings().all()
|
||||
}
|
||||
scope_slug = connection.execute(text(
|
||||
"SELECT slug FROM core_scopes WHERE id = 'tenant-1'"
|
||||
)).scalar_one()
|
||||
self.assertIn("core_scopes", tables)
|
||||
self.assertNotIn("tenancy_tenants", tables)
|
||||
self.assertEqual(account["normalized_email"], "owner@example.local")
|
||||
self.assertEqual(account["password_hash"], "legacy-password-hash")
|
||||
self.assertEqual(scope_slug, "legacy")
|
||||
self.assertEqual(membership["account_id"], account["id"])
|
||||
self.assertEqual(migrated_session["account_id"], account["id"])
|
||||
self.assertEqual(owner_assignment, "owner")
|
||||
|
||||
@@ -26,9 +26,15 @@ from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
OrganizationFunctionAssignment,
|
||||
OrganizationFunctionType,
|
||||
OrganizationRelation,
|
||||
OrganizationRelationType,
|
||||
OrganizationStructure,
|
||||
OrganizationUnit,
|
||||
OrganizationUnitType,
|
||||
)
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
from govoplan_core.tenancy.scope import create_scope_tables
|
||||
from tests.db_isolation import temporary_database
|
||||
|
||||
|
||||
@@ -70,8 +76,8 @@ class _FakeOrganizationDirectory:
|
||||
assignment = OrganizationFunctionAssignmentRef(
|
||||
id="assignment-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id=function.id,
|
||||
organization_unit_id=unit.id,
|
||||
applies_to_subunits=True,
|
||||
@@ -100,6 +106,13 @@ class _FakeOrganizationDirectory:
|
||||
return ()
|
||||
return (self.assignment,)
|
||||
|
||||
def function_assignments_for_identity(self, identity_id: str, *, tenant_id: str | None = None):
|
||||
if identity_id != self.assignment.identity_id:
|
||||
return ()
|
||||
if tenant_id is not None and tenant_id != self.assignment.tenant_id:
|
||||
return ()
|
||||
return (self.assignment,)
|
||||
|
||||
|
||||
class IdentityOrganizationContractTests(unittest.TestCase):
|
||||
def test_protocol_shapes_match_reference_implementations(self) -> None:
|
||||
@@ -129,6 +142,7 @@ class IdentityOrganizationContractTests(unittest.TestCase):
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-directory-contracts-"))
|
||||
try:
|
||||
with temporary_database(f"sqlite:///{root / 'directory.db'}") as database:
|
||||
create_scope_tables(database.engine)
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.session() as session:
|
||||
tenant = Tenant(id="tenant-directory", slug="directory", name="Directory Tenant", settings={})
|
||||
@@ -138,15 +152,60 @@ class IdentityOrganizationContractTests(unittest.TestCase):
|
||||
account_id="account-directory",
|
||||
is_primary=True,
|
||||
)
|
||||
unit_type = OrganizationUnitType(
|
||||
id="unit-type-directory",
|
||||
tenant_id=tenant.id,
|
||||
slug="office",
|
||||
name="Office",
|
||||
)
|
||||
structure = OrganizationStructure(
|
||||
id="structure-directory",
|
||||
tenant_id=tenant.id,
|
||||
slug="employer",
|
||||
name="Employer hierarchy",
|
||||
)
|
||||
relation_type = OrganizationRelationType(
|
||||
id="relation-type-directory",
|
||||
tenant_id=tenant.id,
|
||||
structure_id=structure.id,
|
||||
slug="supervises",
|
||||
name="Supervises",
|
||||
source_unit_type_id=unit_type.id,
|
||||
target_unit_type_id=unit_type.id,
|
||||
)
|
||||
unit = OrganizationUnit(
|
||||
id="ou-directory",
|
||||
tenant_id=tenant.id,
|
||||
unit_type_id=unit_type.id,
|
||||
slug="registry",
|
||||
name="Registry",
|
||||
)
|
||||
child_unit = OrganizationUnit(
|
||||
id="ou-directory-child",
|
||||
tenant_id=tenant.id,
|
||||
unit_type_id=unit_type.id,
|
||||
slug="registry-frontdesk",
|
||||
name="Registry Frontdesk",
|
||||
)
|
||||
relation = OrganizationRelation(
|
||||
id="relation-directory",
|
||||
tenant_id=tenant.id,
|
||||
structure_id=structure.id,
|
||||
relation_type_id=relation_type.id,
|
||||
source_unit_id=unit.id,
|
||||
target_unit_id=child_unit.id,
|
||||
)
|
||||
function_type = OrganizationFunctionType(
|
||||
id="function-type-directory",
|
||||
tenant_id=tenant.id,
|
||||
slug="clerk",
|
||||
name="Clerk",
|
||||
organization_unit_type_id=unit_type.id,
|
||||
)
|
||||
function = OrganizationFunction(
|
||||
id="function-directory",
|
||||
tenant_id=tenant.id,
|
||||
function_type_id=function_type.id,
|
||||
organization_unit_id=unit.id,
|
||||
slug="registry-clerk",
|
||||
name="Registry Clerk",
|
||||
@@ -154,26 +213,44 @@ class IdentityOrganizationContractTests(unittest.TestCase):
|
||||
assignment = OrganizationFunctionAssignment(
|
||||
id="assignment-directory",
|
||||
tenant_id=tenant.id,
|
||||
account_id="account-directory",
|
||||
identity_id=identity.id,
|
||||
function_id=function.id,
|
||||
organization_unit_id=unit.id,
|
||||
applies_to_subunits=True,
|
||||
)
|
||||
session.add_all([tenant, identity, link, unit, function, assignment])
|
||||
session.add_all([
|
||||
tenant,
|
||||
identity,
|
||||
link,
|
||||
unit_type,
|
||||
structure,
|
||||
relation_type,
|
||||
unit,
|
||||
child_unit,
|
||||
relation,
|
||||
function_type,
|
||||
function,
|
||||
assignment,
|
||||
])
|
||||
session.commit()
|
||||
|
||||
from govoplan_identity.backend.directory import SqlIdentityDirectory
|
||||
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
|
||||
|
||||
identity_directory = SqlIdentityDirectory()
|
||||
organization_directory = SqlOrganizationDirectory()
|
||||
organization_directory = SqlOrganizationDirectory(identity_directory=identity_directory)
|
||||
|
||||
self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr]
|
||||
self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr]
|
||||
self.assertEqual(["account-directory"], [item.account_id for item in identity_directory.accounts_for_identity("identity-directory")])
|
||||
self.assertEqual("Registry", organization_directory.get_organization_unit("ou-directory").name) # type: ignore[union-attr]
|
||||
self.assertEqual("unit-type-directory", organization_directory.get_organization_unit("ou-directory").unit_type_id) # type: ignore[union-attr]
|
||||
self.assertEqual("Registry Clerk", organization_directory.get_function("function-directory").name) # type: ignore[union-attr]
|
||||
self.assertEqual("function-type-directory", organization_directory.get_function("function-directory").function_type_id) # type: ignore[union-attr]
|
||||
self.assertEqual(
|
||||
["assignment-directory"],
|
||||
[item.id for item in organization_directory.function_assignments_for_identity("identity-directory", tenant_id="tenant-directory")],
|
||||
)
|
||||
self.assertEqual(
|
||||
["assignment-directory"],
|
||||
[item.id for item in organization_directory.function_assignments_for_account("account-directory", tenant_id="tenant-directory")],
|
||||
|
||||
@@ -219,11 +219,15 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
def test_discovers_installed_core_and_product_module_manifests(self) -> None:
|
||||
manifests = available_module_manifests()
|
||||
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
|
||||
self.assertEqual(manifests["campaigns"].dependencies, ("access",))
|
||||
self.assertEqual(manifests["campaigns"].dependencies, ())
|
||||
self.assertTrue(manifests["campaigns"].required_capabilities)
|
||||
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail"))
|
||||
self.assertEqual(manifests["dashboard"].dependencies, ("access",))
|
||||
self.assertEqual(manifests["docs"].dependencies, ("access",))
|
||||
self.assertEqual(manifests["ops"].dependencies, ("access",))
|
||||
self.assertEqual(manifests["dashboard"].dependencies, ())
|
||||
self.assertTrue(manifests["dashboard"].required_capabilities)
|
||||
self.assertEqual(manifests["docs"].dependencies, ())
|
||||
self.assertTrue(manifests["docs"].required_capabilities)
|
||||
self.assertEqual(manifests["ops"].dependencies, ())
|
||||
self.assertTrue(manifests["ops"].required_capabilities)
|
||||
|
||||
def test_access_manifest_contributes_admin_frontend_metadata(self) -> None:
|
||||
manifests = available_module_manifests()
|
||||
@@ -266,11 +270,12 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.tenancy.scope import scope_registry
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
|
||||
self.assertIs(AccessBase, Base)
|
||||
self.assertEqual("access_accounts", Account.__tablename__)
|
||||
self.assertEqual("tenancy_tenants", Tenant.__tablename__)
|
||||
self.assertEqual("core_scopes", Tenant.__tablename__)
|
||||
self.assertEqual("access_users", User.__tablename__)
|
||||
self.assertEqual("access_api_keys", ApiKey.__tablename__)
|
||||
self.assertEqual("access_auth_sessions", AuthSession.__tablename__)
|
||||
@@ -278,7 +283,8 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
self.assertEqual("admin_governance_templates", GovernanceTemplate.__tablename__)
|
||||
self.assertEqual("core_system_settings", SystemSettings.__tablename__)
|
||||
self.assertIn("access_accounts", Base.metadata.tables)
|
||||
self.assertIn("tenancy_tenants", Base.metadata.tables)
|
||||
self.assertIn("core_scopes", scope_registry.metadata.tables)
|
||||
self.assertNotIn("core_scopes", Base.metadata.tables)
|
||||
self.assertNotIn("accounts", Base.metadata.tables)
|
||||
self.assertNotIn("auth_sessions", Base.metadata.tables)
|
||||
|
||||
@@ -408,15 +414,15 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
|
||||
def test_enabled_module_permutations_register_expected_routes(self) -> None:
|
||||
cases = (
|
||||
("core_only", (), {"tenancy", "access"}, set()),
|
||||
("files_only", ("files",), {"tenancy", "access", "files"}, {"/api/v1/files"}),
|
||||
("mail_only", ("mail",), {"tenancy", "access", "mail"}, {"/api/v1/mail"}),
|
||||
("campaign_without_files_or_mail", ("campaigns",), {"tenancy", "access", "campaigns"}, {"/api/v1/campaigns"}),
|
||||
("campaign_without_files", ("campaigns", "mail"), {"tenancy", "access", "campaigns", "mail"}, {"/api/v1/campaigns", "/api/v1/mail"}),
|
||||
("campaign_with_files_but_no_mail", ("campaigns", "files"), {"tenancy", "access", "campaigns", "files"}, {"/api/v1/campaigns", "/api/v1/files"}),
|
||||
("dashboard_only", ("dashboard",), {"tenancy", "access", "dashboard"}, set()),
|
||||
("docs_and_ops", ("docs", "ops"), {"tenancy", "access", "docs", "ops"}, {"/api/v1/docs", "/api/v1/ops"}),
|
||||
("full_product", ("dashboard", "campaigns", "files", "mail", "docs", "ops"), {"tenancy", "access", "dashboard", "campaigns", "files", "mail", "docs", "ops"}, {"/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"}),
|
||||
("core_only", (), {"access"}, set()),
|
||||
("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}),
|
||||
("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}),
|
||||
("campaign_without_files_or_mail", ("campaigns",), {"access", "campaigns"}, {"/api/v1/campaigns"}),
|
||||
("campaign_without_files", ("campaigns", "mail"), {"access", "campaigns", "mail"}, {"/api/v1/campaigns", "/api/v1/mail"}),
|
||||
("campaign_with_files_but_no_mail", ("campaigns", "files"), {"access", "campaigns", "files"}, {"/api/v1/campaigns", "/api/v1/files"}),
|
||||
("dashboard_only", ("dashboard",), {"access", "dashboard"}, set()),
|
||||
("docs_and_ops", ("docs", "ops"), {"access", "docs", "ops"}, {"/api/v1/docs", "/api/v1/ops"}),
|
||||
("full_product", ("dashboard", "campaigns", "files", "mail", "docs", "ops"), {"access", "dashboard", "campaigns", "files", "mail", "docs", "ops"}, {"/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"}),
|
||||
)
|
||||
for name, enabled_modules, expected_modules, expected_prefixes in cases:
|
||||
with self.subTest(name=name):
|
||||
@@ -524,12 +530,32 @@ finally:
|
||||
self.assertEqual(files_enabled, registry.integration_enabled("campaigns", "files"))
|
||||
self.assertEqual(mail_enabled, registry.integration_enabled("campaigns", "mail"))
|
||||
|
||||
def test_registry_rejects_missing_required_capability(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="needs_auth",
|
||||
name="Needs Auth",
|
||||
version="test",
|
||||
required_capabilities=("auth.principalResolver",),
|
||||
))
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "requires unavailable capability"):
|
||||
registry.validate()
|
||||
|
||||
def test_access_and_organizations_do_not_hard_depend_on_tenancy(self) -> None:
|
||||
manifests = available_module_manifests()
|
||||
|
||||
self.assertNotIn("tenancy", manifests["access"].dependencies)
|
||||
self.assertIn("tenancy", manifests["access"].optional_dependencies)
|
||||
self.assertNotIn("tenancy", manifests["organizations"].dependencies)
|
||||
self.assertIn("tenancy", manifests["organizations"].optional_dependencies)
|
||||
|
||||
def test_module_management_plan_adds_protected_modules_and_required_dependencies(self) -> None:
|
||||
manifests = available_module_manifests()
|
||||
|
||||
plan = plan_desired_enabled_modules(("campaigns",), manifests)
|
||||
|
||||
self.assertEqual(("tenancy", "access", "admin", "campaigns"), plan.enabled_modules)
|
||||
self.assertEqual(("access", "admin", "campaigns"), plan.enabled_modules)
|
||||
self.assertEqual((), plan.added_dependencies)
|
||||
|
||||
def test_module_install_plan_is_saved_alongside_desired_state(self) -> None:
|
||||
@@ -2032,7 +2058,7 @@ finally:
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload_modules = {item["id"] for item in response.json()["modules"]}
|
||||
self.assertEqual({"tenancy", "access", "files"}, payload_modules)
|
||||
self.assertEqual({"access", "files"}, payload_modules)
|
||||
|
||||
def test_create_app_ignores_saved_desired_modules_that_are_not_installed(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-state-stale-", dir=_TEST_ROOT))
|
||||
@@ -2065,7 +2091,7 @@ finally:
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload_modules = {item["id"] for item in response.json()["modules"]}
|
||||
self.assertEqual({"tenancy", "access", "files"}, payload_modules)
|
||||
self.assertEqual({"access", "files"}, payload_modules)
|
||||
|
||||
def test_create_app_preserves_admin_when_configured_and_saved_state_is_older(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-state-admin-", dir=_TEST_ROOT))
|
||||
@@ -2098,7 +2124,7 @@ finally:
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload_modules = {item["id"] for item in response.json()["modules"]}
|
||||
self.assertEqual({"tenancy", "access", "admin", "files"}, payload_modules)
|
||||
self.assertEqual({"access", "admin", "files"}, payload_modules)
|
||||
|
||||
def test_module_lifecycle_enables_and_disables_routes_without_restart(self) -> None:
|
||||
app, _settings_obj = self._app_for_modules(())
|
||||
@@ -2108,14 +2134,14 @@ finally:
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/platform/modules")
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual({"tenancy", "access"}, {item["id"] for item in response.json()["modules"]})
|
||||
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
|
||||
self.assertFalse("/api/v1/files" in _route_paths(app))
|
||||
|
||||
result = lifecycle.apply_enabled_modules(("files",), migrate=False)
|
||||
self.assertEqual(("files",), result.activated_modules)
|
||||
response = client.get("/api/v1/platform/modules")
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual({"tenancy", "access", "files"}, {item["id"] for item in response.json()["modules"]})
|
||||
self.assertEqual({"access", "files"}, {item["id"] for item in response.json()["modules"]})
|
||||
self.assertTrue("/api/v1/files" in _route_paths(app))
|
||||
self.assertEqual(401, client.get("/api/v1/files").status_code)
|
||||
|
||||
@@ -2123,7 +2149,7 @@ finally:
|
||||
self.assertEqual(("files",), result.deactivated_modules)
|
||||
response = client.get("/api/v1/platform/modules")
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual({"tenancy", "access"}, {item["id"] for item in response.json()["modules"]})
|
||||
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
|
||||
disabled_response = client.get("/api/v1/files")
|
||||
self.assertEqual(404, disabled_response.status_code)
|
||||
self.assertEqual("Module is disabled: files", disabled_response.json()["detail"])
|
||||
@@ -2131,12 +2157,12 @@ finally:
|
||||
|
||||
def test_module_permutations_start_when_absent_modules_are_physically_unavailable(self) -> None:
|
||||
cases = (
|
||||
((), ("govoplan_files", "govoplan_mail", "govoplan_campaign")),
|
||||
(("files",), ("govoplan_mail", "govoplan_campaign")),
|
||||
(("mail",), ("govoplan_files", "govoplan_campaign")),
|
||||
(("campaigns",), ("govoplan_files", "govoplan_mail")),
|
||||
(("campaigns", "files"), ("govoplan_mail",)),
|
||||
(("campaigns", "mail"), ("govoplan_files",)),
|
||||
((), ("govoplan_tenancy", "govoplan_files", "govoplan_mail", "govoplan_campaign")),
|
||||
(("files",), ("govoplan_tenancy", "govoplan_mail", "govoplan_campaign")),
|
||||
(("mail",), ("govoplan_tenancy", "govoplan_files", "govoplan_campaign")),
|
||||
(("campaigns",), ("govoplan_tenancy", "govoplan_files", "govoplan_mail")),
|
||||
(("campaigns", "files"), ("govoplan_tenancy", "govoplan_mail")),
|
||||
(("campaigns", "mail"), ("govoplan_tenancy", "govoplan_files")),
|
||||
(("campaigns", "files", "mail"), ()),
|
||||
)
|
||||
for enabled_modules, blocked_modules in cases:
|
||||
|
||||
26
webui/package-lock.json
generated
26
webui/package-lock.json
generated
@@ -16,7 +16,8 @@
|
||||
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui"
|
||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.2",
|
||||
@@ -208,6 +209,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-organizations/webui": {
|
||||
"name": "@govoplan/organizations-webui",
|
||||
"version": "0.1.6",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -968,6 +988,10 @@
|
||||
"resolved": "../../govoplan-ops/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/organizations-webui": {
|
||||
"resolved": "../../govoplan-organizations/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.6",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.6",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.6",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.6",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,6 +8,7 @@ const packageByModule = {
|
||||
docs: "@govoplan/docs-webui",
|
||||
files: "@govoplan/files-webui",
|
||||
mail: "@govoplan/mail-webui",
|
||||
organizations: "@govoplan/organizations-webui",
|
||||
ops: "@govoplan/ops-webui"
|
||||
};
|
||||
|
||||
@@ -19,11 +20,12 @@ const cases = [
|
||||
{ name: "dashboard-only", modules: ["dashboard"] },
|
||||
{ name: "files-only", modules: ["files"] },
|
||||
{ name: "mail-only", modules: ["mail"] },
|
||||
{ name: "organizations-only", modules: ["organizations"] },
|
||||
{ name: "campaign-only", modules: ["campaigns"] },
|
||||
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
|
||||
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
|
||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||
{ name: "full-product", modules: ["access", "admin", "dashboard", "campaigns", "files", "mail", "docs", "ops"] }
|
||||
{ name: "full-product", modules: ["access", "admin", "dashboard", "organizations", "campaigns", "files", "mail", "docs", "ops"] }
|
||||
];
|
||||
|
||||
const npmExec = process.env.npm_execpath;
|
||||
|
||||
@@ -19,6 +19,7 @@ const defaultWebModulePackages = [
|
||||
"@govoplan/docs-webui",
|
||||
"@govoplan/files-webui",
|
||||
"@govoplan/mail-webui",
|
||||
"@govoplan/organizations-webui",
|
||||
"@govoplan/ops-webui"
|
||||
];
|
||||
|
||||
@@ -95,6 +96,7 @@ export default defineConfig({
|
||||
fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url))
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user