Files
sso-manager-node/docs/site-join.md
T
wmantly c96a4b6652 feat(site): multi-site join server endpoints + persisted site role + emoji fix
Server endpoints for joining a spoke to a master directory (MULTI_SITE_SPEC.md).
This pass is server-only; setup.sh wiring and the UI are the next layer.

- Site join keys (SiteJoinKey model, stj_ prefix): mint/revoke/delete/list,
  hashed at rest, shown once — the same model as agent join keys.
- POST /api/site/export (master, Bearer stj_ key, no admin session): returns the
  local LDAP tree (slapcat LDIF) + resource catalog + siteSlug + baseDn.
- POST /api/site/join (spoke, admin): { masterUrl, joinKey } pulls the master
  export, imports resources (upsert by slug) + LDAP (ldapadd -c), and persists
  the spoke role. Refused if already a spoke.
- Persisted site role: utils/site_config.js keeps isMaster/masterUrl/siteSlug in
  /config/site.json (env seeds defaults); site-status/site-promote now use it.
- Unit tests (site_join, site_config) with in-memory stubs, wired into npm test.
- docs/site-join.md + docs router entry.
- Repairs the corrupted multi-site emojis (crown/bolt) in directory.ejs.
- .gitguardian.yml ignores the generic-password false positive on reading the
  LDAP bind credential from runtime config (never a hardcoded secret).
2026-08-10 05:58:28 -07:00

3.6 KiB

Multi-Site: Joining a Spoke to the Master Directory

The Directory can be deployed across multiple sites. The master site holds single write authority for the shared catalog; spoke sites run a read-only 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.

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.
  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).

Endpoints

Join key management (admin session)

Method Path Purpose
GET /api/site/join-keys List keys (prefix + usage only; never the key)
POST /api/site/join-keys Mint one — returned once
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)

Mint a key:

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

Export (master side — no admin session)

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:

{ "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:

# 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...

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.

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.