Proxy to another Paskia #5

Open
LeoVasanko wants to merge 10 commits from feature/remote-satellite into main
5 changed files with 181 additions and 73 deletions
Showing only changes of commit 2a04d7e0d3 - Show all commits
+73 -71
View File
@@ -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 (`http://127.0.0.1:4401`); both remain usable interchangeably, and the
satellite ultimately uses `auth.example.com`. satellite ultimately uses `auth.example.com`.
Status: **implemented** (see `paskia/satellite.py`, `paskia/syncfeed.py`, Status: **implemented**. The feature lives in `paskia/satellite.py`
`paskia/fastapi/sync.py`, `paskia/fastapi/proxy.py`). The design review (satellite side: replica, sync client, host dispatch, forwarding) and
comparing the rejected alternatives is at the end of this document. `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 ## Configuration
A domain becomes remote through its stored domain config (admin domains A domain becomes remote in the admin domains UI (master admin, on the
API; the frontend form may lag): 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 ```json
"remote": { "remote": {
@@ -37,34 +41,39 @@ API; the frontend form may lag):
via its `PASKIA_SYNC_TOKENS` environment variable** (comma-separated); via its `PASKIA_SYNC_TOKENS` environment variable** (comma-separated);
nothing is stored in the remote's database, and with the variable unset 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 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 - `cache_ttl` — seconds the replica remains trusted after the sync channel
goes down; then the satellite fails closed (503). Set it large (up to goes down; then checks fail closed (503). Set it large (up to the 24 h
the 24 h session lifetime) for fail-open behavior. session lifetime) for fail-open behavior.
- `refresh_interval` — seconds between full snapshots (reconciliation); - `refresh_interval` — seconds between reconnects; every connect starts
reconnects in between replay missed events from the remote's RAM ring from a full snapshot, which reconciles any drift.
buffer.
A remote domain **must mark an auth host** (validated cross-domain): the A remote domain **must mark an auth host** (validated cross-domain and in
profile, admin and sign-in pages live there, so browsers and WebSockets the UI): the profile, admin and sign-in pages live there, so browsers and
go directly to the remote. Other domains on the same satellite remain WebSockets go directly to the remote. Other domains on the same satellite
fully local — the multi-domain config mixes both kinds freely. 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, **Dispatch is keyed by host, and only this module knows about stores.**
users, credentials, sessions) as another plain `DB` struct instance, `satellite.store_for_host(host)` returns the local DB or the replica of
attached to the runtime `Domain` as its `store`. It is never persisted, the remote backing the host's domain (raising 503 `HTTPException` when
rebuilt from a snapshot on startup, kept current by sequenced events over the replica is unavailable). The session read path (`session_ctx`,
the sync WebSocket, and swept for expired sessions locally. The feed `authz.verify`, `build_user_info`, `/check`) just passes the host it
carries no usable secrets: sessions are keyed by `hash_secret` output, already has; writes dispatch likewise (`satellite.refresh_session`
credentials carry public keys only, and the OIDC signing key is never write-behind for remote, `db.update_session` for local;
replicated. `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 **The replica** is a plain `DB` struct instance in RAM, never persisted.
verify/`/check`/`/user-info` helpers take an explicit `store` (the On connect the remote sends a snapshot of the replicated tables
dispatched domain's), defaulting to the local database. There is no (permissions, orgs, roles, users, credentials, sessions), then live
context-dependent global accessor. 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 ## 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) | | `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/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/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/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 | | `/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). optimistic eviction).
2. Changes made **directly on the remote**: a sync event, ~1 network RTT. 2. Changes made **directly on the remote**: a sync event, ~1 network RTT.
3. Channel down: the replica stays authoritative until `cache_ttl` past 3. Channel down: the replica stays authoritative until `cache_ttl` past
the disconnect, then 503. On reconnect, missed events are replayed the disconnect (dead-peer detection is bounded by the ~10 s keepalive),
from the remote's ring buffer, or a full snapshot is taken (always at then 503. Every reconnect starts from a fresh snapshot.
`refresh_interval` and after remote restarts, detected via a
generation stamp).
## The remote side ## The remote side
Strictly additive and RAM-only: a `syncfeed` ring buffer fed by hooks in Strictly additive and RAM-only: `syncfeed` (a subscriber set fed by the
the struct `store()`/`delete()` methods (which also cover cascade commit hooks) and the token-gated `/auth/api/sync/ws` endpoint serving
deletes) plus explicit emits for field-mutating operations, and the snapshot + live events and accepting `session_refresh` write-backs. With
token-gated `/auth/api/sync/ws` endpoint serving snapshots, event replay no satellites connected, the hooks are a no-op.
and live events, and accepting `session_refresh` write-backs. With no
satellites connected, the hooks are a no-op.
## Trust and caveats ## Trust and caveats
- The satellite host holds a full copy of the remote's auth data in RAM - 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. (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 - Avatars are stored on the remote's disk; `user-info` from a replica
reports no avatar URL. reports no avatar URL.
- OIDC sessions in a replica-backed `user-info` show the client UUID - OIDC sessions in a replica-backed `user-info` show the client UUID
rather than its name (OIDC clients are not replicated). 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 ## 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 ## What the read-only local state buys over the HTTP cache
- **Full `SessionContext` locally.** A replays the byte-response it once - **Full `SessionContext` locally.** A replays the byte-response it once
saw; D *computes* the answer. Query combinations never seen before saw; the satellite *computes* the answer. Query combinations never seen
(new `perm`/`max_age`/`public` shapes) are served locally by D but miss before (new `perm`/`max_age`/`public` shapes) are served locally but
A's cache. D caches the *domain model*, so derived answers (effective miss A's cache. The replica holds the *domain model*, so derived
permissions per host, `max_age` against `credential.last_used`, answers (effective permissions per host, `max_age` against
`Remote-*` composition) are correct without having been witnessed. `credential.last_used`, `Remote-*` composition) are correct without
having been witnessed.
- **One invalidation model.** A hand-builds invalidation rules per - **One invalidation model.** A hand-builds invalidation rules per
endpoint (query-key mapping, cookie re-keying on renew, 401 variants). 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. endpoint becomes consistent at once — including future ones.
- **Degradation behaves like a real instance.** With the remote down, D - **Degradation behaves like a real instance.** With the remote down, the
serves a coherent auth service from the replica (expiry enforced satellite serves a coherent auth service from the replica (expiry
locally, bounded by `cache_ttl`); A serves unrelated cached responses enforced locally, bounded by `cache_ttl`); A serves unrelated cached
with gaps wherever the cache was cold. responses with gaps wherever the cache was cold.
- **Multi-domain uniformity.** D is a property of a domain in the - **Multi-domain uniformity.** Remote backing is a property of a domain
existing registry; local and remote rp-ids coexist in one instance. A in the existing registry; local and remote rp-ids coexist in one
is a separate component bolted in front of specific URLs. instance. A is a separate component bolted in front of specific URLs.
- **User simplicity.** D is configured once in domain config; A needs - **User simplicity.** Configured once in domain config; A needs
deployment and cache-key discipline per frontend application. deployment and cache-key discipline per frontend application.
## What D costs ## What it costs
- A store-explicitness refactor in the security-critical read path - The read path must be honest about which DB it reads: `DB.session_ctx`
(`DB.session_ctx` self-contained; explicit `store` parameters) — small and `/check` were rewritten to use their own tables instead of struct
but review-worthy. (The first draft's contextvar-dependent `db.data()` convenience properties that reach the global database. (A first draft's
was rejected: a global accessor whose meaning shifts under the caller.) contextvar-dependent `db.data()` was rejected: a global accessor whose
- A versioned sync protocol (snapshot + sequenced events + ring-buffer meaning shifts under the caller. Dispatch is instead keyed explicitly
replay + reconnect reconciliation). 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 (full data copy in RAM).
- Additive remote code (sync endpoint + commit hooks), where A needs - Additive remote code (sync endpoint + commit hooks), where A needs
none. none.
- Replica housekeeping (expiry sweeper, write-behind ordering, - Replica housekeeping (expiry sweeper, write-behind, optimistic
optimistic eviction vs. event confirmation). eviction).
## Summary ## Summary
A(+B) is the right tool to "make forward-auth fast in front of an 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 untouched server". The satellite — implemented here — is the right tool
satellite should *be* a paskia instance for its remote domains: one when it should *be* a paskia instance for its remote domains: one
consistency model, correct answers for un-cached query shapes, graceful consistency model, correct answers for un-cached query shapes, graceful
degradation, and per-domain mixing with local rp-ids, at the price of the 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.
+15 -2
View File
@@ -480,6 +480,7 @@ function createDomain() {
origins: [], origins: [],
originValidation: [], originValidation: [],
wellKnownCheck: null, wellKnownCheck: null,
remote: null,
}) })
} }
@@ -495,6 +496,8 @@ function openDomain(domain) {
origins: rows.map(r => r.key), origins: rows.map(r => r.key),
originValidation: rows.map(() => null), originValidation: rows.map(() => null),
wellKnownCheck: 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() 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 const req = d.isNew
? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, 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 } }) : apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins, remote } })
req req
.then(() => { .then(() => {
authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500) authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
+1
View File
@@ -464,6 +464,7 @@ defineExpose({ focusFirstElement })
</div> </div>
<div class="perm-id-info"> <div class="perm-id-info">
<span class="id-text">{{ domain.rp_id }}</span> <span class="id-text">{{ domain.rp_id }}</span>
<span v-if="domain.remote" class="id-text" :title="`Served from remote ${domain.remote.url} (satellite mode)`">🛰 {{ domain.remote.url }}</span>
</div> </div>
</td> </td>
<td class="domain-origins"><span v-for="(e, i) in originDisplayEntries(domain)" :key="e.key">{{ i ? ', ' : '' }}{{ e.key }}{{ e.auth ? '🔑' : '' }}{{ e.related ? '🔗' : '' }}</span></td> <td class="domain-origins"><span v-for="(e, i) in originDisplayEntries(domain)" :key="e.key">{{ i ? ', ' : '' }}{{ e.key }}{{ e.auth ? '🔑' : '' }}{{ e.related ? '🔗' : '' }}</span></td>
@@ -18,6 +18,33 @@ const title = computed(() =>
// compares against it, and hosts are case-insensitive) // compares against it, and hosts are case-insensitive)
const dialogRpId = computed(() => (props.dialog.data?.rp_id || '').trim().toLowerCase()) 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 // Block submit on hard errors: malformed entries, an over-cap related
// list (the server rejects the save), a save that would lock the admin // 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. // 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 (relatedEntries.value.length > 5) return true
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
if (lockoutWarning.value) 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 return false
}) })
@@ -515,6 +544,33 @@ function onRemoveOrigin(i) {
<p class="small muted"> <p class="small muted">
Only the listed sites may sign in with {{ dialog.data.rp_id }} passkeys. Wildcards may be used: <strong>**.{{ dialog.data.rp_id }}</strong> allows the whole domain, <strong>*.{{ dialog.data.rp_id }}</strong> only a single subdomain level.<template v-if="relatedEntries.length"> 🔗 means related host requiring WebAuthn ROR setup.</template><template v-if="dialog.data.auth_host"> 🔑 is the dedicated Paskia host for all account management.</template> Only the listed sites may sign in with {{ dialog.data.rp_id }} passkeys. Wildcards may be used: <strong>**.{{ dialog.data.rp_id }}</strong> allows the whole domain, <strong>*.{{ dialog.data.rp_id }}</strong> only a single subdomain level.<template v-if="relatedEntries.length"> 🔗 means related host requiring WebAuthn ROR setup.</template><template v-if="dialog.data.auth_host"> 🔑 is the dedicated Paskia host for all account management.</template>
</p> </p>
<div class="origin-label">
<label class="remote-toggle">
<input type="checkbox" v-model="remoteEnabled" />
Remote instance (satellite mode)
</label>
</div>
<template v-if="dialog.data.remote">
<label>Remote URL
<input v-model="dialog.data.remote.url" placeholder="https://auth.example.com" data-form-type="other" :class="{ 'input-error': remoteUrlInvalid }" />
</label>
<p v-if="remoteUrlInvalid" class="small error">Must be an http(s) URL.</p>
<p v-if="remoteMissingAuthHost" class="small error">A remote domain must mark an auth host above profile, admin and sign-in pages live there (typically the remote's own site).</p>
<label>Sync token
<input v-model="dialog.data.remote.token" type="password" placeholder="Token in the remote's PASKIA_SYNC_TOKENS" autocomplete="off" data-form-type="other" />
</label>
<p class="small muted">Accepted by the remote via its PASKIA_SYNC_TOKENS environment variable.<template v-if="!dialog.data.isNew"> Leave empty to keep the stored token.</template></p>
<label>Staleness limit (cache TTL, seconds)
<input v-model.number="dialog.data.remote.cache_ttl" type="number" min="1" />
</label>
<label>Full re-sync interval (seconds)
<input v-model.number="dialog.data.remote.refresh_interval" type="number" min="30" />
</label>
<p class="small muted">
Session checks run locally against a RAM replica of the remote (sub-millisecond). If the connection is down longer than the staleness limit, checks fail closed (503).
</p>
</template>
</AdminDialog> </AdminDialog>
</template> </template>
@@ -540,4 +596,7 @@ function onRemoveOrigin(i) {
border-color: var(--color-error); border-color: var(--color-error);
background: var(--color-error-bg, rgba(239, 68, 68, 0.05)); 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; }
</style> </style>
+33
View File
@@ -375,6 +375,39 @@ async def test_admin_configures_remote_domain(client, session_token, test_db):
assert entry["remote"]["url"] == "http://remote.test" assert entry["remote"]["url"] == "http://remote.test"
assert "token" not in entry["remote"] # write-only 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 @pytest.mark.asyncio
async def test_admin_remote_domain_requires_auth_host(client, session_token): async def test_admin_remote_domain_requires_auth_host(client, session_token):