Commit Graph

22 Commits

Author SHA1 Message Date
wmantly 2c3ec4e967 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.
2026-08-10 22:08:04 -04:00
wmantly dc3d760d2b feat(multi-site): UI for live replication, promotion handoff, signing key
Closes the gap where all of this session's new server-side capability
(live replication, coordinated promotion, identical signing keys) had
no UI at all -- an operator using the Master Site modal had no way to
know any of it existed or was working.

- Master Site modal: new "Live Replication" row (spoke) shows whether
  this join actually registered for live updates or is stuck on a
  one-time snapshot; new "Registered Spokes" row (master) shows how
  many spokes are receiving live pushes.
- Join form: new "this site's own reachable URL" field, prefilled from
  window.location.origin, wired to the selfUrl the join API already
  supported but the UI never sent -- a UI-driven join previously NEVER
  registered for live replication, only the setup.sh bootstrap path did.
  The success toast now reports whether live replication actually
  activated, not just "joined".
- Promote button: success toast now surfaces the handoff result (old
  master demoted / unreachable / no previous master), so the operator
  sees immediately whether the coordinated demotion actually happened.
- GET /api/site/config no longer returns masterJoinKey or
  replicationPushToken in the response -- found while wiring this up:
  live credentials were being sent straight to the browser for every
  admin session. Replaced with boolean derivatives
  (hasMasterJoinKey, liveReplication).
- GET /api/directory-admin/site-status gained liveReplication (spoke)
  and registeredSpokesCount (master) so the modal has something to render.

Verified by actually driving it in a real browser against a live
container (not just code review): logged in, opened the modal, saw the
new rows, minted a real join key end-to-end, no console errors.

docs/site-join.md rewritten to cover live replication, signing-key sync,
coordinated promotion/demote, and the new endpoints -- it previously only
described the v2.2.0-v2.3.0 one-time-snapshot behavior.
2026-08-10 18:30:48 -04:00
wmantly 9c604f0258 fix(multi-site): coordinated master promotion + a dead-on-arrival authz bug
Two real bugs, both only surfaced by the live two-container e2e test
(docker-compose.multisite-e2e.yml), not by inspection:

1. POST /site-promote's god_admin check read req.user.groups -- a field
   nothing in the codebase ever populates (Auth.checkToken returns
   User.get(), which has no .groups; every other admin gate resolves
   membership live via permission.byGroup()/Group.list(user.dn), which
   also handles nested-group membership). The check silently evaluated to
   an empty array on every request, so site-promote returned 403 for
   every user, including a real god_admin -- unusable since it shipped in
   v2.0.0. Fixed to use permission.byGroup(), the same pattern used
   elsewhere in this file and in api_site.js.

2. The read-only write-gate middleware (api_directory_admin.js) is
   registered before router.post('/site-promote', ...) later in the same
   file, so on a spoke it 403'd every promotion attempt before the
   handler ever ran -- the one mutating request a spoke must be able to
   make to itself. Exempted /site-promote from the gate.

Added coordinated demotion (MULTI_SITE_SPEC.md §3.2 -- promotion as ONE
action, never a two-step gap with two masters): site-promote now calls
the previous master's new POST /api/site/demote (Bearer the join key it
already holds, handing over a freshly-minted key for the demoted node's
own future use) before flipping itself to master. Best-effort: an
unreachable old master never blocks a god_admin's local promotion (the
WAN-outage scenario is the entire reason this control exists), it's
just reported in the response for manual reconciliation.

e2e test extended to promote the spoke, verify the old master was
actually demoted (isMaster:false, masterUrl pointing at the new master),
and verify writes now succeed on the new master and 403 on the old one.
Full chain verified passing: join -> live replication -> promotion ->
demotion -> write authority follows the promotion.
2026-08-10 16:48:33 -04:00
wmantly d27763e556 feat(multi-site): live catalog replication + identical-directory signing key
The shipped join flow (v2.2.0-v2.3.0) was a one-time snapshot: a spoke's
catalog never updated after joining. This adds the two pieces that were
explicitly designed but missing:

- Live replication: a spoke registers its own endpoint with the master
  right after joining (POST /api/site/spokes, Bearer join-key), receiving
  a pushToken. Every successful catalog write on the master now fires a
  fire-and-forget resync ping (utils/site_replicate.js) at every known
  spoke, concurrently -- one unreachable spoke never blocks or delays
  another (wired into the existing write-gate middleware in
  api_directory_admin.js). The spoke's POST /api/site/resync handler
  reuses the already-tested export+import path rather than applying a
  partial diff.

- Identical directories: POST /api/site/export now best-effort includes
  the master's agent-signing key; a spoke adopts it via agent_keys.adopt()
  on both join and every resync, so every site's sso-manager can validly
  sign a command for any agent enrolled anywhere -- the accepted tradeoff
  discussed for this deployment's scale (blast radius for simplicity).

New SiteSpoke model tracks registered spokes (endpoint + pushToken);
registered it in models/index.js (a real bug the e2e test below caught --
SiteSpoke.list() 500'd with "Cannot read properties of null (reading
'adapter')" until the model was added to initORM's model list).

Verified end-to-end against docker-compose.multisite-e2e.yml: mint join
key -> join with selfUrl -> write a NEW resource on master post-join ->
poll the spoke -> it shows up within a few seconds via the resync push,
no manual re-join needed. MULTISITE E2E PASS.

Unit tests: nodejs/tests/site_replicate.test.js (concurrent fan-out, one
failing spoke doesn't block another, empty-registry and list()-throws
edge cases).
2026-08-10 16:34:38 -04:00
wmantly 9d266d2e4c feat(site): join UI, spoke read-only enforcement, live WAN health, fresh-install guard
Completes the multi-site join layer on top of the v2.2.0 endpoints:

- UI (Master Site modal): a fresh install (canJoin) gets a 'Join an Existing
  Site' form (master URL + stj_ key); a master gets a 'Site Join Keys' manager
  (mint/revoke/list, key shown once); WAN Sync Health now reflects a live probe.
- POST /api/site/ping (Bearer stj_ key, no admin session): lightweight master
  reachability probe for WAN health (cheap vs /export).
- Spoke read-only: directory-write routes (resources/edges/groups/secrets/
  grants/driver-action/discovered) reject with 403 pointing at the master.
- Fresh-install guard: /api/site/join refuses unless no users beyond the
  bootstrap admin and no enrolled agents (siteIsFresh), and site-status exposes
  canJoin so the UI only offers join on a genuinely fresh install. The
  bootstrap's seeded default resources are NOT the signal (they always exist).
- The spoke stores the join key (masterJoinKey) in /config/site.json so WAN
  health (and a future write-proxy) can reach the master.
- Tests: siteIsFresh cases in tests/site_join.test.js.
2026-08-10 09:12:28 -07:00
wmantly c96a4b6652 feat(site): multi-site join server endpoints + persisted site role + emoji fix
Server endpoints for joining a spoke to a master directory (MULTI_SITE_SPEC.md).
This pass is server-only; setup.sh wiring and the UI are the next layer.

- Site join keys (SiteJoinKey model, stj_ prefix): mint/revoke/delete/list,
  hashed at rest, shown once — the same model as agent join keys.
- POST /api/site/export (master, Bearer stj_ key, no admin session): returns the
  local LDAP tree (slapcat LDIF) + resource catalog + siteSlug + baseDn.
- POST /api/site/join (spoke, admin): { masterUrl, joinKey } pulls the master
  export, imports resources (upsert by slug) + LDAP (ldapadd -c), and persists
  the spoke role. Refused if already a spoke.
- Persisted site role: utils/site_config.js keeps isMaster/masterUrl/siteSlug in
  /config/site.json (env seeds defaults); site-status/site-promote now use it.
- Unit tests (site_join, site_config) with in-memory stubs, wired into npm test.
- docs/site-join.md + docs router entry.
- Repairs the corrupted multi-site emojis (crown/bolt) in directory.ejs.
- .gitguardian.yml ignores the generic-password false positive on reading the
  LDAP bind credential from runtime config (never a hardcoded secret).
2026-08-10 05:58:28 -07:00
wmantly 5c0018f24a release(v2.0.3): fix Directory tab managed-filter bug and site-status 500 (#186)
- GET /api/directory-admin/resources let every kind:'host' resource through
  regardless of promotion status, so "Auto-promote to Directory" unchecked on
  a discovery plugin never kept unpromoted devices out of the Directory tab.
- GET /api/directory-admin/site-status queried the nonexistent Resource.subType
  column instead of metadata.subType, throwing SequelizeDatabaseError.
- Added a "Show ignored" toggle to Discovered Inventory (off by default).
- Untracked nodejs/config/inventory.sqlite -- the app's default runtime DB,
  not a fixture, committed by mistake across 13 prior releases.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 16:29:08 -07:00
wmantly 98ed99a7e9 release(v2.0.1): fix openbao container discovery, agent version API collection, console logs, cache invalidation, site status API 500, secrets filtering, and auto-group spawning 2026-08-09 14:46:33 -04:00
wmantly d442f1e3f9 fix(agent): preserve full telemetry (disks, cpu_details, logged_users) and format desktop_control driver-action params (#180) 2026-08-08 23:41:46 -04:00
wmantly ebd7e9e434 feat(v2.0.0): bump version to 2.0.0, add Multi-Site Master badge, site-status API, and Master promotion UI (#179)
* feat(v2.0.0): bump version to 2.0.0, add Multi-Site Master badge, site-status API, and Master promotion UI

* fix(test): update test script to run unit tests without requiring live Redis socket
2026-08-08 21:43:33 -04:00
wmantly 620f401091 release: v1.33.0 - Directory Key Badges, Discovered Inventory Merge/Ignore & Desktop Operations 2026-08-08 18:12:37 -04:00
wmantly a442dc9921 release: v1.32.0 - Subtype Drivers Engine, Explicit Secret Inheritance & App Tokens consolidation 2026-08-08 15:38:35 -04:00
wmantly 15d9ce1078 feat: release v1.31.0 with Zero-View secrets engine, generator, multi-level inheritance, and SSSD key mappings 2026-08-07 23:26:39 -04:00
wmantly e8d04203c3 fix: group names match docs, dedupe resource groups, agent 404, shared-secrets + vault apps, promote + plugin logs (v1.27.0) (#168)
- 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
2026-08-04 23:00:25 -04:00
wmantly 8db00f0ed6 fix: drop legacy app_super_admin -- SUPER_ADMIN_GROUP is now god_admin (v1.26.1) (#167)
god_admin now exists at boot (seeded by docker-entrypoint), so the canonical
cross-resource super group nested into every resource's _admin group is god_admin,
not the legacy app_super_admin. docker-entrypoint no longer seeds or nests
app_super_admin (god_admin nests into the app_sso_* groups directly). isSuperAdmin
still recognizes a pre-existing app_super_admin as a migration alias until rebuild.
2026-08-04 19:31:52 -04:00
wmantly 8a9de94d24 release/v1.26.0: complete group model, enforce naming, fix docs + status dots (#166)
* feat: complete the group model (god_admin, site groups, aggregates), enforce naming, fix docs 500s + status dots (v1.26.0)

- seed god_admin + nest into app_super_admin; auto-provision site groups (S_super_admin, S_hosts_*/S_apps_* aggregates, S_everyone) on site create + self-heal on Directory load
- map service resources to the app kind (site_local_app_<slug>_*); nest per-resource groups into site aggregates (physical inheritance lattice)
- enforce the group naming convention server-side on POST /groups; surface god_admin + site groups on the site resource modal
- fix in-app /docs/<slug> 500s (Dockerfile never copied docs/); serve doc images at /docs/images
- fix Directory status dots (neutral grey when agent endpoint unreachable); align Profile/API cards full-width
- group resolver: keep the site slug verbatim (site_local not re-slugified)
- bump to 1.26.0

* fix: use verbatim resource slugs in group names (matches access-request tests + live convention)

The group naming inserts a kind segment (resourceGroupCns(site, kind, slug, level)),
but the access-request tests + the live directory convention are verbatim
({site}_{slug}_{level} -- the kind is carried in the resource slug, e.g. host_theta-env).
For bare test slugs this produced site_x_host_artest-host_x_access instead of the
expected site_x_artest-host_x_access, so the requester was never removed from the
auto-provisioned access group and every request 409'd. resourceGroupCns is now
(site, slug, level) with the verbatim slug; the kind is used only to pick the
aggregate the group nests into.
2026-08-04 19:07:51 -04:00
wmantly 1cb693a1eb fix: resolve discovery, plugins, and vault issues 2026-08-02 18:45:16 -04:00
wmantly 011d4b2975 feat(release): v1.14.0 discovery and conf pages 2026-08-01 01:21:27 -04:00
wmantly 4a592f9795 Release 1.11.0: end-user catalog, access requests, nested groups
Closes the end-user half of the directory and adds nested LDAP groups.

The directory could describe the lab but could not tell anyone what they had
or how to reach it, and several of the paths meant to do so were silently
returning nothing:

  - GET /api/discovery/me resolved groups from req.user.groups, which does not
    exist (req.user carries memberOf), so it returned only isPublic resources
    for every human caller -- "My Services" was blank for everyone. The same
    read made isDirectoryAdmin() false for real admins.
  - The portal's "Discover More Services" called the admin-gated endpoint and
    swallowed the 403, so it never rendered for non-admins at all.
  - Services reported no address, because /me had reimplemented getMyAccess
    without its parent-walking resolution.

Adds the catalog at /, self-service access requests, and admin access
visibility (per-resource counts, and the reverse "what can this user reach").

Nested groups come in two halves. groupOfNames.member already accepts a group
DN, so nesting needs no schema -- what it needs is resolution, which no
released OpenLDAP performs. The all-in-one image therefore builds slapd from a
pinned master commit for the nestgroup overlay, and the app computes the
closure itself when pointed at a server without it. Both paths are covered.

member-values is deliberately left out of nestgroup-flags: it expands `member`
when reading a group, which destroys the distinction between "listed here" and
"reachable through a nested group" and is not recoverable afterwards.

Full suite green in both resolution modes: 215 passed, 2 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:22:08 -04:00
wmantly 0e955abc73 Standardize the resource modal: tabs, footer, linkable URL, Children tab, site-slug group prefixing (#120)
* Add Resource audit fields (created/updated by/on) and site-slug group prefixing

Resource had no created_by/created_on/updated_by/updated_on fields at all,
unlike proxy's Host and jump-host's ApiToken which already track this --
needed for the upcoming resource-modal footer. @simpleworkjs/orm has no
auto-timestamp hook, so these are set explicitly in the directory-admin
route handlers on every create/update.

Also: when a host/service resource is created, its two auto-created LDAP
groups (<slug>_access/_admin) now get prefixed with the nearest ancestor
site's slug (via a new Resource.findAncestorSiteSlug walk), so groups from
different sites don't collide/look identical. Falls back to today's
unprefixed naming when a resource has no site ancestor.

Included the checked-in dev inventory.sqlite's ALTER TABLE for the new
columns, since @simpleworkjs/orm's sync() only creates missing tables, never
alters existing ones -- the raw model change alone would have broken every
Resource read/write against this file with "no such column: created_by".

* Migrate Resource modal onto app.modal's tabs/footer/URL, add Children tab

The Directory's resource modal was a separate, hand-rolled, always-in-DOM
Bootstrap modal, independent of the shared app.modal singleton -- migrating
it onto app.modal (now published with tabs/footer/url support in
@simpleworkjs/frontend 0.2.6) is the pilot for standardizing entity modals
across the stack.

- General/Details/Associated LDAP Groups/Children tabs, replacing the old
  single long form (Details keeps every kind-conditional container
  unchanged; toggleFormFields() didn't need to change at all).
- Footer shows created/updated by/on (via the new Resource audit fields)
  and the Save button; Groups/Children tabs are hidden in add-mode since
  they need an existing resource id.
- New Children tab lists a resource's existing children (reusing the
  already-loaded edges/resourcesById data, no new endpoint) and an "Add
  Child Resource" button that reuses openAddModal's existing preset-parent
  support. Folded the pre-existing generic "Relationships (Graph Edges)"
  section in underneath, under an "advanced" subheading, rather than
  dropping it or giving it a 5th tab of its own.
- GET /directory/:slug (mirroring the existing /users/:uid precedent) plus
  a client-side app.modal.deepLinkSlug() check makes a resource's modal
  linkable and directly loadable.
- Converted the groups/edges lists from jq-repeat to plain manual DOM
  rendering: jq-repeat's MutationObserver-based scope (re)registration for
  an element that's destroyed and recreated on every modal open runs
  asynchronously, so populating synchronously right after open() (as
  refreshGroupsUI/refreshEdgesUI must) raced it -- on the second and later
  opens, the old scope's destroy() ran after the new data was pushed onto
  it, silently discarding it. Manual rendering (matching the new Children
  tab) sidesteps the race entirely.
- The #res-name/#res-kind auto-slug handler is now bound via
  app.modal.on() (delegated) instead of directly -- a direct bind would
  have silently stopped firing after the first Add/Edit, since the modal
  body is rebuilt from scratch on every open().

Verified live against the running dev stack: tabs/footer/groups/children
all render and populate correctly (including on a second open, confirming
the jq-repeat race fix), the address bar updates to /directory/{slug} and
reverts on close, browser Back closes the modal via popstate without a
page reload, and a resource created under a Site gets correctly
site-slug-prefixed LDAP groups.
2026-07-28 17:42:05 -04:00
wmantly fc0d9104d0 Release 1.4.0: shared @simpleworkjs/* packages; fix discovery envelope drift + client_secret_hash leak
Rewire onto @simpleworkjs/directory-schema, /ldap, and /app-stack. The
directory discovery API now returns the {results} envelope via explicit
/resources, /resources/:slug, /graph, /me handlers and routes every read
through projectResource/projectResources, which unconditionally strips
client_secret_hash (and any /secret|password|privatekey/i key) and reduces
metadata to a public allowlist for non-admins — closing the leak where the ORM
serialized metadata wholesale. The dead routes/api_discovery.js (mounted after
the 404 catcher) is removed; ?group= now returns 200 instead of 404. user_ldap
+ group_ldap take escapeFilter/escapeDN + makeClient/withClient from the shared
ldap package (posix/write-side stays app-local; cert validation unchanged).
build_info unified to {buildVersion,buildHash,buildYear}; ldapts ^8.1.8. New
tests/discovery.test.js locks in the envelope + no-secrets guarantees. Lockfile
regenerated from the registry (no file:/link:).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 16:39:10 -04:00
wmantly c4d7a1a8e9 feat: actionable metrics, LDAP log parsing, UI updates 2026-07-22 21:58:06 -04:00