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 @@ -93,6 +93,7 @@ All notable changes to this project are documented in this file. The format is b
- `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.
- `audit_log` follows the module layout: `audit_log.core.{constants,settings,services,capture}`, `audit_log.entries.{service,filters,resolve,activity,export}`, `audit_log.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. The `simple_module.audit_sinks` entry point now targets `audit_log.core.capture:audit_callback`. Nothing outside the module imported the old paths, so no aliases remain.
- `auth` follows the module layout: `auth.core.{constants,state}` and `auth.web.{deps,middleware}`; `auth.oidc_common` stays as a declared domain area. `auth.constants`, `auth.state`, `auth.deps` and `auth.middleware` still import, with a `DeprecationWarning`, until 0.0.46.


- 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
4 changes: 2 additions & 2 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,10 +442,10 @@ or `simple-module-keycloak` (Keycloak OIDC). Both implement the `AuthProvider`
protocol from `auth.contracts.provider`.

**Module authors never import from `users` or `keycloak` directly.** Use only:
- `from auth.deps import CurrentUser, require_permission`
- `from auth.web.deps import CurrentUser, require_permission`
- `from auth.contracts.schemas import UserContext`

The `AuthMiddleware` (in `auth/middleware.py`) delegates to the active provider's
The `AuthMiddleware` (in `auth/web/middleware.py`) delegates to the active provider's
`resolve_user()` method, then falls through to the principal-resolver chain.
API paths (`/api/*`) receive 401 JSON when unauthenticated; view paths receive
a 302 redirect to the provider's login URL.
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ scopes. Because the cap can only constrain a route that asks for a permission,
dependency) applies a fail-closed rule to every route:

- a route whose dependency tree contains `RequiresPermission(...)` or
`auth.deps.require_permission(...)` admits tokens — the gate decides;
`auth.web.deps.require_permission(...)` admits tokens — the gate decides;
- a page (a module view router, or any route depending on `InertiaDep`)
refuses tokens with 403, gated or not;
- `Depends(TokenAllowed())` from `simple_module_hosting.token_guard` admits
Expand Down
4 changes: 2 additions & 2 deletions docs/framework/public-routes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Public routes (anonymous access)

