Skip to content

src/genro_asgi/auth/mixin.py

Source from this local checkout, regenerated when the reader rebuilds. Line links use #L<number>; a GitHub line range opens its first line.

1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 #     https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """Auth capability: header/session identity resolution as a mixin (D16).16 17 ``AuthMixin`` is composed BEFORE ``SessionMixin``/``MiddlewareMixin``/18 ``BaseServer`` (``class S(AuthMixin, SessionMixin, MiddlewareMixin,19 BaseServer)``). Its cooperative ``__init__`` peels ``auth=`` (the config dict;20 ``None`` builds an ``AuthCore`` with no header backends armed) and ARMS21 ``AuthMiddleware`` by injecting ``{"auth": True}`` into the ``middleware`` config22 it forwards along the cooperative chain — the same mechanism ``SessionMixin``23 uses, so composing the mixins arms header auth with no user action while an24 explicit ``middleware={"auth": False}`` still wins.25 26 It also wires the server's identity stores. ``users=`` and ``tokens=`` each take27 a config dict (``{mount, prefix}``, defaulting to ``site:users`` /28 ``site:api_keys``) OR a ready store instance; ``admin_password=`` seeds the29 bootstrap admin. The stores are built AFTER ``super().__init__()`` returns — by30 then the cooperative chain has run and ``self.storage`` exists, since AuthMixin31 precedes StorageMixin in the MRO. The ``user_store`` / ``api_key_store``32 properties return ``None`` when unconfigured (the shape ``login`` already reads).33 A config dict without storage on the server is a boot error (no silent fallback);34 a ready instance needs no storage. ``admin_password`` with no ``users=`` config35 implies the default users store. The bootstrap admin is an UPSERT at boot (config36 wins): runtime edits to the admin record do not survive a reboot.37 38 It overrides the §4 contract method ``authenticate(request)`` with the §5.539 identity precedence: an ``Authorization`` header wins (API-first) — its40 ``AuthCore`` verdict is an ``Avatar`` or a raised ``HTTPUnauthorized``; with no41 header the request's session avatar is used. "Nobody" is ``None`` uniformly:42 an anonymous session carries ``avatar is None`` and ``self.session(request)``43 returns ``None`` unchanged when ``SessionMixin`` is absent, so the precedence44 degrades to ``None`` in both cases. In the middleware chain45 ``SessionMiddleware`` (order 400) runs OUTSIDE ``AuthMiddleware`` (order 450),46 so the session is already on the scope when the fallback runs.47 """48 49 from __future__ import annotations50 51 from typing import Any52 53 from .api_key_store import ApiKeyStore, FileApiKeyStore54 from .core import AuthCore55 from .user_store import FileUserStore, UserStore56 57 __all__ = ["AuthMixin"]58 59 ADMIN_IDENTITY = "admin"60 #: The bootstrap admin answers for the whole server, so it carries both the61 #: administration tag and the one gating the monitor: a freshly installed62 #: server is observable by the identity that configures it. The UPSERT at boot63 #: applies this list to an existing record too, so no store needs migrating.64 ADMIN_TAGS = ["SUPERADMIN", "SERVER_ADMIN"]65 66 67 class AuthMixin:68     """Auth capability mixin, composed BEFORE the session/middleware/server classes.69 70     Constructor kwargs peeled here: ``auth`` — the credential config dict71     (``{'basic': ..., 'bearer': ..., 'jwt': [...]}``); ``None`` arms no header72     backend but still resolves the session identity through §5.5 precedence.73     ``users`` / ``tokens`` — a ``{mount, prefix}`` config dict or a ready store74     instance for the identity/api-key stores; ``admin_password`` — the bootstrap75     admin's password (implies the default users store when ``users`` is absent).76     """77 78     def __init__(self, **kwargs: Any) -> None:79         auth: dict[str, Any] | None = kwargs.pop("auth", None)80         users = kwargs.pop("users", None)81         tokens = kwargs.pop("tokens", None)82         admin_password: str | None = kwargs.pop("admin_password", None)83         middleware: dict[str, Any] = dict(kwargs.get("middleware") or {})84         middleware.setdefault("auth", True)85         kwargs["middleware"] = middleware86         super().__init__(**kwargs)87         if users is None and admin_password is not None:88             users = {}89         self._user_store = self._build_user_store(users)90         self._api_key_store = self._build_api_key_store(tokens)91         self._auth_core = AuthCore(**(auth or {}), api_key_store=self._api_key_store)92         if admin_password is not None:93             self._bootstrap_admin(admin_password)94 95     @property96     def auth_core(self) -> AuthCore:97         """The credential store backing this server's header authentication."""98         return self._auth_core99 100     @property101     def user_store(self) -> UserStore | None:102         """The local identity store, or ``None`` when no ``users`` is configured."""103         return self._user_store104 105     @property106     def api_key_store(self) -> ApiKeyStore | None:107         """The api-key registry, or ``None`` when no ``tokens`` is configured."""108         return self._api_key_store109 110     def _build_user_store(self, users: Any) -> UserStore | None:111         """Build the user store from a config dict, pass through an instance, or None."""112         if users is None:113             return None114         if isinstance(users, UserStore):115             return users116         return FileUserStore(self._require_storage("users"), **users)117 118     def _build_api_key_store(self, tokens: Any) -> ApiKeyStore | None:119         """Build the api-key store from a config dict, pass through an instance, or None."""120         if tokens is None:121             return None122         if isinstance(tokens, ApiKeyStore):123             return tokens124         return FileApiKeyStore(self._require_storage("tokens"), **tokens)125 126     def _require_storage(self, section: str) -> Any:127         """Return the server storage, or raise when a config-dict store needs it.128 129         A ``{mount, prefix}`` store cannot be built without a StorageMixin on the130         server: an incoherent configuration is a boot error, never a silent None.131         """132         storage = getattr(self, "storage", None)133         if storage is None:134             raise RuntimeError(135                 f"'{section}' store needs a storage mount, but the server has no storage"136             )137         return storage138 139     def _bootstrap_admin(self, admin_password: str) -> None:140         """UPSERT the bootstrap admin at boot (config wins over any stored record)."""141         store = self._user_store142         store.save(143             {144                 "identity": ADMIN_IDENTITY,145                 "password_hash": store.hash_password(admin_password),146                 "tags": list(ADMIN_TAGS),147                 "enabled": True,148             }149         )150 151     def authenticate(self, request: Any) -> Any:152         """Resolve the request identity: header credentials win, else the session.153 154         The ``Authorization`` header is API-first — a valid credential yields an155         ``Avatar``, an invalid one raises ``HTTPUnauthorized`` (no fallback).156         Without a header, the session avatar is returned (``None`` when no157         session capability is composed or the session is anonymous).158         """159         avatar = self.auth_core.authenticate(request)160         if avatar is not None:161             return avatar162         session = self.session(request)163         return session.avatar() if session is not None else None