e8d04203c3
- group names match docs/GROUPS.md: {site}_{kind}_{name}_{level} (kind always present; services -> app kind); updated resolver + tests + access_request test
- site resource carries only god_admin + site-wide groups
- groups no longer appear 3x: idempotent ResourceGroup linking (self-heal was creating duplicates on every Directory load)
- /api/agent/* no longer 404s: REST router mounts unconditionally (was gated on the WS server)
- shared-secrets: slug regex allows underscores; GET list uses static pathFor (fixes 's.path is not a function')
- vault Apps tab: new GET /api/vault/apps + Minted apps list + purpose text; /docs/vault help link + docs cover Apps/Shared
- discovery promote: load instance and call update() (fixes 'Resource.update is not a function')
- discovery plugin cards: last-run time/status + Logs button
199 lines
7.6 KiB
JavaScript
199 lines
7.6 KiB
JavaScript
'use strict';
|
|
|
|
// Public directory discovery API. Mounted at /api/discovery (app.js, before
|
|
// the 404 catcher). Every response uses the `{ results }` envelope and the
|
|
// security projection from @simpleworkjs/directory-schema, so secrets (e.g. an
|
|
// OAuth client's client_secret_hash) never leave the server and non-admins only
|
|
// see the public metadata allowlist.
|
|
//
|
|
// This replaces the autoRouter mount (which returned bare arrays — the shape
|
|
// jump-host's `data.results || []` silently collapsed to `[]`, so no user could
|
|
// bridge) and absorbs the dead /me handler that used to live in
|
|
// routes/api_discovery.js (mounted after the 404, so unreachable).
|
|
//
|
|
// Group CNs come from utils/user_groups — `req.user` has `memberOf` (DNs) and
|
|
// no `.groups`, so reading `.groups` off it directly yields [] for every human
|
|
// caller. See that file for what that silently broke.
|
|
|
|
const router = require('express').Router();
|
|
const { Resource, ResourceGroup } = require('../models/resource');
|
|
const { withGroups } = require('../utils/user_groups');
|
|
const {
|
|
envelope,
|
|
projectResource,
|
|
projectResources,
|
|
isDirectoryAdmin,
|
|
} = require('@simpleworkjs/directory-schema');
|
|
|
|
// Resolve the caller's groups once per request and hand back the projection
|
|
// flag. Every handler needs both, and both are wrong if taken off req.user raw.
|
|
async function callerView(req) {
|
|
const user = await withGroups(req.user);
|
|
return { user, fullMetadata: isDirectoryAdmin(user) };
|
|
}
|
|
|
|
// GET /api/discovery/resources[?kind=&group=&parent=]
|
|
router.get('/resources', async (req, res, next) => {
|
|
try {
|
|
const { fullMetadata } = await callerView(req);
|
|
const resources = await Resource.search(req.query);
|
|
res.json(envelope(projectResources(resources, { fullMetadata })));
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// GET /api/discovery/resources/:slug
|
|
router.get('/resources/:slug', async (req, res, next) => {
|
|
try {
|
|
const { fullMetadata } = await callerView(req);
|
|
const resource = await Resource.getBySlug(req.params.slug);
|
|
// parents/children are edges (no secrets); project only the resource body.
|
|
const projected = projectResource(resource, { fullMetadata });
|
|
projected.parents = resource.parents;
|
|
projected.children = resource.children;
|
|
res.json(envelope(projected));
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// GET /api/discovery/graph
|
|
router.get('/graph', async (req, res, next) => {
|
|
try {
|
|
const { fullMetadata } = await callerView(req);
|
|
const graph = await Resource.getGraph();
|
|
res.json(envelope({
|
|
resources: projectResources(graph.resources, { fullMetadata }),
|
|
edges: graph.edges,
|
|
updated_on: graph.updated_on
|
|
}));
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// GET /api/discovery/me
|
|
// Returns the resources the current caller can reach. Machines see only their
|
|
// own resource; humans get the union of their LDAP groups' resources plus
|
|
// anything flagged isPublic.
|
|
router.get('/me', async (req, res, next) => {
|
|
try {
|
|
const { user, fullMetadata } = await callerView(req);
|
|
let accessible;
|
|
if (req.user && req.user.isMachine) {
|
|
accessible = await Resource.list({ where: { id: req.resourceId } });
|
|
} else {
|
|
const ids = new Set();
|
|
if (user.groups.length) {
|
|
const rgs = await ResourceGroup.list({ where: { groupCn: { in: user.groups } } });
|
|
for (const rg of rgs) ids.add(rg.resourceId);
|
|
}
|
|
const all = await Resource.list();
|
|
accessible = all.filter(r => {
|
|
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
|
const isManaged = r.metadata?.managed === true;
|
|
if (isAuto && !isManaged) return false;
|
|
return ids.has(r.id) || (r.metadata && r.metadata.isPublic);
|
|
});
|
|
}
|
|
// resolvedAddress is the whole point of /me ("how do I reach it") and a
|
|
// service inherits it from its host, so it must be computed here rather
|
|
// than left to each caller to guess at address || ip.
|
|
accessible = await Resource.withResolvedAddress(accessible);
|
|
res.json(envelope(projectResources(accessible, { fullMetadata })));
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// GET /api/discovery/access/:uid[/:slug]
|
|
// Answers per-user access for a machine caller (e.g. jump-host).
|
|
router.get(['/access/:uid', '/access/:uid/:slug'], async (req, res, next) => {
|
|
try {
|
|
const { fullMetadata } = await callerView(req);
|
|
if (!req.user || (!req.user.isMachine && !fullMetadata)) {
|
|
return res.status(403).json(envelope({ error: 'Only machine identities or admins may query access for other users.' }));
|
|
}
|
|
const { User } = require('../models/user_ldap');
|
|
const { groupCns } = require('../utils/user_groups');
|
|
|
|
const targetUser = await User.get(req.params.uid).catch(() => null);
|
|
if (!targetUser) return res.status(404).json(envelope({ error: 'User not found' }));
|
|
|
|
const groups = await groupCns(targetUser);
|
|
const ids = new Set();
|
|
if (groups.length) {
|
|
const rgs = await ResourceGroup.list({ where: { groupCn: { in: groups } } });
|
|
for (const rg of rgs) ids.add(rg.resourceId);
|
|
}
|
|
|
|
let all = await Resource.list();
|
|
if (req.params.slug) all = all.filter(r => r.slug === req.params.slug);
|
|
|
|
let accessible = all.filter(r => {
|
|
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
|
const isManaged = r.metadata?.managed === true;
|
|
if (isAuto && !isManaged) return false;
|
|
return ids.has(r.id) || (r.metadata && r.metadata.isPublic);
|
|
});
|
|
|
|
accessible = await Resource.withResolvedAddress(accessible);
|
|
res.json(envelope(projectResources(accessible, { fullMetadata })));
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// POST /api/discovery/sync
|
|
// Used by external agents (e.g. ldap-client) to push discovery data.
|
|
router.post('/sync', async (req, res, next) => {
|
|
try {
|
|
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
|
// Assuming the caller provides a source name and payload
|
|
const source = req.body.source || 'agent';
|
|
await DiscoveryReconciler.reconcile(source, req.body.payload || req.body);
|
|
res.json(envelope({ success: true }));
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// POST /api/discovery/promote/:slug
|
|
// Promotes an unmanaged device to managed by creating its LDAP groups.
|
|
router.post('/promote/:slug', async (req, res, next) => {
|
|
try {
|
|
const resource = await Resource.getBySlug(req.params.slug);
|
|
if (!resource) return res.status(404).json(envelope({ error: 'Not found' }));
|
|
|
|
const { Group } = require('../models/group_ldap');
|
|
|
|
const accessGroup = `${resource.slug}_access`;
|
|
const adminGroup = `${resource.slug}_admin`;
|
|
|
|
// Create groups if they don't exist
|
|
try { await Group.get(accessGroup); } catch (e) {
|
|
if (e.status === 404) await Group.add({ name: accessGroup, description: `Access to ${resource.name}`, owner: req.user.dn });
|
|
else throw e;
|
|
}
|
|
try { await Group.get(adminGroup); } catch (e) {
|
|
if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${resource.name}`, owner: req.user.dn });
|
|
else throw e;
|
|
}
|
|
|
|
// Link them
|
|
const crypto = require('crypto');
|
|
await ResourceGroup.create({
|
|
id: crypto.randomUUID(),
|
|
resourceId: resource.id,
|
|
groupCn: accessGroup,
|
|
accessLevel: 'user'
|
|
});
|
|
await ResourceGroup.create({
|
|
id: crypto.randomUUID(),
|
|
resourceId: resource.id,
|
|
groupCn: adminGroup,
|
|
accessLevel: 'admin'
|
|
});
|
|
|
|
const meta = resource.metadata || {};
|
|
meta.managed = true;
|
|
// `Resource.update` is not a static — `update` is an instance method
|
|
// (@simpleworkjs/orm). Load a fresh instance and call it on that.
|
|
const inst = await Resource.get(resource.id);
|
|
await inst.update({ metadata: meta });
|
|
|
|
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
module.exports = router;
|