Merge pull request #32 from theta42/feat-machine-identity

feat: use machine identity for access queries
This commit is contained in:
2026-08-02 18:50:45 -04:00
committed by GitHub
2 changed files with 33 additions and 64 deletions
+23 -35
View File
@@ -8,39 +8,33 @@ function stubLdap(groups) {
return { getGroups: async () => groups }; return { getGroups: async () => groups };
} }
function stubFetch(byGroup) { function stubFetch(byUid) {
return async (url) => { return async (url) => {
const cn = decodeURIComponent(url.split('group=')[1]); const uid = url.split('/access/')[1];
return { ok: true, json: async () => ({ results: byGroup[cn] || [] }) }; return { ok: true, json: async () => ({ results: byUid[uid] || [] }) };
}; };
} }
test('unions hosts across groups, dedupes, drops non-hosts', async () => { test('drops non-hosts from access projection', async () => {
clearCache(); clearCache();
const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' }; const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' };
const fetchImpl = stubFetch({ const fetchImpl = stubFetch({
host_web01_access: [ alice: [
{ id: '1', kind: 'host', slug: 'host_web01' }, { id: '1', kind: 'host', slug: 'host_web01' },
{ id: '2', kind: 'host', slug: 'host_db' },
{ id: '9', kind: 'service', slug: 'app_gitea' }, // dropped: not a host { id: '9', kind: 'service', slug: 'app_gitea' }, // dropped: not a host
], ],
host_db_access: [
{ id: '1', kind: 'host', slug: 'host_web01' }, // dupe by id
{ id: '2', kind: 'host', slug: 'host_db' },
],
}); });
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['host_web01_access', 'host_db_access']) }); const hosts = await accessibleHosts(user, { fetchImpl });
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']); assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
}); });
test('a failing group query does not sink the rest', async () => { test('a failing access query returns empty list without throwing', async () => {
clearCache(); clearCache();
const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' }; const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' };
const fetchImpl = async (url) => { const fetchImpl = async () => ({ ok: false, status: 500 });
if (url.includes('bad')) return { ok: false, status: 500 }; const hosts = await accessibleHosts(user, { fetchImpl });
return { ok: true, json: async () => ({ results: [{ id: '3', kind: 'host', slug: 'host_ok' }] }) }; assert.deepStrictEqual(hosts, []);
};
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['bad_access', 'good_access']) });
assert.deepStrictEqual(hosts.map((h) => h.id), ['3']);
}); });
test('caches per uid', async () => { test('caches per uid', async () => {
@@ -48,23 +42,19 @@ test('caches per uid', async () => {
let calls = 0; let calls = 0;
const user = { uid: 'cara', dn: 'd' }; const user = { uid: 'cara', dn: 'd' };
const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; }; const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; };
const ldap = { getGroups: async () => ['g1'] }; await accessibleHosts(user, { fetchImpl });
await accessibleHosts(user, { fetchImpl, ldap }); await accessibleHosts(user, { fetchImpl });
await accessibleHosts(user, { fetchImpl, ldap });
assert.strictEqual(calls, 1); assert.strictEqual(calls, 1);
}); });
test('accepts pre-resolved groups (web UI/OIDC session) without calling ldap.getGroups', async () => { test('does not depend on user.groups or ldap.getGroups', async () => {
clearCache(); clearCache();
let ldapCalled = false; const user = { uid: 'erin' }; // no dn, no groups
const user = { uid: 'erin', groups: ['host_web01_access'] };
const fetchImpl = stubFetch({ const fetchImpl = stubFetch({
host_web01_access: [{ id: '5', kind: 'host', slug: 'host_web01' }], erin: [{ id: '5', kind: 'host', slug: 'host_web01' }],
}); });
const ldap = { getGroups: async () => { ldapCalled = true; return []; } }; const hosts = await accessibleHosts(user, { fetchImpl });
const hosts = await accessibleHosts(user, { fetchImpl, ldap });
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']); assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
assert.strictEqual(ldapCalled, false);
}); });
test('allHosts fetches the whole host inventory with no group filter', async () => { test('allHosts fetches the whole host inventory with no group filter', async () => {
@@ -80,16 +70,14 @@ test('allHosts fetches the whole host inventory with no group filter', async ()
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']); assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
}); });
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => { test('a bare-array response (envelope drift) returns empty list', async () => {
clearCache(); clearCache();
const user = { uid: 'dave', dn: 'd' }; const user = { uid: 'dave', dn: 'd' };
// drift shape: a bare array instead of { results: [...] }. The shared client // drift shape: a bare array instead of { results: [...] }. The shared client
// throws DirectoryEnvelopeViolation; access.js must catch + continue, so a // throws DirectoryEnvelopeViolation; access.js must catch + continue.
// good group alongside still yields its hosts. const fetchImpl = async () => {
const fetchImpl = async (url) => { return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
if (url.includes('drift')) return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
return { ok: true, json: async () => ({ results: [{ id: '8', kind: 'host' }] }) };
}; };
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['drift_access', 'good_access']) }); const hosts = await accessibleHosts(user, { fetchImpl });
assert.deepStrictEqual(hosts.map((h) => h.id), ['8']); assert.deepStrictEqual(hosts, []);
}); });
+10 -29
View File
@@ -17,14 +17,9 @@ if (conf.standalone && conf.standalone.enabled) {
// Which directory hosts may a user reach, and how do we dial them? // Which directory hosts may a user reach, and how do we dial them?
// //
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's // We use the SSO's machine-aware /api/discovery/access/:uid endpoint,
// /api/discovery/me only answers for the API token's own user, and /graph // which evaluates the user's groups server-side and returns their complete
// omits ResourceGroup links — so we combine the user's LDAP groups (queried // access projection in one call.
// directly) with per-group resource lookups:
//
// 1. LDAP: groups the user's DN is a member of
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
// 3. union, keep kind === 'host'
// //
// Results are cached per-uid for a short TTL — the TUI picker and the // Results are cached per-uid for a short TTL — the TUI picker and the
// username-grammar path share the cache. Dependency-injected fetch/ldap for // username-grammar path share the cache. Dependency-injected fetch/ldap for
@@ -56,32 +51,18 @@ if (conf.standalone && conf.standalone.enabled) {
return resources.filter(r => r.kind === 'host'); return resources.filter(r => r.kind === 'host');
} }
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) { async function accessibleHosts(user, { fetchImpl = fetch } = {}) {
const hit = cache.get(user.uid); const hit = cache.get(user.uid);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts; if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
// The SSH path passes an LDAP user ({dn, uid, ...}) with no .groups, so we let resources = [];
// look them up; the web UI already has the session's OIDC groups claim try {
// and passes it directly, skipping a redundant LDAP round-trip. resources = await directoryClient({ fetchImpl }).getAccess(user.uid);
const groups = user.groups || await ldap.getGroups(user.dn); } catch (error) {
console.error(`[access] ${error.message}`);
const seen = new Map();
for (const cn of groups) {
let resources;
try {
resources = await fetchResourcesByGroup(cn, { fetchImpl });
} catch (error) {
// One bad group must not hide the rest; the SSO being down
// surfaces as an empty list + log line, not a crash.
console.error(`[access] ${error.message}`);
continue;
}
for (const r of resources) {
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
}
} }
const hosts = [...seen.values()]; const hosts = resources.filter(r => r.kind === 'host');
cache.set(user.uid, { at: Date.now(), hosts }); cache.set(user.uid, { at: Date.now(), hosts });
return hosts; return hosts;
} }