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