feat(site): join UI, spoke read-only enforcement, live WAN health, fresh-install guard
Completes the multi-site join layer on top of the v2.2.0 endpoints: - UI (Master Site modal): a fresh install (canJoin) gets a 'Join an Existing Site' form (master URL + stj_ key); a master gets a 'Site Join Keys' manager (mint/revoke/list, key shown once); WAN Sync Health now reflects a live probe. - POST /api/site/ping (Bearer stj_ key, no admin session): lightweight master reachability probe for WAN health (cheap vs /export). - Spoke read-only: directory-write routes (resources/edges/groups/secrets/ grants/driver-action/discovered) reject with 403 pointing at the master. - Fresh-install guard: /api/site/join refuses unless no users beyond the bootstrap admin and no enrolled agents (siteIsFresh), and site-status exposes canJoin so the UI only offers join on a genuinely fresh install. The bootstrap's seeded default resources are NOT the signal (they always exist). - The spoke stores the join key (masterJoinKey) in /config/site.json so WAN health (and a future write-proxy) can reach the master. - Tests: siteIsFresh cases in tests/site_join.test.js.
This commit is contained in:
+38
-53
@@ -6,25 +6,28 @@ copy for local latency and autonomy (see the root `MULTI_SITE_SPEC.md` for the
|
||||
full architecture). This page covers the server endpoints that make a spoke
|
||||
"join" an existing master.
|
||||
|
||||
> Status: **server endpoints only.** The `setup.sh` wiring and the UI that calls
|
||||
> them are the next layer; the join is designed to run during a fresh bring-up
|
||||
> (before the bootstrap seeds local content), so there is nothing local to wipe
|
||||
> when adopting the master's directory.
|
||||
> Status: **server endpoints + UI + setup.sh wiring.** A fresh bring-up can
|
||||
> adopt a master directory via the Directory UI or via `setup.env`, and a
|
||||
> joined spoke is read-only with live WAN health.
|
||||
|
||||
## The flow
|
||||
|
||||
1. On the **master**, an admin mints a **site join key** (`stj_…`, shown once,
|
||||
stored hashed, revocable).
|
||||
2. On the **spoke** (a fresh install), an admin calls `POST /api/site/join`
|
||||
with the master's URL + that key.
|
||||
stored hashed, revocable) — Directory → the Master Site modal → **Site Join Keys**.
|
||||
2. On the **spoke** (a fresh install), either:
|
||||
- **UI**: Directory → the Master Site modal → **Join an Existing Site**, or
|
||||
- **setup.sh**: set `CFG_MASTER_DIRECTORY_URL` + `CFG_MASTER_DIRECTORY_JOIN_KEY`
|
||||
in `setup.env` before the first run.
|
||||
3. The spoke pulls the master's directory export (LDAP tree + resource
|
||||
catalog), imports it, and persists its own spoke role
|
||||
(`isMaster: false`, `masterUrl`, `siteSlug`).
|
||||
(`isMaster: false`, `masterUrl`, `siteSlug`) in `/config/site.json`.
|
||||
|
||||
Joining is allowed only on a **fresh install** (no users beyond the bootstrap
|
||||
admin, no enrolled agents) — the join endpoint enforces this, so a populated
|
||||
directory can never be merged into a master's.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Join key management (admin session)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| :--- | :--- | :--- |
|
||||
| `GET` | `/api/site/join-keys` | List keys (prefix + usage only; never the key) |
|
||||
@@ -32,60 +35,42 @@ full architecture). This page covers the server endpoints that make a spoke
|
||||
| `POST` | `/api/site/join-keys/:id/revoke` | Stop it accepting new joins |
|
||||
| `DELETE` | `/api/site/join-keys/:id` | Remove it |
|
||||
| `GET` | `/api/site/config` | Current role (isMaster, masterUrl, siteSlug) |
|
||||
| `POST` | `/api/site/export` | Master directory export (Bearer `stj_` key) |
|
||||
| `POST` | `/api/site/ping` | Lightweight master reachability probe (Bearer `stj_` key) |
|
||||
| `POST` | `/api/site/join` | Adopt a master directory (admin session) |
|
||||
|
||||
Mint a key:
|
||||
## Behavior after joining (spoke)
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $SESSION_TOKEN" \
|
||||
-X POST https://sso.master.example.com/api/site/join-keys \
|
||||
-H 'Content-Type: application/json' -d '{"label":"staten-island"}'
|
||||
# -> { "joinKey": {...}, "key": "stj_9f2e..." } # show `key` once
|
||||
```
|
||||
- **Read-only**: directory-write requests (resources, edges, groups, secrets,
|
||||
grants, driver actions, discovery merges) are rejected with `403` pointing at
|
||||
the master. Writes must go to the master.
|
||||
- **WAN health**: `site-status` pings the master over the stored site join key
|
||||
and reports `wanConnected`; the Master Site modal shows live Online/Offline.
|
||||
- **Role persists**: `isMaster`/`masterUrl`/`siteSlug` live in `/config/site.json`
|
||||
(the env vars `IS_MASTER`/`MASTER_URL`/`SITE_SLUG` only seed the defaults), so
|
||||
a restart never silently reverts a spoke to master.
|
||||
|
||||
### Export (master side — no admin session)
|
||||
## Deployment (setup.sh)
|
||||
|
||||
`POST /api/site/export` authenticated with a site join key
|
||||
(`Authorization: Bearer stj_…`). Returns the local LDAP tree as an LDIF
|
||||
(`slapcat`), the resource catalog (`Resource` + `ResourceEdge` rows), the site
|
||||
slug, and the LDAP base DN. The spoke's join endpoint calls this.
|
||||
|
||||
### Join (spoke side — admin session)
|
||||
|
||||
`POST /api/site/join` with:
|
||||
|
||||
```json
|
||||
{ "masterUrl": "https://sso.master.example.com", "joinKey": "stj_9f2e..." }
|
||||
```
|
||||
|
||||
The spoke:
|
||||
|
||||
1. **Imports the resource catalog** — resources are upserted by slug (the
|
||||
master is authoritative for the shared catalog) and edges are recreated.
|
||||
2. **Imports the LDAP tree** — the master's LDIF is loaded into the local
|
||||
slapd with `ldapadd -c`, so the spoke keeps its own `cn=admin` / base DN and
|
||||
inherits the master's users/groups.
|
||||
3. **Persists the spoke role** in `/config/site.json` (survives restarts).
|
||||
|
||||
The join is refused if this node is already a spoke (no re-join).
|
||||
|
||||
## Deployment (setup.sh wiring — next layer)
|
||||
|
||||
`setup.env` will carry the intent so the join runs only on a **fresh** bring-up:
|
||||
`setup.env` carries the intent so the join runs only on a **fresh** bring-up:
|
||||
|
||||
```
|
||||
# Multi-site: join an existing (master) deployment instead of seeding a fresh one.
|
||||
# Honored ONLY on first run; re-runs ignore it once ./config/ exists.
|
||||
#CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||
#CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||
CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||
CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||
```
|
||||
|
||||
The role itself is seeded from the environment (`IS_MASTER`, `MASTER_URL`,
|
||||
`SITE_SLUG`) and overridden by `/config/site.json` once a promote/join writes it.
|
||||
`setup.sh` runs `bootstrap/site-join.js` inside the sso-manager container after
|
||||
the bootstrap; it logs in as the admin and calls `/api/site/join`. A node that
|
||||
already joined reports "already a spoke" and setup continues (idempotent).
|
||||
|
||||
## Security
|
||||
|
||||
- Join keys are single-use-intent credentials: shown once, stored as a SHA-256
|
||||
hash, revocable/expirable — the same model as agent join keys.
|
||||
- The export endpoint never returns admin secrets; it returns the LDAP tree +
|
||||
resource catalog the spoke needs to operate.
|
||||
- Join is admin-gated on the spoke and key-gated on the master.
|
||||
- The export/ping endpoints return only the directory tree/catalog (no admin
|
||||
secrets) and require a valid join key.
|
||||
- Join is admin-gated on the spoke, key-gated on the master, and fresh-install
|
||||
gated on both sides.
|
||||
- The join key is stored on the spoke only so it can reach the master for WAN
|
||||
health (and, in a later layer, write-proxy).
|
||||
|
||||
Reference in New Issue
Block a user