Files
paskia/docs/RemoteProxy.md
T

175 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.