Compare commits
81 Commits
v1.5.0
...
release-pki
| Author | SHA1 | Date | |
|---|---|---|---|
| 522093e898 | |||
| 7b84a10420 | |||
| c461723ec7 | |||
| b948cd8625 | |||
| 36aa114d7c | |||
| fbce59b1be | |||
| 554a0999ab | |||
| 3ca221d64d | |||
| 75b133f610 | |||
| f1d52601de | |||
| ecd21c4984 | |||
| 5ba2ace835 | |||
| 25b0d57a97 | |||
| 320e7594e4 | |||
| cec0d92c25 | |||
| 21a56dce50 | |||
| ebb5b2c2a7 | |||
| c8c4cad46d | |||
| 59d4b65195 | |||
| 74746e409b | |||
| 70aed035a5 | |||
| 0264a62b22 | |||
| c212537163 | |||
| 391ad12afc | |||
| 622317b6da | |||
| 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 |
@@ -86,6 +86,11 @@ ops/cookbooks/vendor
|
||||
secrets.json
|
||||
secrets.js
|
||||
|
||||
# Per-deployment secret files (real LDAP/SMTP/jwtSecret + generated OAuth
|
||||
# creds). theta-env bind-mounts ./config and generates/fills these at setup;
|
||||
# they must never be committed. The empty *.example templates ARE tracked.
|
||||
config/*-secrets.js
|
||||
|
||||
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
||||
docs/_site
|
||||
|
||||
|
||||
@@ -703,6 +703,10 @@ The authenticated user is automatically set as the group owner.
|
||||
{ "results": true, "message": "Added user uid to group group." }
|
||||
```
|
||||
|
||||
Returns `409` if the user is already a member — common in practice, since
|
||||
`groupOfNames` requires at least one member and so seeds whoever created the
|
||||
group into it.
|
||||
|
||||
---
|
||||
|
||||
### Remove User from Group
|
||||
@@ -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 /api/group/:group`** — `app_sso_admin` or group owner
|
||||
@@ -1135,6 +1199,143 @@ Configurable per-client via `token_lifetime`. Global defaults (in seconds):
|
||||
|
||||
---
|
||||
|
||||
## Plugin Endpoints
|
||||
|
||||
Base path: `/api/plugins`
|
||||
|
||||
All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `app_super_admin` membership. Secret field values are always returned masked (`********`); they are stored in OpenBao at `secret/plugins/<instance-id>/conf`, never in the database row. See [Plugins](docs/plugins.html).
|
||||
|
||||
### List Plugin Types
|
||||
|
||||
**`GET /api/plugins/types`**
|
||||
|
||||
Returns the installed plugin types and their `configSchema` (used to build the create-instance form).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"type": "proxmox",
|
||||
"category": "discovery",
|
||||
"name": "Proxmox VE",
|
||||
"description": "Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.",
|
||||
"configSchema": [
|
||||
{ "key": "url", "label": "API URL", "type": "url", "required": true },
|
||||
{ "key": "tokenId", "label": "Token ID", "type": "text", "required": true },
|
||||
{ "key": "tokenSecret", "label": "Token Secret", "type": "password", "required": true, "secret": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### List Plugin Instances
|
||||
|
||||
**`GET /api/plugins/`**
|
||||
|
||||
**Response:** `{ "results": [ { "id", "pluginType", "category", "name", "slug", "enabled", "cron", "config", "secrets": {…masked…}, "lastRunAt", "lastStatus", "lastError" } ] }`
|
||||
|
||||
---
|
||||
|
||||
### Get One Instance
|
||||
|
||||
**`GET /api/plugins/:id`** — same shape as a list entry.
|
||||
|
||||
---
|
||||
|
||||
### Create Instance
|
||||
|
||||
**`POST /api/plugins/`**
|
||||
|
||||
`config` is a flat object of **all** field values (secret and non-secret); the server splits it — non-secret fields go to the DB row, secret fields to OpenBao. Creating an enabled instance schedules it and kicks one immediate run. `slug` is the discovery source name (lowercase letters/digits/_/-, max 64, unique).
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"pluginType": "proxmox",
|
||||
"name": "Proxmox — Home Lab",
|
||||
"slug": "proxmox-homelab",
|
||||
"cron": "0 * * * *",
|
||||
"config": { "url": "https://pve:8006", "tokenId": "u@pam!t", "tokenSecret": "secret-value" }
|
||||
}
|
||||
```
|
||||
|
||||
Errors: `400` if the plugin type is unknown, the slug is malformed/duplicated, or a required field is missing; `400` with an OpenBao hint if writing the secret fails (re-run `./setup.sh` with theta-suite ≥ v1.30.1).
|
||||
|
||||
---
|
||||
|
||||
### Update Instance
|
||||
|
||||
**`PUT /api/plugins/:id`** — update `name`, `cron`, `enabled`, and non-secret `config`. Secret fields are changed via `PUT /:id/secrets`. Re-schedules if `cron` or `enabled` changed.
|
||||
|
||||
---
|
||||
|
||||
### Update Secrets
|
||||
|
||||
**`PUT /api/plugins/:id/secrets`** — body is a flat object of secret field values. Blank/`********` values are ignored (kept as-is).
|
||||
|
||||
---
|
||||
|
||||
### Test Instance
|
||||
|
||||
**`POST /api/plugins/:id/test`** — runs the plugin's `validate`. Returns `{ "ok": true }` or `400 { "ok": false, "error": "..." }`.
|
||||
|
||||
---
|
||||
|
||||
### Load / Unload / Run Now
|
||||
|
||||
- **`POST /api/plugins/:id/load`** — enable + schedule + run now.
|
||||
- **`POST /api/plugins/:id/unload`** — unschedule + disable.
|
||||
- **`POST /api/plugins/:id/run`** — enqueue one immediate run (regardless of enabled).
|
||||
|
||||
---
|
||||
|
||||
### Last Run Status
|
||||
|
||||
**`GET /api/plugins/:id/runs`** → `{ "results": { "lastRunAt", "lastStatus", "lastError" } }` (`lastStatus` is `ok` | `error` | `running`).
|
||||
|
||||
---
|
||||
|
||||
### Delete Instance
|
||||
|
||||
**`DELETE /api/plugins/:id`** — unschedules, removes the OpenBao secret namespace, and deletes the row.
|
||||
|
||||
## Configuration Endpoints
|
||||
|
||||
Base path: `/api/conf`
|
||||
|
||||
All endpoints require authentication and `app_sso_admin` membership. Runtime configuration (SMTP, discovery, OAuth) is stored in OpenBao at `secret/sso-manager/conf` and overlaid onto the live app config; changes take effect immediately and persist across restarts. Secret fields (`smtp.pass`, `oauth.jwtSecret`) are **always returned masked** (`********`); submit a blank or `********` value to keep the current stored secret, or a new non-blank value to replace it.
|
||||
|
||||
### Get Configuration
|
||||
|
||||
**`GET /api/conf`** — returns the editable config groups (`smtp`, `discovery`, `oauth`) with secret fields masked to `********`.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
|
||||
"discovery": { },
|
||||
"oauth": { "issuer": "https://sso.example.com", "jwtSecret": "********", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
|
||||
}
|
||||
```
|
||||
|
||||
### Save Configuration
|
||||
|
||||
**`POST /api/conf`** — deep-merges the submitted groups into `secret/sso-manager/conf` (per-key shallow merge of nested objects) and re-applies them to the live config. A blank or `********` value for `smtp.pass` or `oauth.jwtSecret` preserves the stored secret.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
|
||||
"oauth": { "issuer": "https://sso.example.com", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `{ "success": true }`
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return errors in this format:
|
||||
|
||||
+386
@@ -1,9 +1,386 @@
|
||||
## v1.19.0
|
||||
- Added WebSocket endpoint for theta-agent C2
|
||||
|
||||
# v1.18.0
|
||||
- feat: Add messaging plugins, Docker discovery, fix reconciliation
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. Format loosely
|
||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
|
||||
## [1.17.2] - 2026-08-01
|
||||
|
||||
Post-deploy fixes from testing the v1.31.0 stack, plus the SMS (VoIP.ms) and
|
||||
Terms-of-Service configuration the `/conf` page was missing. Seven issues:
|
||||
|
||||
### Fixed
|
||||
- **Plugin slug is now auto-generated** from the instance name — the New Plugin
|
||||
modal no longer asks for a Slug (it derived a stable, unique handle from the
|
||||
name, appending `-2`, `-3`, … on collision). The generated slug still shows in
|
||||
the table and the Edit (read-only) modal. `POST /api/plugins` `slug` is now
|
||||
optional; an explicit slug is still accepted and validated. (`routes/api_plugins.js`,
|
||||
`views/plugins.ejs`)
|
||||
- **Plugin schedule is a dropdown**, not a raw cron box: Hourly / Daily /
|
||||
Weekly, plus **Custom** which reveals the raw 5-field cron input. Stored value
|
||||
is still a cron string, so the server is unchanged. (`views/plugins.ejs`)
|
||||
- **`/vault` secrets list no longer 403s.** Root cause: the per-user, per-app,
|
||||
and admin OpenBao policies granted `list` only on `secret/metadata/.../*`
|
||||
(nested paths), never on the directory path itself — so listing a directory's
|
||||
*contents* (which checks `list` on the directory, e.g. `secret/metadata/users/<uid>`
|
||||
or the mount root `secret/metadata`) was denied. `vault_broker.js`'s
|
||||
`userPolicyHcl`/`appPolicyHcl` now also grant `list` on the bare directory
|
||||
path, and `ensurePolicy` now always re-writes the policy (idempotent) so
|
||||
already-created `user-<uid>` policies pick up the new grant on the next
|
||||
vault-page visit. The matching `sso-admin` mount-root grant ships in
|
||||
theta-suite v1.31.1 (`setup.sh`), where `ensure_policy` is likewise made
|
||||
always-write so re-running `./setup.sh` applies policy edits.
|
||||
- **`/profile` no longer shows literal `{{…}}` tags.** Three template fragments
|
||||
sat outside the `jq-repeat="user"` scope, so they rendered raw: the card
|
||||
header `Profile: {{user.uid}}`, the `Members of {{user.uid}}'s Group` tab
|
||||
label, and the Admin Actions block's `{{#isActive}}`/`{{#isInactive}}`
|
||||
buttons. The header/label are now populated by JS (the `Members` label
|
||||
already had a setter pointing at a missing id); the Admin Actions block is
|
||||
moved inside the scope so `{{uid}}`/`{{#isActive}}`/`{{#isInactive}}` render
|
||||
and the correct Activate/Deactivate button shows. (`views/profile.ejs`)
|
||||
- **Editing a plugin now persists.** The Edit modal had been prefilled with the
|
||||
masked secret values and rendered them as fields, but `PUT /:id` only saves
|
||||
non-secret config — so an edited secret was silently dropped. The Edit modal
|
||||
now shows **non-secret fields only** (secrets have their own Edit-Secrets
|
||||
modal), removing the confusion. (`views/plugins.ejs`)
|
||||
- **nmap plugin: "NMAP not found at command location: nmap"** — the `nmap`
|
||||
binary was not installed in the app image. `Dockerfile.openldap` now `apk
|
||||
add`s `nmap` in the runtime stage, and `plugins/discovery/nmap.js` translates
|
||||
the opaque node-nmap spawn-missing error into an actionable `lastError`.
|
||||
|
||||
### Added
|
||||
- **SMS (VoIP.ms) configuration on `/conf`.** The existing VoIP.ms SMS sender
|
||||
(`models/sms.js`, used for 2FA OTP delivery) was configurable only via env /
|
||||
config files. It now has an SMS card on `/conf` (API username, DID, API
|
||||
password), saved to OpenBao at `secret/sso-manager/conf` under `voipms`, with
|
||||
the API password masked (`********`) and leave-blank-to-keep — mirroring the
|
||||
SMTP card exactly. `models/sms.js` reads `conf.voipms.*` at call time, so a
|
||||
saved change takes effect live without a restart. (`routes/api_conf.js`,
|
||||
`views/conf.ejs`)
|
||||
- **Terms of Service editor moved to `/conf`** from the admin Overview
|
||||
dashboard, where it never belonged. The same `app.tos.get`/`update` flow,
|
||||
the "require all users to re-accept" checkbox, and the `app_sso_admin` gate
|
||||
(matching `routes/tos.js`'s PUT gate) are preserved. The Overview page keeps
|
||||
stats, notifications, and metrics. (`views/conf.ejs`, `views/overview.ejs`)
|
||||
|
||||
### Notes
|
||||
- The `/vault` 403 fix is split across two repos: the sso-side per-user/app
|
||||
policy grants and `ensurePolicy`-always-write ship here; the `sso-admin`
|
||||
mount-root grant and `ensure_policy`-always-write ship in theta-suite v1.31.1.
|
||||
Re-running `./setup.sh` after upgrading applies the sso-admin grant; per-user
|
||||
policies self-heal on the next vault-page visit.
|
||||
|
||||
## [1.17.1] - 2026-08-01
|
||||
|
||||
Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to
|
||||
match the plugin-secrets discipline: the SMTP password and OAuth JWT secret are
|
||||
no longer returned in cleartext by `GET /api/conf` or round-tripped through the
|
||||
form. They remain saved in OpenBao at `secret/sso-manager/conf` at runtime
|
||||
(unchanged) — only how they're surfaced to the admin changes.
|
||||
|
||||
### Changed
|
||||
- **`GET /api/conf`** now masks `smtp.pass` and `oauth.jwtSecret` to `********`
|
||||
(was: returned in cleartext). Non-secret fields (host, port, user, from,
|
||||
secure, issuer, token lifetimes) are returned as before.
|
||||
- **`POST /api/conf`** now treats a blank or `********` secret-field submission
|
||||
as "keep the current stored value" — so an admin editing the From address or
|
||||
token lifetimes no longer has to re-enter (or leak) the SMTP password / JWT
|
||||
secret. Only a genuinely new, non-blank value overwrites. The preserved values
|
||||
are re-applied to live `conf` immediately, as before.
|
||||
- **`/conf` page** (`views/conf.ejs`): the Password and JWT Secret fields carry
|
||||
a "leave unchanged to keep the current value stored in OpenBao" hint; the page
|
||||
copy notes secret fields are masked. No JSON-textarea editing is involved —
|
||||
SMTP is and remains configured through structured form fields.
|
||||
|
||||
### Notes
|
||||
- SMTP (and OAuth) config was **already** saved to OpenBao at runtime before
|
||||
this release (via `POST /api/conf` → `baoConf.set('sso-manager/conf')`, and
|
||||
overlaid back at boot by `bao-conf.init`). This release closes the
|
||||
cleartext-exposure gap; it does not move the storage path.
|
||||
- No theta-suite policy change required — `secret/sso-manager/conf` was already
|
||||
granted to the `sso-broker` policy.
|
||||
|
||||
## [1.17.0] - 2026-08-01
|
||||
|
||||
A real **plugin system**: the half-built discovery plugins (statically
|
||||
configured in `sso-secrets.js`, only toggleable for cron/enabled) become
|
||||
**configurable, loadable/unloadable plugin instances** you manage from a
|
||||
dedicated **Plugins** page and the `/api/plugins` API, with multiple runtime
|
||||
copies of each type and per-instance secrets stored in OpenBao.
|
||||
|
||||
### Added
|
||||
- **Plugin instances** — a new `PluginInstance` ORM model
|
||||
(`nodejs/models/plugin_instance.js`, Sequelize) is the registry of
|
||||
configured, scheduled plugin copies. Each has a `pluginType`, a unique
|
||||
`slug` (the discovery source name), a cron schedule, an `enabled` flag
|
||||
(load/unload), non-secret `config` (JSON), and last-run bookkeeping. Multiple
|
||||
instances of the same type are supported.
|
||||
- **Plugin registry** (`nodejs/services/plugin_registry.js`) — generalizes the
|
||||
one-shot discovery-plugin scan in `scheduler.js`. Plugin types are modules
|
||||
under `nodejs/plugins/<category>/<type>.js` exporting a manifest
|
||||
(`type`, `category`, `name`, `description`, `configSchema`, `validate`,
|
||||
`run`/`discover`). Exposes `getTypes`, `getModule`, `splitConfig` (secret vs
|
||||
non-secret), `mask`, and required-field helpers for the UI/API.
|
||||
- **Per-instance secrets in OpenBao** (`nodejs/utils/plugin_secrets.js`) —
|
||||
`configSchema` fields flagged `secret:true` (e.g. a Proxmox `tokenSecret`,
|
||||
UniFi `password`) are stored at `secret/plugins/<instance-id>/conf`, never in
|
||||
the DB. The UI only ever sees masked (`********`) values. Plugins run
|
||||
in-process (BullMQ workers), so they need no OpenBao token of their own — the
|
||||
SSO reads/writes via the `sso-broker` token. **Requires theta-suite ≥ v1.30.1**
|
||||
for the `sso-broker` policy grant on `secret/plugins/*`; the API fails-soft
|
||||
with a clear error if absent.
|
||||
- **`/api/plugins` API** (`nodejs/routes/api_plugins.js`, replaces the old
|
||||
`routes/plugins.js`) — `GET /types`, list/get/create/update/update-secrets/
|
||||
test/load/unload/run/delete/runs. Admin-only
|
||||
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`).
|
||||
- **Plugins page** (`/plugins`, `views/plugins.ejs`) + nav entry — instance
|
||||
table with New/Edit/Edit-Secrets/Test/Run-now/Load/Unload/Delete, config forms
|
||||
rendered from each type's `configSchema`.
|
||||
- **`validate`** ("Test" button) on the built-in Proxmox/UniFi/Nmap plugins.
|
||||
|
||||
### Changed
|
||||
- `services/scheduler.js` now schedules from the `PluginInstance` table instead
|
||||
of static `conf.discovery.plugins` + a Redis override hash. Each instance owns
|
||||
a stable BullMQ JobScheduler id (`plugin:<instanceId>`) so load/unload
|
||||
upsert/remove one schedule without disturbing the rest. Discovery plugins
|
||||
reconcile results under the instance's `slug`.
|
||||
- The three discovery plugins (`plugins/discovery/{proxmox,unifi,nmap}.js`)
|
||||
gained manifests (`configSchema`, `validate`, `run` alias). `nmap`'s
|
||||
`targetRange` is non-secret; Proxmox `tokenSecret` and UniFi `password` are
|
||||
secret.
|
||||
- The `/plugins` page route renders the page instead of redirecting to
|
||||
`/directory`; the **Agents & Scheduler** tab was removed from `/directory`
|
||||
(plugins are now managed on the Plugins page). The `/docs/agents` link is
|
||||
aliased to `/docs/plugins`.
|
||||
- `docs/plugins.md`, `docs/vault.md`, `docs/_config.yml` (nav), and `API.md`
|
||||
(Plugin Endpoints section) document the new system.
|
||||
|
||||
### Legacy migration
|
||||
On first boot of v1.17.0, if the `PluginInstance` table is empty **and**
|
||||
`conf.discovery.plugins` has entries, one instance per configured type is seeded
|
||||
automatically (secret fields copied into OpenBao). After that the static
|
||||
config is ignored — manage plugins from the UI/API. Idempotent (guarded by the
|
||||
empty-table check).
|
||||
|
||||
### Prerequisite
|
||||
**theta-suite ≥ v1.30.1** — re-run `./setup.sh` after upgrading so the
|
||||
`sso-broker` OpenBao policy is granted `secret/plugins/*`. Without it, storing
|
||||
plugin secrets fails with a clear error.
|
||||
|
||||
## [1.16.1] - 2026-08-01
|
||||
|
||||
Fix: the Configuration (`/conf`) and Vault (`/vault`) pages returned **401** for
|
||||
a logged-in admin. Both view routes did server-side auth using `req.user`, but
|
||||
this app's auth-token is a header set by client-side JS (localStorage), not a
|
||||
cookie — so `req.user` is undefined on a plain browser navigation.
|
||||
`permission.byGroup(undefined, …)` throws status 401, and the `middleware.auth`
|
||||
gate on `/vault` threw `Auth.errors.login()` (401) for the same reason.
|
||||
|
||||
Both routes now render the shell unconditionally (like `/users`, `/directory`,
|
||||
`/overview`) and gate client-side: `conf.ejs` already called
|
||||
`app.auth.forceLogin(['admin','app_sso_admin'])`; `vault.ejs` now derives
|
||||
`isAdmin` + the personal namespace from `/api/user/me` after `forceLogin()`
|
||||
instead of server-rendering them. The `/api/conf` and `/api/vault` endpoints
|
||||
still enforce `app_sso_admin` + the OpenBao scope server-side, so protection is
|
||||
unchanged — only the view-route gating moved client-side where the session
|
||||
actually lives. Also removed a dead duplicate `/conf` route definition.
|
||||
|
||||
## [1.16.0] - 2026-08-01
|
||||
|
||||
OpenBao becomes the central secrets store for the theta42 stack, and the SSO
|
||||
Manager becomes its broker. This is the SSO's half of the move: it loads its
|
||||
own secrets from OpenBao, mints scoped tokens for users and external apps,
|
||||
and exposes a fixed, role-scoped personal-secrets UI.
|
||||
|
||||
### Changed
|
||||
- **Secrets now load from OpenBao at boot** via
|
||||
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||
deep-merges `secret/sso-manager/conf` over the file-loaded config
|
||||
(replacing the old `utils/conf_manager.js`, which did a shallow-per-key
|
||||
merge). `bin/www` runs `bao-conf.init()` after `models.initORM()` and
|
||||
before `listen`. Fail-soft: if OpenBao is unreachable, boot continues from
|
||||
`CONF_SECRETS`. The SSO authenticates with a scoped `VAULT_TOKEN` (policy
|
||||
`sso-broker`), never the root token. The admin **Configuration** UI
|
||||
(`/api/conf`) now writes through `bao-conf.set('sso-manager', …)`.
|
||||
- **`/api/vault` proxy reworked** — the old endpoint was an ungated
|
||||
pass-through that never injected an `X-Vault-Token` (so the UI was both
|
||||
ungated *and* broken). It is now `middleware.auth` → `scopeGuard` → a
|
||||
token-injecting proxy. `scopeGuard` resolves a per-user (`user-<uid>`) or
|
||||
per-admin (`sso-admin`) token via the new `utils/vault_broker.js`
|
||||
(Redis-cached, minted through the `sso-broker` token role) and enforces a
|
||||
path prefix as a second layer on top of the OpenBao policy. The client
|
||||
`auth-token` is stripped; only the server-minted token reaches OpenBao.
|
||||
- **Vault UI reworked and renamed** (`views/vaultwarden.ejs` →
|
||||
`views/vault.ejs`; the `/vault` route is now `middleware.auth`-gated).
|
||||
Non-admin users see only their `secret/users/<uid>/` namespace; admins get
|
||||
free-form path entry across `secret/` plus an **Apps** tab to mint scoped
|
||||
tokens for external apps (`secret/apps/<name>/*`, shown once with copy +
|
||||
`curl` convention).
|
||||
- Bumped package version to track the release tag.
|
||||
|
||||
### Removed
|
||||
- `nodejs/utils/conf_manager.js` (replaced by `@simpleworkjs/bao-conf`).
|
||||
- `nodejs/views/vaultwarden.ejs` (renamed `vault.ejs`).
|
||||
|
||||
### Security
|
||||
- **Committed-secrets remediation.** `config/sso-secrets.js` (LDAP bind
|
||||
password, SMTP, `oauth.jwtSecret`) and `nodejs/test_plugins.js` (a
|
||||
hardcoded Proxmox root API token and a UniFi password) were tracked on
|
||||
master. They are now untracked + gitignored (`config/*-secrets.js`), and
|
||||
`test_plugins.js` is deleted; `config/proxy-secrets.js.example` added as a
|
||||
placeholder template. **The secrets remain in git history — rotation at
|
||||
the providers is the real remediation and is the operator's to perform.**
|
||||
OpenBao is now the authoritative store; the local files are seed artifacts
|
||||
only.
|
||||
|
||||
> Note: releases v1.12.0–v1.15.2 were tagged from merge PRs without
|
||||
> corresponding `CHANGELOG.md` entries or GitHub releases; this entry
|
||||
> resumes the changelog at v1.16.0.
|
||||
|
||||
## [1.11.0] - 2026-07-31
|
||||
|
||||
Closes the end-user half of the directory. The admin side could describe the lab; the user side could not tell anyone what they had or how to use it, and several of the paths meant to do so were silently returning nothing.
|
||||
|
||||
### Fixed
|
||||
- **`GET /api/discovery/me` returned only `isPublic` resources for every human caller.** It resolved the caller's groups from `req.user.groups`, which does not exist — `req.user` is a `User` carrying `memberOf` (DNs). The empty list failed open into "no group-granted resources", so "My Services" on the profile page and the portal's service list were blank for everyone. The same bug made `isDirectoryAdmin()` false for real directory admins, silently downgrading them to the public metadata projection. Group CNs now come from `utils/user_groups.js`.
|
||||
- **The portal's "Discover More Services" was dead for every non-admin.** It called the admin-gated `directory-admin/resources` and swallowed the 403 into an empty array — so the one discovery feature never rendered for the audience it existed for. It now calls `/api/discovery/resources`.
|
||||
- **Services reported no address.** `/api/discovery/me` had reimplemented `Resource.getMyAccess` without its parent-walking address resolution, leaving clients to guess `address || ip`, which is exactly wrong for a service that is reached at its host's IP. Both paths now share `Resource.withResolvedAddress()`.
|
||||
- **Approving access for a user already in the target group threw a 500** and left the request stuck pending. `groupOfNames` requires at least one member, so a resource's auto-created groups are seeded with the creator's DN; the grant is now idempotent.
|
||||
- **`DELETE /api/directory-admin/resources/:id` deleted the resource before its edges and group links.** With no transaction, a failure mid-way orphaned rows pointing at a nonexistent id — invisible in the UI and poisonous to `getGraph()`. Dependents go first now.
|
||||
- `PUT /api/directory-admin/resources/:id` validated the body only after loading the row, and carried a dead if/else whose branches were identical.
|
||||
- `/api/directory-admin/audit-logs` shelled out to `tail` three times via `execSync`; replaced with a bounded async file read (no `child_process`, at most the trailing 256 KB).
|
||||
|
||||
### Added
|
||||
- **End-user catalog at `/`**, and the first ungated nav item — previously every nav entry was admin-only and a normal user had no signposted destination. Search/filter, per-kind icons, and a **how to reach it** block per card: the URL for a service, the SSH invocation for a host (using the jump-host `uid_-_slug@host` grammar when `directory.jumpHost` is configured).
|
||||
- **Self-service access requests** — `AccessRequest` model plus `/api/access-requests` (create, list own, list decidable, approve, deny, withdraw). Approving performs the LDAP group add, so LDAP remains the access-control truth. Requests target a resource's `member`-level group, never its `_admin` one. Replaces the "coming soon" stub.
|
||||
- **Admin access visibility**: an Access column on the directory table showing member and group counts (and flagging links whose LDAP group has been deleted), plus a "what can this user reach" lookup — the reverse question, which previously had no UI at all. Backed by `GET /api/directory-admin/access-summary` and `/user-access/:uid`.
|
||||
- `conf.directory` — `jumpHost` and `defaultSshPort`, the connection conventions the catalog renders.
|
||||
- `tests/access_request.test.js` — the request → approve → grant-is-real loop end to end, including the regression guard for the `user.groups` bug.
|
||||
|
||||
### Added — nested groups
|
||||
- **A group can now contain another group.** `groupOfNames.member` accepts any DN, so nesting needs no new schema; what it needs is *resolution*, which no released OpenLDAP performs — `memberOf` and `(member=X)` both return direct membership only. Two halves:
|
||||
- **Server-side**: the all-in-one image now builds slapd from a pinned OpenLDAP master commit (`350e9eb3`) to get the **`nestgroup`** overlay (ITS#10161), enabled with `member-filter memberof-filter memberof-values`. `member-values` is deliberately omitted — it expands `member` when reading a group, which destroys the distinction between "listed here" and "reachable via nesting" and is not recoverable afterwards. `pw-sha2` is built from contrib in the same stage; without it every existing `{SSHA512}` password would be unverifiable.
|
||||
- **Client-side**: `Group.list(dn)` computes the transitive closure itself (cycle-detected, depth-capped) when the server can't, selected by `conf.ldap.nestedGroupsServerSide` — which `docker-entrypoint.sh` derives from probing for `nestgroup.so` rather than hardcoding. Both paths are covered by the full suite.
|
||||
- `PUT`/`DELETE /api/group/:group/nested/:child` and `GET /api/group/:group/effective`, plus a **Nested** tab on each group card. Cycles are refused (409) rather than silently depth-truncated.
|
||||
- **`app_super_admin` is now seeded** (it never was) and nested into `app_sso_admin` / `app_sso_invite` / `app_sso_oauth_admin`, so the privilege is real LDAP membership visible to SSSD and sudo — not just a special case in `utils/permission.js`. Not nested into `app_sso_service_account`, which marks non-person accounts rather than granting anything.
|
||||
- Creating a directory resource nests `app_super_admin → <slug>_admin` and `<slug>_admin → <slug>_access`. Both previously required adding every super admin to every new group by hand, so they drifted.
|
||||
- `ldap_group_nesting_level = 5` in ldap-client's SSSD template, for hosts pointed at a server without `nestgroup`. Against the bundled slapd the existing `memberof=` access filter is already transitive, so SSH login inherits nesting for free.
|
||||
|
||||
### Fixed
|
||||
- `PUT /api/group/:group/:uid` returned a bare **500** when the user was already a member — common, since `groupOfNames` requires a member and so seeds whoever created the group. Now a 409 that says so.
|
||||
- Un-nesting (or removing) the last member of a group returned a 500 `ObjectClassViolationError`; now a 409 explaining that a group must keep at least one member.
|
||||
- `GET /api/user/me` derived `isAdmin` from `memberOf`, which is only transitive when `nestgroup` is present. Against a stock server an admin holding their group via nesting would get `isAdmin=false` and lose the entire admin UI while still passing every server-side permission check.
|
||||
- `utils/permission.js`'s `byGroup` checked `group.member.includes(user.dn)` per group, seeing only direct membership.
|
||||
- `/api/directory-admin/access-summary` counted `member` values; it now counts the transitive closure, which matters precisely because `app_super_admin` is nested into every resource's admin group.
|
||||
- Broken `api.html` link in the published docs (`API.md` lives at the repo root, so Jekyll never rendered one); pointed at the source, and added an API entry to the docs nav.
|
||||
|
||||
### Changed
|
||||
- `@simpleworkjs/directory-schema` bumped to `^1.1.0`, which declares the ten metadata keys the admin form has always written but the schema never listed (`port`, `externalPort`, `isExternalReachable`, `os`, `gitRepo`, `isCurrentSite` as public; `vmid`, `macAddress`, `installPath`, `systemdService` as admin-only). Undeclared keys are dropped for non-admin callers, which blanked the portal's `OS:` field, hid every service's port from users, and left machine tokens unable to read the port mapping the firewall consumer exists to render.
|
||||
- Resource metadata now includes `icon` and `tagline`, collected on the admin form (with a live icon preview) and rendered on the catalog cards.
|
||||
|
||||
## [1.10.0] - 2026-07-30
|
||||
|
||||
### Added
|
||||
- **`app_super_admin` cross-app group**: members are full admins here regardless of `app_sso_admin` membership. Bypassed centrally in `utils/permission.js`'s `byGroup`, folded into `GET /api/user/me`'s `isAdmin` flag, and added to nav/`forceLogin` gates. The same group is now also recognized by proxy and jump-host, and by `ldap-client`'s SSSD access filter (SSH login on every host).
|
||||
|
||||
### Changed
|
||||
- **Renamed the Executive page to Overview** (route, view, `/api/metrics/overview`, nav label, docs). `/executive` kept as a 301 redirect alongside the existing `/admin`, `/notifications`, `/dashboard` legacy redirects.
|
||||
|
||||
## [1.9.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **Directory modal's Associated LDAP Groups tab now supports full membership management**: view, add, and remove members/owners of each associated group directly from the tab, reusing the same `PUT`/`DELETE group/:group/:uid` routes and member-mapping pattern already used on the Groups page.
|
||||
- **`app.util.revealItem()`** (in the shared `app-base.js`, byte-identical across the 3 apps): scrolls a just-added/-edited element into view and flashes its background. Wired into the Directory table, the Groups tab's member list, and the Groups page's create-group flow.
|
||||
|
||||
### Changed
|
||||
- **Groups page's search/sort bar is now sticky**, staying visible while scrolling through a long group list. Introduces `--sw-content-offset` (set in `top.ejs` alongside `#spa-shell`'s margin-top) so an in-page sticky element can offset itself below the fixed navbar/update-banner instead of being hidden behind them.
|
||||
- **Directory table**: Kind/Name/Env/Host merged into a single "Resource" column.
|
||||
- `@simpleworkjs/frontend` bumped to `^0.2.7`.
|
||||
|
||||
## [1.8.3] - 2026-07-28
|
||||
|
||||
### Changed
|
||||
- **`profile.ejs`'s self-service API-token UI unified onto `app.modal`**, matching the pattern already shipped this round in `directory.ejs`, proxy, and jump-host: the static `#secretModal`/`#editModal` elements are retired in favor of the shared `app.modal` singleton, the always-visible inline create-form card becomes a "+ New Token" button + modal, and badge classes switch from `bg-*` to `text-bg-*`.
|
||||
- Checkmark-flash copy feedback (silently broken by FontAwesome's `<i>`→`<svg>` replacement) replaced with toast-based `copyFieldValue`, matching proxy and jump-host.
|
||||
|
||||
## [1.8.2] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **Creating a new OAuth integration didn't reliably show the "save this client secret now" reveal modal** — `saveResource()` called `app.modal.close()` immediately before conditionally showing the secret via `app.modal.open()`. `app.modal` is a singleton, and `close()` immediately followed by `open()` collides with Bootstrap's hide-transition guard. An intervening `await loadResources()` made this race unlikely to lose in practice, but not guaranteed to — found while fixing the same, guaranteed-to-lose bug in jump-host and proxy's API-token create flows.
|
||||
|
||||
## [1.8.1] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **The resource modal's "Associated LDAP Groups" autocomplete went empty after the first Add/Edit** — `loadLdapGroups()`'s fetch-once cache guard (`if (ldapGroupsCache) return;`) also skipped repopulating the `<datalist>` on every call after the first, but the modal body (including that `<datalist>`) is rebuilt fresh and empty on every `app.modal.open()`. Now the fetch is still cached, but the datalist is always repopulated.
|
||||
|
||||
## [1.8.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **Directory resource modal: General / Details / Associated LDAP Groups / Children tabs**, replacing one long form. The new Children tab lists a resource's existing children and lets you add another right from the modal.
|
||||
- **Resource audit trail**: `created_by`/`created_on`/`updated_by`/`updated_on`, shown in the modal's new footer (mirrors the convention already used by proxy's `Host` and jump-host's `ApiToken`). Existing resources predating this change show "—" until next edited.
|
||||
- **Linkable resource URLs**: `GET /directory/:slug` plus a client-side deep-link check make a resource's modal directly bookmarkable/shareable; the address bar updates to `/directory/{slug}` while its modal is open and reverts on close (including via the browser Back button).
|
||||
- **Auto-created LDAP groups are now prefixed with their nearest ancestor Site's slug** (e.g. `site_local_myhost_access` instead of `myhost_access`), so groups for same-named hosts/services under different sites no longer collide or look identical. Resources with no Site ancestor keep the old unprefixed naming.
|
||||
|
||||
### Changed
|
||||
- `@simpleworkjs/frontend` bumped to 0.2.6: `app.modal` gained the `tabs`/`footer`/`url` options (all opt-in, existing callers unaffected) plus `showTab`/`on`/`deepLinkSlug`/`formatAudit`/`footerButtons` helpers — the shared building blocks behind this release's modal work, reusable by future entity modals in any of the 3 apps.
|
||||
|
||||
### Fixed
|
||||
- The Directory's Associated LDAP Groups / Relationships lists no longer risk silently dropping their contents on a second modal open (a `jq-repeat`/DOM-rebuild timing race, now rendered manually instead).
|
||||
|
||||
### Operational note
|
||||
The new `Resource` audit fields require a schema migration on any existing deployment: `ALTER TABLE Resource ADD COLUMN created_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN created_on INTEGER; ALTER TABLE Resource ADD COLUMN updated_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN updated_on INTEGER;` (adjust types for non-sqlite dialects) — `@simpleworkjs/orm`'s `sync()` only creates missing tables, it never alters existing ones.
|
||||
|
||||
## [1.7.0] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **`formAJAX`'s loading indicator showed literal HTML** ("<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
|
||||
@@ -241,6 +618,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.
|
||||
- Merged OAuth Apps + LDAP Info into a single Integrations page.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.14.0] - 2026-08-01
|
||||
|
||||
### Added
|
||||
- Added Configuration page in the UI to manage SSO configurations stored securely in OpenBao Vault.
|
||||
- Added Discovery plugin and Scheduler integration within the Directory.
|
||||
- Re-routed Vault proxy under `/api/vault` and implemented Vault authentication headers.
|
||||
|
||||
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.16...HEAD
|
||||
[1.1.15]: https://github.com/theta42/sso-manager-node/compare/v1.1.14...v1.1.15
|
||||
[1.1.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14
|
||||
|
||||
-501
@@ -1,501 +0,0 @@
|
||||
# Deployment Guide — SSO Manager
|
||||
|
||||
Two supported deployment methods:
|
||||
|
||||
1. **Docker** — a single all-in-one image bundling the app + OpenLDAP + Redis (`docker compose up`).
|
||||
2. **Bare metal** — `install.sh` on Debian/Ubuntu (installs Node.js, OpenLDAP, Redis, the app, and a systemd unit).
|
||||
|
||||
## How configuration works
|
||||
|
||||
The app loads configuration via [`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf), which deep-merges, in order:
|
||||
|
||||
1. `conf/base.js` (committed, generic defaults)
|
||||
2. `conf/<NODE_ENV>.js` (optional)
|
||||
3. `conf/secrets.js` (gitignored — secrets + per-deployment values)
|
||||
4. **`app_*` environment variables** — the highest-precedence layer
|
||||
|
||||
Any env var whose name starts with `app_` overrides the merged config. The rest
|
||||
of the name is split on **double-underscore** (`__`) into a nested path. Values
|
||||
are `JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept
|
||||
as raw strings otherwise. Examples:
|
||||
|
||||
| Env var | Sets | Type |
|
||||
|---------|------|------|
|
||||
| `app_ldap__url=ldap://host:389` | `conf.ldap.url` | string |
|
||||
| `app_ldap__bindPassword=secret` | `conf.ldap.bindPassword` | string |
|
||||
| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) |
|
||||
| `app_oauth__jwtSecret=...` | `conf.oauth.jwtSecret` | string |
|
||||
| `app_smtp__secure=false` | `conf.smtp.secure` | boolean |
|
||||
| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number |
|
||||
| `app_name=My SSO` | `conf.name` | string |
|
||||
|
||||
> **Requires `@simpleworkjs/conf` >= 1.1.0.** The Docker image will not honor
|
||||
> `app_*` env vars on 1.0.0. Before building the image, refresh the app's
|
||||
> dependency lock from the `nodejs/` directory:
|
||||
> ```bash
|
||||
> cd nodejs && npm install @simpleworkjs/conf@^1.1.0
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Method 1: Docker (all-in-one)
|
||||
|
||||
The image (`Dockerfile.openldap`) bundles OpenLDAP, Redis, and the app in one container.
|
||||
The app connects to the bundled slapd over `localhost:389` automatically; you only
|
||||
need to set a few secrets.
|
||||
|
||||
### Setup
|
||||
|
||||
The bundled `docker-compose.yml` reads config from a bind-mounted
|
||||
`./config/sso-secrets.js` (not from a `.env` file). Copy the example, fill in
|
||||
your secrets, then build + start:
|
||||
|
||||
```bash
|
||||
mkdir -p config && chmod 700 config
|
||||
cp secrets.js.example config/sso-secrets.js
|
||||
$EDITOR config/sso-secrets.js # set ldap.bindPassword, oauth.jwtSecret, ...
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`docker-entrypoint.sh` symlinks `/config/sso-secrets.js` → `/app/conf/secrets.js`
|
||||
so `@simpleworkjs/conf` reads it, and pulls the server-side LDAP vars (base DN,
|
||||
admin password, org, domain, cert CN, JWT secret) out of the same file. No
|
||||
`app_*` env is passed — `app_*` env would override `secrets.js` (env beats the
|
||||
file in `@simpleworkjs/conf`), so the file is kept authoritative.
|
||||
|
||||
> **Your domain is entered once, as the LDAP base DN.** Set `stack.ldapBaseDn`
|
||||
> (e.g. `dc=718it,dc=biz`) and keep the LDAP DNs consistent with it — they all
|
||||
> derive from that one value: `ldap.bindDN` = `cn=admin,<dn>`,
|
||||
> `ldap.userBase` = `ou=people,<dn>`, `ldap.groupBase` = `ou=groups,<dn>`,
|
||||
> and `stack.ldapDomain` = the dotted form (`718it.biz`). `oauth.issuer` is the
|
||||
> public SSO URL (`https://<ssoHost>`). Drifting these apart (e.g. leaving
|
||||
> `ldap.bindDN` at `dc=example,dc=com` while `stack.ldapBaseDn` is your real
|
||||
> domain) makes the SSO bind against a non-existent root DN and every login
|
||||
> fails with `Invalid Credentials`.
|
||||
>
|
||||
> Running the unified `theta-env` stack? You don't hand-edit these DNs at all
|
||||
> — its `setup.sh` generates `./config/sso-secrets.js` (+ `./config/proxy-secrets.js`)
|
||||
> from a single `setup.env` (where the domain is asked once, as the base DN) with
|
||||
> random secrets, and snapshots state before rebuilds — so the DNs can't drift.
|
||||
> See the theta-env README.
|
||||
|
||||
**Quick test (defaults):** with no `./config/sso-secrets.js` the entrypoint
|
||||
falls back to env-mode with safe defaults (`dc=example,dc=com`, admin password
|
||||
`admin`, an auto-generated JWT secret) — fine for kicking the tires, not for
|
||||
production.
|
||||
|
||||
**Advanced — env vars instead of the file:** the entrypoint also supports
|
||||
config via `LDAP_*` / `app_*` env vars (env-mode, used when
|
||||
`/config/sso-secrets.js` is absent). Since the bundled compose no longer passes
|
||||
those env vars, you'd add them to its `environment:` block yourself, e.g.
|
||||
`LDAP_ADMIN_PASS`, `JWT_SECRET`, `app_oauth__issuer`. This is mainly for
|
||||
bare-metal / advanced standalone use; most deployments should use the file.
|
||||
|
||||
### What the entrypoint does
|
||||
|
||||
`docker-entrypoint.sh` (run as the container entrypoint):
|
||||
|
||||
1. If `/config/sso-secrets.js` is mounted, symlinks it to `/app/conf/secrets.js`
|
||||
and reads the server-side LDAP vars from it (secrets.js mode). Otherwise it
|
||||
derives them from `LDAP_*` env vars with safe defaults (env mode).
|
||||
2. Generates a self-signed TLS cert (unless one is already present at
|
||||
`LDAP_CERT_DIR`), generates a `slapd.conf` for the bundled OpenLDAP (`mdb`
|
||||
database, `pw-sha2`/`ppolicy`/`memberof`/`refint` modules + overlays, TLS,
|
||||
indexes, access controls), and starts `slapd -f /etc/openldap/slapd.conf`
|
||||
listening on `ldap:///` (389) and `ldaps:///` (636).
|
||||
3. Seeds the directory (base DN, `ou=people`/`ou=groups`/`ou=policies`, a default
|
||||
`pwdPolicy`, and the required SSO groups `app_sso_admin`, `app_sso_invite`,
|
||||
`app_sso_oauth_admin`, `app_sso_service_account`) — idempotently, so
|
||||
container restarts are safe.
|
||||
4. Starts a bundled Redis (the app uses `model-redis` for models/sessions and
|
||||
stores OAuth clients there), AOF+RDB persisted to `/data`, unless
|
||||
`app_redis__host` is set (then it's expected to be external).
|
||||
5. In env mode, exports `app_*` env vars so the app binds to the local slapd. In
|
||||
secrets.js mode it exports none (the app reads the file directly).
|
||||
6. `exec`s `node bin/www`.
|
||||
|
||||
### Access
|
||||
|
||||
- SSO Manager UI: `http://localhost:3001` (HTTP inside the container — put a TLS-terminating proxy in front for browser access)
|
||||
- Health check: `http://localhost:3001/health` → `{"status":"ok"}`
|
||||
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
|
||||
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
|
||||
- LDAPS (direct binds: Linux hosts, LDAP-native apps): `ldaps://<host>:636` (TLS)
|
||||
|
||||
### API tokens (personal access tokens)
|
||||
|
||||
Any logged-in user can mint a long-lived bearer token to call the management
|
||||
API from scripts/CI/other services, without a browser session. Tokens are
|
||||
self-service and authenticate **as their creator** — a token carries the
|
||||
creator's LDAP group permissions, so the same `permission.byGroup` checks apply
|
||||
(group membership is re-resolved from LDAP live on each request).
|
||||
|
||||
Create one in the UI under **API Tokens** (the token string is shown **once**),
|
||||
then use it as a bearer token:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer sso_<id>_<secret>" https://sso.example.com/api/user
|
||||
```
|
||||
|
||||
Format: `sso_<id>_<secret>` — the `id` is the lookup key, the `secret` is
|
||||
bcrypt-hashed and never stored in plaintext. Rotate or revoke a token from the
|
||||
same UI page; revocation takes effect immediately. Optional expiry (in days) at
|
||||
creation. API tokens persist in the bundled Redis, so they survive rebuilds
|
||||
(Redis is persisted via AOF — see *Backups and restore*).
|
||||
|
||||
The token has the same access as a browser session for that user — an
|
||||
`app_sso_admin`'s token can manage users/groups; a non-admin's token is limited
|
||||
to what they could do in the UI.
|
||||
|
||||
### Logs
|
||||
|
||||
The all-in-one image runs the Node app and slapd (OpenLDAP) in one container,
|
||||
both writing to the container's stdout/stderr, so `docker compose logs` is the
|
||||
primary view (slapd runs with `-d 0`, so LDAP output is there too).
|
||||
|
||||
```bash
|
||||
docker compose logs -f sso-manager # app + slapd (stdout/stderr)
|
||||
docker compose logs --tail=200 --since=10m sso-manager # recent context
|
||||
docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \
|
||||
-D "cn=admin,$LDAP_BASE_DN" -W -b "$LDAP_BASE_DN" # LDAP health check
|
||||
```
|
||||
|
||||
### Available environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LDAP_BASE_DN` | `dc=example,dc=com` | slapd suffix + app user/group base |
|
||||
| `LDAP_DOMAIN` | derived from `LDAP_BASE_DN` | DNS domain; default for `LDAP_CERT_CN` and OAuth issuer |
|
||||
| `LDAP_ADMIN_PASS` | `admin` | slapd root password + app bind password |
|
||||
| `ORG_NAME` | `SSO Manager` | org name in UI/email/group descriptions |
|
||||
| `JWT_SECRET` | auto-generated | OAuth JWT signing secret (persist it!) |
|
||||
| `OAUTH_ISSUER` | `https://sso.<LDAP_DOMAIN>` | OIDC issuer in the discovery doc (browser-facing URL) |
|
||||
| `LDAP_CERT_CN` | `LDAP_DOMAIN` | CN/SAN on the LDAPS cert (hostname clients verify against) |
|
||||
| `LDAP_CERT_DIR` | `/etc/openldap/certs` | where the entrypoint looks for `ldap.crt`+`ldap.key` (mount your own here) |
|
||||
| `SMTP_HOST`/`SMTP_PORT`/`SMTP_USER`/`SMTP_PASS`/`SMTP_FROM` | localhost / 587 / empty | outbound email |
|
||||
| `PORT` | `3001` | host port mapped to the UI |
|
||||
| `LDAPS_PORT` | `636` | host port mapped to LDAPS |
|
||||
| `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) |
|
||||
| `LDAP_SERVER_ID` | empty | Unique integer ID (e.g. 1, 2) required to enable Multi-Master replication |
|
||||
| `LDAP_REPLICATION_HOSTS` | empty | Space-separated list of other sites' LDAP URLs for replication (e.g. `ldaps://site2:636`) |
|
||||
|
||||
Any `app_*` var may also be set directly to override any config value (see the
|
||||
table at the top).
|
||||
|
||||
### LDAP TLS (LDAPS / StartTLS)
|
||||
|
||||
The bundled slapd generates a **self-signed cert** on first start (CN = `LDAP_CERT_CN`,
|
||||
valid 10 years, SAN includes the CN + `localhost` + `127.0.0.1`) and listens on
|
||||
`ldaps:///` (636) plus offers StartTLS on `ldap:///` (389). The cert is stored on the
|
||||
`ldap-certs` volume so it persists across container recreation — clients don't need
|
||||
to re-trust on every rebuild.
|
||||
|
||||
The `/integrations` page derives its LDAPS URL from the OAuth issuer by default.
|
||||
To advertise a separate, internal-only hostname (e.g. `ldap.internal.example.com`
|
||||
or `sso-manager` for Docker-internal clients), set `conf.ldap.ldapsHost` in your
|
||||
secrets file or pass `app_ldap__ldapsHost=...`. See `docs/ldap.md` for
|
||||
recommended network layouts and how to match the cert SAN to the hostname.
|
||||
|
||||
- **Trusting the self-signed cert** (clients): copy `/etc/openldap/certs/ldap.crt`
|
||||
out of the container and add it to the client's trusted CA store, or set
|
||||
`TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with:
|
||||
```bash
|
||||
docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt
|
||||
```
|
||||
- **Use your own cert** (CA-signed / internal CA): replace the `ldap-certs` named
|
||||
volume with a bind mount containing your own `ldap.crt` + `ldap.key`:
|
||||
```yaml
|
||||
volumes:
|
||||
- ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key
|
||||
```
|
||||
The entrypoint leaves existing certs untouched (idempotent).
|
||||
|
||||
> Port 389 (plain LDAP) is **not** mapped to the host by default, to avoid cleartext
|
||||
> password binds over the LAN. Direct-LDAP clients should use LDAPS (636) or
|
||||
> StartTLS. Uncomment the `389` mapping in `docker-compose.yml` only if you need
|
||||
> plain LAN binds and accept the risk.
|
||||
|
||||
### Fronting with a reverse proxy (theta42/proxy)
|
||||
|
||||
The SSO Manager runs HTTP inside the container; terminate TLS at a front proxy.
|
||||
The [`theta42/proxy`](https://github.com/theta42/proxy) is an OIDC-protected reverse
|
||||
proxy and a natural fit — it's both an **OIDC client** of the SSO Manager *and* a
|
||||
**direct LDAP client** for user lookups. To run both together:
|
||||
|
||||
1. **Put them on one Docker network** so the proxy can reach the SSO Manager
|
||||
internally at `http://sso-manager:3001` for token/userinfo (server-to-server),
|
||||
without exposing the SSO Manager's HTTP port to the internet:
|
||||
```yaml
|
||||
# in the proxy's compose, or a shared external network:
|
||||
networks:
|
||||
- sso-net
|
||||
```
|
||||
2. **Set the SSO's `OAUTH_ISSUER`** to the *browser-facing* HTTPS URL the proxy
|
||||
serves the SSO at (e.g. `https://sso.yourdomain.com`). The proxy's
|
||||
`oidc.issuer`/endpoints must match — it can get them from the SSO's
|
||||
`/.well-known/openid-configuration`. Server-to-server calls from the proxy go to
|
||||
the internal `http://sso-manager:3001` URL; only the issuer/redirect URLs must
|
||||
be public.
|
||||
3. **Register the proxy as an OAuth/OIDC client** in the SSO Manager UI, with a
|
||||
`redirectUri` matching the proxy's callback (e.g.
|
||||
`https://proxy.yourdomain.com/api/auth/oidc/callback`), and put the client
|
||||
secret in the proxy's `secrets.js`.
|
||||
4. **LDAP for the proxy**: point the proxy's `ldap.url` at
|
||||
`ldaps://sso-manager:636` (TLS, same Docker network) rather than a LAN IP, and
|
||||
create a dedicated LDAP service account under `ou=people` (e.g.
|
||||
`cn=ldapclient,ou=people,…`) via the SSO Manager UI — don't reuse the admin DN.
|
||||
|
||||
### Backups and restore
|
||||
|
||||
**What lives where**
|
||||
|
||||
| State | Location | Persisted? |
|
||||
|-------|----------|------------|
|
||||
| LDAP directory (users, groups, policies) | `ldap-data` volume (`/var/lib/ldap`) | yes (volume) |
|
||||
| LDAP TLS cert | `ldap-certs` volume (`/etc/openldap/certs`) | yes (volume) |
|
||||
| Redis (OAuth clients, tokens, sessions) | `sso-data` volume (`/data`) | yes (AOF + RDB) |
|
||||
| Secrets (LDAP admin pass, JWT secret, SMTP) | `./config/sso-secrets.js` (bind mount) | your responsibility — back up off-host |
|
||||
|
||||
**Automatic snapshots** — when run as part of the unified `theta-env` stack,
|
||||
`setup.sh` snapshots LDAP + Redis + `./config/` to `./backups/<timestamp>/`
|
||||
before every rebuild and keeps the last `BACKUP_KEEP` (default 5). Standalone
|
||||
deployments should run `ops/backup.sh` the same way (on a cron/systemd timer,
|
||||
or by hand before an upgrade):
|
||||
|
||||
```bash
|
||||
./ops/backup.sh # keeps the last 5 by default
|
||||
./ops/backup.sh 10 # or override retention
|
||||
BACKUP_KEEP=10 ./ops/backup.sh
|
||||
```
|
||||
|
||||
It snapshots LDAP (`slapcat`, auto-detecting your base DN from
|
||||
`./config/sso-secrets.js`), Redis (`BGSAVE`, falling back to a synchronous
|
||||
`SAVE` if that doesn't complete quickly), and `./config/` to
|
||||
`./backups/<timestamp>/`, pruning older backups beyond the retention count —
|
||||
the same approach `theta-env`'s `setup.sh` uses, just scoped to this one
|
||||
container. Equivalent manual steps, if you'd rather not use the script:
|
||||
|
||||
```bash
|
||||
# LDAP — full directory export (works while slapd is running)
|
||||
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
|
||||
-b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif
|
||||
|
||||
# Redis — hot snapshot: trigger a save, then copy the RDB out
|
||||
docker compose exec sso-manager redis-cli BGSAVE
|
||||
docker compose cp sso-manager:/data/dump.rdb sso-redis-$(date +%F).rdb
|
||||
|
||||
# Secrets — copy the config dir (holds LDAP_ADMIN_PASS, JWT secret, etc.)
|
||||
cp -a ./config config-backup-$(date +%F) && chmod 700 config-backup-$(date +%F)
|
||||
```
|
||||
Store the backup **off the host** — it contains secrets and the whole user
|
||||
directory.
|
||||
|
||||
**Restore — full (disaster recovery)**
|
||||
|
||||
The SSO image uses a static `slapd.conf` (slapd starts with `-f`, not `-F`
|
||||
cn=config), so LDAP restore uses `slapadd -f /etc/openldap/slapd.conf`:
|
||||
|
||||
```bash
|
||||
# 1. Secrets
|
||||
cp -a config-backup-<date> ./config && chmod 700 ./config
|
||||
./setup.sh # fresh empty volumes (or: docker compose up -d)
|
||||
docker compose stop sso-manager
|
||||
|
||||
# 2. LDAP — wipe the mdb files, then load the LDIF into the stopped directory
|
||||
docker compose run --rm --no-deps --entrypoint sh sso-manager -c \
|
||||
'rm -f /var/lib/ldap/* && slapadd -f /etc/openldap/slapd.conf -l /dev/stdin' \
|
||||
< ldap-backup-<date>.ldif
|
||||
docker compose start sso-manager
|
||||
|
||||
# 3. Redis — see the AOF note below
|
||||
docker compose stop sso-manager
|
||||
docker compose run --rm --no-deps --entrypoint sh sso-manager -c \
|
||||
'rm -f /data/appendonly.aof /data/appendonly.aof.*' # REQUIRED — see note
|
||||
docker compose cp sso-redis-<date>.rdb sso-manager:/data/dump.rdb
|
||||
docker compose start sso-manager
|
||||
```
|
||||
|
||||
**Restore — Redis only** = step 3 above. **Restore — LDAP only** = step 2 above.
|
||||
|
||||
> **AOF vs RDB (important):** with `--appendonly yes`, Redis loads
|
||||
> `appendonly.aof` on startup and **ignores** `dump.rdb` if the AOF exists. To
|
||||
> restore from an RDB snapshot you **must delete the AOF first** (step 3 does
|
||||
> this); Redis then loads the RDB and writes a fresh AOF. Verify after restoring:
|
||||
> `docker compose exec sso-manager redis-cli DBSIZE` and
|
||||
> `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`.
|
||||
|
||||
**Upgrades**
|
||||
|
||||
```bash
|
||||
./setup.sh # backs up, then rebuilds — volumes keep LDAP + Redis state
|
||||
# (standalone) docker compose pull && docker compose up -d
|
||||
```
|
||||
LDAP data and Redis state survive the rebuild because they live on named
|
||||
volumes, not in the image. Verify health (`docker compose ps`, log in, check an
|
||||
OAuth client). Note: re-running bootstrap resets the bootstrap-admin and
|
||||
service-account passwords to the values in `./config/sso-secrets.js`; non-theta
|
||||
OAuth clients live in SSO Redis and are preserved by the volume.
|
||||
|
||||
---
|
||||
|
||||
## Method 2: Bare metal (Debian/Ubuntu)
|
||||
|
||||
`install.sh` is an idempotent installer: it installs Node.js 22.x and Redis,
|
||||
force-syncs the repo to `/opt/theta42/sso-manager`, and symlinks the systemd
|
||||
config from the repo. Re-run it to update — it prints the version you're
|
||||
updating from and to (or "Already up to date" if there's nothing new).
|
||||
|
||||
On the **first run only** it also installs and configures OpenLDAP (modules +
|
||||
overlays + custom schema + directory tree + required groups — see
|
||||
`ops/ldap-setup.sh`) and seeds `/etc/sso-manager/secrets.js` with a generated
|
||||
LDAP admin password and JWT secret (SMTP is left as a placeholder). Once that
|
||||
file exists it's never touched again, and LDAP is never re-bootstrapped —
|
||||
edit the file and restart the service to change anything.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Debian 11+ / Ubuntu 20.04+
|
||||
- Root (`sudo`)
|
||||
- Internet access
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
|
||||
```
|
||||
|
||||
or, if you already have the repo checked out:
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
| Env var | Description |
|
||||
|---------|-------------|
|
||||
| `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) — first run only |
|
||||
| `LDAP_ADMIN_PASS` | LDAP admin password (default auto-generated) — first run only |
|
||||
| `JWT_SECRET` | JWT secret (default auto-generated) — first run only |
|
||||
| `ORG_NAME` | Org name (default `SSO Manager`) — first run only |
|
||||
| `PORT` | HTTP port (default `3001`) — first run only |
|
||||
| `SKIP_LDAP` | `true` to skip OpenLDAP bootstrap entirely (point at an existing server yourself) |
|
||||
| `REPO_URL`, `REPO_DIR`, `BRANCH`, `SECRETS_FILE` | Override the defaults |
|
||||
|
||||
### Post-install
|
||||
|
||||
```bash
|
||||
sudo systemctl status sso-manager
|
||||
journalctl -fu sso-manager
|
||||
curl http://localhost:3001/health # -> {"status":"ok"}
|
||||
```
|
||||
|
||||
### What `install.sh` does
|
||||
|
||||
1. Installs Node.js 22.x (NodeSource) and Redis.
|
||||
2. Clones/updates the repo at `/opt/theta42/sso-manager`.
|
||||
3. **First run only:** installs OpenLDAP (`slapd`) with `pw-sha2`, `ppolicy`,
|
||||
`memberof`, `refint` modules + overlays; the custom `theta42Person` schema
|
||||
(`dateOfBirth`); indexes; `ou=people`/`ou=groups`/`ou=policies`; a default
|
||||
`pwdPolicy`; and the SSO groups — then seeds `/etc/sso-manager/secrets.js`.
|
||||
4. Symlinks `ops/systemd/sso-manager.service` into `/etc/systemd/system` and
|
||||
runs `npm ci --omit=dev`.
|
||||
5. Enables and (re)starts the service.
|
||||
|
||||
> For an existing LDAP server, run with `SKIP_LDAP=true` and write
|
||||
> `/etc/sso-manager/secrets.js` yourself (see `secrets.js.example`) before
|
||||
> starting the service. To (re)configure overlays on an already-installed
|
||||
> slapd, use `ops/ldap-setup.sh` directly (idempotent, auto-detects the user
|
||||
> database).
|
||||
|
||||
---
|
||||
|
||||
## LDAP requirements (for any external LDAP server)
|
||||
|
||||
The app needs these on the LDAP server:
|
||||
|
||||
- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`), `ppolicy`,
|
||||
`memberof`, `refint`.
|
||||
- **Custom schema:** the `theta42Person` auxiliary objectClass with `dateOfBirth`
|
||||
(OID `1.3.6.1.4.1.99999.x`) — see `ops/ldap-setup.sh` for the LDIF.
|
||||
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN, a
|
||||
default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
|
||||
- **Required groups:** `app_sso_admin` (full admin), `app_sso_invite` (invitation
|
||||
management), `app_sso_oauth_admin` (OAuth client management),
|
||||
`app_sso_service_account` (not a permission — marks a `posixAccount` as a
|
||||
non-person service account; see docs/ldap.md).
|
||||
|
||||
`ops/ldap-setup.sh -p <admin-password>` configures all of the above idempotently
|
||||
against a running slapd (auto-detects the database holding your base DN, and
|
||||
verifies `pwdAccountLockedTime` is live — the attribute the app's
|
||||
active/inactive toggle depends on).
|
||||
|
||||
---
|
||||
|
||||
## Migrating an existing instance to the generic defaults
|
||||
|
||||
The committed `nodejs/conf/base.js` now ships **generic** defaults
|
||||
(`dc=example,dc=com`, `localhost`, `SSO Manager`). Previously it carried
|
||||
Theta42-specific values (LDAP bind DN/bases, SMTP host/user/sender, OAuth issuer).
|
||||
If you run an existing instance off this repo:
|
||||
|
||||
- Move those per-deployment, non-secret values (bind DN, user/group bases, SMTP
|
||||
host/user/sender, OAuth issuer, org name) from `base.js` into your gitignored
|
||||
`conf/secrets.js`, **or** set them as `app_*` env vars. Secret values (LDAP bind
|
||||
password, SMTP password, JWT secret) already belong in `secrets.js`.
|
||||
- After the change, verify the merged config: `node -e "console.log(require('@simpleworkjs/conf'))"` from the `nodejs/` directory.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `503 OpenLDAP ppolicy overlay is not configured`
|
||||
The ppolicy overlay isn't attached to the database holding your users, so the
|
||||
active/inactive toggle can't set `pwdAccountLockedTime`. Run:
|
||||
```bash
|
||||
sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com
|
||||
```
|
||||
|
||||
### App starts but LDAP operations 401 / "Invalid Credentials"
|
||||
Check the merged LDAP config the app actually sees:
|
||||
```bash
|
||||
cd nodejs && node -e "console.log(require('@simpleworkjs/conf').ldap)"
|
||||
```
|
||||
Confirm `url`/`bindDN`/`bindPassword`/`userBase` match your directory. Remember
|
||||
`app_*` env vars override `secrets.js` which overrides `base.js`.
|
||||
|
||||
### `app_*` env vars seem to do nothing
|
||||
You're on `@simpleworkjs/conf` 1.0.0. Bump to 1.1.0+:
|
||||
```bash
|
||||
cd nodejs && npm install @simpleworkjs/conf@^1.1.0
|
||||
```
|
||||
|
||||
### LDAP connection refused
|
||||
```bash
|
||||
docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base'
|
||||
systemctl status slapd # bare metal
|
||||
netstat -tlnp | grep 389
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security notes
|
||||
|
||||
1. **Never commit `secrets.js`** — it's in `.gitignore`.
|
||||
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
|
||||
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
|
||||
StartTLS); port 389 is not mapped to the host by default so LAN clients can't
|
||||
bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,
|
||||
`theta42/proxy`) should use `ldaps://…:636` or StartTLS.
|
||||
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
|
||||
set `JWT_SECRET`, issued tokens invalidate on container recreation.
|
||||
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
|
||||
proxy and keep `3001` on the Docker network / localhost only.
|
||||
5. **Don't port-forward LDAPS (636) to the internet either.** It's mapped to the
|
||||
host by default for LAN/VPN clients that bind LDAP directly (other hosts
|
||||
running `ldap-client`, apps with their own LDAP auth settings) — not for
|
||||
exposure through your router/firewall. LDAP simple-bind is a brute-force
|
||||
target with no rate limiting in front of it the way the HTTP login endpoints
|
||||
have. If a remote host needs to bind LDAP, put it behind a VPN (Tailscale,
|
||||
WireGuard, …) instead of forwarding 636 publicly.
|
||||
6. The all-in-one image runs slapd as the `ldap` user but the app process as root
|
||||
(matches the bare-metal systemd unit). Harden the app to a non-root user for
|
||||
production if needed.
|
||||
+106
-27
@@ -33,39 +33,119 @@ RUN if [ -n "$GIT_COMMIT" ]; then \
|
||||
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
||||
fi
|
||||
|
||||
# ── OpenLDAP from source ─────────────────────────────────────────────────────
|
||||
# We build slapd from OpenLDAP master rather than installing Alpine's packages,
|
||||
# for exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
|
||||
# 2024-03-21), which evaluates nested groups server-side. Nothing in any 2.6.x
|
||||
# release can do this -- verified: 2.6.13 ships 26 overlay modules and
|
||||
# nestgroup is not among them -- and the alternative is resolving nesting
|
||||
# separately in every consumer (this app, SSSD on each host, jump-host, proxy),
|
||||
# where any consumer that forgets silently under-grants access.
|
||||
#
|
||||
# Consequence to know about: master ships LMDB 1.0.0, whose on-disk format the
|
||||
# 0.9.x used by 2.6.x cannot read, and vice versa
|
||||
# ("MDB_INVALID: File is not an LMDB file"). Moving an existing directory onto
|
||||
# this image is a slapcat/slapadd migration, not a restart. See DEPLOYMENT.md.
|
||||
FROM node:20-alpine AS ldapbuild
|
||||
|
||||
# groff is not optional despite producing nothing we ship: the build descends
|
||||
# into doc/man unconditionally and its Makefile calls soelim, which groff
|
||||
# provides. Without it the whole `make` fails at the man-page stage
|
||||
# ("soelim: not found") long after slapd itself has compiled fine.
|
||||
RUN apk add --no-cache \
|
||||
build-base autoconf automake libtool \
|
||||
openssl-dev cyrus-sasl-dev \
|
||||
git make pkgconf util-linux-dev groff
|
||||
|
||||
# Pinned to an exact commit, not a branch tip. This is the directory server the
|
||||
# whole lab authenticates against; an unpinned `master` would mean every image
|
||||
# rebuild silently ships whatever landed upstream that morning, and a bad day on
|
||||
# master would take out logins with no way to tell what changed.
|
||||
#
|
||||
# TODO: drop this whole from-source stage once nestgroup ships in a release.
|
||||
# It is master-only today (ITS#10161, 2024-03-21); the 2.7 roadmap has slipped
|
||||
# from Fall 2024 to Fall 2025 and is still unreleased. When 2.7 lands with
|
||||
# nestgroup, revert to `apk add openldap openldap-overlay-nestgroup ...` --
|
||||
# the entrypoint already probes for nestgroup.so and needs no change, and the
|
||||
# app already keys off app_ldap__nestedGroupsServerSide either way.
|
||||
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
|
||||
|
||||
WORKDIR /src
|
||||
RUN git init -q . \
|
||||
&& git remote add origin https://git.openldap.org/openldap/openldap.git \
|
||||
&& git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \
|
||||
&& git checkout -q FETCH_HEAD \
|
||||
&& git rev-parse HEAD > /opt-openldap-commit.txt
|
||||
|
||||
# Overlays are built as loadable modules (=mod) because docker-entrypoint.sh
|
||||
# `moduleload`s them individually; nestgroup joins that set.
|
||||
RUN ./configure \
|
||||
--prefix=/opt/openldap \
|
||||
--enable-slapd \
|
||||
--enable-modules \
|
||||
--enable-mdb \
|
||||
--enable-memberof=mod \
|
||||
--enable-refint=mod \
|
||||
--enable-ppolicy=mod \
|
||||
--enable-dynlist=mod \
|
||||
--enable-nestgroup=mod \
|
||||
--enable-syncprov=mod \
|
||||
--enable-auditlog=mod \
|
||||
--with-tls=openssl \
|
||||
--with-cyrus-sasl \
|
||||
&& make depend \
|
||||
&& make -j"$(nproc)" \
|
||||
&& make install
|
||||
|
||||
# pw-sha2 provides {SSHA512}, which every existing user password is stored as.
|
||||
# It lives in contrib and is not covered by the configure flags above, so it is
|
||||
# built separately against the just-built tree -- omitting it would make every
|
||||
# user password unverifiable.
|
||||
RUN cd contrib/slapd-modules/passwd/sha2 \
|
||||
&& make prefix=/opt/openldap OPENLDAP_SRC=/src \
|
||||
&& cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install OpenLDAP and required packages.
|
||||
# Alpine splits OpenLDAP into many small subpackages; there is no catch-all
|
||||
# "openldap-overlays" package. We install exactly the backends/overlays/modules
|
||||
# the app depends on:
|
||||
# openldap-back-mdb : the mdb backend (slapd.conf uses `database mdb`)
|
||||
# openldap-overlay-ppolicy : ppolicy module + overlay (account locking)
|
||||
# openldap-overlay-memberof : reverse group membership
|
||||
# openldap-overlay-refint : referential integrity on group members
|
||||
# openldap-passwd-sha2 : pw-sha2 module ({SSHA512} user password hashing)
|
||||
# Note: Alpine does NOT ship a ppolicy.schema file — on OpenLDAP 2.6 the ppolicy
|
||||
# schema is built into ppolicy.so and registered when the module loads, so
|
||||
# docker-entrypoint.sh loads it via `moduleload ppolicy` (no schema include).
|
||||
# openssl : used by docker-entrypoint.sh to generate a JWT secret
|
||||
# Runtime libraries the from-source slapd links against, plus the app's own
|
||||
# deps. No openldap* packages here: everything LDAP comes from /opt/openldap.
|
||||
# libltdl (module loading -- slapd is useless without it, since every overlay
|
||||
# is a loadable module) and libuuid are pulled in by the source build but are
|
||||
# NOT dependencies of anything else here, so they must be named explicitly;
|
||||
# omitting them fails at runtime with "Error relocating ... lt_dlopenext:
|
||||
# symbol not found", not at build time.
|
||||
RUN apk add --no-cache \
|
||||
openldap \
|
||||
openldap-clients \
|
||||
openldap-back-mdb \
|
||||
openldap-overlay-ppolicy \
|
||||
openldap-overlay-memberof \
|
||||
openldap-overlay-refint \
|
||||
openldap-overlay-syncprov \
|
||||
openldap-overlay-auditlog \
|
||||
openldap-passwd-sha2 \
|
||||
openssl \
|
||||
libsasl \
|
||||
libltdl \
|
||||
libuuid \
|
||||
dumb-init \
|
||||
bash \
|
||||
openssl \
|
||||
redis \
|
||||
nmap \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# The openldap package already creates the `ldap` user/group, which slapd runs
|
||||
# as (see -u ldap -g ldap in docker-entrypoint.sh). Nothing to add here.
|
||||
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
||||
# Which upstream commit this slapd was built from — so a running container can
|
||||
# answer "what am I actually running" without rebuilding.
|
||||
COPY --from=ldapbuild /opt-openldap-commit.txt /opt/openldap/COMMIT
|
||||
|
||||
# The Alpine openldap package used to create these; nothing does now, and
|
||||
# docker-entrypoint.sh runs slapd as -u ldap -g ldap.
|
||||
RUN addgroup -S ldap 2>/dev/null || true \
|
||||
&& adduser -S -D -H -G ldap ldap 2>/dev/null || true
|
||||
|
||||
# docker-entrypoint.sh invokes slapd/slappasswd/ldapadd/ldapsearch by bare name
|
||||
# and probes a list of candidate module directories, so putting the from-source
|
||||
# tree first on PATH is all that is needed to redirect it. Schemas are symlinked
|
||||
# into the conventional location because the entrypoint's slapd.conf includes
|
||||
# /etc/openldap/schema/*.schema, and the app's own schemas (theta42, sudo,
|
||||
# openssh-lpk) are copied there too.
|
||||
ENV PATH="/opt/openldap/bin:/opt/openldap/sbin:/opt/openldap/libexec:${PATH}"
|
||||
RUN mkdir -p /etc/openldap/schema \
|
||||
&& for f in /opt/openldap/etc/openldap/schema/*.schema; do \
|
||||
ln -sf "$f" "/etc/openldap/schema/$(basename "$f")"; \
|
||||
done
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -90,6 +170,7 @@ COPY nodejs/services ./services
|
||||
COPY nodejs/utils ./utils
|
||||
COPY nodejs/views ./views
|
||||
COPY nodejs/public ./public
|
||||
COPY nodejs/plugins ./plugins
|
||||
|
||||
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
|
||||
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
|
||||
@@ -101,10 +182,8 @@ COPY tos.md /tos.md
|
||||
# without internet access. Same flattened-path convention as tos.md above.
|
||||
COPY README.md /README.md
|
||||
COPY CHANGELOG.md /CHANGELOG.md
|
||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
||||
COPY API.md /API.md
|
||||
COPY directory_spec.md /directory_spec.md
|
||||
COPY docs /docs
|
||||
|
||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||
|
||||
@@ -36,10 +36,8 @@ RUN mkdir -p /app/config
|
||||
COPY tos.md /tos.md
|
||||
COPY README.md /README.md
|
||||
COPY CHANGELOG.md /CHANGELOG.md
|
||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
||||
COPY API.md /API.md
|
||||
COPY directory_spec.md /directory_spec.md
|
||||
COPY docs /docs
|
||||
|
||||
# Seed script and utility
|
||||
COPY test_seed.js ./test_seed.js
|
||||
|
||||
@@ -132,6 +132,29 @@ v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full
|
||||
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
||||
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
|
||||
|
||||
## Secrets
|
||||
|
||||
Secrets are loaded from **OpenBao** at boot via
|
||||
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||
deep-merges `secret/sso-manager/conf` over the file-loaded config (fail-soft:
|
||||
if OpenBao is unreachable, boot continues from `CONF_SECRETS`). The SSO
|
||||
authenticates to OpenBao with the scoped `VAULT_TOKEN` (env, policy
|
||||
`sso-broker`) — never the root token.
|
||||
|
||||
The SSO also acts as the **vault broker** for the whole stack: it mints
|
||||
per-user (`user-<uid>`) and per-admin (`sso-admin`) tokens through the
|
||||
`sso-broker` token role and exposes the personal-secrets UI at **Vault → My
|
||||
Secrets** (`secret/users/<uid>/*`, server-side token injection + path-scope
|
||||
guard) and an admin **Apps** tab to mint scoped tokens for external apps
|
||||
(`secret/apps/<name>/*`). The old `utils/conf_manager.js` was replaced by
|
||||
`@simpleworkjs/bao-conf`; the admin **Configuration** UI (`/api/conf`) now
|
||||
writes `secret/sso-manager/conf` through `bao-conf.set`.
|
||||
|
||||
The `config/*-secrets.js` files are operator-edit seed artifacts (gitignored),
|
||||
not the authoritative store. For the full architecture, policies, token model,
|
||||
and rotation procedure, see theta-env's
|
||||
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
oidc: {
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
// Example proxy secrets file. theta-env generates a real ./config/proxy-secrets.js
|
||||
// from this shape at setup (with empty clientId/clientSecret), then bootstrap.js
|
||||
// writes the SSO-generated OAuth client creds into it AND into OpenBao
|
||||
// (secret/proxy/conf). The proxy loads it via @simpleworkjs/conf, then overlays
|
||||
// secret/proxy/conf from OpenBao via @simpleworkjs/bao-conf at boot.
|
||||
//
|
||||
// The real file is gitignored (config/*-secrets.js) — never commit live creds.
|
||||
// This .example is tracked to document the expected shape only.
|
||||
module.exports = {
|
||||
oidc: {
|
||||
// The SSO registers the proxy as an OAuth client and writes the real
|
||||
// values here (and into OpenBao). "set-me" is the bootstrap placeholder.
|
||||
clientId: 'set-me',
|
||||
clientSecret: 'set-me',
|
||||
},
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// Example secrets configuration file (file-based config).
|
||||
//
|
||||
// Bare-metal: install.sh seeds a filled-in version of this file at
|
||||
// /etc/sso-manager/secrets.js on first run (LDAP + JWT already live; only
|
||||
// SMTP is left as a placeholder). Only write this one by hand if you're
|
||||
// skipping install.sh's LDAP bootstrap (SKIP_LDAP=true) or setting up
|
||||
// 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 = {
|
||||
port: 3001,
|
||||
name: 'SSO Manager', // shown in UI and outbound email
|
||||
logo: '/static/img/theta42.svg', // nav/favicon image; point at your own file under public/ to white-label
|
||||
ldap: {
|
||||
url: 'ldap://localhost', // or ldaps://host:636 for TLS
|
||||
bindDN: 'cn=admin,dc=example,dc=com',
|
||||
bindPassword: 'ldap-admin-pass',
|
||||
userBase: 'ou=people,dc=example,dc=com',
|
||||
groupBase: 'ou=groups,dc=example,dc=com',
|
||||
// ldapsHost: 'ldap.internal.example.com', // optional: hostname shown for
|
||||
// direct LDAPS binds on /integrations. Leave empty to derive from the
|
||||
// OAuth issuer. Set an internal-only name to avoid port-forwarding 636.
|
||||
// ldapsPort: 636,
|
||||
},
|
||||
smtp: {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false, // true for 465, false for other ports
|
||||
user: 'noreply@example.com',
|
||||
pass: 'your-smtp-password',
|
||||
from: 'SSO Manager <noreply@example.com>',
|
||||
},
|
||||
voipms: {
|
||||
username: '', // VoIP.ms username (optional)
|
||||
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
-2
@@ -76,11 +76,22 @@ fi
|
||||
# ── Locate the OpenLDAP module directory ────────────────────────────────────
|
||||
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
|
||||
# path varies by distro; auto-detect rather than hardcode.
|
||||
# /opt/openldap/libexec/openldap is first: that is the from-source build (see
|
||||
# Dockerfile.openldap), which is the only one carrying the nestgroup overlay.
|
||||
MODULE_PATH=""
|
||||
for p in /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
|
||||
for p in /opt/openldap/libexec/openldap /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
|
||||
if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi
|
||||
done
|
||||
|
||||
# Nested-group support is only available when slapd was built with the
|
||||
# nestgroup overlay. Detect rather than assume, so this entrypoint still
|
||||
# produces a working slapd.conf against a distro OpenLDAP (where the app falls
|
||||
# back to resolving nesting itself -- see nodejs/models/group_ldap.js).
|
||||
NESTGROUP_AVAILABLE=0
|
||||
if [[ -n "$MODULE_PATH" && -f "$MODULE_PATH/nestgroup.so" ]]; then
|
||||
NESTGROUP_AVAILABLE=1
|
||||
fi
|
||||
|
||||
# ── TLS certificate for LDAPS / StartTLS ────────────────────────────────────
|
||||
# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the
|
||||
# network. To keep password binds off the wire in cleartext we expose LDAPS
|
||||
@@ -140,6 +151,7 @@ moduleload ppolicy
|
||||
moduleload memberof
|
||||
moduleload refint
|
||||
moduleload auditlog
|
||||
NESTGROUP_MODULE_PLACEHOLDER
|
||||
SYNCPROV_MODULE_PLACEHOLDER
|
||||
|
||||
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
||||
@@ -188,6 +200,8 @@ memberof-memberof-ad memberOf
|
||||
overlay refint
|
||||
refint_attributes memberOf member manager owner
|
||||
|
||||
NESTGROUP_OVERLAY_PLACEHOLDER
|
||||
|
||||
# auditlog overlay (LDIF audit trail of all changes)
|
||||
overlay auditlog
|
||||
auditlog /var/lib/ldap/auditlog.ldif
|
||||
@@ -221,6 +235,37 @@ else
|
||||
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
||||
fi
|
||||
|
||||
# ── Nested groups (nestgroup overlay) ──
|
||||
# Three of the four flags, deliberately:
|
||||
#
|
||||
# member-filter (member=X) finds parent groups transitively. This is what
|
||||
# Group.list(dn) rides on -- the core access question.
|
||||
# memberof-filter (memberOf=X) matches members of nested groups. This is
|
||||
# what SSSD's ldap_access_filter uses, so SSH/sudo inherit
|
||||
# nesting without any client-side walking.
|
||||
# memberof-values expands memberOf when reading a user, so anything that
|
||||
# reads the attribute rather than searching still sees the
|
||||
# full picture.
|
||||
#
|
||||
# member-values is deliberately NOT enabled. It expands the `member` attribute
|
||||
# when reading a *group*, which sounds symmetric but destroys the distinction
|
||||
# between "listed on this group" and "reachable through a nested one" -- and
|
||||
# that distinction is not recoverable afterwards, because the raw values are
|
||||
# simply not returned. The Groups UI needs it to show nested groups as nested
|
||||
# rather than as a crowd of phantom users, and un-nesting needs it to know what
|
||||
# it is actually removing. Transitive *answers* come from the filter flags and
|
||||
# from Group.effectiveMembers(), which computes the closure explicitly.
|
||||
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||
info "nestgroup overlay available — nested groups resolved server-side"
|
||||
sed -i "s|^NESTGROUP_MODULE_PLACEHOLDER$|moduleload nestgroup|" /etc/openldap/slapd.conf
|
||||
NESTGROUP_BLOCK="# nestgroup overlay (server-side nested group evaluation)\noverlay nestgroup\nnestgroup-base ou=groups,${LDAP_BASE_DN}\nnestgroup-flags member-filter memberof-filter memberof-values"
|
||||
sed -i "s|^NESTGROUP_OVERLAY_PLACEHOLDER$|${NESTGROUP_BLOCK}|" /etc/openldap/slapd.conf
|
||||
else
|
||||
info "nestgroup overlay not present in ${MODULE_PATH:-<no module path>} — nested groups will be resolved by the app instead"
|
||||
sed -i "/^NESTGROUP_MODULE_PLACEHOLDER$/d" /etc/openldap/slapd.conf
|
||||
sed -i "/^NESTGROUP_OVERLAY_PLACEHOLDER$/d" /etc/openldap/slapd.conf
|
||||
fi
|
||||
|
||||
# ── Multi-Master Replication Configuration ──
|
||||
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
|
||||
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
|
||||
@@ -310,7 +355,7 @@ EOF
|
||||
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
||||
# app_sso_service_account is a marker (not a permission gate) for
|
||||
# non-person accounts -- see the Users page.
|
||||
for group in app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
||||
for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
||||
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||
objectClass: groupOfNames
|
||||
@@ -321,6 +366,27 @@ member: ${LDAP_BIND_DN}
|
||||
EOF
|
||||
done
|
||||
|
||||
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
|
||||
# hold those rights by membership rather than by a special case in app code.
|
||||
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
|
||||
# anything binding LDAP directly -- instead of only to callers that happen
|
||||
# to route through utils/permission.js.
|
||||
#
|
||||
# app_sso_service_account is deliberately excluded: it is a marker for
|
||||
# non-person accounts, not a permission, and nesting admins into it would
|
||||
# misclassify them as service accounts on the Users page.
|
||||
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
|
||||
ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true
|
||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||
changetype: modify
|
||||
add: member
|
||||
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
|
||||
EOF
|
||||
done
|
||||
info "Nested app_super_admin into the SSO admin groups"
|
||||
fi
|
||||
|
||||
info "LDAP directory initialized"
|
||||
else
|
||||
info "LDAP directory already initialized — skipping seed"
|
||||
@@ -380,6 +446,14 @@ if [[ "${SECRETS_JS_MODE:-0}" != 1 ]]; then
|
||||
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
|
||||
export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
|
||||
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}"
|
||||
# Tell the app whether slapd resolves nested groups for it. When true the app
|
||||
# trusts a plain (member=) search to be transitive; when false it computes
|
||||
# the closure itself. Getting this wrong in the "true" direction silently
|
||||
# under-grants, so it is derived from the same nestgroup.so probe that
|
||||
# decides whether the overlay is configured at all -- never hardcoded.
|
||||
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||
export app_ldap__nestedGroupsServerSide="${app_ldap__nestedGroupsServerSide:-true}"
|
||||
fi
|
||||
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
|
||||
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
|
||||
# public https URL on the SSO subdomain of the LDAP domain; override with
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
title: SSO Manager
|
||||
description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI, for home labs and small businesses that want their own identity provider.
|
||||
url: "https://theta42.github.io"
|
||||
baseurl: "/sso-manager-node"
|
||||
logo: /assets/img/theta42.svg
|
||||
lang: en_US
|
||||
|
||||
plugins:
|
||||
- jekyll-seo-tag
|
||||
- jekyll-sitemap
|
||||
|
||||
github:
|
||||
repository_url: https://github.com/theta42/sso-manager-node
|
||||
zip_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.zip
|
||||
tar_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.tar.gz
|
||||
repository_name: theta42/sso-manager-node
|
||||
|
||||
nav:
|
||||
- title: Home
|
||||
page: /
|
||||
icon: fa-house
|
||||
- title: Deployment
|
||||
page: /deployment.html
|
||||
icon: fa-server
|
||||
- title: Configuration
|
||||
page: /configuration.html
|
||||
icon: fa-gears
|
||||
- title: OAuth
|
||||
page: /oauth.html
|
||||
icon: fa-key
|
||||
- title: LDAP
|
||||
page: /ldap.html
|
||||
icon: fa-address-book
|
||||
- title: Directory
|
||||
page: /directory.html
|
||||
icon: fa-server
|
||||
- title: Changelog
|
||||
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
||||
icon: fa-list
|
||||
|
||||
defaults:
|
||||
- scope:
|
||||
path: ""
|
||||
type: "pages"
|
||||
values:
|
||||
layout: default
|
||||
image: /assets/img/theta42.svg
|
||||
@@ -1,82 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/theta42.svg' | relative_url }}">
|
||||
|
||||
{% seo title=false %}
|
||||
<title>{% if page.title %}{{ page.title }} · {% endif %}{{ site.title }}</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100">
|
||||
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
|
||||
<div class="container-fluid px-3">
|
||||
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
|
||||
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
|
||||
{{ site.title }}
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navMain">
|
||||
<ul class="navbar-nav">
|
||||
{% for item in site.nav %}
|
||||
<li class="nav-item">
|
||||
{% if item.page %}
|
||||
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
|
||||
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
|
||||
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="flex-grow-1" style="margin-top: 4.5rem;">
|
||||
<div class="container-fluid py-4 py-md-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 col-xl-8">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-body p-4 p-md-5 site-content">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="py-3 bg-dark text-light mt-auto">
|
||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<a href="https://theta42.com" target="_blank" rel="noopener">
|
||||
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
|
||||
</a>
|
||||
© {{ 'now' | date: '%Y' }} theta42 ·
|
||||
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
|
||||
</span>
|
||||
<span class="d-flex align-items-center gap-3">
|
||||
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
||||
<i class="fa-brands fa-github"></i> GitHub
|
||||
</a>
|
||||
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
||||
<i class="fa-solid fa-list"></i> Changelog
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,116 +0,0 @@
|
||||
/* theta42 docs site — shares the in-app dark navbar/footer + card look
|
||||
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
|
||||
generic Jekyll theme. */
|
||||
|
||||
body {
|
||||
background-color: #f4f5f6;
|
||||
}
|
||||
|
||||
.navbar-brand img {
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link.active {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Markdown content typography, scoped to the card body so it doesn't leak
|
||||
into the nav/footer. */
|
||||
.site-content h1:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.site-content h1,
|
||||
.site-content h2,
|
||||
.site-content h3 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.site-content h2 {
|
||||
margin-top: 2.5rem;
|
||||
padding-bottom: .4rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.site-content h3 {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.site-content a {
|
||||
color: #a3671f;
|
||||
text-decoration-color: rgba(163, 103, 31, .35);
|
||||
}
|
||||
|
||||
.site-content a:hover {
|
||||
color: #8a5a16;
|
||||
}
|
||||
|
||||
.site-content pre {
|
||||
background-color: #212529;
|
||||
color: #f8f9fa;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: .375rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.site-content code {
|
||||
color: #a3671f;
|
||||
background-color: #f4f0e8;
|
||||
padding: .15em .4em;
|
||||
border-radius: .25rem;
|
||||
font-size: .875em;
|
||||
}
|
||||
|
||||
.site-content pre code {
|
||||
color: inherit;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.site-content table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.site-content table th,
|
||||
.site-content table td {
|
||||
border: 1px solid #dee2e6;
|
||||
padding: .5rem .75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.site-content table th {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.site-content blockquote {
|
||||
border-left: 4px solid #C59341;
|
||||
padding: .5rem 1rem;
|
||||
margin: 1.25rem 0;
|
||||
background-color: #f8f6f1;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.site-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Screenshot grids in the markdown use width="49%" inline attrs for a
|
||||
two-up desktop layout -- stack them on narrow screens instead of
|
||||
squeezing to illegibility. */
|
||||
@media (max-width: 576px) {
|
||||
.site-content img[width] {
|
||||
width: 100% !important;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.site-content hr {
|
||||
margin: 2rem 0;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#C59341" />
|
||||
<stop offset="20%" stop-color="#E4B869" />
|
||||
<stop offset="40%" stop-color="#FBF0B9" />
|
||||
<stop offset="60%" stop-color="#DFB260" />
|
||||
<stop offset="80%" stop-color="#BC8837" />
|
||||
<stop offset="100%" stop-color="#A36F28" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#FFFFFF" />
|
||||
<stop offset="40%" stop-color="#F5E3B5" />
|
||||
<stop offset="70%" stop-color="#D4A343" />
|
||||
<stop offset="100%" stop-color="#8A5A16" />
|
||||
</linearGradient>
|
||||
|
||||
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#drop-shadow)">
|
||||
<g fill="url(#gold-grad)">
|
||||
<path d="M 200,40
|
||||
C 290,40 350,110 350,200
|
||||
C 350,290 290,360 200,360
|
||||
C 110,360 50,290 50,200
|
||||
C 50,110 110,40 200,40 Z
|
||||
M 200,75
|
||||
C 130,75 88,130 88,200
|
||||
C 88,270 130,325 200,325
|
||||
C 270,325 312,270 312,200
|
||||
C 312,130 270,75 200,75 Z"
|
||||
fill-rule="evenodd" />
|
||||
|
||||
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
|
||||
|
||||
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
|
||||
</g>
|
||||
|
||||
<text x="200" y="222"
|
||||
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
font-size="78"
|
||||
font-weight="900"
|
||||
fill="url(#text-grad)"
|
||||
text-anchor="middle"
|
||||
letter-spacing="-2">42</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,102 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: Accounts, Groups & Managers
|
||||
description: A plain-language guide to users, service accounts, personal groups, and managers in SSO Manager.
|
||||
---
|
||||
|
||||
# Accounts, Groups & Managers
|
||||
|
||||
This page explains the concepts behind the Users and Groups pages in plain
|
||||
language. If you want the technical schema/attribute-level detail instead,
|
||||
see the [LDAP reference](ldap.html).
|
||||
|
||||
## What's an account?
|
||||
|
||||
Every person (or app) that can sign in through this SSO Manager has an
|
||||
**account** — a username, a display name, maybe an email address, and a
|
||||
password (or, for service accounts, no password at all — see below).
|
||||
Accounts live in the directory this app manages, and any other app you've
|
||||
connected (Gitea, Home Assistant, your Wi-Fi, whatever) checks against these
|
||||
same accounts instead of keeping its own separate list of users and
|
||||
passwords.
|
||||
|
||||
## Two kinds of account: people and service accounts
|
||||
|
||||
Most accounts belong to an actual person — check **Users → People** to see
|
||||
them. But sometimes you need an account for something that *isn't* a
|
||||
person: a media server, a backup script, a bind account another app uses to
|
||||
look people up. These are **service accounts**, listed separately under
|
||||
**Users → Service Accounts**, and they're different from a person's account
|
||||
in two ways that matter:
|
||||
|
||||
- **No email required.** A service account doesn't need a mailbox, so the
|
||||
form doesn't ask for one.
|
||||
- **A password is optional.** If you leave it blank, nobody can log in as
|
||||
that account — which is exactly what you want for something that only
|
||||
ever gets used programmatically (a script authenticating with an API
|
||||
token, or another app binding with a fixed, separately-configured
|
||||
password you set yourself). Only give it a password if the account
|
||||
genuinely needs to log in or bind somewhere as itself.
|
||||
|
||||
Aside from those two differences, a service account is a completely normal
|
||||
account under the hood — it can belong to groups, have a manager, and so
|
||||
on, just like anyone else's.
|
||||
|
||||
## Groups: who can do what
|
||||
|
||||
A **group** is just a named list of accounts, used to control access. This
|
||||
app has a handful of built-in groups that grant admin powers (e.g. only
|
||||
people in the `app_sso_admin` group can see the Users/Groups/Integrations
|
||||
pages at all), but you can also make your own groups for any app you
|
||||
connect — say, a group listing everyone who should be allowed into your
|
||||
photo server. Once a group exists, add or remove members from the
|
||||
**Groups** page, and point the other app's "who's allowed in" setting at
|
||||
that group's name.
|
||||
|
||||
## Every account's personal group
|
||||
|
||||
Separately from the groups above, every single account — person or
|
||||
service account — automatically gets its own small, personal group when
|
||||
it's created, named after the account itself. Most of the time you'll
|
||||
never think about this; it exists so that, on a Linux system connected to
|
||||
this directory, each account "owns" its own files by default the same way
|
||||
a normal Unix user account would.
|
||||
|
||||
Occasionally you'll want to share that ownership with someone else — for
|
||||
example, letting a second account also have write access to files a
|
||||
service account owns. That's what the **"Members of `<uid>`'s group"**
|
||||
section on a profile page is for: add another account there, and the
|
||||
underlying Linux permissions treat them as if they belong to that same
|
||||
personal group too.
|
||||
|
||||
## What's a "manager"?
|
||||
|
||||
Every account has one or more **managers** — the people allowed to edit
|
||||
that account's profile (phone number, SSH key, home directory, and so on)
|
||||
without needing full admin rights. By default, whoever created an account
|
||||
(the admin who added it, or whoever sent the invite) becomes its first
|
||||
manager, but you can add or remove managers later from the account's Edit
|
||||
form.
|
||||
|
||||
This is useful for service accounts especially: if a service account
|
||||
belongs to a particular project or person, make them its manager so they
|
||||
can maintain it — rotate its SSH key, adjust its description — without
|
||||
needing to be a full SSO administrator.
|
||||
|
||||
## Inviting someone vs. adding them yourself
|
||||
|
||||
From the Users page you can either fill in someone's details yourself
|
||||
("Add new user"), or send them an **invite** — an email (or a link you copy
|
||||
and send however you like) that lets them pick their own username and
|
||||
password. Either way, the resulting account is identical; invites are just
|
||||
a convenience so you don't have to know someone's preferred username or
|
||||
handle their password directly.
|
||||
|
||||
## Want more detail?
|
||||
|
||||
This page deliberately leaves out LDAP schema names, attribute types, and
|
||||
protocol-level detail. If you're connecting a third-party app directly to
|
||||
the LDAP directory, or you just want to know exactly what's stored where,
|
||||
see the [LDAP reference](ldap.html).
|
||||
|
||||
[← Back to Home](index.html)
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: API Tokens
|
||||
description: A plain-language guide to personal access tokens in SSO Manager.
|
||||
---
|
||||
|
||||
# API Tokens
|
||||
|
||||
This page explains what an API token is and when you'd want one. For the
|
||||
full list of API endpoints a token can call, see the
|
||||
[API reference](api.html).
|
||||
|
||||
## What's an API token, in plain terms?
|
||||
|
||||
Normally, you interact with this app by logging in through a web browser.
|
||||
An **API token** (also called a personal access token, or PAT) is an
|
||||
alternative way in — a long, random string that a script, a scheduled job,
|
||||
or another program can use instead of a username and password, to act on
|
||||
your behalf without a human typing a login in each time.
|
||||
|
||||
If you've ever set up a script to talk to GitHub, GitLab, or a similar
|
||||
service using a "token" instead of your real password, this is the same
|
||||
idea.
|
||||
|
||||
## When would you actually need one?
|
||||
|
||||
Most people never need to create one of these — you'll only want a token
|
||||
if you're automating something, for example:
|
||||
|
||||
- A script that syncs users or groups from somewhere else into this SSO
|
||||
Manager on a schedule.
|
||||
- A backup or monitoring job that checks this app's health via its API.
|
||||
- A CI/CD pipeline that needs to register or update an OAuth client
|
||||
automatically.
|
||||
|
||||
If you're not doing any of that, you don't need an API token — just log in
|
||||
normally through the web UI.
|
||||
|
||||
## How it works
|
||||
|
||||
Create a token from your Profile page, give it a name so you remember what
|
||||
it's for later, and optionally an expiry. You'll be shown the token's
|
||||
value **exactly once** — copy it somewhere safe immediately, because it
|
||||
can't be viewed again afterward (only revoked or rotated). Whatever script
|
||||
or tool you're using it with sends it along with each request, the same
|
||||
way a browser sends your login session.
|
||||
|
||||
A token acts **as you**, with **your** permissions — if you're not an
|
||||
admin, a token you create can't do admin-only things either. If you ever
|
||||
suspect a token has leaked (ended up somewhere it shouldn't have, like a
|
||||
public script or log file), revoke it immediately from your Profile page;
|
||||
it stops working right away.
|
||||
|
||||
## Want more detail?
|
||||
|
||||
This page doesn't attempt to list every API endpoint or show request/
|
||||
response examples — for that, see the full [API reference](api.html).
|
||||
|
||||
[← Back to Home](index.html)
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: Connecting Apps (Single Sign-On)
|
||||
description: A plain-language guide to OAuth/OIDC clients and single sign-on in SSO Manager.
|
||||
---
|
||||
|
||||
# Connecting Apps (Single Sign-On)
|
||||
|
||||
This page explains, in plain language, what happens when you "connect" an
|
||||
app to your SSO Manager so people can log into it with their existing
|
||||
account. For the technical endpoint/token detail, see the
|
||||
[OAuth reference](oauth.html).
|
||||
|
||||
## What does "single sign-on" actually mean?
|
||||
|
||||
Instead of every app you run having its own separate list of usernames and
|
||||
passwords, they all check with this SSO Manager instead. You log in once,
|
||||
here, and any connected app trusts that login — no separate password to
|
||||
remember or manage for each one. If you ever need to lock someone out
|
||||
everywhere at once, you do it in one place (deactivate their account here)
|
||||
instead of hunting down every app individually.
|
||||
|
||||
The technology behind this is called **OAuth 2.0** and **OpenID Connect
|
||||
(OIDC)** — you'll see both names used, often together, referring to the
|
||||
same thing. You don't need to understand the protocol to use this page;
|
||||
what matters practically is the handful of concepts below.
|
||||
|
||||
## What's a "client"?
|
||||
|
||||
Every app you connect is registered here as a **client** — a single entry
|
||||
on the Integrations page representing that one app. Registering a client
|
||||
gives you a **Client ID** and **Client Secret**: think of these like a
|
||||
username and password, but for the *app itself* rather than for a person.
|
||||
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
|
||||
screen, along with the discovery URL shown at the top of this page, and
|
||||
that app is now able to ask this SSO Manager to authenticate people on its
|
||||
behalf.
|
||||
|
||||
**Treat the Client Secret like a password** — anyone who has it can
|
||||
impersonate that app when talking to your SSO Manager. If you ever suspect
|
||||
it's leaked, rotate it from the client's card.
|
||||
|
||||
## What are "scopes"?
|
||||
|
||||
**Scopes** control what information a connected app is allowed to ask for
|
||||
about the person logging in — their username, email, group memberships,
|
||||
and so on. Most apps tell you exactly which scopes they need in their own
|
||||
setup instructions; when in doubt, the default set (`openid`, `profile`,
|
||||
`email`, `groups`) covers what nearly every app expects.
|
||||
|
||||
## "Restrict to Groups"
|
||||
|
||||
By default, *any* account with an SSO Manager login can sign into a
|
||||
connected app. If that's not what you want — say, a home automation
|
||||
dashboard that only certain family members should reach — set **Restrict
|
||||
to Groups** on that client to one of your [groups](concepts-accounts.html).
|
||||
Only members of that group will be allowed to log into that particular
|
||||
app; everyone else gets turned away at the login step, even though their
|
||||
SSO Manager account still works everywhere else.
|
||||
|
||||
## Redirect URIs
|
||||
|
||||
A **Redirect URI** is the exact web address the connected app wants people
|
||||
sent back to once they've logged in here — it's a security measure so an
|
||||
attacker can't trick the login flow into redirecting somewhere else. The
|
||||
app's own setup instructions will tell you this value; copy it in exactly
|
||||
as given. If the app is reachable via more than one hostname (for example,
|
||||
because it sits behind [theta42/proxy](https://theta42.github.io/proxy/)),
|
||||
this field supports wildcard patterns — see the inline help under the
|
||||
field itself for the exact syntax.
|
||||
|
||||
## Want more detail?
|
||||
|
||||
This page intentionally skips the protocol-level detail (exact endpoint
|
||||
URLs, token formats, claim names). If you're troubleshooting a connection
|
||||
or building something against the API directly, see the
|
||||
[OAuth reference](oauth.html).
|
||||
|
||||
[← Back to Home](index.html)
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: Configuration
|
||||
description: SSO Manager's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables.
|
||||
---
|
||||
|
||||
# Configuration
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
The app loads configuration via
|
||||
[`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf), which
|
||||
deep-merges, in order (later wins):
|
||||
|
||||
1. `conf/base.js` — committed, generic defaults (`dc=example,dc=com`,
|
||||
`localhost`, `SSO Manager`).
|
||||
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
|
||||
3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
|
||||
4. **`app_*` environment variables** — the highest-precedence layer.
|
||||
|
||||
Any env var whose name starts with `app_` overrides the merged config. The rest
|
||||
of the name splits on **double-underscore** (`__`) into a nested path. Values are
|
||||
`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as
|
||||
raw strings otherwise.
|
||||
|
||||
## Examples
|
||||
|
||||
| Env var | Sets | Type |
|
||||
|---------|------|------|
|
||||
| `app_ldap__url=ldap://host:389` | `conf.ldap.url` | string |
|
||||
| `app_ldap__bindPassword=secret` | `conf.ldap.bindPassword` | string |
|
||||
| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string |
|
||||
| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) |
|
||||
| `app_ldap__uidGidReservedFloor=9000` | `conf.ldap.uidGidReservedFloor` | number (ids at/above this are ignored when allocating) |
|
||||
| `app_ldap__ldapsHost=ldap.internal.example.com` | `conf.ldap.ldapsHost` | string (hostname shown on `/integrations` for LDAPS binds; empty = derive from `oauth.issuer`) |
|
||||
| `app_ldap__ldapsPort=636` | `conf.ldap.ldapsPort` | number (port shown on `/integrations`) |
|
||||
| `app_oauth__jwtSecret=...` | `conf.oauth.jwtSecret` | string |
|
||||
| `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | string |
|
||||
| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number |
|
||||
| `app_smtp__secure=false` | `conf.smtp.secure` | boolean |
|
||||
| `app_smtp__host=smtp.example.com` | `conf.smtp.host` | string |
|
||||
| `app_name=My SSO` | `conf.name` | string |
|
||||
| `app_redis__host=redis.local` | `conf.redis.host` | string (external Redis) |
|
||||
|
||||
## The `app_*` env layer requires conf >= 1.1.0
|
||||
|
||||
The `app_*` environment-variable override layer was added in
|
||||
`@simpleworkjs/conf` **1.1.0**. On 1.0.0 the app ignores all `app_*` vars and only
|
||||
reads `base.js` / `<NODE_ENV>.js` / `secrets.js`. The Docker image will not honor
|
||||
`app_*` env on 1.0.0. Refresh the lock from the `nodejs/` directory:
|
||||
|
||||
```bash
|
||||
cd nodejs && npm install @simpleworkjs/conf@^1.1.0
|
||||
```
|
||||
|
||||
## Inspecting the merged config
|
||||
|
||||
From the `nodejs/` directory:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('@simpleworkjs/conf').ldap)"
|
||||
node -e "console.log(require('@simpleworkjs/conf').oauth)"
|
||||
node -e "console.log(require('@simpleworkjs/conf'))" # everything
|
||||
```
|
||||
|
||||
Or, inside the running container:
|
||||
|
||||
```bash
|
||||
docker compose exec sso-manager node -e "console.log(require('@simpleworkjs/conf').ldap)"
|
||||
```
|
||||
|
||||
`app_*` env vars override `secrets.js`, which overrides `base.js` — if a value
|
||||
isn't what you expect, check those layers in that order.
|
||||
|
||||
## Migrating an existing instance to the generic defaults
|
||||
|
||||
The committed `nodejs/conf/base.js` ships **generic** defaults
|
||||
(`dc=example,dc=com`, `localhost`, `SSO Manager`). Previously it carried
|
||||
Theta42-specific values (LDAP bind DN/bases, SMTP host/user/sender, OAuth
|
||||
issuer). If you run an existing instance off this repo:
|
||||
|
||||
- Move per-deployment, non-secret values (bind DN, user/group bases, SMTP
|
||||
host/user/sender, OAuth issuer, org name) from `base.js` into your gitignored
|
||||
`conf/secrets.js`, **or** set them as `app_*` env vars.
|
||||
- Secret values (LDAP bind password, SMTP password, JWT secret) already belong
|
||||
in `secrets.js`.
|
||||
|
||||
## Troubleshooting `app_*` env vars
|
||||
|
||||
### `app_*` vars seem to do nothing
|
||||
|
||||
You're on `@simpleworkjs/conf` 1.0.0. Bump to 1.1.0+ (above).
|
||||
|
||||
### LDAP operations 401 / "Invalid Credentials"
|
||||
|
||||
Check the merged LDAP config the app actually sees:
|
||||
|
||||
```bash
|
||||
cd nodejs && node -e "console.log(require('@simpleworkjs/conf').ldap)"
|
||||
```
|
||||
|
||||
Confirm `url` / `bindDN` / `bindPassword` / `userBase` match your directory.
|
||||
|
||||
[← Back to Home](index.html)
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: Deployment
|
||||
description: Deploying SSO Manager — the all-in-one Docker image, bare-metal install, config layers, and backups.
|
||||
---
|
||||
|
||||
# Deployment Guide
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
The full deployment guide — Docker (all-in-one image), bare-metal install,
|
||||
the `app_*` env reference, backups, and the security notes (including why
|
||||
LDAPS shouldn't be port-forwarded to the internet) — lives in one place to
|
||||
avoid two copies drifting out of sync:
|
||||
|
||||
**[DEPLOYMENT.md on GitHub](https://github.com/theta42/sso-manager-node/blob/master/DEPLOYMENT.md)**
|
||||
|
||||
See also [Configuration](configuration.html) for the config layer merge
|
||||
order, and [LDAP](ldap.html) for the directory layout and connecting a
|
||||
3rd-party app.
|
||||
|
||||
[← Back to Home](index.html)
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: Directory Management
|
||||
description: Managing your Home-Lab infrastructure, services, and LDAP access relationships via the SSO Directory API.
|
||||
---
|
||||
|
||||
# Directory Management
|
||||
|
||||
The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services.
|
||||
|
||||
## Architecture
|
||||
|
||||
The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`).
|
||||
|
||||
There are three primary **Kinds** of resources you can define:
|
||||
- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents.
|
||||
- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host.
|
||||
- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service.
|
||||
- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service.
|
||||
|
||||
By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure.
|
||||
|
||||
## Automatic LDAP Group Creation
|
||||
|
||||
When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource:
|
||||
|
||||
1. `<slug>_access` (Member level access)
|
||||
2. `<slug>_admin` (Owner level access)
|
||||
|
||||
For example, if you create a Service named "Emby" with the slug `app_emby`, the system will create the LDAP groups `app_emby_access` and `app_emby_admin`. You can then assign users to these groups, and they will immediately see the service populate on their "My Services" dashboard.
|
||||
|
||||
## Resource Metadata
|
||||
|
||||
Resources carry a flexible `metadata` JSON object that can store essential context for your applications. The UI natively supports the following metadata fields:
|
||||
|
||||
### Common Metadata
|
||||
- **Sub Type**: Free-form text to categorize the resource (e.g., `proxmox_node`, `linux`, `lxc`, `web`).
|
||||
- **IP Address**: The internal IP address of the resource.
|
||||
- **MAC Address**: The hardware address of the primary interface.
|
||||
- **Host / URI Address**: The FQDN or URL of the resource (e.g., `https://emby.home.arpa`).
|
||||
- **Production Environment**: A boolean toggle indicating if the resource is in production.
|
||||
|
||||
### Host Metadata
|
||||
- **VMID**: The hypervisor VM or Container ID (e.g. `101`).
|
||||
- **OS**: The operating system name (e.g. `Ubuntu 22.04.3 LTS`).
|
||||
- **Kernel**: The kernel version string (e.g. `5.15.0-100-generic`).
|
||||
|
||||
### Service Metadata
|
||||
- **Internal Port**: The local port the service binds to (e.g. `8080`).
|
||||
- **External Port**: The reverse-proxy or external port (defaults to Internal Port if left blank).
|
||||
- **Public (No Auth)**: Indicates if the service is exposed publicly without authentication.
|
||||
- **External Reachable**: Indicates if the service is accessible outside the VPN/local network.
|
||||
- **Git Repo**: The source code repository for the service (e.g. `https://github.com/...`).
|
||||
- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
|
||||
- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`).
|
||||
|
||||
## 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.
|
||||
|
||||
## Slug conventions
|
||||
|
||||
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
|
||||
|
||||
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
|
||||
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
|
||||
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
|
||||
|
||||
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_admin`), so keep slugs stable once access groups are in use.
|
||||
|
||||
## Automatic registration
|
||||
|
||||
You don't have to build the graph by hand — the theta42 tooling registers itself:
|
||||
|
||||
### The stack itself (theta-env)
|
||||
|
||||
[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
|
||||
|
||||
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
|
||||
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
|
||||
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
|
||||
- the proxy's auto-registered **OAuth client**, linked under its service
|
||||
|
||||
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
|
||||
|
||||
### Linux hosts (ldap-client)
|
||||
|
||||
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<hostname>` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
|
||||
|
||||
## Consumers of the directory
|
||||
|
||||
The inventory graph isn't just documentation — other components read it to make decisions:
|
||||
|
||||
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=<cn>`), then bridges them in. The `host_<hostname>` slugs and `host_<slug>_access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group.
|
||||
|
||||
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
|
||||
|
||||
## API
|
||||
|
||||
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
||||
|
||||
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
|
||||
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
|
||||
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
|
||||
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 232 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 362 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 357 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 284 KiB |
@@ -1,84 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: Home
|
||||
description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI. One login for your modern apps, one LDAP directory for the rest, no phone-home.
|
||||
---
|
||||
|
||||
# SSO Manager
|
||||
|
||||
A self-hosted **OpenID Connect provider** with a bundled **OpenLDAP directory**
|
||||
and a web management UI — for home labs and small businesses that want their
|
||||
own identity provider instead of a hosted one.
|
||||
|
||||
One place to manage your users and groups, one login (OIDC) your modern apps
|
||||
can use, and one LDAP directory your older or odder apps can bind to directly.
|
||||
Everything runs on your own hardware; no phone-home, no hosted control plane,
|
||||
no per-user pricing.
|
||||
|
||||
Part of the theta42 self-hosted identity stack, alongside
|
||||
[Proxy](https://theta42.github.io/proxy/) (an OIDC + LDAP-aware reverse proxy)
|
||||
and [theta-env](https://theta42.github.io/theta-env/) (the two composed with
|
||||
one command).
|
||||
|
||||
## Screenshots
|
||||
|
||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
|
||||
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
|
||||
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
|
||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth clients" width="49%"></a>
|
||||
|
||||
*(click any screenshot to view full size)*
|
||||
|
||||
## Why this over the alternatives
|
||||
|
||||
Tools like Keycloak, Authentik, Authelia, or Zitadel are OIDC providers, but
|
||||
LDAP is either a paid feature, a federation target you have to run
|
||||
separately, or absent. If your stack already has apps that speak LDAP
|
||||
directly — or you just want one real directory as the source of truth — you
|
||||
end up running *two* identity systems and keeping them in sync.
|
||||
|
||||
SSO Manager bundles the OpenLDAP directory with the OIDC provider, so OIDC
|
||||
apps and LDAP apps read from the same users and groups. The trade-off is
|
||||
scope: it's intentionally small and self-hosted, not an enterprise IAM suite.
|
||||
If you want a lightweight, self-contained identity provider with a real LDAP
|
||||
backend, that's the niche.
|
||||
|
||||
## Features
|
||||
|
||||
- **OpenID Connect / OAuth 2.0 provider** — your own access/refresh/ID
|
||||
tokens; standard discovery document at `/.well-known/openid-configuration`.
|
||||
- **Bundled OpenLDAP directory** — users, groups, POSIX accounts, SSH public
|
||||
keys, and sudo roles, with `memberOf` + referential-integrity overlays.
|
||||
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
||||
invite and password-reset flows over email; self-service profile + API
|
||||
tokens.
|
||||
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
|
||||
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
|
||||
directory.
|
||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||
run the pieces separately via `app_*` env config.
|
||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
||||
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
||||
|
||||
## Get it
|
||||
|
||||
```bash
|
||||
git clone https://github.com/theta42/sso-manager-node.git
|
||||
cd sso-manager-node
|
||||
cp secrets.js.example nodejs/conf/secrets.js # edit it, or use app_* env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
That's the standalone quick start. For the full set of install options
|
||||
(Docker, bare-metal, or as part of the combined SSO + proxy stack), the
|
||||
`app_*` env reference, and the OAuth/LDAP internals, see the
|
||||
**[GitHub repository](https://github.com/theta42/sso-manager-node)**.
|
||||
|
||||
## Related projects
|
||||
|
||||
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
||||
reverse proxy, designed to sit in front of this SSO.
|
||||
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that
|
||||
uses this SSO's directory to decide who may reach which machine.
|
||||
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
|
||||
Manager and the proxy together with one command.
|
||||
-373
@@ -1,373 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: LDAP
|
||||
description: SSO Manager's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly.
|
||||
---
|
||||
|
||||
# LDAP Directory
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
> Looking for a plainer explanation of accounts, groups, and managers
|
||||
> instead of schema/attribute detail? See
|
||||
> [Accounts, Groups & Managers](concepts-accounts.html).
|
||||
|
||||
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
|
||||
authenticates against it over `localhost:389` (inside the all-in-one container)
|
||||
and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
|
||||
directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
|
||||
theta42/proxy, etc.
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
dc=yourdomain,dc=com
|
||||
├── ou=people users (inetOrgPerson + posixAccount + …)
|
||||
├── ou=groups groups (groupOfNames)
|
||||
└── ou=policies password policies (pwdPolicy)
|
||||
└── cn=ppolicy default policy
|
||||
```
|
||||
|
||||
### Users
|
||||
|
||||
User entries are `cn=<uid>,ou=people,<base>` and carry the objectClasses:
|
||||
|
||||
- `inetOrgPerson` (cn, sn, mail, …) — identity / contact attrs.
|
||||
- `posixAccount` (uid, uidNumber, gidNumber, homeDirectory) — the SSO's
|
||||
`userFilter` is `(objectClass=posixAccount)`, so a user is "a real account"
|
||||
iff it has `posixAccount`.
|
||||
- `ldapPublicKey` — SSH public keys (`sshPublicKey`).
|
||||
- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
|
||||
- `theta42Person` (custom auxiliary; `dateOfBirth`).
|
||||
|
||||
Every user (person or service account) also carries a `manager` attribute
|
||||
(the standard COSINE `manager`, `SUP distinguishedName`) — one or more DNs of
|
||||
the people who created/administer that account. Set automatically to the
|
||||
creator's DN on signup (whoever an admin was logged in as, or whoever sent
|
||||
the invite), and reassignable later from the account's Edit form. Anyone
|
||||
listed as a `manager` can edit that account (same fields an admin can:
|
||||
mobile, description, SSH key, date of birth, home directory, login shell,
|
||||
and the manager list itself) without needing `app_sso_admin`.
|
||||
|
||||
Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64),
|
||||
verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the
|
||||
canonical hasher; if you provision users out-of-band, hash passwords the same
|
||||
way or use `slappasswd -h '{SSHA512}'`.
|
||||
|
||||
### Groups
|
||||
|
||||
Groups are `cn=<name>,ou=groups,<base>` (`groupOfNames`) with a `member`
|
||||
attribute listing member DNs. The `memberOf` overlay populates reverse
|
||||
membership (`memberOf` on the user); `refint` keeps it consistent on
|
||||
add/remove. **Admin permission checks read the group's `member` list**, not
|
||||
`memberOf` on the user.
|
||||
|
||||
### Personal groups
|
||||
|
||||
Every user (person or service account) also gets a **personal Unix group**
|
||||
at creation — `cn=<uid>,ou=groups,<base>`, `objectClass: posixGroup` (RFC
|
||||
2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a
|
||||
different schema than the `groupOfNames` groups above — its membership
|
||||
attribute is `memberUid` (a bare username, not a DN), and unlike
|
||||
`groupOfNames` it's valid with zero members. It's excluded from the
|
||||
`/groups` page (which filters on `objectClass=groupOfNames`) and managed
|
||||
instead from the owning user's own profile page ("Members of `<uid>`'s
|
||||
group", admin-only) — add other accounts as supplementary members, e.g. to
|
||||
share write access to files owned by this group.
|
||||
|
||||
The SSO requires three groups (seeded automatically by the entrypoint /
|
||||
`install.sh`):
|
||||
|
||||
| Group | Grants |
|
||||
|-------|--------|
|
||||
| `app_sso_admin` | full admin (users, groups, settings) |
|
||||
| `app_sso_oauth_admin` | OAuth client management |
|
||||
| `app_sso_invite` | invitation management |
|
||||
| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below) |
|
||||
|
||||
## TLS (LDAPS / StartTLS)
|
||||
|
||||
The bundled slapd generates a **self-signed cert** on first start (CN =
|
||||
`LDAP_CERT_CN`, valid 10y, SAN = CN + `localhost` + `127.0.0.1`) and listens on:
|
||||
|
||||
- `ldaps:///` — **636**, TLS (the port to expose for direct-LDAP clients).
|
||||
- `ldap:///` — **389**, plain + StartTLS (not mapped to the host by default).
|
||||
|
||||
The cert lives on the `ldap-certs` volume so it persists across container
|
||||
recreation.
|
||||
|
||||
### Trusting the self-signed cert
|
||||
|
||||
Copy it out and add it to the client's CA store:
|
||||
|
||||
```bash
|
||||
docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt
|
||||
```
|
||||
|
||||
…or, for quick LAN use, set `TLS_REQCERT never` on the client (the theta42/proxy
|
||||
sets `app_ldap__tlsOptions__rejectUnauthorized=false` for the same effect).
|
||||
|
||||
### Using your own cert
|
||||
|
||||
Replace the `ldap-certs` named volume with a bind mount containing your own
|
||||
`ldap.crt` + `ldap.key`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key
|
||||
```
|
||||
|
||||
The entrypoint leaves existing certs untouched (idempotent).
|
||||
|
||||
## Choosing the LDAPS hostname
|
||||
|
||||
The `/integrations` page advertises an **LDAPS URL** for direct LDAP binds. By
|
||||
default it derives that URL from the public OAuth issuer (e.g.
|
||||
`https://sso.example.com` → `ldaps://sso.example.com:636`). That is convenient,
|
||||
but it implies LDAP clients reach your directory through the same public
|
||||
hostname — which usually means port-forwarding 636 through your router.
|
||||
|
||||
**Do not port-forward LDAPS (636) to the public internet.** LDAP simple binds
|
||||
have no rate limiting and are a brute-force target. Instead, use one of these
|
||||
internal-only patterns and set `conf.ldap.ldapsHost` (or
|
||||
`app_ldap__ldapsHost`) so the `/integrations` page shows the right URL.
|
||||
|
||||
### 1. Same Docker / local network host (best for apps on this machine)
|
||||
|
||||
If the LDAP client runs on the same Docker network as the SSO Manager (for
|
||||
example, the bundled `theta-env` stack), use the internal service name:
|
||||
|
||||
```
|
||||
ldaps://sso-manager:636
|
||||
```
|
||||
|
||||
In `conf/secrets.js`:
|
||||
|
||||
```javascript
|
||||
ldap: {
|
||||
ldapsHost: 'sso-manager',
|
||||
ldapsPort: 636,
|
||||
}
|
||||
```
|
||||
|
||||
The proxy in theta-env already uses this internally. The bundled slapd cert
|
||||
includes `sso-manager` in its SAN when `LDAP_CERT_CN` is left at its default,
|
||||
so hostname verification works without extra setup.
|
||||
|
||||
### 2. LAN host behind your router (best for separate home-lan machines)
|
||||
|
||||
Create an internal-only DNS record — e.g. `ldap.internal.example.com` →
|
||||
`192.168.1.10` — using your router, Pi-hole, or a local `hosts` file. Then get
|
||||
or generate a cert whose SAN/CN matches that internal name:
|
||||
|
||||
- **Let's Encrypt wildcard** (`*.internal.example.com`) works if you own the
|
||||
public domain and can complete DNS-01 challenge; the record itself can stay
|
||||
private/routable only inside your LAN.
|
||||
- **Internal CA** is fine for a pure LAN: run a small CA, issue a cert for
|
||||
`ldap.internal.example.com`, and distribute the CA cert to clients.
|
||||
- **Self-signed** with `LDAP_CERT_CN=ldap.internal.example.com` also works; copy
|
||||
the generated `ldap.crt` to each client and trust it.
|
||||
|
||||
In `conf/secrets.js`:
|
||||
|
||||
```javascript
|
||||
ldap: {
|
||||
ldapsHost: 'ldap.internal.example.com',
|
||||
ldapsPort: 636,
|
||||
}
|
||||
```
|
||||
|
||||
The URL on `/integrations` becomes `ldaps://ldap.internal.example.com:636`.
|
||||
|
||||
### 3. Public hostname (acceptable only behind a VPN/firewall)
|
||||
|
||||
If a remote host must bind LDAP, put it behind a VPN (Tailscale, WireGuard,
|
||||
etc.) or a tightly locked-down firewall rule. In that case the public hostname
|
||||
may be appropriate, but the LDAPS port should still not be reachable from the
|
||||
open internet.
|
||||
|
||||
### Why not just use the LDAP server's IP address?
|
||||
|
||||
TLS clients verify the server name against the certificate. Connecting to
|
||||
`ldaps://192.168.1.10:636` with a cert issued for `*.internal.example.com`
|
||||
will fail hostname verification unless you disable cert checks — which removes
|
||||
most of the security benefit of LDAPS. Always use a hostname that matches the
|
||||
cert.
|
||||
|
||||
## Service accounts
|
||||
|
||||
A service account is a normal `posixAccount` for something that isn't a
|
||||
person: a media manager, a torrent client, a service like Emby, or a
|
||||
read-only bind account an app uses to look users up — anything that needs a
|
||||
real `uidNumber`/`gidNumber` to own files, or that other accounts join via a
|
||||
group for write access (e.g. a `stuff_manager` group granting write rights
|
||||
to a media library). There's only one kind — every account, person or
|
||||
service, is a real `posixAccount` with a UID.
|
||||
|
||||
Create one from the **Users → Service Accounts** tab's "Add new user" form
|
||||
with **This is a service account** checked — it skips the birthday/
|
||||
Terms-of-Service fields a real person's account needs and asks for just an
|
||||
account name. It's flagged (via membership in the `app_sso_service_account`
|
||||
group) so it's listed separately from real people and excluded from "all
|
||||
users" notification broadcasts.
|
||||
|
||||
Email and password are both optional for a service account:
|
||||
|
||||
- No `mail` is set unless you give it one (it never needs a mailbox).
|
||||
- Leaving the password blank is fine — no `userPassword` attribute is set at
|
||||
all, and an entry with no `userPassword` simply can't bind with any
|
||||
password (standard LDAP simple-bind behavior). Only set a password if the
|
||||
account actually needs to authenticate as itself (e.g. a bind-only account
|
||||
an app uses to look users up).
|
||||
|
||||
theta-env's bootstrap creates its own `cn=ldapclient` bind account directly
|
||||
against LDAP (independent of this app), and the proxy binds as it — that
|
||||
account won't show up in the Service Accounts tab since it isn't managed
|
||||
through this app, but it keeps working unchanged.
|
||||
|
||||
Either way: don't reuse the admin DN, and give a service account only the
|
||||
group memberships and `manager`s it actually needs.
|
||||
|
||||
Example bind test (a service account with a password set):
|
||||
|
||||
```bash
|
||||
ldapsearch -x -H ldaps://sso.example.com:636 \
|
||||
-D "cn=ldapclient,ou=people,dc=yourdomain,dc=com" -W \
|
||||
-b "ou=people,dc=yourdomain,dc=com" '(objectClass=posixAccount)' cn mail
|
||||
```
|
||||
|
||||
## Connecting a 3rd-party app or container
|
||||
|
||||
Most self-hosted apps with an "LDAP authentication" settings page — Gitea,
|
||||
Nextcloud, Grafana, Emby, Jenkins, etc. — or containers configured via
|
||||
`LDAP_*` env vars, all ask for the same handful of values. These are the
|
||||
`conf.ldap` values from [Configuration](configuration.html), applied to
|
||||
*your* domain:
|
||||
|
||||
| Field the app asks for | Value |
|
||||
|---|---|
|
||||
| Host / URL | `ldaps://<your-sso-host>:636` (preferred), or `ldap://<host>:389` + StartTLS |
|
||||
| Bind DN | a dedicated service account — e.g. `cn=ldapclient,ou=people,<base>` (see above) |
|
||||
| Bind password | that service account's password |
|
||||
| User search base | `ou=people,<base>` |
|
||||
| User search filter | `(objectClass=posixAccount)` |
|
||||
| Username attribute | `uid` |
|
||||
| Email attribute | `mail` |
|
||||
| Group search base | `ou=groups,<base>` |
|
||||
| Group membership attribute | `memberOf` (on the user entry — populated by the `memberof` overlay) |
|
||||
| TLS | required for 636 (LDAPS); if using the bundled self-signed cert, either trust it (see *TLS* above) or set the app's "don't verify cert" option for LAN-only use |
|
||||
|
||||
### Worked example: Gitea
|
||||
|
||||
Gitea's **Admin → Authentication Sources → Add Authentication Source** (type
|
||||
LDAP, "Bind DN/Password") maps directly:
|
||||
|
||||
- Security Protocol: `LDAPS`
|
||||
- Host / Port: your SSO host / `636`
|
||||
- Bind DN: `cn=ldapclient,ou=people,dc=yourdomain,dc=com`
|
||||
- Bind Password: the service account's password
|
||||
- User Search Base: `ou=people,dc=yourdomain,dc=com`
|
||||
- User Filter: `(&(objectClass=posixAccount)(uid=%s))`
|
||||
- Username Attribute: `uid`
|
||||
- E-mail Attribute: `mail`
|
||||
|
||||
Other apps with an LDAP settings UI follow the same shape — the field names
|
||||
above are the constants; only the base DN and hostname change per deployment.
|
||||
|
||||
### Generic Docker container (`LDAP_*` env vars)
|
||||
|
||||
For images that take a flat env-var LDAP config (there's no single standard,
|
||||
but most look like this):
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
LDAP_URL: ldaps://sso.example.com:636
|
||||
LDAP_BIND_DN: cn=ldapclient,ou=people,dc=yourdomain,dc=com
|
||||
LDAP_BIND_PASSWORD: <service-account-password>
|
||||
LDAP_USER_BASE: ou=people,dc=yourdomain,dc=com
|
||||
LDAP_USER_FILTER: (objectClass=posixAccount)
|
||||
LDAP_GROUP_BASE: ou=groups,dc=yourdomain,dc=com
|
||||
```
|
||||
|
||||
Check the specific image's docs for its actual variable names — the values
|
||||
you plug in are still the ones from the table above.
|
||||
|
||||
### Full Linux host auth (SSH, sudo, login) instead of a single app
|
||||
|
||||
If you want a *host* (not just one app) to authenticate logins, SSH keys, and
|
||||
sudo against this LDAP directory — not just one application — that's a
|
||||
different integration (SSSD + PAM + NSS, not a single bind). See
|
||||
[theta42/ldap-client](https://github.com/theta42/ldap-client): a script that
|
||||
configures SSSD on Ubuntu/Debian hosts against this directory, including
|
||||
group-based access control and SSH public key retrieval from LDAP.
|
||||
|
||||
## Modules + overlays (external LDAP servers)
|
||||
|
||||
If you point the app at your own LDAP server instead of the bundled slapd, it
|
||||
needs:
|
||||
|
||||
- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`),
|
||||
`ppolicy`, `memberof`, `refint`.
|
||||
- **Custom schema:** the `theta42Person` auxiliary objectClass with
|
||||
`dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
|
||||
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
|
||||
a default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
|
||||
- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`.
|
||||
|
||||
`ops/ldap-setup.sh -p <admin-password>` configures all of the above
|
||||
idempotently against a running slapd (auto-detects the database holding your
|
||||
base DN, and verifies `pwdAccountLockedTime` is live — the attribute the app's
|
||||
active/inactive toggle depends on).
|
||||
|
||||
## Backups and restore
|
||||
|
||||
`ops/backup.sh` automates this (LDAP + Redis + `./config/`, with retention)
|
||||
for standalone deployments — see the *Backups and restore* section of
|
||||
`DEPLOYMENT.md`. The manual LDAP-only steps below are what it does under the
|
||||
hood, useful if you want just the directory without Redis/config.
|
||||
|
||||
**Backup** (while slapd is running):
|
||||
|
||||
```bash
|
||||
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
|
||||
-b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif
|
||||
```
|
||||
|
||||
Store the `.ldif` off the host — it contains every user's password hash.
|
||||
|
||||
**Restore** into a stopped directory. The SSO image uses a static `slapd.conf`
|
||||
(slapd starts with `-f`, not cn=config `-F`), so restore uses `slapadd -f`:
|
||||
|
||||
```bash
|
||||
docker compose stop sso-manager
|
||||
docker compose run --rm --no-deps --entrypoint sh sso-manager -c \
|
||||
'rm -f /var/lib/ldap/* && slapadd -f /etc/openldap/slapd.conf -l /dev/stdin' \
|
||||
< ldap-backup-<date>.ldif
|
||||
docker compose start sso-manager
|
||||
```
|
||||
|
||||
Verify: `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`.
|
||||
|
||||
Redis state (OAuth clients, tokens) and `./config/` secrets are backed up
|
||||
separately — see the *Backups and restore* section of `DEPLOYMENT.md` for the
|
||||
full (LDAP + Redis + secrets) runbook.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `503 OpenLDAP ppolicy overlay is not configured`
|
||||
|
||||
The ppolicy overlay isn't attached to the database holding your users, so the
|
||||
active/inactive toggle can't set `pwdAccountLockedTime`:
|
||||
|
||||
```bash
|
||||
sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com
|
||||
```
|
||||
|
||||
### LDAP connection refused
|
||||
|
||||
```bash
|
||||
docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base'
|
||||
systemctl status slapd # bare metal
|
||||
```
|
||||
|
||||
[← Back to Home](index.html)
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: OAuth / OIDC
|
||||
description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery document, client registration, and token endpoints.
|
||||
---
|
||||
|
||||
# OAuth 2.0 / OpenID Connect
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
> Looking for a plainer explanation of clients/scopes/redirect URIs instead
|
||||
> of endpoint-level detail? See
|
||||
> [Connecting Apps (Single Sign-On)](concepts-oauth-apps.html).
|
||||
|
||||
SSO Manager is an **OpenID Connect / OAuth 2.0 provider**: it issues its own
|
||||
access, refresh, and ID tokens that your apps can consume to authenticate
|
||||
users and authorize API calls. It also runs a full OpenLDAP directory, so it
|
||||
can be both your SSO and your user directory at once.
|
||||
|
||||
## Discovery
|
||||
|
||||
The provider publishes a standards-compliant discovery document:
|
||||
|
||||
```
|
||||
GET https://<sso-host>/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
It advertises the `issuer`, `authorization_endpoint`, `token_endpoint`,
|
||||
`userinfo_endpoint`, `end_session_endpoint`, supported scopes, and token
|
||||
lifetimes. OIDC clients (e.g. the theta42/proxy) can read their endpoint URLs
|
||||
from here rather than configuring each one.
|
||||
|
||||
The `issuer` advertised is `conf.oauth.issuer` — set it to the **browser-facing**
|
||||
HTTPS URL the SSO is served at (e.g. `https://sso.example.com`), either in
|
||||
`conf/secrets.js` or via `app_oauth__issuer` / `OAUTH_ISSUER`.
|
||||
|
||||
## OAuth clients
|
||||
|
||||
An OAuth client represents an app that authenticates against the SSO. Each has:
|
||||
|
||||
- `client_id` (UUID) + `client_secret` (bcrypt-hashed; the **raw secret is
|
||||
shown once** when the client is created or rotated — save it immediately).
|
||||
- `name`, `description`, `created_by` (the admin uid that created it).
|
||||
- `redirect_uris` — allowed callback URLs. Each entry matches exactly, or may
|
||||
use `*` (one hostname label) / `**` (any number of labels) as a wildcard —
|
||||
e.g. `https://*.example.com/__proxy_auth/callback` covers every host
|
||||
theta42/proxy fronts under `example.com`, so you don't have to register
|
||||
each proxied host's callback individually.
|
||||
- `scopes` — requested scopes (default `openid profile email groups`).
|
||||
- `allowed_groups` — restrict the client to members of specific SSO groups
|
||||
(empty = any valid user).
|
||||
- `token_lifetime` — `access_token` / `refresh_token` lifetimes (seconds).
|
||||
|
||||
### Managing clients
|
||||
|
||||
Clients are managed directly from the **Directory** tab in the web UI. They are modeled as resources of `kind: oauth` and must belong to a parent Service.
|
||||
|
||||
| Action | How to do it |
|
||||
|--------|--------------|
|
||||
| **Create** | Click the green **+** on a parent Service to add a child resource. Choose **OAuth Integration**. The raw `client_secret` is shown once upon creation. |
|
||||
| **Edit** | Click the edit pencil on the OAuth resource in the Directory list or tree. You can update redirect URIs, scopes, allowed groups, and token TTLs. |
|
||||
| **Delete** | Click the trash can on the OAuth resource in the Directory list. |
|
||||
| **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. |
|
||||
|
||||
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
|
||||
|
||||
## Scopes
|
||||
|
||||
| Scope | Claims / access |
|
||||
|-------|-----------------|
|
||||
| `openid` | OIDC ID token + discovery |
|
||||
| `profile` | `preferred_username`, display name, etc. |
|
||||
| `email` | the user's `mail` |
|
||||
| `groups` | the user's group memberships (the `groups` claim) |
|
||||
|
||||
The `groups` claim is what relying parties (e.g. the proxy's
|
||||
`app_auth__adminGroups`) use to map group membership to roles.
|
||||
|
||||
## Token lifetimes
|
||||
|
||||
Defaults (overridable per-client via `token_lifetime`, or globally via
|
||||
`app_oauth__token_lifetime__access_token` /
|
||||
`app_oauth__token_lifetime__refresh_token`):
|
||||
|
||||
- access token: 3600s (1 hour)
|
||||
- refresh token: 2592000s (30 days)
|
||||
|
||||
## Admin gating
|
||||
|
||||
SSO admin actions are gated by LDAP group membership (checked via the group's
|
||||
`member` list, not `memberOf` on the user):
|
||||
|
||||
- `app_sso_admin` — full admin (users, groups, settings).
|
||||
- `app_sso_oauth_admin` — OAuth client management.
|
||||
- `app_sso_invite` — invitation management.
|
||||
|
||||
The bootstrap in [theta-env](https://github.com/theta42/theta-env) creates your
|
||||
first admin and adds them to `app_sso_admin` + `app_sso_oauth_admin`
|
||||
automatically; for a standalone install, add the admin's DN to those groups
|
||||
manually (or via `ops/ldap-setup.sh`).
|
||||
|
||||
## JWT signing
|
||||
|
||||
Tokens are signed with `conf.oauth.jwtSecret` (`app_oauth__jwtSecret` /
|
||||
`JWT_SECRET`). **Persist this secret** — if it changes, every issued token
|
||||
stops validating. The all-in-one Docker image auto-generates one if none is set,
|
||||
but that generated value does not survive container recreation unless you
|
||||
persist it (set `JWT_SECRET` in your `.env`).
|
||||
|
||||
[← Back to Home](index.html)
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: Geo-Location Scaling (Replication)
|
||||
---
|
||||
|
||||
# Geo-Location Scaling (Replication)
|
||||
|
||||
SSO Manager is built to be a self-contained identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability.
|
||||
|
||||
## Why and when to use this?
|
||||
- **High Availability (HA)**: If your primary site goes completely offline, your other sites can still authenticate users locally without depending on a WAN link.
|
||||
- **Low Latency**: Applications at a remote site can bind directly to their local LDAP server (`localhost` or LAN IP) instead of traversing the internet to query the primary site, making logins blazing fast.
|
||||
- **Independent Failure Domains**: By replicating only the LDAP directory (the source of truth) and keeping session state (Redis) independent, you prevent complex "split-brain" scenarios in the web UI. A failure at Site A won't bring down Site B.
|
||||
|
||||
By default, the `sso-manager` Docker container runs a single, independent OpenLDAP instance. However, you can enable **N-Way Multi-Master Replication** via environment variables.
|
||||
|
||||
## How it works
|
||||
|
||||
In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (`slapd`).
|
||||
- **Reads and Writes anywhere**: A user can change their password or update their profile at Site A, Site B, or Site C.
|
||||
- **Conflict Resolution**: OpenLDAP's `syncrepl` engine uses Context Sequence Numbers (CSN) to track changes. If Site A goes offline and a user changes their password at Site B, Site A will automatically pull the newest changes the moment it rejoins the cluster.
|
||||
- **Independent Redis**: Session data, API Tokens, and OAuth Clients are stored in Redis. By design, Redis is NOT replicated in this geographic setup. This ensures that a failure at Site A never causes Site B's Redis to become read-only, which would break the web UI at Site B. OAuth clients must be configured per-site.
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable replication, you must pass two environment variables to the `sso-manager` container:
|
||||
|
||||
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
|
||||
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
|
||||
|
||||
### Example using `theta-env` / Docker Compose
|
||||
|
||||
**Site 1 (`setup.env` or `docker-compose.yml`)**
|
||||
```env
|
||||
LDAP_SERVER_ID=1
|
||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||
```
|
||||
|
||||
**Site 2 (`setup.env` or `docker-compose.yml`)**
|
||||
```env
|
||||
LDAP_SERVER_ID=2
|
||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
||||
```
|
||||
|
||||
**Site 3 (`setup.env` or `docker-compose.yml`)**
|
||||
```env
|
||||
LDAP_SERVER_ID=3
|
||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
|
||||
```
|
||||
|
||||
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
|
||||
|
||||
## User Locations
|
||||
|
||||
When creating or editing a user, you can specify their **Location (Site)**. This maps directly to the standard LDAP `l` (localityName) attribute, allowing you to track which physical site a user belongs to natively within the directory.
|
||||
@@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://theta42.github.io/sso-manager-node/sitemap.xml
|
||||
+36
-1
@@ -43,6 +43,9 @@ app.onListen.push(function(){
|
||||
// socket.broadcast.emit('P2PSub', msg);
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize Theta Agent WebSockets
|
||||
require('./routes/api_agent')(app);
|
||||
});
|
||||
|
||||
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
|
||||
@@ -91,9 +94,13 @@ app.use('/api/group', middleware.auth, require('./routes/group'));
|
||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
||||
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
||||
// Self-service access requests — any authenticated user may ask; deciding is
|
||||
// gated per-resource inside the router (owner or directory admin).
|
||||
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
|
||||
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
||||
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
||||
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
|
||||
app.use('/api/conf', middleware.auth, require('./routes/api_conf'));
|
||||
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||
|
||||
@@ -102,7 +109,22 @@ app.use('/oauth', oauthRouter);
|
||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
||||
app.get('/.well-known/openid-configuration', discovery);
|
||||
app.use('/api/webhook', require('./routes/webhook'));
|
||||
// Plugin instances — loadable/unloadable, configurable plugin copies with
|
||||
// per-instance secrets in OpenBao (secret/plugins/*). Admin-only (gated inside
|
||||
// the router to app_sso_admin / app_sso_directory_admin).
|
||||
app.use('/api/plugins', middleware.auth, require('./routes/api_plugins'));
|
||||
|
||||
// OpenBao vault API. The broker mints a server-side scoped token per user
|
||||
// (per-user user-<uid> or, for admins, sso-admin), enforces the path prefix
|
||||
// (scopeGuard), and injects ONLY that token into the proxied request — the
|
||||
// client's sso auth headers are stripped and never reach OpenBao. Non-admins
|
||||
// are confined to secret/users/<uid>/*; admins roam all of secret/. The
|
||||
// admin-only app-token mint route is mounted BEFORE the proxy so it isn't
|
||||
// shadowed by the catch-all /api/vault proxy.
|
||||
const vaultBroker = require('./utils/vault_broker');
|
||||
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
|
||||
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
|
||||
|
||||
// Catch 404 and forward to error handler. If none of the above routes are
|
||||
// used, this is what will be called.
|
||||
@@ -125,5 +147,18 @@ app.use(function(err, req, res, next) {
|
||||
}
|
||||
|
||||
res.status(err.status || 500);
|
||||
res.json({name: err.name, message: err.message});
|
||||
if (req.accepts('html') && !req.originalUrl.startsWith('/api/')) {
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const buildInfo = require('./utils/build_info');
|
||||
res.render('error', {
|
||||
name: conf.name,
|
||||
title: 'Error',
|
||||
titleIcon: '',
|
||||
logo: conf.logo,
|
||||
error: err,
|
||||
...buildInfo
|
||||
});
|
||||
} else {
|
||||
res.json({name: err.name, message: err.message});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -25,15 +25,41 @@ var server = http.createServer(app);
|
||||
var io = require('socket.io')(server);
|
||||
app.io = io;
|
||||
|
||||
const WebSocket = require('ws');
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
// We only handle upgrade for /api/agent/ws.
|
||||
// Socket.IO handles its own upgrades natively because it attaches directly to `server`.
|
||||
if (request.url.startsWith('/api/agent/ws')) {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, request);
|
||||
});
|
||||
}
|
||||
});
|
||||
app.wss = wss;
|
||||
|
||||
const models = require('../models');
|
||||
|
||||
/**
|
||||
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
models.initORM().then(() => {
|
||||
// Overlay secret/sso-manager/conf from OpenBao over the file-loaded conf.
|
||||
// Fail-soft: if OpenBao is unreachable, conf keeps the ./config/sso-secrets.js
|
||||
// values and boot continues. (Same position the old conf_manager held, so
|
||||
// call-time conf readers — which is how sso consumes its secrets — are
|
||||
// unaffected; nothing in sso captures a secret at require time.)
|
||||
return require('@simpleworkjs/bao-conf').init({ path: 'sso-manager', conf });
|
||||
}).then(() => {
|
||||
server.listen(port);
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
|
||||
// Initialize scheduler
|
||||
const { initScheduler } = require('../services/scheduler');
|
||||
initScheduler(conf.discovery).catch(err => {
|
||||
console.error('Failed to initialize scheduler:', err);
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Failed to initialize ORM:', err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -29,6 +29,11 @@ module.exports = {
|
||||
// public 636 port forward. See docs/ldap.md.
|
||||
ldapsHost: '',
|
||||
ldapsPort: 636,
|
||||
// True when slapd carries the `nestgroup` overlay, which resolves nested
|
||||
// groups server-side. Set automatically by docker-entrypoint.sh for the
|
||||
// all-in-one image; leave false when pointing at a stock OpenLDAP (no
|
||||
// 2.6.x release ships nestgroup) and the app resolves nesting itself.
|
||||
nestedGroupsServerSide: false,
|
||||
// New users/personal groups (see addPosixAccount/addPosixGroup in
|
||||
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
||||
// Existing entries >= uidGidReservedFloor are ignored when computing
|
||||
@@ -60,6 +65,16 @@ module.exports = {
|
||||
pass: '__in secrets file__',
|
||||
from: 'SSO Manager <noreply@example.com>',
|
||||
},
|
||||
directory: {
|
||||
// Public SSH jump host fronting the lab, if there is one (the jump-host
|
||||
// component). When set, a host card in the catalog shows the real
|
||||
// invocation — `ssh <uid>_-_<slug>@<jumpHost>` — instead of a bare
|
||||
// `ssh <uid>@<ip>` that only works from inside the LAN. Empty is fine;
|
||||
// the card falls back to the direct form.
|
||||
jumpHost: '',
|
||||
// Default SSH port assumed when a host carries no metadata.sshPort.
|
||||
defaultSshPort: 22,
|
||||
},
|
||||
service: {
|
||||
updateCheck: {
|
||||
enabled: true,
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,132 @@
|
||||
# Plugins
|
||||
|
||||
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
|
||||
**type** is an installed module; a plugin **instance** is a configured, loadable
|
||||
copy of a type. You can create, edit, load/unload, run, and delete instances
|
||||
from the **Plugins** page (or the `/api/plugins` API), and you can run several
|
||||
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
|
||||
and token on its own schedule.
|
||||
|
||||
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
|
||||
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
|
||||
ever shows them masked (`********`); the plugin reads them at run time. This
|
||||
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
|
||||
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
|
||||
|
||||
## Plugin types
|
||||
|
||||
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
|
||||
filename basename (without `.js`) is the `type`; the parent directory is the
|
||||
`category`. The built-ins ship under `plugins/discovery/`:
|
||||
|
||||
- `proxmox` — Proxmox VE (URL + API token)
|
||||
- `unifi` — UniFi Network controller (URL + username/password)
|
||||
- `nmap` — nmap OS + port scan (a target range; no credentials)
|
||||
|
||||
A module exports a **manifest**:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
// Identity — `type`/`category` default to the file/dir name but can be set
|
||||
// explicitly. `name`/`description` show up in the UI.
|
||||
type: 'proxmox',
|
||||
category: 'discovery',
|
||||
name: 'Proxmox VE',
|
||||
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
|
||||
|
||||
// Drives the admin UI form, API validation, and secret masking. Fields with
|
||||
// `secret: true` are stored in OpenBao; the rest live in the DB row.
|
||||
configSchema: [
|
||||
{ key: 'url', label: 'API URL', type: 'url', required: true },
|
||||
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
|
||||
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
||||
],
|
||||
|
||||
// "Test" button: validate the config (don't do the work). Return
|
||||
// { ok: true } or { ok: false, error: '...' }. Optional.
|
||||
validate: async (config) => { … },
|
||||
|
||||
// The work. `run` is the generalized contract name; the discovery plugins
|
||||
// also keep `discover` as an alias for back-compat. For `category:
|
||||
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
|
||||
run: async (config) => { return { resources, edges }; },
|
||||
discover: async (config) => { return { resources, edges }; }
|
||||
};
|
||||
```
|
||||
|
||||
`run(config)` receives the merged non-secret config + secret values as one flat
|
||||
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
|
||||
returns `{ resources, edges }`; the reconciler upserts them into the resource
|
||||
graph attributed to the instance's **slug** (the `discovery_sources` name).
|
||||
|
||||
### Writing a custom plugin type
|
||||
|
||||
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
|
||||
following the manifest above. New types are picked up at boot, so restart the
|
||||
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
|
||||
adding a new type still needs a restart.
|
||||
|
||||
## The Plugins page
|
||||
|
||||
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
|
||||
/ `app_super_admin`):
|
||||
|
||||
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
|
||||
source name + the URL the resource graph attributes results to), set a cron
|
||||
schedule, and fill in the config form (secret fields are password inputs).
|
||||
Creating it schedules it and kicks one immediate run.
|
||||
- **Edit** — name, cron, and non-secret config.
|
||||
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
|
||||
field blank to keep its current value.
|
||||
- **Test** (vial icon) — runs the plugin's `validate`.
|
||||
- **Run now** (play icon) — enqueues one immediate run regardless of state.
|
||||
- **Load / Unload** — enable/disable the schedule without deleting the instance.
|
||||
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
|
||||
|
||||
## API
|
||||
|
||||
All endpoints are mounted at `/api/plugins`, require an authenticated admin
|
||||
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
|
||||
secret values masked.
|
||||
|
||||
| Method + path | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
|
||||
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
|
||||
| `GET /api/plugins/:id` | one instance |
|
||||
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
|
||||
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
|
||||
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
|
||||
| `POST /api/plugins/:id/test` | run `validate` → `{ ok }` or `{ ok:false, error }` |
|
||||
| `POST /api/plugins/:id/load` | enable + schedule + run now |
|
||||
| `POST /api/plugins/:id/unload` | unschedule + disable |
|
||||
| `POST /api/plugins/:id/run` | enqueue one immediate run |
|
||||
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
|
||||
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
|
||||
|
||||
## Scheduler internals
|
||||
|
||||
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
|
||||
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
|
||||
that one schedule without disturbing the others. A daily `garbage_collect` job
|
||||
prunes discovery resources not seen in > 7 days.
|
||||
|
||||
### Legacy migration
|
||||
|
||||
Before this system, plugins were configured statically in `sso-secrets.js`:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
discovery: {
|
||||
plugins: {
|
||||
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
|
||||
empty **and** `conf.discovery.plugins` has entries, one instance per configured
|
||||
type is seeded automatically (secret fields copied into OpenBao). After that the
|
||||
table is non-empty and the static config is ignored — manage plugins from the
|
||||
UI/API instead. The migration is idempotent (guarded by the empty-table check).
|
||||
@@ -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 };
|
||||
+182
-4
@@ -112,18 +112,190 @@ async function cachedListDetail() {
|
||||
return promise;
|
||||
}
|
||||
|
||||
// --- Nested groups -------------------------------------------------------
|
||||
//
|
||||
// `groupOfNames.member` holds DNs, and nothing says those DNs must be users --
|
||||
// a group DN is a perfectly legal member. That is how nesting is stored here:
|
||||
// as-is, no extra schema, no denormalization, the nesting visible in LDAP
|
||||
// exactly as an admin entered it.
|
||||
//
|
||||
// What LDAP will NOT do is resolve it. The memberof overlay records only
|
||||
// *direct* membership, and a `(member=<dn>)` filter likewise finds only the
|
||||
// groups that list the DN literally. So transitivity is computed here, and
|
||||
// every membership question in the app must go through these helpers or it
|
||||
// will silently see one level and grant nothing for a nested group.
|
||||
//
|
||||
// The whole group set is one subtree search, so the closure is computed in
|
||||
// memory rather than issuing a query per level. `resolverCache` keeps that
|
||||
// search off the hot path for bursts; it is cleared by every write below, so
|
||||
// the only staleness it can introduce is from edits made outside this app.
|
||||
// Auth decisions ride on this, hence the deliberately short TTL.
|
||||
|
||||
const NESTING_TTL_MS = 15 * 1000;
|
||||
const MAX_NESTING_DEPTH = Number(conf.groupNestingDepth) > 0 ? Number(conf.groupNestingDepth) : 10;
|
||||
|
||||
const resolverCache = new LRUCache({ max: 1, ttl: NESTING_TTL_MS, ttlAutopurge: true });
|
||||
|
||||
async function allGroupsForResolver() {
|
||||
const hit = resolverCache.get('all');
|
||||
if (hit) return hit;
|
||||
const promise = withClient(async (client) => {
|
||||
const groups = await getGroups(client);
|
||||
return groups.map(g => ({ ...g }));
|
||||
}).then(plain => {
|
||||
resolverCache.set('all', plain);
|
||||
return plain;
|
||||
}).catch(err => {
|
||||
resolverCache.delete('all');
|
||||
throw err;
|
||||
});
|
||||
resolverCache.set('all', promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
const lc = dn => String(dn || '').toLowerCase();
|
||||
|
||||
// dn -> [groups that list dn as a member]. One pass, reused for every lookup.
|
||||
function buildParentIndex(groups) {
|
||||
const parents = new Map();
|
||||
for (const group of groups) {
|
||||
for (const member of [].concat(group.member || []).filter(Boolean)) {
|
||||
const key = lc(member);
|
||||
if (!parents.has(key)) parents.set(key, []);
|
||||
parents.get(key).push(group);
|
||||
}
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
// Every group `dn` belongs to, directly or through any chain of nested groups.
|
||||
// Breadth-first with a visited set, so a cycle (A in B, B in A) terminates
|
||||
// instead of hanging, and MAX_NESTING_DEPTH bounds a pathological chain.
|
||||
function closureUp(dn, groups) {
|
||||
const parents = buildParentIndex(groups);
|
||||
const found = new Map(); // cn -> group
|
||||
const seen = new Set([lc(dn)]);
|
||||
let frontier = [lc(dn)];
|
||||
|
||||
for (let depth = 0; depth < MAX_NESTING_DEPTH && frontier.length; depth++) {
|
||||
const next = [];
|
||||
for (const current of frontier) {
|
||||
for (const group of parents.get(current) || []) {
|
||||
const groupDn = lc(group.dn);
|
||||
if (seen.has(groupDn)) continue;
|
||||
seen.add(groupDn);
|
||||
found.set(group.cn, group);
|
||||
// The group itself is now a member to look up: this is the step
|
||||
// that makes the walk transitive rather than one-level.
|
||||
next.push(groupDn);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return [...found.values()];
|
||||
}
|
||||
|
||||
// Every member DN reachable from a group, split into the users it effectively
|
||||
// grants and the groups it nests. `direct` is kept separate so the UI can show
|
||||
// "3 members, 12 effective" and so removal stays unambiguous.
|
||||
function closureDown(group, groups) {
|
||||
const byDn = new Map(groups.map(g => [lc(g.dn), g]));
|
||||
const users = new Set();
|
||||
const nested = new Map();
|
||||
const seen = new Set([lc(group.dn)]);
|
||||
let frontier = [group];
|
||||
|
||||
for (let depth = 0; depth < MAX_NESTING_DEPTH && frontier.length; depth++) {
|
||||
const next = [];
|
||||
for (const current of frontier) {
|
||||
for (const member of [].concat(current.member || []).filter(Boolean)) {
|
||||
const key = lc(member);
|
||||
const asGroup = byDn.get(key);
|
||||
if (asGroup) {
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
nested.set(asGroup.cn, asGroup);
|
||||
next.push(asGroup);
|
||||
} else {
|
||||
users.add(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return { users: [...users], nested: [...nested.values()] };
|
||||
}
|
||||
|
||||
var Group = {};
|
||||
|
||||
// Set when slapd carries the nestgroup overlay (docker-entrypoint.sh exports
|
||||
// app_ldap__nestedGroupsServerSide=true after detecting nestgroup.so). With it,
|
||||
// a plain `(member=<dn>)` search already returns the full transitive set and the
|
||||
// in-app closure is redundant work on every request. Without it -- e.g. pointed
|
||||
// at a stock 2.6.x server, which no release ships nestgroup in -- the app must
|
||||
// compute the closure itself or nested groups silently grant nothing.
|
||||
const SERVER_SIDE_NESTING = String(conf.nestedGroupsServerSide) === 'true';
|
||||
|
||||
// Transitive: every group CN this member belongs to, at any nesting depth.
|
||||
// Callers making an access decision must use this rather than reading
|
||||
// `memberOf`, which a server without nestgroup only ever populates one level
|
||||
// deep.
|
||||
Group.list = async function(member){
|
||||
if (member) {
|
||||
return withClient(async (client) => {
|
||||
const groups = await getGroups(client, member);
|
||||
return groups.map(group => group.cn);
|
||||
});
|
||||
if (SERVER_SIDE_NESTING) {
|
||||
return withClient(async (client) => {
|
||||
const groups = await getGroups(client, member);
|
||||
return groups.map(group => group.cn);
|
||||
});
|
||||
}
|
||||
const groups = await allGroupsForResolver();
|
||||
return closureUp(member, groups).map(group => group.cn);
|
||||
}
|
||||
return (await cachedListDetail()).map(group => group.cn);
|
||||
}
|
||||
|
||||
// The members a group effectively grants: users reached through any chain of
|
||||
// nested groups, plus the nested groups themselves for display.
|
||||
Group.effectiveMembers = async function(cn){
|
||||
const groups = await allGroupsForResolver();
|
||||
const group = groups.find(g => g.cn === cn);
|
||||
if (!group) {
|
||||
let error = new Error('GroupNotFound');
|
||||
error.name = 'GroupNotFound';
|
||||
error.message = `LDAP:${cn} does not exists`;
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
const { users, nested } = closureDown(group, groups);
|
||||
const directMembers = [].concat(group.member || []).filter(Boolean);
|
||||
const groupDns = new Set(groups.map(g => lc(g.dn)));
|
||||
return {
|
||||
cn: group.cn,
|
||||
direct: directMembers.filter(dn => !groupDns.has(lc(dn))),
|
||||
nestedGroups: nested.map(g => ({ cn: g.cn, dn: g.dn })),
|
||||
effective: users,
|
||||
};
|
||||
};
|
||||
|
||||
// Would adding `childDn` to `parentCn` create a cycle? A group may not contain
|
||||
// itself, nor anything that already (transitively) contains it -- such a chain
|
||||
// makes membership unanswerable, and callers would rely on the depth cap to
|
||||
// stop rather than getting a real answer.
|
||||
Group.wouldCycle = async function(parentCn, childDn){
|
||||
const groups = await allGroupsForResolver();
|
||||
const parent = groups.find(g => g.cn === parentCn);
|
||||
if (!parent) return false;
|
||||
if (lc(parent.dn) === lc(childDn)) return true;
|
||||
const child = groups.find(g => lc(g.dn) === lc(childDn));
|
||||
if (!child) return false; // a user DN can never close a cycle
|
||||
// Adding child under parent is a cycle exactly when parent is already
|
||||
// reachable downward from child.
|
||||
const { nested } = closureDown(child, groups);
|
||||
return nested.some(g => lc(g.dn) === lc(parent.dn));
|
||||
};
|
||||
|
||||
Group.clearResolverCache = function(){ resolverCache.clear(); };
|
||||
|
||||
Group.listDetail = async function(member){
|
||||
if (member) {
|
||||
return withClient(async (client) => getGroups(client, member));
|
||||
@@ -166,6 +338,7 @@ Group.add = async function(data){
|
||||
return withClient(async (client) => {
|
||||
await addGroup(client, data);
|
||||
cache.clear();
|
||||
resolverCache.clear();
|
||||
return this.get(data);
|
||||
});
|
||||
}
|
||||
@@ -174,6 +347,7 @@ Group.addMember = async function(user){
|
||||
await withClient(async (client) => addMember(client, this, user));
|
||||
this.member = [].concat(this.member || []).concat([user.dn]);
|
||||
cache.clear();
|
||||
resolverCache.clear();
|
||||
return this;
|
||||
};
|
||||
|
||||
@@ -186,6 +360,7 @@ Group.removeMember = async function(user){
|
||||
}
|
||||
this.member = [].concat(this.member || []).filter(dn => dn !== user.dn);
|
||||
cache.clear();
|
||||
resolverCache.clear();
|
||||
return this;
|
||||
};
|
||||
|
||||
@@ -193,6 +368,7 @@ Group.addOwner = async function(user){
|
||||
await withClient(async (client) => addOwner(client, this, user));
|
||||
this.owner = [].concat(this.owner || []).concat([user.dn]);
|
||||
cache.clear();
|
||||
resolverCache.clear();
|
||||
return this;
|
||||
};
|
||||
|
||||
@@ -205,12 +381,14 @@ Group.removeOwner = async function(user){
|
||||
}
|
||||
this.owner = [].concat(this.owner || []).filter(dn => dn !== user.dn);
|
||||
cache.clear();
|
||||
resolverCache.clear();
|
||||
return this;
|
||||
};
|
||||
|
||||
Group.remove = async function(){
|
||||
await withClient(async (client) => client.del(this.dn));
|
||||
cache.clear();
|
||||
resolverCache.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ require('./api_token');
|
||||
|
||||
const { init } = require('@simpleworkjs/orm');
|
||||
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
||||
|
||||
const { AccessRequest } = require('./access_request');
|
||||
const { Webhook } = require('./webhook');
|
||||
const { PluginInstance } = require('./plugin_instance');
|
||||
async function initORM() {
|
||||
const ormConf = conf.orm || {
|
||||
dialect: 'sqlite',
|
||||
@@ -28,7 +30,7 @@ async function initORM() {
|
||||
await init({
|
||||
conf: { orm: ormConf },
|
||||
models: [
|
||||
Resource, ResourceEdge, ResourceGroup,
|
||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||
]
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
// PluginInstance — the registry of configured, loadable plugin copies.
|
||||
//
|
||||
// The SSO plugin system (see nodejs/services/plugin_registry.js) distinguishes
|
||||
// **plugin types** (the .js modules under nodejs/plugins/<category>/<type>.js)
|
||||
// from **plugin instances** — a configured, loadable/unloadable *copy* of a
|
||||
// type. You can have several instances of the same type (e.g. two Proxmox
|
||||
// endpoints with their own URLs + tokens), each on its own schedule.
|
||||
//
|
||||
// This table holds the *non-secret* per-instance state: which type it is, its
|
||||
// schedule (cron), whether it's loaded (enabled), and its non-secret config.
|
||||
// Per-instance **secrets** (the configSchema fields flagged `secret:true`,
|
||||
// e.g. a Proxmox `tokenSecret` or UniFi `password`) live in OpenBao at
|
||||
// `secret/plugins/<id>/conf` (see nodejs/utils/plugin_secrets.js) — never in
|
||||
// the DB. The DB row's `config` JSON column holds only non-secret field values.
|
||||
//
|
||||
// `slug` is the discovery source name passed to DiscoveryReconciler.reconcile,
|
||||
// so a discovery instance's resources are attributed to a stable, human-chosen
|
||||
// name rather than its uuid. Unique, so two instances can't shadow each other
|
||||
// in the resource graph's `discovery_sources`.
|
||||
//
|
||||
// Like Resource/AccessRequest, there is no ORM auto-timestamp hook: the route
|
||||
// handler stamps created_by/on + updated_by/on explicitly on every write (see
|
||||
// routes/api_plugins.js). `id` (uuid) is generated by the ORM on create.
|
||||
|
||||
const { Model } = require('@simpleworkjs/orm');
|
||||
|
||||
const STATUS = {
|
||||
OK: 'ok',
|
||||
ERROR: 'error',
|
||||
RUNNING: 'running',
|
||||
};
|
||||
|
||||
class PluginInstance extends Model {
|
||||
static fields = {
|
||||
id: { type: 'uuid', primaryKey: true },
|
||||
// A registered plugin type slug (matches a manifest `type`). Validated
|
||||
// against the registry before a row is created.
|
||||
pluginType: { type: 'string', isRequired: true, min: 1, max: 64 },
|
||||
// The plugin's category (e.g. 'discovery'). Copied from the manifest at
|
||||
// create time so the scheduler can dispatch without re-reading the registry
|
||||
// on every run (and so a later type removal still shows what the instance was).
|
||||
category: { type: 'string', isRequired: true, default: 'discovery', min: 1, max: 64 },
|
||||
// Human label for the instance.
|
||||
name: { type: 'string', isRequired: true, min: 1, max: 120 },
|
||||
// Stable handle: discovery source name + unique constraint. Lowercase
|
||||
// alnum + hyphen/underscore to stay safe as a resource-graph slug.
|
||||
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||
// Loaded into the scheduler? `false` = unloaded (no scheduled runs).
|
||||
enabled: { type: 'boolean', default: true },
|
||||
// Cron schedule (5-field). The scheduler turns this into a BullMQ
|
||||
// repeatable JobScheduler.
|
||||
cron: { type: 'string', isRequired: true, default: '0 * * * *' },
|
||||
// Non-secret configSchema field values. Secret fields are NOT here.
|
||||
config: { type: 'json', default: {} },
|
||||
// Last-run bookkeeping, updated by the scheduler worker.
|
||||
lastRunAt: { type: 'integer' },
|
||||
lastStatus: { type: 'string' },
|
||||
lastError: { type: 'text' },
|
||||
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||
created_by: { type: 'string' },
|
||||
created_on: { type: 'integer' },
|
||||
updated_by: { type: 'string' },
|
||||
updated_on: { type: 'integer' },
|
||||
};
|
||||
|
||||
// All instances the scheduler should run: enabled only. Loaded fresh each
|
||||
// boot / load; not cached on the model (the scheduler is the source of truth
|
||||
// for what's actually scheduled).
|
||||
static async listEnabled() {
|
||||
return this.list({ where: { enabled: true } });
|
||||
}
|
||||
|
||||
// Look up by slug — used by tests + the reconciler when only a slug is known.
|
||||
static async getBySlug(slug) {
|
||||
const rows = await this.list({ where: { slug } });
|
||||
return rows[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginInstance, STATUS };
|
||||
+63
-30
@@ -103,6 +103,40 @@ class Resource extends Model {
|
||||
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) {
|
||||
const userGroups = await Group.list(userDn);
|
||||
if (!userGroups || userGroups.length === 0) return [];
|
||||
@@ -110,38 +144,11 @@ class Resource extends Model {
|
||||
const resourceGroups = await ResourceGroup.list({
|
||||
where: { groupCn: { in: userGroups } }
|
||||
});
|
||||
|
||||
|
||||
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
|
||||
if (resourceIds.length === 0) return [];
|
||||
|
||||
const resources = 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;
|
||||
});
|
||||
|
||||
return this.withResolvedAddress(await this.list({ where: { id: { in: resourceIds } } }));
|
||||
}
|
||||
|
||||
static fields = {
|
||||
@@ -152,10 +159,36 @@ class Resource extends Model {
|
||||
owner: { type: 'string' },
|
||||
description: { type: 'text' },
|
||||
metadata: { type: 'json', default: {} },
|
||||
// Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these
|
||||
// are set explicitly by the route handler on every create/update (see
|
||||
// routes/api_directory_admin.js). Existing rows predating this change
|
||||
// simply read back undefined -- callers must render a fallback.
|
||||
created_by: { type: 'string' },
|
||||
created_on: { type: 'integer' },
|
||||
updated_by: { type: 'string' },
|
||||
updated_on: { type: 'integer' },
|
||||
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
||||
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
||||
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
||||
};
|
||||
|
||||
// Walk parent ResourceEdges from resourceId up to the nearest ancestor
|
||||
// whose kind === 'site', returning its slug (or null if none exists -- a
|
||||
// top-level resource with no site parent keeps its unprefixed group name).
|
||||
static async findAncestorSiteSlug(resourceId, visited = new Set()) {
|
||||
if (visited.has(resourceId)) return null;
|
||||
visited.add(resourceId);
|
||||
|
||||
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } });
|
||||
for (const edge of parentEdges) {
|
||||
const parent = await this.get(edge.parentId);
|
||||
if (!parent) continue;
|
||||
if (parent.kind === 'site') return parent.slug;
|
||||
const found = await this.findAncestorSiteSlug(parent.id, visited);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceEdge extends Model {
|
||||
|
||||
@@ -10,6 +10,21 @@ function toE164Digits(number) {
|
||||
}
|
||||
|
||||
async function send(to, message) {
|
||||
const { PluginInstance } = require('./plugin_instance');
|
||||
const registry = require('../services/plugin_registry');
|
||||
const pluginSecrets = require('../utils/plugin_secrets');
|
||||
|
||||
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
|
||||
if (instances.length > 0) {
|
||||
const inst = instances[0];
|
||||
const manifest = registry.getManifest(inst.pluginType);
|
||||
if (manifest && manifest.sendMessage) {
|
||||
const secrets = await pluginSecrets.read(inst.id).catch(() => ({}));
|
||||
const config = { ...inst.config, ...secrets };
|
||||
return manifest.sendMessage(config, { to, message });
|
||||
}
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
api_username: conf.username,
|
||||
api_password: conf.password,
|
||||
|
||||
@@ -295,6 +295,9 @@ User.listDetail = async function(){
|
||||
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
||||
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
||||
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
||||
// hasSshKey is a boolean flag for the UI -- sshPublicKey may be an array,
|
||||
// and Mustache's {{#sshPublicKey}}...{{/sshPublicKey}} iterates over each item.
|
||||
obj.hasSshKey = obj.sshPublicKey ? 'yes' : '';
|
||||
|
||||
return obj;
|
||||
}));
|
||||
@@ -473,6 +476,19 @@ User.update = async function(data){
|
||||
}
|
||||
|
||||
if(data.sshPublicKey){
|
||||
// Ensure the auxiliary objectClass is present before setting the attribute
|
||||
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
||||
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
||||
try {
|
||||
await client.modify(this.dn, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
||||
}),
|
||||
]);
|
||||
} catch(e) {
|
||||
if(e.name !== 'TypeOrValueExistsError') throw e;
|
||||
}
|
||||
await client.modify(this.dn, [
|
||||
new Change({
|
||||
operation: 'replace',
|
||||
@@ -784,6 +800,19 @@ User.addSSHkey = async function(data) {
|
||||
let result;
|
||||
try {
|
||||
await withClient(async (client) => {
|
||||
// Ensure the auxiliary objectClass is present before setting the attribute
|
||||
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
||||
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
||||
try {
|
||||
await client.modify(user.dn, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
||||
}),
|
||||
]);
|
||||
} catch(e) {
|
||||
if (e.name !== 'TypeOrValueExistsError') throw e;
|
||||
}
|
||||
await client.modify(user.dn, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
|
||||
@@ -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 };
|
||||
Generated
+523
-15
@@ -1,28 +1,33 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.5.0",
|
||||
"version": "1.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.5.0",
|
||||
"version": "1.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bullmq": "^6.0.3",
|
||||
"compression": "^1.8.1",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"extend": "^3.0.2",
|
||||
"http-proxy-middleware": "^2.0.10",
|
||||
"ioredis": "^6.0.0",
|
||||
"jq-repeat": "^2.2.0",
|
||||
"jquery": "^4.0.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
@@ -32,9 +37,12 @@
|
||||
"model-redis": "^1.6.0",
|
||||
"moment": "^2.30.1",
|
||||
"mustache": "^4.2.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-nmap": "^4.0.0",
|
||||
"nodemailer": "^9.0.0",
|
||||
"p2psub": "^0.2.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"ws": "^8.21.1",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -649,6 +657,12 @@
|
||||
"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": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -1097,6 +1111,84 @@
|
||||
"@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": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
@@ -1257,6 +1349,18 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/bao-conf": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
|
||||
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/conf": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||
@@ -1270,9 +1374,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/directory-schema": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/directory-schema/-/directory-schema-1.0.0.tgz",
|
||||
"integrity": "sha512-thZhPGNdDYlD8rlhXidnbCHTKjdSkj9ag1zE/gz1AwuclYypsKAP+v3BAvcZ/YDQP8RBDJNPXof5EpVheLovTg==",
|
||||
"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"
|
||||
@@ -1413,6 +1526,15 @@
|
||||
"@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": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -2234,7 +2356,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
@@ -2324,6 +2445,54 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -2749,6 +2918,18 @@
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2838,6 +3019,15 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -3191,6 +3381,12 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
@@ -3441,7 +3637,6 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -3508,6 +3703,26 @@
|
||||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -3871,6 +4086,44 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||
@@ -3987,6 +4240,59 @@
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"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": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
@@ -4029,7 +4335,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -4059,7 +4364,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
@@ -4072,12 +4376,23 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
@@ -5072,6 +5387,15 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
@@ -5170,6 +5494,31 @@
|
||||
"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": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
@@ -5314,6 +5663,37 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"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": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
||||
@@ -5385,6 +5765,12 @@
|
||||
"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": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
||||
@@ -5394,6 +5780,26 @@
|
||||
"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": {
|
||||
"version": "12.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
||||
@@ -5430,6 +5836,21 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
||||
@@ -5476,6 +5897,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
@@ -6067,6 +6498,12 @@
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||
@@ -6191,6 +6628,15 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -6201,6 +6647,12 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
|
||||
@@ -6295,6 +6747,15 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"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": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -6905,6 +7366,12 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -7332,7 +7799,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
@@ -7366,13 +7832,17 @@
|
||||
"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": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
@@ -7603,6 +8073,22 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -7764,6 +8250,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": {
|
||||
"version": "1.0.15",
|
||||
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
||||
|
||||
+11
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.5.0",
|
||||
"version": "1.19.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
@@ -23,18 +23,23 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bullmq": "^6.0.3",
|
||||
"compression": "^1.8.1",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"extend": "^3.0.2",
|
||||
"http-proxy-middleware": "^2.0.10",
|
||||
"ioredis": "^6.0.0",
|
||||
"jq-repeat": "^2.2.0",
|
||||
"jquery": "^4.0.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
@@ -44,9 +49,12 @@
|
||||
"model-redis": "^1.6.0",
|
||||
"moment": "^2.30.1",
|
||||
"mustache": "^4.2.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-nmap": "^4.0.0",
|
||||
"nodemailer": "^9.0.0",
|
||||
"p2psub": "^0.2.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"ws": "^8.21.1",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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,81 @@
|
||||
const http = require('http');
|
||||
|
||||
module.exports = {
|
||||
type: 'docker',
|
||||
category: 'discovery',
|
||||
name: 'Docker Daemon',
|
||||
description: 'Discover running containers and networks from a local or remote Docker daemon.',
|
||||
configSchema: [
|
||||
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
|
||||
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' }
|
||||
],
|
||||
|
||||
validate: async (config) => {
|
||||
if (!config.socketPath && !config.tcpHost) {
|
||||
return { ok: false, error: 'Must provide either socketPath or tcpHost' };
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
discover: async (config) => {
|
||||
const isTcp = !!config.tcpHost;
|
||||
|
||||
const requestOptions = {
|
||||
path: '/containers/json',
|
||||
method: 'GET'
|
||||
};
|
||||
|
||||
if (isTcp) {
|
||||
const url = new URL(config.tcpHost);
|
||||
requestOptions.host = url.hostname;
|
||||
requestOptions.port = url.port || (url.protocol === 'https:' ? 443 : 80);
|
||||
requestOptions.protocol = url.protocol;
|
||||
} else {
|
||||
requestOptions.socketPath = config.socketPath || '/var/run/docker.sock';
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(requestOptions, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`Docker API error: ${res.statusCode} ${body}`));
|
||||
}
|
||||
|
||||
try {
|
||||
const containers = JSON.parse(body);
|
||||
const resources = [];
|
||||
const edges = [];
|
||||
|
||||
for (const c of containers) {
|
||||
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
|
||||
const slug = `docker-cnt-${c.Id.substring(0, 12)}`;
|
||||
|
||||
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
|
||||
|
||||
resources.push({
|
||||
kind: 'container',
|
||||
name: name,
|
||||
slug: slug,
|
||||
metadata: {
|
||||
image: c.Image,
|
||||
state: c.State,
|
||||
status: c.Status,
|
||||
ports: ports
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolve({ resources, edges });
|
||||
} catch (e) {
|
||||
reject(new Error(`Failed to parse Docker response: ${e.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => reject(new Error(`Docker connection error: ${e.message}`)));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
const nmap = require('node-nmap');
|
||||
nmap.nmapLocation = "nmap"; // default
|
||||
|
||||
module.exports = {
|
||||
// Plugin manifest — see nodejs/services/plugin_registry.js. `targetRange` is
|
||||
// not secret (it's a network range to scan), so it lives in the DB row, not
|
||||
// OpenBao. nmap itself has no credentials to test, so `validate` only checks
|
||||
// the range parses — running a real scan is what `run` does.
|
||||
type: 'nmap',
|
||||
category: 'discovery',
|
||||
name: 'Nmap Network Scan',
|
||||
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
|
||||
configSchema: [
|
||||
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' }
|
||||
],
|
||||
|
||||
validate: async (config) => {
|
||||
const { targetRange } = config;
|
||||
if (!targetRange) return { ok: false, error: 'Missing targetRange' };
|
||||
// nmap accepts CIDR (a.b.c.d/24), ranges (a.b.c.d-50), and host lists. We
|
||||
// only sanity-check shape here — reject anything with shell metacharacters
|
||||
// or whitespace, since node-nmap passes this straight to the nmap binary.
|
||||
if (/\s|[;|&$`<>]/.test(targetRange)) {
|
||||
return { ok: false, error: 'targetRange must not contain whitespace or shell metacharacters' };
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
discover: async (config) => {
|
||||
const { targetRange } = config;
|
||||
if (!targetRange) throw new Error("Missing targetRange for Nmap");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const scan = new nmap.OsAndPortScan(targetRange);
|
||||
scan.command.push('-Pn');
|
||||
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) {
|
||||
// node-nmap's spawn-missing-binary message ("NMAP not found at command
|
||||
// location: nmap") is opaque to an admin reading lastError. Translate
|
||||
// it into something actionable. (The Dockerfile installs nmap in the
|
||||
// app image; this only fires if someone runs outside the container or
|
||||
// strips the package.)
|
||||
var msg = (error && error.message) || String(error);
|
||||
if (/nmap.*not found|command location/i.test(msg)) {
|
||||
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
scan.startScan();
|
||||
});
|
||||
},
|
||||
|
||||
// Generalized plugin contract alias for `discover`. See proxmox.js for why
|
||||
// this references module.exports rather than `this`.
|
||||
run: async (config) => module.exports.discover(config)
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
const fetch = require('node-fetch');
|
||||
const https = require('https');
|
||||
|
||||
// Custom agent to bypass self-signed certs typical in Proxmox
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
|
||||
// drives the admin UI form and validation; fields flagged `secret:true` are
|
||||
// stored in OpenBao (secret/plugins/<instance-id>/conf), never in the DB.
|
||||
type: 'proxmox',
|
||||
category: 'discovery',
|
||||
name: 'Proxmox VE',
|
||||
description: 'Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.',
|
||||
configSchema: [
|
||||
{ key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' },
|
||||
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' },
|
||||
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
||||
],
|
||||
|
||||
// "Test" button in the UI: hit the unauthenticated version endpoint with the
|
||||
// API token to confirm the URL + token are valid before scheduling runs.
|
||||
validate: async (config) => {
|
||||
const { url, tokenId, tokenSecret } = config;
|
||||
if (!url || !tokenId || !tokenSecret) return { ok: false, error: 'Missing url, tokenId, or tokenSecret' };
|
||||
try {
|
||||
const res = await fetch(`${url}/api2/json/version`, { headers: { 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}` }, agent });
|
||||
if (!res.ok) return { ok: false, error: `Proxmox API rejected the token (${res.status})` };
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
},
|
||||
|
||||
discover: async (config) => {
|
||||
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) {
|
||||
const errText = await resNodes.text();
|
||||
throw new Error(`Proxmox API error on nodes: ${resNodes.status} ${errText}`);
|
||||
}
|
||||
const nodes = (await resNodes.json()).data;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.status !== 'online') continue;
|
||||
|
||||
const nodeSlug = `pve-node-${node.node}`;
|
||||
resources.push({
|
||||
kind: 'host',
|
||||
name: node.node,
|
||||
slug: nodeSlug,
|
||||
metadata: {
|
||||
subType: 'hypervisor',
|
||||
os: 'Proxmox VE',
|
||||
isProduction: true,
|
||||
interfaces: []
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Get VMs for this node
|
||||
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
|
||||
const vms = resVms.ok ? ((await resVms.json()).data || []) : [];
|
||||
|
||||
for (const vm of vms) {
|
||||
const vmSlug = `vm-${vm.vmid}`;
|
||||
const isTemplate = vm.template === 1;
|
||||
|
||||
let ips = [];
|
||||
let macs = [];
|
||||
|
||||
// Enrich from QEMU guest agent if running
|
||||
if (vm.status === 'running') {
|
||||
try {
|
||||
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
|
||||
if (agentRes.ok) {
|
||||
const agentData = (await agentRes.json()).data;
|
||||
if (agentData && agentData.result) {
|
||||
for (const iface of agentData.result) {
|
||||
if (iface['hardware-address'] && iface['hardware-address'] !== '00:00:00:00:00:00') macs.push(iface['hardware-address']);
|
||||
if (iface['ip-addresses']) {
|
||||
for (const ip of iface['ip-addresses']) {
|
||||
if (ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1') {
|
||||
ips.push(ip['ip-address']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// Enrich from VM config to at least get MAC if agent failed/stopped
|
||||
try {
|
||||
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
|
||||
if (configRes.ok) {
|
||||
const confData = (await configRes.json()).data;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (confData[`net${i}`]) {
|
||||
const m = confData[`net${i}`].match(/(?:virtio|e1000|rtl8139|vmxnet3)=([0-9a-fA-F:]+)/);
|
||||
if(m) macs.push(m[1].toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
|
||||
|
||||
resources.push({
|
||||
kind: isTemplate ? 'template' : 'host',
|
||||
name: vm.name || `VM ${vm.vmid}`,
|
||||
slug: vmSlug,
|
||||
metadata: {
|
||||
subType: isTemplate ? 'template' : 'vm',
|
||||
vmid: vm.vmid,
|
||||
isProduction: vm.status === 'running',
|
||||
interfaces,
|
||||
ip: ips[0] || null
|
||||
}
|
||||
});
|
||||
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
|
||||
}
|
||||
|
||||
// 3. Get LXCs for this node
|
||||
const resLxcs = await fetch(`${url}/api2/json/nodes/${node.node}/lxc`, { headers, agent });
|
||||
const lxcs = resLxcs.ok ? ((await resLxcs.json()).data || []) : [];
|
||||
|
||||
for (const lxc of lxcs) {
|
||||
const lxcSlug = `lxc-${lxc.vmid}`;
|
||||
const isTemplate = lxc.template === 1;
|
||||
|
||||
let ips = [];
|
||||
let macs = [];
|
||||
|
||||
// Enrich from LXC config
|
||||
try {
|
||||
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
|
||||
if (configRes.ok) {
|
||||
const confData = (await configRes.json()).data;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (confData[`net${i}`]) {
|
||||
const hwMatch = confData[`net${i}`].match(/hwaddr=([0-9a-fA-F:]+)/);
|
||||
const ipMatch = confData[`net${i}`].match(/ip=([0-9\.]+)/); // Ignores dhcp
|
||||
if(hwMatch) macs.push(hwMatch[1].toLowerCase());
|
||||
if(ipMatch) ips.push(ipMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
|
||||
|
||||
resources.push({
|
||||
kind: isTemplate ? 'template' : 'host',
|
||||
name: lxc.name || `LXC ${lxc.vmid}`,
|
||||
slug: lxcSlug,
|
||||
metadata: {
|
||||
subType: isTemplate ? 'template' : 'lxc',
|
||||
vmid: lxc.vmid,
|
||||
isProduction: lxc.status === 'running',
|
||||
interfaces,
|
||||
ip: ips[0] || null
|
||||
}
|
||||
});
|
||||
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
|
||||
}
|
||||
}
|
||||
|
||||
return { resources, edges };
|
||||
},
|
||||
|
||||
// The generalized plugin contract calls `run`; the discovery plugins keep
|
||||
// `discover` as their implementation name for back-compat, and `run` is just
|
||||
// an alias. Referenced via module.exports (not `this`) so it survives being
|
||||
// detached and called as a bare function reference.
|
||||
run: async (config) => module.exports.discover(config)
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
const fetch = require('node-fetch');
|
||||
const https = require('https');
|
||||
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
// Plugin manifest — see nodejs/services/plugin_registry.js. `password` is
|
||||
// secret and stored in OpenBao (secret/plugins/<instance-id>/conf).
|
||||
type: 'unifi',
|
||||
category: 'discovery',
|
||||
name: 'UniFi Network',
|
||||
description: 'Discover UniFi network devices and clients from a UniFi Controller / UDM endpoint.',
|
||||
configSchema: [
|
||||
{ key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' },
|
||||
{ key: 'user', label: 'Username', type: 'text', required: true },
|
||||
{ key: 'password', label: 'Password', type: 'password', required: true, secret: true }
|
||||
],
|
||||
|
||||
// "Test": attempt the UDM login (falls back to the legacy controller login);
|
||||
// succeeds only if one of the two login endpoints returns 200.
|
||||
validate: async (config) => {
|
||||
const { url, user, password } = config;
|
||||
if (!url || !user || !password) return { ok: false, error: 'Missing url, user, or password' };
|
||||
try {
|
||||
let loginRes = await fetch(`${url}/api/auth/login`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password }), agent
|
||||
});
|
||||
if (!loginRes.ok) {
|
||||
loginRes = await fetch(`${url}/api/login`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password }), agent
|
||||
});
|
||||
}
|
||||
if (!loginRes.ok) return { ok: false, error: `UniFi auth failed (${loginRes.status})` };
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
},
|
||||
|
||||
discover: async (config) => {
|
||||
const { url, user, password } = config;
|
||||
if (!url || !user || !password) {
|
||||
throw new Error("Missing Unifi config");
|
||||
}
|
||||
|
||||
// 1. Authenticate
|
||||
let loginRes = await fetch(`${url}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password }),
|
||||
agent
|
||||
});
|
||||
|
||||
let isUdm = true;
|
||||
if (!loginRes.ok) {
|
||||
loginRes = await fetch(`${url}/api/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password }),
|
||||
agent
|
||||
});
|
||||
isUdm = false;
|
||||
}
|
||||
|
||||
if (!loginRes.ok) {
|
||||
throw new Error(`Unifi auth failed: ${loginRes.status}`);
|
||||
}
|
||||
|
||||
const cookie = loginRes.headers.get('set-cookie');
|
||||
// UniFi often requires the CSRF token from the cookie
|
||||
let csrf = '';
|
||||
if (cookie) {
|
||||
const match = cookie.match(/csrf_token=([^;]+)/);
|
||||
if (match) csrf = match[1];
|
||||
}
|
||||
const headers = { 'Cookie': cookie, 'X-Csrf-Token': csrf };
|
||||
|
||||
const resources = [];
|
||||
const edges = [];
|
||||
|
||||
const basePath = isUdm ? '/proxy/network' : '';
|
||||
|
||||
// 2. Get Devices (Switches/APs)
|
||||
const devRes = await fetch(`${url}${basePath}/api/s/default/stat/device`, { headers, agent });
|
||||
const devData = (await devRes.json()).data || [];
|
||||
|
||||
for (const dev of devData) {
|
||||
const devSlug = `unifi-device-${dev.mac.replace(/:/g, '')}`;
|
||||
resources.push({
|
||||
kind: 'network_device',
|
||||
name: dev.name || dev.model,
|
||||
slug: devSlug,
|
||||
metadata: {
|
||||
make: 'Ubiquiti',
|
||||
model: dev.model,
|
||||
firmware: dev.version,
|
||||
interfaces: [{ mac: dev.mac, ip: dev.ip }]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Get Clients
|
||||
const clientRes = await fetch(`${url}${basePath}/api/s/default/stat/sta`, { headers, agent });
|
||||
const clientData = (await clientRes.json()).data || [];
|
||||
|
||||
for (const client of clientData) {
|
||||
const clientSlug = `unifi-client-${client.mac.replace(/:/g, '')}`;
|
||||
resources.push({
|
||||
kind: 'host', // Or unmanaged_device initially
|
||||
name: client.hostname || client.name || client.mac,
|
||||
slug: clientSlug,
|
||||
metadata: {
|
||||
interfaces: [{ mac: client.mac, ip: client.ip }]
|
||||
}
|
||||
});
|
||||
|
||||
// If we know which switch/AP it's on
|
||||
if (client.ap_mac) {
|
||||
const apSlug = `unifi-device-${client.ap_mac.replace(/:/g, '')}`;
|
||||
edges.push({ parentSlug: apSlug, childSlug: clientSlug, relation: 'connected_to' });
|
||||
}
|
||||
}
|
||||
|
||||
return { resources, edges };
|
||||
},
|
||||
|
||||
// Generalized plugin contract alias for `discover`. See proxmox.js for why
|
||||
// this references module.exports rather than `this`.
|
||||
run: async (config) => module.exports.discover(config)
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
const https = require('https');
|
||||
|
||||
module.exports = {
|
||||
type: 'twilio',
|
||||
category: 'messaging',
|
||||
name: 'Twilio SMS',
|
||||
description: 'Send SMS messages (like 2FA codes) via Twilio.',
|
||||
|
||||
configSchema: [
|
||||
{ key: 'accountSid', label: 'Account SID', type: 'text', required: true },
|
||||
{ key: 'authToken', label: 'Auth Token', type: 'password', required: true, secret: true },
|
||||
{ key: 'fromNumber', label: 'From Phone Number', type: 'text', required: true, placeholder: '+15551234567' }
|
||||
],
|
||||
|
||||
validate: async (config) => {
|
||||
if (!config.accountSid || !config.authToken) return { ok: false, error: 'Missing credentials' };
|
||||
if (!config.fromNumber) return { ok: false, error: 'Missing fromNumber' };
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
sendMessage: async (config, payload) => {
|
||||
const { to, message } = payload;
|
||||
if (!to || !message) throw new Error("Missing 'to' or 'message' in payload");
|
||||
|
||||
const data = new URLSearchParams();
|
||||
data.append('To', to);
|
||||
data.append('From', config.fromNumber);
|
||||
data.append('Body', message);
|
||||
|
||||
const postData = data.toString();
|
||||
|
||||
const options = {
|
||||
hostname: 'api.twilio.com',
|
||||
port: 443,
|
||||
path: `/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Basic ' + Buffer.from(config.accountSid + ':' + config.authToken).toString('base64'),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData)
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(JSON.parse(body));
|
||||
} else {
|
||||
reject(new Error(`Twilio API Error: ${res.statusCode} ${body}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
module.exports = {
|
||||
type: 'webhook',
|
||||
category: 'messaging',
|
||||
name: 'Universal REST Webhook',
|
||||
description: 'Send a generic HTTP POST request with a custom JSON payload. Variables {{to}} and {{message}} will be replaced.',
|
||||
|
||||
configSchema: [
|
||||
{ key: 'url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://api.example.com/send' },
|
||||
{ key: 'method', label: 'HTTP Method', type: 'text', required: true, placeholder: 'POST' },
|
||||
{ key: 'headers', label: 'Custom Headers (JSON)', type: 'text', required: false, placeholder: '{"Authorization": "Bearer ...", "Content-Type": "application/json"}' },
|
||||
{ key: 'payloadTemplate', label: 'Payload Template', type: 'text', required: true, placeholder: '{"recipient": "{{to}}", "text": "{{message}}"}' },
|
||||
{ key: 'apiSecret', label: 'API Secret / Auth Token', type: 'password', required: false, secret: true }
|
||||
],
|
||||
|
||||
validate: async (config) => {
|
||||
if (!config.url) return { ok: false, error: 'URL is required' };
|
||||
if (!config.payloadTemplate) return { ok: false, error: 'Payload template is required' };
|
||||
try {
|
||||
if (config.headers) JSON.parse(config.headers);
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'Headers must be valid JSON' };
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
sendMessage: async (config, payload) => {
|
||||
const { to, message } = payload;
|
||||
let payloadStr = config.payloadTemplate || '{}';
|
||||
|
||||
// Replace template variables safely
|
||||
payloadStr = payloadStr.replace(/\{\{to\}\}/g, to).replace(/\{\{message\}\}/g, message);
|
||||
|
||||
// If there is an API secret, replace {{secret}} in the headers or url
|
||||
let headersObj = {};
|
||||
if (config.headers) {
|
||||
try {
|
||||
const parsed = JSON.parse(config.headers);
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
headersObj[k] = config.apiSecret ? String(v).replace(/\{\{secret\}\}/g, config.apiSecret) : v;
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
if (!headersObj['Content-Type']) {
|
||||
headersObj['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const urlObj = new URL(config.url);
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: config.method || 'POST',
|
||||
headers: headersObj
|
||||
};
|
||||
|
||||
const client = urlObj.protocol === 'https:' ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = client.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({ status: res.statusCode, body });
|
||||
} else {
|
||||
reject(new Error(`Webhook failed: ${res.statusCode} ${body}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(payloadStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,12 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
/* Height of the fixed navbar (plus the update banner, while shown --
|
||||
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
|
||||
sticky element offset itself below both fixed elements via
|
||||
`top: var(--sw-content-offset)` instead of colliding with them at the
|
||||
viewport's true top:0. */
|
||||
--sw-content-offset: 4.5rem;
|
||||
}
|
||||
|
||||
#spa-shell {
|
||||
|
||||
+2
-16
@@ -67,13 +67,6 @@ app.user = (function(app){
|
||||
});
|
||||
}
|
||||
|
||||
function remove(args, callack){
|
||||
if(!confirm('Delete '+ args.uid+ 'user?')) return false;
|
||||
app.api.delete('user/'+ args.uid, function(error, data){
|
||||
callack(error, data);
|
||||
});
|
||||
}
|
||||
|
||||
function changePassword(args, callack){
|
||||
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
||||
callack(error, data);
|
||||
@@ -110,7 +103,7 @@ app.user = (function(app){
|
||||
return m ? m[1] : dn;
|
||||
}
|
||||
|
||||
return {list, remove, createInvite, setActive, dnToUid};
|
||||
return {list, createInvite, setActive, dnToUid};
|
||||
|
||||
})(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){
|
||||
app.api.put('oauth/client/' + args.client_id, args, function(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.tos = (function(app){
|
||||
|
||||
@@ -363,7 +363,7 @@ app.auth = (function(app){
|
||||
}
|
||||
|
||||
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||
app.util.actionMessage(
|
||||
app.messages.action(
|
||||
`<h1>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
<b>You do not have permission to be here.</b>
|
||||
@@ -520,68 +520,15 @@ app.util = (function(app){
|
||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||
};
|
||||
|
||||
function actionMessage(message, $targetPassed, type, callback){
|
||||
message = message || '';
|
||||
|
||||
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
||||
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
||||
|
||||
type = type || 'info';
|
||||
callback = callback || function(){};
|
||||
|
||||
if($target.html() === message) return;
|
||||
|
||||
if($target.html()){
|
||||
$target.slideUp('fast', function(){
|
||||
$target.html('')
|
||||
$target.removeClass (function(index, className){
|
||||
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
|
||||
});
|
||||
if(message) return actionMessage(message, $target, type, callback);
|
||||
$target.hide()
|
||||
})
|
||||
}else{
|
||||
if(type) $target.addClass('bg-' + type);
|
||||
|
||||
// Messages that bring their own buttons (actionConfirm) are left
|
||||
// alone; everything else gets the standard dismiss button.
|
||||
if(!message.includes('<button')) message = `
|
||||
<span class="align-middle">${message}</span>
|
||||
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
`
|
||||
$target.html(message).slideDown('fast');
|
||||
}
|
||||
setTimeout(callback,10)
|
||||
}
|
||||
|
||||
function actionConfirm(message, $target, type, callback){
|
||||
return new Promise((resolve, reject) =>{
|
||||
let id = crypto.randomUUID();
|
||||
message = `
|
||||
<h4 class"align-middle" >
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
<b>${message}</b>
|
||||
<span class="float-end">
|
||||
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
|
||||
<i class="fa-solid fa-circle-check"></i>
|
||||
Confirm
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger confirm-${id}">
|
||||
<i class="fa-solid fa-circle-stop"></i>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
</h4>
|
||||
`
|
||||
actionMessage(message, $target, type);
|
||||
$("body").on('click', `.confirm-${id}`, function(){
|
||||
actionMessage('', $target, type);
|
||||
resolve(!!$(this).data('confirm'));
|
||||
});
|
||||
});
|
||||
|
||||
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
|
||||
// app.util.escapeHtml and app.messages.action/confirm.
|
||||
function escapeHtml(s){
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
$.fn.serializeObject = function() {
|
||||
@@ -637,11 +584,31 @@ app.util = (function(app){
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
|
||||
// Scroll a just-added/-edited element into view and flash its
|
||||
// background, so the user's eye lands on the row that changed instead of
|
||||
// it silently appearing/updating somewhere off-screen. Takes a jQuery
|
||||
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
|
||||
function revealItem(el){
|
||||
var node = el && el.jquery ? el[0] : el;
|
||||
if (!node) return;
|
||||
if (typeof node.scrollIntoView === 'function') {
|
||||
node.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
}
|
||||
var prevTransition = node.style.transition;
|
||||
var prevBg = node.style.backgroundColor;
|
||||
node.style.transition = 'background-color 1.5s ease';
|
||||
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
|
||||
setTimeout(function(){
|
||||
node.style.backgroundColor = prevBg;
|
||||
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return {
|
||||
downloadFile: downloadFile,
|
||||
getUrlParameter: getUrlParameter,
|
||||
actionMessage: actionMessage,
|
||||
actionConfirm,
|
||||
escapeHtml: escapeHtml,
|
||||
revealItem: revealItem,
|
||||
}
|
||||
})(app);
|
||||
|
||||
@@ -696,9 +663,9 @@ $( document ).ready(async function(){
|
||||
$(this).closest('.card').slideUp('fast');
|
||||
});
|
||||
|
||||
$('.actionMessage').on('click', 'button.action-close', function(event){
|
||||
app.util.actionMessage(null, $(this));
|
||||
});
|
||||
// action-close click handling is wired by @simpleworkjs/frontend's
|
||||
// app.messages.js (delegated on document, so it also covers messages
|
||||
// rendered after this ready handler runs).
|
||||
|
||||
setInterval(()=>{
|
||||
$('.momentFromNow').each((idx, el)=>{
|
||||
@@ -729,20 +696,17 @@ function formAJAX(btn){
|
||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||
|
||||
if($form.validate && !$form.validate()){
|
||||
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
||||
app.messages.action('Please fix the form errors.', $form, 'danger')
|
||||
return false;
|
||||
}
|
||||
|
||||
app.util.actionMessage(
|
||||
`<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>`,
|
||||
$form,
|
||||
'info'
|
||||
);
|
||||
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||
// render literally instead of as an element.
|
||||
app.messages.action('Saving…', $form, 'info');
|
||||
|
||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||
$form.validateClear();
|
||||
if(!error){
|
||||
$form.trigger("reset");
|
||||
@@ -750,7 +714,7 @@ function formAJAX(btn){
|
||||
}else{
|
||||
console.log('formAJAX res error', error, data)
|
||||
if(data && data.name === 'ObjectValidateError'){
|
||||
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||
}
|
||||
if(data && data.keys){
|
||||
console.log('form key errors', data.keys)
|
||||
|
||||
@@ -1,201 +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){
|
||||
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 ));
|
||||
|
||||
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
|
||||
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
|
||||
// incoming host may be a wildcard ("*.example.com"); the target may not.
|
||||
(function(){
|
||||
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
|
||||
// Either one bare label (Docker service names, /etc/hosts entries) or a
|
||||
// dotted hostname with an alphabetic TLD.
|
||||
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
|
||||
var FORBIDDEN = /[\s/:]/;
|
||||
|
||||
function isIPv4( value ) {
|
||||
var parts = value.split( '.' );
|
||||
if ( parts.length !== 4 ) return false;
|
||||
return parts.every( function( p ) {
|
||||
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
|
||||
// (any number of fragments, incl. a bare "**" global catch-all).
|
||||
function isHostPattern( value ) {
|
||||
if ( value.length > 253 ) return false;
|
||||
return value.split( '.' ).every( function( l ) {
|
||||
return l === '*' || l === '**' || LABEL.test( l );
|
||||
});
|
||||
}
|
||||
|
||||
function forbidden( value ) {
|
||||
return FORBIDDEN.test( value ) || value.includes( '://' );
|
||||
}
|
||||
|
||||
// Incoming host: IPv4 or a wildcard host pattern.
|
||||
function checkHost( value ) {
|
||||
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
|
||||
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
|
||||
if ( isIPv4( value ) || isHostPattern( value ) ) return;
|
||||
return "Enter a valid host or wildcard (*, **)";
|
||||
}
|
||||
|
||||
// Downstream target: IPv4 or a strict hostname, no wildcard.
|
||||
function checkTarget( value ) {
|
||||
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
|
||||
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
|
||||
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
|
||||
return "Enter a valid hostname or IP";
|
||||
}
|
||||
|
||||
$.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";
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
|
||||
host: function( value ) {
|
||||
return checkHost( value );
|
||||
},
|
||||
|
||||
// Downstream target — hostname or IPv4, no wildcard.
|
||||
target: function( value ) {
|
||||
return checkTarget( value );
|
||||
},
|
||||
|
||||
// Back-compat alias (no wildcard).
|
||||
hostname: function( value ) {
|
||||
return checkTarget( value );
|
||||
},
|
||||
|
||||
user: function( value ) {
|
||||
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
|
||||
if ( reg.test( value ) === false ) {
|
||||
return "Invalid";
|
||||
}
|
||||
},
|
||||
|
||||
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
|
||||
// or at least 3 of {lowercase, uppercase, number, symbol}.
|
||||
password: function( value ) {
|
||||
if ( typeof value !== 'string' || value.length < 8 ) {
|
||||
return "Password must be at least 8 characters";
|
||||
}
|
||||
if ( value.length >= 12 ) return;
|
||||
|
||||
var classes = 0;
|
||||
if ( /[a-z]/.test( value ) ) classes++;
|
||||
if ( /[A-Z]/.test( value ) ) classes++;
|
||||
if ( /[0-9]/.test( value ) ) classes++;
|
||||
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
|
||||
|
||||
if ( classes < 3 ) {
|
||||
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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,53 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = function initAgentWebSockets(app) {
|
||||
if (!app.wss) {
|
||||
console.warn("WebSocket server for agents is not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
app.wss.on('connection', (ws, req) => {
|
||||
// Parse the token from query param or header (e.g. ?token=XYZ)
|
||||
// For the beta, we will just accept it if a token is present.
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get('token') || req.headers['authorization'];
|
||||
|
||||
if (!token) {
|
||||
ws.close(4001, 'Unauthorized: Missing token');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Theta Agent] Agent connected from ${req.socket.remoteAddress}`);
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
|
||||
// Example handling incoming telemetry
|
||||
if (data.type === 'telemetry') {
|
||||
// Send to discovery service or log
|
||||
// console.log(`[Theta Agent] Received telemetry from ${data.host}`);
|
||||
|
||||
// We can publish it to the event bus for the UI
|
||||
if(app.contoller && app.contoller.ps) {
|
||||
app.contoller.ps.publish('agent.telemetry', data);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Theta Agent] Error parsing message:", err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log(`[Theta Agent] Agent disconnected`);
|
||||
});
|
||||
|
||||
// Example: Send a welcome config payload to the agent
|
||||
ws.send(JSON.stringify({
|
||||
type: 'config',
|
||||
payload: {
|
||||
message: 'Welcome to SSO Manager C2'
|
||||
}
|
||||
}));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
const router = require('express').Router();
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
const permission = require('../utils/permission');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||
next();
|
||||
} catch(err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Secret fields stored inside secret/sso-manager/conf. These are NEVER returned
|
||||
// in cleartext by GET /api/conf (masked to MASK below) and, on save, a blank or
|
||||
// mask-valued submission preserves the stored value so an admin editing an
|
||||
// unrelated field (e.g. the From address) doesn't have to re-enter — or leak —
|
||||
// the SMTP password / OAuth JWT secret. Mirrors the plugin-secrets discipline.
|
||||
const MASK = '********';
|
||||
const SECRET_PATHS = [
|
||||
['smtp', 'pass'],
|
||||
['oauth', 'jwtSecret'],
|
||||
['voipms', 'password'],
|
||||
];
|
||||
|
||||
function maskSecrets(obj) {
|
||||
const out = JSON.parse(JSON.stringify(obj));
|
||||
for (const [grp, key] of SECRET_PATHS) {
|
||||
if (out[grp] && out[grp][key]) out[grp][key] = MASK;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const editable = maskSecrets({
|
||||
smtp: conf.smtp || {},
|
||||
discovery: conf.discovery || {},
|
||||
oauth: conf.oauth || {},
|
||||
voipms: conf.voipms || {}
|
||||
});
|
||||
res.json(editable);
|
||||
});
|
||||
|
||||
// Shallow-per-key merge of `src` into the live conf object (matches the old
|
||||
// conf_manager.applyConf behaviour: nested objects are spread, not deep-merged,
|
||||
// so call-time conf readers see saved values without a restart).
|
||||
function applyToLiveConf(src) {
|
||||
if (!src) return;
|
||||
for (const key of Object.keys(src)) {
|
||||
if (typeof src[key] === 'object' && src[key] !== null && !Array.isArray(src[key])) {
|
||||
conf[key] = { ...(conf[key] || {}), ...src[key] };
|
||||
} else {
|
||||
conf[key] = src[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const existing = await baoConf.get('sso-manager/conf') || {};
|
||||
const incoming = req.body || {};
|
||||
|
||||
// Preserve secret fields the admin left blank (or left showing the mask):
|
||||
// drop them from the incoming merge so the stored value survives. Only a
|
||||
// genuinely new, non-blank, non-mask value overwrites.
|
||||
for (const [grp, key] of SECRET_PATHS) {
|
||||
if (incoming[grp] && incoming[grp][key] !== undefined) {
|
||||
const submitted = incoming[grp][key];
|
||||
if (submitted === '' || submitted === MASK) delete incoming[grp][key];
|
||||
}
|
||||
}
|
||||
|
||||
// Deep merge incoming into existing
|
||||
for (const key of Object.keys(incoming)) {
|
||||
if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) {
|
||||
existing[key] = { ...(existing[key] || {}), ...incoming[key] };
|
||||
} else {
|
||||
existing[key] = incoming[key];
|
||||
}
|
||||
}
|
||||
await baoConf.set('sso-manager/conf', existing);
|
||||
// Reflect the saved values in the live conf immediately (the next boot's
|
||||
// bao-conf.init() would pick them up too, but this keeps running readers
|
||||
// current without a restart, as the old conf_manager did). `existing`
|
||||
// carries the preserved secret values, so live conf keeps them too.
|
||||
applyToLiveConf(existing);
|
||||
res.json({ success: true });
|
||||
} catch(err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
router.get('/proxy', async (req, res, next) => {
|
||||
try {
|
||||
const proxyConf = await baoConf.get('proxy/conf') || {};
|
||||
const editable = JSON.parse(JSON.stringify(proxyConf));
|
||||
if (editable.oidc && editable.oidc.clientSecret) editable.oidc.clientSecret = MASK;
|
||||
if (editable.ldap && editable.ldap.bindPassword) editable.ldap.bindPassword = MASK;
|
||||
res.json(editable);
|
||||
} catch(err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/proxy', async (req, res, next) => {
|
||||
try {
|
||||
const existing = await baoConf.get('proxy/conf') || {};
|
||||
const incoming = req.body || {};
|
||||
|
||||
if (incoming.oidc && incoming.oidc.clientSecret !== undefined) {
|
||||
if (incoming.oidc.clientSecret === '' || incoming.oidc.clientSecret === MASK) delete incoming.oidc.clientSecret;
|
||||
}
|
||||
if (incoming.ldap && incoming.ldap.bindPassword !== undefined) {
|
||||
if (incoming.ldap.bindPassword === '' || incoming.ldap.bindPassword === MASK) delete incoming.ldap.bindPassword;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(incoming)) {
|
||||
if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) {
|
||||
existing[key] = { ...(existing[key] || {}), ...incoming[key] };
|
||||
} else {
|
||||
existing[key] = incoming[key];
|
||||
}
|
||||
}
|
||||
await baoConf.set('proxy/conf', existing);
|
||||
res.json({ success: true });
|
||||
} catch(err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,8 +3,32 @@ const router = require('express').Router();
|
||||
const permission = require('../utils/permission');
|
||||
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
||||
const { Group } = require('../models/group_ldap');
|
||||
const { User } = require('../models/user_ldap');
|
||||
const { cnFromDn } = require('../utils/user_groups');
|
||||
const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||
|
||||
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||
|
||||
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
||||
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
||||
// the goal state, and a missing group (e.g. app_super_admin absent on a
|
||||
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
|
||||
// caller's real work.
|
||||
async function nestGroup(childCn, parentCn) {
|
||||
try {
|
||||
const parent = await Group.get(parentCn);
|
||||
const child = await Group.get(childCn);
|
||||
if (await Group.wouldCycle(parentCn, child.dn)) {
|
||||
console.error(`nestGroup: refusing ${childCn} -> ${parentCn} (would create a cycle)`);
|
||||
return;
|
||||
}
|
||||
await parent.addMember({ dn: child.dn });
|
||||
} catch (err) {
|
||||
const benign = err.name === 'TypeOrValueExistsError' || err.code === 20 || err.name === 'GroupNotFound';
|
||||
if (!benign) console.error(`nestGroup: ${childCn} -> ${parentCn} failed:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Require the admin group
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
@@ -18,7 +42,12 @@ router.use(async (req, res, next) => {
|
||||
// --- Resources ---
|
||||
router.get('/resources', async (req, res, next) => {
|
||||
try {
|
||||
const resources = await Resource.list();
|
||||
let resources = await Resource.list();
|
||||
resources = resources.filter(r => {
|
||||
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||
const isManaged = r.metadata?.managed === true;
|
||||
return !isAuto || isManaged;
|
||||
});
|
||||
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||
// the wire; projectResources strips it unconditionally.
|
||||
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
||||
@@ -43,25 +72,35 @@ router.post('/resources', async (req, res, next) => {
|
||||
}
|
||||
|
||||
req.body.owner = req.body.owner || req.user.uid;
|
||||
|
||||
|
||||
const now = Date.now();
|
||||
req.body.created_by = req.body.created_by || req.user.uid;
|
||||
req.body.created_on = now;
|
||||
req.body.updated_by = req.user.uid;
|
||||
req.body.updated_on = now;
|
||||
|
||||
let r;
|
||||
if (req.body.kind === 'oauth') {
|
||||
const { OAuthClient } = require('../models/oauth_client');
|
||||
// Pass created_by explicitly for the wrapper
|
||||
// Pass created_by explicitly for the wrapper (overrides the generic
|
||||
// assignment above -- this is OAuthClient-wrapper-specific behavior).
|
||||
req.body.created_by = req.body.owner;
|
||||
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
||||
r = await OAuthClient.add(req.body);
|
||||
} else {
|
||||
r = await Resource.create(req.body);
|
||||
}
|
||||
|
||||
|
||||
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
|
||||
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||
}
|
||||
|
||||
|
||||
if (r.kind === 'host' || r.kind === 'service') {
|
||||
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
||||
const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`);
|
||||
|
||||
const createGroup = async (suffix, accessLevel) => {
|
||||
const cn = `${r.slug}_${suffix}`;
|
||||
const cn = groupCn(suffix);
|
||||
try {
|
||||
await Group.add({
|
||||
name: cn,
|
||||
@@ -79,6 +118,21 @@ router.post('/resources', async (req, res, next) => {
|
||||
};
|
||||
await createGroup('access', 'member');
|
||||
await createGroup('admin', 'owner');
|
||||
|
||||
// Wire up the two standing relationships every resource has, as nesting
|
||||
// rather than as membership that has to be maintained per resource:
|
||||
//
|
||||
// app_super_admin -> <slug>_admin cross-app super admins administer
|
||||
// every resource, automatically
|
||||
// <slug>_admin -> <slug>_access administering something implies
|
||||
// being able to use it
|
||||
//
|
||||
// Before nesting, both of these could only be expressed by adding every
|
||||
// super admin to every new group by hand -- which nobody does, so the
|
||||
// groups drifted. A failure here must not fail resource creation: the
|
||||
// resource and its groups already exist and the nesting is repairable.
|
||||
await nestGroup(groupCn('admin'), groupCn('access'));
|
||||
await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin'));
|
||||
}
|
||||
|
||||
res.json({ results: r });
|
||||
@@ -95,15 +149,8 @@ router.post('/resources', async (req, res, next) => {
|
||||
|
||||
router.put('/resources/:id', async (req, res, next) => {
|
||||
try {
|
||||
let r;
|
||||
if (req.body.kind === 'oauth') {
|
||||
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' });
|
||||
|
||||
// Validate before loading anything -- a rejected body should never have
|
||||
// touched the store.
|
||||
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||
}
|
||||
@@ -113,14 +160,20 @@ router.put('/resources/:id', async (req, res, next) => {
|
||||
if (req.body.kind === 'oauth' && !req.body.hostId) {
|
||||
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
|
||||
}
|
||||
|
||||
let updated;
|
||||
if (req.body.kind === 'oauth') {
|
||||
updated = await r.update(req.body);
|
||||
} else {
|
||||
updated = await r.update(req.body);
|
||||
}
|
||||
|
||||
|
||||
// OAuthClient is a wrapper over the same `resource` row, but its .update()
|
||||
// handles the oauth-specific body fields (redirect_uris, scopes,
|
||||
// token_lifetime) that a bare Resource would drop into metadata unvalidated.
|
||||
const { OAuthClient } = require('../models/oauth_client');
|
||||
const model = req.body.kind === 'oauth' ? OAuthClient : Resource;
|
||||
const r = await model.get(req.params.id);
|
||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
req.body.updated_by = req.user.uid;
|
||||
req.body.updated_on = Date.now();
|
||||
|
||||
const updated = await r.update(req.body);
|
||||
|
||||
if ((updated.kind === 'host' || updated.kind === 'service' || updated.kind === 'oauth') && req.body.hostId !== undefined) {
|
||||
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
|
||||
for (const e of existingEdges) {
|
||||
@@ -152,13 +205,18 @@ router.delete('/resources/:id', async (req, res, next) => {
|
||||
try {
|
||||
const r = await Resource.get(req.params.id);
|
||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||
await r.delete();
|
||||
// Also delete edges and groups involving this resource
|
||||
// Clear the dependents FIRST. There is no transaction here, so ordering is
|
||||
// the only thing protecting us: if a dependent delete throws after the
|
||||
// resource row is gone, the leftovers are edges/links pointing at a
|
||||
// nonexistent id -- invisible in the UI and poisonous to getGraph(). Failing
|
||||
// with the resource still present is the recoverable direction (retry the
|
||||
// delete); the caller sees the error either way.
|
||||
const edgesParent = await ResourceEdge.list({ where: { parentId: req.params.id } });
|
||||
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
|
||||
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
|
||||
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
|
||||
for (const g of groups) await g.delete();
|
||||
await r.delete();
|
||||
res.json({ results: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -211,19 +269,138 @@ router.delete('/groups/:id', async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Access visibility ---
|
||||
//
|
||||
// The two questions an access-control pane has to answer, neither of which the
|
||||
// directory could answer before: "who can reach this resource" (a column on the
|
||||
// table, rather than three clicks into a modal) and "what can this user reach"
|
||||
// (which had no UI at all). Both are joins of the same two sets, so both are
|
||||
// served from one cached Group.listDetail() rather than a lookup per row.
|
||||
|
||||
// dn -> uid, so member DNs can be reported as the uids admins actually think in.
|
||||
async function dnToUidMap() {
|
||||
const users = await User.listDetail();
|
||||
return new Map(users.map(u => [String(u.dn).toLowerCase(), u.uid]));
|
||||
}
|
||||
|
||||
// GET /access-summary — { resourceId: { groups: [...], memberCount } }
|
||||
router.get('/access-summary', async (req, res, next) => {
|
||||
try {
|
||||
const [links, groups, uidByDn] = await Promise.all([
|
||||
ResourceGroup.list(),
|
||||
Group.listDetail(),
|
||||
dnToUidMap(),
|
||||
]);
|
||||
|
||||
const groupByCn = new Map(groups.map(g => [g.cn, g]));
|
||||
const summary = {};
|
||||
|
||||
for (const link of links) {
|
||||
const group = groupByCn.get(link.groupCn);
|
||||
// A link whose LDAP group has been deleted out from under it: report it
|
||||
// rather than skipping, since a dangling link grants nothing and the
|
||||
// admin needs to see that it is dead.
|
||||
//
|
||||
// Counts come from the transitive closure, not from `member`. Reading the
|
||||
// attribute would report only who is listed on the group, missing anyone
|
||||
// who reaches it through a nested group -- and since app_super_admin is
|
||||
// nested into every resource's _admin group, that is not an edge case.
|
||||
let members = [];
|
||||
if (group) {
|
||||
const eff = await Group.effectiveMembers(link.groupCn);
|
||||
members = eff.effective.map(dn => uidByDn.get(String(dn).toLowerCase()) || cnFromDn(dn));
|
||||
}
|
||||
|
||||
const entry = summary[link.resourceId] || (summary[link.resourceId] = { groups: [], members: [] });
|
||||
entry.groups.push({
|
||||
cn: link.groupCn,
|
||||
accessLevel: link.accessLevel,
|
||||
exists: !!group,
|
||||
memberCount: members.length,
|
||||
});
|
||||
for (const uid of members) {
|
||||
if (!entry.members.includes(uid)) entry.members.push(uid);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of Object.keys(summary)) {
|
||||
summary[id].memberCount = summary[id].members.length;
|
||||
}
|
||||
|
||||
res.json({ results: summary });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /user-access/:uid — every resource a given user can reach, and via which
|
||||
// group. This is the reverse lookup; previously an admin could only see their
|
||||
// own access, via /api/discovery/me.
|
||||
router.get('/user-access/:uid', async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.get({ uid: req.params.uid });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const dn = String(user.dn).toLowerCase();
|
||||
const groups = await Group.listDetail();
|
||||
const memberOf = groups
|
||||
.filter(g => [].concat(g.member || []).some(m => String(m).toLowerCase() === dn))
|
||||
.map(g => g.cn);
|
||||
|
||||
const [links, resources] = await Promise.all([ResourceGroup.list(), Resource.list()]);
|
||||
const byId = new Map(resources.map(r => [r.id, r]));
|
||||
|
||||
const results = [];
|
||||
for (const link of links) {
|
||||
if (!memberOf.includes(link.groupCn)) continue;
|
||||
const resource = byId.get(link.resourceId);
|
||||
if (!resource) continue;
|
||||
results.push({
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
slug: resource.slug,
|
||||
kind: resource.kind,
|
||||
groupCn: link.groupCn,
|
||||
accessLevel: link.accessLevel,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ results: { uid: user.uid, groups: memberOf, resources: results } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Tail the last `lines` lines of a log file without shelling out. Reads at most
|
||||
// the trailing MAX_TAIL_BYTES so an unrotated multi-GB log can't blow up the
|
||||
// heap. A missing/unreadable file is normal (the log only exists once slapd has
|
||||
// written to it), so it yields '' rather than an error.
|
||||
const MAX_TAIL_BYTES = 256 * 1024;
|
||||
|
||||
async function tailFile(filePath, lines = 100) {
|
||||
const fs = require('fs/promises');
|
||||
let fh;
|
||||
try {
|
||||
fh = await fs.open(filePath, 'r');
|
||||
const { size } = await fh.stat();
|
||||
const start = Math.max(0, size - MAX_TAIL_BYTES);
|
||||
const buf = Buffer.alloc(Math.min(size, MAX_TAIL_BYTES));
|
||||
await fh.read(buf, 0, buf.length, start);
|
||||
const text = buf.toString('utf8');
|
||||
// A partial first line when we started mid-file; drop it.
|
||||
const rows = (start > 0 ? text.slice(text.indexOf('\n') + 1) : text).split('\n');
|
||||
return rows.slice(-lines).join('\n');
|
||||
} catch (err) {
|
||||
return '';
|
||||
} finally {
|
||||
if (fh) await fh.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/audit-logs', async (req, res, next) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
let ldapLogs = '';
|
||||
let oauthLogs = '';
|
||||
let auditLogs = '';
|
||||
|
||||
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 } });
|
||||
const [ldap, oauth, audit] = await Promise.all([
|
||||
tailFile('/var/lib/ldap/slapd.log'),
|
||||
tailFile('/var/lib/ldap/oauth.log'),
|
||||
tailFile('/var/lib/ldap/auditlog.ldif'),
|
||||
]);
|
||||
res.json({ results: { ldap, oauth, audit } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ const router = require('express').Router();
|
||||
const permission = require('../utils/permission');
|
||||
const metrics = require('../utils/metrics');
|
||||
|
||||
// /api/metrics/executive
|
||||
router.get('/executive', async (req, res, next) => {
|
||||
// /api/metrics/overview
|
||||
router.get('/overview', async (req, res, next) => {
|
||||
try {
|
||||
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
'use strict';
|
||||
|
||||
// Plugin instances API — the loadable, configurable, multi-copy plugin system.
|
||||
//
|
||||
// Replaces the old routes/plugins.js (which only toggled cron/enabled on static
|
||||
// config via a Redis hash). Here every plugin is a PluginInstance row (see
|
||||
// models/plugin_instance.js) with its own schedule and its secrets in OpenBao
|
||||
// (utils/plugin_secrets.js), created/edited/loaded/unloaded through this API.
|
||||
//
|
||||
// Gated router-wide to the same admin groups as the directory admin API, so
|
||||
// existing directory admins keep access. Secrets are never returned in
|
||||
// cleartext — only masked (`********`) — and never persisted in the DB.
|
||||
|
||||
const router = require('express').Router();
|
||||
const permission = require('../utils/permission');
|
||||
const registry = require('../services/plugin_registry');
|
||||
const pluginSecrets = require('../utils/plugin_secrets');
|
||||
const { PluginInstance, STATUS } = require('../models/plugin_instance');
|
||||
const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../services/scheduler');
|
||||
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
// Derive a stable, unique slug from an instance name when the caller didn't
|
||||
// supply one. Lowercases, collapses non-alnum runs to a single hyphen, trims,
|
||||
// and prefixes `plugin-` if the result would otherwise start with a character
|
||||
// SLUG_RE rejects. `isTaken(slug)` is consulted for uniqueness (a DB lookup);
|
||||
// on collision we append `-2`, `-3`, … up to MAX_TRIES, then give up.
|
||||
function slugify(name) {
|
||||
let s = String(name || '').toLowerCase().trim();
|
||||
s = s.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
if (!s) s = 'plugin';
|
||||
if (!/^[a-z0-9]/.test(s)) s = 'plugin-' + s;
|
||||
return s.slice(0, 64);
|
||||
}
|
||||
|
||||
async function makeSlug(name, isTaken) {
|
||||
const base = slugify(name);
|
||||
if (!await isTaken(base)) return base;
|
||||
for (let i = 2; i <= 16; i++) {
|
||||
const cand = `${base}-${i}`.slice(0, 64);
|
||||
if (!await isTaken(cand)) return cand;
|
||||
}
|
||||
return null; // exhausted
|
||||
}
|
||||
|
||||
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
|
||||
// (app_super_admin is always allowed by permission.byGroup).
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Plain object for the wire, with masked secret values attached under
|
||||
// `secrets` and the run-state fields surfaced. The DB row never holds secrets.
|
||||
async function serialize(instance) {
|
||||
const obj = instance.toJSON ? instance.toJSON() : { ...instance };
|
||||
const secrets = await pluginSecrets.read(instance.id).catch(() => ({}));
|
||||
obj.secrets = registry.mask(instance.pluginType, secrets);
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Validate a create/update payload against a plugin type's configSchema.
|
||||
// Returns an error string or null. `flat` is the merged config + secret values
|
||||
// (the UI sends one flat object; the API splits it).
|
||||
function validateFields(type, flat) {
|
||||
const required = registry.requiredKeys(type);
|
||||
for (const key of required) {
|
||||
const v = flat && flat[key];
|
||||
if (v === undefined || v === null || v === '') {
|
||||
return `Missing required field: ${key}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Plugin types (for the create-instance picker + form) ---
|
||||
router.get('/types', (req, res) => {
|
||||
res.json({ results: registry.getTypes() });
|
||||
});
|
||||
|
||||
// --- List instances ---
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const instances = await PluginInstance.list();
|
||||
const out = [];
|
||||
for (const inst of instances) out.push(await serialize(inst));
|
||||
res.json({ results: out });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const inst = await PluginInstance.get(req.params.id);
|
||||
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ results: await serialize(inst) });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Create instance ---
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { pluginType, name, slug, cron } = req.body;
|
||||
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
|
||||
if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
|
||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||
// Slug is optional: derive it from the name when absent. When supplied,
|
||||
// validate it (admins editing via API may still pass one explicitly).
|
||||
let finalSlug = slug;
|
||||
if (finalSlug) {
|
||||
if (!SLUG_RE.test(finalSlug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
|
||||
} else {
|
||||
finalSlug = await makeSlug(name, async (s) => !!(await PluginInstance.getBySlug(s)));
|
||||
if (!finalSlug) return res.status(400).json({ error: 'Could not generate a unique slug from the name; supply one explicitly.' });
|
||||
}
|
||||
if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' });
|
||||
|
||||
// `config` from the client is a flat object of all field values (secret +
|
||||
// non-secret). Split it: non-secret -> DB, secret -> OpenBao.
|
||||
const flat = (req.body.config && typeof req.body.config === 'object') ? req.body.config : {};
|
||||
const fieldErr = validateFields(pluginType, flat);
|
||||
if (fieldErr) return res.status(400).json({ error: fieldErr });
|
||||
|
||||
const manifest = registry.getManifest(pluginType);
|
||||
const { config, secrets } = registry.splitConfig(pluginType, flat);
|
||||
const enabled = req.body.enabled !== false; // default true
|
||||
const now = Date.now();
|
||||
|
||||
const instance = await PluginInstance.create({
|
||||
pluginType,
|
||||
category: manifest.category,
|
||||
name,
|
||||
slug: finalSlug,
|
||||
enabled,
|
||||
cron: cron || '0 * * * *',
|
||||
config,
|
||||
created_by: req.user.uid,
|
||||
created_on: now,
|
||||
updated_by: req.user.uid,
|
||||
updated_on: now
|
||||
});
|
||||
|
||||
try {
|
||||
await pluginSecrets.write(instance.id, secrets);
|
||||
} catch (err) {
|
||||
// Most likely the sso-broker policy lacks secret/plugins/* — the
|
||||
// operator needs theta-suite >= v1.30.1. Delete the row so a failed
|
||||
// secret write doesn't strand a half-created instance.
|
||||
await instance.delete().catch(() => {});
|
||||
return res.status(400).json({ error: `Failed to store plugin secrets in OpenBao: ${err.message}. Re-run ./setup.sh with theta-suite >= v1.30.1.` });
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
await scheduleInstance(instance);
|
||||
await runInstanceNow(instance.id);
|
||||
}
|
||||
res.json({ results: await serialize(instance) });
|
||||
} catch (err) {
|
||||
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||
return res.status(400).json({ error: 'A plugin instance with this slug already exists.' });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Update instance (name/cron/enabled/non-secret config) ---
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const inst = await PluginInstance.get(req.params.id);
|
||||
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||
if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` });
|
||||
|
||||
const updates = {};
|
||||
if (req.body.name !== undefined) updates.name = req.body.name;
|
||||
if (req.body.cron !== undefined) {
|
||||
if (typeof req.body.cron !== 'string' || !req.body.cron.trim()) return res.status(400).json({ error: 'cron must be a non-empty string' });
|
||||
updates.cron = req.body.cron;
|
||||
}
|
||||
if (req.body.enabled !== undefined) updates.enabled = !!req.body.enabled;
|
||||
|
||||
// Non-secret config: split the client's flat config so secret fields are
|
||||
// never written to the DB. Secrets are changed via PUT /:id/secrets.
|
||||
if (req.body.config !== undefined && typeof req.body.config === 'object') {
|
||||
const { config } = registry.splitConfig(inst.pluginType, req.body.config);
|
||||
updates.config = config;
|
||||
}
|
||||
|
||||
updates.updated_by = req.user.uid;
|
||||
updates.updated_on = Date.now();
|
||||
|
||||
const updated = await inst.update(updates);
|
||||
|
||||
// Re-schedule if the schedule-relevant fields moved.
|
||||
if (updates.cron !== undefined || updates.enabled !== undefined) {
|
||||
await scheduleInstance(updated);
|
||||
}
|
||||
res.json({ results: await serialize(updated) });
|
||||
} catch (err) {
|
||||
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||
return res.status(400).json({ error: 'A plugin instance with this slug already exists.' });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Update secrets only ---
|
||||
router.put('/:id/secrets', async (req, res, next) => {
|
||||
try {
|
||||
const inst = await PluginInstance.get(req.params.id);
|
||||
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||
if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` });
|
||||
|
||||
// Keep only declared secret fields; pluginSecrets.write drops blank/MASK
|
||||
// values so an unchanged masked field is a no-op.
|
||||
const { secrets } = registry.splitConfig(inst.pluginType, req.body || {});
|
||||
await pluginSecrets.write(inst.id, secrets);
|
||||
await inst.update({ updated_by: req.user.uid, updated_on: Date.now() });
|
||||
res.json({ results: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Test (validate) ---
|
||||
router.post('/:id/test', async (req, res, next) => {
|
||||
try {
|
||||
const inst = await PluginInstance.get(req.params.id);
|
||||
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||
const mod = registry.getModule(inst.pluginType);
|
||||
if (typeof mod.validate !== 'function') return res.json({ ok: true, note: 'no validate defined' });
|
||||
const cfg = await pluginSecrets.mergeForRun(inst);
|
||||
const result = await mod.validate(cfg);
|
||||
if (result && result.ok) return res.json(result);
|
||||
return res.status(400).json(result || { ok: false, error: 'validation failed' });
|
||||
} catch (err) {
|
||||
return res.status(400).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Load (enable + schedule + run now) ---
|
||||
router.post('/:id/load', async (req, res, next) => {
|
||||
try {
|
||||
const inst = await PluginInstance.get(req.params.id);
|
||||
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||
const updated = await inst.update({ enabled: true, updated_by: req.user.uid, updated_on: Date.now() });
|
||||
await scheduleInstance(updated);
|
||||
await runInstanceNow(updated.id);
|
||||
res.json({ results: await serialize(updated) });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Unload (unschedule + disable) ---
|
||||
router.post('/:id/unload', async (req, res, next) => {
|
||||
try {
|
||||
const inst = await PluginInstance.get(req.params.id);
|
||||
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||
await unscheduleInstance(inst.id);
|
||||
const updated = await inst.update({ enabled: false, updated_by: req.user.uid, updated_on: Date.now() });
|
||||
res.json({ results: await serialize(updated) });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Run now (regardless of enabled) ---
|
||||
router.post('/:id/run', async (req, res, next) => {
|
||||
try {
|
||||
const inst = await PluginInstance.get(req.params.id);
|
||||
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||
await runInstanceNow(inst.id);
|
||||
res.json({ results: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Last-run status ---
|
||||
router.get('/:id/runs', async (req, res, next) => {
|
||||
try {
|
||||
const inst = await PluginInstance.get(req.params.id);
|
||||
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Delete (unschedule + remove secrets + delete row) ---
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const inst = await PluginInstance.get(req.params.id);
|
||||
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||
await unscheduleInstance(inst.id);
|
||||
await pluginSecrets.remove(inst.id); // best-effort
|
||||
await inst.delete();
|
||||
res.json({ results: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,20 @@
|
||||
const router = require('express').Router();
|
||||
const permission = require('../utils/permission');
|
||||
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||
next();
|
||||
} catch(err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.render('conf', {
|
||||
title: 'Configuration',
|
||||
user: req.user
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+91
-12
@@ -10,9 +10,14 @@
|
||||
// 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,
|
||||
@@ -20,20 +25,29 @@ const {
|
||||
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: isDirectoryAdmin(req.user) })));
|
||||
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: isDirectoryAdmin(req.user) });
|
||||
const projected = projectResource(resource, { fullMetadata });
|
||||
projected.parents = resource.parents;
|
||||
projected.children = resource.children;
|
||||
res.json(envelope(projected));
|
||||
@@ -43,9 +57,10 @@ router.get('/resources/:slug', async (req, res, next) => {
|
||||
// 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: isDirectoryAdmin(req.user) }),
|
||||
resources: projectResources(graph.resources, { fullMetadata }),
|
||||
edges: graph.edges,
|
||||
}));
|
||||
} catch (err) { next(err); }
|
||||
@@ -54,26 +69,90 @@ router.get('/graph', async (req, res, next) => {
|
||||
// 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. Uses req.user.groups (populated by the auth
|
||||
// middleware for session/PAT callers) rather than re-querying LDAP by DN, so it
|
||||
// works for every auth transport without assuming a .dn is present.
|
||||
// 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 userGroups = (req.user && req.user.groups) || [];
|
||||
const ids = new Set();
|
||||
if (userGroups.length) {
|
||||
const rgs = await ResourceGroup.list({ where: { groupCn: { in: userGroups } } });
|
||||
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 => ids.has(r.id) || (r.metadata && r.metadata.isPublic));
|
||||
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);
|
||||
});
|
||||
}
|
||||
res.json(envelope(projectResources(accessible, { fullMetadata: isDirectoryAdmin(req.user) })));
|
||||
// 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); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
// 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,9 @@ const DOCS = {
|
||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
||||
|
||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||
|
||||
+99
-2
@@ -43,6 +43,87 @@ router.get('/:name', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
// ── Nested groups ───────────────────────────────────────────────────────────
|
||||
// A groupOfNames `member` may be any DN, including another group's, which is
|
||||
// how nesting is stored. These routes are mounted before /:group/:uid so the
|
||||
// literal "nested"/"effective" path segments are not swallowed by that
|
||||
// wildcard, which would otherwise try to resolve them as a uid.
|
||||
|
||||
// GET /api/group/:group/effective — who this group actually grants, split into
|
||||
// directly-listed users, the groups nested into it, and the full transitive set
|
||||
// of users. The UI shows "3 direct, 12 effective"; a plain member read cannot
|
||||
// answer that, and on a server with nestgroup it silently returns the expanded
|
||||
// list with no indication which entries are direct.
|
||||
router.get('/:group/effective', async function(req, res, next){
|
||||
try{
|
||||
return res.json({ results: await Group.effectiveMembers(req.params.group) });
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/group/:group/nested/:child — nest :child inside :group.
|
||||
router.put('/:group/nested/:child', async function(req, res, next){
|
||||
try{
|
||||
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
|
||||
|
||||
const parent = await Group.get(req.params.group);
|
||||
const child = await Group.get(req.params.child);
|
||||
|
||||
if(parent.dn === child.dn){
|
||||
return res.status(400).json({message: 'A group cannot contain itself.'});
|
||||
}
|
||||
// Refuse rather than rely on the resolver's depth cap: a cycle makes
|
||||
// "who is in this group" unanswerable, and the cap would quietly return
|
||||
// a truncated answer instead of an error anyone would notice.
|
||||
if(await Group.wouldCycle(req.params.group, child.dn)){
|
||||
return res.status(409).json({
|
||||
message: `"${req.params.child}" already contains "${req.params.group}" — nesting them would create a loop.`
|
||||
});
|
||||
}
|
||||
|
||||
const results = await parent.addMember({dn: child.dn});
|
||||
User.clearCache();
|
||||
return res.json({
|
||||
results,
|
||||
message: `Nested ${req.params.child} inside ${req.params.group}.`
|
||||
});
|
||||
}catch(error){
|
||||
if(error.name === 'TypeOrValueExistsError' || error.code === 20){
|
||||
return res.status(409).json({message: `"${req.params.child}" is already nested in "${req.params.group}".`});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/group/:group/nested/:child — un-nest.
|
||||
router.delete('/:group/nested/:child', async function(req, res, next){
|
||||
try{
|
||||
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
|
||||
|
||||
const parent = await Group.get(req.params.group);
|
||||
const child = await Group.get(req.params.child);
|
||||
const results = await parent.removeMember({dn: child.dn});
|
||||
User.clearCache();
|
||||
return res.json({
|
||||
results,
|
||||
message: `Removed ${req.params.child} from ${req.params.group}.`
|
||||
});
|
||||
}catch(error){
|
||||
// groupOfNames requires at least one member, so emptying a group is a
|
||||
// schema violation rather than a permission problem. Surfacing the raw
|
||||
// error as a 500 makes it look like a bug in the server; it is really a
|
||||
// "you cannot do that, and here is why" -- the same reason the last user
|
||||
// cannot be removed from a group either.
|
||||
if(error.name === 'ObjectClassViolationError' || error.code === 65){
|
||||
return res.status(409).json({
|
||||
message: `"${req.params.child}" is the only member of "${req.params.group}". A group must keep at least one member — add another first.`
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/owner/:group/:uid', async function(req, res, next){
|
||||
try{
|
||||
|
||||
@@ -82,11 +163,25 @@ router.put('/:group/:uid', async function(req, res, next){
|
||||
|
||||
var group = await Group.get(req.params.group);
|
||||
var user = await User.get(req.params.uid);
|
||||
const results = await group.addMember(user);
|
||||
// Group membership feeds directly into cached-User-derived state
|
||||
// (isServiceAccount, isAdmin, group-gated nav/UI) -- without this,
|
||||
// a membership change here is invisible for up to the cache's TTL.
|
||||
User.clearCache();
|
||||
return res.json({
|
||||
results: await group.addMember(user),
|
||||
results,
|
||||
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
||||
});
|
||||
}catch(error){
|
||||
// Already a member -- surfaced as a plain 500 before, which read as a
|
||||
// server fault for what is really a no-op. Common in practice because
|
||||
// groupOfNames needs at least one member, so whoever creates a group is
|
||||
// seeded into it and is then "added" again by the obvious next click.
|
||||
if(error.name === 'TypeOrValueExistsError' || error.code === 20){
|
||||
return res.status(409).json({
|
||||
message: `"${req.params.uid}" is already a member of "${req.params.group}".`
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -98,8 +193,10 @@ router.delete('/:group/:uid', async function(req, res, next){
|
||||
|
||||
var group = await Group.get(req.params.group);
|
||||
var user = await User.get(req.params.uid);
|
||||
const results = await group.removeMember(user);
|
||||
User.clearCache();
|
||||
return res.json({
|
||||
results: await group.removeMember(user),
|
||||
results,
|
||||
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
|
||||
});
|
||||
}catch(error){
|
||||
|
||||
+63
-6
@@ -17,6 +17,13 @@ const values ={
|
||||
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
||||
name: conf.name,
|
||||
logo: conf.logo,
|
||||
// Connection conventions the catalog needs to render "how to reach this"
|
||||
// (conf/base.js `directory`). Safe to expose: a jump-host name and a default
|
||||
// port are public connection info, not credentials.
|
||||
directoryConf: {
|
||||
jumpHost: (conf.directory && conf.directory.jumpHost) || '',
|
||||
defaultSshPort: (conf.directory && conf.directory.defaultSshPort) || 22,
|
||||
},
|
||||
...buildInfo,
|
||||
}
|
||||
|
||||
@@ -28,7 +35,7 @@ const values ={
|
||||
// every deploy and isn't cache-busted/fingerprinted.
|
||||
mountStaticModules(router, {
|
||||
root: path.join(__dirname, '..'),
|
||||
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat'],
|
||||
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
|
||||
});
|
||||
|
||||
// Public health endpoint for container/orchestration healthchecks.
|
||||
@@ -48,18 +55,68 @@ router.get('/tos', async function(req, res, next) {
|
||||
|
||||
// Admin dashboard (stats + recent/inactive users) and Notifications
|
||||
// (broadcast + history) merged into one page.
|
||||
router.get('/executive', function(req, res) {
|
||||
res.render('executive', {...values});
|
||||
router.get('/overview', function(req, res) {
|
||||
res.render('overview', {...values});
|
||||
});
|
||||
|
||||
router.get('/admin', (req, res) => res.redirect(301, '/executive'));
|
||||
router.get('/notifications', (req, res) => res.redirect(301, '/executive'));
|
||||
router.get('/dashboard', (req, res) => res.redirect(301, '/executive'));
|
||||
router.get('/admin', (req, res) => res.redirect(301, '/overview'));
|
||||
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
|
||||
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
||||
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
||||
|
||||
router.get('/conf', function(req, res) {
|
||||
// Admin-only Configuration page. The view renders the shell for anyone
|
||||
// (like /users, /directory, etc.); the client gates access with
|
||||
// app.auth.forceLogin(['admin','app_sso_admin']) and the /api/conf endpoint
|
||||
// enforces app_sso_admin server-side. The previous server-side
|
||||
// permission.byGroup(req.user,…) 401'd on a browser navigation because this
|
||||
// app's auth-token is a header set by client JS (localStorage), not a
|
||||
// cookie — so req.user is undefined on a plain page load.
|
||||
res.render('conf', {...values});
|
||||
});
|
||||
|
||||
router.get('/directory', function(req, res) {
|
||||
res.render('directory', {...values});
|
||||
});
|
||||
|
||||
router.get('/discovery', function(req, res, next) {
|
||||
res.redirect('/directory');
|
||||
});
|
||||
|
||||
router.get('/plugins', function(req, res, next) {
|
||||
// Plugin instances page — loadable/unloadable, configurable plugin copies
|
||||
// with per-instance secrets in OpenBao. Renders the shell for anyone; the
|
||||
// client gates with app.auth.forceLogin(['app_sso_admin',
|
||||
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
|
||||
// the same server-side. Same header-vs-navigation auth model as /conf and
|
||||
// /vault (auth-token is a client-set header, not a cookie).
|
||||
res.render('plugins', {...values});
|
||||
});
|
||||
|
||||
router.get('/vault', function(req, res) {
|
||||
// Personal per-user secrets (secret/users/<uid>/*) for everyone; admins get
|
||||
// free-form access across all of secret/ plus an Apps tab to mint scoped
|
||||
// tokens for external apps. The view renders the shell for any logged-in
|
||||
// user; the client gates login via app.auth.forceLogin() and derives the
|
||||
// admin/namespace scope from /api/user/me. The /api/vault proxy enforces the
|
||||
// same scoping server-side (scopeGuard + the token's own OpenBao policy), so
|
||||
// the client-derived scope is only cosmetic. vaultAddr is the only
|
||||
// server-rendered value (it's a non-user-specific env var); uid + isAdmin
|
||||
// are resolved client-side to avoid the header-vs-navigation auth mismatch.
|
||||
res.render('vault', {
|
||||
...values,
|
||||
vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200',
|
||||
});
|
||||
});
|
||||
|
||||
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
||||
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
|
||||
// use of :slug at all -- the client reads location.pathname itself and opens
|
||||
// the matching resource's modal once the page's own data has loaded.
|
||||
router.get('/directory/:slug', function(req, res) {
|
||||
res.render('directory', {...values});
|
||||
});
|
||||
|
||||
// Route removed since it's now in directory
|
||||
|
||||
router.get('/onboarding', async function(req, res, next) {
|
||||
|
||||
@@ -97,7 +97,7 @@ router.delete('/:client_id', async function(req, res, next) {
|
||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||
|
||||
const client = await OAuthClient.get(req.params.client_id);
|
||||
await client.remove();
|
||||
await client.delete();
|
||||
|
||||
return res.json({
|
||||
client_id: req.params.client_id,
|
||||
|
||||
+15
-7
@@ -4,6 +4,7 @@ const router = require('express').Router();
|
||||
const {User} = require('../models/user');
|
||||
const {Group} = require('../models/group_ldap');
|
||||
const permission = require('../utils/permission');
|
||||
const {groupCns} = require('../utils/user_groups');
|
||||
const {UserVerification} = require('../models/verification');
|
||||
const {InviteToken} = require('../models/token');
|
||||
|
||||
@@ -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){
|
||||
next(error);
|
||||
}
|
||||
@@ -78,11 +79,18 @@ router.get('/me', async function(req, res, next){
|
||||
|
||||
// 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; group-level gating still reads memberOf.
|
||||
const groups = (user.memberOf || []).map(function(dn){
|
||||
return String(dn).split(',')[0].replace(/^cn=/i, '');
|
||||
});
|
||||
user.isAdmin = groups.includes('app_sso_admin');
|
||||
// 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){
|
||||
@@ -107,7 +115,7 @@ router.put('/password', async function(req, res, next){
|
||||
const verif = await UserVerification.getOrCreate(req.user.uid);
|
||||
await verif.update({ password_must_change: false });
|
||||
User.clearCache();
|
||||
return res.json({results: result});
|
||||
return res.json({results: result, message: 'Password changed.'});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -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,149 @@
|
||||
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 (case-insensitive)
|
||||
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
|
||||
const macs = res.metadata.interfaces.map(i => i.mac ? i.mac.toLowerCase() : null).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 => i.mac && macs.includes(i.mac.toLowerCase()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 && ei.mac.toLowerCase() === ni.mac.toLowerCase()) ||
|
||||
(ni.ip && ei.ip && ei.ip === ni.ip)
|
||||
);
|
||||
if (idx >= 0) existingIntfs[idx] = { ...existingIntfs[idx], ...ni };
|
||||
else existingIntfs.push(ni);
|
||||
}
|
||||
mergedMeta.interfaces = existingIntfs;
|
||||
}
|
||||
|
||||
// Add discovery source
|
||||
const sources = new Set(mergedMeta.discovery_sources || []);
|
||||
sources.add(sourceName);
|
||||
mergedMeta.discovery_sources = [...sources];
|
||||
|
||||
mergedMeta.last_seen = Date.now();
|
||||
|
||||
const isIp = (str) => /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(str || '');
|
||||
let bestName = existing.name;
|
||||
if (res.name && (!bestName || isIp(bestName) || res.name.length > bestName.length && !isIp(res.name))) {
|
||||
bestName = res.name;
|
||||
}
|
||||
|
||||
await existing.update({
|
||||
name: bestName,
|
||||
description: res.description || existing.description,
|
||||
metadata: mergedMeta,
|
||||
updated_on: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
} 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,172 @@
|
||||
'use strict';
|
||||
|
||||
// Plugin type registry.
|
||||
//
|
||||
// A **plugin type** is a module under nodejs/plugins/<category>/<type>.js
|
||||
// exporting a manifest:
|
||||
//
|
||||
// { type, category, name, description, configSchema[], validate(), run() }
|
||||
//
|
||||
// `configSchema` is an array of field descriptors that drive the admin UI form
|
||||
// and API validation. Fields with `secret: true` are stored in OpenBao
|
||||
// (secret/plugins/<instance-id>/conf via utils/plugin_secrets.js); all other
|
||||
// field values live in the PluginInstance DB row's `config` JSON column.
|
||||
//
|
||||
// `run(cfg)` does the work; the discovery plugins keep their historical
|
||||
// `discover(cfg)` name and add `run` as an alias (the loader uses `run`).
|
||||
//
|
||||
// A **plugin instance** (models/plugin_instance.js) is a configured, loadable
|
||||
// copy of a type — you can have several of the same type. This registry only
|
||||
// knows about *types*; instances live in the DB.
|
||||
//
|
||||
// The scan happens once at require time (the set of installed .js files does
|
||||
// not change without a redeploy). Runtime load/unload is per-instance, not
|
||||
// per-type — adding a new plugin type still needs a restart.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const pluginsRoot = path.join(__dirname, '../plugins');
|
||||
const MASK = '********';
|
||||
|
||||
// type -> module. Built once.
|
||||
const _modules = new Map();
|
||||
// type -> manifest summary (a safe, serializable subset for the UI/API).
|
||||
const _summaries = [];
|
||||
|
||||
function loadAll() {
|
||||
_modules.clear();
|
||||
_summaries.length = 0;
|
||||
if (!fs.existsSync(pluginsRoot)) return;
|
||||
for (const category of fs.readdirSync(pluginsRoot)) {
|
||||
const catDir = path.join(pluginsRoot, category);
|
||||
const stat = fs.statSync(catDir);
|
||||
if (!stat.isDirectory()) continue;
|
||||
for (const file of fs.readdirSync(catDir)) {
|
||||
if (!file.endsWith('.js')) continue;
|
||||
const type = path.basename(file, '.js');
|
||||
// require fresh-ish: a plugin file should be idempotent to load. Clear
|
||||
// from the cache so a future re-scan (e.g. in tests) picks up edits.
|
||||
const full = path.join(catDir, file);
|
||||
delete require.cache[require.resolve(full)];
|
||||
const mod = require(full);
|
||||
// Backfill manifest defaults so older plugins (only exporting discover)
|
||||
// still register with a usable summary.
|
||||
const manifest = {
|
||||
type: mod.type || type,
|
||||
category: mod.category || category,
|
||||
name: mod.name || type,
|
||||
description: mod.description || '',
|
||||
configSchema: Array.isArray(mod.configSchema) ? mod.configSchema : [],
|
||||
validate: typeof mod.validate === 'function' ? mod.validate : null,
|
||||
run: typeof mod.run === 'function' ? mod.run
|
||||
: typeof mod.discover === 'function' ? mod.discover : null
|
||||
};
|
||||
_modules.set(manifest.type, { mod, manifest });
|
||||
_summaries.push({
|
||||
type: manifest.type,
|
||||
category: manifest.category,
|
||||
name: manifest.name,
|
||||
description: manifest.description,
|
||||
configSchema: manifest.configSchema
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadAll();
|
||||
|
||||
// All registered plugin types, as serializable summaries (no functions).
|
||||
// Used by GET /api/plugins/types to build the "New Plugin" picker + form.
|
||||
function getTypes() {
|
||||
return _summaries.map(s => ({ ...s }));
|
||||
}
|
||||
|
||||
// The raw module for a type (has run/validate/discover). Throws if unknown.
|
||||
function getModule(type) {
|
||||
const entry = _modules.get(type);
|
||||
if (!entry) {
|
||||
const err = new Error(`Unknown plugin type: ${type}`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
return entry.mod;
|
||||
}
|
||||
|
||||
// The manifest summary for a type. Returns null if unknown (callers gate on
|
||||
// this to validate a pluginType before creating an instance).
|
||||
function getManifest(type) {
|
||||
const entry = _modules.get(type);
|
||||
return entry ? entry.manifest : null;
|
||||
}
|
||||
|
||||
// Keys of the secret fields in a type's configSchema.
|
||||
function secretKeys(type) {
|
||||
const m = getManifest(type);
|
||||
if (!m) return [];
|
||||
return m.configSchema.filter(f => f.secret).map(f => f.key);
|
||||
}
|
||||
|
||||
// Non-secret field keys in a type's configSchema.
|
||||
function publicKeys(type) {
|
||||
const m = getManifest(type);
|
||||
if (!m) return [];
|
||||
return m.configSchema.filter(f => !f.secret).map(f => f.key);
|
||||
}
|
||||
|
||||
// All declared field keys (secret + non-secret) — for required-field validation.
|
||||
function fieldKeys(type) {
|
||||
const m = getManifest(type);
|
||||
if (!m) return [];
|
||||
return m.configSchema.map(f => f.key);
|
||||
}
|
||||
|
||||
// Required field keys.
|
||||
function requiredKeys(type) {
|
||||
const m = getManifest(type);
|
||||
if (!m) return [];
|
||||
return m.configSchema.filter(f => f.required).map(f => f.key);
|
||||
}
|
||||
|
||||
// Replace each present secret value with MASK, keeping the keys so the UI can
|
||||
// render a prefilled (masked) password field. Non-secret values are passed
|
||||
// through unchanged. `values` is a plain object of field->value.
|
||||
function mask(type, values) {
|
||||
if (!values || typeof values !== 'object') return values;
|
||||
const sk = new Set(secretKeys(type));
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
out[k] = sk.has(k) && v ? MASK : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Split a flat {field: value} object (as the UI/API sends it) into non-secret
|
||||
// config (for the DB row) and secret values (for OpenBao). Unknown keys are
|
||||
// dropped — only declared configSchema fields are kept.
|
||||
function splitConfig(type, flat) {
|
||||
const manifest = getManifest(type);
|
||||
const config = {};
|
||||
const secrets = {};
|
||||
if (!manifest || !flat) return { config, secrets };
|
||||
for (const f of manifest.configSchema) {
|
||||
if (!(f.key in flat)) continue;
|
||||
if (f.secret) secrets[f.key] = flat[f.key];
|
||||
else config[f.key] = flat[f.key];
|
||||
}
|
||||
return { config, secrets };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTypes,
|
||||
getModule,
|
||||
getManifest,
|
||||
secretKeys,
|
||||
publicKeys,
|
||||
fieldKeys,
|
||||
requiredKeys,
|
||||
mask,
|
||||
splitConfig,
|
||||
// for tests
|
||||
_reload: loadAll
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
'use strict';
|
||||
|
||||
// Discovery / plugin scheduler.
|
||||
//
|
||||
// Generalized from the one-shot discovery-plugin loader: plugin *types* live
|
||||
// under nodejs/plugins/<category>/<type>.js (see services/plugin_registry.js),
|
||||
// and configured, loadable/unloadable *instances* live in the PluginInstance
|
||||
// table (models/plugin_instance.js). This module schedules enabled instances
|
||||
// on cron via BullMQ JobSchedulers and runs them in a Worker.
|
||||
//
|
||||
// Each instance owns a stable JobScheduler id (`plugin:<instanceId>`) so load/
|
||||
// unload can add/remove a single schedule without disturbing the others —
|
||||
// `upsertJobScheduler`/`removeJobScheduler` (BullMQ v6) take that id directly.
|
||||
//
|
||||
// Per-instance secrets are merged in from OpenBao (utils/plugin_secrets.js) at
|
||||
// run time; the plugin's run()/discover() receives the combined non-secret
|
||||
// config + secret values as a single `config` object, exactly as the legacy
|
||||
// static-config path did.
|
||||
|
||||
const { Queue, Worker } = require('bullmq');
|
||||
const { DiscoveryReconciler } = require('./discovery_reconciler');
|
||||
const pluginRegistry = require('./plugin_registry');
|
||||
const pluginSecrets = require('../utils/plugin_secrets');
|
||||
const { PluginInstance, STATUS } = require('../models/plugin_instance');
|
||||
const Redis = require('ioredis');
|
||||
|
||||
// Ensure Redis connection works for BullMQ
|
||||
const redisOpts = { maxRetriesPerRequest: null };
|
||||
const connection = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', redisOpts);
|
||||
|
||||
const discoveryQueue = new Queue('discovery', { connection });
|
||||
|
||||
const RUN = 'run_plugin';
|
||||
const GC = 'garbage_collect';
|
||||
function pluginSchedulerId(id) { return `plugin:${id}`; }
|
||||
|
||||
const worker = new Worker('discovery', async job => {
|
||||
if (job.name === RUN) {
|
||||
await runPluginJob(job.data && job.data.instanceId);
|
||||
} else if (job.name === GC) {
|
||||
console.log('[Scheduler] Running garbage collection');
|
||||
await DiscoveryReconciler.garbageCollect();
|
||||
}
|
||||
}, { connection });
|
||||
|
||||
// Run one plugin instance. Loads the row (skip silently if it was deleted or
|
||||
// disabled after the job was enqueued), merges its OpenBao secrets into its
|
||||
// config, calls the plugin's run()/discover(), and — for discovery plugins —
|
||||
// reconciles the result into the resource graph under the instance's slug.
|
||||
// Bookkeeping (lastRunAt/lastStatus/lastError) is stamped on the row so the UI
|
||||
// can show run state without querying BullMQ.
|
||||
async function runPluginJob(instanceId) {
|
||||
if (!instanceId) { console.warn('[Scheduler] run_plugin job with no instanceId'); return; }
|
||||
const instance = await PluginInstance.get(instanceId);
|
||||
if (!instance) { console.warn(`[Scheduler] instance ${instanceId} gone — skipping`); return; }
|
||||
if (!instance.enabled) { console.warn(`[Scheduler] instance ${instance.slug} (${instanceId}) disabled — skipping`); return; }
|
||||
|
||||
let mod;
|
||||
try { mod = pluginRegistry.getModule(instance.pluginType); }
|
||||
catch (err) {
|
||||
console.error(`[Scheduler] instance ${instance.slug}: type ${instance.pluginType} unavailable:`, err.message);
|
||||
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.ERROR, lastError: `plugin type unavailable: ${instance.pluginType}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const runFn = mod.run || mod.discover;
|
||||
if (typeof runFn !== 'function') {
|
||||
console.error(`[Scheduler] instance ${instance.slug}: type ${instance.pluginType} has no run()/discover()`);
|
||||
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.ERROR, lastError: 'plugin type has no run()/discover()' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Running plugin: ${instance.slug} (${instance.pluginType})`);
|
||||
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null });
|
||||
try {
|
||||
const cfg = await pluginSecrets.mergeForRun(instance);
|
||||
const payload = await runFn(cfg);
|
||||
if (instance.category === 'discovery') {
|
||||
await DiscoveryReconciler.reconcile(instance.slug, payload);
|
||||
}
|
||||
await instance.update({ lastStatus: STATUS.OK, lastError: null });
|
||||
} catch (err) {
|
||||
console.error(`[Scheduler] Plugin ${instance.slug} failed:`, err.message);
|
||||
await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule one instance: upsert a repeatable JobScheduler keyed by its id. Does
|
||||
// NOT trigger an immediate run — call runInstanceNow(id) separately for that
|
||||
// (used on boot and on "load"). Safe to call repeatedly (upsert is idempotent
|
||||
// and will update the cron if it changed).
|
||||
async function scheduleInstance(instance) {
|
||||
if (!instance || !instance.id) return;
|
||||
if (!instance.enabled) { await unscheduleInstance(instance.id); return; }
|
||||
const cron = instance.cron || '0 * * * *';
|
||||
await discoveryQueue.upsertJobScheduler(pluginSchedulerId(instance.id), { pattern: cron }, {
|
||||
name: RUN,
|
||||
data: { instanceId: instance.id }
|
||||
});
|
||||
console.log(`[Scheduler] Scheduled instance ${instance.slug} with cron ${cron}`);
|
||||
}
|
||||
|
||||
// Remove an instance's repeatable schedule. No-op if it had none.
|
||||
async function unscheduleInstance(id) {
|
||||
if (!id) return;
|
||||
try { await discoveryQueue.removeJobScheduler(pluginSchedulerId(id)); }
|
||||
catch (err) { /* missing scheduler is fine */ }
|
||||
}
|
||||
|
||||
// Enqueue a single immediate run for an instance (the "Run now" button / boot
|
||||
// kick). Runs once regardless of enabled, on top of any schedule.
|
||||
async function runInstanceNow(id) {
|
||||
if (!id) return;
|
||||
await discoveryQueue.add(RUN, { instanceId: id });
|
||||
}
|
||||
|
||||
// One-time legacy migration: if the PluginInstance table is empty AND
|
||||
// conf.discovery.plugins has entries (the old static-config shape), seed one
|
||||
// instance per configured type and copy its secret fields into OpenBao. After
|
||||
// the first boot, the table is non-empty and the static config is ignored.
|
||||
// Idempotent (guarded by the empty-table check).
|
||||
async function migrateLegacyPlugins(discoveryConfig) {
|
||||
const existing = await PluginInstance.list();
|
||||
if (existing && existing.length) return;
|
||||
|
||||
const legacy = discoveryConfig && discoveryConfig.plugins;
|
||||
if (!legacy || typeof legacy !== 'object') return;
|
||||
const names = Object.keys(legacy);
|
||||
if (!names.length) return;
|
||||
|
||||
console.log(`[Scheduler] Migrating ${names.length} legacy discovery plugin(s) to instances…`);
|
||||
for (const name of names) {
|
||||
const entry = legacy[name] || {};
|
||||
const manifest = pluginRegistry.getManifest(name);
|
||||
if (!manifest) {
|
||||
console.warn(`[Scheduler] legacy plugin '${name}' has no registered type — skipping`);
|
||||
continue;
|
||||
}
|
||||
// splitConfig keeps only declared configSchema fields and separates secret
|
||||
// from non-secret. Legacy `enabled`/`cron` are not in configSchema, so they
|
||||
// are dropped here and read from the entry directly below.
|
||||
const { config, secrets } = pluginRegistry.splitConfig(name, entry);
|
||||
const instance = await PluginInstance.create({
|
||||
pluginType: name,
|
||||
category: manifest.category,
|
||||
name: manifest.name,
|
||||
slug: name,
|
||||
enabled: entry.enabled !== false,
|
||||
cron: entry.cron || '0 * * * *',
|
||||
config,
|
||||
created_by: 'legacy-migration'
|
||||
});
|
||||
try {
|
||||
await pluginSecrets.write(instance.id, secrets);
|
||||
console.log(`[Scheduler] migrated '${name}' -> instance ${instance.id} (slug ${instance.slug})`);
|
||||
} catch (err) {
|
||||
// The instance row exists; if we can't write secrets (e.g. the sso-broker
|
||||
// policy predates theta-suite v1.30.1) the operator gets a clear error
|
||||
// from the API on edit, and the instance still runs with its non-secret
|
||||
// config. Don't delete the row — the operator just needs to re-run
|
||||
// setup.sh and edit/save the secrets.
|
||||
console.error(`[Scheduler] migrated '${name}' row but FAILED to write secrets:`, err.message);
|
||||
await instance.update({ lastStatus: STATUS.ERROR, lastError: `secret migration failed: ${err.message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Boot-time initialization: clear stale schedulers, schedule garbage collection,
|
||||
// migrate any legacy static-config plugins, then schedule every enabled
|
||||
// instance and kick one immediate run for each.
|
||||
async function initScheduler(discoveryConfig) {
|
||||
// Clear stale plugin/gc schedulers from a previous boot. Other-named
|
||||
// schedulers (none in this app) are left alone.
|
||||
try {
|
||||
const schedulers = await discoveryQueue.getJobSchedulers();
|
||||
for (const s of schedulers) {
|
||||
if (s.name === RUN || s.name === GC) {
|
||||
await discoveryQueue.removeJobScheduler(s.key || s.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[Scheduler] Could not clear old job schedulers:', e.message);
|
||||
}
|
||||
|
||||
// Daily garbage collection of stale discovery resources.
|
||||
await discoveryQueue.upsertJobScheduler(GC, { pattern: '0 0 * * *' }, { name: GC, data: {} });
|
||||
|
||||
try {
|
||||
await migrateLegacyPlugins(discoveryConfig);
|
||||
} catch (err) {
|
||||
console.error('[Scheduler] legacy migration failed:', err.message);
|
||||
}
|
||||
|
||||
const enabled = await PluginInstance.listEnabled();
|
||||
for (const instance of enabled) {
|
||||
await scheduleInstance(instance);
|
||||
await runInstanceNow(instance.id); // boot kick
|
||||
}
|
||||
console.log(`[Scheduler] initialized — ${enabled.length} instance(s) scheduled`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initScheduler,
|
||||
scheduleInstance,
|
||||
unscheduleInstance,
|
||||
runInstanceNow,
|
||||
discoveryQueue,
|
||||
connection
|
||||
};
|
||||
@@ -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,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,69 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
// Mock dependencies before requiring the route
|
||||
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
}));
|
||||
jest.mock('../utils/permission', () => ({
|
||||
byGroup: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
jest.mock('@simpleworkjs/conf', () => ({}));
|
||||
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
const apiConf = require('../routes/api_conf');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// Add a mock user for the permission check
|
||||
app.use((req, res, next) => {
|
||||
req.user = { uid: 'testadmin' };
|
||||
next();
|
||||
});
|
||||
app.use('/api/conf', apiConf);
|
||||
|
||||
describe('Proxy Conf API (Vault Integration)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('GET /api/conf/proxy returns proxy conf with masked secrets', async () => {
|
||||
baoConf.get.mockResolvedValueOnce({
|
||||
oidc: { issuer: 'https://test', clientId: 'cid', clientSecret: 'real_secret' },
|
||||
ldap: { bindPassword: 'real_ldap_password' }
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/conf/proxy');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.oidc.issuer).toBe('https://test');
|
||||
expect(res.body.oidc.clientSecret).toBe('********'); // MASKED
|
||||
expect(res.body.ldap.bindPassword).toBe('********'); // MASKED
|
||||
expect(baoConf.get).toHaveBeenCalledWith('proxy/conf');
|
||||
});
|
||||
|
||||
it('POST /api/conf/proxy merges configuration securely to OpenBao', async () => {
|
||||
baoConf.get.mockResolvedValueOnce({
|
||||
oidc: { clientSecret: 'old_secret' },
|
||||
ldap: { bindPassword: 'old_ldap' }
|
||||
});
|
||||
|
||||
const payload = {
|
||||
oidc: { issuer: 'https://new', clientSecret: '********' }, // Admin left it unchanged
|
||||
ldap: { bindPassword: 'new_password' }
|
||||
};
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/conf/proxy')
|
||||
.send(payload);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(baoConf.set).toHaveBeenCalledTimes(1);
|
||||
const saved = baoConf.set.mock.calls[0][1];
|
||||
|
||||
expect(saved.oidc.issuer).toBe('https://new');
|
||||
expect(saved.oidc.clientSecret).toBe('old_secret'); // Preserved because incoming was mask
|
||||
expect(saved.ldap.bindPassword).toBe('new_password'); // Overwritten because incoming was new
|
||||
});
|
||||
});
|
||||
@@ -151,6 +151,32 @@ describe('Groups — member management', () => {
|
||||
const members = Array.isArray(group.member) ? group.member : [group.member];
|
||||
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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// Reproduces exactly what the theta-env bootstrap does: create, list,
|
||||
// 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.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 () => {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
'use strict';
|
||||
|
||||
// Tests for the plugin system:
|
||||
// - plugin_registry: pure type discovery + configSchema helpers (no ORM, no
|
||||
// OpenBao, no LDAP) — the registry just requires the plugins/discovery/*.js
|
||||
// modules, which are real deps (node-fetch, node-nmap).
|
||||
// - plugin_secrets: OpenBao read/write/mergeForRun, with @simpleworkjs/bao-conf
|
||||
// mocked so no live OpenBao is needed.
|
||||
// - PluginInstance model: ORM round-trip against the same sqlite store the
|
||||
// rest of the suite uses (initORM), incl. the unique-slug constraint and
|
||||
// listEnabled. Like resource_site_slug.test.js, this is direct model use
|
||||
// rather than the LDAP-gated HTTP routes.
|
||||
|
||||
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
request: jest.fn(),
|
||||
}));
|
||||
|
||||
const registry = require('../services/plugin_registry');
|
||||
const pluginSecrets = require('../utils/plugin_secrets');
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
const { PluginInstance } = require('../models/plugin_instance');
|
||||
|
||||
describe('plugin_registry', () => {
|
||||
test('getTypes lists the built-in discovery plugins', () => {
|
||||
const types = registry.getTypes();
|
||||
const byType = Object.fromEntries(types.map(t => [t.type, t]));
|
||||
expect(byType.proxmox).toBeDefined();
|
||||
expect(byType.unifi).toBeDefined();
|
||||
expect(byType.nmap).toBeDefined();
|
||||
expect(byType.proxmox.category).toBe('discovery');
|
||||
expect(byType.proxmox.configSchema.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('configSchema marks secret fields', () => {
|
||||
const m = registry.getManifest('proxmox');
|
||||
const secret = m.configSchema.find(f => f.key === 'tokenSecret');
|
||||
expect(secret.secret).toBe(true);
|
||||
expect(secret.required).toBe(true);
|
||||
expect(m.configSchema.find(f => f.key === 'url').secret).toBeFalsy();
|
||||
});
|
||||
|
||||
test('requiredKeys / secretKeys / publicKeys split correctly', () => {
|
||||
expect(registry.requiredKeys('proxmox').sort()).toEqual(['tokenId', 'tokenSecret', 'url']);
|
||||
expect(registry.secretKeys('proxmox')).toEqual(['tokenSecret']);
|
||||
expect(registry.secretKeys('unifi')).toEqual(['password']);
|
||||
expect(registry.secretKeys('nmap')).toEqual([]);
|
||||
expect(registry.publicKeys('nmap')).toEqual(['targetRange']);
|
||||
});
|
||||
|
||||
test('splitConfig separates secret from non-secret and drops undeclared keys', () => {
|
||||
const { config, secrets } = registry.splitConfig('proxmox', {
|
||||
url: 'https://pve:8006',
|
||||
tokenId: 'u@pam!t',
|
||||
tokenSecret: 'shh',
|
||||
enabled: true, // not in configSchema -> dropped
|
||||
cron: '0 * * * *' // not in configSchema -> dropped
|
||||
});
|
||||
expect(config).toEqual({ url: 'https://pve:8006', tokenId: 'u@pam!t' });
|
||||
expect(secrets).toEqual({ tokenSecret: 'shh' });
|
||||
});
|
||||
|
||||
test('mask redacts only secret values', () => {
|
||||
const masked = registry.mask('proxmox', { url: 'https://pve:8006', tokenId: 'u@pam!t', tokenSecret: 'shh' });
|
||||
expect(masked.url).toBe('https://pve:8006');
|
||||
expect(masked.tokenId).toBe('u@pam!t');
|
||||
expect(masked.tokenSecret).toBe('********');
|
||||
});
|
||||
|
||||
test('getModule throws for an unknown type', () => {
|
||||
expect(() => registry.getModule('does-not-exist')).toThrow(/Unknown plugin type/);
|
||||
});
|
||||
|
||||
test('getModule returns a module with run()/discover()', () => {
|
||||
const mod = registry.getModule('proxmox');
|
||||
expect(typeof mod.run).toBe('function');
|
||||
expect(typeof mod.discover).toBe('function');
|
||||
expect(typeof mod.validate).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin_secrets', () => {
|
||||
const VALID_ID = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
beforeEach(() => { baoConf.get.mockReset(); baoConf.set.mockReset(); baoConf.request.mockReset(); });
|
||||
|
||||
test('read returns the data object', async () => {
|
||||
baoConf.get.mockResolvedValue({ tokenSecret: 'shh' });
|
||||
const out = await pluginSecrets.read(VALID_ID);
|
||||
expect(out).toEqual({ tokenSecret: 'shh' });
|
||||
expect(baoConf.get).toHaveBeenCalledWith(`plugins/${VALID_ID}/conf`);
|
||||
});
|
||||
|
||||
test('read returns {} when none stored', async () => {
|
||||
baoConf.get.mockResolvedValue(null);
|
||||
expect(await pluginSecrets.read(VALID_ID)).toEqual({});
|
||||
});
|
||||
|
||||
test('write drops blank and masked placeholder values', async () => {
|
||||
await pluginSecrets.write(VALID_ID, { tokenSecret: 'new', keep: '********', blank: '' });
|
||||
expect(baoConf.set).toHaveBeenCalledWith(`plugins/${VALID_ID}/conf`, { tokenSecret: 'new' });
|
||||
});
|
||||
|
||||
test('mergeForRun layers secrets over the row config', async () => {
|
||||
baoConf.get.mockResolvedValue({ tokenSecret: 'shh' });
|
||||
const instance = { id: VALID_ID, config: { url: 'https://pve:8006', tokenId: 'u@pam!t' } };
|
||||
const cfg = await pluginSecrets.mergeForRun(instance);
|
||||
expect(cfg).toEqual({ url: 'https://pve:8006', tokenId: 'u@pam!t', tokenSecret: 'shh' });
|
||||
});
|
||||
|
||||
test('read rejects a non-uuid id', async () => {
|
||||
await expect(pluginSecrets.read('not-a-uuid')).rejects.toThrow(/invalid plugin instance id/);
|
||||
});
|
||||
|
||||
test('remove is best-effort (404 is fine)', async () => {
|
||||
baoConf.request.mockResolvedValue({ status: 404 });
|
||||
await expect(pluginSecrets.remove(VALID_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginInstance model', () => {
|
||||
const marker = 'test_plugin_' + Date.now();
|
||||
const created = [];
|
||||
|
||||
async function makeInstance(slug, extra = {}) {
|
||||
const r = await PluginInstance.create({
|
||||
pluginType: 'proxmox',
|
||||
category: 'discovery',
|
||||
name: 'Test ' + slug,
|
||||
slug: `${marker}_${slug}`,
|
||||
enabled: true,
|
||||
cron: '0 * * * *',
|
||||
config: { url: 'https://pve:8006' },
|
||||
...extra
|
||||
});
|
||||
created.push(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const { initORM } = require('../models');
|
||||
await initORM();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const r of created) {
|
||||
try { await r.delete(); } catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
test('create generates a uuid id and round-trips json config', async () => {
|
||||
const r = await makeInstance('a');
|
||||
expect(r.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
const fetched = await PluginInstance.get(r.id);
|
||||
expect(fetched.slug).toBe(`${marker}_a`);
|
||||
expect(fetched.config).toEqual({ url: 'https://pve:8006' });
|
||||
});
|
||||
|
||||
test('slug is unique', async () => {
|
||||
await makeInstance('dup');
|
||||
await expect(makeInstance('dup')).rejects.toThrow(/Validation error|SequelizeUniqueConstraint/i);
|
||||
});
|
||||
|
||||
test('getBySlug resolves', async () => {
|
||||
const r = await makeInstance('bySlug');
|
||||
const found = await PluginInstance.getBySlug(`${marker}_bySlug`);
|
||||
expect(found.id).toBe(r.id);
|
||||
});
|
||||
|
||||
test('listEnabled returns only enabled instances', async () => {
|
||||
const on = await makeInstance('on', { enabled: true });
|
||||
const off = await makeInstance('off', { enabled: false });
|
||||
const enabled = await PluginInstance.listEnabled();
|
||||
const slugs = enabled.map(e => e.slug);
|
||||
expect(slugs).toContain(on.slug);
|
||||
expect(slugs).not.toContain(off.slug);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,30 @@
|
||||
|
||||
const {Group} = require('../models/group_ldap');
|
||||
|
||||
const SUPER_ADMIN_GROUP = 'app_super_admin';
|
||||
|
||||
let byGroup = async function(user, groups, ownerOf){
|
||||
for(let group of groups){
|
||||
try{
|
||||
group = await Group.get(group);
|
||||
if(group.member.includes(user.dn)) return true
|
||||
}catch(error){
|
||||
// group not found, continue checking
|
||||
}
|
||||
// Membership is resolved once, transitively: a user placed in an admin group
|
||||
// through a nested group is as much a member as one listed on it directly.
|
||||
// Checking `group.member.includes(user.dn)` per group -- as this used to --
|
||||
// only ever sees the literal member list and would deny them.
|
||||
let memberOfCns = [];
|
||||
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 || []){
|
||||
try{
|
||||
group = await Group.get(group);
|
||||
@@ -28,4 +42,4 @@ let byGroup = async function(user, groups, ownerOf){
|
||||
throw error;
|
||||
}
|
||||
|
||||
module.exports = {byGroup};
|
||||
module.exports = {byGroup, SUPER_ADMIN_GROUP};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
|
||||
// Per-instance plugin secrets, stored in OpenBao at `secret/plugins/<id>/conf`.
|
||||
//
|
||||
// Plugins run in-process (as BullMQ workers in the SSO Node process), so they
|
||||
// need no OpenBao token of their own — the SSO reads/writes their secrets
|
||||
// server-side through the `sso-broker` token (@simpleworkjs/bao-conf), exactly
|
||||
// like it reads its own `secret/sso-manager/conf`. This mirrors the per-user
|
||||
// (`secret/users/<uid>/*`) and per-app (`secret/apps/<name>/*`) namespaces.
|
||||
//
|
||||
// Only the configSchema fields flagged `secret:true` are stored here; the rest
|
||||
// of an instance's config lives in the PluginInstance DB row. The admin UI
|
||||
// only ever sees these masked (`********`).
|
||||
//
|
||||
// Requires theta-suite >= v1.30.1: the sso-broker policy must grant
|
||||
// `secret/data/plugins/*` + `secret/metadata/plugins/*`. Without it, write/
|
||||
// read fail with a 403 — the API surfaces that as a clear error so the operator
|
||||
// knows to re-run `./setup.sh`.
|
||||
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
|
||||
// Instance ids are ORM-generated uuids, so this is defense-in-depth against a
|
||||
// bogus id ever being interpolated into a secret path. 404s are expected
|
||||
// (no secret written yet); other malformed input is rejected hard.
|
||||
function assertId(id) {
|
||||
if (typeof id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
||||
const err = new Error('invalid plugin instance id for secret path');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function path(id) {
|
||||
return `plugins/${id}/conf`; // baoConf.get/set add the secret/data prefix
|
||||
}
|
||||
|
||||
// Read the secret field values for an instance. Returns {} when none are
|
||||
// stored yet (a brand-new instance, or one with no secret fields). A 404 from
|
||||
// OpenBao is normal — anything else propagates.
|
||||
async function read(id) {
|
||||
assertId(id);
|
||||
try {
|
||||
const data = await baoConf.get(path(id));
|
||||
return (data && typeof data === 'object') ? data : {};
|
||||
} catch (err) {
|
||||
// bao-conf treats a missing KV path as null/empty, but a 403 means the
|
||||
// sso-broker policy lacks secret/plugins/* — surface that distinctly.
|
||||
if (err && /403|permission/i.test(err.message)) throw err;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Write (replace) the secret field values for an instance. `secrets` is a flat
|
||||
// {field: value} object of only the secret configSchema fields. Empty/blank
|
||||
// values are dropped so we never store a masked placeholder back as a secret.
|
||||
async function write(id, secrets) {
|
||||
assertId(id);
|
||||
const clean = {};
|
||||
for (const [k, v] of Object.entries(secrets || {})) {
|
||||
if (v === undefined || v === null || v === '' || v === '********') continue;
|
||||
clean[k] = v;
|
||||
}
|
||||
await baoConf.set(path(id), clean);
|
||||
}
|
||||
|
||||
// Merge the stored secret field values over the instance's non-secret config,
|
||||
// producing the single `config` object the plugin's run()/validate() receive.
|
||||
// Non-secret values come from the DB row; secret values come from OpenBao.
|
||||
async function mergeForRun(instance) {
|
||||
if (!instance) return {};
|
||||
const config = (instance.config && typeof instance.config === 'object') ? instance.config : {};
|
||||
const secrets = await read(instance.id);
|
||||
return { ...config, ...secrets };
|
||||
}
|
||||
|
||||
// Best-effort delete of the instance's secret namespace. Called when an
|
||||
// instance is deleted. A 404 (already gone / never written) is fine; anything
|
||||
// else is logged and swallowed so a stuck OpenBao can't strand an instance row.
|
||||
async function remove(id) {
|
||||
assertId(id);
|
||||
try {
|
||||
const res = await baoConf.request('DELETE', `secret/metadata/plugins/${id}/conf`);
|
||||
if (res && res.status && res.status !== 404 && !res.ok) {
|
||||
console.error(`[plugin_secrets] delete for ${id} returned ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[plugin_secrets] failed to delete secrets for ${id}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { read, write, remove, mergeForRun };
|
||||
+11
-4
@@ -38,9 +38,16 @@ module.exports = {
|
||||
// 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: [
|
||||
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin']},
|
||||
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin']},
|
||||
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin']},
|
||||
{href: '/executive', icon: 'fa-solid fa-gauge-high', label: 'Executive', groups: ['app_sso_admin']},
|
||||
// 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: '/plugins', icon: 'fa-solid fa-plug', label: 'Plugins', 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 };
|
||||
@@ -0,0 +1,244 @@
|
||||
'use strict';
|
||||
|
||||
// Vault broker — mints scoped OpenBao tokens for end users, admins, and
|
||||
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
|
||||
// `sso-broker` token role created by theta-env/setup.sh.
|
||||
//
|
||||
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
|
||||
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
|
||||
// secret/* admin UI sessions (sso-admin policy)
|
||||
//
|
||||
// The sso-broker policy grants update on auth/token/create/sso-broker and on
|
||||
// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to
|
||||
// create the per-subject policies and mint their tokens. Per-user/admin tokens
|
||||
// are cached in Redis for the token's lifetime and re-minted on miss; per-app
|
||||
// tokens are returned ONCE (displayed in the UI, never stored retrievably).
|
||||
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
const { createClient } = require('redis');
|
||||
const express = require('express');
|
||||
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const permission = require('./permission');
|
||||
|
||||
const ROLE = 'sso-broker';
|
||||
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
|
||||
|
||||
let redisClient;
|
||||
async function getRedis() {
|
||||
if (!redisClient) {
|
||||
const url = (conf.redis && typeof conf.redis === 'string') ? conf.redis
|
||||
: (conf.redis && conf.redis.url) ? conf.redis.url : undefined;
|
||||
redisClient = createClient({ url });
|
||||
redisClient.on('error', (err) => console.error('Redis vault_broker error', err));
|
||||
await redisClient.connect();
|
||||
}
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
async function cacheGet(key) {
|
||||
try { return await (await getRedis()).get(key); } catch (e) { return null; }
|
||||
}
|
||||
async function cacheSet(key, value, ttl) {
|
||||
try { await (await getRedis()).set(key, value, { EX: ttl }); } catch (e) { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Low-level OpenBao call via @simpleworkjs/bao-conf.request (authenticates with
|
||||
// SSO_VAULT_TOKEN). Throws on non-2xx.
|
||||
async function bao(method, path, body) {
|
||||
const res = await baoConf.request(method, path, body);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`OpenBao ${method} ${path} failed (${res.status}) ${text}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes —
|
||||
// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding
|
||||
// a list grant on a directory path) propagate on the next vault-page visit
|
||||
// without an operator re-running setup.sh. Skipping on an existing policy
|
||||
// would strand the old, narrower HCL forever.
|
||||
async function ensurePolicy(name, hcl) {
|
||||
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
||||
if (existing.status !== 200 && existing.status !== 404) {
|
||||
const t = await existing.text().catch(() => '');
|
||||
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
|
||||
}
|
||||
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
|
||||
}
|
||||
|
||||
// Mint a token through the sso-broker role with the given policies. Returns
|
||||
// { token, ttl } (ttl = lease_duration seconds, falls back to DEFAULT_TTL).
|
||||
async function mintToken(policies) {
|
||||
const res = await bao('POST', 'auth/token/create/sso-broker', { policies });
|
||||
const json = await res.json();
|
||||
const token = json && json.auth && json.auth.client_token;
|
||||
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
|
||||
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
|
||||
return { token, ttl };
|
||||
}
|
||||
|
||||
// ── Per-user token ──────────────────────────────────────────────────────────
|
||||
function userPolicyHcl(uid) {
|
||||
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
|
||||
// into a policy path, so reject anything but a safe charset.
|
||||
// The bare `secret/metadata/users/<uid>` grant is required to LIST the
|
||||
// contents of the namespace: `.../*` covers nested paths but NOT the
|
||||
// directory itself, so without it the /vault secrets list 403s.
|
||||
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/users/${uid}/" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||
}
|
||||
|
||||
// Mint (or return the cached) per-user token confined to secret/users/<uid>/*.
|
||||
// Re-minted when the cache entry expires (a little before the token's own TTL).
|
||||
async function getOrCreateUserToken(uid) {
|
||||
if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`);
|
||||
const cacheKey = `vault_token:${uid}`;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
await ensurePolicy(`user-${uid}`, userPolicyHcl(uid));
|
||||
const { token, ttl } = await mintToken([`user-${uid}`]);
|
||||
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||
return token;
|
||||
}
|
||||
|
||||
// ── Admin token (read/write all of secret/) ─────────────────────────────────
|
||||
async function getOrCreateAdminToken(uid) {
|
||||
const cacheKey = `vault_token:admin:${uid || 'global'}`;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
const { token, ttl } = await mintToken(['sso-admin']);
|
||||
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||
return token;
|
||||
}
|
||||
|
||||
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
||||
function appPolicyHcl(name) {
|
||||
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own
|
||||
// namespace root (see userPolicyHcl for why `/*` alone isn't enough).
|
||||
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||
}
|
||||
|
||||
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
|
||||
// (the admin UI shows it with a copy button); it is not stored retrievably, so
|
||||
// a later compromise of an admin session cannot recover previously-minted app
|
||||
// tokens. The caller must record it in the external app immediately.
|
||||
async function mintAppToken(name) {
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
|
||||
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
|
||||
}
|
||||
await ensurePolicy(`app-${name}`, appPolicyHcl(name));
|
||||
const { token, ttl } = await mintToken([`app-${name}`]);
|
||||
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
|
||||
}
|
||||
|
||||
// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
|
||||
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
|
||||
// nothing). The guard mints a server-side token for the user (per-user or
|
||||
// admin) and enforces the path prefix as defense-in-depth on top of the
|
||||
// token's own policy; the proxy injects ONLY that token and strips the
|
||||
// client's sso auth headers so OpenBao never sees them.
|
||||
|
||||
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
|
||||
const ADMIN_GROUP = 'app_sso_admin';
|
||||
|
||||
async function isAdmin(user) {
|
||||
try {
|
||||
await permission.byGroup(user, [ADMIN_GROUP]);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a KV-v2 request path by stripping the data/metadata segment so the
|
||||
// prefix check works on the logical path: /secret/data/users/alice/foo ->
|
||||
// /secret/users/alice/foo. Returns null if the path isn't under /secret/.
|
||||
function normalizeVaultPath(p) {
|
||||
const norm = p.replace(/^\/secret\/(data|metadata)\//, '/secret/');
|
||||
if (norm !== '/secret' && !norm.startsWith('/secret/')) return null;
|
||||
return norm;
|
||||
}
|
||||
|
||||
async function scopeGuard(req, res, next) {
|
||||
if (!req.user || req.user.isMachine) {
|
||||
return res.status(403).json({ error: 'machine tokens cannot use the vault API' });
|
||||
}
|
||||
const uid = req.user.uid;
|
||||
const admin = await isAdmin(req.user);
|
||||
let token;
|
||||
try {
|
||||
token = admin ? await getOrCreateAdminToken(uid) : await getOrCreateUserToken(uid);
|
||||
} catch (e) {
|
||||
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
|
||||
}
|
||||
|
||||
// Defense-in-depth: confirm the requested path is within the subject's
|
||||
// namespace. Admins roam all of secret/; users are confined to
|
||||
// secret/users/<uid>/. (The token's own policy enforces the same at the
|
||||
// OpenBao layer; this catches a buggy/malicious client early with a clear
|
||||
// 403 instead of an opaque OpenBao denial.)
|
||||
const norm = normalizeVaultPath(req.path);
|
||||
if (norm === null) {
|
||||
return res.status(403).json({ error: 'vault paths must be under /secret/' });
|
||||
}
|
||||
const base = `/secret/users/${uid}`;
|
||||
const allowed = admin || norm === base || norm.startsWith(base + '/');
|
||||
if (!allowed) {
|
||||
return res.status(403).json({ error: 'path outside your vault namespace' });
|
||||
}
|
||||
|
||||
req.vaultToken = token;
|
||||
req.vaultIsAdmin = admin;
|
||||
next();
|
||||
}
|
||||
|
||||
function vaultProxy() {
|
||||
return createProxyMiddleware({
|
||||
target: VAULT_ADDR,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/': '/v1/' },
|
||||
on: {
|
||||
proxyReq(proxyReq, req, res, options) {
|
||||
fixRequestBody(proxyReq, req, res, options);
|
||||
// Inject ONLY the server-minted scoped token; strip the client's
|
||||
// sso session/api auth so it never reaches OpenBao.
|
||||
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
|
||||
proxyReq.removeHeader('auth-token');
|
||||
proxyReq.removeHeader('authorization');
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Admin-only: mint a one-time token for an external app. POST /api/vault/apps
|
||||
// { name } -> { token, ttl, policy, path }. The token is returned ONCE and is
|
||||
// not cached/stored retrievably. Mount BEFORE the /api/vault proxy.
|
||||
const mintAppRouter = express.Router();
|
||||
mintAppRouter.post('/', async (req, res, next) => {
|
||||
try {
|
||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||
const name = (req.body && req.body.name || '').trim();
|
||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||
const result = await mintAppToken(name);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getOrCreateUserToken,
|
||||
getOrCreateAdminToken,
|
||||
mintAppToken,
|
||||
ensurePolicy,
|
||||
scopeGuard,
|
||||
vaultProxy,
|
||||
mintAppRouter,
|
||||
};
|
||||
@@ -0,0 +1,379 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin(['admin', 'app_sso_admin']);
|
||||
|
||||
$(document).ready(function() {
|
||||
loadConf();
|
||||
loadProxyConf();
|
||||
loadTos();
|
||||
});
|
||||
|
||||
async function loadConf() {
|
||||
try {
|
||||
const data = await app.api.get('conf');
|
||||
// Populate SMTP
|
||||
if (data.smtp) {
|
||||
$('#smtp-host').val(data.smtp.host || '');
|
||||
$('#smtp-port').val(data.smtp.port || 587);
|
||||
$('#smtp-user').val(data.smtp.user || '');
|
||||
$('#smtp-pass').val(data.smtp.pass || '');
|
||||
$('#smtp-from').val(data.smtp.from || '');
|
||||
$('#smtp-secure').prop('checked', !!data.smtp.secure);
|
||||
}
|
||||
|
||||
// Populate OAuth
|
||||
if (data.oauth) {
|
||||
$('#oauth-issuer').val(data.oauth.issuer || '');
|
||||
$('#oauth-jwtsecret').val(data.oauth.jwtSecret || '');
|
||||
if (data.oauth.token_lifetime) {
|
||||
$('#oauth-token-access').val(data.oauth.token_lifetime.access_token || 3600);
|
||||
$('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate SMS (VoIP.ms)
|
||||
if (data.voipms) {
|
||||
$('#voipms-username').val(data.voipms.username || '');
|
||||
$('#voipms-did').val(data.voipms.did || '');
|
||||
$('#voipms-password').val(data.voipms.password || '');
|
||||
}
|
||||
} 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...');
|
||||
|
||||
const payload = {
|
||||
smtp: {
|
||||
host: $('#smtp-host').val(),
|
||||
port: parseInt($('#smtp-port').val(), 10) || 587,
|
||||
user: $('#smtp-user').val(),
|
||||
pass: $('#smtp-pass').val(),
|
||||
from: $('#smtp-from').val(),
|
||||
secure: $('#smtp-secure').is(':checked')
|
||||
},
|
||||
oauth: {
|
||||
issuer: $('#oauth-issuer').val(),
|
||||
jwtSecret: $('#oauth-jwtsecret').val(),
|
||||
token_lifetime: {
|
||||
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
|
||||
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
|
||||
}
|
||||
},
|
||||
voipms: {
|
||||
username: $('#voipms-username').val(),
|
||||
did: $('#voipms-did').val(),
|
||||
password: $('#voipms-password').val()
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await app.api.post('conf', payload);
|
||||
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success');
|
||||
} catch (error) {
|
||||
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
|
||||
} finally {
|
||||
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration');
|
||||
}
|
||||
}
|
||||
|
||||
function togglePassword(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el.type === 'password') {
|
||||
el.type = 'text';
|
||||
} else {
|
||||
el.type = 'password';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProxyConf() {
|
||||
try {
|
||||
const data = await app.api.get('conf/proxy');
|
||||
if (data.oidc) {
|
||||
$('#proxy-issuer').val(data.oidc.issuer || '');
|
||||
$('#proxy-client-id').val(data.oidc.clientId || '');
|
||||
$('#proxy-client-secret').val(data.oidc.clientSecret || '');
|
||||
}
|
||||
if (data.ldap) {
|
||||
$('#proxy-ldap-bindpass').val(data.ldap.bindPassword || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load Proxy conf:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProxyConf() {
|
||||
const btn = $('#btn-save-proxy');
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
|
||||
|
||||
const payload = {
|
||||
oidc: {
|
||||
issuer: $('#proxy-issuer').val(),
|
||||
clientId: $('#proxy-client-id').val(),
|
||||
clientSecret: $('#proxy-client-secret').val()
|
||||
},
|
||||
ldap: {
|
||||
bindPassword: $('#proxy-ldap-bindpass').val()
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await app.api.post('conf/proxy', payload);
|
||||
app.messages.toast('Proxy configuration saved securely to OpenBao!', 'success');
|
||||
} catch (error) {
|
||||
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
|
||||
} finally {
|
||||
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Proxy Secrets');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Terms of Service editor ──────────────────────────────────────────
|
||||
// Moved here from the admin Overview dashboard — it's a configuration
|
||||
// control, so it belongs on the System Configuration page. The API is
|
||||
// routes/tos.js (GET to read, PUT to save; PUT is app_sso_admin-gated, which
|
||||
// matches this page's gate). app.tos.get/update are the shared frontend
|
||||
// helpers (@simpleworkjs/frontend).
|
||||
async function loadTos() {
|
||||
try {
|
||||
const tos = await app.tos.get();
|
||||
document.getElementById('tos-content').value = tos.content;
|
||||
document.getElementById('tos-meta').textContent =
|
||||
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
||||
} catch(e) {
|
||||
console.error('Failed to load ToS:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function saveTos() {
|
||||
const content = document.getElementById('tos-content').value.trim();
|
||||
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
|
||||
const msgEl = document.getElementById('tos-result');
|
||||
|
||||
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) {
|
||||
if (error) {
|
||||
msgEl.className = 'alert alert-danger mt-2';
|
||||
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
|
||||
msgEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
msgEl.className = 'alert alert-success mt-2';
|
||||
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
|
||||
msgEl.style.display = '';
|
||||
document.getElementById('tos-reset-acceptance').checked = false;
|
||||
loadTos();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container py-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
|
||||
settings. These are stored securely in OpenBao and take effect immediately.
|
||||
Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password)
|
||||
are masked — leave them unchanged to keep the stored value.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<ul class="nav nav-tabs mb-4" id="confTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#smtp" type="button" role="tab">SMTP Settings</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#oauth" type="button" role="tab">OAuth & JWT</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#sms" type="button" role="tab">SMS (VoIP.ms)</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#tos" type="button" role="tab">Terms of Service</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#proxy" type="button" role="tab">Proxy Secrets</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="confTabsContent">
|
||||
<!-- SMTP Tab -->
|
||||
<div class="tab-pane fade show active" id="smtp" role="tabpanel">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
||||
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Host</label>
|
||||
<input type="text" class="form-control" id="smtp-host">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" class="form-control" id="smtp-port">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">User</label>
|
||||
<input type="text" class="form-control" id="smtp-user">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
|
||||
</div>
|
||||
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">From Address</label>
|
||||
<input type="text" class="form-control" id="smtp-from">
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="smtp-secure">
|
||||
<label class="form-check-label">Use Secure (TLS)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OAuth Tab -->
|
||||
<div class="tab-pane fade" id="oauth" role="tabpanel">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
||||
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Issuer URL</label>
|
||||
<input type="text" class="form-control" id="oauth-issuer">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">JWT Secret</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
|
||||
</div>
|
||||
<div class="form-text">Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Token Lifetime (seconds)</label>
|
||||
<input type="number" class="form-control" id="oauth-token-access">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Refresh Token Lifetime (seconds)</label>
|
||||
<input type="number" class="form-control" id="oauth-token-refresh">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMS Tab -->
|
||||
<div class="tab-pane fade" id="sms" role="tabpanel">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
||||
<h5 class="mb-0"><i class="fas fa-comment text-info me-2"></i> SMS (VoIP.ms)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="form-text">Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Username</label>
|
||||
<input type="text" class="form-control" id="voipms-username">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">DID (sender number)</label>
|
||||
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Password</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="voipms-password" placeholder="********">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
|
||||
</div>
|
||||
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxy Secrets Tab -->
|
||||
<div class="tab-pane fade" id="proxy" role="tabpanel">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
||||
<h5 class="mb-0"><i class="fas fa-shield-alt text-warning me-2"></i> Proxy Secrets (OpenBao)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="form-text">These secrets are stored directly in OpenBao (`secret/proxy/conf`) and read by the Proxy at boot.</p>
|
||||
|
||||
<h6 class="mt-3 mb-2">OAuth / OIDC Integration</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Issuer URL</label>
|
||||
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client ID</label>
|
||||
<input type="text" class="form-control" id="proxy-client-id">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client Secret</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="proxy-client-secret" placeholder="********">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-4 mb-2">LDAP Integration</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Bind Password</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button>
|
||||
</div>
|
||||
<div class="form-text">Password for the Proxy's LDAP service account.</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-save-proxy" class="btn btn-warning mt-2" onclick="saveProxyConf()"><i class="fas fa-save"></i> Save Proxy Secrets</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ToS Tab -->
|
||||
<div class="tab-pane fade" id="tos" role="tabpanel">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service</h5>
|
||||
<small class="text-muted" id="tos-meta"></small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
|
||||
<textarea class="form-control" id="tos-content" rows="8"></textarea>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
|
||||
<label class="form-check-label" for="tos-reset-acceptance">Require all users to re-accept these terms</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk"></i> Save Terms</button>
|
||||
<div id="tos-result" style="display:none" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('bottom') %>
|
||||
+810
-346
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
<%- 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;
|
||||
|
||||
const isAuto = r.metadata && r.metadata.discovery_sources && r.metadata.discovery_sources.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||
if(!isAuto) 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') %>
|
||||
+147
-17
@@ -4,9 +4,38 @@
|
||||
var userlist;
|
||||
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){
|
||||
if (!Array.isArray(value.member)) value.member = value.member ? [value.member] : [];
|
||||
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){
|
||||
return !value.member.includes(user.dn);
|
||||
});
|
||||
@@ -32,14 +61,39 @@
|
||||
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){
|
||||
let data = await app.group.get(group);
|
||||
$.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');
|
||||
setTimeout(function(group){
|
||||
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
|
||||
}, 400, group);
|
||||
setTimeout(function(){ app.util.revealItem($("#group-card-" + group)); }, 400);
|
||||
}
|
||||
|
||||
function applySort() {
|
||||
@@ -64,57 +118,98 @@
|
||||
$('#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();
|
||||
// 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);
|
||||
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) {
|
||||
const $item = $(btn).closest('li');
|
||||
$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; }
|
||||
try {
|
||||
const data = await app.api.delete(`group/${groupCN}/${uid}`);
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.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) {
|
||||
$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) {
|
||||
const $item = $(btn).closest('li');
|
||||
$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; }
|
||||
try {
|
||||
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.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) {
|
||||
$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) {
|
||||
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;
|
||||
try {
|
||||
await app.api.delete(`group/${cn}`);
|
||||
$.scope.groupCard.remove('cn', cn);
|
||||
} 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(){
|
||||
userlist = (await app.user.list()).results;
|
||||
@@ -123,7 +218,7 @@
|
||||
</script>
|
||||
<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;">
|
||||
<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()">
|
||||
@@ -146,7 +241,7 @@
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<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">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
|
||||
@@ -177,6 +272,12 @@
|
||||
Members
|
||||
</a>
|
||||
</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">
|
||||
<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>
|
||||
@@ -214,7 +315,7 @@
|
||||
</button>
|
||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
||||
{{ #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}}
|
||||
</a>
|
||||
{{/.}}{{ /toAdd }}
|
||||
@@ -223,6 +324,35 @@
|
||||
</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">
|
||||
<p>
|
||||
<ul class="list-group">
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
function startImpersonate(uid){
|
||||
app.impersonate.create(uid, function(error, data){
|
||||
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;
|
||||
}
|
||||
$('#impersonateModalTitle').text(data.uid);
|
||||
@@ -79,7 +79,7 @@ function startImpersonate(uid){
|
||||
$('#impersonateStopBtn').off('click').on('click', function(){
|
||||
app.impersonate.revoke(data.uid, function(err){
|
||||
$('#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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user