Decouple scopes from tenancy schema
This commit is contained in:
@@ -13,6 +13,7 @@ from govoplan_core.db.base import Base
|
|||||||
from govoplan_core.server.default_config import get_server_config
|
from govoplan_core.server.default_config import get_server_config
|
||||||
from govoplan_core.server.registry import build_platform_registry
|
from govoplan_core.server.registry import build_platform_registry
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
from govoplan_core.tenancy.scope import scope_registry
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
database_url = config.attributes.get("database_url") or settings.database_url
|
database_url = config.attributes.get("database_url") or settings.database_url
|
||||||
@@ -30,7 +31,7 @@ def _target_metadata():
|
|||||||
enabled_modules,
|
enabled_modules,
|
||||||
manifest_factories=manifest_factories,
|
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))
|
return tuple(dict.fromkeys(plan.metadata))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""rename tenancy scope table to core scopes
|
||||||
|
|
||||||
|
Revision ID: 4f2a9c8e7b6d
|
||||||
|
Revises: 3f4a5b6c7d8e
|
||||||
|
Create Date: 2026-07-10 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "4f2a9c8e7b6d"
|
||||||
|
down_revision = "3f4a5b6c7d8e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
LEGACY_SCOPE_TABLE = "tenancy_tenants"
|
||||||
|
CORE_SCOPE_TABLE = "core_scopes"
|
||||||
|
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
|
||||||
|
CORE_SLUG_INDEX = "ix_core_scopes_slug"
|
||||||
|
|
||||||
|
|
||||||
|
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
||||||
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
|
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_tables(bind: sa.Connection) -> set[str]:
|
||||||
|
return set(sa.inspect(bind).get_table_names())
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_table_if_empty(bind: sa.Connection, table_name: str) -> bool:
|
||||||
|
if _row_count(bind, table_name) != 0:
|
||||||
|
return False
|
||||||
|
op.drop_table(table_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_slug_index(bind: sa.Connection, table_name: str, index_name: str, old_index_name: str) -> None:
|
||||||
|
indexes = {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||||
|
if old_index_name in indexes:
|
||||||
|
op.drop_index(old_index_name, table_name=table_name)
|
||||||
|
indexes.remove(old_index_name)
|
||||||
|
if index_name not in indexes:
|
||||||
|
op.create_index(op.f(index_name), table_name, ["slug"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_scope_table(old_name: str, new_name: str) -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
tables = _scope_tables(bind)
|
||||||
|
if old_name not in tables:
|
||||||
|
if new_name in tables:
|
||||||
|
_ensure_slug_index(bind, new_name, CORE_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else LEGACY_SLUG_INDEX, LEGACY_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else CORE_SLUG_INDEX)
|
||||||
|
return
|
||||||
|
|
||||||
|
if new_name in tables:
|
||||||
|
if _drop_table_if_empty(bind, new_name):
|
||||||
|
tables.remove(new_name)
|
||||||
|
elif _drop_table_if_empty(bind, old_name):
|
||||||
|
_ensure_slug_index(bind, new_name, CORE_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else LEGACY_SLUG_INDEX, LEGACY_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else CORE_SLUG_INDEX)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Cannot reconcile non-empty {old_name} over non-empty {new_name}")
|
||||||
|
|
||||||
|
if old_name in tables and new_name not in tables:
|
||||||
|
op.rename_table(old_name, new_name)
|
||||||
|
_ensure_slug_index(bind, new_name, CORE_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else LEGACY_SLUG_INDEX, LEGACY_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else CORE_SLUG_INDEX)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_rename_scope_table(LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_rename_scope_table(CORE_SCOPE_TABLE, LEGACY_SCOPE_TABLE)
|
||||||
@@ -47,8 +47,8 @@ helpers, and secret helpers. The extracted access implementation lives in
|
|||||||
`govoplan-access`; live ORM table definitions have been split across their
|
`govoplan-access`; live ORM table definitions have been split across their
|
||||||
platform owners using module-prefixed table names. The old core route,
|
platform owners using module-prefixed table names. The old core route,
|
||||||
admin-service, and access-security re-export modules have been removed.
|
admin-service, and access-security re-export modules have been removed.
|
||||||
Callers must use module-owned imports, the public `govoplan_access.auth` request
|
Callers must use module-owned imports, the public `govoplan_core.auth` request
|
||||||
dependency API, or kernel capabilities.
|
dependency facade, or kernel capabilities.
|
||||||
The remaining platform compatibility surfaces are temporary until the matching
|
The remaining platform compatibility surfaces are temporary until the matching
|
||||||
platform modules are fully self-contained:
|
platform modules are fully self-contained:
|
||||||
|
|
||||||
@@ -114,25 +114,27 @@ Known access-related capability names are defined in
|
|||||||
- `security.secretProvider`
|
- `security.secretProvider`
|
||||||
- `audit.sink`
|
- `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.permissionEvaluator`, `access.directory`, `access.tenantProvisioner`,
|
||||||
`access.administration`, and `access.governanceMaterializer`.
|
`access.administration`, and `access.governanceMaterializer`.
|
||||||
`govoplan-tenancy` registers `tenancy.tenantResolver`. The minimal
|
`govoplan-tenancy` registers `tenancy.tenantResolver`. The minimal
|
||||||
authenticated platform set is now `tenancy` plus `access`; the registry
|
authenticated platform set is now `access`; tenancy is optional and adds tenant
|
||||||
inserts `tenancy` before `access` when only feature modules are requested.
|
administration plus tenant resolver behavior when installed.
|
||||||
Feature modules should prefer these capabilities over direct reads of
|
Feature modules should prefer these capabilities over direct reads of
|
||||||
access/tenant ORM models when they need labels, group membership, default
|
access/tenant ORM models when they need labels, group membership, default
|
||||||
access provisioning, counts, audit actor labels, or tenant metadata.
|
access provisioning, counts, audit actor labels, or tenant metadata.
|
||||||
|
|
||||||
FastAPI route dependencies for authenticated endpoints are access-owned and
|
FastAPI route dependencies for authenticated endpoints are imported from the
|
||||||
published from `govoplan_access.auth`. Routers may import that public API for
|
core `govoplan_core.auth` facade. Routers may import that public API for
|
||||||
`ApiPrincipal`, `get_api_principal`, `has_scope`, `require_scope`, and
|
`ApiPrincipal`, `get_api_principal`, `has_scope`, `require_scope`, and
|
||||||
`require_any_scope`; they must not import access ORM models or
|
`require_any_scope`; they must not import access ORM models or
|
||||||
`govoplan_access.backend.*` implementation internals.
|
`govoplan_access.backend.*` implementation internals.
|
||||||
|
|
||||||
Current live table ownership:
|
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`,
|
- `govoplan-access`: `access_accounts`, `access_users`, `access_groups`,
|
||||||
`access_roles`, `access_system_role_assignments`,
|
`access_roles`, `access_system_role_assignments`,
|
||||||
`access_user_group_memberships`, `access_user_role_assignments`,
|
`access_user_group_memberships`, `access_user_role_assignments`,
|
||||||
@@ -580,7 +582,27 @@ Examples:
|
|||||||
|
|
||||||
## Cross-Module Integration
|
## 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:
|
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
|
- access source may not import files/mail/campaign internals
|
||||||
- feature modules may not import access implementation internals
|
- feature modules may not import access implementation internals
|
||||||
- feature modules may not add new direct imports of sibling feature modules
|
- 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
|
- the transitional allowlist is expected to stay empty
|
||||||
|
|
||||||
Any future exception is extraction debt and must be temporary, documented in the
|
Any future exception is extraction debt and must be temporary, documented in the
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ PREFIXES = {
|
|||||||
}
|
}
|
||||||
FEATURE_OWNERS = ("files", "mail", "campaign")
|
FEATURE_OWNERS = ("files", "mail", "campaign")
|
||||||
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard")
|
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard")
|
||||||
PUBLIC_ACCESS_IMPORTS = ("govoplan_access.auth",)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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
|
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]:
|
def _violations() -> list[Violation]:
|
||||||
violations: list[Violation] = []
|
violations: list[Violation] = []
|
||||||
for owner, root in REPOS.items():
|
for owner, root in REPOS.items():
|
||||||
@@ -105,8 +100,6 @@ def _violations() -> list[Violation]:
|
|||||||
imported_owner = _import_owner(imported)
|
imported_owner = _import_owner(imported)
|
||||||
if imported_owner is None or imported_owner == owner:
|
if imported_owner is None or imported_owner == owner:
|
||||||
continue
|
continue
|
||||||
if imported_owner == "access" and _is_public_access_import(imported):
|
|
||||||
continue
|
|
||||||
if owner == "core" and imported_owner in FEATURE_OWNERS:
|
if owner == "core" and imported_owner in FEATURE_OWNERS:
|
||||||
if not _allowed(owner, path, imported_owner):
|
if not _allowed(owner, path, imported_owner):
|
||||||
violations.append(Violation(owner, path, lineno, imported, imported_owner))
|
violations.append(Violation(owner, path, lineno, imported, imported_owner))
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from 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.change_sequence import record_change
|
||||||
from govoplan_core.core.events import current_event_trace, normalize_trace_id
|
from govoplan_core.core.events import current_event_trace, normalize_trace_id
|
||||||
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
|
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Core auth dependency facade.
|
||||||
|
|
||||||
|
Routers depend on this module instead of a concrete access-provider package.
|
||||||
|
The current implementation delegates to the access module; replacing this
|
||||||
|
facade is the remaining step toward a fully provider-neutral auth kernel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from govoplan_access.auth import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ApiPrincipal",
|
||||||
|
"get_api_principal",
|
||||||
|
"has_scope",
|
||||||
|
"require_any_scope",
|
||||||
|
"require_scope",
|
||||||
|
]
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
|
|||||||
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
|
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
|
||||||
CAPABILITY_AUDIT_SINK = "audit.sink"
|
CAPABILITY_AUDIT_SINK = "audit.sink"
|
||||||
|
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
||||||
|
|
||||||
ACCESS_CAPABILITY_NAMES = frozenset(
|
ACCESS_CAPABILITY_NAMES = frozenset(
|
||||||
{
|
{
|
||||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
||||||
@@ -38,6 +41,15 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
|||||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||||
CAPABILITY_AUDIT_SINK,
|
CAPABILITY_AUDIT_SINK,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_CAPABILITY_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from govoplan_core.server.registry import parse_enabled_modules
|
|||||||
|
|
||||||
MODULE_SETTINGS_KEY = "module_management"
|
MODULE_SETTINGS_KEY = "module_management"
|
||||||
INSTALL_PLAN_KEY = "install_plan"
|
INSTALL_PLAN_KEY = "install_plan"
|
||||||
REQUIRED_PLATFORM_MODULES = ("tenancy", "access")
|
REQUIRED_PLATFORM_MODULES = ("access",)
|
||||||
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
||||||
INSTALL_PLAN_ACTIONS = ("install", "uninstall")
|
INSTALL_PLAN_ACTIONS = ("install", "uninstall")
|
||||||
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ class ModuleManifest:
|
|||||||
version: str
|
version: str
|
||||||
dependencies: tuple[str, ...] = ()
|
dependencies: tuple[str, ...] = ()
|
||||||
optional_dependencies: tuple[str, ...] = ()
|
optional_dependencies: tuple[str, ...] = ()
|
||||||
|
required_capabilities: tuple[str, ...] = ()
|
||||||
|
optional_capabilities: tuple[str, ...] = ()
|
||||||
permissions: tuple[PermissionDefinition, ...] = ()
|
permissions: tuple[PermissionDefinition, ...] = ()
|
||||||
role_templates: tuple[RoleTemplate, ...] = ()
|
role_templates: tuple[RoleTemplate, ...] = ()
|
||||||
route_factory: RouteFactory | None = None
|
route_factory: RouteFactory | None = None
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class OrganizationUnitRef:
|
|||||||
tenant_id: str
|
tenant_id: str
|
||||||
slug: str
|
slug: str
|
||||||
name: str
|
name: str
|
||||||
|
unit_type_id: str | None = None
|
||||||
parent_id: str | None = None
|
parent_id: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: OrganizationStatus = "active"
|
status: OrganizationStatus = "active"
|
||||||
@@ -31,6 +32,7 @@ class OrganizationFunctionRef:
|
|||||||
organization_unit_id: str
|
organization_unit_id: str
|
||||||
slug: str
|
slug: str
|
||||||
name: str
|
name: str
|
||||||
|
function_type_id: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
delegable: bool = False
|
delegable: bool = False
|
||||||
act_in_place_allowed: bool = False
|
act_in_place_allowed: bool = False
|
||||||
@@ -41,10 +43,10 @@ class OrganizationFunctionRef:
|
|||||||
class OrganizationFunctionAssignmentRef:
|
class OrganizationFunctionAssignmentRef:
|
||||||
id: str
|
id: str
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
account_id: str
|
identity_id: str
|
||||||
function_id: str
|
function_id: str
|
||||||
organization_unit_id: str
|
organization_unit_id: str
|
||||||
identity_id: str | None = None
|
account_id: str | None = None
|
||||||
applies_to_subunits: bool = False
|
applies_to_subunits: bool = False
|
||||||
source: FunctionAssignmentSource = "direct"
|
source: FunctionAssignmentSource = "direct"
|
||||||
delegated_from_assignment_id: str | None = None
|
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 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(
|
def function_assignments_for_account(
|
||||||
self,
|
self,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
|
|||||||
@@ -152,12 +152,16 @@ class PlatformRegistry:
|
|||||||
|
|
||||||
def validate(self) -> RegistrySnapshot:
|
def validate(self) -> RegistrySnapshot:
|
||||||
ordered = tuple(self._topologically_sorted())
|
ordered = tuple(self._topologically_sorted())
|
||||||
|
available_capabilities = set(self._capability_factories)
|
||||||
seen_permissions: dict[str, PermissionDefinition] = {}
|
seen_permissions: dict[str, PermissionDefinition] = {}
|
||||||
for manifest in ordered:
|
for manifest in ordered:
|
||||||
_validate_manifest_shape(manifest)
|
_validate_manifest_shape(manifest)
|
||||||
for dependency in manifest.dependencies:
|
for dependency in manifest.dependencies:
|
||||||
if dependency not in self._manifests:
|
if dependency not in self._manifests:
|
||||||
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
|
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:
|
for dependency in manifest.optional_dependencies:
|
||||||
if dependency == manifest.id:
|
if dependency == manifest.id:
|
||||||
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
|
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, "dependencies", manifest.dependencies)
|
||||||
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_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)
|
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
||||||
if overlap:
|
if overlap:
|
||||||
joined = ", ".join(sorted(overlap))
|
joined = ", ".join(sorted(overlap))
|
||||||
raise RegistryError(f"Module {manifest.id!r} lists dependencies as both required and optional: {joined}")
|
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 is not None:
|
||||||
if manifest.migration_spec.module_id != manifest.id:
|
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}")
|
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:
|
def _validate_nav_item(module_id: str, item: NavItem) -> None:
|
||||||
if not item.path.startswith("/"):
|
if not item.path.startswith("/"):
|
||||||
raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}")
|
raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}")
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ from govoplan_core.security.permissions import TENANT_SCOPES
|
|||||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account
|
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_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)
|
DEFAULT_SCOPES = sorted(TENANT_SCOPES)
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ def create_all_tables() -> None:
|
|||||||
build_platform_registry(enabled_modules)
|
build_platform_registry(enabled_modules)
|
||||||
|
|
||||||
engine = get_database().engine
|
engine = get_database().engine
|
||||||
|
create_scope_tables(engine)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ REVISION_FILE_FOLDERS = "4e5f6a7b8c9d"
|
|||||||
REVISION_NAMESPACE_PLATFORM_TABLES = "2e3f4a5b6c7d"
|
REVISION_NAMESPACE_PLATFORM_TABLES = "2e3f4a5b6c7d"
|
||||||
REVISION_CORE_CHANGE_SEQUENCE = "3f4a5b6c7d8e"
|
REVISION_CORE_CHANGE_SEQUENCE = "3f4a5b6c7d8e"
|
||||||
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
|
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
|
||||||
|
LEGACY_SCOPE_TABLE = "tenancy_tenants"
|
||||||
|
CORE_SCOPE_TABLE = "core_scopes"
|
||||||
|
|
||||||
_NAMESPACE_TABLE_RENAMES = (
|
_NAMESPACE_TABLE_RENAMES = (
|
||||||
("tenants", "tenancy_tenants"),
|
("tenants", "tenancy_tenants"),
|
||||||
@@ -126,12 +128,13 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
|
|||||||
"access_users": {"account_id", "settings", "mail_profile_policy"},
|
"access_users": {"account_id", "settings", "mail_profile_policy"},
|
||||||
"access_groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
|
"access_groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
|
||||||
"access_roles": {"system_template_id", "system_required"},
|
"access_roles": {"system_template_id", "system_required"},
|
||||||
"tenancy_tenants": {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"},
|
|
||||||
"campaigns": {"settings", "mail_profile_policy"},
|
"campaigns": {"settings", "mail_profile_policy"},
|
||||||
"mail_server_profiles": {"scope_type", "scope_id"},
|
"mail_server_profiles": {"scope_type", "scope_id"},
|
||||||
"core_system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
|
"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 = {
|
_CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
|
||||||
"file_folders": {"uq_file_folders_active_user_path", "uq_file_folders_active_group_path"},
|
"file_folders": {"uq_file_folders_active_user_path", "uq_file_folders_active_group_path"},
|
||||||
"campaign_versions": {
|
"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:
|
def _has_create_all_schema_through_hierarchical_settings(inspector, tables: set[str]) -> bool:
|
||||||
if not _CREATE_ALL_THROUGH_HIERARCHICAL_TABLES.issubset(tables):
|
if not _CREATE_ALL_THROUGH_HIERARCHICAL_TABLES.issubset(tables):
|
||||||
return False
|
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():
|
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):
|
if table_name not in tables or not _has_columns(inspector, table_name, required_columns):
|
||||||
return False
|
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}"))
|
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:
|
def reconcile_namespace_table_drift(database_url: str | None = None) -> bool:
|
||||||
"""Repair dev databases stamped past the namespace-table migration.
|
"""Repair dev databases stamped past the namespace-table migration.
|
||||||
|
|
||||||
@@ -466,6 +521,8 @@ def migrate_database(
|
|||||||
),
|
),
|
||||||
"heads",
|
"heads",
|
||||||
)
|
)
|
||||||
|
if reconcile_legacy_schema:
|
||||||
|
reconcile_scope_table_retirement_drift(url)
|
||||||
current = database_revision(url)
|
current = database_revision(url)
|
||||||
return MigrationResult(
|
return MigrationResult(
|
||||||
previous_revision=previous,
|
previous_revision=previous,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from govoplan_access.backend.db.models import Group, User
|
|||||||
from govoplan_audit.backend.db.models import AuditLog
|
from govoplan_audit.backend.db.models import AuditLog
|
||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.settings import settings
|
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"
|
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
|
||||||
RETENTION_DAY_KEYS = (
|
RETENTION_DAY_KEYS = (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from sqlalchemy.engine import make_url
|
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.core.registry import PlatformRegistry
|
||||||
from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables
|
from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Iterable, Sequence
|
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.discovery import discover_module_manifests
|
||||||
from govoplan_core.core.modules import ModuleManifest
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
|
|
||||||
|
|
||||||
ManifestFactory = Callable[[], ModuleManifest]
|
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]:
|
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
@@ -32,8 +36,20 @@ def available_module_manifests(
|
|||||||
ignore_load_errors=ignore_load_errors,
|
ignore_load_errors=ignore_load_errors,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
manifests.setdefault("tenancy", get_tenancy_manifest())
|
loading_all = enabled_modules is None
|
||||||
manifests.setdefault("access", get_access_manifest())
|
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:
|
for factory in manifest_factories:
|
||||||
manifest = factory()
|
manifest = factory()
|
||||||
manifests[manifest.id] = manifest
|
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:
|
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry:
|
||||||
requested = parse_enabled_modules(enabled_modules)
|
requested = parse_enabled_modules(enabled_modules)
|
||||||
if "tenancy" not in requested:
|
|
||||||
requested.insert(0, "tenancy")
|
|
||||||
if "access" not in requested:
|
if "access" not in requested:
|
||||||
tenancy_index = requested.index("tenancy")
|
requested.insert(0, "access")
|
||||||
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")
|
|
||||||
|
|
||||||
available = available_module_manifests(manifest_factories, enabled_modules=requested)
|
available = available_module_manifests(manifest_factories, enabled_modules=requested)
|
||||||
registry = PlatformRegistry()
|
registry = PlatformRegistry()
|
||||||
@@ -61,3 +70,14 @@ def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_fa
|
|||||||
registry.register(manifest)
|
registry.register(manifest)
|
||||||
registry.validate()
|
registry.validate()
|
||||||
return registry
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def _load_builtin_manifest(module_id: str) -> ModuleManifest:
|
||||||
|
target = _BUILTIN_MANIFESTS[module_id]
|
||||||
|
module_name, function_name = target.split(":", 1)
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
factory = getattr(module, function_name)
|
||||||
|
manifest = factory()
|
||||||
|
if not isinstance(manifest, ModuleManifest):
|
||||||
|
raise RegistryError(f"Built-in module manifest factory returned invalid value: {module_id}")
|
||||||
|
return manifest
|
||||||
|
|||||||
50
src/govoplan_core/tenancy/scope.py
Normal file
50
src/govoplan_core/tenancy/scope.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, JSON, MetaData, String, Text
|
||||||
|
from sqlalchemy.types import DateTime
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, registry
|
||||||
|
|
||||||
|
from govoplan_core.db.base import NAMING_CONVENTION, utcnow
|
||||||
|
|
||||||
|
scope_registry = registry(metadata=MetaData(naming_convention=NAMING_CONVENTION))
|
||||||
|
|
||||||
|
|
||||||
|
def new_uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
@scope_registry.mapped
|
||||||
|
class Tenant:
|
||||||
|
"""Core-owned scope row used when the tenancy module is not installed.
|
||||||
|
|
||||||
|
The table was historically named ``tenancy_tenants``. It is now owned by
|
||||||
|
core as ``core_scopes`` so access, auth and policy code can use a stable
|
||||||
|
scope identifier without importing or requiring the tenancy module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "core_scopes"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
|
||||||
|
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
allow_api_keys: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def create_scope_tables(engine: Engine) -> None:
|
||||||
|
scope_registry.metadata.create_all(bind=engine, checkfirst=True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Tenant", "create_scope_tables", "new_uuid", "scope_registry"]
|
||||||
@@ -8,7 +8,7 @@ from govoplan_core.admin.common import AdminValidationError
|
|||||||
from govoplan_core.admin.settings import get_system_settings
|
from govoplan_core.admin.settings import get_system_settings
|
||||||
from govoplan_core.core.runtime import get_registry
|
from govoplan_core.core.runtime import get_registry
|
||||||
from govoplan_access.backend.db.models import ApiKey, Group, User
|
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)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ from govoplan_core.core.registry import PlatformRegistry
|
|||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.server.app import create_app
|
from govoplan_core.server.app import create_app
|
||||||
from govoplan_core.server.config import GovoplanServerConfig
|
from govoplan_core.server.config import GovoplanServerConfig
|
||||||
|
from govoplan_core.tenancy.scope import create_scope_tables
|
||||||
from govoplan_access.backend.db.models import (
|
from govoplan_access.backend.db.models import (
|
||||||
Account,
|
Account,
|
||||||
Function,
|
Function,
|
||||||
@@ -604,6 +605,7 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-directory-"))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-access-directory-"))
|
||||||
try:
|
try:
|
||||||
with temporary_database(f"sqlite:///{root / 'directory.db'}") as database:
|
with temporary_database(f"sqlite:///{root / 'directory.db'}") as database:
|
||||||
|
create_scope_tables(database.engine)
|
||||||
Base.metadata.create_all(bind=database.engine)
|
Base.metadata.create_all(bind=database.engine)
|
||||||
with database.session() as session:
|
with database.session() as session:
|
||||||
tenant = Tenant(id="tenant-directory", slug="directory", name="Directory Tenant", settings={})
|
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-"))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-access-semantic-api-"))
|
||||||
try:
|
try:
|
||||||
with temporary_database(f"sqlite:///{root / 'semantic.db'}") as database:
|
with temporary_database(f"sqlite:///{root / 'semantic.db'}") as database:
|
||||||
|
create_scope_tables(database.engine)
|
||||||
Base.metadata.create_all(bind=database.engine)
|
Base.metadata.create_all(bind=database.engine)
|
||||||
tenant_id = "tenant-semantic"
|
tenant_id = "tenant-semantic"
|
||||||
account_id = "account-semantic"
|
account_id = "account-semantic"
|
||||||
@@ -906,6 +909,7 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
|
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
|
||||||
app = create_app(config)
|
app = create_app(config)
|
||||||
|
|
||||||
|
create_scope_tables(database.engine)
|
||||||
Base.metadata.create_all(bind=database.engine)
|
Base.metadata.create_all(bind=database.engine)
|
||||||
with database.session() as session:
|
with database.session() as session:
|
||||||
tenant = Tenant(id=tenant_id, slug="capability", name="Capability Tenant", settings={})
|
tenant = Tenant(id=tenant_id, slug="capability", name="Capability Tenant", settings={})
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
job_indexes = {index["name"] for index in inspector.get_indexes("campaign_jobs")}
|
job_indexes = {index["name"] for index in inspector.get_indexes("campaign_jobs")}
|
||||||
attempt_columns = {column["name"] for column in inspector.get_columns("send_attempts")}
|
attempt_columns = {column["name"] for column in inspector.get_columns("send_attempts")}
|
||||||
tables = set(inspector.get_table_names())
|
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")}
|
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")}
|
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")}
|
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("claim_token", attempt_columns)
|
||||||
self.assertIn("access_accounts", tables)
|
self.assertIn("access_accounts", tables)
|
||||||
self.assertIn("access_system_role_assignments", 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("core_system_settings", tables)
|
||||||
self.assertIn("admin_governance_templates", tables)
|
self.assertIn("admin_governance_templates", tables)
|
||||||
self.assertIn("admin_governance_template_assignments", tables)
|
self.assertIn("admin_governance_template_assignments", tables)
|
||||||
@@ -234,6 +236,34 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
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:
|
def test_repairs_current_schema_stamped_at_file_folders(self) -> None:
|
||||||
with tempfile.TemporaryDirectory(prefix="msm-current-schema-marker-test-") as directory:
|
with tempfile.TemporaryDirectory(prefix="msm-current-schema-marker-test-") as directory:
|
||||||
database = Path(directory) / "current-schema-marker.db"
|
database = Path(directory) / "current-schema-marker.db"
|
||||||
@@ -355,6 +385,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
engine = create_engine(url)
|
engine = create_engine(url)
|
||||||
try:
|
try:
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
|
tables = set(inspect(connection).get_table_names())
|
||||||
account = connection.execute(text(
|
account = connection.execute(text(
|
||||||
"SELECT id, normalized_email, password_hash FROM access_accounts"
|
"SELECT id, normalized_email, password_hash FROM access_accounts"
|
||||||
)).mappings().one()
|
)).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')"
|
"SELECT slug, is_builtin FROM access_roles WHERE tenant_id IS NULL AND slug IN ('system_owner', 'system_admin', 'system_auditor')"
|
||||||
)).mappings().all()
|
)).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["normalized_email"], "owner@example.local")
|
||||||
self.assertEqual(account["password_hash"], "legacy-password-hash")
|
self.assertEqual(account["password_hash"], "legacy-password-hash")
|
||||||
|
self.assertEqual(scope_slug, "legacy")
|
||||||
self.assertEqual(membership["account_id"], account["id"])
|
self.assertEqual(membership["account_id"], account["id"])
|
||||||
self.assertEqual(migrated_session["account_id"], account["id"])
|
self.assertEqual(migrated_session["account_id"], account["id"])
|
||||||
self.assertEqual(owner_assignment, "owner")
|
self.assertEqual(owner_assignment, "owner")
|
||||||
|
|||||||
@@ -26,9 +26,15 @@ from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
|||||||
from govoplan_organizations.backend.db.models import (
|
from govoplan_organizations.backend.db.models import (
|
||||||
OrganizationFunction,
|
OrganizationFunction,
|
||||||
OrganizationFunctionAssignment,
|
OrganizationFunctionAssignment,
|
||||||
|
OrganizationFunctionType,
|
||||||
|
OrganizationRelation,
|
||||||
|
OrganizationRelationType,
|
||||||
|
OrganizationStructure,
|
||||||
OrganizationUnit,
|
OrganizationUnit,
|
||||||
|
OrganizationUnitType,
|
||||||
)
|
)
|
||||||
from govoplan_tenancy.backend.db.models import Tenant
|
from govoplan_tenancy.backend.db.models import Tenant
|
||||||
|
from govoplan_core.tenancy.scope import create_scope_tables
|
||||||
from tests.db_isolation import temporary_database
|
from tests.db_isolation import temporary_database
|
||||||
|
|
||||||
|
|
||||||
@@ -70,8 +76,8 @@ class _FakeOrganizationDirectory:
|
|||||||
assignment = OrganizationFunctionAssignmentRef(
|
assignment = OrganizationFunctionAssignmentRef(
|
||||||
id="assignment-1",
|
id="assignment-1",
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
account_id="account-1",
|
|
||||||
identity_id="identity-1",
|
identity_id="identity-1",
|
||||||
|
account_id="account-1",
|
||||||
function_id=function.id,
|
function_id=function.id,
|
||||||
organization_unit_id=unit.id,
|
organization_unit_id=unit.id,
|
||||||
applies_to_subunits=True,
|
applies_to_subunits=True,
|
||||||
@@ -100,6 +106,13 @@ class _FakeOrganizationDirectory:
|
|||||||
return ()
|
return ()
|
||||||
return (self.assignment,)
|
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):
|
class IdentityOrganizationContractTests(unittest.TestCase):
|
||||||
def test_protocol_shapes_match_reference_implementations(self) -> None:
|
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-"))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-directory-contracts-"))
|
||||||
try:
|
try:
|
||||||
with temporary_database(f"sqlite:///{root / 'directory.db'}") as database:
|
with temporary_database(f"sqlite:///{root / 'directory.db'}") as database:
|
||||||
|
create_scope_tables(database.engine)
|
||||||
Base.metadata.create_all(bind=database.engine)
|
Base.metadata.create_all(bind=database.engine)
|
||||||
with database.session() as session:
|
with database.session() as session:
|
||||||
tenant = Tenant(id="tenant-directory", slug="directory", name="Directory Tenant", settings={})
|
tenant = Tenant(id="tenant-directory", slug="directory", name="Directory Tenant", settings={})
|
||||||
@@ -138,15 +152,60 @@ class IdentityOrganizationContractTests(unittest.TestCase):
|
|||||||
account_id="account-directory",
|
account_id="account-directory",
|
||||||
is_primary=True,
|
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(
|
unit = OrganizationUnit(
|
||||||
id="ou-directory",
|
id="ou-directory",
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
|
unit_type_id=unit_type.id,
|
||||||
slug="registry",
|
slug="registry",
|
||||||
name="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(
|
function = OrganizationFunction(
|
||||||
id="function-directory",
|
id="function-directory",
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
|
function_type_id=function_type.id,
|
||||||
organization_unit_id=unit.id,
|
organization_unit_id=unit.id,
|
||||||
slug="registry-clerk",
|
slug="registry-clerk",
|
||||||
name="Registry Clerk",
|
name="Registry Clerk",
|
||||||
@@ -154,26 +213,44 @@ class IdentityOrganizationContractTests(unittest.TestCase):
|
|||||||
assignment = OrganizationFunctionAssignment(
|
assignment = OrganizationFunctionAssignment(
|
||||||
id="assignment-directory",
|
id="assignment-directory",
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
account_id="account-directory",
|
|
||||||
identity_id=identity.id,
|
identity_id=identity.id,
|
||||||
function_id=function.id,
|
function_id=function.id,
|
||||||
organization_unit_id=unit.id,
|
organization_unit_id=unit.id,
|
||||||
applies_to_subunits=True,
|
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()
|
session.commit()
|
||||||
|
|
||||||
from govoplan_identity.backend.directory import SqlIdentityDirectory
|
from govoplan_identity.backend.directory import SqlIdentityDirectory
|
||||||
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
|
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
|
||||||
|
|
||||||
identity_directory = SqlIdentityDirectory()
|
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("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("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(["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("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("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(
|
self.assertEqual(
|
||||||
["assignment-directory"],
|
["assignment-directory"],
|
||||||
[item.id for item in organization_directory.function_assignments_for_account("account-directory", tenant_id="tenant-directory")],
|
[item.id for item in organization_directory.function_assignments_for_account("account-directory", tenant_id="tenant-directory")],
|
||||||
|
|||||||
@@ -219,11 +219,15 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
def test_discovers_installed_core_and_product_module_manifests(self) -> None:
|
def test_discovers_installed_core_and_product_module_manifests(self) -> None:
|
||||||
manifests = available_module_manifests()
|
manifests = available_module_manifests()
|
||||||
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(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["campaigns"].optional_dependencies, ("files", "mail"))
|
||||||
self.assertEqual(manifests["dashboard"].dependencies, ("access",))
|
self.assertEqual(manifests["dashboard"].dependencies, ())
|
||||||
self.assertEqual(manifests["docs"].dependencies, ("access",))
|
self.assertTrue(manifests["dashboard"].required_capabilities)
|
||||||
self.assertEqual(manifests["ops"].dependencies, ("access",))
|
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:
|
def test_access_manifest_contributes_admin_frontend_metadata(self) -> None:
|
||||||
manifests = available_module_manifests()
|
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_access.backend.db.models import Account, ApiKey, AuthSession, User
|
||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.tenancy.scope import scope_registry
|
||||||
from govoplan_tenancy.backend.db.models import Tenant
|
from govoplan_tenancy.backend.db.models import Tenant
|
||||||
|
|
||||||
self.assertIs(AccessBase, Base)
|
self.assertIs(AccessBase, Base)
|
||||||
self.assertEqual("access_accounts", Account.__tablename__)
|
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_users", User.__tablename__)
|
||||||
self.assertEqual("access_api_keys", ApiKey.__tablename__)
|
self.assertEqual("access_api_keys", ApiKey.__tablename__)
|
||||||
self.assertEqual("access_auth_sessions", AuthSession.__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("admin_governance_templates", GovernanceTemplate.__tablename__)
|
||||||
self.assertEqual("core_system_settings", SystemSettings.__tablename__)
|
self.assertEqual("core_system_settings", SystemSettings.__tablename__)
|
||||||
self.assertIn("access_accounts", Base.metadata.tables)
|
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("accounts", Base.metadata.tables)
|
||||||
self.assertNotIn("auth_sessions", 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:
|
def test_enabled_module_permutations_register_expected_routes(self) -> None:
|
||||||
cases = (
|
cases = (
|
||||||
("core_only", (), {"tenancy", "access"}, set()),
|
("core_only", (), {"access"}, set()),
|
||||||
("files_only", ("files",), {"tenancy", "access", "files"}, {"/api/v1/files"}),
|
("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}),
|
||||||
("mail_only", ("mail",), {"tenancy", "access", "mail"}, {"/api/v1/mail"}),
|
("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}),
|
||||||
("campaign_without_files_or_mail", ("campaigns",), {"tenancy", "access", "campaigns"}, {"/api/v1/campaigns"}),
|
("campaign_without_files_or_mail", ("campaigns",), {"access", "campaigns"}, {"/api/v1/campaigns"}),
|
||||||
("campaign_without_files", ("campaigns", "mail"), {"tenancy", "access", "campaigns", "mail"}, {"/api/v1/campaigns", "/api/v1/mail"}),
|
("campaign_without_files", ("campaigns", "mail"), {"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"}),
|
("campaign_with_files_but_no_mail", ("campaigns", "files"), {"access", "campaigns", "files"}, {"/api/v1/campaigns", "/api/v1/files"}),
|
||||||
("dashboard_only", ("dashboard",), {"tenancy", "access", "dashboard"}, set()),
|
("dashboard_only", ("dashboard",), {"access", "dashboard"}, set()),
|
||||||
("docs_and_ops", ("docs", "ops"), {"tenancy", "access", "docs", "ops"}, {"/api/v1/docs", "/api/v1/ops"}),
|
("docs_and_ops", ("docs", "ops"), {"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"}),
|
("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:
|
for name, enabled_modules, expected_modules, expected_prefixes in cases:
|
||||||
with self.subTest(name=name):
|
with self.subTest(name=name):
|
||||||
@@ -524,12 +530,32 @@ finally:
|
|||||||
self.assertEqual(files_enabled, registry.integration_enabled("campaigns", "files"))
|
self.assertEqual(files_enabled, registry.integration_enabled("campaigns", "files"))
|
||||||
self.assertEqual(mail_enabled, registry.integration_enabled("campaigns", "mail"))
|
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:
|
def test_module_management_plan_adds_protected_modules_and_required_dependencies(self) -> None:
|
||||||
manifests = available_module_manifests()
|
manifests = available_module_manifests()
|
||||||
|
|
||||||
plan = plan_desired_enabled_modules(("campaigns",), 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)
|
self.assertEqual((), plan.added_dependencies)
|
||||||
|
|
||||||
def test_module_install_plan_is_saved_alongside_desired_state(self) -> None:
|
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)
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
payload_modules = {item["id"] for item in response.json()["modules"]}
|
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:
|
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))
|
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)
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
payload_modules = {item["id"] for item in response.json()["modules"]}
|
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:
|
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))
|
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)
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
payload_modules = {item["id"] for item in response.json()["modules"]}
|
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:
|
def test_module_lifecycle_enables_and_disables_routes_without_restart(self) -> None:
|
||||||
app, _settings_obj = self._app_for_modules(())
|
app, _settings_obj = self._app_for_modules(())
|
||||||
@@ -2108,14 +2134,14 @@ finally:
|
|||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
response = client.get("/api/v1/platform/modules")
|
response = client.get("/api/v1/platform/modules")
|
||||||
self.assertEqual(response.status_code, 200, response.text)
|
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))
|
self.assertFalse("/api/v1/files" in _route_paths(app))
|
||||||
|
|
||||||
result = lifecycle.apply_enabled_modules(("files",), migrate=False)
|
result = lifecycle.apply_enabled_modules(("files",), migrate=False)
|
||||||
self.assertEqual(("files",), result.activated_modules)
|
self.assertEqual(("files",), result.activated_modules)
|
||||||
response = client.get("/api/v1/platform/modules")
|
response = client.get("/api/v1/platform/modules")
|
||||||
self.assertEqual(response.status_code, 200, response.text)
|
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.assertTrue("/api/v1/files" in _route_paths(app))
|
||||||
self.assertEqual(401, client.get("/api/v1/files").status_code)
|
self.assertEqual(401, client.get("/api/v1/files").status_code)
|
||||||
|
|
||||||
@@ -2123,7 +2149,7 @@ finally:
|
|||||||
self.assertEqual(("files",), result.deactivated_modules)
|
self.assertEqual(("files",), result.deactivated_modules)
|
||||||
response = client.get("/api/v1/platform/modules")
|
response = client.get("/api/v1/platform/modules")
|
||||||
self.assertEqual(response.status_code, 200, response.text)
|
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")
|
disabled_response = client.get("/api/v1/files")
|
||||||
self.assertEqual(404, disabled_response.status_code)
|
self.assertEqual(404, disabled_response.status_code)
|
||||||
self.assertEqual("Module is disabled: files", disabled_response.json()["detail"])
|
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:
|
def test_module_permutations_start_when_absent_modules_are_physically_unavailable(self) -> None:
|
||||||
cases = (
|
cases = (
|
||||||
((), ("govoplan_files", "govoplan_mail", "govoplan_campaign")),
|
((), ("govoplan_tenancy", "govoplan_files", "govoplan_mail", "govoplan_campaign")),
|
||||||
(("files",), ("govoplan_mail", "govoplan_campaign")),
|
(("files",), ("govoplan_tenancy", "govoplan_mail", "govoplan_campaign")),
|
||||||
(("mail",), ("govoplan_files", "govoplan_campaign")),
|
(("mail",), ("govoplan_tenancy", "govoplan_files", "govoplan_campaign")),
|
||||||
(("campaigns",), ("govoplan_files", "govoplan_mail")),
|
(("campaigns",), ("govoplan_tenancy", "govoplan_files", "govoplan_mail")),
|
||||||
(("campaigns", "files"), ("govoplan_mail",)),
|
(("campaigns", "files"), ("govoplan_tenancy", "govoplan_mail")),
|
||||||
(("campaigns", "mail"), ("govoplan_files",)),
|
(("campaigns", "mail"), ("govoplan_tenancy", "govoplan_files")),
|
||||||
(("campaigns", "files", "mail"), ()),
|
(("campaigns", "files", "mail"), ()),
|
||||||
)
|
)
|
||||||
for enabled_modules, blocked_modules in cases:
|
for enabled_modules, blocked_modules in cases:
|
||||||
|
|||||||
26
webui/package-lock.json
generated
26
webui/package-lock.json
generated
@@ -16,7 +16,8 @@
|
|||||||
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
||||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||||
"@govoplan/mail-webui": "file:../../govoplan-mail/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": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -968,6 +988,10 @@
|
|||||||
"resolved": "../../govoplan-ops/webui",
|
"resolved": "../../govoplan-ops/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/organizations-webui": {
|
||||||
|
"resolved": "../../govoplan-organizations/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
||||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||||
|
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui"
|
"@govoplan/ops-webui": "file:../../govoplan-ops/webui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.6",
|
"@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/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/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"
|
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const packageByModule = {
|
|||||||
docs: "@govoplan/docs-webui",
|
docs: "@govoplan/docs-webui",
|
||||||
files: "@govoplan/files-webui",
|
files: "@govoplan/files-webui",
|
||||||
mail: "@govoplan/mail-webui",
|
mail: "@govoplan/mail-webui",
|
||||||
|
organizations: "@govoplan/organizations-webui",
|
||||||
ops: "@govoplan/ops-webui"
|
ops: "@govoplan/ops-webui"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,11 +20,12 @@ const cases = [
|
|||||||
{ name: "dashboard-only", modules: ["dashboard"] },
|
{ name: "dashboard-only", modules: ["dashboard"] },
|
||||||
{ name: "files-only", modules: ["files"] },
|
{ name: "files-only", modules: ["files"] },
|
||||||
{ name: "mail-only", modules: ["mail"] },
|
{ name: "mail-only", modules: ["mail"] },
|
||||||
|
{ name: "organizations-only", modules: ["organizations"] },
|
||||||
{ name: "campaign-only", modules: ["campaigns"] },
|
{ name: "campaign-only", modules: ["campaigns"] },
|
||||||
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
|
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
|
||||||
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
|
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
|
||||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
{ 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;
|
const npmExec = process.env.npm_execpath;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const defaultWebModulePackages = [
|
|||||||
"@govoplan/docs-webui",
|
"@govoplan/docs-webui",
|
||||||
"@govoplan/files-webui",
|
"@govoplan/files-webui",
|
||||||
"@govoplan/mail-webui",
|
"@govoplan/mail-webui",
|
||||||
|
"@govoplan/organizations-webui",
|
||||||
"@govoplan/ops-webui"
|
"@govoplan/ops-webui"
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ export default defineConfig({
|
|||||||
fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-files/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-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-campaign/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url))
|
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url))
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user