diff --git a/CHANGELOG.md b/CHANGELOG.md index 50cddd248..aac62aa2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index ec93f638a..cac04f53d 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -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. diff --git a/docs/framework/permissions.md b/docs/framework/permissions.md index baaebfa79..82c35c64c 100644 --- a/docs/framework/permissions.md +++ b/docs/framework/permissions.md @@ -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 diff --git a/docs/framework/public-routes.md b/docs/framework/public-routes.md index 6f3526ece..cb06d3078 100644 --- a/docs/framework/public-routes.md +++ b/docs/framework/public-routes.md @@ -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 @@ -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` diff --git a/docs/module-layout.md b/docs/module-layout.md index 87aeed600..899ceb599 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -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 | diff --git a/docs/modules/api_tokens.md b/docs/modules/api_tokens.md index 4615d1b63..19303975e 100644 --- a/docs/modules/api_tokens.md +++ b/docs/modules/api_tokens.md @@ -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. @@ -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/*`) | diff --git a/docs/modules/auth.md b/docs/modules/auth.md index b7e16ea8b..2d0a3fbdf 100644 --- a/docs/modules/auth.md +++ b/docs/modules/auth.md @@ -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. | @@ -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() diff --git a/docs/modules/permissions.md b/docs/modules/permissions.md index 36dbf3c3b..51d4908d8 100644 --- a/docs/modules/permissions.md +++ b/docs/modules/permissions.md @@ -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). diff --git a/framework/hosting/simple_module_hosting/_phase_helpers.py b/framework/hosting/simple_module_hosting/_phase_helpers.py index 317dac6ae..e504902b5 100644 --- a/framework/hosting/simple_module_hosting/_phase_helpers.py +++ b/framework/hosting/simple_module_hosting/_phase_helpers.py @@ -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 diff --git a/host/tests/test_deprecation_expiry.py b/host/tests/test_deprecation_expiry.py index 6304c3bef..3f1275dd2 100644 --- a/host/tests/test_deprecation_expiry.py +++ b/host/tests/test_deprecation_expiry.py @@ -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", @@ -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", diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py index ecd886aa5..42600701d 100644 --- a/host/tests/test_module_layout.py +++ b/host/tests/test_module_layout.py @@ -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"}), @@ -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 diff --git a/modules/api_tokens/tests/credentials/scopes/test_api_tokens_scopes.py b/modules/api_tokens/tests/credentials/scopes/test_api_tokens_scopes.py index a574f7f4a..5e0fd2ff7 100644 --- a/modules/api_tokens/tests/credentials/scopes/test_api_tokens_scopes.py +++ b/modules/api_tokens/tests/credentials/scopes/test_api_tokens_scopes.py @@ -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) diff --git a/modules/auth/README.md b/modules/auth/README.md index e6063d8fc..a2c731181 100644 --- a/modules/auth/README.md +++ b/modules/auth/README.md @@ -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() @@ -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, diff --git a/modules/auth/auth/constants.py b/modules/auth/auth/constants.py index 995de861b..76655f891 100644 --- a/modules/auth/auth/constants.py +++ b/modules/auth/auth/constants.py @@ -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") diff --git a/modules/auth/auth/core/__init__.py b/modules/auth/auth/core/__init__.py new file mode 100644 index 000000000..1e1fd50ea --- /dev/null +++ b/modules/auth/auth/core/__init__.py @@ -0,0 +1 @@ +"""What every auth feature builds on: browser session keys and the app.state.auth container.""" diff --git a/modules/auth/auth/core/constants.py b/modules/auth/auth/core/constants.py new file mode 100644 index 000000000..995de861b --- /dev/null +++ b/modules/auth/auth/core/constants.py @@ -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 diff --git a/modules/auth/auth/core/state.py b/modules/auth/auth/core/state.py new file mode 100644 index 000000000..f5a031557 --- /dev/null +++ b/modules/auth/auth/core/state.py @@ -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"] diff --git a/modules/auth/auth/deps.py b/modules/auth/auth/deps.py index 3dfd5bb5b..e5d37daba 100644 --- a/modules/auth/auth/deps.py +++ b/modules/auth/auth/deps.py @@ -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") diff --git a/modules/auth/auth/middleware.py b/modules/auth/auth/middleware.py index 7945629a0..6483fb91d 100644 --- a/modules/auth/auth/middleware.py +++ b/modules/auth/auth/middleware.py @@ -1,192 +1,11 @@ -"""Provider-agnostic authentication middleware. - -Delegates user resolution to the ``AuthProvider`` registered on -``app.state.auth.auth_provider``, then falls through to the -principal-resolver chain. Sets ``request.state.user`` and the -``current_user_id`` ContextVar for audit listeners. -""" +"""Moved to :mod:`auth.web.middleware`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import logging -from urllib.parse import urlsplit - -from simple_module_core.app_state import get_openapi_url -from simple_module_core.redirect_safety import SESSION_NEXT_KEY, safe_next_or_none -from simple_module_db.listeners import current_user_id -from simple_module_hosting.inertia_deps import is_inertia_request -from simple_module_hosting.path_matching import matches_path, validated_prefixes -from simple_module_hosting.public_access import is_public as route_is_public -from starlette.requests import Request -from starlette.responses import JSONResponse, RedirectResponse -from starlette.types import ASGIApp, Receive, Scope, Send - -from auth._route_match import route_exists - -logger = logging.getLogger(__name__) - -_FRAMEWORK_PUBLIC_PREFIXES = validated_prefixes( - "/health/", - "/static/", - "/api/docs/", - "/api/redoc/", - "/i18n/", -) -_FRAMEWORK_PUBLIC_EXACT = ("/", "/health", "/api/docs", "/api/redoc") - - -def _query_suffix(request: Request) -> str: - return f"?{request.url.query}" if request.url.query else "" - - -def _is_navigation(request: Request) -> bool: - """Whether this request is the page the visitor asked for, not something it loaded. - - Only a navigation may become the post-login target: the sign-in page's - own favicon request used to overwrite it, so sign-in landed on - /favicon.ico (#416). Inertia visits count — a session expiring mid-click - must still return the user to that page. Without fetch metadata (old - clients, scripts) keep the previous behaviour. - """ - headers = request.headers - if is_inertia_request(request.scope): - return True - dest = headers.get("sec-fetch-dest") - if dest is not None: - return dest == "document" - mode = headers.get("sec-fetch-mode") - if mode is not None: - return mode == "navigate" - return True - - -def _post_login_target(request: Request) -> str | None: - """Where sign-in should return the visitor, or ``None``. - - A GET/HEAD navigation returns to itself. A mutation (an Inertia DELETE - whose session expired) must not: replaying its URL as a GET after sign-in - hits a 405, so it returns to the same-site page it was sent from instead. - """ - if not _is_navigation(request): - return None - if request.method in ("GET", "HEAD"): - return safe_next_or_none(request.url.path + _query_suffix(request)) - try: - referer = urlsplit(request.headers.get("referer", "")) - # Same origin means host *and* port: another port on this host is a - # different site. A malformed Referer (``http://[``) raises ValueError. - if referer.netloc and ( - referer.hostname != request.url.hostname or referer.port != request.url.port - ): - return None - except ValueError: - return None - query = f"?{referer.query}" if referer.query else "" - return safe_next_or_none(referer.path + query) if referer.path else None - - -class AuthMiddleware: - """Authenticate requests via the registered AuthProvider. - - On cache miss (provider returns None), falls through to the - principal-resolver chain. Unauthenticated API requests get 401 JSON; - unauthenticated browser requests get a redirect to the provider's - login URL. - """ - - def __init__(self, app: ASGIApp) -> None: - self.app = app - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope["type"] != "http": - await self.app(scope, receive, send) - return - - path: str = scope["path"] - method: str = scope.get("method", "GET") - app_state = scope["app"].state - auth_state = app_state.auth - provider = auth_state.auth_provider - - if provider is None: - await self.app(scope, receive, send) - return - - is_public = ( - matches_path(path, exact=_FRAMEWORK_PUBLIC_EXACT, prefixes=_FRAMEWORK_PUBLIC_PREFIXES) - # Anonymous exactly when the host serves it (dev, or openapi_public). - or path == get_openapi_url(scope["app"]) - ) - if not is_public: - is_public = route_is_public(scope["app"], method, path) - - # Contract with the host's RateLimitMiddleware (plain scope key, no import - # between them): whether *this* middleware treats the request as anonymous- - # allowed, from all three sources above. One source of truth. - scope.setdefault("state", {})["auth_public"] = is_public - - request = Request(scope) - user_ctx = await provider.resolve_user(request) - - if user_ctx is None: - for resolver in auth_state.principal_resolvers: - try: - user_ctx = await resolver(request) - except Exception: - logger.exception( - "Principal resolver %r raised; treating as no-match", - resolver, - ) - continue - if user_ctx is not None: - break - - if user_ctx is None and not is_public and not route_exists(scope): - # Nothing would serve this path (#473): let the app's 404 answer it - # — the same page a signed-in caller gets — rather than sending the - # visitor to sign in for a page that does not exist, or keeping it - # as their post-login target. The request now reaches the app - # anonymously, so the rate limiter treats it like any public hit. - scope["state"]["auth_public"] = True - is_public = True - - if user_ctx is None and not is_public: - if path.startswith("/api/") or provider.is_bearer_request(request): - response = JSONResponse({"detail": "Not authenticated"}, status_code=401) - else: - # Stash where they were heading so the login flow can return - # them there. Relative, not ``str(request.url)``: the value is - # replayed into a ``Location`` header, and an absolute URL is - # both needless and an open-redirect shape. ``next_url`` also - # goes to the provider — session-based providers ignore it, - # but a redirect-based one (OIDC) needs it in the auth URL. - session = scope.get("session", {}) - # Only a navigation is a target: a subresource (the sign-in - # page's own favicon) would overwrite it (#416). - next_url = _post_login_target(request) - if next_url is not None: - session[SESSION_NEXT_KEY] = next_url - response = RedirectResponse( - provider.get_login_url(request, next_url), status_code=302 - ) - await response(scope, receive, send) - return - - if user_ctx is not None: - request.state.user = user_ctx - for guard in auth_state.principal_guards: - refusal = guard(request) - if refusal is not None: - await refusal(scope, receive, send) - return - token = current_user_id.set(user_ctx.id) - try: - await self.app(scope, receive, send) - finally: - current_user_id.reset(token) - return - - await self.app(scope, receive, send) +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -__all__ = ["AuthMiddleware"] +deprecated_module("auth.middleware", remove_in="0.0.46", use="auth.web.middleware") +sys.modules[__name__] = importlib.import_module("auth.web.middleware") diff --git a/modules/auth/auth/module.py b/modules/auth/auth/module.py index 9f047a89c..84ea59ce7 100644 --- a/modules/auth/auth/module.py +++ b/modules/auth/auth/module.py @@ -41,13 +41,13 @@ class AuthModule(ModuleBase): ) def register_settings(self, app: FastAPI) -> None: - from auth.state import AuthState + from auth.core.state import AuthState app.state.auth = AuthState() app.state.principal_serializer = _serialize_principal def register_middleware(self, app: FastAPI) -> None: - from auth.middleware import AuthMiddleware + from auth.web.middleware import AuthMiddleware app.add_middleware(AuthMiddleware) diff --git a/modules/auth/auth/oidc_common/renewal.py b/modules/auth/auth/oidc_common/renewal.py index ea849ad1c..0eea1139e 100644 --- a/modules/auth/auth/oidc_common/renewal.py +++ b/modules/auth/auth/oidc_common/renewal.py @@ -10,8 +10,8 @@ from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY from starlette.requests import Request -from auth.constants import REFRESH_RETRY_AFTER_SECONDS from auth.contracts.schemas import UserContext +from auth.core.constants import REFRESH_RETRY_AFTER_SECONDS from auth.oidc_common.renewal_outcomes import coordinated_refresh from auth.oidc_common.session import SessionKeys diff --git a/modules/auth/auth/oidc_common/renewal_outcomes.py b/modules/auth/auth/oidc_common/renewal_outcomes.py index d1638631a..6434992c7 100644 --- a/modules/auth/auth/oidc_common/renewal_outcomes.py +++ b/modules/auth/auth/oidc_common/renewal_outcomes.py @@ -15,8 +15,8 @@ from sqlalchemy import delete, or_ from sqlmodel import col -from auth.constants import REFRESH_RETRY_AFTER_SECONDS, SESSION_AUTHORITY_KEY, SESSION_ID_KEY from auth.contracts.schemas import UserContext +from auth.core.constants import REFRESH_RETRY_AFTER_SECONDS, SESSION_AUTHORITY_KEY, SESSION_ID_KEY from auth.models import RefreshOutcome from auth.oidc_common.session import SessionKeys diff --git a/modules/auth/auth/oidc_common/session.py b/modules/auth/auth/oidc_common/session.py index 1d016dcbe..5306375fa 100644 --- a/modules/auth/auth/oidc_common/session.py +++ b/modules/auth/auth/oidc_common/session.py @@ -13,7 +13,7 @@ from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY, sign_in_preserved_keys from starlette.responses import RedirectResponse -from auth.constants import SESSION_AUTHORITY_KEY, SESSION_ID_KEY +from auth.core.constants import SESSION_AUTHORITY_KEY, SESSION_ID_KEY @dataclass(frozen=True) diff --git a/modules/auth/auth/state.py b/modules/auth/auth/state.py index f5a031557..122f2b668 100644 --- a/modules/auth/auth/state.py +++ b/modules/auth/auth/state.py @@ -1,38 +1,11 @@ -"""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) -""" +"""Moved to :mod:`auth.core.state`; this alias goes in 0.0.46 (module layout).""" 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) +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -__all__ = ["AuthState"] +deprecated_module("auth.state", remove_in="0.0.46", use="auth.core.state") +sys.modules[__name__] = importlib.import_module("auth.core.state") diff --git a/modules/auth/auth/web/__init__.py b/modules/auth/auth/web/__init__.py new file mode 100644 index 000000000..11e8ac72a --- /dev/null +++ b/modules/auth/auth/web/__init__.py @@ -0,0 +1,5 @@ +"""The HTTP side. + +The provider-agnostic authentication middleware, route matching and the FastAPI +auth dependencies. +""" diff --git a/modules/auth/auth/_route_match.py b/modules/auth/auth/web/_route_match.py similarity index 100% rename from modules/auth/auth/_route_match.py rename to modules/auth/auth/web/_route_match.py diff --git a/modules/auth/auth/web/deps.py b/modules/auth/auth/web/deps.py new file mode 100644 index 000000000..3dfd5bb5b --- /dev/null +++ b/modules/auth/auth/web/deps.py @@ -0,0 +1,66 @@ +"""FastAPI auth dependencies — get_current_user, require_permission.""" + +from __future__ import annotations + +from typing import Annotated + +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 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)) diff --git a/modules/auth/auth/web/middleware.py b/modules/auth/auth/web/middleware.py new file mode 100644 index 000000000..754f89426 --- /dev/null +++ b/modules/auth/auth/web/middleware.py @@ -0,0 +1,192 @@ +"""Provider-agnostic authentication middleware. + +Delegates user resolution to the ``AuthProvider`` registered on +``app.state.auth.auth_provider``, then falls through to the +principal-resolver chain. Sets ``request.state.user`` and the +``current_user_id`` ContextVar for audit listeners. +""" + +from __future__ import annotations + +import logging +from urllib.parse import urlsplit + +from simple_module_core.app_state import get_openapi_url +from simple_module_core.redirect_safety import SESSION_NEXT_KEY, safe_next_or_none +from simple_module_db.listeners import current_user_id +from simple_module_hosting.inertia_deps import is_inertia_request +from simple_module_hosting.path_matching import matches_path, validated_prefixes +from simple_module_hosting.public_access import is_public as route_is_public +from starlette.requests import Request +from starlette.responses import JSONResponse, RedirectResponse +from starlette.types import ASGIApp, Receive, Scope, Send + +from auth.web._route_match import route_exists + +logger = logging.getLogger(__name__) + +_FRAMEWORK_PUBLIC_PREFIXES = validated_prefixes( + "/health/", + "/static/", + "/api/docs/", + "/api/redoc/", + "/i18n/", +) +_FRAMEWORK_PUBLIC_EXACT = ("/", "/health", "/api/docs", "/api/redoc") + + +def _query_suffix(request: Request) -> str: + return f"?{request.url.query}" if request.url.query else "" + + +def _is_navigation(request: Request) -> bool: + """Whether this request is the page the visitor asked for, not something it loaded. + + Only a navigation may become the post-login target: the sign-in page's + own favicon request used to overwrite it, so sign-in landed on + /favicon.ico (#416). Inertia visits count — a session expiring mid-click + must still return the user to that page. Without fetch metadata (old + clients, scripts) keep the previous behaviour. + """ + headers = request.headers + if is_inertia_request(request.scope): + return True + dest = headers.get("sec-fetch-dest") + if dest is not None: + return dest == "document" + mode = headers.get("sec-fetch-mode") + if mode is not None: + return mode == "navigate" + return True + + +def _post_login_target(request: Request) -> str | None: + """Where sign-in should return the visitor, or ``None``. + + A GET/HEAD navigation returns to itself. A mutation (an Inertia DELETE + whose session expired) must not: replaying its URL as a GET after sign-in + hits a 405, so it returns to the same-site page it was sent from instead. + """ + if not _is_navigation(request): + return None + if request.method in ("GET", "HEAD"): + return safe_next_or_none(request.url.path + _query_suffix(request)) + try: + referer = urlsplit(request.headers.get("referer", "")) + # Same origin means host *and* port: another port on this host is a + # different site. A malformed Referer (``http://[``) raises ValueError. + if referer.netloc and ( + referer.hostname != request.url.hostname or referer.port != request.url.port + ): + return None + except ValueError: + return None + query = f"?{referer.query}" if referer.query else "" + return safe_next_or_none(referer.path + query) if referer.path else None + + +class AuthMiddleware: + """Authenticate requests via the registered AuthProvider. + + On cache miss (provider returns None), falls through to the + principal-resolver chain. Unauthenticated API requests get 401 JSON; + unauthenticated browser requests get a redirect to the provider's + login URL. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path: str = scope["path"] + method: str = scope.get("method", "GET") + app_state = scope["app"].state + auth_state = app_state.auth + provider = auth_state.auth_provider + + if provider is None: + await self.app(scope, receive, send) + return + + is_public = ( + matches_path(path, exact=_FRAMEWORK_PUBLIC_EXACT, prefixes=_FRAMEWORK_PUBLIC_PREFIXES) + # Anonymous exactly when the host serves it (dev, or openapi_public). + or path == get_openapi_url(scope["app"]) + ) + if not is_public: + is_public = route_is_public(scope["app"], method, path) + + # Contract with the host's RateLimitMiddleware (plain scope key, no import + # between them): whether *this* middleware treats the request as anonymous- + # allowed, from all three sources above. One source of truth. + scope.setdefault("state", {})["auth_public"] = is_public + + request = Request(scope) + user_ctx = await provider.resolve_user(request) + + if user_ctx is None: + for resolver in auth_state.principal_resolvers: + try: + user_ctx = await resolver(request) + except Exception: + logger.exception( + "Principal resolver %r raised; treating as no-match", + resolver, + ) + continue + if user_ctx is not None: + break + + if user_ctx is None and not is_public and not route_exists(scope): + # Nothing would serve this path (#473): let the app's 404 answer it + # — the same page a signed-in caller gets — rather than sending the + # visitor to sign in for a page that does not exist, or keeping it + # as their post-login target. The request now reaches the app + # anonymously, so the rate limiter treats it like any public hit. + scope["state"]["auth_public"] = True + is_public = True + + if user_ctx is None and not is_public: + if path.startswith("/api/") or provider.is_bearer_request(request): + response = JSONResponse({"detail": "Not authenticated"}, status_code=401) + else: + # Stash where they were heading so the login flow can return + # them there. Relative, not ``str(request.url)``: the value is + # replayed into a ``Location`` header, and an absolute URL is + # both needless and an open-redirect shape. ``next_url`` also + # goes to the provider — session-based providers ignore it, + # but a redirect-based one (OIDC) needs it in the auth URL. + session = scope.get("session", {}) + # Only a navigation is a target: a subresource (the sign-in + # page's own favicon) would overwrite it (#416). + next_url = _post_login_target(request) + if next_url is not None: + session[SESSION_NEXT_KEY] = next_url + response = RedirectResponse( + provider.get_login_url(request, next_url), status_code=302 + ) + await response(scope, receive, send) + return + + if user_ctx is not None: + request.state.user = user_ctx + for guard in auth_state.principal_guards: + refusal = guard(request) + if refusal is not None: + await refusal(scope, receive, send) + return + token = current_user_id.set(user_ctx.id) + try: + await self.app(scope, receive, send) + finally: + current_user_id.reset(token) + return + + await self.app(scope, receive, send) + + +__all__ = ["AuthMiddleware"] diff --git a/modules/auth/tests/test_auth_provider_protocol.py b/modules/auth/tests/contracts/provider/test_auth_provider_protocol.py similarity index 100% rename from modules/auth/tests/test_auth_provider_protocol.py rename to modules/auth/tests/contracts/provider/test_auth_provider_protocol.py diff --git a/modules/auth/tests/test_resolver_registry.py b/modules/auth/tests/contracts/resolver/test_auth_resolver_registry.py similarity index 95% rename from modules/auth/tests/test_resolver_registry.py rename to modules/auth/tests/contracts/resolver/test_auth_resolver_registry.py index 0d0e0e148..4477b99d8 100644 --- a/modules/auth/tests/test_resolver_registry.py +++ b/modules/auth/tests/contracts/resolver/test_auth_resolver_registry.py @@ -32,7 +32,7 @@ async def fake_resolver(request: Request) -> UserContext | None: def test_auth_state_initializes_with_empty_resolvers(): - from auth.state import AuthState + from auth.core.state import AuthState state = AuthState() assert state.principal_resolvers == [] @@ -40,7 +40,7 @@ def test_auth_state_initializes_with_empty_resolvers(): def test_auth_state_resolvers_is_mutable_list(): """Modules register resolvers by appending; the list must be a list, not a tuple.""" - from auth.state import AuthState + from auth.core.state import AuthState state = AuthState() @@ -53,8 +53,8 @@ async def resolver(request): # pragma: no cover - registration smoke only def test_auth_module_register_settings_populates_app_state(): """``AuthModule.register_settings(app)`` must put an AuthState on ``app.state.auth``.""" + from auth.core.state import AuthState from auth.module import AuthModule - from auth.state import AuthState from fastapi import FastAPI app = FastAPI() @@ -110,7 +110,7 @@ def test_auth_module_registers_principal_serializer(): def test_auth_state_has_auth_provider_field(): - from auth.state import AuthState + from auth.core.state import AuthState state = AuthState() assert state.auth_provider is None @@ -118,7 +118,7 @@ def test_auth_state_has_auth_provider_field(): def test_auth_state_accepts_auth_provider(): from auth.contracts.provider import AuthProvider - from auth.state import AuthState + from auth.core.state import AuthState class FakeProvider: name = "fake" diff --git a/modules/auth/tests/test_user_context.py b/modules/auth/tests/contracts/schemas/test_auth_user_context.py similarity index 100% rename from modules/auth/tests/test_user_context.py rename to modules/auth/tests/contracts/schemas/test_auth_user_context.py diff --git a/modules/auth/tests/test_auth_parse_bearer.py b/modules/auth/tests/contracts/tokens/test_auth_parse_bearer.py similarity index 100% rename from modules/auth/tests/test_auth_parse_bearer.py rename to modules/auth/tests/contracts/tokens/test_auth_parse_bearer.py diff --git a/modules/auth/tests/test_auth_audit_redaction.py b/modules/auth/tests/core/audit/test_auth_audit_redaction.py similarity index 100% rename from modules/auth/tests/test_auth_audit_redaction.py rename to modules/auth/tests/core/audit/test_auth_audit_redaction.py diff --git a/modules/auth/tests/test_module.py b/modules/auth/tests/core/module/test_auth_module.py similarity index 100% rename from modules/auth/tests/test_module.py rename to modules/auth/tests/core/module/test_auth_module.py diff --git a/modules/auth/tests/test_auth_oidc_authority.py b/modules/auth/tests/oidc_common/authority/test_auth_oidc_authority.py similarity index 100% rename from modules/auth/tests/test_auth_oidc_authority.py rename to modules/auth/tests/oidc_common/authority/test_auth_oidc_authority.py diff --git a/modules/auth/tests/test_auth_oidc_jwks_cache.py b/modules/auth/tests/oidc_common/jwks/test_auth_oidc_jwks_cache.py similarity index 100% rename from modules/auth/tests/test_auth_oidc_jwks_cache.py rename to modules/auth/tests/oidc_common/jwks/test_auth_oidc_jwks_cache.py diff --git a/modules/auth/tests/test_auth_oidc_jwt_hardening.py b/modules/auth/tests/oidc_common/jwks/test_auth_oidc_jwt_hardening.py similarity index 100% rename from modules/auth/tests/test_auth_oidc_jwt_hardening.py rename to modules/auth/tests/oidc_common/jwks/test_auth_oidc_jwt_hardening.py diff --git a/modules/auth/tests/test_auth_oidc_authority_outcomes.py b/modules/auth/tests/oidc_common/renewal/test_auth_oidc_authority_outcomes.py similarity index 100% rename from modules/auth/tests/test_auth_oidc_authority_outcomes.py rename to modules/auth/tests/oidc_common/renewal/test_auth_oidc_authority_outcomes.py diff --git a/modules/auth/tests/test_auth_oidc_concurrent_renewal.py b/modules/auth/tests/oidc_common/renewal/test_auth_oidc_concurrent_renewal.py similarity index 100% rename from modules/auth/tests/test_auth_oidc_concurrent_renewal.py rename to modules/auth/tests/oidc_common/renewal/test_auth_oidc_concurrent_renewal.py diff --git a/modules/auth/tests/test_auth_oidc_outcome_logout.py b/modules/auth/tests/oidc_common/renewal/test_auth_oidc_outcome_logout.py similarity index 100% rename from modules/auth/tests/test_auth_oidc_outcome_logout.py rename to modules/auth/tests/oidc_common/renewal/test_auth_oidc_outcome_logout.py diff --git a/modules/auth/tests/test_auth_oidc_outcome_migration.py b/modules/auth/tests/oidc_common/renewal/test_auth_oidc_outcome_migration.py similarity index 95% rename from modules/auth/tests/test_auth_oidc_outcome_migration.py rename to modules/auth/tests/oidc_common/renewal/test_auth_oidc_outcome_migration.py index 2c9c329d7..9cef8af3a 100644 --- a/modules/auth/tests/test_auth_oidc_outcome_migration.py +++ b/modules/auth/tests/oidc_common/renewal/test_auth_oidc_outcome_migration.py @@ -12,7 +12,7 @@ def test_refresh_outcome_sqlite_roundtrip(): - root = Path(__file__).resolve().parents[3] + root = Path(__file__).resolve().parents[5] script = ScriptDirectory.from_config(Config(str(root / "host/alembic.ini"))) migration = script.get_revision("auth@head").module engine = create_engine("sqlite://") diff --git a/modules/auth/tests/test_auth_oidc_shared_helpers.py b/modules/auth/tests/oidc_common/session/test_auth_oidc_shared_helpers.py similarity index 100% rename from modules/auth/tests/test_auth_oidc_shared_helpers.py rename to modules/auth/tests/oidc_common/session/test_auth_oidc_shared_helpers.py diff --git a/modules/auth/tests/test_auth_oidc_sign_in.py b/modules/auth/tests/oidc_common/session/test_auth_oidc_sign_in.py similarity index 100% rename from modules/auth/tests/test_auth_oidc_sign_in.py rename to modules/auth/tests/oidc_common/session/test_auth_oidc_sign_in.py diff --git a/modules/auth/tests/test_auth_oidc_tokens.py b/modules/auth/tests/oidc_common/tokens/test_auth_oidc_tokens.py similarity index 100% rename from modules/auth/tests/test_auth_oidc_tokens.py rename to modules/auth/tests/oidc_common/tokens/test_auth_oidc_tokens.py diff --git a/modules/auth/tests/test_deps.py b/modules/auth/tests/web/deps/test_auth_deps.py similarity index 98% rename from modules/auth/tests/test_deps.py rename to modules/auth/tests/web/deps/test_auth_deps.py index 3d5ac9a0e..30e56dc40 100644 --- a/modules/auth/tests/test_deps.py +++ b/modules/auth/tests/web/deps/test_auth_deps.py @@ -9,7 +9,7 @@ import pytest from auth.contracts.schemas import UserContext -from auth.deps import get_current_user, require_permission +from auth.web.deps import get_current_user, require_permission from fastapi import HTTPException from simple_module_core.i18n import I18nRegistry, Translator diff --git a/modules/auth/tests/test_auth_permission_deprecation.py b/modules/auth/tests/web/deps/test_auth_permission_deprecation.py similarity index 72% rename from modules/auth/tests/test_auth_permission_deprecation.py rename to modules/auth/tests/web/deps/test_auth_permission_deprecation.py index 2ace99181..3022d8dea 100644 --- a/modules/auth/tests/test_auth_permission_deprecation.py +++ b/modules/auth/tests/web/deps/test_auth_permission_deprecation.py @@ -1,7 +1,7 @@ """The legacy any-of permission helper carries a migration warning (#698).""" import pytest -from auth.deps import require_permission +from auth.web.deps import require_permission from simple_module_core.deprecation import registered_deprecations @@ -9,5 +9,7 @@ def test_auth_require_permission_warns_and_registers_removal(): with pytest.warns(DeprecationWarning, match="RequiresPermission"): dependency = require_permission("products.view") assert callable(dependency.dependency) - entry = next(d for d in registered_deprecations() if d.name == "auth.deps.require_permission") + entry = next( + d for d in registered_deprecations() if d.name == "auth.web.deps.require_permission" + ) assert entry.remove_in == "0.1.0" diff --git a/modules/auth/tests/test_auth_middleware.py b/modules/auth/tests/web/middleware/test_auth_middleware.py similarity index 99% rename from modules/auth/tests/test_auth_middleware.py rename to modules/auth/tests/web/middleware/test_auth_middleware.py index 4b4409df4..713c02dc1 100644 --- a/modules/auth/tests/test_auth_middleware.py +++ b/modules/auth/tests/web/middleware/test_auth_middleware.py @@ -5,8 +5,8 @@ import httpx import pytest from auth.contracts.schemas import UserContext -from auth.middleware import AuthMiddleware -from auth.state import AuthState +from auth.core.state import AuthState +from auth.web.middleware import AuthMiddleware from fastapi import FastAPI, Request from starlette.middleware.sessions import SessionMiddleware from starlette.responses import JSONResponse diff --git a/modules/auth/tests/test_auth_next_target.py b/modules/auth/tests/web/middleware/test_auth_next_target.py similarity index 98% rename from modules/auth/tests/test_auth_next_target.py rename to modules/auth/tests/web/middleware/test_auth_next_target.py index cc240b934..dbd70370b 100644 --- a/modules/auth/tests/test_auth_next_target.py +++ b/modules/auth/tests/web/middleware/test_auth_next_target.py @@ -4,8 +4,8 @@ import httpx import pytest -from auth.middleware import AuthMiddleware -from auth.state import AuthState +from auth.core.state import AuthState +from auth.web.middleware import AuthMiddleware from fastapi import FastAPI, Request from simple_module_core.redirect_safety import SESSION_NEXT_KEY from starlette.middleware.sessions import SessionMiddleware diff --git a/modules/auth/tests/test_auth_unknown_paths.py b/modules/auth/tests/web/middleware/test_auth_unknown_paths.py similarity index 98% rename from modules/auth/tests/test_auth_unknown_paths.py rename to modules/auth/tests/web/middleware/test_auth_unknown_paths.py index 5de6d5d94..7b6610135 100644 --- a/modules/auth/tests/test_auth_unknown_paths.py +++ b/modules/auth/tests/web/middleware/test_auth_unknown_paths.py @@ -10,8 +10,8 @@ import httpx import pytest from auth.contracts.schemas import UserContext -from auth.middleware import AuthMiddleware -from auth.state import AuthState +from auth.core.state import AuthState +from auth.web.middleware import AuthMiddleware from fastapi import APIRouter, FastAPI from simple_module_core.redirect_safety import SESSION_NEXT_KEY from starlette.middleware.sessions import SessionMiddleware @@ -152,7 +152,7 @@ async def test_signed_in_unknown_path_is_404(): async def test_probe_failure_fails_closed(monkeypatch): - import auth._route_match as rm + import auth.web._route_match as rm def boom(router, probe): raise RuntimeError("router exploded") diff --git a/modules/permissions/README.md b/modules/permissions/README.md index 7c50283b9..aecb3c87e 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.web.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.web.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. diff --git a/modules/users/README.md b/modules/users/README.md index 9d4ea0ea0..53f5e618f 100644 --- a/modules/users/README.md +++ b/modules/users/README.md @@ -39,7 +39,7 @@ SM_USERS_BOOTSTRAP_PASSWORD=change-me Program: ```python -from auth.deps import CurrentUser +from auth.web.deps import CurrentUser @router.get("/profile") diff --git a/modules/users/tests/_middleware_support.py b/modules/users/tests/_middleware_support.py index 74edd31f5..e0e4f9b4f 100644 --- a/modules/users/tests/_middleware_support.py +++ b/modules/users/tests/_middleware_support.py @@ -14,7 +14,7 @@ from typing import Any import pytest -from auth.middleware import AuthMiddleware +from auth.web.middleware import AuthMiddleware from fastapi import FastAPI, Request from simple_module_test import forge_session_cookie from starlette.middleware.sessions import SessionMiddleware @@ -36,7 +36,7 @@ async def _build_app(db_state, inner_handler=None, *, principal_resolvers=None): Defaults to an empty registry — matches a production app where no downstream module has registered anything. """ - from auth.state import AuthState + from auth.core.state import AuthState async def _default_handler(request: Request): user = getattr(request.state, "user", None) diff --git a/modules/users/tests/test_users_browser_session_logout.py b/modules/users/tests/test_users_browser_session_logout.py index 1a6394ede..425d8e790 100644 --- a/modules/users/tests/test_users_browser_session_logout.py +++ b/modules/users/tests/test_users_browser_session_logout.py @@ -8,7 +8,7 @@ import httpx import pytest from _users_app_builders import user_row -from auth.constants import SESSION_ID_KEY +from auth.core.constants import SESSION_ID_KEY from simple_module_core.invalidation import InvalidationBus from sqlalchemy import select from users.auth_local.browser_sessions import revoke_browser_session diff --git a/modules/users/users/auth_local/browser_sessions.py b/modules/users/users/auth_local/browser_sessions.py index 5c2180466..c13e97d80 100644 --- a/modules/users/users/auth_local/browser_sessions.py +++ b/modules/users/users/auth_local/browser_sessions.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime from typing import Any -from auth.constants import SESSION_ID_KEY +from auth.core.constants import SESSION_ID_KEY from fastapi import Request from simple_module_db.callbacks import register_on_commit from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY, SESSION_SIGNATURE_MAX_AGE diff --git a/modules/users/users/deps.py b/modules/users/users/deps.py index f9f310f23..a8a2b7c50 100644 --- a/modules/users/users/deps.py +++ b/modules/users/users/deps.py @@ -15,7 +15,7 @@ import uuid from typing import TYPE_CHECKING -from auth.deps import CurrentUser +from auth.web.deps import CurrentUser from fastapi import Depends, HTTPException, Request from fastapi_users import FastAPIUsers from fastapi_users import exceptions as fu_exceptions diff --git a/modules/users/users/manager.py b/modules/users/users/manager.py index a39a344a7..84afa6c73 100644 --- a/modules/users/users/manager.py +++ b/modules/users/users/manager.py @@ -9,7 +9,7 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING -from auth.constants import SESSION_ID_KEY +from auth.core.constants import SESSION_ID_KEY from fastapi import Depends, Request from fastapi_users import BaseUserManager, UUIDIDMixin, exceptions from fastapi_users.jwt import generate_jwt diff --git a/modules/users/users/middleware.py b/modules/users/users/middleware.py index 67bfdce3b..99152ff14 100644 --- a/modules/users/users/middleware.py +++ b/modules/users/users/middleware.py @@ -1,10 +1,10 @@ """Backwards-compatibility re-export. -The canonical AuthMiddleware now lives in ``auth.middleware``. This shim +The canonical AuthMiddleware now lives in ``auth.web.middleware``. This shim exists only to avoid breaking imports in downstream apps that referenced ``users.middleware.AuthMiddleware`` directly. """ -from auth.middleware import AuthMiddleware +from auth.web.middleware import AuthMiddleware __all__ = ["AuthMiddleware"] diff --git a/modules/users/users/provider.py b/modules/users/users/provider.py index d1cffec9c..ebbba6dd9 100644 --- a/modules/users/users/provider.py +++ b/modules/users/users/provider.py @@ -13,9 +13,9 @@ from datetime import UTC, datetime from typing import Any -from auth.constants import SESSION_ID_KEY from auth.contracts.schemas import UserContext from auth.contracts.tokens import is_api_token, parse_bearer +from auth.core.constants import SESSION_ID_KEY from simple_module_hosting.session import ( SESSION_COOKIE_MAX_AGE, SESSION_EXPIRES_AT_KEY,