Add LDAP-over-HTTPS API, agent secrets/IAM engines, and join key management
See CHANGELOG.md for the full breakdown. Summary:
- POST /api/v1/ldap/{bind,search}: LDAP-over-HTTPS so a client stops
speaking raw LDAP and instead calls the SSO, which binds/searches its
own OpenLDAP on the caller's behalf (DESIGN.md §3).
- LDAP byte-pump relay (utils/ldap_tunnel.js): forwards raw LDAP bytes
from an agent's local socket into OpenLDAP over the existing agent WSS
channel; the SSO never parses LDAP (DESIGN.md §4).
- POST /api/v1/agent/secrets: node-scoped OpenBao secret fetch for
agents, enforced to each agent's own secret/data/nodes/<id>/* prefix
(DESIGN.md §5).
- iam_apply signed command: push node-scoped IAM config (sudo rules, SSH
keys, access control, revocation) to an agent (DESIGN.md §6).
- Agent capability badges on the Directory Metrics tab, sourced from the
agent's own discovery frame.
- Join key management: GET /api/agent/join-keys/:id/agents (which hosts
enrolled through a key) plus a Manage join keys table in the Install
Agent modal with Revoke/Delete actions, confirmed inline per-row rather
than a blocking native confirm() or the shared app.messages.confirm()
banner (which desyncs across concurrent rows -- see CHANGELOG).
- docs/agents.md: capability matrix updated for the three new
capabilities, a full secrets-engine walkthrough with screenshots
(bash + Node consuming a rendered secret, plus the direct-API
alternative), and the join-key reuse/UI/audit questions answered.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ const middleware = require('../middleware/auth');
|
||||
const permission = require('../utils/permission');
|
||||
const agentManager = require('../utils/agent_manager');
|
||||
const agentKeys = require('../utils/agent_keys');
|
||||
const ldapTunnel = require('../utils/ldap_tunnel');
|
||||
const { Agent, AgentJoinKey } = require('../models/agent');
|
||||
|
||||
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||
@@ -12,7 +13,7 @@ const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_adm
|
||||
// Commands that can change or run code on the host. They are signed with the
|
||||
// SSO's persisted Ed25519 key and the agent verifies against the key pinned in
|
||||
// its agent.yml.
|
||||
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
|
||||
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply'];
|
||||
|
||||
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
|
||||
// This is a plain Express Router exported directly so app.js can
|
||||
@@ -183,6 +184,23 @@ router.get('/join-keys', async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Which hosts enrolled through a given key. There is no stored relation --
|
||||
// join keys are exchanged for a per-agent token immediately, and from then on
|
||||
// the agent's own identity is what matters -- so this matches on the
|
||||
// human-readable trace `Agent.enroll` already leaves in `description`
|
||||
// ("Self-enrolled with join key <prefix>") rather than a foreign key. Prefixes
|
||||
// are 12 random hex chars, so a collision is not a practical concern.
|
||||
router.get('/join-keys/:id/agents', async (req, res, next) => {
|
||||
try {
|
||||
const key = await AgentJoinKey.get(req.params.id);
|
||||
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||
const marker = `join key ${key.keyPrefix}`;
|
||||
const agents = await Agent.list();
|
||||
const matches = agents.filter(a => (a.description || '').includes(marker));
|
||||
res.json({ status: 'ok', agents: matches.map(a => a.toPublic(agentManager.liveState(a.id))) });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/join-keys', async (req, res, next) => {
|
||||
try {
|
||||
const { label, expiresInDays } = req.body || {};
|
||||
@@ -358,6 +376,11 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
|
||||
await agentManager.handleResponse(current, payload);
|
||||
if (app.io) app.io.emit('agent.response', { agentId: current.id, payload });
|
||||
break;
|
||||
case 'ldap_tunnel':
|
||||
// Raw LDAP bytes from the agent's local socket → relay into OpenLDAP
|
||||
// and pipe the response back (DESIGN.md §4).
|
||||
ldapTunnel.handleTunnel(current.id, ws, payload);
|
||||
break;
|
||||
default:
|
||||
console.log(`[Theta Agent] Received message type '${data.type}' from ${current.id}`);
|
||||
}
|
||||
@@ -369,6 +392,7 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
|
||||
ws.on('close', () => {
|
||||
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) disconnected`);
|
||||
agentManager.unregisterAgent(agent.id, ws);
|
||||
ldapTunnel.cleanup(agent.id);
|
||||
});
|
||||
|
||||
// Send initial welcome/config payload. When this connection enrolled via a
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
|
||||
// Agent-facing operations (DESIGN.md §5, §6). These are NOT admin-gated: the
|
||||
// caller is the agent itself, authenticated by its own token (the same one it
|
||||
// presents on its WSS channel). Mounted at /api/v1/agent.
|
||||
|
||||
const express = require('express');
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
const { authenticateAgent } = require('../utils/agent_auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /secrets — fetch node-scoped OpenBao secrets for the agent's own node.
|
||||
//
|
||||
// { paths: ["secret/data/nodes/<agent-id>/db"] }
|
||||
// -> { status: "ok", secrets: { "secret/data/nodes/<agent-id>/db": { key: value } } }
|
||||
//
|
||||
// The agent may only read under its own node prefix (secret/data/nodes/<id>/*),
|
||||
// so a compromised agent cannot reach other nodes' or shared secrets. The SSO
|
||||
// fetches with its own OpenBao access (SSO_VAULT_TOKEN); the agent never holds a
|
||||
// Vault token.
|
||||
router.post('/secrets', async (req, res, next) => {
|
||||
try {
|
||||
const agent = await authenticateAgent(req);
|
||||
if (!agent) return res.status(401).json({ status: 'error', message: 'unauthorized' });
|
||||
|
||||
const { paths } = req.body || {};
|
||||
if (!Array.isArray(paths) || paths.length === 0) {
|
||||
return res.status(400).json({ status: 'error', message: 'paths (array) is required' });
|
||||
}
|
||||
|
||||
const nodeScope = `secret/data/nodes/${agent.id}/`;
|
||||
const secrets = {};
|
||||
for (const p of paths) {
|
||||
if (typeof p !== 'string' || !p.startsWith(nodeScope)) {
|
||||
return res.status(403).json({ status: 'error', message: `path outside node scope: ${p}` });
|
||||
}
|
||||
const r = await baoConf.request('GET', p);
|
||||
if (r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
secrets[p] = (body.data && body.data.data) || {};
|
||||
} else {
|
||||
// Missing secret: return an empty object for that path rather than
|
||||
// failing the whole batch; the agent renders what it can.
|
||||
secrets[p] = {};
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ status: 'ok', secrets });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,105 @@
|
||||
'use strict';
|
||||
|
||||
// LDAP-over-HTTPS API (DESIGN.md §3).
|
||||
//
|
||||
// The whole point of this API is that a client stops speaking LDAP and instead
|
||||
// does an HTTPS call to the SSO, where the directory is reachable. That kills
|
||||
// the hostname / cross-network / LDAPS-cert-chain pain: no LDAP protocol, no
|
||||
// cert to trust, no firewall rule.
|
||||
//
|
||||
// POST /api/v1/ldap/bind {username, password} -> 200 {dn, uid} | 401
|
||||
// POST /api/v1/ldap/search {base_dn, scope, filter, attributes} -> 200 {entries}
|
||||
//
|
||||
// Caller auth: a Bearer token in the Authorization header. Two kinds of caller
|
||||
// are accepted, reusing existing credentials:
|
||||
// - an agent token (the same one the agent presents on its WSS channel) — the
|
||||
// caller is a node acting for SSSD;
|
||||
// - a self-service API token (PAT, `sso_...`) — the caller is a user/app.
|
||||
// The API authorizes the *caller*; OpenLDAP enforces the actual directory ACLs.
|
||||
//
|
||||
// Security note on /search: it runs under the directory admin bind (withClient),
|
||||
// so it can read the whole tree. It is therefore restricted to agent callers
|
||||
// (the SSSD user/group-resolution use case) and must eventually move to a
|
||||
// scoped read-only service account rather than the admin bind. See DESIGN.md §9.
|
||||
|
||||
const express = require('express');
|
||||
const { createLdapClient } = require('@simpleworkjs/ldap');
|
||||
const conf = require('@simpleworkjs/conf').ldap;
|
||||
const { Agent } = require('../models/agent');
|
||||
const { ApiToken } = require('../models/api_token');
|
||||
|
||||
const router = express.Router();
|
||||
const ldap = createLdapClient(conf);
|
||||
|
||||
// Resolve a Bearer token to a caller identity, or null. Tries the agent token
|
||||
// first, then a PAT. Every failure collapses to null so a probing caller learns
|
||||
// nothing about which credential was wrong.
|
||||
async function authenticateCaller(req) {
|
||||
const auth = req.headers['authorization'] || '';
|
||||
const m = /^Bearer\s+(.+)$/i.exec(auth);
|
||||
if (!m) return null;
|
||||
const token = String(m[1]).trim();
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const agent = await Agent.authenticate(token);
|
||||
if (agent) return { kind: 'agent', id: agent.id, name: agent.name };
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const pat = await ApiToken.authenticate(token);
|
||||
if (pat) return { kind: 'user', id: pat.created_by };
|
||||
} catch (_) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// POST /bind — authenticate a username/password against the directory.
|
||||
router.post('/bind', async (req, res, next) => {
|
||||
try {
|
||||
const caller = await authenticateCaller(req);
|
||||
if (!caller) return res.status(401).json({ status: 'error', message: 'unauthorized' });
|
||||
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ status: 'error', message: 'username and password are required' });
|
||||
}
|
||||
|
||||
// Resolve the username to a DN, then simple-bind as that DN. A missing user
|
||||
// and a wrong password both surface as 401 (no user-existence oracle).
|
||||
const user = await ldap.getUser(String(username));
|
||||
if (!user) return res.status(401).json({ status: 'error', message: 'invalid credentials' });
|
||||
|
||||
const ok = await ldap.checkPassword(user.dn, String(password));
|
||||
if (!ok) return res.status(401).json({ status: 'error', message: 'invalid credentials' });
|
||||
|
||||
return res.json({ status: 'ok', dn: user.dn, uid: user.uid });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /search — run a directory search. Agent callers only (see header note).
|
||||
router.post('/search', async (req, res, next) => {
|
||||
try {
|
||||
const caller = await authenticateCaller(req);
|
||||
if (!caller) return res.status(401).json({ status: 'error', message: 'unauthorized' });
|
||||
if (caller.kind !== 'agent') {
|
||||
return res.status(403).json({ status: 'error', message: 'search is restricted to agents' });
|
||||
}
|
||||
|
||||
const { base_dn, scope, filter, attributes } = req.body || {};
|
||||
if (!filter) return res.status(400).json({ status: 'error', message: 'filter is required' });
|
||||
|
||||
const entries = await ldap.withClient(async (client) => {
|
||||
const { searchEntries } = await client.search(base_dn || conf.userBase, {
|
||||
scope: scope || 'sub',
|
||||
filter: String(filter),
|
||||
attributes: Array.isArray(attributes) && attributes.length ? attributes : undefined,
|
||||
});
|
||||
return searchEntries;
|
||||
});
|
||||
|
||||
return res.json({ status: 'ok', entries });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user