diff --git a/alembic/env.py b/alembic/env.py index 59f667e..d00ecda 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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)) diff --git a/alembic/versions/4f2a9c8e7b6d_rename_tenancy_tenants_to_core_scopes.py b/alembic/versions/4f2a9c8e7b6d_rename_tenancy_tenants_to_core_scopes.py new file mode 100644 index 0000000..61ba0ad --- /dev/null +++ b/alembic/versions/4f2a9c8e7b6d_rename_tenancy_tenants_to_core_scopes.py @@ -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) diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index 6d452b8..6f38e92 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -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 diff --git a/scripts/check_dependency_boundaries.py b/scripts/check_dependency_boundaries.py index 7dbc013..f632b7f 100644 --- a/scripts/check_dependency_boundaries.py +++ b/scripts/check_dependency_boundaries.py @@ -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)) diff --git a/src/govoplan_core/audit/logging.py b/src/govoplan_core/audit/logging.py index 29ff7f4..1142492 100644 --- a/src/govoplan_core/audit/logging.py +++ b/src/govoplan_core/audit/logging.py @@ -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 diff --git a/src/govoplan_core/auth/__init__.py b/src/govoplan_core/auth/__init__.py index e69de29..6f9f59b 100644 --- a/src/govoplan_core/auth/__init__.py +++ b/src/govoplan_core/auth/__init__.py @@ -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", +] diff --git a/src/govoplan_core/core/access.py b/src/govoplan_core/core/access.py index d21dd6b..29d484d 100644 --- a/src/govoplan_core/core/access.py +++ b/src/govoplan_core/core/access.py @@ -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, } ) diff --git a/src/govoplan_core/core/module_management.py b/src/govoplan_core/core/module_management.py index d044e6f..cc17f34 100644 --- a/src/govoplan_core/core/module_management.py +++ b/src/govoplan_core/core/module_management.py @@ -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") diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index 317f573..1a500e0 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -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 diff --git a/src/govoplan_core/core/organizations.py b/src/govoplan_core/core/organizations.py index e000149..737ff8d 100644 --- a/src/govoplan_core/core/organizations.py +++ b/src/govoplan_core/core/organizations.py @@ -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, diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 280e512..01248af 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -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}") diff --git a/src/govoplan_core/db/bootstrap.py b/src/govoplan_core/db/bootstrap.py index ecd9606..98f4289 100644 --- a/src/govoplan_core/db/bootstrap.py +++ b/src/govoplan_core/db/bootstrap.py @@ -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) diff --git a/src/govoplan_core/db/migrations.py b/src/govoplan_core/db/migrations.py index 58f532e..d3df220 100644 --- a/src/govoplan_core/db/migrations.py +++ b/src/govoplan_core/db/migrations.py @@ -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, diff --git a/src/govoplan_core/privacy/retention.py b/src/govoplan_core/privacy/retention.py index a609142..6f38f99 100644 --- a/src/govoplan_core/privacy/retention.py +++ b/src/govoplan_core/privacy/retention.py @@ -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 = ( diff --git a/src/govoplan_core/server/default_config.py b/src/govoplan_core/server/default_config.py index a6dc02a..ea21196 100644 --- a/src/govoplan_core/server/default_config.py +++ b/src/govoplan_core/server/default_config.py @@ -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 diff --git a/src/govoplan_core/server/registry.py b/src/govoplan_core/server/registry.py index 69e91c5..d536f3d 100644 --- a/src/govoplan_core/server/registry.py +++ b/src/govoplan_core/server/registry.py @@ -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 diff --git a/src/govoplan_core/tenancy/scope.py b/src/govoplan_core/tenancy/scope.py new file mode 100644 index 0000000..e50f5da --- /dev/null +++ b/src/govoplan_core/tenancy/scope.py @@ -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"] diff --git a/src/govoplan_core/tenancy/service.py b/src/govoplan_core/tenancy/service.py index 5ac21ef..3229d32 100644 --- a/src/govoplan_core/tenancy/service.py +++ b/src/govoplan_core/tenancy/service.py @@ -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) diff --git a/tests/test_access_contracts.py b/tests/test_access_contracts.py index 6320538..22a6674 100644 --- a/tests/test_access_contracts.py +++ b/tests/test_access_contracts.py @@ -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={}) diff --git a/tests/test_database_migrations.py b/tests/test_database_migrations.py index 892bc65..8f092f4 100644 --- a/tests/test_database_migrations.py +++ b/tests/test_database_migrations.py @@ -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") diff --git a/tests/test_identity_organization_contracts.py b/tests/test_identity_organization_contracts.py index fb2d446..0fcb114 100644 --- a/tests/test_identity_organization_contracts.py +++ b/tests/test_identity_organization_contracts.py @@ -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")], diff --git a/tests/test_module_system.py b/tests/test_module_system.py index ae321f3..daccb1a 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -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: diff --git a/webui/package-lock.json b/webui/package-lock.json index 3814b3a..620ae8d 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -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", diff --git a/webui/package.json b/webui/package.json index 88d4ae3..2577dcf 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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": { diff --git a/webui/package.release.json b/webui/package.release.json index c1739b7..4e45a8e 100644 --- a/webui/package.release.json +++ b/webui/package.release.json @@ -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": { diff --git a/webui/scripts/test-module-permutations.mjs b/webui/scripts/test-module-permutations.mjs index 5ba2bc1..2f5e1e6 100644 --- a/webui/scripts/test-module-permutations.mjs +++ b/webui/scripts/test-module-permutations.mjs @@ -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; diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 4ef8678..a76c660 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -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)) ]