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
2 changes: 1 addition & 1 deletion docs/modules/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ async def delete_order(order_id: int, user: CurrentUser) -> None:
...
```

For permission checks that need to honour **direct user grants** (not just role-derived perms), use [`RequiresPermission` from the permissions module](/modules/permissions#using-requirespermission) instead.
`require_permission` passes when the user holds *any* of the listed keys. It reads the same per-request permission set as `simple_module_hosting.permissions.RequiresPermission`, which covers roles from the registry's role map plus every grant source (direct user grants, when the [permissions module](/modules/permissions#using-requirespermission) is installed).

## Pluggable auth

Expand Down
18 changes: 11 additions & 7 deletions docs/modules/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ The permissions module decouples role-based and per-user permission grants from

- Two assignment tables (`permissions_role_permission`, `permissions_user_permission`).
- An admin UI to edit them.
- A `RequiresPermission` dependency that consults *both* roles and direct user grants.
- A grant source that feeds direct user grants into the framework's permission resolution.

The framework ships its own simpler `RequiresPermission` in `simple_module_hosting.permissions` that only checks roles; if you install the `permissions` module, prefer the one re-exported from `permissions.deps` because it also honours direct grants.
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.

## ModuleMeta

Expand Down Expand Up @@ -45,7 +45,7 @@ All require authentication. Read endpoints need `permissions.view`; mutate endpo

```python
from fastapi import APIRouter, Depends
from permissions.deps import RequiresPermission
from simple_module_hosting.permissions import RequiresPermission

router = APIRouter()

