feat: release v1.31.0 with Zero-View secrets engine, generator, multi-level inheritance, and SSSD key mappings

This commit is contained in:
2026-08-07 23:26:39 -04:00
parent 181ca8c9cb
commit 15d9ce1078
10 changed files with 740 additions and 52 deletions
+94 -7
View File
@@ -306,13 +306,24 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
const joinKey = await AgentJoinKey.authenticate(token);
if (joinKey) {
const hostname = (url.searchParams.get('hostname') || '').trim();
const enrolled = await Agent.enroll({
name: hostname || `agent-${Date.now().toString(36)}`,
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
enrolledBy: `join-key:${joinKey.label}`
});
agent = enrolled.agent;
issuedToken = enrolled.token;
let existingAgent = null;
if (hostname) {
const matches = await Agent.list({ where: { name: hostname } });
existingAgent = matches && matches.find(a => !a.revoked);
}
if (existingAgent) {
const newToken = await existingAgent.rotateToken();
agent = existingAgent;
issuedToken = newToken;
} else {
const enrolled = await Agent.enroll({
name: hostname || `agent-${Date.now().toString(36)}`,
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
enrolledBy: `join-key:${joinKey.label}`
});
agent = enrolled.agent;
issuedToken = enrolled.token;
}
await joinKey.update({
use_count: (joinKey.use_count || 0) + 1,
last_used_on: Math.floor(Date.now() / 1000)
@@ -345,6 +356,23 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
// `ws` discards messages emitted while no listener is attached.
agentManager.registerAgent(agent, ws, remoteAddr);
if (issuedToken) {
const publicKey = await agentManager.publicKeyBase64();
try {
ws.send(JSON.stringify({
type: 'config',
payload: {
enrolled: true,
auth_token: issuedToken,
public_key: publicKey
}
}));
console.log(`[Theta Agent] Sent auto-enrollment credentials to "${agent.name}"`);
} catch (err) {
console.error(`[Theta Agent] Failed to send auto-enrollment config to "${agent.name}":`, err.message);
}
}
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
@@ -364,6 +392,65 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
case 'discovery':
await agentManager.handleDiscovery(current, payload);
if (app.io) app.io.emit('agent.discovery', { agentId: current.id, payload });
if (payload.capabilities && payload.capabilities.configure_ldap) {
const conf = require('@simpleworkjs/conf');
const os = require('os');
const ssoHost = (conf.stack && conf.stack.ssoHost) || 'sso.laptop-dev.vm42.us';
const ldapBaseDn = (conf.stack && conf.stack.ldapBaseDn) || 'dc=laptop-dev,dc=vm42,dc=us';
const lanIps = [];
const ifaces = os.networkInterfaces();
for (const dev in ifaces) {
for (const details of ifaces[dev]) {
if (!details.internal && details.family === 'IPv4') lanIps.push(details.address);
}
}
const uriList = [
`ldapi://%2frun%2ftheta%2fldap.sock`,
`ldap://127.0.0.1:3890`,
`ldap://127.0.0.1:389`,
`ldap://${ssoHost}:389`,
`ldaps://${ssoHost}:636`,
...lanIps.map(ip => `ldap://${ip}:389`)
];
const ldapUris = [...new Set(uriList)].join(', ');
const sssdConfig = `[sssd]
config_file_version = 2
domains = default
[domain/default]
id_provider = ldap
auth_provider = ldap
chpass_provider = ldap
sudo_provider = ldap
ldap_uri = ${ldapUris}
ldap_search_base = ${ldapBaseDn}
ldap_user_search_base = ou=people,${ldapBaseDn}
ldap_group_search_base = ou=groups,${ldapBaseDn}
ldap_sudo_search_base = ou=people,${ldapBaseDn}
ldap_schema = rfc2307bis
ldap_user_object_class = posixAccount
ldap_user_name = uid
ldap_user_ssh_public_key = sshPublicKey
ldap_group_object_class = groupOfNames
ldap_group_member = member
ldap_id_mapping = false
ldap_id_use_start_tls = false
ldap_tls_reqcert = never
cache_credentials = true
entry_cache_timeout = 600
entry_cache_user_timeout = 600
entry_cache_group_timeout = 600
entry_cache_sudo_timeout = 600
refresh_expired_interval = 300
`;
agentManager.sendCommand(current, 'configure_ldap', { config: sssdConfig }, true).then(() => {
console.log(`[Theta Agent] Pushed auto configure_ldap to "${current.name}"`);
}).catch(err => {
console.error(`[Theta Agent] Auto push configure_ldap to "${current.name}" failed:`, err.message);
});
}
break;
case 'telemetry':
await agentManager.handleTelemetry(current, payload);
+73 -11
View File
@@ -19,29 +19,91 @@ const router = express.Router();
// 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.
const { Resource } = require('../models/resource');
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const { SharedSecret } = require('../models/shared_secret');
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' });
let { paths } = req.body || {};
let boundResource = null;
if (agent.resourceId) {
boundResource = await Resource.get(agent.resourceId).catch(() => null);
}
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}` });
if (!Array.isArray(paths) || paths.length === 0) {
paths = [`secret/data/nodes/${agent.id}/conf`];
if (boundResource && boundResource.slug) {
paths.push(`secret/data/resources/${boundResource.slug}/conf`);
}
}
// Allowed prefixes for this agent:
// 1. Node scope: secret/data/nodes/<agent.id>/
// 2. Bound Resource scope: secret/data/resources/<resource.slug>/
// 3. Shared Resource Grants: secret/data/resources/<grantee-slug>/
const allowedPrefixes = [`secret/data/nodes/${agent.id}/`];
if (boundResource && boundResource.slug) {
allowedPrefixes.push(`secret/data/resources/${boundResource.slug}/`);
}
// Add granted shared resources
if (boundResource) {
const grants = await SharedSecretGrant.listForGrantee('resource', boundResource.id).catch(() => []);
for (const g of grants) {
const sharedSec = await SharedSecret.get(g.secretId).catch(() => null);
if (sharedSec && sharedSec.slug) {
allowedPrefixes.push(`secret/data/resources/${sharedSec.slug}/`);
allowedPrefixes.push(`secret/data/shared/${sharedSec.ownerUid}/${sharedSec.slug}/`);
}
}
}
const secrets = {};
for (let p of paths) {
if (typeof p !== 'string') continue;
// Normalize human shorthand "resources/foo/bar" -> "secret/data/resources/foo/bar"
if (p.startsWith('resources/')) {
p = `secret/data/resources/${p.slice('resources/'.length)}`;
}
const isAllowed = allowedPrefixes.some(prefix => p.startsWith(prefix));
if (!isAllowed) {
return res.status(403).json({ status: 'error', message: `path outside authorized 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) || {};
const rawMap = (body.data && body.data.data) || {};
const resolvedMap = {};
for (const [k, v] of Object.entries(rawMap)) {
const strV = String(v || '');
if (strV.startsWith('INHERIT:')) {
const parts = strV.split(':');
if (parts.length >= 3) {
const targetSlug = parts[1];
const targetKey = parts[2];
const parentR = await baoConf.request('GET', `secret/data/resources/${targetSlug}/conf`);
if (parentR.ok) {
const parentBody = await parentR.json().catch(() => ({}));
const parentMap = (parentBody.data && parentBody.data.data) || {};
resolvedMap[k] = parentMap[targetKey] || '';
} else {
resolvedMap[k] = '';
}
} else {
resolvedMap[k] = '';
}
} else {
resolvedMap[k] = v;
}
}
secrets[p] = resolvedMap;
} else {
// Missing secret: return an empty object for that path rather than
// failing the whole batch; the agent renders what it can.
secrets[p] = {};
}
}
+137
View File
@@ -199,6 +199,7 @@ router.get('/resources', async (req, res, next) => {
try {
let resources = await Resource.list();
resources = resources.filter(r => {
if (r.kind === 'host' || r.kind === 'site') return true;
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
const isManaged = r.metadata?.managed === true;
return !isAuto || isManaged;
@@ -600,4 +601,140 @@ router.get('/audit-logs', async (req, res, next) => {
} catch (err) { next(err); }
});
// ── Resource Secrets API (OpenBao KV-v2 under secret/data/resources/<slug>/conf) ──
const SECRET_KEY_REGEX = /^[A-Za-z0-9_]+$/;
router.get('/resources/:id/secrets', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const baoConf = require('@simpleworkjs/bao-conf');
// Read resource secrets from OpenBao
const path = `secret/data/resources/${resource.slug}/conf`;
const r = await baoConf.request('GET', path);
let secretsMap = {};
if (r.ok) {
const body = await r.json().catch(() => ({}));
secretsMap = (body.data && body.data.data) || {};
}
// Zero-View Security: Return metadata only, NEVER return raw secret values
const secrets = Object.keys(secretsMap).map(key => {
const val = String(secretsMap[key] || '');
let isInherited = false;
let parentSlug = null;
let parentKey = null;
if (val.startsWith('INHERIT:')) {
isInherited = true;
const parts = val.split(':');
if (parts.length >= 3) {
parentSlug = parts[1];
parentKey = parts[2];
} else if (parts.length === 2) {
parentKey = parts[1];
}
}
return {
key,
hasValue: val.length > 0,
isInherited,
parentSlug,
parentKey
};
});
// Find all ancestor resources across any depth (Host, Site, etc.) + Global Sites
const parentSecrets = [];
const seenAncestors = new Set();
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
const sites = await Resource.list({ where: { kind: 'site' } }).catch(() => []);
const allAncestors = [...ancestors, ...sites];
for (const parent of allAncestors) {
if (!parent || parent.id === resource.id || seenAncestors.has(parent.id)) continue;
seenAncestors.add(parent.id);
const parentPath = `secret/data/resources/${parent.slug}/conf`;
const parentR = await baoConf.request('GET', parentPath);
if (parentR.ok) {
const parentBody = await parentR.json().catch(() => ({}));
const pMap = (parentBody.data && parentBody.data.data) || {};
for (const pKey of Object.keys(pMap)) {
parentSecrets.push({
parentSlug: parent.slug,
parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'PARENT'})`,
key: pKey
});
}
}
}
res.json({ status: 'ok', resourceId: resource.id, slug: resource.slug, secrets, parentSecrets });
} catch (err) { next(err); }
});
router.post('/resources/:id/secrets', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const secrets = (req.body.secrets && typeof req.body.secrets === 'object') ? req.body.secrets : {};
// Validate key names (Standard Env Var format: A-Z, 0-9, underscores)
for (const key of Object.keys(secrets)) {
if (!SECRET_KEY_REGEX.test(key)) {
return res.status(400).json({
status: 'error',
message: `Invalid secret key '${key}'. Keys must contain only letters, numbers, and underscores (e.g. DB_PASSWORD)`
});
}
}
const baoConf = require('@simpleworkjs/bao-conf');
const path = `secret/data/resources/${resource.slug}/conf`;
const r = await baoConf.request('POST', path, { data: secrets });
if (!r.ok) {
return res.status(500).json({ status: 'error', message: 'failed to save secrets to OpenBao' });
}
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
router.get('/resources/:id/grants', async (req, res, next) => {
try {
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const { SharedSecret } = require('../models/shared_secret');
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const grants = await SharedSecretGrant.listForGrantee('resource', resource.id);
const sharedSecretIds = grants.map(g => g.secretId);
const secrets = sharedSecretIds.length ? await SharedSecret.list({ where: { id: { in: sharedSecretIds } } }) : [];
res.json({ status: 'ok', grants: secrets.map(s => ({ id: s.id, slug: s.slug, description: s.description })) });
} catch (err) { next(err); }
});
router.post('/resources/:id/grants', async (req, res, next) => {
try {
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const { SharedSecret } = require('../models/shared_secret');
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const { secretSlug, action } = req.body || {};
const secret = await SharedSecret.getBySlug(secretSlug);
if (!secret) return res.status(404).json({ status: 'error', message: `shared secret '${secretSlug}' not found` });
if (action === 'revoke') {
const existing = await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType: 'resource', granteeId: resource.id } });
for (const g of existing) await g.delete();
return res.json({ status: 'ok', message: 'grant revoked' });
} else {
await SharedSecretGrant.grant({ secretId: secret.id, granteeType: 'resource', granteeId: resource.id, grantedBy: req.user.uid });
return res.json({ status: 'ok', message: 'grant created' });
}
} catch (err) { next(err); }
});
module.exports = router;