Files
sso-manager-node/nodejs/models/site_join_key.js
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

73 lines
2.4 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const { Model } = require('@simpleworkjs/orm');
// A site join key: the one credential a SPOKE deployment presents to the MASTER
// to pull a full directory export (LDAP LDIF + resource catalog) when joining
// (MULTI_SITE_SPEC.md). It works like an agent join key — issued once, shown
// once, stored hashed, revocable, expirable.
//
// The master's POST /api/site/export authenticates callers with this key; the
// spoke's POST /api/site/join consumes it. The `stj_` prefix distinguishes a
// site join key from an agent token / `tjk_` agent join key at a glance.
class SiteJoinKey extends Model {
static hashKey(raw) {
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
}
static generateKey() {
return 'stj_' + crypto.randomBytes(32).toString('hex');
}
// Resolve a presented key to a usable site join key, or null. Expiry and
// revocation are enforced here so no caller can forget one.
static async authenticate(rawKey) {
if (!rawKey || typeof rawKey !== 'string') return null;
const keyHash = this.hashKey(rawKey);
const matches = await this.list({ where: { keyHash } });
const key = matches && matches[0];
if (!key) return null;
if (key.revoked) return null;
if (key.expires_on && key.expires_on < Math.floor(Date.now() / 1000)) return null;
return key;
}
static async issue({ label, createdBy, expiresInDays }) {
const raw = this.generateKey();
const key = await this.create({
id: crypto.randomUUID(),
label: label || 'default',
keyHash: this.hashKey(raw),
keyPrefix: raw.slice(0, 12),
revoked: false,
created_by: createdBy || null,
created_on: Math.floor(Date.now() / 1000),
expires_on: expiresInDays ? Math.floor(Date.now() / 1000) + expiresInDays * 86400 : null,
use_count: 0
});
return { key, raw };
}
static fields = {
id: { type: 'uuid', primaryKey: true },
label: { type: 'string', isRequired: true },
keyHash: { type: 'string', isRequired: true },
keyPrefix: { type: 'string' },
revoked: { type: 'boolean', default: false },
created_by: { type: 'string' },
created_on: { type: 'integer' },
expires_on: { type: 'integer' },
use_count: { type: 'integer', default: 0 },
last_used_on: { type: 'integer' }
};
toPublic() {
const data = this.toJSON ? this.toJSON() : { ...this };
delete data.keyHash;
return data;
}
}
module.exports = { SiteJoinKey };