Files
paskia/paskia/realms.py
T
LeoVasanko 33d3b88941 Test suite for the realm architecture
- conftest: bootstrap seeds a localhost realm Config; realm_registry
  fixture builds the runtime registry; avatar storage redirected to a
  per-test tmp dir; credentials/sessions stamped with the test realm.
- test_cli rewritten for the init/serve split, incl. legacy adoption.
- TestServerConfig replaced by TestRealms covering the realm CRUD API,
  cross-realm validation, delete guards and effective-auth-host fallback.
- Avatar/OIDC tests updated for per-realm providers and realm-derived
  URLs; obsolete PASKIA_DB path tests removed.
2026-09-06 04:28:35 +00:00

306 lines
10 KiB
Python

"""Realm registry: per-rp-id runtime state and host resolution.
A **realm** is one rp-id with its associated hosts and origins. The
registry is built from the stored combined ``Config`` at startup and
rebuilt on admin realm changes; request dispatch resolves hosts to realms
through it. The database itself is global — only the *current realm*
(passkey, site URLs, OIDC view) varies per request, tracked via a
contextvar set by the dispatch middleware.
"""
from __future__ import annotations
import contextvars
import os
from fastapi_vue.hostutil import parse_endpoints
from paskia.db.structs import Config, RealmConfig
from paskia.util import hostutil
from paskia.util.constants import DEFAULT_PORT
# Maximum number of related (non-subdomain) origins per realm. WebAuthn
# Related Origin Requests require browsers to support at least 5 labels.
DEFAULT_RELATED_ORIGIN_CAP = 5
class Realm:
"""Runtime view of one realm: stored config plus derived values."""
def __init__(self, config: RealmConfig, site_url: str, site_path: str):
# Lazy import: paskia.sansio depends on paskia.db, which (via
# paskia.db.operations → paskia.oidc_notify) depends on this module.
from paskia.sansio import Passkey # noqa: PLC0415
self.config = config
self.site_url = site_url
self.site_path = site_path
self.passkey = Passkey(
rp_id=config.rp_id,
rp_name=config.rp_name,
origins=config.origins,
)
@property
def rp_id(self) -> str:
return self.config.rp_id
@property
def rp_name(self) -> str:
return self.passkey.rp_name
@property
def own_auth_host(self) -> str | None:
"""This realm's own auth host as host[:port], if configured."""
if not self.config.auth_host:
return None
return hostutil.auth_host_netloc(self.config.auth_host)
@property
def related_origins(self) -> list[str]:
"""Configured origins outside the rp-id subtree (ROR origins)."""
related = []
for origin in self.config.origins or []:
hostname = hostutil.origin_hostname(origin)
if hostname and not hostutil.is_subdomain(hostname, self.rp_id):
related.append(origin)
return related
@property
def is_root_mode(self) -> bool:
"""Whether this realm's UI lives at the site root (own auth host)."""
return self.config.auth_host is not None
@property
def ui_base_path(self) -> str:
return "/" if self.is_root_mode else "/auth/"
@property
def auth_site_url(self) -> str:
"""Base URL of this realm's auth site UI."""
return self.site_url + self.site_path
def api_url(self, path: str = "") -> str:
"""Return an absolute URL under the canonical /auth/api/ prefix."""
if not path:
return f"{self.site_url}/auth/api/"
return f"{self.site_url}/auth/api/{path.lstrip('/')}"
def reset_link_url(self, token: str) -> str:
"""Generate a reset link URL for the given token on this realm."""
return f"{self.auth_site_url}{token}"
class RealmRegistry:
"""Resolved realms and host lookup tables."""
def __init__(self, realms: list[Realm]):
self._by_rp_id = {r.rp_id: r for r in realms}
self._auth_hosts: dict[str, Realm] = {}
self._related_hosts: dict[str, Realm] = {}
for realm in realms:
if own := realm.own_auth_host:
self._auth_hosts[hostutil.normalize_host(own) or own] = realm
for origin in realm.related_origins:
if hostname := hostutil.origin_hostname(origin):
self._related_hosts[hostname] = realm
@property
def realms(self) -> list[Realm]:
"""All realms, in configuration order (first is the default)."""
return list(self._by_rp_id.values())
@property
def default(self) -> Realm:
"""The default realm (first in configuration order)."""
return next(iter(self._by_rp_id.values()))
def get(self, rp_id: str) -> Realm | None:
return self._by_rp_id.get(rp_id)
def effective_auth_host(self, realm: Realm) -> str | None:
"""Auth host serving WS/restricted APIs for a realm: its own, or the
first configured auth host (in realm order) as a shared fallback.
Returns host[:port] suitable for URL building, or None.
"""
if realm.own_auth_host:
return realm.own_auth_host
for candidate in self._by_rp_id.values():
if candidate.own_auth_host:
return candidate.own_auth_host
return None
def resolve(self, host: str | None) -> Realm | None:
"""Resolve a request Host header to a realm.
Order: exact rp-id → exact auth host → exact related-origin
hostname → longest-suffix rp-id. Unknown hosts return None.
"""
h = hostutil.normalize_host(host)
if not h:
return None
if realm := self._by_rp_id.get(h):
return realm
if realm := self._auth_hosts.get(h):
return realm
if realm := self._related_hosts.get(h):
return realm
best = None
for rp_id, realm in self._by_rp_id.items():
if h.endswith(f".{rp_id}") and (
best is None or len(rp_id) > len(best.rp_id)
):
best = realm
return best
def validate_config(
config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP
) -> None:
"""Validate a combined configuration cross-realm. Raises ValueError."""
if not config.realms:
raise ValueError("At least one realm (rp-id) is required")
rp_ids: set[str] = set()
auth_hosts: dict[str, str] = {} # normalized host -> owning rp_id
related_hosts: dict[str, str] = {} # hostname -> owning rp_id
for realm in config.realms:
hostutil.validate_rp_id(realm.rp_id)
if realm.rp_id in rp_ids:
raise ValueError(f"Duplicate rp-id '{realm.rp_id}'")
rp_ids.add(realm.rp_id)
if realm.auth_host:
hostutil.validate_auth_host(realm.auth_host, realm.rp_id)
hn = hostutil.normalize_host(
hostutil.auth_host_netloc(realm.auth_host) or ""
)
if hn:
if hn in auth_hosts:
raise ValueError(
f"auth-host '{hn}' is configured for both "
f"'{auth_hosts[hn]}' and '{realm.rp_id}'"
)
auth_hosts[hn] = realm.rp_id
related = 0
for origin in realm.origins or []:
hn = hostutil.origin_hostname(origin)
if not hn:
raise ValueError(f"Invalid origin URL: '{origin}'")
if hostutil.is_subdomain(hn, realm.rp_id):
continue # Classic subtree origin
related += 1
if hn in related_hosts:
raise ValueError(
f"Related origin host '{hn}' is configured for both "
f"'{related_hosts[hn]}' and '{realm.rp_id}'"
)
related_hosts[hn] = realm.rp_id
if related > related_origin_cap:
raise ValueError(
f"Realm '{realm.rp_id}' has {related} related origins "
f"(maximum {related_origin_cap})"
)
for hn, owner in auth_hosts.items():
if hn in rp_ids:
raise ValueError(f"auth-host '{hn}' collides with an rp-id")
if hn in related_hosts:
raise ValueError(
f"auth-host '{hn}' collides with a related origin of "
f"realm '{related_hosts[hn]}'"
)
for hn, owner in related_hosts.items():
if hn in rp_ids:
raise ValueError(f"Related origin host '{hn}' collides with an rp-id")
for other in rp_ids:
if other != owner and hostutil.is_subdomain(hn, other):
raise ValueError(
f"Related origin host '{hn}' of realm '{owner}' "
f"falls inside realm '{other}'"
)
def _derive_site(
realm: RealmConfig, *, listen_port: int | None, vite_url: str | None
) -> tuple[str, str]:
"""Compute a realm's site_url and site_path.
Priority: auth_host > origins[0] > PASKIA_VITE_URL (localhost realm
only) > http://localhost:port (localhost realm) > https://rp-id.
"""
if realm.auth_host:
return realm.auth_host, "/"
if realm.origins:
return realm.origins[0], "/auth/"
if realm.rp_id == "localhost":
if vite_url:
return vite_url.rstrip("/"), "/auth/"
if listen_port:
return f"http://localhost:{listen_port}", "/auth/"
return f"https://{realm.rp_id}", "/auth/"
_registry: RealmRegistry | None = None
_listen: list[str] | None = None
def configure(*, listen: list[str] | None = None) -> None:
"""Record process-global serve parameters for site URL derivation."""
global _listen
_listen = listen
def build(config: Config) -> RealmRegistry:
"""Validate and build a registry from a combined configuration."""
validate_config(config)
endpoint = next(iter(parse_endpoints(_listen, DEFAULT_PORT)), {})
vite_url = os.environ.get("PASKIA_VITE_URL")
realms = [
Realm(
rc,
*_derive_site(rc, listen_port=endpoint.get("port"), vite_url=vite_url),
)
for rc in config.realms
]
return RealmRegistry(realms)
def init_registry(config: Config) -> RealmRegistry:
"""Build and install the global registry from a combined configuration."""
global _registry
_registry = build(config)
return _registry
def registry() -> RealmRegistry:
"""Return the global registry (must be initialized)."""
if _registry is None:
raise RuntimeError("Realm registry is not initialized")
return _registry
_current_realm: contextvars.ContextVar[Realm | None] = contextvars.ContextVar(
"paskia_current_realm", default=None
)
def set_current_realm(realm: Realm | None) -> contextvars.Token:
return _current_realm.set(realm)
def reset_current_realm(token: contextvars.Token) -> None:
_current_realm.reset(token)
def current_realm() -> Realm:
"""Return the request's realm, or the default realm without request context."""
realm = _current_realm.get()
if realm is not None:
return realm
return registry().default