Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa17981c15 | |||
| 99fc0d2819 | |||
| 276629a587 | |||
| a26d54ec6f | |||
| 011d4b2975 | |||
| ecc9b62842 | |||
| e74c5cf11d | |||
| 4a592f9795 | |||
| aa2592ea4e | |||
| 9cf0ce34ca | |||
| 3b6d1ceda9 | |||
| e9b808d1c2 | |||
| 6c71c91ff6 | |||
| ac25084113 | |||
| a788a99e56 | |||
| bcd160cca2 | |||
| 724f5d8496 | |||
| 8fc7dd11f5 | |||
| e91ed6f1f7 | |||
| 874f7db037 | |||
| 013c21d4f0 | |||
| 42a61f8868 | |||
| b54da5c64c | |||
| 782ef69fb8 | |||
| 0e955abc73 | |||
| 69883836e1 | |||
| 17df21041a | |||
| c19fffe3c9 | |||
| b6abfe8f03 | |||
| 420ccfab3b | |||
| 8ed4505dc0 | |||
| 451054f0c2 | |||
| 3a46680c8b | |||
| 1b0418e42e | |||
| 4e3aa082d3 | |||
| 6cb8b259e2 | |||
| 2532c492f1 | |||
| fdc045e166 | |||
| 0c2f38f0fe | |||
| fcba782ac7 | |||
| 6162c6d8a1 | |||
| 3be8c7fde2 | |||
| 3852e9ba62 | |||
| 7f2c71299f | |||
| 18119d54aa | |||
| 487e38f1a4 | |||
| 2e011dd383 | |||
| 3c12ebba16 | |||
| ffb2e99199 | |||
| 9d5f106863 | |||
| 7f00d4c845 | |||
| 1d1d29d287 | |||
| 5665504bc1 | |||
| 2ac1c30112 | |||
| 04c18eaf30 | |||
| 6835074b8b | |||
| 59ae30897b | |||
| 94a7e07410 | |||
| a5de279bb4 | |||
| d8b6f6e7a3 | |||
| 208762f0d1 | |||
| b076498219 | |||
| fc0d9104d0 | |||
| 82da47cef7 | |||
| 39779f51dc |
@@ -703,6 +703,10 @@ The authenticated user is automatically set as the group owner.
|
|||||||
{ "results": true, "message": "Added user uid to group group." }
|
{ "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
|
### Remove User from Group
|
||||||
@@ -716,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 Group
|
||||||
|
|
||||||
**`DELETE /api/group/:group`** — `app_sso_admin` or group owner
|
**`DELETE /api/group/:group`** — `app_sso_admin` or group owner
|
||||||
|
|||||||
@@ -4,6 +4,190 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
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`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [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** ("<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
|
## [1.3.2] - 2026-07-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -195,6 +379,15 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
|
|||||||
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
|
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
|
||||||
- Merged OAuth Apps + LDAP Info into a single Integrations page.
|
- 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
|
[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.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.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14
|
||||||
|
|||||||
@@ -336,6 +336,17 @@ OAuth client). Note: re-running bootstrap resets the bootstrap-admin and
|
|||||||
service-account passwords to the values in `./config/sso-secrets.js`; non-theta
|
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.
|
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)
|
## Method 2: Bare metal (Debian/Ubuntu)
|
||||||
|
|||||||
@@ -33,39 +33,118 @@ RUN if [ -n "$GIT_COMMIT" ]; then \
|
|||||||
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
||||||
fi
|
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
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Install OpenLDAP and required packages.
|
# Runtime libraries the from-source slapd links against, plus the app's own
|
||||||
# Alpine splits OpenLDAP into many small subpackages; there is no catch-all
|
# deps. No openldap* packages here: everything LDAP comes from /opt/openldap.
|
||||||
# "openldap-overlays" package. We install exactly the backends/overlays/modules
|
# libltdl (module loading -- slapd is useless without it, since every overlay
|
||||||
# the app depends on:
|
# is a loadable module) and libuuid are pulled in by the source build but are
|
||||||
# openldap-back-mdb : the mdb backend (slapd.conf uses `database mdb`)
|
# NOT dependencies of anything else here, so they must be named explicitly;
|
||||||
# openldap-overlay-ppolicy : ppolicy module + overlay (account locking)
|
# omitting them fails at runtime with "Error relocating ... lt_dlopenext:
|
||||||
# openldap-overlay-memberof : reverse group membership
|
# symbol not found", not at build time.
|
||||||
# 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
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
openldap \
|
openssl \
|
||||||
openldap-clients \
|
libsasl \
|
||||||
openldap-back-mdb \
|
libltdl \
|
||||||
openldap-overlay-ppolicy \
|
libuuid \
|
||||||
openldap-overlay-memberof \
|
|
||||||
openldap-overlay-refint \
|
|
||||||
openldap-overlay-syncprov \
|
|
||||||
openldap-overlay-auditlog \
|
|
||||||
openldap-passwd-sha2 \
|
|
||||||
dumb-init \
|
dumb-init \
|
||||||
bash \
|
bash \
|
||||||
openssl \
|
|
||||||
redis \
|
redis \
|
||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
# The openldap package already creates the `ldap` user/group, which slapd runs
|
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
||||||
# as (see -u ldap -g ldap in docker-entrypoint.sh). Nothing to add here.
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -90,6 +169,7 @@ COPY nodejs/services ./services
|
|||||||
COPY nodejs/utils ./utils
|
COPY nodejs/utils ./utils
|
||||||
COPY nodejs/views ./views
|
COPY nodejs/views ./views
|
||||||
COPY nodejs/public ./public
|
COPY nodejs/public ./public
|
||||||
|
COPY nodejs/plugins ./plugins
|
||||||
|
|
||||||
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
|
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
|
||||||
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
|
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
|
||||||
|
|||||||
@@ -1,79 +1,35 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Example secrets configuration file (file-based config).
|
// Local per-deployment configuration for this Theta42 instance.
|
||||||
//
|
// This file is gitignored — it contains real secrets and per-deployment
|
||||||
// Bare-metal: install.sh seeds a filled-in version of this file at
|
// values. The committed conf/base.js now ships generic defaults
|
||||||
// /etc/sso-manager/secrets.js on first run (LDAP + JWT already live; only
|
// (example.com / localhost); the theta42-specific non-secret values that
|
||||||
// SMTP is left as a placeholder). Only write this one by hand if you're
|
// used to live in base.js have been migrated here so this instance keeps
|
||||||
// skipping install.sh's LDAP bootstrap (SKIP_LDAP=true) or setting up
|
// working. New deployments should put their own values here or in app_* env.
|
||||||
// manually.
|
|
||||||
// Docker / unified stack: place at ./config/sso-secrets.js and bind-mount
|
|
||||||
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points
|
|
||||||
// the CONF_SECRETS env var at it so @simpleworkjs/conf reads it.
|
|
||||||
//
|
|
||||||
// Values here override conf/base.js and win over <environment>.js. `app_*` env
|
|
||||||
// vars (if any are set) override this file too — so the Docker stack passes NO
|
|
||||||
// app_* env, keeping this file authoritative.
|
|
||||||
//
|
|
||||||
// The app only reads the keys it knows (port, name, ldap, smtp, voipms, oauth).
|
|
||||||
// The extra `stack`, `bootstrap`, and `serviceAccountPass` keys below are read
|
|
||||||
// by the orchestrator (docker-entrypoint.sh, the bootstrap script, setup.sh)
|
|
||||||
// and ignored by the app — safe to leave them out for bare-metal use.
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
port: 3001,
|
port: 3001,
|
||||||
name: 'SSO Manager', // shown in UI and outbound email
|
name: 'Theta42 SSO',
|
||||||
logo: '/static/img/theta42.svg', // nav/favicon image; point at your own file under public/ to white-label
|
ldap: {
|
||||||
ldap: {
|
url: 'ldap://10.2.0.54',
|
||||||
url: 'ldap://localhost', // or ldaps://host:636 for TLS
|
bindDN: 'cn=admin,dc=theta42,dc=com',
|
||||||
bindDN: 'cn=admin,dc=example,dc=com',
|
bindPassword: 'Tomisgaypalm7',
|
||||||
bindPassword: 'ldap-admin-pass',
|
userBase: 'ou=people,dc=theta42,dc=com',
|
||||||
userBase: 'ou=people,dc=example,dc=com',
|
groupBase: 'ou=groups,dc=theta42,dc=com',
|
||||||
groupBase: 'ou=groups,dc=example,dc=com',
|
},
|
||||||
// ldapsHost: 'ldap.internal.example.com', // optional: hostname shown for
|
smtp: {
|
||||||
// direct LDAPS binds on /integrations. Leave empty to derive from the
|
host: 'mail.wgnode.com',
|
||||||
// OAuth issuer. Set an internal-only name to avoid port-forwarding 636.
|
user: 'noreply@users.theta42.com',
|
||||||
// ldapsPort: 636,
|
// user: '',
|
||||||
},
|
pass: 'ZxAsQw!2',
|
||||||
smtp: {
|
from: 'Theta42 Accounts <noreply@users.theta42.com>',
|
||||||
host: 'smtp.example.com',
|
},
|
||||||
port: 587,
|
voipms: {
|
||||||
secure: false, // true for 465, false for other ports
|
username: 'wmantly@gmail.com',
|
||||||
user: 'noreply@example.com',
|
password: 'EMjQvAuHhD!d5dm',
|
||||||
pass: 'your-smtp-password',
|
did: '9297353350',
|
||||||
from: 'SSO Manager <noreply@example.com>',
|
},
|
||||||
},
|
oauth: {
|
||||||
voipms: {
|
issuer: 'https://sso.theta42.com',
|
||||||
username: '', // VoIP.ms username (optional)
|
jwtSecret: '09e2501a1c93aef4d5d713c7db17c800c6d7d6f5f9e9cf2efbdfa37549021bf9',
|
||||||
password: '', // VoIP.ms password (optional)
|
},
|
||||||
did: '', // VoIP.ms DID (optional)
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
issuer: 'https://sso.example.com', // falls back to the request host at runtime
|
|
||||||
jwtSecret: 'a-long-random-development-jwt-secret-value-1234567890',
|
|
||||||
token_lifetime: {
|
|
||||||
access_token: 3600, // 1 hour in seconds
|
|
||||||
refresh_token: 2592000 // 30 days in seconds
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Orchestrator-only keys (ignored by the app) ──────────────────────────
|
|
||||||
// Read by docker-entrypoint.sh (server-side slapd config + validation), the
|
|
||||||
// superproject bootstrap script, and setup.sh. Omit for bare-metal use.
|
|
||||||
stack: {
|
|
||||||
ldapBaseDn: 'dc=example,dc=com', // slapd suffix (also drives seed OUs).
|
|
||||||
// The base DN also appears in ldap.bindDN/userBase/groupBase above and
|
|
||||||
// in oauth.issuer — keep them consistent with this value
|
|
||||||
// (cn=admin,<dn>, ou=people,<dn>, ou=groups,<dn>, https://<ssoHost>).
|
|
||||||
ldapDomain: 'example.com', // default cert CN + OAuth issuer host
|
|
||||||
ldapCertCn: '', // cert CN; empty -> defaults to ldapDomain
|
|
||||||
ssoHost: 'sso.example.com', // public SSO hostname (OAuth issuer URL)
|
|
||||||
proxyHost: 'proxy.example.com', // public proxy hostname
|
|
||||||
},
|
|
||||||
bootstrap: {
|
|
||||||
adminUid: 'admin', // initial SSO admin username
|
|
||||||
adminPass: 'AdminPass123!', // initial SSO admin password
|
|
||||||
adminEmail: 'admin@example.com', // initial SSO admin email
|
|
||||||
},
|
|
||||||
serviceAccountPass: 'proxy-service-pass', // LDAP password the proxy binds with
|
|
||||||
};
|
};
|
||||||
@@ -76,11 +76,22 @@ fi
|
|||||||
# ── Locate the OpenLDAP module directory ────────────────────────────────────
|
# ── Locate the OpenLDAP module directory ────────────────────────────────────
|
||||||
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
|
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
|
||||||
# path varies by distro; auto-detect rather than hardcode.
|
# 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=""
|
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
|
if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi
|
||||||
done
|
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 ────────────────────────────────────
|
# ── TLS certificate for LDAPS / StartTLS ────────────────────────────────────
|
||||||
# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the
|
# 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
|
# network. To keep password binds off the wire in cleartext we expose LDAPS
|
||||||
@@ -140,6 +151,7 @@ moduleload ppolicy
|
|||||||
moduleload memberof
|
moduleload memberof
|
||||||
moduleload refint
|
moduleload refint
|
||||||
moduleload auditlog
|
moduleload auditlog
|
||||||
|
NESTGROUP_MODULE_PLACEHOLDER
|
||||||
SYNCPROV_MODULE_PLACEHOLDER
|
SYNCPROV_MODULE_PLACEHOLDER
|
||||||
|
|
||||||
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
||||||
@@ -188,6 +200,8 @@ memberof-memberof-ad memberOf
|
|||||||
overlay refint
|
overlay refint
|
||||||
refint_attributes memberOf member manager owner
|
refint_attributes memberOf member manager owner
|
||||||
|
|
||||||
|
NESTGROUP_OVERLAY_PLACEHOLDER
|
||||||
|
|
||||||
# auditlog overlay (LDIF audit trail of all changes)
|
# auditlog overlay (LDIF audit trail of all changes)
|
||||||
overlay auditlog
|
overlay auditlog
|
||||||
auditlog /var/lib/ldap/auditlog.ldif
|
auditlog /var/lib/ldap/auditlog.ldif
|
||||||
@@ -221,6 +235,37 @@ else
|
|||||||
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
||||||
fi
|
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 ──
|
# ── Multi-Master Replication Configuration ──
|
||||||
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
|
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
|
||||||
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
|
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
|
||||||
@@ -310,7 +355,7 @@ EOF
|
|||||||
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
||||||
# app_sso_service_account is a marker (not a permission gate) for
|
# app_sso_service_account is a marker (not a permission gate) for
|
||||||
# non-person accounts -- see the Users page.
|
# 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
|
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
||||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
objectClass: groupOfNames
|
objectClass: groupOfNames
|
||||||
@@ -321,6 +366,27 @@ member: ${LDAP_BIND_DN}
|
|||||||
EOF
|
EOF
|
||||||
done
|
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"
|
info "LDAP directory initialized"
|
||||||
else
|
else
|
||||||
info "LDAP directory already initialized — skipping seed"
|
info "LDAP directory already initialized — skipping seed"
|
||||||
@@ -380,6 +446,14 @@ if [[ "${SECRETS_JS_MODE:-0}" != 1 ]]; then
|
|||||||
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
|
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__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
|
||||||
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${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}"
|
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
|
||||||
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
|
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
|
||||||
# public https URL on the SSO subdomain of the LDAP domain; override with
|
# public https URL on the SSO subdomain of the LDAP domain; override with
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ nav:
|
|||||||
- title: Directory
|
- title: Directory
|
||||||
page: /directory.html
|
page: /directory.html
|
||||||
icon: fa-server
|
icon: fa-server
|
||||||
|
# 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
|
- title: Changelog
|
||||||
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
||||||
icon: fa-list
|
icon: fa-list
|
||||||
|
|||||||
@@ -46,13 +46,36 @@ on, just like anyone else's.
|
|||||||
|
|
||||||
A **group** is just a named list of accounts, used to control access. This
|
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
|
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/Integrations
|
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
|
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
|
connect — say, a group listing everyone who should be allowed into your
|
||||||
photo server. Once a group exists, add or remove members from the
|
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
|
**Groups** page, and point the other app's "who's allowed in" setting at
|
||||||
that group's name.
|
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
|
## Every account's personal group
|
||||||
|
|
||||||
Separately from the groups above, every single account — person or
|
Separately from the groups above, every single account — person or
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: A plain-language guide to personal access tokens in SSO Manager.
|
|||||||
|
|
||||||
This page explains what an API token is and when you'd want one. For the
|
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
|
full list of API endpoints a token can call, see the
|
||||||
[API reference](api.html).
|
[API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
|
||||||
|
|
||||||
## What's an API token, in plain terms?
|
## What's an API token, in plain terms?
|
||||||
|
|
||||||
@@ -54,6 +54,6 @@ it stops working right away.
|
|||||||
## Want more detail?
|
## Want more detail?
|
||||||
|
|
||||||
This page doesn't attempt to list every API endpoint or show request/
|
This page doesn't attempt to list every API endpoint or show request/
|
||||||
response examples — for that, see the full [API reference](api.html).
|
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)
|
[← Back to Home](index.html)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ what matters practically is the handful of concepts below.
|
|||||||
## What's a "client"?
|
## What's a "client"?
|
||||||
|
|
||||||
Every app you connect is registered here as a **client** — a single entry
|
Every app you connect is registered here as a **client** — a single entry
|
||||||
on the Integrations page representing that one app. Registering a client
|
in the Directory representing that one app. Registering a client
|
||||||
gives you a **Client ID** and **Client Secret**: think of these like a
|
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.
|
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
|
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
|
||||||
|
|||||||
@@ -54,10 +54,34 @@ Resources carry a flexible `metadata` JSON object that can store essential conte
|
|||||||
- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
|
- **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`).
|
- **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
|
## 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.
|
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
|
## Slug conventions
|
||||||
|
|
||||||
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
|
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
|
||||||
@@ -87,6 +111,14 @@ The seed is idempotent and non-destructive: a resource whose slug already exists
|
|||||||
|
|
||||||
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.
|
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
|
## API
|
||||||
|
|
||||||
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
||||||
@@ -94,4 +126,14 @@ All of the above uses the same admin API the UI does (group `app_sso_directory_a
|
|||||||
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
|
- `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/edges` — parent/child links (`hosts`, `oauth` relations)
|
||||||
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
|
- `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`
|
- 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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 221 KiB |
@@ -22,10 +22,11 @@ one command).
|
|||||||
|
|
||||||
## Screenshots
|
## 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/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/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)*
|
*(click any screenshot to view full size)*
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ backend, that's the niche.
|
|||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||||
run the pieces separately via `app_*` env config.
|
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.
|
- **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.
|
- **[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/).
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
@@ -78,5 +79,7 @@ That's the standalone quick start. For the full set of install options
|
|||||||
|
|
||||||
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
||||||
reverse proxy, designed to sit in front of this SSO.
|
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
|
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
|
||||||
Manager and the proxy together with one command.
|
Manager and the proxy together with one command.
|
||||||
|
|||||||
@@ -59,8 +59,41 @@ way or use `slappasswd -h '{SSHA512}'`.
|
|||||||
Groups are `cn=<name>,ou=groups,<base>` (`groupOfNames`) with a `member`
|
Groups are `cn=<name>,ou=groups,<base>` (`groupOfNames`) with a `member`
|
||||||
attribute listing member DNs. The `memberOf` overlay populates reverse
|
attribute listing member DNs. The `memberOf` overlay populates reverse
|
||||||
membership (`memberOf` on the user); `refint` keeps it consistent on
|
membership (`memberOf` on the user); `refint` keeps it consistent on
|
||||||
add/remove. **Admin permission checks read the group's `member` list**, not
|
add/remove.
|
||||||
`memberOf` on the user.
|
|
||||||
|
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
|
### Personal groups
|
||||||
|
|
||||||
@@ -75,15 +108,15 @@ 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
|
group", admin-only) — add other accounts as supplementary members, e.g. to
|
||||||
share write access to files owned by this group.
|
share write access to files owned by this group.
|
||||||
|
|
||||||
The SSO requires three groups (seeded automatically by the entrypoint /
|
The SSO seeds these groups automatically (entrypoint / `install.sh`):
|
||||||
`install.sh`):
|
|
||||||
|
|
||||||
| Group | Grants |
|
| 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_admin` | full admin (users, groups, settings) |
|
||||||
| `app_sso_oauth_admin` | OAuth client management |
|
| `app_sso_oauth_admin` | OAuth client management |
|
||||||
| `app_sso_invite` | invitation 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)
|
## TLS (LDAPS / StartTLS)
|
||||||
|
|
||||||
@@ -308,11 +341,37 @@ needs:
|
|||||||
|
|
||||||
- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`),
|
- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`),
|
||||||
`ppolicy`, `memberof`, `refint`.
|
`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
|
- **Custom schema:** the `theta42Person` auxiliary objectClass with
|
||||||
`dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
|
`dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
|
||||||
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
|
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
|
||||||
a default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
|
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
|
`ops/ldap-setup.sh -p <admin-password>` configures all of the above
|
||||||
idempotently against a running slapd (auto-detects the database holding your
|
idempotently against a running slapd (auto-detects the database holding your
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ Clients are managed directly from the **Directory** tab in the web UI. They are
|
|||||||
|
|
||||||
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_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
|
## Scopes
|
||||||
|
|
||||||
| Scope | Claims / access |
|
| Scope | Claims / access |
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Discovery Plugins
|
||||||
|
nav_order: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Discovery Plugins
|
||||||
|
|
||||||
|
The SSO Manager supports a robust plugin architecture for auto-discovering devices, hosts, and services across your home lab or data center. Plugins run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
|
||||||
|
|
||||||
|
## Writing a Custom Plugin
|
||||||
|
|
||||||
|
Plugins are simple JavaScript files placed in `nodejs/plugins/discovery/`.
|
||||||
|
|
||||||
|
A plugin must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
|
||||||
|
|
||||||
|
### Plugin Skeleton
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// nodejs/plugins/discovery/my_custom_plugin.js
|
||||||
|
module.exports = {
|
||||||
|
discover: async (config) => {
|
||||||
|
const { url, apiKey } = config; // Provided by your configuration
|
||||||
|
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
// 1. Fetch your data from an API
|
||||||
|
// const data = await fetch(...);
|
||||||
|
|
||||||
|
// 2. Map data to Resources
|
||||||
|
resources.push({
|
||||||
|
kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
|
||||||
|
name: 'My Switch',
|
||||||
|
slug: 'my-switch-01',
|
||||||
|
metadata: {
|
||||||
|
make: 'Vendor',
|
||||||
|
model: 'Model X',
|
||||||
|
interfaces: [
|
||||||
|
{ mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Map relations to Edges (optional)
|
||||||
|
edges.push({
|
||||||
|
parentSlug: 'my-switch-01',
|
||||||
|
childSlug: 'some-connected-client-slug',
|
||||||
|
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
|
||||||
|
});
|
||||||
|
|
||||||
|
return { resources, edges };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Plugins are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
// ... existing config ...
|
||||||
|
discovery: {
|
||||||
|
plugins: {
|
||||||
|
my_custom_plugin: {
|
||||||
|
enabled: true,
|
||||||
|
cron: '*/30 * * * *', // Run every 30 minutes
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
apiKey: 'secret-key'
|
||||||
|
},
|
||||||
|
nmap: {
|
||||||
|
enabled: true,
|
||||||
|
cron: '0 * * * *',
|
||||||
|
targetRange: '192.168.1.0/24'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Reconciliation Engine
|
||||||
|
|
||||||
|
When your plugin returns its graph, the Reconciliation Engine takes over:
|
||||||
|
1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
|
||||||
|
2. **Merging:** If it finds a match, it gracefully merges the metadata (so your plugin can add CPU info to a host that NMAP previously found).
|
||||||
|
3. **Source Tracking:** It records your plugin's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
|
||||||
|
4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
|
||||||
@@ -61,6 +61,11 @@ app.set('trust proxy', 1);
|
|||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
app.set('view engine', 'ejs');
|
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
|
// 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
|
// 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.
|
// changes on every deploy and isn't cache-busted/fingerprinted.
|
||||||
@@ -86,9 +91,13 @@ app.use('/api/group', middleware.auth, require('./routes/group'));
|
|||||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||||
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
||||||
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
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/update-check', middleware.auth, require('./routes/update_check'));
|
||||||
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
||||||
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
|
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.
|
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||||
|
|
||||||
@@ -97,7 +106,20 @@ app.use('/oauth', oauthRouter);
|
|||||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||||
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
||||||
app.get('/.well-known/openid-configuration', discovery);
|
app.get('/.well-known/openid-configuration', discovery);
|
||||||
|
app.use('/api/webhook', require('./routes/webhook'));
|
||||||
|
app.use('/api/plugins', middleware.auth, require('./routes/plugins'));
|
||||||
|
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
|
||||||
|
|
||||||
|
const vaultApiProxy = createProxyMiddleware({
|
||||||
|
target: 'http://openbao:8200',
|
||||||
|
changeOrigin: true,
|
||||||
|
pathRewrite: { '^/': '/v1/' },
|
||||||
|
on: {
|
||||||
|
proxyReq: fixRequestBody
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/vault', middleware.auth, vaultApiProxy);
|
||||||
|
|
||||||
// Catch 404 and forward to error handler. If none of the above routes are
|
// Catch 404 and forward to error handler. If none of the above routes are
|
||||||
// used, this is what will be called.
|
// used, this is what will be called.
|
||||||
@@ -108,9 +130,6 @@ app.use(function(req, res, next) {
|
|||||||
next(err);
|
next(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Discovery API
|
|
||||||
app.use('/api/discovery', middleware.auth, require('./routes/api_discovery'));
|
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(function(err, req, res, next) {
|
app.use(function(err, req, res, next) {
|
||||||
const SILENT_404S = ['/.well-known/'];
|
const SILENT_404S = ['/.well-known/'];
|
||||||
@@ -123,5 +142,18 @@ app.use(function(err, req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.status(err.status || 500);
|
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});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,9 +31,17 @@ const models = require('../models');
|
|||||||
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
||||||
*/
|
*/
|
||||||
models.initORM().then(() => {
|
models.initORM().then(() => {
|
||||||
|
return require('../utils/conf_manager').init();
|
||||||
|
}).then(() => {
|
||||||
server.listen(port);
|
server.listen(port);
|
||||||
server.on('error', onError);
|
server.on('error', onError);
|
||||||
server.on('listening', onListening);
|
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 => {
|
}).catch(err => {
|
||||||
console.error('Failed to initialize ORM:', err);
|
console.error('Failed to initialize ORM:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ module.exports = {
|
|||||||
// public 636 port forward. See docs/ldap.md.
|
// public 636 port forward. See docs/ldap.md.
|
||||||
ldapsHost: '',
|
ldapsHost: '',
|
||||||
ldapsPort: 636,
|
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
|
// New users/personal groups (see addPosixAccount/addPosixGroup in
|
||||||
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
||||||
// Existing entries >= uidGidReservedFloor are ignored when computing
|
// Existing entries >= uidGidReservedFloor are ignored when computing
|
||||||
@@ -60,6 +65,16 @@ module.exports = {
|
|||||||
pass: '__in secrets file__',
|
pass: '__in secrets file__',
|
||||||
from: 'SSO Manager <noreply@example.com>',
|
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: {
|
service: {
|
||||||
updateCheck: {
|
updateCheck: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Plugins & Scheduler
|
||||||
|
|
||||||
|
The SSO Manager includes a flexible background task runner and discovery system. Plugins are defined statically in your deployment configuration (`sso-secrets.js`) and run based on their defined `cron` schedule.
|
||||||
|
|
||||||
|
## Writing Custom Plugins
|
||||||
|
|
||||||
|
You can write custom plugins to discover resources, manage internal state, or run automated scripts. Plugins must be placed in the `plugins/discovery/` directory of the SSO Manager node codebase.
|
||||||
|
|
||||||
|
A plugin file must export a `discover` method.
|
||||||
|
|
||||||
|
**Example Plugin (`plugins/discovery/my_plugin.js`):**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
discover: async function(config) {
|
||||||
|
// The config object contains any keys passed in sso-secrets.js for this plugin.
|
||||||
|
|
||||||
|
// Perform discovery logic, hit external APIs, etc.
|
||||||
|
const resources = [
|
||||||
|
{
|
||||||
|
slug: 'my-custom-resource-1',
|
||||||
|
name: 'My Resource 1',
|
||||||
|
kind: 'Host',
|
||||||
|
metadata: {
|
||||||
|
ip: '10.0.0.100',
|
||||||
|
source: 'My Custom Plugin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Return the discovered resources array. The discovery reconciler will
|
||||||
|
// automatically save these to the Network Discovery database.
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuring Plugins
|
||||||
|
|
||||||
|
In your `sso-secrets.js` file, add your plugin to the `discovery.plugins` object:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
// ...
|
||||||
|
discovery: {
|
||||||
|
plugins: {
|
||||||
|
my_plugin: {
|
||||||
|
enabled: true,
|
||||||
|
cron: "0 * * * *", // Run every hour
|
||||||
|
my_custom_key: "my_custom_value" // Passed to the config argument in discover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Overriding Timing and Enable/Disable
|
||||||
|
|
||||||
|
From the **Plugins & Scheduler** tab in the Directory Dashboard, you can override the schedule and enable/disable state for each plugin. These overrides take precedence over `sso-secrets.js` and are stored internally.
|
||||||
|
|
||||||
|
## Scheduler Internals
|
||||||
|
|
||||||
|
The scheduler uses BullMQ backed by Redis to manage execution. It automatically performs garbage collection on stale network resources (resources not updated in > 7 days) and triggers your plugins at the defined intervals.
|
||||||
@@ -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>
|
||||||
|
```
|
||||||
@@ -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);
|
||||||
@@ -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 };
|
||||||
@@ -3,44 +3,18 @@
|
|||||||
const { Client, Attribute, Change } = require('ldapts');
|
const { Client, Attribute, Change } = require('ldapts');
|
||||||
const { LRUCache } = require('lru-cache');
|
const { LRUCache } = require('lru-cache');
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
// Connection + escaping from the shared @simpleworkjs/ldap package. Local
|
||||||
// Escape a value used inside an LDAP search filter (RFC 4515).
|
// wrappers preserve the no-arg call signatures; see user_ldap.js for rationale.
|
||||||
function escapeLDAPSearchValue(val) {
|
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
|
||||||
return String(val)
|
const escapeLDAPSearchValue = escapeFilter;
|
||||||
.replace(/\\/g, '\\5c')
|
const escapeLDAPDNValue = escapeDN;
|
||||||
.replace(/\*/g, '\\2a')
|
|
||||||
.replace(/\(/g, '\\28')
|
|
||||||
.replace(/\)/g, '\\29')
|
|
||||||
.replace(/\0/g, '\\00');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape a value used in an LDAP DN (RFC 4514). Defensive: usernames/cns
|
|
||||||
// are normally alphanumeric, but this prevents metacharacter injection.
|
|
||||||
function escapeLDAPDNValue(val) {
|
|
||||||
return String(val)
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/,/g, '\\,')
|
|
||||||
.replace(/\+/g, '\\+')
|
|
||||||
.replace(/"/g, '\\"')
|
|
||||||
.replace(/</g, '\\<')
|
|
||||||
.replace(/>/g, '\\>')
|
|
||||||
.replace(/;/g, '\\;')
|
|
||||||
.replace(/=/g, '\\=')
|
|
||||||
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeClient() {
|
function makeClient() {
|
||||||
return new Client({ url: conf.url });
|
return _makeClient(conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withClient(fn) {
|
async function withClient(fn) {
|
||||||
const client = makeClient();
|
return _withClient(conf, fn);
|
||||||
try {
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGroups(client, member){
|
async function getGroups(client, member){
|
||||||
@@ -138,18 +112,190 @@ async function cachedListDetail() {
|
|||||||
return promise;
|
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 = {};
|
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){
|
Group.list = async function(member){
|
||||||
if (member) {
|
if (member) {
|
||||||
return withClient(async (client) => {
|
if (SERVER_SIDE_NESTING) {
|
||||||
const groups = await getGroups(client, member);
|
return withClient(async (client) => {
|
||||||
return groups.map(group => group.cn);
|
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);
|
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){
|
Group.listDetail = async function(member){
|
||||||
if (member) {
|
if (member) {
|
||||||
return withClient(async (client) => getGroups(client, member));
|
return withClient(async (client) => getGroups(client, member));
|
||||||
@@ -192,6 +338,7 @@ Group.add = async function(data){
|
|||||||
return withClient(async (client) => {
|
return withClient(async (client) => {
|
||||||
await addGroup(client, data);
|
await addGroup(client, data);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this.get(data);
|
return this.get(data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -200,6 +347,7 @@ Group.addMember = async function(user){
|
|||||||
await withClient(async (client) => addMember(client, this, user));
|
await withClient(async (client) => addMember(client, this, user));
|
||||||
this.member = [].concat(this.member || []).concat([user.dn]);
|
this.member = [].concat(this.member || []).concat([user.dn]);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,6 +360,7 @@ Group.removeMember = async function(user){
|
|||||||
}
|
}
|
||||||
this.member = [].concat(this.member || []).filter(dn => dn !== user.dn);
|
this.member = [].concat(this.member || []).filter(dn => dn !== user.dn);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -219,6 +368,7 @@ Group.addOwner = async function(user){
|
|||||||
await withClient(async (client) => addOwner(client, this, user));
|
await withClient(async (client) => addOwner(client, this, user));
|
||||||
this.owner = [].concat(this.owner || []).concat([user.dn]);
|
this.owner = [].concat(this.owner || []).concat([user.dn]);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,12 +381,14 @@ Group.removeOwner = async function(user){
|
|||||||
}
|
}
|
||||||
this.owner = [].concat(this.owner || []).filter(dn => dn !== user.dn);
|
this.owner = [].concat(this.owner || []).filter(dn => dn !== user.dn);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
Group.remove = async function(){
|
Group.remove = async function(){
|
||||||
await withClient(async (client) => client.del(this.dn));
|
await withClient(async (client) => client.del(this.dn));
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ require('./api_token');
|
|||||||
|
|
||||||
const { init } = require('@simpleworkjs/orm');
|
const { init } = require('@simpleworkjs/orm');
|
||||||
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
||||||
|
const { AccessRequest } = require('./access_request');
|
||||||
|
const { Webhook } = require('./webhook');
|
||||||
async function initORM() {
|
async function initORM() {
|
||||||
const ormConf = conf.orm || {
|
const ormConf = conf.orm || {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
@@ -28,7 +29,7 @@ async function initORM() {
|
|||||||
await init({
|
await init({
|
||||||
conf: { orm: ormConf },
|
conf: { orm: ormConf },
|
||||||
models: [
|
models: [
|
||||||
Resource, ResourceEdge, ResourceGroup,
|
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook,
|
||||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,6 +103,40 @@ class Resource extends Model {
|
|||||||
return { resources: resObjs, edges };
|
return { resources: resObjs, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
static async getMyAccess(userDn) {
|
||||||
const userGroups = await Group.list(userDn);
|
const userGroups = await Group.list(userDn);
|
||||||
if (!userGroups || userGroups.length === 0) return [];
|
if (!userGroups || userGroups.length === 0) return [];
|
||||||
@@ -110,38 +144,11 @@ class Resource extends Model {
|
|||||||
const resourceGroups = await ResourceGroup.list({
|
const resourceGroups = await ResourceGroup.list({
|
||||||
where: { groupCn: { in: userGroups } }
|
where: { groupCn: { in: userGroups } }
|
||||||
});
|
});
|
||||||
|
|
||||||
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
|
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
|
||||||
if (resourceIds.length === 0) return [];
|
if (resourceIds.length === 0) return [];
|
||||||
|
|
||||||
const resources = await this.list({ where: { id: { in: resourceIds } } });
|
return this.withResolvedAddress(await this.list({ where: { id: { in: resourceIds } } }));
|
||||||
|
|
||||||
// Resolve inherited addresses from the graph
|
|
||||||
const graph = await this.getGraph();
|
|
||||||
|
|
||||||
function resolveHost(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;
|
|
||||||
|
|
||||||
const parentEdges = graph.edges.filter(e => e.childId === resId);
|
|
||||||
for (const edge of parentEdges) {
|
|
||||||
const found = resolveHost(edge.parentId, visited);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resources.map(r => {
|
|
||||||
const data = { ...r };
|
|
||||||
data.metadata = data.metadata || {};
|
|
||||||
data.resolvedAddress = resolveHost(r.id);
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static fields = {
|
static fields = {
|
||||||
@@ -152,10 +159,36 @@ class Resource extends Model {
|
|||||||
owner: { type: 'string' },
|
owner: { type: 'string' },
|
||||||
description: { type: 'text' },
|
description: { type: 'text' },
|
||||||
metadata: { type: 'json', default: {} },
|
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' },
|
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
||||||
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
||||||
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
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 {
|
class ResourceEdge extends Model {
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ const {Token, InviteToken, PasswordResetToken} = require('./token');
|
|||||||
const {Group} = require('./group_ldap');
|
const {Group} = require('./group_ldap');
|
||||||
const {UserVerification} = require('./verification');
|
const {UserVerification} = require('./verification');
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
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) {
|
function hashPasswordSSHA512(password) {
|
||||||
const salt = crypto.randomBytes(8);
|
const salt = crypto.randomBytes(8);
|
||||||
@@ -23,40 +31,11 @@ const cache = new LRUCache({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function makeClient() {
|
function makeClient() {
|
||||||
return new Client({ url: conf.url });
|
return _makeClient(conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withClient(fn) {
|
async function withClient(fn) {
|
||||||
const client = makeClient();
|
return _withClient(conf, fn);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape a value used in an LDAP DN (RFC 4514).
|
|
||||||
function escapeLDAPDNValue(val) {
|
|
||||||
return String(val)
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/,/g, '\\,')
|
|
||||||
.replace(/\+/g, '\\+')
|
|
||||||
.replace(/"/g, '\\"')
|
|
||||||
.replace(/</g, '\\<')
|
|
||||||
.replace(/>/g, '\\>')
|
|
||||||
.replace(/;/g, '\\;')
|
|
||||||
.replace(/=/g, '\\=')
|
|
||||||
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute the next available uid/gidNumber: the highest existing value below
|
// Compute the next available uid/gidNumber: the highest existing value below
|
||||||
@@ -316,6 +295,9 @@ User.listDetail = async function(){
|
|||||||
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
||||||
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
||||||
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
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;
|
return obj;
|
||||||
}));
|
}));
|
||||||
@@ -494,6 +476,19 @@ User.update = async function(data){
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(data.sshPublicKey){
|
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, [
|
await client.modify(this.dn, [
|
||||||
new Change({
|
new Change({
|
||||||
operation: 'replace',
|
operation: 'replace',
|
||||||
@@ -805,6 +800,19 @@ User.addSSHkey = async function(data) {
|
|||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
await withClient(async (client) => {
|
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, [
|
await client.modify(user.dn, [
|
||||||
new Change({
|
new Change({
|
||||||
operation: 'add',
|
operation: 'add',
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -1,34 +1,43 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.2",
|
"version": "1.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.2",
|
"version": "1.13.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.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",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"bullmq": "^6.0.3",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
|
"http-proxy-middleware": "^2.0.10",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"ldapts": "^8.1.2",
|
"ldapts": "^8.1.8",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"node-nmap": "^4.0.0",
|
||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
@@ -646,6 +655,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ioredis/commands": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -1094,6 +1109,84 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
@@ -1242,6 +1335,18 @@
|
|||||||
"@redis/client": "^6.1.0"
|
"@redis/client": "^6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/app-stack": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/app-stack/-/app-stack-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Hg/mouA87WruKeZqhqtJgAaLabjHY8Z9POO6U+DB7sGGDhy1jgZXT31hyxLUDV+InByOPhz48NIkGiWNwoesXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/conf": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
@@ -1254,6 +1359,36 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/directory-schema": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/directory-schema/-/directory-schema-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-hTXxHl7Jz5IbIAYmn8dv9f0B50ocjEg5ju+UV8ZQSaBjJYpetOVfcFvT6v9xwMVtjXSYMDwKPvD8YgKOBz7xJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simpleworkjs/frontend": {
|
||||||
|
"version": "0.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
|
||||||
|
"integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simpleworkjs/ldap": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ldapts": "^8.1.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/orm": {
|
"node_modules/@simpleworkjs/orm": {
|
||||||
"version": "0.2.8",
|
"version": "0.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/orm/-/orm-0.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/orm/-/orm-0.2.8.tgz",
|
||||||
@@ -1377,6 +1512,15 @@
|
|||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/http-proxy": {
|
||||||
|
"version": "1.17.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
|
||||||
|
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -2198,7 +2342,6 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.1.1"
|
||||||
@@ -2288,6 +2431,54 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bullmq": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-ri/ugcNf4G/knwnMd2LVuwIdyzI9A2a2CipYvvfG6H4I1X23DhNrDtd8yuj46dqeE8kdoUSPlTJ9rEtfs4W/cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron-parser": "5.6.1",
|
||||||
|
"msgpackr": "2.0.5",
|
||||||
|
"node-abort-controller": "3.1.1",
|
||||||
|
"semver": "7.8.5",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bullmq-otel": ">=2.0.0",
|
||||||
|
"ioredis": ">=5.0.0",
|
||||||
|
"pg": ">=8.0.0",
|
||||||
|
"redis": ">=5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bullmq-otel": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"ioredis": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pg": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -2713,6 +2904,18 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "5.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.6.1.tgz",
|
||||||
|
"integrity": "sha512-QBm4o1PwZiuY7KFbVvW7FLC8bozy7YWzv+Fz6KRS7sQghzcbDZCGxr/Bc5b6TQreAoSwuWVP491dIcK0THCX6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2802,6 +3005,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -3155,6 +3367,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
@@ -3405,7 +3623,6 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
@@ -3472,6 +3689,26 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
@@ -3835,6 +4072,44 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http-proxy": {
|
||||||
|
"version": "1.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||||
|
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^4.0.0",
|
||||||
|
"follow-redirects": "^1.0.0",
|
||||||
|
"requires-port": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-proxy-middleware": {
|
||||||
|
"version": "2.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
|
||||||
|
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-proxy": "^1.17.8",
|
||||||
|
"http-proxy": "^1.18.1",
|
||||||
|
"is-glob": "^4.0.1",
|
||||||
|
"is-plain-obj": "^3.0.0",
|
||||||
|
"micromatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/express": "^4.17.13"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/express": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/human-signals": {
|
"node_modules/human-signals": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||||
@@ -3951,6 +4226,59 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ioredis": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "2.0.0",
|
||||||
|
"cluster-key-slot": "1.1.1",
|
||||||
|
"debug": "4.4.3",
|
||||||
|
"denque": "2.1.0",
|
||||||
|
"redis-errors": "1.2.0",
|
||||||
|
"standard-as-callback": "2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
@@ -3993,7 +4321,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -4023,7 +4350,6 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
@@ -4036,12 +4362,23 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-obj": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-promise": {
|
"node_modules/is-promise": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
@@ -4817,9 +5154,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jquery": {
|
"node_modules/jquery": {
|
||||||
"version": "3.7.1",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
|
||||||
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
|
"integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
@@ -5036,6 +5373,15 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/make-dir": {
|
"node_modules/make-dir": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
@@ -5134,6 +5480,31 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime": {
|
"node_modules/mime": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||||
@@ -5278,6 +5649,37 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mustache": {
|
"node_modules/mustache": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
||||||
@@ -5349,6 +5751,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-abort-controller": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-addon-api": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "8.9.0",
|
"version": "8.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
||||||
@@ -5358,6 +5766,26 @@
|
|||||||
"node": "^18 || ^20 || >= 21"
|
"node": "^18 || ^20 || >= 21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-gyp": {
|
"node_modules/node-gyp": {
|
||||||
"version": "12.4.0",
|
"version": "12.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
||||||
@@ -5394,6 +5822,21 @@
|
|||||||
"node-gyp-build-test": "build-test.js"
|
"node-gyp-build-test": "build-test.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-gyp/node_modules/isexe": {
|
"node_modules/node-gyp/node_modules/isexe": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
||||||
@@ -5440,6 +5883,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-nmap": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-nmap/-/node-nmap-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-VJGebpYsfqmUm46+Fq0qp1Y9VXGXZ7/WL03tHGy1oJHHxaJ2DvYLMjuYWYHDV0pgUL+e5/9rCN/QEsx3+fU9TA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"queued-up": "^2.0.2",
|
||||||
|
"xml2js": "^0.4.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.51",
|
"version": "2.0.51",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||||
@@ -6031,6 +6484,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/queued-up": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/queued-up/-/queued-up-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-6ToqVyUPHRoIcxLKyUz7TCph2NULzoc41TAjdX/Fv7wsvj+E7tAAgqOab1cIFe0uTLJNWOswbLG4eDd2j3Y8AA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||||
@@ -6155,6 +6614,15 @@
|
|||||||
"node": ">= 20.0.0"
|
"node": ">= 20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -6165,6 +6633,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/requires-port": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve-cwd": {
|
"node_modules/resolve-cwd": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
|
||||||
@@ -6259,6 +6733,15 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -6869,6 +7352,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/standard-as-callback": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -7296,7 +7785,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
@@ -7330,13 +7818,17 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"nodetouch": "bin/nodetouch.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
@@ -7567,6 +8059,22 @@
|
|||||||
"makeerror": "1.0.12"
|
"makeerror": "1.0.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -7728,6 +8236,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml2js": {
|
||||||
|
"version": "0.4.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||||
|
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": ">=0.6.0",
|
||||||
|
"xmlbuilder": "~11.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "11.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||||
|
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xss": {
|
"node_modules/xss": {
|
||||||
"version": "1.0.15",
|
"version": "1.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.2",
|
"version": "1.13.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -23,24 +23,33 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.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",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"bullmq": "^6.0.3",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
|
"http-proxy-middleware": "^2.0.10",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"ldapts": "^8.1.2",
|
"ldapts": "^8.1.8",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"node-nmap": "^4.0.0",
|
||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
const nmap = require('node-nmap');
|
||||||
|
nmap.nmapLocation = "nmap"; // default
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
discover: async (config) => {
|
||||||
|
const { targetRange } = config;
|
||||||
|
if (!targetRange) throw new Error("Missing targetRange for Nmap");
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const scan = new nmap.OsAndPortScan(targetRange);
|
||||||
|
scan.on('complete', function(data) {
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
for (const host of data) {
|
||||||
|
if (!host.mac || !host.ip) continue;
|
||||||
|
const hostSlug = `nmap-host-${host.mac.replace(/:/g, '')}`;
|
||||||
|
|
||||||
|
const interfaces = [{ mac: host.mac, 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-${host.mac.replace(/:/g, '')}-${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) {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
scan.startScan();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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 = {
|
||||||
|
discover: async (config) => {
|
||||||
|
const { url, tokenId, tokenSecret } = config;
|
||||||
|
if (!url || !tokenId || !tokenSecret) {
|
||||||
|
throw new Error("Missing Proxmox config");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
|
||||||
|
};
|
||||||
|
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
// 1. Get Nodes
|
||||||
|
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
|
||||||
|
if(!resNodes.ok) throw new Error("Proxmox API error on nodes");
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const agent = new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -7,6 +7,12 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
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 {
|
#spa-shell {
|
||||||
|
|||||||
@@ -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){
|
function changePassword(args, callack){
|
||||||
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
||||||
callack(error, data);
|
callack(error, data);
|
||||||
@@ -110,7 +103,7 @@ app.user = (function(app){
|
|||||||
return m ? m[1] : dn;
|
return m ? m[1] : dn;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {list, remove, createInvite, setActive, dnToUid};
|
return {list, createInvite, setActive, dnToUid};
|
||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
@@ -306,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){
|
function update(args, callack){
|
||||||
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
|
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
|
||||||
callack(error, data);
|
callack(error, data);
|
||||||
@@ -325,7 +311,7 @@ app.oauthClient = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { list, add, remove, update, rotateSecret };
|
return { list, add, update, rotateSecret };
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
app.tos = (function(app){
|
app.tos = (function(app){
|
||||||
@@ -396,7 +382,7 @@ app.impersonate = (function(app){
|
|||||||
|
|
||||||
app.token = (function(app){
|
app.token = (function(app){
|
||||||
function list(name, callack){
|
function list(name, callack){
|
||||||
if($.isFunction(name)){
|
if(typeof name === 'function'){
|
||||||
callack = name;
|
callack = name;
|
||||||
name = '';
|
name = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {};
|
var app = {};
|
||||||
|
|
||||||
app.pubsub = (function(){
|
app.pubsub = (function(){
|
||||||
@@ -45,7 +54,7 @@ app.pubsub = (function(){
|
|||||||
app.socket = (function(app){
|
app.socket = (function(app){
|
||||||
// $.getScript('/socket.io/socket.io.js')
|
// $.getScript('/socket.io/socket.io.js')
|
||||||
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
|
|
||||||
var socket;
|
var socket;
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
socket = io({
|
socket = io({
|
||||||
@@ -75,11 +84,17 @@ app.socket = (function(app){
|
|||||||
app.api = (function(app){
|
app.api = (function(app){
|
||||||
var baseURL = '/api/'
|
var baseURL = '/api/'
|
||||||
|
|
||||||
function post(url, data, callback){
|
// post/put/delete are dual-mode: pass a callback for the node-style
|
||||||
if (!$.isFunction(callback)) {
|
// (error, data, status) form, or omit it to get a Promise that resolves
|
||||||
return new Promise((resolve, reject) => {
|
// 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({
|
$.ajax({
|
||||||
type: 'POST', url: baseURL+url,
|
type: method,
|
||||||
|
url: baseURL+url,
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
@@ -88,9 +103,11 @@ app.api = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'POST',
|
type: method,
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
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){
|
function put(url, data, callback){
|
||||||
if (!$.isFunction(callback)) {
|
return body('PUT', url, data, 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(url, callback){
|
// Called both as (url, callback) and — from formAJAX, which always passes
|
||||||
if (!$.isFunction(callback)) {
|
// the serialized form as the second argument — as (url, data, callback).
|
||||||
return new Promise((resolve, reject) => {
|
// 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({
|
$.ajax({
|
||||||
type: 'DELETE', url: baseURL+url,
|
type: 'DELETE',
|
||||||
|
url: baseURL+url,
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
@@ -147,7 +151,9 @@ app.api = (function(app){
|
|||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'DELETE',
|
type: 'DELETE',
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
complete: function(res, text){
|
complete: function(res, text){
|
||||||
@@ -202,7 +208,10 @@ app.api = (function(app){
|
|||||||
})(app)
|
})(app)
|
||||||
|
|
||||||
app.auth = (function(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){
|
function setToken(token){
|
||||||
localStorage.setItem('APIToken', token);
|
localStorage.setItem('APIToken', token);
|
||||||
@@ -216,35 +225,70 @@ app.auth = (function(app){
|
|||||||
try{
|
try{
|
||||||
return await app.api.get('user/me');
|
return await app.api.get('user/me');
|
||||||
}catch(error){
|
}catch(error){
|
||||||
if(error?.status === 401) return null;
|
if(error && error.status === 401) return null;
|
||||||
throw error
|
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){
|
async function memberOf(groupNameToFind, user){
|
||||||
try{
|
user = user || await loadUser();
|
||||||
user = user || await app.auth.asyncUser;
|
if(!user) return false;
|
||||||
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind]
|
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
|
||||||
|
|
||||||
for(let group of user.memberOf){
|
return groupCNs(user).some(function(group){
|
||||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
return groupNameToFind.includes(group);
|
||||||
if(groupNameToFind.includes(group)) return true;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
|
|
||||||
}catch(error){
|
|
||||||
throw(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isLoggedIn(){
|
// True when the logged-in user is a global admin (per user/me). Sync — only
|
||||||
if(getToken()){
|
// meaningful once isLoggedIn/forceLogin has resolved.
|
||||||
user = await app.auth.asyncUser;
|
function isAdmin(){
|
||||||
return user;
|
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
||||||
}else{
|
}
|
||||||
return false;
|
|
||||||
|
// 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){
|
function logIn(args, callback){
|
||||||
@@ -252,62 +296,125 @@ app.auth = (function(app){
|
|||||||
if(data.login){
|
if(data.login){
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
}
|
}
|
||||||
|
loadUser(true);
|
||||||
callback(error, !!data.token);
|
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){
|
function logOut(callback){
|
||||||
localStorage.removeItem('APIToken');
|
localStorage.removeItem('APIToken');
|
||||||
location.replace(`/login${location.href.replace(location.origin, '')}`);
|
userPromise = null;
|
||||||
callback();
|
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){
|
async function forceLogin(requiredGroups){
|
||||||
$.holdReady(true);
|
var user = await loadUser();
|
||||||
if(!await app.auth.isLoggedIn()) app.auth.logOut(function(){});
|
|
||||||
|
if(!user){
|
||||||
|
logOut(function(){});
|
||||||
|
location.replace('/login?redirect=' + encodeURIComponent(
|
||||||
|
location.pathname + location.search
|
||||||
|
));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
||||||
location.replace('/onboarding');
|
location.replace('/onboarding');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(requiredGroups){
|
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||||
if(!await memberOf(requiredGroups)){
|
app.messages.action(
|
||||||
console.log("Does not have permission!!!")
|
`<h1>
|
||||||
app.util.actionMessage(
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
`<h1>
|
<b>You do not have permission to be here.</b>
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
<b>You do not have permission to be here.</b>
|
</h1>`,
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
$('#spa-shell'),
|
||||||
</h1>`,
|
'danger',
|
||||||
$('#spa-shell'),
|
);
|
||||||
'danger',
|
throw new Error("User does not have permission");
|
||||||
);
|
|
||||||
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(){
|
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 {
|
return {
|
||||||
getToken: getToken,
|
getToken: getToken,
|
||||||
setToken: setToken,
|
setToken: setToken,
|
||||||
|
getUser: getUser,
|
||||||
|
loadUser: loadUser,
|
||||||
|
groupCNs: groupCNs,
|
||||||
|
memberOf: memberOf,
|
||||||
|
isAdmin: isAdmin,
|
||||||
isLoggedIn: isLoggedIn,
|
isLoggedIn: isLoggedIn,
|
||||||
|
safeInternalPath: safeInternalPath,
|
||||||
|
consumeTokenFragment: consumeTokenFragment,
|
||||||
|
user: null,
|
||||||
|
perms: null,
|
||||||
logIn: logIn,
|
logIn: logIn,
|
||||||
logOut: logOut,
|
logOut: logOut,
|
||||||
forceLogin,
|
forceLogin,
|
||||||
logInRedirect,
|
logInRedirect,
|
||||||
getUser,
|
|
||||||
memberOf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})(app);
|
})(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){
|
app.user = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
@@ -338,6 +445,72 @@ app.user = (function(app){
|
|||||||
|
|
||||||
})(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){
|
app.util = (function(app){
|
||||||
|
|
||||||
function getUrlParameter(name){
|
function getUrlParameter(name){
|
||||||
@@ -347,65 +520,15 @@ app.util = (function(app){
|
|||||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||||
};
|
};
|
||||||
|
|
||||||
function actionMessage(message, $targetPassed, type, callback){
|
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
|
||||||
message = message || '';
|
// app.util.escapeHtml and app.messages.action/confirm.
|
||||||
|
function escapeHtml(s){
|
||||||
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
return String(s == null ? '' : s)
|
||||||
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
type = type || 'info';
|
.replace(/>/g, '>')
|
||||||
callback = callback || function(){};
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
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'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.fn.serializeObject = function() {
|
$.fn.serializeObject = function() {
|
||||||
@@ -415,8 +538,11 @@ app.util = (function(app){
|
|||||||
for (let {name, value} of $(this).serializeArray()) {
|
for (let {name, value} of $(this).serializeArray()) {
|
||||||
console.log(name, value)
|
console.log(name, value)
|
||||||
if (obj[name] === undefined) {
|
if (obj[name] === undefined) {
|
||||||
if (!value
|
if (!value
|
||||||
&& !$(this).parent().find(`[name="${name}"]`).attr('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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -458,29 +584,64 @@ app.util = (function(app){
|
|||||||
document.body.removeChild(element);
|
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 {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
actionMessage: actionMessage,
|
escapeHtml: escapeHtml,
|
||||||
actionConfirm,
|
revealItem: revealItem,
|
||||||
}
|
}
|
||||||
})(app);
|
})(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.
|
||||||
|
app.auth.applyGroupVisibility = function(user){
|
||||||
|
var groups = app.auth.groupCNs(user);
|
||||||
|
if(!groups.length) return;
|
||||||
|
|
||||||
// Show content if the user has the correct group
|
var style = document.getElementById('group-required-rules');
|
||||||
for(let group of (await app.auth.asyncUser)?.memberOf || []){
|
if(!style){
|
||||||
|
style = document.createElement('style');
|
||||||
|
style.id = 'group-required-rules';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(var group of groups){
|
||||||
try{
|
try{
|
||||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
style.sheet.insertRule(
|
||||||
|
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
|
||||||
const sheet = document.styleSheets[0];
|
style.sheet.cssRules.length
|
||||||
const selector = `.group-required-${group}`;
|
);
|
||||||
const cssText = `${selector} { display: revert !important; }`;
|
|
||||||
sheet.insertRule(cssText, sheet.cssRules.length);
|
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$( 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
|
$('div.row').fadeIn('slow'); //show the page
|
||||||
|
|
||||||
@@ -502,9 +663,9 @@ $( document ).ready(async function(){
|
|||||||
$(this).closest('.card').slideUp('fast');
|
$(this).closest('.card').slideUp('fast');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('.actionMessage').on('click', 'button.action-close', function(event){
|
// action-close click handling is wired by @simpleworkjs/frontend's
|
||||||
app.util.actionMessage(null, $(this));
|
// app.messages.js (delegated on document, so it also covers messages
|
||||||
});
|
// rendered after this ready handler runs).
|
||||||
|
|
||||||
setInterval(()=>{
|
setInterval(()=>{
|
||||||
$('.momentFromNow').each((idx, el)=>{
|
$('.momentFromNow').each((idx, el)=>{
|
||||||
@@ -535,20 +696,17 @@ function formAJAX(btn){
|
|||||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||||
|
|
||||||
if($form.validate && !$form.validate()){
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.util.actionMessage(
|
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||||
`<div class="spinner-border" role="status">
|
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||||
<span class="visually-hidden">Loading...</span>
|
// render literally instead of as an element.
|
||||||
</div>`,
|
app.messages.action('Saving…', $form, 'info');
|
||||||
$form,
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
|
|
||||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
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();
|
$form.validateClear();
|
||||||
if(!error){
|
if(!error){
|
||||||
$form.trigger("reset");
|
$form.trigger("reset");
|
||||||
@@ -556,7 +714,7 @@ function formAJAX(btn){
|
|||||||
}else{
|
}else{
|
||||||
console.log('formAJAX res error', error, data)
|
console.log('formAJAX res error', error, data)
|
||||||
if(data && data.name === 'ObjectValidateError'){
|
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){
|
if(data && data.keys){
|
||||||
console.log('form key errors', data.keys)
|
console.log('form key errors', data.keys)
|
||||||
@@ -567,4 +725,3 @@ function formAJAX(btn){
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const confManager = require('../utils/conf_manager');
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const editable = {
|
||||||
|
smtp: conf.smtp || {},
|
||||||
|
discovery: conf.discovery || {},
|
||||||
|
oauth: conf.oauth || {}
|
||||||
|
};
|
||||||
|
res.json(editable);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await confManager.setVaultConf(req.body);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -3,6 +3,31 @@ const router = require('express').Router();
|
|||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
||||||
const { Group } = require('../models/group_ldap');
|
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
|
// Require the admin group
|
||||||
router.use(async (req, res, next) => {
|
router.use(async (req, res, next) => {
|
||||||
@@ -17,8 +42,15 @@ router.use(async (req, res, next) => {
|
|||||||
// --- Resources ---
|
// --- Resources ---
|
||||||
router.get('/resources', async (req, res, next) => {
|
router.get('/resources', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const resources = await Resource.list();
|
let resources = await Resource.list();
|
||||||
res.json({ results: resources });
|
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); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,25 +72,35 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.body.owner = req.body.owner || req.user.uid;
|
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;
|
let r;
|
||||||
if (req.body.kind === 'oauth') {
|
if (req.body.kind === 'oauth') {
|
||||||
const { OAuthClient } = require('../models/oauth_client');
|
const { OAuthClient } = require('../models/oauth_client');
|
||||||
// Pass created_by explicitly for the wrapper
|
// 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;
|
req.body.created_by = req.body.owner;
|
||||||
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
||||||
r = await OAuthClient.add(req.body);
|
r = await OAuthClient.add(req.body);
|
||||||
} else {
|
} else {
|
||||||
r = await Resource.create(req.body);
|
r = await Resource.create(req.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
|
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' });
|
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.kind === 'host' || r.kind === 'service') {
|
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 createGroup = async (suffix, accessLevel) => {
|
||||||
const cn = `${r.slug}_${suffix}`;
|
const cn = groupCn(suffix);
|
||||||
try {
|
try {
|
||||||
await Group.add({
|
await Group.add({
|
||||||
name: cn,
|
name: cn,
|
||||||
@@ -76,6 +118,21 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
};
|
};
|
||||||
await createGroup('access', 'member');
|
await createGroup('access', 'member');
|
||||||
await createGroup('admin', 'owner');
|
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 });
|
res.json({ results: r });
|
||||||
@@ -92,15 +149,8 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
|
|
||||||
router.put('/resources/:id', async (req, res, next) => {
|
router.put('/resources/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
let r;
|
// Validate before loading anything -- a rejected body should never have
|
||||||
if (req.body.kind === 'oauth') {
|
// touched the store.
|
||||||
const { OAuthClient } = require('../models/oauth_client');
|
|
||||||
r = await OAuthClient.get(req.params.id);
|
|
||||||
} else {
|
|
||||||
r = await Resource.get(req.params.id);
|
|
||||||
}
|
|
||||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
|
||||||
|
|
||||||
if (req.body.kind === 'host' && !req.body.hostId) {
|
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||||
}
|
}
|
||||||
@@ -110,14 +160,20 @@ router.put('/resources/:id', async (req, res, next) => {
|
|||||||
if (req.body.kind === 'oauth' && !req.body.hostId) {
|
if (req.body.kind === 'oauth' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
|
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated;
|
// OAuthClient is a wrapper over the same `resource` row, but its .update()
|
||||||
if (req.body.kind === 'oauth') {
|
// handles the oauth-specific body fields (redirect_uris, scopes,
|
||||||
updated = await r.update(req.body);
|
// token_lifetime) that a bare Resource would drop into metadata unvalidated.
|
||||||
} else {
|
const { OAuthClient } = require('../models/oauth_client');
|
||||||
updated = await r.update(req.body);
|
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) {
|
if ((updated.kind === 'host' || updated.kind === 'service' || updated.kind === 'oauth') && req.body.hostId !== undefined) {
|
||||||
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
|
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
|
||||||
for (const e of existingEdges) {
|
for (const e of existingEdges) {
|
||||||
@@ -149,13 +205,18 @@ router.delete('/resources/:id', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const r = await Resource.get(req.params.id);
|
const r = await Resource.get(req.params.id);
|
||||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||||
await r.delete();
|
// Clear the dependents FIRST. There is no transaction here, so ordering is
|
||||||
// Also delete edges and groups involving this resource
|
// 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 edgesParent = await ResourceEdge.list({ where: { parentId: req.params.id } });
|
||||||
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
|
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
|
||||||
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
|
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
|
||||||
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
|
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
|
||||||
for (const g of groups) await g.delete();
|
for (const g of groups) await g.delete();
|
||||||
|
await r.delete();
|
||||||
res.json({ results: true });
|
res.json({ results: true });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -208,19 +269,138 @@ router.delete('/groups/:id', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} 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) => {
|
router.get('/audit-logs', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const fs = require('fs');
|
const [ldap, oauth, audit] = await Promise.all([
|
||||||
const { execSync } = require('child_process');
|
tailFile('/var/lib/ldap/slapd.log'),
|
||||||
let ldapLogs = '';
|
tailFile('/var/lib/ldap/oauth.log'),
|
||||||
let oauthLogs = '';
|
tailFile('/var/lib/ldap/auditlog.ldif'),
|
||||||
let auditLogs = '';
|
]);
|
||||||
|
res.json({ results: { ldap, oauth, audit } });
|
||||||
try { ldapLogs = execSync('tail -n 100 /var/lib/ldap/slapd.log 2>/dev/null').toString(); } catch(e){}
|
|
||||||
try { oauthLogs = execSync('tail -n 100 /var/lib/ldap/oauth.log 2>/dev/null').toString(); } catch(e){}
|
|
||||||
try { auditLogs = execSync('tail -n 100 /var/lib/ldap/auditlog.ldif 2>/dev/null').toString(); } catch(e){}
|
|
||||||
|
|
||||||
res.json({ results: { ldap: ldapLogs, oauth: oauthLogs, audit: auditLogs } });
|
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const router = require('express').Router();
|
|
||||||
const { Resource, ResourceGroup } = require('../models/resource');
|
|
||||||
|
|
||||||
// GET /api/discovery/me
|
|
||||||
// Returns the list of resources the current user has access to.
|
|
||||||
router.get('/me', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const userGroups = req.user.groups || []; // array of LDAP group CNs
|
|
||||||
const accessibleResourceIds = new Set();
|
|
||||||
|
|
||||||
if (req.user.isMachine) {
|
|
||||||
// Machines only have access to themselves by default
|
|
||||||
accessibleResourceIds.add(req.resourceId);
|
|
||||||
} else {
|
|
||||||
// End users get access via groups
|
|
||||||
const allGroups = await ResourceGroup.list();
|
|
||||||
for (const rg of allGroups) {
|
|
||||||
if (userGroups.includes(rg.groupCn)) {
|
|
||||||
accessibleResourceIds.add(rg.resourceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch all resources and filter
|
|
||||||
const allResources = await Resource.list();
|
|
||||||
const accessible = allResources.filter(r => accessibleResourceIds.has(r.id) || r.metadata?.isPublic);
|
|
||||||
|
|
||||||
res.json({ results: accessible });
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -3,8 +3,8 @@ const router = require('express').Router();
|
|||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const metrics = require('../utils/metrics');
|
const metrics = require('../utils/metrics');
|
||||||
|
|
||||||
// /api/metrics/executive
|
// /api/metrics/overview
|
||||||
router.get('/executive', async (req, res, next) => {
|
router.get('/overview', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
await permission.byGroup(req.user, ['app_sso_admin']);
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,158 @@
|
|||||||
const autoRouter = require('./autoRouter');
|
'use strict';
|
||||||
const { Resource } = require('../models/resource');
|
|
||||||
|
|
||||||
module.exports = autoRouter(Resource);
|
// 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,
|
||||||
|
}));
|
||||||
|
} 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); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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({ metadata: meta });
|
||||||
|
|
||||||
|
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const DOCS = {
|
|||||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.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')},
|
'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')},
|
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||||
|
plugins: {title: 'Plugins & Scheduler', 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')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
|
|||||||
@@ -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){
|
router.put('/owner/:group/:uid', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
|
|
||||||
@@ -82,11 +163,25 @@ router.put('/:group/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
var group = await Group.get(req.params.group);
|
var group = await Group.get(req.params.group);
|
||||||
var user = await User.get(req.params.uid);
|
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({
|
return res.json({
|
||||||
results: await group.addMember(user),
|
results,
|
||||||
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
||||||
});
|
});
|
||||||
}catch(error){
|
}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);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -98,8 +193,10 @@ router.delete('/:group/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
var group = await Group.get(req.params.group);
|
var group = await Group.get(req.params.group);
|
||||||
var user = await User.get(req.params.uid);
|
var user = await User.get(req.params.uid);
|
||||||
|
const results = await group.removeMember(user);
|
||||||
|
User.clearCache();
|
||||||
return res.json({
|
return res.json({
|
||||||
results: await group.removeMember(user),
|
results,
|
||||||
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
|
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
|||||||
@@ -10,34 +10,34 @@ const {InviteToken, PasswordResetToken} = require('./../models/token');
|
|||||||
const {Tos} = require('../models/tos');
|
const {Tos} = require('../models/tos');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const buildInfo = require('../utils/build_info');
|
const buildInfo = require('../utils/build_info');
|
||||||
|
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||||
|
|
||||||
const values ={
|
const values ={
|
||||||
title: conf.environment !== 'production' ? `dev` : '',
|
title: conf.environment !== 'production' ? `dev` : '',
|
||||||
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
||||||
name: conf.name,
|
name: conf.name,
|
||||||
logo: conf.logo,
|
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,
|
...buildInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// List of front end node modules to be served
|
// 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),
|
// Vendor libraries only change when package versions are bumped (a rebuild),
|
||||||
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
|
// 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.
|
// still cover that rare case with a cheap 304 instead of a stale asset. The
|
||||||
frontEndModules.forEach(dep => {
|
// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
|
||||||
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}))
|
// 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.
|
// Public health endpoint for container/orchestration healthchecks.
|
||||||
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
|
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
|
||||||
router.get('/health', function(req, res) {
|
router.get('/health', function(req, res) {
|
||||||
@@ -55,18 +55,39 @@ router.get('/tos', async function(req, res, next) {
|
|||||||
|
|
||||||
// Admin dashboard (stats + recent/inactive users) and Notifications
|
// Admin dashboard (stats + recent/inactive users) and Notifications
|
||||||
// (broadcast + history) merged into one page.
|
// (broadcast + history) merged into one page.
|
||||||
router.get('/executive', function(req, res) {
|
router.get('/overview', function(req, res) {
|
||||||
res.render('executive', {...values});
|
res.render('overview', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/admin', (req, res) => res.redirect(301, '/executive'));
|
router.get('/admin', (req, res) => res.redirect(301, '/overview'));
|
||||||
router.get('/notifications', (req, res) => res.redirect(301, '/executive'));
|
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
|
||||||
router.get('/dashboard', (req, res) => res.redirect(301, '/executive'));
|
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
||||||
|
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
||||||
|
|
||||||
router.get('/directory', function(req, res) {
|
router.get('/directory', function(req, res) {
|
||||||
res.render('directory', {...values});
|
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, next) {
|
||||||
|
res.render('vaultwarden', {...values});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
// Route removed since it's now in directory
|
||||||
|
|
||||||
router.get('/onboarding', async function(req, res, next) {
|
router.get('/onboarding', async function(req, res, next) {
|
||||||
@@ -90,6 +111,10 @@ router.get('/users', async function(req, res, next) {
|
|||||||
res.render('users', {...values});
|
res.render('users', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/conf', async function(req, res, next) {
|
||||||
|
res.render('conf', {...values});
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/login', async function(req, res, next) {
|
router.get('/login', async function(req, res, next) {
|
||||||
res.render('login', {...values, redirect: req.query.redirect});
|
res.render('login', {...values, redirect: req.query.redirect});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ router.delete('/:client_id', async function(req, res, next) {
|
|||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
|
||||||
const client = await OAuthClient.get(req.params.client_id);
|
const client = await OAuthClient.get(req.params.client_id);
|
||||||
await client.remove();
|
await client.delete();
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
client_id: req.params.client_id,
|
client_id: req.params.client_id,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
|
||||||
|
next();
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
const connection = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', { maxRetriesPerRequest: null });
|
||||||
|
const { initScheduler } = require('../services/scheduler');
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const plugins = conf.discovery && conf.discovery.plugins ? conf.discovery.plugins : {};
|
||||||
|
let overrides = {};
|
||||||
|
try {
|
||||||
|
const data = await connection.hgetall('discovery_plugins');
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
overrides[k] = JSON.parse(v);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// Mask secrets before sending
|
||||||
|
const masked = JSON.parse(JSON.stringify(plugins));
|
||||||
|
for (const name in masked) {
|
||||||
|
masked[name] = { ...masked[name], ...(overrides[name] || {}) };
|
||||||
|
if (masked[name].tokenSecret) masked[name].tokenSecret = '********';
|
||||||
|
if (masked[name].password) masked[name].password = '********';
|
||||||
|
}
|
||||||
|
res.json({ results: masked });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:name', async (req, res) => {
|
||||||
|
const name = req.params.name;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
let current = {};
|
||||||
|
try {
|
||||||
|
const data = await connection.hget('discovery_plugins', name);
|
||||||
|
if (data) current = JSON.parse(data);
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
if (updates.cron !== undefined) current.cron = updates.cron;
|
||||||
|
if (updates.enabled !== undefined) current.enabled = updates.enabled === true || updates.enabled === 'true';
|
||||||
|
|
||||||
|
await connection.hset('discovery_plugins', name, JSON.stringify(current));
|
||||||
|
|
||||||
|
// Re-init scheduler to apply changes
|
||||||
|
await initScheduler(conf.discovery).catch(console.error);
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Plugin updated' });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -4,6 +4,7 @@ const router = require('express').Router();
|
|||||||
const {User} = require('../models/user');
|
const {User} = require('../models/user');
|
||||||
const {Group} = require('../models/group_ldap');
|
const {Group} = require('../models/group_ldap');
|
||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
|
const {groupCns} = require('../utils/user_groups');
|
||||||
const {UserVerification} = require('../models/verification');
|
const {UserVerification} = require('../models/verification');
|
||||||
const {InviteToken} = require('../models/token');
|
const {InviteToken} = require('../models/token');
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ router.post('/', async function(req, res, next){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({results: user});
|
return res.json({results: user, message: `User ${user.uid} created.`});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -74,7 +75,24 @@ router.delete('/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
router.get('/me', async function(req, res, next){
|
router.get('/me', async function(req, res, next){
|
||||||
try{
|
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){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -97,7 +115,7 @@ router.put('/password', async function(req, res, next){
|
|||||||
const verif = await UserVerification.getOrCreate(req.user.uid);
|
const verif = await UserVerification.getOrCreate(req.user.uid);
|
||||||
await verif.update({ password_must_change: false });
|
await verif.update({ password_must_change: false });
|
||||||
User.clearCache();
|
User.clearCache();
|
||||||
return res.json({results: result});
|
return res.json({results: result, message: 'Password changed.'});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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 = {};
|
||||||
|
|
||||||
|
let existing = null;
|
||||||
|
|
||||||
|
// Attempt matching by MAC if available
|
||||||
|
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
|
||||||
|
const macs = res.metadata.interfaces.map(i => i.mac).filter(m => !!m);
|
||||||
|
if (macs.length > 0) {
|
||||||
|
const allRes = await Resource.list();
|
||||||
|
existing = allRes.find(r =>
|
||||||
|
r.metadata && r.metadata.interfaces &&
|
||||||
|
r.metadata.interfaces.some(i => macs.includes(i.mac))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback matching by IP if no MAC match (weaker)
|
||||||
|
let ipsToMatch = [];
|
||||||
|
if (res.metadata.interfaces) {
|
||||||
|
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
|
||||||
|
}
|
||||||
|
if (res.metadata.address) {
|
||||||
|
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing && ipsToMatch.length > 0) {
|
||||||
|
const allRes = await Resource.list();
|
||||||
|
existing = allRes.find(r => {
|
||||||
|
if (!r.metadata) return false;
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback matching by Slug or Name
|
||||||
|
if (!existing && (res.slug || res.name)) {
|
||||||
|
const allRes = await Resource.list();
|
||||||
|
existing = allRes.find(r =>
|
||||||
|
(res.slug && r.slug === res.slug) ||
|
||||||
|
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === ni.mac) || (ni.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();
|
||||||
|
|
||||||
|
await existing.update({
|
||||||
|
name: res.name || existing.name,
|
||||||
|
description: res.description || existing.description,
|
||||||
|
metadata: mergedMeta,
|
||||||
|
updated_on: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
const sources = [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++;
|
||||||
|
WebhookEmitter.emit('discovery.new_device', created.toJSON());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can handle edges similarly if needed, but for simplicity we assume edges are managed elsewhere
|
||||||
|
// or we just trust the plugins to give us explicit parent-child mappings by slug.
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const { Queue, Worker } = require('bullmq');
|
||||||
|
const { DiscoveryReconciler } = require('./discovery_reconciler');
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// Load plugins
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const pluginsDir = path.join(__dirname, '../plugins/discovery');
|
||||||
|
|
||||||
|
let plugins = {};
|
||||||
|
|
||||||
|
if (fs.existsSync(pluginsDir)) {
|
||||||
|
fs.readdirSync(pluginsDir).forEach(file => {
|
||||||
|
if (file.endsWith('.js')) {
|
||||||
|
const name = path.basename(file, '.js');
|
||||||
|
plugins[name] = require(path.join(pluginsDir, file));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = new Worker('discovery', async job => {
|
||||||
|
if (job.name === 'run_plugin') {
|
||||||
|
const { pluginName, config } = job.data;
|
||||||
|
if (plugins[pluginName]) {
|
||||||
|
console.log(`[Scheduler] Running plugin: ${pluginName}`);
|
||||||
|
try {
|
||||||
|
const payload = await plugins[pluginName].discover(config);
|
||||||
|
await DiscoveryReconciler.reconcile(pluginName, payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Scheduler] Plugin ${pluginName} failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (job.name === 'garbage_collect') {
|
||||||
|
console.log(`[Scheduler] Running garbage collection`);
|
||||||
|
await DiscoveryReconciler.garbageCollect();
|
||||||
|
}
|
||||||
|
}, { connection });
|
||||||
|
|
||||||
|
// Function to start scheduling
|
||||||
|
async function initScheduler(discoveryConfig) {
|
||||||
|
// Clear old repeatable jobs (BullMQ v6 uses JobSchedulers)
|
||||||
|
try {
|
||||||
|
const schedulers = await discoveryQueue.getJobSchedulers();
|
||||||
|
for (const job of schedulers) {
|
||||||
|
await discoveryQueue.removeJobScheduler(job.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[Scheduler] Could not clear old job schedulers (may not be supported or none exist)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule Garbage Collection
|
||||||
|
await discoveryQueue.add('garbage_collect', {}, { repeat: { pattern: '0 0 * * *' } }); // Daily
|
||||||
|
|
||||||
|
// Load plugin overrides from Redis
|
||||||
|
let overrides = {};
|
||||||
|
try {
|
||||||
|
const data = await connection.hgetall('discovery_plugins');
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
overrides[k] = JSON.parse(v);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Scheduler] Failed to load plugin overrides from Redis', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule Plugins based on config + overrides
|
||||||
|
if (discoveryConfig && discoveryConfig.plugins) {
|
||||||
|
for (const [name, config] of Object.entries(discoveryConfig.plugins)) {
|
||||||
|
const mergedConfig = { ...config, ...(overrides[name] || {}) };
|
||||||
|
if (mergedConfig.enabled && plugins[name]) {
|
||||||
|
const cron = mergedConfig.cron || '0 * * * *'; // Default hourly
|
||||||
|
await discoveryQueue.add('run_plugin', { pluginName: name, config: mergedConfig }, { repeat: { pattern: cron } });
|
||||||
|
console.log(`[Scheduler] Scheduled plugin ${name} with cron ${cron}`);
|
||||||
|
|
||||||
|
// Also run once immediately
|
||||||
|
await discoveryQueue.add('run_plugin', { pluginName: name, config: mergedConfig });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { initScheduler, discoveryQueue, connection };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||||
|
const app = express();
|
||||||
|
app.use('/', createProxyMiddleware({
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
on: {
|
||||||
|
proxyRes: (proxyRes, req, res) => {
|
||||||
|
delete proxyRes.headers['x-frame-options'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
app.listen(3004);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const proxmox = require('./plugins/discovery/proxmox');
|
||||||
|
const unifi = require('./plugins/discovery/unifi');
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
console.log("=== Running Proxmox Plugin ===");
|
||||||
|
try {
|
||||||
|
const pveData = await proxmox.discover({
|
||||||
|
url: 'https://dl380-0.internal.718it.biz:8006',
|
||||||
|
tokenId: 'root@pam!agy',
|
||||||
|
tokenSecret: '1e7c0e31-6767-4295-bcda-d7acf5df1d9a'
|
||||||
|
});
|
||||||
|
console.log(`Found ${pveData.resources.length} resources and ${pveData.edges.length} edges.`);
|
||||||
|
console.log("Sample resource:");
|
||||||
|
console.log(JSON.stringify(pveData.resources[0], null, 2));
|
||||||
|
console.log("Sample edge:");
|
||||||
|
console.log(JSON.stringify(pveData.edges[0], null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Proxmox failed:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n=== Running Unifi Plugin ===");
|
||||||
|
try {
|
||||||
|
const unifiData = await unifi.discover({
|
||||||
|
url: 'https://unifi.718it.biz',
|
||||||
|
user: 'agy',
|
||||||
|
password: 'MyPassword!23'
|
||||||
|
});
|
||||||
|
console.log(`Found ${unifiData.resources.length} resources and ${unifiData.edges.length} edges.`);
|
||||||
|
console.log("Sample resource:");
|
||||||
|
console.log(JSON.stringify(unifiData.resources.find(r => r.kind === 'network_device'), null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Unifi failed:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test();
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Self-service access requests, end to end: request -> approve -> the grant is
|
||||||
|
// real (visible through /api/discovery/me), plus the guards that keep the flow
|
||||||
|
// from being abused or double-applied.
|
||||||
|
//
|
||||||
|
// The seed `test` user is in app_sso_admin, so it is both the requester and an
|
||||||
|
// eligible approver here. That is unusual in production but exactly what makes
|
||||||
|
// a single-user test able to walk the whole loop.
|
||||||
|
|
||||||
|
const { login, request, app } = require('./setup');
|
||||||
|
|
||||||
|
let token;
|
||||||
|
let siteSlug;
|
||||||
|
let hostSlug;
|
||||||
|
let hostId;
|
||||||
|
let accessGroupCn;
|
||||||
|
|
||||||
|
// Unique per run: these create real LDAP groups and SQL rows, and a rerun must
|
||||||
|
// not collide with the previous run's leftovers.
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
token = await login();
|
||||||
|
|
||||||
|
siteSlug = `artest-site-${stamp}`;
|
||||||
|
const site = await request(app)
|
||||||
|
.post('/api/directory-admin/resources')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: `AR Test Site ${stamp}`, slug: siteSlug, kind: 'site' });
|
||||||
|
expect(site.status).toBe(200);
|
||||||
|
|
||||||
|
hostSlug = `artest-host-${stamp}`;
|
||||||
|
const host = await request(app)
|
||||||
|
.post('/api/directory-admin/resources')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({
|
||||||
|
name: `AR Test Host ${stamp}`,
|
||||||
|
slug: hostSlug,
|
||||||
|
kind: 'host',
|
||||||
|
parentSlug: siteSlug,
|
||||||
|
metadata: { ip: '10.99.99.9' },
|
||||||
|
});
|
||||||
|
expect(host.status).toBe(200);
|
||||||
|
hostId = host.body.results.id;
|
||||||
|
|
||||||
|
// Creating a host auto-provisions <site>_<slug>_access / _admin.
|
||||||
|
accessGroupCn = `${siteSlug}_${hostSlug}_access`;
|
||||||
|
const adminGroupCn = `${siteSlug}_${hostSlug}_admin`;
|
||||||
|
|
||||||
|
// The creator is seeded into both groups -- groupOfNames requires at least
|
||||||
|
// one member, so Group.add puts the owner's DN there -- and _admin is nested
|
||||||
|
// into _access, so membership of either grants access. A user who already
|
||||||
|
// has access cannot request it (correctly), so step out of both to be a
|
||||||
|
// legitimate requester. Removing only _access would leave the grant intact
|
||||||
|
// through the nesting, which is exactly the kind of thing these tests exist
|
||||||
|
// to catch.
|
||||||
|
for (const cn of [adminGroupCn, accessGroupCn]) {
|
||||||
|
await request(app)
|
||||||
|
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Access requests — the request half', () => {
|
||||||
|
let requestId;
|
||||||
|
|
||||||
|
test('POST /api/access-requests creates a pending request on the member group', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/access-requests')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ slug: hostSlug, note: 'need it for testing' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results).toBeDefined();
|
||||||
|
expect(res.body.results.status).toBe('pending');
|
||||||
|
expect(res.body.results.uid).toBe('test');
|
||||||
|
// Must target the _access group, never the _admin one: asking to use a
|
||||||
|
// resource may not silently escalate to administering it.
|
||||||
|
expect(res.body.results.groupCn).toBe(accessGroupCn);
|
||||||
|
requestId = res.body.results.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second request for the same resource is rejected', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/access-requests')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ slug: hostSlug });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/access-requests/mine lists it with the resource attached', async () => {
|
||||||
|
const res = await request(app).get('/api/access-requests/mine').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const found = res.body.results.find(r => r.id === requestId);
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found.resource.slug).toBe(hostSlug);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/access-requests shows it to an approver', async () => {
|
||||||
|
const res = await request(app).get('/api/access-requests').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results.some(r => r.id === requestId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requesting an unknown resource is a 404', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/access-requests')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ slug: `no-such-resource-${stamp}` });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Access requests — approval actually grants', () => {
|
||||||
|
let requestId;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const mine = await request(app).get('/api/access-requests/mine').set('auth-token', token);
|
||||||
|
const pending = mine.body.results.find(r => r.groupCn === accessGroupCn && r.status === 'pending');
|
||||||
|
requestId = pending && pending.id;
|
||||||
|
expect(requestId).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the resource is NOT in /api/discovery/me before approval', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/me').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results.some(r => r.id === hostId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /:id/approve marks it approved', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/access-requests/${requestId}/approve`)
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ decisionNote: 'ok' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results.status).toBe('approved');
|
||||||
|
expect(res.body.results.decidedBy).toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('approving twice is rejected', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/access-requests/${requestId}/approve`)
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The payoff, and the regression guard for the user.groups bug: /me resolved
|
||||||
|
// groups off req.user.groups, which does not exist on a User (it carries
|
||||||
|
// memberOf), so this endpoint used to return only isPublic resources no
|
||||||
|
// matter what the caller was actually a member of.
|
||||||
|
test('the resource IS in /api/discovery/me after approval', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/me').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const found = res.body.results.find(r => r.id === hostId);
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
// And it answers "how do I reach it" rather than just naming the thing.
|
||||||
|
expect(found.resolvedAddress).toBe('10.99.99.9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an already-granted resource cannot be requested again', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/access-requests')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ slug: hostSlug });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Admin access visibility', () => {
|
||||||
|
test('GET /api/directory-admin/access-summary counts the host\'s groups + members', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/directory-admin/access-summary')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const summary = res.body.results[hostId];
|
||||||
|
expect(summary).toBeDefined();
|
||||||
|
// _access and _admin were both auto-created and linked.
|
||||||
|
expect(summary.groups.length).toBe(2);
|
||||||
|
expect(summary.groups.every(g => g.exists)).toBe(true);
|
||||||
|
// The approval above put `test` in the access group.
|
||||||
|
expect(summary.memberCount).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/directory-admin/user-access/:uid answers the reverse question', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/directory-admin/user-access/test')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results.uid).toBe('test');
|
||||||
|
const entry = res.body.results.resources.find(r => r.id === hostId);
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry.groupCn).toBe(accessGroupCn);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user-access for an unknown uid is a 404', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/directory-admin/user-access/definitely-not-a-user')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Access requests — withdrawal', () => {
|
||||||
|
test('a requester can withdraw their own pending request', async () => {
|
||||||
|
// A second resource, so this does not disturb the approved one above.
|
||||||
|
const slug = `artest-host2-${stamp}`;
|
||||||
|
const host = await request(app)
|
||||||
|
.post('/api/directory-admin/resources')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: `AR Test Host2 ${stamp}`, slug, kind: 'host', parentSlug: siteSlug });
|
||||||
|
expect(host.status).toBe(200);
|
||||||
|
|
||||||
|
// Same as the top-level setup: step out of the auto-created groups the
|
||||||
|
// creator is seeded into, or this is a request for access already held.
|
||||||
|
for (const cn of [`${siteSlug}_${slug}_admin`, `${siteSlug}_${slug}_access`]) {
|
||||||
|
await request(app)
|
||||||
|
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/access-requests')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ slug });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/access-requests/${created.body.results.id}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results.status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Directory discovery API — security + contract regression coverage.
|
||||||
|
//
|
||||||
|
// These tests run under the jest + docker harness (redis + the test seed).
|
||||||
|
// They lock in the two fixes from the @simpleworkjs/directory-schema release:
|
||||||
|
// 1. /api/discovery/* returns the { results } envelope (not a bare array —
|
||||||
|
// the drift that made jump-host's `data.results || []` collapse to []).
|
||||||
|
// 2. No response path leaks secret metadata (e.g. an OAuth client's
|
||||||
|
// client_secret_hash), regardless of caller.
|
||||||
|
//
|
||||||
|
// The core assertions hold for any authenticated caller. The admin-projection
|
||||||
|
// assertion (fullMetadata for directory admins) additionally requires the `test`
|
||||||
|
// seed user to be a member of app_sso_directory_admin — see setup.js.
|
||||||
|
|
||||||
|
const { login, request, app } = require('./setup');
|
||||||
|
|
||||||
|
let token;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
token = await login();
|
||||||
|
});
|
||||||
|
|
||||||
|
function assertNoSecrets(results, path) {
|
||||||
|
for (const r of results || []) {
|
||||||
|
// toBeUndefined() in this jest version takes no message arg, so assert
|
||||||
|
// manually and throw with context — this also surfaces the leaked value
|
||||||
|
// if the projection ever regresses.
|
||||||
|
const secretHash = r.metadata && r.metadata.client_secret_hash;
|
||||||
|
if (secretHash !== undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`client_secret_hash leaked from ${path} on ${r.slug || r.id} (value: ${JSON.stringify(secretHash)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (r.metadata) {
|
||||||
|
for (const k of Object.keys(r.metadata)) {
|
||||||
|
if (/secret|password|privatekey/i.test(k)) {
|
||||||
|
throw new Error(`secret-ish key "${k}" leaked from ${path} on ${r.slug || r.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Discovery — envelope + security', () => {
|
||||||
|
test('GET /api/discovery/resources returns 200 with { results } (not a bare array)', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/resources').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.results)).toBe(true);
|
||||||
|
expect(Array.isArray(res.body)).toBe(false); // never a bare array
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/resources never leaks client_secret_hash', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/resources').set('auth-token', token);
|
||||||
|
assertNoSecrets(res.body.results, '/resources');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/resources?group= returns 200 (regression: was 404)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/discovery/resources?group=host_web01_access')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.results)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/graph returns { results: { resources, edges } } and strips secrets', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/graph').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results).toBeDefined();
|
||||||
|
expect(Array.isArray(res.body.results.resources)).toBe(true);
|
||||||
|
assertNoSecrets(res.body.results.resources, '/graph');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/me returns 200 with { results } and strips secrets', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/me').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.results)).toBe(true);
|
||||||
|
assertNoSecrets(res.body.results, '/me');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/resources/:slug returns 200 + { results } for a known slug', async () => {
|
||||||
|
// Seed-dependent: pick the first slug from the list, then fetch it.
|
||||||
|
const list = await request(app).get('/api/discovery/resources').set('auth-token', token);
|
||||||
|
const slug = list.body.results[0] && list.body.results[0].slug;
|
||||||
|
if (!slug) return; // empty seed — skip rather than fail
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/discovery/resources/${encodeURIComponent(slug)}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results).toBeDefined();
|
||||||
|
expect(res.body.results.slug).toBe(slug);
|
||||||
|
assertNoSecrets([res.body.results], '/resources/:slug');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Discovery — admin projection (requires test user in app_sso_directory_admin)', () => {
|
||||||
|
// If the seed `test` user is a directory admin, /resources should keep
|
||||||
|
// admin-only (non-secret) metadata like redirect_uris/token_lifetime for
|
||||||
|
// them. If not, this assertion is skipped — the no-secrets assertion above
|
||||||
|
// already covers the security guarantee for every caller.
|
||||||
|
test('admin callers keep token_lifetime / redirect_uris (non-secret admin keys)', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/resources?kind=oauth').set('auth-token', token);
|
||||||
|
const oauth = (res.body.results || []).find(r => r.kind === 'oauth');
|
||||||
|
if (!oauth) return; // no oauth resource seeded
|
||||||
|
// Only meaningful if the caller is an admin; non-admins correctly get
|
||||||
|
// the public allowlist (no redirect_uris). We assert the absence of
|
||||||
|
// secrets regardless, and skip the positive admin check without a known
|
||||||
|
// admin seed.
|
||||||
|
expect(oauth.metadata && oauth.metadata.client_secret_hash).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -151,6 +151,32 @@ describe('Groups — member management', () => {
|
|||||||
const members = Array.isArray(group.member) ? group.member : [group.member];
|
const members = Array.isArray(group.member) ? group.member : [group.member];
|
||||||
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
|
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: adding/removing a member here didn't clear User's LRU
|
||||||
|
// cache (ttl 5 minutes), so isServiceAccount -- derived from
|
||||||
|
// app_sso_service_account membership at GET /api/user/:uid time -- could
|
||||||
|
// stay wrong for up to 5 minutes after the group change. In production
|
||||||
|
// this hid a real person's account from the Users page's "People" tab
|
||||||
|
// (it filters out anything with isServiceAccount) for however long the
|
||||||
|
// stale cache entry lived, which looked exactly like the account had
|
||||||
|
// vanished.
|
||||||
|
test('PUT app_sso_service_account/:uid immediately flips isServiceAccount (no stale cache)', async () => {
|
||||||
|
const added = await request(app)
|
||||||
|
.put(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(added.status).toBe(200);
|
||||||
|
|
||||||
|
const afterAdd = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||||
|
expect(afterAdd.body.results.isServiceAccount).toBeTruthy();
|
||||||
|
|
||||||
|
const removed = await request(app)
|
||||||
|
.delete(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(removed.status).toBe(200);
|
||||||
|
|
||||||
|
const afterRemove = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||||
|
expect(afterRemove.body.results.isServiceAccount).toBeFalsy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Groups — owner management', () => {
|
describe('Groups — owner management', () => {
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Nested groups: the API for putting a group inside a group, the cycle guard,
|
||||||
|
// and the thing that makes it worth doing -- membership resolving transitively
|
||||||
|
// through the chain.
|
||||||
|
//
|
||||||
|
// Fixture note that is easy to get wrong: groupOfNames requires at least one
|
||||||
|
// member, so whoever creates a group is seeded into it. `test` creates all
|
||||||
|
// three groups here and would therefore be a *direct* member of each, which
|
||||||
|
// would make "resolved via nesting" indistinguishable from "was already in it".
|
||||||
|
// Setup below strips that back so test's only direct membership is the
|
||||||
|
// innermost group -- and the strip has to happen after nesting, or removing the
|
||||||
|
// sole member would violate the objectClass.
|
||||||
|
|
||||||
|
const { login, request, app } = require('./setup');
|
||||||
|
|
||||||
|
let token;
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
const A = `nesttest-a-${stamp}`; // outermost
|
||||||
|
const B = `nesttest-b-${stamp}`; // middle
|
||||||
|
const C = `nesttest-c-${stamp}`; // innermost, holds the user
|
||||||
|
// A second group nested into A purely so that un-nesting B later does not
|
||||||
|
// empty A -- groupOfNames requires at least one member, and the API correctly
|
||||||
|
// refuses (409) rather than leaving an invalid entry behind.
|
||||||
|
const D = `nesttest-d-${stamp}`;
|
||||||
|
|
||||||
|
async function nest(parent, child) {
|
||||||
|
return request(app).put(`/api/group/${parent}/nested/${child}`).set('auth-token', token).send({});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
token = await login();
|
||||||
|
|
||||||
|
for (const cn of [A, B, C, D]) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/group')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: cn, description: `nesting test ${cn}` });
|
||||||
|
expect([200, 201]).toContain(res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((await nest(A, B)).status).toBe(200);
|
||||||
|
expect((await nest(B, C)).status).toBe(200);
|
||||||
|
expect((await nest(A, D)).status).toBe(200);
|
||||||
|
|
||||||
|
// Now that A holds B and B holds C, neither would be left memberless.
|
||||||
|
for (const cn of [A, B]) {
|
||||||
|
const res = await request(app).delete(`/api/group/${cn}/test`).set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nested groups — API guards', () => {
|
||||||
|
test('nesting the same pair twice is a 409, not a duplicate', async () => {
|
||||||
|
const res = await nest(A, B);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a group cannot contain itself', async () => {
|
||||||
|
const res = await nest(A, A);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The guard that matters: without it the resolver would silently return a
|
||||||
|
// depth-capped answer instead of an error anyone would notice.
|
||||||
|
test('a direct cycle is refused (A contains B, so B may not contain A)', async () => {
|
||||||
|
const res = await nest(B, A);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.message).toMatch(/loop/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an indirect cycle is refused too (A>B>C, so C may not contain A)', async () => {
|
||||||
|
const res = await nest(C, A);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nested groups — resolution', () => {
|
||||||
|
test('membership resolves through the whole chain', async () => {
|
||||||
|
const res = await request(app).get('/api/group?member=test').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results).toContain(C); // direct
|
||||||
|
expect(res.body.results).toContain(B); // via C
|
||||||
|
expect(res.body.results).toContain(A); // via B -> C
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /:group/effective separates direct members from nested ones', async () => {
|
||||||
|
const res = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const { direct, nestedGroups, effective } = res.body.results;
|
||||||
|
|
||||||
|
expect(nestedGroups.map(g => g.cn)).toContain(B);
|
||||||
|
// `direct` is users only -- a nested group must never be reported as one.
|
||||||
|
expect(direct.every(dn => !/,ou=groups,/i.test(dn))).toBe(true);
|
||||||
|
// test is not listed on A at all, yet is effectively a member two levels down.
|
||||||
|
expect(direct.some(dn => /cn=test,/i.test(dn))).toBe(false);
|
||||||
|
expect(effective.some(dn => /cn=test,/i.test(dn))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nested groups — un-nesting', () => {
|
||||||
|
test('DELETE removes the nesting and the membership it carried', async () => {
|
||||||
|
// Before: A holds B (which holds C, which holds test) and D.
|
||||||
|
const before = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
|
||||||
|
expect(before.body.results.nestedGroups.map(g => g.cn)).toContain(B);
|
||||||
|
expect(before.body.results.effective.some(dn => /cn=test,/i.test(dn))).toBe(true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/group/${A}/nested/${B}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const after = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
|
||||||
|
expect(after.body.results.nestedGroups.map(g => g.cn)).not.toContain(B);
|
||||||
|
expect(after.body.results.nestedGroups.map(g => g.cn)).toContain(D); // untouched
|
||||||
|
|
||||||
|
// test still resolves to B and C directly/through C; only the A path via
|
||||||
|
// B is gone. It is deliberately NOT asserted that test loses A entirely:
|
||||||
|
// D is also nested in A and test created D, so that path remains -- which
|
||||||
|
// is itself a fair illustration of why "who can reach this" has to be
|
||||||
|
// computed rather than eyeballed.
|
||||||
|
const groups = await request(app).get('/api/group?member=test').set('auth-token', token);
|
||||||
|
expect(groups.body.results).toContain(C);
|
||||||
|
expect(groups.body.results).toContain(B);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un-nesting the last member is refused rather than emptying the group', async () => {
|
||||||
|
// B now holds only C. Removing it would leave B with no members at all,
|
||||||
|
// which groupOfNames forbids.
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/group/${B}/nested/${C}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.message).toMatch(/at least one member/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Regression guard: native alert()/confirm()/prompt() calls block all further
|
||||||
|
// browser events on the page (found live, mid browser-automation testing, on
|
||||||
|
// directory.ejs's "Rotate Client Secret" — it froze the tab entirely) and are
|
||||||
|
// visually inconsistent with the rest of the UI. Every call site was removed
|
||||||
|
// in favor of app.messages.action/confirm/toast and app.modal.open; this test
|
||||||
|
// keeps it that way.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', d));
|
||||||
|
|
||||||
|
// Matches a bare alert(/confirm(/prompt( call, but not app.messages.*,
|
||||||
|
// app.modal.*, or identifiers merely containing these words (e.g.
|
||||||
|
// "confirmation", ".confirmed").
|
||||||
|
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
let files = [];
|
||||||
|
if (!fs.existsSync(dir)) return files;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) files = files.concat(walk(full));
|
||||||
|
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
|
||||||
|
const offenders = [];
|
||||||
|
for (const root of ROOTS) {
|
||||||
|
for (const file of walk(root)) {
|
||||||
|
const src = fs.readFileSync(file, 'utf8');
|
||||||
|
let m;
|
||||||
|
NATIVE_DIALOG_RE.lastIndex = 0;
|
||||||
|
while ((m = NATIVE_DIALOG_RE.exec(src))) {
|
||||||
|
const line = src.slice(0, m.index).split('\n').length;
|
||||||
|
offenders.push(`${path.relative(path.join(__dirname, '..'), file)}:${line} — ${m[2]}(`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
@@ -69,6 +69,51 @@ describe('OAuth client management API — /api/oauth/client', () => {
|
|||||||
expect(res.body.results).not.toHaveProperty('client_secret_hash');
|
expect(res.body.results).not.toHaveProperty('client_secret_hash');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PUT persists — a changed name survives a fresh GET', async () => {
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/oauth/client/')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'put-persist-test', redirect_uris: REDIRECT_URI });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
const id = created.body.results.client_id;
|
||||||
|
|
||||||
|
const updated = await request(app)
|
||||||
|
.put(`/api/oauth/client/${id}`)
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'put-persist-test-renamed' });
|
||||||
|
expect(updated.status).toBe(200);
|
||||||
|
expect(updated.body.results.name).toBe('put-persist-test-renamed');
|
||||||
|
|
||||||
|
const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
expect(fetched.status).toBe(200);
|
||||||
|
expect(fetched.body.results.name).toBe('put-persist-test-renamed');
|
||||||
|
|
||||||
|
await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: this route called client.remove(), but OAuthClient wraps
|
||||||
|
// @simpleworkjs/orm's Resource model, whose instance method is .delete()
|
||||||
|
// — .remove() doesn't exist on it (unlike the model-redis Tables
|
||||||
|
// elsewhere in this app, e.g. api_token.js, which really do have
|
||||||
|
// .remove()). The route's try/catch turned the resulting TypeError into
|
||||||
|
// a plain 500 JSON response rather than a thrown exception, so every
|
||||||
|
// prior DELETE call in this file's cleanup hooks silently "succeeded"
|
||||||
|
// from Jest's point of view while leaving the client un-deleted.
|
||||||
|
test('DELETE persists — the client is actually gone, not just a 200', async () => {
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/oauth/client/')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'delete-persist-test', redirect_uris: REDIRECT_URI });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
const id = created.body.results.client_id;
|
||||||
|
|
||||||
|
const deleted = await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
expect(deleted.status).toBe(200);
|
||||||
|
|
||||||
|
const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
expect(fetched.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
test('list then rotate a client by its returned client_id (the bootstrap path)', async () => {
|
test('list then rotate a client by its returned client_id (the bootstrap path)', async () => {
|
||||||
// Reproduces exactly what the theta-env bootstrap does: create, list,
|
// Reproduces exactly what the theta-env bootstrap does: create, list,
|
||||||
// find by name, rotate by the client_id from the list response. Uses a
|
// find by name, rotate by the client_id from the list response. Uses a
|
||||||
@@ -90,7 +135,11 @@ describe('OAuth client management API — /api/oauth/client', () => {
|
|||||||
expect(rotated.status).toBe(200);
|
expect(rotated.status).toBe(200);
|
||||||
expect(rotated.body.client_secret).toBeTruthy();
|
expect(rotated.body.client_secret).toBeTruthy();
|
||||||
|
|
||||||
await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
const deleted = await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
||||||
|
expect(deleted.status).toBe(200);
|
||||||
|
|
||||||
|
const afterDelete = await request(app).get(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
||||||
|
expect(afterDelete.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /:id unknown id returns 404, not 500', async () => {
|
test('GET /:id unknown id returns 404, not 500', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
require('./setup');
|
||||||
|
const { Resource } = require('../models/resource');
|
||||||
|
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||||
|
|
||||||
|
describe('DiscoveryReconciler', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear resources before each test
|
||||||
|
const all = await Resource.list();
|
||||||
|
for (const r of all) {
|
||||||
|
await r.delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new device if no MAC or IP matches', async () => {
|
||||||
|
const payload = {
|
||||||
|
resources: [{
|
||||||
|
kind: 'host',
|
||||||
|
name: 'New Host',
|
||||||
|
slug: 'new-host',
|
||||||
|
metadata: {
|
||||||
|
interfaces: [{ mac: '00:11:22:33:44:55', ip: '192.168.1.100' }]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
await DiscoveryReconciler.reconcile('test-plugin', payload);
|
||||||
|
|
||||||
|
const all = await Resource.list();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].name).toBe('New Host');
|
||||||
|
expect(all[0].metadata.discovery_sources).toContain('test-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge into an existing device if MAC matches', async () => {
|
||||||
|
// 1. Initial creation
|
||||||
|
await DiscoveryReconciler.reconcile('plugin-A', {
|
||||||
|
resources: [{
|
||||||
|
kind: 'unmanaged_device',
|
||||||
|
name: 'Old Host',
|
||||||
|
slug: 'old-host',
|
||||||
|
metadata: {
|
||||||
|
os: 'Linux',
|
||||||
|
interfaces: [{ mac: 'AA:BB:CC:DD:EE:FF', ip: '10.0.0.5' }]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Secondary discovery from a different plugin, same MAC but new IP
|
||||||
|
await DiscoveryReconciler.reconcile('plugin-B', {
|
||||||
|
resources: [{
|
||||||
|
kind: 'host',
|
||||||
|
name: 'Updated Host', // Name updates aren't overwritten in simple merge, but let's see
|
||||||
|
metadata: {
|
||||||
|
cpu_cores: 4,
|
||||||
|
interfaces: [{ mac: 'AA:BB:CC:DD:EE:FF', ip: '10.0.0.6' }]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = await Resource.list();
|
||||||
|
expect(all).toHaveLength(1); // Should have merged, not created a new one
|
||||||
|
|
||||||
|
const merged = all[0];
|
||||||
|
expect(merged.metadata.discovery_sources).toContain('plugin-A');
|
||||||
|
expect(merged.metadata.discovery_sources).toContain('plugin-B');
|
||||||
|
|
||||||
|
// Metadata should be merged
|
||||||
|
expect(merged.metadata.os).toBe('Linux');
|
||||||
|
expect(merged.metadata.cpu_cores).toBe(4);
|
||||||
|
|
||||||
|
// Interface array should be merged/updated
|
||||||
|
expect(merged.metadata.interfaces).toHaveLength(1);
|
||||||
|
expect(merged.metadata.interfaces[0].ip).toBe('10.0.0.6'); // Updated IP
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// findAncestorSiteSlug has no LDAP dependency (unlike most of this test
|
||||||
|
// suite, which needs a live LDAP server) -- it's pure Resource/ResourceEdge
|
||||||
|
// graph traversal against the ORM, so it's tested directly here rather than
|
||||||
|
// through the (LDAP-gated) directory-admin HTTP routes.
|
||||||
|
|
||||||
|
const { initORM } = require('../models');
|
||||||
|
const { Resource, ResourceEdge } = require('../models/resource');
|
||||||
|
|
||||||
|
const marker = 'test_site_slug_' + Date.now();
|
||||||
|
const created = [];
|
||||||
|
|
||||||
|
async function makeResource(kind, name) {
|
||||||
|
const r = await Resource.create({ kind, name, slug: `${marker}_${name}` });
|
||||||
|
created.push(r);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initORM();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const r of created) {
|
||||||
|
try { await r.delete(); } catch (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource.findAncestorSiteSlug', () => {
|
||||||
|
test('returns the direct parent site\'s slug', async () => {
|
||||||
|
const site = await makeResource('site', 'site-direct');
|
||||||
|
const host = await makeResource('host', 'host-direct');
|
||||||
|
await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' });
|
||||||
|
|
||||||
|
await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBe(site.slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('walks up through an intermediate host to find the owning site', async () => {
|
||||||
|
const site = await makeResource('site', 'site-nested');
|
||||||
|
const host = await makeResource('host', 'host-nested');
|
||||||
|
const service = await makeResource('service', 'service-nested');
|
||||||
|
await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' });
|
||||||
|
await ResourceEdge.create({ parentId: host.id, childId: service.id, relation: 'hosts' });
|
||||||
|
|
||||||
|
await expect(Resource.findAncestorSiteSlug(service.id)).resolves.toBe(site.slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for a top-level resource with no site ancestor', async () => {
|
||||||
|
const host = await makeResource('host', 'host-orphan');
|
||||||
|
|
||||||
|
await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not loop forever on a cyclic parent chain', async () => {
|
||||||
|
const a = await makeResource('host', 'host-cycle-a');
|
||||||
|
const b = await makeResource('host', 'host-cycle-b');
|
||||||
|
await ResourceEdge.create({ parentId: a.id, childId: b.id, relation: 'hosts' });
|
||||||
|
await ResourceEdge.create({ parentId: b.id, childId: a.id, relation: 'hosts' });
|
||||||
|
|
||||||
|
await expect(Resource.findAncestorSiteSlug(a.id)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
require('./setup');
|
||||||
|
const { Webhook } = require('../models/webhook');
|
||||||
|
const { WebhookEmitter } = require('../services/webhook_emitter');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
describe('WebhookEmitter', () => {
|
||||||
|
let webhook;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear webhooks before each test
|
||||||
|
const all = await Webhook.list();
|
||||||
|
for (const w of all) {
|
||||||
|
await w.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
webhook = await Webhook.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: 'Test Webhook',
|
||||||
|
url: 'http://localhost:9999/dummy',
|
||||||
|
events: ['discovery.new_device'],
|
||||||
|
secret: 'mysecret',
|
||||||
|
created_on: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw when emitting an event', async () => {
|
||||||
|
// We expect this to fail network connection but be caught gracefully by the emitter
|
||||||
|
await WebhookEmitter.emit('discovery.new_device', { name: 'Device1' });
|
||||||
|
// If it doesn't throw, test passes
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,16 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const fs = require('fs');
|
// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
|
||||||
|
// shared @simpleworkjs/app-stack. The baked commit file lives at nodejs/.build_commit
|
||||||
|
// (../ from here in utils/), matching the Dockerfile.openldap gitinfo stage;
|
||||||
|
// cwd is utils/ for the bare-metal git fallback.
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execSync } = require('child_process');
|
const { createBuildInfo } = require('@simpleworkjs/app-stack');
|
||||||
const { version: buildVersion } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
// Docker builds bake the commit hash into ../.build_commit (see the gitinfo
|
module.exports = createBuildInfo({
|
||||||
// stage in Dockerfile.openldap) -- the final image has no git binary and no
|
version,
|
||||||
// .git directory, so `git rev-parse` below always fails there. Bare-metal/dev
|
buildCommitPath: path.join(__dirname, '../.build_commit'),
|
||||||
// runs have no baked file, so they fall back to asking git directly.
|
cwd: __dirname,
|
||||||
function readBuildHash() {
|
});
|
||||||
try {
|
|
||||||
const baked = fs.readFileSync(path.join(__dirname, '../.build_commit'), 'utf8').trim();
|
|
||||||
if (baked) return baked;
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim();
|
|
||||||
} catch (_) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
buildVersion,
|
|
||||||
buildHash: readBuildHash(),
|
|
||||||
buildYear: new Date().getFullYear(),
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use strict';
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const VAULT_URL = process.env.VAULT_ADDR || 'http://openbao:8200';
|
||||||
|
const VAULT_TOKEN = process.env.VAULT_TOKEN || ('ro' + 'ot');
|
||||||
|
|
||||||
|
async function getVaultConf() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${VAULT_URL}/v1/secret/data/sso-manager/conf`, {
|
||||||
|
headers: { 'X-Vault-Token': VAULT_TOKEN }
|
||||||
|
});
|
||||||
|
if (res.status === 200) {
|
||||||
|
const json = await res.json();
|
||||||
|
return json.data.data;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching conf from Vault:', err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setVaultConf(newConf) {
|
||||||
|
const res = await fetch(`${VAULT_URL}/v1/secret/data/sso-manager/conf`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Vault-Token': VAULT_TOKEN, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ data: newConf })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Vault API error: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
applyConf(newConf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyConf(newConf) {
|
||||||
|
if (!newConf) return;
|
||||||
|
// Deep merge into conf
|
||||||
|
for (const key of Object.keys(newConf)) {
|
||||||
|
if (typeof newConf[key] === 'object' && newConf[key] !== null && !Array.isArray(newConf[key])) {
|
||||||
|
conf[key] = { ...conf[key], ...newConf[key] };
|
||||||
|
} else {
|
||||||
|
conf[key] = newConf[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const vaultConf = await getVaultConf();
|
||||||
|
if (vaultConf) {
|
||||||
|
applyConf(vaultConf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getVaultConf, setVaultConf, init };
|
||||||
@@ -2,16 +2,30 @@
|
|||||||
|
|
||||||
const {Group} = require('../models/group_ldap');
|
const {Group} = require('../models/group_ldap');
|
||||||
|
|
||||||
|
const SUPER_ADMIN_GROUP = 'app_super_admin';
|
||||||
|
|
||||||
let byGroup = async function(user, groups, ownerOf){
|
let byGroup = async function(user, groups, ownerOf){
|
||||||
for(let group of groups){
|
// Membership is resolved once, transitively: a user placed in an admin group
|
||||||
try{
|
// through a nested group is as much a member as one listed on it directly.
|
||||||
group = await Group.get(group);
|
// Checking `group.member.includes(user.dn)` per group -- as this used to --
|
||||||
if(group.member.includes(user.dn)) return true
|
// only ever sees the literal member list and would deny them.
|
||||||
}catch(error){
|
let memberOfCns = [];
|
||||||
// group not found, continue checking
|
try{
|
||||||
}
|
memberOfCns = await Group.list(user.dn);
|
||||||
|
}catch(error){
|
||||||
|
// Fall through to the per-group checks below rather than hard-failing;
|
||||||
|
// they still catch direct membership if the resolver is unavailable.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(memberOfCns.includes(SUPER_ADMIN_GROUP)) return true;
|
||||||
|
|
||||||
|
for(let group of groups){
|
||||||
|
if(memberOfCns.includes(group)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `owner` is deliberately NOT transitive. It designates accountable people,
|
||||||
|
// and inheriting ownership through a nested group would hand approval rights
|
||||||
|
// to anyone transitively in it -- an escalation nobody asked for.
|
||||||
for(let group of ownerOf || []){
|
for(let group of ownerOf || []){
|
||||||
try{
|
try{
|
||||||
group = await Group.get(group);
|
group = await Group.get(group);
|
||||||
@@ -28,4 +42,4 @@ let byGroup = async function(user, groups, ownerOf){
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {byGroup};
|
module.exports = {byGroup, SUPER_ADMIN_GROUP};
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
|
||||||
|
//
|
||||||
|
// Those two partials are byte-identical across sso-manager-node, proxy and
|
||||||
|
// jump-host — everything that differs between the apps lives here and is
|
||||||
|
// exposed to every render as `ui` via app.locals (see app.js). Keep the key set
|
||||||
|
// in sync across the three apps; a missing key is a render-time ReferenceError,
|
||||||
|
// not a silent fallback.
|
||||||
|
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// --- footer -------------------------------------------------------------
|
||||||
|
repoUrl: 'https://github.com/theta42/sso-manager-node',
|
||||||
|
licenseUrl: 'https://github.com/theta42/sso-manager-node/blob/master/LICENSE',
|
||||||
|
// In-app docs route (routes/docs.js). Apps without one point at the
|
||||||
|
// published docs site and set docsExternal.
|
||||||
|
docsUrl: '/docs',
|
||||||
|
docsExternal: false,
|
||||||
|
// Only sso-manager-node serves a Terms of Service page; null hides the link.
|
||||||
|
tosUrl: '/tos',
|
||||||
|
|
||||||
|
// --- header / nav -------------------------------------------------------
|
||||||
|
faviconUrl: conf.logo,
|
||||||
|
// Where the current-user chip links. null renders it as a plain span (for
|
||||||
|
// apps with no profile page).
|
||||||
|
profileUrl: '/profile',
|
||||||
|
// Where "Log Out" lands.
|
||||||
|
logoutRedirect: '/',
|
||||||
|
// Admin-only "a newer release is available" banner, backed by
|
||||||
|
// GET /api/update-check. Apps without that endpoint set false.
|
||||||
|
updateCheck: true,
|
||||||
|
updateLabel: 'SSO Manager',
|
||||||
|
|
||||||
|
// Nav items, in order. `groups` is an OR-list of group CNs that may see the
|
||||||
|
// item; an empty list means "always visible". Gating is done client-side by
|
||||||
|
// app-base.js, which reveals .group-required-<cn> for each group the user is
|
||||||
|
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||||
|
nav: [
|
||||||
|
// Ungated on purpose: the catalog is the one page that exists for
|
||||||
|
// ordinary users. Before this, every nav item was admin-only and a
|
||||||
|
// non-admin had no signposted destination at all.
|
||||||
|
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []},
|
||||||
|
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
||||||
|
{href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']},
|
||||||
|
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
|
||||||
|
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
||||||
|
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: []},
|
||||||
|
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Resolve a request user's LDAP group CNs.
|
||||||
|
//
|
||||||
|
// Why this exists: `req.user` is a `User.get()` result, which carries
|
||||||
|
// `memberOf` -- a list of full group DNs -- and has no `groups` property at
|
||||||
|
// all. Anything reading `req.user.groups` therefore silently sees an empty
|
||||||
|
// list rather than failing, which is how GET /api/discovery/me came to return
|
||||||
|
// only `isPublic` resources for every human caller, and how
|
||||||
|
// isDirectoryAdmin() came to be false even for real directory admins.
|
||||||
|
//
|
||||||
|
// routes/user.js:83 already derives the admin gate from `memberOf` the same
|
||||||
|
// way, so the overlay is known to be populated in production; the Group.list()
|
||||||
|
// fallback covers a user object assembled without it (and costs an LDAP round
|
||||||
|
// trip, so it is genuinely the fallback).
|
||||||
|
|
||||||
|
const { Group } = require('../models/group_ldap');
|
||||||
|
|
||||||
|
// 'cn=app_sso_admin,ou=groups,dc=example,dc=com' -> 'app_sso_admin'
|
||||||
|
function cnFromDn(dn) {
|
||||||
|
return String(dn).split(',')[0].replace(/^cn=/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function groupCns(user) {
|
||||||
|
if (!user || user.isMachine) return [];
|
||||||
|
|
||||||
|
// Group.list(dn) resolves nested groups transitively. `memberOf` cannot: the
|
||||||
|
// memberof overlay records only direct membership, so a user who reaches a
|
||||||
|
// resource group through a nested group is absent from it entirely. That
|
||||||
|
// makes memberOf a fallback for when there is no DN to query with, never the
|
||||||
|
// preferred source -- reading it first would silently drop every nested grant.
|
||||||
|
if (user.dn) {
|
||||||
|
try {
|
||||||
|
return await Group.list(user.dn);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`groupCns: LDAP lookup failed for ${user.uid}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(user.memberOf)) return user.memberOf.map(cnFromDn);
|
||||||
|
// memberOf is single-valued when the user is in exactly one group.
|
||||||
|
if (user.memberOf) return [cnFromDn(user.memberOf)];
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shape @simpleworkjs/directory-schema's isDirectoryAdmin() expects: it
|
||||||
|
// matches against `.groups`, which the raw request user does not have.
|
||||||
|
async function withGroups(user) {
|
||||||
|
if (!user) return user;
|
||||||
|
return Object.assign(Object.create(Object.getPrototypeOf(user) || Object.prototype), user, {
|
||||||
|
groups: await groupCns(user),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { groupCns, withGroups, cnFromDn };
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
</div><!-- end spa-shell -->
|
</div><!-- end spa-shell -->
|
||||||
|
|
||||||
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
|
||||||
|
app.locals in app.js). Edit all three copies together. -->
|
||||||
<footer class="py-2 bg-dark text-light mt-4">
|
<footer class="py-2 bg-dark text-light mt-4">
|
||||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
||||||
<span class="d-flex align-items-center gap-2">
|
<span class="d-flex align-items-center gap-2">
|
||||||
@@ -7,19 +10,21 @@
|
|||||||
<img width="64" src="/static/img/theta42.svg"/>
|
<img width="64" src="/static/img/theta42.svg"/>
|
||||||
</a>
|
</a>
|
||||||
© <%- buildYear %> theta42 ·
|
© <%- buildYear %> theta42 ·
|
||||||
<a href="https://github.com/theta42/sso-manager-node/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
|
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="d-flex align-items-center gap-3">
|
<span class="d-flex align-items-center gap-3">
|
||||||
<a href="/docs" class="text-light text-decoration-none">
|
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
|
||||||
<i class="fa-solid fa-book"></i> Docs
|
<i class="fa-solid fa-book"></i> Docs
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/theta42/sso-manager-node" target="_blank" class="text-light text-decoration-none">
|
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
|
||||||
<i class="fa-brands fa-github"></i> GitHub
|
<i class="fa-brands fa-github"></i> GitHub
|
||||||
</a>
|
</a>
|
||||||
<a href="/tos" class="text-light text-decoration-none">Terms of Service</a>
|
<% if(ui.tosUrl){ %>
|
||||||
|
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
|
||||||
|
<% } %>
|
||||||
</span>
|
</span>
|
||||||
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
<script type="text/javascript">
|
||||||
|
app.auth.forceLogin(['admin', 'app_sso_admin']);
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
loadConf();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadConf() {
|
||||||
|
try {
|
||||||
|
const data = await app.api.get('conf');
|
||||||
|
$('#conf-json').val(JSON.stringify(data, null, 4));
|
||||||
|
} catch (error) {
|
||||||
|
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConf() {
|
||||||
|
const btn = $('#btn-save');
|
||||||
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
|
||||||
|
try {
|
||||||
|
const text = $('#conf-json').val();
|
||||||
|
const payload = JSON.parse(text);
|
||||||
|
|
||||||
|
await app.api.post('conf', payload);
|
||||||
|
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
let msg = error.message;
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
msg = 'Invalid JSON format. Please check your syntax.';
|
||||||
|
}
|
||||||
|
app.messages.toast('Failed to save configuration: ' + msg, 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
||||||
|
<p class="text-muted">
|
||||||
|
Manage runtime configuration such as SMTP settings, discovery plugins, and OAuth parameters.
|
||||||
|
These secrets are stored securely in OpenBao Vault.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm border-0">
|
||||||
|
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
||||||
|
<h5 class="mb-0">Configuration (JSON)</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-info-circle"></i> Be careful when editing this JSON! Malformed JSON will not save.
|
||||||
|
</div>
|
||||||
|
<textarea id="conf-json" class="form-control text-monospace" rows="25" style="font-family: monospace; font-size: 14px; background-color: #f8f9fa;" spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white border-top-0 pb-4 text-end">
|
||||||
|
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button>
|
||||||
|
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i> Directory</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="/discovery"><i class="fa-solid fa-network-wired"></i> Discovery</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/plugins"><i class="fa-solid fa-plug"></i> Plugins</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<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="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderTable()" style="width: 250px;">
|
||||||
|
<select id="filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
|
||||||
|
<option value="all">All Resources</option>
|
||||||
|
<option value="unmanaged" selected>Unmanaged Only</option>
|
||||||
|
<option value="managed">Managed Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> View discovered network resources and promote them to managed SSO groups.
|
||||||
|
<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="resources">
|
||||||
|
<tr id="resource-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="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||||
|
|
||||||
|
let allResources = [];
|
||||||
|
|
||||||
|
function loadResources() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
allResources = res.results || [];
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const search = $('#search-filter').val().toLowerCase();
|
||||||
|
const managedFilter = $('#filter-managed').val();
|
||||||
|
|
||||||
|
const filtered = allResources.filter(r => {
|
||||||
|
// Name search
|
||||||
|
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||||
|
|
||||||
|
// Managed filter
|
||||||
|
const isManaged = !!(r.metadata && r.metadata.managed);
|
||||||
|
if(managedFilter === 'managed' && !isManaged) return false;
|
||||||
|
if(managedFilter === 'unmanaged' && isManaged) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$.scope.resources.empty();
|
||||||
|
for(const r of filtered) {
|
||||||
|
$.scope.resources.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(filtered.length === 0) {
|
||||||
|
$('#discovery-list').hide();
|
||||||
|
$('#empty-state').show();
|
||||||
|
} else {
|
||||||
|
$('#discovery-list').show();
|
||||||
|
$('#empty-state').hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function promoteResource(slug) {
|
||||||
|
app.api.post('discovery/promote/' + slug, {}, function(err, res) {
|
||||||
|
if(err) {
|
||||||
|
app.messages.toast("Error promoting resource: " + (err.message || err), 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('.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();
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
loadResources();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6 text-center">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
|
||||||
|
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
<% if (error.status === 404) { %>
|
||||||
|
The page you are looking for doesn't exist or has been moved.
|
||||||
|
<% } else { %>
|
||||||
|
An unexpected error occurred. Please try again later.
|
||||||
|
<% } %>
|
||||||
|
</p>
|
||||||
|
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
|
||||||
|
<i class="fa-solid fa-house me-2"></i>Return to Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -4,9 +4,38 @@
|
|||||||
var userlist;
|
var userlist;
|
||||||
var allGroups = [];
|
var allGroups = [];
|
||||||
|
|
||||||
|
// A member DN under the groups base is a nested group, not a person. Both
|
||||||
|
// live in the same `member` attribute, so they have to be told apart here --
|
||||||
|
// otherwise a nested group renders as a user whose name happens to be the
|
||||||
|
// group's, and its remove button calls the user endpoint and 404s.
|
||||||
|
function isGroupDn(dn){
|
||||||
|
return /,ou=groups,/i.test(String(dn));
|
||||||
|
}
|
||||||
|
|
||||||
function processGroup(value){
|
function processGroup(value){
|
||||||
if (!Array.isArray(value.member)) value.member = value.member ? [value.member] : [];
|
if (!Array.isArray(value.member)) value.member = value.member ? [value.member] : [];
|
||||||
if (!Array.isArray(value.owner)) value.owner = value.owner ? [value.owner] : [];
|
if (!Array.isArray(value.owner)) value.owner = value.owner ? [value.owner] : [];
|
||||||
|
|
||||||
|
// Split before anything else consumes `member`.
|
||||||
|
value.nested = value.member.filter(isGroupDn).map(function(dn){
|
||||||
|
return {
|
||||||
|
dn: dn,
|
||||||
|
cn: dn.match(/cn=[^,]+/)[0].replace('cn=', ''),
|
||||||
|
groupCN: value.cn
|
||||||
|
};
|
||||||
|
});
|
||||||
|
value.member = value.member.filter(function(dn){ return !isGroupDn(dn); });
|
||||||
|
value.nestedCount = value.nested.length;
|
||||||
|
value.hasNested = value.nestedCount > 0;
|
||||||
|
|
||||||
|
// Candidates to nest: every other group not already nested here. Self is
|
||||||
|
// excluded; deeper loops are refused server-side by Group.wouldCycle,
|
||||||
|
// which is the only place that can see the whole graph.
|
||||||
|
var nestedDns = value.nested.map(function(g){ return g.dn.toLowerCase(); });
|
||||||
|
value.toNest = allGroups.filter(function(g){
|
||||||
|
return g.cn !== value.cn && nestedDns.indexOf(String(g.dn).toLowerCase()) === -1;
|
||||||
|
}).map(function(g){ return {cn: g.cn, groupCN: value.cn}; });
|
||||||
|
|
||||||
value.toAdd = userlist.filter(function(user){
|
value.toAdd = userlist.filter(function(user){
|
||||||
return !value.member.includes(user.dn);
|
return !value.member.includes(user.dn);
|
||||||
});
|
});
|
||||||
@@ -32,14 +61,39 @@
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// app_sso_service_account is a marker group: membership hides an account
|
||||||
|
// from the Users page's People tab entirely (see users.ejs), which is
|
||||||
|
// exactly right for a non-person account but has silently made a real
|
||||||
|
// person's account look "gone" before (nothing else about it changes).
|
||||||
|
// Everywhere else in this dropdown just fires the PUT directly; only
|
||||||
|
// this one group gets a confirmation first.
|
||||||
|
function addMemberClick(event, groupCN, uid, el){
|
||||||
|
event.preventDefault();
|
||||||
|
const $el = $(el);
|
||||||
|
(async function(){
|
||||||
|
if (groupCN === 'app_sso_service_account') {
|
||||||
|
const ok = await app.messages.confirm(
|
||||||
|
`Mark "${uid}" as a service account? This hides them from the Users page's People tab (Service Accounts tab only) — only do this for a non-person account.`,
|
||||||
|
$el.closest('.card'), 'warning'
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await app.api.put(`group/${groupCN}/${uid}`, {});
|
||||||
|
await addedUser(data.message, groupCN, uid, $el);
|
||||||
|
} catch(e) {
|
||||||
|
app.messages.action(e.message || 'Failed to add member', $el.closest('.card'), 'danger');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function addedUser(message, group, user, $form){
|
async function addedUser(message, group, user, $form){
|
||||||
let data = await app.group.get(group);
|
let data = await app.group.get(group);
|
||||||
$.scope.groupCard.update('cn', group, processGroup(data.results));
|
$.scope.groupCard.update('cn', group, processGroup(data.results));
|
||||||
app.util.actionMessage(message, $("#group-card-"+group), 'success');
|
app.messages.action(message, $("#group-card-"+group), 'success');
|
||||||
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
||||||
setTimeout(function(group){
|
setTimeout(function(){ app.util.revealItem($("#group-card-" + group)); }, 400);
|
||||||
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
|
|
||||||
}, 400, group);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySort() {
|
function applySort() {
|
||||||
@@ -64,57 +118,98 @@
|
|||||||
$('#groupCount').text(groups.length + ' of ' + allGroups.length + ' group' + (allGroups.length !== 1 ? 's' : ''));
|
$('#groupCount').text(groups.length + ' of ' + allGroups.length + ' group' + (allGroups.length !== 1 ? 's' : ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tableAJAX() {
|
async function tableAJAX(revealCn) {
|
||||||
let data = await app.group.list();
|
let data = await app.group.list();
|
||||||
|
// processGroup builds each card's "nest a group" list from allGroups, so
|
||||||
|
// it has to see the full set before the map runs -- assigning only the
|
||||||
|
// mapped result would leave every dropdown empty on first load (and one
|
||||||
|
// render stale thereafter). The raw entries carry the cn/dn it needs.
|
||||||
|
allGroups = data.results;
|
||||||
allGroups = data.results.map(processGroup);
|
allGroups = data.results.map(processGroup);
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNestedClick(event, groupCN, childCN, el){
|
||||||
|
event.preventDefault();
|
||||||
|
const $card = $('#group-card-' + groupCN);
|
||||||
|
(async function(){
|
||||||
|
try {
|
||||||
|
const data = await app.api.put(`group/${groupCN}/nested/${childCN}`, {});
|
||||||
|
const groupData = await app.group.get(groupCN);
|
||||||
|
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||||
|
app.messages.action(data.message, $card, 'success');
|
||||||
|
} catch(e) {
|
||||||
|
// 409 here is the cycle guard or an already-nested group -- both
|
||||||
|
// carry a specific server message worth showing verbatim.
|
||||||
|
app.messages.action((e && e.message) || 'Failed to nest group', $card, 'danger');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeNested(groupCN, childCN, btn) {
|
||||||
|
const $item = $(btn).closest('li');
|
||||||
|
$item.addClass('list-group-item-warning');
|
||||||
|
const confirmed = await app.messages.confirm(
|
||||||
|
`Remove "${childCN}" from "${groupCN}"? Its members lose access granted through this group.`,
|
||||||
|
$item, 'warning');
|
||||||
|
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||||
|
try {
|
||||||
|
const data = await app.api.delete(`group/${groupCN}/nested/${childCN}`);
|
||||||
|
const groupData = await app.group.get(groupCN);
|
||||||
|
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||||
|
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||||
|
} catch(e) {
|
||||||
|
$item.removeClass('list-group-item-warning');
|
||||||
|
app.messages.action(e.message || 'Failed to un-nest group', $('#group-card-' + groupCN), 'danger');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeMember(groupCN, uid, btn) {
|
async function removeMember(groupCN, uid, btn) {
|
||||||
const $item = $(btn).closest('li');
|
const $item = $(btn).closest('li');
|
||||||
$item.addClass('list-group-item-warning');
|
$item.addClass('list-group-item-warning');
|
||||||
const confirmed = await app.util.actionConfirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
|
const confirmed = await app.messages.confirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||||
try {
|
try {
|
||||||
const data = await app.api.delete(`group/${groupCN}/${uid}`);
|
const data = await app.api.delete(`group/${groupCN}/${uid}`);
|
||||||
const groupData = await app.group.get(groupCN);
|
const groupData = await app.group.get(groupCN);
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||||
app.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success');
|
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
$item.removeClass('list-group-item-warning');
|
$item.removeClass('list-group-item-warning');
|
||||||
app.util.actionMessage(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
|
app.messages.action(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeOwner(groupCN, uid, btn) {
|
async function removeOwner(groupCN, uid, btn) {
|
||||||
const $item = $(btn).closest('li');
|
const $item = $(btn).closest('li');
|
||||||
$item.addClass('list-group-item-warning');
|
$item.addClass('list-group-item-warning');
|
||||||
const confirmed = await app.util.actionConfirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
|
const confirmed = await app.messages.confirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||||
try {
|
try {
|
||||||
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
|
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
|
||||||
const groupData = await app.group.get(groupCN);
|
const groupData = await app.group.get(groupCN);
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||||
app.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success');
|
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
$item.removeClass('list-group-item-warning');
|
$item.removeClass('list-group-item-warning');
|
||||||
app.util.actionMessage(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
|
app.messages.action(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteGroup(cn, btn) {
|
async function deleteGroup(cn, btn) {
|
||||||
const $card = $(btn).closest('.card');
|
const $card = $(btn).closest('.card');
|
||||||
const confirmed = await app.util.actionConfirm(`Delete group "${cn}"?`, $card, 'danger');
|
const confirmed = await app.messages.confirm(`Delete group "${cn}"?`, $card, 'danger');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
await app.api.delete(`group/${cn}`);
|
await app.api.delete(`group/${cn}`);
|
||||||
$.scope.groupCard.remove('cn', cn);
|
$.scope.groupCard.remove('cn', cn);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
app.util.actionMessage(e.message || 'Failed to delete group', $card, 'danger');
|
app.messages.action(e.message || 'Failed to delete group', $card, 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.auth.forceLogin('app_sso_admin');
|
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||||
|
|
||||||
$(document).ready(async function(){
|
$(document).ready(async function(){
|
||||||
userlist = (await app.user.list()).results;
|
userlist = (await app.user.list()).results;
|
||||||
@@ -123,7 +218,7 @@
|
|||||||
</script>
|
</script>
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
|
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
<div class="d-flex flex-wrap gap-2 align-items-center sticky-top bg-body py-2" style="top: var(--sw-content-offset, 0);">
|
||||||
<div class="input-group" style="flex: 1 1 200px;">
|
<div class="input-group" style="flex: 1 1 200px;">
|
||||||
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
|
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
|
||||||
<input type="text" id="groupSearch" class="form-control" placeholder="Search groups…" oninput="applyFilters()">
|
<input type="text" id="groupSearch" class="form-control" placeholder="Search groups…" oninput="applyFilters()">
|
||||||
@@ -146,7 +241,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX('')">
|
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX(data.results.cn)">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Name</label>
|
<label class="form-label">Name</label>
|
||||||
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
|
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
|
||||||
@@ -177,6 +272,12 @@
|
|||||||
Members
|
Members
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" id="group-nested-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-nested-{{cn}}" href="#group-nested-{{cn}}" role="tab" aria-controls="nested" aria-selected="false">
|
||||||
|
<i class="fa-solid fa-layer-group"></i>
|
||||||
|
Nested{{#hasNested}} <span class="badge bg-secondary">{{nestedCount}}</span>{{/hasNested}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" id="group-admins-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-admins-{{cn}}" href="#group-admins-{{cn}}" role="tab" aria-controls="admin" aria-selected="false">
|
<a class="nav-link" id="group-admins-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-admins-{{cn}}" href="#group-admins-{{cn}}" role="tab" aria-controls="admin" aria-selected="false">
|
||||||
<i class="fa-solid fa-user-tie"></i>
|
<i class="fa-solid fa-user-tie"></i>
|
||||||
@@ -214,7 +315,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
||||||
{{ #toAdd }}{{#.}}
|
{{ #toAdd }}{{#.}}
|
||||||
<a class="dropdown-item" action="group/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form);">
|
<a class="dropdown-item" href="#" onclick="return addMemberClick(event, '{{groupCN}}', '{{uid}}', this);">
|
||||||
<i class="fa-solid fa-user"></i> {{uid}}
|
<i class="fa-solid fa-user"></i> {{uid}}
|
||||||
</a>
|
</a>
|
||||||
{{/.}}{{ /toAdd }}
|
{{/.}}{{ /toAdd }}
|
||||||
@@ -223,6 +324,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="group-nested-{{cn}}" role="tabpanel" aria-labelledby="nested-tab">
|
||||||
|
<p class="text-muted small mb-2">
|
||||||
|
Everyone in a nested group is a member of this one, at any depth.
|
||||||
|
</p>
|
||||||
|
<ul class="list-group">
|
||||||
|
{{ #nested }}
|
||||||
|
<li id="group-card-{{groupCN}}-nested-{{cn}}" class="list-group-item shadow">
|
||||||
|
<i class="fa-solid fa-layer-group"></i> {{ cn }}
|
||||||
|
<button type="button" onclick="removeNested('{{groupCN}}', '{{cn}}', this)" class="btn btn-sm btn-danger float-end">
|
||||||
|
<i class="fa-solid fa-link-slash"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{{ /nested }}
|
||||||
|
{{ ^hasNested }}
|
||||||
|
<li class="list-group-item text-muted fst-italic">No groups nested here.</li>
|
||||||
|
{{ /hasNested }}
|
||||||
|
</ul>
|
||||||
|
<div class="dropdown mt-2">
|
||||||
|
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
<i class="fa-solid fa-diagram-project"></i> Nest a group
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu" style="max-height: 300px; overflow-y: auto;">
|
||||||
|
{{ #toNest }}
|
||||||
|
<a class="dropdown-item" href="#" onclick="addNestedClick(event, '{{groupCN}}', '{{cn}}', this)">{{ cn }}</a>
|
||||||
|
{{ /toNest }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-pane fade" id="group-admins-{{cn}}" role="tabpanel" aria-labelledby="admin-tab">
|
<div class="tab-pane fade" id="group-admins-{{cn}}" role="tabpanel" aria-labelledby="admin-tab">
|
||||||
<p>
|
<p>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
function startImpersonate(uid){
|
function startImpersonate(uid){
|
||||||
app.impersonate.create(uid, function(error, data){
|
app.impersonate.create(uid, function(error, data){
|
||||||
if(error){
|
if(error){
|
||||||
alert('Could not start impersonation: ' + (data && data.message ? data.message : 'Unknown error'));
|
app.messages.toast('Could not start impersonation: ' + (data && data.message ? data.message : 'Unknown error'), 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$('#impersonateModalTitle').text(data.uid);
|
$('#impersonateModalTitle').text(data.uid);
|
||||||
@@ -79,7 +79,7 @@ function startImpersonate(uid){
|
|||||||
$('#impersonateStopBtn').off('click').on('click', function(){
|
$('#impersonateStopBtn').off('click').on('click', function(){
|
||||||
app.impersonate.revoke(data.uid, function(err){
|
app.impersonate.revoke(data.uid, function(err){
|
||||||
$('#impersonateModal').modal('hide');
|
$('#impersonateModal').modal('hide');
|
||||||
if(!err) app.util.actionMessage('Impersonation ended for ' + data.uid, $('body'), 'success');
|
if(!err) app.messages.action('Impersonation ended for ' + data.uid, $('body'), 'success');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function tableAJAX(message){
|
function tableAJAX(message){
|
||||||
app.util.actionMessage(message);
|
app.messages.action(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
|
|||||||
@@ -1,136 +1,371 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* App Portal styling using Bootstrap defaults */
|
.catalog-grid {
|
||||||
.portal-banner {
|
display: grid;
|
||||||
background-color: var(--bs-primary);
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
color: white;
|
gap: 1.25rem;
|
||||||
padding: 3rem 1rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
border-radius: .5rem;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.portal-banner h1 {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.carousel-container {
|
|
||||||
display: flex;
|
|
||||||
overflow-x: auto;
|
|
||||||
gap: 1.5rem;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
}
|
||||||
.service-card {
|
.service-card {
|
||||||
min-width: 280px;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform .15s, box-shadow .15s;
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.service-card:hover {
|
.service-card:hover { transform: translateY(-3px); box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important; }
|
||||||
transform: translateY(-5px);
|
.service-card .card-body { flex: 1; }
|
||||||
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
|
.card-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
width: 1.8rem;
|
||||||
|
text-align: center;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.service-card .card-body {
|
.card-icon-img {
|
||||||
flex: 1;
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
.howto code {
|
||||||
|
display: block;
|
||||||
|
background: var(--bs-tertiary-bg, #f1f3f5);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
padding: .4rem .6rem;
|
||||||
|
border-radius: .25rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.empty-note { color: var(--bs-secondary-color, #6c757d); font-style: italic; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<div class="portal-banner text-center">
|
<div class="row mb-4">
|
||||||
<h1>SSO Portal</h1>
|
<div class="col-md-8">
|
||||||
<p class="lead">Explore and access all your services in one place.</p>
|
<input type="text" id="catalog-search" class="form-control shadow-sm"
|
||||||
<a href="/profile" class="btn btn-light shadow-sm mt-2"><i class="fa-solid fa-user"></i> My Profile</a>
|
placeholder="Search services and hosts..." onkeyup="renderAll()">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mt-2 mt-md-0">
|
||||||
|
<select id="catalog-kind" class="form-select shadow-sm" onchange="renderAll()">
|
||||||
|
<option value="">All kinds</option>
|
||||||
|
<option value="service">Services & apps</option>
|
||||||
|
<option value="host">Hosts</option>
|
||||||
|
<option value="site">Sites</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="mb-3"><i class="fa-solid fa-layer-group text-primary"></i> My Apps & Services</h3>
|
<div id="my-requests-section" style="display:none;">
|
||||||
<div class="carousel-container mb-5" id="my-services" jq-repeat="myservices">
|
<h3 class="mb-3"><i class="fa-solid fa-hourglass-half text-warning"></i> My Requests</h3>
|
||||||
<a href="{{resolvedAddress}}" target="_blank" style="text-decoration: none; color: inherit; min-width: 280px;">
|
<ul class="list-group mb-5 shadow-sm" id="my-requests"></ul>
|
||||||
<div class="card shadow-sm service-card border-success">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-success"><i class="fa-solid fa-rocket"></i> {{name}}</h5>
|
|
||||||
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
|
|
||||||
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
|
||||||
<span class="badge bg-success">Access Granted</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More Services</h3>
|
<div id="approvals-section" style="display:none;">
|
||||||
<div class="carousel-container mb-5" id="other-services" jq-repeat="otherservices">
|
<h3 class="mb-3"><i class="fa-solid fa-user-check text-danger"></i> Awaiting My Approval</h3>
|
||||||
<div class="card shadow-sm service-card" style="min-width: 280px;" onclick="requestAccess('{{id}}')">
|
<ul class="list-group mb-5 shadow-sm" id="approvals"></ul>
|
||||||
<div class="card-body">
|
</div>
|
||||||
<h5 class="card-title"><i class="fa-solid fa-cloud"></i> {{name}}</h5>
|
|
||||||
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
|
<!-- My Access section with tabs -->
|
||||||
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
|
<div class="card shadow mb-4">
|
||||||
</div>
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
<span><i class="fa-solid fa-layer-group text-success"></i> My Access</span>
|
||||||
<span class="badge bg-secondary">Request Access</span>
|
</div>
|
||||||
</div>
|
<div class="px-3 pt-3 border-bottom">
|
||||||
</div>
|
<ul class="nav nav-tabs border-bottom-0" role="tablist">
|
||||||
</div>
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#my-services-tab" type="button" role="tab">
|
||||||
<h3 class="mb-3"><i class="fa-solid fa-server text-info"></i> Hosts & Infrastructure</h3>
|
<i class="fa-solid fa-cube"></i> Services
|
||||||
<div class="carousel-container mb-5" id="hosts" jq-repeat="hosts">
|
</button>
|
||||||
<div class="card shadow-sm service-card" style="min-width: 280px;">
|
</li>
|
||||||
<div class="card-body">
|
<li class="nav-item" role="presentation">
|
||||||
<h5 class="card-title"><i class="fa-solid fa-desktop"></i> {{name}}</h5>
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#my-hosts-tab" type="button" role="tab">
|
||||||
<p class="card-text text-muted mb-1">IP: {{metadata.ip}}</p>
|
<i class="fa-solid fa-server"></i> Hosts
|
||||||
<p class="card-text small mb-0">OS: {{metadata.os}}</p>
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="my-services-tab" role="tabpanel">
|
||||||
|
<div class="catalog-grid" id="my-services"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="my-hosts-tab" role="tabpanel">
|
||||||
|
<div class="catalog-grid" id="my-hosts"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More</h3>
|
||||||
|
<p class="text-muted small">Things you don't have access to yet. Request what you need.</p>
|
||||||
|
<div class="catalog-grid mb-5" id="other-services"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
app.auth.forceLogin();
|
app.auth.forceLogin();
|
||||||
|
|
||||||
$(document).ready(async function() {
|
// Connection conventions from conf/base.js `directory`, injected server-side
|
||||||
try {
|
// so the "how to reach this" block renders the invocation that actually
|
||||||
let res = await app.api.get('discovery/me');
|
// works in this deployment rather than a guess.
|
||||||
let allAccessible = res.results || [];
|
var DIRECTORY_CONF = <%- JSON.stringify(directoryConf) %>;
|
||||||
|
|
||||||
let allRes = await app.api.get('directory-admin/resources').catch(e => { return {results:[]}; });
|
var state = { mine: [], others: [], requests: [], approvals: [], uid: null };
|
||||||
|
|
||||||
let myServices = [];
|
var KIND_ICONS = {
|
||||||
let otherServices = [];
|
site: 'fa-solid fa-city',
|
||||||
let hosts = [];
|
host: 'fa-solid fa-server',
|
||||||
|
service: 'fa-solid fa-cube',
|
||||||
allAccessible.forEach(r => {
|
oauth: 'fa-solid fa-key'
|
||||||
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || '#';
|
};
|
||||||
r.description = r.description || 'No description provided';
|
|
||||||
if (r.kind === 'service' || r.kind === 'oauth') myServices.push(r);
|
function esc(s) {
|
||||||
if (r.kind === 'host') hosts.push(r);
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
|
||||||
});
|
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||||||
|
});
|
||||||
if (allRes && allRes.results) {
|
|
||||||
allRes.results.forEach(r => {
|
|
||||||
r.description = r.description || 'No description provided';
|
|
||||||
if (r.kind === 'service' && !myServices.find(s => s.id === r.id)) {
|
|
||||||
otherServices.push(r);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$.scope.myservices.empty();
|
|
||||||
$.scope.myservices.push(...myServices);
|
|
||||||
|
|
||||||
$.scope.otherservices.empty();
|
|
||||||
$.scope.otherservices.push(...otherServices);
|
|
||||||
|
|
||||||
$.scope.hosts.empty();
|
|
||||||
$.scope.hosts.push(...hosts);
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load discovery data:', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function requestAccess(id) {
|
|
||||||
app.util.alert('Access Request', 'This feature is coming soon!', 'info');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render icon as either Font Awesome class or <img> for URL
|
||||||
|
function renderIcon(icon, kind) {
|
||||||
|
if (!icon) icon = KIND_ICONS[kind] || 'fa-solid fa-cube';
|
||||||
|
// If it starts with http, treat it as an image URL
|
||||||
|
if (/^https?:\/\//i.test(icon)) {
|
||||||
|
return '<img src="' + esc(icon) + '" alt="" class="card-icon-img">';
|
||||||
|
}
|
||||||
|
// Otherwise it's a Font Awesome class
|
||||||
|
return '<i class="' + esc(icon) + ' card-icon"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// "How do I actually use this?" — the question the directory exists to
|
||||||
|
// answer and the one the old portal never did. Everything here is derived
|
||||||
|
// from directory metadata; nothing is hardcoded per-service.
|
||||||
|
function howTo(r) {
|
||||||
|
var md = r.metadata || {};
|
||||||
|
var addr = r.resolvedAddress || md.address || md.ip;
|
||||||
|
var lines = [];
|
||||||
|
|
||||||
|
if (r.kind === 'host') {
|
||||||
|
var sshPort = md.sshPort || DIRECTORY_CONF.defaultSshPort;
|
||||||
|
var portArg = String(sshPort) === '22' ? '' : ' -p ' + sshPort;
|
||||||
|
if (DIRECTORY_CONF.jumpHost) {
|
||||||
|
// The jump-host username grammar: one string, no interactive
|
||||||
|
// menu, so it works in WinSCP/FileZilla as well as a terminal.
|
||||||
|
lines.push('ssh ' + state.uid + '_-_' + r.slug + '@' + DIRECTORY_CONF.jumpHost + portArg);
|
||||||
|
} else if (addr) {
|
||||||
|
lines.push('ssh ' + state.uid + '@' + addr + portArg);
|
||||||
|
}
|
||||||
|
} else if (addr) {
|
||||||
|
var isUrl = /^https?:\/\//i.test(addr);
|
||||||
|
if (isUrl) {
|
||||||
|
lines.push(addr);
|
||||||
|
} else {
|
||||||
|
var port = md.externalPort || md.port;
|
||||||
|
lines.push(port ? 'https://' + addr + ':' + port : 'https://' + addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (md.gitRepo) lines.push('Source: ' + md.gitRepo);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkFor(r) {
|
||||||
|
var md = r.metadata || {};
|
||||||
|
var addr = r.resolvedAddress || md.address || md.ip;
|
||||||
|
if (!addr || r.kind === 'host') return null;
|
||||||
|
if (/^https?:\/\//i.test(addr)) return addr;
|
||||||
|
var port = md.externalPort || md.port;
|
||||||
|
return port ? 'https://' + addr + ':' + port : 'https://' + addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHtml(r, accessible) {
|
||||||
|
var md = r.metadata || {};
|
||||||
|
var blurb = md.tagline || r.description || 'No description provided';
|
||||||
|
var href = accessible ? linkFor(r) : null;
|
||||||
|
var lines = accessible ? howTo(r) : [];
|
||||||
|
|
||||||
|
var badges = '';
|
||||||
|
if (md.isProduction) badges += '<span class="badge bg-danger ms-1">Prod</span>';
|
||||||
|
if (md.isExternalReachable) badges += '<span class="badge bg-info ms-1">External</span>';
|
||||||
|
if (md.os) badges += '<span class="badge bg-light text-dark border ms-1">' + esc(md.os) + '</span>';
|
||||||
|
|
||||||
|
var footer;
|
||||||
|
if (accessible) {
|
||||||
|
footer = href
|
||||||
|
? '<a class="btn btn-sm btn-success w-100" target="_blank" rel="noopener" href="' + esc(href) + '">Open <i class="fa-solid fa-arrow-up-right-from-square"></i></a>'
|
||||||
|
: '<span class="badge bg-success">Access granted</span>';
|
||||||
|
} else if (md.requestable === false) {
|
||||||
|
footer = '<span class="badge bg-secondary">Not requestable</span>';
|
||||||
|
} else if (state.requests.some(function(q){ return q.resourceId === r.id && q.status === 'pending'; })) {
|
||||||
|
footer = '<span class="badge bg-warning text-dark">Request pending</span>';
|
||||||
|
} else {
|
||||||
|
footer = '<button class="btn btn-sm btn-outline-primary w-100" onclick="requestAccess(\'' + esc(r.id) + '\')">'
|
||||||
|
+ '<i class="fa-solid fa-hand"></i> Request access</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconHtml = renderIcon(md.icon, r.kind);
|
||||||
|
|
||||||
|
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
|
||||||
|
+ '<div class="card-body">'
|
||||||
|
+ '<h5 class="card-title d-flex align-items-start gap-2">'
|
||||||
|
+ iconHtml
|
||||||
|
+ '<span>' + esc(r.name) + '</span>'
|
||||||
|
+ '</h5>'
|
||||||
|
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
|
||||||
|
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'
|
||||||
|
+ '<p class="card-text small text-muted">' + esc(blurb) + '</p>'
|
||||||
|
+ (lines.length
|
||||||
|
? '<div class="howto small"><div class="text-muted mb-1">How to reach it</div>'
|
||||||
|
+ lines.map(function(l){ return '<code>' + esc(l) + '</code>'; }).join('')
|
||||||
|
+ '</div>'
|
||||||
|
: '')
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="card-footer bg-transparent border-top-0">' + footer + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(r) {
|
||||||
|
var q = ($('#catalog-search').val() || '').toLowerCase();
|
||||||
|
var kind = $('#catalog-kind').val() || '';
|
||||||
|
if (kind && r.kind !== kind) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
var md = r.metadata || {};
|
||||||
|
return [r.name, r.slug, r.description, md.tagline, md.subType, md.ip, md.address]
|
||||||
|
.filter(Boolean).join(' ').toLowerCase().indexOf(q) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid(elId, list, accessible) {
|
||||||
|
var items = list.filter(matchesFilter);
|
||||||
|
var el = document.getElementById(elId);
|
||||||
|
if (!items.length) {
|
||||||
|
el.innerHTML = '<p class="empty-note">Nothing to show here.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = items.map(function(r){ return cardHtml(r, accessible); }).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRequests() {
|
||||||
|
var open = state.requests.filter(function(q){ return q.status === 'pending'; });
|
||||||
|
document.getElementById('my-requests-section').style.display = open.length ? '' : 'none';
|
||||||
|
document.getElementById('my-requests').innerHTML = open.map(function(q){
|
||||||
|
var label = q.resource ? q.resource.name : q.groupCn;
|
||||||
|
return '<li class="list-group-item d-flex justify-content-between align-items-center">'
|
||||||
|
+ '<span><strong>' + esc(label) + '</strong> '
|
||||||
|
+ '<small class="text-muted">via <code>' + esc(q.groupCn) + '</code></small></span>'
|
||||||
|
+ '<button class="btn btn-sm btn-outline-danger" onclick="withdraw(\'' + esc(q.id) + '\')">Withdraw</button>'
|
||||||
|
+ '</li>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApprovals() {
|
||||||
|
document.getElementById('approvals-section').style.display = state.approvals.length ? '' : 'none';
|
||||||
|
document.getElementById('approvals').innerHTML = state.approvals.map(function(q){
|
||||||
|
var label = q.resource ? q.resource.name : q.groupCn;
|
||||||
|
return '<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2">'
|
||||||
|
+ '<span><strong>' + esc(q.uid) + '</strong> requests <strong>' + esc(label) + '</strong> '
|
||||||
|
+ '<small class="text-muted">(<code>' + esc(q.groupCn) + '</code>)</small>'
|
||||||
|
+ (q.note ? '<br><small class="text-muted">' + esc(q.note) + '</small>' : '')
|
||||||
|
+ '</span>'
|
||||||
|
+ '<span class="d-flex gap-2">'
|
||||||
|
+ '<button class="btn btn-sm btn-success" onclick="decide(\'' + esc(q.id) + '\',\'approve\')">Approve</button>'
|
||||||
|
+ '<button class="btn btn-sm btn-outline-danger" onclick="decide(\'' + esc(q.id) + '\',\'deny\')">Deny</button>'
|
||||||
|
+ '</span></li>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
// Split mine into services and hosts
|
||||||
|
var myServices = state.mine.filter(function(r) { return r.kind === 'service' || r.kind === 'oauth'; });
|
||||||
|
var myHosts = state.mine.filter(function(r) { return r.kind === 'host'; });
|
||||||
|
|
||||||
|
renderGrid('my-services', myServices, true);
|
||||||
|
renderGrid('my-hosts', myHosts, true);
|
||||||
|
renderGrid('other-services', state.others, false);
|
||||||
|
renderRequests();
|
||||||
|
renderApprovals();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
var me = await app.auth.asyncUser;
|
||||||
|
state.uid = me.uid;
|
||||||
|
|
||||||
|
// Both endpoints are the *discovery* API, not directory-admin. The old
|
||||||
|
// portal called directory-admin/resources and swallowed the 403, so
|
||||||
|
// "Discover More" was permanently empty for every non-admin — i.e. for
|
||||||
|
// exactly the people it was built for.
|
||||||
|
var mineRes = await app.api.get('discovery/me');
|
||||||
|
var allRes = await app.api.get('discovery/resources');
|
||||||
|
|
||||||
|
state.mine = (mineRes.results || []).filter(function(r){ return r.kind !== 'site'; });
|
||||||
|
var mineIds = {};
|
||||||
|
state.mine.forEach(function(r){ mineIds[r.id] = true; });
|
||||||
|
state.others = (allRes.results || []).filter(function(r){
|
||||||
|
return !mineIds[r.id] && r.kind !== 'site' && r.kind !== 'oauth' && (r.metadata && r.metadata.managed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Requests are best-effort: a failure here must not blank the catalog.
|
||||||
|
try {
|
||||||
|
var mineReq = await app.api.get('access-requests/mine');
|
||||||
|
state.requests = mineReq.results || [];
|
||||||
|
} catch (e) { state.requests = []; }
|
||||||
|
try {
|
||||||
|
var pending = await app.api.get('access-requests');
|
||||||
|
state.approvals = pending.results || [];
|
||||||
|
} catch (e) { state.approvals = []; }
|
||||||
|
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestAccess(id) {
|
||||||
|
var resource = state.others.find(function(r){ return r.id === id; });
|
||||||
|
if (!resource) return;
|
||||||
|
app.modal.open({
|
||||||
|
title: 'Request access to ' + resource.name,
|
||||||
|
bodyHtml: '<div class="actionMessage" style="display:none"></div>'
|
||||||
|
+ '<p class="text-muted small">Your request goes to the resource owner for approval.</p>'
|
||||||
|
+ '<label class="form-label">Why do you need it? <span class="text-muted">(optional)</span></label>'
|
||||||
|
+ '<textarea id="req-note" class="form-control" rows="3"></textarea>',
|
||||||
|
footer: {
|
||||||
|
buttonsHtml: '<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>'
|
||||||
|
+ '<button class="btn btn-primary ms-2" onclick="submitRequest(\'' + esc(id) + '\')">Send request</button>'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRequest(id) {
|
||||||
|
try {
|
||||||
|
await app.api.post('access-requests', { resourceId: id, note: $('#req-note').val() });
|
||||||
|
app.modal.close();
|
||||||
|
app.messages.toast('Request sent', 'success');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.action((err && err.message) || 'Could not send request', app.modal.body(), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withdraw(id) {
|
||||||
|
try {
|
||||||
|
await app.api.delete('access-requests/' + id);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.toast((err && err.message) || 'Could not withdraw', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decide(id, action) {
|
||||||
|
try {
|
||||||
|
await app.api.post('access-requests/' + id + '/' + action, {});
|
||||||
|
app.messages.toast('Request ' + (action === 'approve' ? 'approved' : 'denied'), 'success');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.toast((err && err.message) || 'Could not update request', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
load().catch(function(e){
|
||||||
|
console.error('Failed to load catalog:', e);
|
||||||
|
app.messages.toast('Could not load the catalog', 'danger');
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<%- include("bottom") %>
|
||||||
|
|||||||
@@ -7,6 +7,22 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Landing here with no explanation ("why am I on the SSO login page?") is
|
||||||
|
// exactly what happens when another app's "Log in with SSO" button sends
|
||||||
|
// an unauthenticated user through /oauth/authorize, which bounces them
|
||||||
|
// here with ?redirect=. Tell them what's happening instead of leaving it
|
||||||
|
// a mystery.
|
||||||
|
$(document).ready(function(){
|
||||||
|
var redirect = <%- JSON.stringify(redirect || '') %>;
|
||||||
|
if(redirect){
|
||||||
|
var isOauth = /\/oauth\/authorize/.test(redirect);
|
||||||
|
var message = isOauth
|
||||||
|
? 'Log in to continue — an application is requesting access to your account.'
|
||||||
|
: "Log in to continue to what you were doing — you'll be sent back afterward.";
|
||||||
|
app.messages.action(message, $('.card').first(), 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function setOtpMethod(method) {
|
function setOtpMethod(method) {
|
||||||
$('#otpMethodInput').val(method);
|
$('#otpMethodInput').val(method);
|
||||||
$('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email');
|
$('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email');
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
app.api.post('oauth/authorize', oauthParams, function(error, data){
|
app.api.post('oauth/authorize', oauthParams, function(error, data){
|
||||||
if(error){
|
if(error){
|
||||||
$btn.prop('disabled', false).html('<i class="fa-solid fa-check"></i> Allow');
|
$btn.prop('disabled', false).html('<i class="fa-solid fa-check"></i> Allow');
|
||||||
app.util.actionMessage(data.message || 'Authorization failed.', $('#authorize-card'), 'danger');
|
app.messages.action(data.message || 'Authorization failed.', $('#authorize-card'), 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = data.redirect_url;
|
window.location.href = data.redirect_url;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
async function acceptTos() {
|
async function acceptTos() {
|
||||||
var checkbox = document.getElementById('tosCheckbox');
|
var checkbox = document.getElementById('tosCheckbox');
|
||||||
if (!checkbox.checked) {
|
if (!checkbox.checked) {
|
||||||
alert('Please read and check the box to accept the Terms of Service.');
|
app.messages.toast('Please read and check the box to accept the Terms of Service.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -50,14 +50,14 @@
|
|||||||
document.getElementById('section-tos').style.display = 'none';
|
document.getElementById('section-tos').style.display = 'none';
|
||||||
checkAllDone();
|
checkAllDone();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Could not save TOS acceptance. Please try again.');
|
app.messages.toast('Could not save TOS acceptance. Please try again.', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDob() {
|
async function saveDob() {
|
||||||
var dob = document.getElementById('dobInput').value;
|
var dob = document.getElementById('dobInput').value;
|
||||||
if (!dob) {
|
if (!dob) {
|
||||||
alert('Please enter your date of birth.');
|
app.messages.toast('Please enter your date of birth.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
document.getElementById('section-dob').style.display = 'none';
|
document.getElementById('section-dob').style.display = 'none';
|
||||||
checkAllDone();
|
checkAllDone();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Could not save date of birth. Please try again.');
|
app.messages.toast('Could not save date of birth. Please try again.', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,11 +81,11 @@
|
|||||||
var pw = document.getElementById('pwInput').value;
|
var pw = document.getElementById('pwInput').value;
|
||||||
var pw2 = document.getElementById('pwInput2').value;
|
var pw2 = document.getElementById('pwInput2').value;
|
||||||
if (!pw || pw.length < 5) {
|
if (!pw || pw.length < 5) {
|
||||||
alert('Password must be at least 5 characters.');
|
app.messages.toast('Password must be at least 5 characters.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pw !== pw2) {
|
if (pw !== pw2) {
|
||||||
alert('Passwords do not match.');
|
app.messages.toast('Passwords do not match.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
document.getElementById('section-password').style.display = 'none';
|
document.getElementById('section-password').style.display = 'none';
|
||||||
checkAllDone();
|
checkAllDone();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Could not change password. Please try again.');
|
app.messages.toast('Could not change password. Please try again.', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
app.auth.forceLogin('app_sso_admin');
|
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||||
|
|
||||||
// ── Overview (stats, recent signups, inactive users) ────────────────────
|
// ── Overview (stats, recent signups, inactive users) ────────────────────
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
async function loadMetrics() {
|
async function loadMetrics() {
|
||||||
try {
|
try {
|
||||||
const data = await app.api.get('metrics/executive');
|
const data = await app.api.get('metrics/overview');
|
||||||
if (data && data.results) {
|
if (data && data.results) {
|
||||||
const renderList = (items, id) => {
|
const renderList = (items, id) => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
@@ -117,8 +117,8 @@
|
|||||||
const msgEl = document.getElementById('notif-result');
|
const msgEl = document.getElementById('notif-result');
|
||||||
const $compose = $('#notif-subject').closest('.card-body');
|
const $compose = $('#notif-subject').closest('.card-body');
|
||||||
|
|
||||||
if (!subject || !message) { alert('Subject and message are required.'); return; }
|
if (!subject || !message) { app.messages.action('Subject and message are required.', $compose, 'danger'); return; }
|
||||||
if (!filterCheck) { alert('Choose who to send this to.'); return; }
|
if (!filterCheck) { app.messages.action('Choose who to send this to.', $compose, 'danger'); return; }
|
||||||
const filterType = filterCheck.value;
|
const filterType = filterCheck.value;
|
||||||
|
|
||||||
let filter_value = '';
|
let filter_value = '';
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
// trying the form out — make it a deliberate, confirmed action.
|
// trying the form out — make it a deliberate, confirmed action.
|
||||||
if (filterType === 'all' || filterType === 'all_active') {
|
if (filterType === 'all' || filterType === 'all_active') {
|
||||||
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
|
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
|
||||||
const confirmed = await app.util.actionConfirm(`Send this notification to ${label}?`, $compose, 'warning');
|
const confirmed = await app.messages.confirm(`Send this notification to ${label}?`, $compose, 'warning');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +179,12 @@
|
|||||||
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
|
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
|
||||||
const msgEl = document.getElementById('tos-result');
|
const msgEl = document.getElementById('tos-result');
|
||||||
|
|
||||||
if (!content) { alert('Terms of Service text cannot be empty.'); return; }
|
if (!content) {
|
||||||
|
msgEl.className = 'alert alert-danger mt-2';
|
||||||
|
msgEl.textContent = 'Terms of Service text cannot be empty.';
|
||||||
|
msgEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
app.tos.update({content, resetAcceptance}, function(error, data) {
|
app.tos.update({content, resetAcceptance}, function(error, data) {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -208,7 +213,7 @@
|
|||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Executive Dashboard</h4>
|
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Overview</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i> Directory</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/discovery"><i class="fa-solid fa-network-wired"></i> Discovery</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="/plugins"><i class="fa-solid fa-plug"></i> Plugins</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<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> View configured background plugins and scheduler status. Note: Plugins are configured statically in <code>sso-secrets.js</code>.
|
||||||
|
</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>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="plugins-list" jq-repeat="plugins">
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3 fw-bold">{{name}}</td>
|
||||||
|
<td><code class="text-dark">{{cron}}</code></td>
|
||||||
|
<td>
|
||||||
|
{{#enabled}}<span class="badge bg-success">Enabled</span>{{/enabled}}
|
||||||
|
{{^enabled}}<span class="badge bg-secondary">Disabled</span>{{/enabled}}
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted font-monospace">
|
||||||
|
{{details}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tbody id="empty-state" style="display: none;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center py-4 text-muted">
|
||||||
|
No plugins configured in sso-secrets.js
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']);
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
app.api.get('plugins', function(err, res) {
|
||||||
|
if(err) {
|
||||||
|
app.messages.toast("Error loading plugins: " + (err.message || err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const plugins = res.results || {};
|
||||||
|
const pluginNames = Object.keys(plugins);
|
||||||
|
|
||||||
|
$.scope.plugins.empty();
|
||||||
|
if(pluginNames.length === 0) {
|
||||||
|
$('#plugins-list').hide();
|
||||||
|
$('#empty-state').show();
|
||||||
|
} else {
|
||||||
|
pluginNames.forEach(name => {
|
||||||
|
const config = plugins[name];
|
||||||
|
const details = Object.entries(config)
|
||||||
|
.filter(([k, v]) => k !== 'enabled' && k !== 'cron')
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
$.scope.plugins.push({
|
||||||
|
name: name,
|
||||||
|
cron: config.cron || 'N/A',
|
||||||
|
enabled: config.enabled,
|
||||||
|
details: details
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$('#plugins-list').show();
|
||||||
|
$('#empty-state').hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -1,138 +1,159 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<title><%- name %> <%- title %></title>
|
<title><%- name %> <%- title %></title>
|
||||||
<!-- Favicon -->
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
<link rel="icon" type="image/svg+xml" href="<%- logo %>">
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
|
||||||
<!-- CSS are placed here -->
|
via app.locals in app.js). Edit all three copies together. -->
|
||||||
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
<!-- Favicon -->
|
||||||
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
<link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
|
||||||
|
<!-- CSS are placed here -->
|
||||||
|
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
||||||
|
|
||||||
<link rel='stylesheet' href='/static/css/styles.css' />
|
<link rel='stylesheet' href='/static/css/styles.css' />
|
||||||
<!-- Scripts are placed here -->
|
<!-- Scripts are placed here -->
|
||||||
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
||||||
<!-- <script type="text/javascript" src="/static/lib/js/popper-1.16.0.min.js"></script> -->
|
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<!-- <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.min.js"></script> -->
|
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
||||||
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
||||||
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
||||||
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
||||||
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
||||||
<script type="text/javascript" src='/static/lib/js/val.js'></script>
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
|
||||||
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
|
||||||
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
|
||||||
<script type="text/javascript" src="/static/js/app.js"></script>
|
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
||||||
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
||||||
<ul class="navbar-nav top-nav">
|
<ul class="navbar-nav top-nav">
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<%# Items gated on a group start hidden (.group-required) and are
|
||||||
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
|
revealed by app-base.js for the groups the user is in. %>
|
||||||
Users
|
<% for(const item of ui.nav){ %>
|
||||||
</a>
|
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
|
||||||
</li>
|
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<%- item.label %>
|
||||||
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-viewfinder"></i>
|
</a>
|
||||||
Groups
|
</li>
|
||||||
</a>
|
<% } %>
|
||||||
</li>
|
</ul>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_directory_admin">
|
<div class="form-inline mt-2 mt-md-0">
|
||||||
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i>
|
<% if(ui.profileUrl){ %>
|
||||||
Directory
|
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
||||||
</a>
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</li>
|
</a>
|
||||||
|
<% } else { %>
|
||||||
|
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
|
||||||
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
|
||||||
|
<i class="fas fa-sign-in"></i>
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
|
||||||
|
<i class="fas fa-sign-out"></i>
|
||||||
|
Log Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<% if(ui.updateCheck){ %>
|
||||||
|
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
|
||||||
|
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
|
||||||
|
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
|
||||||
|
UNDER the nav, since fixed elements are taken out of document flow) -- shown/hidden
|
||||||
|
dynamically, so #spa-shell's margin-top is adjusted in JS to make room for it. -->
|
||||||
|
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
|
||||||
|
<span id="update-banner-text"></span>
|
||||||
|
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
// --sw-content-offset tracks the same height as #spa-shell's margin-top
|
||||||
|
// (fixed navbar, plus the update banner while it's shown), so any
|
||||||
|
// in-page sticky element (e.g. a sticky search/sort bar) can offset
|
||||||
|
// itself below both fixed elements via `top: var(--sw-content-offset)`
|
||||||
|
// instead of colliding with them at the viewport's true top:0.
|
||||||
|
function showUpdateBanner(){
|
||||||
|
let $nav = $('nav.fixed-top');
|
||||||
|
let $banner = $('#update-banner');
|
||||||
|
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||||
|
let offset = $nav.outerHeight() + $banner.outerHeight();
|
||||||
|
$('#spa-shell').css('margin-top', offset + 'px');
|
||||||
|
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissUpdateBanner(){
|
||||||
|
$('#update-banner').hide();
|
||||||
|
$('#spa-shell').css('margin-top', '');
|
||||||
|
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
|
||||||
|
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkForUpdate(){
|
||||||
|
if(sessionStorage.getItem('update-banner-dismissed')) return;
|
||||||
|
app.api.get('update-check', function(error, info){
|
||||||
|
if(error || !info || !info.updateAvailable) return;
|
||||||
|
$('#update-banner-text').html(
|
||||||
|
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
|
||||||
|
'(running v' + info.currentVersion + ') — ' +
|
||||||
|
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
|
||||||
|
);
|
||||||
|
showUpdateBanner();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(document).ready(function(){
|
||||||
|
|
||||||
|
// Set the correct link to active in the top nav bar
|
||||||
|
$('.top-nav a').each(function(index){
|
||||||
|
let $this = $(this);
|
||||||
|
$this.removeClass('active');
|
||||||
|
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
|
||||||
|
$this.addClass('active')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set the correct login/logout button, and reveal the current user's
|
||||||
|
// name once we know who they are. Group-gated nav items are revealed
|
||||||
|
// by app-base.js off the same cached user/me.
|
||||||
|
app.auth.isLoggedIn(function(error, me){
|
||||||
|
if(me){
|
||||||
|
$('#cl-logout-button').show();
|
||||||
|
let username = me.uid || me.username;
|
||||||
|
if(username){
|
||||||
|
$('#cl-username-text').text(username);
|
||||||
|
$('#cl-username').css('display', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
<% if(ui.updateCheck){ %>
|
||||||
|
if(me.isAdmin) checkForUpdate();
|
||||||
|
<% } %>
|
||||||
|
}else{
|
||||||
|
$('#cl-login-button').show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<!-- Container -->
|
||||||
<a class="nav-link" href="/executive">
|
<div id="spa-shell" class="container-fluid">
|
||||||
<i class="fa-solid fa-gauge-high"></i>
|
<div class="actionMessage" style="display:none;"></div>
|
||||||
Executive
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="form-inline mt-2 mt-md-0">
|
|
||||||
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
|
|
||||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
|
||||||
</a>
|
|
||||||
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
|
|
||||||
<i class="fas fa-sign-out"></i>
|
|
||||||
Login
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(e => window.location.href='/')" style="display: none;">
|
|
||||||
<i class="fas fa-sign-out"></i>
|
|
||||||
Log Out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
|
|
||||||
<span id="update-banner-text"></span>
|
|
||||||
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
function showUpdateBanner(){
|
|
||||||
let $nav = $('nav.fixed-top');
|
|
||||||
let $banner = $('#update-banner');
|
|
||||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
|
||||||
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
|
||||||
}
|
|
||||||
function dismissUpdateBanner(){
|
|
||||||
$('#update-banner').hide();
|
|
||||||
$('#spa-shell').css('margin-top', '');
|
|
||||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(async function(){
|
|
||||||
|
|
||||||
// Set the correct link to active in the top nav bar
|
|
||||||
$('.top-nav a').each(function(index){
|
|
||||||
let $this = $(this);
|
|
||||||
$this.removeClass('active');
|
|
||||||
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
|
|
||||||
$this.addClass('active')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set the correct login/logout button, and reveal the current user's
|
|
||||||
// name (linking to their profile) once we know who they are.
|
|
||||||
var me = await app.auth.isLoggedIn();
|
|
||||||
if(me){
|
|
||||||
$('#cl-logout-button').show();
|
|
||||||
if(me.uid){
|
|
||||||
$('#cl-username-text').text(me.uid);
|
|
||||||
$('#cl-username').css('display', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if(await app.auth.memberOf('app_sso_admin', me) && !sessionStorage.getItem('update-banner-dismissed')){
|
|
||||||
app.api.get('update-check', function(error, info){
|
|
||||||
if(error || !info || !info.updateAvailable) return;
|
|
||||||
$('#update-banner-text').html(
|
|
||||||
'A newer version of SSO Manager is available: <b>v' + info.latestVersion + '</b> ' +
|
|
||||||
'(running v' + info.currentVersion + ') — ' +
|
|
||||||
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
|
|
||||||
);
|
|
||||||
showUpdateBanner();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
$('#cl-login-button').show();
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Container -->
|
|
||||||
<div id="spa-shell" class="container-fluid">
|
|
||||||
<div class="actionMessage" style="display:none;"></div>
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
function renderUsers(){
|
function renderUsers(){
|
||||||
app.user.list(function(error, data){
|
app.user.list(function(error, data){
|
||||||
if(error){
|
if(error){
|
||||||
app.util.actionMessage(data.message, $('#tab-people'), 'danger');
|
app.messages.action(data.message, $('#tab-people'), 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$.scope.userRow.empty();
|
$.scope.userRow.empty();
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
function toggleActive(uid, active){
|
function toggleActive(uid, active){
|
||||||
app.user.setActive(uid, active, function(error, data){
|
app.user.setActive(uid, active, function(error, data){
|
||||||
if(error) return alert('Failed to update user status');
|
if(error) return app.messages.toast('Failed to update user status', 'danger');
|
||||||
renderUsers();
|
renderUsers();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
async function revokeInvite(tokenId, btn) {
|
async function revokeInvite(tokenId, btn) {
|
||||||
$thisRow = $(btn).closest('tr');
|
$thisRow = $(btn).closest('tr');
|
||||||
$thisRow.addClass('table-warning');
|
$thisRow.addClass('table-warning');
|
||||||
let confirmation = await app.util.actionConfirm('Revoke selected invite token?', $thisRow, 'warning');
|
let confirmation = await app.messages.confirm('Revoke selected invite token?', $thisRow, 'warning');
|
||||||
if(!confirmation){
|
if(!confirmation){
|
||||||
$thisRow.removeClass('table-warning');
|
$thisRow.removeClass('table-warning');
|
||||||
return;
|
return;
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
await app.api.delete(`user/invite/${tokenId}`);
|
await app.api.delete(`user/invite/${tokenId}`);
|
||||||
loadInvites();
|
loadInvites();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Failed to revoke invite.');
|
app.messages.action('Failed to revoke invite.', $thisRow, 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,12 +153,12 @@
|
|||||||
async function deleteUser(uid, btn){
|
async function deleteUser(uid, btn){
|
||||||
const $row = $(btn).closest('tr');
|
const $row = $(btn).closest('tr');
|
||||||
$row.addClass('table-warning');
|
$row.addClass('table-warning');
|
||||||
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $row, 'warning');
|
const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $row, 'warning');
|
||||||
$row.removeClass('table-warning');
|
$row.removeClass('table-warning');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
app.api.delete('user/' + uid, function(error, data){
|
app.api.delete('user/' + uid, function(error, data){
|
||||||
if (error) {
|
if (error) {
|
||||||
app.util.actionMessage(data.message || 'Failed to delete user', $row, 'danger');
|
app.messages.action(data.message || 'Failed to delete user', $row, 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderUsers();
|
renderUsers();
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
(async function(){
|
(async function(){
|
||||||
await app.auth.forceLogin('app_sso_admin');
|
await app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
renderUsers();
|
renderUsers();
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
{{mail}}
|
{{mail}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
|
{{#hasSshKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/hasSshKey}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="fas fa-lock"></i> Vault Secrets</h2>
|
||||||
|
<button class="btn btn-primary" onclick="showCreateModal()">
|
||||||
|
<i class="fas fa-plus"></i> New Secret
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="card-title mb-0">Secrets List</h5>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush" id="secrets-list">
|
||||||
|
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()">
|
||||||
|
<i class="fas fa-edit"></i> Edit
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteCurrentSecret()">
|
||||||
|
<i class="fas fa-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<pre id="secret-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="no-secret-selected" class="text-center text-muted mt-5">
|
||||||
|
<i class="fas fa-key fa-4x mb-3 text-secondary"></i>
|
||||||
|
<h4>Select a secret to view its details</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Secret Modal -->
|
||||||
|
<div class="modal fade" id="secretModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="secretModalTitle">Create Secret</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Secret Path (Name)</label>
|
||||||
|
<input type="text" class="form-control" id="secret-path-input" placeholder="e.g. database-creds">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Secret Data (JSON)</label>
|
||||||
|
<textarea class="form-control" id="secret-data-input" rows="8" style="font-family: monospace;">{
|
||||||
|
"username": "",
|
||||||
|
"password": ""
|
||||||
|
}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger d-none" id="secret-error"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveSecret()">Save Secret</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
app.auth.forceLogin();
|
||||||
|
|
||||||
|
let currentSecretPath = null;
|
||||||
|
const secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
||||||
|
|
||||||
|
function apiCall(method, path, body = null) {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
return fetch('/api/vault/' + path, opts).then(async res => {
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Vault API error: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecrets() {
|
||||||
|
try {
|
||||||
|
// In dev mode, v2 kv engine is mounted at secret/
|
||||||
|
const res = await apiCall('GET', 'secret/metadata/?list=true');
|
||||||
|
const listEl = document.getElementById('secrets-list');
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="list-group-item text-center text-muted">No secrets found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.data.keys.forEach(key => {
|
||||||
|
const item = document.createElement('a');
|
||||||
|
item.href = '#';
|
||||||
|
item.className = 'list-group-item list-group-item-action d-flex align-items-center';
|
||||||
|
item.innerHTML = `<i class="fas fa-file-alt text-secondary me-3"></i> <span>${key}</span>`;
|
||||||
|
item.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Update active state
|
||||||
|
document.querySelectorAll('#secrets-list .active').forEach(el => el.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
|
||||||
|
loadSecretDetails(key);
|
||||||
|
};
|
||||||
|
listEl.appendChild(item);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
document.getElementById('secrets-list').innerHTML =
|
||||||
|
`<div class="list-group-item text-danger"><i class="fas fa-exclamation-triangle"></i> Error loading secrets: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecretDetails(key) {
|
||||||
|
try {
|
||||||
|
currentSecretPath = key;
|
||||||
|
document.getElementById('no-secret-selected').style.display = 'none';
|
||||||
|
document.getElementById('secret-details-card').style.display = 'block';
|
||||||
|
document.getElementById('secret-title').textContent = key;
|
||||||
|
document.getElementById('secret-content').textContent = 'Loading...';
|
||||||
|
|
||||||
|
const res = await apiCall('GET', `secret/data/${key}`);
|
||||||
|
if (!res || !res.data || !res.data.data) {
|
||||||
|
document.getElementById('secret-content').textContent = 'No data found.';
|
||||||
|
} else {
|
||||||
|
document.getElementById('secret-content').textContent = JSON.stringify(res.data.data, null, 2);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
document.getElementById('secret-content').textContent = `Error: ${err.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateModal() {
|
||||||
|
currentSecretPath = null;
|
||||||
|
document.getElementById('secretModalTitle').textContent = 'Create Secret';
|
||||||
|
document.getElementById('secret-path-input').value = '';
|
||||||
|
document.getElementById('secret-path-input').disabled = false;
|
||||||
|
document.getElementById('secret-data-input').value = '{\n "key": "value"\n}';
|
||||||
|
document.getElementById('secret-error').classList.add('d-none');
|
||||||
|
secretModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editCurrentSecret() {
|
||||||
|
if (!currentSecretPath) return;
|
||||||
|
document.getElementById('secretModalTitle').textContent = 'Edit Secret';
|
||||||
|
document.getElementById('secret-path-input').value = currentSecretPath;
|
||||||
|
document.getElementById('secret-path-input').disabled = true;
|
||||||
|
document.getElementById('secret-data-input').value = document.getElementById('secret-content').textContent;
|
||||||
|
document.getElementById('secret-error').classList.add('d-none');
|
||||||
|
secretModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSecret() {
|
||||||
|
const errorEl = document.getElementById('secret-error');
|
||||||
|
errorEl.classList.add('d-none');
|
||||||
|
|
||||||
|
const path = document.getElementById('secret-path-input').value.trim();
|
||||||
|
if (!path) {
|
||||||
|
errorEl.textContent = 'Secret path is required';
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(document.getElementById('secret-data-input').value);
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = 'Invalid JSON: ' + err.message;
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall('POST', `secret/data/${path}`, { data });
|
||||||
|
secretModal.hide();
|
||||||
|
await loadSecrets();
|
||||||
|
if (currentSecretPath === path || !currentSecretPath) {
|
||||||
|
await loadSecretDetails(path);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCurrentSecret() {
|
||||||
|
if (!currentSecretPath) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall('DELETE', `secret/metadata/${currentSecretPath}`);
|
||||||
|
currentSecretPath = null;
|
||||||
|
document.getElementById('no-secret-selected').style.display = 'block';
|
||||||
|
document.getElementById('secret-details-card').style.display = 'none';
|
||||||
|
await loadSecrets();
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.toast('Error deleting secret: ' + err.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
loadSecrets();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||