From 2f02ad269957c3ad86278f2905adaf4bdb9aefd3 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 23 Oct 2026 19:15:15 +0200 Subject: [PATCH 1/4] refactor(framework): typed app.state accessors in simple_module_core.app_state Closes #609 Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../core/simple_module_core/app_state.py | 195 ++++++++++++++++++ framework/core/simple_module_core/tenancy.py | 6 +- framework/core/tests/test_core_app_state.py | 57 +++++ .../simple_module_hosting/_boot_checks.py | 3 +- .../_deferred_startup.py | 5 +- .../simple_module_hosting/_error_page.py | 6 +- .../simple_module_hosting/_inertia_setup.py | 7 +- .../simple_module_hosting/_inertia_shared.py | 9 +- .../simple_module_hosting/_lifespan.py | 3 +- .../_middleware_pipeline.py | 3 +- .../simple_module_hosting/_rate_limit.py | 5 +- .../simple_module_hosting/_route_access.py | 3 +- .../simple_module_hosting/_spa_mount.py | 7 +- .../hosting/simple_module_hosting/_tenant.py | 5 +- .../hosting/simple_module_hosting/health.py | 3 +- .../simple_module_hosting/maintenance.py | 3 +- .../simple_module_hosting/middleware.py | 5 +- .../simple_module_hosting/permissions.py | 3 +- .../simple_module_hosting/public_access.py | 5 +- .../simple_module_hosting/setup_gate.py | 5 +- .../simple_module_hosting/shared_props.py | 4 +- .../hosting/simple_module_hosting/tenancy.py | 5 +- .../simple_module_hosting/token_guard.py | 3 +- modules/auth/auth/middleware.py | 3 +- modules/branding/branding/endpoints/api.py | 3 +- modules/branding/branding/endpoints/views.py | 3 +- modules/branding/branding/tenant_branding.py | 3 +- .../branding/branding/tenant_definitions.py | 3 +- modules/dashboard/dashboard/doctor.py | 5 +- modules/permissions/permissions/grants.py | 3 +- modules/settings/settings/deps.py | 3 +- modules/site_lock/site_lock/middleware.py | 4 +- modules/tenants/tenants/host_resolver.py | 3 +- modules/tenants/tenants/resolver.py | 5 +- modules/users/users/admin/links.py | 3 +- modules/users/users/session_revocation.py | 4 +- 36 files changed, 339 insertions(+), 56 deletions(-) create mode 100644 framework/core/simple_module_core/app_state.py create mode 100644 framework/core/tests/test_core_app_state.py diff --git a/framework/core/simple_module_core/app_state.py b/framework/core/simple_module_core/app_state.py new file mode 100644 index 000000000..7dc48cb02 --- /dev/null +++ b/framework/core/simple_module_core/app_state.py @@ -0,0 +1,195 @@ +"""Typed reads of optional app services, preserving absent-state fallbacks. + +Concrete hosting types are imported only for type checking, as in Services: +core stays usable without hosting or any plugin installed. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol + +if TYPE_CHECKING: + from asyncio import Lock + from collections.abc import Callable + + from simple_module_hosting._host_services import _HostServices + from simple_module_hosting._tenant import TenantResolver + from simple_module_hosting.middleware import PrincipalSerializer + from simple_module_hosting.shared_props import SharedPropsProvider + from simple_module_inertia import Inertia + from starlette.requests import Request + + from simple_module_core.body_limits import BodyLimitRegistry + from simple_module_core.design_packs import DesignPackRegistry + from simple_module_core.module import ModuleBase + from simple_module_core.public_routes import PublicRouteRegistry + from simple_module_core.rate_limit import RateLimitRegistry + from simple_module_core.services import Services + from simple_module_core.tenancy import TenantExists, TenantMembers + + +class AuthProvider(Protocol): + """Framework-facing auth extension; installed by users or keycloak.""" + + async def resolve_user(self, request: Request) -> Any: ... + + def get_login_url(self, request: Request, next_url: str | None = None) -> str: ... + + +class BrandingState(Protocol): + """Optional branding services consumed by the document renderer.""" + + @property + def settings(self) -> object: ... + + @property + def favicon_url(self) -> str | None: ... + + +def get_auth_provider(app: Any) -> AuthProvider | None: + """Auth.register_settings installs auth; users/keycloak install its provider.""" + return getattr(getattr(app.state, "auth", None), "auth_provider", None) + + +def get_services(app: Any) -> Services | None: + """create_app installs the framework service container. + + Returns None when absent. + """ + return getattr(getattr(app, "state", None), "sm", None) + + +def get_host_services(app: Any) -> _HostServices | None: + """Host settings registration installs the host service container. + + Returns None when absent. + """ + return getattr(app.state, "host", None) + + +def get_public_routes(app: Any) -> PublicRouteRegistry | None: + """create_app installs the public-route registry. + + Returns None when absent. + """ + return getattr(app.state, "public_routes", None) + + +def get_body_limits(app: Any) -> BodyLimitRegistry | None: + """create_app installs the request-body limit registry. + + Returns None when absent. + """ + return getattr(app.state, "body_limits", None) + + +def get_rate_limit_rules(app: Any) -> RateLimitRegistry | None: + """create_app installs the per-route rate-limit registry. + + Returns None when absent. + """ + return getattr(app.state, "rate_limit_rules", None) + + +def get_design_packs(app: Any) -> DesignPackRegistry | None: + """create_app installs the design-pack registry. + + Returns None when absent. + """ + return getattr(app.state, "design_packs", None) + + +def get_migration_status(app: Any) -> dict[str, Any] | None: + """The hosting lifespan and setup wizard install the migration snapshot. + + Returns None when absent. + """ + return getattr(app.state, "migration", None) + + +def get_openapi_url(app: Any) -> str | None: + """Hosting boot records the served OpenAPI URL, or None when disabled. + + Returns None when absent. + """ + return getattr(app.state, "openapi_url", None) + + +def get_spa_prefixes(app: Any) -> tuple[str, ...]: + """SPA mounting records the mounted prefixes; absent means no mounts. + + Returns () when absent. + """ + return getattr(app.state, "spa_prefixes", ()) + + +def get_deferred_startup(app: Any) -> list[ModuleBase] | None: + """The hosting lifespan records modules awaiting migrations. + + Returns None when absent. + """ + return getattr(app.state, "deferred_startup", None) + + +def get_deferred_startup_lock(app: Any) -> Lock | None: + """Deferred startup replay lazily installs its per-app lock. + + Returns None when absent. + """ + return getattr(app.state, "deferred_startup_lock", None) + + +def get_principal_serializer(app: Any) -> PrincipalSerializer | None: + """The auth module installs the principal-to-Inertia serializer. + + Returns None when absent. + """ + return getattr(app.state, "principal_serializer", None) + + +def get_tenant_resolver(app: Any) -> TenantResolver | None: + """The tenants module installs request tenant resolution. + + Returns None when absent. + """ + return getattr(getattr(app, "state", None), "tenant_resolver", None) + + +def get_tenant_exists(app: Any) -> TenantExists | None: + """The tenants module installs the tenant-existence callback. + + Returns None when absent. + """ + return getattr(app.state, "tenant_exists", None) + + +def get_tenant_members(app: Any) -> TenantMembers | None: + """The tenants module installs the membership lookup callback. + + Returns None when absent. + """ + return getattr(app.state, "tenant_members", None) + + +def get_inertia_dependency(app: Any) -> Callable[..., Inertia] | None: + """Hosting Inertia setup installs the configured dependency factory. + + Returns None when absent. + """ + return getattr(app.state, "inertia_dependency", None) + + +def get_inertia_shared_providers(app: Any) -> list[SharedPropsProvider] | None: + """register_inertia_shared_provider lazily installs the provider list. + + Returns None when absent. + """ + return getattr(app.state, "inertia_shared_providers", None) + + +def get_branding(app: Any) -> BrandingState | None: + """The branding module installs the optional application theme services. + + Returns None when absent. + """ + return getattr(app.state, "branding", None) diff --git a/framework/core/simple_module_core/tenancy.py b/framework/core/simple_module_core/tenancy.py index 20a574d14..91a39ba89 100644 --- a/framework/core/simple_module_core/tenancy.py +++ b/framework/core/simple_module_core/tenancy.py @@ -17,6 +17,8 @@ from enum import StrEnum from typing import Any +from simple_module_core.app_state import get_tenant_exists, get_tenant_members + TENANT_ROLE_PREFIX = "tenant:" @@ -72,7 +74,7 @@ async def tenant_exists(app: Any, tenant_id: str) -> bool | None: URL ask through this, so they can refuse a typo without importing that module; with none installed the answer is ``None`` and they accept the id. """ - check: TenantExists | None = getattr(app.state, "tenant_exists", None) + check: TenantExists | None = get_tenant_exists(app) if check is None: return None return bool(await check(tenant_id)) @@ -97,7 +99,7 @@ async def tenant_member_ids( no membership store is installed; callers on a multi-tenant app treat it as "nobody" (fail closed). """ - check: TenantMembers | None = getattr(app.state, "tenant_members", None) + check: TenantMembers | None = get_tenant_members(app) if check is None: return None if not user_ids: diff --git a/framework/core/tests/test_core_app_state.py b/framework/core/tests/test_core_app_state.py new file mode 100644 index 000000000..4b2ac53f7 --- /dev/null +++ b/framework/core/tests/test_core_app_state.py @@ -0,0 +1,57 @@ +"""Optional app services retain their defaults and installed object identity.""" + +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from simple_module_core import app_state + + +@pytest.mark.parametrize( + ("attribute", "accessor_name", "default"), + [ + ("sm", "get_services", None), + ("host", "get_host_services", None), + ("public_routes", "get_public_routes", None), + ("body_limits", "get_body_limits", None), + ("rate_limit_rules", "get_rate_limit_rules", None), + ("design_packs", "get_design_packs", None), + ("migration", "get_migration_status", None), + ("openapi_url", "get_openapi_url", None), + ("spa_prefixes", "get_spa_prefixes", ()), + ("deferred_startup", "get_deferred_startup", None), + ("deferred_startup_lock", "get_deferred_startup_lock", None), + ("principal_serializer", "get_principal_serializer", None), + ("tenant_resolver", "get_tenant_resolver", None), + ("tenant_exists", "get_tenant_exists", None), + ("tenant_members", "get_tenant_members", None), + ("inertia_dependency", "get_inertia_dependency", None), + ("inertia_shared_providers", "get_inertia_shared_providers", None), + ("branding", "get_branding", None), + ("auth", "get_auth_provider", None), + ], +) +def test_core_optional_app_service(attribute, accessor_name, default): + app = FastAPI() + accessor = getattr(app_state, accessor_name) + assert accessor(app) == default + + installed = object() + value = SimpleNamespace(auth_provider=installed) if attribute == "auth" else installed + setattr(app.state, attribute, value) + assert accessor(app) is installed + + setattr(app.state, attribute, None) + assert accessor(app) is None + + +def test_core_partial_auth_state_has_no_provider(): + app = FastAPI() + app.state.auth = SimpleNamespace() + assert app_state.get_auth_provider(app) is None + + +def test_core_optional_scope_app_can_lack_state(): + assert app_state.get_services(None) is None + assert app_state.get_services(SimpleNamespace()) is None + assert app_state.get_tenant_resolver(None) is None diff --git a/framework/hosting/simple_module_hosting/_boot_checks.py b/framework/hosting/simple_module_hosting/_boot_checks.py index a8e9a4596..9c059969f 100644 --- a/framework/hosting/simple_module_hosting/_boot_checks.py +++ b/framework/hosting/simple_module_hosting/_boot_checks.py @@ -13,6 +13,7 @@ import logging from typing import TYPE_CHECKING, Any +from simple_module_core.app_state import get_tenant_resolver from simple_module_core.diagnostics import Diagnostic, check_tenant_resolver, print_diagnostics from simple_module_hosting._route_access import check_unguarded_api_routes @@ -27,7 +28,7 @@ def _tenant_checks(app: FastAPI, settings: Any) -> list[Diagnostic]: return check_tenant_resolver( multi_tenant=bool(getattr(settings, "multi_tenant", False)), - resolver=getattr(app.state, "tenant_resolver", None), + resolver=get_tenant_resolver(app), ) diff --git a/framework/hosting/simple_module_hosting/_deferred_startup.py b/framework/hosting/simple_module_hosting/_deferred_startup.py index 4f75a76d8..1d685d793 100644 --- a/framework/hosting/simple_module_hosting/_deferred_startup.py +++ b/framework/hosting/simple_module_hosting/_deferred_startup.py @@ -6,6 +6,7 @@ import logging from fastapi import FastAPI +from simple_module_core.app_state import get_deferred_startup, get_deferred_startup_lock from simple_module_core.module_settings import get_module_settings_registry from simple_module_db.stored_settings import SessionOverrideSource, settings_table_exists @@ -46,10 +47,10 @@ async def run_deferred_startup(app: FastAPI) -> None: rather than raised, because this runs after the migrations have already committed and a 500 there would help nobody. """ - deferred = getattr(app.state, "deferred_startup", None) + deferred = get_deferred_startup(app) if not deferred: return - lock = getattr(app.state, "deferred_startup_lock", None) + lock = get_deferred_startup_lock(app) if lock is None: # no await between check and set, so this cannot race lock = app.state.deferred_startup_lock = asyncio.Lock() async with lock: diff --git a/framework/hosting/simple_module_hosting/_error_page.py b/framework/hosting/simple_module_hosting/_error_page.py index 7ffffe102..c44e6aaa6 100644 --- a/framework/hosting/simple_module_hosting/_error_page.py +++ b/framework/hosting/simple_module_hosting/_error_page.py @@ -7,6 +7,7 @@ from http import HTTPStatus from fastapi.responses import JSONResponse +from simple_module_core.app_state import get_auth_provider, get_inertia_dependency from simple_module_inertia import ( Inertia, InertiaConfig, @@ -52,8 +53,7 @@ def _login_url(request: Request) -> str | None: concern and ``SM009`` forbids framework code importing ``modules/*``. An app with no auth provider installed simply gets no sign-in button. """ - auth_state = getattr(request.app.state, "auth", None) - provider = getattr(auth_state, "auth_provider", None) + provider = get_auth_provider(request.app) if provider is None: return None try: @@ -94,7 +94,7 @@ async def render_error_page( # root-relative page url: the adapter does that by construction now, # so the fallback no longer needs a separate patch to avoid the # cross-scheme `pushState` SecurityError behind a TLS proxy. - inertia_dep = getattr(request.app.state, "inertia_dependency", None) + inertia_dep = get_inertia_dependency(request.app) if inertia_dep is not None: inertia = inertia_dep(request, None) else: diff --git a/framework/hosting/simple_module_hosting/_inertia_setup.py b/framework/hosting/simple_module_hosting/_inertia_setup.py index 084fb6753..01f6b4b7c 100644 --- a/framework/hosting/simple_module_hosting/_inertia_setup.py +++ b/framework/hosting/simple_module_hosting/_inertia_setup.py @@ -6,6 +6,7 @@ from pathlib import Path from fastapi import FastAPI +from simple_module_core.app_state import get_branding, get_host_services from simple_module_inertia import InertiaConfig, SiteInfo, inertia_dependency_factory from starlette.requests import Request @@ -50,8 +51,8 @@ def branding_head(request: Request) -> dict: """ # A per-request object (same duck shape) wins: branding resolves a tenant's # own theme into ``request.state.branding`` from its shared-props provider. - services = getattr(getattr(request, "state", None), "branding", None) or getattr( - request.app.state, "branding", None + services = getattr(getattr(request, "state", None), "branding", None) or get_branding( + request.app ) settings = getattr(services, "settings", None) app_name = getattr(settings, "app_name", "") or _DEFAULT_APP_NAME @@ -68,7 +69,7 @@ def branding_head(request: Request) -> dict: def site_info(request: Request) -> SiteInfo: """The public base URL (operator setting, never the request ``Host``) and app name.""" - host = getattr(request.app.state, "host", None) + host = get_host_services(request.app) base_url = getattr(getattr(host, "settings", None), "public_base_url", "") or "" return SiteInfo(base_url=base_url, app_name=branding_head(request)["app_name"]) diff --git a/framework/hosting/simple_module_hosting/_inertia_shared.py b/framework/hosting/simple_module_hosting/_inertia_shared.py index ea3cc7eed..fae6c0749 100644 --- a/framework/hosting/simple_module_hosting/_inertia_shared.py +++ b/framework/hosting/simple_module_hosting/_inertia_shared.py @@ -7,6 +7,7 @@ from collections.abc import Callable from typing import Any +from simple_module_core.app_state import get_inertia_shared_providers, get_services from simple_module_core.i18n import Translator from starlette.datastructures import Headers from starlette.requests import Request @@ -45,7 +46,7 @@ def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool = """ # Test fixtures sometimes build a bare FastAPI with a partial app.state.sm # stub (e.g. permissions-only, no i18n); guard both lookups to keep them usable. - sm = getattr(request.app.state, "sm", None) + sm = get_services(request.app) registry = getattr(sm, "i18n_registry", None) if sm is not None else None locale = getattr(request.state, "locale", None) if registry is None or locale is None: @@ -138,7 +139,7 @@ def build_menu_translator(request: Request) -> Callable[[str], str] | None: with a partial ``app.state.sm``, and a missing registry must degrade to the literal labels rather than raise on every request. """ - sm = getattr(request.app.state, "sm", None) + sm = get_services(request.app) registry = getattr(sm, "i18n_registry", None) if sm is not None else None locale = getattr(request.state, "locale", None) if registry is None or locale is None: @@ -158,7 +159,7 @@ async def merge_shared_prop_providers(app: Any, request: Request, shared: dict) provider's key. A provider may be ``async`` (its result is awaited) — a per-tenant lookup cannot be answered from a process-wide object. """ - providers = getattr(app.state, "inertia_shared_providers", None) or () + providers = get_inertia_shared_providers(app) or () for provider in providers: name = getattr(provider, "__name__", provider) try: @@ -187,6 +188,6 @@ def default_home_url(app: Any) -> str: named by string from ``sm.view_prefixes`` so nothing is imported (SM009); ``/`` is a host route in every app, so the link can never 404. """ - sm = getattr(app.state, "sm", None) + sm = get_services(app) prefix = getattr(sm, "view_prefixes", {}).get("Dashboard") return f"{prefix}/" if prefix else "/" diff --git a/framework/hosting/simple_module_hosting/_lifespan.py b/framework/hosting/simple_module_hosting/_lifespan.py index b3623dda6..814834223 100644 --- a/framework/hosting/simple_module_hosting/_lifespan.py +++ b/framework/hosting/simple_module_hosting/_lifespan.py @@ -18,6 +18,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from simple_module_core.app_state import get_migration_status from simple_module_hosting._deferred_startup import hydrate_settings_from_db from simple_module_hosting._route_guard import guard_included_routers @@ -76,7 +77,7 @@ async def _is_first_run(app: FastAPI) -> bool: try: done = await step.is_complete(app) except Exception: - migration = getattr(app.state, "migration", None) or {} + migration = get_migration_status(app) or {} never_migrated = migration.get("current_revision") is None if not never_migrated: logger.warning( diff --git a/framework/hosting/simple_module_hosting/_middleware_pipeline.py b/framework/hosting/simple_module_hosting/_middleware_pipeline.py index 98f254e86..d64f6f116 100644 --- a/framework/hosting/simple_module_hosting/_middleware_pipeline.py +++ b/framework/hosting/simple_module_hosting/_middleware_pipeline.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from fastapi import FastAPI +from simple_module_core.app_state import get_body_limits from simple_module_core.environments import NON_PROD_ENVIRONMENTS from simple_module_db import CommitBeforeResponseMiddleware from starlette.middleware.gzip import GZipMiddleware @@ -173,7 +174,7 @@ def install_middleware( app.add_middleware( BodyLimitMiddleware, max_bytes=settings.max_request_body_bytes, - registry=getattr(app.state, "body_limits", None), + registry=get_body_limits(app), ) # CORS executes between RequestLogging and BodyLimit: preflights are answered # before auth, rate limit, setup and maintenance can reject them, and a 413 diff --git a/framework/hosting/simple_module_hosting/_rate_limit.py b/framework/hosting/simple_module_hosting/_rate_limit.py index 48c1bbb89..f480705ef 100644 --- a/framework/hosting/simple_module_hosting/_rate_limit.py +++ b/framework/hosting/simple_module_hosting/_rate_limit.py @@ -27,6 +27,7 @@ import logging +from simple_module_core.app_state import get_public_routes, get_rate_limit_rules from simple_module_core.rate_limit import ( InProcessWindowStore, RateLimitRule, @@ -68,7 +69,7 @@ def _policy(self, scope: Scope) -> tuple[str, RateSpec, str] | None: if self.authenticated_rate is None: return None return "auth", self.authenticated_rate, KEY_USER_OR_IP - registry = getattr(scope["app"].state, "public_routes", None) + registry = get_public_routes(scope["app"]) rule = registry.match(scope["method"], scope["path"]) if registry is not None else None flag = scope.get("state", {}).get("auth_public") if flag is None: # no auth middleware recorded a decision @@ -88,7 +89,7 @@ def _policy(self, scope: Scope) -> tuple[str, RateSpec, str] | None: return "public", self.public_rate, KEY_IP def _rule(self, scope: Scope) -> tuple[str, RateLimitRule] | None: - registry = getattr(scope["app"].state, "rate_limit_rules", None) + registry = get_rate_limit_rules(scope["app"]) found = registry.match_entry(scope["method"], scope["path"]) if registry else None if found is None: return None diff --git a/framework/hosting/simple_module_hosting/_route_access.py b/framework/hosting/simple_module_hosting/_route_access.py index b48167d44..b2e07e50d 100644 --- a/framework/hosting/simple_module_hosting/_route_access.py +++ b/framework/hosting/simple_module_hosting/_route_access.py @@ -21,6 +21,7 @@ from fastapi import FastAPI from fastapi.routing import iter_route_contexts +from simple_module_core.app_state import get_openapi_url from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel from simple_module_hosting.public_access import is_public @@ -66,7 +67,7 @@ def iter_api_operations(app: FastAPI) -> Iterator[ApiOperation]: def is_public_operation(app: FastAPI, method: str, path: str) -> bool: """Whether an anonymous *method* on the templated *path* is let through.""" - if path == getattr(app.state, "openapi_url", None): + if path == get_openapi_url(app): return True for placeholder in _PLACEHOLDERS: concrete = _PARAM.sub(placeholder, path) diff --git a/framework/hosting/simple_module_hosting/_spa_mount.py b/framework/hosting/simple_module_hosting/_spa_mount.py index 2f029ee4a..7ee75ff43 100644 --- a/framework/hosting/simple_module_hosting/_spa_mount.py +++ b/framework/hosting/simple_module_hosting/_spa_mount.py @@ -18,6 +18,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles +from simple_module_core.app_state import get_host_services, get_public_routes, get_spa_prefixes from simple_module_core.diagnostics._spa_mount import check_spa_mount_overlaps from simple_module_core.spa_mount import SPA_AUTH_PUBLIC, SpaMount from starlette.datastructures import Headers @@ -108,7 +109,7 @@ def mount_spa_mounts(app: FastAPI, modules: list) -> None: raise SpaMountError("\n".join(str(p) for p in problems)) prefixes: list[str] = [] - registry = getattr(app.state, "public_routes", None) + registry = get_public_routes(app) for mod in modules: for mount in mod.spa_mounts(): if _mount(app, mod.meta.name, mount, registry): @@ -142,7 +143,7 @@ def _mount(app: FastAPI, module_name: str, mount: SpaMount, registry) -> bool: def native_prefixes(app: FastAPI) -> list[str]: """Prefixes the client must not intercept: mounted SPAs + the host setting.""" - host = getattr(app.state, "host", None) + host = get_host_services(app) configured = getattr(getattr(host, "settings", None), "native_link_prefixes", None) or [] - spa = [f"{prefix}/" for prefix in getattr(app.state, "spa_prefixes", ())] + spa = [f"{prefix}/" for prefix in get_spa_prefixes(app)] return sorted({*spa, *configured}) diff --git a/framework/hosting/simple_module_hosting/_tenant.py b/framework/hosting/simple_module_hosting/_tenant.py index 63f7d6f2a..807aed976 100644 --- a/framework/hosting/simple_module_hosting/_tenant.py +++ b/framework/hosting/simple_module_hosting/_tenant.py @@ -7,6 +7,7 @@ from typing import Any from uuid import UUID +from simple_module_core.app_state import get_tenant_resolver from simple_module_db import current_tenant_id, is_valid_tenant_id from starlette.datastructures import Headers, MutableHeaders from starlette.requests import Request @@ -188,9 +189,7 @@ async def _resolve(self, request: Request, scope: Scope) -> TenantResolution: if self.fixed is not None: return TenantResolution(self.fixed, "fixed") app = scope.get("app") - resolver: TenantResolver | None = getattr( - getattr(app, "state", None), "tenant_resolver", None - ) + resolver: TenantResolver | None = get_tenant_resolver(app) if resolver is not None: return _normalise(await resolver(request)) diff --git a/framework/hosting/simple_module_hosting/health.py b/framework/hosting/simple_module_hosting/health.py index 5e90b875f..c89590665 100644 --- a/framework/hosting/simple_module_hosting/health.py +++ b/framework/hosting/simple_module_hosting/health.py @@ -6,6 +6,7 @@ import logging from fastapi import APIRouter, Request +from simple_module_core.app_state import get_migration_status from simple_module_core.health import HealthCheckResult, HealthRegistry, HealthStatus logger = logging.getLogger(__name__) @@ -29,7 +30,7 @@ @router.get("/health") async def health(request: Request) -> dict: - migration = getattr(request.app.state, "migration", None) + migration = get_migration_status(request.app) return { _KEY_STATUS: _STATUS_HEALTHY, _KEY_MIGRATION: migration, diff --git a/framework/hosting/simple_module_hosting/maintenance.py b/framework/hosting/simple_module_hosting/maintenance.py index 2be0266cb..8ac559fbd 100644 --- a/framework/hosting/simple_module_hosting/maintenance.py +++ b/framework/hosting/simple_module_hosting/maintenance.py @@ -12,6 +12,7 @@ from __future__ import annotations +from simple_module_core.app_state import get_host_services from simple_module_core.permissions import is_admin_principal from starlette.requests import Request from starlette.responses import JSONResponse @@ -75,7 +76,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: @staticmethod def _host_settings(scope: Scope): - host_state = getattr(scope["app"].state, "host", None) + host_state = get_host_services(scope["app"]) return getattr(host_state, "settings", None) @staticmethod diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py index 7e130487a..48955232e 100644 --- a/framework/hosting/simple_module_hosting/middleware.py +++ b/framework/hosting/simple_module_hosting/middleware.py @@ -13,6 +13,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any +from simple_module_core.app_state import get_principal_serializer from starlette.datastructures import MutableHeaders from starlette.requests import Request from starlette.types import ASGIApp, Message, Receive, Scope, Send @@ -245,9 +246,7 @@ async def _shared(self, request: Request, scope: Scope, user: Any, resolved: set i18n_block = build_i18n_block(scope, request, is_authenticated=is_authenticated) - principal_serializer: PrincipalSerializer | None = getattr( - scope["app"].state, "principal_serializer", None - ) + principal_serializer: PrincipalSerializer | None = get_principal_serializer(scope["app"]) user_payload: dict[str, Any] | None = ( principal_serializer(user) if user is not None and principal_serializer else None ) diff --git a/framework/hosting/simple_module_hosting/permissions.py b/framework/hosting/simple_module_hosting/permissions.py index 208ce275e..8d6bb93a4 100644 --- a/framework/hosting/simple_module_hosting/permissions.py +++ b/framework/hosting/simple_module_hosting/permissions.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any from fastapi import HTTPException, Request +from simple_module_core.app_state import get_services from simple_module_core.permission_denied import PERMISSION_DENIED_PREFIX, PermissionDenied from simple_module_core.permissions import DEFAULT_ROLE_PERMISSIONS, WILDCARD, grants @@ -163,7 +164,7 @@ async def resolve_request_permissions( def _registry_for(request: Request) -> PermissionRegistry | None: - sm = getattr(getattr(request.app, "state", None), "sm", None) + sm = get_services(request.app) return getattr(sm, "permissions", None) if sm is not None else None diff --git a/framework/hosting/simple_module_hosting/public_access.py b/framework/hosting/simple_module_hosting/public_access.py index 839beebef..a8c0d03c8 100644 --- a/framework/hosting/simple_module_hosting/public_access.py +++ b/framework/hosting/simple_module_hosting/public_access.py @@ -5,6 +5,7 @@ import logging from fastapi import FastAPI +from simple_module_core.app_state import get_auth_provider, get_public_routes from simple_module_core.public_routes import legacy_provider_public_paths logger = logging.getLogger(__name__) @@ -13,10 +14,10 @@ def is_public(app: FastAPI, method: str, path: str) -> bool: """Whether the registry or legacy provider opens this concrete path.""" - registry = getattr(app.state, "public_routes", None) + registry = get_public_routes(app) if registry is not None and registry.matches(method, path): return True - provider = getattr(getattr(app.state, "auth", None), "auth_provider", None) + provider = get_auth_provider(app) if provider is None: return False try: diff --git a/framework/hosting/simple_module_hosting/setup_gate.py b/framework/hosting/simple_module_hosting/setup_gate.py index 2aa7dc5e4..2791dcb15 100644 --- a/framework/hosting/simple_module_hosting/setup_gate.py +++ b/framework/hosting/simple_module_hosting/setup_gate.py @@ -16,6 +16,7 @@ import logging import time +from simple_module_core.app_state import get_migration_status, get_services from starlette.datastructures import Headers from starlette.responses import RedirectResponse from starlette.types import ASGIApp, Receive, Scope, Send @@ -69,7 +70,7 @@ async def _database_migrated(app) -> bool: restarts it. Only the behind-head branch pays for the query, so a healthy install still answers from the snapshot. """ - status = getattr(app.state, "migration", None) + status = get_migration_status(app) if not status: return True if status.get("is_current", True): @@ -201,7 +202,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: return starlette_app = scope.get("app") - registry = getattr(getattr(starlette_app, "state", None), "sm", None) + registry = get_services(starlette_app) registry = getattr(registry, "setup_registry", None) # No registry, or nothing registered a step: nothing can gate the app. # ``create_app`` always seeds ``host.migrations``, so in a real host diff --git a/framework/hosting/simple_module_hosting/shared_props.py b/framework/hosting/simple_module_hosting/shared_props.py index 442ad2ab0..34a393b07 100644 --- a/framework/hosting/simple_module_hosting/shared_props.py +++ b/framework/hosting/simple_module_hosting/shared_props.py @@ -18,6 +18,8 @@ from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING +from simple_module_core.app_state import get_inertia_shared_providers + if TYPE_CHECKING: from fastapi import FastAPI from starlette.requests import Request @@ -35,7 +37,7 @@ def register_inertia_shared_provider(app: FastAPI, provider: SharedPropsProvider appends ``provider``. Safe to call from a module lifecycle hook before the framework has set up the list. """ - providers = getattr(app.state, _STATE_ATTR, None) + providers = get_inertia_shared_providers(app) if providers is None: providers = [] setattr(app.state, _STATE_ATTR, providers) diff --git a/framework/hosting/simple_module_hosting/tenancy.py b/framework/hosting/simple_module_hosting/tenancy.py index bb97451f4..bd418b205 100644 --- a/framework/hosting/simple_module_hosting/tenancy.py +++ b/framework/hosting/simple_module_hosting/tenancy.py @@ -12,6 +12,7 @@ from typing import Any from fastapi import HTTPException, Request +from simple_module_core.app_state import get_services from simple_module_core.tenancy import TenancyMode from simple_module_db import DEFAULT_TENANT_ID, is_valid_tenant_id, tenant_context @@ -38,7 +39,7 @@ def tenancy_mode(app: Any) -> TenancyMode: ``SINGLE`` for an app built outside ``create_app``. """ - sm = getattr(app.state, "sm", None) + sm = get_services(app) return getattr(sm, "tenancy", TenancyMode.SINGLE) @@ -47,7 +48,7 @@ def single_tenant_id(app: Any) -> str: ``default_tenant`` when set, else ``DEFAULT_TENANT_ID`` (``"default"``). """ - sm = getattr(app.state, "sm", None) + sm = get_services(app) db = getattr(sm, "db", None) return getattr(db, "default_tenant_id", None) or DEFAULT_TENANT_ID diff --git a/framework/hosting/simple_module_hosting/token_guard.py b/framework/hosting/simple_module_hosting/token_guard.py index e4f05bd57..04a18374c 100644 --- a/framework/hosting/simple_module_hosting/token_guard.py +++ b/framework/hosting/simple_module_hosting/token_guard.py @@ -40,6 +40,7 @@ from typing import Any from fastapi import HTTPException +from simple_module_core.app_state import get_services from starlette.requests import HTTPConnection from simple_module_hosting._anonymous import demote_to_anonymous @@ -172,7 +173,7 @@ def is_token_principal(user: Any) -> bool: def _translate(request: HTTPConnection, key: str, fallback: str) -> str: - sm = getattr(request.app.state, "sm", None) + sm = get_services(request.app) registry = getattr(sm, "i18n_registry", None) if registry is None: return fallback diff --git a/modules/auth/auth/middleware.py b/modules/auth/auth/middleware.py index 897b4b00e..7ebf05cee 100644 --- a/modules/auth/auth/middleware.py +++ b/modules/auth/auth/middleware.py @@ -10,6 +10,7 @@ import logging +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.public_access import is_public as route_is_public @@ -87,7 +88,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: any(path.startswith(p) for p in _FRAMEWORK_PUBLIC_PREFIXES) or path in _FRAMEWORK_PUBLIC_EXACT # Anonymous exactly when the host serves it (dev, or openapi_public). - or path == getattr(app_state, "openapi_url", None) + or path == get_openapi_url(scope["app"]) ) if not is_public: is_public = route_is_public(scope["app"], method, path) diff --git a/modules/branding/branding/endpoints/api.py b/modules/branding/branding/endpoints/api.py index 12166e17e..bc4925865 100644 --- a/modules/branding/branding/endpoints/api.py +++ b/modules/branding/branding/endpoints/api.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile from file_storage.deps import get_file_storage_service from file_storage.service import FileStorageService +from simple_module_core.app_state import get_design_packs from simple_module_hosting.permissions import RequiresPermission from branding import constants @@ -36,7 +37,7 @@ def _validate_design_pack(request: Request, changes: dict) -> None: pack = changes.get("design_pack") if not pack: return - registry = getattr(request.app.state, "design_packs", None) + registry = get_design_packs(request.app) if registry is None or not registry.has(pack): raise HTTPException( status_code=422, diff --git a/modules/branding/branding/endpoints/views.py b/modules/branding/branding/endpoints/views.py index 68130043e..910c3a5d2 100644 --- a/modules/branding/branding/endpoints/views.py +++ b/modules/branding/branding/endpoints/views.py @@ -3,6 +3,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, Request +from simple_module_core.app_state import get_design_packs from simple_module_hosting.inertia_deps import InertiaDep from simple_module_hosting.permissions import RequiresPermission from simple_module_inertia import InertiaResponse @@ -27,7 +28,7 @@ async def manage(request: Request, inertia: InertiaDep) -> InertiaResponse: # ``getattr`` rather than direct access so this published module still # renders on a host older than the design-pack registry — it just offers # no packs to choose from. - registry = getattr(request.app.state, "design_packs", None) + registry = get_design_packs(request.app) packs = registry.all() if registry is not None else [] # The page name is inlined as a literal (rather than constants._PAGE_MANAGE) # so the SM003/SM004 diagnostics — which do static AST analysis and can't diff --git a/modules/branding/branding/tenant_branding.py b/modules/branding/branding/tenant_branding.py index 10ca8630c..d4a93a33f 100644 --- a/modules/branding/branding/tenant_branding.py +++ b/modules/branding/branding/tenant_branding.py @@ -28,6 +28,7 @@ from cachetools import TTLCache from pydantic import ValidationError +from simple_module_core.app_state import get_services from branding.constants import PACKAGE, TENANT_CACHE_TTL_SECONDS, TENANT_FIELDS from branding.settings import BrandingSettings @@ -107,7 +108,7 @@ def on_settings_changed(inv: Invalidation) -> None: def tenancy_active(app: FastAPI) -> bool: - settings = getattr(getattr(app.state, "sm", None), "settings", None) + settings = getattr(get_services(app), "settings", None) return bool(getattr(settings, "multi_tenant", False)) diff --git a/modules/branding/branding/tenant_definitions.py b/modules/branding/branding/tenant_definitions.py index c6b850f0a..53ba7a170 100644 --- a/modules/branding/branding/tenant_definitions.py +++ b/modules/branding/branding/tenant_definitions.py @@ -29,6 +29,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ValidationError +from simple_module_core.app_state import get_design_packs from branding.constants import ( IMAGE_KEY_GENERIC_WRITE_ERROR, @@ -66,7 +67,7 @@ async def check(request: Request, tenant_id: str, value: str) -> None: except ValidationError as exc: raise ValueError(exc.errors()[0]["msg"]) from exc if name == "design_pack" and value: - registry = getattr(request.app.state, "design_packs", None) + registry = get_design_packs(request.app) if registry is None or not registry.has(value): raise ValueError(f"Unknown design pack {value!r}.") diff --git a/modules/dashboard/dashboard/doctor.py b/modules/dashboard/dashboard/doctor.py index 803ac80a7..425fe4c4d 100644 --- a/modules/dashboard/dashboard/doctor.py +++ b/modules/dashboard/dashboard/doctor.py @@ -18,6 +18,7 @@ from urllib.parse import urlsplit from fastapi import FastAPI, Request +from simple_module_core.app_state import get_migration_status from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel, collect_tsx_pages from simple_module_core.discovery import get_module_package_name from simple_module_hosting.migrations import list_migrations @@ -216,7 +217,7 @@ def _module_chip(message: str, packages: set[str]) -> str: def migration_rows(app: FastAPI) -> list[dict[str, Any]]: """Recent Alembic revisions with the applied flag this database earns.""" - state = getattr(app.state, "migration", None) or {} + state = get_migration_status(app) or {} rows = list_migrations(current_revision=state.get("current_revision")) packages = {get_module_package_name(m) for m in app.state.sm.modules} for row in rows: @@ -253,7 +254,7 @@ def doctor_props(request: Request, *, module_count: int) -> dict[str, Any]: """Everything the Doctor screen renders, all of it live.""" app = request.app checks = collect_checks(app) - migration = getattr(app.state, "migration", None) or {} + migration = get_migration_status(app) or {} return { "checks": checks, "checks_available": app.state.sm.diagnostics.supported, diff --git a/modules/permissions/permissions/grants.py b/modules/permissions/permissions/grants.py index 82094bb98..38c610b3e 100644 --- a/modules/permissions/permissions/grants.py +++ b/modules/permissions/permissions/grants.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Any from cachetools import TTLCache +from simple_module_core.app_state import get_services from sqlalchemy import select from permissions.models import UserPermission @@ -114,7 +115,7 @@ async def publish_grants_changed(app: FastAPI, user_id: uuid.UUID) -> None: change must not be broadcast. Falls back to a local eviction when the app has no bus (a single-module test harness), so a change never looks inert. """ - bus = getattr(getattr(app.state, "sm", None), "invalidation", None) + bus = getattr(get_services(app), "invalidation", None) if bus is None: logger.debug("No invalidation bus on this app; dropping %s locally", user_id) _GRANTS.pop(user_id, None) diff --git a/modules/settings/settings/deps.py b/modules/settings/settings/deps.py index 5cff6a082..85a09261e 100644 --- a/modules/settings/settings/deps.py +++ b/modules/settings/settings/deps.py @@ -11,6 +11,7 @@ from typing import Annotated from fastapi import Depends, Request +from simple_module_core.app_state import get_services from simple_module_db.deps import get_db from sqlalchemy.ext.asyncio import AsyncSession @@ -25,7 +26,7 @@ async def get_setting_service( request: Request, db: AsyncSession = Depends(get_db), ) -> SettingService: - bus = getattr(getattr(request.app.state, "sm", None), "invalidation", None) + bus = getattr(get_services(request.app), "invalidation", None) async def tenant_is_live(tenant_id: str) -> bool: return await is_known_tenant(request, tenant_id) diff --git a/modules/site_lock/site_lock/middleware.py b/modules/site_lock/site_lock/middleware.py index 6948ab257..3d0c8bb19 100644 --- a/modules/site_lock/site_lock/middleware.py +++ b/modules/site_lock/site_lock/middleware.py @@ -17,6 +17,7 @@ import secrets from urllib.parse import quote +from simple_module_core.app_state import get_auth_provider from starlette.requests import Request from starlette.responses import ( HTMLResponse, @@ -162,8 +163,7 @@ async def _is_admin(scope: Scope) -> bool: This is the documented lockout escape hatch: an admin who enables the gate and mistypes the password keeps access to the settings screen. """ - auth_state = getattr(scope["app"].state, "auth", None) - provider = getattr(auth_state, "auth_provider", None) + provider = get_auth_provider(scope["app"]) if provider is None: return False try: diff --git a/modules/tenants/tenants/host_resolver.py b/modules/tenants/tenants/host_resolver.py index cf13f360a..55f9667e9 100644 --- a/modules/tenants/tenants/host_resolver.py +++ b/modules/tenants/tenants/host_resolver.py @@ -22,6 +22,7 @@ from cachetools import TTLCache from fastapi import FastAPI +from simple_module_core.app_state import get_public_routes from sqlalchemy import select from starlette.requests import Request @@ -67,7 +68,7 @@ async def tenant_for_slug(app: FastAPI, slug: str) -> tuple[str, str] | None: def is_public_route(request: Request) -> bool: - registry = getattr(request.app.state, "public_routes", None) + registry = get_public_routes(request.app) return registry is not None and registry.matches(request.method, request.url.path) diff --git a/modules/tenants/tenants/resolver.py b/modules/tenants/tenants/resolver.py index b0bf4a052..c1016323d 100644 --- a/modules/tenants/tenants/resolver.py +++ b/modules/tenants/tenants/resolver.py @@ -21,6 +21,7 @@ from auth.contracts.tokens import AUTH_KIND_PAT, AUTH_KIND_SERVICE, AUTH_KIND_SESSION from cachetools import TTLCache from fastapi import FastAPI +from simple_module_core.app_state import get_services from simple_module_core.invalidation import Invalidation, InvalidationBus from simple_module_core.tenancy import is_tenant_role from simple_module_db import is_valid_tenant_id @@ -81,7 +82,7 @@ def subscribe(bus: InvalidationBus) -> None: def make_invalidator(app: FastAPI): async def invalidate(key: str | None) -> None: forget(key) # this worker, now — the bus reaches the others - bus = getattr(getattr(app.state, "sm", None), "invalidation", None) + bus = getattr(get_services(app), "invalidation", None) if bus is not None: await bus.publish(INVALIDATION_CHANNEL, key=key) @@ -225,7 +226,7 @@ async def _resolve_subdomain(request: Request, user: Any, slug: str) -> str | No def _header_name(request: Request) -> str: - settings = getattr(getattr(request.app.state, "sm", None), "settings", None) + settings = getattr(get_services(request.app), "settings", None) return getattr(settings, "tenant_header", "") or "" diff --git a/modules/users/users/admin/links.py b/modules/users/users/admin/links.py index 087c68555..5f1c94f19 100644 --- a/modules/users/users/admin/links.py +++ b/modules/users/users/admin/links.py @@ -18,6 +18,7 @@ from __future__ import annotations +from simple_module_core.app_state import get_host_services from starlette.requests import Request from users.settings import UsersSettings @@ -31,7 +32,7 @@ def admin_link_base(request: Request) -> str: configured = str(getattr(users, "base_url", "") or "") if configured and configured != _DEFAULT_BASE_URL: return configured.rstrip("/") - host = getattr(getattr(request.app.state, "host", None), "settings", None) + host = getattr(get_host_services(request.app), "settings", None) public = str(getattr(host, "public_base_url", "") or "") if public: return public.rstrip("/") diff --git a/modules/users/users/session_revocation.py b/modules/users/users/session_revocation.py index a898f1f75..90d31f43a 100644 --- a/modules/users/users/session_revocation.py +++ b/modules/users/users/session_revocation.py @@ -20,6 +20,8 @@ import uuid from typing import TYPE_CHECKING +from simple_module_core.app_state import get_services + from users.models import User from users.session_version_cache import clear_session_version_cache, forget_session_version @@ -102,7 +104,7 @@ async def publish_revocation(app: FastAPI, user_id) -> None: single-module test harness (``build_test_app``) rather than in any real app. Silently doing nothing there would make a revocation look like it worked. """ - bus = getattr(getattr(app.state, "sm", None), "invalidation", None) + bus = getattr(get_services(app), "invalidation", None) if bus is None: logger.debug("No invalidation bus on this app; dropping %s locally", user_id) forget_session_version(user_id) From 20a717dcc3307e65a671093d659ab8d36dfb5f20 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 23 Oct 2026 19:18:29 +0200 Subject: [PATCH 2/4] fix(users): enforce demo read-only on the resolved principal; demo accounts cannot mint tokens Closes #611 Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/auth/auth/middleware.py | 5 + modules/auth/auth/state.py | 7 + .../tests/demo/test_users_demo_credentials.py | 139 ++++++++++++++++++ modules/users/users/auth_local/token_api.py | 12 +- modules/users/users/demo/guard.py | 52 +++++-- modules/users/users/module.py | 10 +- 6 files changed, 202 insertions(+), 23 deletions(-) create mode 100644 modules/users/tests/demo/test_users_demo_credentials.py diff --git a/modules/auth/auth/middleware.py b/modules/auth/auth/middleware.py index 7ebf05cee..43c7e9d8f 100644 --- a/modules/auth/auth/middleware.py +++ b/modules/auth/auth/middleware.py @@ -151,6 +151,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 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) diff --git a/modules/auth/auth/state.py b/modules/auth/auth/state.py index 9e44eb2bb..6fd0eda7c 100644 --- a/modules/auth/auth/state.py +++ b/modules/auth/auth/state.py @@ -9,12 +9,16 @@ 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 @@ -24,6 +28,9 @@ class AuthState: auth_provider: AuthProvider | None = None principal_resolvers: list[PrincipalResolver] = field(default_factory=list) + # Run after every credential source resolves, before downstream middleware. + # A guard returns a refusal response or None to continue; errors fail closed. + principal_guards: list[Callable[[Request], Response | None]] = field(default_factory=list) __all__ = ["AuthState"] diff --git a/modules/users/tests/demo/test_users_demo_credentials.py b/modules/users/tests/demo/test_users_demo_credentials.py new file mode 100644 index 000000000..09c6c3c6a --- /dev/null +++ b/modules/users/tests/demo/test_users_demo_credentials.py @@ -0,0 +1,139 @@ +"""Demo read-only mode follows identities authenticated without demo cookies.""" + +import httpx +import pytest +from api_tokens.constants import TokenKind +from api_tokens.service import ApiTokenService +from simple_module_test import forge_session_cookie +from users.auth_local.token_api import _create_token_pair +from users.demo import ensure_demo_users + + +@pytest.fixture +async def demo_credentials(demo_app): + settings = demo_app.state.users.settings + settings.demo_admin_password = "DemoPass1!" + await ensure_demo_users(demo_app) + user_id = demo_app.state.users.demo_user_ids[0] + async with demo_app.state.sm.db.session_factory() as db: + pair = await _create_token_pair(db, user_id, settings) + _, pat = await ApiTokenService(db).create( + kind=TokenKind.PERSONAL, + name="pre-existing demo PAT", + scopes=["users.manage", "api_tokens.use"], + expires_in_days=1, + max_lifetime_days=30, + owner_user_id=user_id, + ) + await db.commit() + return {"bearer": pair.access_token, "pat": pat, "refresh": pair.refresh_token} + + +@pytest.mark.parametrize("credential", ["bearer", "pat"]) +@pytest.mark.parametrize("read_only", [True, False]) +async def test_demo_token_writes_follow_read_only_setting( + demo_app, demo_client, demo_credentials, credential, read_only +): + demo_app.state.users.settings.demo_read_only = read_only + headers = {"Authorization": f"Bearer {demo_credentials[credential]}"} + # A successful read proves this credential actually resolves the demo identity. + target = demo_app.state.users.demo_user_ids[1] + assert (await demo_client.get("/api/users/admin", headers=headers)).status_code == 200 + response = await demo_client.patch( + f"/api/users/admin/{target}", + json={"email": demo_app.state.users.settings.demo_user_email, "full_name": "Changed"}, + headers=headers, + ) + assert response.status_code == (403 if read_only else 200), response.text + if read_only: + assert response.json() == {"detail": "DEMO_READ_ONLY"} + else: + assert response.json()["full_name"] == "Changed" + + +@pytest.mark.parametrize("credential", ["bearer", "pat"]) +async def test_demo_token_inertia_write_uses_existing_refusal( + demo_app, demo_client, demo_credentials, credential +): + response = await demo_client.patch( + f"/api/users/admin/{demo_app.state.users.demo_user_ids[1]}", + json={"email": demo_app.state.users.settings.demo_user_email, "full_name": "Changed"}, + headers={ + "Authorization": f"Bearer {demo_credentials[credential]}", + "X-Inertia": "true", + "Referer": "http://testserver/admin/users/", + }, + ) + assert response.status_code == 409 + assert response.headers["X-Inertia-Location"] == "http://testserver/admin/users/" + + +@pytest.mark.parametrize("operation", ["login", "refresh"]) +async def test_demo_cannot_mint_token_pair_without_session( + demo_app, demo_client, demo_credentials, operation +): + if operation == "login": + path = "/api/users/auth/token" + body = {"email": demo_app.state.users.settings.demo_admin_email, "password": "DemoPass1!"} + else: + path = "/api/users/auth/token/refresh" + body = {"refresh_token": demo_credentials["refresh"]} + response = await demo_client.post(path, json=body) + assert response.status_code == 403, response.text + assert response.json() == {"detail": "DEMO_READ_ONLY"} + # Refusal must not consume the refresh token or leave a login lockout slot. + demo_app.state.users.settings.demo_read_only = False + assert (await demo_client.post(path, json=body)).status_code == 200 + + +async def test_demo_session_cannot_create_pat(demo_app): + cookie = forge_session_cookie( + str(demo_app.state.sm.settings.secret_key), + {"user_id": str(demo_app.state.users.demo_user_ids[0]), "sm_csrf_token": "csrf"}, + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=demo_app), + base_url="http://testserver", + cookies={"session": cookie}, + ) as client: + response = await client.post( + "/api/api_tokens/tokens/", + json={"name": "demo", "scopes": ["users.manage"], "expires_in_days": 1}, + headers={"X-CSRF-Token": "csrf"}, + ) + assert response.status_code == 403 + assert response.json() == {"detail": "DEMO_READ_ONLY"} + + +async def test_real_bearer_account_can_still_write_on_demo_instance(demo_app, demo_client): + response = await demo_client.post( + "/api/users/auth/token", json={"email": "admin@example.com", "password": "AdminPass1!"} + ) + assert response.status_code == 200 + response = await demo_client.patch( + f"/api/users/admin/{demo_app.state.users.demo_user_ids[1]}", + json={ + "email": demo_app.state.users.settings.demo_user_email, + "full_name": "Real admin write", + }, + headers={"Authorization": f"Bearer {response.json()['access_token']}"}, + ) + assert response.status_code == 200 + assert response.json()["full_name"] == "Real admin write" + + +async def test_demo_token_login_still_checks_password(demo_app, demo_client, demo_credentials): + response = await demo_client.post( + "/api/users/auth/token", + json={"email": demo_app.state.users.settings.demo_admin_email, "password": "wrong"}, + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize("credential", ["bearer", "pat"]) +async def test_demo_token_can_still_logout(demo_client, demo_credentials, credential): + response = await demo_client.post( + "/api/users/auth/logout", + headers={"Authorization": f"Bearer {demo_credentials[credential]}"}, + ) + assert response.status_code == 204 diff --git a/modules/users/users/auth_local/token_api.py b/modules/users/users/auth_local/token_api.py index f1d268a88..dc776e2e5 100644 --- a/modules/users/users/auth_local/token_api.py +++ b/modules/users/users/auth_local/token_api.py @@ -24,6 +24,7 @@ record_success, release, ) +from users.demo.guard import DEMO_READ_ONLY_DETAIL, is_read_only_demo from users.models import User from users.models.refresh_token import RefreshToken @@ -101,6 +102,10 @@ async def token_login( locked = await record_failure(db, attempt, admission) raise locked or HTTPException(status_code=401, detail="Invalid credentials") settings = request.app.state.users.settings + if is_read_only_demo(request.app, user.id): + await rollback_session(db) + await release(db, attempt, admission) + raise HTTPException(status_code=403, detail=DEMO_READ_ONLY_DETAIL) if not user.is_verified and settings.require_verification: # Same rule as the session login: a self-registered account that never # proved its address must not mint bearer tokens either (#547). @@ -140,9 +145,6 @@ async def token_refresh( if rt is None: raise HTTPException(status_code=401, detail="Invalid or expired refresh token") - rt.revoked_at = now - await db.flush() - # The account is re-read rather than trusted from the row: a refresh token # outlives the access tokens it mints, so between two refreshes the user may # have been deactivated, or signed everything out. Minting from a stale @@ -151,7 +153,11 @@ async def token_refresh( user = (await db.execute(select(User).where(col(User.id) == rt.user_id))).scalar_one_or_none() if user is None or not user.is_active or user.disabled_at is not None: raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + if is_read_only_demo(request.app, user.id): + raise HTTPException(status_code=403, detail=DEMO_READ_ONLY_DETAIL) + rt.revoked_at = now + await db.flush() settings = request.app.state.users.settings return await _create_token_pair(db, user.id, settings, user.session_version) diff --git a/modules/users/users/demo/guard.py b/modules/users/users/demo/guard.py index fdc6f957f..8858a50d2 100644 --- a/modules/users/users/demo/guard.py +++ b/modules/users/users/demo/guard.py @@ -12,17 +12,20 @@ every installed module, including ones written before demo mode existed. A dependency covers only the routes that remember to declare it. -Why it reads the session rather than ``request.state.user``: module middleware -sorts by ``depends_on``, and ``users`` depends on ``Auth``, so this executes -*before* ``AuthMiddleware`` has resolved anyone. The session cookie is already -decoded by then (``Session`` sits outside every module's middleware), and it -carries both the stamp the demo endpoints write and the user id — which is -what catches a visitor who signed in with a published demo password through -the ordinary form instead of a button. +Two layers cover different lifetimes. The middleware runs before Auth and +checks the decoded session's demo stamp or user id, including an expired +identity whose demo stamp remains. The registered principal guard runs after +Auth has resolved any credential source, covering session cookies, bearer +access tokens and personal API tokens by the resolved identity. Both layers +use the same allowed routes and refusal response. Token login and refresh +also check the account before minting non-session credentials. """ from __future__ import annotations +from typing import TYPE_CHECKING +from uuid import UUID + from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.types import ASGIApp, Receive, Scope, Send @@ -30,6 +33,9 @@ from users.constants import SESSION_USER_ID_KEY from users.demo import SESSION_DEMO_KEY +if TYPE_CHECKING: + from fastapi import FastAPI + #: The error body the frontend matches on to show "this is a read-only demo". DEMO_READ_ONLY_DETAIL = "DEMO_READ_ONLY" @@ -52,6 +58,31 @@ _ALLOWED_PREFIXES = ("/api/users/auth/demo/",) +def _write_is_allowed(scope: Scope) -> bool: + return ( + scope.get("method", "GET") in _SAFE_METHODS + or scope["path"] in _ALLOWED_PATHS + or scope["path"].startswith(_ALLOWED_PREFIXES) + ) + + +def is_read_only_demo(app: FastAPI, user_id: str | UUID) -> bool: + """Check a resolved account against live demo settings, independent of cookies.""" + state = app.state.users + return state.settings.demo_read_only and any( + str(user_id) == str(known) for known in state.demo_user_ids + ) + + +def demo_principal_refusal(request: Request) -> Response | None: + """Auth's post-resolution guard, including fallback principal resolvers.""" + if not _write_is_allowed(request.scope) and is_read_only_demo( + request.app, request.state.user.id + ): + return _refusal(request.scope) + return None + + def is_demo_session(scope: Scope, demo_user_ids) -> bool: """Whether this request carries one of the demo accounts' sessions.""" session = scope.get("session") or {} @@ -90,7 +121,7 @@ def __init__(self, app: ASGIApp) -> None: self.app = app async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope["type"] != "http" or scope.get("method", "GET") in _SAFE_METHODS: + if scope["type"] != "http" or _write_is_allowed(scope): await self.app(scope, receive, send) return @@ -110,11 +141,6 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) return - path = scope["path"] - if path in _ALLOWED_PATHS or path.startswith(_ALLOWED_PREFIXES): - await self.app(scope, receive, send) - return - if not is_demo_session(scope, getattr(state, "demo_user_ids", ())): await self.app(scope, receive, send) return diff --git a/modules/users/users/module.py b/modules/users/users/module.py index 50aa7d55b..f209e6056 100644 --- a/modules/users/users/module.py +++ b/modules/users/users/module.py @@ -134,14 +134,10 @@ def register_exception_handlers(self, app: FastAPI) -> None: install_exception_handlers(app) def register_middleware(self, app: FastAPI) -> None: - """Refuse writes from either shared demo session. - - A no-op until ``demo_mode`` and ``demo_read_only`` are both on — it - re-reads them per request — so an install that never hosts a demo pays - one frozenset lookup on unsafe methods and nothing on reads. - """ - from users.demo.guard import DemoReadOnlyMiddleware + """Guard demo sessions early and resolved demo identities after auth.""" + from users.demo.guard import DemoReadOnlyMiddleware, demo_principal_refusal + app.state.auth.principal_guards.append(demo_principal_refusal) app.add_middleware(DemoReadOnlyMiddleware) def register_public_routes(self, registry: PublicRouteRegistry) -> None: From 984865c10d78035e07781a64074065b89c4ae39f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 23 Oct 2026 19:30:12 +0200 Subject: [PATCH 3/4] fix: address code review findings (round 1, pass 1) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 1 + framework/core/simple_module_core/app_state.py | 6 +++++- framework/hosting/simple_module_hosting/shared_props.py | 9 +++++---- modules/auth/auth/state.py | 3 ++- modules/users/users/auth_local/token_api.py | 9 +++++++-- 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be69ed05..0e328b8ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ All notable changes to this project are documented in this file. The format is b tracked for removal in 0.1.0 (#594). ### Fixed +- Users: read-only demo mode now follows the resolved identity, not only the session cookie. Writes made with a bearer access token or personal API token of a demo account are refused (403 `DEMO_READ_ONLY`, or the Inertia 409 redirect) through the new `AuthState.principal_guards` hook, and demo accounts can no longer mint tokens via `POST /api/users/auth/token` or `/token/refresh` while `demo_read_only` is on (#611). - Users: the default user role is created at startup when missing, so an app scaffolded with `smpy new` (schema baseline, no data migrations) can register users (#600). - Tenants: deleting a tenant now tears down its data. `TenantDeleted` (now defined in diff --git a/framework/core/simple_module_core/app_state.py b/framework/core/simple_module_core/app_state.py index 7dc48cb02..015e73f2a 100644 --- a/framework/core/simple_module_core/app_state.py +++ b/framework/core/simple_module_core/app_state.py @@ -28,6 +28,10 @@ from simple_module_core.tenancy import TenantExists, TenantMembers +#: Attribute ``register_inertia_shared_provider`` writes and the renderer reads. +INERTIA_SHARED_PROVIDERS_STATE_ATTR = "inertia_shared_providers" + + class AuthProvider(Protocol): """Framework-facing auth extension; installed by users or keycloak.""" @@ -184,7 +188,7 @@ def get_inertia_shared_providers(app: Any) -> list[SharedPropsProvider] | None: Returns None when absent. """ - return getattr(app.state, "inertia_shared_providers", None) + return getattr(app.state, INERTIA_SHARED_PROVIDERS_STATE_ATTR, None) def get_branding(app: Any) -> BrandingState | None: diff --git a/framework/hosting/simple_module_hosting/shared_props.py b/framework/hosting/simple_module_hosting/shared_props.py index 34a393b07..f4ed85b85 100644 --- a/framework/hosting/simple_module_hosting/shared_props.py +++ b/framework/hosting/simple_module_hosting/shared_props.py @@ -18,7 +18,10 @@ from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING -from simple_module_core.app_state import get_inertia_shared_providers +from simple_module_core.app_state import ( + INERTIA_SHARED_PROVIDERS_STATE_ATTR, + get_inertia_shared_providers, +) if TYPE_CHECKING: from fastapi import FastAPI @@ -27,8 +30,6 @@ SharedPropsProvider = Callable[["Request"], dict | Awaitable[dict]] """A function mapping a request to a dict merged into Inertia shared props.""" -_STATE_ATTR = "inertia_shared_providers" - def register_inertia_shared_provider(app: FastAPI, provider: SharedPropsProvider) -> None: """Register a shared-props provider on the app. @@ -40,5 +41,5 @@ def register_inertia_shared_provider(app: FastAPI, provider: SharedPropsProvider providers = get_inertia_shared_providers(app) if providers is None: providers = [] - setattr(app.state, _STATE_ATTR, providers) + setattr(app.state, INERTIA_SHARED_PROVIDERS_STATE_ATTR, providers) providers.append(provider) diff --git a/modules/auth/auth/state.py b/modules/auth/auth/state.py index 6fd0eda7c..97d01dcd8 100644 --- a/modules/auth/auth/state.py +++ b/modules/auth/auth/state.py @@ -29,7 +29,8 @@ class AuthState: auth_provider: AuthProvider | None = None principal_resolvers: list[PrincipalResolver] = field(default_factory=list) # Run after every credential source resolves, before downstream middleware. - # A guard returns a refusal response or None to continue; errors fail closed. + # 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) diff --git a/modules/users/users/auth_local/token_api.py b/modules/users/users/auth_local/token_api.py index dc776e2e5..36fed3e6d 100644 --- a/modules/users/users/auth_local/token_api.py +++ b/modules/users/users/auth_local/token_api.py @@ -145,6 +145,11 @@ async def token_refresh( if rt is None: raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + # Burn the presented token first: it is committed even when the account + # check below answers 401, so a deactivated account's token stays dead. + rt.revoked_at = now + await db.flush() + # The account is re-read rather than trusted from the row: a refresh token # outlives the access tokens it mints, so between two refreshes the user may # have been deactivated, or signed everything out. Minting from a stale @@ -154,10 +159,10 @@ async def token_refresh( if user is None or not user.is_active or user.disabled_at is not None: raise HTTPException(status_code=401, detail="Invalid or expired refresh token") if is_read_only_demo(request.app, user.id): + # A refusal must not consume the token: undo the revocation above. + await rollback_session(db) raise HTTPException(status_code=403, detail=DEMO_READ_ONLY_DETAIL) - rt.revoked_at = now - await db.flush() settings = request.app.state.users.settings return await _create_token_pair(db, user.id, settings, user.session_version) From 41ae07425d1a270ae617997d60d7852a7b8e7fb4 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 23 Oct 2026 19:31:35 +0200 Subject: [PATCH 4/4] fix: address code review findings (round 1, pass 2) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/auth/auth/state.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/auth/auth/state.py b/modules/auth/auth/state.py index 97d01dcd8..f5a031557 100644 --- a/modules/auth/auth/state.py +++ b/modules/auth/auth/state.py @@ -28,7 +28,8 @@ class AuthState: auth_provider: AuthProvider | None = None principal_resolvers: list[PrincipalResolver] = field(default_factory=list) - # Run after every credential source resolves, before downstream middleware. + # 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)