diff --git a/docs/RemoteProxy.md b/docs/RemoteProxy.md index fc44295..18c4f64 100644 --- a/docs/RemoteProxy.md +++ b/docs/RemoteProxy.md @@ -1,264 +1,174 @@ -# Remote Proxy / Satellite Design Review +# Remote Proxy / Satellite -Design notes for serving configured domains (rp-ids) from a **remote** paskia -instance instead of the local database, so that 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 +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 previously used `https://auth.example.com` as their -auth backend (forward-auth checks) only repoint to the local satellite +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: **proposal, not implemented.** Revision 2: no `db.data()` magic; -explicit domain-aware stores; auth host mandatory for remote domains. +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. -## Facts from the architecture review +## Configuration -- The whole DB is already in RAM (msgspec structs, Kanta JSONL for - persistence). Reads never touch disk — the remote is fast, so the latency - problem is purely network RTT. The job is replicating *read state*, not - reimplementing a database. -- `/auth/api/forward` (the hot path) is a **pure read**: - `session_ctx(cookie, host)` → perm/max_age checks → 204 + `Remote-*` - headers. No DB write, no cookie mutation. -- Sessions are host-bound and expire by `validated + SESSION_LIFETIME`; - expiry is enforced by background deletion (`db/lifecycle.py`), and - `Remote-Session-Expires` is already present in the 204 response headers. -- `/auth/api/validate` is the only renewal write (throttled to ≥5 min - spacing); it can set a fresh cookie. -- Login/register/remote-auth run over WebSockets with state in process RAM - (PoW challenges, `remoteauth.instance`, `authcode` 60 s exchange codes). - These cannot be cached or replicated. -- The sync feed carries no usable secrets: sessions are keyed by - `hash_secret` output, credentials carry public keys only, and the OIDC - signing key is never exported. Knowing a stored session hash does not let - you authenticate. -- Restricted paths (`/auth/api/admin/`, `/auth/api/user/`, `/auth/ws/`) are - already refused off a domain's own auth host - (`fastapi/auth_host.py:is_restricted_path`), and UI paths already redirect - to the auth host. With an auth host configured for a remote domain, the - satellite never serves profile, admin or WS traffic for it — browsers and - WebSockets go directly to the remote auth host (the WS dispatch rules in - `fastapi/dispatch.py` already allow an origin domain's own auth host). +A domain becomes remote through its stored domain config (admin domains +API; the frontend form may lag): -## Option A — caching HTTP reverse proxy +```json +"remote": { + "url": "https://auth.example.com", + "token": "", + "cache_ttl": 60, + "refresh_interval": 300 +} +``` -A thin proxy that caches `/auth/api/forward`, `/check`, `/user-info`, -`/settings` responses keyed by `(Host, cookie, query)` with -`TTL = min(configured TTL, Remote-Session-Expires − now)`. Everything else is -forwarded verbatim; WebSockets are tunneled; `/logout` and session-scoped -mutations are intercepted to evict cache entries. Optionally -stale-while-revalidate so remote-side changes propagate within a TTL without -adding latency to any request. +- `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. -**Option B** is A plus a small remote addition: a WebSocket change feed -(session deleted, user/role/permission changed) so the proxy evicts within -one network RTT instead of waiting for the TTL. +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. -- Remote changes: **none** for A; one additive, RAM-only endpoint for B. -- Staleness: TTL-bounded for anything that bypasses the proxy (admin edits, - logout-all from another device) in A; ~1 RTT in B. -- The proxy needs no credentials of its own — every cacheable request is - authenticated by the end user's cookie, forwarded on a miss. +## What the satellite holds -## Option D — satellite instance with per-domain RAM replica (chosen direction) +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. -The same paskia codebase, with two kinds of domains in the existing -multi-domain config: +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. -- **Local domains** — exactly as today, backed by the local Kanta DB. -- **Remote domains** — `DomainConfig` gains `remote: {url, token, cache_ttl, - refresh_interval}`. This is the only new persisted state, and it lives in - the **satellite's** DB. The remote's DB gets nothing new. A remote domain - **must** have an auth host configured (the remote's); this is what makes - the simplifications below possible. - -For a remote domain the satellite holds a **RAM-only read replica** of the -remote's tables (users, orgs, roles, permissions, credentials, sessions) — -another plain `DB` struct instance, never opened by Kanta, rebuilt from the -remote on startup and kept fresh by the sync channel plus snooping on -proxied traffic. - -### No `db.data()` magic: explicit per-request stores - -The first draft proposed making the global `db.data()` accessor return a -replica based on the `current_domain` contextvar. Rejected: a global -accessor whose meaning shifts under the caller is exactly the kind of magic -that breeds bugs in no-request-context code (background jobs, OIDC notify, -CLI). Instead: - -- The runtime `Domain` object (`domains.py`) gets a `store` attribute: a - `DB` instance. For local domains it **is** the global `operations._db`; - for remote domains it is the replica. Registry build / remote-manager - startup attaches it. -- Endpoints resolve the store once, explicitly, from the request: - `request.state.domain.store` (dispatch already puts the domain there), and - pass it down as an ordinary parameter. No global accessor changes - meaning; local domains observe zero functional change. - -Refactor needed to make the hot path store-explicit (small, all in the -"manageable core"): - -- `DB.session_ctx()` (`db/structs.py`) currently leans on struct convenience - properties (`s.user`, `user.role`, `role.org`, `s.credential`, - `org.permissions`) that internally call the global `db.data()`. Rewrite it - to index `self.*` tables directly. This is a pure correctness fix — it - makes `session_ctx` honest about which DB it reads. -- `authsession.session_ctx`, `permutil.session_context`, `authz.verify`, - `sessionutil.check_session_age` gain an explicit `store: DB` parameter - (or are called with one) instead of reaching the global. -- `userinfo.build_user_info` takes a `store` and lists - `store.sessions`/`store.credentials` filtered by user instead of the - `user.sessions` / `user.credentials` convenience properties. -- `userinfo.build_session_context`, `api._remote_headers`, - `authz.auth_error_content` already operate on the `SessionContext` alone — - no change. -- Struct convenience properties (`User.sessions`, `Session.user`, …) keep - using the global `db.data()`; they are only used by local-domain code - (admin, profile, CLI) and by Kanta maintenance. Remote-domain request - paths simply never call them. - -### Endpoint categorization for remote domains - -**1. True special handling (remote-aware logic):** +## Endpoint behavior for remote domains | Endpoint | Handling | |---|---| -| `POST /auth/api/validate` | Read from replica; apply the throttled refresh to the replica and write it behind as `session_refresh{key, validated, ip, ua}` over the sync WS; set the renewed cookie locally (same secret, same rules as today) | -| `POST /auth/api/logout` | Forward to remote with the user's cookie; evict the session from the replica immediately (optimistic); the sync event confirms | -| `POST /auth/api/set-session` | Forward code redemption to the remote (the 60 s exchange code lives in its RAM); relay the `Set-Cookie`; the new session arrives via sync event (created during the login ceremony) | -| `GET /auth/api/token-info` | Proxy to remote (reset tokens exist only there) | -| `/auth/oidc/token`, `/userinfo`, `/keys`, `/backchannel-logout` | Proxy to remote (signing key and OIDC sessions stay there); `/keys` is cacheable | -| Avatar `GET .../profile.webp` | Proxy to remote (storage may be remote-local); not latency-critical | +| `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 | -**2. Store-explicit, no functional change** (the "alike db magic" group — -they read the database but only through the store they are handed): +Freshness hierarchy: -- `GET /auth/api/forward` — the hot path, pure read from `store`. -- `POST /auth/api/validate` — the read/verify half (category 1 covers the - refresh half). -- `GET /auth/api/check` — reads users/roles/orgs/permissions from `store`. -- `GET /auth/api/user-info` — `build_user_info(store, …)`. +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). -**3. Untouched — no knowledge of remoting:** +## The remote side -- `GET /auth/api/settings` — domain config only; already reports - `auth_host`/`auth_site_url`, which is how frontends learn to open - WebSockets on the remote auth host directly. -- `/.well-known/openid-configuration`, `/.well-known/webauthn` — derived - from Host and local domain config. -- Frontend statics, `/auth/restricted/iframe` HTML, reset-token pages — - static files; their API calls land in categories 1–2. -- `auth_host.redirect_middleware` — already redirects UI to the remote auth - host and 404s restricted paths off it; with auth host mandatory this is - exactly the desired behavior, unchanged. -- The entire admin app, user-profile app, `/auth/ws/*`, `/auth/remote-auth/*` - — reachable only on the domain's auth host, which for remote domains is - the remote itself; the satellite never executes this code for remote - domains. -- Dispatch middleware, background cleanup of the local DB, CLI, bootstrap. -- All local-domain handling of every endpoint. +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. -### The sync channel (the one remote addition, RAM-only) +## Trust and caveats -New endpoint on the remote, e.g. `wss://…/auth/api/sync/ws`, plus an -emission hook in `db/operations.py` (publish after each commit; no-op when -no peer is connected): +- 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. -- **Remote → satellite**: `hello{generation, protocol_version}` → full - snapshot (chunked msgspec; these tables are tiny — 10k sessions ≈ 2 MB) → - then `event{seq, op: upsert|delete, table, key, payload}`. The remote - keeps a bounded event ring buffer in RAM; on reconnect the satellite sends - its last `seq` and either gets a replay or a fresh snapshot. -- **Satellite → remote**: `session_refresh` write-behind; optionally - `session_deleted` as belt-and-braces alongside proxied logout. -- **Authentication**: the satellite presents a token the remote reads from - env/CLI (e.g. `PASKIA_SYNC_TOKENS`) — nothing lands in the remote's - database. mTLS client certs are an alternative. +--- -Freshness hierarchy, worst case last: +# Design review (the rejected alternative) -1. Changes made **through** the satellite: immediate (optimistic eviction on - logout, snooped `Set-Cookie`/login results). -2. Changes made **directly on the remote** (admin on another device, - logout-all): WS event, ~1 network RTT. -3. WS down: per-entry TTL lazy revalidation + periodic full snapshot; on - reconnect, replay or re-sync. Fail-open (the replica enforces - `validated + EXPIRES` itself, with its own little sweeper mirroring - `cleanup_expired`) vs fail-closed is configurable per domain. +## Option A — caching HTTP reverse proxy -Deliberately out of scope: +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. -- **Running WebAuthn ceremonies on the satellite** — duplicates the - security-critical verification path and breaks centralized sign-count - clone detection; browsers talk to the remote auth host directly anyway. -- **OIDC signing on the satellite** — key material never leaves the remote. -- **Admin/profile functionality for remote domains** — deferred to the - remote auth host; this is what keeps the satellite's surface small. +- 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. -## D vs. A(+B): what the read-only local state buys +## What the read-only local state buys over the HTTP cache -Semantics, not just speed: - -- **Full `SessionContext` locally.** A answers the exact byte-response it - once saw; D *computes* the answer. New `perm`/`max_age`/`public` query - combinations never seen before are served locally by D, while A must go - upstream on every cache-key miss. D caches the *domain model*, so derived - answers (effective permissions per host, `max_age` against - `credential.last_used`, per-request `Remote-*` header composition) are - correct without having been witnessed before. -- **One invalidation model.** In A, invalidation rules are hand-built per - endpoint (query-key mapping, cookie re-keying on renew, 401 variants, - `/user-info` vs `/forward` overlap). In D, events mutate the replica - (upsert/delete by table+key) and every endpoint becomes consistent at - once — including endpoints added in the future. +- **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 - keeps serving a coherent auth service from the replica (expiry enforced - locally). A serves a pile of 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 rp-ids and remote rp-ids coexist, and the same instance - genuinely *is* the auth server for its local domains. A is a separate - component bolted in front of specific URLs. -- **User simplicity.** D is configured once as domain config - (`remote.url` + token); A needs deployment and key-mapping discipline per - frontend application. + 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 core.** `DB.session_ctx` must stop - leaning on global-accessing convenience properties; the verify/userinfo - helper chain gains a `store` parameter. Small and mechanical, but it - touches the security-critical read path, so it wants careful review and - tests proving local-domain behavior is byte-identical. (The rejected - alternative — a contextvar-dependent `db.data()` — avoided this refactor - at the price of a global whose meaning shifts under the caller.) -- **A sync protocol to design and version.** Snapshot + sequenced events + - ring-buffer replay + reconnect reconciliation, plus a protocol-version - handshake because remote and satellite releases can drift in - `SessionContext` semantics. A needs no protocol at all. -- **A trusted satellite host.** The replica holds everything the remote DB - has except the OIDC key (public keys, hashed session keys, user data) in - RAM. The host must be as trusted as the remote. A caches only what traffic - it saw, in the shape it saw it. -- **Remote code, albeit additive.** D requires the sync endpoint and commit - hooks on the remote (RAM-only, inert when unused, token-gated). A requires - zero remote changes; B requires the same endpoint in a thinner form. -- **Replica housekeeping** — its own expiry sweeper, write-behind ordering, - optimistic eviction vs. event confirmation. A's failure modes are dumber - and narrower. +- 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 when the goal is "make forward-auth fast in front -of an untouched server" — small, deployable anywhere, byte-compatible. D 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. With -the auth host made mandatory for remote domains, D's surface shrinks to four -locally served read endpoints, a handful of proxied mutations, an explicit -`store` parameter in the core read path, and one RAM-only sync endpoint on -the remote. +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. diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 97fabfc..58d66b7 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -183,7 +183,9 @@ def add_permission_to_org( with _transaction("admin:add_permission_to_org", ctx): _db.permissions[permission_uuid].orgs[org_uuid] = True - syncfeed.emit("permissions", str(permission_uuid), _db.permissions[permission_uuid]) + syncfeed.emit( + "permissions", str(permission_uuid), _db.permissions[permission_uuid] + ) def remove_permission_from_org( @@ -201,7 +203,9 @@ def remove_permission_from_org( with _transaction("admin:remove_permission_from_org", ctx): _db.permissions[permission_uuid].orgs.pop(org_uuid, None) - syncfeed.emit("permissions", str(permission_uuid), _db.permissions[permission_uuid]) + syncfeed.emit( + "permissions", str(permission_uuid), _db.permissions[permission_uuid] + ) def create_role(role: Role, *, ctx: SessionContext | None = None) -> None: @@ -611,7 +615,9 @@ def login( # Update credential _db.credentials[credential_uuid].sign_count = sign_count _db.credentials[credential_uuid].last_used = now - syncfeed.emit("credentials", str(credential_uuid), _db.credentials[credential_uuid]) + syncfeed.emit( + "credentials", str(credential_uuid), _db.credentials[credential_uuid] + ) return token @@ -639,7 +645,9 @@ def oidc_login( # Update credential _db.credentials[credential_uuid].sign_count = sign_count _db.credentials[credential_uuid].last_used = now - syncfeed.emit("credentials", str(credential_uuid), _db.credentials[credential_uuid]) + syncfeed.emit( + "credentials", str(credential_uuid), _db.credentials[credential_uuid] + ) def create_credential_session( diff --git a/paskia/domains.py b/paskia/domains.py index 0d2c035..140ec5c 100644 --- a/paskia/domains.py +++ b/paskia/domains.py @@ -233,9 +233,7 @@ def validate_config( if domain.remote is not None and not domain.remote.url.startswith( ("https://", "http://") ): - raise ValueError( - f"Domain '{rp_id}': remote URL must be an http(s) URL" - ) + raise ValueError(f"Domain '{rp_id}': remote URL must be an http(s) URL") domain_auth_host: str | None = None related_count = 0 @@ -420,7 +418,9 @@ def sanitize_config( for key in related[related_origin_cap:]: del origins[key] - domains[rp_id] = DomainConfig(rp_name=domain.rp_name, origins=origins, remote=remote) + domains[rp_id] = DomainConfig( + rp_name=domain.rp_name, origins=origins, remote=remote + ) if not domains: raise ValueError("No servable domain in the stored configuration") diff --git a/paskia/fastapi/admin/domains.py b/paskia/fastapi/admin/domains.py index 854532c..b2530c4 100644 --- a/paskia/fastapi/admin/domains.py +++ b/paskia/fastapi/admin/domains.py @@ -46,7 +46,9 @@ def _domain_to_api(domain: domains.Domain) -> ApiDomain: ) -def _normalize_remote(value, existing: RemoteConfig | None = None) -> RemoteConfig | None: +def _normalize_remote( + value, existing: RemoteConfig | None = None +) -> RemoteConfig | None: """Parse a remote object from the admin UI (raises on malformed). An absent/empty token keeps the previously stored one — the token is diff --git a/paskia/fastapi/proxy.py b/paskia/fastapi/proxy.py index 9a12a10..5a51c2b 100644 --- a/paskia/fastapi/proxy.py +++ b/paskia/fastapi/proxy.py @@ -40,9 +40,7 @@ def _client(base_url: str) -> httpx.AsyncClient: async def proxy_to_remote(request: Request, remote: RemoteConfig) -> Response: """Forward this request to the remote instance unchanged.""" - headers = { - k: v for k, v in request.headers.items() if k.lower() not in _HOP_BY_HOP - } + headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP_BY_HOP} upstream = await _client(remote.url).request( request.method, request.url.path, diff --git a/paskia/fastapi/sync.py b/paskia/fastapi/sync.py index 19a6de8..5cd502f 100644 --- a/paskia/fastapi/sync.py +++ b/paskia/fastapi/sync.py @@ -32,14 +32,19 @@ def _snapshot_messages() -> list[bytes]: messages = [] for table, attr in _SNAPSHOT_TABLES: items = [ - [str(key), msgspec.to_builtins(obj)] for key, obj in getattr(data, attr).items() + [str(key), msgspec.to_builtins(obj)] + for key, obj in getattr(data, attr).items() ] - messages.append(syncfeed.encode({"type": "snapshot", "table": table, "items": items})) + messages.append( + syncfeed.encode({"type": "snapshot", "table": table, "items": items}) + ) return messages async def _send(ws: WebSocket, message: dict | bytes) -> None: - await ws.send_bytes(message if isinstance(message, bytes) else syncfeed.encode(message)) + await ws.send_bytes( + message if isinstance(message, bytes) else syncfeed.encode(message) + ) async def _apply_client_message(message: dict) -> None: @@ -115,5 +120,5 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None: try: while True: await _send(ws, await queue.get()) - except (WebSocketDisconnect, RuntimeError, asyncio.CancelledError): + except WebSocketDisconnect, RuntimeError, asyncio.CancelledError: pass diff --git a/paskia/syncfeed.py b/paskia/syncfeed.py index 690cd23..4fcb9fc 100644 --- a/paskia/syncfeed.py +++ b/paskia/syncfeed.py @@ -84,7 +84,11 @@ def emit(table: str, key: str, obj) -> None: def tokens_from_env() -> set[str]: """Accepted sync tokens (PASKIA_SYNC_TOKENS, comma-separated).""" - return {t.strip() for t in os.environ.get("PASKIA_SYNC_TOKENS", "").split(",") if t.strip()} + return { + t.strip() + for t in os.environ.get("PASKIA_SYNC_TOKENS", "").split(",") + if t.strip() + } def encode(message: dict) -> bytes: diff --git a/tests/test_remote.py b/tests/test_remote.py index 3470122..b38f548 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -79,9 +79,7 @@ def test_apply_upsert_and_delete(): replica = DB() user = User.create(display_name="U", role=UUID(int=1)) user.uuid = UUID(int=2) - satellite._apply( - replica, "users", str(user.uuid), "upsert", _builtins(user) - ) + satellite._apply(replica, "users", str(user.uuid), "upsert", _builtins(user)) assert replica.users[user.uuid].display_name == "U" satellite._apply(replica, "users", str(user.uuid), "delete", None) assert not replica.users @@ -344,10 +342,16 @@ async def test_admin_configures_remote_domain(client, session_token, test_db): json={ "rp_id": "example.com", "rp_name": "Example", - "origins": {"**.example.com": True, "auth.example.com": {"auth_host": True}}, + "origins": { + "**.example.com": True, + "auth.example.com": {"auth_host": True}, + }, "remote": {"url": "http://remote.test", "token": "sekret", "cache_ttl": 30}, }, - headers={"Host": "localhost:4401", "Cookie": f"{AUTH_COOKIE_NAME}={session_token}"}, + headers={ + "Host": "localhost:4401", + "Cookie": f"{AUTH_COOKIE_NAME}={session_token}", + }, ) assert r.status_code == 200, r.text stored = test_db.config.domains["example.com"] @@ -356,7 +360,10 @@ async def test_admin_configures_remote_domain(client, session_token, test_db): r = await client.get( "/auth/api/admin/domains/", - headers={"Host": "localhost:4401", "Cookie": f"{AUTH_COOKIE_NAME}={session_token}"}, + headers={ + "Host": "localhost:4401", + "Cookie": f"{AUTH_COOKIE_NAME}={session_token}", + }, ) entry = next(d for d in r.json() if d["rp_id"] == "example.com") assert entry["remote"]["url"] == "http://remote.test" @@ -372,7 +379,10 @@ async def test_admin_remote_domain_requires_auth_host(client, session_token): "origins": {"**.example.com": True}, "remote": {"url": "http://remote.test"}, }, - headers={"Host": "localhost:4401", "Cookie": f"{AUTH_COOKIE_NAME}={session_token}"}, + headers={ + "Host": "localhost:4401", + "Cookie": f"{AUTH_COOKIE_NAME}={session_token}", + }, ) assert r.status_code == 400 assert "auth host" in r.json()["detail"]