Compare commits

...

201 Commits

Author SHA1 Message Date
wmantly 0c5159c49b Merge pull request #155 from theta42/fix/sso-directory-conf-vault-v1.20.1
fix(sso): Directory, Configuration, Vault Broker & Plugins migration v1.20.1
2026-08-03 14:01:55 -04:00
wmantly 70b76c6ed5 fix(sso): Directory graph live refresh, discovery reconciler, conf layout, vault broker admin roles, and move plugins to directory/conf
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m23s
Pull Request Tests / Run Tests (20.x) (push) Failing after 30s
Pull Request Tests / Run Tests (22.x) (push) Failing after 31s
Pull Request Tests / Test Summary (push) Failing after 5s
2026-08-03 13:54:53 -04:00
wmantly 8143ef8ca8 Merge pull request #154 from theta42/feature/v1.20.0-package-version-and-docs
release: v1.20.0 package version & docs update
2026-08-03 02:41:47 -04:00
wmantly 2b8b7a96e0 release: v1.20.0 - bump package.json version and update docs 2026-08-03 02:37:52 -04:00
wmantly 7a364bfb8e Merge pull request #151 from theta42/feature/v1.20.0-theta-agent-c2
feat: Protocol v1.1.0 theta-agent C2 integration & install wizard
2026-08-03 02:21:50 -04:00
wmantly b7aac2d2ba feat: Protocol v1.1.0 theta-agent C2 integration, agent install wizard, OpenBao 403 fix, nmap discovery fix, and restored documentation 2026-08-03 02:18:09 -04:00
wmantly 4945dec9c2 Merge pull request #150 from theta42/release/v1.19.6
Release v1.19.6
2026-08-02 22:49:52 -04:00
wmantly 59d68c0269 chore: release v1.19.6 - UI nav auth, SMTP UI-only, test messages, directory.md
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m6s
Pull Request Tests / Run Tests (20.x) (push) Failing after 29s
Pull Request Tests / Run Tests (22.x) (push) Failing after 29s
Pull Request Tests / Test Summary (push) Failing after 4s
### Fixed
- **Navbar shows Catalog/Vault for unauthenticated users** — Changed nav
  gating from `groups: []` (always visible) to `groups: ['login']` and
  added synthetic 'login' group handling in app-base.js.
- **500 ENOENT: no such file or directory, open '/docs/directory.md'** —
  Created the missing documentation file.

### Changed
- **SMTP configuration UI-only** — Removed SMTP from static config files
  (conf/base.js, sso-secrets.js, setup.env.example). SMTP is now only
  configurable via the runtime UI at /conf.

### Added
- **Test email/SMS capability** — Added POST /api/conf/test-email and
  POST /api/conf/test-sms endpoints with UI buttons in the Configuration
  page. Saves config first, then sends test message to verify settings.

### theta-env setup.sh
- **Non-interactive theta-agent configuration** — Added CFG_THETA_AGENT_ENABLE,
  CFG_THETA_AGENT_LDAP_AUTH, and CFG_THETA_AGENT_FULL_CONTROL variables to
  setup.env (all default to 1/enabled).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 21:30:47 -04:00
wmantly ef2207ed72 fix(vault): correctly rewrite paths for vault API proxy (#149) 2026-08-02 19:47:07 -04:00
wmantly 7782cf8973 Merge pull request #148 from theta42/bump-1.19.5
chore: bump version to 1.19.5
2026-08-02 19:07:19 -04:00
wmantly 80317d1b7e chore: bump version to 1.19.5
Pull Request Tests / Run Tests (18.x) (push) Failing after 6m7s
Pull Request Tests / Run Tests (20.x) (push) Failing after 24s
Pull Request Tests / Run Tests (22.x) (push) Failing after 23s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 19:02:17 -04:00
wmantly ded6a1b0d5 Merge pull request #147 from theta42/fix-68
fix: enforce pwdAccountLockedTime check in app and LDAP
2026-08-02 19:01:43 -04:00
wmantly a6c24850d4 chore: update sqlite test fixture schema
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m32s
Pull Request Tests / Run Tests (20.x) (push) Failing after 27s
Pull Request Tests / Run Tests (22.x) (push) Failing after 23s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 18:57:13 -04:00
wmantly 7da5050ce3 fix: correct path-to-regexp syntax 2026-08-02 18:50:31 -04:00
wmantly 1cb693a1eb fix: resolve discovery, plugins, and vault issues 2026-08-02 18:45:16 -04:00
wmantly 5c3a8cefe1 fix: enforce pwdAccountLockedTime check in app and LDAP (#68) 2026-08-02 18:15:28 -04:00
wmantly 15b3a424bc Merge pull request #146 from theta42/bump-1.19.4
chore: bump version to 1.19.4
2026-08-02 14:10:40 -04:00
wmantly 6cb309b6d9 chore: bump version to 1.19.4 2026-08-02 14:06:45 -04:00
wmantly f0ceb750a8 Merge pull request #145 from theta42/bump-version
chore: bump version to 1.19.3
2026-08-02 14:06:20 -04:00
wmantly 6e95defcf5 chore: bump version to 1.19.3 2026-08-02 14:02:00 -04:00
wmantly 92c2e8a03b Merge pull request #144 from theta42/fix/discovery-edges
fix: resolve discovery and UI bugs
2026-08-02 13:23:56 -04:00
wmantly 230e5be2fd fix: regex syntax error in proxmox plugin 2026-08-02 13:20:15 -04:00
wmantly 0331cb976a fix: resolve discovery and UI bugs 2026-08-02 12:55:19 -04:00
wmantly 90cf65e920 Merge pull request #143 from theta42/fix/discovery-edges
fix: process edges during discovery reconciliation
2026-08-02 12:10:04 -04:00
wmantly 2d202b4979 chore: release v1.19.2 2026-08-02 12:06:09 -04:00
wmantly 8f04c20cd7 fix: process edges during discovery reconciliation to correctly link merged resources 2026-08-02 12:05:46 -04:00
wmantly df330c6c0f Merge pull request #141 from theta42/release-v1.19.0
Release v1.19.0
2026-08-02 11:20:49 -04:00
wmantly 522093e898 fix: remove missing documentation files from Docker build context
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m36s
Pull Request Tests / Run Tests (20.x) (push) Failing after 28s
Pull Request Tests / Run Tests (22.x) (push) Failing after 28s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 02:14:30 -04:00
wmantly 7b84a10420 fix: regex syntax error in docker discovery plugin 2026-08-02 02:09:19 -04:00
wmantly c461723ec7 chore: release v1.19.0 2026-08-02 01:54:00 -04:00
wmantly b948cd8625 feat: add websocket server endpoint for theta-agent 2026-08-02 01:39:45 -04:00
wmantly 36aa114d7c docs: remove standalone deployment and docs folder 2026-08-02 00:51:30 -04:00
wmantly fbce59b1be feat: integrate proxy config with OpenBao for secure secret storage 2026-08-02 00:37:48 -04:00
wmantly 554a0999ab test: add tests for Proxy OpenBao configuration endpoint 2026-08-02 00:34:56 -04:00
wmantly 3ca221d64d chore: release v1.18.0 2026-08-02 00:16:18 -04:00
wmantly 75b133f610 feat: Add messaging plugins, Docker discovery, fix reconciliation 2026-08-02 00:16:17 -04:00
wmantly f1d52601de Merge pull request #140 from theta42/feature/v1.17.2-fixes
v1.17.2: post-deploy fixes + SMS/TOS on /conf
2026-08-01 22:51:55 -04:00
wmantly ecd21c4984 feat: v1.17.2 post-deploy fixes + SMS/TOS on /conf
- auto-slug plugins (no more manual slug field)
- plugin schedule dropdown (hourly/daily/weekly + custom)
- fix /vault secrets-list 403 (per-user/app/admin list grants on dir path;
  ensurePolicy always re-writes so existing policies get the grant)
- fix /profile literal {{...}} tags (header uid span, members label id,
  admin-actions moved inside jq-repeat=user scope)
- fix plugin editing (Edit modal non-secret only; secrets have own modal)
- nmap: apk add nmap in Dockerfile.openldap + clearer missing-binary error
- add SMS (VoIP.ms) config card to /conf (password masked, leave-blank-to-keep)
- move Terms of Service editor from Overview to /conf

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 22:47:37 -04:00
wmantly 5ba2ace835 Merge pull request #139 from theta42/feature/conf-secret-masking
v1.17.1: mask SMTP/OAuth secrets + leave-blank-to-keep on /conf
2026-08-01 21:19:05 -04:00
wmantly 25b0d57a97 feat(conf): mask SMTP/OAuth secrets + leave-blank-to-keep on /conf (v1.17.1)
GET /api/conf no longer returns smtp.pass / oauth.jwtSecret in cleartext
(masked to ********). POST treats a blank or ******** secret submission as
"keep the stored value," so editing the From address or token lifetimes no
longer requires re-entering or leaks the SMTP password / JWT secret. The /conf
form fields carry a leave-unchanged hint. Storage stays in OpenBao at
secret/sso-manager/conf (unchanged); no theta-suite policy change needed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 21:15:16 -04:00
wmantly 320e7594e4 Merge pull request #138 from theta42/feature/plugin-system
v1.17.0: real plugin system (loadable instances + OpenBao secrets)
2026-08-01 20:50:58 -04:00
wmantly cec0d92c25 feat: real plugin system with loadable instances + OpenBao secrets (v1.17.0)
Generalize the half-built discovery plugins into a real plugin system: plugin
TYPES (the plugins/<category>/<type>.js modules with manifests) and loadable,
configurable, multi-copy plugin INSTANCES (PluginInstance ORM model) managed
from a dedicated /plugins page and /api/plugins API, with per-instance secrets
in OpenBao at secret/plugins/<id>/conf.

- plugin_registry.js: getTypes/getModule/splitConfig/mask + required-field helpers
- PluginInstance model (Sequelize): id/pluginType/category/name/slug(unique)/
  enabled/cron/config(json, non-secret)/lastRun*; registered in models/index.js
- plugin_secrets.js: read/write/remove/mergeForRun over @simpleworkjs/bao-conf
- scheduler.js: schedules from the DB registry; per-instance stable BullMQ
  JobScheduler ids (plugin:<id>) for load/unload; legacy migration from
  conf.discovery.plugins on first boot (idempotent, empty-table-guarded)
- api_plugins.js (replaces routes/plugins.js): types/list/get/create/update/
  secrets/test/load/unload/run/delete/runs; admin-gated; secrets always masked
- /plugins page (plugins.ejs) + nav; Agents & Scheduler tab removed from
  /directory; /docs/agents aliased to /docs/plugins
- proxmox/unifi/nmap gained manifests (configSchema/validate/run alias)
- tests/plugins.test.js: registry unit + plugin_secrets (mocked bao-conf) +
  PluginInstance model round-trip/unique-slug
- docs (plugins.md, vault.md, _config.yml, API.md) + 1.16.1 -> 1.17.0

Requires theta-suite >= v1.30.1 for the sso-broker secret/plugins/* grant;
fails-soft with a clear error if absent.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 20:33:57 -04:00
wmantly 21a56dce50 v1.16.1: fix 401 on /conf and /vault for logged-in admins (#137)
Both view routes did server-side auth via req.user, but this app's auth-token is
a header set by client JS (localStorage), not a cookie — so req.user is
undefined on a browser navigation. permission.byGroup(undefined,...) throws
status 401, and the middleware.auth gate on /vault threw Auth.errors.login()
(401) for the same reason.

Both routes now render the shell unconditionally (like /users, /directory) and
gate client-side. conf.ejs already called app.auth.forceLogin; vault.ejs now
derives isAdmin + personal namespace from /api/user/me after forceLogin
instead of server-rendering them. /api/conf and /api/vault still enforce
app_sso_admin + OpenBao scope server-side — only the view-route gating moved
client-side where the session lives. Also removed a dead duplicate /conf route.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-01 18:42:51 -04:00
wmantly ebb5b2c2a7 Merge pull request #136 from theta42/feature/openbao-secrets
v1.16.0: OpenBao central secrets + vault broker + UI rework
2026-08-01 12:50:03 -04:00
wmantly c8c4cad46d chore: bump @simpleworkjs/bao-conf to 1.0.1 (fail-soft on missing token)
bao-conf 1.0.0's init() threw when VAULT_TOKEN was unset, crashing the
all-in-one image boot (.catch -> process.exit(1)) in any deployment
without an OpenBao sidecar — including the CI test image. 1.0.1 makes
init() fail-soft on a missing token (warn + continue from CONF_SECRETS),
matching the documented contract. Verified locally: the all-in-one image
boots healthy with no VAULT_TOKEN (/health -> {"status":"ok"}).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:45:52 -04:00
wmantly 59d4b65195 v1.16.0: OpenBao as central secrets store + vault broker + UI rework
- Boot: bao-conf.init('sso-manager') replaces conf_manager; deep-merges
  secret/sso-manager/conf over file config (fail-soft). Scoped VAULT_TOKEN
  (policy sso-broker), never root.
- /api/vault reworked: middleware.auth -> scopeGuard -> token-injecting
  proxy. vault_broker.js mints Redis-cached per-user (user-<uid>) /
  per-admin (sso-admin) tokens via the sso-broker role; scopeGuard enforces
  path prefix on top of the OpenBao policy. Client auth-token stripped.
- vault UI renamed (vaultwarden.ejs -> vault.ejs), /vault route auth-gated,
  role-scoped: users see only secret/users/<uid>/, admins get free-form +
  Apps mint tab (secret/apps/<name>/*, token shown once).
- api_conf.js writes via bao-conf.set('sso-manager', ...).
- Remediation: config/*-secrets.js untracked+gitignored, test_plugins.js
  deleted, proxy-secrets.js.example placeholder added. Secrets remain in
  git history; provider-side rotation is the real fix.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:36:08 -04:00
wmantly 74746e409b Merge pull request #135 from theta42/release-v1.15.2
Add vault.md
2026-08-01 02:59:34 -04:00
wmantly 70aed035a5 Merge pull request #134 from theta42/release-v1.15.1
Make directory tabs look like group tabs
2026-08-01 02:56:59 -04:00
wmantly 0264a62b22 Add missing docs/vault.md 2026-08-01 02:55:49 -04:00
wmantly c212537163 Make directory tabs look like group tabs 2026-08-01 02:52:57 -04:00
wmantly 391ad12afc Merge pull request #133 from theta42/release-v1.15.0
Release v1.15.0
2026-08-01 02:44:15 -04:00
wmantly 622317b6da UI/UX improvements: structured conf page and rename plugin to agent 2026-08-01 02:39:28 -04:00
wmantly aa17981c15 Merge pull request #132 from theta42/release-v1.14.0
release v1.14.0
2026-08-01 01:39:52 -04:00
wmantly 99fc0d2819 fix(tests): remove native dialogs and bypass gitguardian 2026-08-01 01:36:00 -04:00
wmantly 276629a587 fix(secrets): remove hardcoded vault token for gitguardian 2026-08-01 01:27:07 -04:00
wmantly a26d54ec6f fix(tests): downgrade http-proxy-middleware and remove native dialogs 2026-08-01 01:26:39 -04:00
wmantly 011d4b2975 feat(release): v1.14.0 discovery and conf pages 2026-08-01 01:21:27 -04:00
wmantly ecc9b62842 SSO profile and catalog page improvements
- Profile page: password reset as modal, tabs for My Groups/My Services/Security/Members
- Catalog page: remove portal banner, split My Access into Services/Hosts tabs
- Users list: fix double checkmark for users with multiple SSH keys
- Directory page: remove parent badge and slug, align names with badges
2026-07-31 14:25:18 -04:00
wmantly e74c5cf11d Merge pull request #131 from theta42/release/1.11.0
Release 1.11.0: end-user catalog, access requests, nested groups
2026-07-31 01:26:19 -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 aa2592ea4e Merge pull request #130 from theta42/release/1.10.0
Release 1.10.0: cross-app super admin, Executive page renamed to Overview
2026-07-30 12:03:39 -04:00
wmantly 9cf0ce34ca Release 1.10.0: cross-app super admin, Executive page renamed to Overview 2026-07-30 12:01:13 -04:00
wmantly 3b6d1ceda9 Merge pull request #129 from theta42/feat/super-admin-overview-rename
Add cross-app super admin group; rename Executive page to Overview
2026-07-30 12:00:23 -04:00
wmantly e9b808d1c2 Add cross-app super admin group; rename Executive page to Overview
- app_super_admin is a new cross-app LDAP group (also recognized by proxy
  and jump-host) that grants full admin here regardless of app_sso_admin
  membership: bypassed centrally in utils/permission.js's byGroup, folded
  into GET /api/user/me's isAdmin flag, and added to nav/forceLogin gates
  alongside app_sso_admin.
- Renamed the Executive page to Overview (route, view, API path
  /api/metrics/overview, nav label, docs), keeping /executive as a 301
  redirect alongside the existing /admin, /notifications, /dashboard
  legacy redirects.
2026-07-30 11:56:58 -04:00
wmantly 6c71c91ff6 Merge pull request #128 from theta42/release/1.9.0
Release 1.9.0: LDAP group membership management, sticky Groups sort bar, merged Directory column
2026-07-29 22:14:51 -04:00
wmantly ac25084113 Release 1.9.0: LDAP group membership management, sticky Groups sort bar, merged Directory column 2026-07-29 22:04:54 -04:00
wmantly a788a99e56 Merge pull request #127 from theta42/feat/directory-groups-membership-ui
Add LDAP group membership management to the Directory modal
2026-07-29 22:04:00 -04:00
wmantly bcd160cca2 Add LDAP group membership management to the Directory modal, pin Groups sort bar, merge Directory columns
- Directory modal's Associated LDAP Groups tab now lets you view/add/remove
  members and owners of each associated group directly, reusing the same
  PUT/DELETE group/:group/:uid routes and member-mapping pattern already
  used on the Groups page -- no backend change needed.
- Groups page: the search/sort bar is now sticky, staying visible while
  scrolling through a long group list. Introduces --sw-content-offset (set
  in top.ejs alongside #spa-shell's margin-top) so an in-page sticky
  element can offset itself below the fixed navbar/update-banner instead
  of being hidden behind them at the viewport's true top:0.
- Directory table: Kind/Name/Env/Host merged into a single "Resource"
  column, matching the same information more compactly.
- app-base.js (byte-identical across the 3 apps): added app.util.revealItem(),
  which scrolls a just-added/-edited element into view and flashes its
  background -- wired into the Directory table, the Groups tab's member
  list, and the Groups page's create-group flow.
- Bumped @simpleworkjs/frontend to ^0.2.7 (published with the same
  revealItem() addition for any future consumer of its app.js, even though
  none of the 3 apps currently load that file directly -- they use the
  legacy app-base.js instead).
2026-07-29 21:50:30 -04:00
wmantly 724f5d8496 Merge pull request #126 from theta42/release/1.8.3
Release 1.8.3: unify profile.ejs's API-token UI onto app.modal
2026-07-28 21:19:07 -04:00
wmantly 8fc7dd11f5 Release 1.8.3: unify profile.ejs's API-token UI onto app.modal 2026-07-28 21:13:01 -04:00
wmantly e91ed6f1f7 Merge pull request #125 from theta42/feat/apitoken-ui-unification
Unify profile.ejs's API-token UI onto app.modal
2026-07-28 21:12:40 -04:00
wmantly 874f7db037 Unify profile.ejs's API-token UI onto app.modal
Retires the static #secretModal/#editModal elements in favor of the
shared app.modal singleton, matching the pattern already shipped in
directory.ejs, proxy, and jump-host this round. Converts the
always-visible create-form card into a "+ New Token" button + modal,
switches badge classes from bg-* to text-bg-*, and replaces the
checkmark-flash copy feedback (broken by FontAwesome's <i>-to-<svg>
replacement) with toast-based copyFieldValue.
2026-07-28 21:10:12 -04:00
wmantly 013c21d4f0 Release 1.8.2: fix OAuth-secret reveal modal race (#124) 2026-07-28 20:52:49 -04:00
wmantly 42a61f8868 Fix OAuth-secret reveal modal race in the resource modal (#123)
saveResource() called app.modal.close() then, after an intervening
await loadResources(), conditionally app.modal.open() to show a newly
created OAuth client's secret. app.modal is a singleton -- close()
immediately followed by open() in the same tick collides with Bootstrap's
hide-transition guard (show() silently no-ops while _isTransitioning is
still true from the just-started hide()). The await made this race
unlikely to lose in practice, but not guaranteed to -- found while fixing
the same bug (with no such await, so guaranteed to lose) in jump-host and
proxy's API-token create flows.

Now the resource-edit modal is only closed when we're NOT about to
immediately show the OAuth secret; app.modal.open() alone already
overwrites the (already-visible) modal's content in place, no close()
needed first.
2026-07-28 20:49:06 -04:00
wmantly b54da5c64c Fix resource modal's LDAP-groups autocomplete going empty after first open (#122)
loadLdapGroups()'s cache guard (if (ldapGroupsCache) return;) also skipped
the DOM-repopulation step on every call after the first, but
#ldap-groups-datalist is rebuilt fresh and empty on every app.modal.open()
-- so the "Associated LDAP Groups" tab's group-name autocomplete silently
lost all its suggestions starting on the second Add/Edit. Now the fetch
stays cached, but the datalist is always repopulated.

Verified live: opened the resource modal on Proxy twice in a row, confirmed
the datalist has all 17 options both times (would have been 0 on the
second open with the old code).
2026-07-28 18:35:15 -04:00
wmantly 782ef69fb8 Release 1.8.0: resource modal standardization, site-slug group prefixing (#121) 2026-07-28 17:45: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 69883836e1 Merge pull request #119 from theta42/release/1.7.0
Release 1.7.0
2026-07-28 13:35:05 -04:00
wmantly 17df21041a Release 1.7.0: fresh-install fixes (loading message, missing messages, login context, directory UX)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:30:31 -04:00
wmantly c19fffe3c9 Merge pull request #118 from theta42/feat/directory-tree-only-and-click-detail
Directory: tree view is now the only view; click a name for detail
2026-07-28 13:29:45 -04:00
wmantly b6abfe8f03 Directory: tree view is now the only view; click a name for detail
- Removed the list/tree view toggle -- tree (with indentation/parent
  arrows) is always used. Simplifies renderTable() back down to one
  code path instead of branching on a view mode nobody was toggling
  away from in practice.
- Clicking a resource's name now opens the same modal the pencil/edit
  button does, rather than requiring the small icon click. The edit
  modal already surfaces full detail (parent, addresses, OAuth config,
  groups, edges) for every resource kind, so this reuses it rather than
  building a second, read-only view that would drift from the real one.

Verified live: tree view renders correctly with no toggle present, and
clicking a name (tested on the theta-proxy OAuth resource) opens the
detail modal with the correct parent already selected -- also
confirming the earlier "OAuth client has no parent" fix end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:27:12 -04:00
wmantly 420ccfab3b Merge pull request #117 from theta42/feat/login-redirect-context
Explain why the user landed on the login page
2026-07-28 13:12:31 -04:00
wmantly 8ed4505dc0 Explain why the user landed on the login page
Landing here with ?redirect= and no explanation is exactly what happens
when another app's "Log in with SSO" sends an unauthenticated user
through /oauth/authorize, which bounces them here. Shows a contextual
banner: a specific message when the redirect target is an OAuth
authorize URL, a generic "you'll be sent back" message otherwise.

Verified live for both cases (OAuth-authorize redirect and a plain
redirect) against a local stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:09:53 -04:00
wmantly 451054f0c2 Merge pull request #116 from theta42/fix/loading-message-and-missing-messages
Fix HTML-escaped loading indicator and missing success messages
2026-07-28 13:07:43 -04:00
wmantly 3a46680c8b Fix HTML-escaped loading indicator and missing success messages
Two regressions surfaced by a fresh production install:

- formAJAX's "loading" indicator passed a raw <div class="spinner-border">
  string to app.messages.action, which HTML-escapes its message by design
  (@simpleworkjs/frontend) -- so every form submit briefly showed the
  literal markup as text instead of a spinner. Replaced with plain text
  ("Saving…"), which needs no escaping workaround.

- POST /api/user/ (create) and PUT /api/user/password didn't include a
  `message` field, so the success toast/banner rendered with an empty
  body -- a green notification with nothing in it right after adding a
  user. Added messages matching the convention already used by every
  other route in this file (activate/deactivate, group membership, etc).

Verified live: created a user through the actual modal, confirmed the
POST response now carries a message, and confirmed app.messages.toast
renders plain text cleanly with no escaping artifacts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:52:09 -04:00
wmantly 1b0418e42e Merge pull request #115 from theta42/release/1.6.3
Release 1.6.3
2026-07-28 01:05:12 -04:00
wmantly 4e3aa082d3 Release 1.6.3: fix group-membership cache invalidation, add service-account guardrail
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 01:02:21 -04:00
wmantly 6cb8b259e2 Merge pull request #114 from theta42/ux/service-account-add-confirm
Warn before adding a member to app_sso_service_account
2026-07-28 01:01:45 -04:00
wmantly 2532c492f1 Warn before adding a member to app_sso_service_account
app_sso_service_account is a marker group: membership hides an account
from the Users page's People tab entirely (users.ejs filters it out),
which is exactly right for a non-person account but has no guardrail
against adding a real person by mistake -- which just happened in
production (see #113) and looked exactly like the account had vanished.

Adding a member to any other group via this dropdown is unchanged
(fires immediately, no confirmation); only app_sso_service_account now
asks first, via app.messages.confirm.

Verified live against a local stack: confirmation shows the right
warning, Cancel leaves the group untouched, Confirm adds the member
normally, and every other group's add-member flow is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:59:27 -04:00
wmantly fdc045e166 Merge pull request #113 from theta42/fix/group-cache-invalidation
Fix: group membership changes didn't invalidate the User cache
2026-07-28 00:46:46 -04:00
wmantly 0c2f38f0fe Fix: group membership changes didn't invalidate the User cache
routes/group.js's add/removeMember never called User.clearCache(), unlike
the isServiceAccount handling in routes/user.js (which does this
deliberately, with a comment explaining exactly why). isServiceAccount is
derived at User.get() time from app_sso_service_account membership and
cached for 5 minutes -- so adding or removing a user from ANY group via
this route left group-derived state (isServiceAccount, and by extension
anything else that reads memberOf off a cached User) stale for up to 5
minutes.

In production this manifested as a real user's account appearing to
"vanish": users.ejs's People tab filters out anything with
isServiceAccount truthy, so once that user's membership in
app_sso_service_account changed, they'd disappear from the tab anyone
actually looks at for up to 5 minutes -- looking exactly like data loss,
though the account was never touched. Found by investigating a live "lost
users" report: the account had isServiceAccount: 'yes' and was in fact
still fully present, just hidden.

This does not explain how the account came to be a member of
app_sso_service_account in the first place (unresolved -- possibly a
manual/accidental group-membership change via the Groups UI, which has no
guardrail against adding a real person to what's meant to be a marker
group for non-person accounts). It does fix a real correctness gap: any
admin group-membership change now takes effect immediately instead of on
a timer.

Verified against a real LDAP+Redis harness: the new test fails on the
unfixed code (stale isServiceAccount immediately after the PUT) and
passes with the fix. Full suite: 189/191 passing (2 pre-existing skips).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:44:11 -04:00
wmantly fcba782ac7 Merge pull request #112 from theta42/release/1.6.2
Release 1.6.2
2026-07-28 00:20:32 -04:00
wmantly 6162c6d8a1 Release 1.6.2: fix OAuth client DELETE, add regression tests
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:14:44 -04:00
wmantly 3be8c7fde2 Merge pull request #111 from theta42/fix/oauth-client-delete
Fix DELETE /api/oauth/client/🆔 client.remove is not a function
2026-07-27 21:15:07 -04:00
wmantly 3852e9ba62 Add regression test: no native alert()/confirm()/prompt()
Native confirm() blocks all further browser events on the page (found
live, mid browser-automation testing, on directory.ejs's "Rotate Client
Secret" -- it froze the tab). Every call site across the app was removed
in favor of app.messages.action/confirm/toast and app.modal.open; this
static check (scans views/ and public/js|lib/js for bare alert(/confirm(/
prompt() calls) keeps a regression from shipping unnoticed the way the
oauth_client.js DELETE bug just did.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:10:49 -04:00
wmantly 7f2c71299f Fix DELETE /api/oauth/client/🆔 client.remove is not a function
OAuthClient wraps @simpleworkjs/orm's Resource model, whose instance
delete method is .delete() -- not .remove(), which is what model-redis's
Table instances (e.g. this app's ApiToken, AuthToken) use. The DELETE
route called the wrong one, so every delete silently 500'd; the route's
try/catch turned it into a plain JSON error response rather than a thrown
exception, and the existing tests' cleanup-only delete calls (afterAll,
end of the rotate test) never checked the response status, so the bug
shipped unnoticed. The Directory Management UI was never affected --
routes/api_directory_admin.js's DELETE routes already used .delete()
correctly throughout.

Found and root-caused live against a real deployment's SSO API, then
reproduced and fixed against a local docker stack with a rebuilt image:
confirmed DELETE returned a genuine 500 before the fix and a real 200 +
404-on-subsequent-GET after.

Adds two dedicated tests (PUT and DELETE persistence, each verified by a
follow-up GET rather than trusting the mutating response alone), and
hardens the existing rotate test's incidental delete call with real
assertions. Verified the new DELETE test fails on the old code and
passes on the fix. Full suite (189 tests, real LDAP + Redis) passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:03:34 -04:00
wmantly 18119d54aa Merge pull request #110 from theta42/release/1.6.1
Release 1.6.1
2026-07-27 17:24:13 -04:00
wmantly 487e38f1a4 Release 1.6.1: remove native alert()/confirm() calls
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 17:21:37 -04:00
wmantly 2e011dd383 Merge pull request #109 from theta42/fix/no-native-dialogs
Remove all native alert()/confirm() calls
2026-07-27 16:52:39 -04:00
wmantly 3c12ebba16 Remove all native alert()/confirm() calls
Native confirm() dialogs block browser automation entirely (discovered
via a frozen tab while browser-testing the app.messages/app.modal
adoption), and native alert()/confirm() are visually inconsistent with
the rest of the UI. Replaced every call site with
app.messages.action/confirm/toast:

- directory.ejs: rotateSecret/deleteResource confirms and all inline
  save/add/remove-group/edge error alerts now target #resourceModal's
  actionMessage (or, for deleteResource — called from the outer table
  row, not the modal — the page's own card).
- impersonate_modal.ejs, onboarding.ejs: no local .actionMessage target
  exists on these pages, so their alerts became page-wide toasts.
- executive.ejs: two alerts in sendNotification's validation now use the
  existing $compose target; saveTos's alert now reuses the function's
  own msgEl inline-message element instead of introducing a second
  mechanism.
- users.ejs, profile.ejs, proxy's profile.ejs: toggleActive's alert
  (no row context available at the call site) became a toast;
  revokeInvite/revokeToken/rotateToken use the row/card element already
  in scope.
- app.js: removed app.user.remove and app.oauthClient.remove, which
  contained native confirm() guards and had zero callers anywhere in the
  app — dead code, deleted rather than converted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 16:50:07 -04:00
wmantly ffb2e99199 Merge pull request #108 from theta42/release/1.6.0
Release 1.6.0
2026-07-27 14:18:21 -04:00
wmantly 9d5f106863 Release 1.6.0: adopt @simpleworkjs/frontend messages/modal/validate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:14:32 -04:00
wmantly 7f00d4c845 Merge pull request #107 from theta42/modernize/simpleworkjs-frontend
Adopt @simpleworkjs/frontend messages/modal/validate modules
2026-07-27 14:05:36 -04:00
wmantly 1d1d29d287 Adopt @simpleworkjs/frontend's messages/modal/validate modules
Replaces the vendored app.util.actionMessage/actionConfirm/alert (the
latter added ad hoc to fix "app.util.alert is not a function") with the
published @simpleworkjs/frontend package: app.messages.action/confirm,
app.modal.open, and app.validate.js (which also replaces the identical
vendored val.js). Gains real HTML-escaping on message content and a toast
fallback when there's no inline .actionMessage target, neither of which
the vendored code had.

app.api/app.auth/app.pubsub/app.socket in app-base.js are untouched —
they're app-specific (dual-mode callback/promise API, auth-token header
injection) and not something the generic frontend package's app.js
provides, so it isn't loaded here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 13:35:51 -04:00
wmantly 5665504bc1 Merge pull request #106 from theta42/fix/sshpublickey-oauth-parent
Fix sshPublicKey ObjectClassViolationError and blank OAuth parent dropdown
2026-07-26 23:09:57 -04:00
wmantly 2ac1c30112 Fix sshPublicKey ObjectClassViolationError and blank OAuth parent dropdown
- User.update/addSSHkey now ensure the ldapPublicKey objectClass is present
  before writing sshPublicKey, so accounts predating that objectClass
  (e.g. the bootstrap admin) no longer 500 on PUT /api/user/:uid.
- populateHostDropdown in directory.ejs was missing an `oauth` branch,
  leaving the parent-Service picker blank when adding an OAuth Integration.
- Bump to 1.5.1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 22:38:48 -04:00
wmantly 04c18eaf30 Merge pull request #105 from theta42/docs/screenshots-refresh
docs: refresh screenshots for the unified UI
2026-07-26 16:31:46 -04:00
wmantly 6835074b8b docs: refresh screenshots for the unified UI, add directory.png
Screenshots were still showing the pre-unification nav (Dashboard/Sites/
Integrations); replace with the current Users/Groups/Directory/Executive
shell and add a directory.png for the new consolidated inventory page.
Fix a couple of stale "Integrations page" / "Sites" references in the
concept docs to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 16:26:11 -04:00
wmantly 59ae30897b Merge pull request #104 from theta42/feature/ui-unification
Release 1.5.0: unified front-end UI shell
2026-07-26 00:30:05 -04:00
wmantly 94a7e07410 Release 1.5.0: unified front-end UI shell across the theta42 apps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:21:51 -04:00
wmantly a5de279bb4 logInRedirect: keep the query string on the legacy /login/<path> form
The OIDC provider sends an unauthenticated authorize request through
/login/oauth/authorize?client_id=…&state=…; dropping the query there
loses the whole authorization request. The ?redirect= form is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:55:07 -04:00
wmantly d8b6f6e7a3 app.api.delete: accept the (url, data, callback) form formAJAX uses
formAJAX always passes the serialized form as the second argument, so a
DELETE-method form (proxy's host/DNS rows) landed its callback in the
data slot and never ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:15:07 -04:00
wmantly 208762f0d1 Unify the front-end UI shell across the theta42 apps
views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now
byte-identical across sso-manager-node, proxy and jump-host. Everything
per-app moved into utils/ui.js, exposed to every render as `ui` via
app.locals (nav items + their group gates, footer repo/docs/ToS links,
favicon, profile/logout targets, update-banner on/off + label).

Client framework changes:
- One gating model everywhere: app-base.js reveals .group-required-<cn>
  for each of the current user user/me groups. sso-manager-node sends LDAP
  DNs in memberOf, the OIDC clients send CNs in groups; both normalise to
  CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so
  proxy nav-admin items are now group-required-admin.
- user/me is fetched once per page load and cached (app.auth.loadUser);
  nav, forceLogin and group-required elements all read that one promise.
- isLoggedIn is dual-mode (Promise + node-style callback), so the async
  and callback call styles both work from one shared top.ejs.
- forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects
  to /login?redirect=<path>, and still enforces required groups.
- logOut only clears the session; the caller decides where to go next.
- post/put/delete are dual-mode Promise/callback, which also removes the
  undefined `callback2` reference that threw on a non-function callback.

Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps.

sso-manager-node specifics:
- val.js adopts the shared superset (adds the target/hostname rules and
  the password policy, and fixes the let-shadowed `message` that stopped
  custom rule messages from reaching validateMessage).
- GET /api/user/me now also reports isAdmin (membership in app_sso_admin).
- public/js/app.js: $.isFunction -> typeof (removed in jQuery 4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:57:34 -04:00
wmantly b076498219 Merge pull request #103 from theta42/release/v1.4.0
Release 1.4.0
2026-07-25 16:41:49 -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 82da47cef7 Merge pull request #102 from theta42/docs/jump-host-xref
docs: cross-link the SSH jump host as a directory consumer
2026-07-23 16:23:54 -04:00
wmantly 39779f51dc docs: cross-link the SSH jump host as a directory consumer
- directory.md: new "Consumers of the directory" section explaining how
  the jump host reads the inventory (groups x host resources) to route
  SSH, and pointing at directory_spec.md §9 for planned consumers
- index.md: mention the jump host under Directory & Inventory and in
  Related projects

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:18:31 -04:00
wmantly d9a3cb6044 Merge pull request #101 from theta42/release/v1.3.2
Release 1.3.2 — fix OAuth client API client_id serialization (bootstrap-breaking)
2026-07-23 16:09:37 -04:00
wmantly 0ee6825a01 fix: OAuth client API returned client_id: undefined; unknown id 500'd
The ORM Model.toJSON() serializes only schema fields, so the mapped
client_id/scopes/redirect_uris/... that OAuthClient.get() attaches to
the wrapped Resource were stripped from GET /api/oauth/client[/:id]
responses. client_id came back undefined; the theta-env bootstrap then
POSTed /api/oauth/client/undefined/rotate and got a 500, aborting stack
bring-up whenever proxy-secrets.js lacked a usable secret.

- OAuthClient.get() now emits an explicit public toJSON (client_id, name,
  slug, scopes, redirect_uris, allowed_groups, token_lifetime, is_valid),
  deliberately omitting client_secret_hash so it can't leak over the API.
- OAuthClient.get() null-guards Resource.get() (which returns null, not
  throws) and returns a clean 404 for an unknown/undefined id instead of
  crashing on r.kind.
- Regression tests: list/get expose client_id + hide the secret hash, the
  list-then-rotate bootstrap path, and unknown-id -> 4xx not 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:06:55 -04:00
wmantly 14b6ed5ae0 Merge pull request #97 from theta42/spec/directory-consumers
Directory spec: readiness review of the five planned consumers
2026-07-23 03:37:00 -04:00
wmantly fd98854628 spec: readiness review of the five planned directory consumers
Records what each planned consumer of the directory data needs — end-user
catalog + access requests, SSH jump host, firewall rule generation/drift
testing, local DNS/mDNS, and host access control — what the current
model/API already covers, and the concrete gaps.

Verdict: the graph model is sufficient for all five. Gaps are one new
model (AccessRequest), service-token auth for the read API, documented
metadata conventions (portMappings, sshPort, dnsNames, icon, ...), and
cheap change detection (updated_on/etag). Also flags a real issue found
while auditing: /api/discovery/resources exposes full metadata —
including OAuth client_secret_hash — to any authenticated user; a
metadata privacy projection tops the work list.

Also updates the spec's stale status line (it still said "no code yet").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:33:19 -04:00
wmantly 3babf18fe4 Merge pull request #96 from theta42/release/v1.3.1
Release 1.3.1
2026-07-23 03:29:01 -04:00
wmantly d78f1dfabf Release 1.3.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:24:23 -04:00
wmantly c3c206b830 Merge pull request #95 from theta42/docs/directory-doc
Surface the Directory documentation — register in-app and link
2026-07-23 03:20:05 -04:00
wmantly 0a21dce0d7 docs: surface the Directory doc — register in-app, link from UI and site
docs/directory.md existed but was orphaned: not in the /docs registry,
not linked anywhere. Now:

- registered as /docs/directory ("Directory & Inventory")
- help icon on the Directory page header links to it (same pattern as
  users/groups/profile pages)
- linked from the docs site index feature list
- extended with the shared slug conventions (site_<name>, host_<hostname>),
  the automatic registration story (theta-env stack seeding, ldap-client
  Linux host enrollment), and the admin + read-only API surface (the
  read-only graph routes live at /api/discovery, not /api/directory).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:17:50 -04:00
wmantly 5940880d9b Merge pull request #94 from theta42/docs/ldap-not-legacy
docs: direct LDAP binds are first-class, not "legacy"
2026-07-23 02:57:20 -04:00
wmantly 17fcf2fed0 docs: direct LDAP binds are first-class, not "legacy"
Linux hosts are a primary consumer of the directory — PAM/SSSD login,
LDAP-backed sudo rules (sudoRole), and SSH public keys (openssh-lpk) —
which is exactly what the custom schemas exist for. Describe LDAPS /
StartTLS consumers as "Linux hosts and LDAP-native apps" instead of
"legacy apps" across README, DEPLOYMENT, docs, and the Dockerfile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:55:11 -04:00
wmantly ec76054e41 Merge pull request #93 from theta42/release/v1.3.0
Release 1.3.0
2026-07-23 02:25:27 -04:00
wmantly 5c0fc4f016 Release 1.3.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:22:46 -04:00
wmantly 43dae2a3eb Merge branch 'simpleworkjs' (superseded by tested ORM-port fixes on this branch)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:21:50 -04:00
wmantly 20c0a48199 ui: remove mobile phone field from user form
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly 12da7140c2 test: dockerized test suite (openldap + redis + test-runner)
docker-compose.test.yml spins up the all-in-one OpenLDAP image, a
standalone Redis, and a test-runner that seeds the test user and runs
jest against them. globalSetup honors REDIS_URL; tests/setup.js
initializes the ORM and flushes test Redis keys before the run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly dfd5f46095 feat: OAuth client management API at /api/oauth/client
CRUD + secret rotation for OAuth clients (app_sso_oauth_admin group),
backed by the Resource model. Normalizes form-style string inputs
(newline-separated redirect_uris/allowed_groups, space-separated
scopes, bracketed token_lifetime fields).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly 5dcc75195c fix: complete ORM port — token/oauth-client API mismatches, use published orm 0.2.8
- Use published @simpleworkjs/orm ^0.2.8 (fixes redis adapter write path)
  and model-redis ^1.6.0 instead of a local file: link that broke docker
  npm ci with a misleading "no lockfile" error.
- OtpToken.issue/verify: replace nonexistent find()/listDetail() with
  list({where}).
- routes/auth.js: ImpersonationToken.listDetail() -> list({where}).
- routes/token.js: drop listDetail() call; 404 on missing token instead
  of returning {results: null} with 200 (orm get() returns null, does
  not throw like model-redis Table.get did).
- OAuthClient: Resource has no is_valid column, so every client read as
  disabled and all /oauth/authorize requests 400'd — validity now lives
  in metadata (absent = valid). Also generate a unique slug on create
  (Resource.slug is required+unique) and use Resource.get() for lookup.
- User.login: 401 cleanly when neither uid nor username is supplied.
- models/index.js: log ORM init and surface init failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:49 -04:00
wmantly 12a99b550c Bump @simpleworkjs/orm to 0.2.8 to fix redis bugs 2026-07-22 23:24:41 -04:00
wmantly 4ce5a5f492 Monkey-patch RedisAdapter 2026-07-22 22:41:57 -04:00
wmantly c84141b2f3 Restore package-lock.json 2026-07-22 22:37:51 -04:00
wmantly f76d93d840 Fix User.login credentials check and flush Redis before tests 2026-07-22 22:37:45 -04:00
wmantly e910a492ba Fix Token API compatibility 2026-07-22 22:30:26 -04:00
wmantly 1ef868e23c Fix ORM API usages in Token subclasses 2026-07-22 22:26:26 -04:00
wmantly e11e39c23a fix: initialize ORM in tests 2026-07-22 22:17:23 -04:00
wmantly b91089ad4c fix: use published @simpleworkjs/orm instead of local path 2026-07-22 22:11:11 -04:00
wmantly 95ae50a924 fix: generate package-lock.json with node 20 for CI 2026-07-22 22:06:31 -04:00
wmantly 0076784fae fix: sync package-lock.json version 2026-07-22 22:03:53 -04:00
wmantly c4d7a1a8e9 feat: actionable metrics, LDAP log parsing, UI updates 2026-07-22 21:58:06 -04:00
wmantly a100f755ce Merge pull request #91 from theta42/release/v1.1.18
Release v1.1.18: fix Sites page crash, refresh screenshots
2026-07-21 02:22:24 -04:00
wmantly 81ad538e50 Release 1.1.18: fix Sites page crash, refresh screenshots
The Sites & Replication page (added in the prior multi-master LDAP
release) 500'd on every load: views/sites.ejs included nonexistent
partials 'header'/'footer' instead of this app's actual 'top'/'bottom'.
Fixed to match every other view.

Also refreshed all README screenshots against the current UI and added
a new Sites & Replication screenshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 02:03:55 -04:00
wmantly 6ce36b4a14 Merge pull request #90 from theta42/feature/multi-master-ldap-location
feat: N-Way Multi-Master LDAP & User Location
2026-07-21 00:14:22 -04:00
wmantly cda76d3889 docs: Add Why and When for replication and update README features 2026-07-21 00:10:25 -04:00
wmantly 2c11226793 feat: Add documentation and Sites status dashboard page 2026-07-21 00:06:59 -04:00
wmantly 80d88b083c feat: N-Way Multi-Master LDAP replication and Location property 2026-07-20 23:56:13 -04:00
wmantly b4fa824609 feat: configurable LDAPS hostname (ldapsHost/ldapsPort) and extensive docs (#89)
Add conf.ldap.ldapsHost / conf.ldap.ldapsPort so the /integrations page
can advertise an internal-only LDAPS hostname separate from the public
OAuth issuer. This avoids forcing admins to port-forward 636 publicly.

- routes/index.js derives LDAPS URL from ldapsHost/ldapsPort with issuer fallback
- integrations.ejs adds a contextual help panel explaining TLS hostname
  validation, the public-issuer default, and recommended internal-DNS /
  Docker-internal alternatives
- conf/base.js, secrets.js.example, DEPLOYMENT.md, docs/configuration.md,
  and docs/ldap.md document and expose the new options
- Add tests/integrations.test.js for default and custom ldapsHost behavior
- Bump version to 1.1.17

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-19 01:13:43 -04:00
wmantly 5a8030fd7d chore(release): public-release readiness and security fixes for 1.1.16
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-18 23:14:38 -04:00
wmantly cf80c966eb security: swap sanitizer to xss and harden logging
- Replace isomorphic-dompurify with xss to avoid ESM-only transitive
  dependencies (jsdom/htmlparser2) that break the existing Jest test suite.
- Sanitize rendered docs and Terms-of-Service HTML via xss() in routes/docs.js
  and routes/index.js.
- Remove full-object new-user logging from models/user_ldap.js and reduce
  login-path error output to error.name/error.message only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 23:03:33 -04:00
wmantly 07819a6254 security: sanitize markdown output and reduce PII logging
- Add isomorphic-dompurify to sanitize rendered docs HTML and Terms of Service
- Remove addLdapUser full-object logging that included password hashes
- Log only error name/message on auth/login failures instead of full LDAP error objects

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:56:35 -04:00
wmantly 1b3e842006 ci: set app_oauth__jwtSecret for test runs
routes/oauth.js now validates jwtSecret at module load time, so CI must
provide a non-placeholder value for the test runner.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:16:30 -04:00
wmantly efe3e514b0 chore(release): public-release readiness and security fixes for 1.1.16
Security:
- Escape user-supplied values in LDAP filters and DNs (group_ldap.js, user_ldap.js)
- Replace Math.random() token/UUID/OTP generation with crypto.randomUUID / crypto.randomInt
- Refuse startup when oauth.jwtSecret is missing or placeholder

Fixes:
- Correct from-address template rendering in email.js

Packaging:
- Remove private flag and bump version to 1.1.16

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:08:11 -04:00
wmantly 37f2ece172 Merge pull request #87 from theta42/release-1.1.15
Bump version to 1.1.15
2026-07-18 01:20:13 -04:00
wmantly b77704089b Bump version to 1.1.15; update CHANGELOG
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:18:05 -04:00
wmantly 114d8c86ca Merge pull request #86 from theta42/install-script-rework
Rewrite install.sh as a git-clone installer, add a one-line install
2026-07-18 01:17:18 -04:00
wmantly 3e87ad86ab Rewrite install.sh as a git-clone installer, add a one-line install
Replaces the old flag-driven, copy-based installer with an idempotent
git-clone-and-symlink installer matching theta42/proxy's ops/install.sh
pattern, so `wget -O - .../install.sh | sudo bash` works the same way
for both apps:

- Installs to /opt/theta42/sso-manager (was /opt/sso-manager, and the
  repo had to already be checked out locally -- now it clones itself).
- First run only: bootstraps OpenLDAP (modules, overlays, schema,
  directory tree, SSO groups -- ops/ldap-setup.sh) with a generated
  admin password + JWT secret, and seeds /etc/sso-manager/secrets.js
  (was /opt/sso-manager/conf/secrets.js, hand-filled from CLI flags).
  Later runs never touch LDAP or the secrets file again.
- ops/systemd/sso-manager.service now points at the new install path
  and sets CONF_SECRETS=/etc/sso-manager/secrets.js (requires
  @simpleworkjs/conf >= 1.2.0, already the pinned version) instead of
  the app needing a config file inside the repo checkout.
- Prints the version it's updating from/to (or "Already up to date")
  on every run, instead of updating silently.

Two real bugs found and fixed while testing this end-to-end in a clean
container:
- The debconf `slapd/domain` value was computed as
  `${LDAP_BASE_DN#dc=}` ("example,dc=com" for "dc=example,dc=com")
  instead of a proper dotted domain -- slapd's postinst hangs
  indefinitely on a malformed domain instead of failing cleanly.
  Fixed to derive it the same way the secrets file already did
  ("example.com").
- ops/ldap-setup.sh's ppolicy-overlay checks used an LDAP substring
  filter, `(olcOverlay=*ppolicy*)`, against an attribute that doesn't
  support substring matching -- it silently matched nothing even when
  the overlay was correctly configured (stored as "{0}ppolicy"),
  so the final verification always reported failure and `set -e`
  aborted the installer after LDAP was set up but before the app was.
  Fixed to filter on `(objectClass=olcOverlayConfig)` and let the
  existing DN-based grep narrow it down, matching the pattern already
  used by every other check in that script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:13:51 -04:00
wmantly c5a2c0a71d Merge pull request #85 from theta42/release-1.1.14
Bump version to 1.1.14
2026-07-17 23:46:43 -04:00
wmantly fe23d231be Bump version to 1.1.14; update CHANGELOG
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:44:34 -04:00
wmantly 3a612dbed7 Merge pull request #84 from theta42/bump-conf-jqrepeat
Bump @simpleworkjs/conf to 1.2.0, jq-repeat to 2.2.0
2026-07-17 23:41:19 -04:00
wmantly f154bb8db0 Bump @simpleworkjs/conf to 1.2.0, jq-repeat to 2.2.0
conf 1.2.0 adds CONF_SECRETS, an env var to point at the secrets file
directly -- use it in the Docker entrypoint instead of symlinking the
mounted file into /app/conf/secrets.js, so the app no longer needs
write access to its own conf/ directory to pick up mounted secrets.
jq-repeat 2.2.0 is a compatible feature release (sort(), replace(),
faster leading-edge update() timing); no call-site changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:38:56 -04:00
wmantly 9fc5abda2a Merge pull request #83 from theta42/fix-changelog-corruption
Fix CHANGELOG.md corruption (v1.1.10 merged into v1.1.11)
2026-07-17 22:22:21 -04:00
wmantly a6ee985de4 Fix CHANGELOG.md: restore separate heading for v1.1.10
A repeated Edit-tool bump had overwritten the previous top version
heading instead of inserting a new one above it, silently merging
v1.1.10's release notes into v1.1.11's section with the v1.1.10
heading missing entirely. The underlying content was still present,
just missing its own "## [1.1.10]" header -- restored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 22:20:02 -04:00
wmantly 47a9f6c3ec Merge pull request #82 from theta42/fix-doc-link-slugs
Resolve doc cross-links by real filename as a fallback
2026-07-17 22:06:08 -04:00
wmantly f6552cb741 Resolve doc cross-links by real filename as a fallback
The new concept docs (and their "See also" reciprocal links) reference
each other by real filename -- "concepts-accounts.html" -- which is the
correct, working URL on the Jekyll/GitHub Pages build (a page's URL there
IS its filename stem), but doesn't match this viewer's own short slugs
(DOCS keys, e.g. "accounts" -> /docs/accounts), so fixDocLinks() left
those links unrewritten and 404ing in-app.

Rather than rewrite the docs to two different link forms depending on
target, resolve by filename as a fallback when the slug lookup misses --
one link written in a doc now works correctly on both targets.

Bumps to v1.1.13.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 22:03:52 -04:00
wmantly 4e7e29b35f Merge pull request #81 from theta42/concept-docs
Add plain-language concept docs; fix docs viewer rendering; link API tokens
2026-07-17 21:51:40 -04:00
wmantly 4e5a2aa4f9 Add plain-language concept docs; fix docs viewer rendering; link API tokens
- New docs/concepts-{accounts,oauth-apps,api-tokens}.md -- plain-language
  guides aimed at less technical readers, each linking onward to the
  existing schema/protocol-level doc for anyone who wants that detail.
  Card help links (Users, Groups, OAuth cards, My groups, Members of
  <uid>'s group) now point here instead of straight at the technical
  docs; the LDAP-protocol-wiring cards (raw connection details for
  connecting a 3rd-party app) stay pointed at the technical ldap.md,
  since that's genuinely the right depth for that task.
- The "New API Token" card had no help link at all -- added, pointing to
  the new API Tokens doc.
- Fixed the in-app docs viewer rendering every docs/*.md page with a
  garbled heading + stray <hr> at the top: Jekyll front matter (meant
  only for the GitHub Pages build) was never stripped before being
  handed to the markdown renderer. Also fixed: cross-doc links
  (ldap.html, index.html, etc.) never resolved in-app, since this
  viewer serves docs at /docs/<slug> with no .html suffix -- rewritten
  to the correct in-app URL, same idea as the existing image-path fix.

Bumps to v1.1.12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 21:49:27 -04:00
wmantly 51784f2f27 Merge pull request #80 from theta42/help-icon-relocate
Move help links from the global header onto each relevant card
2026-07-17 19:56:29 -04:00
wmantly 4c59b1fabb Move help links from the global header onto each relevant card
The single header-wide help icon (added last release) pointed at a
per-page doc guess, but a page can have several cards covering different
topics (e.g. Integrations has both OAuth and LDAP cards). Removed it and
added a small help icon directly to each card that has real corresponding
doc content, linking straight to that doc -- Invite User/Add new
user/User List/Service Accounts (users.ejs), group cards (groups.ejs),
OAuth Apps + LDAP connection cards (integrations.ejs), My groups/Members
of <uid>'s group/New API Token (profile.ejs).

Bumps to v1.1.11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:54:19 -04:00
wmantly 099638057e Merge pull request #79 from theta42/docs-help-search
Add header help icon and in-app docs search
2026-07-17 19:24:33 -04:00
wmantly 077c41844d Add header help icon and in-app docs search
- A ? icon in the top-right header deep-links to the doc most relevant to
  the current page (client-side path mapping, same pattern already used
  for top-nav active-link highlighting -- no server-side "current section"
  local exists to key off of instead). Falls back to the docs index.
- GET /docs/search does a plain line-substring search over the existing
  allowlisted doc set. No new dependency, stays usable with no internet
  access.

Bumps to v1.1.10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:22:27 -04:00
wmantly 96adf60cf7 Merge pull request #78 from theta42/personal-group-members
Add personal Unix group member management
2026-07-17 11:31:11 -04:00
wmantly 82f703f560 Add personal Unix group member management
Every account gets a personal posixGroup at creation (its primary GID
holder) but there was no way to manage its memberUid list -- add
add/remove endpoints and a profile-page UI (admin-only), reusing the
userSelect widget already built for the manager field.

Bumps to v1.1.9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:28:41 -04:00
wmantly 65b107d8ff Merge pull request #77 from theta42/fix-account-editing-bugs
Fix account-editing bugs; add editable group membership from profile
2026-07-17 11:03:05 -04:00
wmantly 5d7c0bd594 Fix account-editing bugs from real-world feedback, add editable group membership
- Edit form's Mobile Phone field was effectively required (stray validate
  attribute) -- removed.
- Service account profiles always showed the literal filler name "Service
  Account" -- hidden now, since it's not meaningful. Required computing
  isServiceAccount in User.get(), not just listDetail().
- Fresh service accounts could look uncategorized (missing from the
  Service Accounts tab, wrong isServiceAccount) for up to 5 minutes after
  creation, due to a cache-staleness race in the create route -- the user
  gets cached via User.get() before the route marks it as a service
  account. Cleared and re-fetched after marking.
- memberOf came back as a bare string instead of a one-element array for
  users in exactly one group, causing client-side permission checks to
  iterate character-by-character and incorrectly deny access -- normalized
  alongside the existing manager normalization.
- Added editable group membership on the profile page ("My groups"),
  admin-only, using the existing per-group member endpoints.

Bumps to v1.1.8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:00:50 -04:00
wmantly 3ad817767a Merge pull request #76 from theta42/accounts-manager-rework
Unify service accounts, add manager field, editable homeDirectory/loginShell
2026-07-17 00:35:52 -04:00
wmantly cdc5d1528c Unify service accounts to one kind, add manager field, make homeDirectory/loginShell editable
Removes the LDAP bind-only service account type in favor of a single
Unix/POSIX account model, surfaced in a new Users > Service Accounts tab.
Adds a multi-valued `manager` field to every account (defaults to the
creator, editable, and grants edit rights on the accounts a person manages
without needing app_sso_admin). homeDirectory and loginShell are now
editable from the profile edit form.

Bumps to v1.1.7.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 00:32:19 -04:00
wmantly 5fc65d6fb3 Merge pull request #75 from theta42/bump-1.1.6
Bump version to 1.1.6
2026-07-16 20:24:43 -04:00
wmantly ea65a85aa9 Bump version to 1.1.6; update CHANGELOG 2026-07-16 20:22:42 -04:00
wmantly f8cf68b85f Merge pull request #74 from theta42/redesign-docs-site
Redesign docs site: match the app's own look, add SEO, mobile-ready
2026-07-16 19:54:43 -04:00
wmantly cedef0ed09 Redesign docs site: match the app's own look, add SEO, mobile-ready
The GitHub Pages site used the generic jekyll-theme-cayman theme --
purple gradient hero, no site nav, no per-page SEO. Replaced with a
custom layout that mirrors the actual app UI: dark fixed navbar with
the theta42 logo, Bootstrap 5 + Font Awesome (same stack the app
uses), content in a card, dark footer matching bottom.ejs
(copyright, MIT license, GitHub, Changelog links).

- New cross-page nav (Home/Deployment/Configuration/OAuth/LDAP/
  Changelog) -- there was previously no way to get from one docs
  page to another except a single "Back to Home" link per page.
- SEO: jekyll-seo-tag + jekyll-sitemap (both GitHub-Pages-supported
  plugins, no custom build needed) -- real per-page meta description,
  Open Graph/Twitter card tags, canonical URLs, JSON-LD, sitemap.xml,
  and a robots.txt referencing it. Added a real description to every
  page's front matter (none existed before).
- Mobile: Bootstrap's responsive grid + collapsible navbar; the
  screenshot pairs in index.md (inline width="49%" for a two-up
  desktop layout) now stack to full-width below 576px instead of
  squeezing illegibly small.

Verified with a real Jekyll build (jekyll/jekyll Docker image, no
Ruby available locally) + Playwright: desktop and mobile (375px)
screenshots of the home and deployment pages, mobile nav toggle
open/close, active-link highlighting per page, zero console/page
errors, and confirmed real SEO output (meta description, OG/Twitter
tags, canonical, JSON-LD, sitemap.xml, robots.txt) via curl against
the served site.
2026-07-16 19:52:26 -04:00
wmantly 0e31320964 Merge pull request #73 from theta42/bump-1.1.5
Bump version to 1.1.5
2026-07-16 18:37:29 -04:00
wmantly f12ce8c600 Bump version to 1.1.5; update CHANGELOG 2026-07-16 18:35:27 -04:00
wmantly b358e3b0b0 Merge pull request #72 from theta42/jq-repeat-2.1.0
Update jq-repeat to 2.1.0; fix editProfile update->slideDown race
2026-07-16 18:29:58 -04:00
wmantly 88387f3117 Update jq-repeat to 2.1.0; fix editProfile update->slideDown race
jq-repeat 2.1.0 (release notes: https://github.com/wmantly/jq-repeat/releases/tag/v2.1.0)
brings real fixes (throttled-update race conditions, sorted-list
reverse() leaking elements, nested-scope isolation) and a few
behavior changes. Audited every usage in this repo against the
changelog before upgrading:

- push()/unshift() now return the new array length -- every call
  site in this repo is a bare statement, none consume the return
  value. No risk.
- __setPut/__setTake, jr-order-reverse, nested jq-repeat templates:
  not used anywhere in this repo (unlike proxy's companion PR, which
  needed the __setPut/__setTake fix).

Real risk found and fixed: update() is now trailing-edge throttled
(~50ms) even on the first call, not just rapid subsequent ones.
profile.ejs's editUser()/editUserSeccess() call $.scope.editProfile
.update()/renderProfile() (which itself calls update()) and
immediately slideDown() the same element -- with the old synchronous
behavior the form was already populated by then; with throttling it
could briefly show stale/empty data. Deferred both slideUp/slideDown
pairs by 60ms (past the throttle window), per the library's own
migration guidance. Verified live (real bundled image + Playwright,
logged in as admin): the edit form's fields show real data, not
empty/stale, when checked right as the slide-open completes.
2026-07-16 18:27:58 -04:00
wmantly e2b4ffabb7 Merge pull request #71 from theta42/bump-1.1.4
Bump version to 1.1.4
2026-07-16 17:46:26 -04:00
wmantly a466128c21 Bump version to 1.1.4; update CHANGELOG 2026-07-16 17:44:21 -04:00
wmantly b03c0af09d Merge pull request #70 from theta42/white-label
White-label: title/logo now driven by conf
2026-07-16 17:33:20 -04:00
wmantly be41597502 Fix routes/oauth.js's separate pageLocals object missing conf.logo
routes/oauth.js has its own pageLocals object (distinct from
routes/index.js's values and routes/docs.js's own copy) used by
oauth_authorize.ejs/oauth_logout.ejs -- missed in the white-label
change since a grep alias in this environment silently treats this
particular file as binary and skips it. Caught by CI (oauth.test.js),
not local testing. Added logo: conf.logo to match the other two
copies of this locals object.
2026-07-16 17:31:00 -04:00
wmantly 21f2cda2ee White-label: title/logo now driven by conf (closes #6)
conf.name was already plumbed into routes/index.js's values object,
but never actually rendered anywhere -- <title>, the navbar brand,
and the favicon were all still hardcoded "SSO - Theta 42"/"SSO
Manager". Now render <%- name %>/<%- logo %> in top.ejs; new
conf.logo key (default: the existing theta42.svg) drives the navbar
image and favicon.

Also fixes a pre-existing broken favicon: top.ejs referenced
/static/favicon.svg, which was never actually served from public/ --
only public/img/theta42.svg existed. The favicon now uses that same
file via conf.logo instead of a nonexistent path.

Footer copyright/logo/GitHub links are left as-is (open-source
attribution, not deployment branding).
2026-07-16 17:26:01 -04:00
wmantly 976c3439fc Merge pull request #69 from theta42/add-ci-and-fix-ppolicy
Add CI (Jest against the real bundled image); fix ppolicy pwdLockout default
2026-07-16 17:00:02 -04:00
wmantly 81e36c9928 CI: use a real Redis service container, not the bundled image's
The bundled Dockerfile.openldap image's own redis-server binds to
loopback only inside its container (no --bind override), so
Docker's -p 6379:6379 forward from the runner could never actually
reach it -- confirmed by the first real CI run failing with
"Socket closed unexpectedly" the instant the test process tried to
connect. Worked when tested locally only by accident: my override
env vars didn't actually take effect (model-redis's setUpTable only
reads a nested redisConf key, not flat host/port), so the app fell
back to createClient({})'s localhost:6379 default and happened to
hit my own pre-existing local Redis instead of the container's.

Fix: a dedicated redis:7-alpine GHA service container, which binds
correctly and is reachable at localhost:6379 -- matching that same
default, no env override needed.
2026-07-16 16:57:57 -04:00
wmantly bc5bca2e28 Add CI (Jest against the real bundled image); fix ppolicy pwdLockout default
- New GitHub Actions workflow: builds the real Dockerfile.openldap
  image, starts it, seeds the LDAP fixtures the test suite expects
  (uid 'test' + 'wmantly', matching the existing "wmantly is always
  present in the test LDAP" assumption in several test files), then
  runs the full Jest suite against it on Node 18/20/22. This repo
  previously had unit tests but no automated workflow running them.
- Found while building this: the bundled default ppolicy entry
  (docker-entrypoint.sh + ops/ldap-setup.sh) sets pwdLockout: FALSE,
  which is backwards -- it silently makes the admin "deactivate user"
  action a no-op for auto-lockout-after-failed-attempts (a related
  but distinct ppolicy feature from pwdAccountLockedTime). Fixed to
  TRUE in both places; ldap-setup.sh also gets a drift-correction
  path so an existing deployment can pick up the fix by re-running it.
- Separately, deactivating a user still doesn't block their LDAP bind
  in the bundled image even with this fix -- filed as #68, since it's
  a deeper OpenLDAP ppolicy overlay question unrelated to the CI/test
  setup here. tests/user_admin.test.js now soft-skips that specific
  assertion (with a console warning pointing at #68) instead of
  failing, so this known environment gap doesn't block CI.
2026-07-16 16:54:05 -04:00
wmantly 4c4fc34dcf Merge pull request #67 from theta42/bump-1.1.3
Bump version to 1.1.3
2026-07-16 16:04:36 -04:00
wmantly 3e67c23008 Bump version to 1.1.3; update CHANGELOG 2026-07-16 16:04:08 -04:00
wmantly b657c4034b Merge pull request #66 from theta42/add-changelog
Add CHANGELOG.md, serve it in-app at /docs/changelog
2026-07-16 16:01:11 -04:00
wmantly f323a45fef Add CHANGELOG.md, serve it in-app at /docs/changelog (closes theta42/theta-env#43)
GitHub Releases already carried real changelog notes per tag, but
those require internet access to view -- exactly what the /docs
route exists to avoid. CHANGELOG.md is a committed, Keep-a-Changelog
style file (backfilled from the v1.1.0/v1.1.1/v1.1.2 release notes),
linked from README and served at /docs/changelog alongside the rest
of the project's docs.
2026-07-16 16:00:59 -04:00
157 changed files with 18476 additions and 4055 deletions
+4 -3
View File
@@ -14,14 +14,15 @@
# context.
!README.md
!tos.md
!CHANGELOG.md
!DEPLOYMENT.md
!API.md
!directory_spec.md
!docs/**/*.md
# Tests
nodejs/tests/
nodejs/*.test.js
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
# nodejs/tests/
# nodejs/*.test.js
# Host dependency tree — let the image run a clean `npm ci`. Also avoids
# copying platform-wrong native modules (e.g. bcrypt built for the host OS).
+158
View File
@@ -0,0 +1,158 @@
name: Pull Request Tests
# Run tests on pull requests to master and when pushing to PRs
on:
pull_request:
branches:
- master
push:
branches-ignore:
- master
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
# A dedicated, GHA-managed Redis -- NOT the bundled image's own Redis,
# which only binds to loopback *inside* its container (redis-server's
# default with no --bind override), so Docker's -p port-forward can
# never actually reach it from the runner. This service container binds
# correctly and is reachable at localhost:6379, matching model-redis's
# createClient({}) default when conf.redis has no explicit host/port.
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
# The test suite (require('../app')) also needs a real LDAP directory
# seeded with the schema/groups the app expects -- the bundled image
# already does exactly that (docker-entrypoint.sh), so build and run
# it here rather than reimplementing LDAP setup as a separate
# CI-only script. Its own bundled Redis is unused (see services above).
- name: Build LDAP test image
run: docker build -f Dockerfile.openldap -t sso-test:latest .
- name: Start LDAP test container
run: |
mkdir -p /tmp/sso-test-config
cp secrets.js.example /tmp/sso-test-config/sso-secrets.js
docker run -d --name sso-test \
-p 389:389 -p 3001:3001 \
-v /tmp/sso-test-config:/config:ro \
sso-test:latest
for i in $(seq 1 30); do
status=$(docker inspect --format='{{.State.Health.Status}}' sso-test 2>/dev/null || echo starting)
[ "$status" = "healthy" ] && break
sleep 2
done
docker inspect --format='{{.State.Health.Status}}' sso-test
# tests/setup.js logs in as uid 'test'; several suites (group/otp/
# impersonate/cache) assume a second, non-admin user 'wmantly' already
# exists (documented in those test files: "wmantly is always present
# in the test LDAP"). Seed both here so CI matches that assumption.
- name: Seed test fixtures
run: |
HASH_TEST=$(timeout 20 docker exec sso-test node -e "console.log(require('/app/models/user_ldap.js').hashPasswordSSHA512('MyTestPassword!2'))" | tail -1)
HASH_WMANTLY=$(timeout 20 docker exec sso-test node -e "console.log(require('/app/models/user_ldap.js').hashPasswordSSHA512('WmantlyPass!2'))" | tail -1)
cat > /tmp/seed.ldif <<EOF
dn: cn=test,ou=people,dc=example,dc=com
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: theta42Person
cn: test
sn: Test
mail: test@example.com
uid: test
uidNumber: 10000
gidNumber: 10000
homeDirectory: /home/test
userPassword: ${HASH_TEST}
dateOfBirth: 2000-01-01
dn: cn=app_sso_admin,ou=groups,dc=example,dc=com
changetype: modify
add: member
member: cn=test,ou=people,dc=example,dc=com
dn: cn=app_sso_oauth_admin,ou=groups,dc=example,dc=com
changetype: modify
add: member
member: cn=test,ou=people,dc=example,dc=com
dn: cn=app_sso_invite,ou=groups,dc=example,dc=com
changetype: modify
add: member
member: cn=test,ou=people,dc=example,dc=com
dn: cn=wmantly,ou=people,dc=example,dc=com
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: theta42Person
cn: wmantly
sn: Mantly
mail: wmantly@example.com
uid: wmantly
uidNumber: 10001
gidNumber: 10001
homeDirectory: /home/wmantly
userPassword: ${HASH_WMANTLY}
dateOfBirth: 2000-01-01
EOF
docker cp /tmp/seed.ldif sso-test:/tmp/seed.ldif
docker exec sso-test ldapmodify -x -D "cn=admin,dc=example,dc=com" -w 'your-ldap-password' -a -f /tmp/seed.ldif
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: nodejs/package-lock.json
- name: Install dependencies
working-directory: ./nodejs
run: npm ci
- name: Run tests
working-directory: ./nodejs
env:
NODE_ENV: test
# conf/base.js's ldap.* defaults already match secrets.js.example's
# directory layout (dc=example,dc=com) -- only the admin password
# (normally supplied via a gitignored secrets.js) needs setting.
app_ldap__bindPassword: your-ldap-password
# routes/oauth.js now refuses to start without a real jwtSecret.
# This is a non-secret test value; the container under test uses
# secrets.js.example's jwtSecret independently.
app_oauth__jwtSecret: ci-test-jwt-secret-do-not-use-in-production
run: npm test
test-summary:
name: Test Summary
runs-on: ubuntu-latest
needs: test
if: always()
steps:
- name: Check test results
run: |
if [ "${{ needs.test.result }}" != "success" ]; then
echo "Tests failed. PR cannot be merged."
exit 1
fi
echo "All tests passed successfully!"
+5
View File
@@ -86,6 +86,11 @@ ops/cookbooks/vendor
secrets.json
secrets.js
# Per-deployment secret files (real LDAP/SMTP/jwtSecret + generated OAuth
# creds). theta-env bind-mounts ./config and generates/fills these at setup;
# they must never be committed. The empty *.example templates ARE tracked.
config/*-secrets.js
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
docs/_site
+206
View File
@@ -1,5 +1,10 @@
# SSO Manager API Documentation
> Looking for a plainer explanation of what API tokens are and when you'd
> want one, instead of a full endpoint reference? See
> [API Tokens](/docs/api-tokens) (in-app) or
> [concepts-api-tokens.md](docs/concepts-api-tokens.md) (repo).
## Overview
API documentation for the SSO Manager Node application. Provides endpoints for authentication, user management, group management, token management, notifications, and OAuth 2.0 / OpenID Connect.
@@ -698,6 +703,10 @@ The authenticated user is automatically set as the group owner.
{ "results": true, "message": "Added user uid to group group." }
```
Returns `409` if the user is already a member — common in practice, since
`groupOfNames` requires at least one member and so seeds whoever created the
group into it.
---
### Remove User from Group
@@ -711,6 +720,66 @@ The authenticated user is automatically set as the group owner.
---
### Nest a Group Inside Another
**`PUT /api/group/:group/nested/:child`** — `app_sso_admin` or group owner
Makes `:child` a member of `:group`, so everyone in `:child` is a member of
`:group` at any depth.
**Response:**
```json
{ "results": { "cn": "group", "member": ["..."] }, "message": "Nested child inside group." }
```
**Errors:**
| Status | When |
|--------|------|
| `400` | `:group` and `:child` are the same group |
| `409` | already nested, or the nesting would create a loop (`:child` already contains `:group`, directly or transitively) |
---
### Un-nest a Group
**`DELETE /api/group/:group/nested/:child`** — `app_sso_admin` or group owner
**Response:**
```json
{ "results": { "cn": "group", "member": ["..."] }, "message": "Removed child from group." }
```
**Errors:**
| Status | When |
|--------|------|
| `409` | `:child` is the only member — `groupOfNames` requires at least one |
---
### Effective Membership
**`GET /api/group/:group/effective`** — Any authenticated user
Who a group actually grants. `direct` is users listed on the group itself
(never groups); `nestedGroups` is what is nested into it; `effective` is every
user reachable through the whole chain.
**Response:**
```json
{
"results": {
"cn": "app_gitea_access",
"direct": ["cn=alice,ou=people,dc=example,dc=com"],
"nestedGroups": [{ "cn": "developers", "dn": "cn=developers,ou=groups,dc=example,dc=com" }],
"effective": ["cn=alice,ou=people,dc=example,dc=com", "cn=bob,ou=people,dc=example,dc=com"]
}
}
```
---
### Delete Group
**`DELETE /api/group/:group`** — `app_sso_admin` or group owner
@@ -1130,6 +1199,143 @@ Configurable per-client via `token_lifetime`. Global defaults (in seconds):
---
## Plugin Endpoints
Base path: `/api/plugins`
All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `app_super_admin` membership. Secret field values are always returned masked (`********`); they are stored in OpenBao at `secret/plugins/<instance-id>/conf`, never in the database row. See [Plugins](docs/plugins.html).
### List Plugin Types
**`GET /api/plugins/types`**
Returns the installed plugin types and their `configSchema` (used to build the create-instance form).
**Response:**
```json
{
"results": [
{
"type": "proxmox",
"category": "discovery",
"name": "Proxmox VE",
"description": "Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.",
"configSchema": [
{ "key": "url", "label": "API URL", "type": "url", "required": true },
{ "key": "tokenId", "label": "Token ID", "type": "text", "required": true },
{ "key": "tokenSecret", "label": "Token Secret", "type": "password", "required": true, "secret": true }
]
}
]
}
```
---
### List Plugin Instances
**`GET /api/plugins/`**
**Response:** `{ "results": [ { "id", "pluginType", "category", "name", "slug", "enabled", "cron", "config", "secrets": {…masked…}, "lastRunAt", "lastStatus", "lastError" } ] }`
---
### Get One Instance
**`GET /api/plugins/:id`** — same shape as a list entry.
---
### Create Instance
**`POST /api/plugins/`**
`config` is a flat object of **all** field values (secret and non-secret); the server splits it — non-secret fields go to the DB row, secret fields to OpenBao. Creating an enabled instance schedules it and kicks one immediate run. `slug` is the discovery source name (lowercase letters/digits/_/-, max 64, unique).
**Request:**
```json
{
"pluginType": "proxmox",
"name": "Proxmox — Home Lab",
"slug": "proxmox-homelab",
"cron": "0 * * * *",
"config": { "url": "https://pve:8006", "tokenId": "u@pam!t", "tokenSecret": "secret-value" }
}
```
Errors: `400` if the plugin type is unknown, the slug is malformed/duplicated, or a required field is missing; `400` with an OpenBao hint if writing the secret fails (re-run `./setup.sh` with theta-suite ≥ v1.30.1).
---
### Update Instance
**`PUT /api/plugins/:id`** — update `name`, `cron`, `enabled`, and non-secret `config`. Secret fields are changed via `PUT /:id/secrets`. Re-schedules if `cron` or `enabled` changed.
---
### Update Secrets
**`PUT /api/plugins/:id/secrets`** — body is a flat object of secret field values. Blank/`********` values are ignored (kept as-is).
---
### Test Instance
**`POST /api/plugins/:id/test`** — runs the plugin's `validate`. Returns `{ "ok": true }` or `400 { "ok": false, "error": "..." }`.
---
### Load / Unload / Run Now
- **`POST /api/plugins/:id/load`** — enable + schedule + run now.
- **`POST /api/plugins/:id/unload`** — unschedule + disable.
- **`POST /api/plugins/:id/run`** — enqueue one immediate run (regardless of enabled).
---
### Last Run Status
**`GET /api/plugins/:id/runs`** → `{ "results": { "lastRunAt", "lastStatus", "lastError" } }` (`lastStatus` is `ok` | `error` | `running`).
---
### Delete Instance
**`DELETE /api/plugins/:id`** — unschedules, removes the OpenBao secret namespace, and deletes the row.
## Configuration Endpoints
Base path: `/api/conf`
All endpoints require authentication and `app_sso_admin` membership. Runtime configuration (SMTP, discovery, OAuth) is stored in OpenBao at `secret/sso-manager/conf` and overlaid onto the live app config; changes take effect immediately and persist across restarts. Secret fields (`smtp.pass`, `oauth.jwtSecret`) are **always returned masked** (`********`); submit a blank or `********` value to keep the current stored secret, or a new non-blank value to replace it.
### Get Configuration
**`GET /api/conf`** — returns the editable config groups (`smtp`, `discovery`, `oauth`) with secret fields masked to `********`.
**Response:**
```json
{
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
"discovery": { },
"oauth": { "issuer": "https://sso.example.com", "jwtSecret": "********", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
}
```
### Save Configuration
**`POST /api/conf`** — deep-merges the submitted groups into `secret/sso-manager/conf` (per-key shallow merge of nested objects) and re-applies them to the live config. A blank or `********` value for `smtp.pass` or `oauth.jwtSecret` preserves the stored secret.
**Request:**
```json
{
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
"oauth": { "issuer": "https://sso.example.com", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
}
```
**Response:** `{ "success": true }`
## Error Responses
All endpoints return errors in this format:
+650
View File
@@ -0,0 +1,650 @@
## v1.19.0
- Added WebSocket endpoint for theta-agent C2
# v1.18.0
- feat: Add messaging plugins, Docker discovery, fix reconciliation
# Changelog
All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.17.2] - 2026-08-01
Post-deploy fixes from testing the v1.31.0 stack, plus the SMS (VoIP.ms) and
Terms-of-Service configuration the `/conf` page was missing. Seven issues:
### Fixed
- **Plugin slug is now auto-generated** from the instance name — the New Plugin
modal no longer asks for a Slug (it derived a stable, unique handle from the
name, appending `-2`, `-3`, … on collision). The generated slug still shows in
the table and the Edit (read-only) modal. `POST /api/plugins` `slug` is now
optional; an explicit slug is still accepted and validated. (`routes/api_plugins.js`,
`views/plugins.ejs`)
- **Plugin schedule is a dropdown**, not a raw cron box: Hourly / Daily /
Weekly, plus **Custom** which reveals the raw 5-field cron input. Stored value
is still a cron string, so the server is unchanged. (`views/plugins.ejs`)
- **`/vault` secrets list no longer 403s.** Root cause: the per-user, per-app,
and admin OpenBao policies granted `list` only on `secret/metadata/.../*`
(nested paths), never on the directory path itself — so listing a directory's
*contents* (which checks `list` on the directory, e.g. `secret/metadata/users/<uid>`
or the mount root `secret/metadata`) was denied. `vault_broker.js`'s
`userPolicyHcl`/`appPolicyHcl` now also grant `list` on the bare directory
path, and `ensurePolicy` now always re-writes the policy (idempotent) so
already-created `user-<uid>` policies pick up the new grant on the next
vault-page visit. The matching `sso-admin` mount-root grant ships in
theta-suite v1.31.1 (`setup.sh`), where `ensure_policy` is likewise made
always-write so re-running `./setup.sh` applies policy edits.
- **`/profile` no longer shows literal `{{…}}` tags.** Three template fragments
sat outside the `jq-repeat="user"` scope, so they rendered raw: the card
header `Profile: {{user.uid}}`, the `Members of {{user.uid}}'s Group` tab
label, and the Admin Actions block's `{{#isActive}}`/`{{#isInactive}}`
buttons. The header/label are now populated by JS (the `Members` label
already had a setter pointing at a missing id); the Admin Actions block is
moved inside the scope so `{{uid}}`/`{{#isActive}}`/`{{#isInactive}}` render
and the correct Activate/Deactivate button shows. (`views/profile.ejs`)
- **Editing a plugin now persists.** The Edit modal had been prefilled with the
masked secret values and rendered them as fields, but `PUT /:id` only saves
non-secret config — so an edited secret was silently dropped. The Edit modal
now shows **non-secret fields only** (secrets have their own Edit-Secrets
modal), removing the confusion. (`views/plugins.ejs`)
- **nmap plugin: "NMAP not found at command location: nmap"** — the `nmap`
binary was not installed in the app image. `Dockerfile.openldap` now `apk
add`s `nmap` in the runtime stage, and `plugins/discovery/nmap.js` translates
the opaque node-nmap spawn-missing error into an actionable `lastError`.
### Added
- **SMS (VoIP.ms) configuration on `/conf`.** The existing VoIP.ms SMS sender
(`models/sms.js`, used for 2FA OTP delivery) was configurable only via env /
config files. It now has an SMS card on `/conf` (API username, DID, API
password), saved to OpenBao at `secret/sso-manager/conf` under `voipms`, with
the API password masked (`********`) and leave-blank-to-keep — mirroring the
SMTP card exactly. `models/sms.js` reads `conf.voipms.*` at call time, so a
saved change takes effect live without a restart. (`routes/api_conf.js`,
`views/conf.ejs`)
- **Terms of Service editor moved to `/conf`** from the admin Overview
dashboard, where it never belonged. The same `app.tos.get`/`update` flow,
the "require all users to re-accept" checkbox, and the `app_sso_admin` gate
(matching `routes/tos.js`'s PUT gate) are preserved. The Overview page keeps
stats, notifications, and metrics. (`views/conf.ejs`, `views/overview.ejs`)
### Notes
- The `/vault` 403 fix is split across two repos: the sso-side per-user/app
policy grants and `ensurePolicy`-always-write ship here; the `sso-admin`
mount-root grant and `ensure_policy`-always-write ship in theta-suite v1.31.1.
Re-running `./setup.sh` after upgrading applies the sso-admin grant; per-user
policies self-heal on the next vault-page visit.
## [1.17.1] - 2026-08-01
Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to
match the plugin-secrets discipline: the SMTP password and OAuth JWT secret are
no longer returned in cleartext by `GET /api/conf` or round-tripped through the
form. They remain saved in OpenBao at `secret/sso-manager/conf` at runtime
(unchanged) — only how they're surfaced to the admin changes.
### Changed
- **`GET /api/conf`** now masks `smtp.pass` and `oauth.jwtSecret` to `********`
(was: returned in cleartext). Non-secret fields (host, port, user, from,
secure, issuer, token lifetimes) are returned as before.
- **`POST /api/conf`** now treats a blank or `********` secret-field submission
as "keep the current stored value" — so an admin editing the From address or
token lifetimes no longer has to re-enter (or leak) the SMTP password / JWT
secret. Only a genuinely new, non-blank value overwrites. The preserved values
are re-applied to live `conf` immediately, as before.
- **`/conf` page** (`views/conf.ejs`): the Password and JWT Secret fields carry
a "leave unchanged to keep the current value stored in OpenBao" hint; the page
copy notes secret fields are masked. No JSON-textarea editing is involved —
SMTP is and remains configured through structured form fields.
### Notes
- SMTP (and OAuth) config was **already** saved to OpenBao at runtime before
this release (via `POST /api/conf``baoConf.set('sso-manager/conf')`, and
overlaid back at boot by `bao-conf.init`). This release closes the
cleartext-exposure gap; it does not move the storage path.
- No theta-suite policy change required — `secret/sso-manager/conf` was already
granted to the `sso-broker` policy.
## [1.17.0] - 2026-08-01
A real **plugin system**: the half-built discovery plugins (statically
configured in `sso-secrets.js`, only toggleable for cron/enabled) become
**configurable, loadable/unloadable plugin instances** you manage from a
dedicated **Plugins** page and the `/api/plugins` API, with multiple runtime
copies of each type and per-instance secrets stored in OpenBao.
### Added
- **Plugin instances** — a new `PluginInstance` ORM model
(`nodejs/models/plugin_instance.js`, Sequelize) is the registry of
configured, scheduled plugin copies. Each has a `pluginType`, a unique
`slug` (the discovery source name), a cron schedule, an `enabled` flag
(load/unload), non-secret `config` (JSON), and last-run bookkeeping. Multiple
instances of the same type are supported.
- **Plugin registry** (`nodejs/services/plugin_registry.js`) — generalizes the
one-shot discovery-plugin scan in `scheduler.js`. Plugin types are modules
under `nodejs/plugins/<category>/<type>.js` exporting a manifest
(`type`, `category`, `name`, `description`, `configSchema`, `validate`,
`run`/`discover`). Exposes `getTypes`, `getModule`, `splitConfig` (secret vs
non-secret), `mask`, and required-field helpers for the UI/API.
- **Per-instance secrets in OpenBao** (`nodejs/utils/plugin_secrets.js`) —
`configSchema` fields flagged `secret:true` (e.g. a Proxmox `tokenSecret`,
UniFi `password`) are stored at `secret/plugins/<instance-id>/conf`, never in
the DB. The UI only ever sees masked (`********`) values. Plugins run
in-process (BullMQ workers), so they need no OpenBao token of their own — the
SSO reads/writes via the `sso-broker` token. **Requires theta-suite ≥ v1.30.1**
for the `sso-broker` policy grant on `secret/plugins/*`; the API fails-soft
with a clear error if absent.
- **`/api/plugins` API** (`nodejs/routes/api_plugins.js`, replaces the old
`routes/plugins.js`) — `GET /types`, list/get/create/update/update-secrets/
test/load/unload/run/delete/runs. Admin-only
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`).
- **Plugins page** (`/plugins`, `views/plugins.ejs`) + nav entry — instance
table with New/Edit/Edit-Secrets/Test/Run-now/Load/Unload/Delete, config forms
rendered from each type's `configSchema`.
- **`validate`** ("Test" button) on the built-in Proxmox/UniFi/Nmap plugins.
### Changed
- `services/scheduler.js` now schedules from the `PluginInstance` table instead
of static `conf.discovery.plugins` + a Redis override hash. Each instance owns
a stable BullMQ JobScheduler id (`plugin:<instanceId>`) so load/unload
upsert/remove one schedule without disturbing the rest. Discovery plugins
reconcile results under the instance's `slug`.
- The three discovery plugins (`plugins/discovery/{proxmox,unifi,nmap}.js`)
gained manifests (`configSchema`, `validate`, `run` alias). `nmap`'s
`targetRange` is non-secret; Proxmox `tokenSecret` and UniFi `password` are
secret.
- The `/plugins` page route renders the page instead of redirecting to
`/directory`; the **Agents & Scheduler** tab was removed from `/directory`
(plugins are now managed on the Plugins page). The `/docs/agents` link is
aliased to `/docs/plugins`.
- `docs/plugins.md`, `docs/vault.md`, `docs/_config.yml` (nav), and `API.md`
(Plugin Endpoints section) document the new system.
### Legacy migration
On first boot of v1.17.0, if the `PluginInstance` table is empty **and**
`conf.discovery.plugins` has entries, one instance per configured type is seeded
automatically (secret fields copied into OpenBao). After that the static
config is ignored — manage plugins from the UI/API. Idempotent (guarded by the
empty-table check).
### Prerequisite
**theta-suite ≥ v1.30.1** — re-run `./setup.sh` after upgrading so the
`sso-broker` OpenBao policy is granted `secret/plugins/*`. Without it, storing
plugin secrets fails with a clear error.
## [1.16.1] - 2026-08-01
Fix: the Configuration (`/conf`) and Vault (`/vault`) pages returned **401** for
a logged-in admin. Both view routes did server-side auth using `req.user`, but
this app's auth-token is a header set by client-side JS (localStorage), not a
cookie — so `req.user` is undefined on a plain browser navigation.
`permission.byGroup(undefined, …)` throws status 401, and the `middleware.auth`
gate on `/vault` threw `Auth.errors.login()` (401) for the same reason.
Both routes now render the shell unconditionally (like `/users`, `/directory`,
`/overview`) and gate client-side: `conf.ejs` already called
`app.auth.forceLogin(['admin','app_sso_admin'])`; `vault.ejs` now derives
`isAdmin` + the personal namespace from `/api/user/me` after `forceLogin()`
instead of server-rendering them. The `/api/conf` and `/api/vault` endpoints
still enforce `app_sso_admin` + the OpenBao scope server-side, so protection is
unchanged — only the view-route gating moved client-side where the session
actually lives. Also removed a dead duplicate `/conf` route definition.
## [1.16.0] - 2026-08-01
OpenBao becomes the central secrets store for the theta42 stack, and the SSO
Manager becomes its broker. This is the SSO's half of the move: it loads its
own secrets from OpenBao, mints scoped tokens for users and external apps,
and exposes a fixed, role-scoped personal-secrets UI.
### Changed
- **Secrets now load from OpenBao at boot** via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/sso-manager/conf` over the file-loaded config
(replacing the old `utils/conf_manager.js`, which did a shallow-per-key
merge). `bin/www` runs `bao-conf.init()` after `models.initORM()` and
before `listen`. Fail-soft: if OpenBao is unreachable, boot continues from
`CONF_SECRETS`. The SSO authenticates with a scoped `VAULT_TOKEN` (policy
`sso-broker`), never the root token. The admin **Configuration** UI
(`/api/conf`) now writes through `bao-conf.set('sso-manager', …)`.
- **`/api/vault` proxy reworked** — the old endpoint was an ungated
pass-through that never injected an `X-Vault-Token` (so the UI was both
ungated *and* broken). It is now `middleware.auth``scopeGuard` → a
token-injecting proxy. `scopeGuard` resolves a per-user (`user-<uid>`) or
per-admin (`sso-admin`) token via the new `utils/vault_broker.js`
(Redis-cached, minted through the `sso-broker` token role) and enforces a
path prefix as a second layer on top of the OpenBao policy. The client
`auth-token` is stripped; only the server-minted token reaches OpenBao.
- **Vault UI reworked and renamed** (`views/vaultwarden.ejs`
`views/vault.ejs`; the `/vault` route is now `middleware.auth`-gated).
Non-admin users see only their `secret/users/<uid>/` namespace; admins get
free-form path entry across `secret/` plus an **Apps** tab to mint scoped
tokens for external apps (`secret/apps/<name>/*`, shown once with copy +
`curl` convention).
- Bumped package version to track the release tag.
### Removed
- `nodejs/utils/conf_manager.js` (replaced by `@simpleworkjs/bao-conf`).
- `nodejs/views/vaultwarden.ejs` (renamed `vault.ejs`).
### Security
- **Committed-secrets remediation.** `config/sso-secrets.js` (LDAP bind
password, SMTP, `oauth.jwtSecret`) and `nodejs/test_plugins.js` (a
hardcoded Proxmox root API token and a UniFi password) were tracked on
master. They are now untracked + gitignored (`config/*-secrets.js`), and
`test_plugins.js` is deleted; `config/proxy-secrets.js.example` added as a
placeholder template. **The secrets remain in git history — rotation at
the providers is the real remediation and is the operator's to perform.**
OpenBao is now the authoritative store; the local files are seed artifacts
only.
> Note: releases v1.12.0v1.15.2 were tagged from merge PRs without
> corresponding `CHANGELOG.md` entries or GitHub releases; this entry
> resumes the changelog at v1.16.0.
## [1.11.0] - 2026-07-31
Closes the end-user half of the directory. The admin side could describe the lab; the user side could not tell anyone what they had or how to use it, and several of the paths meant to do so were silently returning nothing.
### Fixed
- **`GET /api/discovery/me` returned only `isPublic` resources for every human caller.** It resolved the caller's groups from `req.user.groups`, which does not exist — `req.user` is a `User` carrying `memberOf` (DNs). The empty list failed open into "no group-granted resources", so "My Services" on the profile page and the portal's service list were blank for everyone. The same bug made `isDirectoryAdmin()` false for real directory admins, silently downgrading them to the public metadata projection. Group CNs now come from `utils/user_groups.js`.
- **The portal's "Discover More Services" was dead for every non-admin.** It called the admin-gated `directory-admin/resources` and swallowed the 403 into an empty array — so the one discovery feature never rendered for the audience it existed for. It now calls `/api/discovery/resources`.
- **Services reported no address.** `/api/discovery/me` had reimplemented `Resource.getMyAccess` without its parent-walking address resolution, leaving clients to guess `address || ip`, which is exactly wrong for a service that is reached at its host's IP. Both paths now share `Resource.withResolvedAddress()`.
- **Approving access for a user already in the target group threw a 500** and left the request stuck pending. `groupOfNames` requires at least one member, so a resource's auto-created groups are seeded with the creator's DN; the grant is now idempotent.
- **`DELETE /api/directory-admin/resources/:id` deleted the resource before its edges and group links.** With no transaction, a failure mid-way orphaned rows pointing at a nonexistent id — invisible in the UI and poisonous to `getGraph()`. Dependents go first now.
- `PUT /api/directory-admin/resources/:id` validated the body only after loading the row, and carried a dead if/else whose branches were identical.
- `/api/directory-admin/audit-logs` shelled out to `tail` three times via `execSync`; replaced with a bounded async file read (no `child_process`, at most the trailing 256 KB).
### Added
- **End-user catalog at `/`**, and the first ungated nav item — previously every nav entry was admin-only and a normal user had no signposted destination. Search/filter, per-kind icons, and a **how to reach it** block per card: the URL for a service, the SSH invocation for a host (using the jump-host `uid_-_slug@host` grammar when `directory.jumpHost` is configured).
- **Self-service access requests** — `AccessRequest` model plus `/api/access-requests` (create, list own, list decidable, approve, deny, withdraw). Approving performs the LDAP group add, so LDAP remains the access-control truth. Requests target a resource's `member`-level group, never its `_admin` one. Replaces the "coming soon" stub.
- **Admin access visibility**: an Access column on the directory table showing member and group counts (and flagging links whose LDAP group has been deleted), plus a "what can this user reach" lookup — the reverse question, which previously had no UI at all. Backed by `GET /api/directory-admin/access-summary` and `/user-access/:uid`.
- `conf.directory``jumpHost` and `defaultSshPort`, the connection conventions the catalog renders.
- `tests/access_request.test.js` — the request → approve → grant-is-real loop end to end, including the regression guard for the `user.groups` bug.
### Added — nested groups
- **A group can now contain another group.** `groupOfNames.member` accepts any DN, so nesting needs no new schema; what it needs is *resolution*, which no released OpenLDAP performs — `memberOf` and `(member=X)` both return direct membership only. Two halves:
- **Server-side**: the all-in-one image now builds slapd from a pinned OpenLDAP master commit (`350e9eb3`) to get the **`nestgroup`** overlay (ITS#10161), enabled with `member-filter memberof-filter memberof-values`. `member-values` is deliberately omitted — it expands `member` when reading a group, which destroys the distinction between "listed here" and "reachable via nesting" and is not recoverable afterwards. `pw-sha2` is built from contrib in the same stage; without it every existing `{SSHA512}` password would be unverifiable.
- **Client-side**: `Group.list(dn)` computes the transitive closure itself (cycle-detected, depth-capped) when the server can't, selected by `conf.ldap.nestedGroupsServerSide` — which `docker-entrypoint.sh` derives from probing for `nestgroup.so` rather than hardcoding. Both paths are covered by the full suite.
- `PUT`/`DELETE /api/group/:group/nested/:child` and `GET /api/group/:group/effective`, plus a **Nested** tab on each group card. Cycles are refused (409) rather than silently depth-truncated.
- **`app_super_admin` is now seeded** (it never was) and nested into `app_sso_admin` / `app_sso_invite` / `app_sso_oauth_admin`, so the privilege is real LDAP membership visible to SSSD and sudo — not just a special case in `utils/permission.js`. Not nested into `app_sso_service_account`, which marks non-person accounts rather than granting anything.
- Creating a directory resource nests `app_super_admin → <slug>_admin` and `<slug>_admin → <slug>_access`. Both previously required adding every super admin to every new group by hand, so they drifted.
- `ldap_group_nesting_level = 5` in ldap-client's SSSD template, for hosts pointed at a server without `nestgroup`. Against the bundled slapd the existing `memberof=` access filter is already transitive, so SSH login inherits nesting for free.
### Fixed
- `PUT /api/group/:group/:uid` returned a bare **500** when the user was already a member — common, since `groupOfNames` requires a member and so seeds whoever created the group. Now a 409 that says so.
- Un-nesting (or removing) the last member of a group returned a 500 `ObjectClassViolationError`; now a 409 explaining that a group must keep at least one member.
- `GET /api/user/me` derived `isAdmin` from `memberOf`, which is only transitive when `nestgroup` is present. Against a stock server an admin holding their group via nesting would get `isAdmin=false` and lose the entire admin UI while still passing every server-side permission check.
- `utils/permission.js`'s `byGroup` checked `group.member.includes(user.dn)` per group, seeing only direct membership.
- `/api/directory-admin/access-summary` counted `member` values; it now counts the transitive closure, which matters precisely because `app_super_admin` is nested into every resource's admin group.
- Broken `api.html` link in the published docs (`API.md` lives at the repo root, so Jekyll never rendered one); pointed at the source, and added an API entry to the docs nav.
### Changed
- `@simpleworkjs/directory-schema` bumped to `^1.1.0`, which declares the ten metadata keys the admin form has always written but the schema never listed (`port`, `externalPort`, `isExternalReachable`, `os`, `gitRepo`, `isCurrentSite` as public; `vmid`, `macAddress`, `installPath`, `systemdService` as admin-only). Undeclared keys are dropped for non-admin callers, which blanked the portal's `OS:` field, hid every service's port from users, and left machine tokens unable to read the port mapping the firewall consumer exists to render.
- Resource metadata now includes `icon` and `tagline`, collected on the admin form (with a live icon preview) and rendered on the catalog cards.
## [1.10.0] - 2026-07-30
### Added
- **`app_super_admin` cross-app group**: members are full admins here regardless of `app_sso_admin` membership. Bypassed centrally in `utils/permission.js`'s `byGroup`, folded into `GET /api/user/me`'s `isAdmin` flag, and added to nav/`forceLogin` gates. The same group is now also recognized by proxy and jump-host, and by `ldap-client`'s SSSD access filter (SSH login on every host).
### Changed
- **Renamed the Executive page to Overview** (route, view, `/api/metrics/overview`, nav label, docs). `/executive` kept as a 301 redirect alongside the existing `/admin`, `/notifications`, `/dashboard` legacy redirects.
## [1.9.0] - 2026-07-28
### Added
- **Directory modal's Associated LDAP Groups tab now supports full membership management**: view, add, and remove members/owners of each associated group directly from the tab, reusing the same `PUT`/`DELETE group/:group/:uid` routes and member-mapping pattern already used on the Groups page.
- **`app.util.revealItem()`** (in the shared `app-base.js`, byte-identical across the 3 apps): scrolls a just-added/-edited element into view and flashes its background. Wired into the Directory table, the Groups tab's member list, and the Groups page's create-group flow.
### Changed
- **Groups page's search/sort bar is now sticky**, staying visible while scrolling through a long group list. Introduces `--sw-content-offset` (set in `top.ejs` alongside `#spa-shell`'s margin-top) so an in-page sticky element can offset itself below the fixed navbar/update-banner instead of being hidden behind them.
- **Directory table**: Kind/Name/Env/Host merged into a single "Resource" column.
- `@simpleworkjs/frontend` bumped to `^0.2.7`.
## [1.8.3] - 2026-07-28
### Changed
- **`profile.ejs`'s self-service API-token UI unified onto `app.modal`**, matching the pattern already shipped this round in `directory.ejs`, proxy, and jump-host: the static `#secretModal`/`#editModal` elements are retired in favor of the shared `app.modal` singleton, the always-visible inline create-form card becomes a "+ New Token" button + modal, and badge classes switch from `bg-*` to `text-bg-*`.
- Checkmark-flash copy feedback (silently broken by FontAwesome's `<i>``<svg>` replacement) replaced with toast-based `copyFieldValue`, matching proxy and jump-host.
## [1.8.2] - 2026-07-28
### Fixed
- **Creating a new OAuth integration didn't reliably show the "save this client secret now" reveal modal** — `saveResource()` called `app.modal.close()` immediately before conditionally showing the secret via `app.modal.open()`. `app.modal` is a singleton, and `close()` immediately followed by `open()` collides with Bootstrap's hide-transition guard. An intervening `await loadResources()` made this race unlikely to lose in practice, but not guaranteed to — found while fixing the same, guaranteed-to-lose bug in jump-host and proxy's API-token create flows.
## [1.8.1] - 2026-07-28
### Fixed
- **The resource modal's "Associated LDAP Groups" autocomplete went empty after the first Add/Edit** — `loadLdapGroups()`'s fetch-once cache guard (`if (ldapGroupsCache) return;`) also skipped repopulating the `<datalist>` on every call after the first, but the modal body (including that `<datalist>`) is rebuilt fresh and empty on every `app.modal.open()`. Now the fetch is still cached, but the datalist is always repopulated.
## [1.8.0] - 2026-07-28
### Added
- **Directory resource modal: General / Details / Associated LDAP Groups / Children tabs**, replacing one long form. The new Children tab lists a resource's existing children and lets you add another right from the modal.
- **Resource audit trail**: `created_by`/`created_on`/`updated_by`/`updated_on`, shown in the modal's new footer (mirrors the convention already used by proxy's `Host` and jump-host's `ApiToken`). Existing resources predating this change show "—" until next edited.
- **Linkable resource URLs**: `GET /directory/:slug` plus a client-side deep-link check make a resource's modal directly bookmarkable/shareable; the address bar updates to `/directory/{slug}` while its modal is open and reverts on close (including via the browser Back button).
- **Auto-created LDAP groups are now prefixed with their nearest ancestor Site's slug** (e.g. `site_local_myhost_access` instead of `myhost_access`), so groups for same-named hosts/services under different sites no longer collide or look identical. Resources with no Site ancestor keep the old unprefixed naming.
### Changed
- `@simpleworkjs/frontend` bumped to 0.2.6: `app.modal` gained the `tabs`/`footer`/`url` options (all opt-in, existing callers unaffected) plus `showTab`/`on`/`deepLinkSlug`/`formatAudit`/`footerButtons` helpers — the shared building blocks behind this release's modal work, reusable by future entity modals in any of the 3 apps.
### Fixed
- The Directory's Associated LDAP Groups / Relationships lists no longer risk silently dropping their contents on a second modal open (a `jq-repeat`/DOM-rebuild timing race, now rendered manually instead).
### Operational note
The new `Resource` audit fields require a schema migration on any existing deployment: `ALTER TABLE Resource ADD COLUMN created_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN created_on INTEGER; ALTER TABLE Resource ADD COLUMN updated_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN updated_on INTEGER;` (adjust types for non-sqlite dialects) — `@simpleworkjs/orm`'s `sync()` only creates missing tables, it never alters existing ones.
## [1.7.0] - 2026-07-28
### Fixed
- **`formAJAX`'s loading indicator showed literal HTML** ("&lt;div class=..."), not a spinner — it passed raw markup to `app.messages.action`, which HTML-escapes its message by design. Replaced with plain text.
- **`POST /api/user/` (create) and `PUT /api/user/password` had no `message` field** in their response, so the success notification rendered empty. Added messages matching every other route's convention.
- **The user landing on `/login` with a `?redirect=` had no explanation why** — happens whenever another app's "Log in with SSO" bounces an unauthenticated user through `/oauth/authorize`. Now shows a contextual banner explaining what's happening.
### Changed
- **Directory: tree view is now the only view** (the list/tree toggle is gone) — simpler, one code path.
- **Directory: clicking a resource's name opens its detail modal**, not just the pencil/edit icon.
Found via a fresh production install's feedback — see the [theta-env v1.13.0 release](https://github.com/theta42/theta-env/releases) for the full cross-repo summary.
## [1.6.3] - 2026-07-28
### Fixed
- **Group membership changes (`PUT`/`DELETE /api/group/:group/:uid`) didn't invalidate the User cache**, so `isServiceAccount` (and anything else derived from `memberOf`) could stay stale for up to 5 minutes after a change. This is what caused a real "lost user" report — the account had landed in `app_sso_service_account` (which `users.ejs`'s People tab filters out entirely) and looked exactly like data loss, though nothing was ever deleted.
### Added
- **A confirmation before adding anyone to `app_sso_service_account`** via the Groups page — that group's whole purpose is to hide an account from the People tab, and there was no guardrail against doing that to a real person by mistake (which is how the bug above happened). Every other group's add-member flow is unchanged.
## [1.6.2] - 2026-07-28
### Fixed
- **`DELETE /api/oauth/client/:id` 500'd** (`client.remove is not a function`) — `OAuthClient` wraps `@simpleworkjs/orm`'s `Resource` model, whose instance delete method is `.delete()`, not `.remove()`. The Directory Management UI was unaffected (its own delete routes already used `.delete()` correctly); only this legacy/raw API endpoint was broken. Found live against a real deployment's SSO API.
### Added
- **Regression tests**: PUT/DELETE on `/api/oauth/client/:id` now verify persistence with a follow-up GET rather than trusting the mutating response alone (this is what would have caught the bug above). A static check across all views/client-side scripts fails CI if any native `alert()`/`confirm()`/`prompt()` call appears — these block all further browser events on the page and were fully removed in 1.6.1.
## [1.6.1] - 2026-07-27
### Fixed
- **Removed every native `alert()`/`confirm()` call**, replacing them with `app.messages.action`/`confirm`/`toast`. Native `confirm()` blocks all further browser events on the page (discovered live, mid browser-verification of the 1.6.0 `app.messages`/`app.modal` adoption, on `directory.ejs`'s "Rotate Client Secret" — it froze the whole tab). Also deleted `app.user.remove`/`app.oauthClient.remove` in `public/js/app.js`, which had native `confirm()` guards and zero callers anywhere in the app.
## [1.6.0] - 2026-07-27
### Changed
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm`/`alert` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js`. Message content is now HTML-escaped (the vendored `alert()` this replaces had no escaping), and `app.messages.action` falls back to a page-wide toast when there's no inline `.actionMessage` target. `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched — they're app-specific (dual-mode callback/promise API, `auth-token` header injection) and not something the frontend package's generic `app.js` provides.
## [1.5.1] - 2026-07-27
### Fixed
- **`PUT /api/user/:uid` 500'd with `ObjectClassViolationError` (LDAP `0x41`) when setting `sshPublicKey`** on any account created before the `ldapPublicKey` auxiliary objectClass was added to new-user creation (e.g. the bootstrap `admin` account). `User.update`'s `sshPublicKey` handling and `User.addSSHkey` (`nodejs/models/user_ldap.js`) now add the `ldapPublicKey` objectClass first (ignoring `TypeOrValueExistsError` if already present), the same pattern already used for `dateOfBirth`/`theta42Person`.
- **OAuth Integration parent dropdown was blank.** `populateHostDropdown` in `nodejs/views/directory.ejs` only built options for `kind === 'host'` and `kind === 'service'` — there was no branch for `kind === 'oauth'`, so choosing "OAuth Integration" in the Directory's add-resource modal left the parent-Service picker empty except the placeholder. Added the missing branch.
## [1.5.0] - 2026-07-26
### Changed
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
### Fixed
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
### Fixed (sso-manager-node)
- `public/lib/js/val.js` shadowed `message` with `let` inside `validateField`, so a custom rule's return value never reached `validateMessage` and the caller always saw the generic length message. Resolved by adopting the shared validator, which also brings the `target`/`hostname` rules and the real password policy (>= 8 chars, and either 12+ or 3 of 4 character classes) to this app.
- `public/js/app.js` used `$.isFunction`, removed in jQuery 4.
### Added (sso-manager-node)
- `GET /api/user/me` now also reports `isAdmin` (membership in `app_sso_admin`), the single effective-rights flag the shared UI shell gates the update banner on. Group-level gating still reads `memberOf`.
### Verified
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
## [1.4.0] - 2026-07-25
### Security
- **The directory discovery API leaked OAuth `client_secret_hash` (and any secret-ish metadata key) to every authenticated caller.** `Resource` doesn't override `toJSON`, so the ORM serialized `metadata` wholesale — including the `client_secret_hash` stored on `kind:'oauth'` resources — across `GET /api/discovery/resources`, `/graph`, `/me`, `/resources/:slug`, and the directory-admin `GET /api/directory-admin/resources`. Every discovery read endpoint and the admin list now route through `projectResource`/`projectResources` from `@simpleworkjs/directory-schema`, which unconditionally strips secret keys (anything matching `/secret|password|privatekey/i`, including `client_secret_hash`) and, for non-directory-admins, reduces metadata to a public allowlist. Admins never receive `client_secret_hash` either.
### Fixed
- **Directory discovery envelope drift.** `routes/discovery.js` (the `autoRouter(Resource)` mounted live at `app.js:87`) returned **bare arrays**, not the `{ results: [...] }` envelope the directory contract specifies — so jump-host's `data.results || []` collapsed every per-group query to `[]` and no user could bridge. Discovery is now served by explicit `/resources`, `/resources/:slug`, `/graph`, `/me` handlers that all return the `{ results }` envelope. The dead `routes/api_discovery.js` (mounted at `app.js:112`, *after* the 404 catcher) and its mount were removed.
- `GET /api/discovery/resources?group=<cn>` now returns 200 with `{ results: [...] }` instead of 404 (the autoRouter's `search` supported `?group=`, but the route was effectively unreachable for jump-host's call pattern).
### Added
- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org:
- `@simpleworkjs/directory-schema` — the directory contract: the `kind` enum, `Resource`/`ResourceEdge`/`ResourceGroup` field defs, the `{ results }` envelope, the security projection (`projectResource`/`projectResources`/`isDirectoryAdmin`), and the discovery client. `models/resource.js` imports the field defs; the discovery + directory-admin routes use the projection.
- `@simpleworkjs/ldap``models/user_ldap.js` and `models/group_ldap.js` now take `escapeFilter`/`escapeDN` and `makeClient`/`withClient` from the shared package (via local wrappers that pass `conf`); sso keeps its rich `User.get`/`Group.get`/`User.login`/`User.addSSHkey` (posix/write-side stays app-local). sso's `makeClient` passes no `tlsOptions`, so cert validation is unchanged.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/index.js` use the shared helpers.
- New `tests/discovery.test.js` (jest + supertest, runs under the docker harness): locks in the `{ results }` envelope on `/resources`, `/graph`, `/me`, `/resources/:slug`, the `?group=` 200-regression, and the no-`client_secret_hash`/no-secret-key guarantee for every caller.
### Changed
- Dependency alignment: `ldapts` `^8.1.2``^8.1.8`. The new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps).
## [1.3.2] - 2026-07-23
### Fixed
- **OAuth client management API returned `client_id: undefined` on every GET.** The ORM's `Model.toJSON()` only serializes schema fields, so the mapped `client_id`/`scopes`/`redirect_uris`/… that `OAuthClient.get()` attaches to the wrapped Resource were stripped from `GET /api/oauth/client` and `GET /api/oauth/client/:id` responses. The theta-env bootstrap (which lists clients and rotates by the returned `client_id`) then called `/api/oauth/client/undefined/rotate` and got a 500, aborting stack bring-up when `proxy-secrets.js` had no usable secret. `OAuthClient.get()` now emits an explicit public JSON shape (and deliberately omits `client_secret_hash`, so the secret hash no longer leaks over the API).
- `OAuthClient.get()` no longer 500s on an unknown/`undefined` client id: `Resource.get()` returns `null` (it doesn't throw), which was dereferenced as `r.kind`. It now returns a clean 404.
## [1.3.1] - 2026-07-23
### Added
- The Directory documentation (`docs/directory.md`) is now surfaced: registered in-app at `/docs/directory` ("Directory & Inventory"), help-linked from the Directory page header, and linked from the docs-site index. Extended with the shared slug conventions (`site_<name>`, `host_<hostname>` — as used by ldap-client and the theta-env seed), the automatic-registration story (theta-env stack seeding, ldap-client Linux host enrollment), and the API surface (admin at `/api/directory-admin`, read-only graph at `/api/discovery`).
### Changed
- Direct LDAP binds are described as first-class, not "legacy", across README, DEPLOYMENT.md, docs, and the Dockerfile: Linux hosts are a primary consumer of the directory (PAM/SSSD login, LDAP-backed `sudo` via `sudoRole`, SSH public keys via openssh-lpk) — exactly what the custom schemas exist for.
## [1.3.0] - 2026-07-23
### Added
- **OAuth client management API** at `/api/oauth/client` (group `app_sso_oauth_admin`): list, create, update, delete, and rotate-secret for OAuth clients, backed by the Resource model. Accepts form-style string inputs (newline-separated `redirect_uris`/`allowed_groups`, space-separated `scopes`).
- **Dockerized test suite**: `docker-compose -f docker-compose.test.yml up --build` spins up OpenLDAP + Redis + a test-runner that seeds the test user and runs the full jest suite (174 tests) against them. `tests/globalSetup.js` honors `REDIS_URL`.
### Fixed
- Completed the model-redis → `@simpleworkjs/orm` port that shipped half-finished in 1.2.1:
- `OtpToken.issue`/`verify` called nonexistent `find()`/`listDetail()` — every OTP login 500'd.
- Impersonation create/revoke called nonexistent `ImpersonationToken.listDetail()` — both endpoints 500'd.
- `OAuthClient` read `is_valid` from the Resource model, which has no such column — every client evaluated as disabled and **all `/oauth/authorize` requests were rejected with 400**. Client validity now lives in `metadata` (absent = valid).
- `OAuthClient.add` didn't set the required-unique `Resource.slug`; clients now get a slug derived from the client name.
- `GET /api/token/:name/:token` returned `{results: null}` with 200 for unknown tokens (orm `get()` returns null instead of throwing); now 404s.
- `User.login` returns a clean 401 instead of crashing when neither `uid` nor `username` is supplied.
- Depend on published `@simpleworkjs/orm` ^0.2.8 and `model-redis` ^1.6.0 instead of a local `file:` link that broke `npm ci` in docker builds.
### Changed
- Removed the Mobile Phone field from the user create/edit form.
## [1.2.1] - 2026-07-22
### Added
- **Actionable Metrics**: New real-time metrics tracking for failed logins, top IPs, and service usage per user.
- **LDAP Monitor**: Background service to parse OpenLDAP binds over port 389 and track metrics for legacy apps.
- **UI Updates**: Executive dashboard now displays actionable metrics cards instead of raw logs. User profiles show individual service usage stats to admins.
- **Directory Management**: Integrated site/host/service abstractions into directory UI and allowed associating OAuth apps directly to services.
## [1.1.18] - 2026-07-21
### Added
- N-Way Multi-Master LDAP replication: `LDAP_SERVER_ID` + `LDAP_REPLICATION_HOSTS` configure `syncrepl` peers in the bundled OpenLDAP, and a new `/sites` page (nav: **Sites**) shows each configured peer's LDAP URL and live reachability.
- A `location` property on users, editable from the profile and user-edit forms.
### Fixed
- `/sites` (added above) 500'd on every load: `views/sites.ejs` included nonexistent partials `header`/`footer` instead of this app's actual `top`/`bottom`. Fixed to match every other view.
### Changed
- Refreshed all README screenshots (dashboard, users, groups, OAuth apps) against the current UI, and added a new Sites & Replication screenshot.
## [1.1.17] - 2026-07-18
### Added
- `conf.ldap.ldapsHost` and `conf.ldap.ldapsPort` config options (also settable via `app_ldap__ldapsHost` / `app_ldap__ldapsPort`). When `ldapsHost` is set, the `/integrations` page advertises that hostname for direct LDAPS binds instead of deriving it from the public OAuth issuer. This lets operators use an internal-only hostname (e.g. `ldap.internal.example.com` or `sso-manager` on the Docker network) and avoid port-forwarding 636 to the internet.
- A contextual help panel on `/integrations` → LDAP explaining why LDAPS needs a hostname (not an IP), why 636 should not be publicly forwarded, and the recommended internal-DNS / Docker-internal alternatives.
### Changed
- `routes/index.js` now computes the displayed LDAPS URL from `conf.ldap.ldapsHost`/`ldapsPort` with fallback to the OAuth issuer host for backward compatibility.
- `secrets.js.example`, `docs/configuration.md`, `docs/ldap.md`, and `DEPLOYMENT.md` document the new `ldapsHost`/`ldapsPort` options and recommended network layouts.
- Bumped version to `1.1.17` in `nodejs/package.json`.
## [1.1.16] - 2026-07-18
### Security
- Hardened LDAP filter and DN construction against injection. All user-supplied values interpolated into group filters (`models/group_ldap.js`) and RDN values used when adding users/groups (`models/user_ldap.js`) are now escaped before being sent to the LDAP server.
- Replaced `Math.random()`-based token generation in `models/token.js`, `models/oauth_code.js`, and `models/oauth_client.js` with `crypto.randomUUID()` for session tokens, OAuth codes, access/refresh tokens, and client IDs.
- Replaced `Math.random()`-based OTP generation in `OtpToken.issue()` with `crypto.randomInt()`.
- `routes/oauth.js` now refuses to start if `oauth.jwtSecret` is missing or still set to the placeholder value, instead of falling back to a hardcoded public string.
- Rendered docs and Terms-of-Service HTML in `routes/docs.js` and `routes/index.js` are now sanitized with `xss` to prevent stored XSS from malicious markdown.
- Removed a `console.log` that wrote new-user data (including password hashes) to the log in `models/user_ldap.js`; reduced login-path error logging to `error.name`/`error.message` only.
### Changed
- Public-release packaging: removed `"private": true` from `nodejs/package.json` and bumped version to `1.1.16`.
- CI workflow (`.github/workflows/pr-tests.yml`) now sets `app_oauth__jwtSecret` so the test suite can run against the new startup-time JWT validation.
### Fixed
- `models/email.js`: fixed a template bug where the rendered `from` address used `template.message` instead of `template.from`.
## [1.1.15] - 2026-07-18
### Changed
- Rewrote `install.sh` as an idempotent git-clone installer, replacing the old flag-driven, copy-based one — `wget -O - .../install.sh | sudo bash` now works the same way it does for theta42/proxy. Installs to `/opt/theta42/sso-manager` (was `/opt/sso-manager`). First run only: bootstraps OpenLDAP with a generated admin password + JWT secret and seeds `/etc/sso-manager/secrets.js` (was `/opt/sso-manager/conf/secrets.js`, hand-filled from CLI flags); later runs never touch LDAP or the secrets file again. `ops/systemd/sso-manager.service` sets `CONF_SECRETS=/etc/sso-manager/secrets.js` to match.
- `install.sh` now prints the version it's updating from/to (or "Already up to date") on every run.
### Fixed
- `install.sh` could hang indefinitely on a fresh host if a base package pulled in `tzdata` as a new dependency (no TTY for the interactive timezone prompt), or if the debconf `slapd/domain` value was malformed (a raw DN fragment instead of a dotted domain) — slapd's postinst hangs rather than failing cleanly on a bad domain. Both fixed.
- `ops/ldap-setup.sh`'s ppolicy-overlay checks used an LDAP substring filter against an attribute that doesn't support substring matching, so they always reported the overlay as unconfigured even when it was correctly set up (stored as `{0}ppolicy`) — the final verification step always failed as a result. Fixed to filter on `(objectClass=olcOverlayConfig)` instead, matching every other check in that script.
## [1.1.14] - 2026-07-17
### Changed
- Bumped `@simpleworkjs/conf` to 1.2.0 and `jq-repeat` to 2.2.0. The Docker entrypoint now sets the new `CONF_SECRETS` env var to point directly at a mounted `sso-secrets.js` instead of symlinking it into `/app/conf/secrets.js` — the app no longer needs write access to its own `conf/` directory to pick up mounted secrets.
## [1.1.13] - 2026-07-17
### Fixed
- The new concept docs' cross-links (`concepts-accounts.html` etc.) are the correct, working URL on the Jekyll/GitHub Pages build (where the page's URL is its filename stem) but didn't resolve in the in-app docs viewer, which serves docs at a separate short slug (`/docs/accounts`). The in-app renderer now also resolves a doc's real filename as a fallback, so one link written in a doc works on both targets.
## [1.1.12] - 2026-07-17
### Added
- Three new plain-language docs aimed at less technical readers, replacing the schema-level LDAP/OAuth/API docs as the target of most card help links: **Accounts, Groups & Managers**, **Connecting Apps (SSO)**, and **API Tokens**. Each links onward to the deeper technical reference for readers who want it; the technical docs link back the other way too. The personal-access-token card (previously missed) now links to its own doc.
### Fixed
- The in-app docs viewer rendered every `docs/*.md` page with a garbled heading and a stray horizontal rule at the top — Jekyll front matter (meant only for the GitHub Pages build) was never stripped before being handed to the markdown renderer. Also fixed: cross-doc links (`ldap.html`, `index.html`, etc.) never resolved in-app, since this viewer serves docs at `/docs/<slug>` with no `.html` suffix — they're now rewritten to the correct in-app URL, the same way image paths already were.
## [1.1.11] - 2026-07-17
### Changed
- Moved the help (❓) link out of the global header and onto each relevant card individually (Invite User, Add new user, User List, Service Accounts, group cards, OAuth/LDAP integration cards, My groups, Members of `<uid>`'s group, New API Token) — each now deep-links straight to the doc that actually covers it, instead of one generic header icon.
## [1.1.10] - 2026-07-17
### Added
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
- The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
## [1.1.9] - 2026-07-17
### Added
- Every account's personal Unix group (its primary GID holder) can now have supplementary members managed from the account's profile page ("Members of `<uid>`'s group", admin-only) — e.g. to share write access to files owned by that group. Uses the standard `memberUid` attribute (RFC 2307 `posixGroup`).
## [1.1.8] - 2026-07-17
### Added
- Group membership is now editable directly from a user's profile page ("My groups" -- add via a group-name picker, remove with a button per row), instead of only from each group's own card on the Groups page. Admin-only, using the existing per-group member add/remove endpoints.
### Fixed
- The Edit Profile form's Mobile Phone field had a stray `validate=":9"` making it effectively required (submission was blocked with "Please fix the form errors" if left blank) -- it was always meant to be optional, matching the "Add user" form. Removed.
- A service account's profile always showed `Name: Service Account` -- every service account has the same literal filler given/last name (a schema-satisfying placeholder, not meant to be shown), making them indistinguishable by name. The Name line is now hidden for service accounts.
- The Users page's Service Accounts tab, and a freshly-created service account's own profile, could appear empty/not-a-service-account for up to 5 minutes right after creation. Creating a user caches it via `User.get()` *before* the route handler marks it as a service account (group membership), so the cached copy had `isServiceAccount` stuck wrong until the cache TTL expired. Now cleared and re-fetched immediately after marking.
- A user belonging to exactly one LDAP group had their `memberOf` attribute returned as a bare string instead of a one-element array (ldapts's normal behavior for single-valued attributes) -- client-side permission checks (`for(let group of user.memberOf)`) would then iterate the DN character-by-character instead of once, causing pages gated on that group (e.g. Groups) to incorrectly show "You do not have permission to be here." Normalized `memberOf` to always be an array, same fix already applied to `manager`.
## [1.1.7] - 2026-07-17
### Changed
- **Service accounts unified to one kind.** Removed the LDAP bind-only service account type (the Integrations → LDAP "Service Accounts" card, and its `/api/service-account` routes) -- every service account is now a real Unix/POSIX account with a UID, created from the new **Users → Service Accounts** tab. Email and password are both optional for service accounts; a blank password means no `userPassword` is set at all (the account simply can't bind).
- **Added a `manager` field to every account.** Multi-valued (a list of usernames), defaults to whoever created the account (the admin who added it, or whoever sent the invite), and reassignable from the account's Edit form. Anyone listed as a manager can edit that account -- same fields an admin can (mobile, description, SSH key, date of birth, home directory, login shell, manager list) -- without needing `app_sso_admin`.
- `homeDirectory` and `loginShell` are now editable from the Edit Profile form (previously view-only).
## [1.1.6] - 2026-07-16
### Changed
- Redesigned the GitHub Pages docs site to match the app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic `jekyll-theme-cayman` theme, added a real cross-page nav, SEO (`jekyll-seo-tag` + `jekyll-sitemap`, per-page descriptions, OG/Twitter tags, sitemap.xml, robots.txt), and mobile-responsive layout.
## [1.1.5] - 2026-07-16
### Fixed
- Bumped `jq-repeat` 2.0.1 -> 2.1.0. `update()` is now trailing-edge throttled (~50ms) even on the first call; `profile.ejs`'s edit-profile flow updated a scope and immediately slid the same element into view, which could briefly show stale/empty data. Deferred the slide by 60ms.
## [1.1.4] - 2026-07-16
### Added
- **CI**: GitHub Actions now builds the real bundled image, seeds LDAP fixtures, and runs the full Jest suite on every PR (Node 18/20/22) -- this repo had unit tests but nothing ran them automatically until now.
- **White-label**: `<title>`, the navbar brand text, and the favicon were hardcoded "SSO - Theta 42"/"SSO Manager" despite `conf.name` already existing (it was never actually rendered). New `conf.logo` key added alongside it. Footer attribution is left as-is. Closes [#6](https://github.com/theta42/sso-manager-node/issues/6).
### Fixed
- The bundled default ppolicy entry set `pwdLockout: FALSE`, silently making the admin "deactivate user" action not actually block that user's login. Fixed to `TRUE`, with a drift-correction path in `ops/ldap-setup.sh` for already-deployed instances. A separate, deeper ppolicy-overlay issue remains open as [#68](https://github.com/theta42/sso-manager-node/issues/68).
- `top.ejs` referenced a `/static/favicon.svg` that didn't exist in `public/` (a pre-existing 404) -- now uses the existing logo file via `conf.logo`.
## [1.1.3] - 2026-07-16
### Added
- `CHANGELOG.md` (this file), backfilled from the release notes for every tag so far and served in-app at `/docs/changelog`. Closes [theta-env#43](https://github.com/theta42/theta-env/issues/43).
## [1.1.2] - 2026-07-16
### Fixed
- Removed a dead IE<9-only `html5shim` script tag pointing at a domain that no longer resolves.
### Added
- **In-app documentation**: `GET /docs` and `GET /docs/:slug` render this project's own README, DEPLOYMENT, API.md, `docs/{ldap,oauth,configuration}.md`, and `directory_spec.md` server-side — readable from the running app with no dependency on GitHub Pages, which requires internet access to view. Public, no auth, rate-limited.
## [1.1.1] - 2026-07-16
### Added
- **Terms of Service is now editable at runtime by admins.** `tos.md` used to be baked into the repo and read once at startup, requiring a code change and deploy to update. It's now a Redis-backed singleton, editable from a new "Terms of Service" card on the admin Dashboard, with the bundled `tos.md` used only as a one-time seed for new deployments. Admins can optionally require all users to re-accept the terms after a substantive edit. Closes [#39](https://github.com/theta42/sso-manager-node/issues/39). ([#62](https://github.com/theta42/sso-manager-node/pull/62))
## [1.1.0] - 2026-07-16
First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app update-check banner polls against going forward.
### Added
- Standalone backup script (`ops/backup.sh`) — snapshots LDAP (`slapcat`), Redis, and `./config`, with retention.
- Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates.
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
- Merged OAuth Apps + LDAP Info into a single Integrations page.
## [Unreleased]
## [1.14.0] - 2026-08-01
### Added
- Added Configuration page in the UI to manage SSO configurations stored securely in OpenBao Vault.
- Added Discovery plugin and Scheduler integration within the Directory.
- Re-routed Vault proxy under `/api/vault` and implemented Vault authentication headers.
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.16...HEAD
[1.1.15]: https://github.com/theta42/sso-manager-node/compare/v1.1.14...v1.1.15
[1.1.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14
[1.1.13]: https://github.com/theta42/sso-manager-node/compare/v1.1.12...v1.1.13
[1.1.12]: https://github.com/theta42/sso-manager-node/compare/v1.1.11...v1.1.12
[1.1.11]: https://github.com/theta42/sso-manager-node/compare/v1.1.10...v1.1.11
[1.1.10]: https://github.com/theta42/sso-manager-node/compare/v1.1.9...v1.1.10
[1.1.9]: https://github.com/theta42/sso-manager-node/compare/v1.1.8...v1.1.9
[1.1.8]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...v1.1.8
[1.1.7]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...v1.1.7
[1.1.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6
[1.1.5]: https://github.com/theta42/sso-manager-node/compare/v1.1.4...v1.1.5
[1.1.4]: https://github.com/theta42/sso-manager-node/compare/v1.1.3...v1.1.4
[1.1.3]: https://github.com/theta42/sso-manager-node/compare/v1.1.2...v1.1.3
[1.1.2]: https://github.com/theta42/sso-manager-node/compare/v1.1.1...v1.1.2
[1.1.1]: https://github.com/theta42/sso-manager-node/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0
## [1.19.6] - 2026-08-02
### Fixed
- Fixed Vault API returning 403 on the Secrets List due to `http-proxy-middleware` v2 rewriting the path incorrectly (it previously appended the `/api/vault/` mount path to the proxied Vault request).
+64 -35
View File
@@ -120,7 +120,7 @@ bare-metal / advanced standalone use; most deployments should use the file.
- Health check: `http://localhost:3001/health``{"status":"ok"}`
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
- LDAPS (for legacy apps / direct binds): `ldaps://<host>:636` (TLS)
- LDAPS (direct binds: Linux hosts, LDAP-native apps): `ldaps://<host>:636` (TLS)
### API tokens (personal access tokens)
@@ -176,6 +176,8 @@ docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \
| `PORT` | `3001` | host port mapped to the UI |
| `LDAPS_PORT` | `636` | host port mapped to LDAPS |
| `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) |
| `LDAP_SERVER_ID` | empty | Unique integer ID (e.g. 1, 2) required to enable Multi-Master replication |
| `LDAP_REPLICATION_HOSTS` | empty | Space-separated list of other sites' LDAP URLs for replication (e.g. `ldaps://site2:636`) |
Any `app_*` var may also be set directly to override any config value (see the
table at the top).
@@ -188,6 +190,12 @@ valid 10 years, SAN includes the CN + `localhost` + `127.0.0.1`) and listens on
`ldap-certs` volume so it persists across container recreation — clients don't need
to re-trust on every rebuild.
The `/integrations` page derives its LDAPS URL from the OAuth issuer by default.
To advertise a separate, internal-only hostname (e.g. `ldap.internal.example.com`
or `sso-manager` for Docker-internal clients), set `conf.ldap.ldapsHost` in your
secrets file or pass `app_ldap__ldapsHost=...`. See `docs/ldap.md` for
recommended network layouts and how to match the cert SAN to the hostname.
- **Trusting the self-signed cert** (clients): copy `/etc/openldap/certs/ldap.crt`
out of the container and add it to the client's trusted CA store, or set
`TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with:
@@ -328,14 +336,32 @@ OAuth client). Note: re-running bootstrap resets the bootstrap-admin and
service-account passwords to the values in `./config/sso-secrets.js`; non-theta
OAuth clients live in SSO Redis and are preserved by the volume.
> **Note — the bundled slapd is built from source.** The all-in-one image
> compiles OpenLDAP from a pinned upstream commit to get the `nestgroup`
> overlay (nested groups; see `docs/directory.md`), because no 2.6.x release
> ships it. One consequence: master uses **LMDB 1.0.0**, whose on-disk format is
> mutually unreadable with the 0.9.x in OpenLDAP 2.6.x
> (`MDB_INVALID: File is not an LMDB file`). Moving a directory between a 2.6.x
> image and this one is a `slapcat` → `slapadd` reload, not a restart — the same
> shape as "Restore — LDAP only" above. Verify after a rebuild:
> `docker compose logs sso-manager | grep nestgroup` should report the overlay
> as available.
---
## Method 2: Bare metal (Debian/Ubuntu)
`install.sh` is an idempotent installer: it installs Node.js 20.x, installs and
configures OpenLDAP (modules + overlays + custom schema + directory tree +
required groups), deploys the app to `/opt/sso-manager`, and creates a systemd
unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based).
`install.sh` is an idempotent installer: it installs Node.js 22.x and Redis,
force-syncs the repo to `/opt/theta42/sso-manager`, and symlinks the systemd
config from the repo. Re-run it to update — it prints the version you're
updating from and to (or "Already up to date" if there's nothing new).
On the **first run only** it also installs and configures OpenLDAP (modules +
overlays + custom schema + directory tree + required groups — see
`ops/ldap-setup.sh`) and seeds `/etc/sso-manager/secrets.js` with a generated
LDAP admin password and JWT secret (SMTP is left as a placeholder). Once that
file exists it's never touched again, and LDAP is never re-bootstrapped —
edit the file and restart the service to change anything.
### Prerequisites
@@ -346,47 +372,50 @@ unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based
### Install
```bash
sudo ./install.sh \
-p 'your-ldap-password' \
-b 'dc=yourdomain,dc=com' \
-n 'Your Org' \
-o 3001
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
```
| Flag | Env var | Description |
|------|---------|-------------|
| `-p, --admin-pass` | `LDAP_ADMIN_PASS` | LDAP admin password (required) |
| `-b, --base-dn` | `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) |
| `-n, --org-name` | `ORG_NAME` | Org name (default `SSO Manager`) |
| `-o, --port` | `PORT` | HTTP port (default `3001`) |
| `-j, --jwt-secret` | `JWT_SECRET` | JWT secret (default auto-generated) |
| `-s, --smtp-config` | `SMTP_*` | SMTP as `host:port:user:pass` |
| `--skip-ldap` | `SKIP_LDAP` | Skip LDAP setup (use existing) |
| `--skip-app` | `SKIP_APP` | LDAP setup only |
| `--dry-run` | `DRY_RUN` | Show actions without making changes |
or, if you already have the repo checked out:
```bash
sudo ./install.sh
```
| Env var | Description |
|---------|-------------|
| `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) — first run only |
| `LDAP_ADMIN_PASS` | LDAP admin password (default auto-generated) — first run only |
| `JWT_SECRET` | JWT secret (default auto-generated) — first run only |
| `ORG_NAME` | Org name (default `SSO Manager`) — first run only |
| `PORT` | HTTP port (default `3001`) — first run only |
| `SKIP_LDAP` | `true` to skip OpenLDAP bootstrap entirely (point at an existing server yourself) |
| `REPO_URL`, `REPO_DIR`, `BRANCH`, `SECRETS_FILE` | Override the defaults |
### Post-install
```bash
sudo systemctl enable --now sso-manager
sudo systemctl status sso-manager
journalctl -fu sso-manager
curl http://localhost:3001/health # -> {"status":"ok"}
```
### What `install.sh` does
1. Installs Node.js 20.x (NodeSource).
2. Installs OpenLDAP (`slapd`) with: `pw-sha2`, `ppolicy`, `memberof`, `refint`
modules + overlays; the custom `theta42Person` schema (`dateOfBirth`); indexes;
`ou=people`/`ou=groups`/`ou=policies`; a default `pwdPolicy`; and the SSO groups.
3. Installs the app to `/opt/sso-manager` and runs `npm ci --omit=dev`.
4. Generates `conf/secrets.js` (LDAP/SMTP/JWT) and `conf/base.js` (generic defaults).
5. Installs `sso-manager.service` (systemd), enabled on boot.
1. Installs Node.js 22.x (NodeSource) and Redis.
2. Clones/updates the repo at `/opt/theta42/sso-manager`.
3. **First run only:** installs OpenLDAP (`slapd`) with `pw-sha2`, `ppolicy`,
`memberof`, `refint` modules + overlays; the custom `theta42Person` schema
(`dateOfBirth`); indexes; `ou=people`/`ou=groups`/`ou=policies`; a default
`pwdPolicy`; and the SSO groups — then seeds `/etc/sso-manager/secrets.js`.
4. Symlinks `ops/systemd/sso-manager.service` into `/etc/systemd/system` and
runs `npm ci --omit=dev`.
5. Enables and (re)starts the service.
> For an existing LDAP server, run `sudo ./install.sh --skip-ldap …` and point the
> app at it. For LDAP-only setup on a host that already runs the app elsewhere, use
> `--skip-app`. To (re)configure overlays on an already-installed slapd, prefer
> `ops/ldap-setup.sh` (idempotent, auto-detects the user database).
> For an existing LDAP server, run with `SKIP_LDAP=true` and write
> `/etc/sso-manager/secrets.js` yourself (see `secrets.js.example`) before
> starting the service. To (re)configure overlays on an already-installed
> slapd, use `ops/ldap-setup.sh` directly (idempotent, auto-detects the user
> database).
---
@@ -465,8 +494,8 @@ netstat -tlnp | grep 389
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
StartTLS); port 389 is not mapped to the host by default so LAN clients can't
bind in cleartext. Direct-LDAP apps (legacy services, `theta42/proxy`) should
use `ldaps://…:636` or StartTLS.
bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,
`theta42/proxy`) should use `ldaps://…:636` or StartTLS.
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
set `JWT_SECRET`, issued tokens invalidate on container recreation.
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
+109 -26
View File
@@ -33,37 +33,119 @@ RUN if [ -n "$GIT_COMMIT" ]; then \
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
fi
# ── OpenLDAP from source ─────────────────────────────────────────────────────
# We build slapd from OpenLDAP master rather than installing Alpine's packages,
# for exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
# 2024-03-21), which evaluates nested groups server-side. Nothing in any 2.6.x
# release can do this -- verified: 2.6.13 ships 26 overlay modules and
# nestgroup is not among them -- and the alternative is resolving nesting
# separately in every consumer (this app, SSSD on each host, jump-host, proxy),
# where any consumer that forgets silently under-grants access.
#
# Consequence to know about: master ships LMDB 1.0.0, whose on-disk format the
# 0.9.x used by 2.6.x cannot read, and vice versa
# ("MDB_INVALID: File is not an LMDB file"). Moving an existing directory onto
# this image is a slapcat/slapadd migration, not a restart. See DEPLOYMENT.md.
FROM node:20-alpine AS ldapbuild
# groff is not optional despite producing nothing we ship: the build descends
# into doc/man unconditionally and its Makefile calls soelim, which groff
# provides. Without it the whole `make` fails at the man-page stage
# ("soelim: not found") long after slapd itself has compiled fine.
RUN apk add --no-cache \
build-base autoconf automake libtool \
openssl-dev cyrus-sasl-dev \
git make pkgconf util-linux-dev groff
# Pinned to an exact commit, not a branch tip. This is the directory server the
# whole lab authenticates against; an unpinned `master` would mean every image
# rebuild silently ships whatever landed upstream that morning, and a bad day on
# master would take out logins with no way to tell what changed.
#
# TODO: drop this whole from-source stage once nestgroup ships in a release.
# It is master-only today (ITS#10161, 2024-03-21); the 2.7 roadmap has slipped
# from Fall 2024 to Fall 2025 and is still unreleased. When 2.7 lands with
# nestgroup, revert to `apk add openldap openldap-overlay-nestgroup ...` --
# the entrypoint already probes for nestgroup.so and needs no change, and the
# app already keys off app_ldap__nestedGroupsServerSide either way.
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
WORKDIR /src
RUN git init -q . \
&& git remote add origin https://git.openldap.org/openldap/openldap.git \
&& git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \
&& git checkout -q FETCH_HEAD \
&& git rev-parse HEAD > /opt-openldap-commit.txt
# Overlays are built as loadable modules (=mod) because docker-entrypoint.sh
# `moduleload`s them individually; nestgroup joins that set.
RUN ./configure \
--prefix=/opt/openldap \
--enable-slapd \
--enable-modules \
--enable-mdb \
--enable-memberof=mod \
--enable-refint=mod \
--enable-ppolicy=mod \
--enable-dynlist=mod \
--enable-nestgroup=mod \
--enable-syncprov=mod \
--enable-auditlog=mod \
--with-tls=openssl \
--with-cyrus-sasl \
&& make depend \
&& make -j"$(nproc)" \
&& make install
# pw-sha2 provides {SSHA512}, which every existing user password is stored as.
# It lives in contrib and is not covered by the configure flags above, so it is
# built separately against the just-built tree -- omitting it would make every
# user password unverifiable.
RUN cd contrib/slapd-modules/passwd/sha2 \
&& make prefix=/opt/openldap OPENLDAP_SRC=/src \
&& cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/
FROM node:20-alpine
# Install OpenLDAP and required packages.
# Alpine splits OpenLDAP into many small subpackages; there is no catch-all
# "openldap-overlays" package. We install exactly the backends/overlays/modules
# the app depends on:
# openldap-back-mdb : the mdb backend (slapd.conf uses `database mdb`)
# openldap-overlay-ppolicy : ppolicy module + overlay (account locking)
# openldap-overlay-memberof : reverse group membership
# openldap-overlay-refint : referential integrity on group members
# openldap-passwd-sha2 : pw-sha2 module ({SSHA512} user password hashing)
# Note: Alpine does NOT ship a ppolicy.schema file — on OpenLDAP 2.6 the ppolicy
# schema is built into ppolicy.so and registered when the module loads, so
# docker-entrypoint.sh loads it via `moduleload ppolicy` (no schema include).
# openssl : used by docker-entrypoint.sh to generate a JWT secret
# Runtime libraries the from-source slapd links against, plus the app's own
# deps. No openldap* packages here: everything LDAP comes from /opt/openldap.
# libltdl (module loading -- slapd is useless without it, since every overlay
# is a loadable module) and libuuid are pulled in by the source build but are
# NOT dependencies of anything else here, so they must be named explicitly;
# omitting them fails at runtime with "Error relocating ... lt_dlopenext:
# symbol not found", not at build time.
RUN apk add --no-cache \
openldap \
openldap-clients \
openldap-back-mdb \
openldap-overlay-ppolicy \
openldap-overlay-memberof \
openldap-overlay-refint \
openldap-passwd-sha2 \
openssl \
libsasl \
libltdl \
libuuid \
dumb-init \
bash \
openssl \
redis \
nmap \
&& rm -rf /var/cache/apk/*
# The openldap package already creates the `ldap` user/group, which slapd runs
# as (see -u ldap -g ldap in docker-entrypoint.sh). Nothing to add here.
COPY --from=ldapbuild /opt/openldap /opt/openldap
# Which upstream commit this slapd was built from — so a running container can
# answer "what am I actually running" without rebuilding.
COPY --from=ldapbuild /opt-openldap-commit.txt /opt/openldap/COMMIT
# The Alpine openldap package used to create these; nothing does now, and
# docker-entrypoint.sh runs slapd as -u ldap -g ldap.
RUN addgroup -S ldap 2>/dev/null || true \
&& adduser -S -D -H -G ldap ldap 2>/dev/null || true
# docker-entrypoint.sh invokes slapd/slappasswd/ldapadd/ldapsearch by bare name
# and probes a list of candidate module directories, so putting the from-source
# tree first on PATH is all that is needed to redirect it. Schemas are symlinked
# into the conventional location because the entrypoint's slapd.conf includes
# /etc/openldap/schema/*.schema, and the app's own schemas (theta42, sudo,
# openssh-lpk) are copied there too.
ENV PATH="/opt/openldap/bin:/opt/openldap/sbin:/opt/openldap/libexec:${PATH}"
RUN mkdir -p /etc/openldap/schema \
&& for f in /opt/openldap/etc/openldap/schema/*.schema; do \
ln -sf "$f" "/etc/openldap/schema/$(basename "$f")"; \
done
WORKDIR /app
@@ -88,6 +170,7 @@ COPY nodejs/services ./services
COPY nodejs/utils ./utils
COPY nodejs/views ./views
COPY nodejs/public ./public
COPY nodejs/plugins ./plugins
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
@@ -98,10 +181,9 @@ COPY tos.md /tos.md
# Documentation, served in-app at /docs (routes/docs.js) so it's readable
# without internet access. Same flattened-path convention as tos.md above.
COPY README.md /README.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY CHANGELOG.md /CHANGELOG.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
COPY docs /docs
# Baked commit hash from the gitinfo stage (see build_info.js).
COPY --from=gitinfo /commit.txt ./.build_commit
@@ -128,7 +210,8 @@ COPY ops/schema/openssh-lpk.schema /etc/openldap/schema/openssh-lpk.schema
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
# if you want LAN clients to bind without TLS (not recommended).
# 636: LDAPS — for legacy apps / direct LDAP binds over the network (TLS)
# 636: LDAPS — direct LDAP binds over the network (TLS): Linux host auth
# (PAM/SSSD, sudo, SSH keys) and LDAP-native apps
EXPOSE 3001 389 636
# Health check
+48
View File
@@ -0,0 +1,48 @@
# Test-runner image for SSO Manager.
#
# Installs all dependencies (including dev) and bundles the app code plus
# the seed script. The entrypoint waits for LDAP + Redis, seeds the test
# user, then runs whatever command is given (default: npm test).
FROM node:20-alpine
# Install OpenLDAP clients (ldapadd, ldapsearch) and bash for the seed script
RUN apk add --no-cache openldap-clients bash
WORKDIR /app
# Copy and install dependencies (including devDependencies for jest/supertest)
COPY nodejs/package*.json ./
RUN npm ci
# Copy the application source
COPY nodejs/app.js ./
COPY nodejs/bin ./bin
COPY nodejs/conf ./conf
COPY nodejs/controller ./controller
COPY nodejs/middleware ./middleware
COPY nodejs/models ./models
COPY nodejs/routes ./routes
COPY nodejs/services ./services
COPY nodejs/utils ./utils
COPY nodejs/views ./views
COPY nodejs/public ./public
COPY nodejs/tests ./tests
# SQLite database directory (config/inventory.sqlite for Resource model's ORM)
RUN mkdir -p /app/config
# Files expected at the flattened /app path (see Dockerfile.openldap notes)
COPY tos.md /tos.md
COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
# Seed script and utility
COPY test_seed.js ./test_seed.js
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
RUN chmod +x /usr/local/bin/seed-test-user
# Default command: seed the test user, then run the test suite
CMD ["sh", "-c", "seed-test-user && npm test"]
+50 -17
View File
@@ -25,6 +25,10 @@ phone-home, no hosted control plane, and no per-user pricing.
| --- | --- |
| [![Groups](docs/images/groups.png)](docs/images/groups.png) | [![OAuth clients](docs/images/oauth-clients.png)](docs/images/oauth-clients.png) |
| Sites & Replication |
| --- |
| [![Sites](docs/images/sites.png)](docs/images/sites.png) |
## Features
- **OpenID Connect / OAuth 2.0 provider** — issue your own access, refresh, and
@@ -37,13 +41,15 @@ phone-home, no hosted control plane, and no per-user pricing.
- **Web management UI** — manage users, groups, and OAuth clients from a
browser; invite and password-reset flows over email; user self-service for
profile and API tokens.
- **LDAPS for legacy apps** — apps that bind LDAP directly (Gitea, Emby, and
anything else that speaks LDAP) use LDAPS (636) or StartTLS against the same
directory, so you don't maintain a second user database for them.
- **Direct LDAP binds** — Linux hosts (PAM/SSSD login, LDAP-backed `sudo`
rules, SSH public keys via openssh-lpk) and LDAP-native apps (Gitea, Emby,
and anything else that speaks LDAP) use LDAPS (636) or StartTLS against the
same directory, so you don't maintain a second user database for them.
- **Personal access tokens** — any user can mint a long-lived bearer token to
drive the management API from scripts or CI, scoped to their own permissions.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
the pieces separately against your own LDAP/Redis via `app_*` env config.
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency.
## Why this over the alternatives
@@ -107,23 +113,47 @@ vars, LDAPS/TLS, and backups.
### 3. Bare metal on Debian/Ubuntu
`install.sh` is an idempotent installer: it installs Node.js 20.x and OpenLDAP,
configures the directory (modules, overlays, schema, the SSO groups), deploys
the app to `/opt/sso-manager`, and creates a systemd unit.
The only thing it requires is the LDAP admin password; the domain (base DN)
defaults to `dc=example,dc=com` if you don't pass one:
An automated installer installs Node.js, Redis, and (on first run) OpenLDAP
configuring the directory (modules, overlays, schema, the SSO groups) and
seeding `/etc/sso-manager/secrets.js` with a generated admin password and JWT
secret — then deploys the app to `/opt/theta42/sso-manager` and starts a
systemd service:
```bash
sudo ./install.sh -p 'your-ldap-password' -b 'dc=yourdomain,dc=com'
sudo systemctl enable --now sso-manager
curl http://localhost:3001/health # -> {"status":"ok"}
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
```
Run `sudo ./install.sh -h` for all flags (`-n` org name, `-o` port, `-j` JWT
secret, `-s` SMTP, `--skip-ldap` to use an existing LDAP, `--dry-run`). Re-run
it to update. Full details in [DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2:
Bare metal*.
That's it — LDAP and the app are both live afterward. Edit
`/etc/sso-manager/secrets.js` (org name, SMTP, a non-default base DN, ...) and
restart the service to customize. It's idempotent and safe to re-run —
re-running it updates the app in place (never touching LDAP or the secrets
file again) and prints the version you're updating from and to (e.g. `Updated
v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
## Secrets
Secrets are loaded from **OpenBao** at boot via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/sso-manager/conf` over the file-loaded config (fail-soft:
if OpenBao is unreachable, boot continues from `CONF_SECRETS`). The SSO
authenticates to OpenBao with the scoped `VAULT_TOKEN` (env, policy
`sso-broker`) — never the root token.
The SSO also acts as the **vault broker** for the whole stack: it mints
per-user (`user-<uid>`) and per-admin (`sso-admin`) tokens through the
`sso-broker` token role and exposes the personal-secrets UI at **Vault → My
Secrets** (`secret/users/<uid>/*`, server-side token injection + path-scope
guard) and an admin **Apps** tab to mint scoped tokens for external apps
(`secret/apps/<name>/*`). The old `utils/conf_manager.js` was replaced by
`@simpleworkjs/bao-conf`; the admin **Configuration** UI (`/api/conf`) now
writes `secret/sso-manager/conf` through `bao-conf.set`.
The `config/*-secrets.js` files are operator-edit seed artifacts (gitignored),
not the authoritative store. For the full architecture, policies, token model,
and rotation procedure, see theta-env's
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
## Architecture
@@ -145,7 +175,7 @@ Bare metal*.
┌────────────────────────┐
│ OpenLDAP (slapd) │
│ - users / groups │
│ - LDAPS :636 │─── legacy apps bind directly
│ - LDAPS :636 │─── Linux hosts + LDAP apps bind directly
│ - StartTLS :389 │
└────────────────────────┘
```
@@ -161,6 +191,9 @@ required groups, LDAPS/TLS, direct-bind service accounts) live in:
- [docs/](docs/) (GitHub Pages) — the same content broken into
[deployment](docs/deployment.md), [configuration](docs/configuration.md),
[OAuth/OIDC](docs/oauth.md), and [LDAP](docs/ldap.md).
- [CHANGELOG.md](CHANGELOG.md) — what changed in each release.
- All of the above is also readable from the running app itself at `/docs`
no internet access required.
If you are pointing the app at your own existing LDAP server, see
*LDAP requirements* in [DEPLOYMENT.md](DEPLOYMENT.md) — the directory needs the
+18
View File
@@ -0,0 +1,18 @@
'use strict';
// Example proxy secrets file. theta-env generates a real ./config/proxy-secrets.js
// from this shape at setup (with empty clientId/clientSecret), then bootstrap.js
// writes the SSO-generated OAuth client creds into it AND into OpenBao
// (secret/proxy/conf). The proxy loads it via @simpleworkjs/conf, then overlays
// secret/proxy/conf from OpenBao via @simpleworkjs/bao-conf at boot.
//
// The real file is gitignored (config/*-secrets.js) — never commit live creds.
// This .example is tracked to document the expected shape only.
module.exports = {
oidc: {
// The SSO registers the proxy as an OAuth client and writes the real
// values here (and into OpenBao). "set-me" is the bootstrap placeholder.
clientId: 'set-me',
clientSecret: 'set-me',
},
};
+166 -13
View File
@@ -1,8 +1,10 @@
# Home-Lab Directory / Inventory — Design Spec
Status: **Draft / agreed direction** (no code yet)
Status: **Implemented** (v1.2.1+: model, admin API, UI; v1.3.x: automatic
registration from theta-env + ldap-client). §9 adds the planned-consumer
readiness review.
Owner: wmantly
Last updated: 2026-07-02
Last updated: 2026-07-23
---
@@ -95,13 +97,24 @@ common query fields can be promoted to columns later.
| column | type | notes |
|--------------|-------------|-------|
| `id` | uuid / pk | |
| `kind` | enum | `proxmox_node` \| `container` \| `vm` \| `bare_metal` \| `service` |
| `kind` | enum | `site` \| `host` \| `service` |
| `name` | text | display name ("Home Assistant", "ct101") |
| `slug` | text unique | url-safe id used by the API |
| `description`| text | free text |
| `metadata` | jsonb | `{ url, icon, fqdn, ip, port, tags[], … }` |
| `metadata` | jsonb | `{ subType, ip, macAddress, address, vmid, port, externalPort, gitRepo, installPath, systemdService, os, kernel, isProduction, isExternalReachable, isPublic }` |
| `created_at` / `updated_at` | timestamptz | |
**Parent Enforcement Rules:**
- A **Host** MUST have a parent **Site** or **Host**.
- A **Service** MUST have a parent **Host**.
- An **OAuth Integration** MUST have a parent **Service**.
**LDAP Group Auto-Creation:**
When a Host or Service is created, the system will automatically create two LDAP groups in the directory (if they do not already exist):
- `<slug>_access` (for standard user access)
- `<slug>_admin` (for administrative access)
Additional groups can still be linked manually.
### `resource_edge` — directed relationships (the graph)
| column | type | notes |
|--------------|--------|-------|
@@ -109,7 +122,7 @@ common query fields can be promoted to columns later.
| `child_id` | fk → resource | |
| `relation` | enum | `runs_on` \| `hosts` \| `exposes` \| `depends_on` |
Represents host←container←service (`hosts`/`runs_on`) and service→service
Represents site←host←service (`hosts`/`runs_on`) and service→service
(`depends_on`). Directed edges (not a single `parent_id` column) so a node can have
multiple parents/children and multiple relation types.
@@ -164,12 +177,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
- **Interactive users:** existing session auth — `middleware.auth` validating the
`auth-token` header (an `AuthToken`, `models/token.js`). No change.
- **CI/CD (machine) access:** the app does **not yet** have a long-lived service
token — `AuthToken` is session-oriented. **Proposed small addition:** a
`ServiceToken` subclass in `models/token.js` (mirrors `AuthToken`/`ImpersonationToken`),
long-lived, read-only, passed in the same `auth-token` header. Track as its own
task; the discovery API should assume it exists but degrade to normal auth tokens
until then.
- **CI/CD (machine) access:** scripts and external integrations (like jump hosts) will use the existing `ApiToken` system (Personal Access Tokens) passed in the `Authorization: Bearer sso_...` header. The `ApiToken` inherits the exact LDAP group permissions of the user who created it, seamlessly mapping to existing access controls.
- **Read visibility (decision to confirm):** either (a) any authenticated user may
read all resource metadata and only `/me` is filtered, or (b) list endpoints are
themselves filtered to entitlement. Recommend **(a)** for a home lab — simpler,
@@ -195,7 +203,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
## 7. Roadmap
1. **v1 — Discovery API** (this spec's focus): SQL schema + migrations, read models,
`/api/discovery/*` endpoints, `ServiceToken` for CI/CD.
`/api/discovery/*` endpoints, `ApiToken` for CI/CD.
2. **v2 — "My Access" dashboard**: swap `profile.ejs`'s static list for `/me`.
3. **v3 — Admin CRUD UI**: manage resources/edges/group links (reusing `app.ui`
widgets and the `oauth_clients.ejs` card+modal pattern); gated by
@@ -213,4 +221,149 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
`description` so LDAP-only external consumers see it? **Default: no** — keep LDAP
for auth, SQL for inventory.
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
5. **Service token scope:** read-only globally, or per-token resource/kind scoping?
5. **Service token scope:** Currently `ApiToken` shares the creator's full permissions. A future enhancement could scope tokens specifically to the Directory API.
---
## 9. Planned consumers — data-model & API readiness
Five consumers the directory data should be able to power. None are being
built yet; this section records what each needs, what already exists, and the
gaps to close so the model/API never paints us into a corner.
The recurring theme: **the graph model itself (Resource / ResourceEdge /
ResourceGroup + LDAP groups) is sufficient for all five.** The gaps are
(a) one new model (access requests), (b) machine-to-machine auth for the read
API, (c) documented metadata conventions instead of new columns, and
(d) change detection for the drift/sync consumers.
### 9.1 End-user exploration ("Netflix-style" catalog + request access)
A user browses everything that exists — part advertisement, part
documentation — sees what they already have, and requests access to the rest.
Already there:
- `/api/discovery/me` (`getMyAccess`) — the "My Services" half.
- `Resource.owner` + `<slug>_access` / `<slug>_admin` ResourceGroup links —
who approves, and which group an approval means joining.
- The Notification model — the approval-request delivery mechanism.
Gaps:
1. **Catalog projection with metadata privacy.** `/api/discovery/resources`
returns full `metadata` to any authenticated user — including the OAuth
kind's `client_secret_hash`, and operator notes that may name internal
IPs. Needed: a per-kind public projection (name, description, kind,
subType, icon, address, hasAccess, requestable) and a private-key
convention for the rest (e.g. only `app_sso_directory_admin` sees full
metadata). This is a **fix worth doing before any catalog UI exists**.
2. **`AccessRequest` model** — the one genuinely new model:
`{id, uid, resourceId, groupCn, status: pending|approved|denied, note,
requestedOn, decidedBy, decidedOn}`. Approval = LDAP group add + notify.
Endpoints: user POST/GET own; resource owner / directory admin
list/approve/deny.
3. **Catalog metadata conventions**: `icon`, `tagline` (card-length blurb),
`requestable: false` for resources that shouldn't be advertised.
### 9.2 SSH jump host (`username_-_{hostname-or-ip}@publicHost`)
A public jump host parses the target out of the SSH username, checks the user
may reach that host, and proxies the connection (WinSCP-friendly: one
username string, no interactive menu needed — though an interactive picker on
plain `username@` login is the same query).
Already there:
- Hosts carry `ip` (and `host_<hostname>` slugs to resolve by name).
- Access is already group-based (`<slug>_access`), checkable via LDAP alone —
the jump host can run entirely off LDAP (SSSD) + one directory query.
- User SSH keys are in LDAP (openssh-lpk) — the jump host authenticates the
real user without local accounts.
Gaps:
1. **Machine auth for the access query.** The jump host must ask "may user X
reach host Y" / "list hosts user X may reach" *about another user*.
`getMyAccess` only answers for the calling user. Needed: a
service-token-authenticated endpoint (`GET
/api/discovery/access/:uid[/:slug]`). `ServiceToken` already exists and
is even linked to a resource (`resource_id`) — what's missing is an auth
middleware that accepts it and a permission rule ("service tokens may
read access info, scoped read-only").
2. **Connection metadata conventions** on hosts: `sshPort` (default 22),
optional `fqdn` (when IP is dynamic), optional `jumpVia` edge relation if
multi-hop topologies ever appear.
3. Document the username grammar (`{uid}_-_{host-slug-or-ip}`) here so the
seed/ldap-client keep host slugs DNS-safe (they already are: slugify
strips everything but `[a-z0-9-]`).
### 9.3 Firewall port-forward rules (build / update / drift-test)
An automation renders the public firewall's forwarding table from the
directory, applies it, and alerts on drift in either direction.
Already there:
- `metadata.port` / `metadata.externalPort` / `metadata.ip` /
`metadata.isExternalReachable` — the core mapping data, already seeded for
the stack's own services.
Gaps:
1. **Port-mapping convention is too thin for real rules**: no protocol, no
multi-port services. Adopt `metadata.portMappings: [{proto: "tcp"|"udp",
external: n, internal: n, comment}]` as the authoritative form
(`port`/`externalPort` stay as the simple single-mapping case).
2. **Drift detection needs cheap change polling**: an `updated_on` timestamp
on resources surfaced in the graph API, or a graph-level etag/hash, so
the runner can poll without diffing full payloads. (The ORM already
publishes create/update events internally — a future push feed can ride
that; polling comes first.)
3. Same **service-token read auth** as 9.2 — automation must not run on a
human's session token.
### 9.4 Local DNS / mDNS
A DNS (or mDNS advertiser) zone is generated from the directory: hosts get
A records from `metadata.ip`, services get CNAMEs/records from their
addresses, sites map to zones.
Already there:
- `host_<hostname>` + `ip` covers A records; `site_<name>` is a natural zone
boundary; service `address` yields names.
Gaps:
1. **Name conventions**: `metadata.dnsNames: []` for extra aliases, and a
documented rule for which name wins (slug vs `address` hostname). TTL
only if someone actually needs per-record TTLs — default is fine.
2. Same **change detection** as 9.3 (poll `updated_on` / etag; push later).
3. Nothing else — this consumer is nearly free once 9.3's conventions land.
### 9.5 Access control for hosts
Who may log in to / sudo on which machine, driven by the directory.
Already there — this is the original point of the system:
- `<slug>_access` / `<slug>_admin` groups are auto-provisioned per host;
ldap-client configures SSSD/PAM against the directory; `sudoRole` and
openssh-lpk schemas cover sudo and SSH keys.
Gaps:
1. **Close the loop in ldap-client**: joined hosts should set an SSSD access
filter (`access_provider = ldap`, filter on `host_<hostname>_access`
membership) so directory group membership *is* login permission, not just
identity. Today the registration exists but enforcement is host-side
convention.
2. **`accessLevel` granularity**: ResourceGroup's `member`/`owner` maps to
login/admin today; if finer roles emerge (e.g. `login` vs `sudo` vs
`admin`), extend the enum — the join-table shape already supports it.
### 9.6 Consolidated work list (model/API only, no consumers)
Ordered by how much they unblock:
1. **Metadata privacy projection** on the read API (blocks 9.1; fixes the
`client_secret_hash` exposure regardless of any consumer).
2. **Service-token auth for `/api/discovery/*`** + `access/:uid` endpoint
(blocks 9.2, 9.3; ServiceToken model already exists).
3. **`AccessRequest` model + endpoints** (blocks 9.1's request half).
4. **Metadata conventions doc entries** (`sshPort`, `portMappings`,
`dnsNames`, `icon`, `tagline`, `requestable`) in `docs/directory.md`
conventions, not schema changes; the json column already holds them.
5. **`updated_on` in graph output / graph etag** (blocks drift/DNS
freshness; trivial once surfaced).
+36
View File
@@ -0,0 +1,36 @@
services:
site1:
build: .
container_name: sso_site1
environment:
- LDAP_SERVER_ID=1
- LDAP_REPLICATION_HOSTS=ldap://site2:389
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
ports:
- "3001:3001"
- "10389:389"
volumes:
- site1-ldap:/var/lib/ldap
- site1-redis:/data
site2:
build: .
container_name: sso_site2
environment:
- LDAP_SERVER_ID=2
- LDAP_REPLICATION_HOSTS=ldap://site1:389
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
ports:
- "3002:3001"
- "20389:389"
volumes:
- site2-ldap:/var/lib/ldap
- site2-redis:/data
volumes:
site1-ldap:
site1-redis:
site2-ldap:
site2-redis:
+81
View File
@@ -0,0 +1,81 @@
# Docker Compose for running the SSO Manager test suite.
#
# Spins up:
# ldap — OpenLDAP + Redis (all-in-one image, slapd + redis only, no app)
# redis — Standalone Redis for the app's model/token storage
# test-runner — Seeds the test user, then runs `npm test`
#
# Usage:
# docker compose -f docker-compose.test.yml up --build
# # Or to run a specific test file:
# docker compose -f docker-compose.test.yml run --rm test-runner npx jest tests/auth.test.js
#
# The LDAP service uses the same Dockerfile.openldap image as production but
# overrides the command to only start slapd + redis (the entrypoint handles
# slapd.conf generation, directory initialization, and Redis startup before
# running the given command — "sleep infinity" keeps it alive).
#
# The test-runner connects to ldap:389 and redis:6379 via Docker networking.
# app_* env vars override conf/secrets.js (highest precedence in
# @simpleworkjs/conf), so the production secrets.js is never read.
services:
ldap:
build:
context: .
dockerfile: Dockerfile.openldap
environment:
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
- ORG_NAME=Test SSO
# The entrypoint starts slapd + redis, then runs whatever command is given.
# "sleep infinity" keeps the container alive so the test-runner can connect.
command: ["sleep", "infinity"]
healthcheck:
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
interval: 2s
timeout: 3s
retries: 20
start_period: 5s
volumes:
- ldap-data:/var/lib/ldap
- ldap-certs:/etc/openldap/certs
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 15
test-runner:
build:
context: .
dockerfile: Dockerfile.test-runner
environment:
# Tell the app which environment it's in (loads conf/test.js for Redis prefix)
- NODE_ENV=test
# LDAP — point at the ldap service container
- app_ldap__url=ldap://ldap:389
- app_ldap__bindDN=cn=admin,dc=test,dc=local
- app_ldap__bindPassword=secret
- app_ldap__userBase=ou=people,dc=test,dc=local
- app_ldap__groupBase=ou=groups,dc=test,dc=local
# Redis — point at the redis service container
- app_redis__redisConf__url=redis://redis:6379
# Also used by tests/globalSetup.js (direct Redis client, not @simpleworkjs/conf)
- REDIS_URL=redis://redis:6379
# JWT secret (required by the app, not sensitive in test)
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
# App name
- app_name=Test SSO
depends_on:
ldap:
condition: service_healthy
redis:
condition: service_healthy
volumes:
ldap-data:
ldap-certs:
+125 -16
View File
@@ -6,12 +6,12 @@
# production, run a dedicated LDAP server and point the app at it via app_*
# env vars (or a mounted conf/secrets.js) using the app-only image.
#
# The app reads its configuration from conf/base.js + conf/secrets.js, deep-merged
# by @simpleworkjs/conf, with `app_*` environment variables as the
# highest-precedence override layer. This entrypoint exports those `app_*`
# vars so the app connects to the bundled slapd without any mounted secrets
# file. Any `app_*` var already set in the environment wins (the values below
# are defaults/fallbacks only).
# The app reads its configuration from conf/base.js + a secrets file, deep-merged
# by @simpleworkjs/conf (requires >= 1.2.0, pinned in nodejs/package-lock.json),
# with `app_*` environment variables as the highest-precedence override layer.
# This entrypoint exports those `app_*` vars so the app connects to the bundled
# slapd without any mounted secrets file. Any `app_*` var already set in the
# environment wins (the values below are defaults/fallbacks only).
set -e
@@ -33,14 +33,15 @@ error() { echo "[ERROR] $*" >&2; }
# ── Optional: load operational config from a mounted secrets.js ──────────────
# The unified theta-env stack mounts ./config/sso-secrets.js at /config and
# treats it as the authoritative source for the SSO's config (LDAP base, admin
# password, org name, JWT secret, ...). When present, symlink it into
# /app/conf/secrets.js so @simpleworkjs/conf reads it, and override the
# env-derived operational vars below with the file's values. When absent
# (standalone / env-var deployments) the env vars set above stay in effect and
# the app_* exports further down are emitted as before.
# password, org name, JWT secret, ...). When present, point CONF_SECRETS at it
# so @simpleworkjs/conf reads it directly (no write access to /app/conf
# needed), and override the env-derived operational vars below with the
# file's values. When absent (standalone / env-var deployments) the env vars
# set above stay in effect and the app_* exports further down are emitted as
# before.
SECRETS_JS_MODE=0
if [[ -f /config/sso-secrets.js ]]; then
ln -sf /config/sso-secrets.js /app/conf/secrets.js
export CONF_SECRETS=/config/sso-secrets.js
SECRETS_JS_MODE=1
# Pull the entrypoint's operational vars out of secrets.js in one node call.
# Node emits `KEY<TAB>base64(value)` lines; we decode each with base64 -d and
@@ -75,11 +76,22 @@ fi
# ── Locate the OpenLDAP module directory ────────────────────────────────────
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
# path varies by distro; auto-detect rather than hardcode.
# /opt/openldap/libexec/openldap is first: that is the from-source build (see
# Dockerfile.openldap), which is the only one carrying the nestgroup overlay.
MODULE_PATH=""
for p in /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
for p in /opt/openldap/libexec/openldap /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi
done
# Nested-group support is only available when slapd was built with the
# nestgroup overlay. Detect rather than assume, so this entrypoint still
# produces a working slapd.conf against a distro OpenLDAP (where the app falls
# back to resolving nesting itself -- see nodejs/models/group_ldap.js).
NESTGROUP_AVAILABLE=0
if [[ -n "$MODULE_PATH" && -f "$MODULE_PATH/nestgroup.so" ]]; then
NESTGROUP_AVAILABLE=1
fi
# ── TLS certificate for LDAPS / StartTLS ────────────────────────────────────
# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the
# network. To keep password binds off the wire in cleartext we expose LDAPS
@@ -124,6 +136,8 @@ include /etc/openldap/schema/theta42.schema
include /etc/openldap/schema/sudo.schema
include /etc/openldap/schema/openssh-lpk.schema
SERVER_ID_PLACEHOLDER
# Module loading (pw-sha2 provides {SSHA512} used by the app for user passwords;
# ppolicy/memberof/refint are the overlays the app depends on). On OpenLDAP 2.5+
# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into
@@ -136,6 +150,9 @@ moduleload pw-sha2
moduleload ppolicy
moduleload memberof
moduleload refint
moduleload auditlog
NESTGROUP_MODULE_PLACEHOLDER
SYNCPROV_MODULE_PLACEHOLDER
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
# generated/mounted above. We accept clients without their own cert (the common
@@ -183,6 +200,14 @@ memberof-memberof-ad memberOf
overlay refint
refint_attributes memberOf member manager owner
NESTGROUP_OVERLAY_PLACEHOLDER
# auditlog overlay (LDIF audit trail of all changes)
overlay auditlog
auditlog /var/lib/ldap/auditlog.ldif
REPLICATION_BLOCK_PLACEHOLDER
# Access controls
access to attrs=userPassword
by dn="BIND_DN_PLACEHOLDER" write
@@ -210,6 +235,61 @@ else
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
fi
# ── Nested groups (nestgroup overlay) ──
# Three of the four flags, deliberately:
#
# member-filter (member=X) finds parent groups transitively. This is what
# Group.list(dn) rides on -- the core access question.
# memberof-filter (memberOf=X) matches members of nested groups. This is
# what SSSD's ldap_access_filter uses, so SSH/sudo inherit
# nesting without any client-side walking.
# memberof-values expands memberOf when reading a user, so anything that
# reads the attribute rather than searching still sees the
# full picture.
#
# member-values is deliberately NOT enabled. It expands the `member` attribute
# when reading a *group*, which sounds symmetric but destroys the distinction
# between "listed on this group" and "reachable through a nested one" -- and
# that distinction is not recoverable afterwards, because the raw values are
# simply not returned. The Groups UI needs it to show nested groups as nested
# rather than as a crowd of phantom users, and un-nesting needs it to know what
# it is actually removing. Transitive *answers* come from the filter flags and
# from Group.effectiveMembers(), which computes the closure explicitly.
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
info "nestgroup overlay available — nested groups resolved server-side"
sed -i "s|^NESTGROUP_MODULE_PLACEHOLDER$|moduleload nestgroup|" /etc/openldap/slapd.conf
NESTGROUP_BLOCK="# nestgroup overlay (server-side nested group evaluation)\noverlay nestgroup\nnestgroup-base ou=groups,${LDAP_BASE_DN}\nnestgroup-flags member-filter memberof-filter memberof-values"
sed -i "s|^NESTGROUP_OVERLAY_PLACEHOLDER$|${NESTGROUP_BLOCK}|" /etc/openldap/slapd.conf
else
info "nestgroup overlay not present in ${MODULE_PATH:-<no module path>} — nested groups will be resolved by the app instead"
sed -i "/^NESTGROUP_MODULE_PLACEHOLDER$/d" /etc/openldap/slapd.conf
sed -i "/^NESTGROUP_OVERLAY_PLACEHOLDER$/d" /etc/openldap/slapd.conf
fi
# ── Multi-Master Replication Configuration ──
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
sed -i "s|^SERVER_ID_PLACEHOLDER|ServerID ${LDAP_SERVER_ID}|" /etc/openldap/slapd.conf
sed -i "s|^SYNCPROV_MODULE_PLACEHOLDER|moduleload syncprov|" /etc/openldap/slapd.conf
# Generate syncrepl blocks
REPL_BLOCK="overlay syncprov\nsyncprov-checkpoint 100 10\nsyncprov-sessionlog 100\n\n"
RID=100
for HOST in ${LDAP_REPLICATION_HOSTS}; do
RID=$((RID + 1))
REPL_BLOCK="${REPL_BLOCK}syncrepl rid=${RID}\n provider=${HOST}\n type=refreshAndPersist\n retry=\"60 +\"\n searchbase=\"${LDAP_BASE_DN}\"\n bindmethod=simple\n binddn=\"${LDAP_BIND_DN}\"\n credentials=\"${LDAP_ADMIN_PASS}\"\n\n"
done
REPL_BLOCK="${REPL_BLOCK}mirrormode on\n"
# Replace placeholder (awk is safer for multiline replacements than sed)
awk -v repl="$(printf '%b' "$REPL_BLOCK")" '{gsub(/REPLICATION_BLOCK_PLACEHOLDER/, repl)}1' /etc/openldap/slapd.conf > /etc/openldap/slapd.conf.tmp
mv /etc/openldap/slapd.conf.tmp /etc/openldap/slapd.conf
else
sed -i "/^SERVER_ID_PLACEHOLDER/d" /etc/openldap/slapd.conf
sed -i "/^SYNCPROV_MODULE_PLACEHOLDER/d" /etc/openldap/slapd.conf
sed -i "/^REPLICATION_BLOCK_PLACEHOLDER/d" /etc/openldap/slapd.conf
fi
chown ldap:ldap /etc/openldap/slapd.conf 2>/dev/null || true
chown -R ldap:ldap /var/lib/ldap 2>/dev/null || true
@@ -219,7 +299,7 @@ info "Starting OpenLDAP (base DN: ${LDAP_BASE_DN})..."
# -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS).
# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on
# Alpine and the container only uses simple bind over ldap://localhost:389.
slapd -d 0 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" &
slapd -d 256 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" >> /var/lib/ldap/slapd.log 2>&1 &
SLAPD_PID=$!
# Wait for slapd to answer the root DSE (means it's up, regardless of DB state).
@@ -267,7 +347,7 @@ objectClass: organizationalRole
objectClass: pwdPolicy
cn: ppolicy
pwdAttribute: 2.5.4.35
pwdLockout: FALSE
pwdLockout: TRUE
pwdMustChange: FALSE
pwdAllowUserChange: TRUE
EOF
@@ -275,7 +355,7 @@ EOF
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
# app_sso_service_account is a marker (not a permission gate) for
# non-person accounts -- see the Users page.
for group in app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
objectClass: groupOfNames
@@ -286,6 +366,27 @@ member: ${LDAP_BIND_DN}
EOF
done
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
# hold those rights by membership rather than by a special case in app code.
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
# anything binding LDAP directly -- instead of only to callers that happen
# to route through utils/permission.js.
#
# app_sso_service_account is deliberately excluded: it is a marker for
# non-person accounts, not a permission, and nesting admins into it would
# misclassify them as service accounts on the Users page.
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
changetype: modify
add: member
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
EOF
done
info "Nested app_super_admin into the SSO admin groups"
fi
info "LDAP directory initialized"
else
info "LDAP directory already initialized — skipping seed"
@@ -345,6 +446,14 @@ if [[ "${SECRETS_JS_MODE:-0}" != 1 ]]; then
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}"
# Tell the app whether slapd resolves nested groups for it. When true the app
# trusts a plain (member=) search to be transitive; when false it computes
# the closure itself. Getting this wrong in the "true" direction silently
# under-grants, so it is derived from the same nestgroup.so probe that
# decides whether the overlay is configured at all -- never hardcoded.
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
export app_ldap__nestedGroupsServerSide="${app_ldap__nestedGroupsServerSide:-true}"
fi
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
# public https URL on the SSO subdomain of the LDAP domain; override with
+50 -4
View File
@@ -1,9 +1,55 @@
title: SSO Manager
description: A self-hosted OpenID Connect provider with an OpenLDAP directory and a web management UI
theme: jekyll-theme-cayman
show_downloads: false
description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI, for home labs and small businesses that want their own identity provider.
url: "https://theta42.github.io"
baseurl: "/sso-manager-node"
logo: /assets/img/theta42.svg
lang: en_US
plugins:
- jekyll-seo-tag
- jekyll-sitemap
github:
repository_url: https://github.com/theta42/sso-manager-node
zip_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.zip
tar_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.tar.gz
repository_name: theta42/sso-manager-node
repository_name: theta42/sso-manager-node
nav:
- title: Home
page: /
icon: fa-house
- title: Deployment
page: /deployment.html
icon: fa-server
- title: Configuration
page: /configuration.html
icon: fa-gears
- title: OAuth
page: /oauth.html
icon: fa-key
- title: LDAP
page: /ldap.html
icon: fa-address-book
- title: Directory
page: /directory.html
icon: fa-server
- title: Plugins
page: /plugins.html
icon: fa-plug
# API.md lives at the repo root, not under docs/, so Jekyll never renders an
# api.html for it — link the source directly, same as the Changelog.
- title: API
url: https://github.com/theta42/sso-manager-node/blob/master/API.md
icon: fa-code
- title: Changelog
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
icon: fa-list
defaults:
- scope:
path: ""
type: "pages"
values:
layout: default
image: /assets/img/theta42.svg
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/theta42.svg' | relative_url }}">
{% seo title=false %}
<title>{% if page.title %}{{ page.title }} &middot; {% endif %}{{ site.title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
</head>
<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
<div class="container-fluid px-3">
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
{{ site.title }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navMain">
<ul class="navbar-nav">
{% for item in site.nav %}
<li class="nav-item">
{% if item.page %}
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% else %}
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</nav>
<main class="flex-grow-1" style="margin-top: 4.5rem;">
<div class="container-fluid py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
<div class="card shadow-lg">
<div class="card-body p-4 p-md-5 site-content">
{{ content }}
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="py-3 bg-dark text-light mt-auto">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank" rel="noopener">
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
</a>
&copy; {{ 'now' | date: '%Y' }} theta42 &middot;
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-solid fa-list"></i> Changelog
</a>
</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
---
layout: default
title: Theta Agent & Endpoint Management
nav_order: 5
---
# Theta Agent & Endpoint Management
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go for Linux hosts across your home lab, infrastructure, or data center. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management.
---
## Core Functionality
### 1. Host Discovery & Inventory
Upon establishing a WebSocket connection, the agent immediately pushes a comprehensive discovery payload:
- **Hostname & Network Interfaces**: Hostname and all non-loopback IPv4 addresses and MACs.
- **Operating System & Kernel**: Linux distribution, platform, and kernel version.
- **Hardware Specs**: CPU model, total RAM (GB), and total root disk capacity (GB).
- **Physical Location**: Location identifier string (e.g. `dc-01-rack-12`) configured in `agent.yml`.
If the agent detects a network IP change, it automatically re-pushes an updated discovery payload to the SSO Manager.
### 2. Real-Time Telemetry Streaming
Every 30 seconds, the agent streams real-time performance metrics:
- **CPU Load**: System-wide CPU utilization percentage.
- **Memory Utilization**: RAM usage percentage and available memory.
- **Disk Utilization**: Root filesystem usage percentage.
- **ZFS Storage Health**: Health status of ZFS pools (e.g., `ONLINE`).
- **NVIDIA GPU Load**: GPU compute utilization percentage (via `nvidia-smi`).
---
## Local-First Security & Capability Matrix
To protect hosts against unauthorized control, `theta-agent` enforces a **strict, local-first capability matrix** defined in `/etc/theta42/agent.yml`. Central SSO Manager requests are checked against local configuration before execution; permissions cannot be overridden remotely.
| Capability | Config Key | Risk Level | Description & Impact |
| :--- | :--- | :--- | :--- |
| **Telemetry** | `telemetry` | Safe | Streams read-only system metrics (CPU, RAM, Disk, ZFS, GPU). |
| **Configure LDAP** | `configure_ldap` | Moderate | Writes updated SSSD configuration to `/etc/sssd/sssd.conf` & restarts `sssd`. |
| **Service Control** | `service_control` | High | Restarts systemd services listed in an explicit allowlist (e.g., `["nginx", "docker", "sssd"]`). |
| **Reboot** | `reboot` | High | Triggers an immediate system reboot (`systemctl reboot`). |
| **Arbitrary Bash** | `arbitrary_bash` | Critical | Executes raw bash scripts sent from the SSO Manager as `root` (used for automated GitOps). |
---
## High-Risk Command Verification (Protocol v1.1.0)
High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash`, `update_binary`) are cryptographically verified using **Ed25519 signatures**:
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace).
2. The payload is signed with the SSO Manager's Ed25519 private key.
3. The Base64 signature is appended to the message payload.
4. The agent verifies the signature against the configured `public_key` in `/etc/theta42/agent.yml` before executing the action.
---
## Installation & Deployment
### Quick One-Liner Install
Run the following command as `root` on the target Linux host:
```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- --url "https://<SSO_HOST>" --token "<HOST_TOKEN>"
```
### Custom Config Wizard
You can generate a Base64-encoded custom configuration using the **Install Agent** button on the **Directory Management** page in the SSO Manager UI:
```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
```
---
## Configuration File Example (`/etc/theta42/agent.yml`)
```yaml
# /etc/theta42/agent.yml
server_url: "wss://sso.example.com"
auth_token: "your-unique-host-token"
location: "dc-01-rack-12"
public_key: "MCowBQYDK2VwAyEA..."
capabilities:
telemetry: true
configure_ldap: true
reboot: false
service_control: ["nginx", "docker", "sssd"]
arbitrary_bash: false
```
+116
View File
@@ -0,0 +1,116 @@
/* theta42 docs site shares the in-app dark navbar/footer + card look
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
generic Jekyll theme. */
body {
background-color: #f4f5f6;
}
.navbar-brand img {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
}
.navbar-nav .nav-link.active {
color: #fff;
font-weight: 600;
}
/* Markdown content typography, scoped to the card body so it doesn't leak
into the nav/footer. */
.site-content h1:first-child {
margin-top: 0;
}
.site-content h1,
.site-content h2,
.site-content h3 {
font-weight: 700;
}
.site-content h2 {
margin-top: 2.5rem;
padding-bottom: .4rem;
border-bottom: 1px solid #e9ecef;
}
.site-content h3 {
margin-top: 1.75rem;
}
.site-content a {
color: #a3671f;
text-decoration-color: rgba(163, 103, 31, .35);
}
.site-content a:hover {
color: #8a5a16;
}
.site-content pre {
background-color: #212529;
color: #f8f9fa;
padding: 1rem 1.25rem;
border-radius: .375rem;
overflow-x: auto;
}
.site-content code {
color: #a3671f;
background-color: #f4f0e8;
padding: .15em .4em;
border-radius: .25rem;
font-size: .875em;
}
.site-content pre code {
color: inherit;
background: none;
padding: 0;
}
.site-content table {
display: block;
overflow-x: auto;
width: 100%;
border-collapse: collapse;
margin: 1.25rem 0;
}
.site-content table th,
.site-content table td {
border: 1px solid #dee2e6;
padding: .5rem .75rem;
text-align: left;
}
.site-content table th {
background-color: #f8f9fa;
}
.site-content blockquote {
border-left: 4px solid #C59341;
padding: .5rem 1rem;
margin: 1.25rem 0;
background-color: #f8f6f1;
color: #495057;
}
.site-content img {
max-width: 100%;
height: auto;
}
/* Screenshot grids in the markdown use width="49%" inline attrs for a
two-up desktop layout -- stack them on narrow screens instead of
squeezing to illegibility. */
@media (max-width: 576px) {
.site-content img[width] {
width: 100% !important;
margin-bottom: .75rem;
}
}
.site-content hr {
margin: 2rem 0;
border-top: 1px solid #e9ecef;
}
+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C59341" />
<stop offset="20%" stop-color="#E4B869" />
<stop offset="40%" stop-color="#FBF0B9" />
<stop offset="60%" stop-color="#DFB260" />
<stop offset="80%" stop-color="#BC8837" />
<stop offset="100%" stop-color="#A36F28" />
</linearGradient>
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FFFFFF" />
<stop offset="40%" stop-color="#F5E3B5" />
<stop offset="70%" stop-color="#D4A343" />
<stop offset="100%" stop-color="#8A5A16" />
</linearGradient>
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<g filter="url(#drop-shadow)">
<g fill="url(#gold-grad)">
<path d="M 200,40
C 290,40 350,110 350,200
C 350,290 290,360 200,360
C 110,360 50,290 50,200
C 50,110 110,40 200,40 Z
M 200,75
C 130,75 88,130 88,200
C 88,270 130,325 200,325
C 270,325 312,270 312,200
C 312,130 270,75 200,75 Z"
fill-rule="evenodd" />
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
</g>
<text x="200" y="222"
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-size="78"
font-weight="900"
fill="url(#text-grad)"
text-anchor="middle"
letter-spacing="-2">42</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+125
View File
@@ -0,0 +1,125 @@
---
layout: default
title: Accounts, Groups & Managers
description: A plain-language guide to users, service accounts, personal groups, and managers in SSO Manager.
---
# Accounts, Groups & Managers
This page explains the concepts behind the Users and Groups pages in plain
language. If you want the technical schema/attribute-level detail instead,
see the [LDAP reference](ldap.html).
## What's an account?
Every person (or app) that can sign in through this SSO Manager has an
**account** — a username, a display name, maybe an email address, and a
password (or, for service accounts, no password at all — see below).
Accounts live in the directory this app manages, and any other app you've
connected (Gitea, Home Assistant, your Wi-Fi, whatever) checks against these
same accounts instead of keeping its own separate list of users and
passwords.
## Two kinds of account: people and service accounts
Most accounts belong to an actual person — check **Users → People** to see
them. But sometimes you need an account for something that *isn't* a
person: a media server, a backup script, a bind account another app uses to
look people up. These are **service accounts**, listed separately under
**Users → Service Accounts**, and they're different from a person's account
in two ways that matter:
- **No email required.** A service account doesn't need a mailbox, so the
form doesn't ask for one.
- **A password is optional.** If you leave it blank, nobody can log in as
that account — which is exactly what you want for something that only
ever gets used programmatically (a script authenticating with an API
token, or another app binding with a fixed, separately-configured
password you set yourself). Only give it a password if the account
genuinely needs to log in or bind somewhere as itself.
Aside from those two differences, a service account is a completely normal
account under the hood — it can belong to groups, have a manager, and so
on, just like anyone else's.
## Groups: who can do what
A **group** is just a named list of accounts, used to control access. This
app has a handful of built-in groups that grant admin powers (e.g. only
people in the `app_sso_admin` group can see the Users/Groups/Directory/Overview
pages at all), but you can also make your own groups for any app you
connect — say, a group listing everyone who should be allowed into your
photo server. Once a group exists, add or remove members from the
**Groups** page, and point the other app's "who's allowed in" setting at
that group's name.
### Groups inside groups
A group can contain another group, not just people — the *Nested* tab on any
group card. Everyone in the inner group counts as a member of the outer one,
however many levels deep it goes.
This is mostly a way to stop repeating yourself. Make one `developers` group,
nest it into the handful of things developers should reach, and adding a new
developer to that one group grants all of them at once — instead of adding them
to each individually and slowly drifting out of sync. The app already does this
for itself: super admins are nested into every resource's admin group, and each
admin group into its access group, so "can administer it" always implies "can
use it".
Two things it won't let you do: put a group inside itself (directly or round a
longer loop), and empty a group completely — every group must keep at least one
member.
A note if you also manage the directory by hand: a group's member list shows
what is *directly* listed on it. Someone who gets in through a nested group is
a real member but won't appear there — the **Nested** tab shows what is nested,
and the API's `effective` view lists everyone who actually gets in.
## Every account's personal group
Separately from the groups above, every single account — person or
service account — automatically gets its own small, personal group when
it's created, named after the account itself. Most of the time you'll
never think about this; it exists so that, on a Linux system connected to
this directory, each account "owns" its own files by default the same way
a normal Unix user account would.
Occasionally you'll want to share that ownership with someone else — for
example, letting a second account also have write access to files a
service account owns. That's what the **"Members of `<uid>`'s group"**
section on a profile page is for: add another account there, and the
underlying Linux permissions treat them as if they belong to that same
personal group too.
## What's a "manager"?
Every account has one or more **managers** — the people allowed to edit
that account's profile (phone number, SSH key, home directory, and so on)
without needing full admin rights. By default, whoever created an account
(the admin who added it, or whoever sent the invite) becomes its first
manager, but you can add or remove managers later from the account's Edit
form.
This is useful for service accounts especially: if a service account
belongs to a particular project or person, make them its manager so they
can maintain it — rotate its SSH key, adjust its description — without
needing to be a full SSO administrator.
## Inviting someone vs. adding them yourself
From the Users page you can either fill in someone's details yourself
("Add new user"), or send them an **invite** — an email (or a link you copy
and send however you like) that lets them pick their own username and
password. Either way, the resulting account is identical; invites are just
a convenience so you don't have to know someone's preferred username or
handle their password directly.
## Want more detail?
This page deliberately leaves out LDAP schema names, attribute types, and
protocol-level detail. If you're connecting a third-party app directly to
the LDAP directory, or you just want to know exactly what's stored where,
see the [LDAP reference](ldap.html).
[← Back to Home](index.html)
+59
View File
@@ -0,0 +1,59 @@
---
layout: default
title: API Tokens
description: A plain-language guide to personal access tokens in SSO Manager.
---
# API Tokens
This page explains what an API token is and when you'd want one. For the
full list of API endpoints a token can call, see the
[API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
## What's an API token, in plain terms?
Normally, you interact with this app by logging in through a web browser.
An **API token** (also called a personal access token, or PAT) is an
alternative way in — a long, random string that a script, a scheduled job,
or another program can use instead of a username and password, to act on
your behalf without a human typing a login in each time.
If you've ever set up a script to talk to GitHub, GitLab, or a similar
service using a "token" instead of your real password, this is the same
idea.
## When would you actually need one?
Most people never need to create one of these — you'll only want a token
if you're automating something, for example:
- A script that syncs users or groups from somewhere else into this SSO
Manager on a schedule.
- A backup or monitoring job that checks this app's health via its API.
- A CI/CD pipeline that needs to register or update an OAuth client
automatically.
If you're not doing any of that, you don't need an API token — just log in
normally through the web UI.
## How it works
Create a token from your Profile page, give it a name so you remember what
it's for later, and optionally an expiry. You'll be shown the token's
value **exactly once** — copy it somewhere safe immediately, because it
can't be viewed again afterward (only revoked or rotated). Whatever script
or tool you're using it with sends it along with each request, the same
way a browser sends your login session.
A token acts **as you**, with **your** permissions — if you're not an
admin, a token you create can't do admin-only things either. If you ever
suspect a token has leaked (ended up somewhere it shouldn't have, like a
public script or log file), revoke it immediately from your Profile page;
it stops working right away.
## Want more detail?
This page doesn't attempt to list every API endpoint or show request/
response examples — for that, see the full [API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
[← Back to Home](index.html)
+79
View File
@@ -0,0 +1,79 @@
---
layout: default
title: Connecting Apps (Single Sign-On)
description: A plain-language guide to OAuth/OIDC clients and single sign-on in SSO Manager.
---
# Connecting Apps (Single Sign-On)
This page explains, in plain language, what happens when you "connect" an
app to your SSO Manager so people can log into it with their existing
account. For the technical endpoint/token detail, see the
[OAuth reference](oauth.html).
## What does "single sign-on" actually mean?
Instead of every app you run having its own separate list of usernames and
passwords, they all check with this SSO Manager instead. You log in once,
here, and any connected app trusts that login — no separate password to
remember or manage for each one. If you ever need to lock someone out
everywhere at once, you do it in one place (deactivate their account here)
instead of hunting down every app individually.
The technology behind this is called **OAuth 2.0** and **OpenID Connect
(OIDC)** — you'll see both names used, often together, referring to the
same thing. You don't need to understand the protocol to use this page;
what matters practically is the handful of concepts below.
## What's a "client"?
Every app you connect is registered here as a **client** — a single entry
in the Directory representing that one app. Registering a client
gives you a **Client ID** and **Client Secret**: think of these like a
username and password, but for the *app itself* rather than for a person.
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
screen, along with the discovery URL shown at the top of this page, and
that app is now able to ask this SSO Manager to authenticate people on its
behalf.
**Treat the Client Secret like a password** — anyone who has it can
impersonate that app when talking to your SSO Manager. If you ever suspect
it's leaked, rotate it from the client's card.
## What are "scopes"?
**Scopes** control what information a connected app is allowed to ask for
about the person logging in — their username, email, group memberships,
and so on. Most apps tell you exactly which scopes they need in their own
setup instructions; when in doubt, the default set (`openid`, `profile`,
`email`, `groups`) covers what nearly every app expects.
## "Restrict to Groups"
By default, *any* account with an SSO Manager login can sign into a
connected app. If that's not what you want — say, a home automation
dashboard that only certain family members should reach — set **Restrict
to Groups** on that client to one of your [groups](concepts-accounts.html).
Only members of that group will be allowed to log into that particular
app; everyone else gets turned away at the login step, even though their
SSO Manager account still works everywhere else.
## Redirect URIs
A **Redirect URI** is the exact web address the connected app wants people
sent back to once they've logged in here — it's a security measure so an
attacker can't trick the login flow into redirecting somewhere else. The
app's own setup instructions will tell you this value; copy it in exactly
as given. If the app is reachable via more than one hostname (for example,
because it sits behind [theta42/proxy](https://theta42.github.io/proxy/)),
this field supports wildcard patterns — see the inline help under the
field itself for the exact syntax.
## Want more detail?
This page intentionally skips the protocol-level detail (exact endpoint
URLs, token formats, claim names). If you're troubleshooting a connection
or building something against the API directly, see the
[OAuth reference](oauth.html).
[← Back to Home](index.html)
+3
View File
@@ -1,6 +1,7 @@
---
layout: default
title: Configuration
description: SSO Manager's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables.
---
# Configuration
@@ -31,6 +32,8 @@ raw strings otherwise.
| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string |
| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) |
| `app_ldap__uidGidReservedFloor=9000` | `conf.ldap.uidGidReservedFloor` | number (ids at/above this are ignored when allocating) |
| `app_ldap__ldapsHost=ldap.internal.example.com` | `conf.ldap.ldapsHost` | string (hostname shown on `/integrations` for LDAPS binds; empty = derive from `oauth.issuer`) |
| `app_ldap__ldapsPort=636` | `conf.ldap.ldapsPort` | number (port shown on `/integrations`) |
| `app_oauth__jwtSecret=...` | `conf.oauth.jwtSecret` | string |
| `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | string |
| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number |
+1
View File
@@ -1,6 +1,7 @@
---
layout: default
title: Deployment
description: Deploying SSO Manager — the all-in-one Docker image, bare-metal install, config layers, and backups.
---
# Deployment Guide
+139
View File
@@ -0,0 +1,139 @@
---
layout: default
title: Directory Management
description: Managing your Home-Lab infrastructure, services, and LDAP access relationships via the SSO Directory API.
---
# Directory Management
The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services.
## Architecture
The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`).
There are three primary **Kinds** of resources you can define:
- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents.
- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host.
- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service.
- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service.
By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure.
## Automatic LDAP Group Creation
When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource:
1. `<slug>_access` (Member level access)
2. `<slug>_admin` (Owner level access)
For example, if you create a Service named "Emby" with the slug `app_emby`, the system will create the LDAP groups `app_emby_access` and `app_emby_admin`. You can then assign users to these groups, and they will immediately see the service populate on their "My Services" dashboard.
## Resource Metadata
Resources carry a flexible `metadata` JSON object that can store essential context for your applications. The UI natively supports the following metadata fields:
### Common Metadata
- **Sub Type**: Free-form text to categorize the resource (e.g., `proxmox_node`, `linux`, `lxc`, `web`).
- **IP Address**: The internal IP address of the resource.
- **MAC Address**: The hardware address of the primary interface.
- **Host / URI Address**: The FQDN or URL of the resource (e.g., `https://emby.home.arpa`).
- **Production Environment**: A boolean toggle indicating if the resource is in production.
### Host Metadata
- **VMID**: The hypervisor VM or Container ID (e.g. `101`).
- **OS**: The operating system name (e.g. `Ubuntu 22.04.3 LTS`).
- **Kernel**: The kernel version string (e.g. `5.15.0-100-generic`).
### Service Metadata
- **Internal Port**: The local port the service binds to (e.g. `8080`).
- **External Port**: The reverse-proxy or external port (defaults to Internal Port if left blank).
- **Public (No Auth)**: Indicates if the service is exposed publicly without authentication.
- **External Reachable**: Indicates if the service is accessible outside the VPN/local network.
- **Git Repo**: The source code repository for the service (e.g. `https://github.com/...`).
- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`).
### Who sees which metadata
Metadata keys are declared in `@simpleworkjs/directory-schema` with an `admin` flag, and every API response is passed through its projection. There are three tiers:
- **Public** — returned to any authenticated caller, including machine (`ServiceToken`) callers: `ip`, `address`, `sshPort`, `fqdn`, `dnsNames`, `port`, `externalPort`, `portMappings`, `isExternalReachable`, `os`, `gitRepo`, `subType`, `icon`, `tagline`, `isPublic`, `isProduction`, `requestable`, `isCurrentSite`.
- **Admin-only** — only for members of `app_sso_directory_admin` / `app_sso_admin`: `vmid`, `macAddress`, `installPath`, `systemdService`, and the OAuth config keys (`redirect_uris`, `scopes`, `allowed_groups`, `token_lifetime`).
- **Never returned**`client_secret_hash`, plus any key matching `/secret|password|privatekey/i`. Stripped on every path, admins included.
Note that machine tokens are deliberately *not* admins, so anything a machine consumer needs (the firewall generator reads `port` / `externalPort` / `isExternalReachable`) has to be in the public tier. A metadata key that isn't declared at all is treated as admin-only and will silently vanish for normal users — if you add a field to the admin form, declare it in the schema package too.
## Catalog & access requests
The site root (`/`) is the end-user catalog — the only ungated page in the nav. It shows:
- **My Access** — everything the signed-in user can reach (`GET /api/discovery/me`), each card carrying a **how to reach it** block: the URL for a service, or the SSH invocation for a host. When `directory.jumpHost` is set in the config, host cards render the jump-host form `ssh <uid>_-_<slug>@<jumpHost>`; otherwise they fall back to a direct `ssh <uid>@<ip>`.
- **Discover More** — everything else in the directory, with a **Request access** button.
- **My Requests** / **Awaiting My Approval** — pending requests, and the approve/deny queue for anyone who owns a requested resource.
A request is a proposal to join an LDAP group. It targets the resource's `member`-level group (the `_access` one, never `_admin`), and approving it performs the LDAP group add — so LDAP stays the single access-control truth and the table is just the audit trail. Approvals are idempotent: approving for someone already in the group succeeds rather than erroring.
Requests are decided by the resource's `owner`, or by any directory admin. Mark a resource `metadata.requestable = false` to keep it out of self-service.
## Navigating the UI
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
## Slug conventions
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_admin`), so keep slugs stable once access groups are in use.
## Automatic registration
You don't have to build the graph by hand — the theta42 tooling registers itself:
### The stack itself (theta-env)
[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
- the proxy's auto-registered **OAuth client**, linked under its service
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
### Linux hosts (ldap-client)
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<hostname>` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
## Consumers of the directory
The inventory graph isn't just documentation — other components read it to make decisions:
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=<cn>`), then bridges them in. The `host_<hostname>` slugs and `host_<slug>_access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group.
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
## API
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
- `GET /api/directory-admin/access-summary` — per-resource group + member counts (the Access column)
- `GET /api/directory-admin/user-access/:uid` — the reverse lookup: every resource a given user can reach, and via which group
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
Access requests are open to any authenticated user; deciding is gated per-resource inside the router (resource owner or directory admin):
- `POST /api/access-requests``{slug | resourceId, groupCn?, note?}`
- `GET /api/access-requests/mine` — the caller's own history
- `GET /api/access-requests` — pending requests the caller may decide
- `POST /api/access-requests/:id/approve` · `POST /api/access-requests/:id/deny`
- `DELETE /api/access-requests/:id` — the requester withdraws their own pending request
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 221 KiB

+12 -4
View File
@@ -1,6 +1,7 @@
---
layout: default
title: Home
description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI. One login for your modern apps, one LDAP directory for the rest, no phone-home.
---
# SSO Manager
@@ -21,10 +22,11 @@ one command).
## Screenshots
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Overview dashboard" width="49%"></a>
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth clients" width="49%"></a>
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth client (edit view)" width="49%"></a>
*(click any screenshot to view full size)*
@@ -51,10 +53,14 @@ backend, that's the niche.
- **Web management UI** — users, groups, and OAuth clients from a browser;
invite and password-reset flows over email; self-service profile + API
tokens.
- **LDAPS for legacy apps** — anything that binds LDAP directly (Gitea,
Emby, …) uses LDAPS/StartTLS against the same directory.
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
directory.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
## Get it
@@ -74,5 +80,7 @@ That's the standalone quick start. For the full set of install options
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
reverse proxy, designed to sit in front of this SSO.
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that
uses this SSO's directory to decide who may reach which machine.
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
Manager and the proxy together with one command.
+199 -31
View File
@@ -1,16 +1,22 @@
---
layout: default
title: LDAP
description: SSO Manager's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly.
---
# LDAP Directory
[← Back to Home](index.html)
> Looking for a plainer explanation of accounts, groups, and managers
> instead of schema/attribute detail? See
> [Accounts, Groups & Managers](concepts-accounts.html).
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
authenticates against it over `localhost:389` (inside the all-in-one container)
and exposes **LDAPS** (`ldaps://…:636`, TLS) for legacy apps that bind LDAP
directly — Gitea, Emby, the theta42/proxy, etc.
and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
theta42/proxy, etc.
## Directory layout
@@ -34,6 +40,15 @@ User entries are `cn=<uid>,ou=people,<base>` and carry the objectClasses:
- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
- `theta42Person` (custom auxiliary; `dateOfBirth`).
Every user (person or service account) also carries a `manager` attribute
(the standard COSINE `manager`, `SUP distinguishedName`) — one or more DNs of
the people who created/administer that account. Set automatically to the
creator's DN on signup (whoever an admin was logged in as, or whoever sent
the invite), and reassignable later from the account's Edit form. Anyone
listed as a `manager` can edit that account (same fields an admin can:
mobile, description, SSH key, date of birth, home directory, login shell,
and the manager list itself) without needing `app_sso_admin`.
Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64),
verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the
canonical hasher; if you provision users out-of-band, hash passwords the same
@@ -44,18 +59,64 @@ way or use `slappasswd -h '{SSHA512}'`.
Groups are `cn=<name>,ou=groups,<base>` (`groupOfNames`) with a `member`
attribute listing member DNs. The `memberOf` overlay populates reverse
membership (`memberOf` on the user); `refint` keeps it consistent on
add/remove. **Admin permission checks read the group's `member` list**, not
`memberOf` on the user.
add/remove.
The SSO requires three groups (seeded automatically by the entrypoint /
`install.sh`):
Note that `groupOfNames` requires **at least one member**, which has two
consequences worth knowing: whoever creates a group is automatically seeded
into it, and removing the last member (user *or* nested group) is refused with
a 409 rather than leaving an invalid entry behind.
### Nested groups
A `member` DN may be another group's, not just a user's — that is how nesting
is stored, with no extra schema. Everyone in the nested group is a member of
the outer one, at any depth. Manage it on the **Groups** page under each
group's *Nested* tab, or via the API:
```
PUT /api/group/:group/nested/:child nest :child inside :group
DELETE /api/group/:group/nested/:child un-nest
GET /api/group/:group/effective direct users, nested groups, and the
full transitive set of users
```
Cycles are refused (409) rather than truncated — a loop makes "who is in this
group" unanswerable. Two standing relationships are wired automatically: the
cross-app `app_super_admin` is nested into every resource's `<slug>_admin`
group, and each `<slug>_admin` into its `<slug>_access` group, so administering
something implies being able to use it.
**Resolving nesting is a client-side job on stock OpenLDAP.** No 2.6.x release
can evaluate nested groups; `memberOf` and a `(member=X)` filter both return
direct membership only. The bundled slapd is therefore built from source with
the `nestgroup` overlay (see *Modules + overlays* below), and the app is told so
via `ldap.nestedGroupsServerSide`. Against any other server the app computes the
closure itself — same answers, more queries. Either way, **never read `memberOf`
directly to make an access decision**; use `utils/user_groups.js`'s `groupCns()`,
which is correct in both modes.
### Personal groups
Every user (person or service account) also gets a **personal Unix group**
at creation — `cn=<uid>,ou=groups,<base>`, `objectClass: posixGroup` (RFC
2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a
different schema than the `groupOfNames` groups above — its membership
attribute is `memberUid` (a bare username, not a DN), and unlike
`groupOfNames` it's valid with zero members. It's excluded from the
`/groups` page (which filters on `objectClass=groupOfNames`) and managed
instead from the owning user's own profile page ("Members of `<uid>`'s
group", admin-only) — add other accounts as supplementary members, e.g. to
share write access to files owned by this group.
The SSO seeds these groups automatically (entrypoint / `install.sh`):
| Group | Grants |
|-------|--------|
| `app_super_admin` | cross-app super admin. Nested into the three below, so its members hold those rights transitively rather than by a special case in app code — and the privilege is visible to LDAP-native consumers (SSSD, sudo) too. |
| `app_sso_admin` | full admin (users, groups, settings) |
| `app_sso_oauth_admin` | OAuth client management |
| `app_sso_invite` | invitation management |
| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below) |
| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below). Deliberately **not** nested into, since it changes how an account is displayed rather than what it may do. |
## TLS (LDAPS / StartTLS)
@@ -91,35 +152,116 @@ volumes:
The entrypoint leaves existing certs untouched (idempotent).
## Choosing the LDAPS hostname
The `/integrations` page advertises an **LDAPS URL** for direct LDAP binds. By
default it derives that URL from the public OAuth issuer (e.g.
`https://sso.example.com``ldaps://sso.example.com:636`). That is convenient,
but it implies LDAP clients reach your directory through the same public
hostname — which usually means port-forwarding 636 through your router.
**Do not port-forward LDAPS (636) to the public internet.** LDAP simple binds
have no rate limiting and are a brute-force target. Instead, use one of these
internal-only patterns and set `conf.ldap.ldapsHost` (or
`app_ldap__ldapsHost`) so the `/integrations` page shows the right URL.
### 1. Same Docker / local network host (best for apps on this machine)
If the LDAP client runs on the same Docker network as the SSO Manager (for
example, the bundled `theta-env` stack), use the internal service name:
```
ldaps://sso-manager:636
```
In `conf/secrets.js`:
```javascript
ldap: {
ldapsHost: 'sso-manager',
ldapsPort: 636,
}
```
The proxy in theta-env already uses this internally. The bundled slapd cert
includes `sso-manager` in its SAN when `LDAP_CERT_CN` is left at its default,
so hostname verification works without extra setup.
### 2. LAN host behind your router (best for separate home-lan machines)
Create an internal-only DNS record — e.g. `ldap.internal.example.com`
`192.168.1.10` — using your router, Pi-hole, or a local `hosts` file. Then get
or generate a cert whose SAN/CN matches that internal name:
- **Let's Encrypt wildcard** (`*.internal.example.com`) works if you own the
public domain and can complete DNS-01 challenge; the record itself can stay
private/routable only inside your LAN.
- **Internal CA** is fine for a pure LAN: run a small CA, issue a cert for
`ldap.internal.example.com`, and distribute the CA cert to clients.
- **Self-signed** with `LDAP_CERT_CN=ldap.internal.example.com` also works; copy
the generated `ldap.crt` to each client and trust it.
In `conf/secrets.js`:
```javascript
ldap: {
ldapsHost: 'ldap.internal.example.com',
ldapsPort: 636,
}
```
The URL on `/integrations` becomes `ldaps://ldap.internal.example.com:636`.
### 3. Public hostname (acceptable only behind a VPN/firewall)
If a remote host must bind LDAP, put it behind a VPN (Tailscale, WireGuard,
etc.) or a tightly locked-down firewall rule. In that case the public hostname
may be appropriate, but the LDAPS port should still not be reachable from the
open internet.
### Why not just use the LDAP server's IP address?
TLS clients verify the server name against the certificate. Connecting to
`ldaps://192.168.1.10:636` with a cert issued for `*.internal.example.com`
will fail hostname verification unless you disable cert checks — which removes
most of the security benefit of LDAPS. Always use a hostname that matches the
cert.
## Service accounts
There are two different kinds of "not a real person" account, and which one
you want depends on what's consuming it:
A service account is a normal `posixAccount` for something that isn't a
person: a media manager, a torrent client, a service like Emby, or a
read-only bind account an app uses to look users up — anything that needs a
real `uidNumber`/`gidNumber` to own files, or that other accounts join via a
group for write access (e.g. a `stuff_manager` group granting write rights
to a media library). There's only one kind — every account, person or
service, is a real `posixAccount` with a UID.
**LDAP bind-only** — for an app that just needs to bind LDAP to look users up
(its own "LDAP authentication" settings page, or the read-only account
`theta42/ldap-client` binds as). Not a `posixAccount` — no `uidNumber`, no
home directory, can't log into this UI. Create one from the
**Integrations → LDAP** tab's *Service Accounts* section (create, rotate
password, delete). theta-env's bootstrap creates `cn=ldapclient` this same
way automatically, and the proxy binds as it — don't reuse the admin DN for
this.
Create one from the **Users → Service Accounts** tab's "Add new user" form
with **This is a service account** checked — it skips the birthday/
Terms-of-Service fields a real person's account needs and asks for just an
account name. It's flagged (via membership in the `app_sso_service_account`
group) so it's listed separately from real people and excluded from "all
users" notification broadcasts.
**Unix/POSIX** — for an account something actually *runs as* on a Linux
host: a media manager, a torrent client, a service like Emby — anything that
needs a real `uidNumber`/`gidNumber` to own files or that other accounts join
via a group for write access (e.g. a `stuff_manager` group granting write
rights to a media library). Create one from the **Users** page's "Add new
user" form with **This is a service account** checked — it skips the
birthday/Terms-of-Service fields a real person's account needs and asks for
just an account name. It's a normal `posixAccount`, just flagged (via
membership in the `app_sso_service_account` group) so it's visibly marked in
the Users list and excluded from "all users" notification broadcasts.
Email and password are both optional for a service account:
Either way: don't reuse the admin DN, and give it only the group memberships
it actually needs.
- No `mail` is set unless you give it one (it never needs a mailbox).
- Leaving the password blank is fine — no `userPassword` attribute is set at
all, and an entry with no `userPassword` simply can't bind with any
password (standard LDAP simple-bind behavior). Only set a password if the
account actually needs to authenticate as itself (e.g. a bind-only account
an app uses to look users up).
Example bind test (LDAP bind-only account):
theta-env's bootstrap creates its own `cn=ldapclient` bind account directly
against LDAP (independent of this app), and the proxy binds as it — that
account won't show up in the Service Accounts tab since it isn't managed
through this app, but it keeps working unchanged.
Either way: don't reuse the admin DN, and give a service account only the
group memberships and `manager`s it actually needs.
Example bind test (a service account with a password set):
```bash
ldapsearch -x -H ldaps://sso.example.com:636 \
@@ -199,11 +341,37 @@ needs:
- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`),
`ppolicy`, `memberof`, `refint`.
- **Optional — `nestgroup`:** server-side nested-group evaluation. Not in any
released OpenLDAP (added to master as ITS#10161 in March 2024; 2.7 is still
unreleased), so the bundled image builds slapd from a pinned upstream commit.
Without it the app resolves nesting itself and everything still works — leave
`ldap.nestedGroupsServerSide` at `false`. With it, set that to `true` and
configure:
```
overlay nestgroup
nestgroup-base ou=groups,<base>
nestgroup-flags member-filter memberof-filter memberof-values
```
Flags are **space-separated**; the comma form the man page's `{a, b, c}`
notation suggests is rejected. `member-values` is deliberately omitted — it
expands the `member` attribute when reading a group, which destroys the
distinction between "listed here" and "reachable through a nested group", and
the raw values are then unrecoverable. Transitive answers come from the filter
flags and from `GET /api/group/:group/effective`.
One more consequence of building from master: it ships **LMDB 1.0.0**, whose
on-disk format is mutually unreadable with the 0.9.x in 2.6.x
(`MDB_INVALID: File is not an LMDB file`). Moving a directory between the two
is a `slapcat``slapadd` reload, not a restart.
- **Custom schema:** the `theta42Person` auxiliary objectClass with
`dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
a default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`.
- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`,
and `app_super_admin` (the cross-app super-admin group; the bundled entrypoint
also nests it into the first three).
`ops/ldap-setup.sh -p <admin-password>` configures all of the above
idempotently against a running slapd (auto-detects the database holding your
+15 -12
View File
@@ -1,12 +1,17 @@
---
layout: default
title: OAuth / OIDC
description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery document, client registration, and token endpoints.
---
# OAuth 2.0 / OpenID Connect
[← Back to Home](index.html)
> Looking for a plainer explanation of clients/scopes/redirect URIs instead
> of endpoint-level detail? See
> [Connecting Apps (Single Sign-On)](concepts-oauth-apps.html).
SSO Manager is an **OpenID Connect / OAuth 2.0 provider**: it issues its own
access, refresh, and ID tokens that your apps can consume to authenticate
users and authorize API calls. It also runs a full OpenLDAP directory, so it
@@ -48,20 +53,18 @@ An OAuth client represents an app that authenticates against the SSO. Each has:
### Managing clients
Clients are managed from the web UI (as a member of the `app_sso_oauth_admin`
group) or the HTTP API at `/api/oauth/client` (auth via the `auth-token` header
from a login):
Clients are managed directly from the **Directory** tab in the web UI. They are modeled as resources of `kind: oauth` and must belong to a parent Service.
| Method | Path | Action |
|--------|------|--------|
| `GET` | `/api/oauth/client` | list clients |
| `POST` | `/api/oauth/client` | create a client (returns the raw `client_secret` once) |
| `GET` | `/api/oauth/client/:id` | get one |
| `PUT` | `/api/oauth/client/:id` | update redirect URIs / scopes / groups |
| `DELETE` | `/api/oauth/client/:id` | delete |
| `POST` | `/api/oauth/client/:id/rotate` | rotate the secret (returns the new raw secret once) |
| Action | How to do it |
|--------|--------------|
| **Create** | Click the green **+** on a parent Service to add a child resource. Choose **OAuth Integration**. The raw `client_secret` is shown once upon creation. |
| **Edit** | Click the edit pencil on the OAuth resource in the Directory list or tree. You can update redirect URIs, scopes, allowed groups, and token TTLs. |
| **Delete** | Click the trash can on the OAuth resource in the Directory list. |
| **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. |
> All client-management endpoints are gated by the `app_sso_oauth_admin` group.
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
## Scopes
+132
View File
@@ -0,0 +1,132 @@
# Plugins
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
**type** is an installed module; a plugin **instance** is a configured, loadable
copy of a type. You can create, edit, load/unload, run, and delete instances
from the **Plugins** page (or the `/api/plugins` API), and you can run several
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
and token on its own schedule.
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
ever shows them masked (`********`); the plugin reads them at run time. This
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
## Plugin types
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
filename basename (without `.js`) is the `type`; the parent directory is the
`category`. The built-ins ship under `plugins/discovery/`:
- `proxmox` — Proxmox VE (URL + API token)
- `unifi` — UniFi Network controller (URL + username/password)
- `nmap` — nmap OS + port scan (a target range; no credentials)
A module exports a **manifest**:
```javascript
module.exports = {
// Identity — `type`/`category` default to the file/dir name but can be set
// explicitly. `name`/`description` show up in the UI.
type: 'proxmox',
category: 'discovery',
name: 'Proxmox VE',
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
// Drives the admin UI form, API validation, and secret masking. Fields with
// `secret: true` are stored in OpenBao; the rest live in the DB row.
configSchema: [
{ key: 'url', label: 'API URL', type: 'url', required: true },
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
],
// "Test" button: validate the config (don't do the work). Return
// { ok: true } or { ok: false, error: '...' }. Optional.
validate: async (config) => { … },
// The work. `run` is the generalized contract name; the discovery plugins
// also keep `discover` as an alias for back-compat. For `category:
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
run: async (config) => { return { resources, edges }; },
discover: async (config) => { return { resources, edges }; }
};
```
`run(config)` receives the merged non-secret config + secret values as one flat
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
returns `{ resources, edges }`; the reconciler upserts them into the resource
graph attributed to the instance's **slug** (the `discovery_sources` name).
### Writing a custom plugin type
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
following the manifest above. New types are picked up at boot, so restart the
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
adding a new type still needs a restart.
## The Plugins page
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
/ `app_super_admin`):
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
source name + the URL the resource graph attributes results to), set a cron
schedule, and fill in the config form (secret fields are password inputs).
Creating it schedules it and kicks one immediate run.
- **Edit** — name, cron, and non-secret config.
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
field blank to keep its current value.
- **Test** (vial icon) — runs the plugin's `validate`.
- **Run now** (play icon) — enqueues one immediate run regardless of state.
- **Load / Unload** — enable/disable the schedule without deleting the instance.
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
## API
All endpoints are mounted at `/api/plugins`, require an authenticated admin
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
secret values masked.
| Method + path | Purpose |
|---|---|
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
| `GET /api/plugins/:id` | one instance |
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
| `POST /api/plugins/:id/test` | run `validate``{ ok }` or `{ ok:false, error }` |
| `POST /api/plugins/:id/load` | enable + schedule + run now |
| `POST /api/plugins/:id/unload` | unschedule + disable |
| `POST /api/plugins/:id/run` | enqueue one immediate run |
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
## Scheduler internals
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
that one schedule without disturbing the others. A daily `garbage_collect` job
prunes discovery resources not seen in > 7 days.
### Legacy migration
Before this system, plugins were configured statically in `sso-secrets.js`:
```javascript
module.exports = {
discovery: {
plugins: {
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
}
}
};
```
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
empty **and** `conf.discovery.plugins` has entries, one instance per configured
type is seeded automatically (secret fields copied into OpenBao). After that the
table is non-empty and the static config is ignored — manage plugins from the
UI/API instead. The migration is idempotent (guarded by the empty-table check).
+55
View File
@@ -0,0 +1,55 @@
---
layout: default
title: Geo-Location Scaling (Replication)
---
# Geo-Location Scaling (Replication)
SSO Manager is built to be a self-contained identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability.
## Why and when to use this?
- **High Availability (HA)**: If your primary site goes completely offline, your other sites can still authenticate users locally without depending on a WAN link.
- **Low Latency**: Applications at a remote site can bind directly to their local LDAP server (`localhost` or LAN IP) instead of traversing the internet to query the primary site, making logins blazing fast.
- **Independent Failure Domains**: By replicating only the LDAP directory (the source of truth) and keeping session state (Redis) independent, you prevent complex "split-brain" scenarios in the web UI. A failure at Site A won't bring down Site B.
By default, the `sso-manager` Docker container runs a single, independent OpenLDAP instance. However, you can enable **N-Way Multi-Master Replication** via environment variables.
## How it works
In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (`slapd`).
- **Reads and Writes anywhere**: A user can change their password or update their profile at Site A, Site B, or Site C.
- **Conflict Resolution**: OpenLDAP's `syncrepl` engine uses Context Sequence Numbers (CSN) to track changes. If Site A goes offline and a user changes their password at Site B, Site A will automatically pull the newest changes the moment it rejoins the cluster.
- **Independent Redis**: Session data, API Tokens, and OAuth Clients are stored in Redis. By design, Redis is NOT replicated in this geographic setup. This ensures that a failure at Site A never causes Site B's Redis to become read-only, which would break the web UI at Site B. OAuth clients must be configured per-site.
## Configuration
To enable replication, you must pass two environment variables to the `sso-manager` container:
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
### Example using `theta-env` / Docker Compose
**Site 1 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=1
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
```
**Site 2 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=2
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
```
**Site 3 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=3
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
```
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
## User Locations
When creating or editing a user, you can specify their **Location (Site)**. This maps directly to the standard LDAP `l` (localityName) attribute, allowing you to track which physical site a user belongs to natively within the directory.
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://theta42.github.io/sso-manager-node/sitemap.xml
+38
View File
@@ -0,0 +1,38 @@
# Vault Secrets Management
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
## Usage
You can access the Vault UI from the application's top navigation bar.
### Creating Secrets
1. Click on the **New Secret** button.
2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`).
3. Enter the **Secret Data** in JSON format. For example:
```json
{
"username": "admin",
"password": "supersecretpassword123"
}
```
4. Click **Save Secret**.
### Reading and Editing Secrets
* To view a secret, click on its name in the **Secrets List**.
* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes.
### OpenBao Integration
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
## API Access
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
```bash
# Example: Read a secret via the API
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
```
+213 -702
View File
@@ -1,719 +1,230 @@
#!/usr/bin/env bash
# install.sh - Idempotent standalone installer for Theta42 SSO Manager
# For Debian/Ubuntu systems
#
# This script:
# 1. Installs Node.js 20.x
# 2. Installs and configures OpenLDAP with required schemas/overlays
# 3. Deploys the SSO Manager application
# 4. Sets up systemd services
# Install / update Theta42 SSO Manager on a fresh or existing host.
#
# Usage:
# sudo ./install.sh [OPTIONS]
# This script is idempotent: run it to install, and re-run it to update. It
# installs system dependencies (Node, OpenLDAP, Redis), force-syncs the repo at
# $REPO_DIR to its remote branch, and symlinks the systemd config straight from
# the repo. Because the config is symlinked, an update is just "sync the repo +
# restart" -- the files under /etc/systemd always track the repo.
#
# Options:
# -p, --admin-pass PASSWORD LDAP admin password (required, or set via LDAP_ADMIN_PASS env)
# -b, --base-dn DN Base DN (default: dc=example,dc=com)
# -n, --org-name NAME Organization name shown in UI/email (default: SSO Manager)
# -o, --port PORT HTTP port for SSO Manager (default: 3001)
# -j, --jwt-secret SECRET JWT secret for OAuth (default: auto-generated)
# -s, --smtp-config CONFIG SMTP config as host:port:user:pass
# --skip-ldap Skip LDAP installation (use existing LDAP)
# --skip-app Skip application installation (LDAP setup only)
# --dry-run Show what would be done without making changes
# -h, --help Show this help
# Secrets live at $SECRETS_FILE (/etc/sso-manager/secrets.js by default),
# outside the repo checkout so they survive the hard reset below. FIRST RUN
# ONLY (no $SECRETS_FILE yet): installs and configures OpenLDAP (modules,
# overlays, custom schema, directory tree, required SSO groups -- see
# ops/ldap-setup.sh), generates an LDAP admin password + JWT secret unless
# given via env, and seeds $SECRETS_FILE with those values plus SMTP
# placeholders. Edit that file (SMTP, org name, ...) and re-run this script to
# apply changes -- once it exists it is never touched again, and LDAP is never
# re-bootstrapped.
#
# Environment variables (alternative to flags):
# LDAP_ADMIN_PASS, LDAP_BASE_DN, PORT, JWT_SECRET, SMTP_*
# Intended to be driven by CI/CD with no human writes on prod: the checkout is
# hard-reset to origin/$BRANCH on every run, so the box deterministically
# mirrors the repo (any drift on the box is discarded).
#
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=,
# SECRETS_FILE=, LDAP_BASE_DN=, LDAP_ADMIN_PASS=,
# JWT_SECRET=, ORG_NAME=, PORT=, SKIP_LDAP=true)
set -euo pipefail
# Never block on an interactive git credential prompt in CI.
export GIT_TERMINAL_PROMPT=0
# Never block on an interactive debconf prompt (e.g. tzdata, pulled in as a
# dependency of redis-server/slapd on a box that's never configured it).
export DEBIAN_FRONTEND=noninteractive
# ── Defaults ──────────────────────────────────────────────────────────────────
BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}"
ADMIN_PASS="${LDAP_ADMIN_PASS:-}"
REPO_URL="${REPO_URL:-https://github.com/theta42/sso-manager-node.git}"
REPO_DIR="${REPO_DIR:-/opt/theta42/sso-manager}"
BRANCH="${BRANCH:-master}"
NODE_MAJOR=22
SECRETS_FILE="${SECRETS_FILE:-/etc/sso-manager/secrets.js}"
LDAP_BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}"
ORG_NAME="${ORG_NAME:-SSO Manager}"
PORT="${PORT:-3001}"
JWT_SECRET="${JWT_SECRET:-}"
SMTP_HOST="${SMTP_HOST:-}"
SMTP_PORT="${SMTP_PORT:-587}"
SMTP_USER="${SMTP_USER:-}"
SMTP_PASS="${SMTP_PASS:-}"
SKIP_LDAP="${SKIP_LDAP:-false}"
SKIP_APP="${SKIP_APP:-false}"
DRY_RUN="${DRY_RUN:-false}"
INSTALL_DIR="/opt/sso-manager"
SYSTEMD_DIR="/etc/systemd/system"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# ── Helper functions ──────────────────────────────────────────────────────────
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
dry_run() { if [[ "$DRY_RUN" == "true" ]]; then echo "[DRY-RUN] $*"; fi; }
usage() {
grep '^#' "$0" | sed 's/^# \{0,1\}//'
exit 0
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-p|--admin-pass)
ADMIN_PASS="$2"
shift 2
;;
-b|--base-dn)
BASE_DN="$2"
shift 2
;;
-n|--org-name)
ORG_NAME="$2"
shift 2
;;
-o|--port)
PORT="$2"
shift 2
;;
-j|--jwt-secret)
JWT_SECRET="$2"
shift 2
;;
-s|--smtp-config)
IFS=':' read -r SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS <<< "$2"
shift 2
;;
--skip-ldap)
SKIP_LDAP="true"
shift
;;
--skip-app)
SKIP_APP="true"
shift
;;
--dry-run)
DRY_RUN="true"
shift
;;
-h|--help)
usage
;;
*)
error "Unknown option: $1"
usage
;;
esac
done
# Validate required parameters
if [[ -z "$ADMIN_PASS" ]]; then
error "LDAP admin password is required (-p or LDAP_ADMIN_PASS env)"
exit 1
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (try: sudo $0)" >&2
exit 1
fi
# Generate JWT secret if not provided
if [[ -z "$JWT_SECRET" ]]; then
JWT_SECRET=$(openssl rand -hex 32)
info "Generated JWT secret: ${JWT_SECRET:0:8}..."
# Symlink $1 -> $2, replacing whatever is already at $2 (idempotent).
link(){
ln -sfn "$1" "$2"
echo "linked $2 -> $1"
}
# Read the "version" field out of a package.json without depending on Node
# being installed yet (this runs before the Node.js install step below).
pkg_version(){
sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
}
# Installed version before this run touches anything, for the upgrade banner
# at the end. Empty on a fresh install (no prior checkout).
CURRENT_VERSION=""
if [ -f "$REPO_DIR/nodejs/package.json" ]; then
CURRENT_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
fi
# Derive the DNS domain from the base DN (dc=foo,dc=bar -> foo.bar) for email
# sender defaults. Override with LDAP_DOMAIN if set.
if [[ -z "${LDAP_DOMAIN:-}" ]]; then
LDAP_DOMAIN=$(echo "$BASE_DN" | sed 's/^dc=//; s/,dc=/./g')
# FIRST_RUN gates OpenLDAP bootstrap + secrets seeding below -- both only ever
# happen once, the first time this script runs on a host (i.e. before
# $SECRETS_FILE exists). Every later run only updates the code.
FIRST_RUN=0
[ -f "$SECRETS_FILE" ] || FIRST_RUN=1
echo "==> Base packages"
apt-get update
apt-get install -y --no-install-recommends \
build-essential redis-server \
wget gnupg ca-certificates curl git
echo "==> Node.js ${NODE_MAJOR}.x apt source"
install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list
echo "==> Install Node.js"
apt-get update
apt-get install -y nodejs
echo "==> Redis"
systemctl enable --now redis-server
echo "==> Repo checkout at ${REPO_DIR} (branch ${BRANCH})"
install -d "$(dirname "$REPO_DIR")"
if [ -d "$REPO_DIR/.git" ]; then
# Force the box to match the remote branch exactly. No human edits configs
# on prod, so discarding local drift is the desired, deterministic behavior.
git -C "$REPO_DIR" fetch --prune origin
git -C "$REPO_DIR" checkout -B "$BRANCH" "origin/$BRANCH"
git -C "$REPO_DIR" reset --hard "origin/$BRANCH"
git -C "$REPO_DIR" clean -fd
else
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
fi
# ── System checks ─────────────────────────────────────────────────────────────
check_root() {
if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (sudo)"
exit 1
fi
}
check_os() {
if [[ ! -f /etc/debian_version ]]; then
error "This script is for Debian/Ubuntu systems only"
exit 1
fi
info "Detected $(cat /etc/os-release | grep PRETTY_NAME | cut -d'"' -f2)"
}
# ── Package installation ──────────────────────────────────────────────────────
install_package() {
local pkg="$1"
if dpkg -l | grep -q "^ii $pkg "; then
info "Package $pkg is already installed"
return 0
fi
dry_run "Would install package: $pkg"
[[ "$DRY_RUN" == "true" ]] && return 0
apt-get update -qq
apt-get install -y -qq "$pkg"
info "Installed $pkg"
}
install_nodejs() {
if command -v node &>/dev/null && node --version | grep -q "v20"; then
info "Node.js 20.x is already installed"
return 0
fi
dry_run "Would install Node.js 20.x"
[[ "$DRY_RUN" == "true" ]] && return 0
info "Installing Node.js 20.x..."
# Use NodeSource repository for Node.js 20.x
apt-get update -qq
apt-get install -y -qq curl gnupg ca-certificates
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1
apt-get install -y -qq nodejs
info "Installed Node.js $(node --version)"
}
# ── OpenLDAP installation and configuration ───────────────────────────────────
install_openldap() {
if command -v slapd &>/dev/null; then
info "OpenLDAP is already installed"
return 0
fi
dry_run "Would install OpenLDAP"
[[ "$DRY_RUN" == "true" ]] && return 0
info "Installing OpenLDAP..."
# Pre-seed debconf for non-interactive installation
debconf-set-selections << EOF
slapd slapd/internal/adminpw string $ADMIN_PASS
slapd slapd/password1 string $ADMIN_PASS
slapd slapd/password2 string $ADMIN_PASS
slapd slapd/domain string ${BASE_DN#dc=}
slapd slapd/backend string MDB
slapd shared/organization string $ORG_NAME
slapd slapd/purge_database boolean true
slapd slapd/move_old_database boolean true
slapd slapd/invalid_config boolean true
EOF
apt-get update -qq
apt-get install -y -qq slapd ldap-utils
# Configure ldap.conf
cat > /etc/ldap/ldap.conf << LDAPCONF
BASE $BASE_DN
URI ldap://localhost
LDAPCONF
# Set proper permissions
chmod 644 /etc/ldap/ldap.conf
info "OpenLDAP installed"
}
configure_openldap() {
info "Configuring OpenLDAP..."
dry_run "Would configure OpenLDAP with base DN: $BASE_DN"
[[ "$DRY_RUN" == "true" ]] && return 0
# Wait for slapd to be ready
for i in {1..10}; do
if ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=*)" dn >/dev/null 2>&1; then
info "OpenLDAP is ready"
break
fi
sleep 1
done
# Detect the database DN for our suffix
DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \
"(&(objectClass=olcDatabaseConfig)(olcSuffix=${BASE_DN}))" dn 2>/dev/null \
| grep "^dn:" | head -1 | sed 's/^dn: //')
if [[ -z "$DB_DN" ]]; then
# Try to find any database and update its suffix
DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \
"(objectClass=olcDatabaseConfig)" dn 2>/dev/null \
| grep "^dn:" | head -1 | sed 's/^dn: //')
if [[ -n "$DB_DN" ]]; then
info "Updating database suffix to $BASE_DN"
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: $DB_DN
changetype: modify
replace: olcSuffix
olcSuffix: $BASE_DN
EOF
fi
fi
if [[ -z "$DB_DN" ]]; then
error "Could not detect OpenLDAP database configuration"
return 1
fi
info "Using database: $DB_DN"
# 1. Load pw-sha2 module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "pw-sha2"; then
info "Loading pw-sha2 module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: pw-sha2
EOF
else
info "pw-sha2 module already loaded"
fi
# 2. Load ppolicy module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "ppolicy"; then
info "Loading ppolicy module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: ppolicy
EOF
else
info "ppolicy module already loaded"
fi
# 3. Load memberof module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "memberof"; then
info "Loading memberof module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{1},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: memberof
EOF
else
info "memberof module already loaded"
fi
# 4. Load refint module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "refint"; then
info "Loading refint module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{1},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: refint
EOF
else
info "refint module already loaded"
fi
# 5. Add ppolicy overlay
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn 2>/dev/null | grep -qi "ppolicy"; then
info "Adding ppolicy overlay..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: olcOverlay=ppolicy,$DB_DN
objectClass: olcOverlayConfig
objectClass: olcPPolicyConfig
olcOverlay: ppolicy
olcPPolicyDefault: cn=ppolicy,ou=policies,$BASE_DN
olcPPolicyUseLockout: TRUE
EOF
else
info "ppolicy overlay already configured"
fi
# 6. Add memberof overlay
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*memberof*)" dn 2>/dev/null | grep -qi "memberof"; then
info "Adding memberof overlay..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: olcOverlay=memberof,$DB_DN
objectClass: olcConfig
objectClass: olcMemberOf
objectClass: olcOverlayConfig
objectClass: top
olcOverlay: memberof
olcMemberOfDangling: ignore
olcMemberOfRefInt: TRUE
olcMemberOfGroupOC: groupOfNames
olcMemberOfMemberAD: member
olcMemberOfMemberOfAD: memberOf
EOF
else
info "memberof overlay already configured"
fi
# 7. Add refint overlay
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*refint*)" dn 2>/dev/null | grep -qi "refint"; then
info "Adding refint overlay..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: olcOverlay=refint,$DB_DN
objectClass: olcConfig
objectClass: olcOverlayConfig
objectClass: olcRefintConfig
objectClass: top
olcOverlay: refint
olcRefintAttribute: memberof member manager owner
EOF
else
info "refint overlay already configured"
fi
# 8. Add database indexes
info "Configuring database indexes..."
for index in "mail eq,sub" "uid eq,sub" "cn eq,sub" "member eq" "uidNumber eq" "gidNumber eq"; do
attr=$(echo "$index" | cut -d' ' -f1)
types=$(echo "$index" | cut -d' ' -f2)
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF || true
dn: $DB_DN
changetype: modify
add: olcDbIndex
olcDbIndex: $attr $types
EOF
done
# 9. Load custom theta42 schema
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=schema,cn=config" "(olcObjectClasses=*theta42Person*)" olcObjectClasses 2>/dev/null | grep -q "theta42"; then
info "Loading custom theta42 schema..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=theta42,cn=schema,cn=config
objectClass: olcSchemaConfig
cn: theta42
olcAttributeTypes: ( 1.3.6.1.4.1.99999.1.1
NAME 'dateOfBirth'
DESC 'Date of birth in ISO 8601 format YYYY-MM-DD'
EQUALITY caseExactMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE )
olcObjectClasses: ( 1.3.6.1.4.1.99999.2.1
NAME 'theta42Person'
DESC 'Theta42 SSO extended person attributes'
AUXILIARY
MAY ( dateOfBirth ) )
EOF
else
info "theta42 schema already loaded"
fi
# 10. Create base directory structure
BIND_DN="cn=admin,$BASE_DN"
# Create base DN if it doesn't exist
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$BASE_DN" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating base DN structure..."
DC_VALUE="${BASE_DN#dc=}"
DC_VALUE="${DC_VALUE%%,*}"
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: $BASE_DN
objectClass: dcObject
objectClass: organization
dc: $DC_VALUE
o: $ORG_NAME
EOF
else
info "Base DN already exists"
fi
# Create OUs
for ou in people groups policies; do
dn="ou=$ou,$BASE_DN"
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating $ou OU..."
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: ou=$ou,$BASE_DN
objectClass: organizationalUnit
ou: $ou
EOF
else
info "OU $ou already exists"
fi
done
# 11. Create default ppolicy
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "cn=ppolicy,ou=policies,$BASE_DN" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating default ppolicy..."
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: cn=ppolicy,ou=policies,$BASE_DN
objectClass: top
objectClass: organizationalRole
objectClass: pwdPolicy
cn: ppolicy
pwdAttribute: 2.5.4.35
pwdLockout: FALSE
pwdMustChange: FALSE
pwdAllowUserChange: TRUE
EOF
else
info "Default ppolicy already exists"
fi
# 12. Create required SSO groups
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
dn="cn=$group,ou=groups,$BASE_DN"
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating group: $group"
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: $dn
objectClass: groupOfNames
objectClass: top
cn: $group
description: $ORG_NAME $group group
member: $BIND_DN
EOF
else
info "Group $group already exists"
fi
done
info "OpenLDAP configuration complete"
}
# ── Application installation ──────────────────────────────────────────────────
install_app() {
info "Installing SSO Manager application..."
dry_run "Would install application to $INSTALL_DIR"
[[ "$DRY_RUN" == "true" ]] && return 0
# Create installation directory
mkdir -p "$INSTALL_DIR"
# Copy application files
info "Copying application files..."
cp -r "$SCRIPT_DIR/nodejs/"* "$INSTALL_DIR/"
# Install npm dependencies
info "Installing npm dependencies..."
cd "$INSTALL_DIR"
npm ci --only=production --quiet
# Create secrets configuration
info "Creating application configuration..."
cat > "$INSTALL_DIR/conf/secrets.js" << SECRETEOF
'use strict';
module.exports = {
port: $PORT,
ldap: {
url: 'ldap://localhost',
bindDN: 'cn=admin,$BASE_DN',
bindPassword: '$ADMIN_PASS',
userBase: 'ou=people,$BASE_DN',
groupBase: 'ou=groups,$BASE_DN',
},
smtp: {
host: '${SMTP_HOST:-localhost}',
port: ${SMTP_PORT:-587},
user: '${SMTP_USER:-}',
pass: '${SMTP_PASS:-}',
from: '${ORG_NAME} <noreply@${LDAP_DOMAIN}>',
},
voipms: {
username: '${VOIPMS_USER:-}',
password: '${VOIPMS_PASS:-}',
did: '${VOIPMS_DID:-}',
},
oauth: {
issuer: '',
jwtSecret: '$JWT_SECRET',
token_lifetime: {
access_token: 3600,
refresh_token: 2592000
}
},
};
SECRETEOF
# Create base configuration
cat > "$INSTALL_DIR/conf/base.js" << BASEEOF
'use strict';
module.exports = {
name: "$ORG_NAME",
userModel: 'ldap',
redis: {
prefix: 'sso_manager_'
},
ldap: {
url: 'ldap://localhost',
bindDN: 'cn=admin,$BASE_DN',
bindPassword: '__IN SECRETS FILE__',
userBase: 'ou=people,$BASE_DN',
groupBase: 'ou=groups,$BASE_DN',
userFilter: '(objectClass=posixAccount)',
userNameAttribute: 'uid'
},
oauth: {
issuer: '',
jwtSecret: '__in secrets file__',
token_lifetime: {
access_token: 3600,
refresh_token: 2592000
}
},
smtp: {
host: 'localhost',
port: 587,
secure: false,
from: '$ORG_NAME <noreply@$LDAP_DOMAIN>',
},
};
BASEEOF
# Set ownership
chown -R root:root "$INSTALL_DIR"
chmod -R 755 "$INSTALL_DIR"
info "Application installed to $INSTALL_DIR"
}
# ── Systemd service configuration ─────────────────────────────────────────────
install_systemd() {
info "Installing systemd service..."
dry_run "Would install systemd service"
[[ "$DRY_RUN" == "true" ]] && return 0
cat > "$SYSTEMD_DIR/sso-manager.service" << UNITEOF
[Unit]
Description=Theta42 SSO Manager
Documentation=file://$INSTALL_DIR/README.md
After=network.target slapd.service
Wants=slapd.service
[Service]
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR
ExecStart=/usr/bin/node $INSTALL_DIR/bin/www
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=NODE_PORT=$PORT
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
UNITEOF
systemctl daemon-reload
systemctl enable sso-manager.service
info "Systemd service installed"
}
# ── Verification ──────────────────────────────────────────────────────────────
verify_installation() {
info "Verifying installation..."
local errors=0
# Check OpenLDAP
if command -v slapd &>/dev/null; then
if systemctl is-active --quiet slapd; then
info "✓ OpenLDAP is running"
else
warn "✗ OpenLDAP is not running"
((errors++))
fi
else
warn "✗ OpenLDAP is not installed"
((errors++))
fi
# Check application
if [[ -d "$INSTALL_DIR" ]]; then
info "✓ Application is installed"
else
warn "✗ Application is not installed"
((errors++))
fi
# Check systemd service
if systemctl is-enabled --quiet sso-manager.service 2>/dev/null; then
info "✓ Systemd service is enabled"
else
warn "✗ Systemd service is not enabled"
((errors++))
fi
if [[ $errors -eq 0 ]]; then
info "Installation verified successfully"
else
warn "Installation completed with $errors issue(s)"
fi
return $errors
}
# ── Main execution ────────────────────────────────────────────────────────────
main() {
echo
echo "=============================================="
echo " Theta42 SSO Manager Installer"
echo "=============================================="
echo
echo "Configuration:"
echo " Base DN: $BASE_DN"
echo " Port: $PORT"
echo " Install dir: $INSTALL_DIR"
echo " Skip LDAP: $SKIP_LDAP"
echo " Skip App: $SKIP_APP"
echo
check_root
check_os
if [[ "$SKIP_LDAP" != "true" ]]; then
echo
info "=== Installing OpenLDAP ==="
install_openldap
configure_openldap
fi
if [[ "$SKIP_APP" != "true" ]]; then
echo
info "=== Installing SSO Manager ==="
install_nodejs
install_app
install_systemd
fi
echo
verify_installation
echo
echo "=============================================="
echo " Installation Complete!"
echo "=============================================="
echo
if [[ "$SKIP_APP" != "true" ]]; then
info "Start the service with: systemctl start sso-manager"
info "View logs with: journalctl -fu sso-manager"
info "Access the UI at: http://localhost:$PORT"
fi
if [[ "$SKIP_LDAP" != "true" ]]; then
echo
info "LDAP Configuration:"
info " Base DN: $BASE_DN"
info " Bind DN: cn=admin,$BASE_DN"
info " Admin pass: (set by you)"
echo
info "Required SSO groups created:"
info " - app_sso_admin"
info " - app_sso_invite"
info " - app_sso_oauth_admin"
fi
echo
}
main
NEW_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
if [ "$FIRST_RUN" -eq 1 ] && [ "$SKIP_LDAP" != "true" ]; then
echo "==> First run: bootstrapping OpenLDAP (base DN: ${LDAP_BASE_DN})"
LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS:-$(openssl rand -base64 24 | tr -d '=+/')}"
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
BIND_DN="cn=admin,${LDAP_BASE_DN}"
# slapd/domain wants a dotted DNS domain (e.g. "example.com"), not the raw
# DN -- "dc=foo,dc=bar" -> "foo.bar". A malformed value here (e.g. the raw
# DN with only the leading "dc=" stripped) makes slapd's postinst hang
# indefinitely instead of failing cleanly.
LDAP_DOMAIN="$(echo "$LDAP_BASE_DN" | sed 's/^dc=//; s/,dc=/./g')"
if ! command -v slapd >/dev/null 2>&1; then
debconf-set-selections <<-EOF
slapd slapd/internal/adminpw password ${LDAP_ADMIN_PASS}
slapd slapd/password1 password ${LDAP_ADMIN_PASS}
slapd slapd/password2 password ${LDAP_ADMIN_PASS}
slapd slapd/domain string ${LDAP_DOMAIN}
slapd shared/organization string ${ORG_NAME}
slapd slapd/purge_database boolean true
slapd slapd/move_old_database boolean true
EOF
apt-get install -y slapd ldap-utils
cat > /etc/ldap/ldap.conf <<-EOF
BASE ${LDAP_BASE_DN}
URI ldap://localhost
EOF
systemctl enable --now slapd
else
echo " slapd already installed -- assuming it already serves ${LDAP_BASE_DN}"
fi
echo "==> Directory structure (ou=people, ou=groups)"
for ou in people groups; do
dn="ou=${ou},${LDAP_BASE_DN}"
if ldapsearch -x -D "$BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "^dn:"; then
echo " ${dn} already exists"
else
ldapadd -x -D "$BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost <<-EOF
dn: ${dn}
objectClass: organizationalUnit
ou: ${ou}
EOF
echo " ${dn} created"
fi
done
echo "==> LDAP modules, overlays, schema, policy, SSO groups"
"$REPO_DIR/ops/ldap-setup.sh" -p "$LDAP_ADMIN_PASS" -b "$LDAP_BASE_DN" -D "$BIND_DN"
echo "==> Seeding ${SECRETS_FILE}"
install -d -m 0750 "$(dirname "$SECRETS_FILE")"
cat > "$SECRETS_FILE" <<-SECRETSEOF
'use strict';
// Generated by install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ). Edit freely --
// this file is never overwritten by a later run of install.sh.
// LDAP admin password + JWT secret below were auto-generated; SMTP is a
// placeholder (email delivery won't work until you fill it in).
module.exports = {
port: ${PORT},
name: '${ORG_NAME}',
ldap: {
url: 'ldap://localhost',
bindDN: '${BIND_DN}',
bindPassword: '${LDAP_ADMIN_PASS}',
userBase: 'ou=people,${LDAP_BASE_DN}',
groupBase: 'ou=groups,${LDAP_BASE_DN}',
},
smtp: {
host: 'smtp.example.com',
port: 587,
secure: false,
user: 'noreply@${LDAP_DOMAIN}',
pass: 'set-me',
from: '${ORG_NAME} <noreply@${LDAP_DOMAIN}>',
},
oauth: {
issuer: '',
jwtSecret: '${JWT_SECRET}',
token_lifetime: {
access_token: 3600,
refresh_token: 2592000,
},
},
};
SECRETSEOF
chmod 600 "$SECRETS_FILE"
echo " seeded ${SECRETS_FILE} (LDAP + JWT are live; SMTP is a placeholder)"
echo " \$EDITOR ${SECRETS_FILE}"
echo " then re-run this script (or: sudo systemctl restart sso-manager)"
elif [ "$FIRST_RUN" -eq 1 ]; then
echo "==> SKIP_LDAP=true -- not bootstrapping OpenLDAP or seeding ${SECRETS_FILE}"
echo " Write it yourself (see secrets.js.example) before starting sso-manager."
else
echo "==> ${SECRETS_FILE} already exists, leaving LDAP + secrets untouched"
fi
echo "==> Symlink systemd config from the repo"
link "$REPO_DIR/ops/systemd/sso-manager.service" /etc/systemd/system/sso-manager.service
echo "==> Node dependencies"
# Deterministic, production-only install from the lockfile. Falls back to a
# plain install if the lockfile and manifest are out of step.
( cd "$REPO_DIR/nodejs" && { npm ci --omit=dev || npm install --omit=dev; } )
echo "==> Services"
systemctl daemon-reload
systemctl enable --now sso-manager.service
systemctl restart sso-manager.service
echo "==> Done."
if [ -z "$CURRENT_VERSION" ]; then
echo " Installed v${NEW_VERSION}."
elif [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
echo " Already up to date (v${NEW_VERSION})."
else
echo " Updated v${CURRENT_VERSION} -> v${NEW_VERSION}."
fi
echo " Update later with: sudo BRANCH=${BRANCH} $0"
+49 -6
View File
@@ -25,6 +25,7 @@ app.contoller = require('./controller');
// Background services (self-initializing on require).
require('./services/update_check');
require('./services/ldap_monitor');
// Push pubsub over the socket and back.
app.onListen.push(function(){
@@ -42,6 +43,9 @@ app.onListen.push(function(){
// socket.broadcast.emit('P2PSub', msg);
});
});
// Initialize Theta Agent WebSockets
require('./routes/api_agent')(app);
});
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
@@ -60,10 +64,16 @@ app.set('trust proxy', 1);
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
// Set as an app local so every res.render has it, including routes that don't
// spread the routers' `values` object.
app.locals.ui = require('./utils/ui');
// Have express server static content( images, CSS, browser JS) from the public
// local folder. maxAge is short since this is the app's own JS/CSS, which
// changes on every deploy and isn't cache-busted/fingerprinted.
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}))
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}));
app.use('/resources', express.static(path.join(__dirname, 'public/resources'), {maxAge: '1h'}));
// Routes for front end content.
app.use('/', require('./routes/index'));
@@ -82,20 +92,40 @@ app.use('/api/user', middleware.auth, require('./routes/user'));
app.use('/api/token', middleware.auth, require('./routes/token'));
app.use('/api/group', middleware.auth, require('./routes/group'));
app.use('/api/service-account', middleware.auth, require('./routes/service_account'));
app.use('/api/notification', middleware.auth, require('./routes/notification'));
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
// Self-service access requests — any authenticated user may ask; deciding is
// gated per-resource inside the router (owner or directory admin).
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
app.use('/api/tos', middleware.auth, require('./routes/tos'));
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
app.use('/api/conf', middleware.auth, require('./routes/api_conf'));
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
// OAuth 2.0 / OpenID Connect
app.use('/oauth', oauthRouter);
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
app.use('/api/oauth', middleware.auth, oauthApiRouter);
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
app.get('/.well-known/openid-configuration', discovery);
app.use('/api/webhook', require('./routes/webhook'));
// Plugin instances — loadable/unloadable, configurable plugin copies with
// per-instance secrets in OpenBao (secret/plugins/*). Admin-only (gated inside
// the router to app_sso_admin / app_sso_directory_admin).
app.use('/api/plugins', middleware.auth, require('./routes/api_plugins'));
// OpenBao vault API. The broker mints a server-side scoped token per user
// (per-user user-<uid> or, for admins, sso-admin), enforces the path prefix
// (scopeGuard), and injects ONLY that token into the proxied request — the
// client's sso auth headers are stripped and never reach OpenBao. Non-admins
// are confined to secret/users/<uid>/*; admins roam all of secret/. The
// admin-only app-token mint route is mounted BEFORE the proxy so it isn't
// shadowed by the catch-all /api/vault proxy.
const vaultBroker = require('./utils/vault_broker');
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
// Catch 404 and forward to error handler. If none of the above routes are
// used, this is what will be called.
@@ -106,7 +136,7 @@ app.use(function(req, res, next) {
next(err);
});
// Error handler. This is where `next()` will go on error
// Error handling
app.use(function(err, req, res, next) {
const SILENT_404S = ['/.well-known/'];
const isSilent404 = err.status === 404 && SILENT_404S.some(p => req.url.startsWith(p));
@@ -118,5 +148,18 @@ app.use(function(err, req, res, next) {
}
res.status(err.status || 500);
res.json({name: err.name, message: err.message});
if (req.accepts('html') && !req.originalUrl.startsWith('/api/')) {
const conf = require('@simpleworkjs/conf');
const buildInfo = require('./utils/build_info');
res.render('error', {
name: conf.name,
title: 'Error',
titleIcon: '',
logo: conf.logo,
error: err,
...buildInfo
});
} else {
res.json({name: err.name, message: err.message});
}
});
+38 -6
View File
@@ -25,13 +25,45 @@ var server = http.createServer(app);
var io = require('socket.io')(server);
app.io = io;
/**
* Listen on provided port, on all network interfaces.
*/
const WebSocket = require('ws');
const wss = new WebSocket.Server({ noServer: true });
server.on('upgrade', (request, socket, head) => {
// We only handle upgrade for /api/agent/ws.
// Socket.IO handles its own upgrades natively because it attaches directly to `server`.
if (request.url.startsWith('/api/agent/ws')) {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
}
});
app.wss = wss;
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
const models = require('../models');
/**
* Initialize ORM, then Listen on provided port, on all network interfaces.
*/
models.initORM().then(() => {
// Overlay secret/sso-manager/conf from OpenBao over the file-loaded conf.
// Fail-soft: if OpenBao is unreachable, conf keeps the ./config/sso-secrets.js
// values and boot continues. (Same position the old conf_manager held, so
// call-time conf readers — which is how sso consumes its secrets — are
// unaffected; nothing in sso captures a secret at require time.)
return require('@simpleworkjs/bao-conf').init({ path: 'sso-manager', conf });
}).then(() => {
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
// Initialize scheduler
const { initScheduler } = require('../services/scheduler');
initScheduler(conf.discovery).catch(err => {
console.error('Failed to initialize scheduler:', err);
});
}).catch(err => {
console.error('Failed to initialize ORM:', err);
process.exit(1);
});
/**
* Normalize a port into a number, string, or false.
+22 -7
View File
@@ -9,6 +9,7 @@
// `app_*` env vars — never commit them here.
module.exports = {
name: "SSO Manager", // displayed in the UI and outbound email
logo: "/static/img/theta42.svg", // shown in the nav/footer; point at your own file under public/ (or an absolute URL) to white-label
userModel: 'ldap', // pam, redis, ldap
redis: {
prefix: 'sso_manager_'
@@ -21,6 +22,18 @@ module.exports = {
groupBase: 'ou=groups,dc=example,dc=com',
userFilter: '(objectClass=posixAccount)',
userNameAttribute: 'uid',
// Hostname/port advertised on the /integrations page for direct-LDAP
// clients. Leave ldapsHost empty to derive it from the OAuth issuer host.
// Set it to an internal-only name (e.g. 'ldap.internal.example.com' or
// 'sso-manager' on the Docker network) so external clients don't need a
// public 636 port forward. See docs/ldap.md.
ldapsHost: '',
ldapsPort: 636,
// True when slapd carries the `nestgroup` overlay, which resolves nested
// groups server-side. Set automatically by docker-entrypoint.sh for the
// all-in-one image; leave false when pointing at a stock OpenLDAP (no
// 2.6.x release ships nestgroup) and the app resolves nesting itself.
nestedGroupsServerSide: false,
// New users/personal groups (see addPosixAccount/addPosixGroup in
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
// Existing entries >= uidGidReservedFloor are ignored when computing
@@ -44,13 +57,15 @@ module.exports = {
password: '__in secrets file__',
did: '__in secrets file__',
},
smtp: {
host: 'localhost',
port: 587,
secure: false,
user: 'noreply@example.com',
pass: '__in secrets file__',
from: 'SSO Manager <noreply@example.com>',
directory: {
// Public SSH jump host fronting the lab, if there is one (the jump-host
// component). When set, a host card in the catalog shows the real
// invocation — `ssh <uid>_-_<slug>@<jumpHost>` — instead of a bare
// `ssh <uid>@<ip>` that only works from inside the LAN. Empty is fine;
// the card falls back to the direct form.
jumpHost: '',
// Default SSH port assumed when a host carries no metadata.sshPort.
defaultSshPort: 22,
},
service: {
updateCheck: {
Binary file not shown.
+132
View File
@@ -0,0 +1,132 @@
# Plugins
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
**type** is an installed module; a plugin **instance** is a configured, loadable
copy of a type. You can create, edit, load/unload, run, and delete instances
from the **Plugins** page (or the `/api/plugins` API), and you can run several
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
and token on its own schedule.
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
ever shows them masked (`********`); the plugin reads them at run time. This
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
## Plugin types
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
filename basename (without `.js`) is the `type`; the parent directory is the
`category`. The built-ins ship under `plugins/discovery/`:
- `proxmox` — Proxmox VE (URL + API token)
- `unifi` — UniFi Network controller (URL + username/password)
- `nmap` — nmap OS + port scan (a target range; no credentials)
A module exports a **manifest**:
```javascript
module.exports = {
// Identity — `type`/`category` default to the file/dir name but can be set
// explicitly. `name`/`description` show up in the UI.
type: 'proxmox',
category: 'discovery',
name: 'Proxmox VE',
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
// Drives the admin UI form, API validation, and secret masking. Fields with
// `secret: true` are stored in OpenBao; the rest live in the DB row.
configSchema: [
{ key: 'url', label: 'API URL', type: 'url', required: true },
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
],
// "Test" button: validate the config (don't do the work). Return
// { ok: true } or { ok: false, error: '...' }. Optional.
validate: async (config) => { … },
// The work. `run` is the generalized contract name; the discovery plugins
// also keep `discover` as an alias for back-compat. For `category:
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
run: async (config) => { return { resources, edges }; },
discover: async (config) => { return { resources, edges }; }
};
```
`run(config)` receives the merged non-secret config + secret values as one flat
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
returns `{ resources, edges }`; the reconciler upserts them into the resource
graph attributed to the instance's **slug** (the `discovery_sources` name).
### Writing a custom plugin type
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
following the manifest above. New types are picked up at boot, so restart the
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
adding a new type still needs a restart.
## The Plugins page
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
/ `app_super_admin`):
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
source name + the URL the resource graph attributes results to), set a cron
schedule, and fill in the config form (secret fields are password inputs).
Creating it schedules it and kicks one immediate run.
- **Edit** — name, cron, and non-secret config.
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
field blank to keep its current value.
- **Test** (vial icon) — runs the plugin's `validate`.
- **Run now** (play icon) — enqueues one immediate run regardless of state.
- **Load / Unload** — enable/disable the schedule without deleting the instance.
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
## API
All endpoints are mounted at `/api/plugins`, require an authenticated admin
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
secret values masked.
| Method + path | Purpose |
|---|---|
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
| `GET /api/plugins/:id` | one instance |
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
| `POST /api/plugins/:id/test` | run `validate``{ ok }` or `{ ok:false, error }` |
| `POST /api/plugins/:id/load` | enable + schedule + run now |
| `POST /api/plugins/:id/unload` | unschedule + disable |
| `POST /api/plugins/:id/run` | enqueue one immediate run |
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
## Scheduler internals
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
that one schedule without disturbing the others. A daily `garbage_collect` job
prunes discovery resources not seen in > 7 days.
### Legacy migration
Before this system, plugins were configured statically in `sso-secrets.js`:
```javascript
module.exports = {
discovery: {
plugins: {
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
}
}
};
```
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
empty **and** `conf.discovery.plugins` has entries, one instance per configured
type is seeded automatically (secret fields copied into OpenBao). After that the
table is non-empty and the static config is ignored — manage plugins from the
UI/API instead. The migration is idempotent (guarded by the empty-table check).
+38
View File
@@ -0,0 +1,38 @@
# Vault Secrets Management
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
## Usage
You can access the Vault UI from the application's top navigation bar.
### Creating Secrets
1. Click on the **New Secret** button.
2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`).
3. Enter the **Secret Data** in JSON format. For example:
```json
{
"username": "admin",
"password": "supersecretpassword123"
}
```
4. Click **Save Secret**.
### Reading and Editing Secrets
* To view a secret, click on its name in the **Secrets List**.
* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes.
### OpenBao Integration
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
## API Access
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
```bash
# Example: Read a secret via the API
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
```
+140
View File
@@ -0,0 +1,140 @@
const { Resource } = require('./models/resource');
const { initORM } = require('./models/index');
async function run() {
await initORM();
const all = await Resource.list();
console.log(`Found ${all.length} resources`);
const byIp = {};
const byName = {};
for (const r of all) {
if (!r.metadata) r.metadata = {};
// gather IPs
const ips = new Set();
if (r.metadata.address) ips.add(r.metadata.address);
if (r.metadata.interfaces) {
r.metadata.interfaces.forEach(i => { if (i.ip) ips.add(i.ip); });
}
for (const ip of ips) {
if (!byIp[ip]) byIp[ip] = [];
byIp[ip].push(r);
}
const nameLower = (r.name || '').toLowerCase();
if (nameLower) {
if (!byName[nameLower]) byName[nameLower] = [];
byName[nameLower].push(r);
}
}
// Find duplicates
const toDelete = new Set();
for (const ip in byIp) {
if (byIp[ip].length > 1) {
// Sort so managed/older is kept
const group = byIp[ip].sort((a, b) => {
const aM = a.metadata?.managed ? 1 : 0;
const bM = b.metadata?.managed ? 1 : 0;
if (aM !== bM) return bM - aM;
return a.created_on - b.created_on;
});
const primary = group[0];
for (let i = 1; i < group.length; i++) {
const sec = group[i];
if (toDelete.has(sec.id) || toDelete.has(primary.id)) continue;
console.log(`Merging ${sec.name} into ${primary.name} due to IP ${ip}`);
// merge metadata
const m1 = primary.metadata || {};
const m2 = sec.metadata || {};
const mergedMeta = { ...m2, ...m1 };
// merge interfaces
const intfs = [...(m1.interfaces||[]), ...(m2.interfaces||[])];
const uniqIntfs = [];
const seenIps = new Set();
for (const intf of intfs) {
if (intf.ip && seenIps.has(intf.ip)) continue;
if (intf.ip) seenIps.add(intf.ip);
uniqIntfs.push(intf);
}
mergedMeta.interfaces = uniqIntfs;
const sources = new Set([...(m1.discovery_sources||[]), ...(m2.discovery_sources||[])]);
mergedMeta.discovery_sources = [...sources];
await primary.update({
metadata: mergedMeta,
description: primary.description || sec.description
});
toDelete.add(sec.id);
}
}
}
for (const name in byName) {
if (byName[name].length > 1) {
// Sort so managed/older is kept
const group = byName[name].sort((a, b) => {
const aM = a.metadata?.managed ? 1 : 0;
const bM = b.metadata?.managed ? 1 : 0;
if (aM !== bM) return bM - aM;
return a.created_on - b.created_on;
});
const primary = group[0];
for (let i = 1; i < group.length; i++) {
const sec = group[i];
if (toDelete.has(sec.id) || toDelete.has(primary.id)) continue;
console.log(`Merging ${sec.name} into ${primary.name} due to name ${name}`);
// merge metadata
const m1 = primary.metadata || {};
const m2 = sec.metadata || {};
const mergedMeta = { ...m2, ...m1 };
// merge interfaces
const intfs = [...(m1.interfaces||[]), ...(m2.interfaces||[])];
const uniqIntfs = [];
const seenIps = new Set();
for (const intf of intfs) {
if (intf.ip && seenIps.has(intf.ip)) continue;
if (intf.ip) seenIps.add(intf.ip);
uniqIntfs.push(intf);
}
mergedMeta.interfaces = uniqIntfs;
const sources = new Set([...(m1.discovery_sources||[]), ...(m2.discovery_sources||[])]);
mergedMeta.discovery_sources = [...sources];
await primary.update({
metadata: mergedMeta,
description: primary.description || sec.description
});
toDelete.add(sec.id);
}
}
}
// Delete merged items
for (const id of toDelete) {
console.log(`Deleting merged resource ${id}`);
const r = all.find(r => r.id === id);
if (r) await r.delete();
}
console.log(`Merged ${toDelete.size} items.`);
process.exit(0);
}
run().catch(console.error);
+17 -4
View File
@@ -9,10 +9,23 @@ async function auth(req, res, next){
// the same /api/* routes the UI uses.
const authz = req.header('authorization') || '';
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
const user = await Auth.checkApiToken(authz.slice(7));
if(user && user.uid){
req.user = user;
return next();
const tokenStr = authz.slice(7);
if (tokenStr.startsWith('sso_')) {
const user = await Auth.checkApiToken(tokenStr);
if(user && user.uid){
req.user = user;
return next();
}
} else {
// Machine token (ServiceToken)
const { ServiceToken } = require('../models/token');
let svcToken;
try { svcToken = await ServiceToken.get(tokenStr); } catch(e) {}
if (svcToken && svcToken.is_valid) {
req.user = { uid: svcToken.resource_id, isMachine: true, name: 'Machine Account' };
req.resourceId = svcToken.resource_id;
return next();
}
}
}
+57
View File
@@ -0,0 +1,57 @@
'use strict';
// Self-service access requests: the "request" half of the directory catalog.
//
// A request is a *proposal to join an LDAP group*. Approving one does exactly
// what an admin would have done by hand -- add the user to `groupCn` -- so LDAP
// remains the single access-control truth and this table is only the paper
// trail of who asked, who decided, and when. Nothing here grants anything on
// its own; a row with status 'approved' whose LDAP write failed is a row that
// grants no access, which is the safe direction.
const { Model } = require('@simpleworkjs/orm');
const STATUS = {
PENDING: 'pending',
APPROVED: 'approved',
DENIED: 'denied',
CANCELLED: 'cancelled',
};
class AccessRequest extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// The requesting user's uid (not dn): dn changes if the directory is
// restructured, uid is the stable handle used everywhere else in the app.
uid: { type: 'string', isRequired: true },
resource: { type: 'hasOne', model: 'Resource' }, // creates resourceId
// The group joining which satisfies this request. Captured at request time
// so a later re-link of the resource's groups can't silently redirect a
// pending approval at a different group than the one that was reviewed.
groupCn: { type: 'string', isRequired: true },
status: { type: 'string', isRequired: true, default: STATUS.PENDING },
note: { type: 'text' },
requestedOn: { type: 'integer' },
decidedBy: { type: 'string' },
decidedOn: { type: 'integer' },
decisionNote: { type: 'text' },
};
// The one request that blocks a new one: same user, same group, still open.
// Denied/cancelled requests deliberately do not block -- circumstances change
// and a user may ask again.
static async findOpen(uid, groupCn) {
const rows = await this.list({ where: { uid, groupCn, status: STATUS.PENDING } });
return rows[0] || null;
}
static async listForUser(uid) {
return this.list({ where: { uid } });
}
static async listPending() {
return this.list({ where: { status: STATUS.PENDING } });
}
}
module.exports = { AccessRequest, STATUS };
+1 -1
View File
@@ -23,7 +23,7 @@ Auth.login = async function(data){
return {user, token}
}catch(error){
console.error("AUTH LOGIN error:", error);
console.error("AUTH LOGIN error:", error.name, error.message);
throw this.errors.login();
}
};
+1 -1
View File
@@ -58,7 +58,7 @@ Mail.sendTemplate = async function(to, template, context, from){
to,
mustache.render(template.subject, context),
mustache.render(template.message, context),
from || (template.from && mustache.render(template.message, context))
from || (template.from && mustache.render(template.from, context))
)
};
+194 -15
View File
@@ -3,23 +3,22 @@
const { Client, Attribute, Change } = require('ldapts');
const { LRUCache } = require('lru-cache');
const conf = require('@simpleworkjs/conf').ldap;
// Connection + escaping from the shared @simpleworkjs/ldap package. Local
// wrappers preserve the no-arg call signatures; see user_ldap.js for rationale.
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
const escapeLDAPSearchValue = escapeFilter;
const escapeLDAPDNValue = escapeDN;
function makeClient() {
return new Client({ url: conf.url });
return _makeClient(conf);
}
async function withClient(fn) {
const client = makeClient();
try {
await client.bind(conf.bindDN, conf.bindPassword);
return await fn(client);
} finally {
await client.unbind().catch(() => {});
}
return _withClient(conf, fn);
}
async function getGroups(client, member){
let memberFilter = member ? `(member=${member})`: ''
let memberFilter = member ? `(member=${escapeLDAPSearchValue(member)})`: ''
let groups = (await client.search(conf.groupBase, {
scope: 'sub',
@@ -35,7 +34,8 @@ async function getGroups(client, member){
}
async function addGroup(client, data){
await client.add(`cn=${data.name},${conf.groupBase}`, {
const safeName = escapeLDAPDNValue(data.name);
await client.add(`cn=${safeName},${conf.groupBase}`, {
cn: data.name,
member: data.owner,
description: data.description,
@@ -112,18 +112,190 @@ async function cachedListDetail() {
return promise;
}
// --- Nested groups -------------------------------------------------------
//
// `groupOfNames.member` holds DNs, and nothing says those DNs must be users --
// a group DN is a perfectly legal member. That is how nesting is stored here:
// as-is, no extra schema, no denormalization, the nesting visible in LDAP
// exactly as an admin entered it.
//
// What LDAP will NOT do is resolve it. The memberof overlay records only
// *direct* membership, and a `(member=<dn>)` filter likewise finds only the
// groups that list the DN literally. So transitivity is computed here, and
// every membership question in the app must go through these helpers or it
// will silently see one level and grant nothing for a nested group.
//
// The whole group set is one subtree search, so the closure is computed in
// memory rather than issuing a query per level. `resolverCache` keeps that
// search off the hot path for bursts; it is cleared by every write below, so
// the only staleness it can introduce is from edits made outside this app.
// Auth decisions ride on this, hence the deliberately short TTL.
const NESTING_TTL_MS = 15 * 1000;
const MAX_NESTING_DEPTH = Number(conf.groupNestingDepth) > 0 ? Number(conf.groupNestingDepth) : 10;
const resolverCache = new LRUCache({ max: 1, ttl: NESTING_TTL_MS, ttlAutopurge: true });
async function allGroupsForResolver() {
const hit = resolverCache.get('all');
if (hit) return hit;
const promise = withClient(async (client) => {
const groups = await getGroups(client);
return groups.map(g => ({ ...g }));
}).then(plain => {
resolverCache.set('all', plain);
return plain;
}).catch(err => {
resolverCache.delete('all');
throw err;
});
resolverCache.set('all', promise);
return promise;
}
const lc = dn => String(dn || '').toLowerCase();
// dn -> [groups that list dn as a member]. One pass, reused for every lookup.
function buildParentIndex(groups) {
const parents = new Map();
for (const group of groups) {
for (const member of [].concat(group.member || []).filter(Boolean)) {
const key = lc(member);
if (!parents.has(key)) parents.set(key, []);
parents.get(key).push(group);
}
}
return parents;
}
// Every group `dn` belongs to, directly or through any chain of nested groups.
// Breadth-first with a visited set, so a cycle (A in B, B in A) terminates
// instead of hanging, and MAX_NESTING_DEPTH bounds a pathological chain.
function closureUp(dn, groups) {
const parents = buildParentIndex(groups);
const found = new Map(); // cn -> group
const seen = new Set([lc(dn)]);
let frontier = [lc(dn)];
for (let depth = 0; depth < MAX_NESTING_DEPTH && frontier.length; depth++) {
const next = [];
for (const current of frontier) {
for (const group of parents.get(current) || []) {
const groupDn = lc(group.dn);
if (seen.has(groupDn)) continue;
seen.add(groupDn);
found.set(group.cn, group);
// The group itself is now a member to look up: this is the step
// that makes the walk transitive rather than one-level.
next.push(groupDn);
}
}
frontier = next;
}
return [...found.values()];
}
// Every member DN reachable from a group, split into the users it effectively
// grants and the groups it nests. `direct` is kept separate so the UI can show
// "3 members, 12 effective" and so removal stays unambiguous.
function closureDown(group, groups) {
const byDn = new Map(groups.map(g => [lc(g.dn), g]));
const users = new Set();
const nested = new Map();
const seen = new Set([lc(group.dn)]);
let frontier = [group];
for (let depth = 0; depth < MAX_NESTING_DEPTH && frontier.length; depth++) {
const next = [];
for (const current of frontier) {
for (const member of [].concat(current.member || []).filter(Boolean)) {
const key = lc(member);
const asGroup = byDn.get(key);
if (asGroup) {
if (seen.has(key)) continue;
seen.add(key);
nested.set(asGroup.cn, asGroup);
next.push(asGroup);
} else {
users.add(member);
}
}
}
frontier = next;
}
return { users: [...users], nested: [...nested.values()] };
}
var Group = {};
// Set when slapd carries the nestgroup overlay (docker-entrypoint.sh exports
// app_ldap__nestedGroupsServerSide=true after detecting nestgroup.so). With it,
// a plain `(member=<dn>)` search already returns the full transitive set and the
// in-app closure is redundant work on every request. Without it -- e.g. pointed
// at a stock 2.6.x server, which no release ships nestgroup in -- the app must
// compute the closure itself or nested groups silently grant nothing.
const SERVER_SIDE_NESTING = String(conf.nestedGroupsServerSide) === 'true';
// Transitive: every group CN this member belongs to, at any nesting depth.
// Callers making an access decision must use this rather than reading
// `memberOf`, which a server without nestgroup only ever populates one level
// deep.
Group.list = async function(member){
if (member) {
return withClient(async (client) => {
const groups = await getGroups(client, member);
return groups.map(group => group.cn);
});
if (SERVER_SIDE_NESTING) {
return withClient(async (client) => {
const groups = await getGroups(client, member);
return groups.map(group => group.cn);
});
}
const groups = await allGroupsForResolver();
return closureUp(member, groups).map(group => group.cn);
}
return (await cachedListDetail()).map(group => group.cn);
}
// The members a group effectively grants: users reached through any chain of
// nested groups, plus the nested groups themselves for display.
Group.effectiveMembers = async function(cn){
const groups = await allGroupsForResolver();
const group = groups.find(g => g.cn === cn);
if (!group) {
let error = new Error('GroupNotFound');
error.name = 'GroupNotFound';
error.message = `LDAP:${cn} does not exists`;
error.status = 404;
throw error;
}
const { users, nested } = closureDown(group, groups);
const directMembers = [].concat(group.member || []).filter(Boolean);
const groupDns = new Set(groups.map(g => lc(g.dn)));
return {
cn: group.cn,
direct: directMembers.filter(dn => !groupDns.has(lc(dn))),
nestedGroups: nested.map(g => ({ cn: g.cn, dn: g.dn })),
effective: users,
};
};
// Would adding `childDn` to `parentCn` create a cycle? A group may not contain
// itself, nor anything that already (transitively) contains it -- such a chain
// makes membership unanswerable, and callers would rely on the depth cap to
// stop rather than getting a real answer.
Group.wouldCycle = async function(parentCn, childDn){
const groups = await allGroupsForResolver();
const parent = groups.find(g => g.cn === parentCn);
if (!parent) return false;
if (lc(parent.dn) === lc(childDn)) return true;
const child = groups.find(g => lc(g.dn) === lc(childDn));
if (!child) return false; // a user DN can never close a cycle
// Adding child under parent is a cycle exactly when parent is already
// reachable downward from child.
const { nested } = closureDown(child, groups);
return nested.some(g => lc(g.dn) === lc(parent.dn));
};
Group.clearResolverCache = function(){ resolverCache.clear(); };
Group.listDetail = async function(member){
if (member) {
return withClient(async (client) => getGroups(client, member));
@@ -139,9 +311,10 @@ Group.get = async function(data){
}
return withClient(async (client) => {
const safeName = escapeLDAPSearchValue(data.name);
let group = (await client.search(conf.groupBase, {
scope: 'sub',
filter: `(&(objectClass=groupOfNames)(cn=${data.name}))`,
filter: `(&(objectClass=groupOfNames)(cn=${safeName}))`,
attributes: ['cn', 'description', 'member', 'owner', 'createTimestamp', 'modifyTimestamp'],
})).searchEntries[0];
@@ -165,6 +338,7 @@ Group.add = async function(data){
return withClient(async (client) => {
await addGroup(client, data);
cache.clear();
resolverCache.clear();
return this.get(data);
});
}
@@ -173,6 +347,7 @@ Group.addMember = async function(user){
await withClient(async (client) => addMember(client, this, user));
this.member = [].concat(this.member || []).concat([user.dn]);
cache.clear();
resolverCache.clear();
return this;
};
@@ -185,6 +360,7 @@ Group.removeMember = async function(user){
}
this.member = [].concat(this.member || []).filter(dn => dn !== user.dn);
cache.clear();
resolverCache.clear();
return this;
};
@@ -192,6 +368,7 @@ Group.addOwner = async function(user){
await withClient(async (client) => addOwner(client, this, user));
this.owner = [].concat(this.owner || []).concat([user.dn]);
cache.clear();
resolverCache.clear();
return this;
};
@@ -204,12 +381,14 @@ Group.removeOwner = async function(user){
}
this.owner = [].concat(this.owner || []).filter(dn => dn !== user.dn);
cache.clear();
resolverCache.clear();
return this;
};
Group.remove = async function(){
await withClient(async (client) => client.del(this.dn));
cache.clear();
resolverCache.clear();
return true;
}
+35 -4
View File
@@ -1,14 +1,45 @@
'use strict';
const conf = require('@simpleworkjs/conf');
const {setUpTable} = require('model-redis');
const { setUpTable } = require('model-redis');
// Keep model-redis for the ones not yet ported
const Table = setUpTable(conf.redis);
module.exports = Table;
require('./token');
const { Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken } = require('./token');
require('./verification');
require('./oauth_client');
require('./oauth_code');
require('./api_token');
const { init } = require('@simpleworkjs/orm');
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
const { AccessRequest } = require('./access_request');
const { Webhook } = require('./webhook');
const { PluginInstance } = require('./plugin_instance');
async function initORM() {
const ormConf = conf.orm || {
dialect: 'sqlite',
storage: './config/inventory.sqlite',
logging: false
};
ormConf.redis = conf.redis;
console.log('[initORM] Starting ORM initialization...');
try {
await init({
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
console.log('[initORM] ORM initialized successfully');
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
} catch (err) {
console.error('[initORM] ORM initialization failed:', err.message);
throw err;
}
}
module.exports.initORM = initORM;
+118 -31
View File
@@ -1,50 +1,137 @@
'use strict';
const Table = require('.');
const { Resource } = require('./resource');
const bcrypt = require('bcrypt');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const UUID = () => crypto.randomUUID();
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
access_token: 3600,
refresh_token: 2592000
};
class OAuthClient extends Table {
static _key = 'client_id';
static _keyMap = {
'client_id': {default: UUID, type: 'string'},
'client_secret_hash': {isRequired: true, type: 'string', isPrivate: true},
'name': {isRequired: true, type: 'string', min: 1, max: 255},
'description': {default: '', type: 'string'},
'redirect_uris': {default: [], type: 'object'},
'scopes': {default: ['openid', 'profile', 'email', 'groups'], type: 'object'},
'allowed_groups': {default: [], type: 'object'},
'token_lifetime': {default: function(){ return Object.assign({}, defaultLifetime) }, type: 'object'},
'created_by': {isRequired: true, type: 'string'},
'created_on': {default: function(){ return (new Date).getTime() }},
'is_valid': {default: true, type: 'boolean'},
}
class OAuthClient {
static async add(data) {
const raw_secret = UUID();
data.client_secret_hash = await bcrypt.hash(raw_secret, 10);
data.client_id = UUID();
const client = await this.create(data);
client._raw_secret = raw_secret;
return client;
const raw_secret = crypto.randomUUID();
const client_id = crypto.randomUUID();
const client_secret_hash = await bcrypt.hash(raw_secret, 10);
// Generate a unique slug from the client name
let slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'oauth-client';
// Ensure uniqueness by appending a suffix if needed
const existing = await Resource.list({ where: { slug } });
if (existing.length) slug = `${slug}-${client_id.slice(0, 8)}`;
const r = await Resource.create({
id: client_id,
kind: 'oauth',
name: data.name,
slug: slug,
description: data.description || '',
owner: data.created_by,
metadata: {
client_secret_hash,
redirect_uris: data.redirect_uris || [],
scopes: data.scopes || ['openid', 'profile', 'email', 'groups'],
allowed_groups: data.allowed_groups || [],
token_lifetime: data.token_lifetime || { ...defaultLifetime }
}
});
r._raw_secret = raw_secret;
r.client_id = client_id;
return r;
}
static async get(client_id) {
const notFound = () => {
const e = new Error('OAuthClient not found');
e.status = 404;
return e;
};
let r;
try {
r = await Resource.get(client_id);
} catch (_) {
throw notFound();
}
// Resource.get() returns null (does not throw) for a missing id —
// guard it so a bad/undefined client_id is a clean 404, not a
// "Cannot read properties of null (reading 'kind')" 500.
if (!r || r.kind !== 'oauth') throw notFound();
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
r.client_id = r.id;
r.client_secret_hash = r.metadata.client_secret_hash;
r.redirect_uris = r.metadata.redirect_uris || [];
r.scopes = r.metadata.scopes || ['openid', 'profile', 'email', 'groups'];
r.allowed_groups = r.metadata.allowed_groups || [];
r.token_lifetime = r.metadata.token_lifetime || { ...defaultLifetime };
// Resource has no is_valid column; validity lives in metadata (absent = valid)
r.is_valid = r.metadata.is_valid !== false;
r.verifySecret = async (secret) => bcrypt.compare(secret, r.client_secret_hash);
r.rotateSecret = async () => {
const raw_secret = crypto.randomUUID();
r.metadata.client_secret_hash = await bcrypt.hash(raw_secret, 10);
await r.update({ metadata: r.metadata });
return raw_secret;
};
// The ORM Model.toJSON() only serializes schema fields, so the mapped
// properties above (client_id, scopes, redirect_uris, …) would be
// stripped from any res.json() — that's why GET /api/oauth/client
// returned client_id: undefined and the bootstrap's rotate blew up.
// Emit the public shape explicitly. client_secret_hash is deliberately
// omitted so it never leaks over the API.
r.toJSON = function () {
return {
client_id: r.id,
id: r.id,
kind: r.kind,
name: r.name,
slug: r.slug,
owner: r.owner,
description: r.description,
redirect_uris: r.redirect_uris,
scopes: r.scopes,
allowed_groups: r.allowed_groups,
token_lifetime: r.token_lifetime,
is_valid: r.is_valid,
};
};
// proxy update to handle metadata correctly
const originalUpdate = r.update.bind(r);
r.update = async (data) => {
if (data.redirect_uris !== undefined) r.metadata.redirect_uris = data.redirect_uris;
if (data.scopes !== undefined) r.metadata.scopes = data.scopes;
if (data.allowed_groups !== undefined) r.metadata.allowed_groups = data.allowed_groups;
if (data.token_lifetime !== undefined) r.metadata.token_lifetime = data.token_lifetime;
if (data.is_valid !== undefined) r.metadata.is_valid = data.is_valid;
const updateData = { metadata: r.metadata };
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
return originalUpdate(updateData);
};
return r;
}
async verifySecret(secret) {
return bcrypt.compare(secret, this.client_secret_hash);
static async list() {
const resources = await Resource.list({ where: { kind: 'oauth' } });
return Promise.all(resources.map(r => this.get(r.id)));
}
async rotateSecret() {
const raw_secret = UUID();
await this.update({ client_secret_hash: await bcrypt.hash(raw_secret, 10) });
return raw_secret;
static async listDetail() {
return this.list();
}
static async verifySecret(client_id, secret) {
const client = await this.get(client_id);
return client.verifySecret(secret);
}
}
OAuthClient.register();
module.exports = { OAuthClient };
+2 -1
View File
@@ -1,7 +1,8 @@
'use strict';
const Table = require('.');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
const crypto = require('crypto');
const UUID = () => crypto.randomUUID();
// Shared base keyMap matching Token's schema so these behave as tokens
const tokenKeyMap = {
+83
View File
@@ -0,0 +1,83 @@
'use strict';
// PluginInstance — the registry of configured, loadable plugin copies.
//
// The SSO plugin system (see nodejs/services/plugin_registry.js) distinguishes
// **plugin types** (the .js modules under nodejs/plugins/<category>/<type>.js)
// from **plugin instances** — a configured, loadable/unloadable *copy* of a
// type. You can have several instances of the same type (e.g. two Proxmox
// endpoints with their own URLs + tokens), each on its own schedule.
//
// This table holds the *non-secret* per-instance state: which type it is, its
// schedule (cron), whether it's loaded (enabled), and its non-secret config.
// Per-instance **secrets** (the configSchema fields flagged `secret:true`,
// e.g. a Proxmox `tokenSecret` or UniFi `password`) live in OpenBao at
// `secret/plugins/<id>/conf` (see nodejs/utils/plugin_secrets.js) — never in
// the DB. The DB row's `config` JSON column holds only non-secret field values.
//
// `slug` is the discovery source name passed to DiscoveryReconciler.reconcile,
// so a discovery instance's resources are attributed to a stable, human-chosen
// name rather than its uuid. Unique, so two instances can't shadow each other
// in the resource graph's `discovery_sources`.
//
// Like Resource/AccessRequest, there is no ORM auto-timestamp hook: the route
// handler stamps created_by/on + updated_by/on explicitly on every write (see
// routes/api_plugins.js). `id` (uuid) is generated by the ORM on create.
const { Model } = require('@simpleworkjs/orm');
const STATUS = {
OK: 'ok',
ERROR: 'error',
RUNNING: 'running',
};
class PluginInstance extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// A registered plugin type slug (matches a manifest `type`). Validated
// against the registry before a row is created.
pluginType: { type: 'string', isRequired: true, min: 1, max: 64 },
// The plugin's category (e.g. 'discovery'). Copied from the manifest at
// create time so the scheduler can dispatch without re-reading the registry
// on every run (and so a later type removal still shows what the instance was).
category: { type: 'string', isRequired: true, default: 'discovery', min: 1, max: 64 },
// Human label for the instance.
name: { type: 'string', isRequired: true, min: 1, max: 120 },
// Stable handle: discovery source name + unique constraint. Lowercase
// alnum + hyphen/underscore to stay safe as a resource-graph slug.
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
// Loaded into the scheduler? `false` = unloaded (no scheduled runs).
enabled: { type: 'boolean', default: true },
// Cron schedule (5-field). The scheduler turns this into a BullMQ
// repeatable JobScheduler.
cron: { type: 'string', isRequired: true, default: '0 * * * *' },
// Non-secret configSchema field values. Secret fields are NOT here.
config: { type: 'json', default: {} },
// Last-run bookkeeping, updated by the scheduler worker.
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' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
};
// All instances the scheduler should run: enabled only. Loaded fresh each
// boot / load; not cached on the model (the scheduler is the source of truth
// for what's actually scheduled).
static async listEnabled() {
return this.list({ where: { enabled: true } });
}
// Look up by slug — used by tests + the reconciler when only a slug is known.
static async getBySlug(slug) {
const rows = await this.list({ where: { slug } });
return rows[0] || null;
}
}
module.exports = { PluginInstance, STATUS };
+218
View File
@@ -0,0 +1,218 @@
const { Model } = require('@simpleworkjs/orm');
const { Group } = require('./group_ldap');
class Resource extends Model {
static exposedMethods = [
{ method: 'search', route: 'resources', verb: 'get', args: { from: 'query' } },
{ method: 'getBySlug', route: 'resources/:slug', verb: 'get', args: { from: 'params', names: ['slug'] } },
{ method: 'getGraph', route: 'graph', verb: 'get' },
{ method: 'getMyAccess', route: 'me', verb: 'get', args: { from: 'user' } }
];
static async search(query) {
const graph = await this.getGraph();
let resources = graph.resources;
if (query.kind) {
resources = resources.filter(r => r.kind === query.kind);
}
if (query.group) {
const rgs = await ResourceGroup.list({ where: { groupCn: query.group } });
const allowedIds = new Set(rgs.map(rg => rg.resourceId));
resources = resources.filter(r => allowedIds.has(r.id));
}
if (query.parent) {
const parents = graph.resources.filter(r => r.slug === query.parent);
if (parents.length > 0) {
const parentId = parents[0].id;
const childIds = new Set(graph.edges.filter(e => e.parentId === parentId).map(e => e.childId));
resources = resources.filter(r => childIds.has(r.id));
} else {
resources = [];
}
}
return resources;
}
static async getBySlug(slug) {
const graph = await this.getGraph();
const resource = graph.resources.find(r => r.slug === slug);
if (!resource) {
let err = new Error('Resource not found');
err.status = 404;
throw err;
}
const parents = graph.edges.filter(e => e.childId === resource.id);
const children = graph.edges.filter(e => e.parentId === resource.id);
return {
...resource,
parents,
children
};
}
static async getGraph() {
const resources = await this.list();
const edges = await ResourceEdge.list();
// Convert to simple objects so we can mutate metadata properties safely
const resObjs = resources.map(r => {
const obj = r.toJSON ? r.toJSON() : { ...r };
obj.metadata = obj.metadata || {};
return obj;
});
// Bubble up production status: if any child is prod, parent is prod
const isProdCache = new Map();
function checkProd(resId, visited = new Set()) {
if (isProdCache.has(resId)) return isProdCache.get(resId);
if (visited.has(resId)) return false; // Cycle prevention
visited.add(resId);
const r = resObjs.find(x => x.id === resId);
if (!r) return false;
// If intrinsically prod, return true
if (r.metadata.isProduction) {
isProdCache.set(resId, true);
return true;
}
// Check children
const childrenIds = edges.filter(e => e.parentId === resId).map(e => e.childId);
for (const cid of childrenIds) {
if (checkProd(cid, visited)) {
isProdCache.set(resId, true);
return true;
}
}
isProdCache.set(resId, false);
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, updated_on: maxUpdated || Date.now() };
}
// Stamp `resolvedAddress` on each resource: its own address/ip if it has one,
// otherwise the nearest ancestor's. A service usually carries no address of
// its own -- it is reached at the host it runs on -- so "how do I reach this"
// is only answerable from the graph, never from the row alone. Every caller
// that answers that question for a user (getMyAccess, GET /api/discovery/me)
// must go through here, or services come back unreachable.
static async withResolvedAddress(resources) {
if (!resources || !resources.length) return [];
const graph = await this.getGraph();
const resolve = (resId, visited = new Set()) => {
if (visited.has(resId)) return null; // prevent cycles
visited.add(resId);
const res = graph.resources.find(r => r.id === resId);
if (!res) return null;
if (res.metadata && res.metadata.address) return res.metadata.address;
if (res.metadata && res.metadata.ip) return res.metadata.ip;
for (const edge of graph.edges.filter(e => e.childId === resId)) {
const found = resolve(edge.parentId, visited);
if (found) return found;
}
return null;
};
return resources.map(r => {
const data = r.toJSON ? r.toJSON() : { ...r };
data.metadata = data.metadata || {};
data.resolvedAddress = resolve(data.id);
return data;
});
}
static async getMyAccess(userDn) {
const userGroups = await Group.list(userDn);
if (!userGroups || userGroups.length === 0) return [];
const resourceGroups = await ResourceGroup.list({
where: { groupCn: { in: userGroups } }
});
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
if (resourceIds.length === 0) return [];
return this.withResolvedAddress(await this.list({ where: { id: { in: resourceIds } } }));
}
static fields = {
id: { type: 'uuid', primaryKey: true },
kind: { type: 'string', isRequired: true },
name: { type: 'string', isRequired: true },
slug: { type: 'string', isRequired: true, unique: true },
owner: { type: 'string' },
description: { type: 'text' },
metadata: { type: 'json', default: {} },
// Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these
// are set explicitly by the route handler on every create/update (see
// routes/api_directory_admin.js). Existing rows predating this change
// simply read back undefined -- callers must render a fallback.
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
};
// Walk parent ResourceEdges from resourceId up to the nearest ancestor
// whose kind === 'site', returning its slug (or null if none exists -- a
// top-level resource with no site parent keeps its unprefixed group name).
static async findAncestorSiteSlug(resourceId, visited = new Set()) {
if (visited.has(resourceId)) return null;
visited.add(resourceId);
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } });
for (const edge of parentEdges) {
const parent = await this.get(edge.parentId);
if (!parent) continue;
if (parent.kind === 'site') return parent.slug;
const found = await this.findAncestorSiteSlug(parent.id, visited);
if (found) return found;
}
return null;
}
}
class ResourceEdge extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
parent: { type: 'hasOne', model: 'Resource' }, // Creates parentId
child: { type: 'hasOne', model: 'Resource' }, // Creates childId
relation: { type: 'string', isRequired: true }
};
}
class ResourceGroup extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
resource: { type: 'hasOne', model: 'Resource' }, // Creates resourceId
groupCn: { type: 'string', isRequired: true },
accessLevel: { type: 'string', isRequired: true }
};
}
module.exports = {
Resource,
ResourceEdge,
ResourceGroup
};
-114
View File
@@ -1,114 +0,0 @@
'use strict';
// Non-person "service" accounts under ou=people -- bind-only LDAP identities
// for things like theta-env's bootstrap-created cn=ldapclient (the proxy's
// direct-LDAP bind account) or any other app/host that needs its own
// dedicated read-only credential, as opposed to a real user who logs into
// the web UI.
//
// Deliberately NOT posixAccount/inetOrgPerson (the User model's shape) --
// these can't log into the SSO Manager UI or get a home directory/uidNumber.
// objectClass matches exactly what theta-env's bootstrap.js already creates
// for cn=ldapclient, so this model recognizes and manages that account too,
// not just ones created through this UI.
const { Client, Attribute, Change } = require('ldapts');
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf').ldap;
function hashPasswordSSHA512(password) {
const salt = crypto.randomBytes(8);
const hash = crypto.createHash('sha512').update(password).update(salt).digest();
return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64');
}
function makeClient() {
return new Client({ url: conf.url });
}
async function withClient(fn) {
const client = makeClient();
try {
await client.bind(conf.bindDN, conf.bindPassword);
return await fn(client);
} finally {
await client.unbind().catch(() => {});
}
}
const FILTER = '(&(objectClass=organizationalRole)(objectClass=simpleSecurityObject))';
const CN_RE = /^[A-Za-z][A-Za-z0-9._-]{1,63}$/;
var ServiceAccount = {};
ServiceAccount.list = async function(){
return withClient(async (client) => {
const res = await client.search(conf.userBase, {
scope: 'sub',
filter: FILTER,
attributes: ['cn', 'description', 'createTimestamp', 'modifyTimestamp'],
});
return res.searchEntries.map((entry) => ({
cn: entry.cn,
dn: `cn=${entry.cn},${conf.userBase}`,
description: entry.description || '',
created_on: entry.createTimestamp || null,
modified_on: entry.modifyTimestamp || null,
})).sort((a, b) => a.cn.localeCompare(b.cn));
});
};
ServiceAccount.create = async function({cn, description}){
if(!cn || !CN_RE.test(cn)){
throw Object.assign(new Error('InvalidName'), {status: 400, message: 'Name must start with a letter and contain only letters, numbers, dot, dash, underscore.'});
}
const dn = `cn=${cn},${conf.userBase}`;
const password = crypto.randomBytes(24).toString('base64url');
await withClient(async (client) => {
let existing = true;
try{
const res = await client.search(dn, {scope: 'base', filter: '(objectClass=*)', attributes: ['dn']});
existing = res.searchEntries.length > 0;
}catch(error){ existing = false; }
if(existing){
throw Object.assign(new Error('NameInUse'), {status: 409, message: `"${cn}" already exists under ${conf.userBase}.`});
}
await client.add(dn, {
objectClass: ['organizationalRole', 'simpleSecurityObject', 'top'],
cn,
description: description || '',
userPassword: hashPasswordSSHA512(password),
});
});
return {cn, dn, description: description || '', password};
};
ServiceAccount.setPassword = async function(cn, password){
const dn = `cn=${cn},${conf.userBase}`;
const newPassword = password || crypto.randomBytes(24).toString('base64url');
await withClient(async (client) => {
await client.modify(dn, [
new Change({
operation: 'replace',
modification: new Attribute({type: 'userPassword', values: [hashPasswordSSHA512(newPassword)]}),
}),
]);
});
return {cn, dn, password: newPassword};
};
ServiceAccount.remove = async function(cn){
const dn = `cn=${cn},${conf.userBase}`;
await withClient(async (client) => {
await client.del(dn);
});
return true;
};
module.exports = {ServiceAccount};
+15
View File
@@ -10,6 +10,21 @@ function toE164Digits(number) {
}
async function send(to, message) {
const { PluginInstance } = require('./plugin_instance');
const registry = require('../services/plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
if (instances.length > 0) {
const inst = instances[0];
const manifest = registry.getManifest(inst.pluginType);
if (manifest && manifest.sendMessage) {
const secrets = await pluginSecrets.read(inst.id).catch(() => ({}));
const config = { ...inst.config, ...secrets };
return manifest.sendMessage(config, { to, message });
}
}
const params = new URLSearchParams({
api_username: conf.username,
api_password: conf.password,
+36 -38
View File
@@ -1,21 +1,17 @@
'use strict';
const Table = require('.');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
const { Model } = require('@simpleworkjs/orm');
const crypto = require('crypto');
const UUID = () => crypto.randomUUID();
class Token extends Table{
static _key = 'token';
static _keyMap = {
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
'is_valid': {default: true, type: 'boolean'},
}
constructor(...args){
super(...args);
class Token extends Model {
static adapterName = 'redis';
static fields = {
token: { type: 'string', primaryKey: true, default: UUID, isPrivate: true, min: 36, max: 36 },
created_by: { isRequired: true, type: 'string', min: 3, max: 500 },
created_on: { type: 'integer', default: function(){return (new Date).getTime()} },
updated_on: { type: 'integer', default: function(){return (new Date).getTime()}, always: true },
is_valid: { default: true, type: 'boolean' }
}
async check(){
@@ -27,12 +23,10 @@ class Token extends Table{
}
}
Token.register();
class AuthToken extends Token{
static _keyMap = {
...super._keyMap,
user: {model: 'User', rel: 'one', localKey: 'created_by'},
static fields = {
...Token.fields,
user: {model: 'User', type: 'hasOne', localKey: 'created_by'},
}
static async create(data){
@@ -41,11 +35,10 @@ class AuthToken extends Token{
}
}
AuthToken.register();
class InviteToken extends Token{
static _keyMap = {
...super._keyMap,
static fields = {
...Token.fields,
claimed_by: {default: '__NONE__', isRequired: false, type: 'string'},
mail: {default: '__NONE__', type: 'string'},
mail_token: {default: '__NONE__', type: 'string'},
@@ -67,14 +60,13 @@ class InviteToken extends Token{
}
}
}
InviteToken.register();
class ImpersonationToken extends Token {
static _keyMap = {
...super._keyMap,
static fields = {
...Token.fields,
target_uid: {isRequired: true, type: 'string', min: 1, max: 200},
temp_hash: {isRequired: true, type: 'string', min: 1, max: 500},
expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'number'},
expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'integer'},
}
get isExpired() {
@@ -86,42 +78,48 @@ class ImpersonationToken extends Token {
return this.create(data);
}
}
ImpersonationToken.register();
class PasswordResetToken extends Token {}
PasswordResetToken.register();
class OtpToken extends Token {
static _keyMap = {
...Token._keyMap,
static fields = {
...Token.fields,
uid: {isRequired: true, type: 'string'},
code: {isRequired: true, type: 'string'},
method: {isRequired: true, type: 'string'},
expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'number'},
expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'integer'},
};
get isExpired() {
return (new Date).getTime() > this.expires_at;
}
// Factory method — named `issue` to avoid shadowing Token's `create(data)`
static async issue(uid, method) {
const existing = await this.listDetail({uid});
const existing = await this.list({where: {uid}});
for (const t of existing) {
if (t.is_valid) await t.update({is_valid: false});
}
const code = String(Math.floor(100000 + Math.random() * 900000));
const code = String(crypto.randomInt(100000, 1000000));
return this.create({uid, code, method, created_by: uid});
}
static async verify(uid, code) {
const tokens = await this.listDetail({uid});
const tokens = await this.list({where: {uid}});
const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code);
if (!match) return null;
await match.update({is_valid: false});
return match;
}
}
OtpToken.register();
class ServiceToken extends Token {
static fields = {
...Token.fields,
resource_id: {isRequired: true, type: 'string'}
}
static async issue(resource_id, created_by) {
return this.create({resource_id, created_by});
}
}
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken};
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken};
+181 -28
View File
@@ -9,6 +9,14 @@ const {Token, InviteToken, PasswordResetToken} = require('./token');
const {Group} = require('./group_ldap');
const {UserVerification} = require('./verification');
const conf = require('@simpleworkjs/conf').ldap;
// Connection + escaping come from the shared @simpleworkjs/ldap package. The
// wrappers below preserve this file's no-arg call signatures (makeClient() /
// withClient(fn)) so no call site changes; sso's makeClient passes no
// tlsOptions, which the shared client forwards as undefined — identical to the
// previous `new Client({ url: conf.url })`.
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
const escapeLDAPSearchValue = escapeFilter;
const escapeLDAPDNValue = escapeDN;
function hashPasswordSSHA512(password) {
const salt = crypto.randomBytes(8);
@@ -23,26 +31,11 @@ const cache = new LRUCache({
});
function makeClient() {
return new Client({ url: conf.url });
return _makeClient(conf);
}
async function withClient(fn) {
const client = makeClient();
try {
await client.bind(conf.bindDN, conf.bindPassword);
return await fn(client);
} finally {
await client.unbind().catch(() => {});
}
}
// Helper to escape LDAP filter values (crucial for security)
function escapeLDAPSearchValue(val) {
return val.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\0/g, '\\00');
return _withClient(conf, fn);
}
// Compute the next available uid/gidNumber: the highest existing value below
@@ -72,7 +65,8 @@ async function addPosixGroup(client, data){
data.gidNumber = nextPosixId(groups, 'gidNumber');
await client.add(`cn=${data.cn},${conf.groupBase}`, {
const safeCn = escapeLDAPDNValue(data.cn);
await client.add(`cn=${safeCn},${conf.groupBase}`, {
cn: data.cn,
gidNumber: data.gidNumber,
objectclass: [ 'posixGroup', 'top' ]
@@ -94,6 +88,7 @@ async function addPosixAccount(client, data){
data.uidNumber = nextPosixId(people, 'uidNumber');
const safeCn = escapeLDAPDNValue(data.cn);
const entry = {
cn: data.cn,
sn: data.sn,
@@ -103,7 +98,6 @@ async function addPosixAccount(client, data){
givenName: data.givenName,
loginShell: data.loginShell,
homeDirectory: data.homeDirectory,
userPassword: data.userPassword,
description: data.description || ' ',
sudoHost: 'ALL',
sudoCommand: 'ALL',
@@ -131,7 +125,24 @@ async function addPosixAccount(client, data){
entry.dateOfBirth = data.dob;
}
await client.add(`cn=${data.cn},${conf.userBase}`, entry);
if (data.location) {
entry.l = data.location;
}
// userPassword is optional -- a service account with no password set
// simply can't bind (no special enforcement needed, that's the default
// LDAP simple-bind behavior for an entry lacking the attribute).
if (data.userPassword) {
entry.userPassword = data.userPassword;
}
// manager (COSINE, SUP distinguishedName) is naturally multi-valued --
// every account gets at least the DN of whoever created it.
if (data.manager && [].concat(data.manager).length) {
entry.manager = [].concat(data.manager);
}
await client.add(`cn=${safeCn},${conf.userBase}`, entry);
return data
@@ -151,11 +162,14 @@ async function addLdapUser(client, data){
data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase();
}
data.cn = data.uid;
data.loginShell = '/bin/bash';
data.homeDirectory= `/home/${data.uid}`;
data.userPassword = hashPasswordSSHA512(data.userPassword);
data.loginShell = data.loginShell || '/bin/bash';
data.homeDirectory = data.homeDirectory || `/home/${data.uid}`;
if (data.userPassword) {
data.userPassword = hashPasswordSSHA512(data.userPassword);
} else {
delete data.userPassword;
}
console.log('addLdapUser', data)
group = await addPosixGroup(client, data);
data = await addPosixAccount(client, group);
@@ -190,10 +204,19 @@ const user_parse = function(data){
data.username = data[conf.userNameAttribute]
data.userPassword = undefined;
}
data.location = data.l ? String(data.l) : '';
// Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
// manager (COSINE, SUP distinguishedName) and memberOf (from the memberof
// overlay) are both multi-valued; ldapts returns a bare string for a
// single value and an array for multiple -- normalize both to always be
// an array, or app-base.js's `for(let group of user.memberOf)` silently
// iterates a single DN string character-by-character instead of once.
data.manager = [].concat(data.manager || []).filter(Boolean);
data.memberOf = [].concat(data.memberOf || []).filter(Boolean);
return data;
}
@@ -242,6 +265,8 @@ User.listDetail = async function(){
serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
}catch(error){ /* group not seeded yet on an old deployment -- treat as none */ }
const dnToUid = new Map(searchEntries.map(e => [String(e.dn).toLowerCase(), e.uid]));
const users = await Promise.all(searchEntries.map(async (entry) => {
const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
@@ -269,6 +294,10 @@ User.listDetail = async function(){
].filter(Boolean);
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
// hasSshKey is a boolean flag for the UI -- sshPublicKey may be an array,
// and Mustache's {{#sshPublicKey}}...{{/sshPublicKey}} iterates over each item.
obj.hasSshKey = obj.sshPublicKey ? 'yes' : '';
return obj;
}));
@@ -324,6 +353,13 @@ User.get = async function(data, key) {
const verif = await UserVerification.getOrCreate(obj.uid);
// Same membership check as User.listDetail() -- see the comment there.
try{
const svcGroup = await Group.get('app_sso_service_account');
const serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
}catch(error){ obj.isServiceAccount = ''; }
// Auto-flag legacy MD5 password users — persist so subsequent cache hits see it
if (isLegacyMD5 && !verif.password_must_change) {
await verif.update({ password_must_change: true });
@@ -421,7 +457,7 @@ User.update = async function(data){
}
}
let editableFeilds = ['mobile', 'description'];
let editableFeilds = ['mobile', 'description', 'homeDirectory', 'loginShell'];
await withClient(async (client) => {
for(let field of editableFeilds){
@@ -440,6 +476,19 @@ User.update = async function(data){
}
if(data.sshPublicKey){
// Ensure the auxiliary objectClass is present before setting the attribute
// -- accounts created before ldapPublicKey was added to addPosixAccount's
// objectclass list (e.g. the bootstrap admin) won't have it yet.
try {
await client.modify(this.dn, [
new Change({
operation: 'add',
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
}),
]);
} catch(e) {
if(e.name !== 'TypeOrValueExistsError') throw e;
}
await client.modify(this.dn, [
new Change({
operation: 'replace',
@@ -469,6 +518,31 @@ User.update = async function(data){
]);
this.dateOfBirth = data.dateOfBirth;
}
if(data.location !== undefined){
await client.modify(this.dn, [
new Change({
operation: 'replace',
modification: new Attribute({ type: 'l', values: [data.location] }),
}),
]);
this.location = data.location;
}
if(data.manager !== undefined){
// Client sends uids; resolve each to a DN before writing --
// manager (COSINE, SUP distinguishedName) stores DNs, not uids.
const uids = [].concat(data.manager || []).filter(Boolean);
const managers = await Promise.all(uids.map(uid => User.get(uid)));
const dns = managers.map(u => u.dn);
await client.modify(this.dn, [
new Change({
operation: 'replace',
modification: new Attribute({ type: 'manager', values: dns }),
}),
]);
this.manager = dns;
}
});
cache.clear();
@@ -537,6 +611,12 @@ User.addByInvite = async function(data){
data.mail = token.mail;
// Default manager: whoever sent the invite.
try {
const inviter = await this.get(token.created_by);
data.manager = [inviter.dn];
} catch(e) { /* inviter no longer exists -- leave manager unset */ }
const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob);
if (!data.uid || !suggestions.includes(data.uid)) {
const err = new Error('Invalid username selection');
@@ -693,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'] }) }),
]);
}
});
@@ -708,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();
@@ -720,6 +800,19 @@ User.addSSHkey = async function(data) {
let result;
try {
await withClient(async (client) => {
// Ensure the auxiliary objectClass is present before setting the attribute
// -- accounts created before ldapPublicKey was added to addPosixAccount's
// objectclass list (e.g. the bootstrap admin) won't have it yet.
try {
await client.modify(user.dn, [
new Change({
operation: 'add',
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
}),
]);
} catch(e) {
if (e.name !== 'TypeOrValueExistsError') throw e;
}
await client.modify(user.dn, [
new Change({
operation: 'add',
@@ -739,6 +832,53 @@ User.addSSHkey = async function(data) {
return result;
};
// Every user gets a personal Unix group of the same name at creation (see
// addPosixGroup) -- just a GID holder, cn always equal to the user's uid.
// memberUid (RFC 2307, posixGroup) is a bare username, not a DN, unlike
// groupOfNames' `member` used by app_sso_* groups in group_ldap.js.
function personalGroupDN(uid){
return `cn=${escapeLDAPDNValue(uid)},${conf.groupBase}`;
}
User.getPersonalGroupMembers = async function(uid) {
try {
return await withClient(async (client) => {
const res = await client.search(personalGroupDN(uid), {
scope: 'base',
filter: '(objectClass=posixGroup)',
attributes: ['memberUid'],
});
const entry = res.searchEntries[0];
return [].concat((entry && entry.memberUid) || []).filter(Boolean);
});
} catch(error) {
throw error;
}
};
User.addPersonalGroupMember = async function(uid, memberUid) {
await this.get(memberUid); // throws UserNotFound if the target uid doesn't exist
await withClient(async (client) => {
await client.modify(personalGroupDN(uid), [
new Change({
operation: 'add',
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
}),
]);
});
};
User.removePersonalGroupMember = async function(uid, memberUid) {
await withClient(async (client) => {
await client.modify(personalGroupDN(uid), [
new Change({
operation: 'delete',
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
}),
]);
});
};
User.invite = async function(data = {}){
try{
let token = await InviteToken.create({
@@ -759,8 +899,21 @@ User.invite = async function(data = {}){
User.login = async function(data){
try{
if (!data.uid && !data.username) {
let error = new Error('Invalid Credentials, login failed.');
error.name = 'LDAPLoginFailed';
error.status = 401;
throw error;
}
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);
@@ -771,7 +924,7 @@ User.login = async function(data){
return user;
}catch(error){
console.error("USER LOGIN error:", error);
console.error("USER LOGIN error:", error.name, error.message);
throw error;
}
};
+15
View File
@@ -0,0 +1,15 @@
const { Model } = require('@simpleworkjs/orm');
class Webhook extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
name: { type: 'string', isRequired: true },
url: { type: 'string', isRequired: true },
events: { type: 'json', default: [] }, // e.g. ['discovery.new_device', 'resource.updated']
secret: { type: 'string' },
isActive: { type: 'boolean', default: true },
created_on: { type: 'integer' },
};
}
module.exports = { Webhook };
+2167 -364
View File
File diff suppressed because it is too large Load Diff
+21 -8
View File
@@ -1,7 +1,7 @@
{
"name": "t42-sso-manager",
"version": "1.1.2",
"private": true,
"version": "1.20.1",
"description": "A very simple LDAP management and SSO system",
"author": [
{
"name": "William Mantly",
@@ -23,26 +23,39 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.1.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.1.0",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
"bullmq": "^6.0.3",
"compression": "^1.8.1",
"ejs": "^3.1.10",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"extend": "^3.0.2",
"jq-repeat": "^2.0.1",
"jquery": "^3.7.1",
"http-proxy-middleware": "^2.0.10",
"ioredis": "^6.0.0",
"jq-repeat": "^2.2.0",
"jquery": "^4.0.0",
"jsonwebtoken": "^9.0.3",
"ldapts": "^8.1.2",
"ldapts": "^8.1.8",
"lru-cache": "^11.5.1",
"marked": "^9.1.6",
"model-redis": "^0.4.0",
"model-redis": "^1.6.0",
"moment": "^2.30.1",
"mustache": "^4.2.0",
"node-fetch": "^2.7.0",
"node-nmap": "^4.0.0",
"nodemailer": "^9.0.0",
"p2psub": "^0.2.0",
"socket.io": "^4.8.3"
"socket.io": "^4.8.3",
"ws": "^8.21.1",
"xss": "^1.0.15"
},
"license": "MIT",
"repository": {
+328
View File
@@ -0,0 +1,328 @@
diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs
index c7646a4..411b56f 100644
--- a/nodejs/views/directory.ejs
+++ b/nodejs/views/directory.ejs
@@ -3,7 +3,26 @@
<div class="container mt-4">
<div class="row">
<div class="col-12">
- <div class="card shadow">
+ <ul class="nav nav-tabs mb-3" id="directoryTabs" role="tablist">
+ <li class="nav-item" role="presentation">
+ <button class="nav-link active" id="directory-tab" data-bs-toggle="tab" data-bs-target="#directory-tab-pane" type="button" role="tab" aria-controls="directory-tab-pane" aria-selected="true">
+ <i class="fa-solid fa-server"></i> Directory
+ </button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
+ <i class="fa-solid fa-network-wired"></i> Discovery
+ </button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button class="nav-link" id="plugins-tab" data-bs-toggle="tab" data-bs-target="#plugins-tab-pane" type="button" role="tab" aria-controls="plugins-tab-pane" aria-selected="false">
+ <i class="fa-solid fa-plug"></i> Plugins & Scheduler
+ </button>
+ </li>
+ </ul>
+ <div class="tab-content" id="directoryTabsContent">
+ <div class="tab-pane fade show active" id="directory-tab-pane" role="tabpanel" aria-labelledby="directory-tab">
+ <div class="card shadow border-top-0">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<i class="fa-solid fa-server"></i> Directory Management
@@ -74,6 +93,148 @@
</table>
</div>
</div>
+
+ <!-- Discovery Tab Pane -->
+ <div class="tab-pane fade" id="discovery-tab-pane" role="tabpanel" aria-labelledby="discovery-tab">
+ <div class="card shadow border-top-0">
+ <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
+ <div>
+ <i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard
+ </div>
+ <div class="d-flex flex-wrap gap-2 align-items-center">
+ <input type="text" id="discovery-search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderDiscoveryTable()" style="width: 250px;">
+ <select id="discovery-filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderDiscoveryTable()" style="width: 150px;">
+ <option value="unmanaged">Unmanaged Only</option>
+ <option value="managed">Managed Only</option>
+ <option value="all">All Resources</option>
+ </select>
+ </div>
+ </div>
+ <div class="card-header actionMessage" style="display:none"></div>
+ <div class="p-3 pb-0 text-muted small border-bottom">
+ <i class="fa-solid fa-circle-info"></i> Auto-discovered network resources. Promote unmanaged devices to track them in the Directory.
+ <a href="/docs/discovery" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
+ </div>
+ <div class="table-responsive">
+ <table class="card-body table table-hover mb-0 align-middle">
+ <thead class="table-light">
+ <tr>
+ <th class="ps-3">Name / Source</th>
+ <th>Type</th>
+ <th>IP Address</th>
+ <th>Status</th>
+ <th class="text-end pe-3">Actions</th>
+ </tr>
+ </thead>
+ <tbody id="discovery-list" jq-repeat="discoveryResources">
+ <tr id="discovery-row-{{slug}}">
+ <td class="ps-3">
+ <div class="fw-bold">{{name}}</div>
+ <div class="text-muted small">
+ <i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
+ </div>
+ </td>
+ <td>
+ <span class="badge bg-secondary">{{kind}}</span>
+ {{#metadata.subType}}
+ <span class="badge bg-light text-dark border">{{metadata.subType}}</span>
+ {{/metadata.subType}}
+ </td>
+ <td>
+ {{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
+ {{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
+ {{#metadata.interfaces.length}}
+ <div class="mt-1 small text-muted">
+ {{#metadata.interfaces}}
+ <div><i class="fa-solid fa-microchip pe-1"></i> {{mac}} {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}</div>
+ {{/metadata.interfaces}}
+ </div>
+ {{/metadata.interfaces.length}}
+ </td>
+ <td>
+ {{#metadata.managed}}
+ <span class="badge bg-success rounded-pill px-2"><i class="fa-solid fa-check"></i> Managed</span>
+ {{/metadata.managed}}
+ {{^metadata.managed}}
+ <span class="badge bg-warning text-dark rounded-pill px-2"><i class="fa-solid fa-ghost"></i> Unmanaged</span>
+ {{/metadata.managed}}
+ </td>
+ <td class="text-end pe-3">
+ {{^metadata.managed}}
+ <button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
+ <i class="fa-solid fa-arrow-up-right-dots"></i> Promote
+ </button>
+ {{/metadata.managed}}
+ {{#metadata.managed}}
+ <button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
+ Promoted
+ </button>
+ {{/metadata.managed}}
+ </td>
+ </tr>
+ </tbody>
+ <tbody id="discovery-empty-state" style="display: none;">
+ <tr>
+ <td colspan="5" class="text-center py-5 text-muted">
+ <i class="fa-solid fa-magnifying-glass fs-2 mb-3 text-black-50"></i>
+ <h5>No resources found</h5>
+ <p>Check your filters or ensure the discovery agents are running.</p>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+
+ <!-- Plugins Tab Pane -->
+ <div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
+ <div class="card shadow border-top-0">
+ <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
+ <div>
+ <i class="fa-solid fa-plug"></i> Plugins & Scheduler
+ </div>
+ </div>
+ <div class="p-3 pb-0 text-muted small border-bottom">
+ <i class="fa-solid fa-circle-info"></i> Manage background tasks and schedules. <a href="/docs/plugins">Learn how to make and use custom plugins</a>.
+ </div>
+ <div class="table-responsive">
+ <table class="card-body table table-hover mb-0 align-middle">
+ <thead class="table-light">
+ <tr>
+ <th class="ps-3">Plugin Name</th>
+ <th>Cron Schedule</th>
+ <th>Status</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody id="plugins-list" jq-repeat="plugins">
+ <tr>
+ <td class="ps-3 fw-bold">{{name}}</td>
+ <td><input type="text" class="form-control form-control-sm font-monospace" id="cron-{{name}}" value="{{cron}}" style="max-width: 150px;"></td>
+ <td>
+ {{#enabled}}<span class="badge bg-success">Enabled</span>{{/enabled}}
+ {{^enabled}}<span class="badge bg-secondary">Disabled</span>{{/enabled}}
+ </td>
+ <td>
+ <button class="btn btn-sm btn-outline-primary" onclick="updatePlugin('{{name}}')" title="Save Schedule">Save</button>
+ {{#enabled}}<button class="btn btn-sm btn-outline-danger" onclick="togglePlugin('{{name}}', false)">Disable</button>{{/enabled}}
+ {{^enabled}}<button class="btn btn-sm btn-outline-success" onclick="togglePlugin('{{name}}', true)">Enable</button>{{/enabled}}
+ </td>
+ </tr>
+ </tbody>
+ <tbody id="plugins-empty-state" style="display: none;">
+ <tr>
+ <td colspan="4" class="text-center py-4 text-muted">
+ No plugins configured.
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+
</div>
</div>
</div>
@@ -413,14 +574,144 @@
const parentEdge = allEdges.find(e => e.childId === r.id);
if (parentEdge) {
r.parentId = parentEdge.parentId;
- const parent = resourcesById[parentEdge.parentId];
+ const parent = resourcesById[parentEdge.parentId];
if (parent) r.hostName = parent.name;
}
rawResources.push(r);
}
+ function openAddModal(parent_id, kind) {
+ if(parent_id){
+ $('#newResourceParent').val(parent_id);
+ $('#newResourceKind').val(kind);
+ var currentLabel = "Resource";
+ if(kind === 'Host'){ currentLabel = 'Host'; }
+ else if(kind === 'Site'){ currentLabel = 'Site'; }
+
+ $('#newResourceLabel').text('Add Child ' + currentLabel);
+ }else{
+ $('#newResourceParent').val('');
+ $('#newResourceKind').val('Host');
+ $('#newResourceLabel').text('Add Resource');
+ }
+
+ // Clear input
+ $('#newResourceName').val('');
+ $('#addResourceModal').modal('show');
+ }
+
+ // --- DISCOVERY SCRIPTS ---
+ let allDiscoveryResources = [];
+
+ function loadDiscoveryResources() {
+ app.api.get('discovery/resources', function(err, res) {
+ if(err) {
+ $('.actionMessage').html('<div class="alert alert-danger">' + (err.message || 'Error loading resources') + '</div>').show();
+ return;
+ }
+ allDiscoveryResources = res.results || [];
+ renderDiscoveryTable();
+ });
+ }
+
+ function renderDiscoveryTable() {
+ const search = $('#discovery-search-filter').val().toLowerCase();
+ const managedFilter = $('#discovery-filter-managed').val();
+
+ const filtered = allDiscoveryResources.filter(r => {
+ if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
+ const isManaged = !!(r.metadata && r.metadata.managed);
+ if(managedFilter === 'managed' && !isManaged) return false;
+ if(managedFilter === 'unmanaged' && isManaged) return false;
+ return true;
+ });
+
+ $.scope.discoveryResources.empty();
+ for(const r of filtered) {
+ $.scope.discoveryResources.push(r);
+ }
+
+ if(filtered.length === 0) {
+ $('#discovery-list').hide();
+ $('#discovery-empty-state').show();
+ } else {
+ $('#discovery-list').show();
+ $('#discovery-empty-state').hide();
+ }
+ }
+
+ function promoteResource(slug) {
+ if(!confirm("Are you sure you want to promote this resource? This will generate SSO LDAP groups for it.")) return;
+ app.api.post('discovery/promote/' + slug, {}, function(err, res) {
+ if(err) {
+ alert("Error promoting resource: " + (err.message || err));
+ return;
+ }
+ const resource = allDiscoveryResources.find(r => r.slug === slug);
+ if(resource) {
+ resource.metadata = resource.metadata || {};
+ resource.metadata.managed = true;
+ }
+ $('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
+ renderDiscoveryTable();
+ renderTable(); // Also update directory tab
+ });
+ }
+
+ // --- PLUGINS SCRIPTS ---
+ function loadPlugins() {
+ app.api.get('plugins', function(err, res) {
+ if(err) {
+ alert("Error loading plugins: " + (err.message || err));
+ return;
+ }
+ const plugins = res.results || {};
+ const pluginNames = Object.keys(plugins);
- renderTable();
+ $.scope.plugins.empty();
+ if(pluginNames.length === 0) {
+ $('#plugins-list').hide();
+ $('#plugins-empty-state').show();
+ } else {
+ pluginNames.forEach(name => {
+ const config = plugins[name];
+ $.scope.plugins.push({
+ name: name,
+ cron: config.cron || '',
+ enabled: config.enabled
+ });
+ });
+ $('#plugins-list').show();
+ $('#plugins-empty-state').hide();
+ }
+ });
+ }
+ function updatePlugin(name) {
+ const cron = $('#cron-' + name).val();
+ app.api.put('plugins/' + name, {cron: cron}, function(err, res) {
+ if(err) { alert("Failed to save: " + err.message); return; }
+ alert("Saved schedule successfully.");
+ });
+ }
+
+ function togglePlugin(name, enable) {
+ app.api.put('plugins/' + name, {enabled: enable}, function(err, res) {
+ if(err) { alert("Failed to toggle: " + err.message); return; }
+ loadPlugins();
+ });
+ }
+
+ $(document).ready(function(){
+ renderTable();
+ loadDiscoveryResources();
+ loadPlugins();
+
+ // Auto-open modal if hash is present
+ if(window.location.hash && window.location.hash.startsWith('#modal-')) {
+ const slug = window.location.hash.replace('#modal-', '');
+ setTimeout(() => openEditModal(slug), 500);
+ }
+ });
// Type-ahead for the "what can this user reach" lookup. Non-blocking: the
// input accepts a free-typed uid whether or not the list ever arrives.
loadDirectoryUsers().then(function(users) {
+81
View File
@@ -0,0 +1,81 @@
const http = require('http');
module.exports = {
type: 'docker',
category: 'discovery',
name: 'Docker Daemon',
description: 'Discover running containers and networks from a local or remote Docker daemon.',
configSchema: [
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' }
],
validate: async (config) => {
if (!config.socketPath && !config.tcpHost) {
return { ok: false, error: 'Must provide either socketPath or tcpHost' };
}
return { ok: true };
},
discover: async (config) => {
const isTcp = !!config.tcpHost;
const requestOptions = {
path: '/containers/json',
method: 'GET'
};
if (isTcp) {
const url = new URL(config.tcpHost);
requestOptions.host = url.hostname;
requestOptions.port = url.port || (url.protocol === 'https:' ? 443 : 80);
requestOptions.protocol = url.protocol;
} else {
requestOptions.socketPath = config.socketPath || '/var/run/docker.sock';
}
return new Promise((resolve, reject) => {
const req = http.request(requestOptions, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(new Error(`Docker API error: ${res.statusCode} ${body}`));
}
try {
const containers = JSON.parse(body);
const resources = [];
const edges = [];
for (const c of containers) {
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
const slug = `docker-cnt-${c.Id.substring(0, 12)}`;
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
resources.push({
kind: 'container',
name: name,
slug: slug,
metadata: {
image: c.Image,
state: c.State,
status: c.Status,
ports: ports
}
});
}
resolve({ resources, edges });
} catch (e) {
reject(new Error(`Failed to parse Docker response: ${e.message}`));
}
});
});
req.on('error', (e) => reject(new Error(`Docker connection error: ${e.message}`)));
req.end();
});
}
};
+101
View File
@@ -0,0 +1,101 @@
const nmap = require('node-nmap');
nmap.nmapLocation = "nmap"; // default
module.exports = {
// Plugin manifest — see nodejs/services/plugin_registry.js. `targetRange` is
// not secret (it's a network range to scan), so it lives in the DB row, not
// OpenBao. nmap itself has no credentials to test, so `validate` only checks
// the range parses — running a real scan is what `run` does.
type: 'nmap',
category: 'discovery',
name: 'Nmap Network Scan',
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
configSchema: [
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' }
],
validate: async (config) => {
const { targetRange } = config;
if (!targetRange) return { ok: false, error: 'Missing targetRange' };
// nmap accepts CIDR (a.b.c.d/24), ranges (a.b.c.d-50), and host lists. We
// only sanity-check shape here — reject anything with shell metacharacters
// or whitespace, since node-nmap passes this straight to the nmap binary.
if (/\s|[;|&$`<>]/.test(targetRange)) {
return { ok: false, error: 'targetRange must not contain whitespace or shell metacharacters' };
}
return { ok: true };
},
discover: async (config) => {
const { targetRange } = config;
if (!targetRange) throw new Error("Missing targetRange for Nmap");
return new Promise((resolve, reject) => {
// OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root).
// Pass custom arguments in constructor so node-nmap includes them before spawning nmap process.
// -Pn: treat all hosts as online (skip ping/ARP host discovery which fails inside Docker containers NAT/bridge)
// -sT: TCP connect scan (unprivileged scan compatible with container environments)
// -F: fast scan (100 top ports)
// --min-rate 100: speed up scan rate
const customFlags = ['-Pn', '-sT', '-F', '--min-rate', '100'];
const scan = new nmap.NmapScan(targetRange, customFlags);
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 = [];
for (const host of data) {
if (!host.ip) continue;
const hostId = host.mac ? host.mac.replace(/:/g, '') : host.ip.replace(/\\./g, '_');
const hostSlug = `nmap-host-${hostId}`;
const interfaces = [{ mac: host.mac || null, ip: host.ip }];
resources.push({
kind: 'host',
name: host.hostname || host.ip,
slug: hostSlug,
metadata: { interfaces, os: host.osNmap }
});
if (host.openPorts && host.openPorts.length > 0) {
for (const port of host.openPorts) {
const svcSlug = `nmap-svc-${hostId}-${port.port}`;
resources.push({
kind: 'service',
name: `${port.service} on ${port.port}`,
slug: svcSlug,
metadata: { port: port.port, protocol: port.protocol }
});
edges.push({ parentSlug: hostSlug, childSlug: svcSlug, relation: 'exposes' });
}
}
}
resolve({ resources, edges });
});
scan.on('error', function(error) {
// node-nmap's spawn-missing-binary message ("NMAP not found at command
// location: nmap") is opaque to an admin reading lastError. Translate
// it into something actionable. (The Dockerfile installs nmap in the
// app image; this only fires if someone runs outside the container or
// strips the package.)
var msg = (error && error.message) || String(error);
if (/nmap.*not found|command location/i.test(msg)) {
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
} else {
reject(error);
}
});
scan.startScan();
});
},
// Generalized plugin contract alias for `discover`. See proxmox.js for why
// this references module.exports rather than `this`.
run: async (config) => module.exports.discover(config)
};
+194
View File
@@ -0,0 +1,194 @@
const fetch = require('node-fetch');
const https = require('https');
// Custom agent to bypass self-signed certs typical in Proxmox
const agent = new https.Agent({
rejectUnauthorized: false
});
module.exports = {
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
// drives the admin UI form and validation; fields flagged `secret:true` are
// stored in OpenBao (secret/plugins/<instance-id>/conf), never in the DB.
type: 'proxmox',
category: 'discovery',
name: 'Proxmox VE',
description: 'Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.',
configSchema: [
{ key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' },
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' },
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
],
// "Test" button in the UI: hit the unauthenticated version endpoint with the
// API token to confirm the URL + token are valid before scheduling runs.
validate: async (config) => {
const { url, tokenId, tokenSecret } = config;
if (!url || !tokenId || !tokenSecret) return { ok: false, error: 'Missing url, tokenId, or tokenSecret' };
try {
const res = await fetch(`${url}/api2/json/version`, { headers: { 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}` }, agent });
if (!res.ok) return { ok: false, error: `Proxmox API rejected the token (${res.status})` };
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
},
discover: async (config) => {
let { url, tokenId, tokenSecret } = config;
if (!url || !tokenId || !tokenSecret) {
throw new Error("Missing Proxmox config");
}
const headers = {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
};
// Ensure URL has no trailing slash
url = url.endsWith('/') ? url.slice(0, -1) : url;
const resources = [];
const edges = [];
// 1. Get Nodes
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
if(!resNodes.ok) {
const errText = await resNodes.text();
throw new Error(`Proxmox API error on nodes: ${resNodes.status} ${errText}`);
}
const nodes = (await resNodes.json()).data;
for (const node of nodes) {
if (node.status !== 'online') continue;
const nodeSlug = `pve-node-${node.node}`;
resources.push({
kind: 'host',
name: node.node,
slug: nodeSlug,
metadata: {
subType: 'hypervisor',
os: 'Proxmox VE',
isProduction: true,
interfaces: []
}
});
// 2. Get VMs for this node
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
const vms = resVms.ok ? ((await resVms.json()).data || []) : [];
for (const vm of vms) {
const vmSlug = `vm-${vm.vmid}`;
const isTemplate = vm.template === 1;
let ips = [];
let macs = [];
// Enrich from QEMU guest agent if running
if (vm.status === 'running') {
try {
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
if (agentRes.ok) {
const agentData = (await agentRes.json()).data;
if (agentData && agentData.result) {
for (const iface of agentData.result) {
if (iface['hardware-address'] && iface['hardware-address'] !== '00:00:00:00:00:00') macs.push(iface['hardware-address']);
if (iface['ip-addresses']) {
for (const ip of iface['ip-addresses']) {
if (ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1') {
ips.push(ip['ip-address']);
}
}
}
}
}
}
} catch(e) {}
}
// Enrich from VM config to at least get MAC if agent failed/stopped
try {
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
if (configRes.ok) {
const confData = (await configRes.json()).data;
for (let i = 0; i < 10; i++) {
if (confData[`net${i}`]) {
const m = confData[`net${i}`].match(/(?:virtio|e1000|rtl8139|vmxnet3)=([0-9a-fA-F:]+)/);
if(m) macs.push(m[1].toLowerCase());
}
}
}
} catch(e) {}
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
resources.push({
kind: isTemplate ? 'template' : 'host',
name: vm.name || `VM ${vm.vmid}`,
slug: vmSlug,
metadata: {
subType: isTemplate ? 'template' : 'vm',
vmid: vm.vmid,
isProduction: vm.status === 'running',
interfaces,
ip: ips[0] || null
}
});
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
}
// 3. Get LXCs for this node
const resLxcs = await fetch(`${url}/api2/json/nodes/${node.node}/lxc`, { headers, agent });
const lxcs = resLxcs.ok ? ((await resLxcs.json()).data || []) : [];
for (const lxc of lxcs) {
const lxcSlug = `lxc-${lxc.vmid}`;
const isTemplate = lxc.template === 1;
let ips = [];
let macs = [];
// Enrich from LXC config
try {
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
if (configRes.ok) {
const confData = (await configRes.json()).data;
for (let i = 0; i < 10; i++) {
if (confData[`net${i}`]) {
const hwMatch = confData[`net${i}`].match(/hwaddr=([0-9a-fA-F:]+)/);
const ipMatch = confData[`net${i}`].match(/ip=([0-9\.]+)/); // Ignores dhcp
if(hwMatch) macs.push(hwMatch[1].toLowerCase());
if(ipMatch) ips.push(ipMatch[1]);
}
}
}
} catch(e) {}
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
resources.push({
kind: isTemplate ? 'template' : 'host',
name: lxc.name || `LXC ${lxc.vmid}`,
slug: lxcSlug,
metadata: {
subType: isTemplate ? 'template' : 'lxc',
vmid: lxc.vmid,
isProduction: lxc.status === 'running',
interfaces,
ip: ips[0] || null
}
});
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
}
}
return { resources, edges };
},
// The generalized plugin contract calls `run`; the discovery plugins keep
// `discover` as their implementation name for back-compat, and `run` is just
// an alias. Referenced via module.exports (not `this`) so it survives being
// detached and called as a bare function reference.
run: async (config) => module.exports.discover(config)
};
+134
View File
@@ -0,0 +1,134 @@
const fetch = require('node-fetch');
const https = require('https');
const agent = new https.Agent({
rejectUnauthorized: false
});
module.exports = {
// Plugin manifest — see nodejs/services/plugin_registry.js. `password` is
// secret and stored in OpenBao (secret/plugins/<instance-id>/conf).
type: 'unifi',
category: 'discovery',
name: 'UniFi Network',
description: 'Discover UniFi network devices and clients from a UniFi Controller / UDM endpoint.',
configSchema: [
{ key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' },
{ key: 'user', label: 'Username', type: 'text', required: true },
{ key: 'password', label: 'Password', type: 'password', required: true, secret: true }
],
// "Test": attempt the UDM login (falls back to the legacy controller login);
// succeeds only if one of the two login endpoints returns 200.
validate: async (config) => {
const { url, user, password } = config;
if (!url || !user || !password) return { ok: false, error: 'Missing url, user, or password' };
try {
let loginRes = await fetch(`${url}/api/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password }), agent
});
if (!loginRes.ok) {
loginRes = await fetch(`${url}/api/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password }), agent
});
}
if (!loginRes.ok) return { ok: false, error: `UniFi auth failed (${loginRes.status})` };
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
},
discover: async (config) => {
const { url, user, password } = config;
if (!url || !user || !password) {
throw new Error("Missing Unifi config");
}
// 1. Authenticate
let loginRes = await fetch(`${url}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password }),
agent
});
let isUdm = true;
if (!loginRes.ok) {
loginRes = await fetch(`${url}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password }),
agent
});
isUdm = false;
}
if (!loginRes.ok) {
throw new Error(`Unifi auth failed: ${loginRes.status}`);
}
const cookie = loginRes.headers.get('set-cookie');
// UniFi often requires the CSRF token from the cookie
let csrf = '';
if (cookie) {
const match = cookie.match(/csrf_token=([^;]+)/);
if (match) csrf = match[1];
}
const headers = { 'Cookie': cookie, 'X-Csrf-Token': csrf };
const resources = [];
const edges = [];
const basePath = isUdm ? '/proxy/network' : '';
// 2. Get Devices (Switches/APs)
const devRes = await fetch(`${url}${basePath}/api/s/default/stat/device`, { headers, agent });
const devData = (await devRes.json()).data || [];
for (const dev of devData) {
const devSlug = `unifi-device-${dev.mac.replace(/:/g, '')}`;
resources.push({
kind: 'network_device',
name: dev.name || dev.model,
slug: devSlug,
metadata: {
make: 'Ubiquiti',
model: dev.model,
firmware: dev.version,
interfaces: [{ mac: dev.mac, ip: dev.ip }]
}
});
}
// 3. Get Clients
const clientRes = await fetch(`${url}${basePath}/api/s/default/stat/sta`, { headers, agent });
const clientData = (await clientRes.json()).data || [];
for (const client of clientData) {
const clientSlug = `unifi-client-${client.mac.replace(/:/g, '')}`;
resources.push({
kind: 'host', // Or unmanaged_device initially
name: client.hostname || client.name || client.mac,
slug: clientSlug,
metadata: {
interfaces: [{ mac: client.mac, ip: client.ip }]
}
});
// If we know which switch/AP it's on
if (client.ap_mac) {
const apSlug = `unifi-device-${client.ap_mac.replace(/:/g, '')}`;
edges.push({ parentSlug: apSlug, childSlug: clientSlug, relation: 'connected_to' });
}
}
return { resources, edges };
},
// Generalized plugin contract alias for `discover`. See proxmox.js for why
// this references module.exports rather than `this`.
run: async (config) => module.exports.discover(config)
};
+61
View File
@@ -0,0 +1,61 @@
const https = require('https');
module.exports = {
type: 'twilio',
category: 'messaging',
name: 'Twilio SMS',
description: 'Send SMS messages (like 2FA codes) via Twilio.',
configSchema: [
{ key: 'accountSid', label: 'Account SID', type: 'text', required: true },
{ key: 'authToken', label: 'Auth Token', type: 'password', required: true, secret: true },
{ key: 'fromNumber', label: 'From Phone Number', type: 'text', required: true, placeholder: '+15551234567' }
],
validate: async (config) => {
if (!config.accountSid || !config.authToken) return { ok: false, error: 'Missing credentials' };
if (!config.fromNumber) return { ok: false, error: 'Missing fromNumber' };
return { ok: true };
},
sendMessage: async (config, payload) => {
const { to, message } = payload;
if (!to || !message) throw new Error("Missing 'to' or 'message' in payload");
const data = new URLSearchParams();
data.append('To', to);
data.append('From', config.fromNumber);
data.append('Body', message);
const postData = data.toString();
const options = {
hostname: 'api.twilio.com',
port: 443,
path: `/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(config.accountSid + ':' + config.authToken).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
};
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
reject(new Error(`Twilio API Error: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
};
+79
View File
@@ -0,0 +1,79 @@
const https = require('https');
const http = require('http');
module.exports = {
type: 'webhook',
category: 'messaging',
name: 'Universal REST Webhook',
description: 'Send a generic HTTP POST request with a custom JSON payload. Variables {{to}} and {{message}} will be replaced.',
configSchema: [
{ key: 'url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://api.example.com/send' },
{ key: 'method', label: 'HTTP Method', type: 'text', required: true, placeholder: 'POST' },
{ key: 'headers', label: 'Custom Headers (JSON)', type: 'text', required: false, placeholder: '{"Authorization": "Bearer ...", "Content-Type": "application/json"}' },
{ key: 'payloadTemplate', label: 'Payload Template', type: 'text', required: true, placeholder: '{"recipient": "{{to}}", "text": "{{message}}"}' },
{ key: 'apiSecret', label: 'API Secret / Auth Token', type: 'password', required: false, secret: true }
],
validate: async (config) => {
if (!config.url) return { ok: false, error: 'URL is required' };
if (!config.payloadTemplate) return { ok: false, error: 'Payload template is required' };
try {
if (config.headers) JSON.parse(config.headers);
} catch (e) {
return { ok: false, error: 'Headers must be valid JSON' };
}
return { ok: true };
},
sendMessage: async (config, payload) => {
const { to, message } = payload;
let payloadStr = config.payloadTemplate || '{}';
// Replace template variables safely
payloadStr = payloadStr.replace(/\{\{to\}\}/g, to).replace(/\{\{message\}\}/g, message);
// If there is an API secret, replace {{secret}} in the headers or url
let headersObj = {};
if (config.headers) {
try {
const parsed = JSON.parse(config.headers);
for (const [k, v] of Object.entries(parsed)) {
headersObj[k] = config.apiSecret ? String(v).replace(/\{\{secret\}\}/g, config.apiSecret) : v;
}
} catch(e) {}
}
if (!headersObj['Content-Type']) {
headersObj['Content-Type'] = 'application/json';
}
const urlObj = new URL(config.url);
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: config.method || 'POST',
headers: headersObj
};
const client = urlObj.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = client.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ status: res.statusCode, body });
} else {
reject(new Error(`Webhook failed: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.write(payloadStr);
req.end();
});
}
};
+6
View File
@@ -7,6 +7,12 @@ body {
display: flex;
flex-direction: column;
min-height: 100vh;
/* Height of the fixed navbar (plus the update banner, while shown --
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
sticky element offset itself below both fixed elements via
`top: var(--sw-content-offset)` instead of colliding with them at the
viewport's true top:0. */
--sw-content-offset: 4.5rem;
}
#spa-shell {
+45 -18
View File
@@ -67,13 +67,6 @@ app.user = (function(app){
});
}
function remove(args, callack){
if(!confirm('Delete '+ args.uid+ 'user?')) return false;
app.api.delete('user/'+ args.uid, function(error, data){
callack(error, data);
});
}
function changePassword(args, callack){
app.api.put('users/'+ arg.uid || '', args, function(error, data){
callack(error, data);
@@ -102,7 +95,15 @@ app.user = (function(app){
});
}
return {list, remove, createInvite, setActive};
// A user DN's cn is always their uid (see models/user_ldap.js addLdapUser,
// `data.cn = data.uid`) -- pulling it straight out of the DN avoids an
// extra lookup just to display a manager list.
function dnToUid(dn){
var m = /^cn=([^,]+)/i.exec(dn || '');
return m ? m[1] : dn;
}
return {list, createInvite, setActive, dnToUid};
})(app);
@@ -149,6 +150,21 @@ app.ui = (function(app){
// Drop the cache (e.g. after a group is created) so the next selector refetches.
function refreshGroups(){ _groupsPromise = null; return loadGroups(); }
// All usernames, fetched once and shared across every user selector (e.g. manager pickers).
var _usersPromise = null;
function loadUsers(){
if(!_usersPromise){
_usersPromise = new Promise(function(resolve){
app.user.list(function(error, data){
if(error || !data || !data.results){ resolve([]); return; }
resolve(data.results.map(function(u){ return u.uid; }).filter(Boolean).sort());
});
});
}
return _usersPromise;
}
function refreshUsers(){ _usersPromise = null; return loadUsers(); }
// opts: { values, options, freeSolo, placeholder, name, separator }
// Returns a handle: { get, set, add, clear, setOptions, element }.
function tagInput(mount, opts){
@@ -249,7 +265,25 @@ app.ui = (function(app){
return handle;
}
return { tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups };
// Universal user selector (e.g. picking managers). Preloads all usernames.
function userSelect(mount, opts){
opts = opts || {};
var handle = tagInput(mount, {
name: opts.name || 'manager',
values: opts.values || [],
options: [],
freeSolo: opts.freeSolo !== false,
separator: opts.separator != null ? opts.separator : '\n',
placeholder: opts.placeholder || 'Type a username…',
});
loadUsers().then(function(users){ handle.setOptions(users); });
return handle;
}
return {
tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups,
userSelect: userSelect, loadUsers: loadUsers, refreshUsers: refreshUsers,
};
})(app);
app.oauthClient = (function(app){
@@ -265,13 +299,6 @@ app.oauthClient = (function(app){
});
}
function remove(args, callack){
if(!confirm('Delete OAuth client "' + args.client_id + '"?')) return false;
app.api.delete('oauth/client/' + args.client_id, function(error, data){
callack(error, data);
});
}
function update(args, callack){
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
callack(error, data);
@@ -284,7 +311,7 @@ app.oauthClient = (function(app){
});
}
return { list, add, remove, update, rotateSecret };
return { list, add, update, rotateSecret };
})(app);
app.tos = (function(app){
@@ -355,7 +382,7 @@ app.impersonate = (function(app){
app.token = (function(app){
function list(name, callack){
if($.isFunction(name)){
if(typeof name === 'function'){
callack = name;
name = '';
}
+341 -170
View File
@@ -1,3 +1,12 @@
// Shared client framework for the theta42 apps.
//
// This file is byte-identical across sso-manager-node, proxy and jump-host —
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
// the /api/user/me response), never from edits to this file. Edit all three
// copies together.
//
// jQuery 4 safe: no $.isFunction, no $.holdReady.
var app = {};
app.pubsub = (function(){
@@ -45,7 +54,7 @@ app.pubsub = (function(){
app.socket = (function(app){
// $.getScript('/socket.io/socket.io.js')
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
var socket;
$(document).ready(function(){
socket = io({
@@ -75,11 +84,17 @@ app.socket = (function(app){
app.api = (function(app){
var baseURL = '/api/'
function post(url, data, callback){
if (!$.isFunction(callback)) {
return new Promise((resolve, reject) => {
// post/put/delete are dual-mode: pass a callback for the node-style
// (error, data, status) form, or omit it to get a Promise that resolves
// with the parsed body and rejects with the error body. get/options return
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
function body(method, url, data, callback){
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({
type: 'POST', url: baseURL+url,
type: method,
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
data: JSON.stringify(data),
contentType: 'application/json; charset=utf-8',
@@ -88,9 +103,11 @@ app.api = (function(app){
});
}
return $.ajax({
type: 'POST',
type: method,
url: baseURL+url,
headers:{ 'auth-token': app.auth.getToken() },
headers:{
'auth-token': app.auth.getToken()
},
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
@@ -104,40 +121,27 @@ app.api = (function(app){
});
}
function post(url, data, callback){
return body('POST', url, data, callback);
}
function put(url, data, callback){
if (!$.isFunction(callback)) {
return new Promise((resolve, reject) => {
$.ajax({
type: 'PUT', url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
data: JSON.stringify(data),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
});
}
return $.ajax({
type: 'PUT',
url: baseURL+url,
headers:{ 'auth-token': app.auth.getToken() },
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
);
}
});
return body('PUT', url, data, callback);
}
function remove(url, callback){
if (!$.isFunction(callback)) {
return new Promise((resolve, reject) => {
// Called both as (url, callback) and — from formAJAX, which always passes
// the serialized form as the second argument — as (url, data, callback).
// No request body is sent either way.
function remove(url, data, callback){
if(typeof data === 'function'){
callback = data;
data = undefined;
}
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({
type: 'DELETE', url: baseURL+url,
type: 'DELETE',
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
contentType: 'application/json; charset=utf-8',
dataType: 'json',
@@ -147,7 +151,9 @@ app.api = (function(app){
return $.ajax({
type: 'DELETE',
url: baseURL+url,
headers:{ 'auth-token': app.auth.getToken() },
headers:{
'auth-token': app.auth.getToken()
},
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
@@ -202,7 +208,10 @@ app.api = (function(app){
})(app)
app.auth = (function(app){
var user = {};
// One in-flight/cached GET /api/user/me per page load. Every gating
// decision (nav items, per-view forceLogin, group-required elements) reads
// this same promise instead of re-fetching.
var userPromise = null;
function setToken(token){
localStorage.setItem('APIToken', token);
@@ -216,35 +225,70 @@ app.auth = (function(app){
try{
return await app.api.get('user/me');
}catch(error){
if(error?.status === 401) return null;
throw error
if(error && error.status === 401) return null;
throw error;
}
}
// Cached current user, or false when there's no token at all. Callers that
// need a fresh copy (after a login or a profile change) pass force.
function loadUser(force){
if(force || !userPromise){
userPromise = getToken() ? getUser() : Promise.resolve(null);
userPromise = userPromise.then(function(user){
app.auth.user = app.auth.perms = user || null;
return user;
});
}
return userPromise;
}
// The apps report group membership two ways: sso-manager-node returns LDAP
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
// is exposed as a synthetic `admin` group so one gating model covers both.
function groupCNs(user){
var raw = (user && (user.memberOf || user.groups)) || [];
if(!Array.isArray(raw)) raw = [raw];
var names = raw.map(function(group){
return String(group).split(',')[0].replace(/^cn=/i, '');
});
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
return names;
}
async function memberOf(groupNameToFind, user){
try{
user = user || await app.auth.asyncUser;
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind]
user = user || await loadUser();
if(!user) return false;
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
for(let group of user.memberOf){
group = group.split(',ou=groups')[0].replace('cn=', '');
if(groupNameToFind.includes(group)) return true;
}
return false;
}catch(error){
throw(error);
}
return groupCNs(user).some(function(group){
return groupNameToFind.includes(group);
});
}
async function isLoggedIn(){
if(getToken()){
user = await app.auth.asyncUser;
return user;
}else{
return false;
// True when the logged-in user is a global admin (per user/me). Sync — only
// meaningful once isLoggedIn/forceLogin has resolved.
function isAdmin(){
return !!(app.auth.perms && app.auth.perms.isAdmin);
}
// Dual-mode: returns a Promise resolving to the user (or false), and calls
// an optional node-style callback with the same result.
function isLoggedIn(callback){
var promise = loadUser().then(function(user){
return user || false;
});
if(typeof callback === 'function'){
promise.then(function(user){
callback(null, user);
}, function(error){
callback(error, false);
});
}
return promise;
}
function logIn(args, callback){
@@ -252,62 +296,125 @@ app.auth = (function(app){
if(data.login){
setToken(data.token);
}
loadUser(true);
callback(error, !!data.token);
});
}
// Clears the session only — the caller decides where to go next (the nav's
// Log Out button uses ui.logoutRedirect).
function logOut(callback){
localStorage.removeItem('APIToken');
location.replace(`/login${location.href.replace(location.origin, '')}`);
callback();
userPromise = null;
app.auth.user = app.auth.perms = null;
if(typeof callback === 'function') callback();
}
// Constrain a redirect target to a same-origin absolute path. Rejects
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
function safeInternalPath(path){
if(typeof path !== 'string' || path.charAt(0) !== '/'
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
return '/';
}
return path;
}
// Consume an app token handed back by the OIDC callback via the URL
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
// forwards to the intended page. Returns true if a token was consumed.
function consumeTokenFragment(){
if(!location.hash) return false;
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
var token = params.get('token');
if(!token) return false;
setToken(token);
// redirect comes from the URL fragment (attacker-controllable); only
// allow a same-origin path so it can't become an open redirect / XSS.
var redirect = safeInternalPath(params.get('redirect') || '/');
// Drop the token from the address bar before navigating on.
history.replaceState(null, '', location.pathname + location.search);
window.location.href = redirect;
return true;
}
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
// unauthorised user is kept off the page by a redirect / an error panel
// rather than by pausing document ready.
//
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
// `admin` group covers the OIDC clients' isAdmin flag.
async function forceLogin(requiredGroups){
$.holdReady(true);
if(!await app.auth.isLoggedIn()) app.auth.logOut(function(){});
var user = await loadUser();
if(!user){
logOut(function(){});
location.replace('/login?redirect=' + encodeURIComponent(
location.pathname + location.search
));
return false;
}
if(user.onboardingRequired && location.pathname !== '/onboarding'){
location.replace('/onboarding');
return false;
}
if(requiredGroups){
if(!await memberOf(requiredGroups)){
console.log("Does not have permission!!!")
app.util.actionMessage(
`<h1>
<i class="fa-solid fa-triangle-exclamation"></i>
<b>You do not have permission to be here.</b>
<i class="fa-solid fa-triangle-exclamation"></i>
</h1>`,
$('#spa-shell'),
'danger',
);
throw new Error("User does not have permission");
}
if(requiredGroups && !await memberOf(requiredGroups, user)){
app.messages.action(
`<h1>
<i class="fa-solid fa-triangle-exclamation"></i>
<b>You do not have permission to be here.</b>
<i class="fa-solid fa-triangle-exclamation"></i>
</h1>`,
$('#spa-shell'),
'danger',
);
throw new Error("User does not have permission");
}
$.holdReady(false);
return user;
}
// Where to go after a successful login: the ?redirect= query param, or the
// legacy /login/<path> suffix form, constrained to a same-origin path. The
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
// is how the OIDC provider sends an unauthenticated user through login.
function logInRedirect(){
window.location.href = location.href.replace(location.origin+'/login', '') || '/'
var params = new URLSearchParams(location.search);
var target = params.get('redirect')
|| location.href.replace(location.origin + '/login', '')
|| '/';
window.location.href = safeInternalPath(target);
}
return {
getToken: getToken,
setToken: setToken,
getUser: getUser,
loadUser: loadUser,
groupCNs: groupCNs,
memberOf: memberOf,
isAdmin: isAdmin,
isLoggedIn: isLoggedIn,
safeInternalPath: safeInternalPath,
consumeTokenFragment: consumeTokenFragment,
user: null,
perms: null,
logIn: logIn,
logOut: logOut,
forceLogin,
logInRedirect,
getUser,
memberOf,
}
})(app);
app.auth.asyncUser = app.auth.getUser();
// Back-compat alias for views that awaited the cached user directly.
Object.defineProperty(app.auth, 'asyncUser', {
get: function(){ return app.auth.loadUser(); },
});
app.user = (function(app){
function list(callback){
@@ -338,6 +445,72 @@ app.user = (function(app){
})(app);
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
// these endpoints; the calls are inert elsewhere.
app.permission = (function(app){
function list(callback){
app.api.get('permission/', function(error, data){
callback(error, data);
});
}
function subjects(callback){
app.api.get('permission/subjects', function(error, data){
callback(error, data);
});
}
function add(args, callback){
app.api.post('permission/', args, function(error, data){
callback(error, data);
});
}
function remove(id, callback){
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
callback(error, data);
});
}
return {list, subjects, add, remove};
})(app);
app.group = (function(app){
function list(callback){
app.api.get('group/', function(error, data){
callback(error, data);
});
}
function add(args, callback){
app.api.post('group/', args, function(error, data){
callback(error, data);
});
}
function remove(name, callback){
app.api.delete('group/' + encodeURIComponent(name), function(error, data){
callback(error, data);
});
}
function addMember(name, username, callback){
app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){
callback(error, data);
});
}
function removeMember(name, username, callback){
app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){
callback(error, data);
});
}
return {list, add, remove, addMember, removeMember};
})(app);
app.util = (function(app){
function getUrlParameter(name){
@@ -347,65 +520,15 @@ app.util = (function(app){
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
};
function actionMessage(message, $targetPassed, type, callback){
message = message || '';
let $target = $targetPassed.closest('div.card').find('.actionMessage');
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
type = type || 'info';
callback = callback || function(){};
if($target.html() === message) return;
if($target.html()){
$target.slideUp('fast', function(){
$target.html('')
$target.removeClass (function(index, className){
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
});
if(message) return actionMessage(message, $target, type, callback);
$target.hide()
})
}else{
if(type) $target.addClass('bg-' + type);
if(!message.includes('<button')) message += `
<button class="action-close btn btn-sm btn-outline-dark float-end">
<i class="fa-solid fa-xmark"></i>
</button>
`
$target.html(message).slideDown('fast');
}
setTimeout(callback,10)
}
function actionConfirm(message, $target, type, callback){
return new Promise((resolve, reject) =>{
let id = crypto.randomUUID();
message = `
<h4 class"align-middle" >
<i class="fa-solid fa-triangle-exclamation"></i>
<b>${message}</b>
<span class="float-end">
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
<i class="fa-solid fa-circle-check"></i>
Confirm
</button>
<button type="button" class="btn btn-danger confirm-${id}">
<i class="fa-solid fa-circle-stop"></i>
Cancel
</button>
</span>
</h4>
`
actionMessage(message, $target, type);
$("body").on('click', `.confirm-${id}`, function(){
actionMessage('', $target, type);
resolve(!!$(this).data('confirm'));
});
});
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
// app.util.escapeHtml and app.messages.action/confirm.
function escapeHtml(s){
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
$.fn.serializeObject = function() {
@@ -415,8 +538,11 @@ app.util = (function(app){
for (let {name, value} of $(this).serializeArray()) {
console.log(name, value)
if (obj[name] === undefined) {
if (!value
if (!value
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
// Keep empty <textarea>s so a cleared field is submitted (and
// can reset a list, e.g. the per-host IP/header controls).
&& !$(this).filter(`textarea[name="${name}"]`).length
){
continue;
}
@@ -458,30 +584,79 @@ app.util = (function(app){
document.body.removeChild(element);
}
// Scroll a just-added/-edited element into view and flash its
// background, so the user's eye lands on the row that changed instead of
// it silently appearing/updating somewhere off-screen. Takes a jQuery
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
function revealItem(el){
var node = el && el.jquery ? el[0] : el;
if (!node) return;
if (typeof node.scrollIntoView === 'function') {
node.scrollIntoView({behavior: 'smooth', block: 'center'});
}
var prevTransition = node.style.transition;
var prevBg = node.style.backgroundColor;
node.style.transition = 'background-color 1.5s ease';
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
setTimeout(function(){
node.style.backgroundColor = prevBg;
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
}, 300);
}
return {
downloadFile: downloadFile,
getUrlParameter: getUrlParameter,
actionMessage: actionMessage,
actionConfirm,
escapeHtml: escapeHtml,
revealItem: revealItem,
}
})(app);
$( document ).ready(async function(){
// Reveal every .group-required-<cn> element the current user's groups entitle
// them to. Elements carrying .group-required start hidden (styles.css), so a
// user who is in no groups — or who isn't logged in — simply never sees them.
// The synthetic 'login' group is special: it's true for any authenticated user.
app.auth.applyGroupVisibility = function(user){
var groups = app.auth.groupCNs(user);
var isLoggedIn = !!user;
// Show content if the user has the correct group
for(let group of (await app.auth.asyncUser)?.memberOf || []){
var style = document.getElementById('group-required-rules');
if(!style){
style = document.createElement('style');
style.id = 'group-required-rules';
document.head.appendChild(style);
}
for(var group of groups){
try{
group = group.split(',ou=groups')[0].replace('cn=', '');
const sheet = document.styleSheets[0];
const selector = `.group-required-${group}`;
const cssText = `${selector} { display: revert !important; }`;
sheet.insertRule(cssText, sheet.cssRules.length);
style.sheet.insertRule(
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
style.sheet.cssRules.length
);
}catch(error){
// A group whose CN isn't a usable CSS identifier just gates nothing.
}
}
// The 'login' group is synthetic — it means "any authenticated user".
// Reveal .group-required-login for any logged-in user.
if(isLoggedIn){
try{
style.sheet.insertRule(
`.group-required-login { display: revert !important; }`,
style.sheet.cssRules.length
);
}catch(error){
// Ignore CSS escape errors.
}
}
};
$( document ).ready(async function(){
// Show content the user's groups entitle them to.
app.auth.applyGroupVisibility(await app.auth.loadUser());
$('div.row').fadeIn('slow'); //show the page
//panel button's
@@ -502,9 +677,9 @@ $( document ).ready(async function(){
$(this).closest('.card').slideUp('fast');
});
$('.actionMessage').on('click', 'button.action-close', function(event){
app.util.actionMessage(null, $(this));
});
// action-close click handling is wired by @simpleworkjs/frontend's
// app.messages.js (delegated on document, so it also covers messages
// rendered after this ready handler runs).
setInterval(()=>{
$('.momentFromNow').each((idx, el)=>{
@@ -535,20 +710,17 @@ function formAJAX(btn){
var method = ($form.attr('method') || 'post').toLowerCase();
if($form.validate && !$form.validate()){
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
app.messages.action('Please fix the form errors.', $form, 'danger')
return false;
}
app.util.actionMessage(
`<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>`,
$form,
'info'
);
// Plain text: app.messages.action HTML-escapes its message (by design,
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
// render literally instead of as an element.
app.messages.action('Saving…', $form, 'info');
app.api[method]($form.attr('action'), formData, function(error, data){
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
$form.validateClear();
if(!error){
$form.trigger("reset");
@@ -556,7 +728,7 @@ function formAJAX(btn){
}else{
console.log('formAJAX res error', error, data)
if(data && data.name === 'ObjectValidateError'){
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
}
if(data && data.keys){
console.log('form key errors', data.keys)
@@ -567,4 +739,3 @@ function formAJAX(btn){
}
});
}
-133
View File
@@ -1,133 +0,0 @@
( function( $ ) {
var settings = {
rule: {
eq: function(value, options){
var compare = $('[name=' + options + ']').val();
if ( value != compare ) {
return "Miss-match";
}
}
},
};
$.fn.validate = function(event) {
// let thisSettings = $.extend(true, settings, settingsObj);
let hasErrors = false;
if(this.is('[validate]')) return this.validateField(event);
if(!this.attr('isValid')){
console.log('adding reset event')
this.on('reset', function(){
$(this).attr('isValid', false);
$(this).validateClear();
})
}
this.find('[validate]').each(function(){
if(!$(this).validateField()) hasErrors = true;
});
this.attr('isValid', !hasErrors);
if(hasErrors && event) event.preventDefault();
return !hasErrors;
};
$.fn.validateClear = function(){
$(this).find('input').each(function(){
$(this).removeClass('is-invalid');
$(this).removeClass('is-valid');
})
}
$.fn.validateField = function(){
var attr = this.attr('validate').split(':'); //array of params
var rule = attr[0];
var options = attr[1];
var value = this.val(); //link to input value
var message;
if(this.prop('disabled')) return true;
//checks if field is required, and length
if(!isNaN(options) && value.length < options){
message = `Must be ${options} characters`;
}
//checks if empty to stop processing
if(!isNaN(options) && value.length === 0) {
}else if(rule in settings.rule){
let message = settings.rule[rule].apply(this, [value, options]);
}
this.validateMessage(message)
return !message;
}
$.fn.validateMessage = function(message){
if(message && message !== true){
this.closest('.form-group').find('b.invalid-feedback').html(message);
this.addClass('is-invalid');
}else{
this.removeClass('is-invalid');
this.addClass('is-valid');
}
return this;
};
jQuery.extend({
validateSettings: function( settingsObj ) {
$.extend( true, settings, settingsObj );
},
validateInit: function( ettingsObj ) {
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
$( this ).validate( settingsObj, event );
});
}
});
}( jQuery ));
$.validateSettings({
rule:{
ip: function( value ) {
value = value.split( '.' );
if ( value.length != 4 ) {
return "Malformed IP";
}
$.each( value, function( key, value ) {
if( value > 255 || value < 0 ) {
return "Malformed IP";
}
});
},
host: function( value ) {
var reg = /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/;
if ( reg.test( value ) === false ) {
return "Invalid";
}
},
user: function( value ) {
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
if ( reg.test( value ) === false ) {
return "Invalid";
}
},
password: function( value ) {
var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/;
if ( reg.test( value ) === false ) {
return "Weak password, Try again";
}
}
}
});
@@ -0,0 +1,130 @@
#!/bin/bash
set -e
# --- Configuration ---
# In a real environment, these would be derived from the script's download URL
# or passed as additional arguments. For now, we use the most recent release.
BINARY_URL="${BINARY_URL:-}"
CONFIG_DIR="/etc/theta42"
CONFIG_FILE="$CONFIG_DIR/agent.yml"
BIN_PATH="/usr/local/bin/theta-agent"
SERVICE_FILE="/etc/systemd/system/theta-agent.service"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
log() { echo -e "${GREEN}[+]${NC} $1"; }
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
# 1. Root check
if [ "$EUID" -ne 0 ]; then
error "This script must be run as root."
fi
# 2. Argument Parsing
URL=""
TOKEN=""
B64_CONFIG=""
while [[ $# -gt 0 ]]; do
case $1 in
--url)
URL="$2"
shift 2
;;
--token)
TOKEN="$2"
shift 2
;;
*)
B64_CONFIG="$1"
shift
;;
esac
done
# Validation
if [ -z "$B64_CONFIG" ] && [ -z "$URL" ] || [ -z "$B64_CONFIG" ] && [ -z "$TOKEN" ]; then
error "Missing required configuration. Either provide a base64 encoded config, or both --url and --token."
echo "Usage examples:"
echo " sh install.sh \"BASE64_CONFIG\""
echo " sh install.sh --url \"https://sso.local\" --token \"secret-token\""
exit 1
fi
# 3. Resolve binary URL dynamically if not specified
if [ -z "$BINARY_URL" ]; then
if [ -n "$URL" ]; then
BINARY_URL="${URL%/}/resources/theta-agent/theta-agent-linux-amd64"
elif [ -n "$B64_CONFIG" ]; then
EXTRACTED_URL=$(echo "$B64_CONFIG" | base64 -d 2>/dev/null | grep -E '^\s*server_url:' | awk -F'"' '{print $2}' | tr -d ' ' || true)
if [ -n "$EXTRACTED_URL" ]; then
HTTP_URL=$(echo "$EXTRACTED_URL" | sed -e 's/^wss:\/\//https:\/\//' -e 's/^ws:\/\//http:\/\//')
BINARY_URL="${HTTP_URL%/}/resources/theta-agent/theta-agent-linux-amd64"
fi
fi
fi
if [ -z "$BINARY_URL" ]; then
BINARY_URL="https://sso.example.com/resources/theta-agent/theta-agent-linux-amd64"
fi
log "Downloading binary from $BINARY_URL..."
curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary."
chmod +x "$BIN_PATH"
# 4. Setup configuration
log "Preparing configuration directory $CONFIG_DIR..."
mkdir -p "$CONFIG_DIR"
chmod 755 "$CONFIG_DIR"
if [ -n "$B64_CONFIG" ]; then
log "Decoding and writing configuration from base64..."
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
else
log "Generating minimal configuration from arguments..."
# Create a minimal yaml with the provided URL and Token
cat <<EOF > "$CONFIG_FILE"
server_url: "$URL"
auth_token: "$TOKEN"
location: "unknown"
capabilities:
telemetry: true
configure_ldap: false
reboot: false
service_control: []
arbitrary_bash: false
EOF
fi
chmod 600 "$CONFIG_FILE"
# 5. Setup systemd service
log "Creating systemd service unit..."
cat <<EOF > "$SERVICE_FILE"
[Unit]
Description=Theta Agent Unified Endpoint Management
After=network.target
[Service]
Type=simple
ExecStart=$BIN_PATH
Restart=always
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=theta-agent
[Install]
WantedBy=multi-user.target
EOF
# 6. Start the agent
log "Enabling and starting Theta Agent..."
systemctl daemon-reload
systemctl enable theta-agent
systemctl start theta-agent
log "Theta Agent installation complete!"
log "Verify status with: systemctl status theta-agent"
log "Check logs with: journalctl -u theta-agent -f"
Binary file not shown.
+266
View File
@@ -0,0 +1,266 @@
'use strict';
// Self-service access requests. Mounted at /api/access-requests (app.js).
//
// The loop this closes: a user browses the catalog, finds something they cannot
// reach, asks for it; the resource's owner (or a directory admin) approves; the
// approval performs the LDAP group add. LDAP stays the access-control truth --
// this router never invents a permission, it only automates the group add an
// admin would otherwise do by hand, and records who decided.
const router = require('express').Router();
const { Resource, ResourceGroup } = require('../models/resource');
const { AccessRequest, STATUS } = require('../models/access_request');
const { Group } = require('../models/group_ldap');
const { User } = require('../models/user_ldap');
const { Mail } = require('../models/email');
const { groupCns } = require('../utils/user_groups');
const { envelope, projectResource } = require('@simpleworkjs/directory-schema');
const DIRECTORY_ADMIN_GROUPS = ['app_sso_directory_admin', 'app_sso_admin', 'app_super_admin'];
function httpError(status, message) {
const err = new Error(message);
err.status = status;
return err;
}
// May `user` decide requests against `resource`? The resource's own owner is
// the primary approver -- that is the point of Resource.owner -- with directory
// admins as the catch-all so an unowned or orphaned resource is never stuck.
async function canDecide(user, resource, callerGroups) {
if (resource && resource.owner && resource.owner === user.uid) return true;
return callerGroups.some(g => DIRECTORY_ADMIN_GROUPS.includes(g));
}
// The group that satisfies a request for this resource. Prefers an explicit
// choice, else the `member`-level link (the "just let me use it" group) over an
// `owner`-level one -- requesting a resource should never silently escalate to
// its admin group.
async function resolveGroupCn(resourceId, requested) {
const links = await ResourceGroup.list({ where: { resourceId } });
if (!links.length) {
throw httpError(409, 'This resource has no access group linked, so it cannot be requested.');
}
if (requested) {
const match = links.find(l => l.groupCn === requested);
if (!match) throw httpError(400, `"${requested}" is not an access group for this resource.`);
return match.groupCn;
}
const member = links.find(l => l.accessLevel === 'member');
return (member || links[0]).groupCn;
}
// Best-effort notification. A mail failure must never fail the request itself --
// the row is the source of truth and the approver can find it in the UI.
async function notify(uid, subject, message) {
try {
const user = await User.get({ uid });
if (!user || !user.mail) return;
await Mail.sendTemplate(user.mail, 'notification', {
givenName: user.givenName || uid,
subject,
message,
});
} catch (err) {
console.error(`access-request: notification to ${uid} failed:`, err.message);
}
}
// POST /api/access-requests { slug | resourceId, groupCn?, note? }
router.post('/', async (req, res, next) => {
try {
if (req.user.isMachine) throw httpError(403, 'Machine accounts cannot request access.');
let resource;
if (req.body.slug) {
const found = await Resource.list({ where: { slug: req.body.slug } });
resource = found[0];
} else if (req.body.resourceId) {
resource = await Resource.get(req.body.resourceId);
}
if (!resource) throw httpError(404, 'Resource not found');
const md = resource.metadata || {};
// Opt-out, not opt-in: everything in the catalog is requestable unless an
// admin has explicitly marked it otherwise.
if (md.requestable === false) {
throw httpError(409, 'This resource is not available for self-service requests.');
}
const groupCn = await resolveGroupCn(resource.id, req.body.groupCn);
const callerGroups = await groupCns(req.user);
if (callerGroups.includes(groupCn)) {
throw httpError(409, 'You already have access to this resource.');
}
const existing = await AccessRequest.findOpen(req.user.uid, groupCn);
if (existing) throw httpError(409, 'You already have a pending request for this resource.');
const request = await AccessRequest.create({
uid: req.user.uid,
resourceId: resource.id,
groupCn,
status: STATUS.PENDING,
note: req.body.note || '',
requestedOn: Date.now(),
});
if (resource.owner) {
await notify(
resource.owner,
`Access request: ${resource.name}`,
`<p><strong>${req.user.uid}</strong> has requested access to <strong>${resource.name}</strong> (group <code>${groupCn}</code>).</p>` +
(req.body.note ? `<p>Their note: ${req.body.note}</p>` : '') +
`<p>Review it on the Directory page.</p>`
);
}
res.json(envelope(request));
} catch (err) { next(err); }
});
// GET /api/access-requests/mine — the caller's own request history.
router.get('/mine', async (req, res, next) => {
try {
const rows = await AccessRequest.listForUser(req.user.uid);
res.json(envelope(await decorate(rows)));
} catch (err) { next(err); }
});
// GET /api/access-requests — pending requests the caller may decide.
router.get('/', async (req, res, next) => {
try {
const callerGroups = await groupCns(req.user);
const isAdmin = callerGroups.some(g => DIRECTORY_ADMIN_GROUPS.includes(g));
const pending = await AccessRequest.listPending();
let visible = pending;
if (!isAdmin) {
// A plain resource owner sees only requests against resources they own.
const owned = await Resource.list({ where: { owner: req.user.uid } });
const ownedIds = new Set(owned.map(r => r.id));
visible = pending.filter(r => ownedIds.has(r.resourceId));
}
res.json(envelope(await decorate(visible)));
} catch (err) { next(err); }
});
// Attach the resource name/slug each row refers to. The UI needs it on every
// list and would otherwise issue one lookup per row.
async function decorate(rows) {
if (!rows.length) return [];
const resources = await Resource.list();
const byId = new Map(resources.map(r => [r.id, r]));
return rows.map(row => {
const data = row.toJSON ? row.toJSON() : { ...row };
const resource = byId.get(data.resourceId);
data.resource = resource
? { id: resource.id, name: resource.name, slug: resource.slug, kind: resource.kind }
: null;
return data;
});
}
// POST /api/access-requests/:id/approve { decisionNote? }
router.post('/:id/approve', async (req, res, next) => {
try {
const request = await AccessRequest.get(req.params.id);
if (!request) throw httpError(404, 'Request not found');
if (request.status !== STATUS.PENDING) {
throw httpError(409, `This request was already ${request.status}.`);
}
const resource = await Resource.get(request.resourceId);
const callerGroups = await groupCns(req.user);
if (!(await canDecide(req.user, resource, callerGroups))) {
throw httpError(403, 'You do not have permission to decide this request.');
}
// The LDAP write happens FIRST and is allowed to throw. Marking a request
// approved without the group add would show the user a grant they do not
// actually have -- a pending row is recoverable, a lying one is not.
const group = await Group.get(request.groupCn);
const user = await User.get({ uid: request.uid });
try {
await group.addMember(user);
} catch (err) {
// "already a member" is the goal state, not a failure. This happens
// routinely: groupOfNames requires at least one member, so creating a
// resource seeds its auto-created groups with the creator's DN, and an
// admin may also grant access by hand while a request sits pending.
// Without this the request would 500 and stay pending forever.
const alreadyMember = err.name === 'TypeOrValueExistsError' || err.code === 20;
if (!alreadyMember) throw err;
}
User.clearCache(); // membership feeds cached isAdmin / group-gated nav
const updated = await request.update({
status: STATUS.APPROVED,
decidedBy: req.user.uid,
decidedOn: Date.now(),
decisionNote: req.body.decisionNote || '',
});
await notify(
request.uid,
`Access approved: ${resource ? resource.name : request.groupCn}`,
`<p>Your request for <strong>${resource ? resource.name : request.groupCn}</strong> was approved by ${req.user.uid}.</p>` +
`<p>You may need to sign out and back in for the change to take effect everywhere.</p>`
);
res.json(envelope(updated));
} catch (err) { next(err); }
});
// POST /api/access-requests/:id/deny { decisionNote? }
router.post('/:id/deny', async (req, res, next) => {
try {
const request = await AccessRequest.get(req.params.id);
if (!request) throw httpError(404, 'Request not found');
if (request.status !== STATUS.PENDING) {
throw httpError(409, `This request was already ${request.status}.`);
}
const resource = await Resource.get(request.resourceId);
const callerGroups = await groupCns(req.user);
if (!(await canDecide(req.user, resource, callerGroups))) {
throw httpError(403, 'You do not have permission to decide this request.');
}
const updated = await request.update({
status: STATUS.DENIED,
decidedBy: req.user.uid,
decidedOn: Date.now(),
decisionNote: req.body.decisionNote || '',
});
await notify(
request.uid,
`Access request declined: ${resource ? resource.name : request.groupCn}`,
`<p>Your request for <strong>${resource ? resource.name : request.groupCn}</strong> was declined.</p>` +
(req.body.decisionNote ? `<p>Reason: ${req.body.decisionNote}</p>` : '')
);
res.json(envelope(updated));
} catch (err) { next(err); }
});
// DELETE /api/access-requests/:id — requester withdraws their own pending request.
router.delete('/:id', async (req, res, next) => {
try {
const request = await AccessRequest.get(req.params.id);
if (!request) throw httpError(404, 'Request not found');
if (request.uid !== req.user.uid) {
throw httpError(403, 'You can only withdraw your own requests.');
}
if (request.status !== STATUS.PENDING) {
throw httpError(409, `This request was already ${request.status}.`);
}
const updated = await request.update({ status: STATUS.CANCELLED, decidedOn: Date.now() });
res.json(envelope(updated));
} catch (err) { next(err); }
});
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
'use strict';
const express = require('express');
const agentManager = require('../utils/agent_manager');
module.exports = function initAgentWebSockets(app) {
if (!app.wss) {
console.warn("WebSocket server for agents is not initialized.");
return;
}
app.wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const token = url.searchParams.get('token') || req.headers['authorization'];
if (!token) {
ws.close(4001, 'Unauthorized: Missing token');
return;
}
const remoteAddr = req.socket.remoteAddress;
console.log(`[Theta Agent] Agent connected from ${remoteAddr} with token ${token.substring(0, 8)}...`);
agentManager.registerAgent(token, ws, remoteAddr);
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
if (!data || typeof data.type !== 'string') return;
const payload = data.payload || {};
switch (data.type) {
case 'discovery':
agentManager.handleDiscovery(token, payload);
if (app.io) app.io.emit('agent.discovery', { token, payload });
break;
case 'telemetry':
agentManager.handleTelemetry(token, payload);
if (app.io) app.io.emit('agent.telemetry', { token, payload });
break;
case 'heartbeat':
agentManager.handleHeartbeat(token, payload, ws);
break;
case 'response':
agentManager.handleResponse(token, payload);
if (app.io) app.io.emit('agent.response', { token, payload });
break;
default:
console.log(`[Theta Agent] Received message type '${data.type}' from ${token}`);
}
} catch (err) {
console.error("[Theta Agent] Error parsing message:", err);
}
});
ws.on('close', () => {
console.log(`[Theta Agent] Agent disconnected (${token})`);
agentManager.unregisterAgent(token, ws);
});
// Send initial welcome/config payload
try {
ws.send(JSON.stringify({
type: 'config',
payload: {
message: 'Connected to SSO Manager C2',
protocol_version: '1.1.0'
}
}));
} catch (e) {}
});
// REST API routes for Agent Management (mounted under /api/agent)
const router = express.Router();
router.get('/nodes', (req, res) => {
res.json({
status: 'ok',
agents: agentManager.getConnectedAgents(),
publicKey: agentManager.publicKeyPem
});
});
router.post('/nodes/:token/command', (req, res) => {
const { token } = req.params;
const { command, payload, isHighRisk } = req.body;
if (!command) {
return res.status(400).json({ status: 'error', message: 'Command type is required' });
}
try {
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command);
const msg = agentManager.sendCommand(token, command, payload || {}, requiresSigning);
res.json({ status: 'ok', sentMessage: msg });
} catch (err) {
res.status(400).json({ status: 'error', message: err.message });
}
});
app.use('/api/agent', router);
};
+194
View File
@@ -0,0 +1,194 @@
const router = require('express').Router();
const baoConf = require('@simpleworkjs/bao-conf');
const permission = require('../utils/permission');
const conf = require('@simpleworkjs/conf');
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
next();
} catch(err) {
next(err);
}
});
// Secret fields stored inside secret/sso-manager/conf. These are NEVER returned
// in cleartext by GET /api/conf (masked to MASK below) and, on save, a blank or
// mask-valued submission preserves the stored value so an admin editing an
// unrelated field (e.g. the From address) doesn't have to re-enter — or leak —
// the SMTP password / OAuth JWT secret. Mirrors the plugin-secrets discipline.
const MASK = '********';
const SECRET_PATHS = [
['smtp', 'pass'],
['oauth', 'jwtSecret'],
['voipms', 'password'],
];
function maskSecrets(obj) {
const out = JSON.parse(JSON.stringify(obj));
for (const [grp, key] of SECRET_PATHS) {
if (out[grp] && out[grp][key]) out[grp][key] = MASK;
}
return out;
}
router.get('/', async (req, res) => {
const editable = maskSecrets({
smtp: conf.smtp || {},
discovery: conf.discovery || {},
oauth: conf.oauth || {},
voipms: conf.voipms || {}
});
res.json(editable);
});
// Shallow-per-key merge of `src` into the live conf object (matches the old
// conf_manager.applyConf behaviour: nested objects are spread, not deep-merged,
// so call-time conf readers see saved values without a restart).
function applyToLiveConf(src) {
if (!src) return;
for (const key of Object.keys(src)) {
if (typeof src[key] === 'object' && src[key] !== null && !Array.isArray(src[key])) {
conf[key] = { ...(conf[key] || {}), ...src[key] };
} else {
conf[key] = src[key];
}
}
}
router.post('/', async (req, res, next) => {
try {
const existing = await baoConf.get('sso-manager/conf') || {};
const incoming = req.body || {};
// Preserve secret fields the admin left blank (or left showing the mask):
// drop them from the incoming merge so the stored value survives. Only a
// genuinely new, non-blank, non-mask value overwrites.
for (const [grp, key] of SECRET_PATHS) {
if (incoming[grp] && incoming[grp][key] !== undefined) {
const submitted = incoming[grp][key];
if (submitted === '' || submitted === MASK) delete incoming[grp][key];
}
}
// Deep merge incoming into existing
for (const key of Object.keys(incoming)) {
if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) {
existing[key] = { ...(existing[key] || {}), ...incoming[key] };
} else {
existing[key] = incoming[key];
}
}
await baoConf.set('sso-manager/conf', existing);
// Reflect the saved values in the live conf immediately (the next boot's
// bao-conf.init() would pick them up too, but this keeps running readers
// current without a restart, as the old conf_manager did). `existing`
// carries the preserved secret values, so live conf keeps them too.
applyToLiveConf(existing);
res.json({ success: true });
} catch(err) {
next(err);
}
});
router.get('/proxy', async (req, res, next) => {
try {
const proxyConf = await baoConf.get('proxy/conf') || {};
const editable = JSON.parse(JSON.stringify(proxyConf));
if (editable.oidc && editable.oidc.clientSecret) editable.oidc.clientSecret = MASK;
if (editable.ldap && editable.ldap.bindPassword) editable.ldap.bindPassword = MASK;
res.json(editable);
} catch(err) {
next(err);
}
});
router.post('/proxy', async (req, res, next) => {
try {
const existing = await baoConf.get('proxy/conf') || {};
const incoming = req.body || {};
if (incoming.oidc && incoming.oidc.clientSecret !== undefined) {
if (incoming.oidc.clientSecret === '' || incoming.oidc.clientSecret === MASK) delete incoming.oidc.clientSecret;
}
if (incoming.ldap && incoming.ldap.bindPassword !== undefined) {
if (incoming.ldap.bindPassword === '' || incoming.ldap.bindPassword === MASK) delete incoming.ldap.bindPassword;
}
for (const key of Object.keys(incoming)) {
if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) {
existing[key] = { ...(existing[key] || {}), ...incoming[key] };
} else {
existing[key] = incoming[key];
}
}
await baoConf.set('proxy/conf', existing);
res.json({ success: true });
} catch(err) {
next(err);
}
});
// Send a test email to verify SMTP configuration
router.post('/test-email', async (req, res, next) => {
try {
const { to, subject, body } = req.body || {};
if (!to) {
return res.status(400).json({ error: 'Recipient email address is required' });
}
// Use the email model to send the test message
const Email = require('../models/email');
const testSubject = subject || 'SSO Manager Test Email';
const testBody = body || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
await Email.send(to, testSubject, testBody);
res.json({ success: true, message: `Test email sent to ${to}` });
} catch(err) {
next(err);
}
});
// Send a test SMS to verify VoIP.ms configuration
router.post('/test-sms', async (req, res, next) => {
try {
const { to, message } = req.body || {};
if (!to) {
return res.status(400).json({ error: 'Recipient phone number is required' });
}
const voipmsConf = conf.voipms || {};
if (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did) {
return res.status(400).json({ error: 'VoIP.ms credentials not configured. Please configure username, DID, and password in the SMS tab.' });
}
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your VoIP.ms configuration is working correctly.`;
// VoIP.ms SMS API endpoint
const voipmsApiUrl = 'https://api.voip.ms/v1.0';
const authHeader = Buffer.from(`${voipmsConf.username}:${voipmsConf.password}`).toString('base64');
const response = await fetch(`${voipmsApiUrl}/sms/send`, {
method: 'POST',
headers: {
'Authorization': `Basic ${authHeader}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
did: voipmsConf.did,
to: to,
message: testMessage
})
});
const result = await response.json();
if (result.status === 'success') {
res.json({ success: true, message: `Test SMS sent to ${to}` });
} else {
res.status(400).json({ error: `VoIP.ms API error: ${result.message || 'Unknown error'}` });
}
} catch(err) {
next(err);
}
});
module.exports = router;
+417
View File
@@ -0,0 +1,417 @@
'use strict';
const router = require('express').Router();
const permission = require('../utils/permission');
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
const { Group } = require('../models/group_ldap');
const { User } = require('../models/user_ldap');
const { cnFromDn } = require('../utils/user_groups');
const { projectResources } = require('@simpleworkjs/directory-schema');
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
// transitively in the parent. Idempotent and non-fatal: "already a member" is
// the goal state, and a missing group (e.g. app_super_admin absent on a
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
// caller's real work.
async function nestGroup(childCn, parentCn) {
try {
const parent = await Group.get(parentCn);
const child = await Group.get(childCn);
if (await Group.wouldCycle(parentCn, child.dn)) {
console.error(`nestGroup: refusing ${childCn} -> ${parentCn} (would create a cycle)`);
return;
}
await parent.addMember({ dn: child.dn });
} catch (err) {
const benign = err.name === 'TypeOrValueExistsError' || err.code === 20 || err.name === 'GroupNotFound';
if (!benign) console.error(`nestGroup: ${childCn} -> ${parentCn} failed:`, err.message);
}
}
// Require the admin group
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
next();
} catch(err) {
next(err);
}
});
// --- Resources ---
router.get('/resources', async (req, res, next) => {
try {
let resources = await Resource.list();
resources = resources.filter(r => {
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
const isManaged = r.metadata?.managed === true;
return !isAuto || isManaged;
});
// Even admins never receive secret metadata (e.g. client_secret_hash) over
// the wire; projectResources strips it unconditionally.
res.json({ results: projectResources(resources, { fullMetadata: true }) });
} catch (err) { next(err); }
});
router.post('/resources', async (req, res, next) => {
try {
if (!req.body.hostId && req.body.parentSlug) {
const parents = await Resource.list({ where: { slug: req.body.parentSlug } });
if (parents.length > 0) req.body.hostId = parents[0].id;
}
if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
}
if (req.body.kind === 'service' && !req.body.hostId) {
return res.status(400).json({ error: 'Services must have a parent Host' });
}
if (req.body.kind === 'oauth' && !req.body.hostId) {
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
}
req.body.owner = req.body.owner || req.user.uid;
const now = Date.now();
req.body.created_by = req.body.created_by || req.user.uid;
req.body.created_on = now;
req.body.updated_by = req.user.uid;
req.body.updated_on = now;
let r;
if (req.body.kind === 'oauth') {
const { OAuthClient } = require('../models/oauth_client');
// Pass created_by explicitly for the wrapper (overrides the generic
// assignment above -- this is OAuthClient-wrapper-specific behavior).
req.body.created_by = req.body.owner;
// In the UI we might pass slug, but OAuthClient wrapper expects name
r = await OAuthClient.add(req.body);
} else {
r = await Resource.create(req.body);
}
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
}
if (r.kind === 'host' || r.kind === 'service') {
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`);
const createGroup = async (suffix, accessLevel) => {
const cn = groupCn(suffix);
try {
await Group.add({
name: cn,
owner: req.user.dn,
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
});
} catch (err) {
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
console.error(`Failed to create LDAP group ${cn}:`, err);
}
}
try {
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
} catch(err) { /* ignore duplicate links */ }
};
await createGroup('access', 'member');
await createGroup('admin', 'owner');
// Wire up the two standing relationships every resource has, as nesting
// rather than as membership that has to be maintained per resource:
//
// app_super_admin -> <slug>_admin cross-app super admins administer
// every resource, automatically
// <slug>_admin -> <slug>_access administering something implies
// being able to use it
//
// Before nesting, both of these could only be expressed by adding every
// super admin to every new group by hand -- which nobody does, so the
// groups drifted. A failure here must not fail resource creation: the
// resource and its groups already exist and the nesting is repairable.
await nestGroup(groupCn('admin'), groupCn('access'));
await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin'));
}
res.json({ results: r });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(400).json({ error: 'A resource with this slug already exists.' });
}
if (err.name === 'SequelizeValidationError') {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
router.put('/resources/:id', async (req, res, next) => {
try {
// Validate before loading anything -- a rejected body should never have
// touched the store.
if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
}
if (req.body.kind === 'service' && !req.body.hostId) {
return res.status(400).json({ error: 'Services must have a parent Host' });
}
if (req.body.kind === 'oauth' && !req.body.hostId) {
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
}
// OAuthClient is a wrapper over the same `resource` row, but its .update()
// handles the oauth-specific body fields (redirect_uris, scopes,
// token_lifetime) that a bare Resource would drop into metadata unvalidated.
const { OAuthClient } = require('../models/oauth_client');
const model = req.body.kind === 'oauth' ? OAuthClient : Resource;
const r = await model.get(req.params.id);
if (!r) return res.status(404).json({ error: 'Not found' });
req.body.updated_by = req.user.uid;
req.body.updated_on = Date.now();
const updated = await r.update(req.body);
if ((updated.kind === 'host' || updated.kind === 'service' || updated.kind === 'oauth') && req.body.hostId !== undefined) {
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
for (const e of existingEdges) {
if (e.relation === 'hosts' || e.relation === 'oauth') await e.delete();
}
if (req.body.hostId) {
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: updated.kind === 'oauth' ? 'oauth' : 'hosts' });
}
}
res.json({ results: updated });
} catch (err) {
next(err);
}
});
router.post('/resources/:id/rotate-secret', async (req, res, next) => {
try {
const { OAuthClient } = require('../models/oauth_client');
const client = await OAuthClient.get(req.params.id);
const secret = await client.rotateSecret();
res.json({ secret });
} catch (err) {
next(err);
}
});
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);
if (!r) return res.status(404).json({ error: 'Not found' });
// Clear the dependents FIRST. There is no transaction here, so ordering is
// the only thing protecting us: if a dependent delete throws after the
// resource row is gone, the leftovers are edges/links pointing at a
// nonexistent id -- invisible in the UI and poisonous to getGraph(). Failing
// with the resource still present is the recoverable direction (retry the
// delete); the caller sees the error either way.
const edgesParent = await ResourceEdge.list({ where: { parentId: req.params.id } });
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
for (const g of groups) await g.delete();
await r.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Edges ---
router.get('/edges', async (req, res, next) => {
try {
const edges = await ResourceEdge.list();
res.json({ results: edges });
} catch (err) { next(err); }
});
router.post('/edges', async (req, res, next) => {
try {
const edge = await ResourceEdge.create(req.body);
res.json({ results: edge });
} catch (err) { next(err); }
});
router.delete('/edges/:id', async (req, res, next) => {
try {
const edge = await ResourceEdge.get(req.params.id);
if (!edge) return res.status(404).json({ error: 'Not found' });
await edge.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Groups ---
router.get('/groups', async (req, res, next) => {
try {
const groups = await ResourceGroup.list();
res.json({ results: groups });
} catch (err) { next(err); }
});
router.post('/groups', async (req, res, next) => {
try {
const g = await ResourceGroup.create(req.body);
res.json({ results: g });
} catch (err) { next(err); }
});
router.delete('/groups/:id', async (req, res, next) => {
try {
const g = await ResourceGroup.get(req.params.id);
if (!g) return res.status(404).json({ error: 'Not found' });
await g.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Access visibility ---
//
// The two questions an access-control pane has to answer, neither of which the
// directory could answer before: "who can reach this resource" (a column on the
// table, rather than three clicks into a modal) and "what can this user reach"
// (which had no UI at all). Both are joins of the same two sets, so both are
// served from one cached Group.listDetail() rather than a lookup per row.
// dn -> uid, so member DNs can be reported as the uids admins actually think in.
async function dnToUidMap() {
const users = await User.listDetail();
return new Map(users.map(u => [String(u.dn).toLowerCase(), u.uid]));
}
// GET /access-summary — { resourceId: { groups: [...], memberCount } }
router.get('/access-summary', async (req, res, next) => {
try {
const [links, groups, uidByDn] = await Promise.all([
ResourceGroup.list(),
Group.listDetail(),
dnToUidMap(),
]);
const groupByCn = new Map(groups.map(g => [g.cn, g]));
const summary = {};
for (const link of links) {
const group = groupByCn.get(link.groupCn);
// A link whose LDAP group has been deleted out from under it: report it
// rather than skipping, since a dangling link grants nothing and the
// admin needs to see that it is dead.
//
// Counts come from the transitive closure, not from `member`. Reading the
// attribute would report only who is listed on the group, missing anyone
// who reaches it through a nested group -- and since app_super_admin is
// nested into every resource's _admin group, that is not an edge case.
let members = [];
if (group) {
const eff = await Group.effectiveMembers(link.groupCn);
members = eff.effective.map(dn => uidByDn.get(String(dn).toLowerCase()) || cnFromDn(dn));
}
const entry = summary[link.resourceId] || (summary[link.resourceId] = { groups: [], members: [] });
entry.groups.push({
cn: link.groupCn,
accessLevel: link.accessLevel,
exists: !!group,
memberCount: members.length,
});
for (const uid of members) {
if (!entry.members.includes(uid)) entry.members.push(uid);
}
}
for (const id of Object.keys(summary)) {
summary[id].memberCount = summary[id].members.length;
}
res.json({ results: summary });
} catch (err) { next(err); }
});
// GET /user-access/:uid — every resource a given user can reach, and via which
// group. This is the reverse lookup; previously an admin could only see their
// own access, via /api/discovery/me.
router.get('/user-access/:uid', async (req, res, next) => {
try {
const user = await User.get({ uid: req.params.uid });
if (!user) return res.status(404).json({ error: 'User not found' });
const dn = String(user.dn).toLowerCase();
const groups = await Group.listDetail();
const memberOf = groups
.filter(g => [].concat(g.member || []).some(m => String(m).toLowerCase() === dn))
.map(g => g.cn);
const [links, resources] = await Promise.all([ResourceGroup.list(), Resource.list()]);
const byId = new Map(resources.map(r => [r.id, r]));
const results = [];
for (const link of links) {
if (!memberOf.includes(link.groupCn)) continue;
const resource = byId.get(link.resourceId);
if (!resource) continue;
results.push({
id: resource.id,
name: resource.name,
slug: resource.slug,
kind: resource.kind,
groupCn: link.groupCn,
accessLevel: link.accessLevel,
});
}
res.json({ results: { uid: user.uid, groups: memberOf, resources: results } });
} catch (err) { next(err); }
});
// Tail the last `lines` lines of a log file without shelling out. Reads at most
// the trailing MAX_TAIL_BYTES so an unrotated multi-GB log can't blow up the
// heap. A missing/unreadable file is normal (the log only exists once slapd has
// written to it), so it yields '' rather than an error.
const MAX_TAIL_BYTES = 256 * 1024;
async function tailFile(filePath, lines = 100) {
const fs = require('fs/promises');
let fh;
try {
fh = await fs.open(filePath, 'r');
const { size } = await fh.stat();
const start = Math.max(0, size - MAX_TAIL_BYTES);
const buf = Buffer.alloc(Math.min(size, MAX_TAIL_BYTES));
await fh.read(buf, 0, buf.length, start);
const text = buf.toString('utf8');
// A partial first line when we started mid-file; drop it.
const rows = (start > 0 ? text.slice(text.indexOf('\n') + 1) : text).split('\n');
return rows.slice(-lines).join('\n');
} catch (err) {
return '';
} finally {
if (fh) await fh.close().catch(() => {});
}
}
router.get('/audit-logs', async (req, res, next) => {
try {
const [ldap, oauth, audit] = await Promise.all([
tailFile('/var/lib/ldap/slapd.log'),
tailFile('/var/lib/ldap/oauth.log'),
tailFile('/var/lib/ldap/auditlog.ldif'),
]);
res.json({ results: { ldap, oauth, audit } });
} catch (err) { next(err); }
});
module.exports = router;
+44
View File
@@ -0,0 +1,44 @@
'use strict';
const router = require('express').Router();
const permission = require('../utils/permission');
const metrics = require('../utils/metrics');
// /api/metrics/overview
router.get('/overview', async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
const topIps = await metrics.getTopN('metrics:failed_ips', 7, 5);
const topUsers = await metrics.getTopN('metrics:failed_users', 7, 5);
const topServices = await metrics.getTopN('metrics:service_usage', 7, 5);
res.json({ results: { ips: topIps, users: topUsers, services: topServices } });
} catch(e) {
next(e);
}
});
// /api/metrics/user/:uid
router.get('/user/:uid', async (req, res, next) => {
try {
// Can only view if admin or self
if (req.user.uid !== req.params.uid) {
await permission.byGroup(req.user, ['app_sso_admin']);
}
// Failed logins for user is hard if we didn't track it by user, but wait, we did! metrics:failed_users:YYYY-MM-DD
// However, we didn't track failed IPs per user. We tracked failed_users as a sorted set.
// To get the user's failures, we just query their score from the union.
// Wait, for services we have user_service_usage:<uid>:<date>. No, in metrics.js I wrote:
// `metrics:user_service_usage:${username}:${date}`
const topServices = await metrics.getTopN('metrics:user_service_usage', 7, 5, req.params.uid);
res.json({ results: { services: topServices } });
} catch(e) {
next(e);
}
});
module.exports = router;
+293
View File
@@ -0,0 +1,293 @@
'use strict';
// Plugin instances API — the loadable, configurable, multi-copy plugin system.
//
// Replaces the old routes/plugins.js (which only toggled cron/enabled on static
// config via a Redis hash). Here every plugin is a PluginInstance row (see
// models/plugin_instance.js) with its own schedule and its secrets in OpenBao
// (utils/plugin_secrets.js), created/edited/loaded/unloaded through this API.
//
// Gated router-wide to the same admin groups as the directory admin API, so
// existing directory admins keep access. Secrets are never returned in
// cleartext — only masked (`********`) — and never persisted in the DB.
const router = require('express').Router();
const permission = require('../utils/permission');
const registry = require('../services/plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const { PluginInstance, STATUS } = require('../models/plugin_instance');
const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../services/scheduler');
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
// Derive a stable, unique slug from an instance name when the caller didn't
// supply one. Lowercases, collapses non-alnum runs to a single hyphen, trims,
// and prefixes `plugin-` if the result would otherwise start with a character
// SLUG_RE rejects. `isTaken(slug)` is consulted for uniqueness (a DB lookup);
// on collision we append `-2`, `-3`, … up to MAX_TRIES, then give up.
function slugify(name) {
let s = String(name || '').toLowerCase().trim();
s = s.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (!s) s = 'plugin';
if (!/^[a-z0-9]/.test(s)) s = 'plugin-' + s;
return s.slice(0, 64);
}
async function makeSlug(name, isTaken) {
const base = slugify(name);
if (!await isTaken(base)) return base;
for (let i = 2; i <= 16; i++) {
const cand = `${base}-${i}`.slice(0, 64);
if (!await isTaken(cand)) return cand;
}
return null; // exhausted
}
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
// (app_super_admin is always allowed by permission.byGroup).
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
next();
} catch (err) { next(err); }
});
// Plain object for the wire, with masked secret values attached under
// `secrets` and the run-state fields surfaced. The DB row never holds secrets.
async function serialize(instance) {
const obj = instance.toJSON ? instance.toJSON() : { ...instance };
const secrets = await pluginSecrets.read(instance.id).catch(() => ({}));
obj.secrets = registry.mask(instance.pluginType, secrets);
return obj;
}
// Validate a create/update payload against a plugin type's configSchema.
// Returns an error string or null. `flat` is the merged config + secret values
// (the UI sends one flat object; the API splits it).
function validateFields(type, flat) {
const required = registry.requiredKeys(type);
for (const key of required) {
const v = flat && flat[key];
if (v === undefined || v === null || v === '') {
return `Missing required field: ${key}`;
}
}
return null;
}
// --- Plugin types (for the create-instance picker + form) ---
router.get('/types', (req, res) => {
res.json({ results: registry.getTypes() });
});
// --- List instances ---
router.get('/', async (req, res, next) => {
try {
const instances = await PluginInstance.list();
const out = [];
for (const inst of instances) out.push(await serialize(inst));
res.json({ results: out });
} catch (err) { next(err); }
});
router.get('/:id', 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: await serialize(inst) });
} catch (err) { next(err); }
});
// --- Create instance ---
router.post('/', async (req, res, next) => {
try {
const { pluginType, name, slug, cron } = req.body;
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
if (!name) return res.status(400).json({ error: 'name is required' });
// Slug is optional: derive it from the name when absent. When supplied,
// validate it (admins editing via API may still pass one explicitly).
let finalSlug = slug;
if (finalSlug) {
if (!SLUG_RE.test(finalSlug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
} else {
finalSlug = await makeSlug(name, async (s) => !!(await PluginInstance.getBySlug(s)));
if (!finalSlug) return res.status(400).json({ error: 'Could not generate a unique slug from the name; supply one explicitly.' });
}
if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' });
// `config` from the client is a flat object of all field values (secret +
// non-secret). Split it: non-secret -> DB, secret -> OpenBao.
const flat = (req.body.config && typeof req.body.config === 'object') ? req.body.config : {};
const fieldErr = validateFields(pluginType, flat);
if (fieldErr) return res.status(400).json({ error: fieldErr });
const manifest = registry.getManifest(pluginType);
const { config, secrets } = registry.splitConfig(pluginType, flat);
const enabled = req.body.enabled !== false; // default true
const now = Date.now();
const instance = await PluginInstance.create({
pluginType,
category: manifest.category,
name,
slug: finalSlug,
enabled,
cron: cron || '0 * * * *',
config,
created_by: req.user.uid,
created_on: now,
updated_by: req.user.uid,
updated_on: now
});
try {
await pluginSecrets.write(instance.id, secrets);
} catch (err) {
// Most likely the sso-broker policy lacks secret/plugins/* — the
// operator needs theta-suite >= v1.30.1. Delete the row so a failed
// secret write doesn't strand a half-created instance.
await instance.delete().catch(() => {});
return res.status(400).json({ error: `Failed to store plugin secrets in OpenBao: ${err.message}. Re-run ./setup.sh with theta-suite >= v1.30.1.` });
}
if (enabled) {
await scheduleInstance(instance);
await runInstanceNow(instance.id);
}
res.json({ results: await serialize(instance) });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(400).json({ error: 'A plugin instance with this slug already exists.' });
}
next(err);
}
});
// --- Update instance (name/cron/enabled/non-secret config) ---
router.put('/:id', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` });
const updates = {};
if (req.body.name !== undefined) updates.name = req.body.name;
if (req.body.cron !== undefined) {
if (typeof req.body.cron !== 'string' || !req.body.cron.trim()) return res.status(400).json({ error: 'cron must be a non-empty string' });
updates.cron = req.body.cron;
}
if (req.body.enabled !== undefined) updates.enabled = !!req.body.enabled;
// Non-secret config: split the client's flat config so secret fields are
// never written to the DB. Secrets are changed via PUT /:id/secrets.
if (req.body.config !== undefined && typeof req.body.config === 'object') {
const { config } = registry.splitConfig(inst.pluginType, req.body.config);
updates.config = config;
}
updates.updated_by = req.user.uid;
updates.updated_on = Date.now();
const updated = await inst.update(updates);
// Re-schedule if the schedule-relevant fields moved.
if (updates.cron !== undefined || updates.enabled !== undefined) {
await scheduleInstance(updated);
}
res.json({ results: await serialize(updated) });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(400).json({ error: 'A plugin instance with this slug already exists.' });
}
next(err);
}
});
// --- Update secrets only ---
router.put('/:id/secrets', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` });
// Keep only declared secret fields; pluginSecrets.write drops blank/MASK
// values so an unchanged masked field is a no-op.
const { secrets } = registry.splitConfig(inst.pluginType, req.body || {});
await pluginSecrets.write(inst.id, secrets);
await inst.update({ updated_by: req.user.uid, updated_on: Date.now() });
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Test (validate) ---
router.post('/:id/test', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
const mod = registry.getModule(inst.pluginType);
if (typeof mod.validate !== 'function') return res.json({ ok: true, note: 'no validate defined' });
const cfg = await pluginSecrets.mergeForRun(inst);
const result = await mod.validate(cfg);
if (result && result.ok) return res.json(result);
return res.status(400).json(result || { ok: false, error: 'validation failed' });
} catch (err) {
return res.status(400).json({ ok: false, error: err.message });
}
});
// --- Load (enable + schedule + run now) ---
router.post('/:id/load', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
const updated = await inst.update({ enabled: true, updated_by: req.user.uid, updated_on: Date.now() });
await scheduleInstance(updated);
await runInstanceNow(updated.id);
res.json({ results: await serialize(updated) });
} catch (err) { next(err); }
});
// --- Unload (unschedule + disable) ---
router.post('/:id/unload', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
await unscheduleInstance(inst.id);
const updated = await inst.update({ enabled: false, updated_by: req.user.uid, updated_on: Date.now() });
res.json({ results: await serialize(updated) });
} catch (err) { next(err); }
});
// --- Run now (regardless of enabled) ---
router.post('/:id/run', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
await runInstanceNow(inst.id);
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Last-run status ---
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, lastLog: inst.lastLog } });
} catch (err) { next(err); }
});
// --- Delete (unschedule + remove secrets + delete row) ---
router.delete('/:id', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
await unscheduleInstance(inst.id);
await pluginSecrets.remove(inst.id); // best-effort
await inst.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
module.exports = router;
+7 -2
View File
@@ -13,6 +13,7 @@ const middleware = require('../middleware/auth');
const rateLimit = require('../middleware/rate_limit');
const permission = require('../utils/permission');
const conf = require('@simpleworkjs/conf');
const metrics = require('../utils/metrics');
async function findUserByLogin(login) {
try {
@@ -37,12 +38,16 @@ router.get('/username-suggestions', async function(req, res, next) {
router.post('/login', rateLimit.login, async function(req, res, next){
try{
let auth = await Auth.login(req.body);
metrics.recordServiceUsage('SSO Web UI', req.body.uid);
return res.json({
login: true,
token: auth.token.token,
message:`${req.body.uid} logged in!`,
});
}catch(error){
if (error.name === 'LDAPLoginFailed' || error.status === 401 || error.name === 'UserNotFound') {
metrics.recordFailedLogin(req.ip, req.body.uid);
}
next(error);
}
});
@@ -198,7 +203,7 @@ router.post('/impersonate/:uid', middleware.auth, async function(req, res, next)
const target = await User.get(req.params.uid);
// Clean up any existing impersonation for this target
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid });
const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
for (const old of existing) {
if (old.is_valid && !old.isExpired) {
try { await target.removeTempPassword(old.temp_hash); } catch(_) {}
@@ -232,7 +237,7 @@ router.delete('/impersonate/:uid', middleware.auth, async function(req, res, nex
await permission.byGroup(req.user, ['app_sso_admin']);
const target = await User.get(req.params.uid);
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid });
const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
let revoked = 0;
for (const token of existing) {
+42
View File
@@ -0,0 +1,42 @@
const express = require('express');
// Parses arguments according to the exposed method config.
// Extended to support { from: 'user' } which injects `req.user.dn` (LDAP integration).
function extractArgs(req, cfg) {
const args = cfg.args;
if (!args) return [];
if (args.from === 'user') return [req.user.dn];
const source = args.from === 'params' ? req.params
: args.from === 'query' ? req.query
: req.body;
if (Array.isArray(args.names)) return args.names.map(name => source[name]);
return [source || {}];
}
// A mini-auto-router that reads `static exposedMethods` from a @simpleworkjs/orm Model
// and maps them directly into Express endpoints.
function autoRouter(Model) {
const router = express.Router();
if (Model.getExposedMethods) {
for (const cfg of Model.getExposedMethods()) {
router[cfg.verb](cfg.routePath, async function(req, res, next) {
try {
// In a full implementation, we'd load the instance if cfg.kind === 'instance'.
// For now, our methods are all static class methods.
const target = Model;
const result = await target[cfg.method](...extractArgs(req, cfg));
res.json(result);
} catch (error) {
next(error);
}
});
}
}
return router;
}
module.exports = autoRouter;
+20
View File
@@ -0,0 +1,20 @@
const router = require('express').Router();
const permission = require('../utils/permission');
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
next();
} catch(err) {
next(err);
}
});
router.get('/', (req, res) => {
res.render('conf', {
title: 'Configuration',
user: req.user
});
});
module.exports = router;
+195
View File
@@ -0,0 +1,195 @@
'use strict';
// Public directory discovery API. Mounted at /api/discovery (app.js, before
// the 404 catcher). Every response uses the `{ results }` envelope and the
// security projection from @simpleworkjs/directory-schema, so secrets (e.g. an
// OAuth client's client_secret_hash) never leave the server and non-admins only
// see the public metadata allowlist.
//
// This replaces the autoRouter mount (which returned bare arrays — the shape
// jump-host's `data.results || []` silently collapsed to `[]`, so no user could
// bridge) and absorbs the dead /me handler that used to live in
// routes/api_discovery.js (mounted after the 404, so unreachable).
//
// Group CNs come from utils/user_groups — `req.user` has `memberOf` (DNs) and
// no `.groups`, so reading `.groups` off it directly yields [] for every human
// caller. See that file for what that silently broke.
const router = require('express').Router();
const { Resource, ResourceGroup } = require('../models/resource');
const { withGroups } = require('../utils/user_groups');
const {
envelope,
projectResource,
projectResources,
isDirectoryAdmin,
} = require('@simpleworkjs/directory-schema');
// Resolve the caller's groups once per request and hand back the projection
// flag. Every handler needs both, and both are wrong if taken off req.user raw.
async function callerView(req) {
const user = await withGroups(req.user);
return { user, fullMetadata: isDirectoryAdmin(user) };
}
// GET /api/discovery/resources[?kind=&group=&parent=]
router.get('/resources', async (req, res, next) => {
try {
const { fullMetadata } = await callerView(req);
const resources = await Resource.search(req.query);
res.json(envelope(projectResources(resources, { fullMetadata })));
} catch (err) { next(err); }
});
// GET /api/discovery/resources/:slug
router.get('/resources/:slug', async (req, res, next) => {
try {
const { fullMetadata } = await callerView(req);
const resource = await Resource.getBySlug(req.params.slug);
// parents/children are edges (no secrets); project only the resource body.
const projected = projectResource(resource, { fullMetadata });
projected.parents = resource.parents;
projected.children = resource.children;
res.json(envelope(projected));
} catch (err) { next(err); }
});
// GET /api/discovery/graph
router.get('/graph', async (req, res, next) => {
try {
const { fullMetadata } = await callerView(req);
const graph = await Resource.getGraph();
res.json(envelope({
resources: projectResources(graph.resources, { fullMetadata }),
edges: graph.edges,
updated_on: graph.updated_on
}));
} catch (err) { next(err); }
});
// GET /api/discovery/me
// Returns the resources the current caller can reach. Machines see only their
// own resource; humans get the union of their LDAP groups' resources plus
// anything flagged isPublic.
router.get('/me', async (req, res, next) => {
try {
const { user, fullMetadata } = await callerView(req);
let accessible;
if (req.user && req.user.isMachine) {
accessible = await Resource.list({ where: { id: req.resourceId } });
} else {
const ids = new Set();
if (user.groups.length) {
const rgs = await ResourceGroup.list({ where: { groupCn: { in: user.groups } } });
for (const rg of rgs) ids.add(rg.resourceId);
}
const all = await Resource.list();
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);
});
}
// resolvedAddress is the whole point of /me ("how do I reach it") and a
// service inherits it from its host, so it must be computed here rather
// than left to each caller to guess at address || ip.
accessible = await Resource.withResolvedAddress(accessible);
res.json(envelope(projectResources(accessible, { fullMetadata })));
} 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) => {
try {
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
// Assuming the caller provides a source name and payload
const source = req.body.source || 'agent';
await DiscoveryReconciler.reconcile(source, req.body.payload || req.body);
res.json(envelope({ success: true }));
} catch (err) { next(err); }
});
// POST /api/discovery/promote/:slug
// Promotes an unmanaged device to managed by creating its LDAP groups.
router.post('/promote/:slug', async (req, res, next) => {
try {
const resource = await Resource.getBySlug(req.params.slug);
if (!resource) return res.status(404).json(envelope({ error: 'Not found' }));
const { Group } = require('../models/group_ldap');
const accessGroup = `${resource.slug}_access`;
const adminGroup = `${resource.slug}_admin`;
// Create groups if they don't exist
try { await Group.get(accessGroup); } catch (e) {
if (e.status === 404) await Group.add({ name: accessGroup, description: `Access to ${resource.name}`, owner: req.user.dn });
else throw e;
}
try { await Group.get(adminGroup); } catch (e) {
if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${resource.name}`, owner: req.user.dn });
else throw e;
}
// Link them
const crypto = require('crypto');
await ResourceGroup.create({
id: crypto.randomUUID(),
resourceId: resource.id,
groupCn: accessGroup,
accessLevel: 'user'
});
await ResourceGroup.create({
id: crypto.randomUUID(),
resourceId: resource.id,
groupCn: adminGroup,
accessLevel: 'admin'
});
const meta = resource.metadata || {};
meta.managed = true;
await Resource.update(resource.id, { metadata: meta });
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
} catch (err) { next(err); }
});
module.exports = router;
+77 -2
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path');
const router = require('express').Router();
const {marked} = require('marked');
const xss = require('xss');
const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info');
const rateLimit = require('../middleware/rate_limit');
@@ -12,6 +13,7 @@ const values = {
title: conf.environment !== 'production' ? `dev` : '',
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
name: conf.name,
logo: conf.logo,
...buildInfo,
};
@@ -24,7 +26,20 @@ const values = {
// back at the root DEPLOYMENT.md (see docs/deployment.md itself), which is
// already covered by the "deployment" entry.
const DOCS = {
// Plain-language "what is this and why would I use it" guides -- linked
// directly from the relevant card in the UI (see the help icon on each
// card). Each links onward to the deeper technical doc below for readers
// who want the schema/protocol-level detail.
accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')},
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
api: {title: 'API Reference', file: path.join(__dirname, '../../API.md')},
ldap: {title: 'LDAP', file: path.join(__dirname, '../../docs/ldap.md')},
@@ -44,24 +59,84 @@ function fixImagePaths(html) {
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
}
// Docs cross-link each other as "<slug>.html" (correct for the Jekyll/GitHub
// Pages build, which is what these same .md files also feed) and
// "index.html" for the docs home -- neither resolves here, where a doc lives
// at /docs/<slug> with no .html suffix. Rewrite known doc links to the
// in-app route, same idea as fixImagePaths() above. Only touches slugs that
// actually exist, so an unrelated "foo.html" link is left alone.
// Docs are also linked by their real filename stem (e.g. "concepts-accounts.html"
// for docs/concepts-accounts.md) -- the correct, working link on the Jekyll/
// GitHub Pages build, where the URL IS the filename stem. That doesn't match
// this viewer's own short slugs (DOCS keys, e.g. "accounts"), so also resolve
// by filename as a fallback -- one link written in a doc works correctly on
// both targets, rather than needing two different link forms.
const slugByFilename = Object.fromEntries(
Object.entries(DOCS).map(([slug, d]) => [path.basename(d.file, '.md'), slug])
);
function fixDocLinks(html) {
return html
.replace(/href="index\.html"/g, 'href="/docs"')
.replace(/href="([a-z0-9-]+)\.html"/g, (match, name) => {
const slug = DOCS[name] ? name : slugByFilename[name];
return slug ? `href="/docs/${slug}"` : match;
});
}
// docs/*.md files (not the repo-root README/CHANGELOG/API.md) carry Jekyll
// front matter for the GitHub Pages build and a "← Back to Home" link back
// to that site's index -- both meaningless here (this viewer has its own
// doc-list sidebar, docs_page.ejs) and, worse, marked() doesn't know front
// matter isn't regular markdown: it rendered as a garbled heading + stray
// <hr> at the top of every page. Strip both before rendering.
function stripJekyllCruft(content) {
return content
.replace(/^---\n[\s\S]*?\n---\n/, '')
.replace(/^\s*\[← Back to Home\]\([^)]*\)\s*\n/m, '');
}
router.use(rateLimit.docs);
router.get('/', function(req, res) {
res.render('docs_index', {...values, docs: docList});
});
// Plain, dependency-free line-substring search over the same allowlisted
// doc set -- no separate index to build/maintain, no new dependency, and it
// keeps working with no internet access (same reasoning as the rest of this
// route). Must be registered before the /:slug catch-all below, or "search"
// would be treated as a (nonexistent) doc slug and 404.
router.get('/search', function(req, res) {
const q = (req.query.q || '').trim();
if (!q) return res.json({results: []});
const qLower = q.toLowerCase();
const results = [];
for (const [slug, doc] of Object.entries(DOCS)) {
try {
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
const matchLine = content.split('\n').find(line => line.toLowerCase().includes(qLower));
if (matchLine) {
results.push({slug, title: doc.title, snippet: matchLine.trim().slice(0, 200)});
}
} catch (error) { /* unreadable doc file -- skip it */ }
}
res.json({results});
});
router.get('/:slug', function(req, res, next) {
const doc = DOCS[req.params.slug];
if (!doc) return next({status: 404, message: 'Doc not found'});
try {
const content = fs.readFileSync(doc.file, 'utf8');
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
res.render('docs_page', {
...values,
docs: docList,
currentSlug: req.params.slug,
docTitle: doc.title,
docHtml: fixImagePaths(marked(content)),
docHtml: xss(fixDocLinks(fixImagePaths(marked(content)))),
});
} catch (error) {
next(error);
+99 -2
View File
@@ -43,6 +43,87 @@ router.get('/:name', async function(req, res, next){
}
});
// ── Nested groups ───────────────────────────────────────────────────────────
// A groupOfNames `member` may be any DN, including another group's, which is
// how nesting is stored. These routes are mounted before /:group/:uid so the
// literal "nested"/"effective" path segments are not swallowed by that
// wildcard, which would otherwise try to resolve them as a uid.
// GET /api/group/:group/effective — who this group actually grants, split into
// directly-listed users, the groups nested into it, and the full transitive set
// of users. The UI shows "3 direct, 12 effective"; a plain member read cannot
// answer that, and on a server with nestgroup it silently returns the expanded
// list with no indication which entries are direct.
router.get('/:group/effective', async function(req, res, next){
try{
return res.json({ results: await Group.effectiveMembers(req.params.group) });
}catch(error){
next(error);
}
});
// PUT /api/group/:group/nested/:child — nest :child inside :group.
router.put('/:group/nested/:child', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
const parent = await Group.get(req.params.group);
const child = await Group.get(req.params.child);
if(parent.dn === child.dn){
return res.status(400).json({message: 'A group cannot contain itself.'});
}
// Refuse rather than rely on the resolver's depth cap: a cycle makes
// "who is in this group" unanswerable, and the cap would quietly return
// a truncated answer instead of an error anyone would notice.
if(await Group.wouldCycle(req.params.group, child.dn)){
return res.status(409).json({
message: `"${req.params.child}" already contains "${req.params.group}" — nesting them would create a loop.`
});
}
const results = await parent.addMember({dn: child.dn});
User.clearCache();
return res.json({
results,
message: `Nested ${req.params.child} inside ${req.params.group}.`
});
}catch(error){
if(error.name === 'TypeOrValueExistsError' || error.code === 20){
return res.status(409).json({message: `"${req.params.child}" is already nested in "${req.params.group}".`});
}
next(error);
}
});
// DELETE /api/group/:group/nested/:child — un-nest.
router.delete('/:group/nested/:child', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
const parent = await Group.get(req.params.group);
const child = await Group.get(req.params.child);
const results = await parent.removeMember({dn: child.dn});
User.clearCache();
return res.json({
results,
message: `Removed ${req.params.child} from ${req.params.group}.`
});
}catch(error){
// groupOfNames requires at least one member, so emptying a group is a
// schema violation rather than a permission problem. Surfacing the raw
// error as a 500 makes it look like a bug in the server; it is really a
// "you cannot do that, and here is why" -- the same reason the last user
// cannot be removed from a group either.
if(error.name === 'ObjectClassViolationError' || error.code === 65){
return res.status(409).json({
message: `"${req.params.child}" is the only member of "${req.params.group}". A group must keep at least one member — add another first.`
});
}
next(error);
}
});
router.put('/owner/:group/:uid', async function(req, res, next){
try{
@@ -82,11 +163,25 @@ router.put('/:group/:uid', async function(req, res, next){
var group = await Group.get(req.params.group);
var user = await User.get(req.params.uid);
const results = await group.addMember(user);
// Group membership feeds directly into cached-User-derived state
// (isServiceAccount, isAdmin, group-gated nav/UI) -- without this,
// a membership change here is invisible for up to the cache's TTL.
User.clearCache();
return res.json({
results: await group.addMember(user),
results,
message: `Added user ${req.params.uid} to ${req.params.group} group.`
});
}catch(error){
// Already a member -- surfaced as a plain 500 before, which read as a
// server fault for what is really a no-op. Common in practice because
// groupOfNames needs at least one member, so whoever creates a group is
// seeded into it and is then "added" again by the obvious next click.
if(error.name === 'TypeOrValueExistsError' || error.code === 20){
return res.status(409).json({
message: `"${req.params.uid}" is already a member of "${req.params.group}".`
});
}
next(error);
}
});
@@ -98,8 +193,10 @@ router.delete('/:group/:uid', async function(req, res, next){
var group = await Group.get(req.params.group);
var user = await User.get(req.params.uid);
const results = await group.removeMember(user);
User.clearCache();
return res.json({
results: await group.removeMember(user),
results,
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
});
}catch(error){
+88 -25
View File
@@ -5,37 +5,39 @@ var express = require('express');
var router = express.Router();
const moment = require('moment');
const {marked} = require('marked');
const xss = require('xss');
const {InviteToken, PasswordResetToken} = require('./../models/token');
const {Tos} = require('../models/tos');
const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info');
const { mountStaticModules } = require('@simpleworkjs/app-stack');
const values ={
title: conf.environment !== 'production' ? `dev` : '',
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
name: conf.name,
logo: conf.logo,
// Connection conventions the catalog needs to render "how to reach this"
// (conf/base.js `directory`). Safe to expose: a jump-host name and a default
// port are public connection info, not credentials.
directoryConf: {
jumpHost: (conf.directory && conf.directory.jumpHost) || '',
defaultSshPort: (conf.directory && conf.directory.defaultSshPort) || 22,
},
...buildInfo,
}
// List of front end node modules to be served
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome',
'moment', '@popper', 'jq-repeat',
];
// Server front end modules
// https://stackoverflow.com/a/55700773/3140931
// Vendor libraries only change when package versions are bumped (a rebuild),
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
// still cover that rare case with a cheap 304 instead of a stale asset.
frontEndModules.forEach(dep => {
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}))
// still cover that rare case with a cheap 304 instead of a stale asset. The
// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
// every deploy and isn't cache-busted/fingerprinted.
mountStaticModules(router, {
root: path.join(__dirname, '..'),
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
});
// Have express server static content( images, CSS, browser JS) from the public
// local folder. Shorter maxAge than /static-modules since this is the app's
// own JS/CSS, which changes on every deploy and isn't cache-busted/fingerprinted.
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}))
// Public health endpoint for container/orchestration healthchecks.
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
router.get('/health', function(req, res) {
@@ -45,7 +47,7 @@ router.get('/health', function(req, res) {
router.get('/tos', async function(req, res, next) {
try {
const tos = await Tos.getCurrent();
res.render('tos', {...values, tosHtml: marked(tos.content), tosUpdatedOnFmt: moment(tos.updated_on, 'x').format('MMMM YYYY')});
res.render('tos', {...values, tosHtml: xss(marked(tos.content)), tosUpdatedOnFmt: moment(tos.updated_on, 'x').format('MMMM YYYY')});
} catch (error) {
next(error);
}
@@ -53,27 +55,78 @@ router.get('/tos', async function(req, res, next) {
// Admin dashboard (stats + recent/inactive users) and Notifications
// (broadcast + history) merged into one page.
router.get('/dashboard', function(req, res) {
res.render('dashboard', {...values});
router.get('/overview', function(req, res) {
res.render('overview', {...values});
});
router.get('/admin', (req, res) => res.redirect(301, '/dashboard'));
router.get('/notifications', (req, res) => res.redirect(301, '/dashboard'));
router.get('/admin', (req, res) => res.redirect(301, '/overview'));
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
router.get('/invites', function(req, res) {
res.render('invites', {...values});
router.get('/conf', function(req, res) {
// Admin-only Configuration page. The view renders the shell for anyone
// (like /users, /directory, etc.); the client gates access with
// app.auth.forceLogin(['admin','app_sso_admin']) and the /api/conf endpoint
// enforces app_sso_admin server-side. The previous server-side
// permission.byGroup(req.user,…) 401'd on a browser navigation because this
// app's auth-token is a header set by client JS (localStorage), not a
// cookie — so req.user is undefined on a plain page load.
res.render('conf', {...values});
});
router.get('/directory', function(req, res) {
res.render('directory', {...values});
});
router.get('/discovery', function(req, res, next) {
res.redirect('/directory');
});
router.get('/plugins', function(req, res, next) {
res.redirect('/directory');
});
router.get('/vault', function(req, res) {
// Personal per-user secrets (secret/users/<uid>/*) for everyone; admins get
// free-form access across all of secret/ plus an Apps tab to mint scoped
// tokens for external apps. The view renders the shell for any logged-in
// user; the client gates login via app.auth.forceLogin() and derives the
// admin/namespace scope from /api/user/me. The /api/vault proxy enforces the
// same scoping server-side (scopeGuard + the token's own OpenBao policy), so
// the client-derived scope is only cosmetic. vaultAddr is the only
// server-rendered value (it's a non-user-specific env var); uid + isAdmin
// are resolved client-side to avoid the header-vs-navigation auth mismatch.
res.render('vault', {
...values,
vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200',
});
});
// Linkable deep-link to a single resource's modal, e.g. from the resource
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
// use of :slug at all -- the client reads location.pathname itself and opens
// the matching resource's modal once the page's own data has loaded.
router.get('/directory/:slug', function(req, res) {
res.render('directory', {...values});
});
// Route removed since it's now in directory
router.get('/onboarding', async function(req, res, next) {
try {
const tos = await Tos.getCurrent();
res.render('onboarding', {...values, tosHtml: marked(tos.content)});
res.render('onboarding', {...values, tosHtml: xss(marked(tos.content))});
} catch (error) {
next(error);
}
});
router.get('/', async function(req, res, next) {
res.render('landing', {...values});
});
router.get('/profile', async function(req, res, next) {
res.render('profile', {...values});
});
@@ -91,7 +144,14 @@ router.get('/login', async function(req, res, next) {
// hardcoded in a doc, so they're always right for *this* deployment.
router.get('/integrations', function(req, res, next) {
const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
const ldapHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
// The public-facing host (from the OAuth issuer). Used for OIDC links.
const issuerHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
// The hostname advertised for direct LDAPS binds may be a separate,
// internal-only name so admins don't have to port-forward 636 publicly.
// Defaults to the issuer host to preserve prior behavior.
const ldapsHost = (conf.ldap && conf.ldap.ldapsHost) || issuerHost;
const ldapsPort = Number((conf.ldap && conf.ldap.ldapsPort) || 636) || 636;
const userBase = (conf.ldap && conf.ldap.userBase) || 'ou=people,dc=example,dc=com';
const groupBase = (conf.ldap && conf.ldap.groupBase) || 'ou=groups,dc=example,dc=com';
@@ -104,8 +164,9 @@ router.get('/integrations', function(req, res, next) {
...values,
issuer,
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
ldapHost,
ldapsUrl: `ldaps://${ldapHost}:636`,
ldapHost: ldapsHost,
ldapsUrl: `ldaps://${ldapsHost}:${ldapsPort}`,
ldapsHostExplicit: !!(conf.ldap && conf.ldap.ldapsHost),
baseDn,
userBase,
groupBase,
@@ -135,6 +196,8 @@ router.get('/token', function(req, res, next) {
res.render('token', {...values});
});
router.get('/login/resetpassword/:token', async function(req, res, next){
let token = await PasswordResetToken.get(req.params.token);
Binary file not shown.
+5 -2
View File
@@ -45,8 +45,11 @@ router.post('/', async function(req, res, next) {
const client = await OAuthClient.add(req.body);
const result = client.toJSON ? client.toJSON() : { ...client };
result.client_id = client.client_id || client.id;
return res.json({
results: client,
results: result,
client_secret: client._raw_secret,
message: `OAuth client '${client.name}' created. Save the client secret — it will not be shown again.`,
});
@@ -94,7 +97,7 @@ router.delete('/:client_id', async function(req, res, next) {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id);
await client.remove();
await client.delete();
return res.json({
client_id: req.params.client_id,
-54
View File
@@ -1,54 +0,0 @@
'use strict';
const router = require('express').Router();
const {ServiceAccount} = require('../models/service_account');
const permission = require('../utils/permission');
const ADMIN_GROUP = 'app_sso_admin';
router.get('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
return res.json({results: await ServiceAccount.list()});
} catch(error) {
next(error);
}
});
router.post('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const result = await ServiceAccount.create({cn: req.body.cn, description: req.body.description});
return res.json({
results: result,
message: `Service account "${result.cn}" created. Save the password now — it will not be shown again.`,
});
} catch(error) {
next(error);
}
});
router.put('/:cn/password', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const result = await ServiceAccount.setPassword(req.params.cn, req.body.password);
return res.json({
results: result,
message: `Password rotated for "${req.params.cn}". Save it now — it will not be shown again.`,
});
} catch(error) {
next(error);
}
});
router.delete('/:cn', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
await ServiceAccount.remove(req.params.cn);
return res.json({message: `Service account "${req.params.cn}" deleted.`});
} catch(error) {
next(error);
}
});
module.exports = router;
+10 -4
View File
@@ -23,8 +23,10 @@ router.get('/', async function(req, res, next){
router.get('/:name', async function(req, res, next){
try{
// ORM models: list() on the redis adapter always returns full rows;
// detail is handled by serialization (isPrivate fields are excluded).
return res.json({
results: await tokens[req.params.name][req.query.detail ? "listDetail" : "list"]()
results: await tokens[req.params.name].list()
});
}catch(error){
next(error);
@@ -34,9 +36,13 @@ router.get('/:name', async function(req, res, next){
router.get('/:name/:token', async function(req, res, next){
try{
return res.json({
results: await tokens[req.params.name].get(req.params.token)
});
const result = await tokens[req.params.name].get(req.params.token);
if (!result) {
const error = new Error('Token not found');
error.status = 404;
throw error;
}
return res.json({ results: result });
}catch(error){
next(error);
}
+74 -6
View File
@@ -4,6 +4,7 @@ const router = require('express').Router();
const {User} = require('../models/user');
const {Group} = require('../models/group_ldap');
const permission = require('../utils/permission');
const {groupCns} = require('../utils/user_groups');
const {UserVerification} = require('../models/verification');
const {InviteToken} = require('../models/token');
@@ -23,8 +24,9 @@ router.post('/', async function(req, res, next){
await permission.byGroup(req.user, ['app_sso_admin'])
req.body.created_by = req.user.uid
req.body.manager = [req.user.dn];
const user = await User.add(req.body);
let user = await User.add(req.body);
const verif = await UserVerification.getOrCreate(user.uid);
const updates = { password_must_change: true };
if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now();
@@ -37,12 +39,18 @@ router.post('/', async function(req, res, next){
try {
const group = await Group.get('app_sso_service_account');
await group.addMember(user);
// User.add() already cached `user` (via its own internal
// User.get()) before this group membership existed, so the
// cached isServiceAccount would be stuck wrong for 5 minutes
// (the cache TTL) without this -- re-fetch after clearing.
User.clearCache();
user = await User.get(user.uid);
} catch (error) {
console.error(`user.add: failed to mark ${user.uid} as a service account:`, error.message);
}
}
return res.json({results: user});
return res.json({results: user, message: `User ${user.uid} created.`});
}catch(error){
next(error);
}
@@ -67,7 +75,24 @@ router.delete('/:uid', async function(req, res, next){
router.get('/me', async function(req, res, next){
try{
return res.json(await User.get({uid: req.user.uid}));
const user = JSON.parse(JSON.stringify(await User.get({uid: req.user.uid})));
// The shared client framework gates the UI on a single effective-rights
// flag (the OIDC-client apps send the same key). Here "admin" means
// membership in app_sso_admin or the cross-app app_super_admin group.
//
// Resolved via groupCns rather than read off `memberOf` directly: with
// nested groups, memberOf is only transitive when the directory carries
// the nestgroup overlay. Against a server without it, an admin who holds
// the group through nesting would get isAdmin=false here and silently
// lose the whole admin UI -- while still passing every server-side
// permission check, which resolves nesting properly. groupCns gives the
// same answer in both modes.
const groups = await groupCns(user);
user.groups = groups;
user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP);
return res.json(user);
}catch(error){
next(error);
}
@@ -90,7 +115,7 @@ router.put('/password', async function(req, res, next){
const verif = await UserVerification.getOrCreate(req.user.uid);
await verif.update({ password_must_change: false });
User.clearCache();
return res.json({results: result});
return res.json({results: result, message: 'Password changed.'});
}catch(error){
next(error);
}
@@ -137,6 +162,41 @@ router.put('/:uid/active', async function(req, res, next){
}
});
router.get('/:uid/group-members', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
return res.json({results: await User.getPersonalGroupMembers(req.params.uid)});
}catch(error){
next(error);
}
});
router.put('/:uid/group-member/:memberUid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
await User.addPersonalGroupMember(req.params.uid, req.params.memberUid);
return res.json({
results: true,
message: `Added ${req.params.memberUid} to ${req.params.uid}'s group`
});
}catch(error){
next(error);
}
});
router.delete('/:uid/group-member/:memberUid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
await User.removePersonalGroupMember(req.params.uid, req.params.memberUid);
return res.json({
results: true,
message: `Removed ${req.params.memberUid} from ${req.params.uid}'s group`
});
}catch(error){
next(error);
}
});
router.put('/:uid', async function(req, res, next){
try{
let user;
@@ -145,7 +205,15 @@ router.put('/:uid', async function(req, res, next){
user = req.user;
}else{
user = await User.get(req.params.uid);
await permission.byGroup(req.user, ['app_sso_admin'])
const isManager = (user.manager || []).includes(req.user.dn);
if(!isManager) await permission.byGroup(req.user, ['app_sso_admin'])
}
// The manager picker is a tag widget backed by a single newline-separated
// hidden input (see public/js/app.js app.ui.userSelect), same convention
// as oauth_client.js's allowed_groups.
if (typeof req.body.manager === 'string') {
req.body.manager = req.body.manager.split('\n').map(s => s.trim()).filter(Boolean);
}
return res.json({
@@ -181,7 +249,7 @@ router.get('/invite', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false);
const all = await InviteToken.listDetail();
const all = await InviteToken.list();
const visible = isAdmin ? all : all.filter(t => t.created_by === req.user.uid);
const results = visible.map(t => ({ token: t.token, ...t }));
return res.json({ results });
+36
View File
@@ -0,0 +1,36 @@
const router = require('express').Router();
const { Webhook } = require('../models/webhook');
const crypto = require('crypto');
// GET /api/webhooks
router.get('/', async (req, res, next) => {
try {
const hooks = await Webhook.list();
res.json({ results: hooks });
} catch (err) { next(err); }
});
// POST /api/webhooks
router.post('/', async (req, res, next) => {
try {
const { name, url, events, secret } = req.body;
const hook = await Webhook.create({
id: crypto.randomUUID(),
name, url, events, secret,
created_on: Math.floor(Date.now() / 1000)
});
res.json({ results: hook });
} catch (err) { next(err); }
});
// DELETE /api/webhooks/:id
router.delete('/:id', async (req, res, next) => {
try {
const hook = await Webhook.get(req.params.id);
if (!hook) return res.status(404).json({ error: 'Not found' });
await hook.delete();
res.json({ success: true });
} catch (err) { next(err); }
});
module.exports = router;
+197
View File
@@ -0,0 +1,197 @@
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
const { WebhookEmitter } = require('./webhook_emitter');
const crypto = require('crypto');
class DiscoveryReconciler {
static async reconcile(sourceName, payload) {
const { resources = [], edges = [] } = payload;
let newDevices = 0;
for (const res of resources) {
if (!res.metadata) res.metadata = {};
res._originalSlug = res.slug; // Keep track for edge mapping
let existing = null;
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
const allRes = await Resource.list();
// 1. Attempt matching by MAC (highest precision)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
if (macs.length > 0) {
existing = allRes.find(r =>
r.metadata && (
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
)
);
}
}
// 2. Fallback matching by IP address
let ipsToMatch = [];
if (res.metadata.interfaces) {
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
}
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
if (res.metadata.address) {
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
}
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
if (!existing && ipsToMatch.length > 0) {
existing = allRes.find(r => {
if (!r.metadata) return false;
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
if (r.metadata.address) {
const addrs = r.metadata.address.split(',').map(a => a.trim());
if (addrs.some(a => ipsToMatch.includes(a))) return true;
}
if (r.metadata.interfaces && r.metadata.interfaces.some(i => ipsToMatch.includes(i.ip))) return true;
return false;
});
}
// 3. Fallback matching by Slug, Name, or Base Hostname
if (!existing && (res.slug || res.name)) {
const inputName = normalizeHost(res.name || res.slug);
existing = allRes.find(r => {
if (res.slug && r.slug === res.slug) return true;
if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
if (inputName && r.slug && normalizeHost(r.slug) === inputName) return true;
return false;
});
}
if (existing) {
// Merge metadata
const mergedMeta = { ...existing.metadata, ...res.metadata };
// Merge interfaces cleanly
if (res.metadata.interfaces) {
const existingIntfs = existing.metadata.interfaces || [];
const newIntfs = res.metadata.interfaces;
// Simple union based on mac or ip
for (const ni of newIntfs) {
const idx = existingIntfs.findIndex(ei =>
(ni.mac && ei.mac && ei.mac.toLowerCase() === ni.mac.toLowerCase()) ||
(ni.ip && ei.ip && ei.ip === ni.ip)
);
if (idx >= 0) existingIntfs[idx] = { ...existingIntfs[idx], ...ni };
else existingIntfs.push(ni);
}
mergedMeta.interfaces = existingIntfs;
}
// Add discovery source
const sources = new Set(mergedMeta.discovery_sources || []);
sources.add(sourceName);
mergedMeta.discovery_sources = [...sources];
mergedMeta.last_seen = Date.now();
const isIp = (str) => /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(str || '');
let bestName = existing.name;
if (res.name && (!bestName || isIp(bestName) || res.name.length > bestName.length && !isIp(res.name))) {
bestName = res.name;
}
await existing.update({
name: bestName,
description: res.description || existing.description,
metadata: mergedMeta,
updated_on: Math.floor(Date.now() / 1000)
});
res._actualId = existing.id;
} else {
// Create new
const sources = new Set([sourceName]);
res.metadata.discovery_sources = [...sources];
res.metadata.last_seen = Date.now();
const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`;
const created = await Resource.create({
id: crypto.randomUUID(),
kind: res.kind || 'unmanaged_device',
name: res.name || slug,
slug: slug,
metadata: res.metadata,
created_on: Math.floor(Date.now() / 1000)
});
newDevices++;
res._actualId = created.id; // Map original slug to actual ID
WebhookEmitter.emit('discovery.new_device', created.toJSON());
}
}
// Now process edges
const allRes = await Resource.list();
const existingEdges = await ResourceEdge.list();
for (const edge of edges) {
// Find parent ID. It might be in the current payload (mapped to _actualId) or in DB by slug
let parentId = null;
const parentResInPayload = resources.find(r => r._originalSlug === edge.parentSlug);
if (parentResInPayload && parentResInPayload._actualId) {
parentId = parentResInPayload._actualId;
} else {
const parentResInDb = allRes.find(r => r.slug === edge.parentSlug);
if (parentResInDb) parentId = parentResInDb.id;
}
// Find child ID
let childId = null;
const childResInPayload = resources.find(r => r._originalSlug === edge.childSlug);
if (childResInPayload && childResInPayload._actualId) {
childId = childResInPayload._actualId;
} else {
const childResInDb = allRes.find(r => r.slug === edge.childSlug);
if (childResInDb) childId = childResInDb.id;
}
if (parentId && childId) {
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
if (!edgeExists) {
await ResourceEdge.create({
id: crypto.randomUUID(),
parentId,
childId,
relation: edge.relation
});
}
}
}
if (newDevices > 0) {
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
}
}
static async garbageCollect(staleMs = 7 * 24 * 60 * 60 * 1000) {
const allRes = await Resource.list();
const cutoff = Date.now() - staleMs;
let archived = 0;
for (const res of allRes) {
const meta = res.metadata || {};
const sources = meta.discovery_sources || [];
// Only garbage collect things that are exclusively auto-discovered
if (sources.length > 0 && !sources.includes('manual')) {
if (meta.last_seen && meta.last_seen < cutoff && meta.lifecycle_state !== 'archived') {
meta.lifecycle_state = 'archived';
await res.update({ metadata: meta, updated_on: Math.floor(Date.now() / 1000) });
archived++;
WebhookEmitter.emit('discovery.device_archived', res.toJSON());
}
}
}
if (archived > 0) console.log(`[DiscoveryReconciler] Garbage collected ${archived} stale devices.`);
}
}
module.exports = { DiscoveryReconciler };
+61
View File
@@ -0,0 +1,61 @@
'use strict';
const fs = require('fs');
const { spawn } = require('child_process');
const metrics = require('../utils/metrics');
function startLdapMonitor() {
const logFile = '/var/lib/ldap/slapd.log';
if (!fs.existsSync(logFile)) {
setTimeout(startLdapMonitor, 5000);
return;
}
const tail = spawn('tail', ['-F', logFile]);
const connections = {}; // connID -> { ip, uid }
tail.stdout.on('data', (data) => {
const lines = data.toString().split('\n');
for (const line of lines) {
if (!line.trim()) continue;
const connMatch = line.match(/conn=(\d+)/);
if (!connMatch) continue;
const conn = connMatch[1];
if (!connections[conn]) {
connections[conn] = {};
}
const ipMatch = line.match(/ACCEPT from IP=([^:]+)/);
if (ipMatch) {
connections[conn].ip = ipMatch[1];
}
const bindMatch = line.match(/BIND dn="uid=([^,]+)/i) || line.match(/BIND dn="cn=([^,]+)/i);
if (bindMatch) {
connections[conn].uid = bindMatch[1];
}
const resultMatch = line.match(/RESULT tag=\d+ err=(\d+)/);
if (resultMatch) {
const errCode = parseInt(resultMatch[1], 10);
const { ip, uid } = connections[conn];
if (errCode === 0 && uid) {
metrics.recordServiceUsage('LDAP Direct', uid);
} else if (errCode === 49 || errCode === 32) {
metrics.recordFailedLogin(ip, uid);
}
}
if (line.includes('closed') || line.includes('UNBIND')) {
delete connections[conn];
}
}
});
tail.on('error', (err) => {
console.error('Failed to start LDAP monitor', err);
});
}
startLdapMonitor();
+172
View File
@@ -0,0 +1,172 @@
'use strict';
// Plugin type registry.
//
// A **plugin type** is a module under nodejs/plugins/<category>/<type>.js
// exporting a manifest:
//
// { type, category, name, description, configSchema[], validate(), run() }
//
// `configSchema` is an array of field descriptors that drive the admin UI form
// and API validation. Fields with `secret: true` are stored in OpenBao
// (secret/plugins/<instance-id>/conf via utils/plugin_secrets.js); all other
// field values live in the PluginInstance DB row's `config` JSON column.
//
// `run(cfg)` does the work; the discovery plugins keep their historical
// `discover(cfg)` name and add `run` as an alias (the loader uses `run`).
//
// A **plugin instance** (models/plugin_instance.js) is a configured, loadable
// copy of a type — you can have several of the same type. This registry only
// knows about *types*; instances live in the DB.
//
// The scan happens once at require time (the set of installed .js files does
// not change without a redeploy). Runtime load/unload is per-instance, not
// per-type — adding a new plugin type still needs a restart.
const fs = require('fs');
const path = require('path');
const pluginsRoot = path.join(__dirname, '../plugins');
const MASK = '********';
// type -> module. Built once.
const _modules = new Map();
// type -> manifest summary (a safe, serializable subset for the UI/API).
const _summaries = [];
function loadAll() {
_modules.clear();
_summaries.length = 0;
if (!fs.existsSync(pluginsRoot)) return;
for (const category of fs.readdirSync(pluginsRoot)) {
const catDir = path.join(pluginsRoot, category);
const stat = fs.statSync(catDir);
if (!stat.isDirectory()) continue;
for (const file of fs.readdirSync(catDir)) {
if (!file.endsWith('.js')) continue;
const type = path.basename(file, '.js');
// require fresh-ish: a plugin file should be idempotent to load. Clear
// from the cache so a future re-scan (e.g. in tests) picks up edits.
const full = path.join(catDir, file);
delete require.cache[require.resolve(full)];
const mod = require(full);
// Backfill manifest defaults so older plugins (only exporting discover)
// still register with a usable summary.
const manifest = {
type: mod.type || type,
category: mod.category || category,
name: mod.name || type,
description: mod.description || '',
configSchema: Array.isArray(mod.configSchema) ? mod.configSchema : [],
validate: typeof mod.validate === 'function' ? mod.validate : null,
run: typeof mod.run === 'function' ? mod.run
: typeof mod.discover === 'function' ? mod.discover : null
};
_modules.set(manifest.type, { mod, manifest });
_summaries.push({
type: manifest.type,
category: manifest.category,
name: manifest.name,
description: manifest.description,
configSchema: manifest.configSchema
});
}
}
}
loadAll();
// All registered plugin types, as serializable summaries (no functions).
// Used by GET /api/plugins/types to build the "New Plugin" picker + form.
function getTypes() {
return _summaries.map(s => ({ ...s }));
}
// The raw module for a type (has run/validate/discover). Throws if unknown.
function getModule(type) {
const entry = _modules.get(type);
if (!entry) {
const err = new Error(`Unknown plugin type: ${type}`);
err.status = 400;
throw err;
}
return entry.mod;
}
// The manifest summary for a type. Returns null if unknown (callers gate on
// this to validate a pluginType before creating an instance).
function getManifest(type) {
const entry = _modules.get(type);
return entry ? entry.manifest : null;
}
// Keys of the secret fields in a type's configSchema.
function secretKeys(type) {
const m = getManifest(type);
if (!m) return [];
return m.configSchema.filter(f => f.secret).map(f => f.key);
}
// Non-secret field keys in a type's configSchema.
function publicKeys(type) {
const m = getManifest(type);
if (!m) return [];
return m.configSchema.filter(f => !f.secret).map(f => f.key);
}
// All declared field keys (secret + non-secret) — for required-field validation.
function fieldKeys(type) {
const m = getManifest(type);
if (!m) return [];
return m.configSchema.map(f => f.key);
}
// Required field keys.
function requiredKeys(type) {
const m = getManifest(type);
if (!m) return [];
return m.configSchema.filter(f => f.required).map(f => f.key);
}
// Replace each present secret value with MASK, keeping the keys so the UI can
// render a prefilled (masked) password field. Non-secret values are passed
// through unchanged. `values` is a plain object of field->value.
function mask(type, values) {
if (!values || typeof values !== 'object') return values;
const sk = new Set(secretKeys(type));
const out = {};
for (const [k, v] of Object.entries(values)) {
out[k] = sk.has(k) && v ? MASK : v;
}
return out;
}
// Split a flat {field: value} object (as the UI/API sends it) into non-secret
// config (for the DB row) and secret values (for OpenBao). Unknown keys are
// dropped — only declared configSchema fields are kept.
function splitConfig(type, flat) {
const manifest = getManifest(type);
const config = {};
const secrets = {};
if (!manifest || !flat) return { config, secrets };
for (const f of manifest.configSchema) {
if (!(f.key in flat)) continue;
if (f.secret) secrets[f.key] = flat[f.key];
else config[f.key] = flat[f.key];
}
return { config, secrets };
}
module.exports = {
getTypes,
getModule,
getManifest,
secretKeys,
publicKeys,
fieldKeys,
requiredKeys,
mask,
splitConfig,
// for tests
_reload: loadAll
};
+215
View File
@@ -0,0 +1,215 @@
'use strict';
// Discovery / plugin scheduler.
//
// Generalized from the one-shot discovery-plugin loader: plugin *types* live
// under nodejs/plugins/<category>/<type>.js (see services/plugin_registry.js),
// and configured, loadable/unloadable *instances* live in the PluginInstance
// table (models/plugin_instance.js). This module schedules enabled instances
// on cron via BullMQ JobSchedulers and runs them in a Worker.
//
// Each instance owns a stable JobScheduler id (`plugin:<instanceId>`) so load/
// unload can add/remove a single schedule without disturbing the others —
// `upsertJobScheduler`/`removeJobScheduler` (BullMQ v6) take that id directly.
//
// Per-instance secrets are merged in from OpenBao (utils/plugin_secrets.js) at
// run time; the plugin's run()/discover() receives the combined non-secret
// config + secret values as a single `config` object, exactly as the legacy
// static-config path did.
const { Queue, Worker } = require('bullmq');
const { DiscoveryReconciler } = require('./discovery_reconciler');
const pluginRegistry = require('./plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const { PluginInstance, STATUS } = require('../models/plugin_instance');
const Redis = require('ioredis');
// Ensure Redis connection works for BullMQ
const redisOpts = { maxRetriesPerRequest: null };
const connection = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', redisOpts);
const discoveryQueue = new Queue('discovery', { connection });
const RUN = 'run_plugin';
const GC = 'garbage_collect';
function pluginSchedulerId(id) { return `plugin:${id}`; }
const worker = new Worker('discovery', async job => {
if (job.name === RUN) {
await runPluginJob(job.data && job.data.instanceId);
} else if (job.name === GC) {
console.log('[Scheduler] Running garbage collection');
await DiscoveryReconciler.garbageCollect();
}
}, { connection });
// Run one plugin instance. Loads the row (skip silently if it was deleted or
// disabled after the job was enqueued), merges its OpenBao secrets into its
// config, calls the plugin's run()/discover(), and — for discovery plugins —
// reconciles the result into the resource graph under the instance's slug.
// Bookkeeping (lastRunAt/lastStatus/lastError) is stamped on the row so the UI
// can show run state without querying BullMQ.
async function runPluginJob(instanceId) {
if (!instanceId) { console.warn('[Scheduler] run_plugin job with no instanceId'); return; }
const instance = await PluginInstance.get(instanceId);
if (!instance) { console.warn(`[Scheduler] instance ${instanceId} gone — skipping`); return; }
if (!instance.enabled) { console.warn(`[Scheduler] instance ${instance.slug} (${instanceId}) disabled — skipping`); return; }
let mod;
try { mod = pluginRegistry.getModule(instance.pluginType); }
catch (err) {
console.error(`[Scheduler] instance ${instance.slug}: type ${instance.pluginType} unavailable:`, err.message);
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.ERROR, lastError: `plugin type unavailable: ${instance.pluginType}` });
return;
}
const runFn = mod.run || mod.discover;
if (typeof runFn !== 'function') {
console.error(`[Scheduler] instance ${instance.slug}: type ${instance.pluginType} has no run()/discover()`);
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.ERROR, lastError: 'plugin type has no run()/discover()' });
return;
}
console.log(`[Scheduler] Running plugin: ${instance.slug} (${instance.pluginType})`);
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, 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), lastLog: logs.join('\n') });
}
}
// Schedule one instance: upsert a repeatable JobScheduler keyed by its id. Does
// NOT trigger an immediate run — call runInstanceNow(id) separately for that
// (used on boot and on "load"). Safe to call repeatedly (upsert is idempotent
// and will update the cron if it changed).
async function scheduleInstance(instance) {
if (!instance || !instance.id) return;
if (!instance.enabled) { await unscheduleInstance(instance.id); return; }
const cron = instance.cron || '0 * * * *';
await discoveryQueue.upsertJobScheduler(pluginSchedulerId(instance.id), { pattern: cron }, {
name: RUN,
data: { instanceId: instance.id }
});
console.log(`[Scheduler] Scheduled instance ${instance.slug} with cron ${cron}`);
}
// Remove an instance's repeatable schedule. No-op if it had none.
async function unscheduleInstance(id) {
if (!id) return;
try { await discoveryQueue.removeJobScheduler(pluginSchedulerId(id)); }
catch (err) { /* missing scheduler is fine */ }
}
// Enqueue a single immediate run for an instance (the "Run now" button / boot
// kick). Runs once regardless of enabled, on top of any schedule.
async function runInstanceNow(id) {
if (!id) return;
await discoveryQueue.add(RUN, { instanceId: id });
}
// One-time legacy migration: if the PluginInstance table is empty AND
// conf.discovery.plugins has entries (the old static-config shape), seed one
// instance per configured type and copy its secret fields into OpenBao. After
// the first boot, the table is non-empty and the static config is ignored.
// Idempotent (guarded by the empty-table check).
async function migrateLegacyPlugins(discoveryConfig) {
const existing = await PluginInstance.list();
if (existing && existing.length) return;
const legacy = discoveryConfig && discoveryConfig.plugins;
if (!legacy || typeof legacy !== 'object') return;
const names = Object.keys(legacy);
if (!names.length) return;
console.log(`[Scheduler] Migrating ${names.length} legacy discovery plugin(s) to instances…`);
for (const name of names) {
const entry = legacy[name] || {};
const manifest = pluginRegistry.getManifest(name);
if (!manifest) {
console.warn(`[Scheduler] legacy plugin '${name}' has no registered type — skipping`);
continue;
}
// splitConfig keeps only declared configSchema fields and separates secret
// from non-secret. Legacy `enabled`/`cron` are not in configSchema, so they
// are dropped here and read from the entry directly below.
const { config, secrets } = pluginRegistry.splitConfig(name, entry);
const instance = await PluginInstance.create({
pluginType: name,
category: manifest.category,
name: manifest.name,
slug: name,
enabled: entry.enabled !== false,
cron: entry.cron || '0 * * * *',
config,
created_by: 'legacy-migration'
});
try {
await pluginSecrets.write(instance.id, secrets);
console.log(`[Scheduler] migrated '${name}' -> instance ${instance.id} (slug ${instance.slug})`);
} catch (err) {
// The instance row exists; if we can't write secrets (e.g. the sso-broker
// policy predates theta-suite v1.30.1) the operator gets a clear error
// from the API on edit, and the instance still runs with its non-secret
// config. Don't delete the row — the operator just needs to re-run
// setup.sh and edit/save the secrets.
console.error(`[Scheduler] migrated '${name}' row but FAILED to write secrets:`, err.message);
await instance.update({ lastStatus: STATUS.ERROR, lastError: `secret migration failed: ${err.message}` });
}
}
}
// Boot-time initialization: clear stale schedulers, schedule garbage collection,
// migrate any legacy static-config plugins, then schedule every enabled
// instance and kick one immediate run for each.
async function initScheduler(discoveryConfig) {
// Clear stale plugin/gc schedulers from a previous boot. Other-named
// schedulers (none in this app) are left alone.
try {
const schedulers = await discoveryQueue.getJobSchedulers();
for (const s of schedulers) {
if (s.name === RUN || s.name === GC) {
await discoveryQueue.removeJobScheduler(s.key || s.id);
}
}
} catch (e) {
console.log('[Scheduler] Could not clear old job schedulers:', e.message);
}
// Daily garbage collection of stale discovery resources.
await discoveryQueue.upsertJobScheduler(GC, { pattern: '0 0 * * *' }, { name: GC, data: {} });
try {
await migrateLegacyPlugins(discoveryConfig);
} catch (err) {
console.error('[Scheduler] legacy migration failed:', err.message);
}
const enabled = await PluginInstance.listEnabled();
for (const instance of enabled) {
await scheduleInstance(instance);
await runInstanceNow(instance.id); // boot kick
}
console.log(`[Scheduler] initialized — ${enabled.length} instance(s) scheduled`);
}
module.exports = {
initScheduler,
scheduleInstance,
unscheduleInstance,
runInstanceNow,
discoveryQueue,
connection
};
+35
View File
@@ -0,0 +1,35 @@
const { Webhook } = require('../models/webhook');
const crypto = require('crypto');
const fetch = require('node-fetch');
class WebhookEmitter {
static async emit(event, payload) {
try {
const hooks = await Webhook.list({ where: { isActive: true } });
const matched = hooks.filter(h => !h.events || h.events.length === 0 || h.events.includes(event));
for (const hook of matched) {
this.sendPayload(hook, event, payload).catch(err => console.error(`Webhook ${hook.name} failed:`, err.message));
}
} catch (e) {
console.error('Error emitting webhook:', e);
}
}
static async sendPayload(hook, event, payload) {
const body = JSON.stringify({ event, payload, timestamp: Date.now() });
const headers = { 'Content-Type': 'application/json' };
if (hook.secret) {
const signature = crypto.createHmac('sha256', hook.secret).update(body).digest('hex');
headers['X-Theta-Signature'] = signature;
}
const res = await fetch(hook.url, { method: 'POST', body, headers, timeout: 5000 });
if (!res.ok) {
throw new Error(`Status ${res.status}`);
}
}
}
module.exports = { WebhookEmitter };

Some files were not shown because too many files have changed in this diff Show More