From f7313d942f87c8568e49bc3be445f3744a92462d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 6 Nov 2026 00:50:28 +0100 Subject: [PATCH 1/2] refactor(permissions): follow the module layout (moves only) constants/service/registry sync/write safety/group labels -> core/, grant source, role invalidation and user cleanup -> grants/ (domain area), deps and endpoints -> web/, pages/components -> ui/components. Tests mirror the areas. permissions.deps and permissions.group_labels stay as deprecated sys.modules aliases (remove_in 0.0.46). The durable handler name permissions.user_cleanup.purge_user_grants is unchanged. Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 1 + docs/module-layout.md | 2 +- docs/modules/permissions.md | 8 +-- host/tests/test_deprecation_expiry.py | 4 +- host/tests/test_module_layout.py | 7 +-- modules/permissions/README.md | 4 +- .../permissions/permissions/core/__init__.py | 5 ++ .../permissions/{ => core}/_registry_sync.py | 0 .../permissions/{ => core}/constants.py | 0 .../permissions/core/group_labels.py | 31 ++++++++++ .../permissions/{ => core}/service.py | 10 ++-- .../permissions/{ => core}/write_safety.py | 2 +- modules/permissions/permissions/deps.py | 60 ++----------------- .../permissions/grants/__init__.py | 5 ++ .../{ => grants}/role_invalidation.py | 2 +- .../{grants.py => grants/source.py} | 2 +- .../permissions/{ => grants}/user_cleanup.py | 2 +- .../permissions/permissions/group_labels.py | 34 +++-------- modules/permissions/permissions/module.py | 18 +++--- .../permissions/pages/RoleEdit.tsx | 4 +- .../permissions/pages/UserEdit.tsx | 4 +- .../components/GrantsGroupCard.tsx | 0 .../{pages => ui}/components/GroupHeading.tsx | 0 .../components/PermissionLabel.tsx | 0 .../components/PermissionRow.tsx | 0 .../components/RoleGroupCard.tsx | 0 .../components/permission-groups.ts | 0 .../permissions/permissions/web/__init__.py | 1 + modules/permissions/permissions/web/deps.py | 59 ++++++++++++++++++ .../{ => web}/endpoints/__init__.py | 0 .../permissions/{ => web}/endpoints/api.py | 6 +- .../permissions/{ => web}/endpoints/views.py | 8 +-- .../tests-js/PermissionRow.test.tsx | 2 +- .../labels/test_permissions_group_i18n.py} | 2 +- .../module}/test_permissions_grant_search.py | 4 +- .../module}/test_permissions_module.py | 4 +- .../module}/test_permissions_startup_race.py | 2 +- .../service}/test_permissions_directory.py | 2 +- .../test_permissions_inheritance_sources.py | 2 +- .../service}/test_permissions_role_overlay.py | 2 +- .../service}/test_permissions_role_update.py | 2 +- .../test_permissions_runtime_sources.py | 2 +- .../test_permissions_app_cache_isolation.py | 2 +- ...t_permissions_direct_grants_everywhere.py} | 4 +- .../test_permissions_grants_singleflight.py | 2 +- ..._permissions_service_grant_invalidation.py | 4 +- .../test_permissions_role_invalidation.py | 4 +- .../test_permissions_write_safety.py | 6 +- .../tests/test_tenant_role_permissions.py | 2 +- packages/ui/src/lib/auth-routes.ts | 2 +- 50 files changed, 180 insertions(+), 149 deletions(-) create mode 100644 modules/permissions/permissions/core/__init__.py rename modules/permissions/permissions/{ => core}/_registry_sync.py (100%) rename modules/permissions/permissions/{ => core}/constants.py (100%) create mode 100644 modules/permissions/permissions/core/group_labels.py rename modules/permissions/permissions/{ => core}/service.py (96%) rename modules/permissions/permissions/{ => core}/write_safety.py (98%) create mode 100644 modules/permissions/permissions/grants/__init__.py rename modules/permissions/permissions/{ => grants}/role_invalidation.py (91%) rename modules/permissions/permissions/{grants.py => grants/source.py} (98%) rename modules/permissions/permissions/{ => grants}/user_cleanup.py (91%) rename modules/permissions/permissions/{pages => ui}/components/GrantsGroupCard.tsx (100%) rename modules/permissions/permissions/{pages => ui}/components/GroupHeading.tsx (100%) rename modules/permissions/permissions/{pages => ui}/components/PermissionLabel.tsx (100%) rename modules/permissions/permissions/{pages => ui}/components/PermissionRow.tsx (100%) rename modules/permissions/permissions/{pages => ui}/components/RoleGroupCard.tsx (100%) rename modules/permissions/permissions/{pages => ui}/components/permission-groups.ts (100%) create mode 100644 modules/permissions/permissions/web/__init__.py create mode 100644 modules/permissions/permissions/web/deps.py rename modules/permissions/permissions/{ => web}/endpoints/__init__.py (100%) rename modules/permissions/permissions/{ => web}/endpoints/api.py (95%) rename modules/permissions/permissions/{ => web}/endpoints/views.py (95%) rename modules/permissions/tests/{test_permission_group_i18n.py => core/labels/test_permissions_group_i18n.py} (96%) rename modules/permissions/tests/{ => core/module}/test_permissions_grant_search.py (88%) rename modules/permissions/tests/{ => core/module}/test_permissions_module.py (98%) rename modules/permissions/tests/{ => core/module}/test_permissions_startup_race.py (97%) rename modules/permissions/tests/{ => core/service}/test_permissions_directory.py (97%) rename modules/permissions/tests/{ => core/service}/test_permissions_inheritance_sources.py (98%) rename modules/permissions/tests/{ => core/service}/test_permissions_role_overlay.py (97%) rename modules/permissions/tests/{ => core/service}/test_permissions_role_update.py (96%) rename modules/permissions/tests/{ => core/service}/test_permissions_runtime_sources.py (98%) rename modules/permissions/tests/{ => grants/cache}/test_permissions_app_cache_isolation.py (97%) rename modules/permissions/tests/{test_direct_grants_everywhere.py => grants/cache/test_permissions_direct_grants_everywhere.py} (95%) rename modules/permissions/tests/{ => grants/cache}/test_permissions_grants_singleflight.py (98%) rename modules/permissions/tests/{ => grants/cache}/test_permissions_service_grant_invalidation.py (94%) rename modules/permissions/tests/{ => grants/invalidation}/test_permissions_role_invalidation.py (96%) rename modules/permissions/tests/{ => web/endpoints}/test_permissions_write_safety.py (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8847e8b20..a7952656d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ All notable changes to this project are documented in this file. The format is b - `oidc` follows the module layout: `oidc.core.{constants,settings,presets,state,runtime}`, `oidc.identity.{provider,client,discovery,renewal,directory,persistence,tokens,jwks}` and `oidc.web.endpoints`. `oidc.{settings,provider,jwks}` still import, with a `DeprecationWarning`, until 0.0.46; other old `oidc.*` paths are gone. - `api_tokens` follows the module layout: `api_tokens.core.{constants,settings,services,tenant_cleanup,user_cleanup}`, `api_tokens.credentials.{format,scopes,owner,service,resolver,audit}` (`tokens.py` is now `credentials/format.py`), `api_tokens.web.{deps,endpoints}`, and the page components and types move to `ui/`. `api_tokens.{constants,service,resolver,tokens}` still import, with a `DeprecationWarning`, until 0.0.46. - `branding` follows the module layout: `branding.core.{constants,settings,services,service,presets,tenant_definitions,tenant_branding}`, `branding.assets.{images,reaper}`, `branding.web.{deps,shared_props,endpoints}`, and the form components move from `components/` to `ui/components/`. `branding.{constants,settings,services,tenant_branding,images}` and `branding.endpoints.assets` still import, with a `DeprecationWarning`, until 0.0.46. +- `permissions` follows the module layout: `permissions.core.{constants,service,group_labels,write_safety}`, `permissions.grants.{source,role_invalidation,user_cleanup}` (`grants.py` is now `grants/source.py`), `permissions.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `permissions.group_labels` still imports, with a `DeprecationWarning`, until 0.0.46. The durable handler name `permissions.user_cleanup.purge_user_grants` is unchanged. - Coalesce concurrent tenant membership and permission grant cache misses. In-flight invalidation retries against fresh authorization data; failures and cancellation release waiting readers. Cache unknown tenant subdomains for five seconds and drop the new slug's host lookups (on a new `tenants.hosts` invalidation channel, so no user id is read as a slug) and the owner's memberships after committed tenant creation; other users' caches are not cleared. Services built outside request dependencies pass `invalidate_hosts=make_host_invalidator(app)` beside `invalidate=make_invalidator(app)`. Retries after concurrent invalidation are bounded to three reads (2026-11-05 local performance review). diff --git a/docs/module-layout.md b/docs/module-layout.md index 339edb0eb..4787b92b8 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -223,7 +223,7 @@ when a later module PR moves files. | keycloak | 3 (+5 shims) | 0 | follows this layout | | mail | 2 (+2 shims) | 0 | follows this layout | | oidc | 3 (+3 shims) | 0 | follows this layout | -| permissions | 12 | 14 | flat | +| permissions | 3 (+2 shims) | 0 | follows this layout | | settings | 31 | 43 | flat | | sharing | 3 (+3 shims) | 0 | follows this layout | | site_lock | 2 (+1 shim) | 0 | follows this layout | diff --git a/docs/modules/permissions.md b/docs/modules/permissions.md index 50bd375b8..36dbf3c3b 100644 --- a/docs/modules/permissions.md +++ b/docs/modules/permissions.md @@ -6,7 +6,7 @@ The permissions module decouples role-based and per-user permission grants from - An admin UI to edit them. - A grant source that feeds direct user grants into the framework's permission resolution. -There is one `RequiresPermission`, in `simple_module_hosting.permissions`. With this module installed it honours direct user grants as well as roles, and so does everything else that reads the resolved set: `resolved_permissions_for`, the menu filter, and the frontend's `auth.permissions`. `permissions.deps.RequiresPermission` is the same class, kept so existing imports still work. Before GH #337 they were separate classes, and a direct grant took effect only on routes that imported the `permissions.deps` one. +There is one `RequiresPermission`, in `simple_module_hosting.permissions`. With this module installed it honours direct user grants as well as roles, and so does everything else that reads the resolved set: `resolved_permissions_for`, the menu filter, and the frontend's `auth.permissions`. `permissions.web.deps.RequiresPermission` is the same class, kept so existing imports still work. Before GH #337 they were separate classes, and a direct grant took effect only on routes that imported the `permissions.web.deps` one. ## ModuleMeta @@ -77,7 +77,7 @@ async def delete_order(order_id: int) -> None: ... `RequiresPermission(permission)` takes a **single** permission key and 403s unless the request's user holds it, considering: 1. The keys assigned to any of the user's roles. -2. The keys assigned directly to the user (`permissions_user_permission`), contributed by `permissions.grants.direct_grant_source` via `PermissionRegistry.add_grant_source`. +2. The keys assigned directly to the user (`permissions_user_permission`), contributed by `permissions.grants.source.direct_grant_source` via `PermissionRegistry.add_grant_source`. 3. The implicit `WILDCARD` grant. The `admin` role is synced to hold every permission key at startup, so admins pass any check. All three are resolved once per request by `InertiaLayoutDataMiddleware` and cached on `request.state.resolved_permissions`. `auth.deps.require_permission(*keys)` reads the same set, with any-of semantics. @@ -86,11 +86,11 @@ Workers that start together (a fresh install, or a release that adds keys) all s ### Caching and propagation -The grant source runs on every authenticated request, so each application caches a user's direct grants for 30 seconds (`permissions.grants.GRANTS_TTL_SECONDS`). Co-hosted applications keep separate caches, and a bus invalidation clears only the cache of the app it was subscribed for, even for the same user UUID (#736). Maintenance paths with no app (`clear_grants_cache()`, account cleanup) still clear every app's cache, which only forgets. Saving a user's grants publishes `permissions.user_grants` on the `InvalidationBus` once the transaction commits. `PermissionService.set_user_permissions` owns this callback, so service callers +The grant source runs on every authenticated request, so each application caches a user's direct grants for 30 seconds (`permissions.grants.source.GRANTS_TTL_SECONDS`). Co-hosted applications keep separate caches, and a bus invalidation clears only the cache of the app it was subscribed for, even for the same user UUID (#736). Maintenance paths with no app (`clear_grants_cache()`, account cleanup) still clear every app's cache, which only forgets. Saving a user's grants publishes `permissions.user_grants` on the `InvalidationBus` once the transaction commits. `PermissionService.set_user_permissions` owns this callback, so service callers receive the same invalidation as endpoints (#687, #621); rollback publishes nothing. Pass `app` to the service for cross-worker delivery. Without an app, commit clears the local grant cache. The endpoint helper -`permissions.deps.invalidate_grants_on_commit` is deprecated until 0.1.0; callers +`permissions.web.deps.invalidate_grants_on_commit` is deprecated until 0.1.0; callers no longer need it. A service constructed without a `request` is a trusted in-process caller; pass the actor's request for delegated writes. The worker that made the change sees it on the next request. Other workers see it immediately when `background_tasks` provides a Redis transport, and otherwise within the TTL. diff --git a/host/tests/test_deprecation_expiry.py b/host/tests/test_deprecation_expiry.py index e2e81acf9..6304c3bef 100644 --- a/host/tests/test_deprecation_expiry.py +++ b/host/tests/test_deprecation_expiry.py @@ -24,7 +24,7 @@ _SHIM_MODULES = ( "auth.deps", "host.routes_legacy", - "permissions.deps", + "permissions.web.deps", "settings.registration", "settings.module_registry", "settings.hydrate", @@ -37,7 +37,7 @@ "ResourceType.shareable_actions", "auth.deps.require_permission", "host.routes_legacy (pre-/admin view URL redirects)", - "permissions.deps.invalidate_grants_on_commit", + "permissions.web.deps.invalidate_grants_on_commit", "settings.registration", "settings.module_registry", "settings.hydrate", diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py index 15a2b69fb..719db71b5 100644 --- a/host/tests/test_module_layout.py +++ b/host/tests/test_module_layout.py @@ -38,6 +38,7 @@ "keycloak": frozenset({"identity"}), "mail": frozenset({"delivery"}), "oidc": frozenset({"identity"}), + "permissions": frozenset({"grants"}), } # Frozen inventory, not a computed baseline: moves must remove their old entries. @@ -77,12 +78,6 @@ uploads.py visibility.py worker.py worker_support.py """.split() ), - "permissions": frozenset( - """ - _registry_sync.py constants.py deps.py endpoints grants.py group_labels.py - role_invalidation.py service.py user_cleanup.py write_safety.py - """.split() - ), "settings": frozenset( """ _announce.py _field_meta.py _listing.py _managed_keys.py _module_settings.py diff --git a/modules/permissions/README.md b/modules/permissions/README.md index f13833790..7c50283b9 100644 --- a/modules/permissions/README.md +++ b/modules/permissions/README.md @@ -13,7 +13,7 @@ pip install simple_module_permissions ## What it provides - `Role` and `Permission` SQLModel tables, seeded from module-registered defaults. -- Direct user-level grants feed the framework's permission resolution through a grant source, so `simple_module_hosting.permissions.RequiresPermission` (re-exported as `permissions.deps.RequiresPermission`) and `auth.deps.require_permission(...)` both honour them. +- Direct user-level grants feed the framework's permission resolution through a grant source, so `simple_module_hosting.permissions.RequiresPermission` (re-exported as `permissions.web.deps.RequiresPermission`) and `auth.deps.require_permission(...)` both honour them. - Admin UI for assigning roles/permissions to users, reached through the users admin area at `/users/admin`; role and user editors live at `/permissions/roles/{id}/edit` and `/permissions/users/{id}/edit`. - `register_permissions(self, registry)` hook — every module declares its permission strings at boot via `registry.add_group(...)`; the registry dedupes and persists them. @@ -37,7 +37,7 @@ Guard a route: ```python from fastapi import APIRouter, Depends -from permissions.deps import RequiresPermission +from permissions.web.deps import RequiresPermission router = APIRouter() diff --git a/modules/permissions/permissions/core/__init__.py b/modules/permissions/permissions/core/__init__.py new file mode 100644 index 000000000..87673deb1 --- /dev/null +++ b/modules/permissions/permissions/core/__init__.py @@ -0,0 +1,5 @@ +"""What every permissions feature builds on. + +Constants, the permission service, registry sync, write safety and localized +group labels. +""" diff --git a/modules/permissions/permissions/_registry_sync.py b/modules/permissions/permissions/core/_registry_sync.py similarity index 100% rename from modules/permissions/permissions/_registry_sync.py rename to modules/permissions/permissions/core/_registry_sync.py diff --git a/modules/permissions/permissions/constants.py b/modules/permissions/permissions/core/constants.py similarity index 100% rename from modules/permissions/permissions/constants.py rename to modules/permissions/permissions/core/constants.py diff --git a/modules/permissions/permissions/core/group_labels.py b/modules/permissions/permissions/core/group_labels.py new file mode 100644 index 000000000..370a68634 --- /dev/null +++ b/modules/permissions/permissions/core/group_labels.py @@ -0,0 +1,31 @@ +"""Registry groups as the permission editors show them, in the request's locale.""" + +from __future__ import annotations + +from collections.abc import Callable + +from simple_module_core.permissions import PermissionGroup, PermissionRegistry + +from permissions.contracts.schemas import PermissionGroupOut + + +def _label(group: PermissionGroup, translate: Callable[[str], str] | None) -> str: + # Translator.t() echoes an unknown key back; keep the literal name then. + if not group.label_key or translate is None: + return group.name + translated = translate(group.label_key) + return group.name if translated == group.label_key else translated + + +def localized_groups( + registry: PermissionRegistry, translate: Callable[[str], str] | None = None +) -> list[PermissionGroupOut]: + return [ + PermissionGroupOut( + name=g.name, + label=_label(g, translate), + permissions=sorted(g.permissions), + labels=registry.source_labels(g.name), + ) + for g in registry.groups + ] diff --git a/modules/permissions/permissions/service.py b/modules/permissions/permissions/core/service.py similarity index 96% rename from modules/permissions/permissions/service.py rename to modules/permissions/permissions/core/service.py index 75be88e98..a37241cde 100644 --- a/modules/permissions/permissions/service.py +++ b/modules/permissions/permissions/core/service.py @@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col -from permissions._registry_sync import RegistrySyncMixin, _role_names from permissions.contracts.schemas import ( PermissionGroupOut, RoleOut, @@ -20,9 +19,10 @@ UserOut, UserPermissionsOut, ) -from permissions.group_labels import localized_groups +from permissions.core._registry_sync import RegistrySyncMixin, _role_names +from permissions.core.group_labels import localized_groups +from permissions.core.write_safety import check_permission_write from permissions.models import RolePermission, UserPermission -from permissions.write_safety import check_permission_write if TYPE_CHECKING: from simple_module_core.permissions import PermissionRegistry @@ -145,7 +145,7 @@ async def committed() -> None: if self.app is None: self.registry.set_role_overlay(role.name, wanted | (existing - registered)) else: - from permissions.role_invalidation import CHANNEL + from permissions.grants.role_invalidation import CHANNEL await self.app.state.sm.invalidation.publish(CHANNEL, key=role.name) @@ -227,7 +227,7 @@ async def set_user_permissions( ) await self.db.flush() - from permissions.grants import clear_grants_cache, publish_grants_changed + from permissions.grants.source import clear_grants_cache, publish_grants_changed app = self.app if app is None: diff --git a/modules/permissions/permissions/write_safety.py b/modules/permissions/permissions/core/write_safety.py similarity index 98% rename from modules/permissions/permissions/write_safety.py rename to modules/permissions/permissions/core/write_safety.py index 3cc654915..7632e62c2 100644 --- a/modules/permissions/permissions/write_safety.py +++ b/modules/permissions/permissions/core/write_safety.py @@ -5,7 +5,7 @@ from simple_module_core.user_directory import DirectoryUser from simple_module_hosting.permissions import resolve_principal_permissions -from permissions.constants import PERM_MANAGE +from permissions.core.constants import PERM_MANAGE class PermissionWriteRefused(HTTPException): diff --git a/modules/permissions/permissions/deps.py b/modules/permissions/permissions/deps.py index 0abaf1f75..a79c7ec4e 100644 --- a/modules/permissions/permissions/deps.py +++ b/modules/permissions/permissions/deps.py @@ -1,59 +1,11 @@ -"""FastAPI dependencies for the Permissions module.""" +"""Moved to :mod:`permissions.web.deps`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from fastapi import Depends, Request -from simple_module_core.deprecation import deprecated -from simple_module_core.permissions import PermissionRegistry -from simple_module_core.user_directory import get_user_directory -from simple_module_db.deps import get_db -from simple_module_hosting.permissions import RequiresPermission as _HostingRequiresPermission -from sqlalchemy.ext.asyncio import AsyncSession +import importlib +import sys -from permissions.grants import publish_grants_changed -from permissions.service import PermissionService +from simple_module_core.deprecation import deprecated_module -__all__ = [ - "RequiresPermission", - "assigned_by", - "get_permission_registry", - "get_permission_service", - "invalidate_grants_on_commit", -] - - -async def get_permission_registry(request: Request) -> PermissionRegistry: - return request.app.state.sm.permissions - - -async def get_permission_service( - request: Request, - db: AsyncSession = Depends(get_db), - registry: PermissionRegistry = Depends(get_permission_registry), -) -> PermissionService: - return PermissionService( - db, registry, get_user_directory(request.app), app=request.app, request=request - ) - - -def assigned_by(request: Request) -> str | None: - """Authenticated user id string, for audit columns.""" - user = getattr(request.state, "user", None) - return str(user.id) if user is not None else None - - -@deprecated(remove_in="0.1.0", use="PermissionService.set_user_permissions (invalidates on commit)") -def invalidate_grants_on_commit(request: Request, service: PermissionService, user_id) -> None: - """Compatibility for callers predating service-owned invalidation.""" - service.db.on_commit(lambda: publish_grants_changed(request.app, user_id)) - - -RequiresPermission = _HostingRequiresPermission -"""Kept for existing imports: the framework's class now honours direct grants. - -This used to be a separate class that read the ``permissions_user_permission`` -table itself, while the framework's read roles only — so a direct grant worked -on this module's routes and nowhere else (GH #337). The grants now reach every -check through :func:`permissions.grants.direct_grant_source`, and one class is -the only way the two cannot drift apart again. -""" +deprecated_module("permissions.deps", remove_in="0.0.46", use="permissions.web.deps") +sys.modules[__name__] = importlib.import_module("permissions.web.deps") diff --git a/modules/permissions/permissions/grants/__init__.py b/modules/permissions/permissions/grants/__init__.py new file mode 100644 index 000000000..40f320454 --- /dev/null +++ b/modules/permissions/permissions/grants/__init__.py @@ -0,0 +1,5 @@ +"""Direct per-user grants. + +The cached grant source, role and grant invalidation across workers, and +deleted-user cleanup. +""" diff --git a/modules/permissions/permissions/role_invalidation.py b/modules/permissions/permissions/grants/role_invalidation.py similarity index 91% rename from modules/permissions/permissions/role_invalidation.py rename to modules/permissions/permissions/grants/role_invalidation.py index 5bb81f203..b88da3e75 100644 --- a/modules/permissions/permissions/role_invalidation.py +++ b/modules/permissions/permissions/grants/role_invalidation.py @@ -4,7 +4,7 @@ from simple_module_core.invalidation import InvalidationBus from simple_module_core.invalidation_refresh import InvalidationRefresh -from permissions.service import PermissionService +from permissions.core.service import PermissionService CHANNEL = "permissions.role_grants" diff --git a/modules/permissions/permissions/grants.py b/modules/permissions/permissions/grants/source.py similarity index 98% rename from modules/permissions/permissions/grants.py rename to modules/permissions/permissions/grants/source.py index 9e56261f4..945f6c9f2 100644 --- a/modules/permissions/permissions/grants.py +++ b/modules/permissions/permissions/grants/source.py @@ -3,7 +3,7 @@ Registered on the :class:`~simple_module_core.permissions.PermissionRegistry` so the framework's one permission resolution — the door (``RequiresPermission``), the menu filter and the frontend's ``auth.permissions`` — honours a grant made -on the permissions screen. Before this, only ``permissions.deps`` read the +on the permissions screen. Before this, only ``permissions.web.deps`` read the table, so the same grant was a 200 on this module's routes and a 403 on every other module's (GH #337). diff --git a/modules/permissions/permissions/user_cleanup.py b/modules/permissions/permissions/grants/user_cleanup.py similarity index 91% rename from modules/permissions/permissions/user_cleanup.py rename to modules/permissions/permissions/grants/user_cleanup.py index ce67e07ad..488b93cac 100644 --- a/modules/permissions/permissions/user_cleanup.py +++ b/modules/permissions/permissions/grants/user_cleanup.py @@ -6,7 +6,7 @@ from simple_module_db.callbacks import register_on_commit from sqlmodel import col, delete -from permissions.grants import CHANNEL, apply_invalidation +from permissions.grants.source import CHANNEL, apply_invalidation from permissions.models import UserPermission diff --git a/modules/permissions/permissions/group_labels.py b/modules/permissions/permissions/group_labels.py index 370a68634..a9c25cdec 100644 --- a/modules/permissions/permissions/group_labels.py +++ b/modules/permissions/permissions/group_labels.py @@ -1,31 +1,13 @@ -"""Registry groups as the permission editors show them, in the request's locale.""" +"""Moved to :mod:`permissions.core.group_labels`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from collections.abc import Callable +import importlib +import sys -from simple_module_core.permissions import PermissionGroup, PermissionRegistry +from simple_module_core.deprecation import deprecated_module -from permissions.contracts.schemas import PermissionGroupOut - - -def _label(group: PermissionGroup, translate: Callable[[str], str] | None) -> str: - # Translator.t() echoes an unknown key back; keep the literal name then. - if not group.label_key or translate is None: - return group.name - translated = translate(group.label_key) - return group.name if translated == group.label_key else translated - - -def localized_groups( - registry: PermissionRegistry, translate: Callable[[str], str] | None = None -) -> list[PermissionGroupOut]: - return [ - PermissionGroupOut( - name=g.name, - label=_label(g, translate), - permissions=sorted(g.permissions), - labels=registry.source_labels(g.name), - ) - for g in registry.groups - ] +deprecated_module( + "permissions.group_labels", remove_in="0.0.46", use="permissions.core.group_labels" +) +sys.modules[__name__] = importlib.import_module("permissions.core.group_labels") diff --git a/modules/permissions/permissions/module.py b/modules/permissions/permissions/module.py index 7fd0346b8..6be370ef8 100644 --- a/modules/permissions/permissions/module.py +++ b/modules/permissions/permissions/module.py @@ -13,7 +13,7 @@ from simple_module_core.permissions import PermissionRegistry from simple_module_db import independent_session -from permissions.constants import _MODULE_AUTH, _MODULE_USERS, USER_CLEANUP_HANDLER +from permissions.core.constants import _MODULE_AUTH, _MODULE_USERS, USER_CLEANUP_HANDLER if TYPE_CHECKING: from fastapi import FastAPI @@ -54,13 +54,13 @@ class PermissionsModule(ModuleBase): def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None: from simple_module_core.user_events import UserDeleted - from permissions.user_cleanup import purge_user_grants + from permissions.grants.user_cleanup import purge_user_grants bus.subscribe_durable(UserDeleted, purge_user_grants, name=USER_CLEANUP_HANDLER) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: - from permissions.endpoints.api import router as api - from permissions.endpoints.views import router as views + from permissions.web.endpoints.api import router as api + from permissions.web.endpoints.views import router as views api_router.include_router(api) view_router.include_router(views) @@ -85,8 +85,8 @@ def register_audit_links(self, registry: AuditLinkRegistry) -> None: ) def register_permissions(self, registry: PermissionRegistry) -> None: - from permissions.constants import PERM_MANAGE, PERM_VIEW, PERMISSION_GROUP - from permissions.grants import direct_grant_source + from permissions.core.constants import PERM_MANAGE, PERM_VIEW, PERMISSION_GROUP + from permissions.grants.source import direct_grant_source registry.add_group( PERMISSION_GROUP, @@ -99,10 +99,10 @@ def register_permissions(self, registry: PermissionRegistry) -> None: def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None: """Let another worker's grant change drop this worker's cached grants.""" - from permissions.grants import subscribe + from permissions.grants.source import subscribe subscribe(bus, app) - from permissions.role_invalidation import subscribe as subscribe_roles + from permissions.grants.role_invalidation import subscribe as subscribe_roles self._refresh = subscribe_roles(bus, app) @@ -112,7 +112,7 @@ def locale_dirs(self) -> dict[str, Path]: async def on_startup(self, app: FastAPI) -> None: """Replay persisted role→permission rows into the live registry.""" - from permissions.service import PermissionService + from permissions.core.service import PermissionService registry = app.state.sm.permissions async with independent_session(app) as db: diff --git a/modules/permissions/permissions/pages/RoleEdit.tsx b/modules/permissions/permissions/pages/RoleEdit.tsx index 0adbfbe15..375b715ad 100644 --- a/modules/permissions/permissions/pages/RoleEdit.tsx +++ b/modules/permissions/permissions/pages/RoleEdit.tsx @@ -12,8 +12,8 @@ import { Search } from 'lucide-react'; import type React from 'react'; import { useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; -import { filterGroups, type PermissionGroup } from './components/permission-groups'; -import { RoleGroupCard } from './components/RoleGroupCard'; +import { filterGroups, type PermissionGroup } from '../ui/components/permission-groups'; +import { RoleGroupCard } from '../ui/components/RoleGroupCard'; type Role = { id: string; name: string; description: string | null }; diff --git a/modules/permissions/permissions/pages/UserEdit.tsx b/modules/permissions/permissions/pages/UserEdit.tsx index c97aae2a5..6280eba49 100644 --- a/modules/permissions/permissions/pages/UserEdit.tsx +++ b/modules/permissions/permissions/pages/UserEdit.tsx @@ -13,8 +13,8 @@ import { Search } from 'lucide-react'; import type React from 'react'; import { useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; -import { GrantsGroupCard } from './components/GrantsGroupCard'; -import { filterGroups, type PermissionGroup } from './components/permission-groups'; +import { GrantsGroupCard } from '../ui/components/GrantsGroupCard'; +import { filterGroups, type PermissionGroup } from '../ui/components/permission-groups'; type UserProp = { id: string; email: string; full_name: string | null }; diff --git a/modules/permissions/permissions/pages/components/GrantsGroupCard.tsx b/modules/permissions/permissions/ui/components/GrantsGroupCard.tsx similarity index 100% rename from modules/permissions/permissions/pages/components/GrantsGroupCard.tsx rename to modules/permissions/permissions/ui/components/GrantsGroupCard.tsx diff --git a/modules/permissions/permissions/pages/components/GroupHeading.tsx b/modules/permissions/permissions/ui/components/GroupHeading.tsx similarity index 100% rename from modules/permissions/permissions/pages/components/GroupHeading.tsx rename to modules/permissions/permissions/ui/components/GroupHeading.tsx diff --git a/modules/permissions/permissions/pages/components/PermissionLabel.tsx b/modules/permissions/permissions/ui/components/PermissionLabel.tsx similarity index 100% rename from modules/permissions/permissions/pages/components/PermissionLabel.tsx rename to modules/permissions/permissions/ui/components/PermissionLabel.tsx diff --git a/modules/permissions/permissions/pages/components/PermissionRow.tsx b/modules/permissions/permissions/ui/components/PermissionRow.tsx similarity index 100% rename from modules/permissions/permissions/pages/components/PermissionRow.tsx rename to modules/permissions/permissions/ui/components/PermissionRow.tsx diff --git a/modules/permissions/permissions/pages/components/RoleGroupCard.tsx b/modules/permissions/permissions/ui/components/RoleGroupCard.tsx similarity index 100% rename from modules/permissions/permissions/pages/components/RoleGroupCard.tsx rename to modules/permissions/permissions/ui/components/RoleGroupCard.tsx diff --git a/modules/permissions/permissions/pages/components/permission-groups.ts b/modules/permissions/permissions/ui/components/permission-groups.ts similarity index 100% rename from modules/permissions/permissions/pages/components/permission-groups.ts rename to modules/permissions/permissions/ui/components/permission-groups.ts diff --git a/modules/permissions/permissions/web/__init__.py b/modules/permissions/permissions/web/__init__.py new file mode 100644 index 000000000..7d668357f --- /dev/null +++ b/modules/permissions/permissions/web/__init__.py @@ -0,0 +1 @@ +"""The HTTP side: request deps and the REST and Inertia endpoints.""" diff --git a/modules/permissions/permissions/web/deps.py b/modules/permissions/permissions/web/deps.py new file mode 100644 index 000000000..1b915fd1a --- /dev/null +++ b/modules/permissions/permissions/web/deps.py @@ -0,0 +1,59 @@ +"""FastAPI dependencies for the Permissions module.""" + +from __future__ import annotations + +from fastapi import Depends, Request +from simple_module_core.deprecation import deprecated +from simple_module_core.permissions import PermissionRegistry +from simple_module_core.user_directory import get_user_directory +from simple_module_db.deps import get_db +from simple_module_hosting.permissions import RequiresPermission as _HostingRequiresPermission +from sqlalchemy.ext.asyncio import AsyncSession + +from permissions.core.service import PermissionService +from permissions.grants.source import publish_grants_changed + +__all__ = [ + "RequiresPermission", + "assigned_by", + "get_permission_registry", + "get_permission_service", + "invalidate_grants_on_commit", +] + + +async def get_permission_registry(request: Request) -> PermissionRegistry: + return request.app.state.sm.permissions + + +async def get_permission_service( + request: Request, + db: AsyncSession = Depends(get_db), + registry: PermissionRegistry = Depends(get_permission_registry), +) -> PermissionService: + return PermissionService( + db, registry, get_user_directory(request.app), app=request.app, request=request + ) + + +def assigned_by(request: Request) -> str | None: + """Authenticated user id string, for audit columns.""" + user = getattr(request.state, "user", None) + return str(user.id) if user is not None else None + + +@deprecated(remove_in="0.1.0", use="PermissionService.set_user_permissions (invalidates on commit)") +def invalidate_grants_on_commit(request: Request, service: PermissionService, user_id) -> None: + """Compatibility for callers predating service-owned invalidation.""" + service.db.on_commit(lambda: publish_grants_changed(request.app, user_id)) + + +RequiresPermission = _HostingRequiresPermission +"""Kept for existing imports: the framework's class now honours direct grants. + +This used to be a separate class that read the ``permissions_user_permission`` +table itself, while the framework's read roles only — so a direct grant worked +on this module's routes and nowhere else (GH #337). The grants now reach every +check through :func:`permissions.grants.source.direct_grant_source`, and one class is +the only way the two cannot drift apart again. +""" diff --git a/modules/permissions/permissions/endpoints/__init__.py b/modules/permissions/permissions/web/endpoints/__init__.py similarity index 100% rename from modules/permissions/permissions/endpoints/__init__.py rename to modules/permissions/permissions/web/endpoints/__init__.py diff --git a/modules/permissions/permissions/endpoints/api.py b/modules/permissions/permissions/web/endpoints/api.py similarity index 95% rename from modules/permissions/permissions/endpoints/api.py rename to modules/permissions/permissions/web/endpoints/api.py index 9e4edf9f2..5708ff393 100644 --- a/modules/permissions/permissions/endpoints/api.py +++ b/modules/permissions/permissions/web/endpoints/api.py @@ -6,7 +6,6 @@ from fastapi import APIRouter, Depends, HTTPException, Request -from permissions.constants import PERM_MANAGE, PERM_VIEW from permissions.contracts.schemas import ( PermissionGroupOut, RolePermissionsOut, @@ -14,12 +13,13 @@ UserPermissionsOut, UserPermissionsUpdate, ) -from permissions.deps import ( +from permissions.core.constants import PERM_MANAGE, PERM_VIEW +from permissions.core.service import PermissionService +from permissions.web.deps import ( RequiresPermission, assigned_by, get_permission_service, ) -from permissions.service import PermissionService router = APIRouter() diff --git a/modules/permissions/permissions/endpoints/views.py b/modules/permissions/permissions/web/endpoints/views.py similarity index 95% rename from modules/permissions/permissions/endpoints/views.py rename to modules/permissions/permissions/web/endpoints/views.py index a3d9d4d04..98a38a8d7 100644 --- a/modules/permissions/permissions/endpoints/views.py +++ b/modules/permissions/permissions/web/endpoints/views.py @@ -15,15 +15,15 @@ from simple_module_inertia import InertiaResponse from starlette.responses import RedirectResponse -from permissions.constants import PERM_MANAGE from permissions.contracts.schemas import RolePermissionsUpdate, UserPermissionsUpdate -from permissions.deps import ( +from permissions.core.constants import PERM_MANAGE +from permissions.core.service import PermissionService +from permissions.core.write_safety import PermissionWriteRefused +from permissions.web.deps import ( RequiresPermission, assigned_by, get_permission_service, ) -from permissions.service import PermissionService -from permissions.write_safety import PermissionWriteRefused router = APIRouter() diff --git a/modules/permissions/tests-js/PermissionRow.test.tsx b/modules/permissions/tests-js/PermissionRow.test.tsx index 10c14292e..31b06778c 100644 --- a/modules/permissions/tests-js/PermissionRow.test.tsx +++ b/modules/permissions/tests-js/PermissionRow.test.tsx @@ -2,7 +2,7 @@ import '@testing-library/jest-dom/vitest'; import { configureI18n } from '@simple-module-py/i18n'; import { render, screen } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; -import { PermissionRow } from '../permissions/pages/components/PermissionRow'; +import { PermissionRow } from '../permissions/ui/components/PermissionRow'; class ResizeObserverStub { observe() {} diff --git a/modules/permissions/tests/test_permission_group_i18n.py b/modules/permissions/tests/core/labels/test_permissions_group_i18n.py similarity index 96% rename from modules/permissions/tests/test_permission_group_i18n.py rename to modules/permissions/tests/core/labels/test_permissions_group_i18n.py index 070496b9d..805a6889c 100644 --- a/modules/permissions/tests/test_permission_group_i18n.py +++ b/modules/permissions/tests/core/labels/test_permissions_group_i18n.py @@ -2,7 +2,7 @@ from __future__ import annotations -from permissions.service import PermissionService +from permissions.core.service import PermissionService from simple_module_core.i18n import Translator from simple_module_core.permissions import PermissionRegistry diff --git a/modules/permissions/tests/test_permissions_grant_search.py b/modules/permissions/tests/core/module/test_permissions_grant_search.py similarity index 88% rename from modules/permissions/tests/test_permissions_grant_search.py rename to modules/permissions/tests/core/module/test_permissions_grant_search.py index 13926ca5d..8c1762924 100644 --- a/modules/permissions/tests/test_permissions_grant_search.py +++ b/modules/permissions/tests/core/module/test_permissions_grant_search.py @@ -2,8 +2,8 @@ from __future__ import annotations -from permissions.constants import PERM_MANAGE, PERM_VIEW, PERMISSION_GROUP -from permissions.service import PermissionService +from permissions.core.constants import PERM_MANAGE, PERM_VIEW, PERMISSION_GROUP +from permissions.core.service import PermissionService from simple_module_core.permissions import PermissionRegistry from test_permissions_module import _seed_user from users.directory import UsersDirectory diff --git a/modules/permissions/tests/test_permissions_module.py b/modules/permissions/tests/core/module/test_permissions_module.py similarity index 98% rename from modules/permissions/tests/test_permissions_module.py rename to modules/permissions/tests/core/module/test_permissions_module.py index 3b3b3ef12..db78795cd 100644 --- a/modules/permissions/tests/test_permissions_module.py +++ b/modules/permissions/tests/core/module/test_permissions_module.py @@ -7,9 +7,9 @@ import httpx import pytest from fastapi import FastAPI -from permissions.constants import PERM_MANAGE, PERM_VIEW, PERMISSION_GROUP +from permissions.core.constants import PERM_MANAGE, PERM_VIEW, PERMISSION_GROUP +from permissions.core.service import PermissionService from permissions.module import PermissionsModule -from permissions.service import PermissionService from simple_module_core.permissions import PermissionRegistry from simple_module_db.transaction import finalize_session from sqlalchemy.ext.asyncio import AsyncSession diff --git a/modules/permissions/tests/test_permissions_startup_race.py b/modules/permissions/tests/core/module/test_permissions_startup_race.py similarity index 97% rename from modules/permissions/tests/test_permissions_startup_race.py rename to modules/permissions/tests/core/module/test_permissions_startup_race.py index f92fdd90a..f8017830a 100644 --- a/modules/permissions/tests/test_permissions_startup_race.py +++ b/modules/permissions/tests/core/module/test_permissions_startup_race.py @@ -4,9 +4,9 @@ from types import SimpleNamespace from fastapi import FastAPI +from permissions.core.service import PermissionService from permissions.models import Base, RolePermission from permissions.module import PermissionsModule -from permissions.service import PermissionService from simple_module_core.permissions import PermissionRegistry from simple_module_db.listeners import register_listeners from simple_module_db.session import init_db diff --git a/modules/permissions/tests/test_permissions_directory.py b/modules/permissions/tests/core/service/test_permissions_directory.py similarity index 97% rename from modules/permissions/tests/test_permissions_directory.py rename to modules/permissions/tests/core/service/test_permissions_directory.py index f5a0d96ea..c66543458 100644 --- a/modules/permissions/tests/test_permissions_directory.py +++ b/modules/permissions/tests/core/service/test_permissions_directory.py @@ -4,7 +4,7 @@ import uuid -from permissions.service import PermissionService +from permissions.core.service import PermissionService from simple_module_core.permissions import ADMIN_ROLE, PermissionRegistry from simple_module_core.user_directory import DirectoryUser, NullUserDirectory from sqlalchemy.ext.asyncio import AsyncSession diff --git a/modules/permissions/tests/test_permissions_inheritance_sources.py b/modules/permissions/tests/core/service/test_permissions_inheritance_sources.py similarity index 98% rename from modules/permissions/tests/test_permissions_inheritance_sources.py rename to modules/permissions/tests/core/service/test_permissions_inheritance_sources.py index 802aa7baf..187e10b3b 100644 --- a/modules/permissions/tests/test_permissions_inheritance_sources.py +++ b/modules/permissions/tests/core/service/test_permissions_inheritance_sources.py @@ -8,7 +8,7 @@ from __future__ import annotations -from permissions.service import PermissionService +from permissions.core.service import PermissionService from simple_module_core.permissions import WILDCARD, PermissionRegistry from sqlalchemy.ext.asyncio import AsyncSession from users.directory import UsersDirectory diff --git a/modules/permissions/tests/test_permissions_role_overlay.py b/modules/permissions/tests/core/service/test_permissions_role_overlay.py similarity index 97% rename from modules/permissions/tests/test_permissions_role_overlay.py rename to modules/permissions/tests/core/service/test_permissions_role_overlay.py index 888afbf42..597cb6384 100644 --- a/modules/permissions/tests/test_permissions_role_overlay.py +++ b/modules/permissions/tests/core/service/test_permissions_role_overlay.py @@ -4,7 +4,7 @@ import httpx from fastapi import FastAPI -from permissions.constants import PERM_VIEW +from permissions.core.constants import PERM_VIEW from simple_module_core.permissions import PermissionRegistry diff --git a/modules/permissions/tests/test_permissions_role_update.py b/modules/permissions/tests/core/service/test_permissions_role_update.py similarity index 96% rename from modules/permissions/tests/test_permissions_role_update.py rename to modules/permissions/tests/core/service/test_permissions_role_update.py index e536c6ee0..1ec019e6f 100644 --- a/modules/permissions/tests/test_permissions_role_update.py +++ b/modules/permissions/tests/core/service/test_permissions_role_update.py @@ -4,7 +4,7 @@ import httpx from fastapi import FastAPI -from permissions.constants import PERM_VIEW +from permissions.core.constants import PERM_VIEW class TestRolePermissionsAPI: diff --git a/modules/permissions/tests/test_permissions_runtime_sources.py b/modules/permissions/tests/core/service/test_permissions_runtime_sources.py similarity index 98% rename from modules/permissions/tests/test_permissions_runtime_sources.py rename to modules/permissions/tests/core/service/test_permissions_runtime_sources.py index f8cd049a9..3341cf36f 100644 --- a/modules/permissions/tests/test_permissions_runtime_sources.py +++ b/modules/permissions/tests/core/service/test_permissions_runtime_sources.py @@ -63,7 +63,7 @@ async def test_source_permission_enforced_by_requires_permission(app: FastAPI): async def test_saving_role_keeps_grants_of_currently_unregistered_source_keys( authenticated_client: httpx.AsyncClient, app: FastAPI ): - from permissions.service import PermissionService + from permissions.core.service import PermissionService from users.constants import USER_ROLE_ID, USER_ROLE_NAME from users.models import Role diff --git a/modules/permissions/tests/test_permissions_app_cache_isolation.py b/modules/permissions/tests/grants/cache/test_permissions_app_cache_isolation.py similarity index 97% rename from modules/permissions/tests/test_permissions_app_cache_isolation.py rename to modules/permissions/tests/grants/cache/test_permissions_app_cache_isolation.py index f5c04d3a5..2daf2410f 100644 --- a/modules/permissions/tests/test_permissions_app_cache_isolation.py +++ b/modules/permissions/tests/grants/cache/test_permissions_app_cache_isolation.py @@ -5,7 +5,7 @@ import pytest from fastapi import FastAPI, HTTPException -from permissions.grants import CHANNEL, direct_grant_source, subscribe +from permissions.grants.source import CHANNEL, direct_grant_source, subscribe from permissions.models import Base, UserPermission from simple_module_core.invalidation import InvalidationBus from simple_module_core.permissions import PermissionRegistry diff --git a/modules/permissions/tests/test_direct_grants_everywhere.py b/modules/permissions/tests/grants/cache/test_permissions_direct_grants_everywhere.py similarity index 95% rename from modules/permissions/tests/test_direct_grants_everywhere.py rename to modules/permissions/tests/grants/cache/test_permissions_direct_grants_everywhere.py index 34b3692a3..fcd8d9a87 100644 --- a/modules/permissions/tests/test_direct_grants_everywhere.py +++ b/modules/permissions/tests/grants/cache/test_permissions_direct_grants_everywhere.py @@ -14,8 +14,8 @@ import pytest from fastapi import FastAPI from feature_flags.core.constants import PERM_FEATURE_FLAGS_VIEW, VIEW_PREFIX -from permissions.deps import RequiresPermission as ModuleRequiresPermission -from permissions.grants import clear_grants_cache +from permissions.grants.source import clear_grants_cache +from permissions.web.deps import RequiresPermission as ModuleRequiresPermission from simple_module_hosting.permissions import RequiresPermission from simple_module_test import forge_session_cookie, inertia_headers diff --git a/modules/permissions/tests/test_permissions_grants_singleflight.py b/modules/permissions/tests/grants/cache/test_permissions_grants_singleflight.py similarity index 98% rename from modules/permissions/tests/test_permissions_grants_singleflight.py rename to modules/permissions/tests/grants/cache/test_permissions_grants_singleflight.py index 63596e3b4..0ee206679 100644 --- a/modules/permissions/tests/test_permissions_grants_singleflight.py +++ b/modules/permissions/tests/grants/cache/test_permissions_grants_singleflight.py @@ -8,7 +8,7 @@ import pytest from cachetools import TTLCache from fastapi import FastAPI -from permissions import grants +from permissions.grants import source as grants from simple_module_core.invalidation import Invalidation from simple_module_core.singleflight import MAX_FILL_ATTEMPTS from starlette.requests import Request diff --git a/modules/permissions/tests/test_permissions_service_grant_invalidation.py b/modules/permissions/tests/grants/cache/test_permissions_service_grant_invalidation.py similarity index 94% rename from modules/permissions/tests/test_permissions_service_grant_invalidation.py rename to modules/permissions/tests/grants/cache/test_permissions_service_grant_invalidation.py index 592f9f7b0..2a5b93082 100644 --- a/modules/permissions/tests/test_permissions_service_grant_invalidation.py +++ b/modules/permissions/tests/grants/cache/test_permissions_service_grant_invalidation.py @@ -5,9 +5,9 @@ import pytest from fastapi import FastAPI -from permissions.grants import CHANNEL, clear_grants_cache, direct_grant_source, subscribe +from permissions.core.service import PermissionService +from permissions.grants.source import CHANNEL, clear_grants_cache, direct_grant_source, subscribe from permissions.models import UserPermission -from permissions.service import PermissionService from simple_module_core.invalidation import InvalidationBus from simple_module_core.permissions import PermissionRegistry from simple_module_core.user_directory import DirectoryUser, NullUserDirectory diff --git a/modules/permissions/tests/test_permissions_role_invalidation.py b/modules/permissions/tests/grants/invalidation/test_permissions_role_invalidation.py similarity index 96% rename from modules/permissions/tests/test_permissions_role_invalidation.py rename to modules/permissions/tests/grants/invalidation/test_permissions_role_invalidation.py index 69dab2eb6..6a239222a 100644 --- a/modules/permissions/tests/test_permissions_role_invalidation.py +++ b/modules/permissions/tests/grants/invalidation/test_permissions_role_invalidation.py @@ -3,8 +3,8 @@ import uuid import pytest +from permissions.core.service import PermissionService from permissions.models import RolePermission -from permissions.service import PermissionService from simple_module_core.invalidation import Invalidation from simple_module_core.permissions import PermissionRegistry from simple_module_db.transaction import finalize_session, rollback_session @@ -55,7 +55,7 @@ async def test_remote_role_change_and_removal_reload_overlay(app): @pytest.mark.parametrize("commit", [False, True]) async def test_role_request_publishes_only_after_commit(app, commit): - from permissions.deps import get_permission_service + from permissions.web.deps import get_permission_service from starlette.requests import Request registry = app.state.sm.permissions diff --git a/modules/permissions/tests/test_permissions_write_safety.py b/modules/permissions/tests/web/endpoints/test_permissions_write_safety.py similarity index 98% rename from modules/permissions/tests/test_permissions_write_safety.py rename to modules/permissions/tests/web/endpoints/test_permissions_write_safety.py index c331d3b0f..2ca7e333d 100644 --- a/modules/permissions/tests/test_permissions_write_safety.py +++ b/modules/permissions/tests/web/endpoints/test_permissions_write_safety.py @@ -9,10 +9,10 @@ import pytest from auth.contracts.schemas import UserContext from fastapi import FastAPI, Request -from permissions.deps import get_permission_service -from permissions.endpoints.api import router as api_router -from permissions.endpoints.views import router as view_router from permissions.models import RolePermission, UserPermission +from permissions.web.deps import get_permission_service +from permissions.web.endpoints.api import router as api_router +from permissions.web.endpoints.views import router as view_router from simple_module_core.permissions import PermissionRegistry from simple_module_core.tenancy import TenantRole, tenant_role from simple_module_core.user_directory import DirectoryUser, NullUserDirectory diff --git a/modules/tenants/tests/test_tenant_role_permissions.py b/modules/tenants/tests/test_tenant_role_permissions.py index 89a8cf89f..3e5c41c74 100644 --- a/modules/tenants/tests/test_tenant_role_permissions.py +++ b/modules/tenants/tests/test_tenant_role_permissions.py @@ -72,8 +72,8 @@ async def test_platform_admin_still_holds_everything(self, authenticated_client) class TestSyncAdminAllPermissions: async def test_never_touches_tenant_roles(self, app, db_session): + from permissions.core.service import PermissionService from permissions.models import RolePermission - from permissions.service import PermissionService from sqlalchemy import select from users.constants import ADMIN_ROLE_NAME diff --git a/packages/ui/src/lib/auth-routes.ts b/packages/ui/src/lib/auth-routes.ts index 2c985eb5c..1ec7191e9 100644 --- a/packages/ui/src/lib/auth-routes.ts +++ b/packages/ui/src/lib/auth-routes.ts @@ -12,7 +12,7 @@ export const REGISTER_PATH = '/users/register'; /** * The Users admin screen's url, as registered by the users module's menu * item (`_URL_USERS_ADMIN` in `users/module.py`, mirrored by `_ADMIN_URL` in - * `permissions/endpoints/views.py`). + * `permissions/web/endpoints/views.py`). * * Permissions pages (`RoleEdit`, `UserEdit`) live outside `/users` but are * reached from — and conceptually belong to — this screen, so they declare From 7ca2fc73c302c1e6d490e8ccc636b4a2839ef383 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 6 Nov 2026 00:53:00 +0100 Subject: [PATCH 2/2] docs(changelog): list the permissions.deps shim Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7952656d..54de5c729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,7 +91,7 @@ All notable changes to this project are documented in this file. The format is b - `oidc` follows the module layout: `oidc.core.{constants,settings,presets,state,runtime}`, `oidc.identity.{provider,client,discovery,renewal,directory,persistence,tokens,jwks}` and `oidc.web.endpoints`. `oidc.{settings,provider,jwks}` still import, with a `DeprecationWarning`, until 0.0.46; other old `oidc.*` paths are gone. - `api_tokens` follows the module layout: `api_tokens.core.{constants,settings,services,tenant_cleanup,user_cleanup}`, `api_tokens.credentials.{format,scopes,owner,service,resolver,audit}` (`tokens.py` is now `credentials/format.py`), `api_tokens.web.{deps,endpoints}`, and the page components and types move to `ui/`. `api_tokens.{constants,service,resolver,tokens}` still import, with a `DeprecationWarning`, until 0.0.46. - `branding` follows the module layout: `branding.core.{constants,settings,services,service,presets,tenant_definitions,tenant_branding}`, `branding.assets.{images,reaper}`, `branding.web.{deps,shared_props,endpoints}`, and the form components move from `components/` to `ui/components/`. `branding.{constants,settings,services,tenant_branding,images}` and `branding.endpoints.assets` still import, with a `DeprecationWarning`, until 0.0.46. -- `permissions` follows the module layout: `permissions.core.{constants,service,group_labels,write_safety}`, `permissions.grants.{source,role_invalidation,user_cleanup}` (`grants.py` is now `grants/source.py`), `permissions.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `permissions.group_labels` still imports, with a `DeprecationWarning`, until 0.0.46. The durable handler name `permissions.user_cleanup.purge_user_grants` is unchanged. +- `permissions` follows the module layout: `permissions.core.{constants,service,group_labels,write_safety}`, `permissions.grants.{source,role_invalidation,user_cleanup}` (`grants.py` is now `grants/source.py`), `permissions.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `permissions.deps` and `permissions.group_labels` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler name `permissions.user_cleanup.purge_user_grants` is unchanged. - Coalesce concurrent tenant membership and permission grant cache misses. In-flight invalidation retries against fresh authorization data; failures and cancellation release waiting readers. Cache unknown tenant subdomains for five seconds and drop the new slug's host lookups (on a new `tenants.hosts` invalidation channel, so no user id is read as a slug) and the owner's memberships after committed tenant creation; other users' caches are not cleared. Services built outside request dependencies pass `invalidate_hosts=make_host_invalidator(app)` beside `invalidate=make_invalidator(app)`. Retries after concurrent invalidation are bounded to three reads (2026-11-05 local performance review).