Admin UI remote option; docs match simplified protocol

Domain edit dialog: satellite-mode toggle with remote URL, write-only
sync token, cache TTL and re-sync interval, with auth-host requirement
validated before submit; domain list marks remote domains. AdminApp
sends remote wholesale on PATCH (null clears, absent token keeps the
stored one).
This commit is contained in:
2026-09-21 01:14:00 +00:00
parent 33e3185b39
commit 2a04d7e0d3
5 changed files with 181 additions and 73 deletions
+73 -71
View File
@@ -12,14 +12,18 @@ backend (forward-auth checks) only repoint to the local satellite
(`http://127.0.0.1:4401`); both remain usable interchangeably, and the
satellite ultimately uses `auth.example.com`.
Status: **implemented** (see `paskia/satellite.py`, `paskia/syncfeed.py`,
`paskia/fastapi/sync.py`, `paskia/fastapi/proxy.py`). The design review
comparing the rejected alternatives is at the end of this document.
Status: **implemented**. The feature lives in `paskia/satellite.py`
(satellite side: replica, sync client, host dispatch, forwarding) and
`paskia/syncfeed.py` + `paskia/fastapi/sync.py` (remote side: change
feed and sync WebSocket). The design review comparing the rejected
alternatives is at the end of this document.
## Configuration
A domain becomes remote through its stored domain config (admin domains
API; the frontend form may lag):
A domain becomes remote in the admin domains UI (master admin, on the
primary server's auth host — this configuration itself never touches a
remote): enable *Remote instance* and set the remote URL and sync token.
In the stored config (`DomainConfig.remote`):
```json
"remote": {
@@ -37,34 +41,39 @@ API; the frontend form may lag):
via its `PASKIA_SYNC_TOKENS` environment variable** (comma-separated);
nothing is stored in the remote's database, and with the variable unset
the sync endpoint stays closed. The token is write-only over the admin
API (never echoed back).
API (an empty field keeps the stored one).
- `cache_ttl` — seconds the replica remains trusted after the sync channel
goes down; then the satellite fails closed (503). Set it large (up to
the 24 h session lifetime) for fail-open behavior.
- `refresh_interval` — seconds between full snapshots (reconciliation);
reconnects in between replay missed events from the remote's RAM ring
buffer.
goes down; then checks fail closed (503). Set it large (up to the 24 h
session lifetime) for fail-open behavior.
- `refresh_interval` — seconds between reconnects; every connect starts
from a full snapshot, which reconciles any drift.
A remote domain **must mark an auth host** (validated cross-domain): the
profile, admin and sign-in pages live there, so browsers and WebSockets
go directly to the remote. Other domains on the same satellite remain
fully local — the multi-domain config mixes both kinds freely.
A remote domain **must mark an auth host** (validated cross-domain and in
the UI): the profile, admin and sign-in pages live there, so browsers and
WebSockets go directly to the remote. Other domains on the same satellite
remain fully local — the multi-domain config mixes both kinds freely.
## What the satellite holds
## How it works
A RAM-only read replica of the remote's tables (permissions, orgs, roles,
users, credentials, sessions) as another plain `DB` struct instance,
attached to the runtime `Domain` as its `store`. It is never persisted,
rebuilt from a snapshot on startup, kept current by sequenced events over
the sync WebSocket, and swept for expired sessions locally. The feed
carries no usable secrets: sessions are keyed by `hash_secret` output,
credentials carry public keys only, and the OIDC signing key is never
replicated.
**Dispatch is keyed by host, and only this module knows about stores.**
`satellite.store_for_host(host)` returns the local DB or the replica of
the remote backing the host's domain (raising 503 `HTTPException` when
the replica is unavailable). The session read path (`session_ctx`,
`authz.verify`, `build_user_info`, `/check`) just passes the host it
already has; writes dispatch likewise (`satellite.refresh_session`
write-behind for remote, `db.update_session` for local;
`satellite.evict_session` on logout). `satellite.forward_request(request)`
returns the proxied response for remote domains or `None` for local ones.
Reads run unchanged against the replica: `DB.session_ctx` and the
verify/`/check`/`/user-info` helpers take an explicit `store` (the
dispatched domain's), defaulting to the local database. There is no
context-dependent global accessor.
**The replica** is a plain `DB` struct instance in RAM, never persisted.
On connect the remote sends a snapshot of the replicated tables
(permissions, orgs, roles, users, credentials, sessions), then live
upsert/delete events emitted from the struct `store()`/`delete()` hooks
(which also cover cascade deletes) and field-mutating operations. A
single ordered WebSocket cannot gap; a slow subscriber is dropped and
resyncs. The feed carries no usable secrets: sessions are keyed by
`hash_secret` output, credentials carry public keys only, and the OIDC
signing key is never replicated.
## Endpoint behavior for remote domains
@@ -73,7 +82,7 @@ context-dependent global accessor.
| `GET /auth/api/forward`, `GET /check`, `GET /user-info`, `GET /settings` | served from the replica (sub-ms) |
| `POST /auth/api/validate` | verified from the replica; the throttled refresh updates the replica and is written back over the sync channel; cookie renewed locally |
| `POST /auth/api/logout` | proxied to the remote (original Host preserved) and evicted from the replica immediately |
| `POST /auth/api/set-session`, `GET /token-info` | proxied (the exchange code/reset token lives in the remote's RAM/DB); the session arrives via sync event |
| `POST /auth/api/set-session`, `GET /token-info` | proxied (the exchange code/reset token lives on the remote); the session arrives via sync event |
| `/auth/oidc/*` | proxied (signing key and OIDC sessions stay on the remote) |
| `/auth/ws/*`, `/auth/remote-auth/*`, admin, profile | not served — the auth host requirement means these are reached on the remote directly |
@@ -83,37 +92,28 @@ Freshness hierarchy:
optimistic eviction).
2. Changes made **directly on the remote**: a sync event, ~1 network RTT.
3. Channel down: the replica stays authoritative until `cache_ttl` past
the disconnect, then 503. On reconnect, missed events are replayed
from the remote's ring buffer, or a full snapshot is taken (always at
`refresh_interval` and after remote restarts, detected via a
generation stamp).
the disconnect (dead-peer detection is bounded by the ~10 s keepalive),
then 503. Every reconnect starts from a fresh snapshot.
## The remote side
Strictly additive and RAM-only: a `syncfeed` ring buffer fed by hooks in
the struct `store()`/`delete()` methods (which also cover cascade
deletes) plus explicit emits for field-mutating operations, and the
token-gated `/auth/api/sync/ws` endpoint serving snapshots, event replay
and live events, and accepting `session_refresh` write-backs. With no
satellites connected, the hooks are a no-op.
Strictly additive and RAM-only: `syncfeed` (a subscriber set fed by the
commit hooks) and the token-gated `/auth/api/sync/ws` endpoint serving
snapshot + live events and accepting `session_refresh` write-backs. With
no satellites connected, the hooks are a no-op.
## Trust and caveats
- The satellite host holds a full copy of the remote's auth data in RAM
(minus the OIDC key) — treat it as trusted as the remote.
- Disconnect detection is bounded by the sync keepalive (~10 s) plus
`cache_ttl`.
- Avatars are stored on the remote's disk; `user-info` from a replica
reports no avatar URL.
- OIDC sessions in a replica-backed `user-info` show the client UUID
rather than its name (OIDC clients are not replicated).
- Remote and satellite should run compatible versions; the sync handshake
carries a generation stamp and protocol mismatches fall back to
snapshots.
---
# Design review (the rejected alternative)
# Design review (the rejected alternatives)
## Option A — caching HTTP reverse proxy
@@ -131,44 +131,46 @@ within one RTT instead of at TTL.
## What the read-only local state buys over the HTTP cache
- **Full `SessionContext` locally.** A replays the byte-response it once
saw; D *computes* the answer. Query combinations never seen before
(new `perm`/`max_age`/`public` shapes) are served locally by D but miss
A's cache. D caches the *domain model*, so derived answers (effective
permissions per host, `max_age` against `credential.last_used`,
`Remote-*` composition) are correct without having been witnessed.
saw; the satellite *computes* the answer. Query combinations never seen
before (new `perm`/`max_age`/`public` shapes) are served locally but
miss A's cache. The replica holds the *domain model*, so derived
answers (effective permissions per host, `max_age` against
`credential.last_used`, `Remote-*` composition) are correct without
having been witnessed.
- **One invalidation model.** A hand-builds invalidation rules per
endpoint (query-key mapping, cookie re-keying on renew, 401 variants).
D's events mutate the replica (upsert/delete by table+key) and every
Events mutate the replica (upsert/delete by table+key) and every
endpoint becomes consistent at once — including future ones.
- **Degradation behaves like a real instance.** With the remote down, D
serves a coherent auth service from the replica (expiry enforced
locally, bounded by `cache_ttl`); A serves unrelated cached responses
with gaps wherever the cache was cold.
- **Multi-domain uniformity.** D is a property of a domain in the
existing registry; local and remote rp-ids coexist in one instance. A
is a separate component bolted in front of specific URLs.
- **User simplicity.** D is configured once in domain config; A needs
- **Degradation behaves like a real instance.** With the remote down, the
satellite serves a coherent auth service from the replica (expiry
enforced locally, bounded by `cache_ttl`); A serves unrelated cached
responses with gaps wherever the cache was cold.
- **Multi-domain uniformity.** Remote backing is a property of a domain
in the existing registry; local and remote rp-ids coexist in one
instance. A is a separate component bolted in front of specific URLs.
- **User simplicity.** Configured once in domain config; A needs
deployment and cache-key discipline per frontend application.
## What D costs
## What it costs
- A store-explicitness refactor in the security-critical read path
(`DB.session_ctx` self-contained; explicit `store` parameters) — small
but review-worthy. (The first draft's contextvar-dependent `db.data()`
was rejected: a global accessor whose meaning shifts under the caller.)
- A versioned sync protocol (snapshot + sequenced events + ring-buffer
replay + reconnect reconciliation).
- The read path must be honest about which DB it reads: `DB.session_ctx`
and `/check` were rewritten to use their own tables instead of struct
convenience properties that reach the global database. (A first draft's
contextvar-dependent `db.data()` was rejected: a global accessor whose
meaning shifts under the caller. Dispatch is instead keyed explicitly
by the request host.)
- A sync protocol (snapshot + live events + reconnect reconciliation).
- A trusted satellite host (full data copy in RAM).
- Additive remote code (sync endpoint + commit hooks), where A needs
none.
- Replica housekeeping (expiry sweeper, write-behind ordering,
optimistic eviction vs. event confirmation).
- Replica housekeeping (expiry sweeper, write-behind, optimistic
eviction).
## Summary
A(+B) is the right tool to "make forward-auth fast in front of an
untouched server". D — implemented here — is the right tool when the
satellite should *be* a paskia instance for its remote domains: one
untouched server". The satellite — implemented here — is the right tool
when it should *be* a paskia instance for its remote domains: one
consistency model, correct answers for un-cached query shapes, graceful
degradation, and per-domain mixing with local rp-ids, at the price of the
core refactor, the sync protocol, and a trusted satellite host.
read-path cleanup, the sync protocol, and a trusted satellite host.