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>
127 lines
3.7 KiB
JavaScript
127 lines
3.7 KiB
JavaScript
'use strict';
|
|
|
|
// LDAP-over-HTTPS API (DESIGN.md §3). Exercises caller auth (agent token vs
|
|
// PAT), the bind flow against the real test OpenLDAP, and the agent-only search
|
|
// restriction.
|
|
|
|
const { TEST_CREDS, request, app } = require('./setup');
|
|
const { Agent } = require('../models/agent');
|
|
const { ApiToken } = require('../models/api_token');
|
|
|
|
async function enrollAgent() {
|
|
const { agent, token } = await Agent.enroll({
|
|
name: `ldap-test-${Date.now().toString(36)}`,
|
|
description: 'api_ldap test agent',
|
|
enrolledBy: 'test'
|
|
});
|
|
return { agent, token };
|
|
}
|
|
|
|
async function makePat() {
|
|
const token = await ApiToken.add({
|
|
name: 'ldap-test-pat',
|
|
description: 'api_ldap test',
|
|
created_by: 'test'
|
|
});
|
|
return token._raw_token;
|
|
}
|
|
|
|
describe('LDAP-over-HTTPS — POST /api/v1/ldap/bind', () => {
|
|
test('valid credentials return the bound DN', async () => {
|
|
const { token } = await enrollAgent();
|
|
const res = await request(app)
|
|
.post('/api/v1/ldap/bind')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.status).toBe('ok');
|
|
expect(res.body.uid).toBe(TEST_CREDS.uid);
|
|
expect(res.body.dn).toContain(TEST_CREDS.uid);
|
|
});
|
|
|
|
test('wrong password returns 401', async () => {
|
|
const { token } = await enrollAgent();
|
|
const res = await request(app)
|
|
.post('/api/v1/ldap/bind')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ username: TEST_CREDS.uid, password: 'wrong-password' });
|
|
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
test('unknown user returns 401 (no existence oracle)', async () => {
|
|
const { token } = await enrollAgent();
|
|
const res = await request(app)
|
|
.post('/api/v1/ldap/bind')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ username: 'no_such_user_xyz', password: 'whatever' });
|
|
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
test('a PAT caller can bind', async () => {
|
|
const pat = await makePat();
|
|
const res = await request(app)
|
|
.post('/api/v1/ldap/bind')
|
|
.set('Authorization', `Bearer ${pat}`)
|
|
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
|
|
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
test('no bearer token returns 401', async () => {
|
|
const res = await request(app)
|
|
.post('/api/v1/ldap/bind')
|
|
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
|
|
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
test('missing username/password returns 400', async () => {
|
|
const { token } = await enrollAgent();
|
|
const res = await request(app)
|
|
.post('/api/v1/ldap/bind')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ username: TEST_CREDS.uid });
|
|
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('LDAP-over-HTTPS — POST /api/v1/ldap/search', () => {
|
|
test('an agent can search the user tree', async () => {
|
|
const { token } = await enrollAgent();
|
|
const res = await request(app)
|
|
.post('/api/v1/ldap/search')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ filter: `(uid=${TEST_CREDS.uid})`, attributes: ['uid', 'cn'] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.status).toBe('ok');
|
|
expect(Array.isArray(res.body.entries)).toBe(true);
|
|
expect(res.body.entries.length).toBeGreaterThan(0);
|
|
expect(res.body.entries[0].uid).toBe(TEST_CREDS.uid);
|
|
});
|
|
|
|
test('a PAT caller is denied search (agent-only)', async () => {
|
|
const pat = await makePat();
|
|
const res = await request(app)
|
|
.post('/api/v1/ldap/search')
|
|
.set('Authorization', `Bearer ${pat}`)
|
|
.send({ filter: `(uid=${TEST_CREDS.uid})` });
|
|
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
test('missing filter returns 400', async () => {
|
|
const { token } = await enrollAgent();
|
|
const res = await request(app)
|
|
.post('/api/v1/ldap/search')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({});
|
|
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|