Files
paskia/docs/RemoteProxy.md
T
LeoVasanko 2209e2395c Satellite v2: upstream-minted scoped sync tokens, per-session contexts
Registration simplified to a single sync URL: minted in the upstream's
admin UI (Satellite access section of the domain dialog), carrying the
token as userinfo — https://<token>@<auth-host>. Tokens live hashed in
the upstream database, scoped to one rp-id, revocable (drops live
connections). PASKIA_SYNC_TOKENS env is gone.

Data minimization: the sync feed now carries only the scope's sessions
and credentials; the user graph never leaves the upstream. Each session
upsert embeds its master-computed ApiSessionContext (identity display
data + effective permissions for the session's host); user-graph
mutations eagerly re-push affected sessions with fresh contexts, so
revocations land within ~1 RTT. /check and /user-info are proxied to
the upstream (they need the user graph).

Satellite side: replicas keyed by rp-id (no more shared-replica config
conflicts — each remote domain has its own token), the replica refuses
to go live on a ready-message scope mismatch, write-backs are confined
to the token's scope server-side.

Admin dialog: one sync-URL paste field (token split from userinfo,
write-only), uplink verification required to save (reachability, rp-id
match, no nesting, auth host present); a verified uplink takes over the
domain's origins (**.<rp-id> + upstream auth host) and auth host. New
domains adopt the upstream's rp-id and name.
2026-10-01 05:40:32 +00:00

220 lines
11 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**. 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
Setup is a single paste. On the **upstream** (the authoritative instance),
open the domain in the admin UI and use *Satellite access → Generate sync
URL*. That mints a token scoped to the domain's rp-id and returns one URL
carrying everything the satellite needs:
```
https://<token>@auth.example.com
```
On the **satellite**, open (or add) the same domain in its admin UI,
enable *Remote instance*, and paste the sync URL. The dialog probes the
upstream's `/auth/api/settings` as you type: it must be reachable, serve
this very rp-id authoritatively (a satellite cannot upstream another
satellite), and have an auth host configured — only such an instance can
serve satellites at all. Once the uplink verifies, the remote
configuration takes over completely: the domain's origins become
`**.<rp-id>` plus the upstream's auth host, and saving requires the
verified uplink.
In the stored config (`DomainConfig.remote`):
```json
"remote": {
"url": "https://auth.example.com",
"token": "<sync token>",
"cache_ttl": 60,
"refresh_interval": 300
}
```
- `url` — the upstream's auth host URL (the userinfo token is split off
before storing). 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, stored **hashed in the
upstream's database** and scoped to one rp-id. Managed in the upstream
admin UI (the domain's *Satellite access* section: generate, list,
revoke); the raw token is shown once, inside the sync URL. On the
satellite it is write-only over the admin API (a sync URL without
userinfo keeps the stored token). Revoking a token drops the satellite's
live connections immediately.
- `cache_ttl` — seconds the replica remains trusted after the sync channel
goes down; then checks fail closed (503). Set it large (up to the 24 h
session lifetime) for fail-open behavior. Minimum 1.
- `refresh_interval` — seconds between reconnects; every connect starts
from a full snapshot, which reconciles any drift. Minimum 30.
Each remote domain gets its own replica and its own token; several domains
may point at the same upstream instance independently.
A remote domain **must mark an auth host** (the uplink's own — the dialog
derives it): the profile, admin and sign-in pages live there, so browsers
and WebSockets go directly to the upstream. Other domains on the same
satellite remain fully local — the multi-domain config mixes both kinds
freely.
## How it works
**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.
**The replica** is a `ReplicaDB` instance in RAM, never persisted. On
connect the upstream sends a snapshot of the replicated tables
(the domain's credentials and sessions), then live upsert/delete events
emitted from the struct `store()`/`delete()` hooks (which also cover
cascade deletes) and field-mutating operations. The feed is scoped to the
token's rp-id, and the user graph never leaves the upstream: every session
upsert carries an embedded `ctx` — the same `ApiSessionContext` sent to
users — holding the session's user/org/role display data and effective
permissions, computed for the session's bound host. Mutations of users,
orgs, roles or permissions are not published; instead the upstream eagerly
re-pushes the affected sessions with freshly computed contexts, so renames
and permission revocations take effect within ~1 RTT. The replica refuses
to go live when the scope in the ready message does not match its domain.
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
| Endpoint | Handling |
|---|---|
| `GET /auth/api/forward`, `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 |
| `GET /check`, `GET /user-info` | proxied — these need the user graph (per-session contexts do not cover arbitrary user lookups or full profile data) |
| `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 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 |
Proxied calls answer 503 when the remote is unreachable. `/auth/api/settings`
reports `remote: true` for a satellite-served domain (this is what the admin
dialog's uplink probe checks). Should a remote domain's auth host be
misrouted to the satellite anyway, profile/avatar requests
(`/auth/api/user/*`) are forwarded to the remote rather than answered from
local storage — the satellite never takes writes for a remote domain.
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 (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 besides the token table: `syncfeed` (a
subscriber set fed by the commit hooks) and the `/auth/api/sync/ws`
endpoint serving snapshot + live events and accepting `session_refresh`
write-backs within the token's scope. With no satellites connected, the
hooks are a no-op.
## Trust and caveats
- The satellite host holds only its own domain's sessions (with their
identity contexts) and credentials in RAM — it learns the users that
actually sign in to its domain, never the directory, and never the OIDC
key. Still treat it as trusted: it is authoritative for session checks
within `cache_ttl`.
- Avatars are stored on the remote's disk; profile data such as
`user-info` is proxied to the remote rather than answered locally.
- OIDC sessions in a proxied `user-info` show the client UUID
rather than its name (OIDC clients are not replicated).
---
# Design review (the rejected alternatives)
## 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; 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).
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, 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 it costs
- 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 (its domain's sessions and credentials in RAM).
- Additive remote code (sync endpoint + commit hooks), where A needs
none.
- 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". 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
read-path cleanup, the sync protocol, and a trusted satellite host.