- Serve multiple domains (RP IDs) from one instance: host-based dispatch, per-domain credentials and sessions, domains managed at runtime in the admin UI — previously one RP per instance - Cross-domain sign-in via Related Origin Requests: per-domain related-origins list with a served .well-known/webauthn document - Explicit per-domain origin lists with shell-glob wildcards (**. for apex + any subdomain depth, *. for one level), editable in the admin UI with validation and self-lockout guards - Per-domain auth hosts: the account/admin UI can live on a different host per domain, no longer confined to subdomains of a single RP - CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an existing database; 'paskia migrate' converts legacy databases BREAKING CHANGES (v2.0): - Database schema: config is now per-domain and credentials/sessions carry an rp_id — existing databases must be converted with 'paskia migrate' - Origins are now explicit: main implicitly allowed every subdomain of the RP; configure '**.' origins to reproduce that behavior - CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
150 lines
4.7 KiB
Python
150 lines
4.7 KiB
Python
"""
|
|
Database lifecycle: initialization and maintenance.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import re
|
|
import signal
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Annotated, Any, Optional
|
|
from uuid import UUID
|
|
|
|
from kanta import Kanta
|
|
from kanta.exceptions import DatabaseError
|
|
|
|
import paskia.db.operations as _ops
|
|
from paskia import oidc_notify
|
|
from paskia.authsession import EXPIRES
|
|
from paskia.db.paths import db_file_path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# The combined database lives at a fixed CWD-relative path; no runtime
|
|
# configuration is needed to locate it.
|
|
kanta = Kanta(str(db_file_path()), _ops._db)
|
|
_ops._db._store = kanta
|
|
|
|
|
|
def _lookup_uuid_in_state(state: dict | None, uuid_str: str) -> str | None:
|
|
"""Resolve UUID to label from serialized state dict."""
|
|
if not state:
|
|
return None
|
|
|
|
# Display-name based entities.
|
|
for bucket in ("users", "orgs", "roles", "permissions"):
|
|
entity = state.get(bucket, {}).get(uuid_str)
|
|
if isinstance(entity, dict):
|
|
display_name = entity.get("display_name")
|
|
if isinstance(display_name, str) and display_name:
|
|
return display_name
|
|
|
|
# OIDC clients use "name" instead of "display_name".
|
|
oidc_state = state.get("oidc", {})
|
|
if isinstance(oidc_state, dict):
|
|
client = oidc_state.get("clients", {}).get(uuid_str)
|
|
if isinstance(client, dict):
|
|
name = client.get("name")
|
|
if isinstance(name, str) and name:
|
|
return name
|
|
|
|
return None
|
|
|
|
|
|
def _resolve_uuid_label(
|
|
uuid_str: str,
|
|
*,
|
|
previous: dict | None = None,
|
|
current: dict | None = None,
|
|
) -> str | None:
|
|
"""Resolve known entity UUIDs to human-readable labels."""
|
|
# Prefer previous state so deletions/renames still show a useful label.
|
|
label = _lookup_uuid_in_state(previous, uuid_str)
|
|
if label:
|
|
return label
|
|
label = _lookup_uuid_in_state(current, uuid_str)
|
|
if label:
|
|
return label
|
|
|
|
try:
|
|
uid = UUID(uuid_str)
|
|
except ValueError:
|
|
return None
|
|
|
|
if uid in _ops._db.users:
|
|
return _ops._db.users[uid].display_name
|
|
if uid in _ops._db.orgs:
|
|
return _ops._db.orgs[uid].display_name
|
|
if uid in _ops._db.roles:
|
|
return _ops._db.roles[uid].display_name
|
|
if uid in _ops._db.permissions:
|
|
return _ops._db.permissions[uid].display_name
|
|
if uid in _ops._db.oidc.clients:
|
|
return _ops._db.oidc.clients[uid].name
|
|
return None
|
|
|
|
|
|
# The OIDC signing key is stored at oidc.key.
|
|
_OIDC_KEY_PATH = re.compile(r"^oidc\.key$")
|
|
|
|
|
|
@kanta.logfmt
|
|
def format_log_uuid(
|
|
value: Any,
|
|
path: str,
|
|
previous: Annotated[dict, "pre"] | None = None,
|
|
current: Annotated[dict, "post"] | None = None,
|
|
) -> Optional[str]: # noqa: UP045
|
|
"""Format UUID values/keys/actor labels and censor secrets in transaction logs."""
|
|
# Censor sensitive OIDC key material regardless of value type, but only
|
|
# when formatting the value: path components are passed with the component
|
|
# itself as value and must stay visible ("oidc.key = <hidden>").
|
|
if _OIDC_KEY_PATH.fullmatch(path) and value != "key":
|
|
return "<hidden>"
|
|
|
|
if not isinstance(value, str):
|
|
return None
|
|
|
|
# Works for transaction actor metadata ($user), values, and path components.
|
|
return _resolve_uuid_label(value, previous=previous, current=current)
|
|
|
|
|
|
@kanta.fatal_error
|
|
def terminate(error: DatabaseError) -> None:
|
|
"""Fatal error callback: terminate the process on background write failures."""
|
|
logger.error("Fatal database error: %s", error)
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
|
|
|
|
async def init():
|
|
"""Load database from JSONL file using kanta.
|
|
|
|
The database must already exist and be initialized (see ``paskia
|
|
init``); the serve command's startup checks guarantee this before the
|
|
lifespan runs.
|
|
"""
|
|
rootpath = Path(kanta.filename).parent
|
|
try:
|
|
await asyncio.to_thread(rootpath.mkdir, parents=True, exist_ok=True)
|
|
await kanta.open()
|
|
except Exception as e:
|
|
raise SystemExit(f"{e}") from e
|
|
|
|
|
|
def cleanup_expired() -> int:
|
|
"""Remove expired sessions and reset tokens. Returns count removed."""
|
|
now = datetime.now(UTC)
|
|
limit = now - EXPIRES
|
|
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
|
|
if expired_sessions:
|
|
oidc_notify.schedule_notifications(expired_sessions)
|
|
with kanta.transaction("expiry"):
|
|
for k in expired_sessions:
|
|
del _ops._db.sessions[k]
|
|
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
|
|
for k in expired_tokens:
|
|
del _ops._db.reset_tokens[k]
|
|
return len(expired_sessions) + len(expired_tokens)
|