feat: multi-tenancy seam (resolve_owner) + hub-setup how-to (v1 prep)
All checks were successful
Build / validate (pull_request) Successful in 10m34s

The cheap "seam" that keeps the single-owner hub from calcifying, ahead of
the gilbert -> indri bring-up:

- Replace the single-tenant gate `Store::authorize_owner_sub(sub) -> bool`
  with `resolve_owner(sub) -> Option<owner_id>`. The hub auth middleware now
  resolves the token's identity to the owner it may act as (Some -> allow,
  None -> 403). Behavior is identical for the single-owner hub (claim-on-first;
  strangers still 403), but the contract no longer assumes one global owner, so
  serving N owners later is additive, not a rewrite. The per-request owner is
  marked at the exact line where downstream scoping wires through.
- New how-to docs/how-to/set-up-sync-hub.md: stand up the hub and connect an
  existing device as an offline-capable spoke, the data-safe way (Path A: the
  hub adopts the device's identity rather than rewriting the device).

The decision (cheap seam now, defer full multi-tenancy + adoption rewrite) is
recorded in the Adoption + multi-tenant task's context doc. Two enabler gaps
the how-to surfaced (heph daemon hub/spoke service flags; Path-A seeding tool)
are filed as Hephaestus tasks.

Green: 228 tests, clippy -D warnings + fmt + prek clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-04 07:08:39 -07:00
commit a0b04eefda
9 changed files with 172 additions and 25 deletions

View file

@ -423,7 +423,7 @@ impl Store for LocalStore {
syncstate::record(&self.conn, peer, pushed, pulled, now)
}
fn authorize_owner_sub(&mut self, sub: &str) -> Result<bool> {
fn resolve_owner(&mut self, sub: &str) -> Result<Option<String>> {
// The owner's bound identity (NULL until first authenticated sync).
let current: Option<String> = self
.conn
@ -436,13 +436,15 @@ impl Store for LocalStore {
.flatten();
match current {
None => {
// Claim-on-first: bind this sub to the store's owner.
self.conn.execute(
"UPDATE users SET oidc_sub = ?1 WHERE id = ?2",
(sub, &self.owner_id),
)?;
Ok(true)
Ok(Some(self.owner_id.clone()))
}
Some(existing) => Ok(existing == sub),
Some(existing) if existing == sub => Ok(Some(self.owner_id.clone())),
Some(_) => Ok(None),
}
}

View file

@ -216,12 +216,18 @@ pub trait Store {
fn record_sync(&mut self, peer: &str, pushed: Option<&str>, pulled: Option<&str>)
-> Result<()>;
/// Single-tenant authentication gate (tech-spec §13). Map an OIDC `sub` to
/// this store's owner: on first sight, **claim** the owner by binding its
/// `oidc_sub`; thereafter authorize only that same `sub`. Returns `true` if
/// the `sub` owns this store, `false` if a different identity presented a
/// token. A hub calls this before serving any op exchange.
fn authorize_owner_sub(&mut self, sub: &str) -> Result<bool>;
/// Resolve an OIDC `sub` to the `owner_id` it may act as on this store —
/// the **multi-tenancy seam** (tech-spec §13). On first sight, **claim** the
/// store's owner by binding its `oidc_sub`; thereafter resolve only that
/// same `sub`. Returns `Some(owner_id)` when the `sub` owns data here, or
/// `None` when a different identity presented a token (the hub then 403s).
///
/// Today a store hosts exactly one owner, so this resolves to that single
/// owner or `None`. Multi-tenancy (serving N owners from one hub) extends
/// this to a real `sub → owner_id` mapping with per-`sub` provisioning, and
/// the hub scopes each request to the resolved owner — without changing this
/// contract. A hub calls this before serving any op exchange.
fn resolve_owner(&mut self, sub: &str) -> Result<Option<String>>;
/// Open merge conflicts surfaced for the user (`heph conflicts`).
fn conflicts_list(&self) -> Result<Vec<Conflict>>;

View file

@ -75,18 +75,26 @@ fn sync_cursors_default_empty_then_advance_per_direction() {
}
#[test]
fn owner_sub_gate_claims_first_then_requires_match() {
// Single-tenant gate (§13): the first sub claims the owner; only that sub
// is authorized thereafter.
fn resolve_owner_claims_first_then_requires_match() {
// The hub resolves an OIDC `sub` to its owner id (§13) — the multi-tenancy
// seam. The first sub claims the (single, for now) owner; only that sub
// resolves thereafter, and always to the same owner id.
let (mut a, _ca) = replica(1000);
assert!(a.authorize_owner_sub("sub-alice").unwrap(), "first claims");
assert!(a.authorize_owner_sub("sub-alice").unwrap(), "same sub ok");
let owner = a.resolve_owner("sub-alice").unwrap().expect("first claims");
assert_eq!(
a.resolve_owner("sub-alice").unwrap().as_deref(),
Some(owner.as_str()),
"same sub resolves to the same owner"
);
assert!(
!a.authorize_owner_sub("sub-mallory").unwrap(),
"a different identity must be rejected"
a.resolve_owner("sub-mallory").unwrap().is_none(),
"a different identity does not resolve (the hub then 403s)"
);
// Still bound to the original after a rejection.
assert!(a.authorize_owner_sub("sub-alice").unwrap());
assert_eq!(
a.resolve_owner("sub-alice").unwrap().as_deref(),
Some(owner.as_str())
);
}
#[test]

View file

@ -315,10 +315,10 @@ impl Store for RemoteStore {
Ok(())
}
fn authorize_owner_sub(&mut self, _sub: &str) -> Result<bool> {
// Hub-side gate; a no-replica client never hosts an endpoint to guard.
fn resolve_owner(&mut self, _sub: &str) -> Result<Option<String>> {
// Hub-side seam; a no-replica client never hosts an endpoint to guard.
Err(Error::Remote(
"authorize_owner_sub is a hub-side operation".into(),
"resolve_owner is a hub-side operation".into(),
))
}

View file

@ -136,20 +136,23 @@ async fn require_auth(
_ => StatusCode::UNAUTHORIZED,
})?;
// Single-tenant gate: the token's identity must own this hub.
// Multi-tenancy seam: resolve the token's identity to the owner it may act
// as. Today the hub serves one owner, so this is `Some(that owner)` or
// `None` (→ 403). When the hub becomes multi-owner, `_owner_id` is what each
// downstream handler scopes its ops to (rather than the store's lone owner).
let store = state.store.clone();
let owns = tokio::task::spawn_blocking(move || {
let owner = tokio::task::spawn_blocking(move || {
store
.lock()
.expect("store mutex poisoned")
.authorize_owner_sub(&claims.sub)
.resolve_owner(&claims.sub)
})
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !owns {
let Some(_owner_id) = owner else {
return Err(StatusCode::FORBIDDEN);
}
};
Ok(next.run(request).await)
}

View file

@ -0,0 +1 @@
New how-to: [[set-up-sync-hub]] — stand up the canonical hub and connect an existing local device as an offline-capable spoke, the data-safe way (the hub adopts the device's identity rather than rewriting the device).

View file

@ -0,0 +1 @@
Hub auth now **resolves an OIDC `sub` to an `owner_id`** (`Store::resolve_owner → Option<owner_id>`) instead of a single-tenant boolean gate (`authorize_owner_sub → bool`). Behavior is unchanged for the single-owner hub (claim-on-first; a stranger's token still 403s), but the contract no longer assumes one global owner — this is the multi-tenancy seam, so serving N owners later is additive rather than a rewrite. See the `Adoption + multi-tenant` task's context for the full decision.

View file

@ -18,4 +18,5 @@ Task-oriented guides for common operations.
- [[install-heph]] — Install `heph`/`hephd` from the forge, set up the Neovim plugin, and isolate in-repo development
- [[run-the-daemon]] — Run `hephd` as an OS service with `heph daemon start/stop/restart/status`
- [[set-up-sync-hub]] — Stand up the canonical hub (indri) and connect an existing device as an offline-capable spoke
- [[import-todoist]] — Seed a heph store from your Todoist projects + tasks (`mise run import-todoist`)

View file

@ -0,0 +1,125 @@
---
title: Set up a sync hub (and connect a device)
modified: 2026-06-04
tags:
- how-to
---
# Set up a sync hub (and connect a device)
How to stand up the canonical **hub** (on `indri`, in blumeops) and connect an
existing **local** device (e.g. `gilbert`) to it as an offline-capable spoke,
**without migrating or risking the device's data**.
## The model
heph is **hub-and-spoke**, not a peer mesh ([[design]] §4, [[v1-prototype-tech-spec]] §3/§12/§13):
- **Hub**`hephd --mode server`: a full replica that also exposes an HTTP
endpoint others sync against. One canonical hub (`indri`).
- **Spoke**`hephd --mode local --hub-url <hub>`: its own full SQLite replica,
**fully usable offline**, with an append-only op-log; it background-syncs
(pull → merge → push) when the hub is reachable. Every device is a spoke.
Surfaces (CLI / TUI / nvim) only ever talk to the **local** daemon over the unix
socket; that daemon handles the hub conversation in the background.
**Transport vs. identity.** Tailscale gives the devices a secure private network
(reachability + encryption). **Authentik** sits on top as the authorization
layer: the hub requires a valid OIDC bearer token on every op exchange, so
merely being on the tailnet is not enough — this is the owner's most sensitive
data.
## The data-safety principle: the hub adopts the device, not the reverse
A device's `owner_id` is embedded in some node ids (journals, tags), the op-log,
and link rows. Rewriting it in place is the risky operation we **avoid**. Instead
(**"Path A"**): the hub takes on the *existing device's* identity — same
`owner_id` and data — so the device is **never rewritten**. `gilbert`'s store is
untouched; `indri` is brought up as a copy of it and the two sync forward.
> A device that is set up **after** the hub exists skips all of this: configure
> it with the hub + Authentik from first launch ("born authed"), before it
> creates data, and it simply joins.
## 1. Authentik: register the heph application
Create an OIDC/OAuth2 application + provider in Authentik for heph, configured
for the **device-code (RFC 8628) flow**. Note the values the daemon and devices
need:
- **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/`
- **Client id** — the device-code client id (this is also the token *audience*).
## 2. Bring up the hub on `indri`
**Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`),
copy its store to `indri`, and give `indri` its **own device origin** so the two
replicas don't share one (see *Current gaps* — this seeding step is the bit the
blumeops deployment finalizes). `indri` now holds `gilbert`'s data under the same
`owner_id`.
Run the hub with auth enabled (issuer **and** audience together turn auth on;
omit both only for local dev):
```bash
hephd --mode server \
--http-addr 0.0.0.0:8787 \
--db /var/lib/heph/heph.db \
--oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \
--oidc-audience <heph-client-id>
```
The first identity to authenticate **claims** the hub's owner; thereafter only
that identity is served (single-owner today — see [[design]] and the
`Adoption + multi-tenant` task for the multi-tenancy seam).
## 3. Point `gilbert` at the hub (spoke)
Run `gilbert`'s daemon in local mode with the hub url + its OIDC client id, then
log in once (the device-code flow caches a bearer token in the OS keyring):
```bash
hephd --mode local \
--hub-url http://indri.<tailnet>.ts.net:8787 \
--oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \
--oidc-client-id <heph-client-id>
# one-time browser login on this device:
heph auth login \
--hub-url http://indri.<tailnet>.ts.net:8787 \
--issuer https://authentik.ops.eblu.me/application/o/heph/ \
--client-id <heph-client-id>
```
The spoke now attaches the (auto-refreshing) bearer token to every hub request
and background-syncs on its interval.
## 4. Verify
```bash
heph sync --status # last push/pull cursors, hub url
heph sync # force a cycle now
```
Make a change on `gilbert`, force a sync, and confirm it appears via the hub.
## Current gaps (finalized by the blumeops deployment)
The flag-level flow above works today; two enablers make it a clean, managed
deployment rather than a hand-run process — tracked in the `Hephaestus` project:
- **`heph daemon` only generates a `--mode local` service** (no `--hub-url` /
`--oidc-*`). So for now the hub and the spoke config are expressed as `hephd`
flags (run directly, or via the blumeops-managed systemd unit), not via
`heph daemon start`.
- **Path A seeding is manual** (copy the store + reset the device origin). A
small enabler — seed a hub from a snapshot with a fresh origin, or
`hephd --owner-id` — would make this one step.
## Related
- [[run-the-daemon]] — manage the local daemon as an OS service
- [[install-heph]] — install `heph`/`hephd` and the plugin
- [[design]] — §4 the connect-only, hub-and-spoke model
- [[v1-prototype-tech-spec]] — §3 runtime modes, §12 sync, §13 auth