Compare commits
23
Commits
main
...
6634f285ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6634f285ae | ||
|
|
6432b03174 | ||
|
|
4991e7be43 | ||
|
|
6895d71ad2 | ||
|
|
7c04a97892 | ||
|
|
a38bb6ccec | ||
|
|
7a34961fa5 | ||
|
|
dd9f6e094b | ||
|
|
1f3410aa88 | ||
|
|
f2b82f04a9 | ||
|
|
444eca651e | ||
|
|
6d49b59cec | ||
|
|
fa0d03729b | ||
|
|
0a59e26e45 | ||
|
|
9da1abc2c7 | ||
|
|
0638117279 | ||
|
|
28fa0ff61f | ||
|
|
05ea188ac9 | ||
|
|
ebe18912fc | ||
|
|
3a6e2bb370 | ||
|
|
0f38a8b096 | ||
|
|
d0c7949052 | ||
|
|
2209e2395c |
No files matched your search
@@ -236,3 +236,4 @@ See [Multi-Site documentation](docs/MultiSite.md) for details.
|
||||
- [Trusted Headers for Backend Apps](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Headers.md)
|
||||
- [Frontend integration](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Integration.md)
|
||||
- [Paskia API](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/API.md)
|
||||
- [Remote Proxy / Satellite](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/RemoteProxy.md) — serve domains from a local satellite instance
|
||||
+8
-1
@@ -76,8 +76,15 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access
|
||||
| POST | /auth/api/admin/domains/ | Create domain `{rp_id, rp_name?, origins?, related?}` | 200/400/401/403 |
|
||||
| PATCH | /auth/api/admin/domains/{rp_id} | Update domain rp_name/origins/related | 200/400/401/403 |
|
||||
| DELETE | /auth/api/admin/domains/{rp_id} | Delete domain (refused while credentials remain) | 200/400/401/403 |
|
||||
| GET | /auth/api/admin/satellites/ | List registered satellite links with live state | 200/401/403 |
|
||||
| POST | /auth/api/admin/satellites/ | Mint a satellite registration offer `{rp_ids}` — wss link URL, RAM-only, 10 min, single-use | 200/400/401/403 |
|
||||
| PATCH | /auth/api/admin/satellites/{id} | Adjust a link's domain scope (pushed over the live link) | 200/400/401/403 |
|
||||
| DELETE | /auth/api/admin/satellites/{id} | Revoke a satellite link (drops its connections) | 200/400/401/403 |
|
||||
| GET | /auth/api/admin/uplinks/ | List this instance's uplinks with served domains and live state | 200/401/403 |
|
||||
| POST | /auth/api/admin/uplinks/ | Register an uplink from a pasted link URL `{url}` (online handshake) | 200/400/401/403 |
|
||||
| DELETE | /auth/api/admin/uplinks/{id} | Unlink an uplink (drops it and its domains) | 200/400/401/403 |
|
||||
|
||||
Domain endpoints require the `auth:admin` permission; writes additionally require recent authentication (5 minutes). Changes are validated cross-domain and apply immediately.
|
||||
Domain endpoints require the `auth:admin` permission; writes additionally require recent authentication (5 minutes). Changes are validated cross-domain and apply immediately. Remote (satellite-served) domains are uplink-managed and cannot be written here; see [Remote Proxy / Satellite](RemoteProxy.md) for the link model. The same applies to the satellite/uplink endpoints.
|
||||
|
||||
`origins` is a single object keyed by all of the domain's sign-in sites. Entries *within* the rp-id domain are in-domain sites: bare hosts, wildcards under the rp-id following the shell-glob convention (`**.example.com` covers the apex and subdomains at any depth, `*.example.com` exactly one subdomain level — https only, any scheme and port under localhost; plain `*` is not accepted), or full origins when not https. Entries *outside* the rp-id domain are related origins that may assert this domain's rp-id (WebAuthn Related Origin Requests, max 5, no wildcards); those are published at `/.well-known/webauthn` on the rp-id host. An empty object allows nothing of the domain itself. A value of `true` marks presence; `{"auth_host": true}` additionally marks an in-domain entry as the domain's authentication host.
|
||||
|
||||
|
||||
+140
-79
@@ -1,6 +1,6 @@
|
||||
# Remote Proxy / Satellite
|
||||
|
||||
Paskia can serve a configured domain (rp-id) from a **remote** paskia
|
||||
Paskia can serve configured domains (rp-ids) 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
|
||||
@@ -13,117 +13,177 @@ backend (forward-auth checks) only repoint to the local satellite
|
||||
satellite ultimately uses `auth.example.com`.
|
||||
|
||||
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.
|
||||
(satellite side: uplinks, replicas, host dispatch, forwarding) and
|
||||
`paskia/syncfeed.py` + `paskia/fastapi/sync.py` (upstream side: change
|
||||
feed, registration handshake, sync WebSocket). The design review comparing
|
||||
the rejected alternatives is at the end of this document.
|
||||
|
||||
## Configuration
|
||||
|
||||
A domain becomes remote in the admin domains UI (master admin, on the
|
||||
primary server's auth host — this configuration itself never touches a
|
||||
remote): enable *Remote instance* and set the remote URL and sync token.
|
||||
In the stored config (`DomainConfig.remote`):
|
||||
Setup is a single copy-paste of a **link URL**, and everything else flows
|
||||
from it. On the **upstream** (the authoritative instance), open the admin
|
||||
*Satellites* panel and use *New satellite link*, choosing the domains the
|
||||
satellite will serve. That mints a registration offer and shows one URL:
|
||||
|
||||
```json
|
||||
"remote": {
|
||||
"url": "https://auth.example.com",
|
||||
"token": "<sync token>",
|
||||
"cache_ttl": 60,
|
||||
"refresh_interval": 300
|
||||
}
|
||||
```
|
||||
wss://<initial-key>@auth.example.com
|
||||
```
|
||||
|
||||
- `url` — the remote instance's base URL. The satellite connects to
|
||||
`{url}/auth/api/sync/ws`; the connection is server-to-server and not
|
||||
host-dispatched, so internal addresses work. The admin dialog probes
|
||||
`{url}/auth/api/settings` as you type: it must be reachable, serve this
|
||||
very rp-id, and not be a satellite for it itself (nested satellites are
|
||||
not supported — always point at the authoritative instance). Probe
|
||||
findings are warnings; the server-side validation is authoritative.
|
||||
- `token` — bearer token for the sync channel. The **remote accepts tokens
|
||||
via its `PASKIA_SYNC_TOKENS` environment variable** (comma-separated);
|
||||
nothing is stored in the remote's database, and with the variable unset
|
||||
the sync endpoint stays closed. The token is write-only over the admin
|
||||
API (an empty field keeps the stored one).
|
||||
- `cache_ttl` — seconds the replica remains trusted after the sync channel
|
||||
goes down; then checks fail closed (503). Set it large (up to the 24 h
|
||||
session lifetime) for fail-open behavior. Minimum 1.
|
||||
- `refresh_interval` — seconds between reconnects; every connect starts
|
||||
from a full snapshot, which reconciles any drift. Minimum 30.
|
||||
The `wss://` scheme is deliberate: the URL is meant only for a satellite
|
||||
to connect to, and browsers refuse to open it. The initial key is held in
|
||||
the upstream's RAM only, expires in 10 minutes, and is single-use — losing
|
||||
or leaking it just means minting a new one. The permanent credentials are
|
||||
created during registration (see *How it works*).
|
||||
|
||||
Several domains may share one remote (they share a single replica); their
|
||||
remote settings must then be identical — conflicting configurations are
|
||||
rejected at validation and aligned with a warning at load.
|
||||
On the **satellite**, either:
|
||||
|
||||
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.
|
||||
- paste the URL in its admin *Satellites* panel under *Uplinks → Add
|
||||
uplink* — registration runs immediately and reports failures inline; or
|
||||
- run `paskia satellite <url>` on the satellite host — the whole setup of
|
||||
a satellite-only installation (it creates the database on first run; no
|
||||
local domain or admin site is needed).
|
||||
|
||||
That's all: the upstream pushes the link's domain set — rp-id, display
|
||||
name and the origins table verbatim, including its auth host — and the
|
||||
satellite persists it. The upstream is authoritative: pushed domains are
|
||||
validated against the satellite's local configuration (an rp-id already
|
||||
served locally, or origins colliding with it, are rejected and reported
|
||||
back into the upstream's link list), and remote domains are read-only on
|
||||
the satellite — the only satellite-side configuration is linking and
|
||||
unlinking.
|
||||
|
||||
A link's scope is **adjustable at any time** from the upstream's link list
|
||||
(edit which domains it serves): the new set is pushed over the live link —
|
||||
register once, no new cryptographic setup. Revoking a link drops the
|
||||
satellite's connections; the satellite observes the policy close and
|
||||
unlinks its side (its copy of the domains is removed).
|
||||
|
||||
A satellite-only instance serves its backend apps on local addresses and
|
||||
needs no inbound connectivity at all — the link is an outbound WebSocket
|
||||
from the satellite.
|
||||
|
||||
In the stored config, a remote domain carries only the link reference:
|
||||
|
||||
```json
|
||||
"remote": { "link": "<uplink id>" }
|
||||
```
|
||||
|
||||
and the uplink itself lives in the database's `uplinks` table (the
|
||||
upstream URL, the shared `key` bytes from the handshake, and the local
|
||||
tolerance knobs):
|
||||
|
||||
- `cache_ttl` (default 60) — seconds the replica remains trusted after
|
||||
the sync channel goes down; then checks fail closed (503). Set it large
|
||||
(up to the 24 h session lifetime) for fail-open behavior.
|
||||
- `refresh_interval` (default 300) — seconds between reconnects; every
|
||||
connect starts from a full snapshot, which reconciles any drift.
|
||||
|
||||
On the upstream, links live in the `satellite_links` table: the peer's
|
||||
`key` bytes, its self-reported label, and the scope (`rp_ids`).
|
||||
|
||||
## How it works
|
||||
|
||||
**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
|
||||
**Registration and the sync channel share one endpoint** —
|
||||
`{upstream}/auth/api/sync/ws` — multiplexed by the first binary frame.
|
||||
Registration is a 3-way dialogue over WebSocket binary frames:
|
||||
|
||||
```
|
||||
satellite → upstream hello {link: null, key: <initial key>, label}
|
||||
upstream → satellite welcome {link, key: <server part>, domains: [...]}
|
||||
satellite → upstream ready {key: <client part>, rejected: {...}}
|
||||
```
|
||||
|
||||
The permanent link credential is created during the handshake from key
|
||||
parts both sides contribute; both sides then store the link in their
|
||||
databases. (Cryptography is currently a placeholder: the parts are
|
||||
exchanged as-is. The final version derives the channel key from the
|
||||
handshake without sending key material, and the JSON frames that follow
|
||||
are transparently encrypted.)
|
||||
|
||||
An established link runs the same dialogue with `hello {link, key}`
|
||||
proving the stored credential; the welcome re-pushes the link's domain set
|
||||
(the scope may have changed while the satellite was offline), the ready is
|
||||
a bare ack carrying any domain rejections. After the handshake the channel
|
||||
switches to plain JSON frames: a snapshot of the scoped tables, `ready`,
|
||||
then live upsert/delete events; the satellite sends `session_refresh`
|
||||
write-backs. A live `domains` frame pushes scope or origin changes;
|
||||
the satellite applies and acknowledges.
|
||||
|
||||
**Dispatch is keyed by host, and only the satellite module knows about
|
||||
stores.** `satellite.store_for_host(host)` returns the local DB or the
|
||||
replica of the uplink backing the host's domain (raising 503
|
||||
`HTTPException` when the replica is unavailable). The session read path
|
||||
(`session_ctx`, `authz.verify`, `build_user_info`) 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.
|
||||
|
||||
**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
|
||||
**The replica** is a `ReplicaDB` instance in RAM, never persisted, one per
|
||||
uplink connection. On connect the upstream sends a snapshot of the scoped
|
||||
tables (the link's domains' credentials and 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.
|
||||
(which also cover cascade deletes) and field-mutating operations. The feed
|
||||
is scoped to the link's rp-ids, and the user graph never leaves the
|
||||
upstream: every session upsert carries an embedded `ctx` — the same
|
||||
`ApiSessionContext` sent to users — holding the session's user/org/role
|
||||
display data and effective permissions, computed for the session's bound
|
||||
host. Mutations of users, orgs, roles or permissions are not published;
|
||||
instead the upstream eagerly re-pushes the affected sessions with freshly
|
||||
computed contexts, so renames and permission revocations take effect
|
||||
within ~1 RTT. 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 | Handling |
|
||||
|---|---|
|
||||
| `GET /auth/api/forward`, `GET /check`, `GET /user-info`, `GET /settings` | served from the replica (sub-ms) |
|
||||
| `GET /auth/api/forward`, `GET /settings` | served from the replica (sub-ms) |
|
||||
| `POST /auth/api/validate` | verified from the replica; the throttled refresh updates the replica and is written back over the sync channel; cookie renewed locally |
|
||||
| `POST /auth/api/logout` | proxied to the remote (original Host preserved) and evicted from the replica immediately |
|
||||
| `POST /auth/api/set-session`, `GET /token-info` | proxied (the exchange code/reset token lives on the remote); the session arrives via sync event |
|
||||
| `/auth/oidc/*` | proxied (signing key and OIDC sessions stay on the remote) |
|
||||
| `/auth/ws/*`, `/auth/remote-auth/*`, admin, profile | not served — the auth host requirement means these are reached on the remote directly |
|
||||
| `GET /check`, `GET /user-info` | proxied — these need the user graph (per-session contexts do not cover arbitrary user lookups or full profile data) |
|
||||
| `POST /auth/api/logout` | proxied to the upstream (original Host preserved) and evicted from the replica immediately |
|
||||
| `POST /auth/api/set-session`, `GET /token-info` | proxied (the exchange code/reset token lives on the upstream); the session arrives via sync event |
|
||||
| `/auth/oidc/*` | proxied (signing key and OIDC sessions stay on the upstream) |
|
||||
| `/auth/ws/*`, `/auth/remote-auth/*`, admin, profile | not served — the auth host requirement means these are reached on the upstream directly |
|
||||
|
||||
Proxied calls answer 503 when the remote is unreachable. `/auth/api/settings`
|
||||
reports `remote: true` for a satellite-served domain (this is what the admin
|
||||
dialog's uplink probe checks). Should a remote domain's auth host be
|
||||
misrouted to the satellite anyway, profile/avatar requests
|
||||
(`/auth/api/user/*`) are forwarded to the remote rather than answered from
|
||||
local storage — the satellite never takes writes for a remote domain.
|
||||
Proxied calls answer 503 when the upstream is unreachable.
|
||||
`/auth/api/settings` reports `remote: true` for a satellite-served domain.
|
||||
Should a remote domain's auth host be misrouted to the satellite anyway,
|
||||
profile/avatar requests (`/auth/api/user/*`) are forwarded to the upstream
|
||||
rather than answered from local storage — the satellite never takes writes
|
||||
for a remote domain.
|
||||
|
||||
Freshness hierarchy:
|
||||
|
||||
1. Changes made **through** the satellite: immediate (write-behind,
|
||||
optimistic eviction).
|
||||
2. Changes made **directly on the remote**: a sync event, ~1 network RTT.
|
||||
2. Changes made **directly on the upstream**: a sync event, ~1 network
|
||||
RTT.
|
||||
3. Channel down: the replica stays authoritative until `cache_ttl` past
|
||||
the disconnect (dead-peer detection is bounded by the ~10 s keepalive),
|
||||
then 503. Every reconnect starts from a fresh snapshot.
|
||||
|
||||
## The remote side
|
||||
## The upstream side
|
||||
|
||||
Strictly additive and RAM-only: `syncfeed` (a subscriber set fed by the
|
||||
commit hooks) and the token-gated `/auth/api/sync/ws` endpoint serving
|
||||
snapshot + live events and accepting `session_refresh` write-backs. With
|
||||
no satellites connected, the hooks are a no-op.
|
||||
Nearly additive: the link table, RAM-only registration offers and the
|
||||
`syncfeed` subscriber set fed by the commit hooks, plus the
|
||||
`/auth/api/sync/ws` endpoint doing the handshake, serving snapshots and
|
||||
live events, pushing domain configuration, and accepting
|
||||
`session_refresh` write-backs within the link's scope. With no satellites
|
||||
connected, the hooks are a no-op.
|
||||
|
||||
## Trust and caveats
|
||||
|
||||
- The satellite host holds a full copy of the remote's auth data in RAM
|
||||
(minus the OIDC key) — treat it as trusted as the remote.
|
||||
- Avatars are stored on the remote's disk; `user-info` from a replica
|
||||
reports no avatar URL.
|
||||
- OIDC sessions in a replica-backed `user-info` show the client UUID
|
||||
- The satellite host holds only its link's domains' sessions (with their
|
||||
identity contexts) and credentials in RAM — it learns the users that
|
||||
actually sign in to those domains, never the directory, and never the
|
||||
OIDC key. Still treat it as trusted: it is authoritative for session
|
||||
checks within `cache_ttl`.
|
||||
- Avatars are stored on the upstream's disk; profile data such as
|
||||
`user-info` is proxied to the upstream rather than answered locally.
|
||||
- OIDC sessions in a proxied `user-info` show the client UUID
|
||||
rather than its name (OIDC clients are not replicated).
|
||||
|
||||
---
|
||||
@@ -163,7 +223,7 @@ within one RTT instead of at TTL.
|
||||
- **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
|
||||
- **User simplicity.** One link registration per satellite; A needs
|
||||
deployment and cache-key discipline per frontend application.
|
||||
|
||||
## What it costs
|
||||
@@ -174,9 +234,10 @@ within one RTT instead of at TTL.
|
||||
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
|
||||
- A sync protocol (registration handshake, snapshot + live events +
|
||||
reconnect reconciliation, live domain pushes).
|
||||
- A trusted satellite host (its link's sessions and credentials in RAM).
|
||||
- Additive upstream code (sync endpoint + commit hooks), where A needs
|
||||
none.
|
||||
- Replica housekeeping (expiry sweeper, write-behind, optimistic
|
||||
eviction).
|
||||
|
||||
@@ -480,7 +480,6 @@ function createDomain() {
|
||||
origins: [],
|
||||
originValidation: [],
|
||||
wellKnownCheck: null,
|
||||
remote: null,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -496,8 +495,6 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -926,19 +923,9 @@ 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, ...(remote ? { remote } : {}) } })
|
||||
: apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins, remote } })
|
||||
? 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 } })
|
||||
req
|
||||
.then(() => {
|
||||
authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
||||
@@ -1022,6 +1009,7 @@ async function submitDialog() {
|
||||
@create-domain="createDomain"
|
||||
@open-domain="openDomain"
|
||||
@delete-domain="deleteDomain"
|
||||
@domains-changed="loadDomains"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
||||
import { formatDate, originDisplayEntries } from '@/utils/helpers'
|
||||
import AdminSatellites from './AdminSatellites.vue'
|
||||
|
||||
const props = defineProps({
|
||||
info: Object,
|
||||
@@ -14,7 +15,7 @@ const props = defineProps({
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'createDomain', 'openDomain', 'deleteDomain', 'navigateOut'])
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'createDomain', 'openDomain', 'deleteDomain', 'domainsChanged', 'navigateOut'])
|
||||
|
||||
// Template refs for navigation
|
||||
const orgActionsRef = ref(null)
|
||||
@@ -460,21 +461,28 @@ defineExpose({ focusFirstElement })
|
||||
<tr v-for="domain in sortedDomains" :key="domain.rp_id">
|
||||
<td class="perm-name-cell">
|
||||
<div class="perm-title">
|
||||
<a :href="'#domain:' + domain.rp_id" @click.prevent="$emit('openDomain', domain)">{{ domain.rp_name || domain.rp_id }}</a>
|
||||
<a v-if="!domain.remote" :href="'#domain:' + domain.rp_id" @click.prevent="$emit('openDomain', domain)">{{ domain.rp_name || domain.rp_id }}</a>
|
||||
<span v-else>{{ domain.rp_name || domain.rp_id }}</span>
|
||||
</div>
|
||||
<div class="perm-id-info">
|
||||
<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>
|
||||
<span v-if="domain.remote" class="id-text" title="Satellite-served — its configuration is pushed by the uplink and managed in the Satellites panel">🛰 via uplink</span>
|
||||
</div>
|
||||
</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="center">
|
||||
<button v-if="domain.rp_id !== currentRpId" @click="$emit('deleteDomain', domain)" class="icon-btn delete-icon" aria-label="Delete domain" title="Delete domain">❌</button>
|
||||
<button v-if="domain.rp_id !== currentRpId && !domain.remote" @click="$emit('deleteDomain', domain)" class="icon-btn delete-icon" aria-label="Delete domain" title="Delete domain">❌</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<AdminSatellites
|
||||
v-if="isMasterAdmin"
|
||||
:domains="domains || []"
|
||||
@domains-changed="$emit('domainsChanged')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
<script setup>
|
||||
// Satellites panel: both link roles of this instance.
|
||||
//
|
||||
// Upstream role ("Satellites" table): other paskia instances registered to
|
||||
// serve this instance's domains. Registration is online: mint a link URL
|
||||
// (RAM-only, single-use, 10 minutes), the satellite pastes it in its own
|
||||
// Satellites panel or runs `paskia satellite <url>`. A link's scope (the
|
||||
// domains it serves) is adjustable at any time — the new set is pushed
|
||||
// over the live link, no new registration. Revoking a link drops the
|
||||
// satellite's connection and its copy of the domains.
|
||||
//
|
||||
// Satellite role ("Uplinks" table): this instance serving other
|
||||
// instances' domains. The only local configuration is the link itself —
|
||||
// domains, origins and auth host arrive from the upstream and are
|
||||
// read-only here.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import AdminDialog from './dialogs/AdminDialog.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { apiJson } from 'paskia'
|
||||
|
||||
const props = defineProps({
|
||||
domains: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['domains-changed'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const links = ref([])
|
||||
const uplinks = ref([])
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
;[links.value, uplinks.value] = await Promise.all([
|
||||
apiJson('/auth/api/admin/satellites/', { method: 'GET' }),
|
||||
apiJson('/auth/api/admin/uplinks/', { method: 'GET' }),
|
||||
])
|
||||
} catch {
|
||||
// Master admin only section; leave lists empty on failure
|
||||
}
|
||||
}
|
||||
|
||||
// While an uplink is not connected, poll gently so status goes live
|
||||
// without a manual refresh (registration and reconnects included); after
|
||||
// minting a link URL, poll for a minute to catch the satellite appearing.
|
||||
let pollTimer = null
|
||||
let mintWatchUntil = 0
|
||||
function schedulePoll() {
|
||||
clearTimeout(pollTimer)
|
||||
const watching =
|
||||
uplinks.value.some(u => !u.connected) ||
|
||||
Date.now() < mintWatchUntil // a minted link's satellite may appear
|
||||
if (watching) {
|
||||
pollTimer = setTimeout(async () => {
|
||||
await load()
|
||||
schedulePoll()
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
schedulePoll()
|
||||
})
|
||||
onBeforeUnmount(() => clearTimeout(pollTimer))
|
||||
|
||||
async function refresh() {
|
||||
await load()
|
||||
schedulePoll()
|
||||
}
|
||||
|
||||
// Domains eligible for a link's scope: locally served, with an auth host
|
||||
// (satellite users' account pages live there).
|
||||
const eligibleDomains = computed(() =>
|
||||
props.domains.filter(d => !d.remote && d.auth_host)
|
||||
)
|
||||
|
||||
function copyText(value, label) {
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Link dialog (create an offer / adjust scope) ---
|
||||
const linkDialog = ref(null) // {id|null, selected:Set, minted:{url,expires_in}|null, busy, error}
|
||||
|
||||
function openLinkDialog(link) {
|
||||
linkDialog.value = {
|
||||
id: link?.id || null,
|
||||
selected: new Set(link?.rp_ids || []),
|
||||
minted: null,
|
||||
busy: false,
|
||||
error: '',
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScope(rpId) {
|
||||
// Replace the Set (in-place mutation is invisible to reactivity)
|
||||
const s = new Set(linkDialog.value.selected)
|
||||
if (s.has(rpId)) s.delete(rpId)
|
||||
else s.add(rpId)
|
||||
linkDialog.value.selected = s
|
||||
}
|
||||
|
||||
async function submitLinkDialog() {
|
||||
const d = linkDialog.value
|
||||
if (!d || d.busy) return
|
||||
if (d.minted) {
|
||||
// The URL is shown — Done dismisses
|
||||
linkDialog.value = null
|
||||
return
|
||||
}
|
||||
d.busy = true
|
||||
d.error = ''
|
||||
try {
|
||||
const rp_ids = [...d.selected]
|
||||
if (d.id) {
|
||||
await apiJson(`/auth/api/admin/satellites/${d.id}`, { method: 'PATCH', body: { rp_ids } })
|
||||
authStore.showMessage('Link scope updated — pushed to the satellite.', 'success', 2500)
|
||||
linkDialog.value = null
|
||||
} else {
|
||||
d.minted = await apiJson('/auth/api/admin/satellites/', { method: 'POST', body: { rp_ids } })
|
||||
mintWatchUntil = Date.now() + 60_000 // watch for the satellite registering
|
||||
}
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
d.error = e.message || 'Failed'
|
||||
} finally {
|
||||
d.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Uplink dialog (register from a pasted link URL) ---
|
||||
const uplinkDialog = ref(null) // {url, busy, error}
|
||||
|
||||
async function submitUplinkDialog() {
|
||||
const d = uplinkDialog.value
|
||||
if (!d || d.busy) return
|
||||
d.busy = true
|
||||
d.error = ''
|
||||
try {
|
||||
const result = await apiJson('/auth/api/admin/uplinks/', { method: 'POST', body: { url: d.url } })
|
||||
uplinkDialog.value = null
|
||||
const served = result.domains?.length ? result.domains.join(', ') : 'no domains yet'
|
||||
authStore.showMessage(`Linked to ${result.url} — serving ${served}.`, 'success', 4000)
|
||||
const rejected = Object.entries(result.rejected || {})
|
||||
if (rejected.length) {
|
||||
authStore.showMessage(
|
||||
`Rejected by configuration: ${rejected.map(([r, why]) => `${r} (${why})`).join(', ')}`,
|
||||
'error'
|
||||
)
|
||||
}
|
||||
await refresh()
|
||||
emit('domains-changed')
|
||||
} catch (e) {
|
||||
d.error = e.message || 'Registration failed'
|
||||
} finally {
|
||||
d.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Confirm dialog (revoke / unlink) ---
|
||||
const confirmDialog = ref(null) // {title, message, submitLabel, action, busy}
|
||||
|
||||
function askConfirm(title, message, submitLabel, action) {
|
||||
confirmDialog.value = { title, message, submitLabel, action, busy: false }
|
||||
}
|
||||
|
||||
async function submitConfirm() {
|
||||
const d = confirmDialog.value
|
||||
if (!d || d.busy) return
|
||||
d.busy = true
|
||||
try {
|
||||
await d.action()
|
||||
confirmDialog.value = null
|
||||
await refresh()
|
||||
emit('domains-changed')
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed', 'error')
|
||||
confirmDialog.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function revokeLink(link) {
|
||||
askConfirm(
|
||||
'Revoke satellite link',
|
||||
`Revoke the link for ${link.label || link.id}? The satellite disconnects immediately and its copy of ${(link.rp_ids || []).join(', ') || 'the domains'} is dropped.`,
|
||||
'Revoke',
|
||||
async () => {
|
||||
await apiJson(`/auth/api/admin/satellites/${link.id}`, { method: 'DELETE' })
|
||||
authStore.showMessage('Satellite link revoked.', 'success', 2500)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function unlinkUplink(uplink) {
|
||||
askConfirm(
|
||||
'Unlink uplink',
|
||||
`Unlink ${uplink.url}? This instance stops serving ${(uplink.domains || []).join(', ') || 'its domains'} — the domains and their local replica are removed.`,
|
||||
'Unlink',
|
||||
async () => {
|
||||
await apiJson(`/auth/api/admin/uplinks/${uplink.id}`, { method: 'DELETE' })
|
||||
authStore.showMessage('Uplink removed.', 'success', 2500)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function uplinkStatus(u) {
|
||||
if (u.connected) return { icon: '●', text: 'connected', cls: 'status-ok' }
|
||||
if (u.available) return { icon: '◐', text: 'offline — answering from cache', cls: 'status-warn' }
|
||||
return { icon: '○', text: 'offline', cls: 'status-bad' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="satellites-section">
|
||||
<div class="section-header">
|
||||
<h2>Satellites</h2>
|
||||
<p class="section-description">
|
||||
A satellite is another paskia instance serving this instance's domains next to the apps that need
|
||||
them — session checks answer locally in a millisecond from a RAM replica kept in sync over a live
|
||||
link. Registration is online: mint a link URL, then paste it in the satellite's own Satellites
|
||||
panel or run <code>paskia satellite <url></code> on the satellite host.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<table v-if="links.length" class="org-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Satellite</th>
|
||||
<th>Serves domains</th>
|
||||
<th class="center">Status</th>
|
||||
<th class="center"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="link in links" :key="link.id">
|
||||
<td class="perm-name-cell">
|
||||
<div class="perm-title">{{ link.label || 'satellite' }}</div>
|
||||
<div class="perm-id-info"><span class="id-text">{{ link.id }}</span><span class="muted">·</span><span>{{ formatDate(link.created) }}</span></div>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="link.rp_ids.length">{{ link.rp_ids.join(', ') }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
<div v-if="Object.keys(link.rejected || {}).length" class="small error">
|
||||
rejected there: {{ Object.entries(link.rejected).map(([r, why]) => `${r} (${why})`).join(', ') }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span v-if="link.connected" class="status-ok" title="The satellite is connected now">●</span>
|
||||
<span v-else class="muted" title="Not connected">○</span>
|
||||
</td>
|
||||
<td class="center">
|
||||
<button type="button" class="icon-btn" @click="openLinkDialog(link)" aria-label="Adjust scope" title="Adjust which domains it serves">✏️</button>
|
||||
<button type="button" class="icon-btn delete-icon" @click="revokeLink(link)" aria-label="Revoke link" title="Revoke — the satellite disconnects and unlinks">❌</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="small muted">No satellites registered.</p>
|
||||
<div class="actions">
|
||||
<button type="button" :disabled="!eligibleDomains.length" @click="openLinkDialog(null)">+ New satellite link</button>
|
||||
<span v-if="!eligibleDomains.length" class="small muted">A domain with an auth host is required first.</span>
|
||||
</div>
|
||||
|
||||
<h3 class="uplinks-title">Uplinks <span class="muted uplinks-hint">— this instance as the satellite</span></h3>
|
||||
<table v-if="uplinks.length" class="org-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Upstream</th>
|
||||
<th>Serving domains</th>
|
||||
<th class="center">Status</th>
|
||||
<th class="center"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="uplink in uplinks" :key="uplink.id">
|
||||
<td class="perm-name-cell">
|
||||
<div class="perm-title">{{ uplink.url }}</div>
|
||||
<div class="perm-id-info"><span class="id-text">{{ uplink.id }}</span><span class="muted">·</span><span>{{ formatDate(uplink.created) }}</span></div>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="uplink.domains.length">{{ uplink.domains.join(', ') }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span :class="uplinkStatus(uplink).cls" :title="uplinkStatus(uplink).text">{{ uplinkStatus(uplink).icon }}</span>
|
||||
</td>
|
||||
<td class="center">
|
||||
<button type="button" class="icon-btn delete-icon" @click="unlinkUplink(uplink)" aria-label="Unlink" title="Unlink — stop serving its domains">❌</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="actions">
|
||||
<button type="button" @click="uplinkDialog = { url: '', busy: false, error: '' }">+ Add uplink</button>
|
||||
</div>
|
||||
|
||||
<AdminDialog
|
||||
v-if="linkDialog"
|
||||
:title="linkDialog.id ? 'Satellite link scope' : 'New satellite link'"
|
||||
:busy="linkDialog.busy"
|
||||
:error="linkDialog.error"
|
||||
:submit-label="linkDialog.minted ? 'Done' : linkDialog.id ? 'Save' : 'Generate link URL'"
|
||||
@submit="submitLinkDialog"
|
||||
@close="linkDialog = null"
|
||||
>
|
||||
<template v-if="!linkDialog.minted">
|
||||
<p class="small muted">
|
||||
Domains this satellite serves. Adjustable later — the new set is pushed over the live link,
|
||||
no new registration.
|
||||
</p>
|
||||
<div class="scope-list">
|
||||
<label v-for="d in eligibleDomains" :key="d.rp_id" class="scope-row">
|
||||
<input type="checkbox" :checked="linkDialog.selected.has(d.rp_id)" @change="toggleScope(d.rp_id)" />
|
||||
<span>{{ d.rp_id }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="small muted">
|
||||
The link URL — hand it to the satellite (its Satellites panel, or
|
||||
<code>paskia satellite <url></code>). Valid for {{ Math.round(linkDialog.minted.expires_in / 60) }} minutes,
|
||||
single-use, shown once:
|
||||
</p>
|
||||
<pre class="wellknown-doc" title="Click to copy" tabindex="0" @click="copyText(linkDialog.minted.url, 'Link URL')" @keydown.enter.prevent="copyText(linkDialog.minted.url, 'Link URL')">{{ linkDialog.minted.url }}</pre>
|
||||
</template>
|
||||
</AdminDialog>
|
||||
|
||||
<AdminDialog
|
||||
v-if="uplinkDialog"
|
||||
title="Add uplink"
|
||||
:busy="uplinkDialog.busy"
|
||||
:error="uplinkDialog.error"
|
||||
submit-label="Register"
|
||||
:submit-disabled="!uplinkDialog.url.trim()"
|
||||
@submit="submitUplinkDialog"
|
||||
@close="uplinkDialog = null"
|
||||
>
|
||||
<label>Link URL
|
||||
<input
|
||||
v-model="uplinkDialog.url"
|
||||
placeholder="wss://alpha.bravo.charlie.delta.echo@auth.example.com"
|
||||
autocomplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<p class="small muted">
|
||||
Minted on the upstream (its Satellites panel → New satellite link). Registration runs now:
|
||||
the domains it covers appear here and in the domains list, managed by the upstream.
|
||||
</p>
|
||||
</AdminDialog>
|
||||
|
||||
<AdminDialog
|
||||
v-if="confirmDialog"
|
||||
:title="confirmDialog.title"
|
||||
:busy="confirmDialog.busy"
|
||||
:submit-label="confirmDialog.submitLabel"
|
||||
@submit="submitConfirm"
|
||||
@close="confirmDialog = null"
|
||||
>
|
||||
<p class="small">{{ confirmDialog.message }}</p>
|
||||
</AdminDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.satellites-section { margin-top: var(--space-2xl); display: flex; flex-direction: column; gap: var(--space-sm); }
|
||||
.satellites-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-xs); }
|
||||
.satellites-section .actions { display: flex; align-items: center; gap: var(--space-sm); }
|
||||
.uplinks-title { margin: var(--space-lg) 0 var(--space-xs); font-size: 1.05rem; }
|
||||
.uplinks-hint { font-weight: 400; font-size: 0.85rem; }
|
||||
.perm-name-cell { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.perm-title { font-weight: 600; color: var(--color-heading); }
|
||||
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
.id-text { font-family: var(--font-mono, monospace); }
|
||||
.org-table .center { width: 4rem; min-width: 4rem; text-align: center; }
|
||||
.status-ok { color: var(--color-success, #16a34a); }
|
||||
.status-warn { color: var(--color-warning, #d97706); }
|
||||
.status-bad { color: var(--color-text-muted); }
|
||||
.scope-list { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.scope-row { display: flex; flex-direction: row; align-items: center; gap: var(--space-xs); }
|
||||
.scope-row input { width: auto; }
|
||||
.wellknown-doc { margin: 0; padding: var(--space-xs) var(--space-sm); font-size: 0.8rem; background: var(--color-bg-soft, rgba(127,127,127,0.08)); border-radius: 4px; white-space: pre-wrap; word-break: break-all; cursor: pointer; }
|
||||
.error { color: var(--color-error); }
|
||||
</style>
|
||||
@@ -18,71 +18,6 @@ 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
|
||||
)
|
||||
|
||||
// --- Uplink verification ---
|
||||
// Fetch the remote's /auth/api/settings (CORS-open), like the origin
|
||||
// connectivity probes: proves reachability, that the peer is a paskia
|
||||
// instance, and that it serves this very domain authoritatively — not as
|
||||
// a satellite itself (nested satellites are not supported). Results are
|
||||
// warnings, never submit blockers (the remote may not be deployed yet).
|
||||
const uplinkCheck = ref(null) // null|validating|valid|unreachable|mismatch|nested
|
||||
let uplinkTimer = null
|
||||
let uplinkSeq = 0
|
||||
watch(
|
||||
() => [props.dialog.data?.remote?.url, dialogRpId.value],
|
||||
() => {
|
||||
clearTimeout(uplinkTimer)
|
||||
uplinkCheck.value = null
|
||||
const url = props.dialog.data?.remote?.url?.trim().replace(/\/+$/, '')
|
||||
if (!url || remoteUrlInvalid.value) return
|
||||
uplinkTimer = setTimeout(() => checkUplink(url), 600)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function checkUplink(url) {
|
||||
const seq = ++uplinkSeq
|
||||
uplinkCheck.value = 'validating'
|
||||
try {
|
||||
const response = await fetch(`${url}/auth/api/settings`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (seq !== uplinkSeq) return // url/rp-id changed while fetching
|
||||
if (!response.ok) throw new Error('not ok')
|
||||
const data = await response.json()
|
||||
uplinkCheck.value =
|
||||
data.rp_id !== dialogRpId.value ? 'mismatch' : data.remote ? 'nested' : 'valid'
|
||||
} catch {
|
||||
if (seq === uplinkSeq) uplinkCheck.value = 'unreachable'
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -96,8 +31,6 @@ 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
|
||||
})
|
||||
|
||||
@@ -444,7 +377,6 @@ function onDocumentClick(e) {
|
||||
onMounted(() => document.addEventListener('click', onDocumentClick))
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
clearTimeout(uplinkTimer)
|
||||
for (const t of originValidateTimers.values()) clearTimeout(t)
|
||||
originValidateTimers.clear()
|
||||
})
|
||||
@@ -583,38 +515,6 @@ function onRemoveOrigin(i) {
|
||||
<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>
|
||||
</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-else-if="uplinkCheck === 'validating'" class="small muted">Checking the uplink…</p>
|
||||
<p v-else-if="uplinkCheck === 'valid'" class="small">✓ Uplink verified — the remote serves this domain authoritatively.</p>
|
||||
<p v-else-if="uplinkCheck === 'unreachable'" class="small">The remote is unreachable — the uplink cannot be verified. Save anyway only if it is not deployed yet.</p>
|
||||
<p v-else-if="uplinkCheck === 'mismatch'" class="small error">The remote does not serve {{ dialogRpId }} — check the URL. Connect directly to the authoritative instance.</p>
|
||||
<p v-else-if="uplinkCheck === 'nested'" class="small error">The remote is itself a satellite for this domain — nested satellites are not supported. Point directly at the authoritative instance.</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>
|
||||
</template>
|
||||
|
||||
@@ -640,7 +540,4 @@ 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; }
|
||||
</style>
|
||||
+84
-1
@@ -9,6 +9,7 @@ from fastapi_vue import env, server, teleport
|
||||
from fastapi_vue.logging import setup_logging
|
||||
from kanta import Kanta
|
||||
|
||||
import paskia.db.operations as ops_db
|
||||
from paskia.db import legacy
|
||||
from paskia.db.bootstrap import bootstrap, log_reset_link
|
||||
from paskia.db.paths import db_file_path
|
||||
@@ -28,6 +29,7 @@ EPILOG = """\
|
||||
Examples:
|
||||
paskia init example.com "Example Corporation"
|
||||
paskia migrate example.com
|
||||
paskia satellite wss://key@auth.example.com
|
||||
paskia --listen 4402 --save
|
||||
paskia
|
||||
"""
|
||||
@@ -190,6 +192,70 @@ def cmd_migrate(args: argparse.Namespace) -> None:
|
||||
print(f"✅ {action} {db_file_path()} (domains: {', '.join(rp_ids)})")
|
||||
|
||||
|
||||
def cmd_satellite(args: argparse.Namespace) -> None:
|
||||
"""Register this instance as a satellite using an upstream's link URL.
|
||||
|
||||
The one setup step for a satellite-only installation: with no database
|
||||
yet, one is created first (default localhost domain and admin
|
||||
bootstrap, like 'paskia init'). The registration handshake runs right
|
||||
here — the URL's initial key is spent, the permanent link credential
|
||||
and the pushed domains are stored. Afterwards 'paskia' serves the
|
||||
linked domains.
|
||||
"""
|
||||
from paskia import satellite # noqa: PLC0415 (heavy deps: fastapi, websockets)
|
||||
|
||||
db_path = db_file_path()
|
||||
fresh = not db_path.exists()
|
||||
# A Kanta of our own, bound to the global db object so db operations
|
||||
# (register_uplink's commits) write through it — like lifecycle's, but
|
||||
# constructed here so the path follows the current directory.
|
||||
kanta = Kanta(str(db_path), ops_db._db)
|
||||
ops_db._db._store = kanta
|
||||
result = {}
|
||||
if fresh:
|
||||
# Satellite-only bootstrap: default config (localhost domain) and
|
||||
# the standard admin seed.
|
||||
@kanta.bootstrap
|
||||
def _bootstrap(data: DB) -> None:
|
||||
result["passphrase"] = bootstrap(data, config=Config())
|
||||
|
||||
async def _register():
|
||||
try:
|
||||
await kanta.open()
|
||||
except Exception as e:
|
||||
raise SystemExit(
|
||||
f"Could not open {db_path} (is a server running?): {e}"
|
||||
) from e
|
||||
try:
|
||||
return await satellite.register_uplink(args.url)
|
||||
finally:
|
||||
await kanta.close()
|
||||
|
||||
try:
|
||||
info = asyncio.run(_register())
|
||||
except (ValueError, RuntimeError) as e:
|
||||
raise SystemExit(f"Registration failed: {e}") from e
|
||||
|
||||
domains_list = (
|
||||
", ".join(info["domains"])
|
||||
or "(none yet — the upstream can scope the link later)"
|
||||
)
|
||||
print(f"🛰 Linked to {info['url']} — serving: {domains_list}")
|
||||
for rp_id, reason in (info.get("rejected") or {}).items():
|
||||
print(f"⚠️ Domain {rp_id} rejected: {reason}")
|
||||
# A successful CLI run means the server was not holding the database
|
||||
# open — the linked domains start serving on the next start.
|
||||
print("Start the server to serve them: paskia")
|
||||
if fresh:
|
||||
print("Created the database.")
|
||||
configure_domains(listen=None)
|
||||
registry = build_registry(_load_stored_config(db_path))
|
||||
log_reset_link(
|
||||
registry.get("localhost").reset_link_url(result["passphrase"]),
|
||||
"Admin registration (for the local admin interface):",
|
||||
)
|
||||
|
||||
|
||||
def cmd_serve(args: argparse.Namespace) -> None:
|
||||
"""Open the combined database and serve all configured domains."""
|
||||
db_path = db_file_path()
|
||||
@@ -255,7 +321,7 @@ def main():
|
||||
"--save",
|
||||
action="store_true",
|
||||
help="Save --listen to the database for future runs. "
|
||||
"Use --listen \"\" to clear the stored endpoints.",
|
||||
'Use --listen "" to clear the stored endpoints.',
|
||||
)
|
||||
|
||||
init_parser = argparse.ArgumentParser(
|
||||
@@ -297,11 +363,28 @@ def main():
|
||||
"in the current directory is selected automatically.",
|
||||
)
|
||||
|
||||
satellite_parser = argparse.ArgumentParser(
|
||||
prog="paskia satellite",
|
||||
description="Register this instance as a satellite of an upstream "
|
||||
"paskia instance, using the link URL minted in the upstream's admin "
|
||||
"interface (Satellites panel). The registration handshake runs "
|
||||
"immediately; afterwards 'paskia' serves the linked domains. With no "
|
||||
"database yet, one is created first — this is the whole setup of a "
|
||||
"satellite-only installation.",
|
||||
)
|
||||
satellite_parser.add_argument(
|
||||
"url",
|
||||
help="The link URL (wss://<key>@<auth-host>) from the upstream's "
|
||||
"Satellites panel. Single-use; valid for 10 minutes from minting.",
|
||||
)
|
||||
|
||||
argv = sys.argv[1:]
|
||||
if argv and argv[0] == "init":
|
||||
cmd_init(init_parser.parse_args(argv[1:]))
|
||||
elif argv and argv[0] == "migrate":
|
||||
cmd_migrate(migrate_parser.parse_args(argv[1:]))
|
||||
elif argv and argv[0] == "satellite":
|
||||
cmd_satellite(satellite_parser.parse_args(argv[1:]))
|
||||
else:
|
||||
cmd_serve(parser.parse_args(argv))
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from paskia.db.bootstrap import bootstrap
|
||||
from paskia.db.operations import (
|
||||
add_permission_to_org,
|
||||
add_permission_to_role,
|
||||
apply_uplink_domains,
|
||||
create_credential,
|
||||
create_credential_session,
|
||||
create_domain,
|
||||
@@ -31,6 +32,8 @@ from paskia.db.operations import (
|
||||
create_permission,
|
||||
create_reset_token,
|
||||
create_role,
|
||||
create_satellite_link,
|
||||
create_uplink,
|
||||
create_user,
|
||||
delete_credential,
|
||||
delete_domain,
|
||||
@@ -38,8 +41,10 @@ from paskia.db.operations import (
|
||||
delete_org,
|
||||
delete_permission,
|
||||
delete_role,
|
||||
delete_satellite_link,
|
||||
delete_session,
|
||||
delete_sessions_for_user,
|
||||
delete_uplink,
|
||||
delete_user,
|
||||
is_username_taken,
|
||||
login,
|
||||
@@ -47,6 +52,7 @@ from paskia.db.operations import (
|
||||
remove_permission_from_org,
|
||||
remove_permission_from_role,
|
||||
reset_oid_client_secret,
|
||||
set_satellite_link_scope,
|
||||
update_credential_sign_count,
|
||||
update_domain,
|
||||
update_oid_client,
|
||||
@@ -70,8 +76,10 @@ from paskia.db.structs import (
|
||||
RemoteConfig,
|
||||
ResetToken,
|
||||
Role,
|
||||
SatelliteLink,
|
||||
Session,
|
||||
SessionContext,
|
||||
Uplink,
|
||||
User,
|
||||
)
|
||||
|
||||
@@ -94,8 +102,10 @@ __all__ = [
|
||||
"RemoteConfig",
|
||||
"ResetToken",
|
||||
"Role",
|
||||
"SatelliteLink",
|
||||
"Session",
|
||||
"SessionContext",
|
||||
"Uplink",
|
||||
"User",
|
||||
# Instance
|
||||
"data",
|
||||
@@ -103,6 +113,7 @@ __all__ = [
|
||||
# Write ops
|
||||
"add_permission_to_org",
|
||||
"add_permission_to_role",
|
||||
"apply_uplink_domains",
|
||||
"bootstrap",
|
||||
"create_credential",
|
||||
"create_credential_session",
|
||||
@@ -111,19 +122,24 @@ __all__ = [
|
||||
"create_domain",
|
||||
"create_reset_token",
|
||||
"create_role",
|
||||
"create_satellite_link",
|
||||
"create_uplink",
|
||||
"create_user",
|
||||
"delete_credential",
|
||||
"delete_org",
|
||||
"delete_permission",
|
||||
"delete_domain",
|
||||
"delete_role",
|
||||
"delete_satellite_link",
|
||||
"delete_session",
|
||||
"delete_sessions_for_user",
|
||||
"delete_uplink",
|
||||
"delete_user",
|
||||
"login",
|
||||
"oidc_login",
|
||||
"remove_permission_from_org",
|
||||
"remove_permission_from_role",
|
||||
"set_satellite_link_scope",
|
||||
"update_credential_sign_count",
|
||||
"update_org_name",
|
||||
"update_permission",
|
||||
|
||||
@@ -86,8 +86,9 @@ def _resolve_uuid_label(
|
||||
return None
|
||||
|
||||
|
||||
# The OIDC signing key is stored at oidc.key.
|
||||
_OIDC_KEY_PATH = re.compile(r"^oidc\.key$")
|
||||
# The OIDC signing key is stored at oidc.key; satellite link key material
|
||||
# at uplinks/satellite_links.<id>.key.
|
||||
_SECRET_KEY_PATH = re.compile(r"^(oidc\.key|(uplinks|satellite_links)\.[^.]+\.key)$")
|
||||
|
||||
|
||||
@kanta.logfmt
|
||||
@@ -98,10 +99,10 @@ def format_log_uuid(
|
||||
current: Annotated[dict, "post"] | None = None,
|
||||
) -> Optional[str]: # noqa: UP045
|
||||
"""Format UUID values/keys/actor labels and censor secrets in transaction logs."""
|
||||
# Censor sensitive OIDC key material regardless of value type, but only
|
||||
# Censor sensitive key material regardless of value type, but only
|
||||
# when formatting the value: path components are passed with the component
|
||||
# itself as value and must stay visible ("oidc.key = <hidden>").
|
||||
if _OIDC_KEY_PATH.fullmatch(path) and value != "key":
|
||||
if _SECRET_KEY_PATH.fullmatch(path) and value != "key":
|
||||
return "<hidden>"
|
||||
|
||||
if not isinstance(value, str):
|
||||
|
||||
+91
-6
@@ -23,11 +23,12 @@ from paskia.db.structs import (
|
||||
Org,
|
||||
OriginEntry,
|
||||
Permission,
|
||||
RemoteConfig,
|
||||
ResetToken,
|
||||
Role,
|
||||
SatelliteLink,
|
||||
Session,
|
||||
SessionContext,
|
||||
Uplink,
|
||||
User,
|
||||
)
|
||||
from paskia.util.crypto import hash_secret
|
||||
@@ -563,6 +564,79 @@ def create_reset_token(
|
||||
return passphrase
|
||||
|
||||
|
||||
def create_satellite_link(
|
||||
link_id: str,
|
||||
rp_ids: list[str],
|
||||
key: bytes,
|
||||
label: str = "",
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Store a freshly registered satellite link under its handshake id.
|
||||
|
||||
Called by the registration handshake once the satellite has confirmed
|
||||
(its ``ready`` frame): the permanent credential ``key`` exists only
|
||||
from this point.
|
||||
"""
|
||||
with _transaction("satellite:register", ctx):
|
||||
_db.satellite_links[link_id] = SatelliteLink(
|
||||
rp_ids=list(rp_ids), key=key, label=label, created=datetime.now(UTC)
|
||||
)
|
||||
|
||||
|
||||
def set_satellite_link_scope(
|
||||
link_id: str, rp_ids: list[str], *, ctx: SessionContext | None = None
|
||||
) -> None:
|
||||
"""Adjust a link's scope; the new domain set is pushed over the live link."""
|
||||
link = _db.satellite_links.get(link_id)
|
||||
if link is None:
|
||||
raise ValueError("Satellite link not found")
|
||||
with _transaction("admin:satellite_link_scope", ctx):
|
||||
link.rp_ids = list(rp_ids)
|
||||
|
||||
|
||||
def delete_satellite_link(link_id: str, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Revoke a satellite link; its live connections are dropped by the caller."""
|
||||
if link_id not in _db.satellite_links:
|
||||
raise ValueError("Satellite link not found")
|
||||
with _transaction("admin:delete_satellite_link", ctx):
|
||||
del _db.satellite_links[link_id]
|
||||
|
||||
|
||||
def create_uplink(
|
||||
uplink_id: str, uplink: Uplink, domains: dict[str, DomainConfig]
|
||||
) -> None:
|
||||
"""Registration commit on the satellite: store the uplink and the
|
||||
domain set the upstream pushed (already validated by the caller)."""
|
||||
with _transaction("satellite:register"):
|
||||
_db.uplinks[uplink_id] = uplink
|
||||
uplink.uplink_id = uplink_id
|
||||
_db.config.domains.update(domains)
|
||||
|
||||
|
||||
def apply_uplink_domains(uplink_id: str, domains: dict[str, DomainConfig]) -> None:
|
||||
"""Replace the domain set of an uplink with the upstream's push.
|
||||
|
||||
``domains`` is the uplink's full accepted set (additions, updates and
|
||||
removals relative to what the config currently holds for the link);
|
||||
validation happens in the caller (satellite.apply_uplink_domains).
|
||||
"""
|
||||
with _transaction("satellite:domains"):
|
||||
for rp_id, domain in list(_db.config.domains.items()):
|
||||
if domain.remote is not None and domain.remote.link == uplink_id:
|
||||
del _db.config.domains[rp_id]
|
||||
_db.config.domains.update(domains)
|
||||
|
||||
|
||||
def delete_uplink(uplink_id: str) -> None:
|
||||
"""Unlink an uplink: drop it and every domain it serves."""
|
||||
with _transaction("satellite:unlink"):
|
||||
_db.uplinks.pop(uplink_id, None)
|
||||
for rp_id, domain in list(_db.config.domains.items()):
|
||||
if domain.remote is not None and domain.remote.link == uplink_id:
|
||||
del _db.config.domains[rp_id]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Composite operations (used by app code)
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -737,28 +811,39 @@ def update_domain(
|
||||
*,
|
||||
rp_name: str | None,
|
||||
origins: dict[str, bool | OriginEntry],
|
||||
remote: RemoteConfig | None = None,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Replace a domain's rp_name, origins table and remote (wholesale).
|
||||
"""Replace a domain's rp_name and origins table (wholesale).
|
||||
|
||||
The rp-id itself is immutable: credentials are stamped with it, so
|
||||
changing it would orphan them — delete and recreate the domain instead.
|
||||
The caller must validate the resulting combined configuration.
|
||||
The caller must validate the resulting combined configuration. Remote
|
||||
(satellite-served) domains are uplink-managed and never pass through
|
||||
here.
|
||||
"""
|
||||
domain = _db.config.domains.get(rp_id)
|
||||
if domain is None:
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
if domain.remote is not None:
|
||||
raise ValueError(
|
||||
f"Domain '{rp_id}' is served from an uplink — managed there, "
|
||||
"not by local edits"
|
||||
)
|
||||
with _transaction("admin:update_domain", ctx):
|
||||
domain.rp_name = rp_name
|
||||
domain.origins = origins
|
||||
domain.remote = remote
|
||||
|
||||
|
||||
def delete_domain(rp_id: str, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete a domain. Refused for the last domain or while credentials remain."""
|
||||
"""Delete a domain. Refused for the last domain, while credentials
|
||||
remain, or for remote domains (those are uplink-managed — unlink or
|
||||
have the upstream narrow the link's scope)."""
|
||||
if rp_id not in _db.config.domains:
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
if _db.config.domains[rp_id].remote is not None:
|
||||
raise ValueError(
|
||||
f"Domain '{rp_id}' is served from an uplink — unlink it instead"
|
||||
)
|
||||
if len(_db.config.domains) <= 1:
|
||||
raise ValueError("Cannot delete the last remaining domain")
|
||||
if any(c.rp_id == rp_id for c in _db.credentials.values()):
|
||||
|
||||
+67
-12
@@ -428,15 +428,14 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
def store(self, last_seen: datetime) -> None:
|
||||
"""Store this session in the database and record a visit.
|
||||
|
||||
Updates user.last_seen and user.visits. Must be called inside
|
||||
a database transaction.
|
||||
Updates user.last_seen and user.visits (instance-local stats, not
|
||||
replicated). Must be called inside a database transaction.
|
||||
"""
|
||||
_data = db.data()
|
||||
_data.sessions[self.key] = self
|
||||
_data.users[self.user_uuid].last_seen = last_seen
|
||||
_data.users[self.user_uuid].visits += 1
|
||||
syncfeed.emit("sessions", self.key, self)
|
||||
syncfeed.emit("users", str(self.user_uuid), _data.users[self.user_uuid])
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Delete this session from the database. Must be called inside a transaction."""
|
||||
@@ -566,6 +565,52 @@ class ResetToken(msgspec.Struct, dict=True):
|
||||
return token, passphrase
|
||||
|
||||
|
||||
class SatelliteLink(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""A registered satellite instance — the upstream side of a link.
|
||||
|
||||
Created by the online registration handshake (fastapi/sync.py): the
|
||||
initial key that bootstrapped the link was RAM-only and short-lived;
|
||||
this permanent credential is created during registration. ``key`` is
|
||||
the cryptographic material for talking to the peer (placeholder: random
|
||||
parts exchanged at registration; the final version derives channel
|
||||
encryption from the handshake instead of sending key material).
|
||||
|
||||
``rp_ids`` is the link's scope: the domains the satellite serves.
|
||||
Adjustable at any time and pushed over the live link — no new
|
||||
cryptographic setup is needed to change it. The sync feed carries only
|
||||
in-scope sessions (each with its identity context) and credentials;
|
||||
the user graph never replicates. Instance-local: never replicated.
|
||||
"""
|
||||
|
||||
created: datetime
|
||||
rp_ids: list[str] = [] # the link's scope — adjust freely, pushed live
|
||||
key: bytes = b"" # shared cryptographic material from the handshake
|
||||
label: str = "" # the satellite's self-reported name (informational)
|
||||
|
||||
|
||||
class Uplink(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""The satellite side of a link: the upstream this instance follows.
|
||||
|
||||
Created by the same registration handshake; ``key`` is the shared
|
||||
cryptographic material. ``url`` is the upstream's auth host base
|
||||
(http(s)); the sync channel connects to its ``/auth/api/sync/ws``.
|
||||
The domains this uplink serves live in ``config.domains`` marked with
|
||||
``RemoteConfig(link=<uplink id>)`` — pushed and maintained by the
|
||||
upstream, never edited locally. ``uplink_id`` is the dict key, set on
|
||||
load.
|
||||
"""
|
||||
|
||||
url: str # upstream auth host base, e.g. "https://auth.example.com"
|
||||
created: datetime
|
||||
key: bytes = b"" # shared cryptographic material from the handshake
|
||||
cache_ttl: int = 60 # staleness bound (seconds) while the channel is down
|
||||
refresh_interval: int = 300 # full re-sync cadence (seconds)
|
||||
|
||||
def __post_init__(self):
|
||||
if not hasattr(self, "uplink_id"):
|
||||
self.uplink_id: str = ""
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# OIDC Provider structures
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -643,18 +688,18 @@ class OriginEntry(msgspec.Struct, omit_defaults=True):
|
||||
|
||||
|
||||
class RemoteConfig(msgspec.Struct, omit_defaults=True):
|
||||
"""Upstream paskia instance backing a remote (satellite-served) domain.
|
||||
"""Marks a domain as satellite-served via an uplink (a ``db.uplinks``
|
||||
dict key).
|
||||
|
||||
The satellite keeps a RAM-only read replica of the remote's tables and
|
||||
The satellite keeps a RAM-only read replica fed by the uplink and
|
||||
answers session-dependent reads locally; mutations are forwarded. The
|
||||
token authenticates the sync channel (the remote reads accepted tokens
|
||||
from its PASKIA_SYNC_TOKENS environment, never from its database).
|
||||
domain's rp-name, origins and auth host are pushed by the upstream and
|
||||
applied verbatim — the satellite only validates them against its local
|
||||
domains. Local edits are rejected; removing the domain means unlinking
|
||||
the uplink or the upstream narrowing the link's scope.
|
||||
"""
|
||||
|
||||
url: str # e.g. "https://auth.example.com"
|
||||
token: str = ""
|
||||
cache_ttl: int = 60 # staleness bound (seconds) while the sync channel is down
|
||||
refresh_interval: int = 300 # full re-sync cadence (seconds)
|
||||
link: str = ""
|
||||
|
||||
|
||||
class DomainConfig(msgspec.Struct, omit_defaults=True):
|
||||
@@ -710,6 +755,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
credentials: dict[UUID, Credential] = {}
|
||||
sessions: dict[str, Session] = {}
|
||||
reset_tokens: dict[str, ResetToken] = {}
|
||||
satellite_links: dict[str, SatelliteLink] = {} # upstream side of links
|
||||
uplinks: dict[str, Uplink] = {} # satellite side of links
|
||||
# OIDC provider data: one instance-global provider (single signing key
|
||||
# and client set); each request Host acts as an issuer alias.
|
||||
oidc: OIDC = msgspec.field(default_factory=OIDC)
|
||||
@@ -732,6 +779,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
session.key = key
|
||||
for key, token in self.reset_tokens.items():
|
||||
token.key = key
|
||||
for uplink_id, uplink in self.uplinks.items():
|
||||
uplink.uplink_id = uplink_id
|
||||
# OIDC
|
||||
for uuid, client in self.oidc.clients.items():
|
||||
client.uuid = uuid
|
||||
@@ -749,13 +798,19 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
Returns:
|
||||
SessionContext if valid, None if session not found, expired, or host mismatch
|
||||
"""
|
||||
|
||||
key = hash_secret("cookie", session_secret)
|
||||
try:
|
||||
s = self.sessions[key]
|
||||
except KeyError:
|
||||
return None
|
||||
return self.session_ctx_for(s, host)
|
||||
|
||||
def session_ctx_for(self, s: Session, host: str | None) -> SessionContext | None:
|
||||
"""Full session context for a session object: host binding, then the
|
||||
user/role/org/credential join with effective permissions. Sync
|
||||
replicas override this to source the identity from the per-session
|
||||
contexts the master pushes instead of local user tables.
|
||||
"""
|
||||
# OIDC sessions (client_uuid set) are not valid for cookie-based auth
|
||||
if s.client_uuid is not None:
|
||||
return None
|
||||
|
||||
+4
-72
@@ -14,7 +14,6 @@ domain are in-domain, entries outside it are related.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import logging
|
||||
import os
|
||||
@@ -213,10 +212,6 @@ def validate_config(
|
||||
|
||||
for rp_id, domain in config.domains.items():
|
||||
hostutil.validate_rp_id(rp_id)
|
||||
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")
|
||||
|
||||
domain_auth_host: str | None = None
|
||||
related_count = 0
|
||||
@@ -299,21 +294,6 @@ def validate_config(
|
||||
f"domain '{related_hosts[hn]}'"
|
||||
)
|
||||
|
||||
# Several domains may share one remote instance (and its replica), but
|
||||
# then its settings must be identical — a conflict would otherwise be
|
||||
# resolved silently and arbitrarily.
|
||||
remotes: dict[str, tuple[str, RemoteConfig]] = {}
|
||||
for rp_id, domain in config.domains.items():
|
||||
if domain.remote is None:
|
||||
continue
|
||||
prior = remotes.setdefault(domain.remote.url, (rp_id, domain.remote))
|
||||
if prior[1] != domain.remote:
|
||||
raise ValueError(
|
||||
f"Domains '{prior[0]}' and '{rp_id}' share remote "
|
||||
f"'{domain.remote.url}' with conflicting settings — a shared "
|
||||
"remote must have one configuration"
|
||||
)
|
||||
|
||||
|
||||
def sanitize_config(
|
||||
config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP
|
||||
@@ -332,7 +312,6 @@ def sanitize_config(
|
||||
warnings.append(msg)
|
||||
|
||||
domains: dict[str, DomainConfig] = {}
|
||||
remotes_seen: dict[str, RemoteConfig] = {}
|
||||
for rp_id, domain in config.domains.items():
|
||||
try:
|
||||
hostutil.validate_rp_id(rp_id)
|
||||
@@ -395,19 +374,13 @@ def sanitize_config(
|
||||
origins[key] = props
|
||||
|
||||
if domain.remote is not None:
|
||||
if not domain.remote.url.startswith(("https://", "http://")):
|
||||
warn(f"Domain '{rp_id}': invalid remote URL — remote dropped")
|
||||
if not domain.remote.link:
|
||||
# Never valid (a v1 remote config decodes to this) — degrade
|
||||
# to a plain local domain rather than failing to serve.
|
||||
warn(f"Domain '{rp_id}': remote without an uplink — remote dropped")
|
||||
remote = None
|
||||
else:
|
||||
remote = domain.remote
|
||||
prior = remotes_seen.setdefault(remote.url, remote)
|
||||
if prior != remote:
|
||||
warn(
|
||||
f"Domain '{rp_id}': remote '{remote.url}' settings "
|
||||
"conflict with another domain's — aligned to the "
|
||||
"first configuration"
|
||||
)
|
||||
remote = prior
|
||||
if not auth_seen:
|
||||
warn(
|
||||
f"Domain '{rp_id}': remote domain without an auth host — "
|
||||
@@ -516,46 +489,6 @@ def _derive_site(
|
||||
|
||||
_registry: DomainRegistry | None = None
|
||||
_listen: list[str] | None = None
|
||||
_rebuild_listeners: list = []
|
||||
_rebuild_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def add_rebuild_listener(fn) -> None:
|
||||
"""Register fn(registry), called after every init_registry rebuild."""
|
||||
_rebuild_listeners.append(fn)
|
||||
|
||||
|
||||
def remove_rebuild_listener(fn) -> None:
|
||||
if fn in _rebuild_listeners:
|
||||
_rebuild_listeners.remove(fn)
|
||||
|
||||
|
||||
def _rebuild_done(task: asyncio.Task) -> None:
|
||||
_rebuild_tasks.discard(task)
|
||||
if not task.cancelled() and (exc := task.exception()) is not None:
|
||||
logger.error("Registry rebuild listener failed: %s", exc)
|
||||
|
||||
|
||||
def _notify_rebuild(reg: DomainRegistry) -> None:
|
||||
"""Run rebuild listeners; schedule coroutines on the running loop.
|
||||
|
||||
Every serving context (lifespan, admin rebuild, tests) runs a loop.
|
||||
Task references are held until completion so listeners are neither
|
||||
garbage-collected mid-run nor silently failing.
|
||||
"""
|
||||
for fn in _rebuild_listeners:
|
||||
result = fn(reg)
|
||||
if not asyncio.iscoroutine(result):
|
||||
continue
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
result.close()
|
||||
logger.warning("Rebuild listener %r skipped: no running event loop", fn)
|
||||
continue
|
||||
task = loop.create_task(result)
|
||||
_rebuild_tasks.add(task)
|
||||
task.add_done_callback(_rebuild_done)
|
||||
|
||||
|
||||
def configure(*, listen: list[str] | None = None) -> None:
|
||||
@@ -596,7 +529,6 @@ def init_registry(config: Config) -> DomainRegistry:
|
||||
"""Build and install the global registry from a combined configuration."""
|
||||
global _registry
|
||||
_registry = build(config)
|
||||
_notify_rebuild(_registry)
|
||||
return _registry
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from paskia.fastapi.admin import (
|
||||
orgs,
|
||||
permissions,
|
||||
roles,
|
||||
satellites,
|
||||
users,
|
||||
)
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
@@ -39,6 +40,8 @@ app.mount("/roles", roles.app)
|
||||
app.mount("/users", users.app)
|
||||
app.mount("/permissions", permissions.app)
|
||||
app.mount("/domains", domains.app)
|
||||
app.mount("/satellites", satellites.links_app)
|
||||
app.mount("/uplinks", satellites.uplinks_app)
|
||||
|
||||
|
||||
def master_admin(ctx) -> bool:
|
||||
|
||||
@@ -7,13 +7,17 @@ origins on unrelated domains (WebAuthn Related Origin Requests). All
|
||||
changes are validated cross-domain before being persisted, and the runtime
|
||||
domain registry is rebuilt after each change so it takes effect
|
||||
immediately.
|
||||
|
||||
Remote (satellite-served) domains are uplink-managed — the upstream pushes
|
||||
their configuration; they cannot be created, edited or deleted here. Domain
|
||||
changes are pushed live to the satellite links serving them.
|
||||
"""
|
||||
|
||||
from fastapi import Body, FastAPI, Request
|
||||
|
||||
from paskia import db, domains
|
||||
from paskia.db.structs import Config, DomainConfig, OriginEntry, RemoteConfig
|
||||
from paskia.fastapi import authz
|
||||
from paskia.db.structs import Config, DomainConfig, OriginEntry
|
||||
from paskia.fastapi import authz, sync
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
@@ -27,14 +31,6 @@ install_error_handlers(app)
|
||||
|
||||
|
||||
def _domain_to_api(domain: domains.Domain) -> ApiDomain:
|
||||
remote = domain.config.remote
|
||||
if remote is not None:
|
||||
# The sync token is a bearer secret: never echoed back
|
||||
remote = RemoteConfig(
|
||||
url=remote.url,
|
||||
cache_ttl=remote.cache_ttl,
|
||||
refresh_interval=remote.refresh_interval,
|
||||
)
|
||||
return ApiDomain(
|
||||
rp_id=domain.rp_id,
|
||||
rp_name=domain.rp_name,
|
||||
@@ -42,28 +38,7 @@ def _domain_to_api(domain: domains.Domain) -> ApiDomain:
|
||||
site_url=domain.site_url,
|
||||
auth_site_url=domain.auth_site_url,
|
||||
auth_host=domain.own_auth_host,
|
||||
remote=remote,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
write-only over the API.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, dict) or not isinstance(value.get("url"), str):
|
||||
raise ValueError("remote must be an object with a url")
|
||||
token = str(value.get("token") or "") or (existing.token if existing else "")
|
||||
return RemoteConfig(
|
||||
url=value["url"].rstrip("/"),
|
||||
token=token,
|
||||
cache_ttl=max(1, int(value.get("cache_ttl") or 60)),
|
||||
refresh_interval=max(30, int(value.get("refresh_interval") or 300)),
|
||||
remote=domain.config.remote,
|
||||
)
|
||||
|
||||
|
||||
@@ -91,6 +66,22 @@ def _rebuild_registry() -> None:
|
||||
domains.init_registry(db.data().config)
|
||||
|
||||
|
||||
def _check_not_remote(payload: dict, rp_id: str | None = None) -> None:
|
||||
"""Remote domains are uplink-managed: created by registering an uplink
|
||||
on the satellite, edited and removed by the upstream's push."""
|
||||
if payload.get("remote") is not None:
|
||||
raise ValueError(
|
||||
"Remote domains are managed by satellite uplinks — register the "
|
||||
"link on the satellite instead (its satellites panel, or the "
|
||||
"'paskia satellite' command)"
|
||||
)
|
||||
if rp_id is not None and db.data().config.domains[rp_id].remote is not None:
|
||||
raise ValueError(
|
||||
f"Domain '{rp_id}' is served from an uplink — managed there, "
|
||||
"not by local edits"
|
||||
)
|
||||
|
||||
|
||||
def _check_not_locking_self_out(
|
||||
request: Request,
|
||||
rp_id: str,
|
||||
@@ -150,10 +141,10 @@ async def admin_create_domain(
|
||||
rp_id = (payload.get("rp_id") or "").strip().lower()
|
||||
if not rp_id:
|
||||
raise ValueError("rp_id is required")
|
||||
_check_not_remote(payload)
|
||||
new = DomainConfig(
|
||||
rp_name=(payload.get("rp_name") or "").strip() or None,
|
||||
origins=_normalize_origins_map(payload.get("origins")),
|
||||
remote=_normalize_remote(payload.get("remote")),
|
||||
)
|
||||
|
||||
config = db.data().config
|
||||
@@ -175,7 +166,7 @@ async def admin_update_domain(
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update a domain's rp_name, origins and related origins (replaced
|
||||
wholesale).
|
||||
wholesale); links serving the domain are pushed the new configuration.
|
||||
|
||||
The rp-id itself is immutable: credentials are stamped with it.
|
||||
"""
|
||||
@@ -186,16 +177,10 @@ async def admin_update_domain(
|
||||
config = db.data().config
|
||||
if rp_id not in config.domains:
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
|
||||
current_remote = config.domains[rp_id].remote
|
||||
_check_not_remote(payload, rp_id)
|
||||
updated = DomainConfig(
|
||||
rp_name=(payload.get("rp_name") or "").strip() or None,
|
||||
origins=_normalize_origins_map(payload.get("origins")),
|
||||
remote=(
|
||||
_normalize_remote(payload["remote"], existing=current_remote)
|
||||
if "remote" in payload
|
||||
else current_remote
|
||||
),
|
||||
)
|
||||
would_be = Config(
|
||||
domains={k: updated if k == rp_id else v for k, v in config.domains.items()},
|
||||
@@ -208,10 +193,10 @@ async def admin_update_domain(
|
||||
rp_id,
|
||||
rp_name=updated.rp_name,
|
||||
origins=updated.origins,
|
||||
remote=updated.remote,
|
||||
ctx=ctx,
|
||||
)
|
||||
_rebuild_registry()
|
||||
sync.refresh_links_serving(rp_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -221,7 +206,8 @@ async def admin_delete_domain(
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Delete a domain (refused for the last domain or while credentials remain)."""
|
||||
"""Delete a domain (refused for the last domain, remote domains, or
|
||||
while credentials remain); links serving it lose it from their scope."""
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
@@ -233,4 +219,5 @@ async def admin_delete_domain(
|
||||
)
|
||||
db.delete_domain(rp_id, ctx=ctx)
|
||||
_rebuild_registry()
|
||||
sync.drop_domain_from_links(rp_id, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Satellite link management API — master admin only, both roles.
|
||||
|
||||
**Upstream role** (``/satellites``): mint registration offers (RAM-only,
|
||||
short-lived, single-use initial keys shown once inside a wss link URL),
|
||||
list registered links with their live state, adjust a link's scope
|
||||
(pushed over the live link — registration is once-only) and revoke links
|
||||
(drops the satellite's connections).
|
||||
|
||||
**Satellite role** (``/uplinks``): register an uplink from a pasted link
|
||||
URL (the online handshake runs synchronously here — the same path the
|
||||
``paskia satellite`` CLI uses), list uplinks with their live replica
|
||||
state, and unlink (drops the uplink and every domain it serves).
|
||||
"""
|
||||
|
||||
from fastapi import Body, FastAPI, Request
|
||||
|
||||
from paskia import db, domains, satellite
|
||||
from paskia.fastapi import authz, sync
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
|
||||
links_app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
uplinks_app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
install_error_handlers(links_app)
|
||||
install_error_handlers(uplinks_app)
|
||||
|
||||
|
||||
def _check_scope(rp_ids) -> list[str]:
|
||||
"""Validate a link scope: existing authoritative domains with an auth
|
||||
host (satellite users' account pages live there). Empty is allowed —
|
||||
register first, scope later."""
|
||||
clean: list[str] = []
|
||||
for raw in rp_ids or []:
|
||||
rp_id = str(raw).strip().lower()
|
||||
if not rp_id or rp_id in clean:
|
||||
continue
|
||||
domain = db.data().config.domains.get(rp_id)
|
||||
if domain is None:
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
if domain.remote is not None:
|
||||
raise ValueError(
|
||||
f"Domain '{rp_id}' is itself satellite-served — "
|
||||
"satellites cannot be nested"
|
||||
)
|
||||
if domains.auth_host_url(domain) is None:
|
||||
raise ValueError(
|
||||
f"Domain '{rp_id}' must mark an auth host before satellites "
|
||||
"can serve it"
|
||||
)
|
||||
clean.append(rp_id)
|
||||
return clean
|
||||
|
||||
|
||||
def _base_url(request: Request, rp_ids: list[str]) -> str:
|
||||
"""The instance URL the link URL points at: the first scoped domain's
|
||||
auth host, else the host the admin is using. Any host of this instance
|
||||
serves the sync endpoint — it is not host-dispatched."""
|
||||
for rp_id in rp_ids:
|
||||
domain = db.data().config.domains[rp_id]
|
||||
if url := domains.auth_host_url(domain):
|
||||
return url
|
||||
return request.state.domain.site_url
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Upstream role: links to satellites
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
@links_app.get("/")
|
||||
async def admin_list_links(request: Request, auth=AUTH_COOKIE):
|
||||
"""List registered satellite links with their live connection state."""
|
||||
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
|
||||
status = sync.links_status()
|
||||
return MsgspecResponse(
|
||||
[
|
||||
{
|
||||
"id": link_id,
|
||||
"label": link.label,
|
||||
"rp_ids": sorted(link.rp_ids),
|
||||
"created": link.created,
|
||||
"connected": link_id in status,
|
||||
"rejected": status.get(link_id, {}).get("rejected") or {},
|
||||
}
|
||||
for link_id, link in db.data().satellite_links.items()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@links_app.post("/")
|
||||
async def admin_create_offer(
|
||||
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
|
||||
):
|
||||
"""Mint a registration offer; the link URL is shown once and expires."""
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
rp_ids = _check_scope(payload.get("rp_ids"))
|
||||
return {
|
||||
"url": sync.create_offer(rp_ids, _base_url(request, rp_ids)),
|
||||
"expires_in": sync.OFFER_TTL,
|
||||
}
|
||||
|
||||
|
||||
@links_app.patch("/{link_id}")
|
||||
async def admin_update_link_scope(
|
||||
link_id: str, request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
|
||||
):
|
||||
"""Adjust a link's scope; the new domain set is pushed over the live
|
||||
link (no new registration)."""
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
rp_ids = _check_scope(payload.get("rp_ids"))
|
||||
db.set_satellite_link_scope(link_id, rp_ids, ctx=ctx)
|
||||
sync.push_link_domains(link_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@links_app.delete("/{link_id}")
|
||||
async def admin_delete_link(link_id: str, request: Request, auth=AUTH_COOKIE):
|
||||
"""Revoke a satellite link; its live connections are dropped, and the
|
||||
satellite unlinks its side on the policy close."""
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
db.delete_satellite_link(link_id, ctx=ctx)
|
||||
await sync.close_link(link_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Satellite role: uplinks
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _uplink_domains_of(uplink_id: str) -> list[str]:
|
||||
return sorted(
|
||||
rp_id
|
||||
for rp_id, d in db.data().config.domains.items()
|
||||
if d.remote is not None and d.remote.link == uplink_id
|
||||
)
|
||||
|
||||
|
||||
@uplinks_app.get("/")
|
||||
async def admin_list_uplinks(request: Request, auth=AUTH_COOKIE):
|
||||
"""List uplinks with the domains they serve and live replica state."""
|
||||
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
|
||||
replicas = satellite.manager.replicas
|
||||
return MsgspecResponse(
|
||||
[
|
||||
{
|
||||
"id": uplink_id,
|
||||
"url": uplink.url,
|
||||
"created": uplink.created,
|
||||
"domains": _uplink_domains_of(uplink_id),
|
||||
"connected": bool(
|
||||
(replica := replicas.get(uplink_id)) and replica.connected
|
||||
),
|
||||
"available": bool(
|
||||
(replica := replicas.get(uplink_id)) and replica.available()
|
||||
),
|
||||
}
|
||||
for uplink_id, uplink in db.data().uplinks.items()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@uplinks_app.post("/")
|
||||
async def admin_register_uplink(
|
||||
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
|
||||
):
|
||||
"""Register an uplink from a pasted link URL — the full handshake runs
|
||||
here, so failures (expired URL, unreachable upstream, rejected domains)
|
||||
come back as the response."""
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
try:
|
||||
return await satellite.register_uplink(str(payload.get("url") or ""))
|
||||
except RuntimeError as e:
|
||||
raise ValueError(str(e)) from e
|
||||
|
||||
|
||||
@uplinks_app.delete("/{uplink_id}")
|
||||
async def admin_unlink_uplink(uplink_id: str, request: Request, auth=AUTH_COOKIE):
|
||||
"""Unlink an uplink: drop it and every domain it serves."""
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
if uplink_id not in db.data().uplinks:
|
||||
raise ValueError("Uplink not found")
|
||||
await satellite.manager.unlink(uplink_id)
|
||||
return {"status": "ok"}
|
||||
@@ -163,6 +163,10 @@ async def check_user(
|
||||
|
||||
No session cookie is read or written. Caller authentication is not required.
|
||||
"""
|
||||
if (proxied := await satellite.forward_request(request)) is not None:
|
||||
# User lookups without a session need the user graph, which only
|
||||
# the remote holds.
|
||||
return proxied
|
||||
host = hostutil.normalize_host(request.headers.get("host"))
|
||||
data = satellite.store_for_host(host)
|
||||
try:
|
||||
@@ -325,6 +329,10 @@ async def api_user_info(
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Get full user profile including credentials and sessions."""
|
||||
if (proxied := await satellite.forward_request(request)) is not None:
|
||||
# The replica holds per-session contexts only, not the user graph —
|
||||
# profile data is answered by the remote.
|
||||
return proxied
|
||||
if auth is None:
|
||||
raise authz.AuthException(
|
||||
status_code=401,
|
||||
|
||||
+279
-25
@@ -1,36 +1,293 @@
|
||||
"""Sync WebSocket endpoint: serves snapshots and live events to satellites.
|
||||
"""Sync WebSocket endpoint: link handshake, snapshots and live events.
|
||||
|
||||
Token-gated via PASKIA_SYNC_TOKENS (env); closed when unset. All state is
|
||||
RAM-only (syncfeed); the database schema is untouched. Protocol: snapshot
|
||||
chunks per table, `ready`, then live upsert/delete events; the client sends
|
||||
session_refresh write-backs. Reconnects always restart from a snapshot.
|
||||
One endpoint serves both registration and established links — the first
|
||||
binary frame decides. Registration is a 3-way dialogue:
|
||||
|
||||
satellite → upstream hello {link: null, key: <initial key>, label}
|
||||
upstream → satellite welcome {link, key: <server part>, domains: [...]}
|
||||
satellite → upstream ready {key: <client part>}
|
||||
|
||||
The initial key comes from a registration offer minted in the admin UI:
|
||||
held in RAM only, short-lived and single-use. The permanent link
|
||||
credential ``key`` is created during the handshake from parts both sides
|
||||
contribute; both sides then store the link in their databases.
|
||||
(Placeholder cryptography: the parts are exchanged as-is — the final
|
||||
version derives the channel key from the handshake without sending key
|
||||
material.)
|
||||
|
||||
An established link runs the same dialogue with hello {link, key} proving
|
||||
the stored key; the welcome re-pushes the link's domain set (the scope may
|
||||
have changed while offline), the ready is a bare ack. A link's scope is
|
||||
adjustable at any time and pushed live as a ``domains`` frame — no new
|
||||
cryptographic setup.
|
||||
|
||||
After the handshake the channel switches to plain JSON frames (to be
|
||||
transparently encrypted in the final version): snapshot chunks per table,
|
||||
`ready`, then live upsert/delete events within the link's scope; the
|
||||
satellite sends session_refresh write-backs and domains_ack reports.
|
||||
Reconnects always restart from a snapshot. Beyond the link table all
|
||||
state is RAM-only (syncfeed).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import msgspec
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
|
||||
from paskia import db, syncfeed
|
||||
from paskia.util.passphrase import generate as generate_passphrase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
OFFER_TTL = 600 # registration offers live 10 minutes, RAM-only
|
||||
_HANDSHAKE_TIMEOUT = 15 # seconds per dialogue frame before a plain close
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Offer:
|
||||
rp_ids: list[str]
|
||||
created: float # monotonic
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Connection:
|
||||
"""One live link connection: its WebSocket, link id, live feed scope
|
||||
(mutated in place on scope changes) and the satellite's latest
|
||||
domains_ack rejections."""
|
||||
|
||||
ws: WebSocket
|
||||
link_id: str
|
||||
scope: set[str]
|
||||
rejected: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
_offers: dict[str, _Offer] = {}
|
||||
_connections: dict[asyncio.Queue, _Connection] = {}
|
||||
|
||||
|
||||
def create_offer(rp_ids: list[str], base_url: str) -> str:
|
||||
"""Mint a RAM-only registration offer; returns the wss link URL.
|
||||
|
||||
The URL is meant only for a satellite to connect to: a wss:// scheme
|
||||
keeps browsers from opening it. The initial key travels as userinfo and
|
||||
is never stored — losing the URL just means minting a new one.
|
||||
"""
|
||||
key = generate_passphrase()
|
||||
now = time.monotonic()
|
||||
for k, offer in list(_offers.items()): # lazy expiry sweep
|
||||
if now - offer.created > OFFER_TTL:
|
||||
_offers.pop(k, None)
|
||||
_offers[key] = _Offer(rp_ids=list(rp_ids), created=now)
|
||||
parts = urlsplit(base_url)
|
||||
scheme = "wss" if parts.scheme == "https" else "ws"
|
||||
return f"{scheme}://{key}@{parts.netloc}"
|
||||
|
||||
|
||||
def _pop_offer(key: str) -> _Offer | None:
|
||||
offer = _offers.pop(key, None) # single-use
|
||||
if offer is None or time.monotonic() - offer.created > OFFER_TTL:
|
||||
return None
|
||||
return offer
|
||||
|
||||
|
||||
def links_status() -> dict[str, dict]:
|
||||
"""Live state per link id for the admin API: connected flag and the
|
||||
satellite's latest domain rejections."""
|
||||
status: dict[str, dict] = {}
|
||||
for conn in _connections.values():
|
||||
entry = status.setdefault(conn.link_id, {"connected": True, "rejected": {}})
|
||||
entry["rejected"].update(conn.rejected)
|
||||
return status
|
||||
|
||||
|
||||
async def close_link(link_id: str) -> None:
|
||||
"""Drop a link's live connections (revocation). The satellite treats
|
||||
the policy close as terminal and unlinks its side."""
|
||||
for conn in list(_connections.values()):
|
||||
if conn.link_id == link_id:
|
||||
with contextlib.suppress(Exception):
|
||||
await conn.ws.close(code=1008)
|
||||
|
||||
|
||||
def domain_infos(rp_ids) -> list[dict]:
|
||||
"""The pushed configuration of each in-scope domain: rp-id, display
|
||||
name and the origins table verbatim — the upstream is authoritative."""
|
||||
infos = []
|
||||
for rp_id in rp_ids:
|
||||
domain = db.data().config.domains.get(rp_id)
|
||||
if domain is None or domain.remote is not None:
|
||||
continue # deleted, or itself satellite-served (no nesting)
|
||||
infos.append(
|
||||
{
|
||||
"rp_id": rp_id,
|
||||
"rp_name": domain.rp_name or "",
|
||||
"origins": msgspec.to_builtins(domain.origins),
|
||||
}
|
||||
)
|
||||
return infos
|
||||
|
||||
|
||||
def push_link_domains(link_id: str) -> None:
|
||||
"""Queue the link's current domain set to its live connections and
|
||||
update their feed scope (after scope edits or origin changes). Frames
|
||||
flow through the connection's queue: the event pump stays the single
|
||||
sender, so frames never interleave with a concurrent send."""
|
||||
link = db.data().satellite_links.get(link_id)
|
||||
if link is None:
|
||||
return
|
||||
message = {"type": "domains", "domains": domain_infos(link.rp_ids)}
|
||||
for queue, conn in list(_connections.items()):
|
||||
if conn.link_id != link_id:
|
||||
continue
|
||||
conn.scope.clear()
|
||||
conn.scope.update(link.rp_ids)
|
||||
try:
|
||||
queue.put_nowait(message)
|
||||
except asyncio.QueueFull:
|
||||
# Slow consumer: drop it like syncfeed does — the reconnect's
|
||||
# welcome re-pushes the domain set anyway.
|
||||
_connections.pop(queue, None)
|
||||
syncfeed.unsubscribe(queue)
|
||||
|
||||
|
||||
def refresh_links_serving(rp_id: str) -> None:
|
||||
"""A domain changed upstream: push its updated info to links serving it."""
|
||||
for link_id, link in db.data().satellite_links.items():
|
||||
if rp_id in link.rp_ids:
|
||||
push_link_domains(link_id)
|
||||
|
||||
|
||||
def drop_domain_from_links(rp_id: str, *, ctx=None) -> None:
|
||||
"""A domain was deleted upstream: shrink every link's scope and push."""
|
||||
for link_id, link in db.data().satellite_links.items():
|
||||
if rp_id in link.rp_ids:
|
||||
db.set_satellite_link_scope(
|
||||
link_id, [r for r in link.rp_ids if r != rp_id], ctx=ctx
|
||||
)
|
||||
push_link_domains(link_id)
|
||||
|
||||
|
||||
async def _send(ws: WebSocket, message: dict) -> None:
|
||||
await ws.send_bytes(syncfeed.encode(message))
|
||||
|
||||
|
||||
async def _apply_client_message(message: dict) -> None:
|
||||
"""Satellite write-behind: session refresh (validated/ip/user-agent)."""
|
||||
if message.get("type") != "session_refresh":
|
||||
def _snapshot_items(table: str, scope: set[str]) -> list:
|
||||
"""Snapshot rows for a table, filtered to the link's rp-id scope;
|
||||
sessions carry their ApiSessionContext as ``ctx``."""
|
||||
items = []
|
||||
for key, obj in getattr(db.data(), table).items():
|
||||
if obj.rp_id not in scope:
|
||||
continue
|
||||
fields = msgspec.to_builtins(obj)
|
||||
if table == "sessions":
|
||||
fields["ctx"] = syncfeed.session_ctx_dict(obj)
|
||||
items.append([str(key), fields])
|
||||
return items
|
||||
|
||||
|
||||
async def _handshake(ws: WebSocket) -> _Connection | None:
|
||||
"""The 3-way link dialogue: hello → welcome → ready.
|
||||
|
||||
Returns the connection state for an accepted link, or None after
|
||||
closing on rejection (bad offer, unknown link or key). Reads are
|
||||
bounded: a peer that stalls mid-dialogue gets a plain server-error
|
||||
close — never 1008, which the satellite reads as revocation.
|
||||
"""
|
||||
try:
|
||||
hello = msgspec.json.decode(
|
||||
await asyncio.wait_for(ws.receive_bytes(), _HANDSHAKE_TIMEOUT)
|
||||
)
|
||||
except TimeoutError:
|
||||
await ws.close(code=1011)
|
||||
return None
|
||||
if hello.get("type") != "hello":
|
||||
await ws.close(code=1008)
|
||||
return None
|
||||
link_id = hello.get("link") or ""
|
||||
server_part: bytes | None = None
|
||||
if link_id:
|
||||
# Established link: prove the stored key.
|
||||
link = db.data().satellite_links.get(link_id)
|
||||
try:
|
||||
proof = bytes.fromhex(hello.get("key") or "")
|
||||
except ValueError:
|
||||
proof = b""
|
||||
if link is None or not proof or not secrets.compare_digest(link.key, proof):
|
||||
await ws.close(code=1008)
|
||||
return None
|
||||
rp_ids = list(link.rp_ids)
|
||||
else:
|
||||
# Registration: spend the RAM-only offer, contribute a key part.
|
||||
offer = _pop_offer(hello.get("key") or "")
|
||||
if offer is None:
|
||||
await ws.close(code=1008)
|
||||
return None
|
||||
link_id = secrets.token_urlsafe(12)
|
||||
server_part = secrets.token_bytes(32)
|
||||
rp_ids = offer.rp_ids
|
||||
welcome: dict = {
|
||||
"type": "welcome",
|
||||
"link": link_id,
|
||||
"domains": domain_infos(rp_ids),
|
||||
}
|
||||
if server_part is not None:
|
||||
welcome["key"] = server_part.hex()
|
||||
await _send(ws, welcome)
|
||||
|
||||
try:
|
||||
ready = msgspec.json.decode(
|
||||
await asyncio.wait_for(ws.receive_bytes(), _HANDSHAKE_TIMEOUT)
|
||||
)
|
||||
except TimeoutError:
|
||||
await ws.close(code=1011)
|
||||
return None
|
||||
if ready.get("type") != "ready":
|
||||
await ws.close(code=1008)
|
||||
return None
|
||||
if server_part is not None:
|
||||
# Registration completes: the permanent credential is both parts.
|
||||
try:
|
||||
client_part = bytes.fromhex(ready.get("key") or "")
|
||||
except ValueError:
|
||||
client_part = b""
|
||||
if not client_part:
|
||||
await ws.close(code=1008)
|
||||
return None
|
||||
db.create_satellite_link(
|
||||
link_id,
|
||||
rp_ids,
|
||||
server_part + client_part,
|
||||
label=(hello.get("label") or "")[:80],
|
||||
)
|
||||
return _Connection(
|
||||
ws=ws,
|
||||
link_id=link_id,
|
||||
scope=set(rp_ids),
|
||||
rejected=ready.get("rejected") or {},
|
||||
)
|
||||
|
||||
|
||||
async def _apply_client_message(message: dict, conn: _Connection) -> None:
|
||||
"""Satellite → upstream frames: session_refresh write-backs (confined
|
||||
to the link's scope) and domains_ack reports."""
|
||||
mtype = message.get("type")
|
||||
if mtype == "domains_ack":
|
||||
conn.rejected = message.get("rejected") or {}
|
||||
return
|
||||
if mtype != "session_refresh":
|
||||
return
|
||||
key = message.get("key") or ""
|
||||
session = db.data().sessions.get(key)
|
||||
if session is None:
|
||||
if session is None or session.rp_id not in conn.scope:
|
||||
return
|
||||
try:
|
||||
validated = msgspec.convert(message.get("validated"), datetime)
|
||||
@@ -46,44 +303,41 @@ async def _apply_client_message(message: dict) -> None:
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def sync_websocket(ws: WebSocket):
|
||||
tokens = syncfeed.tokens_from_env()
|
||||
auth = ws.headers.get("authorization", "")
|
||||
if not tokens or auth.removeprefix("Bearer ").strip() not in tokens:
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
await ws.accept()
|
||||
|
||||
queue = syncfeed.subscribe()
|
||||
queue: asyncio.Queue | None = None
|
||||
try:
|
||||
data = db.data()
|
||||
conn = await _handshake(ws)
|
||||
if conn is None:
|
||||
return
|
||||
queue = syncfeed.subscribe(conn.scope)
|
||||
_connections[queue] = conn
|
||||
for table in syncfeed.TABLES:
|
||||
await _send(
|
||||
ws,
|
||||
{
|
||||
"type": "snapshot",
|
||||
"table": table,
|
||||
"items": [
|
||||
[str(key), msgspec.to_builtins(obj)]
|
||||
for key, obj in getattr(data, table).items()
|
||||
],
|
||||
"items": _snapshot_items(table, conn.scope),
|
||||
},
|
||||
)
|
||||
await _send(ws, {"type": "ready"})
|
||||
await _send(ws, {"type": "ready", "rp_ids": sorted(conn.scope)})
|
||||
|
||||
sender = asyncio.create_task(_pump(ws, queue))
|
||||
try:
|
||||
while True:
|
||||
await _apply_client_message(
|
||||
msgspec.json.decode(await ws.receive_bytes())
|
||||
msgspec.json.decode(await ws.receive_bytes()), conn
|
||||
)
|
||||
finally:
|
||||
sender.cancel()
|
||||
except WebSocketDisconnect:
|
||||
except WebSocketDisconnect, RuntimeError:
|
||||
pass
|
||||
except Exception:
|
||||
_logger.exception("Sync WebSocket failed")
|
||||
finally:
|
||||
syncfeed.unsubscribe(queue)
|
||||
if queue is not None:
|
||||
_connections.pop(queue, None)
|
||||
syncfeed.unsubscribe(queue)
|
||||
|
||||
|
||||
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
||||
|
||||
+440
-67
@@ -1,24 +1,41 @@
|
||||
"""Satellite side of remote domains: RAM-only replicas + host dispatch.
|
||||
"""Satellite side of remote domains: uplinks, RAM-only replicas, dispatch.
|
||||
|
||||
Domains configured with ``DomainConfig.remote`` are backed by a remote
|
||||
paskia instance. This module owns the whole feature: it resolves which
|
||||
store serves a request host (local DB or the remote's read replica),
|
||||
dispatches session writes (refresh write-behind, logout eviction), and
|
||||
forwards requests the satellite cannot answer (exchange-code redemption,
|
||||
OIDC, reset tokens) to the remote.
|
||||
A domain configured with ``DomainConfig.remote`` is backed by an upstream
|
||||
paskia instance over a **link**: registered online (3-way handshake over
|
||||
the sync WebSocket — one ``register_uplink`` path serves the admin UI and
|
||||
the ``paskia satellite`` CLI), then kept up by a long-lived connection per
|
||||
uplink. The upstream owns the domain set: it pushes the link's domains
|
||||
(rp-id, display name, origins verbatim) at handshake and whenever the
|
||||
scope or a domain changes; the satellite validates them against its local
|
||||
config, persists the accepted set, and reports rejections. Local edits of
|
||||
remote domains are rejected — the only satellite-side configuration is
|
||||
link creation and unlinking.
|
||||
|
||||
A replica is a plain DB instance, never persisted, fed by a sync
|
||||
WebSocket (snapshot on connect, then live events) and swept for
|
||||
expired sessions locally. While the channel is down the replica stays trusted for the
|
||||
domain's cache_ttl, then reads fail with RemoteUnavailable (fail-closed;
|
||||
a large cache_ttl gives fail-open behavior bounded by session expiry).
|
||||
This module owns the rest of the feature: it resolves which store serves
|
||||
a request host (local DB or an uplink's read replica), dispatches session
|
||||
writes (refresh write-behind, logout eviction), and forwards requests the
|
||||
satellite cannot answer (exchange-code redemption, OIDC, reset tokens,
|
||||
session-less lookups like /check) to the upstream.
|
||||
|
||||
A replica is a ReplicaDB instance, never persisted, fed by the sync
|
||||
channel (snapshot on connect, then live events) and swept for expired
|
||||
sessions locally. The link's scope confines the replica to the pushed
|
||||
domains' sessions and credentials, each session carrying the
|
||||
ApiSessionContext the upstream computed for it (identity display data and
|
||||
effective permissions for the session's host) — the user graph itself
|
||||
never leaves the upstream. While the channel is down the replica stays
|
||||
trusted for the uplink's cache_ttl, then reads fail closed (503; a large
|
||||
cache_ttl gives fail-open behavior bounded by session expiry).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import secrets
|
||||
import socket
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import unquote, urlsplit, urlunsplit
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
@@ -26,41 +43,294 @@ import msgspec
|
||||
import websockets
|
||||
from fastapi import HTTPException, Request, Response
|
||||
|
||||
from paskia import db, domains
|
||||
from paskia import db, domains, syncfeed
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
Config,
|
||||
Credential,
|
||||
DomainConfig,
|
||||
Org,
|
||||
OriginEntry,
|
||||
Permission,
|
||||
RemoteConfig,
|
||||
Role,
|
||||
Session,
|
||||
SessionContext,
|
||||
Uplink,
|
||||
User,
|
||||
)
|
||||
from paskia.util.apistructs import ApiSessionContext
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_TABLES = {
|
||||
"permissions": (Permission, True),
|
||||
"orgs": (Org, True),
|
||||
"roles": (Role, True),
|
||||
"users": (User, True),
|
||||
"credentials": (Credential, True),
|
||||
"sessions": (Session, False),
|
||||
}
|
||||
|
||||
_RECONNECT_DELAY = 5
|
||||
_SWEEP_INTERVAL = 60
|
||||
_HANDSHAKE_TIMEOUT = 15 # seconds the upstream may take to answer a hello
|
||||
|
||||
|
||||
class RemoteReplica:
|
||||
"""One remote instance's replica, its sync client and write-behind queue."""
|
||||
class LinkRevokedError(Exception):
|
||||
"""The upstream closed the handshake with a policy violation: the link
|
||||
is revoked (or the key desynced) — the satellite unlinks its side."""
|
||||
|
||||
def __init__(self, remote: RemoteConfig):
|
||||
self.remote = remote
|
||||
self.db = DB()
|
||||
|
||||
class ReplicaDB(DB):
|
||||
"""A sync replica's store: the uplink's sessions and credentials plus
|
||||
the per-session contexts (identity and effective permissions) the
|
||||
upstream pushes. Holds no user graph of its own; lookups join the
|
||||
session with its pushed context."""
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.session_contexts: dict[str, ApiSessionContext] = {}
|
||||
|
||||
def session_ctx_for(self, s: Session, host: str | None) -> SessionContext | None:
|
||||
if s.client_uuid is not None or s.host != host:
|
||||
return None
|
||||
stored = self.session_contexts.get(s.key)
|
||||
if stored is None:
|
||||
return None
|
||||
try:
|
||||
credential = self.credentials[s.credential_uuid]
|
||||
except KeyError:
|
||||
return None
|
||||
user = User(
|
||||
display_name=stored.user.display_name,
|
||||
role_uuid=stored.role.uuid,
|
||||
created_at=s.validated,
|
||||
visits=0,
|
||||
theme=stored.user.theme,
|
||||
)
|
||||
user.uuid = stored.user.uuid
|
||||
role = Role(org_uuid=stored.org.uuid, display_name=stored.role.display_name)
|
||||
role.uuid = stored.role.uuid
|
||||
org = Org(display_name=stored.org.display_name)
|
||||
org.uuid = stored.org.uuid
|
||||
return SessionContext(
|
||||
session=s,
|
||||
user=user,
|
||||
org=org,
|
||||
role=role,
|
||||
credential=credential,
|
||||
permissions=[
|
||||
Permission(scope=scope, display_name=scope)
|
||||
for scope in stored.permissions
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def ws_url_for(base_url: str) -> str:
|
||||
"""The upstream's sync WebSocket URL for an http(s) base URL."""
|
||||
return base_url.replace("http", "ws", 1) + "/auth/api/sync/ws"
|
||||
|
||||
|
||||
def parse_link_url(url: str) -> tuple[str, str]:
|
||||
"""Split a satellite link URL — ``wss://<initial-key>@<host>`` — into
|
||||
the upstream's https base URL and the initial registration key.
|
||||
|
||||
The wss scheme is deliberate: the URL is meant for a satellite to
|
||||
connect to, and browsers refuse to open it.
|
||||
"""
|
||||
parts = urlsplit(url.strip())
|
||||
if parts.scheme not in ("wss", "ws"):
|
||||
raise ValueError(
|
||||
"A link URL is a wss:// URL copied from the upstream's satellites panel"
|
||||
)
|
||||
key = unquote(parts.password or parts.username or "")
|
||||
if not key:
|
||||
raise ValueError("The link URL carries no registration key")
|
||||
netloc = parts.hostname or ""
|
||||
if not netloc:
|
||||
raise ValueError("The link URL carries no host")
|
||||
if parts.port:
|
||||
netloc = f"{netloc}:{parts.port}"
|
||||
scheme = "https" if parts.scheme == "wss" else "http"
|
||||
base = urlunsplit((scheme, netloc, parts.path, "", "")).rstrip("/")
|
||||
return base, key
|
||||
|
||||
|
||||
def _label() -> str:
|
||||
"""This instance's self-reported name on links (informational)."""
|
||||
return socket.gethostname() or "satellite"
|
||||
|
||||
|
||||
def _uplink_domains(uplink_id: str) -> dict[str, DomainConfig]:
|
||||
"""The config domains currently served by an uplink."""
|
||||
return {
|
||||
rp_id: d
|
||||
for rp_id, d in db.data().config.domains.items()
|
||||
if d.remote is not None and d.remote.link == uplink_id
|
||||
}
|
||||
|
||||
|
||||
def _accepted_domains(
|
||||
uplink_id: str, infos: list[dict]
|
||||
) -> tuple[dict[str, DomainConfig], dict[str, str]]:
|
||||
"""Validate a pushed domain set against the local configuration.
|
||||
|
||||
The upstream is authoritative for the domains it pushes, but they must
|
||||
not collide with what this instance already serves: an rp-id served
|
||||
locally or by another uplink is rejected, as is a push that would break
|
||||
the merged config (e.g. an auth-host collision with a local domain).
|
||||
Returns (accepted, {rp_id: rejection reason}).
|
||||
"""
|
||||
merged = {
|
||||
rp_id: d
|
||||
for rp_id, d in db.data().config.domains.items()
|
||||
if not (d.remote is not None and d.remote.link == uplink_id)
|
||||
}
|
||||
accepted: dict[str, DomainConfig] = {}
|
||||
rejected: dict[str, str] = {}
|
||||
for info in infos:
|
||||
rp_id = (info.get("rp_id") or "").strip().lower()
|
||||
if not rp_id:
|
||||
continue
|
||||
existing = db.data().config.domains.get(rp_id)
|
||||
if existing is not None and not (
|
||||
existing.remote is not None and existing.remote.link == uplink_id
|
||||
):
|
||||
rejected[rp_id] = "served locally or by another uplink"
|
||||
continue
|
||||
try:
|
||||
origins = msgspec.convert(
|
||||
info.get("origins") or {}, dict[str, bool | OriginEntry]
|
||||
)
|
||||
except msgspec.ValidationError:
|
||||
rejected[rp_id] = "malformed origins"
|
||||
continue
|
||||
domain = DomainConfig(
|
||||
rp_name=info.get("rp_name") or None,
|
||||
origins=origins,
|
||||
remote=RemoteConfig(link=uplink_id),
|
||||
)
|
||||
try:
|
||||
domains.validate_config(
|
||||
Config(domains={**merged, **accepted, rp_id: domain})
|
||||
)
|
||||
except ValueError as e:
|
||||
rejected[rp_id] = str(e)
|
||||
continue
|
||||
accepted[rp_id] = domain
|
||||
return accepted, rejected
|
||||
|
||||
|
||||
def _drop_replica_rows(replica: ReplicaDB, removed_rp_ids: set[str]) -> None:
|
||||
"""Evict a removed domain's rows from a replica."""
|
||||
for key, s in list(replica.sessions.items()):
|
||||
if s.rp_id in removed_rp_ids:
|
||||
del replica.sessions[key]
|
||||
replica.session_contexts.pop(key, None)
|
||||
for cred_uuid, c in list(replica.credentials.items()):
|
||||
if c.rp_id in removed_rp_ids:
|
||||
del replica.credentials[cred_uuid]
|
||||
|
||||
|
||||
def apply_domains_push(
|
||||
uplink_id: str, infos: list[dict], replica_db: ReplicaDB | None = None
|
||||
) -> tuple[dict[str, str], bool]:
|
||||
"""Apply a domain set pushed by the uplink: persist the accepted set
|
||||
(adding, updating and removing the link's domains) and rebuild the
|
||||
registry. Returns ({rp_id: rejection reason}, changed) — the rejections
|
||||
to report upstream, and whether the accepted set moved (the caller then
|
||||
resyncs: a newly added domain's existing sessions only arrive with a
|
||||
fresh snapshot)."""
|
||||
accepted, rejected = _accepted_domains(uplink_id, infos)
|
||||
current = _uplink_domains(uplink_id)
|
||||
changed = accepted != current
|
||||
if changed:
|
||||
db.apply_uplink_domains(uplink_id, accepted)
|
||||
domains.init_registry(db.data().config)
|
||||
if replica_db is not None and (removed := set(current) - set(accepted)):
|
||||
_drop_replica_rows(replica_db, removed)
|
||||
return rejected, changed
|
||||
|
||||
|
||||
async def register_uplink(url: str) -> dict:
|
||||
"""Register this instance as a satellite of an upstream.
|
||||
|
||||
The one registration path, serving the admin UI and the ``paskia
|
||||
satellite`` CLI alike: connect to the link URL's sync endpoint, run the
|
||||
3-way handshake (spending the URL's RAM-only initial key), then commit
|
||||
the permanent link credential and the pushed domain set to the local
|
||||
database. The replica connection itself is (re)established by the
|
||||
manager afterwards, through the same handshake as an established link.
|
||||
"""
|
||||
base_url, initial_key = parse_link_url(url)
|
||||
if any(u.url == base_url for u in db.data().uplinks.values()):
|
||||
raise ValueError(f"Already linked to {base_url} — unlink first to re-register")
|
||||
try:
|
||||
async with websockets.connect(
|
||||
ws_url_for(base_url), ping_interval=5, ping_timeout=5
|
||||
) as ws:
|
||||
await ws.send(
|
||||
syncfeed.encode(
|
||||
{
|
||||
"type": "hello",
|
||||
"link": None,
|
||||
"key": initial_key,
|
||||
"label": _label(),
|
||||
}
|
||||
)
|
||||
)
|
||||
try:
|
||||
welcome = msgspec.json.decode(
|
||||
await asyncio.wait_for(ws.recv(), _HANDSHAKE_TIMEOUT)
|
||||
)
|
||||
except websockets.exceptions.ConnectionClosed as e:
|
||||
raise RuntimeError(
|
||||
"the upstream rejected the link URL (unknown or expired offer)"
|
||||
) from e
|
||||
except TimeoutError as e:
|
||||
raise RuntimeError("the upstream did not answer in time") from e
|
||||
if welcome.get("type") != "welcome":
|
||||
raise RuntimeError("the upstream did not complete the registration")
|
||||
link_id = welcome.get("link") or ""
|
||||
try:
|
||||
server_part = bytes.fromhex(welcome.get("key") or "")
|
||||
except ValueError:
|
||||
server_part = b""
|
||||
if not link_id or not server_part:
|
||||
raise RuntimeError("the upstream sent a malformed registration")
|
||||
accepted, rejected = _accepted_domains(
|
||||
link_id, welcome.get("domains") or []
|
||||
)
|
||||
client_part = secrets.token_bytes(32)
|
||||
await ws.send(
|
||||
syncfeed.encode(
|
||||
{"type": "ready", "key": client_part.hex(), "rejected": rejected}
|
||||
)
|
||||
)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"the upstream is unreachable: {e}") from e
|
||||
uplink = Uplink(
|
||||
url=base_url, created=datetime.now(UTC), key=server_part + client_part
|
||||
)
|
||||
db.create_uplink(link_id, uplink, accepted)
|
||||
domains.init_registry(db.data().config)
|
||||
if manager.started:
|
||||
await manager.sync_uplinks()
|
||||
return {
|
||||
"link": link_id,
|
||||
"url": base_url,
|
||||
"domains": sorted(accepted),
|
||||
"rejected": rejected,
|
||||
}
|
||||
|
||||
|
||||
class UplinkReplica:
|
||||
"""One uplink's replica, its sync client and write-behind queue."""
|
||||
|
||||
def __init__(self, uplink: Uplink):
|
||||
self.uplink = uplink # uplink.uplink_id is set
|
||||
self.db = ReplicaDB()
|
||||
self.last_contact = 0.0 # monotonic time the feed last went down
|
||||
self.connected = False
|
||||
self._pending_refresh: dict[str, dict] = {}
|
||||
@@ -74,13 +344,13 @@ class RemoteReplica:
|
||||
if not self.last_contact:
|
||||
return False
|
||||
return self.connected or (
|
||||
time.monotonic() - self.last_contact <= self.remote.cache_ttl
|
||||
time.monotonic() - self.last_contact <= self.uplink.cache_ttl
|
||||
)
|
||||
|
||||
def refresh_session(
|
||||
self, key: str, validated: datetime, ip: str | None, user_agent: str | None
|
||||
) -> None:
|
||||
"""Apply a /validate refresh locally and queue it for the remote."""
|
||||
"""Apply a /validate refresh locally and queue it for the upstream."""
|
||||
session = self.db.sessions.get(key)
|
||||
if session is not None:
|
||||
session.validated = validated
|
||||
@@ -100,6 +370,7 @@ class RemoteReplica:
|
||||
async def start(self) -> None:
|
||||
self._stopped = False
|
||||
self._task = asyncio.create_task(self._run())
|
||||
self._task.add_done_callback(lambda t: _replica_done(self, t))
|
||||
self._sweeper = asyncio.create_task(self._sweep())
|
||||
|
||||
async def stop(self) -> None:
|
||||
@@ -107,7 +378,7 @@ class RemoteReplica:
|
||||
for task in (self._task, self._sweeper):
|
||||
if task:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
with contextlib.suppress(asyncio.CancelledError, LinkRevokedError):
|
||||
await task
|
||||
|
||||
async def _sweep(self) -> None:
|
||||
@@ -116,6 +387,7 @@ class RemoteReplica:
|
||||
limit = datetime.now(UTC) - SESSION_LIFETIME
|
||||
for key in [k for k, s in self.db.sessions.items() if s.validated < limit]:
|
||||
del self.db.sessions[key]
|
||||
self.db.session_contexts.pop(key, None)
|
||||
|
||||
async def _run(self) -> None:
|
||||
while not self._stopped:
|
||||
@@ -123,8 +395,10 @@ class RemoteReplica:
|
||||
await self._connect()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except LinkRevokedError:
|
||||
raise # the manager unlinks via the done-callback
|
||||
except Exception as e:
|
||||
_logger.info("Sync to %s failed: %s", self.remote.url, e)
|
||||
_logger.info("Sync to %s failed: %s", self.uplink.url, e)
|
||||
if self.connected:
|
||||
# The TTL clock starts when the feed goes down, not at the
|
||||
# last message — an idle connection is healthy.
|
||||
@@ -133,23 +407,55 @@ class RemoteReplica:
|
||||
if not self._stopped:
|
||||
await asyncio.sleep(_RECONNECT_DELAY)
|
||||
|
||||
async def _recv(self, ws, timeout: float | None = None) -> dict:
|
||||
try:
|
||||
if timeout is None:
|
||||
message = await ws.recv()
|
||||
else:
|
||||
message = await asyncio.wait_for(ws.recv(), timeout)
|
||||
return msgspec.json.decode(message)
|
||||
except websockets.exceptions.ConnectionClosedError as e:
|
||||
if e.code == 1008:
|
||||
raise LinkRevokedError(self.uplink.url) from e
|
||||
raise
|
||||
|
||||
async def _connect(self) -> None:
|
||||
ws_url = self.remote.url.replace("http", "ws", 1) + "/auth/api/sync/ws"
|
||||
async with websockets.connect(
|
||||
ws_url,
|
||||
additional_headers={"Authorization": f"Bearer {self.remote.token}"},
|
||||
ws_url_for(self.uplink.url),
|
||||
# Prompt dead-peer detection: availability semantics count on it
|
||||
ping_interval=5,
|
||||
ping_timeout=5,
|
||||
) as ws:
|
||||
await ws.send(
|
||||
syncfeed.encode(
|
||||
{
|
||||
"type": "hello",
|
||||
"link": self.uplink.uplink_id,
|
||||
"key": self.uplink.key.hex(),
|
||||
"label": _label(),
|
||||
}
|
||||
)
|
||||
)
|
||||
welcome = await self._recv(ws, timeout=_HANDSHAKE_TIMEOUT)
|
||||
if welcome.get("type") != "welcome":
|
||||
raise ValueError("sync: protocol error (expected welcome)")
|
||||
# The upstream re-pushes the link's domain set on every connect
|
||||
# (the scope may have changed while offline) — apply and ack.
|
||||
# The snapshot that follows covers any added domains, so no
|
||||
# reconnect is needed here.
|
||||
rejected, _ = apply_domains_push(
|
||||
self.uplink.uplink_id, welcome.get("domains") or [], replica_db=self.db
|
||||
)
|
||||
await ws.send(syncfeed.encode({"type": "ready", "rejected": rejected}))
|
||||
|
||||
sender = asyncio.create_task(self._send_loop(ws))
|
||||
staging: DB | None = None
|
||||
staging: ReplicaDB | None = None
|
||||
ready_at = 0.0
|
||||
try:
|
||||
while True:
|
||||
if ready_at:
|
||||
# Periodic reconnects give full-snapshot reconciliation
|
||||
remaining = self.remote.refresh_interval - (
|
||||
remaining = self.uplink.refresh_interval - (
|
||||
time.monotonic() - ready_at
|
||||
)
|
||||
if remaining <= 0:
|
||||
@@ -164,7 +470,7 @@ class RemoteReplica:
|
||||
message = msgspec.json.decode(await ws.recv())
|
||||
mtype = message.get("type")
|
||||
if mtype == "snapshot":
|
||||
staging = staging or DB()
|
||||
staging = staging or ReplicaDB()
|
||||
for key, fields in message["items"]:
|
||||
_apply(staging, message["table"], key, fields)
|
||||
elif mtype == "event":
|
||||
@@ -176,6 +482,23 @@ class RemoteReplica:
|
||||
message["key"],
|
||||
message.get("fields"),
|
||||
)
|
||||
elif mtype == "domains":
|
||||
# Live scope/origin change: apply and acknowledge. A
|
||||
# changed domain set means a resync: a newly added
|
||||
# domain's existing sessions only arrive with a fresh
|
||||
# snapshot, so reconnect for one.
|
||||
rejected, changed = apply_domains_push(
|
||||
self.uplink.uplink_id,
|
||||
message.get("domains") or [],
|
||||
replica_db=self.db,
|
||||
)
|
||||
await ws.send(
|
||||
syncfeed.encode(
|
||||
{"type": "domains_ack", "rejected": rejected}
|
||||
)
|
||||
)
|
||||
if changed:
|
||||
return
|
||||
elif mtype == "ready":
|
||||
if staging is not None:
|
||||
self.db = staging
|
||||
@@ -192,63 +515,101 @@ class RemoteReplica:
|
||||
self._refresh_signal.clear()
|
||||
while self._pending_refresh:
|
||||
_, message = self._pending_refresh.popitem()
|
||||
await ws.send(msgspec.json.encode(message))
|
||||
await ws.send(syncfeed.encode(message))
|
||||
await self._refresh_signal.wait()
|
||||
|
||||
|
||||
def _apply(replica: DB, table: str, key: str, fields: dict | None) -> None:
|
||||
"""Apply an upsert (fields given) or delete (fields None) to a replica."""
|
||||
def _replica_done(replica: UplinkReplica, task: asyncio.Task) -> None:
|
||||
"""A replica's loop ended: a revocation (the only way out besides
|
||||
cancellation) unlinks the satellite side."""
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if not isinstance(exc, LinkRevokedError):
|
||||
return
|
||||
_logger.info("Uplink %s revoked the link — unlinking", replica.uplink.url)
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
loop.create_task(manager.unlink(replica.uplink.uplink_id))
|
||||
|
||||
|
||||
def _apply(replica: ReplicaDB, table: str, key: str, fields: dict | None) -> None:
|
||||
"""Apply an upsert (fields given) or delete (fields None) to a replica.
|
||||
Session upserts carry their upstream-computed context as ``ctx``."""
|
||||
cls, uuid_key = _TABLES[table]
|
||||
store = getattr(replica, table)
|
||||
store_key = UUID(key) if uuid_key else key
|
||||
if fields is None:
|
||||
store.pop(store_key, None)
|
||||
if table == "sessions":
|
||||
replica.session_contexts.pop(key, None)
|
||||
return
|
||||
fields = dict(fields)
|
||||
ctx = fields.pop("ctx", None) if table == "sessions" else None
|
||||
obj = msgspec.convert(fields, cls)
|
||||
if uuid_key:
|
||||
obj.uuid = store_key
|
||||
else:
|
||||
obj.key = key
|
||||
store[store_key] = obj
|
||||
if table == "sessions":
|
||||
if ctx is not None:
|
||||
replica.session_contexts[key] = msgspec.convert(ctx, ApiSessionContext)
|
||||
else:
|
||||
replica.session_contexts.pop(key, None)
|
||||
|
||||
|
||||
class SatelliteManager:
|
||||
"""Replicas keyed by remote URL; domains sharing a remote share one."""
|
||||
"""Replicas keyed by uplink id; one connection per uplink carries all
|
||||
of the link's domains."""
|
||||
|
||||
def __init__(self):
|
||||
self.replicas: dict[str, RemoteReplica] = {}
|
||||
self.replicas: dict[str, UplinkReplica] = {}
|
||||
self.started = False
|
||||
|
||||
async def start(self) -> None:
|
||||
domains.add_rebuild_listener(self.reconcile)
|
||||
await self.reconcile(domains.registry())
|
||||
self.started = True
|
||||
await self.sync_uplinks()
|
||||
|
||||
async def stop(self) -> None:
|
||||
domains.remove_rebuild_listener(self.reconcile)
|
||||
self.started = False
|
||||
for replica in self.replicas.values():
|
||||
await replica.stop()
|
||||
self.replicas.clear()
|
||||
for url in list(_clients):
|
||||
await _drop_client(url)
|
||||
|
||||
async def reconcile(self, registry: domains.DomainRegistry) -> None:
|
||||
"""Start/stop replicas to match the configured remote domains."""
|
||||
wanted = {}
|
||||
for domain in registry.domains:
|
||||
if domain.remote is not None:
|
||||
wanted.setdefault(domain.remote.url, domain.remote)
|
||||
for url in list(self.replicas):
|
||||
if url not in wanted:
|
||||
await self.replicas.pop(url).stop()
|
||||
await _drop_client(url)
|
||||
for url, remote in wanted.items():
|
||||
replica = self.replicas.get(url)
|
||||
if replica is None or replica.remote != remote:
|
||||
async def sync_uplinks(self) -> None:
|
||||
"""Start/stop replicas to match the stored uplinks."""
|
||||
wanted = db.data().uplinks
|
||||
for uplink_id in list(self.replicas):
|
||||
if uplink_id not in wanted:
|
||||
replica = self.replicas.pop(uplink_id)
|
||||
await replica.stop()
|
||||
await _drop_client(replica.uplink.url)
|
||||
# Snapshot: a revocation task may mutate the uplinks mid-loop
|
||||
for uplink_id, uplink in list(wanted.items()):
|
||||
uplink.uplink_id = uplink_id # fresh objects lack it until load
|
||||
replica = self.replicas.get(uplink_id)
|
||||
if replica is None or replica.uplink != uplink:
|
||||
if replica is not None:
|
||||
await replica.stop()
|
||||
replica = RemoteReplica(remote)
|
||||
self.replicas[url] = replica
|
||||
replica = UplinkReplica(uplink)
|
||||
self.replicas[uplink_id] = replica
|
||||
await replica.start()
|
||||
|
||||
async def unlink(self, uplink_id: str) -> None:
|
||||
"""Drop an uplink and every domain it serves (admin unlink, or the
|
||||
upstream revoked the link)."""
|
||||
replica = self.replicas.pop(uplink_id, None)
|
||||
if replica is not None:
|
||||
await replica.stop()
|
||||
await _drop_client(replica.uplink.url)
|
||||
db.delete_uplink(uplink_id)
|
||||
domains.init_registry(db.data().config)
|
||||
|
||||
|
||||
manager = SatelliteManager()
|
||||
|
||||
@@ -258,21 +619,22 @@ manager = SatelliteManager()
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def replica_for_host(host: str | None) -> RemoteReplica | None:
|
||||
def replica_for_host(host: str | None) -> UplinkReplica | None:
|
||||
"""The replica serving this host, or None for locally served hosts."""
|
||||
domain = domains.registry().resolve(host)
|
||||
if domain is None or domain.remote is None:
|
||||
return None
|
||||
return manager.replicas.get(domain.remote.url)
|
||||
return manager.replicas.get(domain.remote.link)
|
||||
|
||||
|
||||
def store_for_host(host: str | None) -> DB:
|
||||
"""The data store to read for a request host: the local database, or
|
||||
the replica of the remote backing the host's domain."""
|
||||
replica = replica_for_host(host)
|
||||
if replica is None:
|
||||
the replica of the uplink backing the host's domain."""
|
||||
domain = domains.registry().resolve(host)
|
||||
if domain is None or domain.remote is None:
|
||||
return db.data()
|
||||
if not replica.available():
|
||||
replica = manager.replicas.get(domain.remote.link)
|
||||
if replica is None or not replica.available():
|
||||
raise HTTPException(503, "Remote authentication service unavailable")
|
||||
return replica.db
|
||||
|
||||
@@ -299,7 +661,9 @@ def evict_session(auth: str, host: str | None) -> None:
|
||||
"""Drop a session from the replica (its remote deletion arrives via sync)."""
|
||||
replica = replica_for_host(host)
|
||||
if replica is not None:
|
||||
replica.db.sessions.pop(hash_secret("cookie", auth), None)
|
||||
key = hash_secret("cookie", auth)
|
||||
replica.db.sessions.pop(key, None)
|
||||
replica.db.session_contexts.pop(key, None)
|
||||
|
||||
|
||||
_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
|
||||
@@ -322,23 +686,32 @@ _clients: dict[str, httpx.AsyncClient] = {}
|
||||
|
||||
|
||||
async def _drop_client(url: str) -> None:
|
||||
"""Close and forget the forwarding client for a remote URL."""
|
||||
"""Close and forget the forwarding client for an uplink URL."""
|
||||
client = _clients.pop(url, None)
|
||||
if client is not None:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def forward_request(request: Request) -> Response | None:
|
||||
"""Forward the request to its domain's remote, or None when local.
|
||||
def _uplink_url(domain: domains.Domain) -> str:
|
||||
"""The base URL of the uplink backing a remote domain (503 if the
|
||||
config and the uplinks table desynced — a bug, not a local answer)."""
|
||||
uplink = db.data().uplinks.get(domain.remote.link)
|
||||
if uplink is None:
|
||||
raise HTTPException(503, "Remote authentication service unavailable")
|
||||
return uplink.url
|
||||
|
||||
The original Host header is preserved so the remote dispatches to the
|
||||
|
||||
async def forward_request(request: Request) -> Response | None:
|
||||
"""Forward the request to its domain's uplink, or None when local.
|
||||
|
||||
The original Host header is preserved so the upstream dispatches to the
|
||||
same domain (sessions are host-bound). The user's cookie authenticates
|
||||
the forwarded call; the satellite needs no credentials of its own.
|
||||
"""
|
||||
domain = domains.registry().resolve(request.headers.get("host"))
|
||||
if domain is None or domain.remote is None:
|
||||
return None
|
||||
url = domain.remote.url
|
||||
url = _uplink_url(domain)
|
||||
client = _clients.get(url)
|
||||
if client is None:
|
||||
client = _clients[url] = httpx.AsyncClient(base_url=url, timeout=_TIMEOUT)
|
||||
|
||||
+95
-27
@@ -2,60 +2,128 @@
|
||||
|
||||
Nothing here touches the database file: committed mutations are pushed to
|
||||
connected satellites over the sync WebSocket (fastapi/sync.py). Satellites
|
||||
authenticate with a token from the PASKIA_SYNC_TOKENS environment variable
|
||||
(comma-separated); with the variable unset the sync endpoint stays closed.
|
||||
authenticate with a link credential created at registration (stored in the
|
||||
database, scoped to the link's rp-ids); a scoped subscription receives only
|
||||
its own rp-ids' sessions and credentials — never the user graph. The scope
|
||||
set is live: adjusting the link's scope mutates it in place.
|
||||
|
||||
Data minimization is per session: every session upsert carries an embedded
|
||||
``ctx`` (the ApiSessionContext also sent to users) holding the session's
|
||||
user/org/role display data and effective permissions, computed for the
|
||||
session's bound host. Mutations of the user graph (users, orgs, roles,
|
||||
permissions) are not published; instead they trigger an eager re-push of
|
||||
the affected sessions with freshly computed contexts, so revocations and
|
||||
renames reach satellites within one RTT.
|
||||
|
||||
There is deliberately no replay log: snapshots are small, so a reconnecting
|
||||
satellite simply takes a fresh one.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from uuid import UUID
|
||||
|
||||
import msgspec
|
||||
|
||||
# Tables mirrored by satellites (reset tokens, OIDC data and domain config
|
||||
# are instance-local and never replicated).
|
||||
TABLES = ("permissions", "orgs", "roles", "users", "credentials", "sessions")
|
||||
# Tables mirrored by satellites, all filtered to the subscriber's rp-id
|
||||
# scope set (reset tokens, link tables, OIDC data and domain config are
|
||||
# instance-local and never replicated).
|
||||
TABLES = ("credentials", "sessions")
|
||||
|
||||
_subscribers: set[asyncio.Queue] = set()
|
||||
|
||||
# The user graph is never published; upserts of these tables drive the
|
||||
# eager re-push of affected session contexts instead.
|
||||
_GRAPH_TABLES = ("permissions", "orgs", "roles", "users")
|
||||
|
||||
_subscribers: dict[asyncio.Queue, set[str] | None] = {}
|
||||
|
||||
|
||||
def session_ctx_dict(session) -> dict | None:
|
||||
"""The session's ApiSessionContext as builtins (embedded in session
|
||||
events and snapshots), computed for the session's bound host."""
|
||||
from paskia import db # noqa: PLC0415 (import cycle)
|
||||
from paskia.util import userinfo # noqa: PLC0415 (import cycle)
|
||||
|
||||
data = db.data()
|
||||
if data is None:
|
||||
return None
|
||||
ctx = data.session_ctx_for(session, session.host)
|
||||
if ctx is None:
|
||||
return None
|
||||
return msgspec.to_builtins(userinfo.build_session_context(ctx))
|
||||
|
||||
|
||||
def _repush_contexts(table: str, key: str) -> None:
|
||||
"""Re-emit the sessions whose context a user-graph upsert may have
|
||||
changed: each carries a freshly computed ctx. Deletions are not needed
|
||||
here — those cascade as session/credential delete events."""
|
||||
from paskia import db # noqa: PLC0415 (import cycle)
|
||||
|
||||
data = db.data()
|
||||
if data is None:
|
||||
return
|
||||
uuid = UUID(key)
|
||||
if table == "users":
|
||||
affected = {uuid}
|
||||
elif table == "roles":
|
||||
affected = {u.uuid for u in data.users.values() if u.role_uuid == uuid}
|
||||
elif table == "orgs":
|
||||
affected = {
|
||||
u.uuid
|
||||
for u in data.users.values()
|
||||
if data.roles.get(u.role_uuid) is not None
|
||||
and data.roles[u.role_uuid].org_uuid == uuid
|
||||
}
|
||||
else: # permissions: roles containing it, then their users
|
||||
affected = {
|
||||
u.uuid
|
||||
for u in data.users.values()
|
||||
if (role := data.roles.get(u.role_uuid)) is not None
|
||||
and uuid in role.permissions
|
||||
}
|
||||
for skey, session in data.sessions.items():
|
||||
if session.user_uuid in affected:
|
||||
emit("sessions", skey, session)
|
||||
|
||||
|
||||
def emit(table: str, key: str, obj) -> None:
|
||||
"""Publish an upsert (obj given) or delete (obj None) to subscribers."""
|
||||
if not _subscribers:
|
||||
return
|
||||
event = {
|
||||
"type": "event",
|
||||
"table": table,
|
||||
"key": key,
|
||||
"fields": msgspec.to_builtins(obj) if obj is not None else None,
|
||||
}
|
||||
for queue in list(_subscribers):
|
||||
if table in _GRAPH_TABLES:
|
||||
# The user graph stays on the master; its changes reach satellites
|
||||
# as re-pushed session contexts.
|
||||
if obj is not None:
|
||||
_repush_contexts(table, key)
|
||||
return
|
||||
fields = msgspec.to_builtins(obj) if obj is not None else None
|
||||
if table == "sessions" and obj is not None:
|
||||
fields["ctx"] = session_ctx_dict(obj)
|
||||
event = {"type": "event", "table": table, "key": key, "fields": fields}
|
||||
for queue, scope in list(_subscribers.items()):
|
||||
if (
|
||||
scope is not None
|
||||
and table in TABLES
|
||||
and fields is not None
|
||||
and fields.get("rp_id") not in scope
|
||||
):
|
||||
# Out of scope upsert; deletes pass (a replica ignores unknown
|
||||
# keys, and the deleted object no longer tells its rp-id).
|
||||
continue
|
||||
try:
|
||||
queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
# Slow consumer: drop it; the client reconnects and resyncs.
|
||||
_subscribers.discard(queue)
|
||||
_subscribers.pop(queue, None)
|
||||
|
||||
|
||||
def subscribe() -> asyncio.Queue:
|
||||
def subscribe(scope: set[str] | None = None) -> asyncio.Queue:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
|
||||
_subscribers.add(queue)
|
||||
_subscribers[queue] = scope
|
||||
return queue
|
||||
|
||||
|
||||
def unsubscribe(queue: asyncio.Queue) -> None:
|
||||
_subscribers.discard(queue)
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
_subscribers.pop(queue, None)
|
||||
|
||||
|
||||
def encode(message: dict) -> bytes:
|
||||
|
||||
@@ -70,9 +70,9 @@ def _covered_by_wildcard(key: str, pattern: str) -> bool:
|
||||
matched = hostutil.is_subdomain(hostname, base)
|
||||
else:
|
||||
# '*.base' covers exactly one subdomain level
|
||||
matched = hostname.endswith(f".{base}") and "." not in hostname[
|
||||
: -len(base) - 1
|
||||
]
|
||||
matched = (
|
||||
hostname.endswith(f".{base}") and "." not in hostname[: -len(base) - 1]
|
||||
)
|
||||
if not matched:
|
||||
return False
|
||||
if hostutil.is_subdomain(base, "localhost"):
|
||||
@@ -125,7 +125,11 @@ def print_startup_config(
|
||||
header_urls = [f"{w}{vite_url}{r} (vite dev)"]
|
||||
else:
|
||||
header_urls = [
|
||||
f"{w}{url}{r}" for d in domains if (url := auth_host_url(d.config))
|
||||
f"{w}{url}{r}"
|
||||
for d in domains
|
||||
# A satellite-served domain's auth host lives on the upstream —
|
||||
# the header links this instance's own account pages.
|
||||
if d.config.remote is None and (url := auth_host_url(d.config))
|
||||
]
|
||||
if not header_urls:
|
||||
guess = min(
|
||||
@@ -164,6 +168,8 @@ def print_startup_config(
|
||||
rp_name = domain.rp_name
|
||||
suffix = f" ({rp_name})" if rp_name and rp_name != domain.rp_id else ""
|
||||
head = f"{w}{domain.rp_id}{r}{suffix}"
|
||||
if domain.remote is not None:
|
||||
head += " 🛰" # satellite-served via an uplink
|
||||
if not domain.config.origins:
|
||||
rows.append(f"{head} — no sign-in sites")
|
||||
continue
|
||||
|
||||
+3
-1
@@ -324,7 +324,9 @@ def test_migrate_merges_kantadb_into_existing_database(run_cli, tmp_path):
|
||||
def test_migrate_merge_label_combines_rp_ids(run_cli, tmp_path):
|
||||
"""A multi-domain source merges in one transaction, rp-ids slash-joined."""
|
||||
run_cli("init", "company.com")
|
||||
src = write_kantadb(tmp_path, {"one.com": "One", "two.com": "Two"}, name="x.kantadb")
|
||||
src = write_kantadb(
|
||||
tmp_path, {"one.com": "One", "two.com": "Two"}, name="x.kantadb"
|
||||
)
|
||||
|
||||
run_cli("migrate", str(src))
|
||||
|
||||
|
||||
+493
-225
@@ -1,6 +1,5 @@
|
||||
"""Tests for remote (satellite) domains: config, replica application, feed."""
|
||||
"""Tests for remote (satellite) domains: config, links, replica, feed."""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
@@ -15,25 +14,46 @@ from fastapi import Response
|
||||
import paskia.db.operations as ops_db
|
||||
from paskia import domains, satellite, syncfeed
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
Config,
|
||||
Credential,
|
||||
DomainConfig,
|
||||
Org,
|
||||
OriginEntry,
|
||||
Permission,
|
||||
RemoteConfig,
|
||||
Role,
|
||||
Session,
|
||||
Uplink,
|
||||
User,
|
||||
)
|
||||
from paskia.fastapi import sync
|
||||
from paskia.fastapi.mainapp import app
|
||||
from paskia.fastapi.session import AUTH_COOKIE_NAME
|
||||
from paskia.util.apistructs import (
|
||||
ApiOrgContext,
|
||||
ApiRoleContext,
|
||||
ApiSessionContext,
|
||||
ApiUserContext,
|
||||
)
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
from .conftest import TEST_RP_ID
|
||||
from .conftest import TEST_RP_ID, create_test_session
|
||||
|
||||
REMOTE_URL = "http://remote.test"
|
||||
UPLINK_ID = "up1"
|
||||
|
||||
|
||||
def _api_ctx(display_name="Remote Admin", permissions=("auth:admin",)):
|
||||
"""An upstream-pushed per-session context, as embedded in session events."""
|
||||
return ApiSessionContext(
|
||||
user=ApiUserContext(uuid=UUID(int=104), display_name=display_name),
|
||||
org=ApiOrgContext(uuid=UUID(int=101), display_name="Org"),
|
||||
role=ApiRoleContext(uuid=UUID(int=103), display_name="Admins"),
|
||||
permissions=list(permissions),
|
||||
)
|
||||
|
||||
|
||||
def _uplink(**kw) -> Uplink:
|
||||
uplink = Uplink(url=REMOTE_URL, created=datetime.now(UTC), key=b"k", **kw)
|
||||
uplink.uplink_id = UPLINK_ID
|
||||
return uplink
|
||||
|
||||
|
||||
def _remote_domain_config(**kw) -> Config:
|
||||
@@ -45,7 +65,7 @@ def _remote_domain_config(**kw) -> Config:
|
||||
"**.example.com": True,
|
||||
"auth.example.com": OriginEntry(auth_host=True),
|
||||
},
|
||||
remote=RemoteConfig(url=REMOTE_URL, token="t", **kw),
|
||||
remote=RemoteConfig(link=UPLINK_ID, **kw),
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -62,27 +82,38 @@ def test_remote_domain_requires_auth_host():
|
||||
domains.validate_config(config)
|
||||
|
||||
|
||||
def test_remote_domain_requires_http_url():
|
||||
config = _remote_domain_config()
|
||||
config.domains["example.com"].remote.url = "ftp://x"
|
||||
with pytest.raises(ValueError, match="http"):
|
||||
domains.validate_config(config)
|
||||
|
||||
|
||||
def test_sanitize_preserves_remote():
|
||||
config, warnings = domains.sanitize_config(_remote_domain_config())
|
||||
assert not warnings
|
||||
assert config.domains["example.com"].remote.url == REMOTE_URL
|
||||
assert config.domains["example.com"].remote.link == UPLINK_ID
|
||||
|
||||
|
||||
def test_sanitize_drops_remote_without_link():
|
||||
"""A remote config pointing nowhere (e.g. a v1 leftover) degrades to a
|
||||
plain local domain instead of failing to serve."""
|
||||
config = _remote_domain_config()
|
||||
config.domains["example.com"].remote = RemoteConfig()
|
||||
sanitized, warnings = domains.sanitize_config(config)
|
||||
assert any("remote dropped" in w for w in warnings)
|
||||
assert sanitized.domains["example.com"].remote is None
|
||||
domains.validate_config(sanitized)
|
||||
|
||||
|
||||
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), _builtins(user))
|
||||
assert replica.users[user.uuid].display_name == "U"
|
||||
satellite._apply(replica, "users", str(user.uuid), None)
|
||||
assert not replica.users
|
||||
replica = satellite.ReplicaDB()
|
||||
cred = Credential.create(
|
||||
credential_id=b"cid",
|
||||
user=UUID(int=1),
|
||||
aaguid=UUID(int=0),
|
||||
public_key=b"pk",
|
||||
sign_count=0,
|
||||
rp_id="example.com",
|
||||
)
|
||||
cred.uuid = UUID(int=2)
|
||||
satellite._apply(replica, "credentials", str(cred.uuid), _builtins(cred))
|
||||
assert replica.credentials[cred.uuid].public_key == b"pk"
|
||||
satellite._apply(replica, "credentials", str(cred.uuid), None)
|
||||
assert not replica.credentials
|
||||
|
||||
|
||||
def _builtins(obj):
|
||||
@@ -90,8 +121,9 @@ def _builtins(obj):
|
||||
|
||||
|
||||
def test_apply_session_roundtrip():
|
||||
"""Sessions keep their string key and datetime/UUID fields."""
|
||||
replica = DB()
|
||||
"""Sessions keep their string key and datetime/UUID fields; the
|
||||
embedded ctx lands in the replica's context map (and goes with it)."""
|
||||
replica = satellite.ReplicaDB()
|
||||
session = Session.create(
|
||||
user=UUID(int=1),
|
||||
credential=UUID(int=2),
|
||||
@@ -102,16 +134,22 @@ def test_apply_session_roundtrip():
|
||||
validated=datetime.now(UTC),
|
||||
rp_id="example.com",
|
||||
)
|
||||
satellite._apply(replica, "sessions", session.key, _builtins(session))
|
||||
fields = _builtins(session)
|
||||
fields["ctx"] = _builtins(_api_ctx())
|
||||
satellite._apply(replica, "sessions", session.key, fields)
|
||||
stored = replica.sessions[session.key]
|
||||
assert stored.host == "app2.example.com"
|
||||
assert stored.validated == session.validated
|
||||
assert stored.user_uuid == UUID(int=1)
|
||||
assert replica.session_contexts[session.key].user.display_name == "Remote Admin"
|
||||
satellite._apply(replica, "sessions", session.key, None)
|
||||
assert not replica.sessions
|
||||
assert not replica.session_contexts
|
||||
|
||||
|
||||
def test_apply_credential_bytes_roundtrip():
|
||||
"""credential_id/public_key are bytes over the wire (base64 in JSON)."""
|
||||
replica = DB()
|
||||
replica = satellite.ReplicaDB()
|
||||
cred = Credential.create(
|
||||
credential_id=secrets.token_bytes(32),
|
||||
user=UUID(int=1),
|
||||
@@ -133,10 +171,17 @@ def test_apply_credential_bytes_roundtrip():
|
||||
def test_feed_emit_to_subscribers():
|
||||
queue = syncfeed.subscribe()
|
||||
try:
|
||||
user = User.create(display_name="A", role=UUID(int=1))
|
||||
syncfeed.emit("users", "k1", user)
|
||||
syncfeed.emit("users", "k1", None)
|
||||
assert queue.get_nowait()["fields"]["display_name"] == "A"
|
||||
cred = Credential.create(
|
||||
credential_id=b"c",
|
||||
user=UUID(int=1),
|
||||
aaguid=UUID(int=0),
|
||||
public_key=b"p",
|
||||
sign_count=0,
|
||||
rp_id="example.com",
|
||||
)
|
||||
syncfeed.emit("credentials", "k1", cred)
|
||||
syncfeed.emit("credentials", "k1", None)
|
||||
assert queue.get_nowait()["fields"]["sign_count"] == 0
|
||||
assert queue.get_nowait()["fields"] is None
|
||||
finally:
|
||||
syncfeed.unsubscribe(queue)
|
||||
@@ -146,32 +191,37 @@ def test_feed_drops_full_queue():
|
||||
queue = syncfeed.subscribe()
|
||||
try:
|
||||
for i in range(1001):
|
||||
syncfeed.emit("users", f"k{i}", None)
|
||||
syncfeed.emit("credentials", f"k{i}", None)
|
||||
assert queue.qsize() == 1000
|
||||
syncfeed.emit("users", "k1001", None) # subscriber already dropped
|
||||
syncfeed.emit("credentials", "k1001", None) # subscriber already dropped
|
||||
assert queue.qsize() == 1000
|
||||
finally:
|
||||
syncfeed.unsubscribe(queue)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operations_emit_events(test_db):
|
||||
"""Writes through db.operations land on the sync feed."""
|
||||
async def test_operations_repush_session_contexts(test_db, test_user, test_credential):
|
||||
"""User-graph changes are never published; instead the affected
|
||||
sessions are re-pushed with a freshly computed ctx."""
|
||||
queue = syncfeed.subscribe()
|
||||
try:
|
||||
user = next(iter(test_db.users.values()))
|
||||
ops_db.update_user_display_name(user.uuid, "Renamed")
|
||||
event = queue.get_nowait()
|
||||
assert event["table"] == "users"
|
||||
assert event["key"] == str(user.uuid)
|
||||
assert event["fields"]["display_name"] == "Renamed"
|
||||
key, _ = create_test_session(
|
||||
user_uuid=test_user.uuid, credential_uuid=test_credential.uuid
|
||||
)
|
||||
_drain(queue) # the session store itself emits (with ctx)
|
||||
ops_db.update_user_display_name(test_user.uuid, "Renamed")
|
||||
events = _drain(queue)
|
||||
assert all(e["table"] != "users" for e in events)
|
||||
repushed = [e for e in events if e["table"] == "sessions" and e["key"] == key]
|
||||
assert repushed
|
||||
assert repushed[-1]["fields"]["ctx"]["user"]["display_name"] == "Renamed"
|
||||
finally:
|
||||
syncfeed.unsubscribe(queue)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replica_refresh_and_evict():
|
||||
replica = satellite.RemoteReplica(RemoteConfig(url=REMOTE_URL, token="t"))
|
||||
replica = satellite.UplinkReplica(_uplink())
|
||||
token = secrets.token_urlsafe(12)
|
||||
session = Session.create(
|
||||
user=UUID(int=1),
|
||||
@@ -191,48 +241,135 @@ async def test_replica_refresh_and_evict():
|
||||
assert queued["type"] == "session_refresh"
|
||||
assert queued["ip"] == "2.2.2.2"
|
||||
|
||||
# Host-keyed dispatch eviction (the replica's domain is resolved by host)
|
||||
# Host-keyed dispatch eviction (the replica's uplink is resolved by host)
|
||||
domains.configure(listen=["localhost:4401"])
|
||||
domains.init_registry(_remote_domain_config())
|
||||
satellite.manager.replicas[REMOTE_URL] = replica
|
||||
satellite.manager.replicas[UPLINK_ID] = replica
|
||||
satellite.evict_session(token, "app2.example.com")
|
||||
assert not replica.db.sessions
|
||||
satellite.manager.replicas.pop(REMOTE_URL)
|
||||
satellite.manager.replicas.pop(UPLINK_ID)
|
||||
|
||||
|
||||
def test_availability_gate():
|
||||
replica = satellite.RemoteReplica(
|
||||
RemoteConfig(url=REMOTE_URL, token="t", cache_ttl=60)
|
||||
)
|
||||
replica = satellite.UplinkReplica(_uplink(cache_ttl=60))
|
||||
assert not replica.available() # never synced
|
||||
replica.last_contact = time.monotonic()
|
||||
assert replica.available()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Link URLs and registration offers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_link_url():
|
||||
base, key = satellite.parse_link_url(
|
||||
"wss://alpha.bravo.charlie.delta.echo@auth.example.com"
|
||||
)
|
||||
assert base == "https://auth.example.com"
|
||||
assert key == "alpha.bravo.charlie.delta.echo"
|
||||
base, key = satellite.parse_link_url("ws://k3y@127.0.0.1:4401")
|
||||
assert base == "http://127.0.0.1:4401"
|
||||
assert key == "k3y"
|
||||
with pytest.raises(ValueError, match="wss"):
|
||||
satellite.parse_link_url("https://alpha.bravo@auth.example.com")
|
||||
with pytest.raises(ValueError, match="key"):
|
||||
satellite.parse_link_url("wss://auth.example.com")
|
||||
|
||||
|
||||
def test_offer_mint_and_spend():
|
||||
"""Offers are wss URLs with a RAM-only, single-use, expiring key."""
|
||||
url = sync.create_offer(["example.com"], "https://auth.example.com")
|
||||
assert url.startswith("wss://")
|
||||
assert url.endswith("@auth.example.com")
|
||||
key = url.split("://", 1)[1].split("@", 1)[0]
|
||||
offer = sync._pop_offer(key)
|
||||
assert offer is not None and offer.rp_ids == ["example.com"]
|
||||
assert sync._pop_offer(key) is None # single-use
|
||||
assert sync._pop_offer("unknown.key.here") is None
|
||||
|
||||
expired = sync.create_offer([], "http://127.0.0.1:4401")
|
||||
assert expired.startswith("ws://")
|
||||
key = expired.split("://", 1)[1].split("@", 1)[0]
|
||||
sync._offers[key].created -= sync.OFFER_TTL + 1
|
||||
assert sync._pop_offer(key) is None # expired
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Pushed-domain application (satellite side)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
_PUSHED = {
|
||||
"rp_id": "example.com",
|
||||
"rp_name": "Example",
|
||||
"origins": {"**.example.com": True, "auth.example.com": {"auth_host": True}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_domains_push_accept_and_remove(test_db, domain_registry):
|
||||
"""A push persists the accepted set (marked remote via the link); a
|
||||
later push without the domain removes it."""
|
||||
rejected, changed = satellite.apply_domains_push(UPLINK_ID, [_PUSHED])
|
||||
assert not rejected
|
||||
assert changed
|
||||
domain = test_db.config.domains["example.com"]
|
||||
assert domain.remote.link == UPLINK_ID
|
||||
assert domain.rp_name == "Example"
|
||||
assert domains.registry().get("example.com").remote is not None
|
||||
|
||||
# Idempotent: the same push again changes nothing
|
||||
rejected, changed = satellite.apply_domains_push(UPLINK_ID, [_PUSHED])
|
||||
assert not rejected and not changed
|
||||
|
||||
# The uplink's scope shrank: the domain leaves the local config
|
||||
rejected, changed = satellite.apply_domains_push(UPLINK_ID, [])
|
||||
assert not rejected
|
||||
assert changed
|
||||
assert "example.com" not in test_db.config.domains
|
||||
assert domains.registry().get("example.com") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_domains_push_rejects_local_conflict(test_db, domain_registry):
|
||||
"""A pushed rp-id that this instance serves locally is rejected."""
|
||||
rejected, changed = satellite.apply_domains_push(
|
||||
UPLINK_ID, [{**_PUSHED, "rp_id": TEST_RP_ID}]
|
||||
)
|
||||
assert TEST_RP_ID in rejected
|
||||
assert not changed
|
||||
assert test_db.config.domains[TEST_RP_ID].remote is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_domains_push_rejects_auth_host_collision(test_db, domain_registry):
|
||||
"""A pushed auth host that collides with a local rp-id is rejected
|
||||
(the merged config must stay servable)."""
|
||||
rejected, _ = satellite.apply_domains_push(
|
||||
UPLINK_ID,
|
||||
[
|
||||
{
|
||||
"rp_id": "example.com",
|
||||
"rp_name": "Example",
|
||||
"origins": {"**.example.com": True, TEST_RP_ID: {"auth_host": True}},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "example.com" in rejected
|
||||
assert "example.com" not in test_db.config.domains
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# API-level: endpoints served from an injected replica
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _replica_db() -> tuple[DB, str]:
|
||||
"""A replica DB holding one org/role/perm/user/credential/session."""
|
||||
replica = DB()
|
||||
org = Org.create(display_name="Org")
|
||||
org.uuid = UUID(int=101)
|
||||
replica.orgs[org.uuid] = org
|
||||
perm = Permission.create(scope="auth:admin", display_name="Admin")
|
||||
perm.uuid = UUID(int=102)
|
||||
perm.orgs[org.uuid] = True
|
||||
replica.permissions[perm.uuid] = perm
|
||||
role = Role.create(org=org.uuid, display_name="Admins", permissions={perm.uuid})
|
||||
role.uuid = UUID(int=103)
|
||||
replica.roles[role.uuid] = role
|
||||
user = User.create(display_name="Remote Admin", role=role.uuid)
|
||||
user.uuid = UUID(int=104)
|
||||
replica.users[user.uuid] = user
|
||||
def _replica_db() -> tuple[satellite.ReplicaDB, str]:
|
||||
"""A replica holding one credential/session and its pushed context."""
|
||||
replica = satellite.ReplicaDB()
|
||||
cred = Credential.create(
|
||||
credential_id=b"cid",
|
||||
user=user.uuid,
|
||||
user=UUID(int=104),
|
||||
aaguid=UUID(int=0),
|
||||
public_key=b"pk",
|
||||
sign_count=0,
|
||||
@@ -242,7 +379,7 @@ def _replica_db() -> tuple[DB, str]:
|
||||
replica.credentials[cred.uuid] = cred
|
||||
secret = secrets.token_urlsafe(12)
|
||||
session = Session.create(
|
||||
user=user.uuid,
|
||||
user=UUID(int=104),
|
||||
credential=cred.uuid,
|
||||
key=hash_secret("cookie", secret),
|
||||
host="app2.example.com",
|
||||
@@ -252,6 +389,7 @@ def _replica_db() -> tuple[DB, str]:
|
||||
rp_id="example.com",
|
||||
)
|
||||
replica.sessions[session.key] = session
|
||||
replica.session_contexts[session.key] = _api_ctx()
|
||||
return replica, secret
|
||||
|
||||
|
||||
@@ -261,18 +399,19 @@ async def remote_client(test_db):
|
||||
config = _remote_domain_config()
|
||||
domains.configure(listen=["localhost:4401"])
|
||||
domains.init_registry(config)
|
||||
ops_db.create_uplink(UPLINK_ID, _uplink(), {})
|
||||
replica_db, secret = _replica_db()
|
||||
replica = satellite.RemoteReplica(RemoteConfig(url=REMOTE_URL, token="t"))
|
||||
replica = satellite.UplinkReplica(_uplink())
|
||||
replica.db = replica_db
|
||||
replica.last_contact = time.monotonic()
|
||||
replica.connected = True
|
||||
satellite.manager.replicas[REMOTE_URL] = replica
|
||||
satellite.manager.replicas[UPLINK_ID] = replica
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://localhost:4401"
|
||||
) as client:
|
||||
yield client, secret, replica
|
||||
satellite.manager.replicas.pop(REMOTE_URL, None)
|
||||
satellite.manager.replicas.pop(UPLINK_ID, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -339,104 +478,17 @@ async def test_logout_proxied_and_evicted(remote_client, monkeypatch):
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert not replica.db.sessions # evicted optimistically
|
||||
assert not replica.db.session_contexts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_configures_remote_domain(client, session_token, test_db):
|
||||
"""The admin domains API stores remote config and masks the token."""
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={
|
||||
"rp_id": "example.com",
|
||||
"rp_name": "Example",
|
||||
"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}",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
stored = test_db.config.domains["example.com"]
|
||||
assert stored.remote.url == "http://remote.test"
|
||||
assert stored.remote.token == "sekret"
|
||||
|
||||
r = await client.get(
|
||||
"/auth/api/admin/domains/",
|
||||
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"
|
||||
assert "token" not in entry["remote"] # write-only
|
||||
|
||||
async def test_admin_rejects_remote_writes(client, session_token, test_db):
|
||||
"""Remote domains are uplink-managed: the domains API refuses to
|
||||
create, edit or delete them."""
|
||||
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):
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={
|
||||
"rp_id": "example.com",
|
||||
"origins": {"**.example.com": True},
|
||||
"remote": {"url": "http://remote.test"},
|
||||
},
|
||||
headers={
|
||||
"Host": "localhost:4401",
|
||||
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "auth host" in r.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_remote_values_clamped(client, session_token, test_db):
|
||||
"""Nonsensical TTL/interval values are clamped to the supported floor."""
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={
|
||||
@@ -445,83 +497,103 @@ async def test_admin_remote_values_clamped(client, session_token, test_db):
|
||||
"**.example.com": True,
|
||||
"auth.example.com": {"auth_host": True},
|
||||
},
|
||||
"remote": {
|
||||
"url": "http://remote.test",
|
||||
"token": "t",
|
||||
"cache_ttl": -5,
|
||||
"refresh_interval": 1,
|
||||
},
|
||||
"remote": {"link": "x"},
|
||||
},
|
||||
headers={
|
||||
"Host": "localhost:4401",
|
||||
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "uplink" in r.json()["detail"]
|
||||
|
||||
# An existing remote domain (as an uplink push would have stored it)
|
||||
ops_db.apply_uplink_domains(
|
||||
UPLINK_ID,
|
||||
{
|
||||
"example.com": DomainConfig(
|
||||
rp_name="Example",
|
||||
origins={
|
||||
"**.example.com": True,
|
||||
"auth.example.com": OriginEntry(auth_host=True),
|
||||
},
|
||||
remote=RemoteConfig(link=UPLINK_ID),
|
||||
)
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
remote = test_db.config.domains["example.com"].remote
|
||||
assert remote.cache_ttl == 1
|
||||
assert remote.refresh_interval == 30
|
||||
|
||||
|
||||
def _add_shared_remote(config: Config, token: str) -> None:
|
||||
config.domains["sub.example.com"] = DomainConfig(
|
||||
origins={
|
||||
"**.sub.example.com": True,
|
||||
"auth.sub.example.com": OriginEntry(auth_host=True),
|
||||
},
|
||||
remote=RemoteConfig(url=REMOTE_URL, token=token),
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/domains/example.com",
|
||||
json={"rp_name": "Ex", "origins": {"**.example.com": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "uplink" in r.json()["detail"]
|
||||
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
|
||||
assert r.status_code == 400
|
||||
assert "unlink" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_shared_remote_same_settings_ok():
|
||||
config = _remote_domain_config()
|
||||
_add_shared_remote(config, token="t")
|
||||
domains.validate_config(config)
|
||||
|
||||
|
||||
def test_shared_remote_conflicting_settings_rejected():
|
||||
config = _remote_domain_config()
|
||||
_add_shared_remote(config, token="different")
|
||||
with pytest.raises(ValueError, match="conflicting"):
|
||||
domains.validate_config(config)
|
||||
|
||||
|
||||
def test_sanitize_aligns_conflicting_shared_remote():
|
||||
config = _remote_domain_config()
|
||||
_add_shared_remote(config, token="different")
|
||||
sanitized, warnings = domains.sanitize_config(config)
|
||||
assert any("conflict" in w for w in warnings)
|
||||
# Aligned to the first configuration, and the result validates
|
||||
assert sanitized.domains["sub.example.com"].remote.token == "t"
|
||||
domains.validate_config(sanitized)
|
||||
|
||||
|
||||
def test_rebuild_listener_skipped_without_running_loop():
|
||||
"""A registry rebuild outside an event loop warns instead of crashing."""
|
||||
|
||||
async def listener(_registry):
|
||||
raise AssertionError("must not run") # pragma: no cover
|
||||
|
||||
domains.add_rebuild_listener(listener)
|
||||
def test_feed_scoped_subscription_filters():
|
||||
"""A scoped subscriber gets only its rp-ids' sessions/credentials
|
||||
(upserts filtered, deletes passed); the user graph is never published."""
|
||||
scoped = syncfeed.subscribe({"example.com"})
|
||||
try:
|
||||
domains.init_registry(_remote_domain_config())
|
||||
in_scope = Session.create(
|
||||
user=UUID(int=1),
|
||||
credential=UUID(int=2),
|
||||
key="k1",
|
||||
host="app2.example.com",
|
||||
ip="1.1.1.1",
|
||||
user_agent="ua",
|
||||
validated=datetime.now(UTC),
|
||||
rp_id="example.com",
|
||||
)
|
||||
out_scope = Session.create(
|
||||
user=UUID(int=1),
|
||||
credential=UUID(int=3),
|
||||
key="k2",
|
||||
host="localhost",
|
||||
ip="1.1.1.1",
|
||||
user_agent="ua",
|
||||
validated=datetime.now(UTC),
|
||||
rp_id="localhost",
|
||||
)
|
||||
syncfeed.emit("sessions", "k1", in_scope)
|
||||
syncfeed.emit("sessions", "k2", out_scope)
|
||||
syncfeed.emit("users", "u1", User.create(display_name="A", role=UUID(int=1)))
|
||||
syncfeed.emit("sessions", "k2", None) # deletes pass regardless
|
||||
assert [e["key"] for e in _drain(scoped)] == ["k1", "k2"]
|
||||
finally:
|
||||
domains.remove_rebuild_listener(listener)
|
||||
syncfeed.unsubscribe(scoped)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebuild_listener_runs_with_loop():
|
||||
ran = asyncio.Event()
|
||||
|
||||
async def listener(_registry):
|
||||
ran.set()
|
||||
|
||||
domains.add_rebuild_listener(listener)
|
||||
def test_feed_scope_updates_live():
|
||||
"""The subscriber scope is mutable in place: a link's scope adjustment
|
||||
takes effect without a reconnect."""
|
||||
scope = {"example.com"}
|
||||
queue = syncfeed.subscribe(scope)
|
||||
try:
|
||||
domains.init_registry(_remote_domain_config())
|
||||
await asyncio.wait_for(ran.wait(), 2)
|
||||
session = Session.create(
|
||||
user=UUID(int=1),
|
||||
credential=UUID(int=2),
|
||||
key="k1",
|
||||
host="localhost",
|
||||
ip="1.1.1.1",
|
||||
user_agent="ua",
|
||||
validated=datetime.now(UTC),
|
||||
rp_id="localhost",
|
||||
)
|
||||
syncfeed.emit("sessions", "k1", session)
|
||||
assert not _drain(queue)
|
||||
scope.add("localhost") # scope widened live
|
||||
syncfeed.emit("sessions", "k1", session)
|
||||
assert [e["key"] for e in _drain(queue)] == ["k1"]
|
||||
finally:
|
||||
domains.remove_rebuild_listener(listener)
|
||||
syncfeed.unsubscribe(queue)
|
||||
|
||||
|
||||
def _drain(queue):
|
||||
items = []
|
||||
while not queue.empty():
|
||||
items.append(queue.get_nowait())
|
||||
return items
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -569,3 +641,199 @@ async def test_user_api_proxied_on_remote_auth_host(remote_client, monkeypatch):
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.content == b"proxied"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Satellite links and uplinks (admin API)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_satellite_link_lifecycle(client, session_token, test_db):
|
||||
"""Offer minting (auth host required), scope adjustment, revocation."""
|
||||
headers = {
|
||||
"Host": "localhost:4401",
|
||||
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
|
||||
}
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={
|
||||
"rp_id": "example.com",
|
||||
"rp_name": "Example",
|
||||
"origins": {"**.example.com": True},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# No auth host yet — the domain cannot be scoped into a link
|
||||
r = await client.post(
|
||||
"/auth/api/admin/satellites/", json={"rp_ids": ["example.com"]}, headers=headers
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "auth host" in r.json()["detail"]
|
||||
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/domains/example.com",
|
||||
json={
|
||||
"rp_name": "Example",
|
||||
"origins": {
|
||||
"**.example.com": True,
|
||||
"auth.example.com": {"auth_host": True},
|
||||
},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Mint an offer: a wss URL carrying the RAM-only initial key
|
||||
r = await client.post(
|
||||
"/auth/api/admin/satellites/", json={"rp_ids": ["example.com"]}, headers=headers
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["url"].startswith("wss://")
|
||||
assert "@auth.example.com" in body["url"]
|
||||
key = body["url"].split("://", 1)[1].split("@", 1)[0]
|
||||
assert key.count(".") == 4 # five-word passphrase
|
||||
assert body["expires_in"] > 0
|
||||
|
||||
# Unknown domains and nested scopes are refused
|
||||
r = await client.post(
|
||||
"/auth/api/admin/satellites/",
|
||||
json={"rp_ids": ["nope.example"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# Register a link directly (the handshake itself is covered live),
|
||||
# then list, adjust scope, revoke
|
||||
ops_db.create_satellite_link("ln1", ["example.com"], b"key", label="sat-1")
|
||||
r = await client.get("/auth/api/admin/satellites/", headers=headers)
|
||||
assert r.status_code == 200
|
||||
(entry,) = r.json()
|
||||
assert entry["id"] == "ln1"
|
||||
assert entry["label"] == "sat-1"
|
||||
assert entry["rp_ids"] == ["example.com"]
|
||||
assert entry["connected"] is False
|
||||
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/satellites/ln1", json={"rp_ids": []}, headers=headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert test_db.satellite_links["ln1"].rp_ids == []
|
||||
|
||||
r = await client.delete("/auth/api/admin/satellites/ln1", headers=headers)
|
||||
assert r.status_code == 200
|
||||
assert not test_db.satellite_links
|
||||
r = await client.delete("/auth/api/admin/satellites/ln1", headers=headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_offer_scope_rejects_remote_domain(client, session_token, test_db):
|
||||
"""A satellite-served domain cannot be scoped into a link: no nesting."""
|
||||
headers = {
|
||||
"Host": "localhost:4401",
|
||||
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
|
||||
}
|
||||
ops_db.apply_uplink_domains(
|
||||
UPLINK_ID,
|
||||
{
|
||||
"example.com": DomainConfig(
|
||||
origins={
|
||||
"**.example.com": True,
|
||||
"auth.example.com": OriginEntry(auth_host=True),
|
||||
},
|
||||
remote=RemoteConfig(link=UPLINK_ID),
|
||||
)
|
||||
},
|
||||
)
|
||||
r = await client.post(
|
||||
"/auth/api/admin/satellites/", json={"rp_ids": ["example.com"]}, headers=headers
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "nested" in r.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_uplinks_list_and_unlink(client, session_token, test_db):
|
||||
"""Uplinks list shows served domains and replica state; unlink removes
|
||||
the uplink and its domains."""
|
||||
headers = {
|
||||
"Host": "localhost:4401",
|
||||
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
|
||||
}
|
||||
ops_db.create_uplink(UPLINK_ID, _uplink(), {})
|
||||
ops_db.apply_uplink_domains(
|
||||
UPLINK_ID,
|
||||
{
|
||||
"example.com": DomainConfig(
|
||||
origins={
|
||||
"**.example.com": True,
|
||||
"auth.example.com": OriginEntry(auth_host=True),
|
||||
},
|
||||
remote=RemoteConfig(link=UPLINK_ID),
|
||||
)
|
||||
},
|
||||
)
|
||||
r = await client.get("/auth/api/admin/uplinks/", headers=headers)
|
||||
assert r.status_code == 200
|
||||
(entry,) = r.json()
|
||||
assert entry["id"] == UPLINK_ID
|
||||
assert entry["url"] == REMOTE_URL
|
||||
assert entry["domains"] == ["example.com"]
|
||||
assert entry["connected"] is False
|
||||
|
||||
r = await client.delete(f"/auth/api/admin/uplinks/{UPLINK_ID}", headers=headers)
|
||||
assert r.status_code == 200
|
||||
assert not test_db.uplinks
|
||||
assert "example.com" not in test_db.config.domains
|
||||
r = await client.get("/auth/api/admin/uplinks/", headers=headers)
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_register_uplink_endpoint(client, session_token, monkeypatch):
|
||||
"""The registration endpoint runs the handshake synchronously (the
|
||||
handshake itself is covered by the live tests) and reports failures."""
|
||||
headers = {
|
||||
"Host": "localhost:4401",
|
||||
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
|
||||
}
|
||||
|
||||
async def fake_register(url):
|
||||
return {
|
||||
"link": "ln1",
|
||||
"url": "https://remote.test",
|
||||
"domains": ["example.com"],
|
||||
"rejected": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(satellite, "register_uplink", fake_register)
|
||||
r = await client.post(
|
||||
"/auth/api/admin/uplinks/",
|
||||
json={"url": "wss://alpha.bravo.charlie.delta.echo@remote.test"},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["domains"] == ["example.com"]
|
||||
|
||||
async def failing_register(url):
|
||||
raise RuntimeError("the upstream rejected the link URL")
|
||||
|
||||
monkeypatch.setattr(satellite, "register_uplink", failing_register)
|
||||
r = await client.post(
|
||||
"/auth/api/admin/uplinks/", json={"url": "wss://x@remote.test"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "rejected" in r.json()["detail"]
|
||||
|
||||
async def malformed(url):
|
||||
raise ValueError("A link URL is a wss:// URL")
|
||||
|
||||
monkeypatch.setattr(satellite, "register_uplink", malformed)
|
||||
r = await client.post(
|
||||
"/auth/api/admin/uplinks/", json={"url": "nope"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 400
|
||||
+394
-53
@@ -1,32 +1,51 @@
|
||||
"""Live end-to-end tests for satellite domains over a real sync channel.
|
||||
|
||||
A real uvicorn server plays the remote (its sync WebSocket fed from the
|
||||
A real uvicorn server plays the upstream (its sync WebSocket fed from the
|
||||
process-global database), while the satellite side runs through the ASGI
|
||||
app with a real RemoteReplica started by the real SatelliteManager
|
||||
reconcile. Unlike test_remote.py (which injects a warm replica by hand),
|
||||
nothing here is stubbed: the snapshot, live events, write-behind, dead-peer
|
||||
TTL handling and reconnect reconciliation all cross a TCP socket.
|
||||
app with a real UplinkReplica started by the real SatelliteManager. Unlike
|
||||
test_remote.py (which injects a warm replica by hand), nothing here is
|
||||
stubbed: the 3-way handshake, pushed domains, snapshot, live events,
|
||||
write-behind, dead-peer TTL handling, reconnect reconciliation, live scope
|
||||
changes and revocation all cross a TCP socket.
|
||||
|
||||
One in-process distortion remains: both sides share the module-level
|
||||
database and registry, so endpoints the satellite *forwards* to the remote
|
||||
(logout, set-session, OIDC) would re-forward and loop here — those paths
|
||||
are covered in test_remote.py with a mocked forward_request.
|
||||
database and registry, so the satellite's pushed-domain application is
|
||||
arranged to be a no-op (the link's domains are absent from the shared
|
||||
config — the satellite registry is synthetic instead), and endpoints the
|
||||
satellite *forwards* to the upstream (logout, set-session, OIDC) would
|
||||
re-forward and loop here — those paths are covered in test_remote.py with
|
||||
a mocked forward_request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import msgspec.structs
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import uvicorn
|
||||
from kanta import Kanta
|
||||
|
||||
import paskia.db.operations as ops_db
|
||||
from paskia import domains, satellite
|
||||
from paskia.db.structs import Config, DomainConfig, OriginEntry, RemoteConfig
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
Config,
|
||||
Credential,
|
||||
DomainConfig,
|
||||
OriginEntry,
|
||||
RemoteConfig,
|
||||
Uplink,
|
||||
)
|
||||
from paskia.fastapi import sync
|
||||
from paskia.fastapi.mainapp import app
|
||||
from paskia.fastapi.session import AUTH_COOKIE_NAME
|
||||
|
||||
@@ -34,6 +53,8 @@ from .conftest import TEST_RP_ID, create_test_session
|
||||
|
||||
REMOTE_DOMAIN = "example.com"
|
||||
APP_HOST = "app2.example.com"
|
||||
LINK_ID = "live-link"
|
||||
LINK_KEY = b"e2e-link-key"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
@@ -52,15 +73,12 @@ async def wait_for(desc: str, condition, timeout: float = 10) -> None:
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def live_remote(test_db, monkeypatch):
|
||||
"""A uvicorn-served 'remote' and the satellite manager wired against it.
|
||||
async def live_server(test_db):
|
||||
"""A uvicorn-served upstream over a real TCP socket.
|
||||
|
||||
The manager is started for real: reconcile builds the replica from the
|
||||
domain config and its sync client connects over TCP. Yielded namespace:
|
||||
client (ASGI, satellite side), replica, db (the remote's database),
|
||||
url/port of the remote, start_server (for restarts), servers.
|
||||
Yields a namespace: db, url, port, start_server (for restarts),
|
||||
servers.
|
||||
"""
|
||||
monkeypatch.setenv("PASKIA_SYNC_TOKENS", "live-tok")
|
||||
servers: list[tuple[uvicorn.Server, asyncio.Task]] = []
|
||||
|
||||
async def start_server(port: int) -> uvicorn.Server:
|
||||
@@ -76,7 +94,34 @@ async def live_remote(test_db, monkeypatch):
|
||||
|
||||
port = _free_port()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
domains.configure(listen=["localhost:4401"])
|
||||
domains.init_registry(test_db.config)
|
||||
await start_server(port)
|
||||
try:
|
||||
yield SimpleNamespace(
|
||||
db=test_db, url=url, port=port, start_server=start_server, servers=servers
|
||||
)
|
||||
finally:
|
||||
for server, task in servers:
|
||||
server.should_exit = True
|
||||
await task
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def live_remote(live_server):
|
||||
"""An established link against the live upstream, and the satellite
|
||||
manager started for real: the replica connects over TCP and runs the
|
||||
established-link handshake (hello {link, key} → welcome → ready).
|
||||
|
||||
Both sides' link rows are stored directly (registration itself is
|
||||
covered by the dedicated tests); the satellite's remote domain lives
|
||||
only in the registry, not the shared config, so pushed-domain
|
||||
application stays a no-op.
|
||||
"""
|
||||
live = live_server
|
||||
ops_db.create_satellite_link(LINK_ID, [REMOTE_DOMAIN], LINK_KEY, label="e2e")
|
||||
uplink = Uplink(url=live.url, created=datetime.now(UTC), key=LINK_KEY)
|
||||
ops_db.create_uplink(LINK_ID, uplink, {})
|
||||
|
||||
domains.configure(listen=["localhost:4401"])
|
||||
domains.init_registry(
|
||||
@@ -88,13 +133,13 @@ async def live_remote(test_db, monkeypatch):
|
||||
f"**.{REMOTE_DOMAIN}": True,
|
||||
f"auth.{REMOTE_DOMAIN}": OriginEntry(auth_host=True),
|
||||
},
|
||||
remote=RemoteConfig(url=url, token="live-tok"),
|
||||
remote=RemoteConfig(link=LINK_ID),
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
await satellite.manager.start()
|
||||
replica = satellite.manager.replicas[url]
|
||||
replica = satellite.manager.replicas[LINK_ID]
|
||||
await wait_for("initial snapshot", lambda: replica.available())
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
@@ -102,47 +147,208 @@ async def live_remote(test_db, monkeypatch):
|
||||
transport=transport, base_url="http://localhost:4401"
|
||||
) as client:
|
||||
try:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
replica=replica,
|
||||
db=test_db,
|
||||
url=url,
|
||||
port=port,
|
||||
start_server=start_server,
|
||||
servers=servers,
|
||||
)
|
||||
live.client = client
|
||||
live.replica = replica
|
||||
yield live
|
||||
finally:
|
||||
await satellite.manager.stop()
|
||||
for server, task in servers:
|
||||
server.should_exit = True
|
||||
await task
|
||||
|
||||
|
||||
def _app_headers(secret: str) -> dict[str, str]:
|
||||
return {"Host": APP_HOST, "Cookie": f"{AUTH_COOKIE_NAME}={secret}"}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def remote_credential(test_db, test_user):
|
||||
"""A credential registered on the remote domain. The feed is rp-id
|
||||
scoped: only the link's own domains' credentials replicate."""
|
||||
credential = Credential.create(
|
||||
credential_id=os.urandom(32),
|
||||
user=test_user.uuid,
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
rp_id=REMOTE_DOMAIN,
|
||||
)
|
||||
ops_db.create_credential(credential)
|
||||
return credential
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Registration (the real 3-way handshake, crossing TCP)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_snapshot_and_user_events(live_remote, test_user):
|
||||
"""Bootstrap data arrives with the snapshot; later mutations as events."""
|
||||
assert test_user.uuid in live_remote.replica.db.users
|
||||
async def test_live_registration(live_server):
|
||||
"""A RAM-only offer is spent by the handshake; both sides store the
|
||||
link with identical key material, and the established-link handshake
|
||||
works right after."""
|
||||
live = live_server
|
||||
offer_url = sync.create_offer([], live.url)
|
||||
assert offer_url.startswith("ws://")
|
||||
|
||||
result = await satellite.register_uplink(offer_url)
|
||||
link_id = result["link"]
|
||||
assert result["domains"] == [] # empty scope: register first, scope later
|
||||
|
||||
uplink = live.db.uplinks[link_id]
|
||||
assert uplink.url == live.url
|
||||
link = live.db.satellite_links[link_id]
|
||||
assert uplink.key == link.key and len(link.key) == 64 # both parts
|
||||
assert link.label # the satellite's self-reported name
|
||||
|
||||
# Re-registering to the same upstream is refused (the offer itself is
|
||||
# single-use — covered in test_remote.py's offer test)
|
||||
with pytest.raises(ValueError, match="Already linked"):
|
||||
await satellite.register_uplink(offer_url)
|
||||
|
||||
# The established-link handshake: the replica connects and syncs
|
||||
await satellite.manager.start()
|
||||
try:
|
||||
replica = satellite.manager.replicas[link_id]
|
||||
await wait_for("established link sync", lambda: replica.available())
|
||||
finally:
|
||||
await satellite.manager.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_registration_rejections(live_server):
|
||||
"""Malformed URLs, unknown keys and unreachable hosts all fail the
|
||||
registration without storing anything."""
|
||||
with pytest.raises(ValueError, match="wss"):
|
||||
await satellite.register_uplink("https://key@auth.example.com")
|
||||
with pytest.raises(ValueError, match="key"):
|
||||
await satellite.register_uplink("wss://auth.example.com")
|
||||
with pytest.raises(RuntimeError, match="rejected"):
|
||||
await satellite.register_uplink(
|
||||
f"ws://unknown.key@127.0.0.1:{live_server.port}"
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="unreachable"):
|
||||
await satellite.register_uplink("ws://some.key@127.0.0.1:1")
|
||||
assert not live_server.db.uplinks
|
||||
assert not live_server.db.satellite_links
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_satellite_command(live_server, tmp_path):
|
||||
"""The whole satellite-only setup, two processes: the CLI registers
|
||||
against the live upstream (creating its own database on the way) and
|
||||
stores the link and the pushed domains — 'paskia' then serves them."""
|
||||
live = live_server
|
||||
ops_db.create_domain(
|
||||
REMOTE_DOMAIN,
|
||||
DomainConfig(
|
||||
rp_name="Example",
|
||||
origins={
|
||||
f"**.{REMOTE_DOMAIN}": True,
|
||||
f"auth.{REMOTE_DOMAIN}": OriginEntry(auth_host=True),
|
||||
},
|
||||
),
|
||||
)
|
||||
offer_url = sync.create_offer([REMOTE_DOMAIN], live.url)
|
||||
proc = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[sys.executable, "-m", "paskia", "satellite", offer_url],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={k: v for k, v in os.environ.items() if not k.startswith("PASKIA_")},
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert REMOTE_DOMAIN in proc.stdout
|
||||
|
||||
# The satellite's own database holds the uplink and the pushed domain,
|
||||
# and both sides hold the same key material for the link.
|
||||
sat_db_path = tmp_path / "paskia.kantadb"
|
||||
kanta = Kanta(str(sat_db_path), DB())
|
||||
await kanta.open(readonly=True)
|
||||
try:
|
||||
sat_domains = dict(kanta.data.config.domains)
|
||||
uplinks = dict(kanta.data.uplinks)
|
||||
finally:
|
||||
await kanta.close()
|
||||
domain = sat_domains[REMOTE_DOMAIN]
|
||||
assert domain.remote is not None
|
||||
assert domain.rp_name == "Example"
|
||||
((link_id, uplink),) = uplinks.items()
|
||||
assert uplink.url == live.url
|
||||
assert live.db.satellite_links[link_id].key == uplink.key
|
||||
assert live.db.satellite_links[link_id].rp_ids == [REMOTE_DOMAIN]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Established link: sync behavior
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_snapshot_and_context_repush(
|
||||
live_remote, test_user, remote_credential
|
||||
):
|
||||
"""Sessions arrive with their upstream-computed context; user-graph
|
||||
changes eagerly re-push the affected sessions' contexts."""
|
||||
live = live_remote
|
||||
key, _ = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=remote_credential.uuid,
|
||||
host=APP_HOST,
|
||||
rp_id=REMOTE_DOMAIN,
|
||||
)
|
||||
await wait_for("session sync", lambda: key in live.replica.db.session_contexts)
|
||||
assert live.replica.db.session_contexts[key].user.display_name == "Test Admin"
|
||||
|
||||
ops_db.update_user_display_name(test_user.uuid, "Live Rename")
|
||||
await wait_for(
|
||||
"rename to propagate",
|
||||
lambda: (
|
||||
live_remote.replica.db.users[test_user.uuid].display_name == "Live Rename"
|
||||
live.replica.db.session_contexts[key].user.display_name == "Live Rename"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_forward_and_writebehind(live_remote, test_user, test_credential):
|
||||
"""A session created on the remote authorizes locally; the /validate
|
||||
refresh travels back over the wire into the remote's database."""
|
||||
async def test_live_permission_revocation_propagates(
|
||||
live_remote, test_user, remote_credential
|
||||
):
|
||||
"""Removing a permission from the user's role re-pushes the session
|
||||
context: the satellite denies within one RTT."""
|
||||
live = live_remote
|
||||
key, secret = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
credential_uuid=remote_credential.uuid,
|
||||
host=APP_HOST,
|
||||
rp_id=REMOTE_DOMAIN,
|
||||
)
|
||||
await wait_for("session sync", lambda: key in live.replica.db.session_contexts)
|
||||
r = await live.client.get(
|
||||
"/auth/api/forward?perm=auth:admin", headers=_app_headers(secret)
|
||||
)
|
||||
assert r.status_code == 204
|
||||
|
||||
perm_uuid = next(
|
||||
p.uuid for p in live.db.permissions.values() if p.scope == "auth:admin"
|
||||
)
|
||||
ops_db.remove_permission_from_role(test_user.role_uuid, perm_uuid)
|
||||
await wait_for(
|
||||
"revocation to propagate",
|
||||
lambda: "auth:admin" not in live.replica.db.session_contexts[key].permissions,
|
||||
)
|
||||
r = await live.client.get(
|
||||
"/auth/api/forward?perm=auth:admin", headers=_app_headers(secret)
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_forward_and_writebehind(live_remote, test_user, remote_credential):
|
||||
"""A session created on the upstream authorizes locally; the /validate
|
||||
refresh travels back over the wire into the upstream's database."""
|
||||
live = live_remote
|
||||
key, secret = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=remote_credential.uuid,
|
||||
host=APP_HOST,
|
||||
rp_id=REMOTE_DOMAIN,
|
||||
)
|
||||
@@ -154,7 +360,7 @@ async def test_live_forward_and_writebehind(live_remote, test_user, test_credent
|
||||
assert r.status_code == 204
|
||||
assert r.headers["remote-name"] == "Test Admin"
|
||||
|
||||
# Age the session on the remote (propagates), then validate on the satellite
|
||||
# Age the session on the upstream (propagates), then validate on the satellite
|
||||
stale = datetime.now(UTC) - timedelta(minutes=10)
|
||||
ops_db.update_session(key, validated=stale)
|
||||
await wait_for(
|
||||
@@ -165,17 +371,19 @@ async def test_live_forward_and_writebehind(live_remote, test_user, test_credent
|
||||
assert r.status_code == 200
|
||||
assert r.json()["renewed"] is True
|
||||
await wait_for(
|
||||
"write-behind to land on the remote",
|
||||
"write-behind to land on the upstream",
|
||||
lambda: live.db.sessions[key].validated > stale,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_session_delete_propagates(live_remote, test_user, test_credential):
|
||||
async def test_live_session_delete_propagates(
|
||||
live_remote, test_user, remote_credential
|
||||
):
|
||||
live = live_remote
|
||||
key, secret = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
credential_uuid=remote_credential.uuid,
|
||||
host=APP_HOST,
|
||||
rp_id=REMOTE_DOMAIN,
|
||||
)
|
||||
@@ -190,8 +398,11 @@ async def test_live_session_delete_propagates(live_remote, test_user, test_crede
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_wrong_token_rejected(live_remote):
|
||||
bad = satellite.RemoteReplica(RemoteConfig(url=live_remote.url, token="wrong"))
|
||||
async def test_live_wrong_key_rejected(live_remote):
|
||||
"""An uplink whose key does not match the stored link's never syncs."""
|
||||
uplink = Uplink(url=live_remote.url, created=datetime.now(UTC), key=b"not-the-key")
|
||||
uplink.uplink_id = "bad-link"
|
||||
bad = satellite.UplinkReplica(uplink)
|
||||
await bad.start()
|
||||
try:
|
||||
await asyncio.sleep(1.5)
|
||||
@@ -202,14 +413,14 @@ async def test_live_wrong_token_rejected(live_remote):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_disconnect_ttl_and_reconnect(
|
||||
live_remote, test_user, test_credential
|
||||
live_remote, test_user, remote_credential
|
||||
):
|
||||
"""Channel down: replica keeps serving within cache_ttl, then fails
|
||||
closed (503); reconnect starts from a snapshot and reconciles drift."""
|
||||
live = live_remote
|
||||
key, secret = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
credential_uuid=remote_credential.uuid,
|
||||
host=APP_HOST,
|
||||
rp_id=REMOTE_DOMAIN,
|
||||
)
|
||||
@@ -225,7 +436,10 @@ async def test_live_disconnect_ttl_and_reconnect(
|
||||
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
|
||||
assert r.status_code == 204 # still authoritative within cache_ttl
|
||||
|
||||
live.replica.remote.cache_ttl = 0 # expire the trust window
|
||||
# Expire the trust window (a copy: the db-owned struct is not mutated)
|
||||
uplink_copy = msgspec.structs.replace(live.replica.uplink, cache_ttl=0)
|
||||
uplink_copy.uplink_id = LINK_ID
|
||||
live.replica.uplink = uplink_copy
|
||||
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
|
||||
assert r.status_code == 503
|
||||
|
||||
@@ -234,19 +448,146 @@ async def test_live_disconnect_ttl_and_reconnect(
|
||||
await live.start_server(live.port)
|
||||
await wait_for("reconnect", lambda: live.replica.available(), timeout=20)
|
||||
assert (
|
||||
live.replica.db.users[test_user.uuid].display_name == "While Down"
|
||||
live.replica.db.session_contexts[key].user.display_name == "While Down"
|
||||
) # snapshot reconciled the drift
|
||||
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_stops_replica_when_remote_removed(live_remote):
|
||||
"""A registry rebuild without the remote config stops the replica."""
|
||||
async def test_unlink_stops_replica(live_remote):
|
||||
"""Unlinking drops the uplink, its domains and the replica."""
|
||||
live = live_remote
|
||||
domains.init_registry(
|
||||
Config(domains={TEST_RP_ID: DomainConfig(origins={f"**.{TEST_RP_ID}": True})})
|
||||
await satellite.manager.unlink(LINK_ID)
|
||||
assert LINK_ID not in satellite.manager.replicas
|
||||
assert LINK_ID not in live.db.uplinks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_revocation_unlinks_satellite(live_remote):
|
||||
"""Revoking the link upstream drops the connection; the satellite's
|
||||
handshake retry gets a policy close and unlinks its side."""
|
||||
live = live_remote
|
||||
ops_db.delete_satellite_link(LINK_ID)
|
||||
await sync.close_link(LINK_ID)
|
||||
await wait_for(
|
||||
"satellite to unlink", lambda: LINK_ID not in live.db.uplinks, timeout=25
|
||||
)
|
||||
assert LINK_ID not in satellite.manager.replicas
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_out_of_scope_session_not_replicated(
|
||||
live_remote, test_user, test_credential, remote_credential
|
||||
):
|
||||
"""The feed is scoped to the link's rp-ids: sessions of other domains
|
||||
(here the satellite's own) never cross the wire."""
|
||||
live = live_remote
|
||||
local_key, _ = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
host="localhost:4401",
|
||||
rp_id=TEST_RP_ID,
|
||||
)
|
||||
# Positive control: an in-scope session created afterwards does arrive,
|
||||
# so the out-of-scope one has already passed through the pipe.
|
||||
remote_key, _ = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=remote_credential.uuid,
|
||||
host=APP_HOST,
|
||||
rp_id=REMOTE_DOMAIN,
|
||||
)
|
||||
await wait_for(
|
||||
"replica shutdown", lambda: live.url not in satellite.manager.replicas
|
||||
"in-scope session sync", lambda: remote_key in live.replica.db.sessions
|
||||
)
|
||||
assert local_key not in live.replica.db.sessions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_out_of_scope_writebehind_ignored(
|
||||
live_remote, test_user, test_credential, remote_credential
|
||||
):
|
||||
"""A session_refresh for a session outside the link's scope is ignored
|
||||
by the upstream (fail-closed), while an in-scope one lands."""
|
||||
live = live_remote
|
||||
stale = datetime.now(UTC) - timedelta(minutes=10)
|
||||
local_key, _ = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
host="localhost:4401",
|
||||
rp_id=TEST_RP_ID,
|
||||
)
|
||||
remote_key, _ = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=remote_credential.uuid,
|
||||
host=APP_HOST,
|
||||
rp_id=REMOTE_DOMAIN,
|
||||
)
|
||||
ops_db.update_session(local_key, validated=stale)
|
||||
ops_db.update_session(remote_key, validated=stale)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
live.replica.refresh_session(local_key, now, None, None)
|
||||
live.replica.refresh_session(remote_key, now, None, None)
|
||||
await wait_for(
|
||||
"in-scope refresh to land",
|
||||
lambda: live.db.sessions[remote_key].validated > stale,
|
||||
)
|
||||
assert live.db.sessions[local_key].validated == stale
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_scope_shrink_stops_feed(live_remote, test_user, remote_credential):
|
||||
"""Narrowing the link's scope on the upstream takes effect on the live
|
||||
connection: sessions of removed domains stop flowing."""
|
||||
live = live_remote
|
||||
ops_db.set_satellite_link_scope(LINK_ID, [])
|
||||
sync.push_link_domains(LINK_ID)
|
||||
|
||||
removed_key, _ = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=remote_credential.uuid,
|
||||
host=APP_HOST,
|
||||
rp_id=REMOTE_DOMAIN,
|
||||
)
|
||||
# Positive control: widen the scope again; a session created now does
|
||||
# arrive, so the earlier one demonstrably passed a closed pipe.
|
||||
ops_db.set_satellite_link_scope(LINK_ID, [REMOTE_DOMAIN])
|
||||
sync.push_link_domains(LINK_ID)
|
||||
remote_key, _ = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=remote_credential.uuid,
|
||||
host=APP_HOST,
|
||||
rp_id=REMOTE_DOMAIN,
|
||||
)
|
||||
await wait_for(
|
||||
"re-scoped session sync", lambda: remote_key in live.replica.db.sessions
|
||||
)
|
||||
assert removed_key not in live.replica.db.sessions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_domain_conflict_reported_upstream(live_remote, test_db):
|
||||
"""A pushed domain the satellite serves locally is rejected, and the
|
||||
rejection is reported into the upstream's link state."""
|
||||
# The shared config now holds the domain authoritatively — from the
|
||||
# satellite's view this is the local-conflict case.
|
||||
ops_db.create_domain(
|
||||
REMOTE_DOMAIN,
|
||||
DomainConfig(
|
||||
rp_name="Example",
|
||||
origins={
|
||||
f"**.{REMOTE_DOMAIN}": True,
|
||||
f"auth.{REMOTE_DOMAIN}": OriginEntry(auth_host=True),
|
||||
},
|
||||
),
|
||||
)
|
||||
sync.push_link_domains(LINK_ID)
|
||||
await wait_for(
|
||||
"rejection to arrive",
|
||||
lambda: (
|
||||
REMOTE_DOMAIN in sync.links_status().get(LINK_ID, {}).get("rejected", {})
|
||||
),
|
||||
)
|
||||
# The satellite kept its (synthetic) registry — no local overwrite
|
||||
assert test_db.config.domains[REMOTE_DOMAIN].remote is None
|
||||
Reference in new issue
Block a user