Compare commits
18
Commits
v2.1.0
...
444eca651e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
444eca651e | ||
|
|
6d49b59cec | ||
|
|
fa0d03729b | ||
|
|
0a59e26e45 | ||
|
|
9da1abc2c7 | ||
|
|
0638117279 | ||
|
|
28fa0ff61f | ||
|
|
05ea188ac9 | ||
|
|
ebe18912fc | ||
|
|
3a6e2bb370 | ||
|
|
0f38a8b096 | ||
|
|
d0c7949052 | ||
|
|
2209e2395c | ||
|
|
da0b3e0e8b | ||
|
|
b5733657f9 | ||
|
|
4de164c457 | ||
|
|
3e4f77ba93 | ||
|
|
66c2a9bf07 |
No files matched your search
@@ -0,0 +1,252 @@
|
||||
# Remote Proxy / Satellite
|
||||
|
||||
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
|
||||
our local host and needs fast local checks, while `app1.example.com` and
|
||||
`auth.example.com` run far away — all sharing `example.com` as rp-id.
|
||||
|
||||
Client applications that used `https://auth.example.com` as their auth
|
||||
backend (forward-auth checks) only repoint to the local satellite
|
||||
(`http://127.0.0.1:4401`); both remain usable interchangeably, and the
|
||||
satellite ultimately uses `auth.example.com`.
|
||||
|
||||
Status: **implemented**. The feature lives in `paskia/satellite.py`
|
||||
(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
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
wss://<initial-key>@auth.example.com
|
||||
```
|
||||
|
||||
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*).
|
||||
|
||||
On the **satellite**, either:
|
||||
|
||||
- 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
|
||||
|
||||
**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 `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. 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 /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 |
|
||||
| `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 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 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 upstream side
|
||||
|
||||
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 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).
|
||||
|
||||
---
|
||||
|
||||
# Design review (the rejected alternatives)
|
||||
|
||||
## Option A — caching HTTP reverse proxy
|
||||
|
||||
A thin proxy caching `/auth/api/forward`, `/check`, `/user-info`,
|
||||
`/settings` responses keyed by `(Host, cookie, query)` with
|
||||
`TTL = min(configured TTL, Remote-Session-Expires − now)`; everything
|
||||
else forwarded verbatim, WebSockets tunneled, `/logout` intercepted for
|
||||
eviction. **Option B** adds a remote change feed so eviction happens
|
||||
within one RTT instead of at TTL.
|
||||
|
||||
- Remote changes: none for A; one additive endpoint for B.
|
||||
- The proxy needs no credentials — requests are authenticated by the end
|
||||
user's cookie, forwarded on a miss.
|
||||
|
||||
## What the read-only local state buys over the HTTP cache
|
||||
|
||||
- **Full `SessionContext` locally.** A replays the byte-response it once
|
||||
saw; the satellite *computes* the answer. Query combinations never seen
|
||||
before (new `perm`/`max_age`/`public` shapes) are served locally but
|
||||
miss A's cache. The replica holds the *domain model*, so derived
|
||||
answers (effective permissions per host, `max_age` against
|
||||
`credential.last_used`, `Remote-*` composition) are correct without
|
||||
having been witnessed.
|
||||
- **One invalidation model.** A hand-builds invalidation rules per
|
||||
endpoint (query-key mapping, cookie re-keying on renew, 401 variants).
|
||||
Events mutate the replica (upsert/delete by table+key) and every
|
||||
endpoint becomes consistent at once — including future ones.
|
||||
- **Degradation behaves like a real instance.** With the remote down, the
|
||||
satellite serves a coherent auth service from the replica (expiry
|
||||
enforced locally, bounded by `cache_ttl`); A serves unrelated cached
|
||||
responses with gaps wherever the cache was cold.
|
||||
- **Multi-domain uniformity.** Remote backing is a property of a domain
|
||||
in the existing registry; local and remote rp-ids coexist in one
|
||||
instance. A is a separate component bolted in front of specific URLs.
|
||||
- **User simplicity.** One link registration per satellite; A needs
|
||||
deployment and cache-key discipline per frontend application.
|
||||
|
||||
## What it costs
|
||||
|
||||
- The read path must be honest about which DB it reads: `DB.session_ctx`
|
||||
and `/check` were rewritten to use their own tables instead of struct
|
||||
convenience properties that reach the global database. (A first draft's
|
||||
contextvar-dependent `db.data()` was rejected: a global accessor whose
|
||||
meaning shifts under the caller. Dispatch is instead keyed explicitly
|
||||
by the request host.)
|
||||
- A sync protocol (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).
|
||||
|
||||
## Summary
|
||||
|
||||
A(+B) is the right tool to "make forward-auth fast in front of an
|
||||
untouched server". The satellite — implemented here — is the right tool
|
||||
when it should *be* a paskia instance for its remote domains: one
|
||||
consistency model, correct answers for un-cached query shapes, graceful
|
||||
degradation, and per-domain mixing with local rp-ids, at the price of the
|
||||
read-path cleanup, the sync protocol, and a trusted satellite host.
|
||||
@@ -1009,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,20 +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="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,386 @@
|
||||
<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 { 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></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></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>
|
||||
@@ -8,11 +8,11 @@
|
||||
* - Disables Vite's screen clearing on startup
|
||||
*
|
||||
* Options:
|
||||
* paths - Array of paths to proxy (default: ["/api"])
|
||||
* paths - Array of paths to proxy (default: ['/api'])
|
||||
*/
|
||||
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402"
|
||||
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||
const backendUrl = process.env.PASKIA_BACKEND_URL || 'http://localhost:4402'
|
||||
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
name: "vite-plugin-fastapi-paskia",
|
||||
name: 'vite-plugin-fastapi-paskia',
|
||||
config: () => ({
|
||||
clearScreen: false,
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../paskia/frontend-build",
|
||||
outDir: '../paskia/frontend-build',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
|
||||
+104
-29
@@ -5,10 +5,11 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
from fastapi_vue import server
|
||||
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
|
||||
@@ -17,13 +18,18 @@ from paskia.domains import build as build_registry
|
||||
from paskia.domains import configure as configure_domains
|
||||
from paskia.domains import validate_config
|
||||
from paskia.util import hostutil, startupbox
|
||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.runtime import ServeConfig
|
||||
from paskia.util.runtime import serve_config
|
||||
|
||||
# Keep the literal value here: fastapi-vue-setup reads DEFAULT_PORT from
|
||||
# this module on upgrades. The app-side shared copy is paskia.util.constants.
|
||||
DEFAULT_PORT = 4401
|
||||
os.environ["FASTAPI_VUE"] = "PASKIA"
|
||||
|
||||
EPILOG = """\
|
||||
Examples:
|
||||
paskia init example.com "Example Corporation"
|
||||
paskia migrate example.com
|
||||
paskia satellite wss://key@auth.example.com
|
||||
paskia --listen 4402 --save
|
||||
paskia
|
||||
"""
|
||||
@@ -186,16 +192,65 @@ def cmd_migrate(args: argparse.Namespace) -> None:
|
||||
print(f"✅ {action} {db_file_path()} (domains: {', '.join(rp_ids)})")
|
||||
|
||||
|
||||
def _save_listen(db_path: Path, listen: list[str] | None) -> None:
|
||||
"""Persist the listen endpoints to the stored configuration."""
|
||||
kanta = Kanta(str(db_path), DB())
|
||||
def cmd_satellite(args: argparse.Namespace) -> None:
|
||||
"""Register this instance as a satellite using an upstream's link URL.
|
||||
|
||||
async def _write() -> None:
|
||||
async with kanta:
|
||||
with kanta.transaction("serve:save_listen"):
|
||||
kanta.data.config.listen = listen
|
||||
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)
|
||||
|
||||
asyncio.run(_write())
|
||||
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}")
|
||||
if fresh:
|
||||
print("Created the database. Serve with: paskia")
|
||||
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:
|
||||
@@ -210,14 +265,20 @@ def cmd_serve(args: argparse.Namespace) -> None:
|
||||
)
|
||||
raise SystemExit(f"Database {db_path} not found — run 'paskia init' first.")
|
||||
|
||||
if args.save and args.listen is not None:
|
||||
# '--listen ""' clears the stored endpoints (back to the default)
|
||||
_save_listen(db_path, _split_multi(args.listen) or None)
|
||||
|
||||
config = _load_stored_config(db_path)
|
||||
|
||||
listen = _split_multi(args.listen) or config.listen
|
||||
configure_domains(listen=listen)
|
||||
# Effective serve parameters, teleported to the server process(es); the
|
||||
# app persists the listen endpoints to the database when save is set.
|
||||
cfg = serve_config()
|
||||
cfg.save = bool(args.save and args.listen is not None)
|
||||
if cfg.save:
|
||||
# '--listen ""' clears the stored endpoints (back to the default)
|
||||
cfg.listen = _split_multi(args.listen) or None
|
||||
else:
|
||||
cfg.listen = _split_multi(args.listen) or config.listen
|
||||
teleport() # Serialize bound config before spawning workers
|
||||
|
||||
configure_domains(listen=cfg.listen)
|
||||
try:
|
||||
registry = build_registry(config)
|
||||
except ValueError as e:
|
||||
@@ -225,29 +286,26 @@ def cmd_serve(args: argparse.Namespace) -> None:
|
||||
# Sanitization warnings (serving is best-effort; fixing the stored config
|
||||
# is the admin's job via the admin interface) are logged by build().
|
||||
|
||||
# Pass process-global serve parameters to the server process(es)
|
||||
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(
|
||||
ServeConfig(listen=listen)
|
||||
).decode()
|
||||
|
||||
startupbox.print_startup_config(registry, listen=listen)
|
||||
startupbox.print_startup_config(
|
||||
registry, listen=cfg.listen, default_port=DEFAULT_PORT
|
||||
)
|
||||
|
||||
# Run the server (spawns processes in dev mode)
|
||||
# tracerite, access logging and log config are handled by fastapi_vue.server;
|
||||
# we print our own startup config box, so disable the built-in one.
|
||||
server.run(
|
||||
"paskia.fastapi.mainapp:app",
|
||||
listen=listen,
|
||||
listen=cfg.listen,
|
||||
default_port=DEFAULT_PORT,
|
||||
server_header=False,
|
||||
startup_box=None,
|
||||
reload=Path(__file__).parent if DEVMODE else False,
|
||||
reload=Path(__file__).parent if env.dev else False,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
# Configure logging to remove the "ERROR:root:" prefix
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||
# Full logging setup (tracerite, formatting) before any CLI output
|
||||
setup_logging()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="paskia",
|
||||
@@ -260,7 +318,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(
|
||||
@@ -302,11 +360,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))
|
||||
|
||||
|
||||
@@ -24,8 +24,15 @@ EXPIRES = SESSION_LIFETIME
|
||||
|
||||
|
||||
def session_ctx(auth: str, host: str | None = None):
|
||||
"""Get session context with normalized host."""
|
||||
return db.data().session_ctx(auth, hostutil.normalize_host(host))
|
||||
"""Get session context with normalized host.
|
||||
|
||||
The store is dispatched by host: remote domains read their replica.
|
||||
"""
|
||||
from paskia import satellite # noqa: PLC0415 (import cycle)
|
||||
|
||||
return satellite.store_for_host(host).session_ctx(
|
||||
auth, hostutil.normalize_host(host)
|
||||
)
|
||||
|
||||
|
||||
def expires() -> datetime:
|
||||
|
||||
+4
-1
@@ -67,7 +67,10 @@ async def check_admin_credentials() -> bool:
|
||||
# Check first admin user for credentials on any configured domain
|
||||
admin_user = admin_users[0]
|
||||
reg = domains.registry()
|
||||
configured = sorted(d.rp_id for d in reg.domains)
|
||||
# Remote domains hold their credentials on the remote instance
|
||||
configured = sorted(d.rp_id for d in reg.domains if d.remote is None)
|
||||
if not configured:
|
||||
return False
|
||||
|
||||
if not any(admin_user.credential_ids_for(rp_id) for rp_id in configured):
|
||||
# Admin exists but has no credential on any domain
|
||||
|
||||
@@ -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,
|
||||
@@ -67,10 +73,13 @@ from paskia.db.structs import (
|
||||
DomainConfig,
|
||||
Org,
|
||||
Permission,
|
||||
RemoteConfig,
|
||||
ResetToken,
|
||||
Role,
|
||||
SatelliteLink,
|
||||
Session,
|
||||
SessionContext,
|
||||
Uplink,
|
||||
User,
|
||||
)
|
||||
|
||||
@@ -90,10 +99,13 @@ __all__ = [
|
||||
"Org",
|
||||
"Permission",
|
||||
"DomainConfig",
|
||||
"RemoteConfig",
|
||||
"ResetToken",
|
||||
"Role",
|
||||
"SatelliteLink",
|
||||
"Session",
|
||||
"SessionContext",
|
||||
"Uplink",
|
||||
"User",
|
||||
# Instance
|
||||
"data",
|
||||
@@ -101,6 +113,7 @@ __all__ = [
|
||||
# Write ops
|
||||
"add_permission_to_org",
|
||||
"add_permission_to_role",
|
||||
"apply_uplink_domains",
|
||||
"bootstrap",
|
||||
"create_credential",
|
||||
"create_credential_session",
|
||||
@@ -109,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):
|
||||
|
||||
+113
-3
@@ -13,7 +13,7 @@ from uuid import UUID
|
||||
|
||||
import uuid7
|
||||
|
||||
from paskia import oidc_notify
|
||||
from paskia import oidc_notify, syncfeed
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
@@ -25,8 +25,10 @@ from paskia.db.structs import (
|
||||
Permission,
|
||||
ResetToken,
|
||||
Role,
|
||||
SatelliteLink,
|
||||
Session,
|
||||
SessionContext,
|
||||
Uplink,
|
||||
User,
|
||||
)
|
||||
from paskia.util.crypto import hash_secret
|
||||
@@ -103,6 +105,7 @@ def update_permission(
|
||||
_db.permissions[uuid].scope = scope
|
||||
_db.permissions[uuid].display_name = display_name
|
||||
_db.permissions[uuid].domain = domain
|
||||
syncfeed.emit("permissions", str(uuid), _db.permissions[uuid])
|
||||
|
||||
|
||||
def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
@@ -155,6 +158,7 @@ def update_org_name(
|
||||
raise ValueError(f"Organization {uuid} not found")
|
||||
with _transaction("admin:update_org_name", ctx):
|
||||
_db.orgs[uuid].display_name = display_name
|
||||
syncfeed.emit("orgs", str(uuid), _db.orgs[uuid])
|
||||
|
||||
|
||||
def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
@@ -180,6 +184,9 @@ def add_permission_to_org(
|
||||
|
||||
with _transaction("admin:add_permission_to_org", ctx):
|
||||
_db.permissions[permission_uuid].orgs[org_uuid] = True
|
||||
syncfeed.emit(
|
||||
"permissions", str(permission_uuid), _db.permissions[permission_uuid]
|
||||
)
|
||||
|
||||
|
||||
def remove_permission_from_org(
|
||||
@@ -197,6 +204,9 @@ def remove_permission_from_org(
|
||||
|
||||
with _transaction("admin:remove_permission_from_org", ctx):
|
||||
_db.permissions[permission_uuid].orgs.pop(org_uuid, None)
|
||||
syncfeed.emit(
|
||||
"permissions", str(permission_uuid), _db.permissions[permission_uuid]
|
||||
)
|
||||
|
||||
|
||||
def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
|
||||
@@ -220,6 +230,7 @@ def update_role_name(
|
||||
raise ValueError(f"Role {uuid} not found")
|
||||
with _transaction("admin:update_role_name", ctx):
|
||||
_db.roles[uuid].display_name = display_name
|
||||
syncfeed.emit("roles", str(uuid), _db.roles[uuid])
|
||||
|
||||
|
||||
def add_permission_to_role(
|
||||
@@ -235,6 +246,7 @@ def add_permission_to_role(
|
||||
raise ValueError(f"Permission {permission_uuid} not found")
|
||||
with _transaction("admin:add_permission_to_role", ctx):
|
||||
_db.roles[role_uuid].permissions[permission_uuid] = True
|
||||
syncfeed.emit("roles", str(role_uuid), _db.roles[role_uuid])
|
||||
|
||||
|
||||
def remove_permission_from_role(
|
||||
@@ -248,6 +260,7 @@ def remove_permission_from_role(
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
with _transaction("admin:remove_permission_from_role", ctx):
|
||||
_db.roles[role_uuid].permissions.pop(permission_uuid, None)
|
||||
syncfeed.emit("roles", str(role_uuid), _db.roles[role_uuid])
|
||||
|
||||
|
||||
def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
@@ -302,6 +315,7 @@ def update_user_display_name(
|
||||
slug = slugify_name(display_name)
|
||||
if slug and not is_username_taken(slug, exclude_uuid=uuid):
|
||||
user.preferred_username = slug
|
||||
syncfeed.emit("users", str(uuid), user)
|
||||
|
||||
|
||||
def update_user_info(
|
||||
@@ -380,6 +394,7 @@ def update_user_info(
|
||||
user.preferred_username = preferred_username
|
||||
if telephone is not _UNSET:
|
||||
user.telephone = telephone
|
||||
syncfeed.emit("users", str(uuid), user)
|
||||
|
||||
|
||||
def update_user_role(
|
||||
@@ -395,6 +410,7 @@ def update_user_role(
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
with _transaction("admin:update_user_role", ctx):
|
||||
_db.users[uuid].role_uuid = role_uuid
|
||||
syncfeed.emit("users", str(uuid), _db.users[uuid])
|
||||
|
||||
|
||||
def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
@@ -429,6 +445,7 @@ def update_credential_sign_count(
|
||||
_db.credentials[uuid].sign_count = sign_count
|
||||
if last_used:
|
||||
_db.credentials[uuid].last_used = last_used
|
||||
syncfeed.emit("credentials", str(uuid), _db.credentials[uuid])
|
||||
|
||||
|
||||
def delete_credential(
|
||||
@@ -476,6 +493,7 @@ def update_session(
|
||||
s.validated = validated
|
||||
if issuer is not None:
|
||||
s.issuer = issuer
|
||||
syncfeed.emit("sessions", key, s)
|
||||
|
||||
|
||||
def delete_session(
|
||||
@@ -546,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)
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -598,6 +689,9 @@ def login(
|
||||
# Update credential
|
||||
_db.credentials[credential_uuid].sign_count = sign_count
|
||||
_db.credentials[credential_uuid].last_used = now
|
||||
syncfeed.emit(
|
||||
"credentials", str(credential_uuid), _db.credentials[credential_uuid]
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@@ -625,6 +719,9 @@ def oidc_login(
|
||||
# Update credential
|
||||
_db.credentials[credential_uuid].sign_count = sign_count
|
||||
_db.credentials[credential_uuid].last_used = now
|
||||
syncfeed.emit(
|
||||
"credentials", str(credential_uuid), _db.credentials[credential_uuid]
|
||||
)
|
||||
|
||||
|
||||
def create_credential_session(
|
||||
@@ -720,20 +817,33 @@ def update_domain(
|
||||
|
||||
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
|
||||
|
||||
|
||||
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()):
|
||||
|
||||
+106
-11
@@ -9,7 +9,7 @@ from uuid import UUID
|
||||
import msgspec
|
||||
import uuid7
|
||||
|
||||
from paskia import db
|
||||
from paskia import db, syncfeed
|
||||
from paskia.util import passphrase as passphrase_util
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
@@ -51,6 +51,7 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
def store(self) -> None:
|
||||
"""Store this permission in the database. Must be called inside a transaction."""
|
||||
db.data().permissions[self.uuid] = self
|
||||
syncfeed.emit("permissions", str(self.uuid), self)
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Delete this permission and remove it from all roles.
|
||||
@@ -59,8 +60,10 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""
|
||||
_data = db.data()
|
||||
for role in _data.roles.values():
|
||||
role.permissions.pop(self.uuid, None)
|
||||
if role.permissions.pop(self.uuid, None) is not None:
|
||||
syncfeed.emit("roles", str(role.uuid), role)
|
||||
del _data.permissions[self.uuid]
|
||||
syncfeed.emit("permissions", str(self.uuid), None)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@@ -103,6 +106,7 @@ class Org(msgspec.Struct, dict=True):
|
||||
def store(self) -> None:
|
||||
"""Store this organization in the database. Must be called inside a transaction."""
|
||||
db.data().orgs[self.uuid] = self
|
||||
syncfeed.emit("orgs", str(self.uuid), self)
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Delete this org and cascade to roles, users. Remove from permissions.
|
||||
@@ -111,12 +115,16 @@ class Org(msgspec.Struct, dict=True):
|
||||
"""
|
||||
_data = db.data()
|
||||
for p in _data.permissions.values():
|
||||
p.orgs.pop(self.uuid, None)
|
||||
if p.orgs.pop(self.uuid, None) is not None:
|
||||
syncfeed.emit("permissions", str(p.uuid), p)
|
||||
for role in self.roles:
|
||||
for user in role.users:
|
||||
del _data.users[user.uuid]
|
||||
syncfeed.emit("users", str(user.uuid), None)
|
||||
del _data.roles[role.uuid]
|
||||
syncfeed.emit("roles", str(role.uuid), None)
|
||||
del _data.orgs[self.uuid]
|
||||
syncfeed.emit("orgs", str(self.uuid), None)
|
||||
|
||||
@classmethod
|
||||
def create(cls, display_name: str, created_at: datetime | None = None) -> Org:
|
||||
@@ -170,10 +178,12 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
def store(self) -> None:
|
||||
"""Store this role in the database. Must be called inside a transaction."""
|
||||
db.data().roles[self.uuid] = self
|
||||
syncfeed.emit("roles", str(self.uuid), self)
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Delete this role from the database. Must be called inside a transaction."""
|
||||
del db.data().roles[self.uuid]
|
||||
syncfeed.emit("roles", str(self.uuid), None)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@@ -254,6 +264,7 @@ class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
|
||||
def store(self) -> None:
|
||||
"""Store this user in the database. Must be called inside a transaction."""
|
||||
db.data().users[self.uuid] = self
|
||||
syncfeed.emit("users", str(self.uuid), self)
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Delete this user and cascade to credentials, sessions, reset tokens.
|
||||
@@ -263,11 +274,14 @@ class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
|
||||
_data = db.data()
|
||||
for cred in self.credentials:
|
||||
del _data.credentials[cred.uuid]
|
||||
syncfeed.emit("credentials", str(cred.uuid), None)
|
||||
for sess in self.sessions:
|
||||
del _data.sessions[sess.key]
|
||||
syncfeed.emit("sessions", sess.key, None)
|
||||
for token in self.reset_tokens:
|
||||
del _data.reset_tokens[token.key]
|
||||
del _data.users[self.uuid]
|
||||
syncfeed.emit("users", str(self.uuid), None)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@@ -331,6 +345,7 @@ class Credential(msgspec.Struct, dict=True):
|
||||
def store(self) -> None:
|
||||
"""Store this credential in the database. Must be called inside a transaction."""
|
||||
db.data().credentials[self.uuid] = self
|
||||
syncfeed.emit("credentials", str(self.uuid), self)
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Delete this credential and all its sessions.
|
||||
@@ -340,7 +355,9 @@ class Credential(msgspec.Struct, dict=True):
|
||||
_data = db.data()
|
||||
for sess in self.sessions:
|
||||
del _data.sessions[sess.key]
|
||||
syncfeed.emit("sessions", sess.key, None)
|
||||
del _data.credentials[self.uuid]
|
||||
syncfeed.emit("credentials", str(self.uuid), None)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@@ -411,17 +428,19 @@ 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)
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Delete this session from the database. Must be called inside a transaction."""
|
||||
del db.data().sessions[self.key]
|
||||
syncfeed.emit("sessions", self.key, None)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@@ -546,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
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -622,6 +687,21 @@ class OriginEntry(msgspec.Struct, omit_defaults=True):
|
||||
auth_host: bool = False # This site hosts the account/admin interface
|
||||
|
||||
|
||||
class RemoteConfig(msgspec.Struct, omit_defaults=True):
|
||||
"""Marks a domain as satellite-served via an uplink (a ``db.uplinks``
|
||||
dict key).
|
||||
|
||||
The satellite keeps a RAM-only read replica fed by the uplink and
|
||||
answers session-dependent reads locally; mutations are forwarded. The
|
||||
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.
|
||||
"""
|
||||
|
||||
link: str = ""
|
||||
|
||||
|
||||
class DomainConfig(msgspec.Struct, omit_defaults=True):
|
||||
"""Configuration for one domain (one WebAuthn rp-id).
|
||||
|
||||
@@ -641,6 +721,7 @@ class DomainConfig(msgspec.Struct, omit_defaults=True):
|
||||
|
||||
rp_name: str | None = None
|
||||
origins: dict[str, bool | OriginEntry] = {}
|
||||
remote: RemoteConfig | None = None
|
||||
|
||||
|
||||
class Config(msgspec.Struct, omit_defaults=True):
|
||||
@@ -674,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)
|
||||
@@ -696,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
|
||||
@@ -713,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
|
||||
@@ -728,17 +819,21 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
if s.host != host:
|
||||
return None
|
||||
|
||||
# Look up via this instance's own tables: a DB must be
|
||||
# self-contained so that read replicas work unchanged.
|
||||
try:
|
||||
user = s.user
|
||||
role = user.role
|
||||
org = role.org
|
||||
credential = s.credential
|
||||
user = self.users[s.user_uuid]
|
||||
role = self.roles[user.role_uuid]
|
||||
org = self.orgs[role.org_uuid]
|
||||
credential = self.credentials[s.credential_uuid]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
# Effective permissions: role's permissions that the org can grant,
|
||||
# filtered by domain restriction
|
||||
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||
org_perm_uuids = {
|
||||
p.uuid for p in self.permissions.values() if org.uuid in p.orgs
|
||||
}
|
||||
|
||||
effective_perms = []
|
||||
for perm_uuid in role.permission_set:
|
||||
|
||||
+30
-2
@@ -20,7 +20,7 @@ import os
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
|
||||
from paskia.db.structs import Config, DomainConfig, OriginEntry
|
||||
from paskia.db.structs import Config, DomainConfig, OriginEntry, RemoteConfig
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.constants import DEFAULT_PORT
|
||||
@@ -101,6 +101,11 @@ class Domain:
|
||||
def rp_name(self) -> str:
|
||||
return self.passkey.rp_name
|
||||
|
||||
@property
|
||||
def remote(self) -> RemoteConfig | None:
|
||||
"""Upstream config when this domain is served as a satellite."""
|
||||
return self.config.remote
|
||||
|
||||
@property
|
||||
def own_auth_host(self) -> str | None:
|
||||
"""This domain's own auth host as host[:port], if configured."""
|
||||
@@ -273,6 +278,11 @@ def validate_config(
|
||||
f"Domain '{rp_id}' has {related_count} related origins "
|
||||
f"(maximum {related_origin_cap})"
|
||||
)
|
||||
if domain.remote is not None and domain_auth_host is None:
|
||||
raise ValueError(
|
||||
f"Domain '{rp_id}' is remote — it must mark an auth host "
|
||||
"(profile, admin and sign-in pages live there)"
|
||||
)
|
||||
|
||||
rp_ids = set(config.domains)
|
||||
for hn, owner in auth_hosts.items():
|
||||
@@ -363,6 +373,22 @@ def sanitize_config(
|
||||
auth_seen = True
|
||||
origins[key] = props
|
||||
|
||||
if domain.remote is not None:
|
||||
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
|
||||
if not auth_seen:
|
||||
warn(
|
||||
f"Domain '{rp_id}': remote domain without an auth host — "
|
||||
"profile, admin and sign-in pages have nowhere to live"
|
||||
)
|
||||
else:
|
||||
remote = None
|
||||
|
||||
related = sorted(k for k in origins if is_related_key(rp_id, k))
|
||||
if len(related) > related_origin_cap:
|
||||
warn(
|
||||
@@ -372,7 +398,9 @@ def sanitize_config(
|
||||
for key in related[related_origin_cap:]:
|
||||
del origins[key]
|
||||
|
||||
domains[rp_id] = DomainConfig(rp_name=domain.rp_name, origins=origins)
|
||||
domains[rp_id] = DomainConfig(
|
||||
rp_name=domain.rp_name, origins=origins, remote=remote
|
||||
)
|
||||
|
||||
if not domains:
|
||||
raise ValueError("No servable domain in the stored configuration")
|
||||
|
||||
@@ -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
|
||||
from paskia.fastapi import authz
|
||||
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
|
||||
@@ -34,6 +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=domain.config.remote,
|
||||
)
|
||||
|
||||
|
||||
@@ -61,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,
|
||||
@@ -120,6 +141,7 @@ 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")),
|
||||
@@ -144,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.
|
||||
"""
|
||||
@@ -155,7 +177,7 @@ async def admin_update_domain(
|
||||
config = db.data().config
|
||||
if rp_id not in config.domains:
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
|
||||
_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")),
|
||||
@@ -174,6 +196,7 @@ async def admin_update_domain(
|
||||
ctx=ctx,
|
||||
)
|
||||
_rebuild_registry()
|
||||
await sync.refresh_links_serving(rp_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -183,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"
|
||||
)
|
||||
@@ -195,4 +219,5 @@ async def admin_delete_domain(
|
||||
)
|
||||
db.delete_domain(rp_id, ctx=ctx)
|
||||
_rebuild_registry()
|
||||
await 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)
|
||||
await 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"}
|
||||
+30
-9
@@ -14,7 +14,7 @@ from fastapi import (
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from paskia import authcode, db
|
||||
from paskia import authcode, db, satellite
|
||||
from paskia._version import __version__
|
||||
from paskia.authsession import EXPIRES, get_reset, session_ctx
|
||||
from paskia.domains import current_domain
|
||||
@@ -122,8 +122,9 @@ async def validate_token(
|
||||
if auth and renew:
|
||||
consumed = datetime.now(UTC) - ctx.session.validated
|
||||
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
||||
db.update_session(
|
||||
satellite.refresh_session(
|
||||
ctx.session.key,
|
||||
request.headers.get("host"),
|
||||
ip=get_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
validated=datetime.now(UTC),
|
||||
@@ -162,16 +163,20 @@ async def check_user(
|
||||
|
||||
No session cookie is read or written. Caller authentication is not required.
|
||||
"""
|
||||
data = db.data()
|
||||
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:
|
||||
u = data.users[user_uuid]
|
||||
role = u.role
|
||||
org = role.org
|
||||
role = data.roles[u.role_uuid]
|
||||
org = data.orgs[role.org_uuid]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
host = hostutil.normalize_host(request.headers.get("host"))
|
||||
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||
org_perm_uuids = {p.uuid for p in data.permissions.values() if org.uuid in p.orgs}
|
||||
|
||||
effective_perms = []
|
||||
for perm_uuid in role.permission_set:
|
||||
@@ -212,7 +217,7 @@ def _remote_headers(ctx) -> dict[str, str]:
|
||||
"Remote-Session-Expires": (
|
||||
(ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
"Remote-Credential": str(ctx.session.credential),
|
||||
"Remote-Credential": str(ctx.credential.uuid),
|
||||
}
|
||||
|
||||
|
||||
@@ -311,6 +316,7 @@ async def get_settings():
|
||||
auth_site_url=domain.auth_site_url,
|
||||
session_cookie=AUTH_COOKIE_NAME,
|
||||
version=__version__,
|
||||
remote=domain.remote is not None,
|
||||
),
|
||||
headers={"Access-Control-Allow-Origin": "*", "Vary": "Origin"},
|
||||
)
|
||||
@@ -323,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,
|
||||
@@ -351,8 +361,10 @@ async def api_user_info(
|
||||
|
||||
|
||||
@app.get("/token-info")
|
||||
async def token_info(credentials=Depends(bearer_auth)):
|
||||
async def token_info(request: Request, credentials=Depends(bearer_auth)):
|
||||
"""Get reset/device-add token info. Pass token via Bearer header."""
|
||||
if (proxied := await satellite.forward_request(request)) is not None:
|
||||
return proxied
|
||||
if not credentials or not credentials.credentials:
|
||||
raise HTTPException(401, "Bearer token required")
|
||||
token = credentials.credentials
|
||||
@@ -375,6 +387,10 @@ async def token_info(credentials=Depends(bearer_auth)):
|
||||
|
||||
@app.post("/logout")
|
||||
async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
if (proxied := await satellite.forward_request(request)) is not None:
|
||||
if auth and proxied.status_code == 200:
|
||||
satellite.evict_session(auth, request.headers.get("host"))
|
||||
return proxied
|
||||
if not auth:
|
||||
return {"message": "Already logged out"}
|
||||
host = request.headers.get("host")
|
||||
@@ -399,6 +415,11 @@ async def api_set_session(
|
||||
if not auth or not auth.credentials:
|
||||
raise HTTPException(400, "Bearer token required")
|
||||
|
||||
if (proxied := await satellite.forward_request(request)) is not None:
|
||||
# The exchange code lives in the remote's RAM; redeem it there. The
|
||||
# session itself reaches the replica via the sync channel.
|
||||
return proxied
|
||||
|
||||
host = hostutil.normalize_host(request.headers.get("host", ""))
|
||||
if not host:
|
||||
raise HTTPException(400, "Host header required")
|
||||
|
||||
@@ -63,6 +63,11 @@ class DispatchMiddleware:
|
||||
host = _header(scope, "host")
|
||||
host_domain = registry.resolve(host)
|
||||
if host_domain is None:
|
||||
# The sync endpoint is server-to-server and token-gated: the
|
||||
# satellite may reach us via an address outside our domains.
|
||||
if scope.get("path") == "/auth/api/sync/ws" and registry.domains:
|
||||
await self._dispatch(scope, receive, send, registry.domains[0])
|
||||
return
|
||||
await send({"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION})
|
||||
return
|
||||
|
||||
|
||||
+17
-13
@@ -5,13 +5,13 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from kanta.logging import configure_logging as configure_kanta_logging
|
||||
from fastapi_vue import env
|
||||
|
||||
from paskia import authcode, db, domains, remoteauth
|
||||
from paskia import authcode, db, domains, remoteauth, satellite
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.db.background import start_background, stop_background
|
||||
from paskia.db.lifecycle import kanta
|
||||
from paskia.fastapi import admin, api, auth_host, oid, ws
|
||||
from paskia.fastapi import admin, api, auth_host, oid, sync, ws
|
||||
from paskia.fastapi.admin.adminapp import adminapp
|
||||
from paskia.fastapi.dispatch import DispatchMiddleware
|
||||
|
||||
@@ -19,12 +19,8 @@ from paskia.fastapi.dispatch import DispatchMiddleware
|
||||
from paskia.fastapi.front import frontend
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import passphrase, vitedev
|
||||
from paskia.util.constants import DEVMODE
|
||||
from paskia.util.runtime import serve_config
|
||||
|
||||
# Configure custom logging
|
||||
configure_kanta_logging()
|
||||
|
||||
# Path to examples/index.html when running from source tree
|
||||
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||
|
||||
@@ -33,22 +29,28 @@ _EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||
async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
"""Application lifespan: open the combined database and build the domain registry.
|
||||
|
||||
Process-global serve parameters (listen endpoints) are passed via the
|
||||
PASKIA_CONFIG JSON env variable (set by the CLI entrypoint) so that
|
||||
uvicorn reload / multiprocess workers derive site URLs the same way.
|
||||
Domain configuration is read from the database.
|
||||
Process-global serve parameters (listen endpoints, save flag) are passed
|
||||
via the PASKIA_CONFIG JSON env variable (set by the CLI entrypoint) so
|
||||
that uvicorn reload / multiprocess workers derive site URLs the same
|
||||
way. With the save flag set, the listen endpoints are persisted here —
|
||||
the CLI never opens the database read-write. Domain configuration is
|
||||
read from the database.
|
||||
"""
|
||||
cfg = serve_config()
|
||||
domains.configure(listen=cfg.listen if cfg else None)
|
||||
domains.configure(listen=cfg.listen)
|
||||
|
||||
await asyncio.to_thread(
|
||||
Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
|
||||
)
|
||||
async with kanta:
|
||||
if cfg.save:
|
||||
with kanta.transaction("serve:save_listen"):
|
||||
db.data().config.listen = cfg.listen
|
||||
try:
|
||||
domains.init_registry(db.data().config)
|
||||
await remoteauth.init()
|
||||
await authcode.start()
|
||||
await satellite.manager.start()
|
||||
except ValueError as e:
|
||||
logging.error(f"⚠️ {e}")
|
||||
# Re-raise to fail fast
|
||||
@@ -59,6 +61,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
await start_background()
|
||||
yield
|
||||
await stop_background()
|
||||
await satellite.manager.stop()
|
||||
await authcode.stop()
|
||||
|
||||
|
||||
@@ -68,7 +71,7 @@ app = FastAPI(
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
debug=DEVMODE,
|
||||
debug=env.dev,
|
||||
)
|
||||
|
||||
# WebSocket and HTTP access logging is handled by fastapi_vue's ASGI middleware;
|
||||
@@ -82,6 +85,7 @@ app.middleware("http")(auth_host.redirect_middleware)
|
||||
app.add_middleware(DispatchMiddleware)
|
||||
|
||||
app.mount("/auth/api/admin/", admin.app)
|
||||
app.mount("/auth/api/sync", sync.app)
|
||||
app.mount("/auth/api/", api.app)
|
||||
app.mount("/auth/ws/", ws.app)
|
||||
app.mount("/auth/oidc/", oid.app)
|
||||
|
||||
@@ -20,7 +20,7 @@ from fastapi import Depends, FastAPI, Form, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from paskia import authcode, db
|
||||
from paskia import authcode, db, satellite
|
||||
from paskia.db.structs import OIDC, Session
|
||||
from paskia.util import avatar, oidjwt
|
||||
from paskia.util.crypto import hash_secret
|
||||
@@ -30,6 +30,14 @@ _logger = logging.getLogger(__name__)
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def proxy_remote_domain(request: Request, call_next):
|
||||
"""OIDC key material and sessions stay on the remote; proxy everything."""
|
||||
if (proxied := await satellite.forward_request(request)) is not None:
|
||||
return proxied
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _provider() -> OIDC:
|
||||
"""Return the instance-global OIDC provider state."""
|
||||
return db.data().oidc
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Sync WebSocket endpoint: link handshake, snapshots and live events.
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def push_link_domains(link_id: str) -> None:
|
||||
"""Send the link's current domain set to its live connections and
|
||||
update their feed scope (after scope edits or origin changes)."""
|
||||
link = db.data().satellite_links.get(link_id)
|
||||
if link is None:
|
||||
return
|
||||
message = {"type": "domains", "domains": domain_infos(link.rp_ids)}
|
||||
for conn in list(_connections.values()):
|
||||
if conn.link_id != link_id:
|
||||
continue
|
||||
conn.scope.clear()
|
||||
conn.scope.update(link.rp_ids)
|
||||
with contextlib.suppress(Exception):
|
||||
await _send(conn.ws, message)
|
||||
|
||||
|
||||
async 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:
|
||||
await push_link_domains(link_id)
|
||||
|
||||
|
||||
async 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
|
||||
)
|
||||
await push_link_domains(link_id)
|
||||
|
||||
|
||||
async def _send(ws: WebSocket, message: dict) -> None:
|
||||
await ws.send_bytes(syncfeed.encode(message))
|
||||
|
||||
|
||||
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 ""
|
||||
)
|
||||
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 or session.rp_id not in conn.scope:
|
||||
return
|
||||
try:
|
||||
validated = msgspec.convert(message.get("validated"), datetime)
|
||||
except msgspec.ValidationError:
|
||||
return
|
||||
db.update_session(
|
||||
key,
|
||||
ip=message.get("ip") or None,
|
||||
user_agent=message.get("user_agent") or None,
|
||||
validated=validated,
|
||||
)
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def sync_websocket(ws: WebSocket):
|
||||
await ws.accept()
|
||||
queue: asyncio.Queue | None = None
|
||||
try:
|
||||
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": _snapshot_items(table, conn.scope),
|
||||
},
|
||||
)
|
||||
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()), conn
|
||||
)
|
||||
finally:
|
||||
sender.cancel()
|
||||
except WebSocketDisconnect, RuntimeError:
|
||||
pass
|
||||
except Exception:
|
||||
_logger.exception("Sync WebSocket failed")
|
||||
finally:
|
||||
if queue is not None:
|
||||
_connections.pop(queue, None)
|
||||
syncfeed.unsubscribe(queue)
|
||||
|
||||
|
||||
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
||||
try:
|
||||
while True:
|
||||
await _send(ws, await queue.get())
|
||||
except WebSocketDisconnect, RuntimeError, asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
_logger.debug("Sync event pump ended", exc_info=True)
|
||||
+14
-1
@@ -11,7 +11,7 @@ from fastapi import (
|
||||
)
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from paskia import db
|
||||
from paskia import db, satellite
|
||||
from paskia.authsession import (
|
||||
delete_credential,
|
||||
expires,
|
||||
@@ -27,6 +27,19 @@ from paskia.util.apistructs import ApiCreateLinkResponse
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def proxy_remote_domain(request: Request, call_next):
|
||||
"""Profile and avatar data live on the remote for remote domains.
|
||||
|
||||
A remote domain's auth host normally resolves to the remote itself;
|
||||
if traffic for it reaches the satellite anyway (misrouted DNS), the
|
||||
satellite forwards rather than writing to its local database.
|
||||
"""
|
||||
if (proxied := await satellite.forward_request(request)) is not None:
|
||||
return proxied
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _can_manage_avatar(ctx, target_user) -> bool:
|
||||
if ctx.user.uuid == target_user.uuid:
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
"""Satellite side of remote domains: uplinks, RAM-only replicas, dispatch.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
import msgspec
|
||||
import websockets
|
||||
from fastapi import HTTPException, Request, Response
|
||||
|
||||
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 = {
|
||||
"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 LinkRevokedError(Exception):
|
||||
"""The upstream closed the handshake with a policy violation: the link
|
||||
is revoked (or the key desynced) — the satellite unlinks its side."""
|
||||
|
||||
|
||||
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
|
||||
) -> dict[str, str]:
|
||||
"""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 the {rp_id: reason} rejections to report upstream."""
|
||||
accepted, rejected = _accepted_domains(uplink_id, infos)
|
||||
current = _uplink_domains(uplink_id)
|
||||
if accepted != current:
|
||||
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
|
||||
|
||||
|
||||
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] = {}
|
||||
self._refresh_signal = asyncio.Event()
|
||||
self._task: asyncio.Task | None = None
|
||||
self._sweeper: asyncio.Task | None = None
|
||||
self._stopped = True
|
||||
|
||||
def available(self) -> bool:
|
||||
"""Synced, and connected now or within cache_ttl of the disconnect."""
|
||||
if not self.last_contact:
|
||||
return False
|
||||
return self.connected or (
|
||||
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 upstream."""
|
||||
session = self.db.sessions.get(key)
|
||||
if session is not None:
|
||||
session.validated = validated
|
||||
if ip is not None:
|
||||
session.ip = ip
|
||||
if user_agent is not None:
|
||||
session.user_agent = user_agent
|
||||
self._pending_refresh[key] = {
|
||||
"type": "session_refresh",
|
||||
"key": key,
|
||||
"validated": msgspec.to_builtins(validated),
|
||||
"ip": ip,
|
||||
"user_agent": user_agent,
|
||||
}
|
||||
self._refresh_signal.set()
|
||||
|
||||
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:
|
||||
self._stopped = True
|
||||
for task in (self._task, self._sweeper):
|
||||
if task:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, LinkRevokedError):
|
||||
await task
|
||||
|
||||
async def _sweep(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(_SWEEP_INTERVAL)
|
||||
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:
|
||||
try:
|
||||
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.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.
|
||||
self.connected = False
|
||||
self.last_contact = time.monotonic()
|
||||
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:
|
||||
async with websockets.connect(
|
||||
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.
|
||||
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: ReplicaDB | None = None
|
||||
ready_at = 0.0
|
||||
try:
|
||||
while True:
|
||||
if ready_at:
|
||||
# Periodic reconnects give full-snapshot reconciliation
|
||||
remaining = self.uplink.refresh_interval - (
|
||||
time.monotonic() - ready_at
|
||||
)
|
||||
if remaining <= 0:
|
||||
return
|
||||
try:
|
||||
message = msgspec.json.decode(
|
||||
await asyncio.wait_for(ws.recv(), remaining)
|
||||
)
|
||||
except TimeoutError:
|
||||
return # periodic resync: reconnect for a snapshot
|
||||
else:
|
||||
message = msgspec.json.decode(await ws.recv())
|
||||
mtype = message.get("type")
|
||||
if mtype == "snapshot":
|
||||
staging = staging or ReplicaDB()
|
||||
for key, fields in message["items"]:
|
||||
_apply(staging, message["table"], key, fields)
|
||||
elif mtype == "event":
|
||||
if staging is not None:
|
||||
raise ValueError("sync: event before ready")
|
||||
_apply(
|
||||
self.db,
|
||||
message["table"],
|
||||
message["key"],
|
||||
message.get("fields"),
|
||||
)
|
||||
elif mtype == "domains":
|
||||
# Live scope/origin change: apply and acknowledge.
|
||||
rejected = 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}
|
||||
)
|
||||
)
|
||||
elif mtype == "ready":
|
||||
if staging is not None:
|
||||
self.db = staging
|
||||
staging = None
|
||||
self.connected = True
|
||||
self.last_contact = ready_at = time.monotonic()
|
||||
finally:
|
||||
sender.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await sender
|
||||
|
||||
async def _send_loop(self, ws) -> None:
|
||||
while True:
|
||||
self._refresh_signal.clear()
|
||||
while self._pending_refresh:
|
||||
_, message = self._pending_refresh.popitem()
|
||||
await ws.send(syncfeed.encode(message))
|
||||
await self._refresh_signal.wait()
|
||||
|
||||
|
||||
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 uplink id; one connection per uplink carries all
|
||||
of the link's domains."""
|
||||
|
||||
def __init__(self):
|
||||
self.replicas: dict[str, UplinkReplica] = {}
|
||||
self.started = False
|
||||
|
||||
async def start(self) -> None:
|
||||
self.started = True
|
||||
await self.sync_uplinks()
|
||||
|
||||
async def stop(self) -> None:
|
||||
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 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)
|
||||
for uplink_id, uplink in 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 = 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()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Host-keyed dispatch: the only interface the rest of the app uses
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
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.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 uplink backing the host's domain."""
|
||||
domain = domains.registry().resolve(host)
|
||||
if domain is None or domain.remote is None:
|
||||
return db.data()
|
||||
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
|
||||
|
||||
|
||||
def refresh_session(
|
||||
key: str,
|
||||
host: str | None,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
validated: datetime,
|
||||
ctx=None,
|
||||
):
|
||||
"""/validate refresh: write-behind for remote domains, else local DB."""
|
||||
replica = replica_for_host(host)
|
||||
if replica is not None:
|
||||
replica.refresh_session(key, validated, ip, user_agent)
|
||||
else:
|
||||
db.update_session(
|
||||
key, ip=ip, user_agent=user_agent, validated=validated, ctx=ctx
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
_HOP_BY_HOP = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-length",
|
||||
"accept-encoding",
|
||||
"content-encoding",
|
||||
}
|
||||
|
||||
_clients: dict[str, httpx.AsyncClient] = {}
|
||||
|
||||
|
||||
async def _drop_client(url: str) -> None:
|
||||
"""Close and forget the forwarding client for an uplink URL."""
|
||||
client = _clients.pop(url, None)
|
||||
if client is not None:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 = _uplink_url(domain)
|
||||
client = _clients.get(url)
|
||||
if client is None:
|
||||
client = _clients[url] = httpx.AsyncClient(base_url=url, timeout=_TIMEOUT)
|
||||
try:
|
||||
upstream = await client.request(
|
||||
request.method,
|
||||
request.url.path,
|
||||
params=request.url.query,
|
||||
content=await request.body(),
|
||||
headers={
|
||||
k: v for k, v in request.headers.items() if k.lower() not in _HOP_BY_HOP
|
||||
},
|
||||
)
|
||||
except httpx.TransportError as e:
|
||||
raise HTTPException(503, "Remote authentication service unavailable") from e
|
||||
response = Response(content=upstream.content, status_code=upstream.status_code)
|
||||
# Raw headers to preserve repeated Set-Cookie
|
||||
response.raw_headers = [
|
||||
(k, v) for k, v in upstream.headers.raw if k.decode().lower() not in _HOP_BY_HOP
|
||||
]
|
||||
return response
|
||||
@@ -0,0 +1,130 @@
|
||||
"""RAM-only change feed letting satellite instances mirror this server.
|
||||
|
||||
Nothing here touches the database file: committed mutations are pushed to
|
||||
connected satellites over the sync WebSocket (fastapi/sync.py). Satellites
|
||||
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
|
||||
from uuid import UUID
|
||||
|
||||
import msgspec
|
||||
|
||||
# Tables mirrored by satellites, all filtered to the subscriber's rp-id
|
||||
# (reset tokens, sync tokens, OIDC data and domain config are
|
||||
# instance-local and never replicated).
|
||||
TABLES = ("credentials", "sessions")
|
||||
SCOPED_TABLES = TABLES
|
||||
|
||||
# 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
|
||||
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 SCOPED_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.pop(queue, None)
|
||||
|
||||
|
||||
def subscribe(scope: set[str] | None = None) -> asyncio.Queue:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
|
||||
_subscribers[queue] = scope
|
||||
return queue
|
||||
|
||||
|
||||
def unsubscribe(queue: asyncio.Queue) -> None:
|
||||
_subscribers.pop(queue, None)
|
||||
|
||||
|
||||
def encode(message: dict) -> bytes:
|
||||
return msgspec.json.encode(message)
|
||||
@@ -14,7 +14,15 @@ import msgspec
|
||||
from uarite import uaparse
|
||||
|
||||
from paskia import db
|
||||
from paskia.db.structs import Credential, Org, OriginEntry, Permission, Role, User
|
||||
from paskia.db.structs import (
|
||||
Credential,
|
||||
Org,
|
||||
OriginEntry,
|
||||
Permission,
|
||||
RemoteConfig,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# API structs - inherit from db structs, add uuid for serialization
|
||||
@@ -177,6 +185,7 @@ class ApiSettings(msgspec.Struct):
|
||||
auth_site_url: str
|
||||
session_cookie: str
|
||||
version: str
|
||||
remote: bool = False # this instance satellite-serves the domain
|
||||
|
||||
|
||||
class ApiDomain(msgspec.Struct):
|
||||
@@ -194,6 +203,7 @@ class ApiDomain(msgspec.Struct):
|
||||
site_url: str
|
||||
auth_site_url: str
|
||||
auth_host: str | None
|
||||
remote: RemoteConfig | None = None
|
||||
|
||||
|
||||
class ApiTokenInfo(msgspec.Struct, omit_defaults=True):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Small, dependency-free constants shared by CLI and server modules."""
|
||||
|
||||
import os
|
||||
|
||||
# App-side default; paskia.__main__ keeps its own literal copy because
|
||||
# fastapi-vue-setup reads DEFAULT_PORT from the CLI module on upgrades.
|
||||
DEFAULT_PORT = 4401
|
||||
DEVMODE = os.getenv("PASKIA_DEV") == "1"
|
||||
@@ -98,15 +98,3 @@ def normalize_host(raw_host: str | None) -> str | None:
|
||||
# Strip port from host:port
|
||||
netloc = netloc.rsplit(":", 1)[0]
|
||||
return netloc.lower().rstrip(".") or None
|
||||
|
||||
|
||||
def format_endpoint(ep: dict) -> str:
|
||||
"""Format an endpoint dict to a listen string (e.g. 'unix:/path' or 'host:port')."""
|
||||
if uds := ep.get("uds"):
|
||||
return f"unix:{uds}"
|
||||
host = ep["host"]
|
||||
port = ep["port"]
|
||||
# Bracket IPv6 addresses
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
return f"{host}:{port}"
|
||||
+11
-18
@@ -2,35 +2,28 @@
|
||||
|
||||
Domain configuration lives in the database (``Config.domains``); the
|
||||
``PASKIA_CONFIG`` environment variable only carries the effective listen
|
||||
endpoints so that child processes (uvicorn reload / workers) can derive
|
||||
site URLs the same way the parent did.
|
||||
endpoints and whether to persist them, so that child processes (uvicorn
|
||||
reload / workers) derive site URLs the same way the parent did. The CLI
|
||||
entry point mutates the bound object before ``server.run()`` calls
|
||||
``teleport()`` to pass it on.
|
||||
"""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
import msgspec
|
||||
from fastapi_vue import env
|
||||
|
||||
|
||||
class ServeConfig(msgspec.Struct):
|
||||
"""Process-global serve parameters."""
|
||||
|
||||
listen: list[str] | None = None
|
||||
save: bool = False # Persist listen to the stored config on startup
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load() -> ServeConfig | None:
|
||||
raw = os.getenv("PASKIA_CONFIG")
|
||||
if not raw:
|
||||
return None
|
||||
return msgspec.json.decode(raw.encode(), type=ServeConfig)
|
||||
|
||||
|
||||
def serve_config() -> ServeConfig | None:
|
||||
"""Return cached serve configuration loaded from PASKIA_CONFIG."""
|
||||
return _load()
|
||||
def serve_config() -> ServeConfig:
|
||||
"""Return the serve configuration bound to PASKIA_CONFIG."""
|
||||
return env(ServeConfig, name="CONFIG")
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Clear cached serve configuration; next serve_config() reloads."""
|
||||
_load.cache_clear()
|
||||
"""Drop the bound configuration; next serve_config() re-decodes."""
|
||||
env._bindings.pop("CONFIG", None) # noqa: SLF001
|
||||
+27
-62
@@ -2,24 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from sys import stderr
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi_vue import env
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
from fastapi_vue.server import print_startup_box
|
||||
|
||||
from paskia._version import __version__
|
||||
from paskia.domains import auth_host_url, origin_url, partition_origins
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.hostutil import format_endpoint, wildcard_base
|
||||
from paskia.util.hostutil import wildcard_base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paskia.domains import DomainRegistry
|
||||
|
||||
BOX_WIDTH = 80 # Maximum inner width (excluding box chars)
|
||||
URL_COL = 22 # Column where header URLs start (past the logo graphic)
|
||||
|
||||
# ANSI color codes
|
||||
@@ -28,46 +25,12 @@ YELLOW = "\033[38;5;184m" # Bright yellow (6x6x6 cube, r=4 g=4)
|
||||
BRIGHT_YELLOW = "\033[38;5;226m" # Brightest yellow (6x6x6 cube)
|
||||
BRIGHT_WHITE = "\033[1;37m" # Bold bright white
|
||||
|
||||
_TOKENS = re.compile(r"\033\[[0-9;]*m|.")
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Calculate visible length of text, ignoring ANSI escape codes."""
|
||||
return len(re.sub(r"\033\[[0-9;]*m", "", text))
|
||||
|
||||
|
||||
def _truncate(text: str, width: int) -> str:
|
||||
"""Cut text to at most `width` visible chars, keeping ANSI codes intact."""
|
||||
if _visible_len(text) <= width:
|
||||
return text
|
||||
out = []
|
||||
visible = 0
|
||||
for tok in _TOKENS.findall(text):
|
||||
if tok.startswith("\033"):
|
||||
out.append(tok)
|
||||
elif visible < width - 1:
|
||||
out.append(tok)
|
||||
visible += 1
|
||||
else:
|
||||
break
|
||||
return "".join(out) + "…" + RESET
|
||||
|
||||
|
||||
def line(text: str = "", width: int = BOX_WIDTH) -> str:
|
||||
"""Format a line inside the box with proper padding, truncating if needed."""
|
||||
text = _truncate(text, width)
|
||||
padding = width - _visible_len(text)
|
||||
return f"┃ {text}{' ' * padding} ┃\n"
|
||||
|
||||
|
||||
def top(width: int = BOX_WIDTH) -> str:
|
||||
return "┏" + "━" * (width + 2) + "┓\n"
|
||||
|
||||
|
||||
def bottom(width: int = BOX_WIDTH) -> str:
|
||||
return "┗" + "━" * (width + 2) + "┛\n"
|
||||
|
||||
|
||||
def _compact_url(url: str) -> str:
|
||||
"""Bare host for https URLs; scheme and port kept for plain http."""
|
||||
stripped = url.removeprefix("https://")
|
||||
@@ -107,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"):
|
||||
@@ -135,7 +98,10 @@ def _signin_summary(in_domain: list[str], rp_id: str) -> str:
|
||||
|
||||
|
||||
def print_startup_config(
|
||||
registry: DomainRegistry, listen: list[str] | None = None
|
||||
registry: DomainRegistry,
|
||||
listen: list[str] | None = None,
|
||||
*,
|
||||
default_port: int,
|
||||
) -> None:
|
||||
"""Print server configuration on startup (one section per domain)."""
|
||||
# Key graphic with yellow shading (bright for highlights, dark for body)
|
||||
@@ -146,18 +112,15 @@ def print_startup_config(
|
||||
|
||||
domains = sorted(registry.domains, key=lambda d: d.rp_id)
|
||||
|
||||
# Format listen endpoints (dev mode only uses the first endpoint)
|
||||
endpoints = list(parse_endpoints(listen, DEFAULT_PORT))
|
||||
if DEVMODE:
|
||||
endpoints = endpoints[:1] # server.run reload=True uses only one
|
||||
parts = [format_endpoint(ep) for ep in endpoints]
|
||||
# Endpoints as bound, passed to fastapi_vue for the {listen} field
|
||||
endpoints = list(parse_endpoints(listen, default_port))
|
||||
|
||||
# Header URLs: when a vite dev server is configured, its URL (marked
|
||||
# "vite dev"); otherwise one per configured auth host (a full origin URL,
|
||||
# clickable in terminals). If none are configured, guess one domain
|
||||
# (prefer the shortest https rp_id) and link its /auth/ site path.
|
||||
# Entries are pre-styled: bold for the URL, plain for any marker.
|
||||
vite_url = os.environ.get("PASKIA_VITE_URL") if DEVMODE else None
|
||||
vite_url = env.vite_url if env.dev else None
|
||||
if vite_url:
|
||||
header_urls = [f"{w}{vite_url}{r} (vite dev)"]
|
||||
else:
|
||||
@@ -178,9 +141,10 @@ def print_startup_config(
|
||||
rows = []
|
||||
# Logo lines 4-5 carry the first two header URLs; further URLs go on
|
||||
# blank-gutter lines beneath the graphic, all at the same column.
|
||||
# @VERSION@/@LISTEN@ are filled in by fastapi_vue's print_startup_box.
|
||||
logo = [
|
||||
f" {b}▄▄▄▄▄{r}",
|
||||
f"{b}█{y} {b}█{r} Paskia {__version__} @ {' '.join(parts)}",
|
||||
f"{b}█{y} {b}█{r} Paskia @VERSION@ @ @LISTEN@",
|
||||
f"{b}█{y} {b}█{y}▄▄▄▄▄▄▄▄▄▄▄▄{r}",
|
||||
f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r}",
|
||||
f" {y}▀▀▀▀▀{r}",
|
||||
@@ -196,7 +160,7 @@ def print_startup_config(
|
||||
rows.append(f"{' ' * URL_COL}{url}")
|
||||
|
||||
for domain in domains:
|
||||
# One compact line per domain; overlong lines are capped at render.
|
||||
# One compact line per domain.
|
||||
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}"
|
||||
@@ -204,21 +168,22 @@ def print_startup_config(
|
||||
rows.append(f"{head} — no sign-in sites")
|
||||
continue
|
||||
in_domain, related = partition_origins(domain.rp_id, domain.config.origins)
|
||||
parts = []
|
||||
phrases = []
|
||||
if in_domain:
|
||||
parts.append(_signin_summary(in_domain, domain.rp_id))
|
||||
parts.extend(_compact_url(origin_url(k)) for k in sorted(related))
|
||||
phrases.append(_signin_summary(in_domain, domain.rp_id))
|
||||
phrases.extend(_compact_url(origin_url(k)) for k in sorted(related))
|
||||
# "with" implies the rp_id itself may sign in (exact key or a full
|
||||
# wildcard); otherwise the origins are a mere list, after a colon.
|
||||
covers_self = any(
|
||||
k == domain.rp_id or k == f"**.{domain.rp_id}" for k in in_domain
|
||||
)
|
||||
sep = " with " if covers_self else ": "
|
||||
rows.append(f"{head}{sep}{' and '.join(parts)}")
|
||||
rows.append(f"{head}{sep}{' and '.join(phrases)}")
|
||||
|
||||
# Size the box to the widest row, capped at BOX_WIDTH.
|
||||
width = min(BOX_WIDTH, max(_visible_len(t) for t in rows))
|
||||
out = [top(width)]
|
||||
out.extend(line(text, width) for text in rows)
|
||||
out.append(bottom(width))
|
||||
stderr.write("".join(out))
|
||||
# fastapi_vue prints the box: version from package metadata, listen
|
||||
# addresses as bound (localhost expanded to both loopbacks). Braces in
|
||||
# our content (e.g. an rp-name) are escaped before template formatting.
|
||||
text = "\n".join(rows)
|
||||
text = text.replace("{", "{{").replace("}", "}}")
|
||||
text = text.replace("@VERSION@", "{version}").replace("@LISTEN@", "{listen}")
|
||||
print_startup_box(text, "paskia.fastapi.mainapp:app", endpoints)
|
||||
+11
-5
@@ -1,6 +1,6 @@
|
||||
"""User information formatting and retrieval logic."""
|
||||
|
||||
from paskia import aaguid, db
|
||||
from paskia import aaguid, satellite
|
||||
from paskia.db import SessionContext
|
||||
from paskia.util import avatar, hostutil
|
||||
from paskia.util.apistructs import (
|
||||
@@ -43,24 +43,30 @@ async def build_user_info(
|
||||
ctx: SessionContext | None = None,
|
||||
) -> ApiUserDetail:
|
||||
"""Build user info struct for authenticated users."""
|
||||
user = db.data().users[user_uuid]
|
||||
data = satellite.store_for_host(request_host)
|
||||
user = data.users[user_uuid]
|
||||
normalized_host = hostutil.normalize_host(request_host)
|
||||
|
||||
user_sessions = [s for s in data.sessions.values() if s.user_uuid == user_uuid]
|
||||
user_credentials = [
|
||||
c for c in data.credentials.values() if c.user_uuid == user_uuid
|
||||
]
|
||||
|
||||
sessions = {
|
||||
s.key: ApiUserSession.from_db(
|
||||
s,
|
||||
current_key=session_key,
|
||||
normalized_host=normalized_host,
|
||||
)
|
||||
for s in user.sessions
|
||||
for s in user_sessions
|
||||
}
|
||||
|
||||
return ApiUserDetail(
|
||||
user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
|
||||
credentials={c.uuid: c for c in user.credentials},
|
||||
credentials={c.uuid: c for c in user_credentials},
|
||||
aaguid_info={
|
||||
k: ApiAaguidInfo(**v)
|
||||
for k, v in aaguid.filter(c.aaguid for c in user.credentials).items()
|
||||
for k, v in aaguid.filter(c.aaguid for c in user_credentials).items()
|
||||
},
|
||||
sessions=sessions,
|
||||
permissions={p.uuid: ApiPermission.from_db(p) for p in ctx.permissions}
|
||||
|
||||
+2
-2
@@ -21,8 +21,8 @@ dependencies = [
|
||||
"pyjwt[crypto]>=2.11.0",
|
||||
"jsondiff>=2.2.1",
|
||||
"msgspec>=0.20.0",
|
||||
"fastapi-vue~=1.4.2",
|
||||
"kanta>=0.7.0",
|
||||
"fastapi-vue~=1.7.2",
|
||||
"kanta>=0.9.2",
|
||||
"uarite>=0.2.1",
|
||||
]
|
||||
[dependency-groups]
|
||||
|
||||
+68
-58
@@ -10,6 +10,7 @@ import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from subprocess import CalledProcessError
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import tracerite
|
||||
@@ -68,10 +69,13 @@ def build_caddyfile(origins: list[str], viteurl: str, backurl: str) -> str:
|
||||
return "\n".join(caddyfile_parts)
|
||||
|
||||
|
||||
async def run_caddy(
|
||||
origins: list[str], viteurl: str, backurl: str
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Start Caddy as HTTPS reverse proxy, wait for ready signal."""
|
||||
async def run_caddy(origins: list[str], viteurl: str, backurl: str) -> None:
|
||||
"""Run Caddy as HTTPS reverse proxy for the group's lifetime.
|
||||
|
||||
Waits for the ready signal, then drains stderr until Caddy exits or the
|
||||
task is cancelled (ProcessGroup shutdown), terminating Caddy on exit.
|
||||
Raises CalledProcessError if Caddy dies, cancelling the group.
|
||||
"""
|
||||
caddy_path = shutil.which("caddy")
|
||||
if not caddy_path:
|
||||
logger.warning("Caddy not found. Install it to use --caddy option.")
|
||||
@@ -86,57 +90,56 @@ async def run_caddy(
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
proc.stdin.write(caddyfile.encode())
|
||||
await proc.stdin.drain()
|
||||
proc.stdin.close()
|
||||
try:
|
||||
proc.stdin.write(caddyfile.encode())
|
||||
await proc.stdin.drain()
|
||||
proc.stdin.close()
|
||||
|
||||
# Wait for ready signal or failure
|
||||
while True:
|
||||
if proc.returncode is not None:
|
||||
remaining = await proc.stderr.read()
|
||||
for line in remaining.decode().splitlines():
|
||||
if line:
|
||||
logger.info("caddy: %s", line)
|
||||
logger.warning("Caddy startup failed (exit code %d)", proc.returncode)
|
||||
raise SystemExit(1)
|
||||
|
||||
line = await proc.stderr.readline()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
decoded = line.decode().rstrip()
|
||||
if "serving initial configuration" in decoded:
|
||||
break
|
||||
|
||||
# Parse and show errors during startup
|
||||
if decoded:
|
||||
try:
|
||||
log = json.loads(decoded)
|
||||
level = log.get("level", "")
|
||||
if level in ("error", "fatal", "warn"):
|
||||
logger.warning("caddy: %s", log.get("msg", decoded))
|
||||
except json.JSONDecodeError:
|
||||
if "error" in decoded.lower() or "fatal" in decoded.lower():
|
||||
logger.warning("caddy: %s", decoded)
|
||||
|
||||
# Start background task to drain stderr
|
||||
async def drain_caddy_stderr():
|
||||
# Wait for ready signal or failure
|
||||
while True:
|
||||
if proc.returncode is not None:
|
||||
await log_caddy_stderr(proc.stderr, starting=True)
|
||||
logger.warning("Caddy startup failed (exit code %d)", proc.returncode)
|
||||
raise CalledProcessError(proc.returncode, cmd)
|
||||
|
||||
line = await proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
decoded = line.decode().rstrip()
|
||||
if decoded:
|
||||
try:
|
||||
log = json.loads(decoded)
|
||||
level = log.get("level", "")
|
||||
if level in ("error", "fatal", "warn"):
|
||||
logger.warning("caddy: %s", log.get("msg", decoded))
|
||||
except json.JSONDecodeError:
|
||||
pass # Ignore non-JSON output after startup
|
||||
continue
|
||||
|
||||
asyncio.create_task(drain_caddy_stderr())
|
||||
return proc
|
||||
decoded = line.decode().rstrip()
|
||||
if "serving initial configuration" in decoded:
|
||||
break
|
||||
|
||||
log_caddy_line(decoded, starting=True)
|
||||
|
||||
# Drain stderr until Caddy exits
|
||||
await proc.wait()
|
||||
await log_caddy_stderr(proc.stderr)
|
||||
raise CalledProcessError(proc.returncode, cmd)
|
||||
finally:
|
||||
with suppress(ProcessLookupError):
|
||||
proc.terminate()
|
||||
await proc.wait()
|
||||
|
||||
|
||||
def log_caddy_line(decoded: str, *, starting: bool = False) -> None:
|
||||
"""Log one Caddy stderr line (JSON during/after startup)."""
|
||||
if not decoded:
|
||||
return
|
||||
try:
|
||||
log = json.loads(decoded)
|
||||
level = log.get("level", "")
|
||||
if level in ("error", "fatal", "warn"):
|
||||
logger.warning("caddy: %s", log.get("msg", decoded))
|
||||
except json.JSONDecodeError:
|
||||
if starting and ("error" in decoded.lower() or "fatal" in decoded.lower()):
|
||||
logger.warning("caddy: %s", decoded)
|
||||
|
||||
|
||||
async def log_caddy_stderr(stream: asyncio.StreamReader, *, starting: bool = False) -> None:
|
||||
"""Drain and log remaining Caddy stderr."""
|
||||
while line := await stream.readline():
|
||||
log_caddy_line(line.decode().rstrip(), starting=starting)
|
||||
|
||||
|
||||
def _split_multi(values: list[str] | None) -> list[str]:
|
||||
@@ -204,22 +207,25 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
caddy_origins.append(f"https://{rp_id}")
|
||||
seen: set = set()
|
||||
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
|
||||
caddy_proc = await run_caddy(caddy_origins, viteurl, backurl)
|
||||
pg._procs.append(caddy_proc)
|
||||
pg._cmds[caddy_proc.pid] = "caddy"
|
||||
pg.create_task(run_caddy(caddy_origins, viteurl, backurl))
|
||||
|
||||
pg.create_task(check_ports_free(viteurl, backurl))
|
||||
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(*paskia)
|
||||
await pg.spawn(*paskia, vital=True)
|
||||
await pg.wait(
|
||||
npm_proc, ready(backurl, path="/auth/api/settings?from=devserver.py")
|
||||
)
|
||||
await pg.spawn(*vite, cwd=frontend_path)
|
||||
await pg.spawn(*vite, cwd=frontend_path, vital=True)
|
||||
|
||||
|
||||
def main():
|
||||
tracerite.load()
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser = argparse.ArgumentParser(
|
||||
add_help=False,
|
||||
description="Run Vite and FastAPI development servers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=HELP_EPILOG,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--listen",
|
||||
@@ -243,8 +249,12 @@ def main():
|
||||
)
|
||||
args, remaining = parser.parse_known_args()
|
||||
|
||||
with suppress(KeyboardInterrupt):
|
||||
try:
|
||||
asyncio.run(run_devserver(args, remaining))
|
||||
except* KeyboardInterrupt:
|
||||
pass # user stopped the devserver: normal exit
|
||||
except* subprocess.SubprocessError, RuntimeError:
|
||||
raise SystemExit(1) from None # logged in devutil already; exit 1
|
||||
|
||||
|
||||
HELP_EPILOG = """
|
||||
|
||||
@@ -11,20 +11,27 @@ from pathlib import Path
|
||||
MIN_NODE_VERSION = 20
|
||||
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
class _Formatter(logging.Formatter):
|
||||
"""Prefix formatter, intentionally different from fastapi_vue.logging.
|
||||
|
||||
INFO and below pass through unprefixed so messages can use their own
|
||||
markings (>>>, ###); WARNING and above get an emoji prefix.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.ERROR:
|
||||
return f"🛑 {record.getMessage()}"
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"⚠️ {record.getMessage()}"
|
||||
return f"💣 {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
_handler.setFormatter(_Formatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False # own handler; do not double-print via a configured root
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
|
||||
@@ -1,108 +1,87 @@
|
||||
# ruff: noqa: INP001
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from asyncio.subprocess import Process
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
from subprocess import CalledProcessError
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
from collections.abc import Awaitable
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
class ProcessGroup(asyncio.TaskGroup):
|
||||
"""TaskGroup with structured ownership of async subprocesses."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize empty process tracking."""
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
def __init__(self, *, terminate_timeout: float = 10) -> None:
|
||||
"""Set the grace period before terminate() escalates to kill()."""
|
||||
super().__init__()
|
||||
self._terminate_timeout = terminate_timeout
|
||||
self._cmds: dict[Process, tuple[str, ...]] = {}
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
*cmd: str,
|
||||
cwd: str | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a subprocess and track it."""
|
||||
cmd_name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._procs.append(proc)
|
||||
self._cmds[proc.pid] = cmd_name
|
||||
return proc
|
||||
self, *cmd: str, cwd: str | None = None, vital: bool = False
|
||||
) -> Process:
|
||||
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
*waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any],
|
||||
) -> None:
|
||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||
async def run() -> None:
|
||||
name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._cmds[proc] = cmd
|
||||
started.set_result(proc)
|
||||
except Exception as e: # noqa: BLE001
|
||||
started.set_exception(e)
|
||||
return
|
||||
|
||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
||||
returncode = await proc.wait()
|
||||
if returncode != 0:
|
||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||
|
||||
tasks = [
|
||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
||||
for w in waitables
|
||||
]
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Enter the async context manager."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||
await self._cleanup(immediate=exc_type is not None)
|
||||
|
||||
async def _cleanup(self, *, immediate: bool = False) -> None:
|
||||
running = [p for p in self._procs if p.returncode is None]
|
||||
if not running:
|
||||
return
|
||||
|
||||
if not immediate:
|
||||
# Wait for any one process to exit
|
||||
with suppress(asyncio.CancelledError):
|
||||
await asyncio.wait(
|
||||
[asyncio.create_task(p.wait()) for p in running],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Terminate remaining processes
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
try:
|
||||
returncode = await proc.wait()
|
||||
finally:
|
||||
with suppress(ProcessLookupError):
|
||||
p.terminate()
|
||||
|
||||
# Wait for all to finish (with overall timeout), shielded from cancellation
|
||||
still_running = [p for p in self._procs if p.returncode is None]
|
||||
if still_running:
|
||||
with suppress(asyncio.CancelledError):
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.shield(
|
||||
asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
),
|
||||
)
|
||||
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.kill()
|
||||
await p.wait()
|
||||
with suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
|
||||
if vital:
|
||||
logger.warning("Vital process %s exited", name)
|
||||
raise CalledProcessError(returncode, cmd)
|
||||
|
||||
started = asyncio.get_running_loop().create_future()
|
||||
self.create_task(run())
|
||||
return await asyncio.shield(started)
|
||||
|
||||
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
|
||||
"""Wait concurrently and return results in argument order."""
|
||||
|
||||
async def task(w: Process | Awaitable) -> Any: # noqa: ANN401
|
||||
if not isinstance(w, Process):
|
||||
return await w
|
||||
if retcode := await w.wait():
|
||||
cmd = self._cmds[w]
|
||||
logger.warning(
|
||||
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
|
||||
)
|
||||
raise CalledProcessError(retcode, cmd)
|
||||
return retcode
|
||||
|
||||
async with asyncio.TaskGroup() as group:
|
||||
tasks = [group.create_task(task(w)) for w in waitables]
|
||||
|
||||
return tuple(task.result() for task in tasks)
|
||||
|
||||
|
||||
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
||||
@@ -128,42 +107,43 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN
|
||||
writer.close()
|
||||
except OSError, EOFError, ValueError, TimeoutError:
|
||||
return None
|
||||
for line in data.decode("latin-1").split("\r\n"):
|
||||
for line in data.decode(errors="replace").split("\r\n"):
|
||||
if line.lower().startswith("server:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return line[7:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
async def check_ports_free(*urls: str) -> None:
|
||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||
"""Verify URLs are not responding (ports are free).
|
||||
|
||||
async def check(url: str) -> None:
|
||||
server = await http_get_server(url, timeout=0.1)
|
||||
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
|
||||
RuntimeError (handled like a failed process) if any URL responds.
|
||||
"""
|
||||
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
|
||||
for url, server in zip(urls, servers, strict=True):
|
||||
if server is not None:
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"Conflicting %s already running at %s", server or "server", url
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
await asyncio.gather(*[check(url) for url in urls])
|
||||
raise RuntimeError(url)
|
||||
|
||||
|
||||
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||
"""Wait for the server to be ready by polling an endpoint.
|
||||
|
||||
Use empty path to disable the check and make this return immediately.
|
||||
Raises SystemExit(1) if server doesn't start in time.
|
||||
Logs, then raises RuntimeError if the server doesn't start in time.
|
||||
"""
|
||||
if not path:
|
||||
return
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||
logger.info("✓ Backend ready!")
|
||||
logger.info("🟢 Backend ready!")
|
||||
return
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
logger.error("Backend at %s didn't start in time", url)
|
||||
raise RuntimeError(url)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ def main() -> None:
|
||||
|
||||
print(f"\nBuild completed successfully for version {version}.")
|
||||
print("To publish, review the artifacts and run:")
|
||||
print(" uv publish && cd paskia-js && npm publish")
|
||||
print("\nuv publish && cd paskia-js && npm publish")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+16
-4
@@ -172,6 +172,7 @@ def test_serve_uses_stored_config(run_cli, tmp_path):
|
||||
assert calls["listen"] is None # stored listen (None) used
|
||||
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
|
||||
assert serve.listen is None
|
||||
assert serve.save is False
|
||||
|
||||
|
||||
def test_serve_listen_override_not_persisted(run_cli, tmp_path):
|
||||
@@ -181,24 +182,33 @@ def test_serve_listen_override_not_persisted(run_cli, tmp_path):
|
||||
assert calls["listen"] == ["4403"]
|
||||
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
|
||||
assert serve.listen == ["4403"]
|
||||
assert serve.save is False
|
||||
# Stored config keeps the original listen value
|
||||
assert stored_config(tmp_path).listen == ["4402"]
|
||||
|
||||
|
||||
def test_serve_listen_save_persists(run_cli, tmp_path):
|
||||
"""--save teleports the save flag; the app persists, the CLI is read-only."""
|
||||
run_cli("init", "--listen", "4402")
|
||||
calls = run_cli("--listen", "4403", "--save")
|
||||
|
||||
assert calls["listen"] == ["4403"]
|
||||
assert stored_config(tmp_path).listen == ["4403"]
|
||||
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
|
||||
assert serve.listen == ["4403"]
|
||||
assert serve.save is True
|
||||
# The CLI itself does not write the database
|
||||
assert stored_config(tmp_path).listen == ["4402"]
|
||||
|
||||
|
||||
def test_serve_listen_save_clear(run_cli, tmp_path):
|
||||
"""--listen "" --save clears the stored endpoints (back to default)."""
|
||||
"""--listen "" --save teleports a clear (back to default) for the app."""
|
||||
run_cli("init", "--listen", "4402")
|
||||
run_cli("--listen", "", "--save")
|
||||
|
||||
assert stored_config(tmp_path).listen is None
|
||||
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
|
||||
assert serve.listen is None
|
||||
assert serve.save is True
|
||||
assert stored_config(tmp_path).listen == ["4402"]
|
||||
|
||||
|
||||
def test_serve_suggests_migrate_when_legacy_present(run_cli, tmp_path):
|
||||
@@ -314,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))
|
||||
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
"""Tests for remote (satellite) domains: config, links, replica, feed."""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import msgspec
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import Response
|
||||
|
||||
import paskia.db.operations as ops_db
|
||||
from paskia import domains, satellite, syncfeed
|
||||
from paskia.db.structs import (
|
||||
Config,
|
||||
Credential,
|
||||
DomainConfig,
|
||||
OriginEntry,
|
||||
RemoteConfig,
|
||||
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, 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:
|
||||
return Config(
|
||||
domains={
|
||||
TEST_RP_ID: DomainConfig(origins={f"**.{TEST_RP_ID}": True}),
|
||||
"example.com": DomainConfig(
|
||||
origins={
|
||||
"**.example.com": True,
|
||||
"auth.example.com": OriginEntry(auth_host=True),
|
||||
},
|
||||
remote=RemoteConfig(link=UPLINK_ID, **kw),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_remote_domain_valid():
|
||||
domains.validate_config(_remote_domain_config())
|
||||
|
||||
|
||||
def test_remote_domain_requires_auth_host():
|
||||
config = _remote_domain_config()
|
||||
config.domains["example.com"].origins = {"**.example.com": True}
|
||||
with pytest.raises(ValueError, match="auth host"):
|
||||
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.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 = 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):
|
||||
return msgspec.to_builtins(obj)
|
||||
|
||||
|
||||
def test_apply_session_roundtrip():
|
||||
"""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),
|
||||
key=hash_secret("cookie", "sekret"),
|
||||
host="app2.example.com",
|
||||
ip="127.0.0.1",
|
||||
user_agent="ua",
|
||||
validated=datetime.now(UTC),
|
||||
rp_id="example.com",
|
||||
)
|
||||
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 = satellite.ReplicaDB()
|
||||
cred = Credential.create(
|
||||
credential_id=secrets.token_bytes(32),
|
||||
user=UUID(int=1),
|
||||
aaguid=UUID(int=0),
|
||||
public_key=secrets.token_bytes(64),
|
||||
sign_count=3,
|
||||
rp_id="example.com",
|
||||
)
|
||||
cred.uuid = UUID(int=9)
|
||||
# Simulate the full wire path: builtins -> JSON -> builtins
|
||||
wire = msgspec.json.decode(msgspec.json.encode(_builtins(cred)))
|
||||
satellite._apply(replica, "credentials", str(cred.uuid), wire)
|
||||
stored = replica.credentials[cred.uuid]
|
||||
assert stored.credential_id == cred.credential_id
|
||||
assert stored.public_key == cred.public_key
|
||||
assert stored.sign_count == 3
|
||||
|
||||
|
||||
def test_feed_emit_to_subscribers():
|
||||
queue = syncfeed.subscribe()
|
||||
try:
|
||||
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)
|
||||
|
||||
|
||||
def test_feed_drops_full_queue():
|
||||
queue = syncfeed.subscribe()
|
||||
try:
|
||||
for i in range(1001):
|
||||
syncfeed.emit("credentials", f"k{i}", None)
|
||||
assert queue.qsize() == 1000
|
||||
syncfeed.emit("credentials", "k1001", None) # subscriber already dropped
|
||||
assert queue.qsize() == 1000
|
||||
finally:
|
||||
syncfeed.unsubscribe(queue)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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:
|
||||
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.UplinkReplica(_uplink())
|
||||
token = secrets.token_urlsafe(12)
|
||||
session = Session.create(
|
||||
user=UUID(int=1),
|
||||
credential=UUID(int=2),
|
||||
key=hash_secret("cookie", token),
|
||||
host="app2.example.com",
|
||||
ip="1.1.1.1",
|
||||
user_agent="ua",
|
||||
validated=datetime(2020, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
replica.db.sessions[session.key] = session
|
||||
|
||||
now = datetime.now(UTC)
|
||||
replica.refresh_session(session.key, now, "2.2.2.2", "new-ua")
|
||||
assert replica.db.sessions[session.key].validated == now
|
||||
queued = replica._pending_refresh[session.key]
|
||||
assert queued["type"] == "session_refresh"
|
||||
assert queued["ip"] == "2.2.2.2"
|
||||
|
||||
# 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[UPLINK_ID] = replica
|
||||
satellite.evict_session(token, "app2.example.com")
|
||||
assert not replica.db.sessions
|
||||
satellite.manager.replicas.pop(UPLINK_ID)
|
||||
|
||||
|
||||
def test_availability_gate():
|
||||
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 = satellite.apply_domains_push(UPLINK_ID, [_PUSHED])
|
||||
assert not rejected
|
||||
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
|
||||
assert not satellite.apply_domains_push(UPLINK_ID, [_PUSHED])
|
||||
|
||||
# The uplink's scope shrank: the domain leaves the local config
|
||||
rejected = satellite.apply_domains_push(UPLINK_ID, [])
|
||||
assert not rejected
|
||||
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 = satellite.apply_domains_push(
|
||||
UPLINK_ID, [{**_PUSHED, "rp_id": TEST_RP_ID}]
|
||||
)
|
||||
assert TEST_RP_ID in rejected
|
||||
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[satellite.ReplicaDB, str]:
|
||||
"""A replica holding one credential/session and its pushed context."""
|
||||
replica = satellite.ReplicaDB()
|
||||
cred = Credential.create(
|
||||
credential_id=b"cid",
|
||||
user=UUID(int=104),
|
||||
aaguid=UUID(int=0),
|
||||
public_key=b"pk",
|
||||
sign_count=0,
|
||||
rp_id="example.com",
|
||||
)
|
||||
cred.uuid = UUID(int=105)
|
||||
replica.credentials[cred.uuid] = cred
|
||||
secret = secrets.token_urlsafe(12)
|
||||
session = Session.create(
|
||||
user=UUID(int=104),
|
||||
credential=cred.uuid,
|
||||
key=hash_secret("cookie", secret),
|
||||
host="app2.example.com",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
validated=datetime.now(UTC),
|
||||
rp_id="example.com",
|
||||
)
|
||||
replica.sessions[session.key] = session
|
||||
replica.session_contexts[session.key] = _api_ctx()
|
||||
return replica, secret
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def remote_client(test_db):
|
||||
"""ASGI client with example.com as a remote domain on a warm replica."""
|
||||
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.UplinkReplica(_uplink())
|
||||
replica.db = replica_db
|
||||
replica.last_contact = time.monotonic()
|
||||
replica.connected = True
|
||||
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(UPLINK_ID, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_served_from_replica(remote_client):
|
||||
client, secret, _ = remote_client
|
||||
r = await client.get(
|
||||
"/auth/api/forward?perm=auth:admin",
|
||||
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
|
||||
)
|
||||
assert r.status_code == 204
|
||||
assert r.headers["remote-name"] == "Remote Admin"
|
||||
assert r.headers["remote-groups"] == "auth:admin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_replica_denies_missing_perm(remote_client):
|
||||
client, secret, _ = remote_client
|
||||
r = await client.get(
|
||||
"/auth/api/forward?perm=other:scope",
|
||||
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_renews_locally_and_queues_writebehind(remote_client):
|
||||
client, secret, replica = remote_client
|
||||
session = next(iter(replica.db.sessions.values()))
|
||||
session.validated = datetime(2020, 1, 1, tzinfo=UTC) # force refresh threshold
|
||||
r = await client.post(
|
||||
"/auth/api/validate",
|
||||
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["renewed"] is True
|
||||
assert session.validated.year > 2020 # applied to the replica
|
||||
queued = replica._pending_refresh[session.key]
|
||||
assert queued["type"] == "session_refresh"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_domain_503_when_replica_stale(remote_client):
|
||||
client, secret, replica = remote_client
|
||||
replica.connected = False
|
||||
replica.last_contact = 0
|
||||
r = await client.get(
|
||||
"/auth/api/forward",
|
||||
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
|
||||
)
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logout_proxied_and_evicted(remote_client, monkeypatch):
|
||||
client, secret, replica = remote_client
|
||||
|
||||
async def fake_forward(request):
|
||||
return Response(status_code=200, content=b'{"message": "Logged out"}')
|
||||
|
||||
monkeypatch.setattr(satellite, "forward_request", fake_forward)
|
||||
r = await client.post(
|
||||
"/auth/api/logout",
|
||||
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
|
||||
)
|
||||
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_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}",
|
||||
}
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={
|
||||
"rp_id": "example.com",
|
||||
"origins": {
|
||||
"**.example.com": True,
|
||||
"auth.example.com": {"auth_host": True},
|
||||
},
|
||||
"remote": {"link": "x"},
|
||||
},
|
||||
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),
|
||||
)
|
||||
},
|
||||
)
|
||||
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_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:
|
||||
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:
|
||||
syncfeed.unsubscribe(scoped)
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
syncfeed.unsubscribe(queue)
|
||||
|
||||
|
||||
def _drain(queue):
|
||||
items = []
|
||||
while not queue.empty():
|
||||
items.append(queue.get_nowait())
|
||||
return items
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_reports_remote_flag(remote_client):
|
||||
client, _, _ = remote_client
|
||||
r = await client.get("/auth/api/settings", headers={"Host": "app2.example.com"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["remote"] is True
|
||||
r = await client.get("/auth/api/settings", headers={"Host": "localhost:4401"})
|
||||
assert r.json()["remote"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logout_remote_down_returns_503(remote_client, monkeypatch):
|
||||
"""A transport failure on a proxied call is a deliberate 503, not a 500."""
|
||||
client, secret, replica = remote_client
|
||||
|
||||
class Down:
|
||||
async def request(self, *args, **kwargs):
|
||||
raise httpx.ConnectError("down")
|
||||
|
||||
monkeypatch.setitem(satellite._clients, REMOTE_URL, Down())
|
||||
r = await client.post(
|
||||
"/auth/api/logout",
|
||||
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
|
||||
)
|
||||
assert r.status_code == 503
|
||||
# Not evicted: the remote never saw the logout, the session is still valid
|
||||
assert replica.db.sessions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_api_proxied_on_remote_auth_host(remote_client, monkeypatch):
|
||||
"""Profile traffic for a remote domain's auth host is forwarded, never
|
||||
written to the satellite's local database."""
|
||||
client, _, _ = remote_client
|
||||
|
||||
async def fake_forward(request):
|
||||
return Response(status_code=200, content=b"proxied")
|
||||
|
||||
monkeypatch.setattr(satellite, "forward_request", fake_forward)
|
||||
r = await client.get(
|
||||
f"/auth/api/user/{UUID(int=104)}/profile.webp",
|
||||
headers={"Host": "auth.example.com"},
|
||||
)
|
||||
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
|
||||
@@ -0,0 +1,593 @@
|
||||
"""Live end-to-end tests for satellite domains over a real sync channel.
|
||||
|
||||
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 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 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 (
|
||||
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
|
||||
|
||||
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:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
async def wait_for(desc: str, condition, timeout: float = 10) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if condition():
|
||||
return
|
||||
await asyncio.sleep(0.02)
|
||||
raise AssertionError(f"timed out waiting for {desc}")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def live_server(test_db):
|
||||
"""A uvicorn-served upstream over a real TCP socket.
|
||||
|
||||
Yields a namespace: db, url, port, start_server (for restarts),
|
||||
servers.
|
||||
"""
|
||||
servers: list[tuple[uvicorn.Server, asyncio.Task]] = []
|
||||
|
||||
async def start_server(port: int) -> uvicorn.Server:
|
||||
config = uvicorn.Config(
|
||||
app, host="127.0.0.1", port=port, log_level="error", lifespan="off"
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
task = asyncio.create_task(server.serve())
|
||||
servers.append((server, task))
|
||||
while not server.started:
|
||||
await asyncio.sleep(0.02)
|
||||
return server
|
||||
|
||||
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(
|
||||
Config(
|
||||
domains={
|
||||
TEST_RP_ID: DomainConfig(origins={f"**.{TEST_RP_ID}": True}),
|
||||
REMOTE_DOMAIN: DomainConfig(
|
||||
origins={
|
||||
f"**.{REMOTE_DOMAIN}": True,
|
||||
f"auth.{REMOTE_DOMAIN}": OriginEntry(auth_host=True),
|
||||
},
|
||||
remote=RemoteConfig(link=LINK_ID),
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
await satellite.manager.start()
|
||||
replica = satellite.manager.replicas[LINK_ID]
|
||||
await wait_for("initial snapshot", lambda: replica.available())
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://localhost:4401"
|
||||
) as client:
|
||||
try:
|
||||
live.client = client
|
||||
live.replica = replica
|
||||
yield live
|
||||
finally:
|
||||
await satellite.manager.stop()
|
||||
|
||||
|
||||
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_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.replica.db.session_contexts[key].user.display_name == "Live Rename"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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=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,
|
||||
)
|
||||
await wait_for("session sync", lambda: key in live.replica.db.sessions)
|
||||
|
||||
r = await live.client.get(
|
||||
"/auth/api/forward?perm=auth:admin", headers=_app_headers(secret)
|
||||
)
|
||||
assert r.status_code == 204
|
||||
assert r.headers["remote-name"] == "Test Admin"
|
||||
|
||||
# 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(
|
||||
"staleness to propagate",
|
||||
lambda: live.replica.db.sessions[key].validated == stale,
|
||||
)
|
||||
r = await live.client.post("/auth/api/validate", headers=_app_headers(secret))
|
||||
assert r.status_code == 200
|
||||
assert r.json()["renewed"] is True
|
||||
await wait_for(
|
||||
"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, remote_credential
|
||||
):
|
||||
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,
|
||||
)
|
||||
await wait_for("session sync", lambda: key in live.replica.db.sessions)
|
||||
|
||||
ops_db.delete_session(key)
|
||||
await wait_for(
|
||||
"session delete to propagate", lambda: key not in live.replica.db.sessions
|
||||
)
|
||||
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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)
|
||||
assert not bad.available()
|
||||
finally:
|
||||
await bad.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_disconnect_ttl_and_reconnect(
|
||||
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=remote_credential.uuid,
|
||||
host=APP_HOST,
|
||||
rp_id=REMOTE_DOMAIN,
|
||||
)
|
||||
await wait_for("session sync", lambda: key in live.replica.db.sessions)
|
||||
|
||||
server, task = live.servers[0]
|
||||
server.should_exit = True
|
||||
await task
|
||||
await wait_for(
|
||||
"dead peer detection", lambda: not live.replica.connected, timeout=15
|
||||
)
|
||||
|
||||
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
|
||||
assert r.status_code == 204 # still authoritative within cache_ttl
|
||||
|
||||
# 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
|
||||
|
||||
# Drift while the channel is down, then let the client auto-reconnect
|
||||
ops_db.update_user_display_name(test_user.uuid, "While Down")
|
||||
await live.start_server(live.port)
|
||||
await wait_for("reconnect", lambda: live.replica.available(), timeout=20)
|
||||
assert (
|
||||
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_unlink_stops_replica(live_remote):
|
||||
"""Unlinking drops the uplink, its domains and the replica."""
|
||||
live = live_remote
|
||||
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(
|
||||
"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, [])
|
||||
await 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])
|
||||
await 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),
|
||||
},
|
||||
),
|
||||
)
|
||||
await 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