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

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

View File

@@ -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))

View File

@@ -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)

View File

@@ -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

View File

@@ -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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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={})

View File

@@ -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")

View File

@@ -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")],

View File

@@ -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:

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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;

View File

@@ -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))
]