From 2a04d7e0d3f01a02b347fca5c96627f35aae7ad0 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 21 Sep 2026 01:14:00 +0000 Subject: [PATCH] Admin UI remote option; docs match simplified protocol Domain edit dialog: satellite-mode toggle with remote URL, write-only sync token, cache TTL and re-sync interval, with auth-host requirement validated before submit; domain list marks remote domains. AdminApp sends remote wholesale on PATCH (null clears, absent token keeps the stored one). --- docs/RemoteProxy.md | 144 +++++++++--------- frontend/auth/admin/AdminApp.vue | 17 ++- frontend/src/admin/AdminOverview.vue | 1 + .../src/admin/dialogs/DomainEditDialog.vue | 59 +++++++ tests/test_remote.py | 33 ++++ 5 files changed, 181 insertions(+), 73 deletions(-) diff --git a/docs/RemoteProxy.md b/docs/RemoteProxy.md index 18c4f64..515cfcf 100644 --- a/docs/RemoteProxy.md +++ b/docs/RemoteProxy.md @@ -12,14 +12,18 @@ 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. +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 -A domain becomes remote through its stored domain config (admin domains -API; the frontend form may lag): +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. +In the stored config (`DomainConfig.remote`): ```json "remote": { @@ -37,34 +41,39 @@ API; the frontend form may lag): 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). + API (an empty field keeps the stored one). - `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. + goes down; then checks fail closed (503). Set it large (up to the 24 h + session lifetime) for fail-open behavior. +- `refresh_interval` — seconds between reconnects; every connect starts + from a full snapshot, which reconciles any drift. -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. +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. -## What the satellite holds +## How it works -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. +**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. -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. +**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 +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 @@ -73,7 +82,7 @@ context-dependent global accessor. | `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 | +| `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 | @@ -83,37 +92,28 @@ Freshness hierarchy: 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 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: 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. +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. ## 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) +# Design review (the rejected alternatives) ## Option A — caching HTTP reverse proxy @@ -131,44 +131,46 @@ within one RTT instead of at TTL. ## 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. + 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). - D's events mutate the replica (upsert/delete by table+key) and every + 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 +- **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 D costs +## What it 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). +- 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 (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). +- 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". D — implemented here — is the right tool when the -satellite should *be* a paskia instance for its remote domains: one +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 -core refactor, the sync protocol, and a trusted satellite host. +read-path cleanup, the sync protocol, and a trusted satellite host. diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index c54a3c5..d2067e0 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -480,6 +480,7 @@ function createDomain() { origins: [], originValidation: [], wellKnownCheck: null, + remote: null, }) } @@ -495,6 +496,8 @@ function openDomain(domain) { origins: rows.map(r => r.key), originValidation: rows.map(() => null), wellKnownCheck: null, + // The sync token is write-only: an empty field keeps the stored one + remote: domain.remote ? { ...domain.remote, token: '' } : null, }) } @@ -923,9 +926,19 @@ async function submitDialog() { } closeDialog() + // remote is replaced wholesale when present; null clears it, an + // absent key (create without remote) leaves it unset. + const remote = d.remote?.url?.trim() + ? { + url: d.remote.url.trim().replace(/\/+$/, ''), + token: d.remote.token || '', + cache_ttl: Number(d.remote.cache_ttl) || 60, + refresh_interval: Number(d.remote.refresh_interval) || 300, + } + : null const req = d.isNew - ? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins } }) - : apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins } }) + ? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins, ...(remote ? { remote } : {}) } }) + : apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins, remote } }) req .then(() => { authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500) diff --git a/frontend/src/admin/AdminOverview.vue b/frontend/src/admin/AdminOverview.vue index 0153a61..eb6e861 100644 --- a/frontend/src/admin/AdminOverview.vue +++ b/frontend/src/admin/AdminOverview.vue @@ -464,6 +464,7 @@ defineExpose({ focusFirstElement })
{{ domain.rp_id }} + 🛰 {{ domain.remote.url }}
{{ i ? ', ' : '' }}{{ e.key }}{{ e.auth ? '🔑' : '' }}{{ e.related ? '🔗' : '' }} diff --git a/frontend/src/admin/dialogs/DomainEditDialog.vue b/frontend/src/admin/dialogs/DomainEditDialog.vue index e0a6170..26682ac 100644 --- a/frontend/src/admin/dialogs/DomainEditDialog.vue +++ b/frontend/src/admin/dialogs/DomainEditDialog.vue @@ -18,6 +18,33 @@ const title = computed(() => // compares against it, and hosts are case-insensitive) const dialogRpId = computed(() => (props.dialog.data?.rp_id || '').trim().toLowerCase()) +// --- Remote (satellite) backing --- +// +// A remote domain is served from another paskia instance: this one keeps a +// RAM-only read replica for fast local session checks and forwards +// mutations. The remote must accept our sync token via its +// PASKIA_SYNC_TOKENS environment variable. An auth host (the remote's) is +// required — profile, admin and sign-in pages live there. +const remoteEnabled = computed({ + get: () => !!props.dialog.data?.remote, + set: on => { + const d = props.dialog.data + if (!d) return + d.remote = on ? { url: '', token: '', cache_ttl: 60, refresh_interval: 300 } : null + }, +}) + +const remoteUrlInvalid = computed(() => { + const url = props.dialog.data?.remote?.url?.trim() + if (!url) return false + return !/^https?:\/\/[^\s/]+/.test(url) +}) + +// Remote domains must mark an auth host (the server rejects the save) +const remoteMissingAuthHost = computed( + () => !!props.dialog.data?.remote && !props.dialog.data?.auth_host +) + // Block submit on hard errors: malformed entries, an over-cap related // list (the server rejects the save), a save that would lock the admin // out of the domain they are using, or validation still in flight. @@ -31,6 +58,8 @@ const isValidationInvalid = computed(() => { if (relatedEntries.value.length > 5) return true if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true if (lockoutWarning.value) return true + if (remoteUrlInvalid.value || remoteMissingAuthHost.value) return true + if (d.remote && !d.remote.url?.trim()) return true return false }) @@ -515,6 +544,33 @@ function onRemoveOrigin(i) {

Only the listed sites may sign in with {{ dialog.data.rp_id }} passkeys. Wildcards may be used: **.{{ dialog.data.rp_id }} allows the whole domain, *.{{ dialog.data.rp_id }} only a single subdomain level.

+ +
+ +
+ @@ -540,4 +596,7 @@ function onRemoveOrigin(i) { border-color: var(--color-error); background: var(--color-error-bg, rgba(239, 68, 68, 0.05)); } + +.remote-toggle { display: flex; align-items: center; gap: var(--space-xs); font-weight: 600; font-size: 0.95rem; } +.remote-toggle input { width: auto; } diff --git a/tests/test_remote.py b/tests/test_remote.py index 14aad7a..b878d61 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -375,6 +375,39 @@ async def test_admin_configures_remote_domain(client, session_token, test_db): assert entry["remote"]["url"] == "http://remote.test" assert "token" not in entry["remote"] # write-only + headers = {"Host": "localhost:4401", "Cookie": f"{AUTH_COOKIE_NAME}={session_token}"} + origins = {"**.example.com": True, "auth.example.com": {"auth_host": True}} + + # PATCH without the remote key preserves it (and its token) + r = await client.patch( + "/auth/api/admin/domains/example.com", + json={"rp_name": "Ex", "origins": origins}, + headers=headers, + ) + assert r.status_code == 200, r.text + assert stored.remote.url == "http://remote.test" + assert stored.remote.token == "sekret" + + # PATCH with a new URL but no token keeps the stored token + r = await client.patch( + "/auth/api/admin/domains/example.com", + json={"rp_name": "Ex", "origins": origins, + "remote": {"url": "http://other.test", "cache_ttl": 30}}, + headers=headers, + ) + assert r.status_code == 200, r.text + assert stored.remote.url == "http://other.test" + assert stored.remote.token == "sekret" + + # PATCH with remote: null clears it + r = await client.patch( + "/auth/api/admin/domains/example.com", + json={"rp_name": "Ex", "origins": origins, "remote": None}, + headers=headers, + ) + assert r.status_code == 200, r.text + assert stored.remote is None + @pytest.mark.asyncio async def test_admin_remote_domain_requires_auth_host(client, session_token):