41 KiB
Multi-Site Support: Combined-Database Plan
Status: draft v4 for review — no code changes made. v4 folds in a
simplification round: Related Origin Requests are now assumed to have
universal browser support (Firefox included); there are no existing
multi-database deployments to migrate — the only legacy path is adopting
a lone <rp-id>.paskiadb into the new combined paskia.kantadb file; and
realm configuration is bootstrap-only on the CLI — rp-ids, rp-names,
origins and auth hosts are managed at runtime through the master-admin web
interface, so the serve command takes no realm arguments at all.
Goal: one paskia process on one port (4401) serves multiple sites with
one combined database. The administrative instance is separated from
the WebAuthn RP: organizations and users are global across rp-ids;
rp-id becomes a first-class per-realm object; passkeys remain tied to
their rp-id (WebAuthn-enforced); sessions remain host-bound exactly as
today. Motivating case: app1.company.com and app2.com cannot share an
rp-id, but user management must be under single common controls.
Decisions already made (from review rounds):
- One combined database at a fixed CWD-relative path:
paskia.kantadb(a single kanta JSONL file). NoPASKIA_DB, no per-rp-id directories, no directory scanning. Thepaskiadb/main.dbnames are retired (§10). - CLI is bootstrap-only:
paskia initseeds the database with the initial realm(s); plainpaskiaopenspaskia.kantadband serves whatever realms are stored. rp-id no longer selects which database to open, which removes the whole class of CLI/runtime mixups. - Runtime realm management via the admin interface: adding rp-ids, changing rp-names, origins and auth hosts are master-admin operations (§9), exactly like rp-name changes work today after first setup. The admin interface is shared across the whole instance — as long as a master admin can log in on some host, all further configuration happens there.
- Cross-rp-id logins are permitted (§2 mechanisms); no separate per-site user silos.
1. What is global vs. per-realm
Global (single instance, shared across realms):
| Data | Notes |
|---|---|
| Organizations, Roles, Users | already global structs; unchanged |
| Permissions | domain field already host-scopes effectiveness (structs.py:704-706) |
| Sessions | already host-bound (Session.host, exact match structs.py:678-682) |
| Credentials/passkeys | global collection, each stamped with its rp_id (§4) |
| Reset tokens | user-bound; global |
| Avatars | users/<uuid>/profile.webp under the one user-files root (§10) |
| Auth codes, remote-auth manager | in-memory; gain rp-id fields (§7) |
Per-realm (registry, keyed by rp-id):
| Data | Notes |
|---|---|
rp_id, rp_name, origins, auth_host |
stored combined Config (§3) |
Passkey instance |
per rp-id; ceremonies verify against the origin realm's rp-id |
site_url/site_path |
runtime derivation, per realm |
| OIDC provider (keys, clients) | per rp-id — each realm is an independent issuer (§8) |
Terminology: a realm is one rp-id with its associated hosts and origins (the feedback's "authentication realm"). A site is any host served by the instance; each host belongs to exactly one realm. The administrative instance is the whole process: global users/orgs, N realms.
The key simplification: db.data() stays a plain global singleton. The
contextvar is needed only for the current realm (passkey, config,
OIDC view) — not for database access.
2. Login architecture: three composable mechanisms
The plan implements the realm infrastructure (§3-§9) once, plus three mechanisms that share it. They are alternatives per deployment, and composable within one instance.
2.A WebAuthn Related Origin Requests (preferred for trusted domain families)
WebAuthn Level 3 lets otherwise-unrelated domains share one rp-id: the
canonical RP publishes /.well-known/webauthn listing permitted origins,
and those origins may then run ceremonies with the common rp-id locally —
no redirects, no cross-domain cookies. Browser support is now universal
(Firefox included), so ROR needs no fallback mechanism for browser
reasons.
Model: realm company.com with related origin https://app2.com. A page
on app2.com calls WebAuthn with rpId: "company.com"; the passkey is
scoped to company.com; clientDataJSON.origin is https://app2.com,
which the backend validates against the realm's allow-list.
Server-side feasibility (verified against the installed webauthn 3.0.0):
paskia's Passkey passes expected_origin=<the pre-validated origin>
and expected_rp_id=self.rp_id (sansio.py:188-193,255-263); the
library string-compares origin and rp-id separately. The frontend never
chooses rpId client-side — ceremony options arrive from the server over
the WS (frontend/src/utils/passkey.js:40,68). So the change set is:
Passkey._validate_origin(sansio.py:95-106) currently requires origin == rp-id or subdomain. New rule: an origin is valid if it is in the rp-id subtree or explicitly listed in the realm's configured origins. Explicit listing becomes the trust boundary — exactly the right semantics, sinceallowed_originsis already an allow-list. (Today's semantics are subtree-AND-listed when a list exists; the new subtree-OR-listed is additive-only, so existing configs keep passing.)- Remove the redundant inline origin gate in
authenticate_and_login(wschat.py:93-95re-implementshostname == rp_id or endswith) — it would reject related origins aftervalidate_originaccepted them. Dispatch already resolved the realm from the Origin; the endpoint-sidevalidate_originis the single origin rule. - New endpoint:
GET /.well-known/webauthnon the canonical rp-id host, serving{"origins": ["https://app2.com", ...]}from the realm's configured non-subdomain origins. - Dispatch resolution gains a rule: a Host matching a configured
related-origin hostname resolves to that origin's realm (exact match
only —
www.app2.comdoes not followapp2.com; document this).
Deployment constraint (documented in §15): the browser fetches
https://<rp-id>/.well-known/webauthn from the canonical apex directly —
if paskia does not host the apex, the JSON must be published there
statically.
Constraints and warnings (from the WebAuthn WG, to be documented): implementations must support at least 5 registrable origin labels and may cap more aggressively — this is for a small family of same-trust domains, not hundreds of customer domains. Sharing an rp-id merges the security boundary: a weakly protected marketing domain should not share the realm of the admin application. Config validation enforces a configurable cap (default 5) on related origins per realm.
Re-enrollment note: passkeys never move between rp-ids (WebAuthn-enforced). A host family that first deploys separate realms (2.B) and later consolidates to Related Origins re-enrolls: authenticate against the old realm (or via 2.C), register a new credential under the common rp-id, retire the old one. The UI's per-credential rp-id badge (§9) makes this visible. No automated credential migration is provided or needed.
2.B Multiple rp-id realms under one administrative instance (the base refactor)
For domains that should not share an rp-id: rp-id is a first-class
object (realm), not an instance attribute. Users are global identities;
credentials carry rp_id:
Instance
├── Orgs / Roles / Users (global)
└── Realms
├── company.com (origins [...], credentials scoped by rp_id)
├── app2.com (origins [...], credentials scoped by rp_id)
└── customer.net (origins [...], credentials scoped by rp_id)
Alice can hold both a company.com and an app2.com passkey; sessions
stay host-only. This is the refactor described in §3-§9 and is worthwhile
regardless of which login mechanism a deployment uses — 2.A is
implemented as "a realm may declare extra origins", 2.C as "a realm may
be entered via remote authorization".
Deferred idea from the feedback — an Identity layer above the
org-owned User (Identity → N org memberships + N credentials). Not
part of this plan: the current User → Role → Org ownership
(structs.py:198-286) is deeply embedded (bootstrap, admin API,
permissions), and multi-site works without it. We do adopt the feedback's
architectural rule now: authentication establishes identity, not
organization — the requested hostname selects the org/permission
context after authentication (already true via Permission.domain
host-scoping and session host binding). A future Identity split should
preserve that rule.
2.C Remote authorization + opportunistic local enrollment (bootstrap/recovery path)
For a realm where the user has no credential, the existing remote-login
mechanism already provides a federation-style flow: unauthenticated device
requests, authenticated device permits, a short-lived single-use opaque
exchange code (60s CookieCode, authcode.py) is redeemed by the
requesting host, which sets its own host-only cookie. No shared cookies,
no reusable tokens in URLs — matching the feedback's
authorization-code-shaped recommendation; the two channels (WS pairing
code vs redirect with state) are UX variants over the same code
redemption primitive.
Changes under this plan:
- Cross-realm permits are allowed (§7.2): a device authenticated at
company.commay authorize a session forapp2.com; the request's realm is recorded and shown to the approver; the target host is registry-validated. - Same-device redirect variant (optional, closes open question from
v1): "logged in at the auth host, bounce to the app host with a code" —
reuse the same
CookieCodemachinery with aredirect_uri+stateparameter set, PKCE not needed server-to-self butstateprotects the redirect leg. This is a small addition over §7.1, kept as an optional follow-up. - Opportunistic local enrollment: after a cross-realm remote login,
the UI offers "Add a passkey for faster login here". The mechanism
already exists — the remote flow's
registeraction issues adevice additionreset token (remote.py:325-333) and registration runs locally under the new realm's rp-id, stampingCredential.rp_id(§4). This makes remote login primarily bootstrap/recovery, while everyday authentication stays local.
Policy summary (how deployments choose)
| Situation | Mechanism |
|---|---|
| Few closely related, equally trusted brand domains | 2.A Related Origins — one passkey |
| Independent / customer / lower-trust domains | 2.B separate realms — passkey per realm |
| User lacks a credential for the current realm | 2.C remote authorization, then enroll locally |
3. Configuration model
3.1 Stored config (breaking change)
class RealmConfig(msgspec.Struct, omit_defaults=True):
rp_id: str
rp_name: str | None = None
auth_host: str | None = None # this realm's dedicated auth host
origins: list[str] | None = None # subdomain origins AND related origins (§2.A)
class Config(msgspec.Struct, omit_defaults=True):
realms: list[RealmConfig] # at least one; first entry is the default realm
listen: list[str] | None = None # process-global
- Old top-level
rp_id/rp_name/auth_host/originsfields removed; a kanta migration converts existing databases (§10). Default constructors move to the new shape everywhere:structs.py:622(DB.config factory),operations.py:40(sentinel),db/bootstrap.py:148. - "At least one realm" is not expressible in msgspec — enforce it in a startup/validation check.
- First entry is the default realm, used only where a default is genuinely needed (bootstrap reset-link URL, startup box ordering, master-admin entry point) — never for dispatch.
- Origin validation: each configured origin is either in the rp-id subtree (classic) or an explicit related origin (§2.A). Related origins are counted and capped (default 5 registrable labels per realm) and must not collide with another realm's rp-id/auth-host/related origins. These rules are enforced both at startup and at admin write time (§9) — startup-only checks are bypassable at runtime. Origins are never implicitly cross-domain.
3.2 CLI: bootstrap (paskia init) vs. serve (paskia)
The CLI is split so that realm options exist only at bootstrap time — they can never mix with runtime configuration of an already-configured instance:
paskia init— createspaskia.kantadbin CWD and seeds it:--rp-id: repeatable/comma-separated, default["localhost"]; normalized, deduped. Multiple values create multiple realms at once (useful for devserver/e2e); the first is the default realm.--rp-name: single value, applies to the default realm. Its purpose is that the very first admin registration ceremony already shows the correct RP name; afterwards rp-names are edited via the admin interface (§9), as are any additional realms' names.--auth-host,--origin: apply to the default realm; existing normalization (validate_auth_host,normalize_origin,normalize_auth_host_and_origins) reused. Further realms' hosts are configured via the admin interface.--listen: stored intoConfig.listen(process-global).- Runs the kanta bootstrap (admin user + registration reset link, link
URL from the default realm) and prints the link. Refuses to run if
paskia.kantadbalready exists, or if an un-adopted legacy*.paskiadbis present (§10 — serve must adopt it first).
paskia— serve. Takes no realm options; only--listen(per-run override of storedConfig.listen, never persisted) and dev/debug flags. Startup flow: legacy-adoption pre-flight (§10) → openpaskia.kantadb→ validate the stored realm set cross-realm (rp-ids distinct; auth hosts distinct from each other and from every rp-id; related origins capped and collision-free) → build the realm registry (§5) → serve. Missing database → startup error pointing atpaskia init.--saveis removed: init always persists, serve has nothing to save, and runtime edits go through the admin API which persists directly.PASKIA_VITE_URLsite_url fallback applies to the localhost realm only (devserver).
Nested rp-ids are allowed (longest-suffix dispatch determinism). Adding a child rp-id moves no data — users are global; only new ceremonies stamp the child rp-id.
3.3 Runtime accessors
- The realm registry is built in the FastAPI lifespan after
kanta.open(), fromdb.data().config.realms— realm data no longer travels throughPASKIA_CONFIGat all. Per-realmsite_url/site_pathare computed at registry-build time by a shared derivation function (same priority as today: auth_host > origins[0] > PASKIA_VITE_URL >http://localhost:port>https://rp-id), using the effective listen endpoints for the localhost fallback. PASKIA_CONFIGshrinks to process-global serve parameters (the effective listen endpoints) so the derivation inside the server process can resolve the localhost-port fallback. A welcome side effect:db/lifecycle.py:28-37no longer needsPASKIA_CONFIGat import time to locate the database — the path is fixed (§10).update_runtime_config→update_realm_runtime(rp_id, realm_config): ports the site_url/site_path recomputation (runtime.py:44-75), persists the combinedConfig, refreshes the registry entry in place (dispatch must see auth_host and related-origin changes immediately). Realm creation/deletion (§9) add/remove registry entries the same way.util/hostutil.pyhelpers take a realm parameter (is_root_mode,dedicated_auth_host,api_url,auth_site_url,ui_base_path,reset_link_url).reset_link_urlhas two context classes: the bootstrap callback (db/bootstrap.py:37-43, no request context) uses the default realm's URL; the request-context call sites (fastapi/user.py:294,admin/users.py:125) must use the current request realm's URL — otherwise device-addition links mint credentials under the wrong realm's rp-id.- Dead code removed:
util/frontend.py,hostutil.reload_config.
4. Credentials get an rp-id
Credential(structs.py:289-304) gainsrp_id: str, stamped at registration from the ceremony's rp-id (Passkey.reg_verify,sansio.py:194-200, andCredential.create,structs.py:337-360, both gain the parameter). Field placement:Credentialis notkw_only, so the required field must precede the defaulted ones (structs.py:303-304). With Related Origins the stamp is always the realm's canonical rp-id regardless of which origin the ceremony ran on — the credential genuinely is acompany.compasskey.- Backfill migration (
migrate_v6): existing credentials get the old storedconfig.rp_id(read from the DB's own config during replay).Credentialhas noomit_defaults, so the field self-normalizes; the migration writes the correct value. authenticate_chat(wschat.py:50-57): the raw_id scan is filtered byc.rp_id == ceremony rp-id— prevents wrong error semantics and a cross-realm oracle ("no credential" vs "verification failed" would leak which rp-id a credential belongs to).exclude_credentials(registration,ws.py:78) and reauthallow_credentials(wschat.py:99-103) are filtered by the ceremony's rp-id —User.credential_idsbecomes cross-realm once users are global.- Cascades are uuid-keyed and unchanged; deleting a user removes their passkeys across all realms (correct: users are global).
5. Dispatch and realm context
- New module
paskia/realms.py:Realm { runtime, passkey }and a registry keyed by rp-id, built in the lifespan from the stored combinedConfig(§3.3) and refreshed on admin realm writes. No per-realm Kanta/DB. - Host resolution (
resolve(host)): normalize (hostutil.normalize_host, gaining trailing-dot stripping), then exact rp-id → exact auth_host → exact related-origin hostname → longest-suffix rp-id. Unknown →None. (Order safe because startup and admin-write validation forbids collisions between these sets.) - Pure ASGI dispatch middleware, outermost (registered after
redirect_middleware), handling"http"and"websocket"scopes. Unknown Host → 421 Misdirected Request (WS: pre-accept rejection). Sets thecurrent_realmcontextvar +request.state.realm. - WebSocket resolution follows the
Origin, not the connectionHost: in auth-host mode the login page is on the app host, the WS connects to the auth host, and Origin names the host being logged into (docs/API.md auth-host section). So:- middleware resolves the origin realm from the Origin hostname —
including related-origin hostnames (a ceremony on
app2.comfor rp-idcompany.comresolves to thecompany.comrealm); - the connection
Hostmust be a valid WS endpoint for that realm: the realm's effective auth host (§6), or the origin host itself when the realm has no auth host at all — else pre-accept reject; validate_originruns endpoint-side against the origin realm'sPasskey(post-accept JSON errors preserved,wsutil.py:23,34-35);current_realm= origin realm for the WS handler's duration. The ceremony rp-id is always the origin realm's rp-id — exactly what the browser enforces for the page's origin under both classic and related-origin rules.
- middleware resolves the origin realm from the Origin hostname —
including related-origin hostnames (a ceremony on
paskia/globals.pydeleted;from paskia.globals import passkey→current_realm().passkey;db.data()stays global.
6. Auth host: per-realm values with global fallback
A realm without its own auth host falls back to the first configured auth host (realm-list order):
effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or None
Own vs. effective auth host must be distinguished everywhere — this was a review finding with real consequences. The split:
- Follow the realm's OWN auth host: UI mode detection
(
App.vue:43-49minimal-profile decision), the redirect middleware (auth_host.py:39-53),ui_base_path, andreset_link_url. A realm with no own auth host keeps its full UI on its own hosts — otherwise reset/registration pages onapp2.comwould redirect toauth.company.com, where the ceremony's Origin resolves the owner realm and stamps the wrongCredential.rp_id, breaking 2.B onboarding and 2.C local enrollment. - Follow the EFFECTIVE auth host: WS endpoint selection only
(
passkey.js:8-12builds the WS URL from settings). The fallback auth host serves WS + restricted APIs for foreign realms. - Settings (
ApiSettings) exposes both fields (addown_auth_hostalongside the effectiveauth_host) so the frontend can make the mode decision correctly. - Root mode (
site_path == "/") applies only on a realm's own auth host, so it is never ambiguous: a realm's UI lives on its own hosts; the fallback auth host serves the owner realm's UI plus WS for the rest. - Admin changes to auth_host re-validate cross-realm uniqueness against the live registry (§9).
7. Login flows and in-memory stores
7.1 Auth codes (authcode.py:45-113)
OIDCCodeandCookieCodegainrp_id, verified at redemption (oid.py:184,api.py:407) — defense in depth; cheap.- Stamping source matters (review blocker): codes are stamped with
the realm of the session they will redeem — not naively with the
current realm at issuance. Remote-completion codes are minted inside
the permit handler (
remote.py:336-341, permitting realm's context) but redeemed by the requesting device on its own host, so they are stamped withRemoteAuthRequest.rp_id(§7.2) — stamping them with the permitter's realm would break every cross-realm remote login. Registration-flow codes (ws.py:97) and OIDC codes (ws.py:233-241) stamp from the current realm (issue and redeem sides always match). The host re-check atapi.py:414-416already bindsCookieCodeindependently; the rp_id check is additive. - Future stores (e.g. docs/AuthTickets.md's
AuthTicket) inherit the rp_id field.
7.2 Remote authentication — cross-realm permits allowed
RemoteAuthRequest(remoteauth.py:33-58) gainsrp_id— the requesting device's origin realm (resolved atremote.py:48-49,93-98).- Permit side may differ from the request side (this is mechanism
2.C): the permitting device authenticates with its realm's passkey,
and the existing
session_host=request.hostoverride (remote.py:315-321) creates the session for the requesting host. Changes required:- the
session_hostoverride must resolve to a configured realm (registry check) — today it is only non-empty-checked (wschat.py:108-114); arbitrary-host session binding is refused; - the request's rp-id is shown to the permitting user ("device at app2.com requests login");
- the login transaction logs both the session host and the permitting host/credential.
- the
- No policy flag for now: cross-realm remote login is how the product works (users are global). A future per-realm policy field can add isolation.
- Exchange codes stay single-use, 60s, host-bound at redemption
(
api.py:407-416re-checkssession_ctx(secret, host)).
7.3 Same-device redirect variant (optional follow-up)
"Logged in at auth host → bounce to app host with a code": reuse
CookieCode with redirect_uri + state; redeem at the target host's
/auth/api/set-session as today. Small addition; optional.
8. OIDC: per-realm providers in one DB
DB.oidc: OIDCbecomesdict[str, OIDC]keyed by rp-id (migration wraps the existing struct under the old rp-id). Each realm is an independent provider: own signing key, own clients.util/oidjwt.pykey cache (:22-24) keyed by rp-id; keys remain stored per realm in the DB (structs.py:599-601).- Issuer stays per-request-Host (
oid.py:64-68, discovery atmainapp.py:89-124) — each realm host is an issuer alias sharing the realm's key.Sessiongains two fields (bothomit_defaults, migration-free):issuer: str | None— stamped from the WS Origin (scheme included,ws.py:207-209) at OIDC-session creation (ws.py:217-226) and re-stamped at refresh (oid.py:251-316; stamping from the WS connection Host would be wrong — that is the effective auth host, not the authorize/discovery host the RP validates against); andrp_id: str | None— the owning realm, needed by every path that runs without request context:- backchannel logout (
oidc_notify.py:24-27issuer,:44client lookup,:91-101signing) usessession.rp_idto select the realm's key andsession.issuerasiss; cleanup_expired(lifecycle.py:149-151) drives the above with no request; pre-upgrade sessions (rp_id=None) fall back to registry issuer→realm resolution, then the default realm;- the logfmt UUID→label lookup (
lifecycle.py:84-93) iterates all realms' client dicts; - session listings (
apistructs.py:120client_name) resolve the client under the session's own realm, not the request's.
- backchannel logout (
- Log censoring must follow the new shape (security): the transaction
log censor (
lifecycle.py:108-109) matches onlyoidc.key/.endswith(".oidc.key"); the new path isoidc.<rp-id>.key— without a segment/regex-based rule, realm signing keys would print in plaintext in the JSONL log and in themigrate:v7diff. Also harden_lookup_uuid_in_state(lifecycle.py:55) for the nested clients. - Admin OIDC-client CRUD operates on the current realm's
OIDCentry. _validate_permission_domain(admin/permissions.py:18-36) accepts a subdomain of any configured rp-id, any related-origin hostname, or any realm's client UUID.domain == client UUIDpermission grouping (oid.py:341,441) looks up the current realm's clients (OIDC sessions are always created under the origin realm).
9. API and frontend changes
GET /auth/api/settings(api.py:301-316): per-request-Host realm values (rp_id, rp_name, effective auth_host, site URLs). Schema unchanged.- Realm management (master admin only) — this is how new rp-ids are
added after bootstrap, mirroring how rp-name is already edited post
setup (
admin/server_config.py:18-78becomes per-realm):- New endpoints, e.g.
GET/POST /auth/api/admin/realmsandPATCH/DELETE /auth/api/admin/realms/{rp_id}, gated on theauth:adminscope. The admin UI gains a realm list/editor. - Create:
rp_id+ optionalrp_name(defaults to the rp-id),auth_host,origins; full §3.1 validation (cap, cross-realm collisions); registry entry added immediately (§3.3), including itsPasskeyinstance and OIDC provider entry (§8). - Update: same validation against the live registry; changing a realm's rp-id itself is not supported (it would orphan every credential stamped with the old rp-id) — delete and recreate instead.
- Delete: refused for the last remaining realm and while any credential carries the realm's rp-id (re-enroll or delete those credentials first); cascades nothing else (users/orgs are global).
- The client-side subdomain check in
AdminDialogs.vue:70-79must relax to accept configured related origins. (AdminDialogs.vue:96's rp-id connectivity probe keeps working: a related origin answers with the realm's rp_id.)
- New endpoints, e.g.
- Credential listings:
Credential.rp_idserializes automatically intoApiUserDetail.credentials(bothGET /auth/api/user-infoandGET /auth/api/admin/users/{uuid}return the raw struct). - Frontend
CredentialList.vue(shared by ProfileView and AdminUserDetail): rp-id badge only whencredential.rp_id !== settings.rp_id— single-realm installs see no change; even multi-realm installs only mark foreign passkeys. The frontend already knows its rp-id (stores/auth.js) and already compares rp-ids elsewhere (AdminDialogs.vue:95-96). - Enrollment prompt (2.C): after a cross-realm remote login, the profile view offers "Add a passkey for faster login here" (mechanism exists; UI wiring only).
- Bootstrap/reset links use the default realm's URL
(
db/bootstrap.py:37-43,paskia/bootstrap.py:40-89). - Bootstrap caveat to handle:
check_admin_credentials(bootstrap.py:40-89) prints a registration link when the first admin "has no credentials" (bootstrap.py:73checks any credential). With global users, an admin may have passkeys only under another realm's rp-id — the check must test for an admin credential under the default realm's rp-id, or the printed link is unusable. - Cosmetic:
admin/users.py:115picks "user registration" vs "account recovery" token labels from any credential existing; under global users this can mislabel (e.g. "recovery" for a user who only lacks a passkey in this realm).token_typeis display-only (no gating:api.py:369,structs.py:469) — adjust the wording logic, no security impact.
10. Database path, adoption, and migrations
- Fixed CWD-relative path:
paskia.kantadb— a single kanta JSONL file. Kanta rotation siblings (paskia@<timestamp>.kantadb) are unaffected.PASKIA_DBremoved with no replacement;db/paths.py:8-47drops the rp-id parameter and the root-directory logic. CWD selects the deployment as needed. - User files (avatars,
util/avatar.py:20) move to a fixed sibling directorypaskia.data/users/(the oldusers/lived under the per-rp-id directory; the namepaskia.datais a proposal, see §16). - Legacy adoption (the only supported migration — no multi-database
merging exists or is needed): if
paskia.kantadbis absent and exactly one*.paskiadbcandidate exists in CWD — a directory containingmain.db, or a legacy single-file database (db/paths.py:39-47's_migrate_legacy_db_filecase) — it is adopted:main.db(or the single file) becomespaskia.kantadb,users/becomespaskia.data/users/, and the old directory is removed. Multiple candidates → startup error listing them, asking the operator to remove or rename strays (e.g. a*.bak.paskiadbbackup); empty directories are ignored. Adoption runs as an explicit pre-flight step in the serve command, before the read-only startup open — read-only opens never trigger adoption or migration writes (verified against kanta: read-only opens replay migrations in memory before decode and skip all writes, and old Config shapes decode becausemigrate_v7runs pre-decode). - Kanta migrations (
db/migrations.py, name-scannedmigrate_vN):migrate_v6:Credential.rp_idbackfill from oldconfig.rp_id.migrate_v7:Configrestructure (old fields →realms[0]); wrapoidcunder the old rp-id key.
kanta.ctx.rp_idis kept (set to the default realm) —migrate_v2(migrations.py:24) still reads it when replaying v1-era databases; only its role as "the" rp-id ends. Alternatively harden v2 to tolerate a missing ctx; keeping the wiring is cheaper.- The startup box prints per-realm lines (
util/startupbox.py).
11. Lifespan and background tasks
- One
Kantaforpaskia.kantadb, constructed at import time from the fixed path (noPASKIA_CONFIGdependency indb/lifecycle.py), opened once in the lifespan; single bootstrap hook; one background cleanup task (db/background.py) — unchanged in shape (DB is global). - The kanta bootstrap hook only ever fires for a database created by
paskia init(which supplies the initial combinedConfig); the serve command never bootstraps — a missing database is a startup error pointing atpaskia init(§3.2). - Registry built from the stored
Configafter open; per-realmPasskeyinstances constructed (each realm's origins validated at startup — fail-fast preserved, now including related-origin cap checks). bootstrap_if_needed/check_admin_credentialsstill run at serve startup (reprint a usable registration link when the admin lacks a credential under the default realm, §9).oidc_notifyfire-and-forget tasks need no realm context for DB access (global DB); issuer comes from the session (§8).- The dispatch middleware is the only place
current_realmis set; admin realm writes refresh the registry (§3.3, §9).
12. Devserver (scripts/devserver.py)
- Extract init-argument parsing into an importable function (e.g.
paskia/cliconfig.py);paskia initanddevserver.pyshare it — no duplicated logic. - devserver
--rp-id/--auth-hostbecome multi-value identically (append + comma-split) and are passed to the init step; forwarding (devserver.py:146-156) loops over the initialized realms. - Caddy dev origins (
devserver.py:167-183): iterate all rp-ids and all effective auth hosts (build_caddyfilealready takes a list); the dev Caddyfile also forwards/.well-known/webauthn. PASKIA_AUTH_HOST(consumed byfrontend/vite.config.js:10) becomes comma-joined; vite config reads the first — dev-only, keep simple.
13. Security model
- Dispatch: unknown Host → 421 before any router/DB access (breaking change vs today: direct-IP and unconfigured-name access stop working; trailing dots normalized).
- Related Origins boundary: cross-domain origins are valid only when explicitly configured and capped; the well-known document is served only for the canonical realm and only lists configured origins. Document the WG's trust warning: all origins sharing an rp-id share one security boundary — do not mix trust levels within a realm.
- Realm administration: realm create/update/delete is gated on
auth:admin(§9) — deployment-wide by design; validation runs on every write, not just at startup. - Passkeys: rp-id binding browser-enforced and now server-recorded; ceremonies, credential scans, exclude/allow lists all scoped to the origin realm's rp-id. No cross-realm oracle in the scan.
- Sessions: unchanged — host-bound, exact match. Cross-realm sessions arise only via (a) a ceremony at the origin realm (incl. related origins), or (b) a remote permit by a device holding a valid session at its own realm (§7.2), with registry-validated target host.
- Users/orgs global: deleting a user/org cascades across all realms —
intended.
auth:adminis deployment-wide (document prominently). Permissiondomainhost-scoping unchanged. - Cross-realm permit transparency: requesting realm/host shown to the approver; both sides logged.
- Secret hygiene in logs: the OIDC signing-key censoring follows the
new
oidc.<rp-id>.keypath shape (§8) — without it, realm keys leak into the JSONL transaction log. - OIDC: per-realm keys/issuers; logout tokens carry the stored issuer.
14. Tests
tests/conftest.py: the import-timePASKIA_CONFIGseed (conftest.py:30-40) goes away with the fixed DB path — the app-level Kanta and the test fixtures chdir to / open a temp directory sopaskia.kantadblands there. Realm config is seeded into the DB fixture (a two-realm config:localhost+test.example.com);passkey_instancebecomes a registry/current-realm fixture.Credential.createcall sites (conftest.py:189-195,203-210,tests/test_admin.py:95,149) gain therp_idargument.- New tests:
- CLI:
paskia initseeds one/multiple realms; init refuses on an existing database; serve without a database errors; legacy adoption (single dir, single file, multiple candidates → error, empty dir ignored);--savegone. - Admin realm management: create/validate (collisions, related-origin cap)/update/delete rules (last realm, credential-bearing realm); registry refresh visible to dispatch without restart.
- dispatch: 421 unknown host; trailing-dot; related-origin hostname →
owning realm (exact only —
www.variants 421); WS origin-realm resolution; WS to auth host with app-host Origin accepted; arbitrary Host/Origin combos rejected. - credentials: rp_id stamping (incl. ceremony on a related origin → canonical rp-id); backfill migration; scan/exclude/allow filtering; no cross-realm oracle.
- Related Origins server-side (pytest only): origin-validation
rules (subtree-OR-listed),
/.well-known/webauthncontents and absence-when-unconfigured. Not e2e: a genuine related origin needs a non-subdomain host over HTTPS, and the browser fetches the well-known document from the browser process itself — not interceptable in the current plain-HTTP harness (playwright.config.js:27). E2E for ROR requires deliberate TLS/DNS infra; skip unless that is built. - cross-realm remote login end-to-end (request at realm B, permit at
realm A, session valid only on B's host); exchange codes minted in
the permit path redeem on the requester's realm (§7.1); arbitrary
session_hostrefused. - bootstrap caveat: admin with only foreign-realm credentials still gets a usable registration link for the default realm.
- OIDC: per-realm keys/issuers; issuer stamped from WS Origin (not the
connection Host); refresh re-stamps; backchannel logout selects the
session realm's key with no request context; log censoring covers
oidc.<rp-id>.key.
- CLI:
- E2E:
global-setup.tsdropsPASKIA_DB, spawnspaskia init --rp-id localhost,test.localhostwithcwdin the tmp dir, then serves; add ahttp://test.localhost:4404project exercising dispatch and a cross-realm remote login (remote auth currently has no e2e coverage; this feature needs it).
15. Docs and compatibility
- README: multi-realm model,
paskia initbootstrap, combined DB atpaskia.kantadb,PASKIA_DBremoval, Related Origins setup. - docs/API.md: auth-host section rewritten for own-vs-effective fallback
semantics and cross-realm behavior;
/.well-known/webauthndocumented; realm-management admin endpoints documented. - Related Origins deployment guidance (the critical operational
bit): the browser fetches
https://<rp-id>/.well-known/webauthnfrom the canonical apex directly. If paskia hosts the apex, our endpoint serves it; if the apex is hosted elsewhere (typical for marketing domains), the JSON must be published there statically. Existing examples serve/.well-known/*statically (docs/proxy/caddy.md,caddy/Caddyfile:10-14) — they must not shadow paskia's endpoint when paskia does host it. - docs/proxy +
caddy/auth/setup: forward/.well-known/openid-configurationand/.well-known/webauthn; the vite dev proxy allowlist (frontend/vite.config.js:16-24) gains both paths; Host preservation requirement unchanged. oidc.md(root): updated for per-realm providers andSession.issuer.- Breaking changes: DB moved to
paskia.kantadb(auto-adopted from a lone legacy*.paskiadb;PASKIA_DBremoved), serve command drops all realm options and--save(usepaskia init/ the admin interface),PASKIA_CONFIGformat reduced to serve parameters,Config/DB.oidc/Credentialschema migrations,paskia/globals.pyremoved, unknown Host → 421,paskia.util.frontendremoved.
16. Open questions
- Same-device redirect flow (§7.3): include in this release or defer?
- User-files directory name:
paskia.data/(proposed) vs something else; it holds only avatars today. - Bare
paskiawith no database: proposed behavior is a startup error pointing atpaskia init. Alternative: keep today's zero-config dev experience by auto-initializing alocalhostrealm. Strictness avoids bootstrap/runtime mixups; auto-init is friendlier for first contact.
(Settled during review, for the record: fallback-auth-host UI semantics —
a realm's UI lives on its own hosts, the fallback auth host serves WS and
restricted APIs for foreign realms plus the owner realm's UI; settings
exposes own vs. effective auth host, §6. OIDC logout signing without
request context — Session.rp_id + Session.issuer fields, §8. Related
Origins e2e — pytest only, §14. Realm configuration after bootstrap —
admin interface only, serve takes no realm options, §3.2. Combined DB
name and location — paskia.kantadb in CWD, §10.)