Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.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).
Expand Down
2 changes: 1 addition & 1 deletion docs/module-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 4 additions & 4 deletions docs/modules/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions host/tests/test_deprecation_expiry.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
_SHIM_MODULES = (
"auth.deps",
"host.routes_legacy",
"permissions.deps",
"permissions.web.deps",
"settings.registration",
"settings.module_registry",
"settings.hydrate",
Expand All @@ -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",
Expand Down
7 changes: 1 addition & 6 deletions host/tests/test_module_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions modules/permissions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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()

Expand Down
5 changes: 5 additions & 0 deletions modules/permissions/permissions/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""What every permissions feature builds on.

Constants, the permission service, registry sync, write safety and localized
group labels.
"""
31 changes: 31 additions & 0 deletions modules/permissions/permissions/core/group_labels.py
Original file line number Diff line number Diff line change
@@ -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
]
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@
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,
RolePermissionsOut,
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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
60 changes: 6 additions & 54 deletions modules/permissions/permissions/deps.py
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 5 additions & 0 deletions modules/permissions/permissions/grants/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Direct per-user grants.

The cached grant source, role and grant invalidation across workers, and
deleted-user cleanup.
"""
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
34 changes: 8 additions & 26 deletions modules/permissions/permissions/group_labels.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading