feat: release v1.31.0 with Zero-View secrets engine, generator, multi-level inheritance, and SSSD key mappings
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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] = {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user