8.4 KiB
Remote Proxy / Satellite
Paskia can serve a configured domain (rp-id) from a remote paskia
instance instead of the local database, so latency-sensitive checks
(/auth/api/forward, /auth/api/validate) answer in ~1 ms even when the
auth server is on another continent. Example: app2.example.com runs on
our local host and needs fast local checks, while app1.example.com and
auth.example.com run far away — all sharing example.com as rp-id.
Client applications that used https://auth.example.com as their auth
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.
Configuration
A domain becomes remote through its stored domain config (admin domains API; the frontend form may lag):
"remote": {
"url": "https://auth.example.com",
"token": "<sync token>",
"cache_ttl": 60,
"refresh_interval": 300
}
url— the remote instance's base URL. The satellite connects to{url}/auth/api/sync/ws; the connection is server-to-server and not host-dispatched, so internal addresses work.token— bearer token for the sync channel. The remote accepts tokens via itsPASKIA_SYNC_TOKENSenvironment 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).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.
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.
What the satellite holds
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.
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.
Endpoint behavior for remote domains
| Endpoint | Handling |
|---|---|
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 |
/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 |
Freshness hierarchy:
- Changes made through the satellite: immediate (write-behind, optimistic eviction).
- Changes made directly on the remote: a sync event, ~1 network RTT.
- Channel down: the replica stays authoritative until
cache_ttlpast the disconnect, then 503. On reconnect, missed events are replayed from the remote's ring buffer, or a full snapshot is taken (always atrefresh_intervaland after remote restarts, detected via a generation stamp).
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.
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-infofrom a replica reports no avatar URL. - OIDC sessions in a replica-backed
user-infoshow 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)
Option A — caching HTTP reverse proxy
A thin proxy caching /auth/api/forward, /check, /user-info,
/settings responses keyed by (Host, cookie, query) with
TTL = min(configured TTL, Remote-Session-Expires − now); everything
else forwarded verbatim, WebSockets tunneled, /logout intercepted for
eviction. Option B adds a remote change feed so eviction happens
within one RTT instead of at TTL.
- Remote changes: none for A; one additive endpoint for B.
- The proxy needs no credentials — requests are authenticated by the end user's cookie, forwarded on a miss.
What the read-only local state buys over the HTTP cache
- Full
SessionContextlocally. A replays the byte-response it once saw; D computes the answer. Query combinations never seen before (newperm/max_age/publicshapes) are served locally by D but miss A's cache. D caches the domain model, so derived answers (effective permissions per host,max_ageagainstcredential.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 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 deployment and cache-key discipline per frontend application.
What D costs
- A store-explicitness refactor in the security-critical read path
(
DB.session_ctxself-contained; explicitstoreparameters) — small but review-worthy. (The first draft's contextvar-dependentdb.data()was rejected: a global accessor whose meaning shifts under the caller.) - A versioned sync protocol (snapshot + sequenced events + ring-buffer replay + 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).
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 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.