Multi-site plan v4: combined paskia.kantadb, bootstrap-only CLI, runtime realm management
This commit is contained in:
@@ -0,0 +1,741 @@
|
||||
# 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). No `PASKIA_DB`, no
|
||||
per-rp-id directories, no directory scanning. The `paskiadb`/`main.db`
|
||||
names are retired (§10).
|
||||
- **CLI is bootstrap-only**: `paskia init` seeds the database with the
|
||||
initial realm(s); plain `paskia` opens `paskia.kantadb` and 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, since `allowed_origins` is 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-95` re-implements `hostname == rp_id or endswith`) — it
|
||||
would reject related origins after `validate_origin` accepted them.
|
||||
Dispatch already resolved the realm from the Origin; the endpoint-side
|
||||
`validate_origin` is the single origin rule.
|
||||
- New endpoint: `GET /.well-known/webauthn` on 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.com` does not follow `app2.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.com` may authorize a session for `app2.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 `CookieCode` machinery with a `redirect_uri`+`state`
|
||||
parameter set, PKCE not needed server-to-self but `state` protects 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 `register` action issues a
|
||||
`device addition` reset token (`remote.py:325-333`) and registration
|
||||
runs locally under the new realm's rp-id, stamping `Credential.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)
|
||||
|
||||
```python
|
||||
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/origins` fields 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`** — creates `paskia.kantadb` in 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 into `Config.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.kantadb` already exists, or if an un-adopted legacy
|
||||
`*.paskiadb` is present (§10 — serve must adopt it first).
|
||||
- **`paskia`** — serve. Takes **no realm options**; only `--listen`
|
||||
(per-run override of stored `Config.listen`, never persisted) and
|
||||
dev/debug flags. Startup flow: legacy-adoption pre-flight (§10) → open
|
||||
`paskia.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 at
|
||||
`paskia init`.
|
||||
- `--save` is removed: init always persists, serve has nothing to save,
|
||||
and runtime edits go through the admin API which persists directly.
|
||||
- `PASKIA_VITE_URL` site_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()`**, from `db.data().config.realms` — realm data no longer
|
||||
travels through `PASKIA_CONFIG` at all. Per-realm
|
||||
`site_url`/`site_path` are 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_CONFIG` shrinks 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-37` no longer needs `PASKIA_CONFIG` at 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 combined `Config`, 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.py` helpers take a realm parameter (`is_root_mode`,
|
||||
`dedicated_auth_host`, `api_url`, `auth_site_url`, `ui_base_path`,
|
||||
`reset_link_url`). `reset_link_url` has 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`) gains `rp_id: str`, stamped at
|
||||
registration from the ceremony's rp-id (`Passkey.reg_verify`,
|
||||
`sansio.py:194-200`, and `Credential.create`, `structs.py:337-360`,
|
||||
both gain the parameter). Field placement: `Credential` is not
|
||||
`kw_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 a `company.com` passkey.
|
||||
- **Backfill migration** (`migrate_v6`): existing credentials get the old
|
||||
stored `config.rp_id` (read from the DB's own config during replay).
|
||||
`Credential` has no `omit_defaults`, so the field self-normalizes; the
|
||||
migration writes the _correct_ value.
|
||||
- `authenticate_chat` (`wschat.py:50-57`): the raw_id scan is filtered by
|
||||
`c.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 reauth
|
||||
`allow_credentials` (`wschat.py:99-103`) are filtered by the ceremony's
|
||||
rp-id — `User.credential_ids` becomes 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 combined
|
||||
`Config` (§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
|
||||
the `current_realm` contextvar + `request.state.realm`.
|
||||
- **WebSocket resolution follows the `Origin`, not the connection
|
||||
`Host`**: 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:
|
||||
1. middleware resolves the origin realm from the Origin hostname —
|
||||
including related-origin hostnames (a ceremony on `app2.com` for
|
||||
rp-id `company.com` resolves to the `company.com` realm);
|
||||
2. the connection `Host` must 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;
|
||||
3. `validate_origin` runs endpoint-side against the origin realm's
|
||||
`Passkey` (post-accept JSON errors preserved, `wsutil.py:23,34-35`);
|
||||
4. `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.
|
||||
- `paskia/globals.py` deleted; `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-49` minimal-profile decision), the redirect middleware
|
||||
(`auth_host.py:39-53`), `ui_base_path`, and `reset_link_url`. A realm
|
||||
with no own auth host keeps its full UI on its own hosts — otherwise
|
||||
reset/registration pages on `app2.com` would redirect to
|
||||
`auth.company.com`, where the ceremony's Origin resolves the _owner_
|
||||
realm and stamps the wrong `Credential.rp_id`, breaking 2.B onboarding
|
||||
and 2.C local enrollment.
|
||||
- **Follow the EFFECTIVE auth host**: WS endpoint selection only
|
||||
(`passkey.js:8-12` builds the WS URL from settings). The fallback auth
|
||||
host serves WS + restricted APIs for foreign realms.
|
||||
- Settings (`ApiSettings`) exposes both fields (add `own_auth_host`
|
||||
alongside the effective `auth_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`)
|
||||
|
||||
- `OIDCCode` and `CookieCode` gain `rp_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 with `RemoteAuthRequest.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 at `api.py:414-416` already binds `CookieCode`
|
||||
independently; 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`) gains `rp_id` — the
|
||||
requesting device's origin realm (resolved at `remote.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.host` override
|
||||
(`remote.py:315-321`) creates the session for the requesting host.
|
||||
Changes required:
|
||||
- the `session_host` override 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.
|
||||
- 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-416` re-checks `session_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: OIDC` becomes `dict[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.py` key 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 at
|
||||
`mainapp.py:89-124`) — each realm host is an issuer alias sharing the
|
||||
realm's key. **`Session` gains two fields** (both `omit_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); and `rp_id: str | None` — the owning realm, needed
|
||||
by every path that runs **without request context**:
|
||||
- backchannel logout (`oidc_notify.py:24-27` issuer, `:44` client
|
||||
lookup, `:91-101` signing) uses `session.rp_id` to select the
|
||||
realm's key and `session.issuer` as `iss`;
|
||||
- `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:120` `client_name`) resolve the
|
||||
client under the session's own realm, not the request's.
|
||||
- **Log censoring must follow the new shape (security)**: the transaction
|
||||
log censor (`lifecycle.py:108-109`) matches only `oidc.key` /
|
||||
`.endswith(".oidc.key")`; the new path is `oidc.<rp-id>.key` — without
|
||||
a segment/regex-based rule, realm signing keys would print in plaintext
|
||||
in the JSONL log and in the `migrate:v7` diff. Also harden
|
||||
`_lookup_uuid_in_state` (`lifecycle.py:55`) for the nested clients.
|
||||
- Admin OIDC-client CRUD operates on the current realm's `OIDC` entry.
|
||||
- `_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 UUID` permission 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-78` becomes per-realm):
|
||||
- New endpoints, e.g. `GET/POST /auth/api/admin/realms` and
|
||||
`PATCH/DELETE /auth/api/admin/realms/{rp_id}`, gated on the
|
||||
`auth:admin` scope. The admin UI gains a realm list/editor.
|
||||
- Create: `rp_id` + optional `rp_name` (defaults to the rp-id),
|
||||
`auth_host`, `origins`; full §3.1 validation (cap, cross-realm
|
||||
collisions); registry entry added immediately (§3.3), including its
|
||||
`Passkey` instance 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-79` must
|
||||
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.)
|
||||
- Credential listings: `Credential.rp_id` serializes automatically into
|
||||
`ApiUserDetail.credentials` (both `GET /auth/api/user-info` and
|
||||
`GET /auth/api/admin/users/{uuid}` return the raw struct).
|
||||
- Frontend `CredentialList.vue` (shared by ProfileView and
|
||||
AdminUserDetail): rp-id badge **only when
|
||||
`credential.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:73` checks _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:115` picks "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_type` is 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_DB` removed with no replacement;
|
||||
`db/paths.py:8-47` drops 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
|
||||
directory **`paskia.data/users/`** (the old `users/` lived under the
|
||||
per-rp-id directory; the name `paskia.data` is a proposal, see §16).
|
||||
- **Legacy adoption** (the only supported migration — no multi-database
|
||||
merging exists or is needed): if `paskia.kantadb` is absent and exactly
|
||||
one `*.paskiadb` candidate exists in CWD — a directory containing
|
||||
`main.db`, or a legacy single-file database (`db/paths.py:39-47`'s
|
||||
`_migrate_legacy_db_file` case) — it is adopted: `main.db` (or the
|
||||
single file) becomes `paskia.kantadb`, `users/` becomes
|
||||
`paskia.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.paskiadb` backup); 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 because `migrate_v7` runs pre-decode).
|
||||
- Kanta migrations (`db/migrations.py`, name-scanned `migrate_vN`):
|
||||
- `migrate_v6`: `Credential.rp_id` backfill from old `config.rp_id`.
|
||||
- `migrate_v7`: `Config` restructure (old fields → `realms[0]`); wrap
|
||||
`oidc` under the old rp-id key.
|
||||
- `kanta.ctx.rp_id` is **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 `Kanta` for `paskia.kantadb`, constructed at import time from the
|
||||
fixed path (no `PASKIA_CONFIG` dependency in `db/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 combined `Config`); the serve
|
||||
command never bootstraps — a missing database is a startup error
|
||||
pointing at `paskia init` (§3.2).
|
||||
- Registry built from the stored `Config` after open; per-realm `Passkey`
|
||||
instances constructed (each realm's origins validated at startup —
|
||||
fail-fast preserved, now including related-origin cap checks).
|
||||
- `bootstrap_if_needed` / `check_admin_credentials` still run at serve
|
||||
startup (reprint a usable registration link when the admin lacks a
|
||||
credential under the default realm, §9).
|
||||
- `oidc_notify` fire-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_realm` is 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 init` and `devserver.py` share it — no
|
||||
duplicated logic.
|
||||
- devserver `--rp-id`/`--auth-host` become 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_caddyfile` already takes a list); the dev
|
||||
Caddyfile also forwards `/.well-known/webauthn`.
|
||||
- `PASKIA_AUTH_HOST` (consumed by `frontend/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:admin` is deployment-wide (document prominently).
|
||||
Permission `domain` host-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>.key` path 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-time `PASKIA_CONFIG` seed
|
||||
(`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 so
|
||||
`paskia.kantadb` lands there. Realm config is seeded into the DB
|
||||
fixture (a two-realm config: `localhost` + `test.example.com`);
|
||||
`passkey_instance` becomes a registry/current-realm fixture.
|
||||
`Credential.create` call sites (`conftest.py:189-195,203-210`,
|
||||
`tests/test_admin.py:95,149`) gain the `rp_id` argument.
|
||||
- New tests:
|
||||
- CLI: `paskia init` seeds 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); `--save` gone.
|
||||
- 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/webauthn` contents 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_host` refused.
|
||||
- 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`.
|
||||
- E2E: `global-setup.ts` drops `PASKIA_DB`, spawns `paskia init --rp-id
|
||||
localhost,test.localhost` with `cwd` in the tmp dir, then serves; add a
|
||||
`http://test.localhost:4404` project 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 init` bootstrap, combined DB at
|
||||
`paskia.kantadb`, `PASKIA_DB` removal, Related Origins setup.
|
||||
- docs/API.md: auth-host section rewritten for own-vs-effective fallback
|
||||
semantics and cross-realm behavior; `/.well-known/webauthn` documented;
|
||||
realm-management admin endpoints documented.
|
||||
- **Related Origins deployment guidance** (the critical operational
|
||||
bit): the browser fetches `https://<rp-id>/.well-known/webauthn` from
|
||||
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-configuration` **and** `/.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 and `Session.issuer`.
|
||||
- Breaking changes: DB moved to `paskia.kantadb` (auto-adopted from a
|
||||
lone legacy `*.paskiadb`; `PASKIA_DB` removed), serve command drops all
|
||||
realm options and `--save` (use `paskia init` / the admin interface),
|
||||
`PASKIA_CONFIG` format reduced to serve parameters,
|
||||
`Config`/`DB.oidc`/`Credential` schema migrations, `paskia/globals.py`
|
||||
removed, unknown Host → 421, `paskia.util.frontend` removed.
|
||||
|
||||
## 16. Open questions
|
||||
|
||||
1. Same-device redirect flow (§7.3): include in this release or defer?
|
||||
2. User-files directory name: `paskia.data/` (proposed) vs something
|
||||
else; it holds only avatars today.
|
||||
3. Bare `paskia` with no database: proposed behavior is a startup error
|
||||
pointing at `paskia init`. Alternative: keep today's zero-config dev
|
||||
experience by auto-initializing a `localhost` realm. 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.)
|
||||
Reference in New Issue
Block a user