Expand All @@ -59,11 +59,15 @@ 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 (read from the request-cached `resolved_permissions`).
2. The keys assigned directly to the user (`permissions_user_permission`).
3. The implicit `WILDCARD` grant — the `admin` role is synced to hold every permission key at startup, so admins pass any check.
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`.
3. The implicit `WILDCARD` grant. The `admin` role is synced to hold every permission key at startup, so admins pass any check.

For something tied to *only* role membership (no direct grants), use `auth.deps.require_permission` instead — it's a hair cheaper.
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.

### Caching and propagation

The grant source runs on every authenticated request, so each process caches a user's direct grants for 30 seconds (`permissions.grants.GRANTS_TTL_SECONDS`). Saving a user's grants publishes `permissions.user_grants` on the `InvalidationBus` once the transaction commits. 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.

## Public contracts

Expand Down
32 changes: 31 additions & 1 deletion framework/core/simple_module_core/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from __future__ import annotations

import logging
from collections.abc import Callable, Collection, Iterable
from collections.abc import Awaitable, Callable, Collection, Iterable
from dataclasses import dataclass, field
from typing import Any

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -50,6 +51,18 @@ def grants(held: Collection[str], required: str) -> bool:
return WILDCARD in held or required in held


GrantSource = Callable[[Any, Any], Awaitable[Collection[str]]]
"""``async (request, user) -> keys`` — permissions a principal holds beyond its roles.

How a module that stores grants of its own (``permissions``' per-user grants)
gets them into the one set every check reads, without the framework importing
it (SM009). Called once per authenticated request by
``simple_module_hosting.permissions.resolve_principal_permissions``, so a
source that reads the database must cache: it sits on the hot path of every
page load.
"""


@dataclass
class PermissionGroup:
"""A named group of related permissions (typically one per module)."""
Expand All @@ -75,6 +88,7 @@ def __init__(self) -> None:
self._role_overlay: dict[str, set[str]] = {}
self._all_permissions_cache: list[str] | None = None
self._role_map_cache: dict[str, list[str]] | None = None
self._grant_sources: list[GrantSource] = []
self._sources: dict[str, PermissionSourceProvider] = {}
self._source_cache: dict[str, tuple[list[str], dict[str, str]]] = {}

Expand Down Expand Up @@ -181,6 +195,22 @@ def map_role(self, role: str, permissions: list[str]) -> None:
self._role_map[role].update(permissions)
self._invalidate()

def add_grant_source(self, source: GrantSource) -> None:
"""Contribute permissions a principal holds beyond its roles (GH #337).

Whatever *source* returns is merged into the request's resolved set, so
``RequiresPermission``, ``resolved_permissions_for``, the menu filter and
the frontend's ``auth.permissions`` all honour it. Before this seam the
``permissions`` module's direct grants were seen by its own dependency
and nothing else, so a grant made in the admin UI did nothing on any
route guarded by the framework's ``RequiresPermission``.
"""
self._grant_sources.append(source)

@property
def grant_sources(self) -> tuple[GrantSource, ...]:
return tuple(self._grant_sources)

def set_role_overlay(self, role: str, permissions: Collection[str]) -> None:
"""Replace *role*'s persisted (admin-editor) grants.

Expand Down
7 changes: 4 additions & 3 deletions framework/hosting/simple_module_hosting/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
RequestLoggingMiddleware,
)
from simple_module_hosting._tenant import TENANT_HEADER, TenantMiddleware, TenantResolver
from simple_module_hosting.permissions import expand_permissions, resolve_permissions
from simple_module_hosting.permissions import expand_permissions, resolve_principal_permissions

if TYPE_CHECKING:
from simple_module_core.menu import MenuRegistry
Expand Down Expand Up @@ -200,9 +200,10 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
is_authenticated = user is not None
roles = getattr(user, "roles", []) if user else []

# Resolve permissions once and cache on request.state for RequiresPermission
# Resolve permissions once — roles plus module grant sources (GH #337) —
# and cache on request.state for RequiresPermission and the menu filter.
resolved = (
resolve_permissions(roles, role_map=self.permission_registry.role_map)
await resolve_principal_permissions(request, user, self.permission_registry)
if is_authenticated
else set()
)
Expand Down
81 changes: 74 additions & 7 deletions framework/hosting/simple_module_hosting/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,26 @@

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any

from fastapi import HTTPException, Request
from simple_module_core.permissions import DEFAULT_ROLE_PERMISSIONS, WILDCARD, grants

if TYPE_CHECKING:
from simple_module_core.permissions import PermissionRegistry

logger = logging.getLogger(__name__)

__all__ = [
"DEFAULT_ROLE_PERMISSIONS",
"PERMISSION_DENIED_PREFIX",
"WILDCARD",
"RequiresPermission",
"ensure_resolved_permissions",
"expand_permissions",
"resolve_permissions",
"resolve_principal_permissions",
"resolved_permissions_for",
]

Expand All @@ -36,6 +46,59 @@ def resolve_permissions(
return permissions


async def resolve_principal_permissions(
request: Request,
user: Any,
registry: PermissionRegistry | None,
) -> set[str]:
"""Everything *user* holds: its roles' permissions plus every grant source.

The one resolution both ``InertiaLayoutDataMiddleware`` and
``RequiresPermission`` use, so the door, the menu and the frontend cannot
disagree about what a principal may do (GH #337).

A source that raises contributes nothing — failing closed, a denied page
rather than an unauthorised one — and is logged rather than turned into a
500 on every request. A principal already holding the wildcard skips the
sources: nothing they return could widen it, and a source may cost a read.
"""
role_map = registry.role_map if registry is not None else None
permissions = resolve_permissions(getattr(user, "roles", []), role_map=role_map)
if registry is None or WILDCARD in permissions:
return permissions
for source in registry.grant_sources:
try:
# Grants are additive keys only: the wildcard (admin) comes from
# roles, never from a source, so a stray ``*`` row can't escalate.
permissions.update(k for k in await source(request, user) if k != WILDCARD)
except Exception:
logger.exception("Grant source %r raised; contributing nothing", source)
return permissions


def _registry_for(request: Request) -> PermissionRegistry | None:
sm = getattr(getattr(request.app, "state", None), "sm", None)
return getattr(sm, "permissions", None) if sm is not None else None


async def ensure_resolved_permissions(request: Request) -> set[str]:
""":func:`resolved_permissions_for`, grant sources included on a cache miss.

The middleware has normally resolved and cached the set already; this is
the fallback for a bare router without it, which — unlike the synchronous
reader — can await the grant sources.
"""
cached: set[str] | None = getattr(request.state, "resolved_permissions", None)
if cached is not None:
return cached
user = getattr(request.state, "user", None)
if user is None:
return set()
permissions = await resolve_principal_permissions(request, user, _registry_for(request))
request.state.resolved_permissions = permissions
return permissions


def expand_permissions(
resolved: set[str],
all_permissions: list[str],
Expand All @@ -59,9 +122,11 @@ def resolved_permissions_for(request: Request) -> set[str]:
labels, for one — and that decision must read the same permission set the
door did.

Role-derived only, matching ``RequiresPermission``: the ``permissions``
module's direct per-user grants are its own dependency's business, and a
caller wanting those consults ``permissions.deps.RequiresPermission``.
Includes every registered grant source (the ``permissions`` module's
direct per-user grants) whenever the middleware or ``RequiresPermission``
resolved first — which is always in a real app. Only the bare-router
fallback below is role-derived, because it cannot await a source; a caller
that may run there first should use :func:`ensure_resolved_permissions`.
"""
cached: set[str] | None = getattr(request.state, "resolved_permissions", None)
if cached is not None:
Expand All @@ -71,8 +136,7 @@ def resolved_permissions_for(request: Request) -> set[str]:
if user is None:
return set()

sm = getattr(getattr(request.app, "state", None), "sm", None)
perm_registry = getattr(sm, "permissions", None) if sm is not None else None
perm_registry = _registry_for(request)
role_map = perm_registry.role_map if perm_registry is not None else None
permissions = resolve_permissions(user.roles, role_map=role_map)
request.state.resolved_permissions = permissions
Expand All @@ -82,6 +146,9 @@ def resolved_permissions_for(request: Request) -> set[str]:
class RequiresPermission:
"""FastAPI dependency that enforces a specific permission.

Honours roles *and* every registered grant source, so a direct per-user
grant from the ``permissions`` module takes effect here too.

Usage::

@router.post("/", dependencies=[Depends(RequiresPermission("products.create"))])
Expand All @@ -92,12 +159,12 @@ async def create_product(...):
def __init__(self, permission: str) -> None:
self.permission = permission

def __call__(self, request: Request) -> None:
async def __call__(self, request: Request) -> None:
user = getattr(request.state, "user", None)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")

if not grants(resolved_permissions_for(request), self.permission):
if not grants(await ensure_resolved_permissions(request), self.permission):
raise HTTPException(
status_code=403,
detail=f"{PERMISSION_DENIED_PREFIX}{self.permission}",
Expand Down
122 changes: 122 additions & 0 deletions framework/hosting/tests/test_grant_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Grant sources reach the door, the menu and the frontend alike (GH #337)."""

from __future__ import annotations

from types import SimpleNamespace

from fastapi import Depends, FastAPI
from httpx import ASGITransport, AsyncClient
from simple_module_core.menu import MenuRegistry
from simple_module_core.permissions import PermissionRegistry
from simple_module_hosting.middleware import InertiaLayoutDataMiddleware
from simple_module_hosting.permissions import RequiresPermission, resolve_principal_permissions


def _registry(*sources) -> PermissionRegistry:
reg = PermissionRegistry()
reg.add_group("orders", ["orders.view", "orders.edit"])
for source in sources:
reg.add_grant_source(source)
return reg


def _user(roles: list[str] | None = None) -> SimpleNamespace:
return SimpleNamespace(id="u1", roles=roles or [])


async def _grants_view(_request, _user):
return {"orders.view"}


async def _raises(_request, _user):
raise RuntimeError("database down")


class TestResolvePrincipalPermissions:
async def test_merges_source_with_roles(self):
reg = _registry(_grants_view)
reg.map_role("clerk", ["orders.edit"])
held = await resolve_principal_permissions(None, _user(["clerk"]), reg)
assert held == {"orders.view", "orders.edit"}

async def test_source_cannot_grant_the_wildcard(self):
async def _wildcard(_request, _user):
return {"*", "orders.view"}

held = await resolve_principal_permissions(None, _user(), _registry(_wildcard))
assert held == {"orders.view"}

async def test_raising_source_fails_closed(self):
held = await resolve_principal_permissions(None, _user(), _registry(_raises, _grants_view))
assert held == {"orders.view"}

async def test_wildcard_skips_sources(self):
calls: list[str] = []

async def counting(_request, _user):
calls.append("called")
return set()

held = await resolve_principal_permissions(None, _user(["admin"]), _registry(counting))
assert "*" in held
assert calls == []


class TestMiddlewareFoldsSources:
async def test_frontend_permissions_include_source(self):
captured: dict = {}

async def inner_app(scope, receive, send):
from starlette.requests import Request

captured["shared"] = Request(scope).state.inertia_shared

mw = InertiaLayoutDataMiddleware(
inner_app, menu_registry=MenuRegistry(), permission_registry=_registry(_grants_view)
)
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [],
"state": {"user": _user()},
"app": SimpleNamespace(state=SimpleNamespace()),
}

async def receive():
return {"type": "http.request", "body": b"", "more_body": False}

async def send(_message):
return None

await mw(scope, receive, send)
assert captured["shared"]["auth"]["permissions"] == ["orders.view"]


class TestRequiresPermissionWithoutMiddleware:
"""A bare router has no middleware to resolve first; the door must still await sources."""

def _app(self, reg: PermissionRegistry) -> FastAPI:
app = FastAPI()
app.state.sm = SimpleNamespace(permissions=reg)

@app.middleware("http")
async def set_user(request, call_next):
request.state.user = _user()
return await call_next(request)

@app.get("/view", dependencies=[Depends(RequiresPermission("orders.view"))])
async def view():
return {"ok": True}

@app.get("/edit", dependencies=[Depends(RequiresPermission("orders.edit"))])
async def edit():
return {"ok": True}

return app

async def test_source_grant_admits_and_absence_denies(self):
transport = ASGITransport(app=self._app(_registry(_grants_view)))
async with AsyncClient(transport=transport, base_url="http://t") as client:
assert (await client.get("/view")).status_code == 200
assert (await client.get("/edit")).status_code == 403
Loading
Loading