c96a4b6652
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).
73 lines
2.4 KiB
JavaScript
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 };
|