181ca8c9cb
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>
106 lines
4.3 KiB
JavaScript
106 lines
4.3 KiB
JavaScript
'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;
|