Files
paskia/paskia/fastapi/admin/domains.py
T
LeoVasanko 0a59e26e45 Bound handshake reads; audit ctx for domain-delete scope shrinking
An accepted-but-silent peer no longer holds a sync connection open
forever: each dialogue frame read has a 15 s bound, closed with 1011 —
never 1008, which the satellite reads as revocation. Domain deletion
passes the admin's session context to link scope shrinks for the audit
log.
2026-10-01 06:40:07 +00:00

224 lines
7.9 KiB
Python

"""Domain (rp-id) management API — master admin only.
Each domain is one rp-id with its own rp-name and an origins table of
sign-in sites: entries within the rp-id domain are in-domain sites (one of
which may be marked as the auth host), entries outside it are related
origins on unrelated domains (WebAuthn Related Origin Requests). All
changes are validated cross-domain before being persisted, and the runtime
domain registry is rebuilt after each change so it takes effect
immediately.
Remote (satellite-served) domains are uplink-managed — the upstream pushes
their configuration; they cannot be created, edited or deleted here. Domain
changes are pushed live to the satellite links serving them.
"""
from fastapi import Body, FastAPI, Request
from paskia import db, domains
from paskia.db.structs import Config, DomainConfig, OriginEntry
from paskia.fastapi import authz, sync
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.sansio import Passkey
from paskia.util import hostutil
from paskia.util.apistructs import ApiDomain
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def _domain_to_api(domain: domains.Domain) -> ApiDomain:
return ApiDomain(
rp_id=domain.rp_id,
rp_name=domain.rp_name,
origins=domain.config.origins,
site_url=domain.site_url,
auth_site_url=domain.auth_site_url,
auth_host=domain.own_auth_host,
remote=domain.config.remote,
)
def _normalize_origins_map(values: dict | None) -> dict[str, bool | OriginEntry]:
"""Normalize an origins object from the admin UI (raises on malformed).
Keys arrive as bare hosts, wildcard patterns, or full origins; they are
stored as origins-table keys (https:// omitted). In-domain vs. related
classification is derived from the rp-id at validation time.
"""
out: dict[str, bool | OriginEntry] = {}
for raw_key, raw_props in (values or {}).items():
key = raw_key.strip()
if not key:
continue
if key != "*" and not hostutil.is_wildcard_pattern(key):
key = domains.origin_key(hostutil.normalize_origin(key))
is_auth = raw_props is not True and bool((raw_props or {}).get("auth_host"))
out[key] = OriginEntry(auth_host=True) if is_auth else True
return out
def _rebuild_registry() -> None:
"""Rebuild the runtime domain registry from the stored configuration."""
domains.init_registry(db.data().config)
def _check_not_remote(payload: dict, rp_id: str | None = None) -> None:
"""Remote domains are uplink-managed: created by registering an uplink
on the satellite, edited and removed by the upstream's push."""
if payload.get("remote") is not None:
raise ValueError(
"Remote domains are managed by satellite uplinks — register the "
"link on the satellite instead (its satellites panel, or the "
"'paskia satellite' command)"
)
if rp_id is not None and db.data().config.domains[rp_id].remote is not None:
raise ValueError(
f"Domain '{rp_id}' is served from an uplink — managed there, "
"not by local edits"
)
def _check_not_locking_self_out(
request: Request,
rp_id: str,
domain: DomainConfig,
) -> None:
"""Refuse domain changes that lock the admin out of their current host.
Applies when the admin edits the domain they are currently using and the
new config has no auth host (with an auth host, ceremonies move there
and it is always allowed). The admin's current host must remain able to
run passkey ceremonies under the new config.
"""
current: domains.Domain = request.state.domain
if rp_id != current.rp_id or domains.auth_host_url(domain):
return
raw_host = (request.headers.get("host") or "").rstrip(".")
if not raw_host:
return
in_domain, related = domains.partition_origins(rp_id, domain.origins)
probe = Passkey(
rp_id=rp_id,
origins=[domains.origin_url(k) for k in in_domain],
related_origins=[domains.origin_url(k) for k in related],
)
for scheme in ("https", "http"):
try:
probe.validate_origin(f"{scheme}://{raw_host}")
return # Current host still works — no lockout
except ValueError:
pass
raise ValueError(
f"This change would lock you out: '{raw_host}' could no longer "
f"run passkey ceremonies for domain '{rp_id}'. Add it to the "
"allowed origins (or mark an auth host) before saving."
)
@app.get("/")
async def admin_list_domains(request: Request, auth=AUTH_COOKIE):
"""List all domains with derived URLs (master admin only)."""
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
registry = domains.registry()
return MsgspecResponse([_domain_to_api(domain) for domain in registry.domains])
@app.post("/")
async def admin_create_domain(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Add a new domain (master admin only, recent authentication required)."""
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
rp_id = (payload.get("rp_id") or "").strip().lower()
if not rp_id:
raise ValueError("rp_id is required")
_check_not_remote(payload)
new = DomainConfig(
rp_name=(payload.get("rp_name") or "").strip() or None,
origins=_normalize_origins_map(payload.get("origins")),
)
config = db.data().config
# Validate the would-be combined configuration before persisting
domains.validate_config(
Config(domains={**config.domains, rp_id: new}, listen=config.listen)
)
db.create_domain(rp_id, new, ctx=ctx)
_rebuild_registry()
return {"status": "ok"}
@app.patch("/{rp_id}")
async def admin_update_domain(
rp_id: str,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update a domain's rp_name, origins and related origins (replaced
wholesale); links serving the domain are pushed the new configuration.
The rp-id itself is immutable: credentials are stamped with it.
"""
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
config = db.data().config
if rp_id not in config.domains:
raise ValueError(f"Domain {rp_id} not found")
_check_not_remote(payload, rp_id)
updated = DomainConfig(
rp_name=(payload.get("rp_name") or "").strip() or None,
origins=_normalize_origins_map(payload.get("origins")),
)
would_be = Config(
domains={k: updated if k == rp_id else v for k, v in config.domains.items()},
listen=config.listen,
)
domains.validate_config(would_be)
_check_not_locking_self_out(request, rp_id, updated)
db.update_domain(
rp_id,
rp_name=updated.rp_name,
origins=updated.origins,
ctx=ctx,
)
_rebuild_registry()
await sync.refresh_links_serving(rp_id)
return {"status": "ok"}
@app.delete("/{rp_id}")
async def admin_delete_domain(
rp_id: str,
request: Request,
auth=AUTH_COOKIE,
):
"""Delete a domain (refused for the last domain, remote domains, or
while credentials remain); links serving it lose it from their scope."""
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
current: domains.Domain = request.state.domain
if rp_id == current.rp_id:
raise ValueError(
"Cannot delete the domain you are currently using — authenticate "
"on another domain first"
)
db.delete_domain(rp_id, ctx=ctx)
_rebuild_registry()
await sync.drop_domain_from_links(rp_id, ctx=ctx)
return {"status": "ok"}