Merge pull request #147 from theta42/fix-68

fix: enforce pwdAccountLockedTime check in app and LDAP
This commit is contained in:
2026-08-02 19:01:43 -04:00
committed by GitHub
13 changed files with 95 additions and 25 deletions
Binary file not shown.
+1
View File
@@ -58,6 +58,7 @@ class PluginInstance extends Model {
lastRunAt: { type: 'integer' },
lastStatus: { type: 'string' },
lastError: { type: 'text' },
lastLog: { type: 'text' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
+3 -1
View File
@@ -96,11 +96,13 @@ class Resource extends Model {
return false;
}
let maxUpdated = 0;
resObjs.forEach(r => {
r.metadata.isProduction = checkProd(r.id);
if (r.updated_on && r.updated_on > maxUpdated) maxUpdated = r.updated_on;
});
return { resources: resObjs, edges };
return { resources: resObjs, edges, updated_on: maxUpdated || Date.now() };
}
// Stamp `resolvedAddress` on each resource: its own address/ip if it has one,
+9 -2
View File
@@ -773,7 +773,7 @@ User.setActive = async function(active) {
]);
} else {
await client.modify(this.dn, [
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['000001010000Z'] }) }),
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['00000101000000Z'] }) }),
]);
}
});
@@ -788,7 +788,7 @@ User.setActive = async function(active) {
throw e;
}
}
this.pwdAccountLockedTime = active ? undefined : '000001010000Z';
this.pwdAccountLockedTime = active ? undefined : '00000101000000Z';
this.isActive = active ? 'active' : '';
this.isInactive = active ? '' : 'inactive';
cache.clear();
@@ -907,6 +907,13 @@ User.login = async function(data){
}
let user = await this.get(data.uid || data.username);
if (user.pwdAccountLockedTime) {
let error = new Error('Invalid Credentials, login failed.');
error.name = 'LDAPLoginFailed';
error.status = 401;
throw error;
}
const loginClient = makeClient();
try {
await loginClient.bind(user.dn, data.password);
+6
View File
@@ -34,7 +34,13 @@ module.exports = {
// OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root).
const scan = new nmap.NmapScan(targetRange);
scan.command.push('-Pn');
scan.command.push('-F'); // fast scan, 100 top ports
scan.command.push('--min-rate', '100'); // speed up the scan
if (config.log) config.log(`Starting nmap scan: ${scan.command.join(' ')}`);
scan.on('complete', function(data) {
if (config.log) config.log(`Scan complete. Found ${data ? data.length : 0} hosts.`);
const resources = [];
const edges = [];
+1 -1
View File
@@ -35,7 +35,7 @@ module.exports = {
},
discover: async (config) => {
const { url, tokenId, tokenSecret } = config;
let { url, tokenId, tokenSecret } = config;
if (!url || !tokenId || !tokenSecret) {
throw new Error("Missing Proxmox config");
}
+10
View File
@@ -201,6 +201,16 @@ router.post('/resources/:id/rotate-secret', async (req, res, next) => {
}
});
router.post('/resources/:id/service-token', async (req, res, next) => {
try {
const { ServiceToken } = require('../models/token');
const token = await ServiceToken.issue(req.params.id, req.user.uid);
res.json({ results: { token: token.token } });
} catch (err) {
next(err);
}
});
router.delete('/resources/:id', async (req, res, next) => {
try {
const r = await Resource.get(req.params.id);
+1 -1
View File
@@ -274,7 +274,7 @@ router.get('/:id/runs', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError } });
res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError, lastLog: inst.lastLog } });
} catch (err) { next(err); }
});
+37
View File
@@ -62,6 +62,7 @@ router.get('/graph', async (req, res, next) => {
res.json(envelope({
resources: projectResources(graph.resources, { fullMetadata }),
edges: graph.edges,
updated_on: graph.updated_on
}));
} catch (err) { next(err); }
});
@@ -98,6 +99,42 @@ router.get('/me', async (req, res, next) => {
} 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) => {
+2 -1
View File
@@ -90,7 +90,8 @@ router.get('/plugins', function(req, res, next) {
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
// the same server-side. Same header-vs-navigation auth model as /conf and
// /vault (auth-token is a client-set header, not a cookie).
res.render('plugins', {...values});
const registry = require('../services/plugin_registry');
res.render('plugins', {...values, pluginTypes: registry.types });
});
router.get('/vault', function(req, res) {
+9 -3
View File
@@ -71,17 +71,23 @@ async function runPluginJob(instanceId) {
}
console.log(`[Scheduler] Running plugin: ${instance.slug} (${instance.pluginType})`);
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null });
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null, lastLog: null });
let logs = [];
try {
const cfg = await pluginSecrets.mergeForRun(instance);
cfg.log = (msg) => {
logs.push(`[${new Date().toISOString()}] ${msg}`);
console.log(`[Plugin ${instance.slug}] ${msg}`);
if (logs.length > 1000) logs.shift();
};
const payload = await runFn(cfg);
if (instance.category === 'discovery') {
await DiscoveryReconciler.reconcile(instance.slug, payload);
}
await instance.update({ lastStatus: STATUS.OK, lastError: null });
await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') });
} catch (err) {
console.error(`[Scheduler] Plugin ${instance.slug} failed:`, err.message);
await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err) });
await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err), lastLog: logs.join('\n') });
}
}
+2 -8
View File
@@ -21,11 +21,6 @@
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderTable()" style="width: 250px;">
<select id="filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
<option value="all">All Resources</option>
<option value="unmanaged" selected>Unmanaged Only</option>
<option value="managed">Managed Only</option>
</select>
</div>
</div>
<div class="card-header actionMessage" style="display:none"></div>
@@ -132,10 +127,9 @@
// Name search
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
// Managed filter
// Always hide items that have been committed to the catalog (managed)
const isManaged = !!(r.metadata && r.metadata.managed);
if(managedFilter === 'managed' && !isManaged) return false;
if(managedFilter === 'unmanaged' && isManaged) return false;
if(isManaged) return false;
const isAuto = r.metadata && r.metadata.discovery_sources && r.metadata.discovery_sources.length > 0 && !r.metadata.discovery_sources.includes('manual');
if(!isAuto) return false;
+10 -4
View File
@@ -57,7 +57,7 @@
<button class="btn btn-sm btn-warning" title="Edit Secrets" onclick="openSecretsModal('{{id}}')"><i class="fa-solid fa-key"></i></button>
<button class="btn btn-sm btn-info" title="Test" onclick="testPlugin('{{id}}')"><i class="fa-solid fa-vial"></i></button>
<button class="btn btn-sm btn-success" title="Run now" onclick="runNow('{{id}}')"><i class="fa-solid fa-play"></i></button>
{{#lastError}}<button class="btn btn-sm btn-secondary" title="View Logs" onclick="showLogs('{{id}}')"><i class="fa-solid fa-file-lines"></i></button>{{/lastError}}
{{#lastRunAt}}<button class="btn btn-sm btn-secondary" title="View Logs" onclick="showLogs('{{id}}')"><i class="fa-solid fa-file-lines"></i></button>{{/lastRunAt}}
{{#enabled}}<button class="btn btn-sm btn-outline-danger" title="Unload" onclick="togglePlugin('{{id}}', false)">Unload</button>{{/enabled}}
{{^enabled}}<button class="btn btn-sm btn-outline-success" title="Load" onclick="togglePlugin('{{id}}', true)">Load</button>{{/enabled}}
<button class="btn btn-sm btn-outline-danger" title="Delete" onclick="deletePlugin('{{id}}')"><i class="fa-solid fa-trash"></i></button>
@@ -370,14 +370,20 @@
}
}
function showLogs(id) {
async function showLogs(id) {
var p = pluginsById[id];
if (!p || !p.lastError) return;
if (!p) return;
try {
const res = await app.api.get('plugins/' + id + '/runs');
const logText = (res.results && res.results.lastLog) || (res.results && res.results.lastError) || 'No logs available.';
app.modal.open({
title: 'Logs — ' + p.name,
bodyHtml: '<pre class="bg-dark text-white p-3 rounded" style="white-space: pre-wrap; font-size: 0.85em;">' + String(p.lastError).replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>',
bodyHtml: '<pre class="bg-dark text-white p-3 rounded" style="white-space: pre-wrap; font-size: 0.85em;">' + String(logText).replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>',
footer: { buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' }
});
} catch (err) {
app.messages.toast('Failed to load logs: ' + (err.message || err), 'danger');
}
}
async function togglePlugin(id, enable) {