175 lines
8.4 KiB
Markdown
175 lines
8.4 KiB
Markdown
# 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):
|
||
|
||
```json
|
||
"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 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).
|
||
- `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:
|
||
|
||
1. Changes made **through** the satellite: immediate (write-behind,
|
||
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 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-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)
|
||
|
||
## 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 `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.
|
||
- **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_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).
|
||
- 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.
|