fix(directory): dedupe access/admin groups on repeated promotion; stop self-healing on every GET

Three independent copies of the same bug: routes/discovery.js's
POST /discovery/promote/:slug (the actual "Promote" button in the UI)
and services/discovery_reconciler.js's autoPromote path both called
ResourceGroup.create() directly with no existence check -- unlike
routes/api_directory_admin.js's own ensureResourceGroup, which already
carried a comment describing this exact "groups appear 3x" bug and
fixing it, just not everywhere it occurred. ResourceGroup has no DB
unique constraint on (resourceId, groupCn), so a resource promoted
more than once (retried UI click, or the same LXC discovered from
multiple Proxmox cluster nodes) silently accumulated duplicate
access/admin rows every time. Added ResourceGroup.ensure() (the
existing check-then-create pattern, now on the model) and switched all
three call sites to it. New regression test in tests/reconciler.test.js.

Also: GET /api/directory-admin/resources ran a full group-model
self-heal fan-out (ensureSiteGroups per site + provisionResourceGroups
per resource, each several sequential LDAP round-trips) unconditionally
on every single list -- confirmed via code read as the actual
bottleneck once a directory has more than a handful of resources, not
data volume. Moved healing to where resources actually change instead
(POST/PUT /resources, POST /discovery/promote/:slug -- PUT had none at
all before this), and added POST /resources/heal-groups as an explicit
on-demand equivalent for backfilling a directory seeded before this
change.
This commit is contained in:
2026-08-10 22:08:04 -04:00
parent b6a82d58d5
commit 2c3ec4e967
6 changed files with 120 additions and 51 deletions
+8 -2
View File
@@ -320,8 +320,14 @@ class DiscoveryReconciler {
await Group.get(adminGroup).catch(async (e) => {
if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${res.name}`, owner: 'cn=admin' });
});
await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: accessGroup, accessLevel: 'user' }).catch(() => {});
await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: adminGroup, accessLevel: 'admin' }).catch(() => {});
// ensure(), not create(): reconcile() runs on every discovery pass
// (e.g. once per Proxmox cluster node reporting the same LXC), and
// a raw create() here had no existence check, so a resource ended
// up with the same access/admin group rows duplicated once per
// pass -- see ResourceGroup.ensure()'s comment for why this can't
// rely on a DB constraint instead.
await ResourceGroup.ensure(res._actualId, accessGroup, 'user').catch(() => {});
await ResourceGroup.ensure(res._actualId, adminGroup, 'admin').catch(() => {});
} catch (err) {
console.error(`[DiscoveryReconciler] autoPromote failed for ${res.slug}:`, err.message);
}