`AuthMiddleware` (in `auth/middleware.py`) gates **every** request behind the
`AuthMiddleware` (in `auth/web/middleware.py`) gates **every** request behind the
active auth provider — unauthenticated browser requests get a 302 to the login
URL, unauthenticated API requests get a 401. Modules that need to expose a
route without a session (a read-only STAC / OGC API, a TileJSON endpoint, an
Expand Down Expand Up @@ -131,4 +131,4 @@ want the exemption to travel with the module that owns the route.
- Wiring — `simple_module_hosting.app_builder.create_app` populates the
registry and publishes it at `app.state.public_routes` (also
`app.state.sm.public_routes`)
- Enforcement — `auth.middleware.AuthMiddleware`
- Enforcement — `auth.web.middleware.AuthMiddleware`
2 changes: 1 addition & 1 deletion docs/module-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ when a later module PR moves files.
| --- | ---: | ---: | --- |
| api_tokens | 3 (+4 shims) | 0 | follows this layout |
| audit_log | 3 | 0 | follows this layout |
| auth | 8 | 20 | flat |
| auth | 3 (+4 shims) | 0 | follows this layout |
| background_tasks | 49 | 83 | flat |
| branding | 2 (+6 shims) | 0 | follows this layout |
| dashboard | 2 | 0 | follows this layout |
Expand Down
4 changes: 2 additions & 2 deletions docs/modules/api_tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ list, and:
- `resolve_principal_permissions` (used by `RequiresPermission`, the menu
filter and the frontend's `auth.permissions`) intersects the resolved set
with the scopes. An admin's wildcard expands to the scope list only.
- `auth.deps.require_permission` skips its admin-role shortcut.
- `auth.web.deps.require_permission` skips its admin-role shortcut.
- Role-only admin gates (`/admin`, the Doctor page, the maintenance bypass) use
`simple_module_core.permissions.is_admin_principal`, which is `False` for any
token.
Expand All @@ -78,7 +78,7 @@ app-wide dependency on every `APIRoute` (module, host and framework routes):

| Route | A token (`scopes` set) gets |
|---|---|
| Has a permission gate in its dependency tree — `RequiresPermission(...)` or `auth.deps.require_permission(...)` | admitted; the gate decides, capped at the scopes |
| Has a permission gate in its dependency tree — `RequiresPermission(...)` or `auth.web.deps.require_permission(...)` | admitted; the gate decides, capped at the scopes |
| A page — on a module view router, or depends on `InertiaDep` | **403**, even when gated: page props are a second, unscoped read API |
| Marked `Depends(TokenAllowed())` | admitted, with no permission check — use only for routes harmless for any token |
| Anything else | **403** (JSON on `/api/*`) |
Expand Down
6 changes: 4 additions & 2 deletions docs/modules/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ Helpers: `has_role(role)` / `has_any_role(roles)` for in-handler role checks; `h
## Dependencies for endpoints

```python
from auth.deps import CurrentUser, get_current_user, require_permission
from auth.web.deps import CurrentUser, get_current_user, require_permission
```

The old `auth.deps` import path still works, with a `DeprecationWarning`, until 0.0.46 (as do `auth.constants`, `auth.state` and `auth.middleware`).

| Dependency | What it does |
|---|---|
| `CurrentUser` (`Annotated` alias) | FastAPI dependency that returns the active `UserContext`. Raises 401 if `request.state.user` isn't set. |
Expand All @@ -62,7 +64,7 @@ from auth.deps import CurrentUser, get_current_user, require_permission

```python
from fastapi import APIRouter, Depends
from auth.deps import CurrentUser, require_permission
from auth.web.deps import CurrentUser, require_permission

router = APIRouter()

Expand Down
2 changes: 1 addition & 1 deletion docs/modules/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ async def delete_order(order_id: int) -> None: ...
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.
All three are resolved once per request by `InertiaLayoutDataMiddleware` and cached on `request.state.resolved_permissions`. `auth.web.deps.require_permission(*keys)` reads the same set, with any-of semantics.

Workers that start together (a fresh install, or a release that adds keys) all see the same admin grants missing. Each inserts them through the ORM in a savepoint and, if another worker got there first, reads again and inserts only what is still missing, so every startup succeeds (#732).

Expand Down
2 changes: 1 addition & 1 deletion framework/hosting/simple_module_hosting/_phase_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def attach_public_routes(app: FastAPI, settings: Settings, registry) -> None:
Modules contribute method-aware rules through their ``register_public_routes``
hook (already applied to *registry* by the caller). This adds the host escape
hatch — ``SM_AUTH_PUBLIC_PATHS`` prefixes — then exposes the registry at
``app.state.public_routes``, where ``auth.middleware.AuthMiddleware`` reads it
``app.state.public_routes``, where ``auth.web.middleware.AuthMiddleware`` reads it
on every request.
"""
# The setup wizard is anonymous by necessity: it is served precisely when
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 @@ -22,7 +22,7 @@

# Modules whose import registers a deprecation (shim modules and call-site shims).
_SHIM_MODULES = (
"auth.deps",
"auth.web.deps",
"host.routes_legacy",
"permissions.web.deps",
"settings.registration",
Expand All @@ -35,7 +35,7 @@
)
_EXPECTED = {
"ResourceType.shareable_actions",
"auth.deps.require_permission",
"auth.web.deps.require_permission",
"host.routes_legacy (pre-/admin view URL redirects)",
"permissions.web.deps.invalidate_grants_on_commit",
"settings.registration",
Expand Down
6 changes: 1 addition & 5 deletions host/tests/test_module_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
DOMAIN_AREAS: dict[str, frozenset[str]] = {
"api_tokens": frozenset({"credentials"}),
"audit_log": frozenset({"entries"}),
"auth": frozenset({"oidc_common"}),
"branding": frozenset({"assets"}),
"keycloak": frozenset({"identity"}),
"mail": frozenset({"delivery"}),
Expand All @@ -44,11 +45,6 @@

# Frozen inventory, not a computed baseline: moves must remove their old entries.
LEGACY_FLAT: dict[str, frozenset[str]] = {
"auth": frozenset(
"""
_route_match.py constants.py deps.py middleware.py oidc_common state.py
""".split()
),
"background_tasks": frozenset(
"""
_lifecycle.py _signal_bus.py _signal_support.py after_commit.py celery_app.py
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ async def test_lifetime_is_capped_by_the_setting(app, actor):


async def test_require_permission_admin_shortcut_skips_tokens():
"""``auth.deps.require_permission`` admits an admin session but not an admin token."""
"""``auth.web.deps.require_permission`` admits an admin session but not an admin token."""
from auth.contracts.schemas import UserContext
from auth.deps import require_permission
from auth.web.deps import require_permission

check = require_permission("x.write").dependency
translator = SimpleNamespace(t=lambda key, **_: key)
Expand Down
4 changes: 2 additions & 2 deletions modules/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pip install simple_module_auth
```python
from fastapi import APIRouter

from auth.deps import CurrentUser
from auth.web.deps import CurrentUser

router = APIRouter()

Expand All @@ -49,7 +49,7 @@ from .constants import PERM_PRODUCTS_CREATE
async def create_product(): ...
```

`auth.deps.require_permission` is deprecated and will be removed in **0.1.0**
`auth.web.deps.require_permission` is deprecated and will be removed in **0.1.0**
(#698). It retains its legacy any-of semantics, role-based admin bypass and cached
permission check. Migrate to `RequiresPermission(PERM_KEY)`; for several required
permissions, declare a dependency for each. Unlike the legacy helper,
Expand Down
15 changes: 10 additions & 5 deletions modules/auth/auth/constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
"""Shared browser session keys."""
"""Moved to :mod:`auth.core.constants`; this alias goes in 0.0.46 (module layout)."""

SESSION_AUTHORITY_KEY = "oidc_authority"
SESSION_ID_KEY = "oidc_session_id"
# Cooldown after a transient refresh failure or skipped coordination.
REFRESH_RETRY_AFTER_SECONDS = 30
from __future__ import annotations

import importlib
import sys

from simple_module_core.deprecation import deprecated_module

deprecated_module("auth.constants", remove_in="0.0.46", use="auth.core.constants")
sys.modules[__name__] = importlib.import_module("auth.core.constants")
1 change: 1 addition & 0 deletions modules/auth/auth/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""What every auth feature builds on: browser session keys and the app.state.auth container."""
6 changes: 6 additions & 0 deletions modules/auth/auth/core/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Shared browser session keys."""

SESSION_AUTHORITY_KEY = "oidc_authority"
SESSION_ID_KEY = "oidc_session_id"
# Cooldown after a transient refresh failure or skipped coordination.
REFRESH_RETRY_AFTER_SECONDS = 30
38 changes: 38 additions & 0 deletions modules/auth/auth/core/state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Module-owned state attached to ``app.state.auth`` by ``AuthModule.register_settings``.

Holds the auth provider (set by one of ``users`` or ``keycloak``) and the
principal-resolver registry. Apps register additional resolvers from their
``on_startup`` hook::

app.state.auth.principal_resolvers.append(my_pat_resolver)
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from auth.contracts.resolver import PrincipalResolver

if TYPE_CHECKING:
from starlette.requests import Request
from starlette.responses import Response

from auth.contracts.provider import AuthProvider


@dataclass
class AuthState:
"""Per-app auth registry. Initialized empty; provider modules populate at boot."""

auth_provider: AuthProvider | None = None
principal_resolvers: list[PrincipalResolver] = field(default_factory=list)
# Run once a principal has resolved (any credential source), before downstream
# middleware; anonymous requests never reach them.
# A guard is a sync callable returning a refusal response or None to continue.
# An exception propagates (a 500), so a broken guard fails closed, never open.
principal_guards: list[Callable[[Request], Response | None]] = field(default_factory=list)


__all__ = ["AuthState"]
67 changes: 6 additions & 61 deletions modules/auth/auth/deps.py
Original file line number Diff line number Diff line change
@@ -1,66 +1,11 @@
"""FastAPI auth dependencies — get_current_user, require_permission."""
"""Moved to :mod:`auth.web.deps`; this alias goes in 0.0.46 (module layout)."""

from __future__ import annotations

from typing import Annotated
import importlib
import sys

from fastapi import Depends, HTTPException, Request
from simple_module_core.deprecation import deprecated
from simple_module_core.permissions import ADMIN_ROLE, grants
from simple_module_hosting.i18n_deps import TranslatorDep
from simple_module_hosting.permissions import ensure_resolved_permissions
from simple_module_hosting.token_guard import GATE_PERMISSION, mark_token_gate
from simple_module_core.deprecation import deprecated_module

from auth.contracts.schemas import UserContext


async def get_current_user(request: Request, t: TranslatorDep) -> UserContext:
"""Extract the authenticated user from request state.

The auth middleware must set ``request.state.user`` before this runs.
"""
user = getattr(request.state, "user", None)
if user is None:
raise HTTPException(status_code=401, detail=t.t("auth.errors.not_authenticated"))
return user


CurrentUser = Annotated[UserContext, Depends(get_current_user)]


@deprecated(remove_in="0.1.0", use="simple_module_hosting.permissions.RequiresPermission")
def require_permission(*permissions: str):
"""Legacy any-of gate, removed in 0.1.0; use ``RequiresPermission`` instead.

Retains the role-based admin bypass and cached permission behaviour for
compatibility. ``RequiresPermission`` rechecks a principal demoted during
the request; this helper does not.
"""

async def check(
request: Request,
t: TranslatorDep,
user: UserContext = Depends(get_current_user),
):
# Admin role bypasses permission checks — but not for a token: an
# admin's PAT is worth only the scopes it carries, and the resolved
# set below already applies that cap.
if ADMIN_ROLE in user.roles and user.scopes is None:
return

# The same resolution RequiresPermission reads: the registry's role map
# plus module grant sources. This used to call get_permissions_for_roles
# without a map, which grants every non-admin role nothing (GH #337).
held = await ensure_resolved_permissions(request)
if not any(grants(held, p) for p in permissions):
raise HTTPException(
status_code=403,
detail=t.t(
"auth.errors.missing_permission",
permissions=", ".join(permissions),
),
)

# A permission gate: API tokens may reach the route, capped at their
# scopes (``simple_module_hosting.token_guard``).
return Depends(mark_token_gate(check, GATE_PERMISSION))
deprecated_module("auth.deps", remove_in="0.0.46", use="auth.web.deps")
sys.modules[__name__] = importlib.import_module("auth.web.deps")
Loading
Loading