Make session read path store-explicit

DB.session_ctx now reads only its own tables instead of struct
convenience properties that reach the global database, so a DB instance
(a read replica) is self-contained. session_ctx/session_context/
authz.verify/build_user_info take an explicit store parameter defaulting
to the local database; api.py endpoints resolve it from the dispatched
domain (Domain.store).
This commit is contained in:
2026-09-20 23:08:56 +00:00
parent b5733657f9
commit 20b145d816
8 changed files with 332 additions and 19 deletions
+264
View File
@@ -0,0 +1,264 @@
# Remote Proxy / Satellite Design Review
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
`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
(`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.
## Facts from the architecture review
- 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).
## Option A — caching HTTP reverse proxy
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.
**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.
- 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.
## Option D — satellite instance with per-domain RAM replica (chosen direction)
The same paskia codebase, with two kinds of domains in the existing
multi-domain config:
- **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 | 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 |
**2. Store-explicit, no functional change** (the "alike db magic" group —
they read the database but only through the store they are handed):
- `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, …)`.
**3. Untouched — no knowledge of remoting:**
- `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 12.
- `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.
### The sync channel (the one remote addition, RAM-only)
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):
- **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:
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.
Deliberately out of scope:
- **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.
## D vs. A(+B): what the read-only local state buys
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.
- **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.
## 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.
## 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.
+8 -4
View File
@@ -18,14 +18,18 @@ from paskia.db.structs import ResetToken
from paskia.util import hostutil from paskia.util import hostutil
if TYPE_CHECKING: if TYPE_CHECKING:
from paskia.db import ResetToken from paskia.db import DB, ResetToken
EXPIRES = SESSION_LIFETIME EXPIRES = SESSION_LIFETIME
def session_ctx(auth: str, host: str | None = None): def session_ctx(auth: str, host: str | None = None, store: DB | None = None):
"""Get session context with normalized host.""" """Get session context with normalized host.
return db.data().session_ctx(auth, hostutil.normalize_host(host))
store defaults to the local database; remote-domain request paths pass
their domain's replica explicitly.
"""
return (store or db.data()).session_ctx(auth, hostutil.normalize_host(host))
def expires() -> datetime: def expires() -> datetime:
+9 -5
View File
@@ -728,17 +728,21 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
if s.host != host: if s.host != host:
return None return None
# Look up via this instance's own tables: a DB must be
# self-contained so that read replicas work unchanged.
try: try:
user = s.user user = self.users[s.user_uuid]
role = user.role role = self.roles[user.role_uuid]
org = role.org org = self.orgs[role.org_uuid]
credential = s.credential credential = self.credentials[s.credential_uuid]
except KeyError: except KeyError:
return None return None
# Effective permissions: role's permissions that the org can grant, # Effective permissions: role's permissions that the org can grant,
# filtered by domain restriction # filtered by domain restriction
org_perm_uuids = {p.uuid for p in org.permissions} org_perm_uuids = {
p.uuid for p in self.permissions.values() if org.uuid in p.orgs
}
effective_perms = [] effective_perms = []
for perm_uuid in role.permission_set: for perm_uuid in role.permission_set:
+18
View File
@@ -90,6 +90,7 @@ class Domain:
self.config = config self.config = config
self.site_url = site_url self.site_url = site_url
self.site_path = site_path self.site_path = site_path
self._store = None
self.passkey = Passkey( self.passkey = Passkey(
rp_id=rp_id, rp_id=rp_id,
rp_name=config.rp_name, rp_name=config.rp_name,
@@ -97,6 +98,23 @@ class Domain:
related_origins=[origin_url(k) for k in related], related_origins=[origin_url(k) for k in related],
) )
@property
def store(self):
"""The DB instance this domain's request paths read.
Defaults to the local database; remote domains get their read
replica attached by the sync client at startup.
"""
if self._store is not None:
return self._store
from paskia import db
return db.data()
@store.setter
def store(self, value) -> None:
self._store = value
@property @property
def rp_name(self) -> str: def rp_name(self) -> str:
return self.passkey.rp_name return self.passkey.rp_name
+10 -2
View File
@@ -97,6 +97,11 @@ def _parse_perm(perm: list[str]) -> list[tuple[str, ...]]:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
def _store(request: Request):
"""The dispatched domain's data store (local DB or read replica)."""
return request.state.domain.store
@app.post("/validate") @app.post("/validate")
async def validate_token( async def validate_token(
request: Request, request: Request,
@@ -114,6 +119,7 @@ async def validate_token(
perm_groups, perm_groups,
host=request.headers.get("host"), host=request.headers.get("host"),
max_age=max_age, max_age=max_age,
store=_store(request),
) )
except HTTPException: except HTTPException:
# Global handler will clear cookie if 401 # Global handler will clear cookie if 401
@@ -162,7 +168,7 @@ async def check_user(
No session cookie is read or written. Caller authentication is not required. No session cookie is read or written. Caller authentication is not required.
""" """
data = db.data() data = _store(request)
try: try:
u = data.users[user_uuid] u = data.users[user_uuid]
role = u.role role = u.role
@@ -267,6 +273,7 @@ async def forward_authentication(
perm_groups, perm_groups,
host=request.headers.get("host"), host=request.headers.get("host"),
max_age=max_age, max_age=max_age,
store=_store(request),
) )
_set_log_extra(request, forwarded, ctx.session.key) _set_log_extra(request, forwarded, ctx.session.key)
remote_headers = _remote_headers(ctx) remote_headers = _remote_headers(ctx)
@@ -329,7 +336,7 @@ async def api_user_info(
detail="Authentication required", detail="Authentication required",
mode="login", mode="login",
) )
ctx = session_ctx(auth, request.headers.get("host")) ctx = session_ctx(auth, request.headers.get("host"), store=_store(request))
if not ctx: if not ctx:
raise authz.AuthException( raise authz.AuthException(
status_code=401, status_code=401,
@@ -346,6 +353,7 @@ async def api_user_info(
session_key=ctx.session.key, session_key=ctx.session.key,
request_host=request.headers.get("host"), request_host=request.headers.get("host"),
ctx=ctx, ctx=ctx,
store=_store(request),
) )
) )
+5 -1
View File
@@ -62,6 +62,7 @@ async def verify(
match: Callable | None = None, match: Callable | None = None,
host: str | None = None, host: str | None = None,
max_age: str | None = None, max_age: str | None = None,
store=None,
): ):
"""Validate session token and optional list of required permissions. """Validate session token and optional list of required permissions.
@@ -69,6 +70,9 @@ async def verify(
scope patterns (OR semantics within a group). All entries must be scope patterns (OR semantics within a group). All entries must be
satisfied (AND semantics). satisfied (AND semantics).
store defaults to the local database; remote-domain request paths pass
their domain's replica explicitly.
Returns the session context. Returns the session context.
Raises AuthException on failure with metadata for UI rendering. Raises AuthException on failure with metadata for UI rendering.
@@ -80,7 +84,7 @@ async def verify(
mode="login", mode="login",
) )
ctx = await permutil.session_context(auth, host) ctx = await permutil.session_context(auth, host, store=store)
if not ctx: if not ctx:
raise AuthException( raise AuthException(
status_code=401, status_code=401,
+2 -2
View File
@@ -129,8 +129,8 @@ def has_all_scopes_groups(scopes: set[str], groups: Sequence[Sequence[str]]) ->
return all(group_satisfied(scopes, g) for g in groups) return all(group_satisfied(scopes, g) for g in groups)
async def session_context(auth: str | None, host: str | None = None): async def session_context(auth: str | None, host: str | None = None, store=None):
if not auth: if not auth:
return None return None
normalized_host = normalize_host(host) if host else None normalized_host = normalize_host(host) if host else None
return session_ctx(auth, normalized_host) return session_ctx(auth, normalized_host, store=store)
+16 -5
View File
@@ -41,26 +41,37 @@ async def build_user_info(
session_key: str, session_key: str,
request_host: str | None, request_host: str | None,
ctx: SessionContext | None = None, ctx: SessionContext | None = None,
store=None,
) -> ApiUserDetail: ) -> ApiUserDetail:
"""Build user info struct for authenticated users.""" """Build user info struct for authenticated users.
user = db.data().users[user_uuid]
store defaults to the local database; remote-domain request paths pass
their domain's replica explicitly.
"""
data = store or db.data()
user = data.users[user_uuid]
normalized_host = hostutil.normalize_host(request_host) normalized_host = hostutil.normalize_host(request_host)
user_sessions = [s for s in data.sessions.values() if s.user_uuid == user_uuid]
user_credentials = [
c for c in data.credentials.values() if c.user_uuid == user_uuid
]
sessions = { sessions = {
s.key: ApiUserSession.from_db( s.key: ApiUserSession.from_db(
s, s,
current_key=session_key, current_key=session_key,
normalized_host=normalized_host, normalized_host=normalized_host,
) )
for s in user.sessions for s in user_sessions
} }
return ApiUserDetail( return ApiUserDetail(
user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)), user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
credentials={c.uuid: c for c in user.credentials}, credentials={c.uuid: c for c in user_credentials},
aaguid_info={ aaguid_info={
k: ApiAaguidInfo(**v) k: ApiAaguidInfo(**v)
for k, v in aaguid.filter(c.aaguid for c in user.credentials).items() for k, v in aaguid.filter(c.aaguid for c in user_credentials).items()
}, },
sessions=sessions, sessions=sessions,
permissions={p.uuid: ApiPermission.from_db(p) for p in ctx.permissions} permissions={p.uuid: ApiPermission.from_db(p) for p in ctx.permissions}