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.
This commit is contained in:
LeoVasanko committed 2026-10-01 05:40:32 +00:00
1 parent da0b3e0e8b
commit 2209e2395c
14 files changed
+1159 -330

No files matched your search

+67 -39
View File
@@ -20,9 +20,25 @@ alternatives is at the end of this document.
## Configuration
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.
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
@@ -34,32 +50,31 @@ In the stored config (`DomainConfig.remote`):
}
```
- `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. The admin dialog probes
`{url}/auth/api/settings` as you type: it must be reachable, serve this
very rp-id, and not be a satellite for it itself (nested satellites are
not supported — always point at the authoritative instance). Probe
findings are warnings; the server-side validation is authoritative.
- `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 (an empty field keeps the stored one).
- `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.
Several domains may share one remote (they share a single replica); their
remote settings must then be identical — conflicting configurations are
rejected at validation and aligned with a warning at load.
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** (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.
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
@@ -73,12 +88,20 @@ 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 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
**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.
@@ -87,8 +110,9 @@ signing key is never replicated.
| Endpoint | Handling |
|---|---|
| `GET /auth/api/forward`, `GET /check`, `GET /user-info`, `GET /settings` | served from the replica (sub-ms) |
| `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) |
@@ -112,18 +136,22 @@ Freshness hierarchy:
## The remote side
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.
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 a full copy of the remote's auth data in RAM
(minus the OIDC key) — treat it as trusted as the remote.
- 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
- 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).
---
@@ -175,7 +203,7 @@ within one RTT instead of at TTL.
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).
- 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